修正CEO报销财务兜底与部署护栏(07-28-ceo-finance-fallback)
这个提交包含在:
@@ -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=<verified-12-char-prefix> \
|
||||
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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
|
||||
@@ -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`,权限面更小。
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
|
||||
@@ -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 报销验证未取得单独批准。
|
||||
@@ -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`。
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ApprovalRoleCode>("manager");
|
||||
const [level2Role, setLevel2Role] = useState<ApprovalRoleCode | "">("");
|
||||
const [level3Role, setLevel3Role] = useState<ApprovalRoleCode | "">("");
|
||||
const [ownerSelfFallbackRole, setOwnerSelfFallbackRole] = useState<ApprovalOwnerSelfFallbackRoleCode | "">("finance_mgr");
|
||||
const [ownerSelfFallbackRole, setOwnerSelfFallbackRole] = useState<ApprovalOwnerSelfFallbackRoleCode | "">("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() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-zinc-500">推荐 finance_mgr;不使用 admin。</p>
|
||||
<p className="text-xs text-zinc-500">推荐财务;系统管理员不参与业务审批。</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-6">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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://<oa-domain>/ready` 或首页登录完成最小探活
|
||||
- [ ] 首次上线先执行系统初始化(migration + seed),不要直接导入旧库整库备份
|
||||
- [ ] 若要导数据,仅准备最小主数据:部门、员工、角色、在用资产、活跃项目、合同元数据
|
||||
|
||||
+30
-3
@@ -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' \
|
||||
|
||||
@@ -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=<ssh-target> REMOTE_DIR=<remote-dir> COMPOSE_PROJECT_NAME=<approved-compose-project> POSTGRES_DATA_DIR=<absolute-pgdata> STORAGE_HOST_DIR=<absolute-storage> DEPLOY_DATA_MODE=existing bash deploy/trialrun/deploy.sh`(推荐);脚本只同步已验证提交的 Git archive,不依赖远端 `git pull`,也不会把本地 ignored 文件带入发布。
|
||||
2) 从本地仓库执行 `DEPLOY_SERVER=<ssh-target> REMOTE_DIR=<remote-dir> COMPOSE_PROJECT_NAME=<approved-compose-project> POSTGRES_DATA_DIR=<absolute-pgdata> STORAGE_HOST_DIR=<absolute-storage> 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` 清理远端历史证据。
|
||||
|
||||
@@ -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"',
|
||||
|
||||
@@ -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 后账号重跑 | 后续新证据 |
|
||||
|
||||
|
||||
+8
-8
@@ -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` 代批或恢复整库绕过。
|
||||
- 既有审批快照不重路由,过去提交失败的单据也不会自动恢复。当前本地候选尚未部署、生产规则尚未回填,因此公网仍按旧配置阻断。
|
||||
|
||||
+1
-1
@@ -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 页默认视图和动态详情页证据,不直接改业务代码 |
|
||||
|
||||
+3
-2
@@ -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)
|
||||
|
||||
|
||||
+5
-4
@@ -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 或受保护计数漂移都会回滚。
|
||||
|
||||
财务和系统管理员混授:
|
||||
|
||||
|
||||
+6
-6
@@ -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` 权限生成审批历史 |
|
||||
| 并发处理 | 两个窗口同时处理同一任务 | 只有一个结果生效,另一个提示任务已变化 |
|
||||
| 通知失败不阻断 | 模拟企业微信通知发送失败 | 审批提交或处理成功,通知记录进入失败可重试 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
在新工单中引用
屏蔽一个用户