From e39e41287e6ee7209c5e727a3f9b624421a5afb5 Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 21 Mar 2026 18:03:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(P25-1/P25-5/P26-1/P26-3/P24-1/P29-4):=20?= =?UTF-8?q?=E8=90=BD=E5=9C=B0=E5=86=85=E9=83=A8OA=E5=A4=9F=E7=94=A8?= =?UTF-8?q?=E7=89=88=E5=90=8E=E7=AB=AF=E4=B8=8E=E8=AF=95=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关联 Issue: P25-1, P25-2, P25-3, P25-4, P25-5, P26-1, P26-3, P24-1, P24-2, P29-1, P29-2, P29-3, P29-4 --- deploy/trialrun/checklist.md | 13 + deploy/trialrun/runbook.md | 33 + docs/migration-mapping.md | 56 ++ server/.env.example | 11 + .../migration.sql | 313 ++++++++ .../migration.sql | 55 ++ server/prisma/schema.prisma | 691 ++++++++++------ server/scripts/lib/load-env-defaults.sh | 43 + server/scripts/regression-assets.sh | 9 +- server/scripts/regression-audit.sh | 9 +- server/scripts/regression-dedup.sh | 9 +- server/scripts/regression-im-notifier.sh | 16 +- server/scripts/regression-integrations.sh | 9 +- server/scripts/regression-ocr.sh | 9 +- server/scripts/regression-procurement.sh | 9 +- server/scripts/regression-verification.sh | 9 +- server/scripts/regression-workflow-actions.sh | 9 +- server/src/app.js | 20 + server/src/config.js | 8 + server/src/constants/permissions.js | 4 + server/src/jobs/ocrJob.js | 5 + server/src/routes/announcements.js | 127 +++ server/src/routes/apOperations.js | 125 +++ server/src/routes/auth.js | 54 ++ server/src/routes/budgetControlRules.js | 227 ++++++ server/src/routes/cutover.js | 80 ++ server/src/routes/leave.js | 22 + server/src/routes/migrationRuns.js | 86 ++ server/src/routes/paymentBatches.js | 747 ++++++++++++++++++ server/src/routes/portal.js | 115 +++ server/src/routes/purchaseRequests.js | 128 ++- server/src/routes/reimbursements.js | 123 +++ server/src/routes/riskFlags.js | 117 +++ server/src/routes/search.js | 225 ++++++ server/src/routes/travelRequests.js | 120 ++- server/src/routes/workflowDefinitions.js | 194 +++++ server/src/scripts/seed-config.js | 110 +++ server/src/scripts/seed-dev.js | 70 +- server/src/services/cutover.js | 79 ++ server/src/services/featureFlags/index.js | 35 + server/src/services/feeControl.js | 741 +++++++++++++++++ .../src/services/notifications/imEnqueue.js | 12 +- .../tests/unit/featureFlags.evaluate.test.js | 9 +- .../tests/unit/routes/travelRequests.test.js | 3 + server/tests/unit/services/imEnqueue.test.js | 10 +- 45 files changed, 4571 insertions(+), 328 deletions(-) create mode 100644 docs/migration-mapping.md create mode 100644 server/prisma/migrations/20260321081321_r12_internal_oa_lite/migration.sql create mode 100644 server/prisma/migrations/20260321150000_invoice_pool_travel_order_constraint/migration.sql create mode 100644 server/scripts/lib/load-env-defaults.sh create mode 100644 server/src/routes/announcements.js create mode 100644 server/src/routes/apOperations.js create mode 100644 server/src/routes/budgetControlRules.js create mode 100644 server/src/routes/cutover.js create mode 100644 server/src/routes/migrationRuns.js create mode 100644 server/src/routes/paymentBatches.js create mode 100644 server/src/routes/portal.js create mode 100644 server/src/routes/riskFlags.js create mode 100644 server/src/routes/search.js create mode 100644 server/src/routes/workflowDefinitions.js create mode 100644 server/src/services/cutover.js create mode 100644 server/src/services/feeControl.js diff --git a/deploy/trialrun/checklist.md b/deploy/trialrun/checklist.md index 6cc84c64..7a9a8986 100644 --- a/deploy/trialrun/checklist.md +++ b/deploy/trialrun/checklist.md @@ -41,3 +41,16 @@ - [ ] `docker compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server` - [ ] `curl -fsS http://127.0.0.1:3000/ready` + + +## E. R12-lite 试点切换检查 +- [ ] 已执行 `cd server && DATABASE_URL=postgresql://hyboa_user:hyboa_password@127.0.0.1:5432/hyb_oa_r12?schema=public npm run seed:config` +- [ ] 运行环境已打开 R12-lite 所需 env 开关 +- [ ] 功能开关页已将 `budget_precheck_v1`、`payment_batch_v1`、`portal_home_v1`、`global_search_v1`、`announcements_v1`、`workflow_definition_admin_v1`、`cutover_console_v1` 切到 `inherit` 或 `force_on` +- [ ] 旧 OA 主数据 CSV 已完成 dry-run,结果为 `dry_run_ok` +- [ ] `docs/migration-mapping.md` 已按试点范围人工核对 +- [ ] 旧 OA 新单停用时间已公告,未完结事项已人工清空或冻结 +- [ ] 仅按部门 / 法体 / 域粒度创建 `CutoverScope` +- [ ] 先开 `portal`,再逐域灰度打开业务入口 +- [ ] Portal、待办、预算预校验、支付批次、公告、搜索已在试点部门可用 +- [ ] 回退方案已演练:关闭 `CutoverScope` + 恢复旧入口 diff --git a/deploy/trialrun/runbook.md b/deploy/trialrun/runbook.md index fe5f4cc8..297e5a7f 100644 --- a/deploy/trialrun/runbook.md +++ b/deploy/trialrun/runbook.md @@ -158,3 +158,36 @@ docker logs -f hyb-oa-trialrun-server - 关闭 HTTP 监听 - 断开 Prisma/PG 连接 - 超时会强制退出(避免卡死导致升级失败)。 + + +## 6. R12-lite 试点切换 Runbook + +### 6.1 打开本轮功能前置条件 +- 执行 `cd server && DATABASE_URL=postgresql://hyboa_user:hyboa_password@127.0.0.1:5432/hyb_oa_r12?schema=public npm run seed:config`,确保 R12-lite 开关与默认配置落库。 +- 在运行环境启用对应 env:`BUDGET_PRECHECK_ENABLED=true`、`PAYMENT_BATCH_ENABLED=true`、`PORTAL_HOME_ENABLED=true`、`GLOBAL_SEARCH_ENABLED=true`、`ANNOUNCEMENTS_ENABLED=true`、`WORKFLOW_DEFINITION_ADMIN_ENABLED=true`、`CUTOVER_CONSOLE_ENABLED=true`。 +- 登录系统后在“功能开关”页将对应开关切到 `inherit` 或 `force_on`;仅设置 env 不会自动放开 DB 中的 `force_off` 记录。 + +### 6.2 旧 OA 主数据导出与 dry-run +1. 从旧 OA 导出人员、部门、预算或主数据 CSV。 +2. 进入“切换控制台”,按 `employees | departments | budgets | masterdata_validation` 上传 CSV 执行 dry-run。 +3. 只接受 `dry_run_ok` 结果进入下一步;若为 `dry_run_failed`,先修正列头和差异。 +4. 以 `docs/migration-mapping.md` 为准完成人工差异核对。 + +### 6.3 试点切换步骤 +1. 确认旧 OA 未完结事项已人工清空或冻结。 +2. 在旧 OA 宣布“新单停用时间点”。 +3. 在新系统创建 `CutoverScope`,粒度仅允许 `department | legal_entity | domain`。 +4. 按试点范围打开对应 `CutoverScope`,先开 `portal`,再按域逐步打开 `expense / leave / procurement / travel / asset`。 +5. 观察 Portal、待办、支付批次、AP 运营和消息深链是否正常。 + +### 6.4 验收证据 +- 保留 dry-run 结果截图或导出记录。 +- 保留切换范围配置、启停时间和操作人审计。 +- 保留回归结果:`server/scripts/regression-all.sh`、`client npm run build`、`client npm run test:e2e`(如环境可用)。 +- 保留试点部门首批新单在新系统流转与付款的样例单据编号。 + +### 6.5 回退步骤 +1. 在“切换控制台”关闭目标 `CutoverScope`。 +2. 恢复旧 OA 对应入口并重新开放新单。 +3. 若需要代码/数据回退,按本文第 3 节恢复到最近备份。 +4. 新系统中试点期间新建单据保留审计,不做 destructive 回写到旧 OA。 diff --git a/docs/migration-mapping.md b/docs/migration-mapping.md new file mode 100644 index 00000000..13f96c29 --- /dev/null +++ b/docs/migration-mapping.md @@ -0,0 +1,56 @@ +# hyb-oa 试点迁移映射清单(R12-lite) + +## 1. 迁移边界 +- 本轮只支持 **人工导出 / dry-run 核对 / 人工导入主数据**。 +- 历史业务单据采取 **旧 OA 只读归档** 策略,不导入到 hyb-oa 继续流转。 +- 新系统只接 **切换后新建单据**。 +- 未完结旧单需在切换前由业务人工清空或冻结。 + +## 2. 主数据映射 +| 旧 OA 对象 | hyb-oa 对象 | 关键字段 | 备注 | +| --- | --- | --- | --- | +| 员工 | `Employee` | `employee_no`、`name`、`email`、`department_id` | dry-run 文件头至少包含 `employee_no,name,department_name` | +| 部门 | `Department` | `name`、`parent_id`、`manager_id` | dry-run 文件头至少包含 `name` | +| 预算 | `Budget` / `BudgetPool` | `code`、`name`、`department_id`、`legal_entity_id`、`cost_center_id` | dry-run 文件头至少包含 `code,name` | +| 法体 / 成本中心 / 供应商 / 费用类型 | 既有主数据表 | 编码、名称、启停用状态 | 建议优先沿用现有编码体系 | + +## 3. 业务域映射 +| 旧 OA 域 | hyb-oa 域 | 切换策略 | +| --- | --- | --- | +| 报销 | `reimbursements` | 切换后新单进入 hyb-oa;旧单仅旧 OA 只读查询 | +| 请假 | `leave-requests` | 切换后新单进入 hyb-oa;旧单不迁移流程状态 | +| 采购申请 | `purchase-requests` | 切换后新单进入 hyb-oa;PO/合同历史仅保留编号映射 | +| 差旅申请 / 支付 | `travel-requests` / `payment-batches` | 切换后新单进入 hyb-oa;历史订单不重放支付状态 | +| 资产台账 | `assets` | 可按主数据导入当前资产卡片,历史流转日志保留在旧 OA | +| 门户入口 | `portal` | 先切 Portal,再逐域切业务入口 | + +## 4. 状态映射原则 +- `draft / reviewing / approved / rejected / cancelled / paid` 等状态只做 **对照说明**,不做历史单据业务态导入。 +- 旧 OA 审批中的单据,不在切换后继续迁移到 hyb-oa 跑剩余流程。 +- 所有状态核对用于审计与人工校验,不用于自动回放。 + +## 5. 编号与附件原则 +- 编号:旧 OA 历史编号保留在旧系统;hyb-oa 新单继续使用自身编号规则。 +- 附件:历史附件仅做映射清单,不搬运到 hyb-oa 继续流转。 +- 如需保留锚点,可在迁移台账中记录“旧编号 / 旧附件路径 / 新系统实体 ID(如有)”对照表。 + +## 6. dry-run 文件要求 +| runType | 必需列 | +| --- | --- | +| `employees` | `employee_no,name,department_name` | +| `departments` | `name` | +| `budgets` | `code,name` | +| `masterdata_validation` | `type,code` | + +## 7. 切换与回退 +1. 导出旧 OA 主数据 CSV。 +2. 在 hyb-oa 执行 dry-run,确认 `dry_run_ok`。 +3. 公告旧 OA 新单停用时间。 +4. 打开目标 `CutoverScope`。 +5. 试点期观测 Portal、待办、预算预校验、支付批次与消息链路。 +6. 如需回退,关闭 `CutoverScope` 并恢复旧入口。 + +## 8. 审计留痕 +- 保存 dry-run 记录 ID、上传文件名、差异摘要。 +- 保存 `CutoverScope` 启停时间、操作人和备注。 +- 保存试点期首批新单编号、审批截图与付款批次号。 diff --git a/server/.env.example b/server/.env.example index 4820bada..4b885156 100644 --- a/server/.env.example +++ b/server/.env.example @@ -52,6 +52,7 @@ MAX_UPLOAD_SIZE_BYTES="10485760" DELEGATION_REVOKE_CRON="0 * * * *" OCR_PROVIDER="off" +# OCR_CRON_ENABLED="false" # 仅关闭后台 OCR 定时任务;保留 OCR API/手工重试能力 OCR_BATCH_SIZE="10" OCR_LOCK_TTL_SECONDS="300" OCR_POLL_CRON="* * * * *" @@ -97,3 +98,13 @@ INTEGRATION_OUTBOX_RETRY_SECONDS="120" # TRIALRUN_ADMIN_EMAIL="admin@hyinda.com" # TRIALRUN_ADMIN_PHONE="13800000000" # TRIALRUN_ADMIN_PASSWORD="" + +# R12 lite feature gates +BUDGET_PRECHECK_ENABLED=false +PAYMENT_BATCH_ENABLED=false +PORTAL_HOME_ENABLED=false +GLOBAL_SEARCH_ENABLED=false +ANNOUNCEMENTS_ENABLED=false +WORKFLOW_DEFINITION_ADMIN_ENABLED=false +CUTOVER_CONSOLE_ENABLED=false +OIDC_PROVIDER_LABEL=企业统一登录 diff --git a/server/prisma/migrations/20260321081321_r12_internal_oa_lite/migration.sql b/server/prisma/migrations/20260321081321_r12_internal_oa_lite/migration.sql new file mode 100644 index 00000000..5ff43fbb --- /dev/null +++ b/server/prisma/migrations/20260321081321_r12_internal_oa_lite/migration.sql @@ -0,0 +1,313 @@ +-- CreateEnum +CREATE TYPE "BudgetControlMode" AS ENUM ('warn', 'block', 'manual_review'); + +-- CreateEnum +CREATE TYPE "RiskFlagStatus" AS ENUM ('open', 'resolved', 'dismissed'); + +-- CreateEnum +CREATE TYPE "PaymentBatchStatus" AS ENUM ('draft', 'reviewed', 'executed', 'failed', 'cancelled'); + +-- CreateEnum +CREATE TYPE "PaymentBatchEntityType" AS ENUM ('reimbursement', 'travel_order'); + +-- CreateEnum +CREATE TYPE "BudgetControlRuleEntityType" AS ENUM ('all', 'reimbursement', 'purchase_request', 'travel_request'); + +-- AlterTable +ALTER TABLE "approval_rules" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "asset_cards" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "asset_categories" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "budget_pools" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "card_authorizations" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "card_transactions" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "cost_centers" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "departments" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "document_categories" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "document_sequences" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "employees" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "expense_types" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "feature_flags" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "holiday_calendar_days" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "holiday_calendars" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "integration_bindings" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "integration_outbox" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "integration_sync_states" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "inventory_checks" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "invoice_pool" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "leave_policies" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "leave_requests" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "leave_types" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "legal_entities" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "notification_channels" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "purchase_request_items" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "purchase_requests" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "reimbursement_forms" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "reimbursement_items" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "suppliers" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "travel_orders" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "travel_reconciliation_batches" ALTER COLUMN "id" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "travel_reconciliation_items" ALTER COLUMN "id" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "travel_request_segments" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "travel_requests" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "workflow_definitions" ADD COLUMN "archived_at" TIMESTAMP(3), +ADD COLUMN "published_at" TIMESTAMP(3), +ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updated_by" UUID; + +-- AlterTable +ALTER TABLE "workflow_instances" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- CreateTable +CREATE TABLE "budget_control_rules" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "entity_type" "BudgetControlRuleEntityType" NOT NULL DEFAULT 'all', + "department_id" UUID, + "legal_entity_id" UUID, + "cost_center_id" UUID, + "expense_type_id" UUID, + "control_mode" "BudgetControlMode" NOT NULL, + "priority" INTEGER NOT NULL DEFAULT 100, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "notes" TEXT, + "archived_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" UUID, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_by" UUID, + + CONSTRAINT "budget_control_rules_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "entity_risk_flags" ( + "id" UUID NOT NULL, + "entity_type" VARCHAR(32) NOT NULL, + "entity_id" UUID NOT NULL, + "risk_code" VARCHAR(64) NOT NULL, + "level" VARCHAR(16) NOT NULL, + "title" TEXT NOT NULL, + "detail" TEXT, + "source" VARCHAR(32) NOT NULL, + "status" "RiskFlagStatus" NOT NULL DEFAULT 'open', + "payload_json" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" UUID, + "resolved_at" TIMESTAMP(3), + "resolved_by" UUID, + + CONSTRAINT "entity_risk_flags_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "payment_batches" ( + "id" UUID NOT NULL, + "batch_no" VARCHAR(64) NOT NULL, + "entity_type" "PaymentBatchEntityType" NOT NULL, + "name" TEXT NOT NULL, + "status" "PaymentBatchStatus" NOT NULL DEFAULT 'draft', + "default_payment_date" DATE, + "default_payment_channel" VARCHAR(20), + "voucher_prefix" VARCHAR(32), + "review_comment" TEXT, + "execution_summary_json" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" UUID, + "reviewed_at" TIMESTAMP(3), + "reviewed_by" UUID, + "executed_at" TIMESTAMP(3), + "executed_by" UUID, + "cancelled_at" TIMESTAMP(3), + "cancelled_by" UUID, + "updated_at" TIMESTAMP(3) NOT NULL, + "updated_by" UUID, + + CONSTRAINT "payment_batches_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "payment_batch_items" ( + "id" UUID NOT NULL, + "payment_batch_id" UUID NOT NULL, + "entity_id" UUID NOT NULL, + "entity_code" TEXT NOT NULL, + "entity_title" TEXT NOT NULL, + "amount" DECIMAL(14,2) NOT NULL, + "currency_code" VARCHAR(8) NOT NULL DEFAULT 'CNY', + "payment_voucher_no_override" VARCHAR(50), + "payment_date_override" DATE, + "payment_channel_override" VARCHAR(20), + "status" VARCHAR(16) NOT NULL DEFAULT 'pending', + "failure_code" VARCHAR(64), + "failure_message" TEXT, + "snapshot_json" JSONB NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "payment_batch_items_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "portal_announcements" ( + "id" UUID NOT NULL, + "title" TEXT NOT NULL, + "summary" TEXT NOT NULL, + "link_url" TEXT, + "starts_at" TIMESTAMP(3), + "ends_at" TIMESTAMP(3), + "enabled" BOOLEAN NOT NULL DEFAULT true, + "priority" INTEGER NOT NULL DEFAULT 100, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" UUID, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_by" UUID, + + CONSTRAINT "portal_announcements_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "migration_runs" ( + "id" UUID NOT NULL, + "run_type" VARCHAR(32) NOT NULL, + "status" VARCHAR(32) NOT NULL DEFAULT 'draft', + "source_filename" TEXT NOT NULL, + "summary_json" JSONB, + "error_json" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" UUID, + + CONSTRAINT "migration_runs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "cutover_scopes" ( + "id" UUID NOT NULL, + "scope_type" VARCHAR(32) NOT NULL, + "scope_key" VARCHAR(64) NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "starts_at" TIMESTAMP(3), + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" UUID, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_by" UUID, + + CONSTRAINT "cutover_scopes_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "idx_budget_control_rules_entity_enabled" ON "budget_control_rules"("entity_type", "enabled", "archived_at"); + +-- CreateIndex +CREATE INDEX "idx_budget_control_rules_scope" ON "budget_control_rules"("department_id", "legal_entity_id", "cost_center_id"); + +-- CreateIndex +CREATE INDEX "idx_budget_control_rules_expense_type" ON "budget_control_rules"("expense_type_id"); + +-- CreateIndex +CREATE INDEX "idx_entity_risk_flags_entity_status" ON "entity_risk_flags"("entity_type", "entity_id", "status"); + +-- CreateIndex +CREATE INDEX "idx_entity_risk_flags_status_source_created" ON "entity_risk_flags"("status", "source", "created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "payment_batches_batch_no_key" ON "payment_batches"("batch_no"); + +-- CreateIndex +CREATE INDEX "idx_payment_batches_entity_status_created" ON "payment_batches"("entity_type", "status", "created_at"); + +-- CreateIndex +CREATE INDEX "idx_payment_batch_items_batch_status" ON "payment_batch_items"("payment_batch_id", "status"); + +-- CreateIndex +CREATE INDEX "idx_payment_batch_items_entity_id" ON "payment_batch_items"("entity_id"); + +-- CreateIndex +CREATE INDEX "idx_portal_announcements_enabled_priority" ON "portal_announcements"("enabled", "priority", "starts_at", "ends_at"); + +-- CreateIndex +CREATE INDEX "idx_migration_runs_type_status_created" ON "migration_runs"("run_type", "status", "created_at"); + +-- CreateIndex +CREATE INDEX "idx_cutover_scopes_enabled_type" ON "cutover_scopes"("enabled", "scope_type"); + +-- CreateIndex +CREATE UNIQUE INDEX "cutover_scopes_scope_type_scope_key_ux" ON "cutover_scopes"("scope_type", "scope_key"); + +-- CreateIndex +CREATE INDEX "idx_workflow_definitions_entity_archived" ON "workflow_definitions"("entity_type", "archived_at"); + +-- RenameForeignKey +ALTER TABLE "travel_reconciliation_items" RENAME CONSTRAINT "travel_reconciliation_items_batch_id_fkey" TO "travel_reconciliation_items_travel_reconciliation_batch_id_fkey"; + +-- AddForeignKey +ALTER TABLE "payment_batch_items" ADD CONSTRAINT "payment_batch_items_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/migrations/20260321150000_invoice_pool_travel_order_constraint/migration.sql b/server/prisma/migrations/20260321150000_invoice_pool_travel_order_constraint/migration.sql new file mode 100644 index 00000000..099f96fe --- /dev/null +++ b/server/prisma/migrations/20260321150000_invoice_pool_travel_order_constraint/migration.sql @@ -0,0 +1,55 @@ +ALTER TABLE "invoice_pool" +DROP CONSTRAINT IF EXISTS "invoice_pool_status_invariants_chk"; + +ALTER TABLE "invoice_pool" +ADD CONSTRAINT "invoice_pool_status_invariants_chk" +CHECK ( + ( + "status" IN ('pending_ocr', 'ocr_failed', 'available') + AND "linked_reimbursement_id" IS NULL + AND "linked_item_id" IS NULL + AND "travel_order_id" IS NULL + AND "archived_reason" IS NULL + AND "archived_at" IS NULL + ) + OR ( + "status" = 'bound' + AND "archived_reason" IS NULL + AND "archived_at" IS NULL + AND ( + ( + "linked_reimbursement_id" IS NOT NULL + AND "travel_order_id" IS NULL + ) + OR ( + "linked_reimbursement_id" IS NULL + AND "travel_order_id" IS NOT NULL + ) + ) + ) + OR ( + "status" = 'archived' + AND "archived_reason" IS NOT NULL + AND "archived_at" IS NOT NULL + AND ( + ( + "archived_reason" = 'used_complete' + AND ( + ( + "linked_reimbursement_id" IS NOT NULL + AND "travel_order_id" IS NULL + ) + OR ( + "linked_reimbursement_id" IS NULL + AND "travel_order_id" IS NOT NULL + ) + ) + ) + OR ( + "archived_reason" IN ('unused_timeout', 'manual') + AND "linked_reimbursement_id" IS NULL + AND "travel_order_id" IS NULL + ) + ) + ) +); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 05fa16ce..f6d9257c 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -233,6 +233,38 @@ enum ExpenseCategory { other } +enum BudgetControlMode { + warn + block + manual_review +} + +enum RiskFlagStatus { + open + resolved + dismissed +} + +enum PaymentBatchStatus { + draft + reviewed + executed + failed + cancelled +} + +enum PaymentBatchEntityType { + reimbursement + travel_order +} + +enum BudgetControlRuleEntityType { + all + reimbursement + purchase_request + travel_request +} + model Department { id String @id @default(uuid()) @db.Uuid name String @@ -242,7 +274,7 @@ model Department { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid parent Department? @relation("DepartmentToParent", fields: [parent_id], references: [id], onDelete: Restrict) @@ -290,13 +322,13 @@ model Employee { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid department Department @relation(fields: [department_id], references: [id], onDelete: Restrict) legal_entity LegalEntity? @relation(fields: [legal_entity_id], references: [id], onDelete: SetNull) - direct_manager Employee? @relation("EmployeeToManager", fields: [direct_manager_id], references: [id], onDelete: Restrict) - subordinates Employee[] @relation("EmployeeToManager") + direct_manager Employee? @relation("EmployeeToManager", fields: [direct_manager_id], references: [id], onDelete: Restrict) + subordinates Employee[] @relation("EmployeeToManager") roles EmployeeRole[] @@ -307,12 +339,12 @@ model Employee { audit_logs AuditLog[] @relation("AuditActor") notifications Notification[] - submitted_reimbursements ReimbursementForm[] @relation("ReimbursementSubmitter") - owned_reimbursements ReimbursementForm[] @relation("ReimbursementOwner") - paid_reimbursements ReimbursementForm[] @relation("ReimbursementPaidBy") - leave_requests LeaveRequest[] - leave_ledgers LeaveLedger[] - owned_cost_centers CostCenter[] @relation("CostCenterOwner") + submitted_reimbursements ReimbursementForm[] @relation("ReimbursementSubmitter") + owned_reimbursements ReimbursementForm[] @relation("ReimbursementOwner") + paid_reimbursements ReimbursementForm[] @relation("ReimbursementPaidBy") + leave_requests LeaveRequest[] + leave_ledgers LeaveLedger[] + owned_cost_centers CostCenter[] @relation("CostCenterOwner") purchase_requests_applicant PurchaseRequest[] @relation("PurchaseRequestApplicant") travel_requests_applicant TravelRequest[] @relation("TravelRequestApplicant") travel_requests_submitted TravelRequest[] @relation("TravelRequestSubmitter") @@ -324,17 +356,17 @@ model Employee { asset_transfers_from AssetTransfer[] @relation("AssetTransferFromEmployee") asset_transfers_to AssetTransfer[] @relation("AssetTransferToEmployee") - owned_invoices InvoicePool[] @relation("InvoiceOwner") - uploaded_invoices InvoicePool[] @relation("InvoiceUploader") - overridden_invoices InvoicePool[] @relation("InvoiceOverrideBy") - requested_invoice_verifications InvoiceVerificationRecord[] @relation("InvoiceVerificationRequester") - travel_orders TravelOrder[] @relation("TravelOrderEmployee") - paid_travel_orders TravelOrder[] @relation("TravelOrderPaidBy") + owned_invoices InvoicePool[] @relation("InvoiceOwner") + uploaded_invoices InvoicePool[] @relation("InvoiceUploader") + overridden_invoices InvoicePool[] @relation("InvoiceOverrideBy") + requested_invoice_verifications InvoiceVerificationRecord[] @relation("InvoiceVerificationRequester") + travel_orders TravelOrder[] @relation("TravelOrderEmployee") + paid_travel_orders TravelOrder[] @relation("TravelOrderPaidBy") confirmed_travel_reconciliation_batches TravelReconciliationBatch[] @relation("TravelReconciliationBatchConfirmedBy") - card_authorizations CardAuthorization[] - card_transactions CardTransaction[] @relation("CardTransactionEmployee") - freeze_suggested_card_transactions CardTransaction[] @relation("CardTransactionFreezeSuggestedBy") - confirmed_travel_reconciliation_items TravelReconciliationItem[] @relation("TravelReconciliationItemConfirmedBy") + card_authorizations CardAuthorization[] + card_transactions CardTransaction[] @relation("CardTransactionEmployee") + freeze_suggested_card_transactions CardTransaction[] @relation("CardTransactionFreezeSuggestedBy") + confirmed_travel_reconciliation_items TravelReconciliationItem[] @relation("TravelReconciliationItemConfirmedBy") workflow_instances_started WorkflowInstance[] @relation("WorkflowInstanceSubmitter") workflow_history_actions WorkflowHistory[] @relation("WorkflowHistoryActor") @@ -473,7 +505,7 @@ model ExpenseType { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid reimbursement_items ReimbursementItem[] @@ -492,39 +524,39 @@ model DocumentSequence { current_number Int @default(0) last_reset_date DateTime? - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid @@map("document_sequences") } model LegalEntity { - id String @id @default(uuid()) @db.Uuid - code String @unique + id String @id @default(uuid()) @db.Uuid + code String @unique name String short_name String? - currency_code String @default("CNY") @db.VarChar(8) + currency_code String @default("CNY") @db.VarChar(8) tax_no String? - default_holiday_calendar_id String? @db.Uuid - enabled Boolean @default(true) + default_holiday_calendar_id String? @db.Uuid + enabled Boolean @default(true) archived_at DateTime? created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid - default_holiday_calendar HolidayCalendar? @relation("LegalEntityDefaultHolidayCalendar", fields: [default_holiday_calendar_id], references: [id], onDelete: SetNull) - cost_centers CostCenter[] - employees Employee[] - holiday_calendars HolidayCalendar[] @relation("HolidayCalendarLegalEntity") - suppliers Supplier[] - purchase_requests PurchaseRequest[] - travel_requests TravelRequest[] + default_holiday_calendar HolidayCalendar? @relation("LegalEntityDefaultHolidayCalendar", fields: [default_holiday_calendar_id], references: [id], onDelete: SetNull) + cost_centers CostCenter[] + employees Employee[] + holiday_calendars HolidayCalendar[] @relation("HolidayCalendarLegalEntity") + suppliers Supplier[] + purchase_requests PurchaseRequest[] + travel_requests TravelRequest[] travel_reconciliation_batches TravelReconciliationBatch[] - card_authorizations CardAuthorization[] - asset_cards AssetCard[] - budget_pools BudgetPool[] @relation("BudgetPoolLegalEntity") + card_authorizations CardAuthorization[] + asset_cards AssetCard[] + budget_pools BudgetPool[] @relation("BudgetPoolLegalEntity") @@index([enabled, archived_at], map: "idx_legal_entities_status") @@index([default_holiday_calendar_id], map: "idx_legal_entities_default_calendar") @@ -543,13 +575,13 @@ model CostCenter { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid - legal_entity LegalEntity @relation(fields: [legal_entity_id], references: [id], onDelete: Restrict) - parent CostCenter? @relation("CostCenterToParent", fields: [parent_id], references: [id], onDelete: Restrict) - children CostCenter[] @relation("CostCenterToParent") - owner_employee Employee? @relation("CostCenterOwner", fields: [owner_employee_id], references: [id], onDelete: SetNull) + legal_entity LegalEntity @relation(fields: [legal_entity_id], references: [id], onDelete: Restrict) + parent CostCenter? @relation("CostCenterToParent", fields: [parent_id], references: [id], onDelete: Restrict) + children CostCenter[] @relation("CostCenterToParent") + owner_employee Employee? @relation("CostCenterOwner", fields: [owner_employee_id], references: [id], onDelete: SetNull) budget_pools BudgetPool[] @relation("BudgetPoolCostCenter") purchase_requests PurchaseRequest[] travel_requests TravelRequest[] @@ -578,7 +610,7 @@ model DocumentCategory { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid purchase_requests PurchaseRequest[] @@ -599,12 +631,12 @@ model HolidayCalendar { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid - legal_entity LegalEntity? @relation("HolidayCalendarLegalEntity", fields: [legal_entity_id], references: [id], onDelete: SetNull) - days HolidayCalendarDay[] - default_for_legal_entities LegalEntity[] @relation("LegalEntityDefaultHolidayCalendar") + legal_entity LegalEntity? @relation("HolidayCalendarLegalEntity", fields: [legal_entity_id], references: [id], onDelete: SetNull) + days HolidayCalendarDay[] + default_for_legal_entities LegalEntity[] @relation("LegalEntityDefaultHolidayCalendar") @@index([legal_entity_id, enabled, archived_at], map: "idx_holiday_calendars_legal_entity_status") @@map("holiday_calendars") @@ -620,7 +652,7 @@ model HolidayCalendarDay { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid calendar HolidayCalendar @relation(fields: [calendar_id], references: [id], onDelete: Cascade) @@ -631,25 +663,25 @@ model HolidayCalendarDay { } model Supplier { - id String @id @default(uuid()) @db.Uuid - code String @unique + id String @id @default(uuid()) @db.Uuid + code String @unique name String short_name String? tax_no String? contact_name String? contact_phone String? payment_terms String? - legal_entity_id String? @db.Uuid - enabled Boolean @default(true) + legal_entity_id String? @db.Uuid + enabled Boolean @default(true) archived_at DateTime? created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid - legal_entity LegalEntity? @relation(fields: [legal_entity_id], references: [id], onDelete: SetNull) - purchase_requests PurchaseRequest[] + legal_entity LegalEntity? @relation(fields: [legal_entity_id], references: [id], onDelete: SetNull) + purchase_requests PurchaseRequest[] @@index([legal_entity_id, enabled, archived_at], map: "idx_suppliers_legal_entity_status") @@index([name], map: "idx_suppliers_name") @@ -657,36 +689,36 @@ model Supplier { } model PurchaseRequest { - id String @id @default(uuid()) @db.Uuid - request_no String? @unique - applicant_id String @db.Uuid - submitter_id String? @db.Uuid - department_id String @db.Uuid - legal_entity_id String? @db.Uuid - cost_center_id String? @db.Uuid - supplier_id String? @db.Uuid - document_category_id String? @db.Uuid - budget_pool_id String? @db.Uuid - title String @default("") - reason String? - currency_code String @default("CNY") @db.VarChar(8) - total_amount Decimal @default(0) @db.Decimal(14, 2) - expected_delivery_date DateTime? @db.Date - status PurchaseRequestStatus @default(draft) + id String @id @default(uuid()) @db.Uuid + request_no String? @unique + applicant_id String @db.Uuid + submitter_id String? @db.Uuid + department_id String @db.Uuid + legal_entity_id String? @db.Uuid + cost_center_id String? @db.Uuid + supplier_id String? @db.Uuid + document_category_id String? @db.Uuid + budget_pool_id String? @db.Uuid + title String @default("") + reason String? + currency_code String @default("CNY") @db.VarChar(8) + total_amount Decimal @default(0) @db.Decimal(14, 2) + expected_delivery_date DateTime? @db.Date + status PurchaseRequestStatus @default(draft) created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid - applicant Employee @relation("PurchaseRequestApplicant", fields: [applicant_id], references: [id], onDelete: Restrict) - submitter Employee? @relation("PurchaseRequestSubmitter", fields: [submitter_id], references: [id], onDelete: SetNull) - department Department @relation(fields: [department_id], references: [id], onDelete: Restrict) - legal_entity LegalEntity? @relation(fields: [legal_entity_id], references: [id], onDelete: SetNull) - cost_center CostCenter? @relation(fields: [cost_center_id], references: [id], onDelete: SetNull) - supplier Supplier? @relation(fields: [supplier_id], references: [id], onDelete: SetNull) - document_category DocumentCategory? @relation(fields: [document_category_id], references: [id], onDelete: SetNull) - budget_pool BudgetPool? @relation(fields: [budget_pool_id], references: [id], onDelete: SetNull) + applicant Employee @relation("PurchaseRequestApplicant", fields: [applicant_id], references: [id], onDelete: Restrict) + submitter Employee? @relation("PurchaseRequestSubmitter", fields: [submitter_id], references: [id], onDelete: SetNull) + department Department @relation(fields: [department_id], references: [id], onDelete: Restrict) + legal_entity LegalEntity? @relation(fields: [legal_entity_id], references: [id], onDelete: SetNull) + cost_center CostCenter? @relation(fields: [cost_center_id], references: [id], onDelete: SetNull) + supplier Supplier? @relation(fields: [supplier_id], references: [id], onDelete: SetNull) + document_category DocumentCategory? @relation(fields: [document_category_id], references: [id], onDelete: SetNull) + budget_pool BudgetPool? @relation(fields: [budget_pool_id], references: [id], onDelete: SetNull) items PurchaseRequestItem[] attachments PurchaseAttachment[] @@ -707,18 +739,18 @@ model PurchaseRequest { } model PurchaseRequestItem { - id String @id @default(uuid()) @db.Uuid - purchase_request_id String @db.Uuid + id String @id @default(uuid()) @db.Uuid + purchase_request_id String @db.Uuid description String specification String? - quantity Decimal @db.Decimal(14, 2) - unit_price Decimal @db.Decimal(14, 2) - line_amount Decimal @db.Decimal(14, 2) - is_asset_candidate Boolean @default(false) + quantity Decimal @db.Decimal(14, 2) + unit_price Decimal @db.Decimal(14, 2) + line_amount Decimal @db.Decimal(14, 2) + is_asset_candidate Boolean @default(false) created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid purchase_request PurchaseRequest @relation(fields: [purchase_request_id], references: [id], onDelete: Cascade) @@ -763,16 +795,16 @@ model PurchaseOrderArchive { } model AssetCategory { - id String @id @default(uuid()) @db.Uuid - code String @unique + id String @id @default(uuid()) @db.Uuid + code String @unique name String - document_category_id String? @db.Uuid - enabled Boolean @default(true) + document_category_id String? @db.Uuid + enabled Boolean @default(true) archived_at DateTime? created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid document_category DocumentCategory? @relation(fields: [document_category_id], references: [id], onDelete: SetNull) @@ -784,30 +816,30 @@ model AssetCategory { } model AssetCard { - id String @id @default(uuid()) @db.Uuid - asset_no String @unique - name String - category_id String @db.Uuid - legal_entity_id String @db.Uuid - cost_center_id String @db.Uuid - purchase_request_id String? @db.Uuid - serial_no String? - location String? - status AssetCardStatus @default(in_stock) - assignee_employee_id String? @db.Uuid - activated_at DateTime? @db.Date - retired_at DateTime? @db.Date + id String @id @default(uuid()) @db.Uuid + asset_no String @unique + name String + category_id String @db.Uuid + legal_entity_id String @db.Uuid + cost_center_id String @db.Uuid + purchase_request_id String? @db.Uuid + serial_no String? + location String? + status AssetCardStatus @default(in_stock) + assignee_employee_id String? @db.Uuid + activated_at DateTime? @db.Date + retired_at DateTime? @db.Date created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid - category AssetCategory @relation(fields: [category_id], references: [id], onDelete: Restrict) - legal_entity LegalEntity @relation(fields: [legal_entity_id], references: [id], onDelete: Restrict) - cost_center CostCenter @relation(fields: [cost_center_id], references: [id], onDelete: Restrict) - purchase_request PurchaseRequest? @relation(fields: [purchase_request_id], references: [id], onDelete: SetNull) - assignee Employee? @relation("AssetCardAssignee", fields: [assignee_employee_id], references: [id], onDelete: SetNull) + category AssetCategory @relation(fields: [category_id], references: [id], onDelete: Restrict) + legal_entity LegalEntity @relation(fields: [legal_entity_id], references: [id], onDelete: Restrict) + cost_center CostCenter @relation(fields: [cost_center_id], references: [id], onDelete: Restrict) + purchase_request PurchaseRequest? @relation(fields: [purchase_request_id], references: [id], onDelete: SetNull) + assignee Employee? @relation("AssetCardAssignee", fields: [assignee_employee_id], references: [id], onDelete: SetNull) assignments AssetAssignment[] transfers AssetTransfer[] inventory_items InventoryCheckItem[] @@ -838,18 +870,18 @@ model AssetAssignment { } model AssetTransfer { - id String @id @default(uuid()) @db.Uuid - asset_id String @db.Uuid - from_employee_id String? @db.Uuid - to_employee_id String? @db.Uuid + id String @id @default(uuid()) @db.Uuid + asset_id String @db.Uuid + from_employee_id String? @db.Uuid + to_employee_id String? @db.Uuid from_location String? to_location String? - transferred_at DateTime @default(now()) + transferred_at DateTime @default(now()) note String? - asset AssetCard @relation(fields: [asset_id], references: [id], onDelete: Cascade) - from_employee Employee? @relation("AssetTransferFromEmployee", fields: [from_employee_id], references: [id], onDelete: SetNull) - to_employee Employee? @relation("AssetTransferToEmployee", fields: [to_employee_id], references: [id], onDelete: SetNull) + asset AssetCard @relation(fields: [asset_id], references: [id], onDelete: Cascade) + from_employee Employee? @relation("AssetTransferFromEmployee", fields: [from_employee_id], references: [id], onDelete: SetNull) + to_employee Employee? @relation("AssetTransferToEmployee", fields: [to_employee_id], references: [id], onDelete: SetNull) @@index([asset_id, transferred_at], map: "idx_asset_transfers_asset_time") @@index([from_employee_id], map: "idx_asset_transfers_from_employee") @@ -868,7 +900,7 @@ model InventoryCheck { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid items InventoryCheckItem[] @@ -911,7 +943,7 @@ model ReimbursementForm { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid submitter Employee @relation("ReimbursementSubmitter", fields: [submitter_id], references: [id], onDelete: Restrict) @@ -946,7 +978,7 @@ model ReimbursementItem { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid reimbursement_form ReimbursementForm @relation(fields: [reimbursement_id], references: [id], onDelete: Cascade) @@ -972,7 +1004,7 @@ model LeaveType { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid leave_policy LeavePolicy? @@ -992,7 +1024,7 @@ model LeavePolicy { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid leave_type LeaveType @relation(fields: [leave_type_id], references: [id], onDelete: Cascade) @@ -1020,7 +1052,7 @@ model LeaveRequest { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid owner Employee @relation(fields: [owner_id], references: [id], onDelete: Restrict) @@ -1128,7 +1160,7 @@ model InvoicePool { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid owner_employee Employee @relation("InvoiceOwner", fields: [owner_employee_id], references: [id], onDelete: Restrict) @@ -1201,15 +1233,15 @@ model BudgetPool { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid - department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull) - legal_entity LegalEntity? @relation("BudgetPoolLegalEntity", fields: [legal_entity_id], references: [id], onDelete: SetNull) - cost_center CostCenter? @relation("BudgetPoolCostCenter", fields: [cost_center_id], references: [id], onDelete: SetNull) - ledger_entries BudgetLedgerEntry[] - purchase_requests PurchaseRequest[] - travel_requests TravelRequest[] + department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull) + legal_entity LegalEntity? @relation("BudgetPoolLegalEntity", fields: [legal_entity_id], references: [id], onDelete: SetNull) + cost_center CostCenter? @relation("BudgetPoolCostCenter", fields: [cost_center_id], references: [id], onDelete: SetNull) + ledger_entries BudgetLedgerEntry[] + purchase_requests PurchaseRequest[] + travel_requests TravelRequest[] card_authorizations CardAuthorization[] @@index([department_id, status], map: "idx_budget_pools_department_status") @@ -1250,13 +1282,165 @@ model FeatureFlag { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid @@index([flag_key], map: "idx_feature_flags_key") @@map("feature_flags") } +model BudgetControlRule { + id String @id @default(uuid()) @db.Uuid + name String + entity_type BudgetControlRuleEntityType @default(all) + department_id String? @db.Uuid + legal_entity_id String? @db.Uuid + cost_center_id String? @db.Uuid + expense_type_id String? @db.Uuid + control_mode BudgetControlMode + priority Int @default(100) + enabled Boolean @default(true) + notes String? + archived_at DateTime? + + created_at DateTime @default(now()) + created_by String? @db.Uuid + updated_at DateTime @default(now()) @updatedAt + updated_by String? @db.Uuid + + @@index([entity_type, enabled, archived_at], map: "idx_budget_control_rules_entity_enabled") + @@index([department_id, legal_entity_id, cost_center_id], map: "idx_budget_control_rules_scope") + @@index([expense_type_id], map: "idx_budget_control_rules_expense_type") + @@map("budget_control_rules") +} + +model EntityRiskFlag { + id String @id @default(uuid()) @db.Uuid + entity_type String @db.VarChar(32) + entity_id String @db.Uuid + risk_code String @db.VarChar(64) + level String @db.VarChar(16) + title String + detail String? + source String @db.VarChar(32) + status RiskFlagStatus @default(open) + payload_json Json? + + created_at DateTime @default(now()) + created_by String? @db.Uuid + resolved_at DateTime? + resolved_by String? @db.Uuid + + @@index([entity_type, entity_id, status], map: "idx_entity_risk_flags_entity_status") + @@index([status, source, created_at], map: "idx_entity_risk_flags_status_source_created") + @@map("entity_risk_flags") +} + +model PaymentBatch { + id String @id @default(uuid()) @db.Uuid + batch_no String @unique @db.VarChar(64) + entity_type PaymentBatchEntityType + name String + status PaymentBatchStatus @default(draft) + default_payment_date DateTime? @db.Date + default_payment_channel String? @db.VarChar(20) + voucher_prefix String? @db.VarChar(32) + review_comment String? + execution_summary_json Json? + + created_at DateTime @default(now()) + created_by String? @db.Uuid + reviewed_at DateTime? + reviewed_by String? @db.Uuid + executed_at DateTime? + executed_by String? @db.Uuid + cancelled_at DateTime? + cancelled_by String? @db.Uuid + updated_at DateTime @updatedAt + updated_by String? @db.Uuid + + items PaymentBatchItem[] + + @@index([entity_type, status, created_at], map: "idx_payment_batches_entity_status_created") + @@map("payment_batches") +} + +model PaymentBatchItem { + id String @id @default(uuid()) @db.Uuid + payment_batch_id String @db.Uuid + entity_id String @db.Uuid + entity_code String + entity_title String + amount Decimal @db.Decimal(14, 2) + currency_code String @default("CNY") @db.VarChar(8) + payment_voucher_no_override String? @db.VarChar(50) + payment_date_override DateTime? @db.Date + payment_channel_override String? @db.VarChar(20) + status String @default("pending") @db.VarChar(16) + failure_code String? @db.VarChar(64) + failure_message String? + snapshot_json Json + created_at DateTime @default(now()) + + payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Cascade) + + @@index([payment_batch_id, status], map: "idx_payment_batch_items_batch_status") + @@index([entity_id], map: "idx_payment_batch_items_entity_id") + @@map("payment_batch_items") +} + +model PortalAnnouncement { + id String @id @default(uuid()) @db.Uuid + title String + summary String + link_url String? + starts_at DateTime? + ends_at DateTime? + enabled Boolean @default(true) + priority Int @default(100) + + created_at DateTime @default(now()) + created_by String? @db.Uuid + updated_at DateTime @default(now()) @updatedAt + updated_by String? @db.Uuid + + @@index([enabled, priority, starts_at, ends_at], map: "idx_portal_announcements_enabled_priority") + @@map("portal_announcements") +} + +model MigrationRun { + id String @id @default(uuid()) @db.Uuid + run_type String @db.VarChar(32) + status String @default("draft") @db.VarChar(32) + source_filename String + summary_json Json? + error_json Json? + + created_at DateTime @default(now()) + created_by String? @db.Uuid + + @@index([run_type, status, created_at], map: "idx_migration_runs_type_status_created") + @@map("migration_runs") +} + +model CutoverScope { + id String @id @default(uuid()) @db.Uuid + scope_type String @db.VarChar(32) + scope_key String @db.VarChar(64) + enabled Boolean @default(false) + starts_at DateTime? + notes String? + + created_at DateTime @default(now()) + created_by String? @db.Uuid + updated_at DateTime @default(now()) @updatedAt + updated_by String? @db.Uuid + + @@unique([scope_type, scope_key], map: "cutover_scopes_scope_type_scope_key_ux") + @@index([enabled, scope_type], map: "idx_cutover_scopes_enabled_type") + @@map("cutover_scopes") +} + model IntegrationOutbox { id String @id @default(uuid()) @db.Uuid event_type String @db.VarChar(64) @@ -1272,7 +1456,7 @@ model IntegrationOutbox { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid @@unique([event_type, dedupe_key], map: "integration_outbox_event_dedupe_ux") @@ -1281,17 +1465,17 @@ model IntegrationOutbox { } model IntegrationBinding { - id String @id @default(uuid()) @db.Uuid - provider String @db.VarChar(32) - entity_type String @db.VarChar(64) - local_id String @db.Uuid - external_id String? @db.VarChar(128) - external_code String? @db.VarChar(128) + id String @id @default(uuid()) @db.Uuid + provider String @db.VarChar(32) + entity_type String @db.VarChar(64) + local_id String @db.Uuid + external_id String? @db.VarChar(128) + external_code String? @db.VarChar(128) last_synced_at DateTime? created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid @@unique([provider, entity_type, local_id], map: "integration_bindings_provider_entity_local_ux") @@ -1301,19 +1485,19 @@ model IntegrationBinding { } model IntegrationSyncState { - id String @id @default(uuid()) @db.Uuid - provider String @db.VarChar(32) - resource_type String @db.VarChar(32) - status String @default("idle") @db.VarChar(16) - last_result Json? - last_error String? - last_started_at DateTime? + id String @id @default(uuid()) @db.Uuid + provider String @db.VarChar(32) + resource_type String @db.VarChar(32) + status String @default("idle") @db.VarChar(16) + last_result Json? + last_error String? + last_started_at DateTime? last_finished_at DateTime? - last_success_at DateTime? + last_success_at DateTime? created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid @@unique([provider, resource_type], map: "integration_sync_states_provider_resource_ux") @@ -1339,7 +1523,7 @@ model TravelRequest { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid applicant Employee @relation("TravelRequestApplicant", fields: [applicant_employee_id], references: [id], onDelete: Restrict) @@ -1349,12 +1533,12 @@ model TravelRequest { cost_center CostCenter @relation(fields: [cost_center_id], references: [id], onDelete: Restrict) budget_pool BudgetPool? @relation(fields: [budget_pool_id], references: [id], onDelete: SetNull) - segments TravelRequestSegment[] - travel_orders TravelOrder[] - reconciliation_items TravelReconciliationItem[] - card_authorizations CardAuthorization[] - card_transactions CardTransaction[] - workflow_instances WorkflowInstance[] + segments TravelRequestSegment[] + travel_orders TravelOrder[] + reconciliation_items TravelReconciliationItem[] + card_authorizations CardAuthorization[] + card_transactions CardTransaction[] + workflow_instances WorkflowInstance[] @@index([applicant_employee_id, start_date], map: "idx_travel_requests_applicant_start") @@index([submitter_id], map: "idx_travel_requests_submitter") @@ -1378,7 +1562,7 @@ model TravelRequestSegment { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid travel_request TravelRequest @relation(fields: [travel_request_id], references: [id], onDelete: Cascade) @@ -1408,14 +1592,14 @@ model TravelOrder { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid - employee Employee @relation("TravelOrderEmployee", fields: [employee_id], references: [id], onDelete: Restrict) - department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull) - linked_reimbursement ReimbursementForm? @relation(fields: [linked_reimbursement_id], references: [id], onDelete: SetNull) - travel_request TravelRequest? @relation(fields: [travel_request_id], references: [id], onDelete: SetNull) - paid_by_employee Employee? @relation("TravelOrderPaidBy", fields: [paid_by], references: [id], onDelete: SetNull) + employee Employee @relation("TravelOrderEmployee", fields: [employee_id], references: [id], onDelete: Restrict) + department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull) + linked_reimbursement ReimbursementForm? @relation(fields: [linked_reimbursement_id], references: [id], onDelete: SetNull) + travel_request TravelRequest? @relation(fields: [travel_request_id], references: [id], onDelete: SetNull) + paid_by_employee Employee? @relation("TravelOrderPaidBy", fields: [paid_by], references: [id], onDelete: SetNull) invoice_pool_items InvoicePool[] card_transactions CardTransaction[] reconciliation_items TravelReconciliationItem[] @@ -1429,35 +1613,35 @@ model TravelOrder { } model CardAuthorization { - id String @id @default(uuid()) @db.Uuid - source String @default("manual") @db.VarChar(32) + id String @id @default(uuid()) @db.Uuid + source String @default("manual") @db.VarChar(32) external_auth_no String - employee_id String @db.Uuid - department_id String? @db.Uuid - travel_request_id String @db.Uuid - budget_pool_id String? @db.Uuid - legal_entity_id String @db.Uuid - cost_center_id String @db.Uuid - authorized_amount Decimal @db.Decimal(14, 2) - settled_amount Decimal @default(0) @db.Decimal(14, 2) - reversed_amount Decimal @default(0) @db.Decimal(14, 2) - currency String @default("CNY") @db.VarChar(8) + employee_id String @db.Uuid + department_id String? @db.Uuid + travel_request_id String @db.Uuid + budget_pool_id String? @db.Uuid + legal_entity_id String @db.Uuid + cost_center_id String @db.Uuid + authorized_amount Decimal @db.Decimal(14, 2) + settled_amount Decimal @default(0) @db.Decimal(14, 2) + reversed_amount Decimal @default(0) @db.Decimal(14, 2) + currency String @default("CNY") @db.VarChar(8) occurred_at DateTime? expires_at DateTime? - status String @default("authorized") @db.VarChar(32) + status String @default("authorized") @db.VarChar(32) raw_payload Json? created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid - employee Employee @relation(fields: [employee_id], references: [id], onDelete: Restrict) - department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull) - travel_request TravelRequest @relation(fields: [travel_request_id], references: [id], onDelete: Cascade) - budget_pool BudgetPool? @relation(fields: [budget_pool_id], references: [id], onDelete: SetNull) - legal_entity LegalEntity @relation(fields: [legal_entity_id], references: [id], onDelete: Restrict) - cost_center CostCenter @relation(fields: [cost_center_id], references: [id], onDelete: Restrict) + employee Employee @relation(fields: [employee_id], references: [id], onDelete: Restrict) + department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull) + travel_request TravelRequest @relation(fields: [travel_request_id], references: [id], onDelete: Cascade) + budget_pool BudgetPool? @relation(fields: [budget_pool_id], references: [id], onDelete: SetNull) + legal_entity LegalEntity @relation(fields: [legal_entity_id], references: [id], onDelete: Restrict) + cost_center CostCenter @relation(fields: [cost_center_id], references: [id], onDelete: Restrict) card_transactions CardTransaction[] @@unique([source, external_auth_no], map: "card_authorizations_source_auth_ux") @@ -1470,37 +1654,37 @@ model CardAuthorization { } model CardTransaction { - id String @id @default(uuid()) @db.Uuid - source String @default("manual") @db.VarChar(32) - external_txn_id String - card_authorization_id String? @db.Uuid - employee_id String @db.Uuid - department_id String? @db.Uuid - travel_request_id String @db.Uuid - travel_order_id String? @db.Uuid - amount Decimal @db.Decimal(14, 2) - currency String @default("CNY") @db.VarChar(8) - txn_type String @db.VarChar(20) - status String @default("pending") @db.VarChar(20) - occurred_at DateTime? - freeze_suggested_at DateTime? - freeze_suggested_by String? @db.Uuid - freeze_reason_code String? @db.VarChar(32) - freeze_reason_note String? - raw_payload Json? + id String @id @default(uuid()) @db.Uuid + source String @default("manual") @db.VarChar(32) + external_txn_id String + card_authorization_id String? @db.Uuid + employee_id String @db.Uuid + department_id String? @db.Uuid + travel_request_id String @db.Uuid + travel_order_id String? @db.Uuid + amount Decimal @db.Decimal(14, 2) + currency String @default("CNY") @db.VarChar(8) + txn_type String @db.VarChar(20) + status String @default("pending") @db.VarChar(20) + occurred_at DateTime? + freeze_suggested_at DateTime? + freeze_suggested_by String? @db.Uuid + freeze_reason_code String? @db.VarChar(32) + freeze_reason_note String? + raw_payload Json? created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid - card_authorization CardAuthorization? @relation(fields: [card_authorization_id], references: [id], onDelete: SetNull) - employee Employee @relation("CardTransactionEmployee", fields: [employee_id], references: [id], onDelete: Restrict) - department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull) - travel_request TravelRequest @relation(fields: [travel_request_id], references: [id], onDelete: Cascade) - travel_order TravelOrder? @relation(fields: [travel_order_id], references: [id], onDelete: SetNull) - freeze_suggested_by_employee Employee? @relation("CardTransactionFreezeSuggestedBy", fields: [freeze_suggested_by], references: [id], onDelete: SetNull) - reconciliation_items TravelReconciliationItem[] + card_authorization CardAuthorization? @relation(fields: [card_authorization_id], references: [id], onDelete: SetNull) + employee Employee @relation("CardTransactionEmployee", fields: [employee_id], references: [id], onDelete: Restrict) + department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull) + travel_request TravelRequest @relation(fields: [travel_request_id], references: [id], onDelete: Cascade) + travel_order TravelOrder? @relation(fields: [travel_order_id], references: [id], onDelete: SetNull) + freeze_suggested_by_employee Employee? @relation("CardTransactionFreezeSuggestedBy", fields: [freeze_suggested_by], references: [id], onDelete: SetNull) + reconciliation_items TravelReconciliationItem[] @@unique([source, external_txn_id], map: "card_transactions_source_txn_ux") @@index([card_authorization_id], map: "idx_card_transactions_authorization") @@ -1513,26 +1697,26 @@ model CardTransaction { } model TravelReconciliationBatch { - id String @id @default(uuid()) @db.Uuid - batch_no String @unique - legal_entity_id String @db.Uuid - cost_center_id String? @db.Uuid - period_start DateTime @db.Date - period_end DateTime @db.Date - status TravelReconciliationBatchStatus @default(draft) + id String @id @default(uuid()) @db.Uuid + batch_no String @unique + legal_entity_id String @db.Uuid + cost_center_id String? @db.Uuid + period_start DateTime @db.Date + period_end DateTime @db.Date + status TravelReconciliationBatchStatus @default(draft) summary_json Json confirmed_at DateTime? - confirmed_by String? @db.Uuid + confirmed_by String? @db.Uuid created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid - legal_entity LegalEntity @relation(fields: [legal_entity_id], references: [id], onDelete: Restrict) - cost_center CostCenter? @relation(fields: [cost_center_id], references: [id], onDelete: SetNull) - confirmed_by_employee Employee? @relation("TravelReconciliationBatchConfirmedBy", fields: [confirmed_by], references: [id], onDelete: SetNull) - items TravelReconciliationItem[] + legal_entity LegalEntity @relation(fields: [legal_entity_id], references: [id], onDelete: Restrict) + cost_center CostCenter? @relation(fields: [cost_center_id], references: [id], onDelete: SetNull) + confirmed_by_employee Employee? @relation("TravelReconciliationBatchConfirmedBy", fields: [confirmed_by], references: [id], onDelete: SetNull) + items TravelReconciliationItem[] @@index([status, period_start, period_end], map: "idx_travel_reconciliation_batches_status_period") @@index([legal_entity_id, cost_center_id], map: "idx_travel_reconciliation_batches_legal_cost_center") @@ -1541,31 +1725,31 @@ model TravelReconciliationBatch { } model TravelReconciliationItem { - id String @id @default(uuid()) @db.Uuid - travel_reconciliation_batch_id String @db.Uuid - item_kind TravelReconciliationItemKind - travel_request_id String? @db.Uuid - travel_order_id String? @db.Uuid - card_transaction_id String? @db.Uuid - match_status TravelReconciliationMatchStatus - resolution_status TravelReconciliationResolutionStatus @default(pending) - order_amount Decimal? @db.Decimal(14, 2) - transaction_amount Decimal? @db.Decimal(14, 2) - currency String @default("CNY") @db.VarChar(8) - anomaly_code String? @db.VarChar(32) - snapshot_json Json - confirmed_at DateTime? - confirmed_by String? @db.Uuid - confirm_note String? + id String @id @default(uuid()) @db.Uuid + travel_reconciliation_batch_id String @db.Uuid + item_kind TravelReconciliationItemKind + travel_request_id String? @db.Uuid + travel_order_id String? @db.Uuid + card_transaction_id String? @db.Uuid + match_status TravelReconciliationMatchStatus + resolution_status TravelReconciliationResolutionStatus @default(pending) + order_amount Decimal? @db.Decimal(14, 2) + transaction_amount Decimal? @db.Decimal(14, 2) + currency String @default("CNY") @db.VarChar(8) + anomaly_code String? @db.VarChar(32) + snapshot_json Json + confirmed_at DateTime? + confirmed_by String? @db.Uuid + confirm_note String? created_at DateTime @default(now()) - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt batch TravelReconciliationBatch @relation(fields: [travel_reconciliation_batch_id], references: [id], onDelete: Cascade) - travel_request TravelRequest? @relation(fields: [travel_request_id], references: [id], onDelete: SetNull) - travel_order TravelOrder? @relation(fields: [travel_order_id], references: [id], onDelete: SetNull) - card_transaction CardTransaction? @relation(fields: [card_transaction_id], references: [id], onDelete: SetNull) - confirmed_by_employee Employee? @relation("TravelReconciliationItemConfirmedBy", fields: [confirmed_by], references: [id], onDelete: SetNull) + travel_request TravelRequest? @relation(fields: [travel_request_id], references: [id], onDelete: SetNull) + travel_order TravelOrder? @relation(fields: [travel_order_id], references: [id], onDelete: SetNull) + card_transaction CardTransaction? @relation(fields: [card_transaction_id], references: [id], onDelete: SetNull) + confirmed_by_employee Employee? @relation("TravelReconciliationItemConfirmedBy", fields: [confirmed_by], references: [id], onDelete: SetNull) @@index([travel_reconciliation_batch_id, resolution_status], map: "idx_travel_reconciliation_items_batch_resolution") @@index([travel_request_id], map: "idx_travel_reconciliation_items_request") @@ -1585,7 +1769,7 @@ model NotificationChannel { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid @@unique([channel_type, name], map: "notification_channels_type_name_ux") @@ -1594,20 +1778,25 @@ model NotificationChannel { } model WorkflowDefinition { - id String @id @default(uuid()) @db.Uuid + id String @id @default(uuid()) @db.Uuid name String entity_type String definition_json Json version Int - is_active Boolean @default(true) + is_active Boolean @default(true) + published_at DateTime? + archived_at DateTime? created_at DateTime @default(now()) created_by String? @db.Uuid + updated_at DateTime @default(now()) @updatedAt + updated_by String? @db.Uuid workflow_instances WorkflowInstance[] @@unique([entity_type, version]) @@index([entity_type, is_active]) + @@index([entity_type, archived_at], map: "idx_workflow_definitions_entity_archived") @@map("workflow_definitions") } @@ -1629,7 +1818,7 @@ model WorkflowInstance { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid workflow_definition WorkflowDefinition @relation(fields: [workflow_id], references: [id], onDelete: Restrict) @@ -1722,7 +1911,7 @@ model ApprovalRule { created_at DateTime @default(now()) created_by String? @db.Uuid - updated_at DateTime @updatedAt + updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid @@index([enabled, priority]) diff --git a/server/scripts/lib/load-env-defaults.sh b/server/scripts/lib/load-env-defaults.sh new file mode 100644 index 00000000..6f58e263 --- /dev/null +++ b/server/scripts/lib/load-env-defaults.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +load_env_defaults() { + local env_file="${1:-.env}" + + if [[ ! -f "$env_file" ]]; then + return 0 + fi + + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" =~ ^[[:space:]]*$ ]] || [[ "$line" =~ ^[[:space:]]*# ]]; then + continue + fi + + if [[ "$line" != *=* ]]; then + continue + fi + + local key="${line%%=*}" + local value="${line#*=}" + + key="${key#"${key%%[![:space:]]*}"}" + key="${key%"${key##*[![:space:]]}"}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + continue + fi + + if [[ -n "${!key+x}" ]]; then + continue + fi + + if [[ "$value" =~ ^\"(.*)\"$ ]]; then + value="${BASH_REMATCH[1]}" + elif [[ "$value" =~ ^\'(.*)\'$ ]]; then + value="${BASH_REMATCH[1]}" + fi + + export "$key=$value" + done < "$env_file" +} diff --git a/server/scripts/regression-assets.sh b/server/scripts/regression-assets.sh index 75649f2f..4d7b2e82 100755 --- a/server/scripts/regression-assets.sh +++ b/server/scripts/regression-assets.sh @@ -23,12 +23,9 @@ fi ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" -if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then - set -a - # shellcheck disable=SC1091 - source .env - set +a -fi +# shellcheck source=scripts/lib/load-env-defaults.sh +source "$ROOT_DIR/scripts/lib/load-env-defaults.sh" +load_env_defaults ".env" if [[ -z "${DATABASE_URL:-}" ]]; then echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2 diff --git a/server/scripts/regression-audit.sh b/server/scripts/regression-audit.sh index 717dba8a..aa8f8d83 100755 --- a/server/scripts/regression-audit.sh +++ b/server/scripts/regression-audit.sh @@ -29,12 +29,9 @@ fi ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" -if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then - set -a - # shellcheck disable=SC1091 - source .env - set +a -fi +# shellcheck source=scripts/lib/load-env-defaults.sh +source "$ROOT_DIR/scripts/lib/load-env-defaults.sh" +load_env_defaults ".env" if [[ -z "${DATABASE_URL:-}" ]]; then echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2 diff --git a/server/scripts/regression-dedup.sh b/server/scripts/regression-dedup.sh index c77c5afc..28006d64 100755 --- a/server/scripts/regression-dedup.sh +++ b/server/scripts/regression-dedup.sh @@ -26,12 +26,9 @@ fi ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" -if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then - set -a - # shellcheck disable=SC1091 - source .env - set +a -fi +# shellcheck source=scripts/lib/load-env-defaults.sh +source "$ROOT_DIR/scripts/lib/load-env-defaults.sh" +load_env_defaults ".env" if [[ -z "${DATABASE_URL:-}" ]]; then echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2 diff --git a/server/scripts/regression-im-notifier.sh b/server/scripts/regression-im-notifier.sh index 17ea33da..05c9b081 100755 --- a/server/scripts/regression-im-notifier.sh +++ b/server/scripts/regression-im-notifier.sh @@ -27,22 +27,18 @@ fi ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" -if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then - set -a - # shellcheck disable=SC1091 - source .env - set +a -fi +# shellcheck source=scripts/lib/load-env-defaults.sh +source "$ROOT_DIR/scripts/lib/load-env-defaults.sh" +load_env_defaults ".env" if [[ -z "${DATABASE_URL:-}" ]]; then echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2 exit 1 fi -if [[ -z "${FIELD_ENCRYPTION_KEY:-}" ]]; then - echo "[ERROR] FIELD_ENCRYPTION_KEY is required for IM secret regression" >&2 - exit 1 -fi +TEST_FIELD_ENCRYPTION_KEY_DEFAULT="MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" +FIELD_ENCRYPTION_KEY="${FIELD_ENCRYPTION_KEY:-$TEST_FIELD_ENCRYPTION_KEY_DEFAULT}" +export FIELD_ENCRYPTION_KEY TMP_DIR="$(mktemp -d)" cleanup() { diff --git a/server/scripts/regression-integrations.sh b/server/scripts/regression-integrations.sh index 97277307..53d07e60 100755 --- a/server/scripts/regression-integrations.sh +++ b/server/scripts/regression-integrations.sh @@ -36,12 +36,9 @@ fi ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" -if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then - set -a - # shellcheck disable=SC1091 - source .env - set +a -fi +# shellcheck source=scripts/lib/load-env-defaults.sh +source "$ROOT_DIR/scripts/lib/load-env-defaults.sh" +load_env_defaults ".env" if [[ -z "${DATABASE_URL:-}" ]]; then echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2 diff --git a/server/scripts/regression-ocr.sh b/server/scripts/regression-ocr.sh index 71968e7a..5ee0e0d8 100755 --- a/server/scripts/regression-ocr.sh +++ b/server/scripts/regression-ocr.sh @@ -9,12 +9,9 @@ if ! command -v node >/dev/null 2>&1; then exit 1 fi -if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then - set -a - # shellcheck disable=SC1091 - source .env - set +a -fi +# shellcheck source=scripts/lib/load-env-defaults.sh +source "$ROOT_DIR/scripts/lib/load-env-defaults.sh" +load_env_defaults ".env" if [[ -z "${DATABASE_URL:-}" ]]; then echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2 diff --git a/server/scripts/regression-procurement.sh b/server/scripts/regression-procurement.sh index eb8d837c..d7157cd5 100755 --- a/server/scripts/regression-procurement.sh +++ b/server/scripts/regression-procurement.sh @@ -23,12 +23,9 @@ fi ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" -if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then - set -a - # shellcheck disable=SC1091 - source .env - set +a -fi +# shellcheck source=scripts/lib/load-env-defaults.sh +source "$ROOT_DIR/scripts/lib/load-env-defaults.sh" +load_env_defaults ".env" if [[ -z "${DATABASE_URL:-}" ]]; then echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2 diff --git a/server/scripts/regression-verification.sh b/server/scripts/regression-verification.sh index 8d4836d1..b3ac394f 100755 --- a/server/scripts/regression-verification.sh +++ b/server/scripts/regression-verification.sh @@ -19,12 +19,9 @@ fi ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" -if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then - set -a - # shellcheck disable=SC1091 - source .env - set +a -fi +# shellcheck source=scripts/lib/load-env-defaults.sh +source "$ROOT_DIR/scripts/lib/load-env-defaults.sh" +load_env_defaults ".env" if [[ -z "${DATABASE_URL:-}" ]]; then echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2 diff --git a/server/scripts/regression-workflow-actions.sh b/server/scripts/regression-workflow-actions.sh index cd76e8ac..f7e1d0bd 100755 --- a/server/scripts/regression-workflow-actions.sh +++ b/server/scripts/regression-workflow-actions.sh @@ -32,12 +32,9 @@ fi ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" -if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then - set -a - # shellcheck disable=SC1091 - source .env - set +a -fi +# shellcheck source=scripts/lib/load-env-defaults.sh +source "$ROOT_DIR/scripts/lib/load-env-defaults.sh" +load_env_defaults ".env" if [[ -z "${DATABASE_URL:-}" ]]; then echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2 diff --git a/server/src/app.js b/server/src/app.js index fe9be006..ea050376 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -38,6 +38,16 @@ const notificationsRoutes = require("./routes/notifications"); const exportsRoutes = require("./routes/exports"); const dashboardRoutes = require("./routes/dashboard"); const budgetsRoutes = require("./routes/budgets"); +const budgetControlRulesRoutes = require("./routes/budgetControlRules"); +const riskFlagsRoutes = require("./routes/riskFlags"); +const paymentBatchesRoutes = require("./routes/paymentBatches"); +const apOperationsRoutes = require("./routes/apOperations"); +const portalRoutes = require("./routes/portal"); +const searchRoutes = require("./routes/search"); +const announcementsRoutes = require("./routes/announcements"); +const workflowDefinitionsRoutes = require("./routes/workflowDefinitions"); +const migrationRunsRoutes = require("./routes/migrationRuns"); +const cutoverRoutes = require("./routes/cutover"); const purchaseRequestRoutes = require("./routes/purchaseRequests"); const assetsRoutes = require("./routes/assets"); const assetMovementsRoutes = require("./routes/assetMovements"); @@ -304,6 +314,16 @@ async function buildApp() { await app.register(notificationsRoutes, { prefix: "/api" }); await app.register(dashboardRoutes, { prefix: "/api" }); await app.register(budgetsRoutes, { prefix: "/api" }); + await app.register(budgetControlRulesRoutes, { prefix: "/api" }); + await app.register(riskFlagsRoutes, { prefix: "/api" }); + await app.register(paymentBatchesRoutes, { prefix: "/api" }); + await app.register(apOperationsRoutes, { prefix: "/api" }); + await app.register(portalRoutes, { prefix: "/api" }); + await app.register(searchRoutes, { prefix: "/api" }); + await app.register(announcementsRoutes, { prefix: "/api" }); + await app.register(workflowDefinitionsRoutes, { prefix: "/api" }); + await app.register(migrationRunsRoutes, { prefix: "/api" }); + await app.register(cutoverRoutes, { prefix: "/api" }); await app.register(purchaseRequestRoutes, { prefix: "/api" }); await app.register(assetsRoutes, { prefix: "/api" }); await app.register(assetMovementsRoutes, { prefix: "/api" }); diff --git a/server/src/config.js b/server/src/config.js index 21b4eead..cfaf8d19 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -85,6 +85,7 @@ function getConfig() { pgBossSchema: process.env.PG_BOSS_SCHEMA ?? "pgboss", ocrProvider: process.env.OCR_PROVIDER ?? "off", + ocrCronEnabled: process.env.OCR_CRON_ENABLED !== "false", ocrBatchSize: Number(process.env.OCR_BATCH_SIZE ?? 10), ocrLockTtlSeconds: Number(process.env.OCR_LOCK_TTL_SECONDS ?? 300), ocrPollCron: process.env.OCR_POLL_CRON ?? "* * * * *", @@ -103,6 +104,13 @@ function getConfig() { verificationTimeoutMs: Number(process.env.VERIFICATION_TIMEOUT_MS ?? 5000), budgetControlMode: process.env.BUDGET_CONTROL_MODE ?? "warn", + budgetPrecheckEnabled: process.env.BUDGET_PRECHECK_ENABLED === "true", + paymentBatchEnabled: process.env.PAYMENT_BATCH_ENABLED === "true", + portalHomeEnabled: process.env.PORTAL_HOME_ENABLED === "true", + globalSearchEnabled: process.env.GLOBAL_SEARCH_ENABLED === "true", + announcementsEnabled: process.env.ANNOUNCEMENTS_ENABLED === "true", + workflowDefinitionAdminEnabled: process.env.WORKFLOW_DEFINITION_ADMIN_ENABLED === "true", + cutoverConsoleEnabled: process.env.CUTOVER_CONSOLE_ENABLED === "true", aiAssistEnabled: process.env.AI_ASSIST_ENABLED === "true", aiProvider: process.env.AI_PROVIDER ?? "mock", diff --git a/server/src/constants/permissions.js b/server/src/constants/permissions.js index 6b9e0cdb..e1a3c447 100644 --- a/server/src/constants/permissions.js +++ b/server/src/constants/permissions.js @@ -152,6 +152,7 @@ const ROLE_PERMISSIONS = { "budget.view", "expense.approve", "expense.finance.pay", + "payment.batch.manage", "expense.view_all", "procurement.view_all", "travel.view_all", @@ -171,6 +172,7 @@ const ROLE_PERMISSIONS = { "ai.assist", "expense.approve", "expense.finance.pay", + "payment.batch.manage", "expense.view_all", "expense.finance.override_duplicate", "procurement.view_all", @@ -183,6 +185,8 @@ const ROLE_PERMISSIONS = { "asset.inventory", "system.settings", "system.audit.view", + "payment.batch.manage", + "risk.review.manage", ], administration: [ "expense.proxy_submit", diff --git a/server/src/jobs/ocrJob.js b/server/src/jobs/ocrJob.js index b2369f18..1d636ef6 100644 --- a/server/src/jobs/ocrJob.js +++ b/server/src/jobs/ocrJob.js @@ -291,6 +291,11 @@ function startOcrJob(fastify) { return null; } + if (!fastify.config.ocrCronEnabled) { + fastify.log.info({ provider: fastify.config.ocrProvider }, "OCR cron disabled"); + return null; + } + let running = false; const workerId = `ocr-worker:${process.pid}`; diff --git a/server/src/routes/announcements.js b/server/src/routes/announcements.js new file mode 100644 index 00000000..9c33bc66 --- /dev/null +++ b/server/src/routes/announcements.js @@ -0,0 +1,127 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); +const { buildAuditLogData } = require("../utils/audit"); +const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); + +const UUID_SCHEMA = z.string().uuid(); +const DATE_TIME_SCHEMA = z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}T/)); +const QUERY_SCHEMA = z.object({ manage: z.enum(["0", "1", "true", "false"]).optional() }); +const WRITE_SCHEMA = z.object({ + title: z.string().trim().min(1).max(120), + summary: z.string().trim().min(1).max(500), + linkUrl: z.string().trim().url().nullable().optional(), + startsAt: DATE_TIME_SCHEMA.nullable().optional(), + endsAt: DATE_TIME_SCHEMA.nullable().optional(), + enabled: z.boolean().optional(), + priority: z.coerce.number().int().min(1).max(9999).optional(), +}).strict(); +const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); + +function buildValidationError(details, statusCode = 400) { + return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); +} +function parseId(value) { + const parsed = UUID_SCHEMA.safeParse(value); + if (!parsed.success) throw buildValidationError({ id: "invalid_uuid" }); + return parsed.data; +} +function parseBody(schema, body) { + const parsed = schema.safeParse(body ?? {}); + if (!parsed.success) throw buildValidationError(parsed.error.flatten()); + return parsed.data; +} +async function ensureFeatureEnabled(fastify) { + const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.announcements, strictDepartment: false }); + if (!gate.enabled) { + throw buildValidationError({ featureFlag: "announcements_v1_disabled", reason: gate.reason }, 409); + } +} +function mapItem(item) { + return { + id: item.id, + title: item.title, + summary: item.summary, + linkUrl: item.link_url ?? null, + startsAt: item.starts_at?.toISOString?.() ?? null, + endsAt: item.ends_at?.toISOString?.() ?? null, + enabled: item.enabled, + priority: item.priority, + createdAt: item.created_at?.toISOString?.() ?? null, + updatedAt: item.updated_at?.toISOString?.() ?? null, + }; +} +async function announcementsRoutes(fastify) { + fastify.get("/announcements", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + const queryParsed = QUERY_SCHEMA.safeParse(request.query ?? {}); + if (!queryParsed.success) throw buildValidationError(queryParsed.error.flatten()); + const query = queryParsed.data; + const isManage = query.manage === "1" || query.manage === "true"; + const now = new Date(); + const where = isManage && fastify.hasPermission(request.employee, "system.settings") + ? {} + : { + enabled: true, + OR: [{ starts_at: null }, { starts_at: { lte: now } }], + AND: [{ OR: [{ ends_at: null }, { ends_at: { gte: now } }] }], + }; + const items = await fastify.prisma.portalAnnouncement.findMany({ + where, + orderBy: [{ priority: "asc" }, { created_at: "desc" }], + }); + return sendData(reply, request, { items: items.map(mapItem) }); + }); + + fastify.post("/announcements", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + const body = parseBody(WRITE_SCHEMA, request.body); + const created = await fastify.prisma.portalAnnouncement.create({ + data: { + title: body.title, + summary: body.summary, + link_url: body.linkUrl ?? null, + starts_at: body.startsAt ? new Date(body.startsAt) : null, + ends_at: body.endsAt ? new Date(body.endsAt) : null, + enabled: body.enabled ?? true, + priority: body.priority ?? 100, + created_by: request.employee.id, + updated_by: request.employee.id, + }, + }); + await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "PORTAL_ANNOUNCEMENT_CREATED", targetType: "portal_announcement", targetId: created.id }) }); + return reply.code(201).send({ requestId: request.id, data: { item: mapItem(created) } }); + }); + + fastify.patch("/announcements/:id", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + const announcementId = parseId(request.params?.id); + const body = parseBody(PATCH_SCHEMA, request.body); + const updated = await fastify.prisma.portalAnnouncement.update({ + where: { id: announcementId }, + data: { + ...(body.title !== undefined ? { title: body.title } : {}), + ...(body.summary !== undefined ? { summary: body.summary } : {}), + ...(body.linkUrl !== undefined ? { link_url: body.linkUrl ?? null } : {}), + ...(body.startsAt !== undefined ? { starts_at: body.startsAt ? new Date(body.startsAt) : null } : {}), + ...(body.endsAt !== undefined ? { ends_at: body.endsAt ? new Date(body.endsAt) : null } : {}), + ...(body.enabled !== undefined ? { enabled: body.enabled } : {}), + ...(body.priority !== undefined ? { priority: body.priority } : {}), + updated_by: request.employee.id, + }, + }); + return sendData(reply, request, { item: mapItem(updated) }); + }); + + fastify.post("/announcements/:id/archive", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + const announcementId = parseId(request.params?.id); + const updated = await fastify.prisma.portalAnnouncement.update({ + where: { id: announcementId }, + data: { enabled: false, ends_at: new Date(), updated_by: request.employee.id }, + }); + return sendData(reply, request, { item: mapItem(updated) }); + }); +} +module.exports = announcementsRoutes; diff --git a/server/src/routes/apOperations.js b/server/src/routes/apOperations.js new file mode 100644 index 00000000..1bdbe48e --- /dev/null +++ b/server/src/routes/apOperations.js @@ -0,0 +1,125 @@ +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); +const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); +const { buildPersistedRiskFlag, toMoneyString } = require("../services/feeControl"); + +function ensureReadPermission(fastify, request) { + if ( + !fastify.hasPermission(request.employee, "payment.batch.manage") && + !fastify.hasPermission(request.employee, "risk.review.manage") + ) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } +} + +async function ensureFeatureEnabled(fastify) { + const gate = await evaluateFeatureFlag({ + prisma: fastify.prisma, + config: fastify.config, + flagKey: FEATURE_FLAG_KEYS.paymentBatch, + strictDepartment: false, + }); + if (!gate.enabled) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { featureFlag: "payment_batch_v1_disabled", reason: gate.reason }, + }); + } +} + +function mapBatchSummary(item) { + return { + id: item.id, + batchNo: item.batch_no, + entityType: item.entity_type, + name: item.name, + status: item.status, + createdAt: item.created_at?.toISOString?.() ?? null, + }; +} + +async function apOperationsRoutes(fastify) { + fastify.get("/ap-operations", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + ensureReadPermission(fastify, request); + + const [batches, failedItems, openRiskFlags, anomalies] = await Promise.all([ + fastify.prisma.paymentBatch.findMany({ + where: { status: { in: ["draft", "reviewed", "failed"] } }, + orderBy: [{ created_at: "desc" }, { id: "desc" }], + take: 20, + }), + fastify.prisma.paymentBatchItem.findMany({ + where: { status: "failed" }, + orderBy: [{ created_at: "desc" }, { id: "desc" }], + take: 50, + }), + fastify.prisma.entityRiskFlag.findMany({ + where: { status: "open" }, + orderBy: [{ created_at: "desc" }, { id: "desc" }], + take: 50, + }), + fastify.prisma.travelReconciliationItem.findMany({ + where: { + resolution_status: "pending", + match_status: { not: "matched" }, + }, + orderBy: [{ created_at: "desc" }, { id: "desc" }], + take: 50, + select: { + id: true, + travel_reconciliation_batch_id: true, + travel_order_id: true, + travel_request_id: true, + match_status: true, + resolution_status: true, + anomaly_code: true, + order_amount: true, + transaction_amount: true, + currency: true, + created_at: true, + }, + }), + ]); + + return sendData(reply, request, { + summary: { + openPaymentBatchCount: batches.length, + failedPaymentBatchItemCount: failedItems.length, + openRiskFlagCount: openRiskFlags.length, + reconciliationAnomalyCount: anomalies.length, + }, + openPaymentBatches: batches.map(mapBatchSummary), + failedPaymentBatchItems: failedItems.map((item) => ({ + id: item.id, + paymentBatchId: item.payment_batch_id, + entityId: item.entity_id, + entityCode: item.entity_code, + entityTitle: item.entity_title, + amount: toMoneyString(item.amount), + currencyCode: item.currency_code, + failureCode: item.failure_code ?? null, + failureMessage: item.failure_message ?? null, + createdAt: item.created_at?.toISOString?.() ?? null, + })), + openRiskFlags: openRiskFlags.map(buildPersistedRiskFlag), + reconciliationAnomalies: anomalies.map((item) => ({ + id: item.id, + travelReconciliationBatchId: item.travel_reconciliation_batch_id, + travelOrderId: item.travel_order_id ?? null, + travelRequestId: item.travel_request_id ?? null, + matchStatus: item.match_status, + resolutionStatus: item.resolution_status, + anomalyCode: item.anomaly_code ?? null, + orderAmount: item.order_amount != null ? toMoneyString(item.order_amount) : null, + transactionAmount: item.transaction_amount != null ? toMoneyString(item.transaction_amount) : null, + currency: item.currency, + createdAt: item.created_at?.toISOString?.() ?? null, + })), + }); + }); +} + +module.exports = apOperationsRoutes; diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 3f4fcffd..c0ecb49f 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -497,6 +497,60 @@ async function authRoutes(fastify) { return sendData(reply, request, { accessToken }); }); + fastify.get("/sso/providers", async (request, reply) => { + if (!fastify.config.oidcEnabled) { + return reply.send({ requestId: request.id, data: [] }); + } + + return reply.send({ + requestId: request.id, + data: [ + { + id: "oidc", + label: process.env.OIDC_PROVIDER_LABEL || "企业统一登录", + enabled: true, + loginUrl: "/api/auth/oidc/login", + }, + ], + }); + }); + + fastify.get("/deeplink", async (request, reply) => { + const token = String(request.query?.token ?? "").trim(); + const verified = verifyToken(token, fastify.config.jwtSecret); + if (!verified.ok) { + throw new AppError({ + code: "AUTH_001", + message: "无效深链", + statusCode: 401, + details: { token: verified.reason }, + }); + } + + const payload = verified.payload && typeof verified.payload === "object" ? verified.payload : {}; + const targetPath = typeof payload.targetPath === "string" ? payload.targetPath.trim() : ""; + const exp = Number(payload.exp ?? 0); + if (!targetPath || !targetPath.startsWith("/")) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { targetPath: "invalid_target_path" }, + }); + } + if (!Number.isFinite(exp) || Date.now() / 1000 > exp) { + throw new AppError({ + code: "AUTH_001", + message: "深链已过期", + statusCode: 401, + details: { token: "expired" }, + }); + } + + const origin = String(fastify.config.appOrigin || "").split(",")[0].trim().replace(/\/+$/, ""); + return reply.redirect(origin ? `${origin}${targetPath}` : targetPath); + }); + fastify.get("/oidc/login", async (request, reply) => { if (!fastify.config.oidcEnabled) { throw new AppError({ diff --git a/server/src/routes/budgetControlRules.js b/server/src/routes/budgetControlRules.js new file mode 100644 index 00000000..25476c05 --- /dev/null +++ b/server/src/routes/budgetControlRules.js @@ -0,0 +1,227 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); +const { buildAuditLogData } = require("../utils/audit"); + +const UUID_SCHEMA = z.string().uuid(); +const ENTITY_TYPE_SCHEMA = z.enum(["all", "reimbursement", "purchase_request", "travel_request"]); +const CONTROL_MODE_SCHEMA = z.enum(["warn", "block", "manual_review"]); + +const LIST_QUERY_SCHEMA = z.object({ + entityType: ENTITY_TYPE_SCHEMA.optional(), + enabled: z.enum(["true", "false"]).optional(), +}); + +const WRITE_SCHEMA = z.object({ + name: z.string().trim().min(1).max(120), + entityType: ENTITY_TYPE_SCHEMA, + departmentId: UUID_SCHEMA.nullable().optional(), + legalEntityId: UUID_SCHEMA.nullable().optional(), + costCenterId: UUID_SCHEMA.nullable().optional(), + expenseTypeId: UUID_SCHEMA.nullable().optional(), + controlMode: CONTROL_MODE_SCHEMA, + priority: z.coerce.number().int().min(1).max(9999).optional(), + enabled: z.boolean().optional(), + notes: z.string().trim().max(500).nullable().optional(), +}).strict(); + +const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { + message: "empty_patch", +}); + +function buildValidationError(details, statusCode = 400) { + return new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode, + details, + }); +} + +function parseRuleId(value) { + const parsed = UUID_SCHEMA.safeParse(value); + if (!parsed.success) { + throw buildValidationError({ id: "invalid_uuid" }); + } + return parsed.data; +} + +function parseQuery(query) { + const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + return { + entityType: parsed.data.entityType ?? null, + enabled: parsed.data.enabled == null ? null : parsed.data.enabled === "true", + }; +} + +function parseBody(schema, body) { + const parsed = schema.safeParse(body ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + + if (parsed.data.costCenterId && !parsed.data.legalEntityId) { + throw buildValidationError({ costCenterId: "legalEntityId_required" }, 409); + } + if (parsed.data.entityType !== "reimbursement" && parsed.data.expenseTypeId) { + throw buildValidationError({ expenseTypeId: "only_reimbursement_supported" }, 409); + } + + return parsed.data; +} + +function mapRule(item) { + return { + id: item.id, + name: item.name, + entityType: item.entity_type, + departmentId: item.department_id ?? null, + legalEntityId: item.legal_entity_id ?? null, + costCenterId: item.cost_center_id ?? null, + expenseTypeId: item.expense_type_id ?? null, + controlMode: item.control_mode, + priority: item.priority, + enabled: item.enabled, + notes: item.notes ?? null, + archivedAt: item.archived_at?.toISOString?.() ?? null, + createdAt: item.created_at?.toISOString?.() ?? null, + updatedAt: item.updated_at?.toISOString?.() ?? null, + }; +} + +async function budgetControlRulesRoutes(fastify) { + fastify.get( + "/budget-control-rules", + { preHandler: fastify.requirePermission("budget.manage") }, + async (request, reply) => { + const query = parseQuery(request.query); + const items = await fastify.prisma.budgetControlRule.findMany({ + where: { + ...(query.entityType ? { entity_type: query.entityType } : {}), + ...(query.enabled == null ? {} : { enabled: query.enabled }), + archived_at: null, + }, + orderBy: [{ priority: "asc" }, { created_at: "asc" }], + }); + + return sendData(reply, request, { items: items.map(mapRule) }); + }, + ); + + fastify.post( + "/budget-control-rules", + { preHandler: fastify.requirePermission("budget.manage") }, + async (request, reply) => { + const body = parseBody(WRITE_SCHEMA, request.body); + const created = await fastify.prisma.budgetControlRule.create({ + data: { + name: body.name, + entity_type: body.entityType, + department_id: body.departmentId ?? null, + legal_entity_id: body.legalEntityId ?? null, + cost_center_id: body.costCenterId ?? null, + expense_type_id: body.expenseTypeId ?? null, + control_mode: body.controlMode, + priority: body.priority ?? 100, + enabled: body.enabled ?? true, + notes: body.notes ?? null, + created_by: request.employee.id, + updated_by: request.employee.id, + }, + }); + + await fastify.prisma.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "BUDGET_CONTROL_RULE_CREATED", + targetType: "budget_control_rule", + targetId: created.id, + extra: mapRule(created), + }), + }); + + return reply.code(201).send({ requestId: request.id, data: { item: mapRule(created) } }); + }, + ); + + fastify.patch( + "/budget-control-rules/:id", + { preHandler: fastify.requirePermission("budget.manage") }, + async (request, reply) => { + const ruleId = parseRuleId(request.params?.id); + const body = parseBody(PATCH_SCHEMA, request.body); + + const current = await fastify.prisma.budgetControlRule.findUnique({ where: { id: ruleId } }); + if (!current || current.archived_at) { + throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + } + + const updated = await fastify.prisma.budgetControlRule.update({ + where: { id: ruleId }, + data: { + ...(body.name !== undefined ? { name: body.name } : {}), + ...(body.entityType !== undefined ? { entity_type: body.entityType } : {}), + ...(body.departmentId !== undefined ? { department_id: body.departmentId } : {}), + ...(body.legalEntityId !== undefined ? { legal_entity_id: body.legalEntityId } : {}), + ...(body.costCenterId !== undefined ? { cost_center_id: body.costCenterId } : {}), + ...(body.expenseTypeId !== undefined ? { expense_type_id: body.expenseTypeId } : {}), + ...(body.controlMode !== undefined ? { control_mode: body.controlMode } : {}), + ...(body.priority !== undefined ? { priority: body.priority } : {}), + ...(body.enabled !== undefined ? { enabled: body.enabled } : {}), + ...(body.notes !== undefined ? { notes: body.notes } : {}), + updated_by: request.employee.id, + }, + }); + + await fastify.prisma.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "BUDGET_CONTROL_RULE_UPDATED", + targetType: "budget_control_rule", + targetId: updated.id, + extra: { fields: Object.keys(body) }, + }), + }); + + return sendData(reply, request, { item: mapRule(updated) }); + }, + ); + + fastify.post( + "/budget-control-rules/:id/archive", + { preHandler: fastify.requirePermission("budget.manage") }, + async (request, reply) => { + const ruleId = parseRuleId(request.params?.id); + const current = await fastify.prisma.budgetControlRule.findUnique({ where: { id: ruleId } }); + if (!current || current.archived_at) { + throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + } + + const archived = await fastify.prisma.budgetControlRule.update({ + where: { id: ruleId }, + data: { + enabled: false, + archived_at: new Date(), + updated_by: request.employee.id, + }, + }); + + await fastify.prisma.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "BUDGET_CONTROL_RULE_ARCHIVED", + targetType: "budget_control_rule", + targetId: archived.id, + }), + }); + + return sendData(reply, request, { item: mapRule(archived) }); + }, + ); +} + +module.exports = budgetControlRulesRoutes; diff --git a/server/src/routes/cutover.js b/server/src/routes/cutover.js new file mode 100644 index 00000000..119abfb1 --- /dev/null +++ b/server/src/routes/cutover.js @@ -0,0 +1,80 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); +const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); + +const ID_SCHEMA = z.string().uuid(); +const SCOPE_TYPE_SCHEMA = z.enum(["department", "legal_entity", "domain"]); +const WRITE_SCHEMA = z.object({ + scopeType: SCOPE_TYPE_SCHEMA, + scopeKey: z.string().trim().min(1).max(64), + enabled: z.boolean().optional(), + startsAt: z.string().datetime({ offset: true }).nullable().optional(), + notes: z.string().trim().max(500).nullable().optional(), +}).strict(); +const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); + +function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); } +function parseId(value) { const parsed = ID_SCHEMA.safeParse(value); if (!parsed.success) throw buildValidationError({ id: "invalid_uuid" }); return parsed.data; } +function parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) throw buildValidationError(parsed.error.flatten()); return parsed.data; } +async function ensureFeatureEnabled(fastify) { + const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.cutoverConsole, strictDepartment: false }); + if (!gate.enabled) throw buildValidationError({ featureFlag: "cutover_console_v1_disabled", reason: gate.reason }, 409); +} +function mapScope(item) { + return { + id: item.id, + scopeType: item.scope_type, + scopeKey: item.scope_key, + enabled: item.enabled, + startsAt: item.starts_at?.toISOString?.() ?? null, + notes: item.notes ?? null, + createdAt: item.created_at?.toISOString?.() ?? null, + updatedAt: item.updated_at?.toISOString?.() ?? null, + updatedBy: item.updated_by ?? null, + }; +} +async function cutoverRoutes(fastify) { + fastify.get("/cutover/scopes", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + const items = await fastify.prisma.cutoverScope.findMany({ orderBy: [{ scope_type: "asc" }, { scope_key: "asc" }] }); + return sendData(reply, request, { items: items.map(mapScope) }); + }); + + fastify.post("/cutover/scopes", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + const body = parseBody(WRITE_SCHEMA, request.body); + const created = await fastify.prisma.cutoverScope.create({ + data: { + scope_type: body.scopeType, + scope_key: body.scopeKey, + enabled: body.enabled ?? false, + starts_at: body.startsAt ? new Date(body.startsAt) : null, + notes: body.notes ?? null, + created_by: request.employee.id, + updated_by: request.employee.id, + }, + }); + return reply.code(201).send({ requestId: request.id, data: { item: mapScope(created) } }); + }); + + fastify.patch("/cutover/scopes/:id", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + const scopeId = parseId(request.params?.id); + const body = parseBody(PATCH_SCHEMA, request.body); + const updated = await fastify.prisma.cutoverScope.update({ + where: { id: scopeId }, + data: { + ...(body.scopeType !== undefined ? { scope_type: body.scopeType } : {}), + ...(body.scopeKey !== undefined ? { scope_key: body.scopeKey } : {}), + ...(body.enabled !== undefined ? { enabled: body.enabled } : {}), + ...(body.startsAt !== undefined ? { starts_at: body.startsAt ? new Date(body.startsAt) : null } : {}), + ...(body.notes !== undefined ? { notes: body.notes ?? null } : {}), + updated_by: request.employee.id, + }, + }); + return sendData(reply, request, { item: mapScope(updated) }); + }); +} +module.exports = cutoverRoutes; diff --git a/server/src/routes/leave.js b/server/src/routes/leave.js index 1573eb82..8e29d709 100644 --- a/server/src/routes/leave.js +++ b/server/src/routes/leave.js @@ -4,6 +4,7 @@ const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { resolveActionableStep } = require("../utils/workflow"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); +const { ensureCutoverAllowed } = require("../services/cutover"); const { DEFAULT_ATTACHMENT_MIME_TYPES, calculateRequestedDays, @@ -476,6 +477,13 @@ async function leaveRoutes(fastify) { preHandler: fastify.requireEntityPermission("leave", "create"), }, async (request, reply) => { + await ensureCutoverAllowed({ + prisma: fastify.prisma, + config: fastify.config, + request, + domain: "leave", + departmentId: request.employee.department_id ?? null, + }); const { body, filePart } = await collectCreatePayload(request); const leaveType = await loadLeaveTypeOrThrow(fastify.prisma, body.leaveTypeId); const attachment = await assertLeaveTypeAttachment( @@ -648,6 +656,13 @@ async function leaveRoutes(fastify) { } const leave = leaveRows[0]; + await ensureCutoverAllowed({ + prisma: tx, + config: fastify.config, + request, + domain: "leave", + departmentId: leave.department_id ?? request.employee.department_id ?? null, + }); if (leave.owner_id !== request.employee.id) { throw new AppError({ code: "PERM_001", @@ -877,6 +892,13 @@ async function leaveRoutes(fastify) { } const leave = leaveRows[0]; + await ensureCutoverAllowed({ + prisma: tx, + config: fastify.config, + request, + domain: "leave", + departmentId: leave.department_id ?? request.employee.department_id ?? null, + }); if (leave.owner_id !== request.employee.id) { throw new AppError({ code: "PERM_001", diff --git a/server/src/routes/migrationRuns.js b/server/src/routes/migrationRuns.js new file mode 100644 index 00000000..5b2447d0 --- /dev/null +++ b/server/src/routes/migrationRuns.js @@ -0,0 +1,86 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); +const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); + +const ID_SCHEMA = z.string().uuid(); +const RUN_TYPE_SCHEMA = z.enum(["employees", "departments", "budgets", "masterdata_validation"]); + +function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); } +function parseId(value) { const parsed = ID_SCHEMA.safeParse(value); if (!parsed.success) throw buildValidationError({ id: "invalid_uuid" }); return parsed.data; } +async function ensureFeatureEnabled(fastify) { + const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.cutoverConsole, strictDepartment: false }); + if (!gate.enabled) throw buildValidationError({ featureFlag: "cutover_console_v1_disabled", reason: gate.reason }, 409); +} +function parseCsv(content) { + const rows = String(content ?? "").trim().split(/\r?\n/).filter(Boolean); + if (!rows.length) return { headers: [], records: [] }; + const headers = rows[0].split(",").map((item) => item.trim()); + const records = rows.slice(1).map((line) => { + const cells = line.split(","); + const record = {}; + headers.forEach((header, index) => { record[header] = (cells[index] ?? "").trim(); }); + return record; + }); + return { headers, records }; +} +function requiredHeaders(runType) { + if (runType === "employees") return ["employee_no", "name", "department_name"]; + if (runType === "departments") return ["name"]; + if (runType === "budgets") return ["code", "name"]; + return ["type", "code"]; +} +function mapRun(item) { + return { + id: item.id, + runType: item.run_type, + status: item.status, + sourceFilename: item.source_filename, + summaryJson: item.summary_json ?? null, + errorJson: item.error_json ?? null, + createdAt: item.created_at?.toISOString?.() ?? null, + createdBy: item.created_by ?? null, + }; +} +async function migrationRunsRoutes(fastify) { + fastify.post("/migration-runs/dry-run", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + const part = await request.file(); + if (!part) throw buildValidationError({ file: "required" }); + const runType = RUN_TYPE_SCHEMA.safeParse(part.fields?.runType?.value ?? request.query?.runType ?? request.body?.runType); + if (!runType.success) throw buildValidationError({ runType: "invalid" }); + const content = await part.toBuffer(); + const parsed = parseCsv(content.toString("utf8")); + const required = requiredHeaders(runType.data); + const missingHeaders = required.filter((header) => !parsed.headers.includes(header)); + const summaryJson = { rowCount: parsed.records.length, headers: parsed.headers, requiredHeaders: required }; + const errorJson = missingHeaders.length ? { missingHeaders } : null; + const status = missingHeaders.length || parsed.records.length === 0 ? "dry_run_failed" : "dry_run_ok"; + const created = await fastify.prisma.migrationRun.create({ + data: { + run_type: runType.data, + status, + source_filename: part.filename, + summary_json: summaryJson, + error_json: errorJson, + created_by: request.employee.id, + }, + }); + return reply.code(201).send({ requestId: request.id, data: { item: mapRun(created) } }); + }); + + fastify.get("/migration-runs", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + const items = await fastify.prisma.migrationRun.findMany({ orderBy: [{ created_at: "desc" }, { id: "desc" }] }); + return sendData(reply, request, { items: items.map(mapRun) }); + }); + + fastify.get("/migration-runs/:id", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + const run = await fastify.prisma.migrationRun.findUnique({ where: { id: parseId(request.params?.id) } }); + if (!run) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + return sendData(reply, request, { item: mapRun(run) }); + }); +} +module.exports = migrationRunsRoutes; diff --git a/server/src/routes/paymentBatches.js b/server/src/routes/paymentBatches.js new file mode 100644 index 00000000..520d9ca7 --- /dev/null +++ b/server/src/routes/paymentBatches.js @@ -0,0 +1,747 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); +const { buildAuditLogData } = require("../utils/audit"); +const { parseDateOnlyString } = require("../utils/date"); +const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); +const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyString } = require("../services/feeControl"); + +const UUID_SCHEMA = z.string().uuid(); +const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "travel_order"]); +const PAYMENT_CHANNEL_SCHEMA = z.enum(["bank", "alipay", "wechat", "cash", "other"]); + +const LIST_QUERY_SCHEMA = z.object({ + entityType: ENTITY_TYPE_SCHEMA.optional(), + status: z.enum(["draft", "reviewed", "executed", "failed", "cancelled"]).optional(), +}); + +const CREATE_SCHEMA = z.object({ + entityType: ENTITY_TYPE_SCHEMA, + name: z.string().trim().min(1).max(120), + entityIds: z.array(UUID_SCHEMA).min(1).max(200), + defaultPaymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + defaultPaymentChannel: PAYMENT_CHANNEL_SCHEMA.optional(), + voucherPrefix: z.string().trim().min(1).max(32).optional(), +}).strict(); + +const PATCH_ITEM_SCHEMA = z.object({ + paymentVoucherNoOverride: z.string().trim().max(50).nullable().optional(), + paymentDateOverride: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(), + paymentChannelOverride: PAYMENT_CHANNEL_SCHEMA.nullable().optional(), +}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); + +const REVIEW_SCHEMA = z.object({ + comment: z.string().trim().max(500).optional(), + resolveRiskFlagIds: z.array(UUID_SCHEMA).optional(), +}).strict(); + +function buildValidationError(details, statusCode = 400) { + return new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode, + details, + }); +} + +function parseId(value, key = "id") { + const parsed = UUID_SCHEMA.safeParse(value); + if (!parsed.success) { + throw buildValidationError({ [key]: "invalid_uuid" }); + } + return parsed.data; +} + +function parseBody(schema, body) { + const parsed = schema.safeParse(body ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + return parsed.data; +} + +function normalizeNullableString(value) { + if (value == null) { + return null; + } + const trimmed = String(value).trim(); + return trimmed || null; +} + +async function ensureFeatureEnabled(fastify) { + const gate = await evaluateFeatureFlag({ + prisma: fastify.prisma, + config: fastify.config, + flagKey: FEATURE_FLAG_KEYS.paymentBatch, + strictDepartment: false, + }); + + if (!gate.enabled) { + throw buildValidationError({ featureFlag: "payment_batch_v1_disabled", reason: gate.reason }, 409); + } + + return gate; +} + +function ensureBatchManagePermission(fastify, request) { + if (!fastify.hasPermission(request.employee, "payment.batch.manage")) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } +} + +function ensureBatchReadPermission(fastify, request) { + if ( + !fastify.hasPermission(request.employee, "payment.batch.manage") && + !fastify.hasPermission(request.employee, "risk.review.manage") + ) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } +} + +function ensureBatchReviewPermission(fastify, request) { + if (!fastify.hasPermission(request.employee, "risk.review.manage")) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } +} + +function buildBatchNo() { + const now = new Date(); + const date = now.toISOString().slice(0, 10).replace(/-/g, ""); + const time = now.toISOString().slice(11, 19).replace(/:/g, ""); + return `PB-${date}-${time}-${String(now.getMilliseconds()).padStart(3, "0")}`; +} + +async function loadBatchWithItems(tx, batchId) { + const batch = await tx.paymentBatch.findUnique({ + where: { id: batchId }, + include: { + items: { + orderBy: [{ created_at: "asc" }, { id: "asc" }], + }, + }, + }); + if (!batch) { + throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + } + return batch; +} + +async function loadEntitySnapshots(tx, entityType, entityIds) { + if (entityType === "reimbursement") { + const items = await tx.reimbursementForm.findMany({ + where: { id: { in: entityIds } }, + select: { + id: true, + form_number: true, + description: true, + total_amount: true, + status: true, + payment_voucher_no: true, + payment_date: true, + payment_channel: true, + paid_at: true, + }, + }); + return items.map((item) => ({ + entityId: item.id, + entityCode: item.form_number ?? item.id, + entityTitle: item.description || item.form_number || item.id, + amount: toMoneyString(item.total_amount), + currencyCode: "CNY", + status: item.status, + snapshotJson: { + formNumber: item.form_number ?? null, + description: item.description ?? null, + paymentVoucherNo: item.payment_voucher_no ?? null, + paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null, + paymentChannel: item.payment_channel ?? null, + paidAt: item.paid_at?.toISOString?.() ?? null, + }, + })); + } + + const items = await tx.travelOrder.findMany({ + where: { id: { in: entityIds } }, + select: { + id: true, + source: true, + external_order_no: true, + amount: true, + currency: true, + status: true, + payment_voucher_no: true, + payment_date: true, + payment_channel: true, + paid_at: true, + }, + }); + return items.map((item) => ({ + entityId: item.id, + entityCode: item.external_order_no, + entityTitle: `${item.source}:${item.external_order_no}`, + amount: toMoneyString(item.amount), + currencyCode: item.currency, + status: item.status, + snapshotJson: { + source: item.source, + externalOrderNo: item.external_order_no, + paymentVoucherNo: item.payment_voucher_no ?? null, + paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null, + paymentChannel: item.payment_channel ?? null, + paidAt: item.paid_at?.toISOString?.() ?? null, + }, + })); +} + +function ensureBatchEntityStatus(entityType, snapshot) { + if (entityType === "reimbursement" && !["approved", "paid"].includes(snapshot.status)) { + throw buildValidationError({ entity: `${snapshot.entityCode}:invalid_pay_status` }, 409); + } + if (entityType === "travel_order" && !["confirmed", "paid"].includes(snapshot.status)) { + throw buildValidationError({ entity: `${snapshot.entityCode}:invalid_pay_status` }, 409); + } +} + +function mapBatch(batch, openRiskFlags = []) { + const riskMap = new Map(); + for (const item of openRiskFlags) { + const entityId = item.entityId; + if (!riskMap.has(entityId)) { + riskMap.set(entityId, []); + } + riskMap.get(entityId).push(item); + } + + return { + id: batch.id, + batchNo: batch.batch_no, + entityType: batch.entity_type, + name: batch.name, + status: batch.status, + defaultPaymentDate: batch.default_payment_date?.toISOString?.()?.slice(0, 10) ?? null, + defaultPaymentChannel: batch.default_payment_channel ?? null, + voucherPrefix: batch.voucher_prefix ?? null, + reviewComment: batch.review_comment ?? null, + executionSummaryJson: batch.execution_summary_json ?? null, + createdAt: batch.created_at?.toISOString?.() ?? null, + createdBy: batch.created_by ?? null, + reviewedAt: batch.reviewed_at?.toISOString?.() ?? null, + reviewedBy: batch.reviewed_by ?? null, + executedAt: batch.executed_at?.toISOString?.() ?? null, + executedBy: batch.executed_by ?? null, + cancelledAt: batch.cancelled_at?.toISOString?.() ?? null, + cancelledBy: batch.cancelled_by ?? null, + items: (batch.items ?? []).map((item) => ({ + id: item.id, + entityId: item.entity_id, + entityCode: item.entity_code, + entityTitle: item.entity_title, + amount: toMoneyString(item.amount), + currencyCode: item.currency_code, + paymentVoucherNoOverride: item.payment_voucher_no_override ?? null, + paymentDateOverride: item.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null, + paymentChannelOverride: item.payment_channel_override ?? null, + status: item.status, + failureCode: item.failure_code ?? null, + failureMessage: item.failure_message ?? null, + snapshotJson: item.snapshot_json, + createdAt: item.created_at?.toISOString?.() ?? null, + openRiskFlags: riskMap.get(item.entity_id) ?? [], + })), + }; +} + +async function listOpenRiskFlagsForBatch(tx, batch) { + const entityIds = batch.items.map((item) => item.entity_id); + if (!entityIds.length) { + return []; + } + const items = await tx.entityRiskFlag.findMany({ + where: { + entity_type: batch.entity_type, + entity_id: { in: entityIds }, + status: "open", + }, + orderBy: [{ created_at: "desc" }, { id: "desc" }], + }); + return items.map(buildPersistedRiskFlag); +} + +async function executeReimbursementPayment(tx, { batch, item, actorId, index }) { + const current = await tx.reimbursementForm.findUnique({ + where: { id: item.entity_id }, + select: { + id: true, + status: true, + form_number: true, + owner_id: true, + submitter_id: true, + department_id: true, + payment_voucher_no: true, + payment_date: true, + payment_channel: true, + paid_at: true, + paid_by: true, + }, + }); + if (!current) { + throw buildNotFoundError(); + } + if (!["approved", "paid"].includes(current.status)) { + throw buildValidationError({ status: "invalid_pay_status" }, 409); + } + + const voucher = normalizeNullableString(item.payment_voucher_no_override) ?? + (batch.voucher_prefix ? `${batch.voucher_prefix}-${String(index + 1).padStart(3, "0")}` : null); + const paymentDate = item.payment_date_override?.toISOString?.()?.slice(0, 10) + ?? batch.default_payment_date?.toISOString?.()?.slice(0, 10) + ?? null; + const paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? null; + const existingPaymentDate = current.payment_date?.toISOString?.()?.slice(0, 10) ?? null; + + if (current.status === "paid") { + const conflict = + (voucher != null && voucher !== (current.payment_voucher_no ?? null)) || + (paymentDate != null && paymentDate !== existingPaymentDate) || + (paymentChannel != null && paymentChannel !== (current.payment_channel ?? null)); + if (conflict) { + throw buildValidationError({ payment: "already_paid_conflict" }, 409); + } + return { idempotent: true }; + } + + const now = new Date(); + await tx.invoicePool.updateMany({ + where: { linked_reimbursement_id: item.entity_id, status: "bound" }, + data: { + status: "archived", + archived_reason: "used_complete", + archived_at: now, + updated_by: actorId, + }, + }); + await tx.reimbursementForm.update({ + where: { id: item.entity_id }, + data: { + status: "paid", + payment_voucher_no: voucher, + payment_date: paymentDate ? parseDateOnlyString(paymentDate) : null, + payment_channel: paymentChannel, + paid_at: now, + paid_by: actorId, + updated_by: actorId, + }, + }); + return { idempotent: false }; +} + +async function executeTravelOrderPayment(tx, { batch, item, actorId, index }) { + const current = await tx.travelOrder.findUnique({ + where: { id: item.entity_id }, + select: { + id: true, + status: true, + amount: true, + currency: true, + payment_voucher_no: true, + payment_date: true, + payment_channel: true, + paid_at: true, + paid_by: true, + }, + }); + if (!current) { + throw buildNotFoundError(); + } + if (!["confirmed", "paid"].includes(current.status)) { + throw buildValidationError({ status: "invalid_pay_status" }, 409); + } + + const voucher = normalizeNullableString(item.payment_voucher_no_override) ?? + (batch.voucher_prefix ? `${batch.voucher_prefix}-${String(index + 1).padStart(3, "0")}` : null); + const paymentDate = item.payment_date_override?.toISOString?.()?.slice(0, 10) + ?? batch.default_payment_date?.toISOString?.()?.slice(0, 10) + ?? null; + const paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? null; + const existingPaymentDate = current.payment_date?.toISOString?.()?.slice(0, 10) ?? null; + + if (current.status === "paid") { + const conflict = + voucher !== (current.payment_voucher_no ?? null) || + paymentDate !== existingPaymentDate || + paymentChannel !== (current.payment_channel ?? null); + if (conflict) { + throw buildValidationError({ payment: "already_paid_conflict" }, 409); + } + return { idempotent: true }; + } + + const matchedTransaction = await tx.cardTransaction.findFirst({ + where: { + travel_order_id: item.entity_id, + txn_type: "charge", + status: "settled", + amount: current.amount, + currency: current.currency, + }, + orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }], + select: { id: true }, + }); + if (!matchedTransaction) { + throw buildValidationError({ payment: "missing_settled_transaction" }, 409); + } + + await tx.travelOrder.update({ + where: { id: item.entity_id }, + data: { + status: "paid", + payment_voucher_no: voucher, + payment_date: paymentDate ? parseDateOnlyString(paymentDate) : null, + payment_channel: paymentChannel, + paid_at: new Date(), + paid_by: actorId, + updated_by: actorId, + }, + }); + return { idempotent: false }; +} + +function buildNotFoundError() { + return new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); +} + +async function paymentBatchesRoutes(fastify) { + fastify.get("/payment-batches", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + ensureBatchReadPermission(fastify, request); + const queryParsed = LIST_QUERY_SCHEMA.safeParse(request.query ?? {}); + if (!queryParsed.success) throw buildValidationError(queryParsed.error.flatten()); + const query = queryParsed.data; + const items = await fastify.prisma.paymentBatch.findMany({ + where: { + ...(query.entityType ? { entity_type: query.entityType } : {}), + ...(query.status ? { status: query.status } : {}), + }, + include: { items: { orderBy: [{ created_at: "asc" }, { id: "asc" }] } }, + orderBy: [{ created_at: "desc" }, { id: "desc" }], + }); + + const mapped = []; + for (const batch of items) { + const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, batch); + mapped.push(mapBatch(batch, openRiskFlags)); + } + return sendData(reply, request, { items: mapped }); + }); + + fastify.post("/payment-batches", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + ensureBatchManagePermission(fastify, request); + const body = parseBody(CREATE_SCHEMA, request.body); + const entityIds = Array.from(new Set(body.entityIds)); + + const created = await fastify.prisma.$transaction(async (tx) => { + const snapshots = await loadEntitySnapshots(tx, body.entityType, entityIds); + if (snapshots.length !== entityIds.length) { + throw buildValidationError({ entityIds: "contains_not_found" }, 404); + } + for (const snapshot of snapshots) { + ensureBatchEntityStatus(body.entityType, snapshot); + } + + const batch = await tx.paymentBatch.create({ + data: { + batch_no: buildBatchNo(), + entity_type: body.entityType, + name: body.name, + status: "draft", + default_payment_date: body.defaultPaymentDate ? parseDateOnlyString(body.defaultPaymentDate) : null, + default_payment_channel: body.defaultPaymentChannel ?? null, + voucher_prefix: normalizeNullableString(body.voucherPrefix), + created_by: request.employee.id, + updated_by: request.employee.id, + }, + }); + + for (const snapshot of snapshots) { + await tx.paymentBatchItem.create({ + data: { + payment_batch_id: batch.id, + entity_id: snapshot.entityId, + entity_code: snapshot.entityCode, + entity_title: snapshot.entityTitle, + amount: snapshot.amount, + currency_code: snapshot.currencyCode, + status: "pending", + snapshot_json: snapshot.snapshotJson, + }, + }); + } + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "PAYMENT_BATCH_CREATED", + targetType: "payment_batch", + targetId: batch.id, + extra: { + entityType: body.entityType, + entityCount: snapshots.length, + }, + }), + }); + + return loadBatchWithItems(tx, batch.id); + }); + + return reply.code(201).send({ requestId: request.id, data: { item: mapBatch(created, []) } }); + }); + + fastify.get("/payment-batches/:id", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + ensureBatchReadPermission(fastify, request); + const batchId = parseId(request.params?.id); + const batch = await loadBatchWithItems(fastify.prisma, batchId); + const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, batch); + return sendData(reply, request, { item: mapBatch(batch, openRiskFlags) }); + }); + + fastify.patch("/payment-batches/:id/items/:itemId", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + ensureBatchManagePermission(fastify, request); + const batchId = parseId(request.params?.id); + const itemId = parseId(request.params?.itemId, "itemId"); + const body = parseBody(PATCH_ITEM_SCHEMA, request.body); + + const batch = await loadBatchWithItems(fastify.prisma, batchId); + if (batch.status !== "draft") { + throw buildValidationError({ status: "only_draft_editable" }, 409); + } + const item = batch.items.find((entry) => entry.id === itemId); + if (!item) { + throw buildNotFoundError(); + } + if (item.status !== "pending") { + throw buildValidationError({ item: "only_pending_editable" }, 409); + } + + const updated = await fastify.prisma.paymentBatchItem.update({ + where: { id: itemId }, + data: { + ...(body.paymentVoucherNoOverride !== undefined + ? { payment_voucher_no_override: normalizeNullableString(body.paymentVoucherNoOverride) } + : {}), + ...(body.paymentDateOverride !== undefined + ? { payment_date_override: body.paymentDateOverride ? parseDateOnlyString(body.paymentDateOverride) : null } + : {}), + ...(body.paymentChannelOverride !== undefined + ? { payment_channel_override: body.paymentChannelOverride ?? null } + : {}), + }, + }); + return sendData(reply, request, { + item: { + id: updated.id, + paymentVoucherNoOverride: updated.payment_voucher_no_override ?? null, + paymentDateOverride: updated.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null, + paymentChannelOverride: updated.payment_channel_override ?? null, + }, + }); + }); + + fastify.post("/payment-batches/:id/review", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + ensureBatchReviewPermission(fastify, request); + const batchId = parseId(request.params?.id); + const body = parseBody(REVIEW_SCHEMA, request.body); + + const result = await fastify.prisma.$transaction(async (tx) => { + const batch = await loadBatchWithItems(tx, batchId); + if (batch.status !== "draft") { + throw buildValidationError({ status: "only_draft_reviewable" }, 409); + } + if (batch.created_by && batch.created_by === request.employee.id) { + throw buildValidationError({ review: "maker_checker_conflict" }, 409); + } + if (batch.items.some((item) => item.status !== "pending")) { + throw buildValidationError({ items: "all_items_must_be_pending" }, 409); + } + + const openRiskFlags = await tx.entityRiskFlag.findMany({ + where: { + entity_type: batch.entity_type, + entity_id: { in: batch.items.map((item) => item.entity_id) }, + status: "open", + }, + }); + const resolveIds = new Set(body.resolveRiskFlagIds ?? []); + const unresolved = openRiskFlags.filter((item) => !resolveIds.has(item.id)); + if (unresolved.length) { + throw buildValidationError({ resolveRiskFlagIds: "open_risk_flags_must_be_resolved" }, 409); + } + + if (openRiskFlags.length) { + await tx.entityRiskFlag.updateMany({ + where: { id: { in: openRiskFlags.map((item) => item.id) } }, + data: { + status: "resolved", + resolved_at: new Date(), + resolved_by: request.employee.id, + }, + }); + } + + const reviewed = await tx.paymentBatch.update({ + where: { id: batchId }, + data: { + status: "reviewed", + review_comment: normalizeNullableString(body.comment), + reviewed_at: new Date(), + reviewed_by: request.employee.id, + updated_by: request.employee.id, + }, + include: { items: true }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "PAYMENT_BATCH_REVIEWED", + targetType: "payment_batch", + targetId: batchId, + extra: { + resolvedRiskFlagCount: openRiskFlags.length, + }, + }), + }); + + return reviewed; + }); + + return sendData(reply, request, { item: mapBatch(result, []) }); + }); + + fastify.post("/payment-batches/:id/execute", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + ensureBatchManagePermission(fastify, request); + const batchId = parseId(request.params?.id); + + const result = await fastify.prisma.$transaction(async (tx) => { + const batch = await loadBatchWithItems(tx, batchId); + if (!["reviewed", "failed"].includes(batch.status)) { + throw buildValidationError({ status: "only_reviewed_or_failed_executable" }, 409); + } + + let successCount = 0; + let failedCount = 0; + for (const [index, item] of batch.items.entries()) { + if (!["pending", "failed"].includes(item.status)) { + continue; + } + + try { + if (batch.entity_type === "reimbursement") { + await executeReimbursementPayment(tx, { batch, item, actorId: request.employee.id, index }); + } else { + await executeTravelOrderPayment(tx, { batch, item, actorId: request.employee.id, index }); + } + + await tx.paymentBatchItem.update({ + where: { id: item.id }, + data: { + status: "executed", + failure_code: null, + failure_message: null, + }, + }); + successCount += 1; + } catch (error) { + const failureCode = error instanceof AppError ? error.code : "SYS_001"; + const failureMessage = error instanceof AppError ? error.message : error instanceof Error ? error.message : "支付执行失败"; + await tx.paymentBatchItem.update({ + where: { id: item.id }, + data: { + status: "failed", + failure_code: failureCode, + failure_message: failureMessage, + }, + }); + await ensureRiskFlag(tx, { + entityType: batch.entity_type, + entityId: item.entity_id, + riskCode: "payment_batch_execute_failed", + level: "high", + title: "支付批次执行失败", + detail: `${item.entity_code} 执行失败:${failureMessage}`, + source: "payment_batch", + actorId: request.employee.id, + payloadJson: { + batchId: batch.id, + itemId: item.id, + failureCode, + }, + }); + failedCount += 1; + } + } + + const refreshed = await tx.paymentBatch.update({ + where: { id: batchId }, + data: { + status: failedCount > 0 ? "failed" : "executed", + execution_summary_json: { + successCount, + failedCount, + attemptedCount: successCount + failedCount, + }, + executed_at: new Date(), + executed_by: request.employee.id, + updated_by: request.employee.id, + }, + include: { items: true }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "PAYMENT_BATCH_EXECUTED", + targetType: "payment_batch", + targetId: batchId, + extra: { + successCount, + failedCount, + }, + }), + }); + + return refreshed; + }); + + const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, result); + return sendData(reply, request, { item: mapBatch(result, openRiskFlags) }); + }); + + fastify.post("/payment-batches/:id/cancel", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + ensureBatchManagePermission(fastify, request); + const batchId = parseId(request.params?.id); + const batch = await loadBatchWithItems(fastify.prisma, batchId); + if (["executed", "cancelled"].includes(batch.status)) { + throw buildValidationError({ status: "batch_not_cancellable" }, 409); + } + const cancelled = await fastify.prisma.paymentBatch.update({ + where: { id: batchId }, + data: { + status: "cancelled", + cancelled_at: new Date(), + cancelled_by: request.employee.id, + updated_by: request.employee.id, + }, + include: { items: true }, + }); + return sendData(reply, request, { item: mapBatch(cancelled, []) }); + }); +} + +module.exports = paymentBatchesRoutes; diff --git a/server/src/routes/portal.js b/server/src/routes/portal.js new file mode 100644 index 00000000..1806a727 --- /dev/null +++ b/server/src/routes/portal.js @@ -0,0 +1,115 @@ +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); +const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); + +async function buildTaskVisibilityFilters(tx, request) { + const roleFilters = (request.employee.roleCodes ?? []).length + ? [{ assignee_role: { in: request.employee.roleCodes } }] + : []; + return [{ assignee_id: request.employee.id }, ...roleFilters]; +} + +async function ensureFeatureEnabled(fastify, request) { + const gate = await evaluateFeatureFlag({ + prisma: fastify.prisma, + config: fastify.config, + flagKey: FEATURE_FLAG_KEYS.portalHome, + departmentId: request.employee.department_id ?? null, + strictDepartment: false, + }); + if (!gate.enabled) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { featureFlag: "portal_home_v1_disabled", reason: gate.reason }, + }); + } +} + +function buildQuickLinks(request) { + const items = []; + if (request.employee.permissionCodes.includes("expense.view_own") || request.employee.permissionCodes.includes("expense.view_all")) { + items.push({ key: "expense", title: "我的报销", href: "/reimbursements" }); + } + if (request.employee.permissionCodes.includes("procurement.view_own") || request.employee.permissionCodes.includes("procurement.view_all")) { + items.push({ key: "procurement", title: "采购申请", href: "/purchase-requests" }); + } + if (request.employee.permissionCodes.includes("travel.view_own") || request.employee.permissionCodes.includes("travel.view_all")) { + items.push({ key: "travel", title: "差旅申请", href: "/travel-requests" }); + } + if (request.employee.permissionCodes.includes("budget.view") || request.employee.permissionCodes.includes("budget.manage")) { + items.push({ key: "budget", title: "预算管理", href: "/budgets" }); + } + if (request.employee.permissionCodes.includes("payment.batch.manage")) { + items.push({ key: "payment-batches", title: "支付批次", href: "/payment-batches" }); + } + return items; +} + +async function portalRoutes(fastify) { + fastify.get("/portal/home", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify, request); + + const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request); + const now = new Date(); + + const [pendingTaskCount, reimbursementsCount, leaveCount, purchaseCount, travelCount, riskCount, announcementRows, paymentBatchCount] = await Promise.all([ + fastify.prisma.workflowTask.count({ where: { status: "pending", OR: assigneeFilters } }), + fastify.prisma.reimbursementForm.count({ where: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] } }), + fastify.prisma.leaveRequest.count({ where: { owner_id: request.employee.id } }), + fastify.prisma.purchaseRequest.count({ where: { applicant_id: request.employee.id } }), + fastify.prisma.travelRequest.count({ where: { OR: [{ applicant_employee_id: request.employee.id }, { submitter_id: request.employee.id }] } }), + (fastify.hasPermission(request.employee, "payment.batch.manage") || fastify.hasPermission(request.employee, "risk.review.manage")) + ? fastify.prisma.entityRiskFlag.count({ where: { status: "open" } }) + : Promise.resolve(0), + fastify.prisma.portalAnnouncement.findMany({ + where: { + enabled: true, + OR: [{ starts_at: null }, { starts_at: { lte: now } }], + AND: [{ OR: [{ ends_at: null }, { ends_at: { gte: now } }] }], + }, + orderBy: [{ priority: "asc" }, { created_at: "desc" }], + take: 6, + }), + fastify.hasPermission(request.employee, "payment.batch.manage") + ? fastify.prisma.paymentBatch.count({ where: { status: { in: ["draft", "reviewed", "failed"] } } }) + : Promise.resolve(0), + ]); + + const cards = [ + { key: "pending_tasks", title: "我的待办", value: pendingTaskCount, href: "/workflow/tasks", severity: pendingTaskCount > 0 ? "warning" : "normal" }, + { key: "my_reimbursements", title: "报销单", value: reimbursementsCount, href: "/reimbursements", severity: "normal" }, + { key: "my_purchases", title: "采购单", value: purchaseCount, href: "/purchase-requests", severity: "normal" }, + { key: "my_travel", title: "差旅单", value: travelCount, href: "/travel-requests", severity: "normal" }, + ]; + if (paymentBatchCount > 0) { + cards.push({ key: "payment_batches", title: "待处理支付批次", value: paymentBatchCount, href: "/payment-batches", severity: "warning" }); + } + if (riskCount > 0) { + cards.push({ key: "risk_flags", title: "待复核风险", value: riskCount, href: "/ap-operations", severity: "high" }); + } + + return sendData(reply, request, { + greeting: { name: request.employee.name ?? request.employee.id }, + cards, + pendingTasks: { count: pendingTaskCount, href: "/workflow/tasks" }, + myApplications: [ + { domain: "reimbursement", count: reimbursementsCount, href: "/reimbursements" }, + { domain: "leave", count: leaveCount, href: "/leave-requests" }, + { domain: "purchase_request", count: purchaseCount, href: "/purchase-requests" }, + { domain: "travel_request", count: travelCount, href: "/travel-requests" }, + ], + riskSummary: riskCount > 0 ? { openCount: riskCount, href: "/ap-operations" } : null, + announcements: announcementRows.map((item) => ({ + id: item.id, + title: item.title, + summary: item.summary, + href: item.link_url ?? null, + })), + quickLinks: buildQuickLinks(request), + }); + }); +} + +module.exports = portalRoutes; diff --git a/server/src/routes/purchaseRequests.js b/server/src/routes/purchaseRequests.js index 5ccf0731..1127f648 100644 --- a/server/src/routes/purchaseRequests.js +++ b/server/src/routes/purchaseRequests.js @@ -12,6 +12,12 @@ const { parseWorkflowSteps, resolveActionableStep, toMoneyString } = require(".. const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool"); const { issueDocumentNumber } = require("../services/documentSequence"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); +const { ensureCutoverAllowed } = require("../services/cutover"); +const { + buildPersistedRiskFlag, + buildPurchaseRequestPrecheck, + createRiskFlagsFromPrecheck, +} = require("../services/feeControl"); const { PROCUREMENT_ERP_TARGETS, enqueuePurchaseAccrualJournalExport, @@ -306,6 +312,31 @@ function normalizeBudgetControlMode(config) { return String(config.budgetControlMode ?? "warn").toLowerCase() === "block" ? "block" : "warn"; } +function buildDisabledPrecheck(entityType, entityId) { + return { + entityType, + entityId, + decision: "pass", + matchedBudgetControlRule: null, + budgetHint: null, + warnings: [], + blockingReasons: [], + riskFlags: [], + nextActions: ["功能未开启,沿用当前提交流程"], + }; +} + +function buildPrecheckBlockedError(precheck) { + return new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { + precheck: precheck.blockingReasons, + }, + }); +} + function ensurePurchaseReadPermission(fastify, request) { const canViewOwn = fastify.hasPermission(request.employee, "procurement.view_own"); const canViewAll = @@ -1010,6 +1041,13 @@ async function purchaseRequestRoutes(fastify) { preHandler: fastify.requirePermission("procurement.create"), }, async (request, reply) => { + await ensureCutoverAllowed({ + prisma: fastify.prisma, + config: fastify.config, + request, + domain: "procurement", + departmentId: request.employee.department_id ?? null, + }); const purchaseRequest = await fastify.prisma.$transaction(async (tx) => { const created = await tx.purchaseRequest.create({ data: { @@ -1078,7 +1116,19 @@ async function purchaseRequestRoutes(fastify) { }); } - return sendData(reply, request, mapPurchaseRequestDetail(purchaseRequest, request)); + const riskFlags = await fastify.prisma.entityRiskFlag.findMany({ + where: { + entity_type: "purchase_request", + entity_id: purchaseRequestId, + status: "open", + }, + orderBy: [{ created_at: "desc" }, { id: "desc" }], + }); + + return sendData(reply, request, { + ...mapPurchaseRequestDetail(purchaseRequest, request), + riskFlags: riskFlags.map(buildPersistedRiskFlag), + }); }, ); @@ -1178,6 +1228,41 @@ async function purchaseRequestRoutes(fastify) { }, ); + fastify.post( + "/purchase-requests/:id/precheck", + { + preHandler: fastify.requirePermission("procurement.submit"), + }, + async (request, reply) => { + const purchaseRequestId = parsePurchaseRequestId(request.params?.id); + const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({ + where: { id: purchaseRequestId }, + select: { id: true, department_id: true }, + }); + if (!purchaseRequest) { + throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + } + + const gate = await evaluateFeatureFlag({ + prisma: fastify.prisma, + config: fastify.config, + flagKey: FEATURE_FLAG_KEYS.budgetPrecheck, + departmentId: purchaseRequest.department_id ?? request.employee.department_id ?? null, + strictDepartment: false, + }); + if (!gate.enabled) { + return sendData(reply, request, buildDisabledPrecheck("purchase_request", purchaseRequestId)); + } + + const precheck = await buildPurchaseRequestPrecheck(fastify.prisma, { + purchaseRequestId, + config: fastify.config, + logger: fastify.log, + }); + return sendData(reply, request, precheck); + }, + ); + fastify.post( "/purchase-requests/:id/submit", { @@ -1197,6 +1282,15 @@ async function purchaseRequestRoutes(fastify) { throw buildValidationError({ status: "only_draft_submittable" }, 409); } + await ensureCutoverAllowed({ + prisma: tx, + config: fastify.config, + request, + domain: "procurement", + departmentId: locked.department_id ?? request.employee.department_id ?? null, + legalEntityId: locked.legal_entity_id ?? null, + }); + const purchaseRequest = await tx.purchaseRequest.findUnique({ where: { id: purchaseRequestId }, select: { @@ -1244,6 +1338,30 @@ async function purchaseRequestRoutes(fastify) { const documentCategory = await ensureDocumentCategoryAvailable(tx, purchaseRequest.document_category_id); const totalAmount = await recomputePurchaseRequestTotal(tx, purchaseRequestId, request.employee.id); + const budgetPrecheckGate = await evaluateFeatureFlag({ + prisma: tx, + config: fastify.config, + flagKey: FEATURE_FLAG_KEYS.budgetPrecheck, + departmentId: purchaseRequest.department_id ?? request.employee.department_id ?? null, + strictDepartment: false, + }); + const authoritativePrecheck = budgetPrecheckGate.enabled + ? await buildPurchaseRequestPrecheck(tx, { + purchaseRequestId, + config: fastify.config, + logger: fastify.log, + }) + : buildDisabledPrecheck("purchase_request", purchaseRequestId); + if (authoritativePrecheck.decision === "block") { + throw buildPrecheckBlockedError(authoritativePrecheck); + } + const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, { + entityType: "purchase_request", + entityId: purchaseRequestId, + actorId: request.employee.id, + precheck: authoritativePrecheck, + }); + const effectiveDate = new Date(); let resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, { departmentId: purchaseRequest.department_id, @@ -1263,12 +1381,14 @@ async function purchaseRequestRoutes(fastify) { } const warnings = []; - const budgetControlMode = normalizeBudgetControlMode(fastify.config); + const budgetControlMode = authoritativePrecheck?.budgetHint?.controlMode ?? normalizeBudgetControlMode(fastify.config); if (!selectedBudgetPoolId) { - if (budgetControlMode === "block") { + if (!budgetPrecheckGate.enabled && budgetControlMode === "block") { throw buildValidationError({ budget: "missing_budget_pool" }, 409); } - warnings.push("missing_budget_pool"); + if (!budgetPrecheckGate.enabled) { + warnings.push("missing_budget_pool"); + } } const workflowDefinition = await tx.workflowDefinition.findFirst({ diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index 39d944ec..65352ce7 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -13,7 +13,13 @@ const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); const { sendNotificationEmailSafe } = require("../services/email"); const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); +const { ensureCutoverAllowed } = require("../services/cutover"); const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool"); +const { + buildPersistedRiskFlag, + buildReimbursementPrecheck, + createRiskFlagsFromPrecheck, +} = require("../services/feeControl"); const { issueDocumentNumber } = require("../services/documentSequence"); const { buildWorkflowStepsFromApprovalRule, @@ -385,6 +391,31 @@ function buildBudgetHint(budgetPool, reimbursementAmount, controlMode) { }; } +function buildDisabledPrecheck(entityType, entityId) { + return { + entityType, + entityId, + decision: "pass", + matchedBudgetControlRule: null, + budgetHint: null, + warnings: [], + blockingReasons: [], + riskFlags: [], + nextActions: ["功能未开启,沿用当前提交流程"], + }; +} + +function buildPrecheckBlockedError(precheck) { + return new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { + precheck: precheck.blockingReasons, + }, + }); +} + async function reimbursementRoutes(fastify) { const createBodySchema = z.object({ ownerEmployeeId: z.string().uuid().optional(), @@ -399,6 +430,13 @@ async function reimbursementRoutes(fastify) { preHandler: fastify.requirePermission("expense.create"), }, async (request, reply) => { + await ensureCutoverAllowed({ + prisma: fastify.prisma, + config: fastify.config, + request, + domain: "expense", + departmentId: request.employee.department_id ?? null, + }); const parsed = createBodySchema.safeParse(request.body ?? {}); if (!parsed.success) { throw new AppError({ @@ -688,6 +726,14 @@ async function reimbursementRoutes(fastify) { const pendingInstance = reimbursement.workflow_instances[0] ?? null; const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null; + const riskFlags = await fastify.prisma.entityRiskFlag.findMany({ + where: { + entity_type: "reimbursement", + entity_id: reimbursementId, + status: "open", + }, + orderBy: [{ created_at: "desc" }, { id: "desc" }], + }); return sendData(reply, request, { id: reimbursement.id, @@ -732,6 +778,7 @@ async function reimbursementRoutes(fastify) { task: pendingTask, } : null, + riskFlags: riskFlags.map(buildPersistedRiskFlag), }); }, ); @@ -763,6 +810,13 @@ async function reimbursementRoutes(fastify) { const reimbursement = reimbursementRows[0]; ensureApplicantOrAdmin(request, reimbursement); + await ensureCutoverAllowed({ + prisma: tx, + config: fastify.config, + request, + domain: "expense", + departmentId: reimbursement.department_id ?? request.employee.department_id ?? null, + }); if (!["draft", "rejected"].includes(reimbursement.status)) { throw new AppError({ @@ -1183,6 +1237,42 @@ async function reimbursementRoutes(fastify) { }, ); + fastify.post( + "/reimbursements/:id/precheck", + { + preHandler: fastify.requirePermission("expense.submit"), + }, + async (request, reply) => { + const reimbursementId = parseReimbursementId(request.params?.id); + const reimbursement = await fastify.prisma.reimbursementForm.findUnique({ + where: { id: reimbursementId }, + select: { id: true, department_id: true }, + }); + if (!reimbursement) { + throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + } + + const gate = await evaluateFeatureFlag({ + prisma: fastify.prisma, + config: fastify.config, + flagKey: FEATURE_FLAG_KEYS.budgetPrecheck, + departmentId: reimbursement.department_id ?? request.employee.department_id ?? null, + strictDepartment: false, + }); + + if (!gate.enabled) { + return sendData(reply, request, buildDisabledPrecheck("reimbursement", reimbursementId)); + } + + const precheck = await buildReimbursementPrecheck(fastify.prisma, { + reimbursementId, + config: fastify.config, + logger: fastify.log, + }); + return sendData(reply, request, precheck); + }, + ); + fastify.post( "/reimbursements/:id/submit", { @@ -1256,6 +1346,30 @@ async function reimbursementRoutes(fastify) { }); } + const budgetPrecheckGate = await evaluateFeatureFlag({ + prisma: tx, + config: fastify.config, + flagKey: FEATURE_FLAG_KEYS.budgetPrecheck, + departmentId: reimbursement.department_id ?? request.employee.department_id ?? null, + strictDepartment: false, + }); + const authoritativePrecheck = budgetPrecheckGate.enabled + ? await buildReimbursementPrecheck(tx, { + reimbursementId, + config: fastify.config, + logger: fastify.log, + }) + : buildDisabledPrecheck("reimbursement", reimbursementId); + if (authoritativePrecheck.decision === "block") { + throw buildPrecheckBlockedError(authoritativePrecheck); + } + const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, { + entityType: "reimbursement", + entityId: reimbursementId, + actorId: request.employee.id, + precheck: authoritativePrecheck, + }); + const budgetPool = await resolveApplicableBudgetPool(tx, reimbursement); if (budgetPool) { const allocatedAmount = Number(budgetPool.allocated_amount ?? 0); @@ -1518,6 +1632,8 @@ async function reimbursementRoutes(fastify) { currentTaskId: workflowTask.id, status: "reviewing", budget: budgetCheck, + precheckDecision: authoritativePrecheck.decision, + openedRiskFlagCount: openedRiskFlags.length, }; }); @@ -1700,6 +1816,13 @@ async function reimbursementRoutes(fastify) { } const reimbursement = reimbursementRows[0]; + await ensureCutoverAllowed({ + prisma: tx, + config: fastify.config, + request, + domain: "expense", + departmentId: reimbursement.department_id ?? request.employee.department_id ?? null, + }); if (!["approved", "paid"].includes(reimbursement.status)) { throw new AppError({ code: "VALID_001", diff --git a/server/src/routes/riskFlags.js b/server/src/routes/riskFlags.js new file mode 100644 index 00000000..49cdc6e0 --- /dev/null +++ b/server/src/routes/riskFlags.js @@ -0,0 +1,117 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); +const { buildAuditLogData } = require("../utils/audit"); +const { buildPersistedRiskFlag } = require("../services/feeControl"); + +const UUID_SCHEMA = z.string().uuid(); +const QUERY_SCHEMA = z.object({ + entityType: z.string().trim().max(32).optional(), + entityId: UUID_SCHEMA.optional(), + status: z.enum(["open", "resolved", "dismissed"]).optional(), + source: z.string().trim().max(32).optional(), +}); + +function buildValidationError(details, statusCode = 400) { + return new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode, + details, + }); +} + +function hasListPermission(fastify, request) { + return ( + fastify.hasPermission(request.employee, "budget.view") || + fastify.hasPermission(request.employee, "payment.batch.manage") || + fastify.hasPermission(request.employee, "risk.review.manage") + ); +} + +function ensureListPermission(fastify, request) { + if (!hasListPermission(fastify, request)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } +} + +function ensureManagePermission(fastify, request) { + if (!fastify.hasPermission(request.employee, "risk.review.manage")) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } +} + +function parseQuery(query) { + const parsed = QUERY_SCHEMA.safeParse(query ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + return parsed.data; +} + +function parseId(value) { + const parsed = UUID_SCHEMA.safeParse(value); + if (!parsed.success) { + throw buildValidationError({ id: "invalid_uuid" }); + } + return parsed.data; +} + +async function riskFlagsRoutes(fastify) { + fastify.get("/risk-flags", { preHandler: fastify.authenticate }, async (request, reply) => { + ensureListPermission(fastify, request); + const query = parseQuery(request.query); + const items = await fastify.prisma.entityRiskFlag.findMany({ + where: { + ...(query.entityType ? { entity_type: query.entityType } : {}), + ...(query.entityId ? { entity_id: query.entityId } : {}), + ...(query.status ? { status: query.status } : {}), + ...(query.source ? { source: query.source } : {}), + }, + orderBy: [{ created_at: "desc" }, { id: "desc" }], + }); + return sendData(reply, request, { items: items.map(buildPersistedRiskFlag) }); + }); + + for (const action of ["resolve", "dismiss"]) { + fastify.post(`/risk-flags/:id/${action}`, { preHandler: fastify.authenticate }, async (request, reply) => { + ensureManagePermission(fastify, request); + const riskFlagId = parseId(request.params?.id); + const current = await fastify.prisma.entityRiskFlag.findUnique({ where: { id: riskFlagId } }); + if (!current) { + throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + } + if (current.status !== "open") { + throw buildValidationError({ status: "risk_flag_not_open" }, 409); + } + + const updated = await fastify.prisma.entityRiskFlag.update({ + where: { id: riskFlagId }, + data: { + status: action === "resolve" ? "resolved" : "dismissed", + resolved_at: new Date(), + resolved_by: request.employee.id, + }, + }); + + await fastify.prisma.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: action === "resolve" ? "ENTITY_RISK_FLAG_RESOLVED" : "ENTITY_RISK_FLAG_DISMISSED", + targetType: "entity_risk_flag", + targetId: updated.id, + extra: { + entityType: updated.entity_type, + entityId: updated.entity_id, + riskCode: updated.risk_code, + }, + }), + }); + + return sendData(reply, request, { item: buildPersistedRiskFlag(updated) }); + }); + } +} + +module.exports = riskFlagsRoutes; diff --git a/server/src/routes/search.js b/server/src/routes/search.js new file mode 100644 index 00000000..6c0efc38 --- /dev/null +++ b/server/src/routes/search.js @@ -0,0 +1,225 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); +const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); + +const QUERY_SCHEMA = z.object({ + q: z.string().trim().min(2).max(100), + limit: z.coerce.number().int().min(1).max(20).optional(), +}); + +function buildValidationError(details, statusCode = 400) { + return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); +} + +async function ensureFeatureEnabled(fastify, request) { + const gate = await evaluateFeatureFlag({ + prisma: fastify.prisma, + config: fastify.config, + flagKey: FEATURE_FLAG_KEYS.globalSearch, + departmentId: request.employee.department_id ?? null, + strictDepartment: false, + }); + if (!gate.enabled) { + throw buildValidationError({ featureFlag: "global_search_v1_disabled", reason: gate.reason }, 409); + } +} + +function matchByValue(query, ...values) { + const lower = query.toLowerCase(); + const normalized = values.filter(Boolean).map((item) => String(item).toLowerCase()); + if (normalized.some((item) => item === lower)) return "exact"; + if (normalized.some((item) => item.startsWith(lower))) return "prefix"; + return "contains"; +} + +function compareMatchedBy(left, right) { + const score = { exact: 0, prefix: 1, contains: 2 }; + return score[left] - score[right]; +} + +async function searchRoutes(fastify) { + fastify.get("/search", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify, request); + const parsed = QUERY_SCHEMA.safeParse(request.query ?? {}); + if (!parsed.success) throw buildValidationError(parsed.error.flatten()); + const q = parsed.data.q.trim(); + const limit = parsed.data.limit ?? 10; + + const tasks = []; + const isAdmin = fastify.hasPermission(request.employee, "*"); + + if (fastify.hasPermission(request.employee, "expense.view_own") || fastify.hasPermission(request.employee, "expense.view_all")) { + tasks.push( + fastify.prisma.reimbursementForm.findMany({ + where: { + AND: [ + fastify.hasPermission(request.employee, "expense.view_all") || isAdmin + ? {} + : { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] }, + { + OR: [ + { form_number: { contains: q, mode: "insensitive" } }, + { description: { contains: q, mode: "insensitive" } }, + ], + }, + ], + }, + take: limit, + orderBy: { updated_at: "desc" }, + select: { id: true, form_number: true, description: true, status: true, updated_at: true }, + }).then((rows) => rows.map((item) => ({ + entityType: "reimbursement", + entityId: item.id, + title: item.form_number ?? item.id, + subtitle: item.description ?? null, + status: item.status, + href: `/reimbursements/${item.id}`, + matchedBy: matchByValue(q, item.form_number, item.description), + updatedAt: item.updated_at?.toISOString?.() ?? null, + }))), + ); + } + + if (fastify.hasPermission(request.employee, "leave.view_own") || fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage")) { + tasks.push( + fastify.prisma.leaveRequest.findMany({ + where: { + AND: [ + fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage") || isAdmin + ? {} + : { owner_id: request.employee.id }, + { OR: [{ reason: { contains: q, mode: "insensitive" } }] }, + ], + }, + take: limit, + orderBy: { updated_at: "desc" }, + select: { id: true, reason: true, status: true, start_date: true, end_date: true, updated_at: true }, + }).then((rows) => rows.map((item) => ({ + entityType: "leave", + entityId: item.id, + title: `请假 ${item.start_date?.toISOString?.()?.slice(0, 10) ?? ""} ~ ${item.end_date?.toISOString?.()?.slice(0, 10) ?? ""}`, + subtitle: item.reason ?? null, + status: item.status, + href: "/leave-requests", + matchedBy: matchByValue(q, item.reason), + updatedAt: item.updated_at?.toISOString?.() ?? null, + }))), + ); + } + + if (fastify.hasPermission(request.employee, "procurement.view_own") || fastify.hasPermission(request.employee, "procurement.view_all") || fastify.hasPermission(request.employee, "procurement.manage")) { + tasks.push( + fastify.prisma.purchaseRequest.findMany({ + where: { + AND: [ + fastify.hasPermission(request.employee, "procurement.view_all") || fastify.hasPermission(request.employee, "procurement.manage") || isAdmin + ? {} + : { applicant_id: request.employee.id }, + { + OR: [ + { request_no: { contains: q, mode: "insensitive" } }, + { title: { contains: q, mode: "insensitive" } }, + ], + }, + ], + }, + take: limit, + orderBy: { updated_at: "desc" }, + select: { id: true, request_no: true, title: true, status: true, updated_at: true }, + }).then((rows) => rows.map((item) => ({ + entityType: "purchase_request", + entityId: item.id, + title: item.request_no ?? item.title, + subtitle: item.title, + status: item.status, + href: `/purchase-requests?id=${item.id}`, + matchedBy: matchByValue(q, item.request_no, item.title), + updatedAt: item.updated_at?.toISOString?.() ?? null, + }))), + ); + } + + if (fastify.hasPermission(request.employee, "travel.view_own") || fastify.hasPermission(request.employee, "travel.view_all") || fastify.hasPermission(request.employee, "travel.manage")) { + tasks.push( + fastify.prisma.travelRequest.findMany({ + where: { + AND: [ + fastify.hasPermission(request.employee, "travel.view_all") || fastify.hasPermission(request.employee, "travel.manage") || isAdmin + ? {} + : { OR: [{ applicant_employee_id: request.employee.id }, { submitter_id: request.employee.id }] }, + { + OR: [ + { request_no: { contains: q, mode: "insensitive" } }, + { purpose: { contains: q, mode: "insensitive" } }, + ], + }, + ], + }, + take: limit, + orderBy: { updated_at: "desc" }, + select: { id: true, request_no: true, purpose: true, status: true, updated_at: true }, + }).then((rows) => rows.map((item) => ({ + entityType: "travel_request", + entityId: item.id, + title: item.request_no ?? item.purpose, + subtitle: item.purpose, + status: item.status, + href: `/travel-requests?id=${item.id}`, + matchedBy: matchByValue(q, item.request_no, item.purpose), + updatedAt: item.updated_at?.toISOString?.() ?? null, + }))), + ); + } + + if ( + fastify.hasPermission(request.employee, "asset.view_own") || + fastify.hasPermission(request.employee, "asset.view_all") || + fastify.hasPermission(request.employee, "asset.manage") || + fastify.hasPermission(request.employee, "asset.transfer") || + fastify.hasPermission(request.employee, "asset.inventory") + ) { + tasks.push( + fastify.prisma.assetCard.findMany({ + where: { + AND: [ + fastify.hasPermission(request.employee, "asset.view_all") || fastify.hasPermission(request.employee, "asset.manage") || fastify.hasPermission(request.employee, "asset.transfer") || fastify.hasPermission(request.employee, "asset.inventory") || isAdmin + ? {} + : { assignee_employee_id: request.employee.id }, + { + OR: [ + { asset_no: { contains: q, mode: "insensitive" } }, + { serial_no: { contains: q, mode: "insensitive" } }, + { name: { contains: q, mode: "insensitive" } }, + ], + }, + ], + }, + take: limit, + orderBy: { updated_at: "desc" }, + select: { id: true, asset_no: true, serial_no: true, name: true, status: true, updated_at: true }, + }).then((rows) => rows.map((item) => ({ + entityType: "asset", + entityId: item.id, + title: item.asset_no, + subtitle: item.name ?? item.serial_no ?? null, + status: item.status, + href: "/assets", + matchedBy: matchByValue(q, item.asset_no, item.serial_no, item.name), + updatedAt: item.updated_at?.toISOString?.() ?? null, + }))), + ); + } + + const items = (await Promise.all(tasks)).flat(); + items.sort((left, right) => { + const matchedDiff = compareMatchedBy(left.matchedBy, right.matchedBy); + if (matchedDiff !== 0) return matchedDiff; + return String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")); + }); + + return sendData(reply, request, { items: items.slice(0, limit) }); + }); +} +module.exports = searchRoutes; diff --git a/server/src/routes/travelRequests.js b/server/src/routes/travelRequests.js index 4993248f..43bd9cf1 100644 --- a/server/src/routes/travelRequests.js +++ b/server/src/routes/travelRequests.js @@ -12,6 +12,13 @@ const { sendNotificationEmailSafe } = require("../services/email"); const { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications"); const { enqueueImNotifications } = require("../services/notifications/imEnqueue"); const { buildTravelPaymentTrack } = require("../services/travelPayments"); +const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); +const { ensureCutoverAllowed } = require("../services/cutover"); +const { + buildPersistedRiskFlag, + buildTravelRequestPrecheck, + createRiskFlagsFromPrecheck, +} = require("../services/feeControl"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); const UUID_SCHEMA = z.string().uuid(); @@ -281,6 +288,29 @@ function ensureDateRangeValid(startDate, endDate) { } } +function buildDisabledPrecheck(entityType, entityId) { + return { + entityType, + entityId, + decision: "pass", + matchedBudgetControlRule: null, + budgetHint: null, + warnings: [], + blockingReasons: [], + riskFlags: [], + nextActions: ["功能未开启,沿用当前提交流程"], + }; +} + +function buildPrecheckBlockedError(precheck) { + return new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { precheck: precheck.blockingReasons }, + }); +} + async function ensureFinancialDimensions(tx, { legalEntityId, costCenterId }) { const [legalEntity, costCenter] = await Promise.all([ tx.legalEntity.findUnique({ @@ -641,6 +671,14 @@ async function createTravelRequestsRoutes(fastify) { }, async (request, reply) => { const body = parseBody(TRAVEL_CREATE_BODY_SCHEMA, request.body); + await ensureCutoverAllowed({ + prisma: fastify.prisma, + config: fastify.config, + request, + domain: "travel", + departmentId: request.employee.department_id ?? null, + legalEntityId: body.legalEntityId, + }); const startDate = parseDateOnlyString(body.startDate); const endDate = parseDateOnlyString(body.endDate); ensureDateRangeValid(startDate, endDate); @@ -719,7 +757,18 @@ async function createTravelRequestsRoutes(fastify) { ensureTravelReadPermission(fastify, request); const travelRequestId = parseTravelRequestId(request.params?.id); const detail = await loadReadableTravelRequest(fastify.prisma, travelRequestId, request); - return sendData(reply, request, mapTravelRequestDetail(detail, request)); + const riskFlags = await fastify.prisma.entityRiskFlag.findMany({ + where: { + entity_type: "travel_request", + entity_id: travelRequestId, + status: "open", + }, + orderBy: [{ created_at: "desc" }, { id: "desc" }], + }); + return sendData(reply, request, { + ...mapTravelRequestDetail(detail, request), + riskFlags: riskFlags.map(buildPersistedRiskFlag), + }); }, ); @@ -836,6 +885,41 @@ async function createTravelRequestsRoutes(fastify) { }, ); + fastify.post( + "/travel-requests/:id/precheck", + { + preHandler: fastify.requirePermission("travel.submit"), + }, + async (request, reply) => { + const travelRequestId = parseTravelRequestId(request.params?.id); + const travelRequest = await fastify.prisma.travelRequest.findUnique({ + where: { id: travelRequestId }, + select: { id: true, department_id: true }, + }); + if (!travelRequest) { + throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + } + + const gate = await evaluateFeatureFlag({ + prisma: fastify.prisma, + config: fastify.config, + flagKey: FEATURE_FLAG_KEYS.budgetPrecheck, + departmentId: travelRequest.department_id ?? request.employee.department_id ?? null, + strictDepartment: false, + }); + if (!gate.enabled) { + return sendData(reply, request, buildDisabledPrecheck("travel_request", travelRequestId)); + } + + const precheck = await buildTravelRequestPrecheck(fastify.prisma, { + travelRequestId, + config: fastify.config, + logger: fastify.log, + }); + return sendData(reply, request, precheck); + }, + ); + fastify.post( "/travel-requests/:id/submit", { @@ -849,6 +933,14 @@ async function createTravelRequestsRoutes(fastify) { await fastify.prisma.$transaction(async (tx) => { const travelRequest = await loadTravelRequestForUpdate(tx, travelRequestId); ensureDraftOwner(request, travelRequest); + await ensureCutoverAllowed({ + prisma: tx, + config: fastify.config, + request, + domain: "travel", + departmentId: travelRequest.department_id ?? request.employee.department_id ?? null, + legalEntityId: travelRequest.legal_entity_id ?? null, + }); if (travelRequest.status !== "draft") { throw buildValidationError({ status: "only_draft_submittable" }, 409); } @@ -856,6 +948,30 @@ async function createTravelRequestsRoutes(fastify) { throw buildValidationError({ segments: "required" }, 409); } + const budgetPrecheckGate = await evaluateFeatureFlag({ + prisma: tx, + config: fastify.config, + flagKey: FEATURE_FLAG_KEYS.budgetPrecheck, + departmentId: travelRequest.department_id ?? request.employee.department_id ?? null, + strictDepartment: false, + }); + const authoritativePrecheck = budgetPrecheckGate.enabled + ? await buildTravelRequestPrecheck(tx, { + travelRequestId, + config: fastify.config, + logger: fastify.log, + }) + : buildDisabledPrecheck("travel_request", travelRequestId); + if (authoritativePrecheck.decision === "block") { + throw buildPrecheckBlockedError(authoritativePrecheck); + } + const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, { + entityType: "travel_request", + entityId: travelRequestId, + actorId: request.employee.id, + precheck: authoritativePrecheck, + }); + const workflowDefinition = await tx.workflowDefinition.findFirst({ where: { entity_type: "travel_request", is_active: true }, orderBy: { version: "desc" }, @@ -1050,6 +1166,8 @@ async function createTravelRequestsRoutes(fastify) { currentTaskId: workflowTask.id, budgetPoolId: selectedBudgetPoolId, budgetReservation, + precheckDecision: authoritativePrecheck.decision, + openedRiskFlagCount: openedRiskFlags.length, }; }); diff --git a/server/src/routes/workflowDefinitions.js b/server/src/routes/workflowDefinitions.js new file mode 100644 index 00000000..a1bb7085 --- /dev/null +++ b/server/src/routes/workflowDefinitions.js @@ -0,0 +1,194 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); +const { buildAuditLogData } = require("../utils/audit"); +const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); + +const UUID_SCHEMA = z.string().uuid(); +const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "leave", "purchase_request", "travel_request"]); +const ASSIGNEE_RULE_SCHEMA = z.union([ + z.object({ type: z.literal("department_manager") }).strict(), + z.object({ type: z.literal("role"), value: z.string().trim().min(1).max(32) }).strict(), + z.object({ type: z.literal("specific_user"), value: UUID_SCHEMA }).strict(), +]); +const WRITE_SCHEMA = z.object({ + entityType: ENTITY_TYPE_SCHEMA, + name: z.string().trim().min(1).max(120), + steps: z.array(z.object({ id: z.string().trim().min(1).max(64), name: z.string().trim().min(1).max(120), assigneeRule: ASSIGNEE_RULE_SCHEMA }).strict()).min(1).max(20), + notes: z.string().trim().max(500).optional(), +}).strict(); +const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); + +function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); } +function parseId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) throw buildValidationError({ id: "invalid_uuid" }); return parsed.data; } +function parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) throw buildValidationError(parsed.error.flatten()); return parsed.data; } +function parseEntityType(value) { const parsed = ENTITY_TYPE_SCHEMA.safeParse(value); if (!parsed.success) throw buildValidationError({ entityType: "invalid" }); return parsed.data; } + +async function ensureFeatureEnabled(fastify) { + const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.workflowDefinitionAdmin, strictDepartment: false }); + if (!gate.enabled) throw buildValidationError({ featureFlag: "workflow_definition_admin_v1_disabled", reason: gate.reason }, 409); +} + +function mapStatus(item) { + if (item.archived_at) return "archived"; + if (item.published_at && item.is_active) return "published"; + return "draft"; +} + +function mapDefinition(item) { + return { + id: item.id, + entityType: item.entity_type, + name: item.name, + version: item.version, + status: mapStatus(item), + source: "manual", + notes: item.definition_json?.notes ?? "", + steps: (item.definition_json?.steps ?? []).map((step) => ({ + id: step.id, + name: step.name, + assigneeRule: step.assignee_rule, + })), + createdAt: item.created_at?.toISOString?.() ?? null, + updatedAt: item.updated_at?.toISOString?.() ?? null, + publishedAt: item.published_at?.toISOString?.() ?? null, + archivedAt: item.archived_at?.toISOString?.() ?? null, + }; +} + +async function buildReimbursementProjection(tx) { + const rules = await tx.approvalRule.findMany({ where: { enabled: true }, orderBy: { priority: "asc" } }); + const steps = []; + const seen = new Set(); + for (const rule of rules) { + for (const roleCode of [rule.level1_role, rule.level2_role, rule.level3_role].filter(Boolean)) { + if (seen.has(roleCode)) continue; + seen.add(roleCode); + steps.push({ + id: `approval_${roleCode}`, + name: roleCode === "manager" ? "直属经理审批" : `${roleCode} 审批`, + assigneeRule: roleCode === "manager" ? { type: "department_manager" } : { type: "role", value: roleCode }, + }); + } + } + return { + id: "reimbursement-derived", + entityType: "reimbursement", + name: "报销流程(审批规则投影)", + version: 1, + status: "derived_readonly", + source: "approval_rules_projection", + notes: "实际报销审批链由 approval_rules 按金额区间投影生成。", + steps, + createdAt: null, + updatedAt: null, + publishedAt: null, + archivedAt: null, + }; +} + +async function workflowDefinitionsRoutes(fastify) { + fastify.get("/workflow-definitions", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + const items = await fastify.prisma.workflowDefinition.findMany({ + where: { entity_type: { in: ["leave", "purchase_request", "travel_request"] } }, + orderBy: [{ entity_type: "asc" }, { version: "desc" }], + }); + const reimbursement = await buildReimbursementProjection(fastify.prisma); + return sendData(reply, request, { items: [reimbursement, ...items.map(mapDefinition)] }); + }); + + fastify.get("/workflow-definitions/entity/:entityType/active", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + const entityType = parseEntityType(request.params?.entityType); + if (entityType === "reimbursement") { + return sendData(reply, request, { item: await buildReimbursementProjection(fastify.prisma) }); + } + const item = await fastify.prisma.workflowDefinition.findFirst({ where: { entity_type: entityType, is_active: true, archived_at: null }, orderBy: { version: "desc" } }); + return sendData(reply, request, { item: item ? mapDefinition(item) : null }); + }); + + fastify.get("/workflow-definitions/:id", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + const id = String(request.params?.id ?? ""); + if (id === "reimbursement-derived") { + return sendData(reply, request, { item: await buildReimbursementProjection(fastify.prisma) }); + } + const definitionId = parseId(id); + const item = await fastify.prisma.workflowDefinition.findUnique({ where: { id: definitionId } }); + if (!item) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + return sendData(reply, request, { item: mapDefinition(item) }); + }); + + fastify.post("/workflow-definitions", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + const body = parseBody(WRITE_SCHEMA, request.body); + if (body.entityType === "reimbursement") throw buildValidationError({ entityType: "unsupported_entity_type" }, 409); + const latest = await fastify.prisma.workflowDefinition.findFirst({ where: { entity_type: body.entityType }, orderBy: { version: "desc" }, select: { version: true } }); + const created = await fastify.prisma.workflowDefinition.create({ + data: { + entity_type: body.entityType, + name: body.name, + version: (latest?.version ?? 0) + 1, + is_active: false, + definition_json: { version: 1, notes: body.notes ?? "", steps: body.steps.map((step) => ({ id: step.id, name: step.name, type: "approval", assignee_rule: step.assigneeRule })) }, + created_by: request.employee.id, + updated_by: request.employee.id, + }, + }); + return reply.code(201).send({ requestId: request.id, data: { item: mapDefinition(created) } }); + }); + + fastify.patch("/workflow-definitions/:id", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + const definitionId = parseId(request.params?.id); + const body = parseBody(PATCH_SCHEMA, request.body); + const current = await fastify.prisma.workflowDefinition.findUnique({ where: { id: definitionId } }); + if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + if (current.entity_type === "reimbursement") throw buildValidationError({ entityType: "unsupported_entity_type" }, 409); + if (current.archived_at || (current.published_at && current.is_active)) throw buildValidationError({ status: "published_or_archived_readonly" }, 409); + const updated = await fastify.prisma.workflowDefinition.update({ + where: { id: definitionId }, + data: { + ...(body.name !== undefined ? { name: body.name } : {}), + ...(body.steps !== undefined || body.notes !== undefined + ? { + definition_json: { + version: 1, + notes: body.notes ?? current.definition_json?.notes ?? "", + steps: (body.steps ?? current.definition_json?.steps ?? []).map((step) => ({ id: step.id, name: step.name, type: "approval", assignee_rule: step.assigneeRule ?? step.assignee_rule })) , + }, + } + : {}), + updated_by: request.employee.id, + }, + }); + return sendData(reply, request, { item: mapDefinition(updated) }); + }); + + fastify.post("/workflow-definitions/:id/publish", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + const definitionId = parseId(request.params?.id); + const result = await fastify.prisma.$transaction(async (tx) => { + const current = await tx.workflowDefinition.findUnique({ where: { id: definitionId } }); + if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + if (current.entity_type === "reimbursement") throw buildValidationError({ entityType: "unsupported_entity_type" }, 409); + if (current.archived_at) throw buildValidationError({ status: "archived_not_publishable" }, 409); + await tx.workflowDefinition.updateMany({ where: { entity_type: current.entity_type, id: { not: definitionId }, is_active: true }, data: { is_active: false, updated_by: request.employee.id } }); + return tx.workflowDefinition.update({ where: { id: definitionId }, data: { is_active: true, published_at: new Date(), updated_by: request.employee.id } }); + }); + return sendData(reply, request, { item: mapDefinition(result) }); + }); + + fastify.post("/workflow-definitions/:id/archive", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { + await ensureFeatureEnabled(fastify); + const definitionId = parseId(request.params?.id); + const current = await fastify.prisma.workflowDefinition.findUnique({ where: { id: definitionId } }); + if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + if (current.entity_type === "reimbursement") throw buildValidationError({ entityType: "unsupported_entity_type" }, 409); + const archived = await fastify.prisma.workflowDefinition.update({ where: { id: definitionId }, data: { is_active: false, archived_at: new Date(), updated_by: request.employee.id } }); + return sendData(reply, request, { item: mapDefinition(archived) }); + }); +} +module.exports = workflowDefinitionsRoutes; diff --git a/server/src/scripts/seed-config.js b/server/src/scripts/seed-config.js index ba100a1b..6dd880d1 100644 --- a/server/src/scripts/seed-config.js +++ b/server/src/scripts/seed-config.js @@ -233,6 +233,70 @@ const DEFAULT_FEATURE_FLAGS = [ department_ids: [], note: "bootstrap default: force_off", }, + { + flag_key: "budget_precheck_v1", + override_mode: "force_off", + rollout_type: "global", + department_ids: [], + note: "bootstrap default: force_off", + }, + { + flag_key: "payment_batch_v1", + override_mode: "force_off", + rollout_type: "global", + department_ids: [], + note: "bootstrap default: force_off", + }, + { + flag_key: "portal_home_v1", + override_mode: "force_off", + rollout_type: "global", + department_ids: [], + note: "bootstrap default: force_off", + }, + { + flag_key: "global_search_v1", + override_mode: "force_off", + rollout_type: "global", + department_ids: [], + note: "bootstrap default: force_off", + }, + { + flag_key: "announcements_v1", + override_mode: "force_off", + rollout_type: "global", + department_ids: [], + note: "bootstrap default: force_off", + }, + { + flag_key: "workflow_definition_admin_v1", + override_mode: "force_off", + rollout_type: "global", + department_ids: [], + note: "bootstrap default: force_off", + }, + { + flag_key: "cutover_console_v1", + override_mode: "force_off", + rollout_type: "global", + department_ids: [], + note: "bootstrap default: force_off", + }, +]; + +const DEFAULT_BUDGET_CONTROL_RULES = [ + { + name: "默认全局预算预警", + entity_type: "all", + department_id: null, + legal_entity_id: null, + cost_center_id: null, + expense_type_id: null, + control_mode: "warn", + priority: 100, + enabled: true, + notes: "bootstrap default global warn rule", + }, ]; const DEFAULT_LEAVE_TYPES = [ @@ -411,6 +475,51 @@ async function seedFeatureFlags(prisma) { } } +async function seedBudgetControlRules(prisma) { + const now = new Date(); + + for (const item of DEFAULT_BUDGET_CONTROL_RULES) { + const existing = await prisma.budgetControlRule.findFirst({ + where: { + name: item.name, + entity_type: item.entity_type, + archived_at: null, + }, + select: { id: true }, + }); + + if (existing) { + await prisma.budgetControlRule.update({ + where: { id: existing.id }, + data: { + department_id: item.department_id, + legal_entity_id: item.legal_entity_id, + cost_center_id: item.cost_center_id, + expense_type_id: item.expense_type_id, + control_mode: item.control_mode, + priority: item.priority, + enabled: item.enabled, + notes: item.notes, + archived_at: null, + updated_at: now, + updated_by: null, + }, + }); + continue; + } + + await prisma.budgetControlRule.create({ + data: { + ...item, + created_at: now, + created_by: null, + updated_at: now, + updated_by: null, + }, + }); + } +} + async function seedLeaveTypes(prisma) { const now = new Date(); @@ -608,6 +717,7 @@ async function main() { await seedWorkflowDefinition(prisma); await seedLeaveTypes(prisma); await seedFeatureFlags(prisma); + await seedBudgetControlRules(prisma); // eslint-disable-next-line no-console console.log("Seeded config data:"); diff --git a/server/src/scripts/seed-dev.js b/server/src/scripts/seed-dev.js index cea21482..46deeb99 100644 --- a/server/src/scripts/seed-dev.js +++ b/server/src/scripts/seed-dev.js @@ -109,6 +109,46 @@ async function ensureCostCenter(prisma, payload) { }); } +async function ensureBudgetPool(prisma, payload) { + const now = new Date(); + const existing = await prisma.budgetPool.findUnique({ + where: { code: payload.code }, + select: { id: true }, + }); + + const data = { + name: payload.name, + department_id: payload.departmentId ?? null, + legal_entity_id: payload.legalEntityId ?? null, + cost_center_id: payload.costCenterId ?? null, + status: "active", + period_start: payload.periodStart, + period_end: payload.periodEnd, + allocated_amount: payload.allocatedAmount, + warning_threshold_percent: payload.warningThresholdPercent ?? 80, + archived_at: null, + updated_at: now, + updated_by: null, + }; + + if (existing) { + return prisma.budgetPool.update({ + where: { id: existing.id }, + data, + }); + } + + return prisma.budgetPool.create({ + data: { + code: payload.code, + used_amount: payload.usedAmount ?? "0.00", + created_at: now, + created_by: null, + ...data, + }, + }); +} + async function ensureSupplier(prisma, payload) { const now = new Date(); const existing = await prisma.supplier.findUnique({ @@ -303,6 +343,9 @@ async function main() { await prisma.$connect(); const hireDate = new Date("2020-01-01"); + const currentYear = new Date().getUTCFullYear(); + const fiscalPeriodStart = new Date(`${currentYear}-01-01T00:00:00.000Z`); + const fiscalPeriodEnd = new Date(`${currentYear}-12-31T00:00:00.000Z`); try { const rootDepartment = await ensureDepartment(prisma, { name: "公司" }); @@ -460,7 +503,7 @@ async function main() { ], }); - await ensureCostCenter(prisma, { + const managementCostCenter = await ensureCostCenter(prisma, { legalEntityId: legalEntity.id, code: "MGMT", name: "管理中心", @@ -478,7 +521,7 @@ async function main() { name: "人力中心", ownerEmployeeId: hr.id, }); - await ensureCostCenter(prisma, { + const rdCostCenter = await ensureCostCenter(prisma, { legalEntityId: legalEntity.id, code: "RD", name: "研发中心", @@ -496,6 +539,27 @@ async function main() { legalEntityId: legalEntity.id, }); + const managementBudgetPool = await ensureBudgetPool(prisma, { + code: `DEV_BUDGET_MGMT_${currentYear}`, + name: `${currentYear} 管理中心预算池`, + legalEntityId: legalEntity.id, + costCenterId: managementCostCenter.id, + periodStart: fiscalPeriodStart, + periodEnd: fiscalPeriodEnd, + allocatedAmount: "500000.00", + warningThresholdPercent: 80, + }); + + const rdBudgetPool = await ensureBudgetPool(prisma, { + code: `DEV_BUDGET_RD_${currentYear}`, + name: `${currentYear} 研发部预算池`, + departmentId: rdDepartment.id, + periodStart: fiscalPeriodStart, + periodEnd: fiscalPeriodEnd, + allocatedAmount: "800000.00", + warningThresholdPercent: 75, + }); + // eslint-disable-next-line no-console console.log("Seeded dev users:"); // eslint-disable-next-line no-console @@ -520,6 +584,8 @@ async function main() { console.log(`- HolidayCalendar: ${holidayCalendar.code} / ${holidayCalendar.name}`); // eslint-disable-next-line no-console console.log(`- Supplier: ${supplier.code} / ${supplier.name}`); + // eslint-disable-next-line no-console + console.log(`- Budget Pools: ${managementBudgetPool.code}, ${rdBudgetPool.code}`); } finally { await prisma.$disconnect(); await pool.end(); diff --git a/server/src/services/cutover.js b/server/src/services/cutover.js new file mode 100644 index 00000000..9a209794 --- /dev/null +++ b/server/src/services/cutover.js @@ -0,0 +1,79 @@ +const { AppError } = require("../utils/AppError"); +const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("./featureFlags"); + +async function ensureCutoverAllowed({ + prisma, + config, + request, + domain, + departmentId = null, + legalEntityId = null, +}) { + const gate = await evaluateFeatureFlag({ + prisma, + config, + flagKey: FEATURE_FLAG_KEYS.cutoverConsole, + departmentId: departmentId ?? request.employee.department_id ?? null, + strictDepartment: false, + }); + + if (!gate.enabled) { + return { + checked: false, + allowed: true, + reason: gate.reason, + matchedScope: null, + }; + } + + const now = new Date(); + const scopes = await prisma.cutoverScope.findMany({ + where: { + enabled: true, + OR: [{ starts_at: null }, { starts_at: { lte: now } }], + }, + orderBy: [{ scope_type: "asc" }, { scope_key: "asc" }], + }); + + const matchedScope = scopes.find((scope) => { + if (scope.scope_type === "domain") { + return scope.scope_key === domain; + } + if (scope.scope_type === "department") { + return departmentId && scope.scope_key === departmentId; + } + if (scope.scope_type === "legal_entity") { + return legalEntityId && scope.scope_key === legalEntityId; + } + return false; + }) ?? null; + + if (!matchedScope) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { + cutover: "not_enabled", + domain, + departmentId, + legalEntityId, + }, + }); + } + + return { + checked: true, + allowed: true, + reason: gate.reason, + matchedScope: { + id: matchedScope.id, + scopeType: matchedScope.scope_type, + scopeKey: matchedScope.scope_key, + }, + }; +} + +module.exports = { + ensureCutoverAllowed, +}; diff --git a/server/src/services/featureFlags/index.js b/server/src/services/featureFlags/index.js index d03e6c3a..805740e0 100644 --- a/server/src/services/featureFlags/index.js +++ b/server/src/services/featureFlags/index.js @@ -3,6 +3,13 @@ const FEATURE_FLAG_KEYS = { erpSync: "erp_sync", invoiceVerification: "invoice_verification", leaveHolidayCalendar: "leave_holiday_calendar", + budgetPrecheck: "budget_precheck_v1", + paymentBatch: "payment_batch_v1", + portalHome: "portal_home_v1", + globalSearch: "global_search_v1", + announcements: "announcements_v1", + workflowDefinitionAdmin: "workflow_definition_admin_v1", + cutoverConsole: "cutover_console_v1", }; const FEATURE_FLAG_KEY_LIST = Object.freeze(Object.values(FEATURE_FLAG_KEYS)); @@ -72,6 +79,34 @@ function resolveEnvGate(config, flagKey) { return Boolean(config.leaveHolidayCalendarEnabled); } + if (flagKey === FEATURE_FLAG_KEYS.budgetPrecheck) { + return Boolean(config.budgetPrecheckEnabled); + } + + if (flagKey === FEATURE_FLAG_KEYS.paymentBatch) { + return Boolean(config.paymentBatchEnabled); + } + + if (flagKey === FEATURE_FLAG_KEYS.portalHome) { + return Boolean(config.portalHomeEnabled); + } + + if (flagKey === FEATURE_FLAG_KEYS.globalSearch) { + return Boolean(config.globalSearchEnabled); + } + + if (flagKey === FEATURE_FLAG_KEYS.announcements) { + return Boolean(config.announcementsEnabled); + } + + if (flagKey === FEATURE_FLAG_KEYS.workflowDefinitionAdmin) { + return Boolean(config.workflowDefinitionAdminEnabled); + } + + if (flagKey === FEATURE_FLAG_KEYS.cutoverConsole) { + return Boolean(config.cutoverConsoleEnabled); + } + return false; } diff --git a/server/src/services/feeControl.js b/server/src/services/feeControl.js new file mode 100644 index 00000000..799517e6 --- /dev/null +++ b/server/src/services/feeControl.js @@ -0,0 +1,741 @@ +const { AppError } = require("../utils/AppError"); +const { resolveApplicableBudgetPoolByContext } = require("./budgetPool"); +const { + buildWorkflowStepsFromApprovalRule, + parseWorkflowSteps, + resolveActionableStep, +} = require("../utils/workflow"); + +const PRECHECK_DECISIONS = Object.freeze(["pass", "warn", "manual_review", "block"]); +const RISK_LEVELS = Object.freeze({ + low: "low", + medium: "medium", + high: "high", +}); + +function buildNotFoundError() { + return new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); +} + +function toMoneyNumber(value) { + const numeric = Number(value ?? 0); + if (!Number.isFinite(numeric)) { + return 0; + } + return Number(numeric.toFixed(2)); +} + +function toMoneyString(value) { + return toMoneyNumber(value).toFixed(2); +} + +function buildBasePrecheck(entityType, entityId) { + return { + entityType, + entityId, + decision: "pass", + matchedBudgetControlRule: null, + budgetHint: null, + warnings: [], + blockingReasons: [], + riskFlags: [], + nextActions: ["可直接提交"], + }; +} + +function buildIssue(code, title, detail) { + return { + code, + title, + detail, + }; +} + +function buildRiskFlagPreview(code, level, title, detail) { + return { + code, + level, + title, + detail, + willOpenOnSubmit: true, + }; +} + +function buildPersistedRiskFlag(record) { + return { + id: record.id, + entityType: record.entity_type, + entityId: record.entity_id, + riskCode: record.risk_code, + level: record.level, + title: record.title, + detail: record.detail ?? null, + source: record.source, + status: record.status, + payloadJson: record.payload_json ?? null, + createdAt: record.created_at?.toISOString?.() ?? null, + createdBy: record.created_by ?? null, + resolvedAt: record.resolved_at?.toISOString?.() ?? null, + resolvedBy: record.resolved_by ?? null, + }; +} + +function normalizeFallbackControlMode(config) { + const mode = String(config?.budgetControlMode ?? "warn").trim().toLowerCase(); + if (mode === "block" || mode === "manual_review") { + return mode; + } + return "warn"; +} + +function computeRuleScopeRank(rule) { + if (rule.cost_center_id && rule.legal_entity_id) { + return 0; + } + if (rule.department_id) { + return 1; + } + return 2; +} + +function matchesBudgetControlRule(rule, context) { + if (!rule?.enabled || rule?.archived_at) { + return false; + } + + if (rule.entity_type !== "all" && rule.entity_type !== context.entityType) { + return false; + } + + if (rule.department_id && rule.department_id !== context.departmentId) { + return false; + } + if (rule.legal_entity_id && rule.legal_entity_id !== context.legalEntityId) { + return false; + } + if (rule.cost_center_id && rule.cost_center_id !== context.costCenterId) { + return false; + } + + if (context.entityType !== "reimbursement" && rule.expense_type_id) { + return false; + } + + if (context.entityType === "reimbursement" && rule.expense_type_id) { + return context.expenseTypeIds.includes(rule.expense_type_id); + } + + return true; +} + +async function resolveBudgetControlRule(tx, context) { + const rules = await tx.budgetControlRule.findMany({ + where: { + enabled: true, + archived_at: null, + entity_type: { in: ["all", context.entityType] }, + }, + orderBy: [{ priority: "asc" }, { created_at: "asc" }], + }); + + const matched = rules + .filter((rule) => matchesBudgetControlRule(rule, context)) + .sort((left, right) => { + const scopeDiff = computeRuleScopeRank(left) - computeRuleScopeRank(right); + if (scopeDiff !== 0) { + return scopeDiff; + } + if (left.priority !== right.priority) { + return left.priority - right.priority; + } + return new Date(left.created_at).getTime() - new Date(right.created_at).getTime(); + })[0] ?? null; + + if (!matched) { + return null; + } + + return { + id: matched.id, + name: matched.name, + controlMode: matched.control_mode, + record: matched, + }; +} + +function buildBudgetHint(budgetPool, currentAmount, controlMode) { + const allocatedAmount = toMoneyNumber(budgetPool?.allocated_amount); + const usedAmount = toMoneyNumber(budgetPool?.used_amount); + const amount = toMoneyNumber(currentAmount); + const nextUsedAmount = toMoneyNumber(usedAmount + amount); + const nextUsagePercent = allocatedAmount > 0 ? Number(((nextUsedAmount / allocatedAmount) * 100).toFixed(2)) : 0; + + return { + budgetId: budgetPool.id, + budgetCode: budgetPool.code, + budgetName: budgetPool.name, + controlMode, + warningThresholdPercent: Number(budgetPool.warning_threshold_percent ?? 80), + allocatedAmount: toMoneyString(allocatedAmount), + usedAmount: toMoneyString(usedAmount), + remainingAmount: toMoneyString(Math.max(0, allocatedAmount - usedAmount)), + currentAmount: toMoneyString(amount), + nextUsedAmount: toMoneyString(nextUsedAmount), + nextUsagePercent, + exceedsThreshold: nextUsagePercent >= Number(budgetPool.warning_threshold_percent ?? 80), + exceedsBudget: nextUsedAmount > allocatedAmount, + }; +} + +function applyDecisionByIssues(response, issues, matchedRule, config) { + if (!issues.length) { + response.nextActions = ["可直接提交"]; + return response; + } + + const controlMode = matchedRule?.controlMode ?? normalizeFallbackControlMode(config); + if (response.budgetHint) { + response.budgetHint.controlMode = controlMode; + } + response.matchedBudgetControlRule = matchedRule + ? { + id: matchedRule.id, + name: matchedRule.name, + controlMode: matchedRule.controlMode, + } + : null; + + if (controlMode === "block") { + response.decision = "block"; + response.blockingReasons.push(...issues.map((item) => buildIssue(item.code, item.title, item.detail))); + response.nextActions = ["修正阻断项后再提交"]; + return response; + } + + if (controlMode === "manual_review") { + response.decision = "manual_review"; + response.riskFlags.push( + ...issues.map((item) => buildRiskFlagPreview(item.code, item.level, item.title, item.detail)), + ); + response.nextActions = ["允许提交,但会生成待复核风险项"]; + return response; + } + + response.decision = "warn"; + response.warnings.push(...issues.map((item) => buildIssue(item.code, item.title, item.detail))); + response.nextActions = ["确认提示后可继续提交"]; + return response; +} + +function appendBlockingReasons(response, issues) { + if (!issues.length) { + return response; + } + + response.decision = "block"; + response.blockingReasons.push(...issues.map((item) => buildIssue(item.code, item.title, item.detail))); + response.nextActions = ["修正阻断项后再提交"]; + return response; +} + +async function createRiskFlagsFromPrecheck(tx, { entityType, entityId, actorId, precheck, source = "precheck" }) { + if (precheck.decision !== "manual_review" || !precheck.riskFlags.length) { + return []; + } + + const created = []; + for (const item of precheck.riskFlags) { + const existing = await tx.entityRiskFlag.findFirst({ + where: { + entity_type: entityType, + entity_id: entityId, + risk_code: item.code, + source, + status: "open", + }, + }); + + if (existing) { + created.push(existing); + continue; + } + + const record = await tx.entityRiskFlag.create({ + data: { + entity_type: entityType, + entity_id: entityId, + risk_code: item.code, + level: item.level, + title: item.title, + detail: item.detail, + source, + status: "open", + payload_json: { + openedByDecision: precheck.decision, + matchedBudgetControlRule: precheck.matchedBudgetControlRule, + budgetHint: precheck.budgetHint, + }, + created_by: actorId ?? null, + }, + }); + created.push(record); + } + + return created; +} + +async function ensureRiskFlag(tx, input) { + const existing = await tx.entityRiskFlag.findFirst({ + where: { + entity_type: input.entityType, + entity_id: input.entityId, + risk_code: input.riskCode, + source: input.source, + status: "open", + }, + }); + + if (existing) { + return existing; + } + + return tx.entityRiskFlag.create({ + data: { + entity_type: input.entityType, + entity_id: input.entityId, + risk_code: input.riskCode, + level: input.level, + title: input.title, + detail: input.detail ?? null, + source: input.source, + status: "open", + payload_json: input.payloadJson ?? null, + created_by: input.actorId ?? null, + }, + }); +} + +async function listRiskFlags(tx, filters = {}) { + const rows = await tx.entityRiskFlag.findMany({ + where: { + ...(filters.entityType ? { entity_type: filters.entityType } : {}), + ...(filters.entityId ? { entity_id: filters.entityId } : {}), + ...(filters.status ? { status: filters.status } : {}), + ...(filters.source ? { source: filters.source } : {}), + }, + orderBy: [{ created_at: "desc" }, { id: "desc" }], + }); + return rows.map(buildPersistedRiskFlag); +} + +async function buildReimbursementPrecheck(tx, { reimbursementId, config, logger }) { + const reimbursement = await tx.reimbursementForm.findUnique({ + where: { id: reimbursementId }, + select: { + id: true, + department_id: true, + reimbursement_date: true, + total_amount: true, + status: true, + owner_id: true, + items: { + select: { + id: true, + expense_type_id: true, + }, + }, + }, + }); + + if (!reimbursement) { + throw buildNotFoundError(); + } + + const response = buildBasePrecheck("reimbursement", reimbursementId); + const blocking = []; + + if (!["draft", "rejected"].includes(reimbursement.status)) { + blocking.push(buildRiskFlagPreview("invalid_submit_status", RISK_LEVELS.high, "当前状态不可提交", "只有草稿或已驳回报销单可提交审批。")); + } + + const activeInstance = await tx.workflowInstance.findFirst({ + where: { + entity_type: "reimbursement", + entity_id: reimbursementId, + current_status: "pending", + }, + select: { id: true }, + }); + if (activeInstance) { + blocking.push(buildRiskFlagPreview("already_in_review", RISK_LEVELS.high, "报销单已在审批中", "当前报销单已有进行中的审批实例。")); + } + + const boundInvoiceCount = await tx.invoicePool.count({ + where: { + linked_reimbursement_id: reimbursementId, + status: "bound", + }, + }); + if (boundInvoiceCount < 1) { + blocking.push(buildRiskFlagPreview("bound_invoice_required", RISK_LEVELS.high, "缺少已绑定票据", "报销提交前至少需要绑定一张票据。")); + } + + const amountString = toMoneyString(reimbursement.total_amount); + const approvalRule = await tx.approvalRule.findFirst({ + where: { + enabled: true, + min_amount: { lte: amountString }, + OR: [{ max_amount: null }, { max_amount: { gt: amountString } }], + }, + orderBy: { priority: "asc" }, + select: { + id: true, + name: true, + level1_role: true, + level2_role: true, + level3_role: true, + priority: true, + }, + }); + if (!approvalRule) { + blocking.push(buildRiskFlagPreview("missing_approval_rule", RISK_LEVELS.high, "缺少审批规则", "当前金额范围未匹配到有效报销审批规则。")); + } else { + const workflowSteps = buildWorkflowStepsFromApprovalRule(approvalRule); + const { actionable } = await resolveActionableStep({ + tx, + steps: workflowSteps, + startIndex: 0, + ownerId: reimbursement.owner_id, + logger, + }); + if (!actionable) { + blocking.push(buildRiskFlagPreview("no_actionable_approver", RISK_LEVELS.high, "缺少可执行审批人", "当前审批链未解析出有效审批人。")); + } + } + + appendBlockingReasons(response, blocking); + if (response.decision === "block") { + return response; + } + + const matchedRule = await resolveBudgetControlRule(tx, { + entityType: "reimbursement", + departmentId: reimbursement.department_id, + legalEntityId: null, + costCenterId: null, + expenseTypeIds: Array.from(new Set(reimbursement.items.map((item) => item.expense_type_id).filter(Boolean))), + }); + + const budgetPool = await resolveApplicableBudgetPoolByContext(tx, { + departmentId: reimbursement.department_id, + effectiveDate: reimbursement.reimbursement_date, + }); + + const budgetIssues = []; + if (!budgetPool) { + budgetIssues.push({ + code: "budget_pool_missing", + level: RISK_LEVELS.medium, + title: "未找到可用预算池", + detail: "当前报销日期和部门范围未匹配到有效预算池。", + }); + } else { + response.budgetHint = buildBudgetHint( + budgetPool, + reimbursement.total_amount, + matchedRule?.controlMode ?? normalizeFallbackControlMode(config), + ); + if (response.budgetHint.exceedsBudget) { + budgetIssues.push({ + code: "budget_exceeded", + level: RISK_LEVELS.high, + title: "预算将被超用", + detail: "当前报销金额会导致预算池超出已分配额度。", + }); + } else if (response.budgetHint.exceedsThreshold) { + budgetIssues.push({ + code: "budget_threshold_exceeded", + level: RISK_LEVELS.medium, + title: "预算达到预警阈值", + detail: "本次提交后预算使用率将达到或超过预警阈值。", + }); + } + } + + return applyDecisionByIssues(response, budgetIssues, matchedRule, config); +} + +async function buildPurchaseRequestPrecheck(tx, { purchaseRequestId, config, logger }) { + const purchaseRequest = await tx.purchaseRequest.findUnique({ + where: { id: purchaseRequestId }, + select: { + id: true, + applicant_id: true, + department_id: true, + legal_entity_id: true, + cost_center_id: true, + supplier_id: true, + document_category_id: true, + budget_pool_id: true, + total_amount: true, + status: true, + items: { select: { id: true } }, + }, + }); + + if (!purchaseRequest) { + throw buildNotFoundError(); + } + + const response = buildBasePrecheck("purchase_request", purchaseRequestId); + const blocking = []; + + if (purchaseRequest.status !== "draft") { + blocking.push(buildRiskFlagPreview("only_draft_submittable", RISK_LEVELS.high, "当前状态不可提交", "采购申请只有草稿状态允许提交。")); + } + if (!purchaseRequest.legal_entity_id) { + blocking.push(buildRiskFlagPreview("legal_entity_required", RISK_LEVELS.high, "缺少法体", "提交前必须选择法体。")); + } + if (!purchaseRequest.cost_center_id) { + blocking.push(buildRiskFlagPreview("cost_center_required", RISK_LEVELS.high, "缺少成本中心", "提交前必须选择成本中心。")); + } + if (!purchaseRequest.supplier_id) { + blocking.push(buildRiskFlagPreview("supplier_required", RISK_LEVELS.high, "缺少供应商", "提交前必须选择供应商。")); + } + if (!purchaseRequest.document_category_id) { + blocking.push(buildRiskFlagPreview("document_category_required", RISK_LEVELS.high, "缺少单据类型", "提交前必须选择采购单据类型。")); + } + if (!purchaseRequest.items.length) { + blocking.push(buildRiskFlagPreview("items_required", RISK_LEVELS.high, "缺少采购明细", "提交前至少需要一条采购明细。")); + } + + const workflowDefinition = await tx.workflowDefinition.findFirst({ + where: { + entity_type: "purchase_request", + is_active: true, + archived_at: null, + }, + orderBy: { version: "desc" }, + select: { id: true, definition_json: true }, + }); + if (!workflowDefinition) { + blocking.push(buildRiskFlagPreview("workflow_definition_missing", RISK_LEVELS.high, "缺少流程定义", "采购申请缺少有效流程定义。")); + } else { + const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json); + const { actionable } = await resolveActionableStep({ + tx, + steps: workflowSteps, + startIndex: 0, + ownerId: purchaseRequest.applicant_id, + logger, + }); + if (!actionable) { + blocking.push(buildRiskFlagPreview("no_actionable_approver", RISK_LEVELS.high, "缺少可执行审批人", "当前采购流程未解析出有效审批人。")); + } + } + + appendBlockingReasons(response, blocking); + if (response.decision === "block") { + return response; + } + + const matchedRule = await resolveBudgetControlRule(tx, { + entityType: "purchase_request", + departmentId: purchaseRequest.department_id, + legalEntityId: purchaseRequest.legal_entity_id, + costCenterId: purchaseRequest.cost_center_id, + expenseTypeIds: [], + }); + + let resolvedBudgetPool = null; + if (purchaseRequest.legal_entity_id && purchaseRequest.cost_center_id) { + resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, { + departmentId: purchaseRequest.department_id, + legalEntityId: purchaseRequest.legal_entity_id, + costCenterId: purchaseRequest.cost_center_id, + effectiveDate: new Date(), + }); + } + + const budgetIssues = []; + const selectedBudgetPoolId = purchaseRequest.budget_pool_id ?? resolvedBudgetPool?.id ?? null; + if (purchaseRequest.budget_pool_id && resolvedBudgetPool && purchaseRequest.budget_pool_id !== resolvedBudgetPool.id) { + budgetIssues.push({ + code: "budget_context_mismatch", + level: RISK_LEVELS.high, + title: "预算池与业务上下文不匹配", + detail: "手工选择的预算池与当前法体/成本中心范围不一致。", + }); + } + + const budgetPool = selectedBudgetPoolId + ? await tx.budgetPool.findUnique({ where: { id: selectedBudgetPoolId } }) + : null; + + if (!budgetPool) { + budgetIssues.push({ + code: "budget_pool_missing", + level: RISK_LEVELS.medium, + title: "未找到可用预算池", + detail: "当前采购申请未匹配到可用预算池。", + }); + } else { + response.budgetHint = buildBudgetHint( + budgetPool, + purchaseRequest.total_amount, + matchedRule?.controlMode ?? normalizeFallbackControlMode(config), + ); + if (response.budgetHint.exceedsBudget) { + budgetIssues.push({ + code: "budget_exceeded", + level: RISK_LEVELS.high, + title: "预算将被超用", + detail: "当前采购金额会导致预算池超出已分配额度。", + }); + } else if (response.budgetHint.exceedsThreshold) { + budgetIssues.push({ + code: "budget_threshold_exceeded", + level: RISK_LEVELS.medium, + title: "预算达到预警阈值", + detail: "本次提交后预算使用率将达到或超过预警阈值。", + }); + } + } + + return applyDecisionByIssues(response, budgetIssues, matchedRule, config); +} + +async function buildTravelRequestPrecheck(tx, { travelRequestId, config, logger }) { + const travelRequest = await tx.travelRequest.findUnique({ + where: { id: travelRequestId }, + select: { + id: true, + applicant_employee_id: true, + department_id: true, + legal_entity_id: true, + cost_center_id: true, + start_date: true, + estimated_amount: true, + status: true, + segments: { select: { id: true } }, + }, + }); + + if (!travelRequest) { + throw buildNotFoundError(); + } + + const response = buildBasePrecheck("travel_request", travelRequestId); + const blocking = []; + + if (travelRequest.status !== "draft") { + blocking.push(buildRiskFlagPreview("only_draft_submittable", RISK_LEVELS.high, "当前状态不可提交", "差旅申请只有草稿状态允许提交。")); + } + if (!travelRequest.legal_entity_id) { + blocking.push(buildRiskFlagPreview("legal_entity_required", RISK_LEVELS.high, "缺少法体", "提交前必须选择法体。")); + } + if (!travelRequest.cost_center_id) { + blocking.push(buildRiskFlagPreview("cost_center_required", RISK_LEVELS.high, "缺少成本中心", "提交前必须选择成本中心。")); + } + if (!travelRequest.segments.length) { + blocking.push(buildRiskFlagPreview("segments_required", RISK_LEVELS.high, "缺少行程段", "提交前至少需要一个行程段。")); + } + + const workflowDefinition = await tx.workflowDefinition.findFirst({ + where: { + entity_type: "travel_request", + is_active: true, + archived_at: null, + }, + orderBy: { version: "desc" }, + select: { id: true, definition_json: true }, + }); + if (!workflowDefinition) { + blocking.push(buildRiskFlagPreview("workflow_definition_missing", RISK_LEVELS.high, "缺少流程定义", "差旅申请缺少有效流程定义。")); + } else { + const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json); + const { actionable } = await resolveActionableStep({ + tx, + steps: workflowSteps, + startIndex: 0, + ownerId: travelRequest.applicant_employee_id, + logger, + }); + if (!actionable) { + blocking.push(buildRiskFlagPreview("no_actionable_approver", RISK_LEVELS.high, "缺少可执行审批人", "当前差旅流程未解析出有效审批人。")); + } + } + + appendBlockingReasons(response, blocking); + if (response.decision === "block") { + return response; + } + + const matchedRule = await resolveBudgetControlRule(tx, { + entityType: "travel_request", + departmentId: travelRequest.department_id, + legalEntityId: travelRequest.legal_entity_id, + costCenterId: travelRequest.cost_center_id, + expenseTypeIds: [], + }); + + const budgetPool = travelRequest.legal_entity_id && travelRequest.cost_center_id + ? await resolveApplicableBudgetPoolByContext(tx, { + departmentId: travelRequest.department_id, + legalEntityId: travelRequest.legal_entity_id, + costCenterId: travelRequest.cost_center_id, + effectiveDate: travelRequest.start_date, + }) + : null; + + const budgetIssues = []; + if (!budgetPool) { + budgetIssues.push({ + code: "budget_pool_missing", + level: RISK_LEVELS.medium, + title: "未找到可用预算池", + detail: "当前差旅申请未匹配到可用预算池。", + }); + } else { + response.budgetHint = buildBudgetHint( + budgetPool, + travelRequest.estimated_amount, + matchedRule?.controlMode ?? normalizeFallbackControlMode(config), + ); + if (response.budgetHint.exceedsBudget) { + budgetIssues.push({ + code: "budget_exceeded", + level: RISK_LEVELS.high, + title: "预算将被超用", + detail: "当前差旅申请金额会导致预算池超出已分配额度。", + }); + } else if (response.budgetHint.exceedsThreshold) { + budgetIssues.push({ + code: "budget_threshold_exceeded", + level: RISK_LEVELS.medium, + title: "预算达到预警阈值", + detail: "本次提交后预算使用率将达到或超过预警阈值。", + }); + } + } + + return applyDecisionByIssues(response, budgetIssues, matchedRule, config); +} + +module.exports = { + PRECHECK_DECISIONS, + RISK_LEVELS, + toMoneyNumber, + toMoneyString, + listRiskFlags, + ensureRiskFlag, + buildPersistedRiskFlag, + createRiskFlagsFromPrecheck, + buildReimbursementPrecheck, + buildPurchaseRequestPrecheck, + buildTravelRequestPrecheck, +}; diff --git a/server/src/services/notifications/imEnqueue.js b/server/src/services/notifications/imEnqueue.js index d9721664..11ca7d5b 100644 --- a/server/src/services/notifications/imEnqueue.js +++ b/server/src/services/notifications/imEnqueue.js @@ -7,6 +7,7 @@ const { matchesNotificationChannel, normalizeChannelType, } = require('./imRouting'); +const { signPayload } = require('../../utils/signedUrl'); function uniq(values) { return Array.from(new Set((values ?? []).filter(Boolean))); @@ -32,7 +33,16 @@ function buildDetailUrl(config, targetPath) { return path; } - return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`; + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + const token = signPayload( + { + targetPath: normalizedPath, + exp: Math.floor(Date.now() / 1000) + 15 * 60, + }, + config.jwtSecret, + ); + + return `${baseUrl}/api/auth/deeplink?token=${encodeURIComponent(token)}`; } async function resolveRecipientRoleCodes(tx, recipientEmployeeIds) { diff --git a/server/tests/unit/featureFlags.evaluate.test.js b/server/tests/unit/featureFlags.evaluate.test.js index d78487cc..4fe81457 100644 --- a/server/tests/unit/featureFlags.evaluate.test.js +++ b/server/tests/unit/featureFlags.evaluate.test.js @@ -212,13 +212,8 @@ describe("listFeatureFlags", () => { invoiceVerificationEnabled: true, }); - expect(items).toHaveLength(4); - expect(items.map((item) => item.flagKey).sort()).toEqual([ - FEATURE_FLAG_KEYS.aiAssist, - FEATURE_FLAG_KEYS.erpSync, - FEATURE_FLAG_KEYS.invoiceVerification, - FEATURE_FLAG_KEYS.leaveHolidayCalendar, - ]); + expect(items).toHaveLength(Object.keys(FEATURE_FLAG_KEYS).length); + expect(items.map((item) => item.flagKey).sort()).toEqual(Object.values(FEATURE_FLAG_KEYS).sort()); const missingItem = items.find((item) => item.flagKey === FEATURE_FLAG_KEYS.aiAssist); expect(missingItem.id).toBeNull(); diff --git a/server/tests/unit/routes/travelRequests.test.js b/server/tests/unit/routes/travelRequests.test.js index b747a606..3c403e0f 100644 --- a/server/tests/unit/routes/travelRequests.test.js +++ b/server/tests/unit/routes/travelRequests.test.js @@ -69,6 +69,9 @@ function buildPrisma({ items = [], detail = null } = {}) { count: vi.fn().mockResolvedValue(items.length), findUnique: vi.fn().mockResolvedValue(detail), }, + entityRiskFlag: { + findMany: vi.fn().mockResolvedValue([]), + }, }; } diff --git a/server/tests/unit/services/imEnqueue.test.js b/server/tests/unit/services/imEnqueue.test.js index 1e5625dc..2e2f180a 100644 --- a/server/tests/unit/services/imEnqueue.test.js +++ b/server/tests/unit/services/imEnqueue.test.js @@ -7,8 +7,8 @@ const { describe("IM enqueue", () => { it("builds detail url from APP_ORIGIN", () => { - expect(buildDetailUrl({ appOrigin: "https://app.example.com" }, "/workflow/tasks/1")).toBe( - "https://app.example.com/workflow/tasks/1", + expect(buildDetailUrl({ appOrigin: "https://app.example.com", jwtSecret: "test-secret" }, "/workflow/tasks/1")).toContain( + "https://app.example.com/api/auth/deeplink?token=", ); expect(buildDetailUrl({ appOrigin: "" }, "/workflow/tasks/1")).toBeNull(); }); @@ -93,6 +93,7 @@ describe("IM enqueue", () => { imNotifierEnabled: true, integrationOutboxMaxAttempts: 5, appOrigin: "https://app.example.com", + jwtSecret: "test-secret", }, logger: { warn: vi.fn() }, recipientEmployeeIds: ["employee-1"], @@ -111,8 +112,8 @@ describe("IM enqueue", () => { expect(result.duplicated).toBe(1); expect(result.matchedChannels).toEqual(["channel-1", "channel-2"]); expect(tx.integrationOutbox.create).toHaveBeenCalledTimes(1); - expect(tx.integrationOutbox.create.mock.calls[0][0].data.payload.detailUrl).toBe( - "https://app.example.com/workflow/tasks/task-1", + expect(tx.integrationOutbox.create.mock.calls[0][0].data.payload.detailUrl).toEqual( + expect.stringContaining("https://app.example.com/api/auth/deeplink?token="), ); }); @@ -150,6 +151,7 @@ describe("IM enqueue", () => { imNotifierEnabled: true, integrationOutboxMaxAttempts: 5, appOrigin: "https://app.example.com", + jwtSecret: "test-secret", }, logger: { warn: vi.fn() }, recipientEmployeeIds: ["employee-1"],