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`
|
- [ ] `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`
|
- [ ] `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 监听
|
- 关闭 HTTP 监听
|
||||||
- 断开 Prisma/PG 连接
|
- 断开 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 * * * *"
|
DELEGATION_REVOKE_CRON="0 * * * *"
|
||||||
|
|
||||||
OCR_PROVIDER="off"
|
OCR_PROVIDER="off"
|
||||||
|
# OCR_CRON_ENABLED="false" # 仅关闭后台 OCR 定时任务;保留 OCR API/手工重试能力
|
||||||
OCR_BATCH_SIZE="10"
|
OCR_BATCH_SIZE="10"
|
||||||
OCR_LOCK_TTL_SECONDS="300"
|
OCR_LOCK_TTL_SECONDS="300"
|
||||||
OCR_POLL_CRON="* * * * *"
|
OCR_POLL_CRON="* * * * *"
|
||||||
@@ -97,3 +98,13 @@ INTEGRATION_OUTBOX_RETRY_SECONDS="120"
|
|||||||
# TRIALRUN_ADMIN_EMAIL="[email protected]"
|
# TRIALRUN_ADMIN_EMAIL="[email protected]"
|
||||||
# TRIALRUN_ADMIN_PHONE="13800000000"
|
# TRIALRUN_ADMIN_PHONE="13800000000"
|
||||||
# TRIALRUN_ADMIN_PASSWORD=""
|
# 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)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
|
# shellcheck source=scripts/lib/load-env-defaults.sh
|
||||||
set -a
|
source "$ROOT_DIR/scripts/lib/load-env-defaults.sh"
|
||||||
# shellcheck disable=SC1091
|
load_env_defaults ".env"
|
||||||
source .env
|
|
||||||
set +a
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${DATABASE_URL:-}" ]]; then
|
if [[ -z "${DATABASE_URL:-}" ]]; then
|
||||||
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
|
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)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
|
# shellcheck source=scripts/lib/load-env-defaults.sh
|
||||||
set -a
|
source "$ROOT_DIR/scripts/lib/load-env-defaults.sh"
|
||||||
# shellcheck disable=SC1091
|
load_env_defaults ".env"
|
||||||
source .env
|
|
||||||
set +a
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${DATABASE_URL:-}" ]]; then
|
if [[ -z "${DATABASE_URL:-}" ]]; then
|
||||||
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
|
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)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
|
# shellcheck source=scripts/lib/load-env-defaults.sh
|
||||||
set -a
|
source "$ROOT_DIR/scripts/lib/load-env-defaults.sh"
|
||||||
# shellcheck disable=SC1091
|
load_env_defaults ".env"
|
||||||
source .env
|
|
||||||
set +a
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${DATABASE_URL:-}" ]]; then
|
if [[ -z "${DATABASE_URL:-}" ]]; then
|
||||||
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
|
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)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
|
# shellcheck source=scripts/lib/load-env-defaults.sh
|
||||||
set -a
|
source "$ROOT_DIR/scripts/lib/load-env-defaults.sh"
|
||||||
# shellcheck disable=SC1091
|
load_env_defaults ".env"
|
||||||
source .env
|
|
||||||
set +a
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${DATABASE_URL:-}" ]]; then
|
if [[ -z "${DATABASE_URL:-}" ]]; then
|
||||||
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
|
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "${FIELD_ENCRYPTION_KEY:-}" ]]; then
|
TEST_FIELD_ENCRYPTION_KEY_DEFAULT="MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
|
||||||
echo "[ERROR] FIELD_ENCRYPTION_KEY is required for IM secret regression" >&2
|
FIELD_ENCRYPTION_KEY="${FIELD_ENCRYPTION_KEY:-$TEST_FIELD_ENCRYPTION_KEY_DEFAULT}"
|
||||||
exit 1
|
export FIELD_ENCRYPTION_KEY
|
||||||
fi
|
|
||||||
|
|
||||||
TMP_DIR="$(mktemp -d)"
|
TMP_DIR="$(mktemp -d)"
|
||||||
cleanup() {
|
cleanup() {
|
||||||
|
|||||||
@@ -36,12 +36,9 @@ fi
|
|||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
|
# shellcheck source=scripts/lib/load-env-defaults.sh
|
||||||
set -a
|
source "$ROOT_DIR/scripts/lib/load-env-defaults.sh"
|
||||||
# shellcheck disable=SC1091
|
load_env_defaults ".env"
|
||||||
source .env
|
|
||||||
set +a
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${DATABASE_URL:-}" ]]; then
|
if [[ -z "${DATABASE_URL:-}" ]]; then
|
||||||
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
|
# shellcheck source=scripts/lib/load-env-defaults.sh
|
||||||
set -a
|
source "$ROOT_DIR/scripts/lib/load-env-defaults.sh"
|
||||||
# shellcheck disable=SC1091
|
load_env_defaults ".env"
|
||||||
source .env
|
|
||||||
set +a
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${DATABASE_URL:-}" ]]; then
|
if [[ -z "${DATABASE_URL:-}" ]]; then
|
||||||
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
|
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)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
|
# shellcheck source=scripts/lib/load-env-defaults.sh
|
||||||
set -a
|
source "$ROOT_DIR/scripts/lib/load-env-defaults.sh"
|
||||||
# shellcheck disable=SC1091
|
load_env_defaults ".env"
|
||||||
source .env
|
|
||||||
set +a
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${DATABASE_URL:-}" ]]; then
|
if [[ -z "${DATABASE_URL:-}" ]]; then
|
||||||
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
|
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)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
|
# shellcheck source=scripts/lib/load-env-defaults.sh
|
||||||
set -a
|
source "$ROOT_DIR/scripts/lib/load-env-defaults.sh"
|
||||||
# shellcheck disable=SC1091
|
load_env_defaults ".env"
|
||||||
source .env
|
|
||||||
set +a
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${DATABASE_URL:-}" ]]; then
|
if [[ -z "${DATABASE_URL:-}" ]]; then
|
||||||
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
|
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)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
|
# shellcheck source=scripts/lib/load-env-defaults.sh
|
||||||
set -a
|
source "$ROOT_DIR/scripts/lib/load-env-defaults.sh"
|
||||||
# shellcheck disable=SC1091
|
load_env_defaults ".env"
|
||||||
source .env
|
|
||||||
set +a
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${DATABASE_URL:-}" ]]; then
|
if [[ -z "${DATABASE_URL:-}" ]]; then
|
||||||
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
|
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 exportsRoutes = require("./routes/exports");
|
||||||
const dashboardRoutes = require("./routes/dashboard");
|
const dashboardRoutes = require("./routes/dashboard");
|
||||||
const budgetsRoutes = require("./routes/budgets");
|
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 purchaseRequestRoutes = require("./routes/purchaseRequests");
|
||||||
const assetsRoutes = require("./routes/assets");
|
const assetsRoutes = require("./routes/assets");
|
||||||
const assetMovementsRoutes = require("./routes/assetMovements");
|
const assetMovementsRoutes = require("./routes/assetMovements");
|
||||||
@@ -304,6 +314,16 @@ async function buildApp() {
|
|||||||
await app.register(notificationsRoutes, { prefix: "/api" });
|
await app.register(notificationsRoutes, { prefix: "/api" });
|
||||||
await app.register(dashboardRoutes, { prefix: "/api" });
|
await app.register(dashboardRoutes, { prefix: "/api" });
|
||||||
await app.register(budgetsRoutes, { 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(purchaseRequestRoutes, { prefix: "/api" });
|
||||||
await app.register(assetsRoutes, { prefix: "/api" });
|
await app.register(assetsRoutes, { prefix: "/api" });
|
||||||
await app.register(assetMovementsRoutes, { prefix: "/api" });
|
await app.register(assetMovementsRoutes, { prefix: "/api" });
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ function getConfig() {
|
|||||||
pgBossSchema: process.env.PG_BOSS_SCHEMA ?? "pgboss",
|
pgBossSchema: process.env.PG_BOSS_SCHEMA ?? "pgboss",
|
||||||
|
|
||||||
ocrProvider: process.env.OCR_PROVIDER ?? "off",
|
ocrProvider: process.env.OCR_PROVIDER ?? "off",
|
||||||
|
ocrCronEnabled: process.env.OCR_CRON_ENABLED !== "false",
|
||||||
ocrBatchSize: Number(process.env.OCR_BATCH_SIZE ?? 10),
|
ocrBatchSize: Number(process.env.OCR_BATCH_SIZE ?? 10),
|
||||||
ocrLockTtlSeconds: Number(process.env.OCR_LOCK_TTL_SECONDS ?? 300),
|
ocrLockTtlSeconds: Number(process.env.OCR_LOCK_TTL_SECONDS ?? 300),
|
||||||
ocrPollCron: process.env.OCR_POLL_CRON ?? "* * * * *",
|
ocrPollCron: process.env.OCR_POLL_CRON ?? "* * * * *",
|
||||||
@@ -103,6 +104,13 @@ function getConfig() {
|
|||||||
verificationTimeoutMs: Number(process.env.VERIFICATION_TIMEOUT_MS ?? 5000),
|
verificationTimeoutMs: Number(process.env.VERIFICATION_TIMEOUT_MS ?? 5000),
|
||||||
|
|
||||||
budgetControlMode: process.env.BUDGET_CONTROL_MODE ?? "warn",
|
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",
|
aiAssistEnabled: process.env.AI_ASSIST_ENABLED === "true",
|
||||||
aiProvider: process.env.AI_PROVIDER ?? "mock",
|
aiProvider: process.env.AI_PROVIDER ?? "mock",
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ const ROLE_PERMISSIONS = {
|
|||||||
"budget.view",
|
"budget.view",
|
||||||
"expense.approve",
|
"expense.approve",
|
||||||
"expense.finance.pay",
|
"expense.finance.pay",
|
||||||
|
"payment.batch.manage",
|
||||||
"expense.view_all",
|
"expense.view_all",
|
||||||
"procurement.view_all",
|
"procurement.view_all",
|
||||||
"travel.view_all",
|
"travel.view_all",
|
||||||
@@ -171,6 +172,7 @@ const ROLE_PERMISSIONS = {
|
|||||||
"ai.assist",
|
"ai.assist",
|
||||||
"expense.approve",
|
"expense.approve",
|
||||||
"expense.finance.pay",
|
"expense.finance.pay",
|
||||||
|
"payment.batch.manage",
|
||||||
"expense.view_all",
|
"expense.view_all",
|
||||||
"expense.finance.override_duplicate",
|
"expense.finance.override_duplicate",
|
||||||
"procurement.view_all",
|
"procurement.view_all",
|
||||||
@@ -183,6 +185,8 @@ const ROLE_PERMISSIONS = {
|
|||||||
"asset.inventory",
|
"asset.inventory",
|
||||||
"system.settings",
|
"system.settings",
|
||||||
"system.audit.view",
|
"system.audit.view",
|
||||||
|
"payment.batch.manage",
|
||||||
|
"risk.review.manage",
|
||||||
],
|
],
|
||||||
administration: [
|
administration: [
|
||||||
"expense.proxy_submit",
|
"expense.proxy_submit",
|
||||||
|
|||||||
@@ -291,6 +291,11 @@ function startOcrJob(fastify) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!fastify.config.ocrCronEnabled) {
|
||||||
|
fastify.log.info({ provider: fastify.config.ocrProvider }, "OCR cron disabled");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
let running = false;
|
let running = false;
|
||||||
const workerId = `ocr-worker:${process.pid}`;
|
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 });
|
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) => {
|
fastify.get("/oidc/login", async (request, reply) => {
|
||||||
if (!fastify.config.oidcEnabled) {
|
if (!fastify.config.oidcEnabled) {
|
||||||
throw new AppError({
|
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 { sendData } = require("../utils/reply");
|
||||||
const { resolveActionableStep } = require("../utils/workflow");
|
const { resolveActionableStep } = require("../utils/workflow");
|
||||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||||
|
const { ensureCutoverAllowed } = require("../services/cutover");
|
||||||
const {
|
const {
|
||||||
DEFAULT_ATTACHMENT_MIME_TYPES,
|
DEFAULT_ATTACHMENT_MIME_TYPES,
|
||||||
calculateRequestedDays,
|
calculateRequestedDays,
|
||||||
@@ -476,6 +477,13 @@ async function leaveRoutes(fastify) {
|
|||||||
preHandler: fastify.requireEntityPermission("leave", "create"),
|
preHandler: fastify.requireEntityPermission("leave", "create"),
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
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 { body, filePart } = await collectCreatePayload(request);
|
||||||
const leaveType = await loadLeaveTypeOrThrow(fastify.prisma, body.leaveTypeId);
|
const leaveType = await loadLeaveTypeOrThrow(fastify.prisma, body.leaveTypeId);
|
||||||
const attachment = await assertLeaveTypeAttachment(
|
const attachment = await assertLeaveTypeAttachment(
|
||||||
@@ -648,6 +656,13 @@ async function leaveRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const leave = leaveRows[0];
|
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) {
|
if (leave.owner_id !== request.employee.id) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
code: "PERM_001",
|
code: "PERM_001",
|
||||||
@@ -877,6 +892,13 @@ async function leaveRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const leave = leaveRows[0];
|
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) {
|
if (leave.owner_id !== request.employee.id) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
code: "PERM_001",
|
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 { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
|
||||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||||
|
const { ensureCutoverAllowed } = require("../services/cutover");
|
||||||
|
const {
|
||||||
|
buildPersistedRiskFlag,
|
||||||
|
buildPurchaseRequestPrecheck,
|
||||||
|
createRiskFlagsFromPrecheck,
|
||||||
|
} = require("../services/feeControl");
|
||||||
const {
|
const {
|
||||||
PROCUREMENT_ERP_TARGETS,
|
PROCUREMENT_ERP_TARGETS,
|
||||||
enqueuePurchaseAccrualJournalExport,
|
enqueuePurchaseAccrualJournalExport,
|
||||||
@@ -306,6 +312,31 @@ function normalizeBudgetControlMode(config) {
|
|||||||
return String(config.budgetControlMode ?? "warn").toLowerCase() === "block" ? "block" : "warn";
|
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) {
|
function ensurePurchaseReadPermission(fastify, request) {
|
||||||
const canViewOwn = fastify.hasPermission(request.employee, "procurement.view_own");
|
const canViewOwn = fastify.hasPermission(request.employee, "procurement.view_own");
|
||||||
const canViewAll =
|
const canViewAll =
|
||||||
@@ -1010,6 +1041,13 @@ async function purchaseRequestRoutes(fastify) {
|
|||||||
preHandler: fastify.requirePermission("procurement.create"),
|
preHandler: fastify.requirePermission("procurement.create"),
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
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 purchaseRequest = await fastify.prisma.$transaction(async (tx) => {
|
||||||
const created = await tx.purchaseRequest.create({
|
const created = await tx.purchaseRequest.create({
|
||||||
data: {
|
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(
|
fastify.post(
|
||||||
"/purchase-requests/:id/submit",
|
"/purchase-requests/:id/submit",
|
||||||
{
|
{
|
||||||
@@ -1197,6 +1282,15 @@ async function purchaseRequestRoutes(fastify) {
|
|||||||
throw buildValidationError({ status: "only_draft_submittable" }, 409);
|
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({
|
const purchaseRequest = await tx.purchaseRequest.findUnique({
|
||||||
where: { id: purchaseRequestId },
|
where: { id: purchaseRequestId },
|
||||||
select: {
|
select: {
|
||||||
@@ -1244,6 +1338,30 @@ async function purchaseRequestRoutes(fastify) {
|
|||||||
const documentCategory = await ensureDocumentCategoryAvailable(tx, purchaseRequest.document_category_id);
|
const documentCategory = await ensureDocumentCategoryAvailable(tx, purchaseRequest.document_category_id);
|
||||||
const totalAmount = await recomputePurchaseRequestTotal(tx, purchaseRequestId, request.employee.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();
|
const effectiveDate = new Date();
|
||||||
let resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, {
|
let resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, {
|
||||||
departmentId: purchaseRequest.department_id,
|
departmentId: purchaseRequest.department_id,
|
||||||
@@ -1263,12 +1381,14 @@ async function purchaseRequestRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
const budgetControlMode = normalizeBudgetControlMode(fastify.config);
|
const budgetControlMode = authoritativePrecheck?.budgetHint?.controlMode ?? normalizeBudgetControlMode(fastify.config);
|
||||||
if (!selectedBudgetPoolId) {
|
if (!selectedBudgetPoolId) {
|
||||||
if (budgetControlMode === "block") {
|
if (!budgetPrecheckGate.enabled && budgetControlMode === "block") {
|
||||||
throw buildValidationError({ budget: "missing_budget_pool" }, 409);
|
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({
|
const workflowDefinition = await tx.workflowDefinition.findFirst({
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
|
|||||||
const { sendNotificationEmailSafe } = require("../services/email");
|
const { sendNotificationEmailSafe } = require("../services/email");
|
||||||
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
|
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
|
||||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||||
|
const { ensureCutoverAllowed } = require("../services/cutover");
|
||||||
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
|
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
|
||||||
|
const {
|
||||||
|
buildPersistedRiskFlag,
|
||||||
|
buildReimbursementPrecheck,
|
||||||
|
createRiskFlagsFromPrecheck,
|
||||||
|
} = require("../services/feeControl");
|
||||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||||
const {
|
const {
|
||||||
buildWorkflowStepsFromApprovalRule,
|
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) {
|
async function reimbursementRoutes(fastify) {
|
||||||
const createBodySchema = z.object({
|
const createBodySchema = z.object({
|
||||||
ownerEmployeeId: z.string().uuid().optional(),
|
ownerEmployeeId: z.string().uuid().optional(),
|
||||||
@@ -399,6 +430,13 @@ async function reimbursementRoutes(fastify) {
|
|||||||
preHandler: fastify.requirePermission("expense.create"),
|
preHandler: fastify.requirePermission("expense.create"),
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
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 ?? {});
|
const parsed = createBodySchema.safeParse(request.body ?? {});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
@@ -688,6 +726,14 @@ async function reimbursementRoutes(fastify) {
|
|||||||
|
|
||||||
const pendingInstance = reimbursement.workflow_instances[0] ?? null;
|
const pendingInstance = reimbursement.workflow_instances[0] ?? null;
|
||||||
const pendingTask = pendingInstance?.workflow_tasks?.[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, {
|
return sendData(reply, request, {
|
||||||
id: reimbursement.id,
|
id: reimbursement.id,
|
||||||
@@ -732,6 +778,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
task: pendingTask,
|
task: pendingTask,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
riskFlags: riskFlags.map(buildPersistedRiskFlag),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -763,6 +810,13 @@ async function reimbursementRoutes(fastify) {
|
|||||||
|
|
||||||
const reimbursement = reimbursementRows[0];
|
const reimbursement = reimbursementRows[0];
|
||||||
ensureApplicantOrAdmin(request, reimbursement);
|
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)) {
|
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||||
throw new AppError({
|
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(
|
fastify.post(
|
||||||
"/reimbursements/:id/submit",
|
"/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);
|
const budgetPool = await resolveApplicableBudgetPool(tx, reimbursement);
|
||||||
if (budgetPool) {
|
if (budgetPool) {
|
||||||
const allocatedAmount = Number(budgetPool.allocated_amount ?? 0);
|
const allocatedAmount = Number(budgetPool.allocated_amount ?? 0);
|
||||||
@@ -1518,6 +1632,8 @@ async function reimbursementRoutes(fastify) {
|
|||||||
currentTaskId: workflowTask.id,
|
currentTaskId: workflowTask.id,
|
||||||
status: "reviewing",
|
status: "reviewing",
|
||||||
budget: budgetCheck,
|
budget: budgetCheck,
|
||||||
|
precheckDecision: authoritativePrecheck.decision,
|
||||||
|
openedRiskFlagCount: openedRiskFlags.length,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1700,6 +1816,13 @@ async function reimbursementRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reimbursement = reimbursementRows[0];
|
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)) {
|
if (!["approved", "paid"].includes(reimbursement.status)) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
code: "VALID_001",
|
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 { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications");
|
||||||
const { enqueueImNotifications } = require("../services/notifications/imEnqueue");
|
const { enqueueImNotifications } = require("../services/notifications/imEnqueue");
|
||||||
const { buildTravelPaymentTrack } = require("../services/travelPayments");
|
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 { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
|
||||||
|
|
||||||
const UUID_SCHEMA = z.string().uuid();
|
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 }) {
|
async function ensureFinancialDimensions(tx, { legalEntityId, costCenterId }) {
|
||||||
const [legalEntity, costCenter] = await Promise.all([
|
const [legalEntity, costCenter] = await Promise.all([
|
||||||
tx.legalEntity.findUnique({
|
tx.legalEntity.findUnique({
|
||||||
@@ -641,6 +671,14 @@ async function createTravelRequestsRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const body = parseBody(TRAVEL_CREATE_BODY_SCHEMA, request.body);
|
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 startDate = parseDateOnlyString(body.startDate);
|
||||||
const endDate = parseDateOnlyString(body.endDate);
|
const endDate = parseDateOnlyString(body.endDate);
|
||||||
ensureDateRangeValid(startDate, endDate);
|
ensureDateRangeValid(startDate, endDate);
|
||||||
@@ -719,7 +757,18 @@ async function createTravelRequestsRoutes(fastify) {
|
|||||||
ensureTravelReadPermission(fastify, request);
|
ensureTravelReadPermission(fastify, request);
|
||||||
const travelRequestId = parseTravelRequestId(request.params?.id);
|
const travelRequestId = parseTravelRequestId(request.params?.id);
|
||||||
const detail = await loadReadableTravelRequest(fastify.prisma, travelRequestId, request);
|
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(
|
fastify.post(
|
||||||
"/travel-requests/:id/submit",
|
"/travel-requests/:id/submit",
|
||||||
{
|
{
|
||||||
@@ -849,6 +933,14 @@ async function createTravelRequestsRoutes(fastify) {
|
|||||||
await fastify.prisma.$transaction(async (tx) => {
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
const travelRequest = await loadTravelRequestForUpdate(tx, travelRequestId);
|
const travelRequest = await loadTravelRequestForUpdate(tx, travelRequestId);
|
||||||
ensureDraftOwner(request, travelRequest);
|
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") {
|
if (travelRequest.status !== "draft") {
|
||||||
throw buildValidationError({ status: "only_draft_submittable" }, 409);
|
throw buildValidationError({ status: "only_draft_submittable" }, 409);
|
||||||
}
|
}
|
||||||
@@ -856,6 +948,30 @@ async function createTravelRequestsRoutes(fastify) {
|
|||||||
throw buildValidationError({ segments: "required" }, 409);
|
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({
|
const workflowDefinition = await tx.workflowDefinition.findFirst({
|
||||||
where: { entity_type: "travel_request", is_active: true },
|
where: { entity_type: "travel_request", is_active: true },
|
||||||
orderBy: { version: "desc" },
|
orderBy: { version: "desc" },
|
||||||
@@ -1050,6 +1166,8 @@ async function createTravelRequestsRoutes(fastify) {
|
|||||||
currentTaskId: workflowTask.id,
|
currentTaskId: workflowTask.id,
|
||||||
budgetPoolId: selectedBudgetPoolId,
|
budgetPoolId: selectedBudgetPoolId,
|
||||||
budgetReservation,
|
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: [],
|
department_ids: [],
|
||||||
note: "bootstrap default: force_off",
|
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 = [
|
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) {
|
async function seedLeaveTypes(prisma) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
@@ -608,6 +717,7 @@ async function main() {
|
|||||||
await seedWorkflowDefinition(prisma);
|
await seedWorkflowDefinition(prisma);
|
||||||
await seedLeaveTypes(prisma);
|
await seedLeaveTypes(prisma);
|
||||||
await seedFeatureFlags(prisma);
|
await seedFeatureFlags(prisma);
|
||||||
|
await seedBudgetControlRules(prisma);
|
||||||
|
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log("Seeded config data:");
|
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) {
|
async function ensureSupplier(prisma, payload) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const existing = await prisma.supplier.findUnique({
|
const existing = await prisma.supplier.findUnique({
|
||||||
@@ -303,6 +343,9 @@ async function main() {
|
|||||||
await prisma.$connect();
|
await prisma.$connect();
|
||||||
|
|
||||||
const hireDate = new Date("2020-01-01");
|
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 {
|
try {
|
||||||
const rootDepartment = await ensureDepartment(prisma, { name: "公司" });
|
const rootDepartment = await ensureDepartment(prisma, { name: "公司" });
|
||||||
@@ -460,7 +503,7 @@ async function main() {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await ensureCostCenter(prisma, {
|
const managementCostCenter = await ensureCostCenter(prisma, {
|
||||||
legalEntityId: legalEntity.id,
|
legalEntityId: legalEntity.id,
|
||||||
code: "MGMT",
|
code: "MGMT",
|
||||||
name: "管理中心",
|
name: "管理中心",
|
||||||
@@ -478,7 +521,7 @@ async function main() {
|
|||||||
name: "人力中心",
|
name: "人力中心",
|
||||||
ownerEmployeeId: hr.id,
|
ownerEmployeeId: hr.id,
|
||||||
});
|
});
|
||||||
await ensureCostCenter(prisma, {
|
const rdCostCenter = await ensureCostCenter(prisma, {
|
||||||
legalEntityId: legalEntity.id,
|
legalEntityId: legalEntity.id,
|
||||||
code: "RD",
|
code: "RD",
|
||||||
name: "研发中心",
|
name: "研发中心",
|
||||||
@@ -496,6 +539,27 @@ async function main() {
|
|||||||
legalEntityId: legalEntity.id,
|
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
|
// eslint-disable-next-line no-console
|
||||||
console.log("Seeded dev users:");
|
console.log("Seeded dev users:");
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@@ -520,6 +584,8 @@ async function main() {
|
|||||||
console.log(`- HolidayCalendar: ${holidayCalendar.code} / ${holidayCalendar.name}`);
|
console.log(`- HolidayCalendar: ${holidayCalendar.code} / ${holidayCalendar.name}`);
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`- Supplier: ${supplier.code} / ${supplier.name}`);
|
console.log(`- Supplier: ${supplier.code} / ${supplier.name}`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`- Budget Pools: ${managementBudgetPool.code}, ${rdBudgetPool.code}`);
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
await pool.end();
|
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",
|
erpSync: "erp_sync",
|
||||||
invoiceVerification: "invoice_verification",
|
invoiceVerification: "invoice_verification",
|
||||||
leaveHolidayCalendar: "leave_holiday_calendar",
|
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));
|
const FEATURE_FLAG_KEY_LIST = Object.freeze(Object.values(FEATURE_FLAG_KEYS));
|
||||||
@@ -72,6 +79,34 @@ function resolveEnvGate(config, flagKey) {
|
|||||||
return Boolean(config.leaveHolidayCalendarEnabled);
|
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;
|
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,
|
matchesNotificationChannel,
|
||||||
normalizeChannelType,
|
normalizeChannelType,
|
||||||
} = require('./imRouting');
|
} = require('./imRouting');
|
||||||
|
const { signPayload } = require('../../utils/signedUrl');
|
||||||
|
|
||||||
function uniq(values) {
|
function uniq(values) {
|
||||||
return Array.from(new Set((values ?? []).filter(Boolean)));
|
return Array.from(new Set((values ?? []).filter(Boolean)));
|
||||||
@@ -32,7 +33,16 @@ function buildDetailUrl(config, targetPath) {
|
|||||||
return path;
|
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) {
|
async function resolveRecipientRoleCodes(tx, recipientEmployeeIds) {
|
||||||
|
|||||||
@@ -212,13 +212,8 @@ describe("listFeatureFlags", () => {
|
|||||||
invoiceVerificationEnabled: true,
|
invoiceVerificationEnabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(items).toHaveLength(4);
|
expect(items).toHaveLength(Object.keys(FEATURE_FLAG_KEYS).length);
|
||||||
expect(items.map((item) => item.flagKey).sort()).toEqual([
|
expect(items.map((item) => item.flagKey).sort()).toEqual(Object.values(FEATURE_FLAG_KEYS).sort());
|
||||||
FEATURE_FLAG_KEYS.aiAssist,
|
|
||||||
FEATURE_FLAG_KEYS.erpSync,
|
|
||||||
FEATURE_FLAG_KEYS.invoiceVerification,
|
|
||||||
FEATURE_FLAG_KEYS.leaveHolidayCalendar,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const missingItem = items.find((item) => item.flagKey === FEATURE_FLAG_KEYS.aiAssist);
|
const missingItem = items.find((item) => item.flagKey === FEATURE_FLAG_KEYS.aiAssist);
|
||||||
expect(missingItem.id).toBeNull();
|
expect(missingItem.id).toBeNull();
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ function buildPrisma({ items = [], detail = null } = {}) {
|
|||||||
count: vi.fn().mockResolvedValue(items.length),
|
count: vi.fn().mockResolvedValue(items.length),
|
||||||
findUnique: vi.fn().mockResolvedValue(detail),
|
findUnique: vi.fn().mockResolvedValue(detail),
|
||||||
},
|
},
|
||||||
|
entityRiskFlag: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ const {
|
|||||||
|
|
||||||
describe("IM enqueue", () => {
|
describe("IM enqueue", () => {
|
||||||
it("builds detail url from APP_ORIGIN", () => {
|
it("builds detail url from APP_ORIGIN", () => {
|
||||||
expect(buildDetailUrl({ appOrigin: "https://app.example.com" }, "/workflow/tasks/1")).toBe(
|
expect(buildDetailUrl({ appOrigin: "https://app.example.com", jwtSecret: "test-secret" }, "/workflow/tasks/1")).toContain(
|
||||||
"https://app.example.com/workflow/tasks/1",
|
"https://app.example.com/api/auth/deeplink?token=",
|
||||||
);
|
);
|
||||||
expect(buildDetailUrl({ appOrigin: "" }, "/workflow/tasks/1")).toBeNull();
|
expect(buildDetailUrl({ appOrigin: "" }, "/workflow/tasks/1")).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -93,6 +93,7 @@ describe("IM enqueue", () => {
|
|||||||
imNotifierEnabled: true,
|
imNotifierEnabled: true,
|
||||||
integrationOutboxMaxAttempts: 5,
|
integrationOutboxMaxAttempts: 5,
|
||||||
appOrigin: "https://app.example.com",
|
appOrigin: "https://app.example.com",
|
||||||
|
jwtSecret: "test-secret",
|
||||||
},
|
},
|
||||||
logger: { warn: vi.fn() },
|
logger: { warn: vi.fn() },
|
||||||
recipientEmployeeIds: ["employee-1"],
|
recipientEmployeeIds: ["employee-1"],
|
||||||
@@ -111,8 +112,8 @@ describe("IM enqueue", () => {
|
|||||||
expect(result.duplicated).toBe(1);
|
expect(result.duplicated).toBe(1);
|
||||||
expect(result.matchedChannels).toEqual(["channel-1", "channel-2"]);
|
expect(result.matchedChannels).toEqual(["channel-1", "channel-2"]);
|
||||||
expect(tx.integrationOutbox.create).toHaveBeenCalledTimes(1);
|
expect(tx.integrationOutbox.create).toHaveBeenCalledTimes(1);
|
||||||
expect(tx.integrationOutbox.create.mock.calls[0][0].data.payload.detailUrl).toBe(
|
expect(tx.integrationOutbox.create.mock.calls[0][0].data.payload.detailUrl).toEqual(
|
||||||
"https://app.example.com/workflow/tasks/task-1",
|
expect.stringContaining("https://app.example.com/api/auth/deeplink?token="),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,6 +151,7 @@ describe("IM enqueue", () => {
|
|||||||
imNotifierEnabled: true,
|
imNotifierEnabled: true,
|
||||||
integrationOutboxMaxAttempts: 5,
|
integrationOutboxMaxAttempts: 5,
|
||||||
appOrigin: "https://app.example.com",
|
appOrigin: "https://app.example.com",
|
||||||
|
jwtSecret: "test-secret",
|
||||||
},
|
},
|
||||||
logger: { warn: vi.fn() },
|
logger: { warn: vi.fn() },
|
||||||
recipientEmployeeIds: ["employee-1"],
|
recipientEmployeeIds: ["employee-1"],
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户