修正CEO报销财务兜底与部署护栏(07-28-ceo-finance-fallback)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-07-28 13:42:48 +08:00
父节点 37b625c5dc
当前提交 35948729cf
共修改 31 个文件,包含 980 行新增和 74 行删除
+49 -4
查看文件
@@ -608,9 +608,10 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready"
### 1. Scope / Trigger ### 1. Scope / Trigger
- Trigger: changing Compose identity, host data mounts, database publication, - 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 - 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 ### 2. Signatures
@@ -618,7 +619,10 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready"
- PostgreSQL mount: `${POSTGRES_DATA_DIR:-./data/postgres}`. - PostgreSQL mount: `${POSTGRES_DATA_DIR:-./data/postgres}`.
- Application storage mount: `${STORAGE_HOST_DIR:-./data/storage}`. - Application storage mount: `${STORAGE_HOST_DIR:-./data/storage}`.
- Formal deploy inputs: absolute `POSTGRES_DATA_DIR`, absolute - 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 - Timer install inputs: `COMPOSE_PROJECT_NAME`, absolute
`POSTGRES_DATA_DIR`, absolute `STORAGE_HOST_DIR`, full 40–64 hex `POSTGRES_DATA_DIR`, absolute `STORAGE_HOST_DIR`, full 40–64 hex
`BUILD_SHA` and its exact 12-hex `VITE_BUILD_SHA` prefix. `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 - Relative defaults preserve local trialrun compatibility, but production must
use explicit absolute command inputs and verify them against active container use explicit absolute command inputs and verify them against active container
mounts before any start, backup or migration. 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 - `existing` is the normal migration mode and refuses a missing PGDATA marker
or storage directory. `initialize` is only for an approved empty first start; or storage directory. `initialize` is only for an approved empty first start;
only that mode may create data directories. 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. valid absolute paths remain installable.
- Formal deploy omits paths/mode, uses a relative/broad/overlapping path, or an - Formal deploy omits paths/mode, uses a relative/broad/overlapping path, or an
`existing` PGDATA lacks `PG_VERSION` -> fail before DB start. `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 - New project resolves the same PGDATA while the old DB runs -> stop before
Compose start; do not use service health as permission to continue. Compose start; do not use service health as permission to continue.
- Old project name was inferred, immutable image ID or baseline counts are - 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`. - `node deploy/trialrun/validate-env-contract.js`.
- The isolated backup/restore drill mutates Compose with a DB port and fixed - 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. - `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, - Timer contract assertions cover all four explicit inputs, path isolation,
PGDATA marker, SHA/prefix validation and the four systemd `Environment=` PGDATA marker, SHA/prefix validation and the four systemd `Environment=`
entries. On the deployment host, assert timer enabled/active, daily service 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 - Good: one approved project resolves one stopped-and-migrated PGDATA and the
matching active storage path; deploy, timer and backup all use that identity. 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 - 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 deployed full/short SHA pair; Compose config and a non-mutating server exec
resolve under those exact values while the daily service stays inactive. 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. the old container name alone is a rollback point.
- Bad: reinstall the timer with only `COMPOSE_PROJECT_NAME`, allowing systemd - Bad: reinstall the timer with only `COMPOSE_PROJECT_NAME`, allowing systemd
Compose interpolation to fall back to repository-relative data directories. 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 ### 7. Wrong vs Correct
@@ -742,6 +770,23 @@ VITE_BUILD_SHA=<verified-12-char-prefix> \
bash deploy/trialrun/install-warch3-ops-timer.sh install 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 ## Scenario: Paired Backup And Shadow-Only Restore
### 1. Scope / Trigger ### 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": {}
}
+1 -1
查看文件
@@ -8,7 +8,7 @@ export type ApprovalRoleCode =
| "finance_mgr" | "finance_mgr"
| "administration" | "administration"
| "hr"; | "hr";
export type ApprovalOwnerSelfFallbackRoleCode = "finance_mgr"; export type ApprovalOwnerSelfFallbackRoleCode = "finance" | "finance_mgr";
export type ApprovalRule = { export type ApprovalRule = {
id: string; id: string;
+10 -6
查看文件
@@ -27,9 +27,13 @@ const ROLE_OPTIONS: Array<{ code: ApprovalRoleCode; label: string }> = [
{ code: "finance", label: "finance" }, { code: "finance", label: "finance" },
{ code: "finance_mgr", label: "finance_mgr" }, { code: "finance_mgr", label: "finance_mgr" },
]; ];
const OWNER_SELF_FALLBACK_ROLE_OPTIONS = ROLE_OPTIONS.filter( const OWNER_SELF_FALLBACK_ROLE_OPTIONS: Array<{
(role) => role.code === "finance_mgr", code: ApprovalOwnerSelfFallbackRoleCode;
); label: string;
}> = [
{ code: "finance", label: "财务" },
{ code: "finance_mgr", label: "财务负责人(兼容)" },
];
function formatAmount(value: string | null) { function formatAmount(value: string | null) {
if (!value) return "-"; if (!value) return "-";
@@ -47,7 +51,7 @@ export function ApprovalRulesPage() {
const [level1Role, setLevel1Role] = useState<ApprovalRoleCode>("manager"); const [level1Role, setLevel1Role] = useState<ApprovalRoleCode>("manager");
const [level2Role, setLevel2Role] = useState<ApprovalRoleCode | "">(""); const [level2Role, setLevel2Role] = useState<ApprovalRoleCode | "">("");
const [level3Role, setLevel3Role] = 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 [enabled, setEnabled] = useState(true);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
@@ -102,7 +106,7 @@ export function ApprovalRulesPage() {
setLevel1Role("manager"); setLevel1Role("manager");
setLevel2Role(""); setLevel2Role("");
setLevel3Role(""); setLevel3Role("");
setOwnerSelfFallbackRole("finance_mgr"); setOwnerSelfFallbackRole("finance");
setEnabled(true); setEnabled(true);
setError(""); setError("");
toast.success("规则已创建"); toast.success("规则已创建");
@@ -334,7 +338,7 @@ export function ApprovalRulesPage() {
</option> </option>
))} ))}
</select> </select>
<p className="text-xs text-zinc-500">推荐 finance_mgr;不使用 admin。</p> <p className="text-xs text-zinc-500">推荐财务;系统管理员不参与业务审批。</p>
</div> </div>
<div className="flex items-center gap-2 pt-6"> <div className="flex items-center gap-2 pt-6">
@@ -899,7 +899,7 @@ async function mockWave4Apis(page: Page, options: Wave4MockOptions = {}) {
level1Role: "manager", level1Role: "manager",
level2Role: null, level2Role: null,
level3Role: null, level3Role: null,
ownerSelfFallbackRole: "finance_mgr", ownerSelfFallbackRole: "finance",
enabled: true, enabled: true,
priority: 1, priority: 1,
createdAt: "2026-05-29T08:00:00.000Z", createdAt: "2026-05-29T08:00:00.000Z",
@@ -1067,6 +1067,19 @@ test("PC 财务列表使用明确动作入口", async ({ page }) => {
await expect(page.getByTestId("invoice-export-csv")).toBeVisible(); 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 }) => { test("/payment-batches 在 768x1024 与 1024x768 下操作列可达", async ({ page }) => {
await mockWave4Apis(page); await mockWave4Apis(page);
await login(page); await login(page);
+1 -1
查看文件
@@ -16,7 +16,7 @@
- [ ] 已将 OA `Caddyfile` 站点块配置到 `/etc/caddy/Caddyfile` - [ ] 已将 OA `Caddyfile` 站点块配置到 `/etc/caddy/Caddyfile`
- [ ] 已确认 Caddy 反代到 `.env` 中 `APP_HOST_PORT` 对应的宿主回环端口,并用 `header_up X-Forwarded-For {remote_host}` 覆盖客户端 XFF - [ ] 已确认 Caddy 反代到 `.env` 中 `APP_HOST_PORT` 对应的宿主回环端口,并用 `header_up X-Forwarded-For {remote_host}` 覆盖客户端 XFF
- [ ] 已执行 `systemctl reload caddy` - [ ] 已执行 `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` 或首页登录完成最小探活 - [ ] 已通过 `https://<oa-domain>/ready` 或首页登录完成最小探活
- [ ] 首次上线先执行系统初始化(migration + seed),不要直接导入旧库整库备份 - [ ] 首次上线先执行系统初始化(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" EXTERNAL_DEPS_SMOKE_SCRIPT="server/src/scripts/smoke-external-deps.js"
PROVENANCE_SCRIPT="deploy/trialrun/check-source-provenance.mjs" PROVENANCE_SCRIPT="deploy/trialrun/check-source-provenance.mjs"
STAGING_DIR="" STAGING_DIR=""
RSYNC_PROTECT_ARGS=()
cleanup() { cleanup() {
if [[ -n "$STAGING_DIR" && -d "$STAGING_DIR" ]]; then 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 exit 1
fi fi
validate_data_path() { validate_absolute_path() {
local name="$1" local name="$1"
local value="$2" local value="$2"
if [[ -z "$value" || "$value" != /* || ! "$value" =~ ^/[A-Za-z0-9._/-]+$ ]]; then if [[ -z "$value" || "$value" != /* || ! "$value" =~ ^/[A-Za-z0-9._/-]+$ ]]; then
@@ -70,8 +71,16 @@ validate_data_path() {
fi fi
} }
validate_data_path POSTGRES_DATA_DIR "$POSTGRES_DATA_DIR" paths_overlap() {
validate_data_path STORAGE_HOST_DIR "$STORAGE_HOST_DIR" 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" \ if [[ "$POSTGRES_DATA_DIR" == "$STORAGE_HOST_DIR" \
|| "$POSTGRES_DATA_DIR" == "$STORAGE_HOST_DIR"/* \ || "$POSTGRES_DATA_DIR" == "$STORAGE_HOST_DIR"/* \
|| "$STORAGE_HOST_DIR" == "$POSTGRES_DATA_DIR"/* ]]; then || "$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 echo "[ERROR] DEPLOY_DATA_MODE 必须显式设为 existing 或 initialize" >&2
exit 1 exit 1
fi 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 if [[ ! -f "${ROOT_DIR}/${PROVENANCE_SCRIPT}" ]]; then
echo "[ERROR] 缺少 ${PROVENANCE_SCRIPT}" >&2 echo "[ERROR] 缺少 ${PROVENANCE_SCRIPT}" >&2
@@ -139,6 +165,7 @@ ssh "${DEPLOY_SERVER}" "mkdir -p '${REMOTE_DIR}'"
echo ">>> 4. 同步已验证提交到服务器" echo ">>> 4. 同步已验证提交到服务器"
rsync -az --delete \ rsync -az --delete \
"${RSYNC_PROTECT_ARGS[@]}" \
--exclude '.git' \ --exclude '.git' \
--exclude '.github' \ --exclude '.github' \
--exclude '.agent' \ --exclude '.agent' \
+2 -2
查看文件
@@ -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。 - 初始化必须发生在依赖新增列的新 server 切换之前;正式部署脚本会先用新 init 镜像校验生产配置并执行迁移,再替换 server。
- 若 `TRIALRUN_ADMIN_PASSWORD` 为空,seed 会生成一次性口令并输出到控制台(请立刻保存)。 - 若 `TRIALRUN_ADMIN_PASSWORD` 为空,seed 会生成一次性口令并输出到控制台(请立刻保存)。
- 正式启用前,推荐先保持“空基线 + 默认种子”,不要直接导入开发库、测试库或旧 OA 整库备份。 - 正式启用前,推荐先保持“空基线 + 默认种子”,不要直接导入开发库、测试库或旧 OA 整库备份。
@@ -392,7 +392,7 @@ systemctl status hyb-oa-public-app-health.service --no-pager
步骤: 步骤:
1) 先做备份(见 E14 脚本) 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` 必须与活动数据目录复制在同一停机窗口启用;本地提交本身不授权切换。 - 在现网目录统一任务中,新的 `COMPOSE_PROJECT_NAME`、`POSTGRES_DATA_DIR`、`STORAGE_HOST_DIR` 必须与活动数据目录复制在同一停机窗口启用;本地提交本身不授权切换。
- 旧栈停止命令必须使用冻结阶段由 `docker compose ls` 实测的旧 project;不得拿新 project 名去停止旧容器。 - 旧栈停止命令必须使用冻结阶段由 `docker compose ls` 实测的旧 project;不得拿新 project 名去停止旧容器。
- `artifacts/` 和 `output/` 是运行期证据/截图目录,不作为源码发布输入,也不由 `rsync --delete` 清理远端历史证据。 - `artifacts/` 和 `output/` 是运行期证据/截图目录,不作为源码发布输入,也不由 `rsync --delete` 清理远端历史证据。
+8 -2
查看文件
@@ -428,8 +428,14 @@ for (const requiredFragment of [
} }
for (const requiredFragment of [ for (const requiredFragment of [
'validate_data_path POSTGRES_DATA_DIR "$POSTGRES_DATA_DIR"', 'validate_absolute_path REMOTE_DIR "$REMOTE_DIR"',
'validate_data_path STORAGE_HOST_DIR "$STORAGE_HOST_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" != "existing"',
'DEPLOY_DATA_MODE" != "initialize"', 'DEPLOY_DATA_MODE" != "initialize"',
'"$POSTGRES_DATA_DIR/PG_VERSION"', '"$POSTGRES_DATA_DIR/PG_VERSION"',
+2 -2
查看文件
@@ -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` 已作为缺企微测试数据软归档;后续真机或公网试运行不得再把这些账号作为当前执行账号。 - WOUA-FIX 后,`SMOKE001`、`SMOKE201`、`SMOKE301`、`SMOKE302`、`SMOKE401`、`T0000` 已作为缺企微测试数据软归档;后续真机或公网试运行不得再把这些账号作为当前执行账号。
- 审批链正确性是业务逻辑验收,优先通过 PC 公网网页或自动化证明,不要求所有审批角色绑定企业微信。 - 审批链正确性是业务逻辑验收,优先通过 PC 公网网页或自动化证明,不要求所有审批角色绑定企业微信。
- 企业微信真机验收只覆盖工作台入口、绑定/免登录、移动布局、待办、详情、PDF 上传入口和通知 deeplink;当前 `oa_smoke_01 -> SMOKE002` 与 `oa_smoke_02 -> SMOKE101` 足够覆盖“提交人 + 审批人”的核心移动体验。 - 企业微信真机验收只覆盖工作台入口、绑定/免登录、移动布局、待办、详情、PDF 上传入口和通知 deeplink;当前 `oa_smoke_01 -> SMOKE002` 与 `oa_smoke_02 -> SMOKE101` 足够覆盖“提交人 + 审批人”的核心移动体验。
@@ -97,7 +97,7 @@
| 普通低/中额单据 | PC/自动化 | `SMOKE002` / 测试员工 | 提交低/中额单据 | 待办分配给 `SMOKE101` | 通过 | S4 自动化证据 | | 普通低/中额单据 | PC/自动化 | `SMOKE002` / 测试员工 | 提交低/中额单据 | 待办分配给 `SMOKE101` | 通过 | S4 自动化证据 |
| 高额/高风险/例外单据 | PC/自动化 | 当前 active 角色账号 | 提交高额/例外单据 | 按当前负责人、财务和财务主管配置流转 | 需按 WOUA-FIX 后账号重跑 | 后续新证据 | | 高额/高风险/例外单据 | PC/自动化 | 当前 active 角色账号 | 提交高额/例外单据 | 按当前负责人、财务和财务主管配置流转 | 需按 WOUA-FIX 后账号重跑 | 后续新证据 |
| 主管本人申请 | 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 admin | 尝试审批测试单据 | 不允许仅凭 `admin` 审批 | 需按 WOUA-FIX 后账号重跑 | 后续新证据 |
| 财务复核与付款/导出 | PC/自动化 | 当前 active 财务角色账号 | 处理已批准测试单据 | 按财务与财务主管权限处理 | 需按 WOUA-FIX 后账号重跑 | 后续新证据 | | 财务复核与付款/导出 | PC/自动化 | 当前 active 财务角色账号 | 处理已批准测试单据 | 按财务与财务主管权限处理 | 需按 WOUA-FIX 后账号重跑 | 后续新证据 |
+8 -8
查看文件
@@ -108,7 +108,7 @@
- 缺直属主管、主管停用、主管链断裂或审批节点解析为申请人本人时,提交必须阻断并提示"未配置可审批人"或"当前审批规则未找到审批人"。 - 缺直属主管、主管停用、主管链断裂或审批节点解析为申请人本人时,提交必须阻断并提示"未配置可审批人"或"当前审批规则未找到审批人"。
- 只有审批规则显式配置老板/总经理或兜底审批节点,且目标不是申请人本人时,才进入 `CEO` 或其他非本人兜底审批人。 - 只有审批规则显式配置老板/总经理或兜底审批节点,且目标不是申请人本人时,才进入 `CEO` 或其他非本人兜底审批人。
- 不自动通过,不静默跳过,不用无条件升级 `CEO` 掩盖组织配置问题。 - 不自动通过,不静默跳过,不用无条件升级 `CEO` 掩盖组织配置问题。
- 当前正式运行口径:`majianyong` 是公司负责人 `ceo` 且不配置直属主管;当前唯一 active `manager` 向其汇报。`wangshunli` 只承担系统开发/系统管理,不配置业务审批角色;`admin` 不代表业务审批权。CEO 本人报销目标由 active 非本人 `finance` 审批,当前实现缺口未修复时保持阻断。 - 当前正式运行口径:`majianyong` 是公司负责人 `ceo` 且不配置直属主管;当前唯一 active `manager` 向其汇报。`wangshunli` 只承担系统开发/系统管理,不配置业务审批角色;`admin` 不代表业务审批权。CEO 本人报销目标由 active 非本人 `finance` 审批;本地候选已实现,部署和生产规则回填前保持阻断。
### W-2 转审适用范围 ### W-2 转审适用范围
- 产品目标:转审是通用审批能力,目标覆盖报销、请假、采购、差旅。 - 产品目标:转审是通用审批能力,目标覆盖报销、请假、采购、差旅。
@@ -153,10 +153,10 @@
- `manage` 不是 `approve` 的别名,也不等于"可任意改历史数据" - `manage` 不是 `approve` 的别名,也不等于"可任意改历史数据"
### R-2 报销/费用金额分层审批 ### R-2 报销/费用金额分层审批
- 当前 5–10 人单管理员团队不按金额分层;所有员工报销统一使用 `直属主管(department_manager) → finance`。金额差异不增加重复审批节点,也不引用无人持有的 `finance_mgr`。 - 当前三档规则固定为:小额 `manager -> finance`、中额 `manager -> finance`、大额 `manager -> ceo -> finance`;三档负责人本人 fallback 均为 `finance`,不引用无人持有的 `finance_mgr`。
- 马建勇同时是杨俊直属主管和唯一 CEO;再追加 CEO 节点只会让同一自然人对同一单据连续审批两次,不形成新的控制。 - 普通员工按规则顺序审批;CEO 本人提交时移除本人 `ceo` 节点、替换不可解析的 `manager` 并去重,最终只有 active 非本人 `finance` 节点。
- 真正的职责分离保留在付款环节:`finance` 创建付款批次,`ceo` 复核,继续执行 maker-checker。 - 真正的职责分离继续保留在付款环节:`finance` 创建付款批次,`ceo` 复核,执行 maker-checker。
- 只有出现第二名实际承担审批职责的主管,或有人基于真实职责正式承担 `finance_mgr`,并且业务证明需要金额升级时,才重新评估金额分层。仅需提示大额时优先用风险标记或通知,不用重复审批节点代替。 - 不因小团队规模放宽自审,也不通过 `admin` 代批或给现有财务追加权限更宽的 `finance_mgr`。
### R-3 资产转移权限 ### R-3 资产转移权限
- `asset.transfer` 只给 `administration / 资产管理员` - `asset.transfer` 只给 `administration / 资产管理员`
@@ -169,6 +169,6 @@
- `finance`、`finance_mgr` 均不拥有 `asset.transfer`;实物调拨继续只属于 `administration`。 - `finance`、`finance_mgr` 均不拥有 `asset.transfer`;实物调拨继续只属于 `administration`。
### R-5 CEO 本人无主管报销限制 ### R-5 CEO 本人无主管报销限制
- 现有负责人本人报销规则只允许 active、非本人的 `finance_mgr` 作为 `ownerSelfFallbackRole`;普通 `finance` 不满足该兜底角色。 - `ownerSelfFallbackRole` 只允许 active、非本人的 `finance` 或兼容角色 `finance_mgr`;本公司新建默认和生产目标均为 `finance`。
- 当 CEO 本人没有可解析直属主管且公司没有合适的非本人 `finance_mgr` 时,报销提交保持阻断。不得通过给财务人员顺带增加 `finance_mgr`、使用 `admin` 代批或恢复整库来绕过。 - CEO 本人没有可解析直属主管时,只有存在 active 非本人 fallback 持有人才能提交;无持有人或 CEO 同时持有 fallback 角色时继续阻断。不得使用 `admin` 代批或恢复整库绕过。
- 授予 `ceo` 后,既有 `assignee_role=ceo` 待办会对该角色持有人可见,新提交单据按当前规则生成 CEO 节点;既有审批快照不重路由,过去提交失败的单据也不会自动恢复。若未来要放开 CEO 本人报销,应另开产品任务调整兜底规则和最小权限,而不是在账号配置阶段扩权。 - 既有审批快照不重路由,过去提交失败的单据也不会自动恢复。当前本地候选尚未部署、生产规则尚未回填,因此公网仍按旧配置阻断。
+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 承接。未取得新任务完整证据前不得宣布最终正式启用完成 | | `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/协同/低代码/财务/资产系统;已补充国内对标来源、小团队取舍原则、用户决策回填和成熟度证据视图;所有问题和建议先记录为待用户采纳、暂缓或否决,不直接改业务代码 | | `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 或业务代码改造 | | `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 | | `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 仍是正式启用前硬门禁 | | `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 页默认视图和动态详情页证据,不直接改业务代码 | | `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` 只作历史治理来源。 **关联治理**:当前 Trellis task;`GOV8` 只作历史治理来源。
**最近审计**:2026-07-16。 **最近审计**:2026-07-28。
**不得误读**:本文只指示当前事实源和读取顺序;历史 Issue CSV 只能用于追溯,不直接生成新开发任务。 **不得误读**:本文只指示当前事实源和读取顺序;历史 Issue CSV 只能用于追溯,不直接生成新开发任务。
**治理口径**:中文优先,物理精简,优先复用已有文档。已确认不用的历史文档不放 `docs/archive/`,直接从当前工作树移除;需要时从 git 历史恢复。 **治理口径**:中文优先,物理精简,优先复用已有文档。已确认不用的历史文档不放 `docs/archive/`,直接从当前工作树移除;需要时从 git 历史恢复。
@@ -24,6 +24,7 @@
- 当前本地 `main` 包含 7 月 P0 可信度修复和后续本地收口,尚未 push、部署或完成公网复检,不能视为线上已生效。 - 当前本地 `main` 包含 7 月 P0 可信度修复和后续本地收口,尚未 push、部署或完成公网复检,不能视为线上已生效。
- 旧 OAOPS1 7 天真实试运行只有 Day 1–Day 4,后续中断,缺 Day 5–Day 7、现场人工业务闭环和 Day 7 Go/No-Go;归档只表示生命周期关闭,不代表目标完成。 - 旧 OAOPS1 7 天真实试运行只有 Day 1–Day 4,后续中断,缺 Day 5–Day 7、现场人工业务闭环和 Day 7 Go/No-Go;归档只表示生命周期关闭,不代表目标完成。
- 下一轮真实连续试运行必须等待当前可信基线部署并完成公网复核,再新建 Trellis task,从新的 Day 1 连续启动。 - 下一轮真实连续试运行必须等待当前可信基线部署并完成公网复核,再新建 Trellis task,从新的 Day 1 连续启动。
- 当前 `07-28-ceo-finance-fallback` 已完成本地候选实现与聚焦验证:CEO 本人三档报销目标均为唯一 active 非本人 `finance`;尚未部署、生产规则尚未回填,因此不能据此宣布公网可用。
## 历史事实源链(截至 2026-05) ## 历史事实源链(截至 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`,不得重置密码、不得打印凭据、不得影响真实员工。 **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` 前,只能视为小团队稳定化收口中,不能宣布最终正式启用完成。 **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 自动改规则不作为正式启用前置条件。 **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. 适用范围 ## 1. 适用范围
- 适用于 `small_team_internal` 工作区模式。 - 适用于 `small_team_internal` 工作区模式。
@@ -32,7 +32,7 @@
- 正式审批组织口径:`majianyong` 配置为公司负责人 `ceo` 且不配置直属主管;`wangshunli` 仅配置系统开发/系统管理职责,不配置 `manager`、`ceo`、`finance` 或 `finance_mgr` 业务角色。 - 正式审批组织口径:`majianyong` 配置为公司负责人 `ceo` 且不配置直属主管;`wangshunli` 仅配置系统开发/系统管理职责,不配置 `manager`、`ceo`、`finance` 或 `finance_mgr` 业务角色。
- 普通员工配置有效直属主管;当前唯一 active `manager` 的直属主管配置为 `majianyong`。不得把 `wangshunli` 放入正常业务汇报或审批链。 - 普通员工配置有效直属主管;当前唯一 active `manager` 的直属主管配置为 `majianyong`。不得把 `wangshunli` 放入正常业务汇报或审批链。
- `admin` 只代表系统管理权限,不代表业务审批权;需要业务审批时必须配置 `manager`、`ceo`、`finance`、`hr` 等正式业务角色。 - `admin` 只代表系统管理权限,不代表业务审批权;需要业务审批时必须配置 `manager`、`ceo`、`finance`、`hr` 等正式业务角色。
- 缺直属主管、主管停用、主管链断裂或审批节点解析到申请人本人时,应先修正组织或审批规则,不要用管理员账号代批。CEO 本人报销只允许通过审批规则进入 active 非本人财务审批;当前 `finance`/`finance_mgr` 配置缺口未修复前保持阻断,不伪造成可用。 - 缺直属主管、主管停用、主管链断裂或审批节点解析到申请人本人时,应先修正组织或审批规则,不要用管理员账号代批。CEO 本人报销只允许通过审批规则进入 active 非本人财务审批;本地代码已支持 `finance`,但部署和生产规则回填完成前仍保持阻断,不伪造成可用。
5. 检查功能开关: 5. 检查功能开关:
- 建议开启:`portal_home_v1`、企业微信入口相关开关、报销/OCR、审批待办、请假、项目基础能力和 `payment_batch_v1`。 - 建议开启:`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`。 - 按需开启:`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 本人报销会阻断。 1. 本地候选代码允许 `owner_self_fallback_role = 'finance' | 'finance_mgr'` 或置空,新建规则默认 `finance`;生产尚未部署该候选,现有启用规则仍为旧 `finance_mgr` 配置,因此 CEO 本人报销继续阻断。
2. 用户确认目标口径是由 active 非本人 `finance` 审批。后续修正必须同时检查 `owner_self_fallback_role` 和中/大额规则里的 `finance_mgr` 节点,保证最终快照不残留不可解析节点。 2. 部署通过后,必须在同一事务内把 3 条启用规则统一为 `finance` 收口:小/中额 `manager -> finance`,大额 `manager -> ceo -> finance`,三档 fallback 均为 `finance`;任一生产前置漂移则零写入停止。
3. 不得为省事把 `admin` 当业务审批角色,也不得仅为绕过阻断给财务人员附加超出实际职责的权限;优先按最小权限调整支持角色和规则。 3. 不得为省事把 `admin` 当业务审批角色,也不得仅为绕过阻断给财务人员附加超出实际职责的权限;优先按最小权限调整支持角色和规则。
4. 提交后系统保存审批快照;之后修改规则只影响新单,不回写历史流程。 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 负责人本人报销兜底审批 ### 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 非本人持有人,提交必须阻断。 - 不允许自审;若兜底角色也是申请人本人持有的角色,或没有 active 非本人持有人,提交必须阻断。
- 不支持 `admin` 作为负责人本人报销兜底角色;`admin` 仍只代表系统管理权限,不代表业务审批权。 - 不支持 `admin` 作为负责人本人报销兜底角色;`admin` 仍只代表系统管理权限,不代表业务审批权。
- 当前首版只覆盖 OA 报销金额规则,不扩展到采购、合同、复杂 BPM 或自由流程。 - 当前首版只覆盖 OA 报销金额规则,不扩展到采购、合同、复杂 BPM 或自由流程。
- 现有实现只允许 `ownerSelfFallbackRole=finance_mgr` 或置空。后续修正必须让 active 非本人 `finance` 可作为 CEO 兜底,并同步清理中/大额规则中不可解析的 `finance_mgr` 节点;完成前负责人本人申请继续阻断。 - 本地候选允许 `ownerSelfFallbackRole=finance|finance_mgr` 或置空,并以 `finance` 为新建默认值;部署与生产规则原子回填完成前,负责人本人申请继续阻断。
预期链路示例: 预期链路示例:
| 金额规则 | 原链路 | 负责人本人申请快照 | | 金额规则 | 原链路 | 负责人本人申请快照 |
| --- | --- | --- | | --- | --- | --- |
| 小额 | `manager -> finance` | 目标:`finance`;当前因只支持 `finance_mgr` 而阻断 | | 小额 | `manager -> finance` | `finance` |
| 中额 | `manager -> finance_mgr` | 目标:`finance`;当前兜底及原规则均需修正 | | 中额 | `manager -> finance` | `finance` |
| 大额 | `manager -> ceo -> finance_mgr` | 目标:`finance`;移除本人 `ceo` 后仍须清理不可解析的 `finance_mgr` 节点 | | 大额 | `manager -> ceo -> finance` | `finance`;移除本人 `ceo` 并去重 |
--- ---
@@ -574,7 +574,7 @@ WF8 当前正式验收通道为站内通知和企业微信个人应用消息。
| 正式主管链路 | 专用员工提交普通低/中额单据;其 active 直属主管处理待办 | 待办分配给实际直属主管,审批历史与组织关系一致 | | 正式主管链路 | 专用员工提交普通低/中额单据;其 active 直属主管处理待办 | 待办分配给实际直属主管,审批历史与组织关系一致 |
| 公司负责人二审 | 高额、高风险或例外单据进入 `ceo` 节点 | 串行节点完整,`majianyong(ceo)` 处理负责人节点 | | 公司负责人二审 | 高额、高风险或例外单据进入 `ceo` 节点 | 串行节点完整,`majianyong(ceo)` 处理负责人节点 |
| 主管本人申请 | 当前 `manager` 提交单据 | 主管本人不能自审,按组织链进入 `majianyong(ceo)` 或明确非本人兜底节点 | | 主管本人申请 | 当前 `manager` 提交单据 | 主管本人不能自审,按组织链进入 `majianyong(ceo)` 或明确非本人兜底节点 |
| 公司负责人本人申请 | `majianyong` 提交并触发负责人本人兜底 | 不允许自审;目标由 active 非本人 `finance` 审批,当前实现缺口未修复时应阻断 | | 公司负责人本人申请 | `majianyong` 提交并触发负责人本人兜底 | 不允许自审;active 非本人 `finance` 唯一审批;部署和生产规则回填前应阻断 |
| 系统管理员无业务审批权 | 仅 `admin` 角色账号尝试审批业务单据 | 服务端拒绝,不因 `admin` 权限生成审批历史 | | 系统管理员无业务审批权 | 仅 `admin` 角色账号尝试审批业务单据 | 服务端拒绝,不因 `admin` 权限生成审批历史 |
| 并发处理 | 两个窗口同时处理同一任务 | 只有一个结果生效,另一个提示任务已变化 | | 并发处理 | 两个窗口同时处理同一任务 | 只有一个结果生效,另一个提示任务已变化 |
| 通知失败不阻断 | 模拟企业微信通知发送失败 | 审批提交或处理成功,通知记录进入失败可重试 | | 通知失败不阻断 | 模拟企业微信通知发送失败 | 审批提交或处理成功,通知记录进入失败可重试 |
+24
查看文件
@@ -86,6 +86,30 @@ if command -v node >/dev/null 2>&1; then
exit 1 exit 1
fi 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" MUTATED_PORT_COMPOSE="$DRILL_DIR/docker-compose-db-port.yml"
sed '/ db:/a\ ports:\n - "5432:5432"' \ sed '/ db:/a\ ports:\n - "5432:5432"' \
"$ROOT_DIR/deploy/trialrun/docker-compose.yml" >"$MUTATED_PORT_COMPOSE" "$ROOT_DIR/deploy/trialrun/docker-compose.yml" >"$MUTATED_PORT_COMPOSE"
+1
查看文件
@@ -16,6 +16,7 @@
"prisma:migrate:dev": "prisma migrate dev", "prisma:migrate:dev": "prisma migrate dev",
"prisma:migrate:deploy": "prisma migrate deploy", "prisma:migrate:deploy": "prisma migrate deploy",
"seed:config": "node src/scripts/seed-config.js", "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-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: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", "seed:trialrun": "node src/scripts/seed-trialrun.js",
+5 -3
查看文件
@@ -4,10 +4,12 @@ const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit"); const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply"); const { sendData } = require("../utils/reply");
const { ROLE_PERMISSIONS } = require("../constants/permissions"); 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 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( const REIMBURSEMENT_APPROVAL_ROLE_CODES = BUSINESS_APPROVAL_ROLE_CODES.filter(
(roleCode) => (ROLE_PERMISSIONS[roleCode] ?? []).includes("expense.approve"), (roleCode) => (ROLE_PERMISSIONS[roleCode] ?? []).includes("expense.approve"),
); );
@@ -144,7 +146,7 @@ async function approvalRulesRoutes(fastify) {
level3_role: body.level3Role ?? null, level3_role: body.level3Role ?? null,
owner_self_fallback_role: Object.prototype.hasOwnProperty.call(body, "ownerSelfFallbackRole") owner_self_fallback_role: Object.prototype.hasOwnProperty.call(body, "ownerSelfFallbackRole")
? body.ownerSelfFallbackRole ?? null ? body.ownerSelfFallbackRole ?? null
: "finance_mgr", : "finance",
enabled: body.enabled ?? true, enabled: body.enabled ?? true,
priority, priority,
created_by: request.employee.id, 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,
};
+28 -13
查看文件
@@ -56,7 +56,7 @@ const DEFAULT_APPROVAL_RULES = [
level1_role: "manager", level1_role: "manager",
level2_role: "finance", level2_role: "finance",
level3_role: null, level3_role: null,
owner_self_fallback_role: "finance_mgr", owner_self_fallback_role: "finance",
priority: 10, priority: 10,
}, },
{ {
@@ -64,9 +64,9 @@ const DEFAULT_APPROVAL_RULES = [
min_amount: "500", min_amount: "500",
max_amount: "5000", max_amount: "5000",
level1_role: "manager", level1_role: "manager",
level2_role: "finance_mgr", level2_role: "finance",
level3_role: null, level3_role: null,
owner_self_fallback_role: "finance_mgr", owner_self_fallback_role: "finance",
priority: 20, priority: 20,
}, },
{ {
@@ -75,8 +75,8 @@ const DEFAULT_APPROVAL_RULES = [
max_amount: null, max_amount: null,
level1_role: "manager", level1_role: "manager",
level2_role: "ceo", level2_role: "ceo",
level3_role: "finance_mgr", level3_role: "finance",
owner_self_fallback_role: "finance_mgr", owner_self_fallback_role: "finance",
priority: 30, priority: 30,
}, },
]; ];
@@ -563,7 +563,11 @@ async function seedExpenseTypes(prisma) {
} }
async function seedApprovalRules(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({ await prisma.approvalRule.createMany({
data: DEFAULT_APPROVAL_RULES.map((item) => ({ data: DEFAULT_APPROVAL_RULES.map((item) => ({
...item, ...item,
@@ -572,6 +576,8 @@ async function seedApprovalRules(prisma) {
updated_by: null, updated_by: null,
})), })),
}); });
return { created: DEFAULT_APPROVAL_RULES.length, preserved: 0 };
} }
async function seedDocumentSequence(prisma) { async function seedDocumentSequence(prisma) {
@@ -1022,7 +1028,7 @@ async function main() {
try { try {
await seedExpenseTypes(prisma); await seedExpenseTypes(prisma);
await seedApprovalRules(prisma); const approvalRuleSeed = await seedApprovalRules(prisma);
await seedDocumentSequence(prisma); await seedDocumentSequence(prisma);
await seedDocumentCategories(prisma); await seedDocumentCategories(prisma);
await seedWorkflowDefinition(prisma); await seedWorkflowDefinition(prisma);
@@ -1037,7 +1043,9 @@ async function main() {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`- expense types: ${ENABLED_EXPENSE_TYPES.length} enabled + ${DISABLED_LEGACY_EXPENSE_TYPES.length} disabled`); console.log(`- expense types: ${ENABLED_EXPENSE_TYPES.length} enabled + ${DISABLED_LEGACY_EXPENSE_TYPES.length} disabled`);
// eslint-disable-next-line no-console // 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 // eslint-disable-next-line no-console
console.log("- document sequence: reimbursement(reset_period=none)"); console.log("- document sequence: reimbursement(reset_period=none)");
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@@ -1070,8 +1078,15 @@ async function main() {
} }
} }
main().catch((error) => { if (require.main === module) {
// eslint-disable-next-line no-console main().catch((error) => {
console.error(error); // eslint-disable-next-line no-console
process.exit(1); console.error(error);
}); process.exit(1);
});
}
module.exports = {
DEFAULT_APPROVAL_RULES,
seedApprovalRules,
};
+3 -1
查看文件
@@ -19,7 +19,8 @@ const BUSINESS_APPROVAL_ROLE_CODES = Object.freeze([
"administration", "administration",
]); ]);
const BUSINESS_APPROVAL_ROLE_CODE_SET = new Set(BUSINESS_APPROVAL_ROLE_CODES); 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([ const OWNER_SELF_FALLBACK_REASONS = new Set([
"no_manager_found", "no_manager_found",
"chain_broken", "chain_broken",
@@ -555,6 +556,7 @@ function toMoneyString(value) {
module.exports = { module.exports = {
BUSINESS_APPROVAL_ROLE_CODES, BUSINESS_APPROVAL_ROLE_CODES,
OWNER_SELF_FALLBACK_ROLE_CODES,
buildApprovalWorkflowSnapshot, buildApprovalWorkflowSnapshot,
buildWorkflowStepsFromApprovalRule, buildWorkflowStepsFromApprovalRule,
getWorkflowStepsSnapshot, getWorkflowStepsSnapshot,
@@ -14,7 +14,7 @@ function buildCreatePayload(roleCode) {
level1Role: roleCode, level1Role: roleCode,
level2Role: null, level2Role: null,
level3Role: null, level3Role: null,
ownerSelfFallbackRole: "finance_mgr", ownerSelfFallbackRole: "finance",
enabled: true, enabled: true,
priority: 1, priority: 1,
}; };
@@ -82,6 +82,74 @@ async function buildApp() {
} }
describe("reimbursement approval-rule role whitelist", () => { 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"])( it.each(["manager", "ceo", "finance", "finance_mgr"])(
"allows role %s because it carries expense.approve", "allows role %s because it carries expense.approve",
async (roleCode) => { async (roleCode) => {
@@ -399,7 +399,7 @@ describe("reimbursements routes expense standard integration", () => {
await app.close(); 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()); resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app, state } = await buildApp({ const { app, state } = await buildApp({
@@ -409,8 +409,8 @@ describe("reimbursements routes expense standard integration", () => {
id: "55555555-5555-4555-8555-555555555555", id: "55555555-5555-4555-8555-555555555555",
level1_role: "manager", level1_role: "manager",
level2_role: "finance", level2_role: "finance",
level3_role: "ceo", level3_role: null,
owner_self_fallback_role: "finance_mgr", owner_self_fallback_role: "finance",
priority: 1, priority: 1,
}, },
}); });
@@ -424,7 +424,7 @@ describe("reimbursements routes expense standard integration", () => {
expect(state.workflowTaskCreatePayload).toEqual( expect(state.workflowTaskCreatePayload).toEqual(
expect.objectContaining({ expect.objectContaining({
assignee_id: null, assignee_id: null,
assignee_role: "finance_mgr", assignee_role: "finance",
}), }),
); );
expect(state.workflowInstanceCreatePayload.current_step_id).toContain("owner_self_fallback"); 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( state.workflowInstanceCreatePayload.context_data.workflow_steps_snapshot.map(
(step) => step.assignee_rule, (step) => step.assignee_rule,
), ),
).toEqual([ ).toEqual([{ type: "role", value: "finance" }]);
{ type: "role", value: "finance_mgr" },
{ type: "role", value: "finance" },
]);
await app.close(); await app.close();
}); });
@@ -452,7 +449,7 @@ describe("reimbursements routes expense standard integration", () => {
level1_role: "manager", level1_role: "manager",
level2_role: "finance", level2_role: "finance",
level3_role: null, level3_role: null,
owner_self_fallback_role: "finance_mgr", owner_self_fallback_role: "finance",
priority: 1, priority: 1,
}, },
}); });
@@ -467,7 +464,7 @@ describe("reimbursements routes expense standard integration", () => {
expect.objectContaining({ expect.objectContaining({
workflow: "no_actionable_approver", workflow: "no_actionable_approver",
reason: "owner_self_fallback_role_unavailable", reason: "owner_self_fallback_role_unavailable",
fallbackRole: "finance_mgr", fallbackRole: "finance",
}), }),
); );
expect(tx.workflowInstance.create).not.toHaveBeenCalled(); 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,
})),
});
});
});
+25 -3
查看文件
@@ -164,6 +164,28 @@ describe("workflow assignee resolution", () => {
expect(resolved.actionable.assigneeRole).toBe("finance_mgr"); 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 () => { it("removes CEO self node and duplicate fallback role for large CEO owner applications", async () => {
const tx = buildTx({ ownerRoles: ["ceo"], managerId: null }); const tx = buildTx({ ownerRoles: ["ceo"], managerId: null });
@@ -225,7 +247,7 @@ describe("workflow assignee resolution", () => {
ownerId: OWNER_ID, ownerId: OWNER_ID,
approvalRule: { approvalRule: {
level1_role: "manager", level1_role: "manager",
owner_self_fallback_role: "finance_mgr", owner_self_fallback_role: "finance",
}, },
}), }),
).rejects.toMatchObject({ ).rejects.toMatchObject({
@@ -238,7 +260,7 @@ describe("workflow assignee resolution", () => {
it("blocks fallback when the owner also holds the fallback role", async () => { it("blocks fallback when the owner also holds the fallback role", async () => {
const tx = buildTx({ const tx = buildTx({
ownerRoles: ["ceo", "finance_mgr"], ownerRoles: ["ceo", "finance"],
managerId: null, managerId: null,
}); });
@@ -248,7 +270,7 @@ describe("workflow assignee resolution", () => {
ownerId: OWNER_ID, ownerId: OWNER_ID,
approvalRule: { approvalRule: {
level1_role: "manager", level1_role: "manager",
owner_self_fallback_role: "finance_mgr", owner_self_fallback_role: "finance",
}, },
}), }),
).rejects.toMatchObject({ ).rejects.toMatchObject({