From 35948729cf703a90403ee18c6753ed46900a5a25 Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 28 Jul 2026 13:42:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3CEO=E6=8A=A5=E9=94=80?= =?UTF-8?q?=E8=B4=A2=E5=8A=A1=E5=85=9C=E5=BA=95=E4=B8=8E=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E6=8A=A4=E6=A0=8F=EF=BC=8807-28-ceo-finance-fallback=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/deployment-trialrun.md | 53 ++- .../07-28-ceo-finance-fallback/check.jsonl | 1 + .../07-28-ceo-finance-fallback/design.md | 63 ++++ .../implement.jsonl | 1 + .../07-28-ceo-finance-fallback/implement.md | 45 +++ .../tasks/07-28-ceo-finance-fallback/prd.md | 69 ++++ .../07-28-ceo-finance-fallback/task.json | 26 ++ client/src/api/approvalRules.ts | 2 +- client/src/pages/ApprovalRulesPage.tsx | 16 +- .../e2e/pc-finance-admin-visual-wave4.spec.ts | 15 +- deploy/trialrun/checklist.md | 2 +- deploy/trialrun/deploy.sh | 33 +- deploy/trialrun/runbook.md | 4 +- deploy/trialrun/validate-env-contract.js | 10 +- docs/trialrun/真机公网测试清单.md | 4 +- docs/产品口径.md | 16 +- docs/文档事实源状态矩阵.md | 2 +- docs/文档索引.md | 5 +- docs/管理员手册.md | 9 +- docs/需求-统一审批中心规约.md | 12 +- scripts/qa/run-backup-restore-drill.sh | 24 ++ server/package.json | 1 + server/src/routes/approvalRules.js | 8 +- .../src/scripts/apply-ceo-finance-fallback.js | 303 ++++++++++++++++++ server/src/scripts/seed-config.js | 41 ++- server/src/utils/workflow.js | 4 +- .../routes/approvalRules.permissions.test.js | 70 +++- .../reimbursements.expense-standards.test.js | 17 +- .../apply-ceo-finance-fallback.test.js | 103 ++++++ .../unit/scripts/seed-config.defaults.test.js | 67 ++++ server/tests/unit/utils/workflow.test.js | 28 +- 31 files changed, 980 insertions(+), 74 deletions(-) create mode 100644 .trellis/tasks/07-28-ceo-finance-fallback/check.jsonl create mode 100644 .trellis/tasks/07-28-ceo-finance-fallback/design.md create mode 100644 .trellis/tasks/07-28-ceo-finance-fallback/implement.jsonl create mode 100644 .trellis/tasks/07-28-ceo-finance-fallback/implement.md create mode 100644 .trellis/tasks/07-28-ceo-finance-fallback/prd.md create mode 100644 .trellis/tasks/07-28-ceo-finance-fallback/task.json create mode 100644 server/src/scripts/apply-ceo-finance-fallback.js create mode 100644 server/tests/unit/scripts/apply-ceo-finance-fallback.test.js create mode 100644 server/tests/unit/scripts/seed-config.defaults.test.js diff --git a/.trellis/spec/hyb-oa/deployment-trialrun.md b/.trellis/spec/hyb-oa/deployment-trialrun.md index 8eb83cdd..080684f9 100644 --- a/.trellis/spec/hyb-oa/deployment-trialrun.md +++ b/.trellis/spec/hyb-oa/deployment-trialrun.md @@ -608,9 +608,10 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" ### 1. Scope / Trigger - Trigger: changing Compose identity, host data mounts, database publication, - deployment entrypoints or operational timers. + deployment entrypoints, deployment-time seeds or operational timers. - Purpose: ensure one approved Compose project owns one verified DB/storage pair - without fixed global container names or accidental shared PGDATA. + without fixed global container names, source synchronization touching runtime + data, or deployment seeds overwriting approved production rules. ### 2. Signatures @@ -618,7 +619,10 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" - PostgreSQL mount: `${POSTGRES_DATA_DIR:-./data/postgres}`. - Application storage mount: `${STORAGE_HOST_DIR:-./data/storage}`. - Formal deploy inputs: absolute `POSTGRES_DATA_DIR`, absolute - `STORAGE_HOST_DIR`, `DEPLOY_DATA_MODE=existing|initialize`. + `STORAGE_HOST_DIR`, absolute `REMOTE_DIR`, absolute `REMOTE_ENV_FILE`, + `DEPLOY_DATA_MODE=existing|initialize`. +- Deployment init includes `npm run seed:config`; approval-rule defaults are + inserted only when `approval_rules` is empty. - Timer install inputs: `COMPOSE_PROJECT_NAME`, absolute `POSTGRES_DATA_DIR`, absolute `STORAGE_HOST_DIR`, full 40–64 hex `BUILD_SHA` and its exact 12-hex `VITE_BUILD_SHA` prefix. @@ -637,6 +641,15 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" - Relative defaults preserve local trialrun compatibility, but production must use explicit absolute command inputs and verify them against active container mounts before any start, backup or migration. +- `REMOTE_DIR` must not equal, contain or be contained by either production + data path. Validate this locally before provenance, SSH or `rsync --delete`. + If `REMOTE_ENV_FILE` is inside the synchronization tree, derive its actual + root-relative path and add explicit rsync protect/exclude filters; a fixed + default `.env` exclusion is insufficient. +- Deployment-time `seed:config` may create the three default approval rules for + an empty database. If any approval rule exists, preserve all existing rules; + production rule changes require their own approved transaction, drift checks + and audit rows. - `existing` is the normal migration mode and refuses a missing PGDATA marker or storage directory. `initialize` is only for an approved empty first start; only that mode may create data directories. @@ -673,6 +686,12 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" valid absolute paths remain installable. - Formal deploy omits paths/mode, uses a relative/broad/overlapping path, or an `existing` PGDATA lacks `PG_VERSION` -> fail before DB start. +- `REMOTE_DIR` overlaps PostgreSQL or attachment storage in either direction -> + fail before provenance, SSH and rsync. +- A custom `REMOTE_ENV_FILE` lies inside the sync tree without dynamic + protect/exclude filters -> deployment contract fails. +- `approval_rules` contains one or more rows -> `seed:config` reports them as + preserved and performs no approval-rule create, update or delete. - New project resolves the same PGDATA while the old DB runs -> stop before Compose start; do not use service health as permission to continue. - Old project name was inferred, immutable image ID or baseline counts are @@ -682,8 +701,11 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" - `node deploy/trialrun/validate-env-contract.js`. - The isolated backup/restore drill mutates Compose with a DB port and fixed - name and proves the contract rejects it. + name, invokes formal deploy with each data path nested below `REMOTE_DIR`, and + proves every unsafe shape is rejected before network access. - `bash -n` covers deploy, timer, backup and restore scripts. +- Seed unit tests assert: an empty table receives exactly three defaults; any + non-empty table receives zero approval-rule writes and no delete call. - Timer contract assertions cover all four explicit inputs, path isolation, PGDATA marker, SHA/prefix validation and the four systemd `Environment=` entries. On the deployment host, assert timer enabled/active, daily service @@ -693,6 +715,9 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" - Good: one approved project resolves one stopped-and-migrated PGDATA and the matching active storage path; deploy, timer and backup all use that identity. +- Good: source lives under one absolute root, data lives under separate absolute + roots, a custom env file is protected, and existing approval rules survive + deployment init byte-for-byte until an approved rule transaction runs. - Good: the timer unit contains the current project, active mount sources and deployed full/short SHA pair; Compose config and a non-mutating server exec resolve under those exact values while the daily service stays inactive. @@ -704,6 +729,9 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" the old container name alone is a rollback point. - Bad: reinstall the timer with only `COMPOSE_PROJECT_NAME`, allowing systemd Compose interpolation to fall back to repository-relative data directories. +- Bad: place PGDATA or attachments below `REMOTE_DIR` and rely on one hard-coded + rsync exclusion, or delete/recreate configurable approval rules in a routine + deployment seed. ### 7. Wrong vs Correct @@ -742,6 +770,23 @@ VITE_BUILD_SHA= \ bash deploy/trialrun/install-warch3-ops-timer.sh install ``` +For source synchronization and approval-rule defaults: + +```bash +# Wrong: runtime data sits below the --delete synchronization root, and seed +# replaces operator-managed rules during every deployment. +REMOTE_DIR=/srv/oa POSTGRES_DATA_DIR=/srv/oa/data/postgres \ + bash deploy/trialrun/deploy.sh +# seedApprovalRules: deleteMany -> createMany + +# Correct: data roots are disjoint; an in-tree env file is dynamically +# protected; seed creates defaults only for an empty approval_rules table. +REMOTE_DIR=/srv/oa-app POSTGRES_DATA_DIR=/srv/oa-data/postgres \ +STORAGE_HOST_DIR=/srv/oa-data/storage DEPLOY_DATA_MODE=existing \ + bash deploy/trialrun/deploy.sh +# seedApprovalRules: count > 0 -> preserve; count = 0 -> create defaults +``` + ## Scenario: Paired Backup And Shadow-Only Restore ### 1. Scope / Trigger diff --git a/.trellis/tasks/07-28-ceo-finance-fallback/check.jsonl b/.trellis/tasks/07-28-ceo-finance-fallback/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/07-28-ceo-finance-fallback/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/07-28-ceo-finance-fallback/design.md b/.trellis/tasks/07-28-ceo-finance-fallback/design.md new file mode 100644 index 00000000..00174156 --- /dev/null +++ b/.trellis/tasks/07-28-ceo-finance-fallback/design.md @@ -0,0 +1,63 @@ +# CEO 本人报销财务兜底设计 + +## 1. Boundary + +本任务复用现有审批规则和工作流快照机制,不新增角色、数据表、迁移或公开端点。`finance_mgr` 保持兼容;本公司的新默认和生产规则改为 `finance`。 + +## 2. Cross-layer Contract + +```text +审批规则配置 + ownerSelfFallbackRole: finance | finance_mgr | null + -> API 白名单校验 + -> ApprovalRule 持久化 + -> 报销提交加载规则 + -> CEO 本人且 manager 无法解析时使用 fallback + -> 移除本人 ceo 节点 + 角色去重 + -> 校验 active 非本人角色持有人 + -> 冻结 workflow_steps_snapshot +``` + +创建 API、省略字段时的默认值和前端新建表单默认值统一为 `finance`。旧规则返回 `finance_mgr` 时前端仍可展示、编辑和保存,避免破坏兼容数据。 + +## 3. Target Rule Matrix + +| 金额档 | 普通员工规则 | CEO 本人快照 | +| --- | --- | --- | +| 小额 | `manager -> finance` | `finance` | +| 中额 | `manager -> finance` | `finance` | +| 大额 | `manager -> ceo -> finance` | `finance` | + +CEO 本人快照只由现有标准步骤完成:`manager` 缺失时插入 `finance` fallback;随后相同 `finance` 节点去重,申请人自身 `ceo` 节点移除。无需添加按金额或人员姓名硬编码的特殊分支。 + +## 4. Validation And Failure Behavior + +- fallback 不在 `finance|finance_mgr`:`400 VALID_001`。 +- CEO 同时持有 fallback:`409 VALID_001 / owner_self_fallback_self_role`。 +- 无 active 非本人 fallback 持有人:`409 VALID_001 / owner_self_fallback_role_unavailable`。 +- 非 CEO:不触发 owner-self fallback,保留正常规则快照。 +- 生产规则数量、金额区间或当前角色结构漂移:回填事务停止,零写入。 + +## 5. Production Rollout + +1. 部署代码与前端,使 runtime 能读写 `finance` fallback;`seed:config` 对既有审批规则只保留不覆盖,避免部署提前改写生产规则。 +2. 核对公网构建号和 runtime smoke。 +3. 生产只读冻结 3 条规则、active `finance` 持有人、CEO 身份、角色/binding/历史快照计数。 +4. 单事务更新 3 条启用规则:中/大额 `finance_mgr` 节点改为 `finance`,全部 fallback 改为 `finance`;逐条写脱敏审计。 +5. 只读解析三档 CEO 快照和普通员工快照,不创建真实报销。 + +## 6. Rollback + +- 代码兼容 `finance_mgr`,生产规则可在精确冻结原值后原子恢复。 +- 回滚规则只影响回滚后新提交单据;既有 `workflow_steps_snapshot` 保持不变。 +- 任一生产检查失败时先回滚规则,不改员工角色或历史单据。 + +## 6.1 Seed Safety + +- 空库首次初始化时创建三条新默认规则。 +- 只要审批规则表已有任意记录,`seed:config` 保留全部既有规则,不删除、不覆盖、不补写;生产规则只允许走获批的独立原子事务。 +- 部署日志只记录创建/保留计数,不输出规则 ID、人员或业务内容。 + +## 7. Trade-off + +不采用“给现有财务追加 `finance_mgr`”方案,因为该角色同时包含主数据、预算、集成、AI、资产和薪酬等更宽权限,超出“审批 CEO 本人报销”的最小职责。允许 `finance` 作为兜底能复用已有 `expense.approve`,权限面更小。 diff --git a/.trellis/tasks/07-28-ceo-finance-fallback/implement.jsonl b/.trellis/tasks/07-28-ceo-finance-fallback/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/07-28-ceo-finance-fallback/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/07-28-ceo-finance-fallback/implement.md b/.trellis/tasks/07-28-ceo-finance-fallback/implement.md new file mode 100644 index 00000000..21edb6dd --- /dev/null +++ b/.trellis/tasks/07-28-ceo-finance-fallback/implement.md @@ -0,0 +1,45 @@ +# CEO 本人报销财务兜底实施计划 + +## 1. Implementation + +- [x] 扩展后端 owner-self fallback 白名单为 `finance|finance_mgr`,创建默认值改为 `finance`。 +- [x] 将三档种子默认规则统一为财务 `finance` 收口,保留大额 `ceo` 节点。 +- [x] 更新前端 API 类型、创建/编辑选项、默认值和推荐文案;兼容展示 `finance_mgr`。 +- [x] 更新工作流、规则 API、报销提交回归测试,覆盖 CEO 三档唯一 `finance`、普通员工链、自审、无持有人和 legacy `finance_mgr`。 +- [x] 修正 `seed:config` 对既有审批规则的删除重建副作用;仅空库创建默认规则,生产既有规则保留到独立原子回填阶段。 +- [x] 新增默认 dry-run、双确认 apply 的生产规则回填脚本;事务内锁定并校验三档规则、CEO/财务/admin/binding 基线,更新三条规则、写三条脱敏审计并复核受保护计数。 +- [x] 更新当前业务事实源,区分目标、代码部署和生产规则回填状态。 + +## 2. Local Validation + +- [x] `cd server && node --check src/utils/workflow.js && node --check src/routes/approvalRules.js && node --check src/scripts/seed-config.js && node --check src/scripts/apply-ceo-finance-fallback.js` +- [x] `cd server && npx vitest run tests/unit/utils/workflow.test.js tests/unit/routes/approvalRules.permissions.test.js tests/unit/routes/reimbursements.expense-standards.test.js tests/unit/scripts/seed-config.defaults.test.js tests/unit/scripts/apply-ceo-finance-fallback.test.js`(58 项通过) +- [x] `cd server && npm run prisma:validate` +- [x] `cd client && npm run typecheck && npm run build` +- [x] `node scripts/qa/check-process-contract.mjs --baseline --json` +- [x] `node scripts/qa/audit-docs.mjs --check-registry`(0 error,保留既有 22 个未注册文档 warning) +- [x] `python3 ./.trellis/scripts/task.py validate 07-28-ceo-finance-fallback` +- [x] `node scripts/qa/check-trellis-governance.mjs && git diff --check` +- [x] `bash scripts/check-minimal.sh`(167 个测试文件、1053 项后端测试、前端 build 与矩阵门禁通过) + +## 3. Review Gate + +- [x] 只读 review 权限白名单、自审阻断、普通员工链、历史快照不回写、API/UI 类型一致性和生产事务边界;发现并修复部署 seed 提前覆盖规则问题后复核无剩余阻断。 + +## 4. Deployment And Production Apply + +- [ ] 提交并推送已验证代码。 +- [x] 修复部署脚本已知的数据目录保护缺陷:同步根目录与数据目录任一方向重叠时在 SSH 前阻断,自定义同步树内 `.env` 动态保护;隔离备份/恢复合同通过。 +- [ ] 部署后核对 `/ready` 构建号、migration 状态、runtime smoke、external deps smoke 和前端 bundle。 +- [ ] 生产只读冻结规则结构、active `finance`、CEO、角色、binding、历史快照和审计基线。 +- [ ] 原子更新恰好 3 条启用规则并逐条写脱敏审计;任一预条件漂移则零写入停止。 +- [ ] 只读解析 CEO 小/中/大额快照均为唯一 `finance`,普通员工链符合目标;不创建真实报销。 +- [ ] 复核无角色、主管、binding、历史快照或企微写入变化。 + +## 5. Stop Conditions + +- 生产 active `finance` 不是唯一非 CEO 持有人,或目标人员 inactive/unbound。 +- 启用规则不是恰好 3 条,金额区间或角色结构与冻结基线不符。 +- 部署路径仍可能删除 `.env`、附件或数据库宿主目录。 +- 任一测试出现自审、管理员代批、历史快照重写或普通员工链异常。 +- 真实 CEO 报销验证未取得单独批准。 diff --git a/.trellis/tasks/07-28-ceo-finance-fallback/prd.md b/.trellis/tasks/07-28-ceo-finance-fallback/prd.md new file mode 100644 index 00000000..755ace57 --- /dev/null +++ b/.trellis/tasks/07-28-ceo-finance-fallback/prd.md @@ -0,0 +1,69 @@ +# CEO 本人报销财务兜底修正 + +## Goal + +让 `majianyong(ceo)` 本人提交任一金额档报销时,在禁止自审的前提下只进入 active 非本人 `finance` 审批;`wangshunli` 的系统管理员身份不进入业务审批链,也不通过给现有财务附加 `finance_mgr` 扩权来绕过问题。 + +## Scope + +- 报销审批工作流的负责人本人 fallback 白名单、快照解析和失败关闭行为。 +- 审批规则 API、前端配置页与三档默认种子规则的 `finance` 默认值和 `finance_mgr` 兼容。 +- 部署同步根目录与 PostgreSQL、附件和环境文件的安全隔离合同。 +- 经部署与公网验证后,对生产恰好 3 条启用报销规则进行一次原子回填和脱敏审计。 + +## Background + +- 用户确认 `majianyong` 是公司 CEO、无直属主管;`wangshunli` 仅承担系统开发和系统管理。 +- 生产有 3 条启用报销金额规则,均使用 `owner_self_fallback_role=finance_mgr`,中/大额规则还包含 `finance_mgr` 节点。 +- 生产有 1 名 active 且已绑定企微的 `finance`,没有 active `finance_mgr`;当前 CEO 本人报销会以 `owner_self_fallback_role_unavailable` 失败关闭。 +- 代码、审批规则 API、前端配置页和种子默认值目前只把 `finance_mgr` 作为负责人本人兜底角色。 +- 现有工作流已具备 CEO 自审节点移除、重复角色去重、active 非本人持有人检查和历史审批快照冻结能力。 + +## Requirements + +- `finance` 和兼容保留的 `finance_mgr` 均可作为 `ownerSelfFallbackRole`;新建规则默认使用 `finance`。 +- 小团队默认金额规则调整为:小额 `manager -> finance`、中额 `manager -> finance`、大额 `manager -> ceo -> finance`,三档 `ownerSelfFallbackRole` 均为 `finance`。 +- CEO 本人申请在三档规则下均生成唯一 `finance` 节点:替换无法解析的 `manager`、移除本人 `ceo`、去除重复 `finance`。 +- `finance` 兜底必须存在 active 非本人持有人;CEO 若同时持有 `finance` 或无可用持有人,继续失败关闭。 +- 普通员工的工作流仍按规则顺序生成,不引入 CEO 本人专属的全局跳步;历史 `workflow_steps_snapshot` 不回写。 +- 审批规则 API 和前端配置页同时支持 `finance`/`finance_mgr`,默认与推荐值显示为 `finance`;不开放 `admin`、`system_admin`、`hr` 或其他角色作为负责人本人兜底。 +- 部署后以原子事务将生产 3 条启用规则从现状修正为上述三档结构并写脱敏审计;写入前必须重新确认规则数量、金额区间、角色结构、active 财务持有人和 CEO 身份,任一漂移即停止。 +- 生产回填不改员工角色、直属主管、既有报销审批快照、密码、企微 binding 或企微通讯录。 +- 公网验证只创建一条由 CEO 本人发起的低风险测试报销时,必须再次取得真实业务写入批准;本任务默认只做规则只读解析/smoke,不自动创建真实报销。 + +## Acceptance Criteria + +- [x] 后端工作流单元测试证明 CEO 本人小/中/大额快照均只有一个 `finance` 节点。 +- [x] 无 active 非本人 `finance`、CEO 同时持有 `finance`、不支持的兜底角色均失败关闭且不创建半截工作流。 +- [x] 普通员工规则快照保持 `manager -> finance` 或 `manager -> ceo -> finance`,无自审放宽。 +- [x] 审批规则 API 接受 `finance` 和兼容的 `finance_mgr`,拒绝非白名单角色;新建默认值为 `finance`。 +- [x] 前端类型、创建/编辑选项和推荐文案支持 `finance`,默认选择 `finance`,typecheck/build 通过。 +- [x] 种子默认三档规则与目标链一致,相关回归测试通过。 +- [x] 只读 review 确认权限、自审、状态机、历史快照和生产回填边界无扩大。 +- [ ] 部署后公网 `/ready` 构建号匹配,runtime smoke 和关联认证/工作流检查通过。 +- [ ] 生产恰好 3 条启用规则按目标结构回填并留下脱敏审计;active 账号、角色、binding 和历史审批快照计数无意外变化。 +- [x] 当前事实源、管理员手册和任务结论不再宣称 `finance_mgr` 是本公司推荐兜底。 + +## Test Plan + +- 后端语法、Prisma validate,以及 workflow、审批规则 API、报销提交和默认种子规则聚焦测试。 +- 前端 typecheck/build 与审批规则配置页聚焦 Playwright。 +- 部署环境静态合同、隔离备份恢复演练、流程合同、文档审计、Trellis task/governance 和敏感内容扫描。 +- 部署后核对 `/ready` 构建号、migration、runtime/external-deps smoke 和公网 bundle。 +- 生产回填前后只读冻结规则、角色、binding、历史快照和审计计数;只读解析 CEO 三档与普通员工链,不创建真实报销。 + +## Out of Scope + +- 不删除 `finance_mgr` 角色或改变其现有权限。 +- 不给现有财务人员新增 `finance_mgr`、`manager` 或其他角色。 +- 不改变普通报销金额阈值,不重构通用工作流引擎,不修改历史审批快照。 +- 不让 `admin`/`system_admin` 获得业务审批权,不允许任何申请人自审。 +- 不执行真实付款、企微写入或未经再次批准的真实 CEO 报销提交。 + +## Technical Notes + +- 后端白名单与快照:`server/src/utils/workflow.js`。 +- 审批规则 API:`server/src/routes/approvalRules.js`。 +- 默认规则:`server/src/scripts/seed-config.js`。 +- 前端类型和页面:`client/src/api/approvalRules.ts`、`client/src/pages/ApprovalRulesPage.tsx`。 +- 聚焦测试:`server/tests/unit/utils/workflow.test.js`、`server/tests/unit/routes/approvalRules.permissions.test.js`、`server/tests/unit/routes/reimbursements.expense-standards.test.js`。 diff --git a/.trellis/tasks/07-28-ceo-finance-fallback/task.json b/.trellis/tasks/07-28-ceo-finance-fallback/task.json new file mode 100644 index 00000000..de665bc0 --- /dev/null +++ b/.trellis/tasks/07-28-ceo-finance-fallback/task.json @@ -0,0 +1,26 @@ +{ + "id": "ceo-finance-fallback", + "name": "ceo-finance-fallback", + "title": "CEO 本人报销财务兜底修正", + "description": "让 CEO 本人报销在禁止自审的前提下由 active 非本人 finance 审批,并以最小权限修正代码、默认规则和生产配置。", + "status": "in_progress", + "dev_type": null, + "scope": "server, client, deploy/trialrun, workflow tests, current docs, production approval rules", + "package": null, + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-07-28", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} diff --git a/client/src/api/approvalRules.ts b/client/src/api/approvalRules.ts index 232b44cd..118b7d69 100644 --- a/client/src/api/approvalRules.ts +++ b/client/src/api/approvalRules.ts @@ -8,7 +8,7 @@ export type ApprovalRoleCode = | "finance_mgr" | "administration" | "hr"; -export type ApprovalOwnerSelfFallbackRoleCode = "finance_mgr"; +export type ApprovalOwnerSelfFallbackRoleCode = "finance" | "finance_mgr"; export type ApprovalRule = { id: string; diff --git a/client/src/pages/ApprovalRulesPage.tsx b/client/src/pages/ApprovalRulesPage.tsx index dac36ccf..6adcdb95 100644 --- a/client/src/pages/ApprovalRulesPage.tsx +++ b/client/src/pages/ApprovalRulesPage.tsx @@ -27,9 +27,13 @@ const ROLE_OPTIONS: Array<{ code: ApprovalRoleCode; label: string }> = [ { code: "finance", label: "finance" }, { code: "finance_mgr", label: "finance_mgr" }, ]; -const OWNER_SELF_FALLBACK_ROLE_OPTIONS = ROLE_OPTIONS.filter( - (role) => role.code === "finance_mgr", -); +const OWNER_SELF_FALLBACK_ROLE_OPTIONS: Array<{ + code: ApprovalOwnerSelfFallbackRoleCode; + label: string; +}> = [ + { code: "finance", label: "财务" }, + { code: "finance_mgr", label: "财务负责人(兼容)" }, +]; function formatAmount(value: string | null) { if (!value) return "-"; @@ -47,7 +51,7 @@ export function ApprovalRulesPage() { const [level1Role, setLevel1Role] = useState("manager"); const [level2Role, setLevel2Role] = useState(""); const [level3Role, setLevel3Role] = useState(""); - const [ownerSelfFallbackRole, setOwnerSelfFallbackRole] = useState("finance_mgr"); + const [ownerSelfFallbackRole, setOwnerSelfFallbackRole] = useState("finance"); const [enabled, setEnabled] = useState(true); const [editOpen, setEditOpen] = useState(false); @@ -102,7 +106,7 @@ export function ApprovalRulesPage() { setLevel1Role("manager"); setLevel2Role(""); setLevel3Role(""); - setOwnerSelfFallbackRole("finance_mgr"); + setOwnerSelfFallbackRole("finance"); setEnabled(true); setError(""); toast.success("规则已创建"); @@ -334,7 +338,7 @@ export function ApprovalRulesPage() { ))} -

推荐 finance_mgr;不使用 admin。

+

推荐财务;系统管理员不参与业务审批。

diff --git a/client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts b/client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts index 57d01f55..5eb55014 100644 --- a/client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts +++ b/client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts @@ -899,7 +899,7 @@ async function mockWave4Apis(page: Page, options: Wave4MockOptions = {}) { level1Role: "manager", level2Role: null, level3Role: null, - ownerSelfFallbackRole: "finance_mgr", + ownerSelfFallbackRole: "finance", enabled: true, priority: 1, createdAt: "2026-05-29T08:00:00.000Z", @@ -1067,6 +1067,19 @@ test("PC 财务列表使用明确动作入口", async ({ page }) => { await expect(page.getByTestId("invoice-export-csv")).toBeVisible(); }); +test("审批规则负责人本人兜底默认财务并兼容财务负责人", async ({ page }) => { + await mockWave4Apis(page); + await page.setViewportSize({ width: 1440, height: 960 }); + await login(page); + + await page.goto("/system/approval-rules"); + const fallbackSelect = page.getByTestId("approval-rules-create-owner-self-fallback"); + await expect(fallbackSelect).toHaveValue("finance"); + await expect(fallbackSelect.locator('option[value="finance"]')).toHaveText("财务"); + await expect(fallbackSelect.locator('option[value="finance_mgr"]')).toHaveText("财务负责人(兼容)"); + await expect(page.getByText("推荐财务;系统管理员不参与业务审批。")).toBeVisible(); +}); + test("/payment-batches 在 768x1024 与 1024x768 下操作列可达", async ({ page }) => { await mockWave4Apis(page); await login(page); diff --git a/deploy/trialrun/checklist.md b/deploy/trialrun/checklist.md index 2058a9de..e197e61a 100644 --- a/deploy/trialrun/checklist.md +++ b/deploy/trialrun/checklist.md @@ -16,7 +16,7 @@ - [ ] 已将 OA `Caddyfile` 站点块配置到 `/etc/caddy/Caddyfile` - [ ] 已确认 Caddy 反代到 `.env` 中 `APP_HOST_PORT` 对应的宿主回环端口,并用 `header_up X-Forwarded-For {remote_host}` 覆盖客户端 XFF - [ ] 已执行 `systemctl reload caddy` -- [ ] 已显式提供 `DEPLOY_SERVER`、`REMOTE_DIR`、绝对 `POSTGRES_DATA_DIR`、绝对 `STORAGE_HOST_DIR` 和 `DEPLOY_DATA_MODE`,并从本地执行发布入口 +- [ ] 已显式提供 `DEPLOY_SERVER`、`REMOTE_DIR`、绝对 `POSTGRES_DATA_DIR`、绝对 `STORAGE_HOST_DIR` 和 `DEPLOY_DATA_MODE`,并确认源码同步根目录与两个数据目录任一方向均不嵌套,再从本地执行发布入口 - [ ] 已通过 `https:///ready` 或首页登录完成最小探活 - [ ] 首次上线先执行系统初始化(migration + seed),不要直接导入旧库整库备份 - [ ] 若要导数据,仅准备最小主数据:部门、员工、角色、在用资产、活跃项目、合同元数据 diff --git a/deploy/trialrun/deploy.sh b/deploy/trialrun/deploy.sh index d8291cff..21e860cc 100755 --- a/deploy/trialrun/deploy.sh +++ b/deploy/trialrun/deploy.sh @@ -22,6 +22,7 @@ RUNTIME_SMOKE_SCRIPT="server/src/scripts/smoke-trialrun-runtime.js" EXTERNAL_DEPS_SMOKE_SCRIPT="server/src/scripts/smoke-external-deps.js" PROVENANCE_SCRIPT="deploy/trialrun/check-source-provenance.mjs" STAGING_DIR="" +RSYNC_PROTECT_ARGS=() cleanup() { if [[ -n "$STAGING_DIR" && -d "$STAGING_DIR" ]]; then @@ -56,7 +57,7 @@ if [[ ! "$COMPOSE_PROJECT_NAME" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then exit 1 fi -validate_data_path() { +validate_absolute_path() { local name="$1" local value="$2" if [[ -z "$value" || "$value" != /* || ! "$value" =~ ^/[A-Za-z0-9._/-]+$ ]]; then @@ -70,8 +71,16 @@ validate_data_path() { fi } -validate_data_path POSTGRES_DATA_DIR "$POSTGRES_DATA_DIR" -validate_data_path STORAGE_HOST_DIR "$STORAGE_HOST_DIR" +paths_overlap() { + local first="$1" + local second="$2" + [[ "$first" == "$second" || "$first" == "$second"/* || "$second" == "$first"/* ]] +} + +validate_absolute_path REMOTE_DIR "$REMOTE_DIR" +validate_absolute_path REMOTE_ENV_FILE "$REMOTE_ENV_FILE" +validate_absolute_path POSTGRES_DATA_DIR "$POSTGRES_DATA_DIR" +validate_absolute_path STORAGE_HOST_DIR "$STORAGE_HOST_DIR" if [[ "$POSTGRES_DATA_DIR" == "$STORAGE_HOST_DIR" \ || "$POSTGRES_DATA_DIR" == "$STORAGE_HOST_DIR"/* \ || "$STORAGE_HOST_DIR" == "$POSTGRES_DATA_DIR"/* ]]; then @@ -82,6 +91,23 @@ if [[ "$DEPLOY_DATA_MODE" != "existing" && "$DEPLOY_DATA_MODE" != "initialize" ] echo "[ERROR] DEPLOY_DATA_MODE 必须显式设为 existing 或 initialize" >&2 exit 1 fi +if paths_overlap "$REMOTE_DIR" "$POSTGRES_DATA_DIR"; then + echo "[ERROR] POSTGRES_DATA_DIR 不得与 REMOTE_DIR 同目录或相互嵌套,避免源码同步触碰数据库" >&2 + exit 1 +fi +if paths_overlap "$REMOTE_DIR" "$STORAGE_HOST_DIR"; then + echo "[ERROR] STORAGE_HOST_DIR 不得与 REMOTE_DIR 同目录或相互嵌套,避免源码同步触碰附件" >&2 + exit 1 +fi +if [[ "$REMOTE_ENV_FILE" == "$REMOTE_DIR" ]]; then + echo "[ERROR] REMOTE_ENV_FILE 不得等于 REMOTE_DIR" >&2 + exit 1 +fi +if [[ "$REMOTE_ENV_FILE" == "$REMOTE_DIR"/* ]]; then + REMOTE_ENV_RELATIVE="${REMOTE_ENV_FILE#"$REMOTE_DIR"/}" + RSYNC_PROTECT_ARGS+=(--filter="protect /${REMOTE_ENV_RELATIVE}") + RSYNC_PROTECT_ARGS+=(--exclude="/${REMOTE_ENV_RELATIVE}") +fi if [[ ! -f "${ROOT_DIR}/${PROVENANCE_SCRIPT}" ]]; then echo "[ERROR] 缺少 ${PROVENANCE_SCRIPT}" >&2 @@ -139,6 +165,7 @@ ssh "${DEPLOY_SERVER}" "mkdir -p '${REMOTE_DIR}'" echo ">>> 4. 同步已验证提交到服务器" rsync -az --delete \ + "${RSYNC_PROTECT_ARGS[@]}" \ --exclude '.git' \ --exclude '.github' \ --exclude '.agent' \ diff --git a/deploy/trialrun/runbook.md b/deploy/trialrun/runbook.md index 96ad4039..76219279 100644 --- a/deploy/trialrun/runbook.md +++ b/deploy/trialrun/runbook.md @@ -119,7 +119,7 @@ docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml ``` 说明: -- `init` 是一次性任务:`migrate:deploy + seed:config + seed:trialrun`。 +- `init` 是一次性任务:`migrate:deploy + seed:config + seed:trialrun`。`seed:config` 仅在审批规则表为空时创建默认三档;已有任意审批规则时必须全部保留,生产规则变更另走获批的原子事务。 - 初始化必须发生在依赖新增列的新 server 切换之前;正式部署脚本会先用新 init 镜像校验生产配置并执行迁移,再替换 server。 - 若 `TRIALRUN_ADMIN_PASSWORD` 为空,seed 会生成一次性口令并输出到控制台(请立刻保存)。 - 正式启用前,推荐先保持“空基线 + 默认种子”,不要直接导入开发库、测试库或旧 OA 整库备份。 @@ -392,7 +392,7 @@ systemctl status hyb-oa-public-app-health.service --no-pager 步骤: 1) 先做备份(见 E14 脚本) -2) 从本地仓库执行 `DEPLOY_SERVER= REMOTE_DIR= COMPOSE_PROJECT_NAME= POSTGRES_DATA_DIR= STORAGE_HOST_DIR= DEPLOY_DATA_MODE=existing bash deploy/trialrun/deploy.sh`(推荐);脚本只同步已验证提交的 Git archive,不依赖远端 `git pull`,也不会把本地 ignored 文件带入发布。 +2) 从本地仓库执行 `DEPLOY_SERVER= REMOTE_DIR= COMPOSE_PROJECT_NAME= POSTGRES_DATA_DIR= STORAGE_HOST_DIR= DEPLOY_DATA_MODE=existing bash deploy/trialrun/deploy.sh`(推荐);脚本只同步已验证提交的 Git archive,不依赖远端 `git pull`,也不会把本地 ignored 文件带入发布。`REMOTE_DIR` 必须与 PostgreSQL、附件宿主目录完全分离;任一方向嵌套都会在 SSH 和 `rsync --delete` 前阻断。位于同步树内的 `REMOTE_ENV_FILE` 按实际相对路径动态保护。 - 在现网目录统一任务中,新的 `COMPOSE_PROJECT_NAME`、`POSTGRES_DATA_DIR`、`STORAGE_HOST_DIR` 必须与活动数据目录复制在同一停机窗口启用;本地提交本身不授权切换。 - 旧栈停止命令必须使用冻结阶段由 `docker compose ls` 实测的旧 project;不得拿新 project 名去停止旧容器。 - `artifacts/` 和 `output/` 是运行期证据/截图目录,不作为源码发布输入,也不由 `rsync --delete` 清理远端历史证据。 diff --git a/deploy/trialrun/validate-env-contract.js b/deploy/trialrun/validate-env-contract.js index b233109d..b7a4762b 100755 --- a/deploy/trialrun/validate-env-contract.js +++ b/deploy/trialrun/validate-env-contract.js @@ -428,8 +428,14 @@ for (const requiredFragment of [ } for (const requiredFragment of [ - 'validate_data_path POSTGRES_DATA_DIR "$POSTGRES_DATA_DIR"', - 'validate_data_path STORAGE_HOST_DIR "$STORAGE_HOST_DIR"', + 'validate_absolute_path REMOTE_DIR "$REMOTE_DIR"', + 'validate_absolute_path REMOTE_ENV_FILE "$REMOTE_ENV_FILE"', + 'validate_absolute_path POSTGRES_DATA_DIR "$POSTGRES_DATA_DIR"', + 'validate_absolute_path STORAGE_HOST_DIR "$STORAGE_HOST_DIR"', + 'paths_overlap "$REMOTE_DIR" "$POSTGRES_DATA_DIR"', + 'paths_overlap "$REMOTE_DIR" "$STORAGE_HOST_DIR"', + 'RSYNC_PROTECT_ARGS+=(--filter="protect /${REMOTE_ENV_RELATIVE}")', + 'RSYNC_PROTECT_ARGS+=(--exclude="/${REMOTE_ENV_RELATIVE}")', 'DEPLOY_DATA_MODE" != "existing"', 'DEPLOY_DATA_MODE" != "initialize"', '"$POSTGRES_DATA_DIR/PG_VERSION"', diff --git a/docs/trialrun/真机公网测试清单.md b/docs/trialrun/真机公网测试清单.md index 1eec1a36..210e939e 100644 --- a/docs/trialrun/真机公网测试清单.md +++ b/docs/trialrun/真机公网测试清单.md @@ -82,7 +82,7 @@ ## 审批与通知 -- 正式审批口径:`majianyong` 是公司负责人 `ceo` 且不配置直属主管,当前唯一 active `manager` 向其汇报;`wangshunli` 只承担系统开发/系统管理,不配置正常业务审批角色;`admin` 不代表业务审批权。CEO 本人报销只有在 active 非本人财务兜底可解析时才能通过,否则必须阻断。 +- 正式审批口径:`majianyong` 是公司负责人 `ceo` 且不配置直属主管,当前唯一 active `manager` 向其汇报;`wangshunli` 只承担系统开发/系统管理,不配置正常业务审批角色;`admin` 不代表业务审批权。CEO 本人报销只有在 active 非本人 `finance` 兜底可解析时才能通过;本地候选已通过,部署与生产规则回填前公网仍必须阻断。 - WOUA-FIX 后,`SMOKE001`、`SMOKE201`、`SMOKE301`、`SMOKE302`、`SMOKE401`、`T0000` 已作为缺企微测试数据软归档;后续真机或公网试运行不得再把这些账号作为当前执行账号。 - 审批链正确性是业务逻辑验收,优先通过 PC 公网网页或自动化证明,不要求所有审批角色绑定企业微信。 - 企业微信真机验收只覆盖工作台入口、绑定/免登录、移动布局、待办、详情、PDF 上传入口和通知 deeplink;当前 `oa_smoke_01 -> SMOKE002` 与 `oa_smoke_02 -> SMOKE101` 足够覆盖“提交人 + 审批人”的核心移动体验。 @@ -97,7 +97,7 @@ | 普通低/中额单据 | PC/自动化 | `SMOKE002` / 测试员工 | 提交低/中额单据 | 待办分配给 `SMOKE101` | 通过 | S4 自动化证据 | | 高额/高风险/例外单据 | PC/自动化 | 当前 active 角色账号 | 提交高额/例外单据 | 按当前负责人、财务和财务主管配置流转 | 需按 WOUA-FIX 后账号重跑 | 后续新证据 | | 主管本人申请 | PC/自动化 | 当前 active 主管账号 | 主管提交本人申请 | 待办分配给非本人上级或明确阻断 | 需按 WOUA-FIX 后账号重跑 | 后续新证据 | -| 公司负责人本人申请 | PC/自动化 | 当前 active 负责人账号 | 负责人提交本人申请 | 不允许自审;无非本人兜底时阻断 | 需按 WOUA-FIX 后账号重跑 | 后续新证据 | +| 公司负责人本人申请 | PC/自动化 | 当前 active 负责人账号 | 负责人提交本人申请 | 三档均唯一进入 active 非本人 `finance`;无持有人或自审时阻断 | 本地聚焦回归通过,待部署和生产规则回填后只读复核 | 当前 Trellis task;后续公网证据 | | 系统管理员无业务审批权 | PC/自动化 | 当前 active admin | 尝试审批测试单据 | 不允许仅凭 `admin` 审批 | 需按 WOUA-FIX 后账号重跑 | 后续新证据 | | 财务复核与付款/导出 | PC/自动化 | 当前 active 财务角色账号 | 处理已批准测试单据 | 按财务与财务主管权限处理 | 需按 WOUA-FIX 后账号重跑 | 后续新证据 | diff --git a/docs/产品口径.md b/docs/产品口径.md index 7a4e2ad1..d155369b 100644 --- a/docs/产品口径.md +++ b/docs/产品口径.md @@ -108,7 +108,7 @@ - 缺直属主管、主管停用、主管链断裂或审批节点解析为申请人本人时,提交必须阻断并提示"未配置可审批人"或"当前审批规则未找到审批人"。 - 只有审批规则显式配置老板/总经理或兜底审批节点,且目标不是申请人本人时,才进入 `CEO` 或其他非本人兜底审批人。 - 不自动通过,不静默跳过,不用无条件升级 `CEO` 掩盖组织配置问题。 -- 当前正式运行口径:`majianyong` 是公司负责人 `ceo` 且不配置直属主管;当前唯一 active `manager` 向其汇报。`wangshunli` 只承担系统开发/系统管理,不配置业务审批角色;`admin` 不代表业务审批权。CEO 本人报销目标由 active 非本人 `finance` 审批,当前实现缺口未修复时保持阻断。 +- 当前正式运行口径:`majianyong` 是公司负责人 `ceo` 且不配置直属主管;当前唯一 active `manager` 向其汇报。`wangshunli` 只承担系统开发/系统管理,不配置业务审批角色;`admin` 不代表业务审批权。CEO 本人报销目标由 active 非本人 `finance` 审批;本地候选已实现,部署和生产规则回填前保持阻断。 ### W-2 转审适用范围 - 产品目标:转审是通用审批能力,目标覆盖报销、请假、采购、差旅。 @@ -153,10 +153,10 @@ - `manage` 不是 `approve` 的别名,也不等于"可任意改历史数据" ### R-2 报销/费用金额分层审批 -- 当前 5–10 人单管理员团队不按金额分层;所有员工报销统一使用 `直属主管(department_manager) → finance`。金额差异不增加重复审批节点,也不引用无人持有的 `finance_mgr`。 -- 马建勇同时是杨俊直属主管和唯一 CEO;再追加 CEO 节点只会让同一自然人对同一单据连续审批两次,不形成新的控制。 -- 真正的职责分离保留在付款环节:`finance` 创建付款批次,`ceo` 复核,继续执行 maker-checker。 -- 只有出现第二名实际承担审批职责的主管,或有人基于真实职责正式承担 `finance_mgr`,并且业务证明需要金额升级时,才重新评估金额分层。仅需提示大额时优先用风险标记或通知,不用重复审批节点代替。 +- 当前三档规则固定为:小额 `manager -> finance`、中额 `manager -> finance`、大额 `manager -> ceo -> finance`;三档负责人本人 fallback 均为 `finance`,不引用无人持有的 `finance_mgr`。 +- 普通员工按规则顺序审批;CEO 本人提交时移除本人 `ceo` 节点、替换不可解析的 `manager` 并去重,最终只有 active 非本人 `finance` 节点。 +- 真正的职责分离继续保留在付款环节:`finance` 创建付款批次,`ceo` 复核,执行 maker-checker。 +- 不因小团队规模放宽自审,也不通过 `admin` 代批或给现有财务追加权限更宽的 `finance_mgr`。 ### R-3 资产转移权限 - `asset.transfer` 只给 `administration / 资产管理员` @@ -169,6 +169,6 @@ - `finance`、`finance_mgr` 均不拥有 `asset.transfer`;实物调拨继续只属于 `administration`。 ### R-5 CEO 本人无主管报销限制 -- 现有负责人本人报销规则只允许 active、非本人的 `finance_mgr` 作为 `ownerSelfFallbackRole`;普通 `finance` 不满足该兜底角色。 -- 当 CEO 本人没有可解析直属主管且公司没有合适的非本人 `finance_mgr` 时,报销提交保持阻断。不得通过给财务人员顺带增加 `finance_mgr`、使用 `admin` 代批或恢复整库来绕过。 -- 授予 `ceo` 后,既有 `assignee_role=ceo` 待办会对该角色持有人可见,新提交单据按当前规则生成 CEO 节点;既有审批快照不重路由,过去提交失败的单据也不会自动恢复。若未来要放开 CEO 本人报销,应另开产品任务调整兜底规则和最小权限,而不是在账号配置阶段扩权。 +- `ownerSelfFallbackRole` 只允许 active、非本人的 `finance` 或兼容角色 `finance_mgr`;本公司新建默认和生产目标均为 `finance`。 +- CEO 本人没有可解析直属主管时,只有存在 active 非本人 fallback 持有人才能提交;无持有人或 CEO 同时持有 fallback 角色时继续阻断。不得使用 `admin` 代批或恢复整库绕过。 +- 既有审批快照不重路由,过去提交失败的单据也不会自动恢复。当前本地候选尚未部署、生产规则尚未回填,因此公网仍按旧配置阻断。 diff --git a/docs/文档事实源状态矩阵.md b/docs/文档事实源状态矩阵.md index 56706a5e..92399ef5 100644 --- a/docs/文档事实源状态矩阵.md +++ b/docs/文档事实源状态矩阵.md @@ -123,7 +123,7 @@ | `OALAUNCH1` | `issues/2026-05-23_16-49-39-oa-final-launch-readiness-v1.csv` | `EVIDENCE` | 历史最终启用门禁来源;真实业务闭环、连续 7 天、权限矩阵、备份恢复、数据治理培训、运营支持和 Day 7 人工决策要求仍可复用,但未来实际执行必须由新 Trellis task 承接。未取得新任务完整证据前不得宣布最终正式启用完成 | | `OMAT1` | `issues/2026-05-23_17-20-00-oa-maturity-gap-review-v1.csv` | `CURRENT` | 当前成熟度缺口审查台账:按 MAGI 审视/执行/提升轮次调查 hyb-oa 是否成熟,并对标国内主流 OA/协同/低代码/财务/资产系统;已补充国内对标来源、小团队取舍原则、用户决策回填和成熟度证据视图;所有问题和建议先记录为待用户采纳、暂缓或否决,不直接改业务代码 | | `OPOLICY1` | `issues/2026-05-24_01-20-52-oa-policy-system-alignment-v1.csv` | `CURRENT` | 当前制度与 OA 对齐合同:先调整外部制度包为小团队 V1,再回写 OA 事实源和启用门禁;制度参数、资料留存、用印台账、审批异常 SOP、制度签收和 P2 冻结进入 OALAUNCH1 数据治理门禁;现有合同台账、资产台账和公告能力可承载 V1,不立即做 schema 或业务代码改造 | -| `OAFLOW1` | `issues/2026-05-24_19-03-07-oa-owner-application-fallback-approval-v1.csv` | `CURRENT` | 负责人本人申请兜底机制仍有效,但当前人员职责已由用户修正:`majianyong` 是 `ceo` 且无直属主管,`wangshunli` 仅为系统开发/系统管理员,不参与正常业务审批;CEO 本人报销目标由 active 非本人 `finance` 审批。现有代码和启用规则仍限定 `finance_mgr`,而生产没有 active `finance_mgr`,因此后续最小权限修正完成前必须阻断,不得把历史 OAFLOW1 证据误读为当前已就绪 | +| `OAFLOW1` | `issues/2026-05-24_19-03-07-oa-owner-application-fallback-approval-v1.csv` | `CURRENT` | 负责人本人申请兜底机制仍有效,但当前人员职责已由用户修正:`majianyong` 是 `ceo` 且无直属主管,`wangshunli` 仅为系统开发/系统管理员,不参与正常业务审批;CEO 本人报销目标由 active 非本人 `finance` 审批。Trellis task `07-28-ceo-finance-fallback` 已完成本地候选实现与聚焦回归,支持 `finance|finance_mgr` 且默认 `finance`;尚未部署、生产 3 条启用规则尚未原子回填,因此当前公网仍阻断,不得把本地通过误读为生产已就绪 | | `OAFRONT6` | `issues/2026-05-25_12-29-03-oa-frontend-maturity-small-step-v1.csv` | `CURRENT` | 当前前端成熟化小步收口合同:承接 OAFRONT5 部署后 PC 硬问题清零和设计审计;路由标题修复、资产台账详情分区、移动视觉矩阵稳定等待、合同台账表单分组和长编号压缩均已实施部署,公网 PC 只读视觉矩阵复测 `PASS_PC_READONLY_VISUAL_READY` 且 `findingCount=0`;Portal 观察继续后置;本合同不写公网业务数据,不替代 OALAUNCH1 真实业务、备份恢复、7 天稳定化或 Go/No-Go | | `OAKISS1` | `issues/2026-05-25_20-53-34-oa-business-flow-kiss-review-v1.csv` | `CURRENT` | 当前全业务流程 KISS 审查合同:按 MAGI 审视/执行/提升轮次,对报销/OCR、审批、采购/费用、合同/项目、付款/AP、票据池、资产、权限、移动/企微入口逐项做自动化和人工步骤审查;输出 KISS 建议台账,不直接改业务代码;OALAUNCH1 真实业务闭环、备份恢复、7 天稳定化和 Go/No-Go 仍是正式启用前硬门禁 | | `PCDESIGN1` | `issues/2026-05-25_21-27-56-oa-pc-page-design-review-v1.csv` | `CURRENT` | 当前 PC 全页面设计审查合同:按 MAGI 审视/执行/提升轮次,以项目经理和设计总监视角盘点 45 条静态 PC 路由、A/B/C 页面分层、关键视觉矩阵和 KISS 建议;关键 PC 矩阵 `PASS_PC_READONLY_VISUAL_READY`,全静态路由扫描 `PASS_PC_STATIC_ROUTE_SCAN_READY`;建议优先收敛 HR/考勤边界、冻结模块入口、高密度 B/C 页默认视图和动态详情页证据,不直接改业务代码 | diff --git a/docs/文档索引.md b/docs/文档索引.md index a63755fa..2492cdf8 100644 --- a/docs/文档索引.md +++ b/docs/文档索引.md @@ -2,9 +2,9 @@ **文档状态**:当前唯一文档入口。 **分类**:入口索引。 -**适用时间**:TRELLIS2 及 2026-07-16 状态收口之后。 +**适用时间**:TRELLIS2 及 2026-07-28 当前任务状态之后。 **关联治理**:当前 Trellis task;`GOV8` 只作历史治理来源。 -**最近审计**:2026-07-16。 +**最近审计**:2026-07-28。 **不得误读**:本文只指示当前事实源和读取顺序;历史 Issue CSV 只能用于追溯,不直接生成新开发任务。 **治理口径**:中文优先,物理精简,优先复用已有文档。已确认不用的历史文档不放 `docs/archive/`,直接从当前工作树移除;需要时从 git 历史恢复。 @@ -24,6 +24,7 @@ - 当前本地 `main` 包含 7 月 P0 可信度修复和后续本地收口,尚未 push、部署或完成公网复检,不能视为线上已生效。 - 旧 OAOPS1 7 天真实试运行只有 Day 1–Day 4,后续中断,缺 Day 5–Day 7、现场人工业务闭环和 Day 7 Go/No-Go;归档只表示生命周期关闭,不代表目标完成。 - 下一轮真实连续试运行必须等待当前可信基线部署并完成公网复核,再新建 Trellis task,从新的 Day 1 连续启动。 +- 当前 `07-28-ceo-finance-fallback` 已完成本地候选实现与聚焦验证:CEO 本人三档报销目标均为唯一 active 非本人 `finance`;尚未部署、生产规则尚未回填,因此不能据此宣布公网可用。 ## 历史事实源链(截至 2026-05) diff --git a/docs/管理员手册.md b/docs/管理员手册.md index c0633525..fb702895 100644 --- a/docs/管理员手册.md +++ b/docs/管理员手册.md @@ -12,7 +12,7 @@ **OSMOKE1 OA-only smoke 账号治理**:企业微信账号数量有限时,不再为了 OA 报销、资产、财务、AP、合同或权限回归额外申请企业微信账号。企微测试账号只服务 HR/考勤链路;公网 OA 自动化使用 OA-only smoke 账号,允许不绑定企微,但必须在 OA 内保持 active、角色正确且可登录。企微/OA 一致性审计会把私有 env 中列出、显式员工号 allowlist 命中,或符合 `SMOKE*`/`smoke-*`/`oa-smoke-*`/测试命名的 OA-only smoke 账号归为 `oaOnlySmokeExempted`,不再当作“缺企微账号”归档;真实员工不得用该命名绕过一致性治理。恢复账号必须先 dry-run,真实修复必须 `--apply` 且设置 `OA_ONLY_SMOKE_REPAIR_APPLY=1`,不得重置密码、不得打印凭据、不得影响真实员工。 **OALAUNCH1 最终启用门禁**:ASSET1D 已完成资产台账公网部署、最小 UI 试录和 3 条脱敏 `EA-DOC` 受控导入;OAOPS1 自动巡检已具备公网只读证据,但正式启用前仍必须补齐真实业务闭环、权限矩阵、备份恢复演练、数据治理培训和 Day 7 Go/No-Go。新增 `scripts/qa/run-oa-final-launch-readiness.sh` 只聚合证据并输出 `PASS_OA_LAUNCH_READY`、`PASS_WITH_FINDINGS`、`PAUSED_MANUAL_CONFIRMATION` 或 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`;脚本不创建业务数据、不执行付款、不写企业微信、不做数据库恢复。未达到 `PASS_OA_LAUNCH_READY` 前,只能视为小团队稳定化收口中,不能宣布最终正式启用完成。 **OPOLICY1 制度与 OA 对齐口径**:公司制度包已裁剪为“小团队 V1”执行口径,原则是先做到能执行、能留痕、能追责。制度参数、资料留存、用印台账、审批异常 SOP、制度签收和 P2 冻结边界进入 OALAUNCH1 数据治理门禁;预算硬控、复杂招采、电子签章、完整电子档案系统、ERP/银行深集成和 AI 自动改规则不作为正式启用前置条件。 -**OAFLOW1 负责人本人报销兜底审批**:当前业务负责人是 `majianyong`(`ceo`),不配置直属主管且不允许自审;`wangshunli` 仅承担系统开发和系统管理,不是业务审批人。用户确认 CEO 本人报销应由财务审批。现有实现仍只允许 `finance_mgr` 作为 `ownerSelfFallbackRole`,而生产当前只有 active `finance`、没有 active `finance_mgr`,因此 CEO 本人报销在后续最小权限修正完成前会被阻断;不得用 `admin` 代批,也不得把系统管理员配置成假直属主管。 +**OAFLOW1 负责人本人报销兜底审批**:当前业务负责人是 `majianyong`(`ceo`),不配置直属主管且不允许自审;`wangshunli` 仅承担系统开发和系统管理,不是业务审批人。用户确认 CEO 本人报销应由财务审批。`07-28-ceo-finance-fallback` 已在本地实现 `finance|finance_mgr` 兼容、新规则默认 `finance` 和三档 CEO 唯一 `finance` 快照,聚焦回归与前端构建通过;代码尚未部署、生产 3 条启用规则尚未回填,因此当前生产仍按旧 `finance_mgr` 配置阻断。不得用 `admin` 代批,也不得把系统管理员配置成假直属主管。 ## 1. 适用范围 - 适用于 `small_team_internal` 工作区模式。 @@ -32,7 +32,7 @@ - 正式审批组织口径:`majianyong` 配置为公司负责人 `ceo` 且不配置直属主管;`wangshunli` 仅配置系统开发/系统管理职责,不配置 `manager`、`ceo`、`finance` 或 `finance_mgr` 业务角色。 - 普通员工配置有效直属主管;当前唯一 active `manager` 的直属主管配置为 `majianyong`。不得把 `wangshunli` 放入正常业务汇报或审批链。 - `admin` 只代表系统管理权限,不代表业务审批权;需要业务审批时必须配置 `manager`、`ceo`、`finance`、`hr` 等正式业务角色。 - - 缺直属主管、主管停用、主管链断裂或审批节点解析到申请人本人时,应先修正组织或审批规则,不要用管理员账号代批。CEO 本人报销只允许通过审批规则进入 active 非本人财务审批;当前 `finance`/`finance_mgr` 配置缺口未修复前保持阻断,不伪造成可用。 + - 缺直属主管、主管停用、主管链断裂或审批节点解析到申请人本人时,应先修正组织或审批规则,不要用管理员账号代批。CEO 本人报销只允许通过审批规则进入 active 非本人财务审批;本地代码已支持 `finance`,但部署和生产规则回填完成前仍保持阻断,不伪造成可用。 5. 检查功能开关: - 建议开启:`portal_home_v1`、企业微信入口相关开关、报销/OCR、审批待办、请假、项目基础能力和 `payment_batch_v1`。 - 按需开启:`announcements_v1`、`presence_board_v1`、`weekly_report_v1`、`employee_lifecycle_v1`、`hr_wecom_directory_sync_v1`、`todo_board_v1`、`global_search_v1`。 @@ -177,10 +177,11 @@ ORDER BY priority; 管理要求: -1. 当前代码只允许 `owner_self_fallback_role = 'finance_mgr'` 或置空;生产虽已回填该值,但没有 active `finance_mgr` 持有人,因此 CEO 本人报销会阻断。 -2. 用户确认目标口径是由 active 非本人 `finance` 审批。后续修正必须同时检查 `owner_self_fallback_role` 和中/大额规则里的 `finance_mgr` 节点,保证最终快照不残留不可解析节点。 +1. 本地候选代码允许 `owner_self_fallback_role = 'finance' | 'finance_mgr'` 或置空,新建规则默认 `finance`;生产尚未部署该候选,现有启用规则仍为旧 `finance_mgr` 配置,因此 CEO 本人报销继续阻断。 +2. 部署通过后,必须在同一事务内把 3 条启用规则统一为 `finance` 收口:小/中额 `manager -> finance`,大额 `manager -> ceo -> finance`,三档 fallback 均为 `finance`;任一生产前置漂移则零写入停止。 3. 不得为省事把 `admin` 当业务审批角色,也不得仅为绕过阻断给财务人员附加超出实际职责的权限;优先按最小权限调整支持角色和规则。 4. 提交后系统保存审批快照;之后修改规则只影响新单,不回写历史流程。 +5. 生产回填先在部署后的 server 容器内执行 `npm run apply:ceo-finance-fallback -- --dry-run --format=json`;只有结果为 `DRY_RUN_PASS`,才设置一次性写入确认开关并执行同一命令的 `--apply`。脚本只输出状态和计数,任一规则、角色、binding 或受保护计数漂移都会回滚。 财务和系统管理员混授: diff --git a/docs/需求-统一审批中心规约.md b/docs/需求-统一审批中心规约.md index 34cc8719..d93baedd 100644 --- a/docs/需求-统一审批中心规约.md +++ b/docs/需求-统一审批中心规约.md @@ -107,7 +107,7 @@ OA 中既有请假、补卡、外出、加班能力转为历史只读、镜像 ### 2.4 OAFLOW1 负责人本人报销兜底审批 -OAFLOW1 后,OA 主办的报销审批规则增加 `ownerSelfFallbackRole`。当公司负责人本人(当前为 `majianyong`,`ceo`)提交报销且直属主管节点无法解析时,系统应使用审批规则上的 active 非本人财务兜底替换第一层 `manager` 节点。用户确认目标审批人为现有 `finance`;当前代码仍只支持 `finance_mgr`,且生产没有 active `finance_mgr`,所以在后续最小权限修正完成前必须阻断,不能宣称 CEO 本人报销已可用。规则只作用于新提交报销单,提交时保存 `workflow_steps_snapshot`,后续审批推进只读快照,不因之后修改审批规则而漂移。 +OAFLOW1 后,OA 主办的报销审批规则增加 `ownerSelfFallbackRole`。当公司负责人本人(当前为 `majianyong`,`ceo`)提交报销且直属主管节点无法解析时,系统应使用审批规则上的 active 非本人财务兜底替换第一层 `manager` 节点。用户确认目标审批人为现有 `finance`;`07-28-ceo-finance-fallback` 本地候选已支持 `finance|finance_mgr`,新建默认 `finance`,但尚未部署且生产规则尚未回填,所以当前生产仍必须阻断,不能宣称 CEO 本人报销已可用。规则只作用于新提交报销单,提交时保存 `workflow_steps_snapshot`,后续审批推进只读快照,不因之后修改审批规则而漂移。 执行边界: @@ -115,15 +115,15 @@ OAFLOW1 后,OA 主办的报销审批规则增加 `ownerSelfFallbackRole`。当 - 不允许自审;若兜底角色也是申请人本人持有的角色,或没有 active 非本人持有人,提交必须阻断。 - 不支持 `admin` 作为负责人本人报销兜底角色;`admin` 仍只代表系统管理权限,不代表业务审批权。 - 当前首版只覆盖 OA 报销金额规则,不扩展到采购、合同、复杂 BPM 或自由流程。 -- 现有实现只允许 `ownerSelfFallbackRole=finance_mgr` 或置空。后续修正必须让 active 非本人 `finance` 可作为 CEO 兜底,并同步清理中/大额规则中不可解析的 `finance_mgr` 节点;完成前负责人本人申请继续阻断。 +- 本地候选允许 `ownerSelfFallbackRole=finance|finance_mgr` 或置空,并以 `finance` 为新建默认值;部署与生产规则原子回填完成前,负责人本人申请继续阻断。 预期链路示例: | 金额规则 | 原链路 | 负责人本人申请快照 | | --- | --- | --- | -| 小额 | `manager -> finance` | 目标:`finance`;当前因只支持 `finance_mgr` 而阻断 | -| 中额 | `manager -> finance_mgr` | 目标:`finance`;当前兜底及原规则均需修正 | -| 大额 | `manager -> ceo -> finance_mgr` | 目标:`finance`;移除本人 `ceo` 后仍须清理不可解析的 `finance_mgr` 节点 | +| 小额 | `manager -> finance` | `finance` | +| 中额 | `manager -> finance` | `finance` | +| 大额 | `manager -> ceo -> finance` | `finance`;移除本人 `ceo` 并去重 | --- @@ -574,7 +574,7 @@ WF8 当前正式验收通道为站内通知和企业微信个人应用消息。 | 正式主管链路 | 专用员工提交普通低/中额单据;其 active 直属主管处理待办 | 待办分配给实际直属主管,审批历史与组织关系一致 | | 公司负责人二审 | 高额、高风险或例外单据进入 `ceo` 节点 | 串行节点完整,`majianyong(ceo)` 处理负责人节点 | | 主管本人申请 | 当前 `manager` 提交单据 | 主管本人不能自审,按组织链进入 `majianyong(ceo)` 或明确非本人兜底节点 | -| 公司负责人本人申请 | `majianyong` 提交并触发负责人本人兜底 | 不允许自审;目标由 active 非本人 `finance` 审批,当前实现缺口未修复时应阻断 | +| 公司负责人本人申请 | `majianyong` 提交并触发负责人本人兜底 | 不允许自审;active 非本人 `finance` 唯一审批;部署和生产规则回填前应阻断 | | 系统管理员无业务审批权 | 仅 `admin` 角色账号尝试审批业务单据 | 服务端拒绝,不因 `admin` 权限生成审批历史 | | 并发处理 | 两个窗口同时处理同一任务 | 只有一个结果生效,另一个提示任务已变化 | | 通知失败不阻断 | 模拟企业微信通知发送失败 | 审批提交或处理成功,通知记录进入失败可重试 | diff --git a/scripts/qa/run-backup-restore-drill.sh b/scripts/qa/run-backup-restore-drill.sh index 7d9dd2bf..b38ee3e6 100755 --- a/scripts/qa/run-backup-restore-drill.sh +++ b/scripts/qa/run-backup-restore-drill.sh @@ -86,6 +86,30 @@ if command -v node >/dev/null 2>&1; then exit 1 fi + if deploy_error="$(DEPLOY_SERVER=unused REMOTE_DIR=/srv/hyb-oa/app \ + POSTGRES_DATA_DIR=/srv/hyb-oa/app/data/postgres \ + STORAGE_HOST_DIR=/srv/hyb-oa/storage DEPLOY_DATA_MODE=existing \ + "$ROOT_DIR/deploy/trialrun/deploy.sh" 2>&1)"; then + echo "[ERR] formal deploy accepted PostgreSQL data nested in the source sync root" >&2 + exit 1 + fi + if [[ "$deploy_error" != *"POSTGRES_DATA_DIR 不得与 REMOTE_DIR"* ]]; then + echo "[ERR] nested PostgreSQL path did not fail at the source-sync isolation guard" >&2 + exit 1 + fi + + if deploy_error="$(DEPLOY_SERVER=unused REMOTE_DIR=/srv/hyb-oa/app \ + POSTGRES_DATA_DIR=/srv/hyb-oa/postgres \ + STORAGE_HOST_DIR=/srv/hyb-oa/app/data/storage DEPLOY_DATA_MODE=existing \ + "$ROOT_DIR/deploy/trialrun/deploy.sh" 2>&1)"; then + echo "[ERR] formal deploy accepted attachment storage nested in the source sync root" >&2 + exit 1 + fi + if [[ "$deploy_error" != *"STORAGE_HOST_DIR 不得与 REMOTE_DIR"* ]]; then + echo "[ERR] nested storage path did not fail at the source-sync isolation guard" >&2 + exit 1 + fi + MUTATED_PORT_COMPOSE="$DRILL_DIR/docker-compose-db-port.yml" sed '/ db:/a\ ports:\n - "5432:5432"' \ "$ROOT_DIR/deploy/trialrun/docker-compose.yml" >"$MUTATED_PORT_COMPOSE" diff --git a/server/package.json b/server/package.json index ec67e519..86959faa 100644 --- a/server/package.json +++ b/server/package.json @@ -16,6 +16,7 @@ "prisma:migrate:dev": "prisma migrate dev", "prisma:migrate:deploy": "prisma migrate deploy", "seed:config": "node src/scripts/seed-config.js", + "apply:ceo-finance-fallback": "node src/scripts/apply-ceo-finance-fallback.js", "seed:dev-users": "node src/scripts/seed-dev.js", "seed:dev": "node src/scripts/dev-seed-safety.js && npm run seed:config && npm run seed:dev-users", "seed:trialrun": "node src/scripts/seed-trialrun.js", diff --git a/server/src/routes/approvalRules.js b/server/src/routes/approvalRules.js index d4e326d2..17ce4105 100644 --- a/server/src/routes/approvalRules.js +++ b/server/src/routes/approvalRules.js @@ -4,10 +4,12 @@ const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); const { ROLE_PERMISSIONS } = require("../constants/permissions"); -const { BUSINESS_APPROVAL_ROLE_CODES } = require("../utils/workflow"); +const { + BUSINESS_APPROVAL_ROLE_CODES, + OWNER_SELF_FALLBACK_ROLE_CODES, +} = require("../utils/workflow"); const MONEY_STRING_SCHEMA = z.string().regex(/^\d+(\.\d{1,2})?$/); -const OWNER_SELF_FALLBACK_ROLE_CODES = ["finance_mgr"]; const REIMBURSEMENT_APPROVAL_ROLE_CODES = BUSINESS_APPROVAL_ROLE_CODES.filter( (roleCode) => (ROLE_PERMISSIONS[roleCode] ?? []).includes("expense.approve"), ); @@ -144,7 +146,7 @@ async function approvalRulesRoutes(fastify) { level3_role: body.level3Role ?? null, owner_self_fallback_role: Object.prototype.hasOwnProperty.call(body, "ownerSelfFallbackRole") ? body.ownerSelfFallbackRole ?? null - : "finance_mgr", + : "finance", enabled: body.enabled ?? true, priority, created_by: request.employee.id, diff --git a/server/src/scripts/apply-ceo-finance-fallback.js b/server/src/scripts/apply-ceo-finance-fallback.js new file mode 100644 index 00000000..ade7d20f --- /dev/null +++ b/server/src/scripts/apply-ceo-finance-fallback.js @@ -0,0 +1,303 @@ +require("dotenv/config"); + +const crypto = require("node:crypto"); +const { Pool } = require("pg"); + +const APPLY_ENV_KEY = "CEO_FINANCE_FALLBACK_APPLY"; +const TARGET_BY_BAND = Object.freeze({ + small: Object.freeze({ + level1_role: "manager", + level2_role: "finance", + level3_role: null, + owner_self_fallback_role: "finance", + }), + medium: Object.freeze({ + level1_role: "manager", + level2_role: "finance", + level3_role: null, + owner_self_fallback_role: "finance", + }), + large: Object.freeze({ + level1_role: "manager", + level2_role: "ceo", + level3_role: "finance", + owner_self_fallback_role: "finance", + }), +}); + +const EXPECTED_CURRENT_BY_BAND = Object.freeze({ + small: Object.freeze({ + level1_role: "manager", + level2_role: "finance", + level3_role: null, + owner_self_fallback_role: "finance_mgr", + }), + medium: Object.freeze({ + level1_role: "manager", + level2_role: "finance_mgr", + level3_role: null, + owner_self_fallback_role: "finance_mgr", + }), + large: Object.freeze({ + level1_role: "manager", + level2_role: "ceo", + level3_role: "finance_mgr", + owner_self_fallback_role: "finance_mgr", + }), +}); + +function parseArgs(args) { + const options = { apply: false, format: "text" }; + for (const arg of args) { + if (arg === "--apply") options.apply = true; + else if (arg === "--dry-run") options.apply = false; + else if (arg === "--format=json") options.format = "json"; + else if (arg === "--help" || arg === "-h") options.help = true; + else throw new Error(`Unsupported argument: ${arg}`); + } + return options; +} + +function getBand(row) { + const min = Number(row.min_amount); + const max = row.max_amount == null ? null : Number(row.max_amount); + if (min === 0 && max === 500) return "small"; + if (min === 500 && max === 5000) return "medium"; + if (min === 5000 && max == null) return "large"; + return null; +} + +function sameRoles(row, expected) { + return ["level1_role", "level2_role", "level3_role", "owner_self_fallback_role"] + .every((field) => (row[field] ?? null) === expected[field]); +} + +function buildRulePlan(rows) { + if (!Array.isArray(rows) || rows.length !== 3) { + throw new Error("approval_rule_count_drift"); + } + + const plan = []; + const seen = new Set(); + for (const row of rows) { + const band = getBand(row); + if (!band || seen.has(band)) { + throw new Error("approval_rule_amount_band_drift"); + } + if (!sameRoles(row, EXPECTED_CURRENT_BY_BAND[band])) { + throw new Error(`approval_rule_role_drift:${band}`); + } + seen.add(band); + plan.push({ id: row.id, band, before: EXPECTED_CURRENT_BY_BAND[band], after: TARGET_BY_BAND[band] }); + } + + if (seen.size !== 3) throw new Error("approval_rule_amount_band_drift"); + return plan.sort((left, right) => ["small", "medium", "large"].indexOf(left.band) + - ["small", "medium", "large"].indexOf(right.band)); +} + +function assertPeopleBaseline({ ceos, finances, admins }) { + if (ceos.length !== 1) throw new Error("active_ceo_count_drift"); + if (ceos[0].direct_manager_id != null) throw new Error("ceo_manager_drift"); + if (!ceos[0].has_wecom_binding) throw new Error("ceo_binding_missing"); + if (ceos[0].roles.includes("finance") || ceos[0].roles.includes("finance_mgr")) { + throw new Error("ceo_holds_fallback_role"); + } + if (finances.length !== 1) throw new Error("active_non_ceo_finance_count_drift"); + if (!finances[0].has_wecom_binding) throw new Error("finance_binding_missing"); + if (admins.length !== 1) throw new Error("active_admin_actor_count_drift"); +} + +async function loadRoleHolders(client, roleCode, excludedEmployeeId = null) { + const result = await client.query( + ` + SELECT + e.id::text, + e.direct_manager_id::text, + EXISTS ( + SELECT 1 FROM wecom_identity_bindings w WHERE w.employee_id = e.id + ) AS has_wecom_binding, + COALESCE(array_agg(all_roles.role_code::text ORDER BY all_roles.role_code::text), ARRAY[]::text[]) AS roles + FROM employees e + JOIN employee_roles selected_role + ON selected_role.employee_id = e.id + AND selected_role.role_code = $1::"RoleCode" + LEFT JOIN employee_roles all_roles ON all_roles.employee_id = e.id + WHERE e.status = 'active' + AND e.archived_at IS NULL + AND ($2::uuid IS NULL OR e.id <> $2::uuid) + GROUP BY e.id, e.direct_manager_id + ORDER BY e.id + `, + [roleCode, excludedEmployeeId], + ); + return result.rows; +} + +async function loadInvariantCounts(client) { + const result = await client.query(` + SELECT + (SELECT COUNT(*)::int FROM employees WHERE status = 'active' AND archived_at IS NULL) AS active_employees, + ( + SELECT COUNT(*)::int + FROM employee_roles er + JOIN employees e ON e.id = er.employee_id + WHERE e.status = 'active' AND e.archived_at IS NULL + ) AS active_role_rows, + ( + SELECT COUNT(*)::int + FROM wecom_identity_bindings w + JOIN employees e ON e.id = w.employee_id + WHERE e.status = 'active' AND e.archived_at IS NULL + ) AS active_bindings, + ( + SELECT COUNT(*)::int + FROM workflow_instances + WHERE context_data ? 'workflow_steps_snapshot' + ) AS workflow_snapshots + `); + return result.rows[0]; +} + +function countsEqual(left, right) { + return ["active_employees", "active_role_rows", "active_bindings", "workflow_snapshots"] + .every((field) => Number(left[field]) === Number(right[field])); +} + +async function execute(options, pool = new Pool({ connectionString: process.env.DATABASE_URL })) { + if (!process.env.DATABASE_URL) throw new Error("missing_database_url"); + if (options.apply && process.env[APPLY_ENV_KEY] !== "1") { + throw new Error(`apply_requires_${APPLY_ENV_KEY}`); + } + + const client = await pool.connect(); + let transactionOpen = false; + try { + await client.query("BEGIN ISOLATION LEVEL SERIALIZABLE"); + transactionOpen = true; + + const rulesResult = await client.query(` + SELECT id::text, min_amount::text, max_amount::text, + level1_role::text, level2_role::text, level3_role::text, + owner_self_fallback_role::text + FROM approval_rules + WHERE enabled = true + ORDER BY priority, id + FOR UPDATE + `); + const plan = buildRulePlan(rulesResult.rows); + + const ceos = await loadRoleHolders(client, "ceo"); + const finances = await loadRoleHolders(client, "finance", ceos[0]?.id ?? null); + const admins = await loadRoleHolders(client, "admin"); + assertPeopleBaseline({ ceos, finances, admins }); + + const beforeCounts = await loadInvariantCounts(client); + const requestId = `ceo-finance-fallback-${crypto.randomUUID()}`; + for (const item of plan) { + const updated = await client.query( + ` + UPDATE approval_rules + SET level1_role = $2::"RoleCode", + level2_role = $3::"RoleCode", + level3_role = $4::"RoleCode", + owner_self_fallback_role = $5::"RoleCode", + updated_by = $6::uuid, + updated_at = NOW() + WHERE id = $1::uuid + RETURNING id::text + `, + [item.id, item.after.level1_role, item.after.level2_role, item.after.level3_role, + item.after.owner_self_fallback_role, admins[0].id], + ); + if (updated.rowCount !== 1) throw new Error(`approval_rule_update_failed:${item.band}`); + + await client.query( + ` + INSERT INTO audit_logs + (request_id, actor_id, action, target_type, target_id, ip_address, user_agent, extra) + VALUES + ($1, $2::uuid, 'APPROVAL_RULE_UPDATED', 'approval_rule', $3::uuid, NULL, + 'controlled-script/07-28-ceo-finance-fallback', $4::jsonb) + `, + [requestId, admins[0].id, item.id, JSON.stringify({ + task: "07-28-ceo-finance-fallback", + band: item.band, + before: item.before, + after: item.after, + })], + ); + } + + const afterRules = await client.query(` + SELECT id::text, min_amount::text, max_amount::text, + level1_role::text, level2_role::text, level3_role::text, + owner_self_fallback_role::text + FROM approval_rules + WHERE enabled = true + ORDER BY priority, id + `); + if (afterRules.rows.length !== 3 || afterRules.rows.some((row) => { + const band = getBand(row); + return !band || !sameRoles(row, TARGET_BY_BAND[band]); + })) { + throw new Error("approval_rule_postcondition_failed"); + } + + const afterCounts = await loadInvariantCounts(client); + if (!countsEqual(beforeCounts, afterCounts)) throw new Error("protected_invariant_changed"); + + if (options.apply) { + await client.query("COMMIT"); + transactionOpen = false; + } else { + await client.query("ROLLBACK"); + transactionOpen = false; + } + + return { + status: options.apply ? "APPLIED" : "DRY_RUN_PASS", + rules: plan.length, + auditRows: options.apply ? plan.length : 0, + protectedCountsStable: true, + realReimbursementsCreated: 0, + wecomWrites: 0, + }; + } catch (error) { + if (transactionOpen) await client.query("ROLLBACK").catch(() => {}); + throw error; + } finally { + client.release(); + await pool.end(); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + process.stdout.write("Usage: node src/scripts/apply-ceo-finance-fallback.js [--dry-run|--apply] [--format=json]\n"); + return; + } + const result = await execute(options); + process.stdout.write(options.format === "json" ? `${JSON.stringify(result)}\n` : [ + `[ceo-finance-fallback] status=${result.status}`, + `[ceo-finance-fallback] rules=${result.rules} auditRows=${result.auditRows}`, + `[ceo-finance-fallback] protectedCountsStable=${result.protectedCountsStable}`, + ].join("\n") + "\n"); +} + +if (require.main === module) { + main().catch((error) => { + process.stderr.write(`[ceo-finance-fallback] failed=${error.message}\n`); + process.exit(1); + }); +} + +module.exports = { + EXPECTED_CURRENT_BY_BAND, + TARGET_BY_BAND, + assertPeopleBaseline, + buildRulePlan, + getBand, + parseArgs, +}; diff --git a/server/src/scripts/seed-config.js b/server/src/scripts/seed-config.js index 38a4c29d..1cdf61a8 100644 --- a/server/src/scripts/seed-config.js +++ b/server/src/scripts/seed-config.js @@ -56,7 +56,7 @@ const DEFAULT_APPROVAL_RULES = [ level1_role: "manager", level2_role: "finance", level3_role: null, - owner_self_fallback_role: "finance_mgr", + owner_self_fallback_role: "finance", priority: 10, }, { @@ -64,9 +64,9 @@ const DEFAULT_APPROVAL_RULES = [ min_amount: "500", max_amount: "5000", level1_role: "manager", - level2_role: "finance_mgr", + level2_role: "finance", level3_role: null, - owner_self_fallback_role: "finance_mgr", + owner_self_fallback_role: "finance", priority: 20, }, { @@ -75,8 +75,8 @@ const DEFAULT_APPROVAL_RULES = [ max_amount: null, level1_role: "manager", level2_role: "ceo", - level3_role: "finance_mgr", - owner_self_fallback_role: "finance_mgr", + level3_role: "finance", + owner_self_fallback_role: "finance", priority: 30, }, ]; @@ -563,7 +563,11 @@ async function seedExpenseTypes(prisma) { } async function seedApprovalRules(prisma) { - await prisma.approvalRule.deleteMany({}); + const existingCount = await prisma.approvalRule.count(); + if (existingCount > 0) { + return { created: 0, preserved: existingCount }; + } + await prisma.approvalRule.createMany({ data: DEFAULT_APPROVAL_RULES.map((item) => ({ ...item, @@ -572,6 +576,8 @@ async function seedApprovalRules(prisma) { updated_by: null, })), }); + + return { created: DEFAULT_APPROVAL_RULES.length, preserved: 0 }; } async function seedDocumentSequence(prisma) { @@ -1022,7 +1028,7 @@ async function main() { try { await seedExpenseTypes(prisma); - await seedApprovalRules(prisma); + const approvalRuleSeed = await seedApprovalRules(prisma); await seedDocumentSequence(prisma); await seedDocumentCategories(prisma); await seedWorkflowDefinition(prisma); @@ -1037,7 +1043,9 @@ async function main() { // eslint-disable-next-line no-console console.log(`- expense types: ${ENABLED_EXPENSE_TYPES.length} enabled + ${DISABLED_LEGACY_EXPENSE_TYPES.length} disabled`); // eslint-disable-next-line no-console - console.log("- approval rules: 3"); + console.log( + `- approval rules: ${approvalRuleSeed.created} created / ${approvalRuleSeed.preserved} preserved`, + ); // eslint-disable-next-line no-console console.log("- document sequence: reimbursement(reset_period=none)"); // eslint-disable-next-line no-console @@ -1070,8 +1078,15 @@ async function main() { } } -main().catch((error) => { - // eslint-disable-next-line no-console - console.error(error); - process.exit(1); -}); +if (require.main === module) { + main().catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + process.exit(1); + }); +} + +module.exports = { + DEFAULT_APPROVAL_RULES, + seedApprovalRules, +}; diff --git a/server/src/utils/workflow.js b/server/src/utils/workflow.js index c532b8a4..9876e565 100644 --- a/server/src/utils/workflow.js +++ b/server/src/utils/workflow.js @@ -19,7 +19,8 @@ const BUSINESS_APPROVAL_ROLE_CODES = Object.freeze([ "administration", ]); const BUSINESS_APPROVAL_ROLE_CODE_SET = new Set(BUSINESS_APPROVAL_ROLE_CODES); -const SUPPORTED_OWNER_SELF_FALLBACK_ROLES = new Set(["finance_mgr"]); +const OWNER_SELF_FALLBACK_ROLE_CODES = Object.freeze(["finance", "finance_mgr"]); +const SUPPORTED_OWNER_SELF_FALLBACK_ROLES = new Set(OWNER_SELF_FALLBACK_ROLE_CODES); const OWNER_SELF_FALLBACK_REASONS = new Set([ "no_manager_found", "chain_broken", @@ -555,6 +556,7 @@ function toMoneyString(value) { module.exports = { BUSINESS_APPROVAL_ROLE_CODES, + OWNER_SELF_FALLBACK_ROLE_CODES, buildApprovalWorkflowSnapshot, buildWorkflowStepsFromApprovalRule, getWorkflowStepsSnapshot, diff --git a/server/tests/unit/routes/approvalRules.permissions.test.js b/server/tests/unit/routes/approvalRules.permissions.test.js index 468663b7..9f67dc78 100644 --- a/server/tests/unit/routes/approvalRules.permissions.test.js +++ b/server/tests/unit/routes/approvalRules.permissions.test.js @@ -14,7 +14,7 @@ function buildCreatePayload(roleCode) { level1Role: roleCode, level2Role: null, level3Role: null, - ownerSelfFallbackRole: "finance_mgr", + ownerSelfFallbackRole: "finance", enabled: true, priority: 1, }; @@ -82,6 +82,74 @@ async function buildApp() { } describe("reimbursement approval-rule role whitelist", () => { + it.each(["finance", "finance_mgr"])( + "allows owner self fallback role %s", + async (fallbackRole) => { + const testApp = await buildApp(); + try { + const response = await testApp.app.inject({ + method: "POST", + url: "/api/approval-rules", + payload: { + ...buildCreatePayload("manager"), + ownerSelfFallbackRole: fallbackRole, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.ownerSelfFallbackRole).toBe(fallbackRole); + } finally { + await testApp.app.close(); + } + }, + ); + + it("defaults owner self fallback to finance when omitted", async () => { + const testApp = await buildApp(); + try { + const payload = buildCreatePayload("manager"); + delete payload.ownerSelfFallbackRole; + const response = await testApp.app.inject({ + method: "POST", + url: "/api/approval-rules", + payload, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.ownerSelfFallbackRole).toBe("finance"); + expect(testApp.approvalRuleCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ owner_self_fallback_role: "finance" }), + }), + ); + } finally { + await testApp.app.close(); + } + }); + + it.each(["admin", "system_admin", "hr"])( + "rejects owner self fallback role %s", + async (fallbackRole) => { + const testApp = await buildApp(); + try { + const response = await testApp.app.inject({ + method: "POST", + url: "/api/approval-rules", + payload: { + ...buildCreatePayload("manager"), + ownerSelfFallbackRole: fallbackRole, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe("VALID_001"); + expect(testApp.transaction).not.toHaveBeenCalled(); + } finally { + await testApp.app.close(); + } + }, + ); + it.each(["manager", "ceo", "finance", "finance_mgr"])( "allows role %s because it carries expense.approve", async (roleCode) => { diff --git a/server/tests/unit/routes/reimbursements.expense-standards.test.js b/server/tests/unit/routes/reimbursements.expense-standards.test.js index 28ecd8a9..c8ab130e 100644 --- a/server/tests/unit/routes/reimbursements.expense-standards.test.js +++ b/server/tests/unit/routes/reimbursements.expense-standards.test.js @@ -399,7 +399,7 @@ describe("reimbursements routes expense standard integration", () => { await app.close(); }); - it("uses finance_mgr fallback and persists the adjusted workflow snapshot for CEO owner submit", async () => { + it("uses one finance fallback and persists the adjusted workflow snapshot for CEO owner submit", async () => { resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); const { app, state } = await buildApp({ @@ -409,8 +409,8 @@ describe("reimbursements routes expense standard integration", () => { id: "55555555-5555-4555-8555-555555555555", level1_role: "manager", level2_role: "finance", - level3_role: "ceo", - owner_self_fallback_role: "finance_mgr", + level3_role: null, + owner_self_fallback_role: "finance", priority: 1, }, }); @@ -424,7 +424,7 @@ describe("reimbursements routes expense standard integration", () => { expect(state.workflowTaskCreatePayload).toEqual( expect.objectContaining({ assignee_id: null, - assignee_role: "finance_mgr", + assignee_role: "finance", }), ); expect(state.workflowInstanceCreatePayload.current_step_id).toContain("owner_self_fallback"); @@ -432,10 +432,7 @@ describe("reimbursements routes expense standard integration", () => { state.workflowInstanceCreatePayload.context_data.workflow_steps_snapshot.map( (step) => step.assignee_rule, ), - ).toEqual([ - { type: "role", value: "finance_mgr" }, - { type: "role", value: "finance" }, - ]); + ).toEqual([{ type: "role", value: "finance" }]); await app.close(); }); @@ -452,7 +449,7 @@ describe("reimbursements routes expense standard integration", () => { level1_role: "manager", level2_role: "finance", level3_role: null, - owner_self_fallback_role: "finance_mgr", + owner_self_fallback_role: "finance", priority: 1, }, }); @@ -467,7 +464,7 @@ describe("reimbursements routes expense standard integration", () => { expect.objectContaining({ workflow: "no_actionable_approver", reason: "owner_self_fallback_role_unavailable", - fallbackRole: "finance_mgr", + fallbackRole: "finance", }), ); expect(tx.workflowInstance.create).not.toHaveBeenCalled(); diff --git a/server/tests/unit/scripts/apply-ceo-finance-fallback.test.js b/server/tests/unit/scripts/apply-ceo-finance-fallback.test.js new file mode 100644 index 00000000..659567ca --- /dev/null +++ b/server/tests/unit/scripts/apply-ceo-finance-fallback.test.js @@ -0,0 +1,103 @@ +const { + assertPeopleBaseline, + buildRulePlan, + parseArgs, +} = require("../../../src/scripts/apply-ceo-finance-fallback"); + +function currentRows() { + return [ + { + id: "small-id", + min_amount: "0.00", + max_amount: "500.00", + level1_role: "manager", + level2_role: "finance", + level3_role: null, + owner_self_fallback_role: "finance_mgr", + }, + { + id: "medium-id", + min_amount: "500.00", + max_amount: "5000.00", + level1_role: "manager", + level2_role: "finance_mgr", + level3_role: null, + owner_self_fallback_role: "finance_mgr", + }, + { + id: "large-id", + min_amount: "5000.00", + max_amount: null, + level1_role: "manager", + level2_role: "ceo", + level3_role: "finance_mgr", + owner_self_fallback_role: "finance_mgr", + }, + ]; +} + +describe("CEO finance fallback production apply contract", () => { + it("builds the exact three-band target without exposing identifiers in the result shape", () => { + const plan = buildRulePlan(currentRows()); + expect(plan.map((item) => ({ band: item.band, after: item.after }))).toEqual([ + { + band: "small", + after: { + level1_role: "manager", + level2_role: "finance", + level3_role: null, + owner_self_fallback_role: "finance", + }, + }, + { + band: "medium", + after: { + level1_role: "manager", + level2_role: "finance", + level3_role: null, + owner_self_fallback_role: "finance", + }, + }, + { + band: "large", + after: { + level1_role: "manager", + level2_role: "ceo", + level3_role: "finance", + owner_self_fallback_role: "finance", + }, + }, + ]); + }); + + it.each([ + [currentRows().slice(0, 2), "approval_rule_count_drift"], + [[...currentRows(), currentRows()[0]], "approval_rule_count_drift"], + [currentRows().map((row, index) => index === 1 ? { ...row, level2_role: "finance" } : row), "approval_rule_role_drift:medium"], + [currentRows().map((row, index) => index === 2 ? { ...row, min_amount: "6000" } : row), "approval_rule_amount_band_drift"], + ])("fails closed on approval-rule drift", (rows, message) => { + expect(() => buildRulePlan(rows)).toThrow(message); + }); + + it("requires one bound CEO without manager/fallback role, one bound non-CEO finance, and one admin", () => { + expect(() => assertPeopleBaseline({ + ceos: [{ direct_manager_id: null, has_wecom_binding: true, roles: ["ceo"] }], + finances: [{ has_wecom_binding: true, roles: ["finance"] }], + admins: [{ roles: ["admin"] }], + })).not.toThrow(); + + expect(() => assertPeopleBaseline({ ceos: [], finances: [], admins: [] })) + .toThrow("active_ceo_count_drift"); + expect(() => assertPeopleBaseline({ + ceos: [{ direct_manager_id: null, has_wecom_binding: true, roles: ["ceo", "finance"] }], + finances: [{ has_wecom_binding: true, roles: ["finance"] }], + admins: [{ roles: ["admin"] }], + })).toThrow("ceo_holds_fallback_role"); + }); + + it("defaults to dry-run and rejects unknown arguments", () => { + expect(parseArgs([])).toEqual({ apply: false, format: "text" }); + expect(parseArgs(["--apply", "--format=json"])).toEqual({ apply: true, format: "json" }); + expect(() => parseArgs(["--force"])).toThrow("Unsupported argument"); + }); +}); diff --git a/server/tests/unit/scripts/seed-config.defaults.test.js b/server/tests/unit/scripts/seed-config.defaults.test.js new file mode 100644 index 00000000..737a90f8 --- /dev/null +++ b/server/tests/unit/scripts/seed-config.defaults.test.js @@ -0,0 +1,67 @@ +const { + DEFAULT_APPROVAL_RULES, + seedApprovalRules, +} = require("../../../src/scripts/seed-config"); + +describe("small-team default reimbursement approval rules", () => { + it("uses finance for every owner fallback and finance review node", () => { + expect(DEFAULT_APPROVAL_RULES).toEqual([ + expect.objectContaining({ + name: "小额", + level1_role: "manager", + level2_role: "finance", + level3_role: null, + owner_self_fallback_role: "finance", + }), + expect.objectContaining({ + name: "中额", + level1_role: "manager", + level2_role: "finance", + level3_role: null, + owner_self_fallback_role: "finance", + }), + expect.objectContaining({ + name: "大额", + level1_role: "manager", + level2_role: "ceo", + level3_role: "finance", + owner_self_fallback_role: "finance", + }), + ]); + }); + + it("preserves existing production approval rules instead of replacing them", async () => { + const approvalRule = { + count: vi.fn().mockResolvedValue(3), + createMany: vi.fn(), + deleteMany: vi.fn(), + }; + + await expect(seedApprovalRules({ approvalRule })).resolves.toEqual({ + created: 0, + preserved: 3, + }); + expect(approvalRule.createMany).not.toHaveBeenCalled(); + expect(approvalRule.deleteMany).not.toHaveBeenCalled(); + }); + + it("creates the three defaults only for an empty approval-rule table", async () => { + const approvalRule = { + count: vi.fn().mockResolvedValue(0), + createMany: vi.fn().mockResolvedValue({ count: 3 }), + }; + + await expect(seedApprovalRules({ approvalRule })).resolves.toEqual({ + created: 3, + preserved: 0, + }); + expect(approvalRule.createMany).toHaveBeenCalledWith({ + data: DEFAULT_APPROVAL_RULES.map((item) => ({ + ...item, + enabled: true, + created_by: null, + updated_by: null, + })), + }); + }); +}); diff --git a/server/tests/unit/utils/workflow.test.js b/server/tests/unit/utils/workflow.test.js index ec84bb0c..362d3b66 100644 --- a/server/tests/unit/utils/workflow.test.js +++ b/server/tests/unit/utils/workflow.test.js @@ -164,6 +164,28 @@ describe("workflow assignee resolution", () => { expect(resolved.actionable.assigneeRole).toBe("finance_mgr"); }); + it.each([ + ["small", { level1_role: "manager", level2_role: "finance", level3_role: null }], + ["medium", { level1_role: "manager", level2_role: "finance", level3_role: null }], + ["large", { level1_role: "manager", level2_role: "ceo", level3_role: "finance" }], + ])("uses one finance fallback node for CEO owner %s rules", async (_name, rule) => { + const tx = buildTx({ ownerRoles: ["ceo"], managerId: null }); + + const steps = await workflowUtils.buildApprovalWorkflowSnapshot({ + tx, + ownerId: OWNER_ID, + approvalRule: { + ...rule, + owner_self_fallback_role: "finance", + }, + }); + + expect(steps.map((step) => step.assignee_rule)).toEqual([ + { type: "role", value: "finance" }, + ]); + expect(steps[0].id).toContain("owner_self_fallback"); + }); + it("removes CEO self node and duplicate fallback role for large CEO owner applications", async () => { const tx = buildTx({ ownerRoles: ["ceo"], managerId: null }); @@ -225,7 +247,7 @@ describe("workflow assignee resolution", () => { ownerId: OWNER_ID, approvalRule: { level1_role: "manager", - owner_self_fallback_role: "finance_mgr", + owner_self_fallback_role: "finance", }, }), ).rejects.toMatchObject({ @@ -238,7 +260,7 @@ describe("workflow assignee resolution", () => { it("blocks fallback when the owner also holds the fallback role", async () => { const tx = buildTx({ - ownerRoles: ["ceo", "finance_mgr"], + ownerRoles: ["ceo", "finance"], managerId: null, }); @@ -248,7 +270,7 @@ describe("workflow assignee resolution", () => { ownerId: OWNER_ID, approvalRule: { level1_role: "manager", - owner_self_fallback_role: "finance_mgr", + owner_self_fallback_role: "finance", }, }), ).rejects.toMatchObject({