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
这个提交包含在:
2026-03-21 18:03:20 +08:00
父节点 9b7cd818f7
当前提交 e39e41287e
共修改 45 个文件,包含 4571 行新增和 328 行删除
+13
查看文件
@@ -41,3 +41,16 @@
- [ ] `docker compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server`
- [ ] `curl -fsS http://127.0.0.1:3000/ready`
## E. R12-lite 试点切换检查
- [ ] 已执行 `cd server && DATABASE_URL=postgresql://hyboa_user:[email protected]:5432/hyb_oa_r12?schema=public npm run seed:config`
- [ ] 运行环境已打开 R12-lite 所需 env 开关
- [ ] 功能开关页已将 `budget_precheck_v1`、`payment_batch_v1`、`portal_home_v1`、`global_search_v1`、`announcements_v1`、`workflow_definition_admin_v1`、`cutover_console_v1` 切到 `inherit` 或 `force_on`
- [ ] 旧 OA 主数据 CSV 已完成 dry-run,结果为 `dry_run_ok`
- [ ] `docs/migration-mapping.md` 已按试点范围人工核对
- [ ] 旧 OA 新单停用时间已公告,未完结事项已人工清空或冻结
- [ ] 仅按部门 / 法体 / 域粒度创建 `CutoverScope`
- [ ] 先开 `portal`,再逐域灰度打开业务入口
- [ ] Portal、待办、预算预校验、支付批次、公告、搜索已在试点部门可用
- [ ] 回退方案已演练:关闭 `CutoverScope` + 恢复旧入口
+33
查看文件
@@ -158,3 +158,36 @@ docker logs -f hyb-oa-trialrun-server
- 关闭 HTTP 监听
- 断开 Prisma/PG 连接
- 超时会强制退出(避免卡死导致升级失败)。
## 6. R12-lite 试点切换 Runbook
### 6.1 打开本轮功能前置条件
- 执行 `cd server && DATABASE_URL=postgresql://hyboa_user:[email protected]:5432/hyb_oa_r12?schema=public npm run seed:config`,确保 R12-lite 开关与默认配置落库。
- 在运行环境启用对应 env:`BUDGET_PRECHECK_ENABLED=true`、`PAYMENT_BATCH_ENABLED=true`、`PORTAL_HOME_ENABLED=true`、`GLOBAL_SEARCH_ENABLED=true`、`ANNOUNCEMENTS_ENABLED=true`、`WORKFLOW_DEFINITION_ADMIN_ENABLED=true`、`CUTOVER_CONSOLE_ENABLED=true`。
- 登录系统后在“功能开关”页将对应开关切到 `inherit` 或 `force_on`;仅设置 env 不会自动放开 DB 中的 `force_off` 记录。
### 6.2 旧 OA 主数据导出与 dry-run
1. 从旧 OA 导出人员、部门、预算或主数据 CSV。
2. 进入“切换控制台”,按 `employees | departments | budgets | masterdata_validation` 上传 CSV 执行 dry-run。
3. 只接受 `dry_run_ok` 结果进入下一步;若为 `dry_run_failed`,先修正列头和差异。
4. 以 `docs/migration-mapping.md` 为准完成人工差异核对。
### 6.3 试点切换步骤
1. 确认旧 OA 未完结事项已人工清空或冻结。
2. 在旧 OA 宣布“新单停用时间点”。
3. 在新系统创建 `CutoverScope`,粒度仅允许 `department | legal_entity | domain`。
4. 按试点范围打开对应 `CutoverScope`,先开 `portal`,再按域逐步打开 `expense / leave / procurement / travel / asset`。
5. 观察 Portal、待办、支付批次、AP 运营和消息深链是否正常。
### 6.4 验收证据
- 保留 dry-run 结果截图或导出记录。
- 保留切换范围配置、启停时间和操作人审计。
- 保留回归结果:`server/scripts/regression-all.sh`、`client npm run build`、`client npm run test:e2e`(如环境可用)。
- 保留试点部门首批新单在新系统流转与付款的样例单据编号。
### 6.5 回退步骤
1. 在“切换控制台”关闭目标 `CutoverScope`。
2. 恢复旧 OA 对应入口并重新开放新单。
3. 若需要代码/数据回退,按本文第 3 节恢复到最近备份。
4. 新系统中试点期间新建单据保留审计,不做 destructive 回写到旧 OA。
+56
查看文件
@@ -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` 启停时间、操作人和备注。
- 保存试点期首批新单编号、审批截图与付款批次号。
+11
查看文件
@@ -52,6 +52,7 @@ MAX_UPLOAD_SIZE_BYTES="10485760"
DELEGATION_REVOKE_CRON="0 * * * *"
OCR_PROVIDER="off"
# OCR_CRON_ENABLED="false" # 仅关闭后台 OCR 定时任务;保留 OCR API/手工重试能力
OCR_BATCH_SIZE="10"
OCR_LOCK_TTL_SECONDS="300"
OCR_POLL_CRON="* * * * *"
@@ -97,3 +98,13 @@ INTEGRATION_OUTBOX_RETRY_SECONDS="120"
# TRIALRUN_ADMIN_EMAIL="[email protected]"
# TRIALRUN_ADMIN_PHONE="13800000000"
# TRIALRUN_ADMIN_PASSWORD=""
# R12 lite feature gates
BUDGET_PRECHECK_ENABLED=false
PAYMENT_BATCH_ENABLED=false
PORTAL_HOME_ENABLED=false
GLOBAL_SEARCH_ENABLED=false
ANNOUNCEMENTS_ENABLED=false
WORKFLOW_DEFINITION_ADMIN_ENABLED=false
CUTOVER_CONSOLE_ENABLED=false
OIDC_PROVIDER_LABEL=企业统一登录
@@ -0,0 +1,313 @@
-- CreateEnum
CREATE TYPE "BudgetControlMode" AS ENUM ('warn', 'block', 'manual_review');
-- CreateEnum
CREATE TYPE "RiskFlagStatus" AS ENUM ('open', 'resolved', 'dismissed');
-- CreateEnum
CREATE TYPE "PaymentBatchStatus" AS ENUM ('draft', 'reviewed', 'executed', 'failed', 'cancelled');
-- CreateEnum
CREATE TYPE "PaymentBatchEntityType" AS ENUM ('reimbursement', 'travel_order');
-- CreateEnum
CREATE TYPE "BudgetControlRuleEntityType" AS ENUM ('all', 'reimbursement', 'purchase_request', 'travel_request');
-- AlterTable
ALTER TABLE "approval_rules" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "asset_cards" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "asset_categories" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "budget_pools" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "card_authorizations" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "card_transactions" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "cost_centers" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "departments" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "document_categories" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "document_sequences" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "employees" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "expense_types" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "feature_flags" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "holiday_calendar_days" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "holiday_calendars" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "integration_bindings" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "integration_outbox" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "integration_sync_states" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "inventory_checks" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "invoice_pool" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "leave_policies" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "leave_requests" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "leave_types" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "legal_entities" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "notification_channels" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "purchase_request_items" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "purchase_requests" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "reimbursement_forms" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "reimbursement_items" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "suppliers" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "travel_orders" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "travel_reconciliation_batches" ALTER COLUMN "id" DROP DEFAULT;
-- AlterTable
ALTER TABLE "travel_reconciliation_items" ALTER COLUMN "id" DROP DEFAULT;
-- AlterTable
ALTER TABLE "travel_request_segments" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "travel_requests" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "workflow_definitions" ADD COLUMN "archived_at" TIMESTAMP(3),
ADD COLUMN "published_at" TIMESTAMP(3),
ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updated_by" UUID;
-- AlterTable
ALTER TABLE "workflow_instances" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- CreateTable
CREATE TABLE "budget_control_rules" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"entity_type" "BudgetControlRuleEntityType" NOT NULL DEFAULT 'all',
"department_id" UUID,
"legal_entity_id" UUID,
"cost_center_id" UUID,
"expense_type_id" UUID,
"control_mode" "BudgetControlMode" NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 100,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"notes" TEXT,
"archived_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" UUID,
CONSTRAINT "budget_control_rules_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "entity_risk_flags" (
"id" UUID NOT NULL,
"entity_type" VARCHAR(32) NOT NULL,
"entity_id" UUID NOT NULL,
"risk_code" VARCHAR(64) NOT NULL,
"level" VARCHAR(16) NOT NULL,
"title" TEXT NOT NULL,
"detail" TEXT,
"source" VARCHAR(32) NOT NULL,
"status" "RiskFlagStatus" NOT NULL DEFAULT 'open',
"payload_json" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"resolved_at" TIMESTAMP(3),
"resolved_by" UUID,
CONSTRAINT "entity_risk_flags_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payment_batches" (
"id" UUID NOT NULL,
"batch_no" VARCHAR(64) NOT NULL,
"entity_type" "PaymentBatchEntityType" NOT NULL,
"name" TEXT NOT NULL,
"status" "PaymentBatchStatus" NOT NULL DEFAULT 'draft',
"default_payment_date" DATE,
"default_payment_channel" VARCHAR(20),
"voucher_prefix" VARCHAR(32),
"review_comment" TEXT,
"execution_summary_json" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"reviewed_at" TIMESTAMP(3),
"reviewed_by" UUID,
"executed_at" TIMESTAMP(3),
"executed_by" UUID,
"cancelled_at" TIMESTAMP(3),
"cancelled_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID,
CONSTRAINT "payment_batches_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payment_batch_items" (
"id" UUID NOT NULL,
"payment_batch_id" UUID NOT NULL,
"entity_id" UUID NOT NULL,
"entity_code" TEXT NOT NULL,
"entity_title" TEXT NOT NULL,
"amount" DECIMAL(14,2) NOT NULL,
"currency_code" VARCHAR(8) NOT NULL DEFAULT 'CNY',
"payment_voucher_no_override" VARCHAR(50),
"payment_date_override" DATE,
"payment_channel_override" VARCHAR(20),
"status" VARCHAR(16) NOT NULL DEFAULT 'pending',
"failure_code" VARCHAR(64),
"failure_message" TEXT,
"snapshot_json" JSONB NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "payment_batch_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "portal_announcements" (
"id" UUID NOT NULL,
"title" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"link_url" TEXT,
"starts_at" TIMESTAMP(3),
"ends_at" TIMESTAMP(3),
"enabled" BOOLEAN NOT NULL DEFAULT true,
"priority" INTEGER NOT NULL DEFAULT 100,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" UUID,
CONSTRAINT "portal_announcements_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "migration_runs" (
"id" UUID NOT NULL,
"run_type" VARCHAR(32) NOT NULL,
"status" VARCHAR(32) NOT NULL DEFAULT 'draft',
"source_filename" TEXT NOT NULL,
"summary_json" JSONB,
"error_json" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
CONSTRAINT "migration_runs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cutover_scopes" (
"id" UUID NOT NULL,
"scope_type" VARCHAR(32) NOT NULL,
"scope_key" VARCHAR(64) NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"starts_at" TIMESTAMP(3),
"notes" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" UUID,
CONSTRAINT "cutover_scopes_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "idx_budget_control_rules_entity_enabled" ON "budget_control_rules"("entity_type", "enabled", "archived_at");
-- CreateIndex
CREATE INDEX "idx_budget_control_rules_scope" ON "budget_control_rules"("department_id", "legal_entity_id", "cost_center_id");
-- CreateIndex
CREATE INDEX "idx_budget_control_rules_expense_type" ON "budget_control_rules"("expense_type_id");
-- CreateIndex
CREATE INDEX "idx_entity_risk_flags_entity_status" ON "entity_risk_flags"("entity_type", "entity_id", "status");
-- CreateIndex
CREATE INDEX "idx_entity_risk_flags_status_source_created" ON "entity_risk_flags"("status", "source", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "payment_batches_batch_no_key" ON "payment_batches"("batch_no");
-- CreateIndex
CREATE INDEX "idx_payment_batches_entity_status_created" ON "payment_batches"("entity_type", "status", "created_at");
-- CreateIndex
CREATE INDEX "idx_payment_batch_items_batch_status" ON "payment_batch_items"("payment_batch_id", "status");
-- CreateIndex
CREATE INDEX "idx_payment_batch_items_entity_id" ON "payment_batch_items"("entity_id");
-- CreateIndex
CREATE INDEX "idx_portal_announcements_enabled_priority" ON "portal_announcements"("enabled", "priority", "starts_at", "ends_at");
-- CreateIndex
CREATE INDEX "idx_migration_runs_type_status_created" ON "migration_runs"("run_type", "status", "created_at");
-- CreateIndex
CREATE INDEX "idx_cutover_scopes_enabled_type" ON "cutover_scopes"("enabled", "scope_type");
-- CreateIndex
CREATE UNIQUE INDEX "cutover_scopes_scope_type_scope_key_ux" ON "cutover_scopes"("scope_type", "scope_key");
-- CreateIndex
CREATE INDEX "idx_workflow_definitions_entity_archived" ON "workflow_definitions"("entity_type", "archived_at");
-- RenameForeignKey
ALTER TABLE "travel_reconciliation_items" RENAME CONSTRAINT "travel_reconciliation_items_batch_id_fkey" TO "travel_reconciliation_items_travel_reconciliation_batch_id_fkey";
-- AddForeignKey
ALTER TABLE "payment_batch_items" ADD CONSTRAINT "payment_batch_items_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,55 @@
ALTER TABLE "invoice_pool"
DROP CONSTRAINT IF EXISTS "invoice_pool_status_invariants_chk";
ALTER TABLE "invoice_pool"
ADD CONSTRAINT "invoice_pool_status_invariants_chk"
CHECK (
(
"status" IN ('pending_ocr', 'ocr_failed', 'available')
AND "linked_reimbursement_id" IS NULL
AND "linked_item_id" IS NULL
AND "travel_order_id" IS NULL
AND "archived_reason" IS NULL
AND "archived_at" IS NULL
)
OR (
"status" = 'bound'
AND "archived_reason" IS NULL
AND "archived_at" IS NULL
AND (
(
"linked_reimbursement_id" IS NOT NULL
AND "travel_order_id" IS NULL
)
OR (
"linked_reimbursement_id" IS NULL
AND "travel_order_id" IS NOT NULL
)
)
)
OR (
"status" = 'archived'
AND "archived_reason" IS NOT NULL
AND "archived_at" IS NOT NULL
AND (
(
"archived_reason" = 'used_complete'
AND (
(
"linked_reimbursement_id" IS NOT NULL
AND "travel_order_id" IS NULL
)
OR (
"linked_reimbursement_id" IS NULL
AND "travel_order_id" IS NOT NULL
)
)
)
OR (
"archived_reason" IN ('unused_timeout', 'manual')
AND "linked_reimbursement_id" IS NULL
AND "travel_order_id" IS NULL
)
)
)
);
文件差异内容过多而无法显示 加载差异
+43
查看文件
@@ -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"
}
+3 -6
查看文件
@@ -23,12 +23,9 @@ fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
# shellcheck source=scripts/lib/load-env-defaults.sh
source "$ROOT_DIR/scripts/lib/load-env-defaults.sh"
load_env_defaults ".env"
if [[ -z "${DATABASE_URL:-}" ]]; then
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
+3 -6
查看文件
@@ -29,12 +29,9 @@ fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
# shellcheck source=scripts/lib/load-env-defaults.sh
source "$ROOT_DIR/scripts/lib/load-env-defaults.sh"
load_env_defaults ".env"
if [[ -z "${DATABASE_URL:-}" ]]; then
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
+3 -6
查看文件
@@ -26,12 +26,9 @@ fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
# shellcheck source=scripts/lib/load-env-defaults.sh
source "$ROOT_DIR/scripts/lib/load-env-defaults.sh"
load_env_defaults ".env"
if [[ -z "${DATABASE_URL:-}" ]]; then
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
+6 -10
查看文件
@@ -27,22 +27,18 @@ fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
# shellcheck source=scripts/lib/load-env-defaults.sh
source "$ROOT_DIR/scripts/lib/load-env-defaults.sh"
load_env_defaults ".env"
if [[ -z "${DATABASE_URL:-}" ]]; then
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
exit 1
fi
if [[ -z "${FIELD_ENCRYPTION_KEY:-}" ]]; then
echo "[ERROR] FIELD_ENCRYPTION_KEY is required for IM secret regression" >&2
exit 1
fi
TEST_FIELD_ENCRYPTION_KEY_DEFAULT="MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
FIELD_ENCRYPTION_KEY="${FIELD_ENCRYPTION_KEY:-$TEST_FIELD_ENCRYPTION_KEY_DEFAULT}"
export FIELD_ENCRYPTION_KEY
TMP_DIR="$(mktemp -d)"
cleanup() {
+3 -6
查看文件
@@ -36,12 +36,9 @@ fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
# shellcheck source=scripts/lib/load-env-defaults.sh
source "$ROOT_DIR/scripts/lib/load-env-defaults.sh"
load_env_defaults ".env"
if [[ -z "${DATABASE_URL:-}" ]]; then
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
+3 -6
查看文件
@@ -9,12 +9,9 @@ if ! command -v node >/dev/null 2>&1; then
exit 1
fi
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
# shellcheck source=scripts/lib/load-env-defaults.sh
source "$ROOT_DIR/scripts/lib/load-env-defaults.sh"
load_env_defaults ".env"
if [[ -z "${DATABASE_URL:-}" ]]; then
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
+3 -6
查看文件
@@ -23,12 +23,9 @@ fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
# shellcheck source=scripts/lib/load-env-defaults.sh
source "$ROOT_DIR/scripts/lib/load-env-defaults.sh"
load_env_defaults ".env"
if [[ -z "${DATABASE_URL:-}" ]]; then
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
+3 -6
查看文件
@@ -19,12 +19,9 @@ fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
# shellcheck source=scripts/lib/load-env-defaults.sh
source "$ROOT_DIR/scripts/lib/load-env-defaults.sh"
load_env_defaults ".env"
if [[ -z "${DATABASE_URL:-}" ]]; then
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
@@ -32,12 +32,9 @@ fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
# shellcheck source=scripts/lib/load-env-defaults.sh
source "$ROOT_DIR/scripts/lib/load-env-defaults.sh"
load_env_defaults ".env"
if [[ -z "${DATABASE_URL:-}" ]]; then
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
+20
查看文件
@@ -38,6 +38,16 @@ const notificationsRoutes = require("./routes/notifications");
const exportsRoutes = require("./routes/exports");
const dashboardRoutes = require("./routes/dashboard");
const budgetsRoutes = require("./routes/budgets");
const budgetControlRulesRoutes = require("./routes/budgetControlRules");
const riskFlagsRoutes = require("./routes/riskFlags");
const paymentBatchesRoutes = require("./routes/paymentBatches");
const apOperationsRoutes = require("./routes/apOperations");
const portalRoutes = require("./routes/portal");
const searchRoutes = require("./routes/search");
const announcementsRoutes = require("./routes/announcements");
const workflowDefinitionsRoutes = require("./routes/workflowDefinitions");
const migrationRunsRoutes = require("./routes/migrationRuns");
const cutoverRoutes = require("./routes/cutover");
const purchaseRequestRoutes = require("./routes/purchaseRequests");
const assetsRoutes = require("./routes/assets");
const assetMovementsRoutes = require("./routes/assetMovements");
@@ -304,6 +314,16 @@ async function buildApp() {
await app.register(notificationsRoutes, { prefix: "/api" });
await app.register(dashboardRoutes, { prefix: "/api" });
await app.register(budgetsRoutes, { prefix: "/api" });
await app.register(budgetControlRulesRoutes, { prefix: "/api" });
await app.register(riskFlagsRoutes, { prefix: "/api" });
await app.register(paymentBatchesRoutes, { prefix: "/api" });
await app.register(apOperationsRoutes, { prefix: "/api" });
await app.register(portalRoutes, { prefix: "/api" });
await app.register(searchRoutes, { prefix: "/api" });
await app.register(announcementsRoutes, { prefix: "/api" });
await app.register(workflowDefinitionsRoutes, { prefix: "/api" });
await app.register(migrationRunsRoutes, { prefix: "/api" });
await app.register(cutoverRoutes, { prefix: "/api" });
await app.register(purchaseRequestRoutes, { prefix: "/api" });
await app.register(assetsRoutes, { prefix: "/api" });
await app.register(assetMovementsRoutes, { prefix: "/api" });
+8
查看文件
@@ -85,6 +85,7 @@ function getConfig() {
pgBossSchema: process.env.PG_BOSS_SCHEMA ?? "pgboss",
ocrProvider: process.env.OCR_PROVIDER ?? "off",
ocrCronEnabled: process.env.OCR_CRON_ENABLED !== "false",
ocrBatchSize: Number(process.env.OCR_BATCH_SIZE ?? 10),
ocrLockTtlSeconds: Number(process.env.OCR_LOCK_TTL_SECONDS ?? 300),
ocrPollCron: process.env.OCR_POLL_CRON ?? "* * * * *",
@@ -103,6 +104,13 @@ function getConfig() {
verificationTimeoutMs: Number(process.env.VERIFICATION_TIMEOUT_MS ?? 5000),
budgetControlMode: process.env.BUDGET_CONTROL_MODE ?? "warn",
budgetPrecheckEnabled: process.env.BUDGET_PRECHECK_ENABLED === "true",
paymentBatchEnabled: process.env.PAYMENT_BATCH_ENABLED === "true",
portalHomeEnabled: process.env.PORTAL_HOME_ENABLED === "true",
globalSearchEnabled: process.env.GLOBAL_SEARCH_ENABLED === "true",
announcementsEnabled: process.env.ANNOUNCEMENTS_ENABLED === "true",
workflowDefinitionAdminEnabled: process.env.WORKFLOW_DEFINITION_ADMIN_ENABLED === "true",
cutoverConsoleEnabled: process.env.CUTOVER_CONSOLE_ENABLED === "true",
aiAssistEnabled: process.env.AI_ASSIST_ENABLED === "true",
aiProvider: process.env.AI_PROVIDER ?? "mock",
+4
查看文件
@@ -152,6 +152,7 @@ const ROLE_PERMISSIONS = {
"budget.view",
"expense.approve",
"expense.finance.pay",
"payment.batch.manage",
"expense.view_all",
"procurement.view_all",
"travel.view_all",
@@ -171,6 +172,7 @@ const ROLE_PERMISSIONS = {
"ai.assist",
"expense.approve",
"expense.finance.pay",
"payment.batch.manage",
"expense.view_all",
"expense.finance.override_duplicate",
"procurement.view_all",
@@ -183,6 +185,8 @@ const ROLE_PERMISSIONS = {
"asset.inventory",
"system.settings",
"system.audit.view",
"payment.batch.manage",
"risk.review.manage",
],
administration: [
"expense.proxy_submit",
+5
查看文件
@@ -291,6 +291,11 @@ function startOcrJob(fastify) {
return null;
}
if (!fastify.config.ocrCronEnabled) {
fastify.log.info({ provider: fastify.config.ocrProvider }, "OCR cron disabled");
return null;
}
let running = false;
const workerId = `ocr-worker:${process.pid}`;
+127
查看文件
@@ -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;
+125
查看文件
@@ -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;
+54
查看文件
@@ -497,6 +497,60 @@ async function authRoutes(fastify) {
return sendData(reply, request, { accessToken });
});
fastify.get("/sso/providers", async (request, reply) => {
if (!fastify.config.oidcEnabled) {
return reply.send({ requestId: request.id, data: [] });
}
return reply.send({
requestId: request.id,
data: [
{
id: "oidc",
label: process.env.OIDC_PROVIDER_LABEL || "企业统一登录",
enabled: true,
loginUrl: "/api/auth/oidc/login",
},
],
});
});
fastify.get("/deeplink", async (request, reply) => {
const token = String(request.query?.token ?? "").trim();
const verified = verifyToken(token, fastify.config.jwtSecret);
if (!verified.ok) {
throw new AppError({
code: "AUTH_001",
message: "无效深链",
statusCode: 401,
details: { token: verified.reason },
});
}
const payload = verified.payload && typeof verified.payload === "object" ? verified.payload : {};
const targetPath = typeof payload.targetPath === "string" ? payload.targetPath.trim() : "";
const exp = Number(payload.exp ?? 0);
if (!targetPath || !targetPath.startsWith("/")) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { targetPath: "invalid_target_path" },
});
}
if (!Number.isFinite(exp) || Date.now() / 1000 > exp) {
throw new AppError({
code: "AUTH_001",
message: "深链已过期",
statusCode: 401,
details: { token: "expired" },
});
}
const origin = String(fastify.config.appOrigin || "").split(",")[0].trim().replace(/\/+$/, "");
return reply.redirect(origin ? `${origin}${targetPath}` : targetPath);
});
fastify.get("/oidc/login", async (request, reply) => {
if (!fastify.config.oidcEnabled) {
throw new AppError({
+227
查看文件
@@ -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;
+80
查看文件
@@ -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;
+22
查看文件
@@ -4,6 +4,7 @@ const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { resolveActionableStep } = require("../utils/workflow");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureCutoverAllowed } = require("../services/cutover");
const {
DEFAULT_ATTACHMENT_MIME_TYPES,
calculateRequestedDays,
@@ -476,6 +477,13 @@ async function leaveRoutes(fastify) {
preHandler: fastify.requireEntityPermission("leave", "create"),
},
async (request, reply) => {
await ensureCutoverAllowed({
prisma: fastify.prisma,
config: fastify.config,
request,
domain: "leave",
departmentId: request.employee.department_id ?? null,
});
const { body, filePart } = await collectCreatePayload(request);
const leaveType = await loadLeaveTypeOrThrow(fastify.prisma, body.leaveTypeId);
const attachment = await assertLeaveTypeAttachment(
@@ -648,6 +656,13 @@ async function leaveRoutes(fastify) {
}
const leave = leaveRows[0];
await ensureCutoverAllowed({
prisma: tx,
config: fastify.config,
request,
domain: "leave",
departmentId: leave.department_id ?? request.employee.department_id ?? null,
});
if (leave.owner_id !== request.employee.id) {
throw new AppError({
code: "PERM_001",
@@ -877,6 +892,13 @@ async function leaveRoutes(fastify) {
}
const leave = leaveRows[0];
await ensureCutoverAllowed({
prisma: tx,
config: fastify.config,
request,
domain: "leave",
departmentId: leave.department_id ?? request.employee.department_id ?? null,
});
if (leave.owner_id !== request.employee.id) {
throw new AppError({
code: "PERM_001",
+86
查看文件
@@ -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;
+747
查看文件
@@ -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;
+115
查看文件
@@ -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;
+124 -4
查看文件
@@ -12,6 +12,12 @@ const { parseWorkflowSteps, resolveActionableStep, toMoneyString } = require("..
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
const { issueDocumentNumber } = require("../services/documentSequence");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureCutoverAllowed } = require("../services/cutover");
const {
buildPersistedRiskFlag,
buildPurchaseRequestPrecheck,
createRiskFlagsFromPrecheck,
} = require("../services/feeControl");
const {
PROCUREMENT_ERP_TARGETS,
enqueuePurchaseAccrualJournalExport,
@@ -306,6 +312,31 @@ function normalizeBudgetControlMode(config) {
return String(config.budgetControlMode ?? "warn").toLowerCase() === "block" ? "block" : "warn";
}
function buildDisabledPrecheck(entityType, entityId) {
return {
entityType,
entityId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["功能未开启,沿用当前提交流程"],
};
}
function buildPrecheckBlockedError(precheck) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
precheck: precheck.blockingReasons,
},
});
}
function ensurePurchaseReadPermission(fastify, request) {
const canViewOwn = fastify.hasPermission(request.employee, "procurement.view_own");
const canViewAll =
@@ -1010,6 +1041,13 @@ async function purchaseRequestRoutes(fastify) {
preHandler: fastify.requirePermission("procurement.create"),
},
async (request, reply) => {
await ensureCutoverAllowed({
prisma: fastify.prisma,
config: fastify.config,
request,
domain: "procurement",
departmentId: request.employee.department_id ?? null,
});
const purchaseRequest = await fastify.prisma.$transaction(async (tx) => {
const created = await tx.purchaseRequest.create({
data: {
@@ -1078,7 +1116,19 @@ async function purchaseRequestRoutes(fastify) {
});
}
return sendData(reply, request, mapPurchaseRequestDetail(purchaseRequest, request));
const riskFlags = await fastify.prisma.entityRiskFlag.findMany({
where: {
entity_type: "purchase_request",
entity_id: purchaseRequestId,
status: "open",
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
return sendData(reply, request, {
...mapPurchaseRequestDetail(purchaseRequest, request),
riskFlags: riskFlags.map(buildPersistedRiskFlag),
});
},
);
@@ -1178,6 +1228,41 @@ async function purchaseRequestRoutes(fastify) {
},
);
fastify.post(
"/purchase-requests/:id/precheck",
{
preHandler: fastify.requirePermission("procurement.submit"),
},
async (request, reply) => {
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: { id: true, department_id: true },
});
if (!purchaseRequest) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: purchaseRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
return sendData(reply, request, buildDisabledPrecheck("purchase_request", purchaseRequestId));
}
const precheck = await buildPurchaseRequestPrecheck(fastify.prisma, {
purchaseRequestId,
config: fastify.config,
logger: fastify.log,
});
return sendData(reply, request, precheck);
},
);
fastify.post(
"/purchase-requests/:id/submit",
{
@@ -1197,6 +1282,15 @@ async function purchaseRequestRoutes(fastify) {
throw buildValidationError({ status: "only_draft_submittable" }, 409);
}
await ensureCutoverAllowed({
prisma: tx,
config: fastify.config,
request,
domain: "procurement",
departmentId: locked.department_id ?? request.employee.department_id ?? null,
legalEntityId: locked.legal_entity_id ?? null,
});
const purchaseRequest = await tx.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: {
@@ -1244,6 +1338,30 @@ async function purchaseRequestRoutes(fastify) {
const documentCategory = await ensureDocumentCategoryAvailable(tx, purchaseRequest.document_category_id);
const totalAmount = await recomputePurchaseRequestTotal(tx, purchaseRequestId, request.employee.id);
const budgetPrecheckGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: purchaseRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
const authoritativePrecheck = budgetPrecheckGate.enabled
? await buildPurchaseRequestPrecheck(tx, {
purchaseRequestId,
config: fastify.config,
logger: fastify.log,
})
: buildDisabledPrecheck("purchase_request", purchaseRequestId);
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, {
entityType: "purchase_request",
entityId: purchaseRequestId,
actorId: request.employee.id,
precheck: authoritativePrecheck,
});
const effectiveDate = new Date();
let resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, {
departmentId: purchaseRequest.department_id,
@@ -1263,12 +1381,14 @@ async function purchaseRequestRoutes(fastify) {
}
const warnings = [];
const budgetControlMode = normalizeBudgetControlMode(fastify.config);
const budgetControlMode = authoritativePrecheck?.budgetHint?.controlMode ?? normalizeBudgetControlMode(fastify.config);
if (!selectedBudgetPoolId) {
if (budgetControlMode === "block") {
if (!budgetPrecheckGate.enabled && budgetControlMode === "block") {
throw buildValidationError({ budget: "missing_budget_pool" }, 409);
}
warnings.push("missing_budget_pool");
if (!budgetPrecheckGate.enabled) {
warnings.push("missing_budget_pool");
}
}
const workflowDefinition = await tx.workflowDefinition.findFirst({
+123
查看文件
@@ -13,7 +13,13 @@ const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { sendNotificationEmailSafe } = require("../services/email");
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureCutoverAllowed } = require("../services/cutover");
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
const {
buildPersistedRiskFlag,
buildReimbursementPrecheck,
createRiskFlagsFromPrecheck,
} = require("../services/feeControl");
const { issueDocumentNumber } = require("../services/documentSequence");
const {
buildWorkflowStepsFromApprovalRule,
@@ -385,6 +391,31 @@ function buildBudgetHint(budgetPool, reimbursementAmount, controlMode) {
};
}
function buildDisabledPrecheck(entityType, entityId) {
return {
entityType,
entityId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["功能未开启,沿用当前提交流程"],
};
}
function buildPrecheckBlockedError(precheck) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
precheck: precheck.blockingReasons,
},
});
}
async function reimbursementRoutes(fastify) {
const createBodySchema = z.object({
ownerEmployeeId: z.string().uuid().optional(),
@@ -399,6 +430,13 @@ async function reimbursementRoutes(fastify) {
preHandler: fastify.requirePermission("expense.create"),
},
async (request, reply) => {
await ensureCutoverAllowed({
prisma: fastify.prisma,
config: fastify.config,
request,
domain: "expense",
departmentId: request.employee.department_id ?? null,
});
const parsed = createBodySchema.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({
@@ -688,6 +726,14 @@ async function reimbursementRoutes(fastify) {
const pendingInstance = reimbursement.workflow_instances[0] ?? null;
const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null;
const riskFlags = await fastify.prisma.entityRiskFlag.findMany({
where: {
entity_type: "reimbursement",
entity_id: reimbursementId,
status: "open",
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
return sendData(reply, request, {
id: reimbursement.id,
@@ -732,6 +778,7 @@ async function reimbursementRoutes(fastify) {
task: pendingTask,
}
: null,
riskFlags: riskFlags.map(buildPersistedRiskFlag),
});
},
);
@@ -763,6 +810,13 @@ async function reimbursementRoutes(fastify) {
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
await ensureCutoverAllowed({
prisma: tx,
config: fastify.config,
request,
domain: "expense",
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
});
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
@@ -1183,6 +1237,42 @@ async function reimbursementRoutes(fastify) {
},
);
fastify.post(
"/reimbursements/:id/precheck",
{
preHandler: fastify.requirePermission("expense.submit"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: { id: true, department_id: true },
});
if (!reimbursement) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
return sendData(reply, request, buildDisabledPrecheck("reimbursement", reimbursementId));
}
const precheck = await buildReimbursementPrecheck(fastify.prisma, {
reimbursementId,
config: fastify.config,
logger: fastify.log,
});
return sendData(reply, request, precheck);
},
);
fastify.post(
"/reimbursements/:id/submit",
{
@@ -1256,6 +1346,30 @@ async function reimbursementRoutes(fastify) {
});
}
const budgetPrecheckGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
const authoritativePrecheck = budgetPrecheckGate.enabled
? await buildReimbursementPrecheck(tx, {
reimbursementId,
config: fastify.config,
logger: fastify.log,
})
: buildDisabledPrecheck("reimbursement", reimbursementId);
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, {
entityType: "reimbursement",
entityId: reimbursementId,
actorId: request.employee.id,
precheck: authoritativePrecheck,
});
const budgetPool = await resolveApplicableBudgetPool(tx, reimbursement);
if (budgetPool) {
const allocatedAmount = Number(budgetPool.allocated_amount ?? 0);
@@ -1518,6 +1632,8 @@ async function reimbursementRoutes(fastify) {
currentTaskId: workflowTask.id,
status: "reviewing",
budget: budgetCheck,
precheckDecision: authoritativePrecheck.decision,
openedRiskFlagCount: openedRiskFlags.length,
};
});
@@ -1700,6 +1816,13 @@ async function reimbursementRoutes(fastify) {
}
const reimbursement = reimbursementRows[0];
await ensureCutoverAllowed({
prisma: tx,
config: fastify.config,
request,
domain: "expense",
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
});
if (!["approved", "paid"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
+117
查看文件
@@ -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;
+225
查看文件
@@ -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;
+119 -1
查看文件
@@ -12,6 +12,13 @@ const { sendNotificationEmailSafe } = require("../services/email");
const { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications");
const { enqueueImNotifications } = require("../services/notifications/imEnqueue");
const { buildTravelPaymentTrack } = require("../services/travelPayments");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureCutoverAllowed } = require("../services/cutover");
const {
buildPersistedRiskFlag,
buildTravelRequestPrecheck,
createRiskFlagsFromPrecheck,
} = require("../services/feeControl");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const UUID_SCHEMA = z.string().uuid();
@@ -281,6 +288,29 @@ function ensureDateRangeValid(startDate, endDate) {
}
}
function buildDisabledPrecheck(entityType, entityId) {
return {
entityType,
entityId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["功能未开启,沿用当前提交流程"],
};
}
function buildPrecheckBlockedError(precheck) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { precheck: precheck.blockingReasons },
});
}
async function ensureFinancialDimensions(tx, { legalEntityId, costCenterId }) {
const [legalEntity, costCenter] = await Promise.all([
tx.legalEntity.findUnique({
@@ -641,6 +671,14 @@ async function createTravelRequestsRoutes(fastify) {
},
async (request, reply) => {
const body = parseBody(TRAVEL_CREATE_BODY_SCHEMA, request.body);
await ensureCutoverAllowed({
prisma: fastify.prisma,
config: fastify.config,
request,
domain: "travel",
departmentId: request.employee.department_id ?? null,
legalEntityId: body.legalEntityId,
});
const startDate = parseDateOnlyString(body.startDate);
const endDate = parseDateOnlyString(body.endDate);
ensureDateRangeValid(startDate, endDate);
@@ -719,7 +757,18 @@ async function createTravelRequestsRoutes(fastify) {
ensureTravelReadPermission(fastify, request);
const travelRequestId = parseTravelRequestId(request.params?.id);
const detail = await loadReadableTravelRequest(fastify.prisma, travelRequestId, request);
return sendData(reply, request, mapTravelRequestDetail(detail, request));
const riskFlags = await fastify.prisma.entityRiskFlag.findMany({
where: {
entity_type: "travel_request",
entity_id: travelRequestId,
status: "open",
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
return sendData(reply, request, {
...mapTravelRequestDetail(detail, request),
riskFlags: riskFlags.map(buildPersistedRiskFlag),
});
},
);
@@ -836,6 +885,41 @@ async function createTravelRequestsRoutes(fastify) {
},
);
fastify.post(
"/travel-requests/:id/precheck",
{
preHandler: fastify.requirePermission("travel.submit"),
},
async (request, reply) => {
const travelRequestId = parseTravelRequestId(request.params?.id);
const travelRequest = await fastify.prisma.travelRequest.findUnique({
where: { id: travelRequestId },
select: { id: true, department_id: true },
});
if (!travelRequest) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: travelRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
return sendData(reply, request, buildDisabledPrecheck("travel_request", travelRequestId));
}
const precheck = await buildTravelRequestPrecheck(fastify.prisma, {
travelRequestId,
config: fastify.config,
logger: fastify.log,
});
return sendData(reply, request, precheck);
},
);
fastify.post(
"/travel-requests/:id/submit",
{
@@ -849,6 +933,14 @@ async function createTravelRequestsRoutes(fastify) {
await fastify.prisma.$transaction(async (tx) => {
const travelRequest = await loadTravelRequestForUpdate(tx, travelRequestId);
ensureDraftOwner(request, travelRequest);
await ensureCutoverAllowed({
prisma: tx,
config: fastify.config,
request,
domain: "travel",
departmentId: travelRequest.department_id ?? request.employee.department_id ?? null,
legalEntityId: travelRequest.legal_entity_id ?? null,
});
if (travelRequest.status !== "draft") {
throw buildValidationError({ status: "only_draft_submittable" }, 409);
}
@@ -856,6 +948,30 @@ async function createTravelRequestsRoutes(fastify) {
throw buildValidationError({ segments: "required" }, 409);
}
const budgetPrecheckGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: travelRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
const authoritativePrecheck = budgetPrecheckGate.enabled
? await buildTravelRequestPrecheck(tx, {
travelRequestId,
config: fastify.config,
logger: fastify.log,
})
: buildDisabledPrecheck("travel_request", travelRequestId);
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, {
entityType: "travel_request",
entityId: travelRequestId,
actorId: request.employee.id,
precheck: authoritativePrecheck,
});
const workflowDefinition = await tx.workflowDefinition.findFirst({
where: { entity_type: "travel_request", is_active: true },
orderBy: { version: "desc" },
@@ -1050,6 +1166,8 @@ async function createTravelRequestsRoutes(fastify) {
currentTaskId: workflowTask.id,
budgetPoolId: selectedBudgetPoolId,
budgetReservation,
precheckDecision: authoritativePrecheck.decision,
openedRiskFlagCount: openedRiskFlags.length,
};
});
+194
查看文件
@@ -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;
+110
查看文件
@@ -233,6 +233,70 @@ const DEFAULT_FEATURE_FLAGS = [
department_ids: [],
note: "bootstrap default: force_off",
},
{
flag_key: "budget_precheck_v1",
override_mode: "force_off",
rollout_type: "global",
department_ids: [],
note: "bootstrap default: force_off",
},
{
flag_key: "payment_batch_v1",
override_mode: "force_off",
rollout_type: "global",
department_ids: [],
note: "bootstrap default: force_off",
},
{
flag_key: "portal_home_v1",
override_mode: "force_off",
rollout_type: "global",
department_ids: [],
note: "bootstrap default: force_off",
},
{
flag_key: "global_search_v1",
override_mode: "force_off",
rollout_type: "global",
department_ids: [],
note: "bootstrap default: force_off",
},
{
flag_key: "announcements_v1",
override_mode: "force_off",
rollout_type: "global",
department_ids: [],
note: "bootstrap default: force_off",
},
{
flag_key: "workflow_definition_admin_v1",
override_mode: "force_off",
rollout_type: "global",
department_ids: [],
note: "bootstrap default: force_off",
},
{
flag_key: "cutover_console_v1",
override_mode: "force_off",
rollout_type: "global",
department_ids: [],
note: "bootstrap default: force_off",
},
];
const DEFAULT_BUDGET_CONTROL_RULES = [
{
name: "默认全局预算预警",
entity_type: "all",
department_id: null,
legal_entity_id: null,
cost_center_id: null,
expense_type_id: null,
control_mode: "warn",
priority: 100,
enabled: true,
notes: "bootstrap default global warn rule",
},
];
const DEFAULT_LEAVE_TYPES = [
@@ -411,6 +475,51 @@ async function seedFeatureFlags(prisma) {
}
}
async function seedBudgetControlRules(prisma) {
const now = new Date();
for (const item of DEFAULT_BUDGET_CONTROL_RULES) {
const existing = await prisma.budgetControlRule.findFirst({
where: {
name: item.name,
entity_type: item.entity_type,
archived_at: null,
},
select: { id: true },
});
if (existing) {
await prisma.budgetControlRule.update({
where: { id: existing.id },
data: {
department_id: item.department_id,
legal_entity_id: item.legal_entity_id,
cost_center_id: item.cost_center_id,
expense_type_id: item.expense_type_id,
control_mode: item.control_mode,
priority: item.priority,
enabled: item.enabled,
notes: item.notes,
archived_at: null,
updated_at: now,
updated_by: null,
},
});
continue;
}
await prisma.budgetControlRule.create({
data: {
...item,
created_at: now,
created_by: null,
updated_at: now,
updated_by: null,
},
});
}
}
async function seedLeaveTypes(prisma) {
const now = new Date();
@@ -608,6 +717,7 @@ async function main() {
await seedWorkflowDefinition(prisma);
await seedLeaveTypes(prisma);
await seedFeatureFlags(prisma);
await seedBudgetControlRules(prisma);
// eslint-disable-next-line no-console
console.log("Seeded config data:");
+68 -2
查看文件
@@ -109,6 +109,46 @@ async function ensureCostCenter(prisma, payload) {
});
}
async function ensureBudgetPool(prisma, payload) {
const now = new Date();
const existing = await prisma.budgetPool.findUnique({
where: { code: payload.code },
select: { id: true },
});
const data = {
name: payload.name,
department_id: payload.departmentId ?? null,
legal_entity_id: payload.legalEntityId ?? null,
cost_center_id: payload.costCenterId ?? null,
status: "active",
period_start: payload.periodStart,
period_end: payload.periodEnd,
allocated_amount: payload.allocatedAmount,
warning_threshold_percent: payload.warningThresholdPercent ?? 80,
archived_at: null,
updated_at: now,
updated_by: null,
};
if (existing) {
return prisma.budgetPool.update({
where: { id: existing.id },
data,
});
}
return prisma.budgetPool.create({
data: {
code: payload.code,
used_amount: payload.usedAmount ?? "0.00",
created_at: now,
created_by: null,
...data,
},
});
}
async function ensureSupplier(prisma, payload) {
const now = new Date();
const existing = await prisma.supplier.findUnique({
@@ -303,6 +343,9 @@ async function main() {
await prisma.$connect();
const hireDate = new Date("2020-01-01");
const currentYear = new Date().getUTCFullYear();
const fiscalPeriodStart = new Date(`${currentYear}-01-01T00:00:00.000Z`);
const fiscalPeriodEnd = new Date(`${currentYear}-12-31T00:00:00.000Z`);
try {
const rootDepartment = await ensureDepartment(prisma, { name: "公司" });
@@ -460,7 +503,7 @@ async function main() {
],
});
await ensureCostCenter(prisma, {
const managementCostCenter = await ensureCostCenter(prisma, {
legalEntityId: legalEntity.id,
code: "MGMT",
name: "管理中心",
@@ -478,7 +521,7 @@ async function main() {
name: "人力中心",
ownerEmployeeId: hr.id,
});
await ensureCostCenter(prisma, {
const rdCostCenter = await ensureCostCenter(prisma, {
legalEntityId: legalEntity.id,
code: "RD",
name: "研发中心",
@@ -496,6 +539,27 @@ async function main() {
legalEntityId: legalEntity.id,
});
const managementBudgetPool = await ensureBudgetPool(prisma, {
code: `DEV_BUDGET_MGMT_${currentYear}`,
name: `${currentYear} 管理中心预算池`,
legalEntityId: legalEntity.id,
costCenterId: managementCostCenter.id,
periodStart: fiscalPeriodStart,
periodEnd: fiscalPeriodEnd,
allocatedAmount: "500000.00",
warningThresholdPercent: 80,
});
const rdBudgetPool = await ensureBudgetPool(prisma, {
code: `DEV_BUDGET_RD_${currentYear}`,
name: `${currentYear} 研发部预算池`,
departmentId: rdDepartment.id,
periodStart: fiscalPeriodStart,
periodEnd: fiscalPeriodEnd,
allocatedAmount: "800000.00",
warningThresholdPercent: 75,
});
// eslint-disable-next-line no-console
console.log("Seeded dev users:");
// eslint-disable-next-line no-console
@@ -520,6 +584,8 @@ async function main() {
console.log(`- HolidayCalendar: ${holidayCalendar.code} / ${holidayCalendar.name}`);
// eslint-disable-next-line no-console
console.log(`- Supplier: ${supplier.code} / ${supplier.name}`);
// eslint-disable-next-line no-console
console.log(`- Budget Pools: ${managementBudgetPool.code}, ${rdBudgetPool.code}`);
} finally {
await prisma.$disconnect();
await pool.end();
+79
查看文件
@@ -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,
};
+35
查看文件
@@ -3,6 +3,13 @@ const FEATURE_FLAG_KEYS = {
erpSync: "erp_sync",
invoiceVerification: "invoice_verification",
leaveHolidayCalendar: "leave_holiday_calendar",
budgetPrecheck: "budget_precheck_v1",
paymentBatch: "payment_batch_v1",
portalHome: "portal_home_v1",
globalSearch: "global_search_v1",
announcements: "announcements_v1",
workflowDefinitionAdmin: "workflow_definition_admin_v1",
cutoverConsole: "cutover_console_v1",
};
const FEATURE_FLAG_KEY_LIST = Object.freeze(Object.values(FEATURE_FLAG_KEYS));
@@ -72,6 +79,34 @@ function resolveEnvGate(config, flagKey) {
return Boolean(config.leaveHolidayCalendarEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.budgetPrecheck) {
return Boolean(config.budgetPrecheckEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.paymentBatch) {
return Boolean(config.paymentBatchEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.portalHome) {
return Boolean(config.portalHomeEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.globalSearch) {
return Boolean(config.globalSearchEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.announcements) {
return Boolean(config.announcementsEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.workflowDefinitionAdmin) {
return Boolean(config.workflowDefinitionAdminEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.cutoverConsole) {
return Boolean(config.cutoverConsoleEnabled);
}
return false;
}
+741
查看文件
@@ -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,
};
+11 -1
查看文件
@@ -7,6 +7,7 @@ const {
matchesNotificationChannel,
normalizeChannelType,
} = require('./imRouting');
const { signPayload } = require('../../utils/signedUrl');
function uniq(values) {
return Array.from(new Set((values ?? []).filter(Boolean)));
@@ -32,7 +33,16 @@ function buildDetailUrl(config, targetPath) {
return path;
}
return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const token = signPayload(
{
targetPath: normalizedPath,
exp: Math.floor(Date.now() / 1000) + 15 * 60,
},
config.jwtSecret,
);
return `${baseUrl}/api/auth/deeplink?token=${encodeURIComponent(token)}`;
}
async function resolveRecipientRoleCodes(tx, recipientEmployeeIds) {
@@ -212,13 +212,8 @@ describe("listFeatureFlags", () => {
invoiceVerificationEnabled: true,
});
expect(items).toHaveLength(4);
expect(items.map((item) => item.flagKey).sort()).toEqual([
FEATURE_FLAG_KEYS.aiAssist,
FEATURE_FLAG_KEYS.erpSync,
FEATURE_FLAG_KEYS.invoiceVerification,
FEATURE_FLAG_KEYS.leaveHolidayCalendar,
]);
expect(items).toHaveLength(Object.keys(FEATURE_FLAG_KEYS).length);
expect(items.map((item) => item.flagKey).sort()).toEqual(Object.values(FEATURE_FLAG_KEYS).sort());
const missingItem = items.find((item) => item.flagKey === FEATURE_FLAG_KEYS.aiAssist);
expect(missingItem.id).toBeNull();
@@ -69,6 +69,9 @@ function buildPrisma({ items = [], detail = null } = {}) {
count: vi.fn().mockResolvedValue(items.length),
findUnique: vi.fn().mockResolvedValue(detail),
},
entityRiskFlag: {
findMany: vi.fn().mockResolvedValue([]),
},
};
}
+6 -4
查看文件
@@ -7,8 +7,8 @@ const {
describe("IM enqueue", () => {
it("builds detail url from APP_ORIGIN", () => {
expect(buildDetailUrl({ appOrigin: "https://app.example.com" }, "/workflow/tasks/1")).toBe(
"https://app.example.com/workflow/tasks/1",
expect(buildDetailUrl({ appOrigin: "https://app.example.com", jwtSecret: "test-secret" }, "/workflow/tasks/1")).toContain(
"https://app.example.com/api/auth/deeplink?token=",
);
expect(buildDetailUrl({ appOrigin: "" }, "/workflow/tasks/1")).toBeNull();
});
@@ -93,6 +93,7 @@ describe("IM enqueue", () => {
imNotifierEnabled: true,
integrationOutboxMaxAttempts: 5,
appOrigin: "https://app.example.com",
jwtSecret: "test-secret",
},
logger: { warn: vi.fn() },
recipientEmployeeIds: ["employee-1"],
@@ -111,8 +112,8 @@ describe("IM enqueue", () => {
expect(result.duplicated).toBe(1);
expect(result.matchedChannels).toEqual(["channel-1", "channel-2"]);
expect(tx.integrationOutbox.create).toHaveBeenCalledTimes(1);
expect(tx.integrationOutbox.create.mock.calls[0][0].data.payload.detailUrl).toBe(
"https://app.example.com/workflow/tasks/task-1",
expect(tx.integrationOutbox.create.mock.calls[0][0].data.payload.detailUrl).toEqual(
expect.stringContaining("https://app.example.com/api/auth/deeplink?token="),
);
});
@@ -150,6 +151,7 @@ describe("IM enqueue", () => {
imNotifierEnabled: true,
integrationOutboxMaxAttempts: 5,
appOrigin: "https://app.example.com",
jwtSecret: "test-secret",
},
logger: { warn: vi.fn() },
recipientEmployeeIds: ["employee-1"],