feat(P25-1/P25-5/P26-1/P26-3/P24-1/P29-4): 落地内部OA够用版后端与试运行能力
关联 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
这个提交包含在:
@@ -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:[email protected]: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` + 恢复旧入口
|
||||
|
||||
@@ -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:[email protected]: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。
|
||||
|
||||
@@ -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` 启停时间、操作人和备注。
|
||||
- 保存试点期首批新单编号、审批截图与付款批次号。
|
||||
@@ -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="[email protected]"
|
||||
# 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=企业统一登录
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
+440
-251
文件差异内容过多而无法显示
加载差异
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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:");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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([]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
在新工单中引用
屏蔽一个用户