完成五人团队正式内用最小收口(07-18-small-team-internal-readiness)

这个提交包含在:
2026-07-18 15:46:14 +08:00
父节点 58c8decef9
当前提交 5d0a5f9a38
共修改 134 个文件,包含 10790 行新增和 1413 行删除
+77
查看文件
@@ -16,3 +16,80 @@ task before modifying approval behavior.
not drift because rules changed later.
- Reviewer review is required after changes touching workflow, status machine,
approval rules, permissions or migrations.
## Scenario: System Operator Is Not A Business Approver
### 1. Scope / Trigger
- Trigger: adding or changing OA account administration, role assignment,
approval eligibility, payment review or emergency-superuser behavior.
### 2. Signatures
- Daily responsibility roles: `manager`, `ceo`, `finance`, `system_admin`.
- Emergency role: `admin` (`*`), hidden from ordinary assignment UI.
- System permissions: `system.accounts.manage`, `system.roles.manage`.
- Payment reviewer permission: `payment.batch.review`.
### 3. Contracts
- `system_admin` manages ordinary accounts and system configuration only; it
does not imply leadership, finance, payroll, personnel-sensitive access or
business approval authority.
- A payment reviewer must have both `payment.batch.review` and an explicit
`manager` or `ceo` role. `admin` or `system_admin` alone is not a normal
reviewer.
- `manager` does not imply that every employee reports to that account;
reporting scope remains in `direct_manager_id`.
- A non-emergency system administrator cannot edit their own roles or grant,
revoke or import `admin` / `system_admin`. Role changes remain audited.
- Employee aggregate endpoints must not treat `system.settings` as personnel-
sensitive authority. Salary summaries require `payroll.manage`; performance
summaries require `system.users_sensitive`; unauthorized branches issue no
salary/performance query and return only the non-sensitive summary shape.
### 4. Validation & Error Matrix
- System administrator changes own roles -> `403 PERM_001` before role writes.
- System administrator changes a protected role -> `403 PERM_001`, zero role
and audit writes.
- Reviewer has permission but lacks `manager|ceo` -> `403 PERM_001`.
- Batch creator attempts review -> deterministic `409 VALID_001`.
- `system_admin` opens ordinary employee statistics -> salary is null,
performance is empty and both sensitive query families remain untouched.
### 5. Good/Base/Bad Cases
- Good: one person may explicitly hold both `manager` and `system_admin`, while
each route evaluates the responsibility required for that action.
- Base: `system_admin` creates or disables an ordinary account without seeing
payroll, bank, identity or employee-contract content.
- Bad: treat `admin` as the default company leader or use `system_admin` as an
approval fallback because the team is small.
### 6. Tests Required
- Role matrix for employee, manager, CEO, finance, system administrator and
emergency administrator.
- Negative tests for self-role change, protected-role assignment/import and
permission-only payment review.
- Negative employee-statistics test using the real `system_admin` permission
set, plus positive tests for explicit payroll/personnel-sensitive authority.
- Reviewer read-only review after changing role or approval semantics.
### 7. Wrong vs Correct
#### Wrong
```js
if (hasPermission(actor, "*")) allowPaymentReview();
```
#### Correct
```js
if (!hasPermission(actor, "payment.batch.review")
|| !actor.roleCodes.some((role) => role === "manager" || role === "ceo")) {
throw forbidden();
}
```
@@ -383,3 +383,82 @@ const currentSession = await tx.authSession.findUnique({ where: { id: sessionId
if (!currentSession || currentSession.revoked_at) return invalidRefresh();
await rotateSession(tx, currentSession);
```
## Scenario: Small-Team Payment Batch Consistency
### 1. Scope / Trigger
- Trigger: changing payment-batch creation, review, execution, retry,
cancellation, receipt evidence or the legacy direct-payment path.
### 2. Signatures
- Candidate API: `GET /api/payment-batches/candidates?q=&page=&pageSize=`.
- State APIs: `POST /api/payment-batches/:id/review`,
`POST /api/payment-batches/:id/execute`,
`POST /api/payment-batches/:id/cancel` with `{ reason }`.
- Receipt invalidation accepts `{ reason }` and retains the record.
- DB: `payment_batches.cancelled_reason TEXT NULL`.
- Isolated concurrency gate:
`bash scripts/qa/run-web-gates.sh payment-concurrency-local`.
### 3. Contracts
- Normal flow is finance maker/executor plus one independent `manager|ceo`
reviewer; the maker cannot review their own batch.
- Lock the batch before state checks, then lock source entities in stable order
and recheck status, amount and reservation inside the same transaction.
- When `payment_batch_v1` is enabled, an approved reimbursement can enter only
one active batch and the legacy direct-payment route must not form a second
write path.
- Retry processes only `pending|failed` items. Existing `executed` items never
repeat payment writes, follow-ups or notifications. Persisted execution
summary is recomputed from every final item state; the audit separately
records the current attempt.
- Cancellation requires a 2-500 character reason. Receipt evidence is voided
with audit history, not physically deleted.
### 4. Validation & Error Matrix
- Missing review, maker-reviewer conflict or terminal state -> `409 VALID_001`.
- Concurrent loser after a valid winner -> deterministic `409`, zero duplicate
state, audit or notification writes.
- Missing/invalid cancellation or void reason -> `400 VALID_001`.
- Source already paid, amount changed or reserved by another active batch ->
`409 VALID_001` and no partial batch mutation.
### 5. Good/Base/Bad Cases
- Good: one executor wins, a concurrent cancel or second executor receives
`409`, and the final payment/audit/notification counts remain one.
- Base: a failed batch retries only failed items and reaches `executed` with an
all-item final summary.
- Bad: count only the retry attempt in `execution_summary_json`, or allow the
direct route to pay while the same source is in a valid batch.
### 6. Tests Required
- Focused route tests for candidate authority, maker-checker, retry summary,
cancellation, receipt void and zero-side-effect refusals.
- Real disposable PostgreSQL races for double execute, execute/cancel, double
create and direct-payment/batch competition; assert unique audits and
notifications and zero residual test databases.
- Prisma validate, clean empty-database migration application and a read-only
reviewer after payment-state changes.
### 7. Wrong vs Correct
#### Wrong
```js
summary.successCount = successesInThisRetry;
batch.status = failuresInThisRetry ? "failed" : "executed";
```
#### Correct
```js
const finalSummary = summarizeFinalItemStatuses(finalItemStatuses);
batch.execution_summary_json = finalSummary;
batch.status = finalSummary.failedCount > 0 ? "failed" : "executed";
```
@@ -442,3 +442,77 @@ validate_isolated_target_before_writes
run_mutating_auth_regression
drop_owned_database_or_fail
```
## Scenario: Repository Local Candidate Gate
### 1. Scope / Trigger
- Trigger: declaring a checkout ready for internal deployment planning without
deploying it or using public, production or real-account evidence.
### 2. Signatures
- Full gate: `bash scripts/qa/run-local-candidate.sh full`.
- Browser-only rerun: `bash scripts/qa/run-local-candidate.sh focused`.
- Parse-only check: `bash scripts/qa/run-local-candidate.sh list`.
- Route contract: `scripts/qa/route-manifest.json`.
- Result verifier: `scripts/qa/verify-local-candidate-results.mjs`.
### 3. Contracts
- `full` alone may print `LOCAL_CANDIDATE_PASS`; `focused` prints
`FOCUSED_BROWSER_PASS`, and `list` prints `LIST_ONLY`.
- The gate accepts only loopback HTTP origins and canonical TCP ports. It uses
local synthetic fixtures, excludes `manual/live` specs and does not consume
historical Issue CSV as a candidate prerequisite.
- Every registered route is classified. Every core bundle has one stable,
source-bound witness; candidate spec basenames are unique.
- Every selected Playwright result must pass. Missing, duplicate, unknown,
skipped, multi-tag or wrong-source witnesses and any failed/skipped
acceptance test make the gate fail.
- The script's full pass proves only its local scope. A task changing payment
concurrency also requires the isolated PostgreSQL payment gate; deployment,
public checks and real-device behavior remain separate states.
### 4. Validation & Error Matrix
- Non-loopback URL, credentialed URL, path/query target or invalid port -> fail
before Vite/Playwright use.
- Unclassified/orphan route or missing core behavior/visual witness -> non-zero
scanner result.
- Playwright suite error, unexpected, failed or skipped test -> non-zero result
gate with basename-only redacted diagnostics.
- `focused` succeeds -> useful rerun evidence, but not a full candidate claim.
### 5. Good/Base/Bad Cases
- Good: full base checks pass, all source-bound witnesses and acceptance specs
execute, and the runner prints one full-pass label.
- Base: use `list` before a long run to prove assets parse, while recording that
no behavior has run.
- Bad: reuse a prior browser pass after parser changes, or report focused/list
output as `LOCAL_CANDIDATE_PASS`.
### 6. Tests Required
- Negative tests for route gaps, manual/live evidence, duplicate basenames,
invalid loopback inputs, witness result/source drift and mode labels.
- Current-checkout `full` run with zero skipped/unexpected results.
- For payment-state changes, run the disposable PostgreSQL gate and prove
cleanup left no matching temporary databases.
### 7. Wrong vs Correct
#### Wrong
```bash
bash scripts/qa/run-local-candidate.sh list
echo LOCAL_CANDIDATE_PASS
```
#### Correct
```bash
bash scripts/qa/run-local-candidate.sh full
# Only this successful branch emits LOCAL_CANDIDATE_PASS.
```
@@ -938,6 +938,69 @@ validation prevent repeated re-entry and reduce support/debugging cost.
- Focused Playwright coverage for draft save, submit validation, unsaved-change
protection and server-validation failure paths when changing form behavior.
## Scenario: Data-Router Unsaved-Change Protection
### 1. Scope / Trigger
- Trigger: a React form can lose unsaved work through in-app navigation,
browser back/forward, reload, closing its panel or switching records.
### 2. Signatures
- Router: `createBrowserRouter(...)` rendered through `RouterProvider`.
- Navigation blocker: React Router `useBlocker(isDirty)`.
- Reload/close guard: `beforeunload` while `isDirty === true`.
- Shared UI contract: one unsaved-change dialog with continue-editing and
discard actions.
### 3. Contracts
- Block navigation before the route or selected record changes; do not restore
old UI state after navigation has already occurred.
- `blocker.reset()` keeps the current route and form values. `blocker.proceed()`
is called only after explicit discard confirmation.
- Record switching and panel closing use the same pending-action contract as
route navigation. Successful save or explicit discard clears dirty state.
- Register `beforeunload` only while dirty and remove it during cleanup.
### 4. Validation & Error Matrix
- Clean form navigation -> continue without a dialog.
- Dirty in-app navigation or browser back -> block and show one dialog.
- Continue editing -> URL, record selection and values remain unchanged.
- Discard -> execute exactly one pending navigation/selection and clear dirty.
- Reload/close while dirty -> browser-level unload warning remains active.
### 5. Good/Base/Bad Cases
- Good: reimbursement, procurement, contract and asset forms reuse one guard
while keeping their own save and dirty-field logic.
- Base: a short non-editable filter form does not register the guard.
- Bad: listen to history after navigation, then push the previous URL back; the
user can see state flicker or lose form data before confirmation.
### 6. Tests Required
- Playwright covers link navigation, browser back, reload and record switch.
- Assert cancel preserves URL and values; discard changes target once; save
removes the warning.
- Frontend typecheck and build after router or shared guard changes.
### 7. Wrong vs Correct
#### Wrong
```ts
window.addEventListener("popstate", () => navigate(previousUrl));
```
#### Correct
```ts
const blocker = useBlocker(isDirty);
// reset to keep editing; proceed only after explicit discard.
```
## Convention: Detail Evidence Layout
**What**: Detail pages use one evidence-first structure so users can understand
@@ -0,0 +1,8 @@
{"file":".trellis/spec/hyb-oa/project-governance.md","reason":"验证任务合同、复审、提交和归档边界"}
{"file":".trellis/spec/hyb-oa/backend-fastify-prisma.md","reason":"验证权限、事务、迁移与服务端回归"}
{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"验证页面一致性、响应式和浏览器证据"}
{"file":".trellis/spec/hyb-oa/approval-workflow.md","reason":"验证审批角色、自审和 workflow 约束"}
{"file":".trellis/spec/hyb-oa/docs-and-evidence.md","reason":"验证证据、finding 生命周期和文档事实源"}
{"file":".trellis/spec/hyb-oa/security-privacy.md","reason":"验证敏感字段、日志、附件与安全摘要"}
{"file":".trellis/spec/hyb-oa/deployment-trialrun.md","reason":"验证本地候选结论没有越界为已部署"}
{"file":".trellis/tasks/archive/2026-07/07-16-system-multidimensional-audit/research/findings.md","reason":"逐项核对已承接 finding 的状态和证据"}
@@ -0,0 +1,99 @@
# 五人团队正式内用最小收口设计
## 实施顺序
1. **角色与敏感资料**:先完成 enum/权限常量、账号与角色边界、员工 DTO、工资/银行/证件/合同授权及矩阵测试。
2. **付款一致性**:在新角色合同稳定后完成候选查询、行锁状态机、取消原因、回单作废、前端选择器及 PostgreSQL 并发验证。
3. **页面与门禁**:完成共享表格、dirty guard、移动路由、权限显隐、路由 manifest 和 `local-candidate` runner,最后回填旧标题任务。
每阶段先跑聚焦测试再扩展到全量。权限、状态机、workflow 和迁移在实现后由只读 reviewer 复核,不让 reviewer 修改代码。
## 角色与权限设计
### 角色模型
- `employee` 是所有日常账号基础角色。
- `manager`、`ceo`、`finance`、`system_admin` 是可兼任职责。
- `finance_mgr`、`hr`、`administration` 保留兼容,不自动迁移。
- `admin` 仍映射 `*`,仅现有超级管理员/初始化流程可用;普通创建和普通角色编辑不提供该选项。
### system_admin 授权
允许:账号创建/启停/凭据重置、为其他用户分配普通业务角色、系统设置、集成、通知渠道、脱敏审计查看。
拒绝:修改自身角色、授予或撤销 `admin/system_admin`、人员完整档案、工资、银行/证件、员工合同、业务审批、付款制单/执行/复核。
普通角色修改继续使用既有 endpoint,但服务端按操作者角色和目标用户实施额外约束;UI 限制只做体验,不作为安全边界。角色变更审计只记录操作者、目标用户 ID、前后角色代码和结果,不记录凭据或员工敏感字段。
### 敏感资料数据流
```text
角色/对象判断 -> 选择最小 Prisma select -> DTO 字段省略 -> 前端按权限渲染
-> 脱敏审计(成功访问/写入)
```
- 业务员工目录固定字段:`id / employeeNo / name / title / department / avatar / status`。
- 完整 profile:本人或人员资料管理权限。
- 银行资料:finance 只读;admin/明确管理权限可写。
- 证件、员工合同:仅明确管理权限;内部路径、原始正文和不必要文件名不进入 DTO/审计。
## 付款设计
### 状态机
```text
draft -> reviewed -> executed
-> failed -> executed(仅失败项)
-> cancelled(仅未执行项)
-> cancelled
```
- `executed`、`cancelled` 终态;已执行 item 永不回滚。
- review 需要 `payment.batch.review` 且角色代码包含 `manager` 或 `ceo`;创建者自审返回 409。
- 50000 元阈值只影响警示/确认文案,不增加状态或第三复核人。
### 锁与并发
- review/execute/cancel/付款元数据更新/回单作废:同一事务内先 `SELECT ... FOR UPDATE` 锁批次。
- create:稳定排序并锁定源报销,复查 `approved`、未付款且未进入非 cancelled 批次。
- execute:稳定排序锁定开放 item 对应源单,再复查状态、金额和回单门槛。
- 竞争失败统一返回 409 业务码;失败重试按所有 item 最终状态重算汇总。
- `payment_batch_v1` 启用时旧直接付款端点返回兼容性 409;关闭 flag 可回滚旧代码路径。
### API / 数据
- Prisma 加法迁移:`RoleCode.system_admin`;`payment_batches.cancelled_reason TEXT NULL`。
- `GET /api/payment-batches/candidates?q=&page=&pageSize=` 返回业务展示字段,entity ID 仅用于后续提交。
- 取消 body:`{ reason }`;DTO 增 `cancelledReason`。
- 回单作废:`{ reason }`;复用现有治理/归档字段,不物理删除。
- 旧 DELETE/直接付款仅作为 feature flag 回滚路径;新版开启时不得产生第二写路径。
### 迁移与回滚
- 只提交加法 migration,不运行 migrate deploy,不修改真实数据。
- 代码回滚时关闭 `payment_batch_v1` 并回退应用提交;enum 和 nullable 列保留,避免破坏已写数据。
- 一旦部署后曾授予 `system_admin`,回退到不识别该枚举值的旧应用前,必须先在兼容版本中撤销或转换该角色,或保留可识别该值的兼容解析;不得让旧 Prisma client 直接读取未知枚举值。
- 不修改迁移历史,不执行 migrate reset。
## 页面与门禁设计
- 表格修复优先调整共享容器:表格自身保留最小宽度,容器 `overflow-x:auto`,document 不横溢;五个代表页做命中和尺寸断言。
- dirty guard 使用共享 hook/provider,统一拦截站内导航、浏览器返回/刷新和记录切换;保存成功或显式放弃后清除 dirty。
- `/mobile/attendance` 复用现有响应式考勤页;移动 resolver、首页和通知目标统一使用该路径,避免 `/attendance -> /mobile` 自循环。
- 移动归档只处理长字段换行/局部 overflow 和底栏安全区,不扩展移动信息架构。
- 路由 manifest 每条注册路由必须显式分类;`core` 必须有可实际执行的行为 spec,标记 `visualRequired` 的路由还必须有视觉 spec;manual/live 文件不计本地候选证据。
- `local-candidate` 组合基础门禁、路由/流程可信扫描和核心本地浏览器测试,不读取公网或生产环境变量。
## 验证矩阵
- **权限**:employee / manager / ceo / finance / system_admin / admin 正负矩阵;字段省略;拒绝时零 DB/file side effect;角色自提升负例。
- **付款**:候选筛选、POST 权威拒绝、maker-checker、高额单次复核、取消原因、回单作废、审计、失败重试和权限负例。
- **PostgreSQL 并发**:双 execute、execute vs cancel、双 create 同一报销、direct pay vs batch execute;使用隔离合成数据库并清理。
- **浏览器**:375×812 移动考勤/归档;768×1024 与 1024×768 五类表格;四类 dirty form;危险动作 0/0/1 mutation;三处权限按钮 count=0。
- **门禁反例**:unclassified、manual-only、core behavior missing、visualRequired missing 均非零退出。
## 证据与状态
- 截图和临时浏览器产物放 `output/playwright/07-18-small-team-internal-readiness/`,只保留合成/脱敏内容。
- 原 finding 台账只更新当前修复状态和证据引用,不改发现时严重度;未部署项继续保持部署/外部验证待定。
- 本地完成结论固定为 `LOCAL_CANDIDATE_PASS`。
@@ -0,0 +1,93 @@
# 五人团队正式内用最小收口证据
## 证据身份与边界
- 发现基线:`bbb5c89`;规划基线:`58c8dec`。
- 复核对象:本文件所在工作提交;提交前所有结果仅归属于当前受控工作树,提交后以本文件所在提交为 `reviewedCommit`。
- 允许环境:回环 HTTP、回环 PostgreSQL、合成角色/业务数据、无网络外部适配器。
- 未执行:部署、push、公网、生产配置、共享或真实数据库、真实账号、企业微信真机、真实 OCR、真实付款、银行流水和 ERP。
- 状态上限:`LOCAL_CANDIDATE_PASS`;不得据此告知用户线上可测试。
## 最终门禁摘要
| 证据 | 结果 | 关键结论 |
| --- | --- | --- |
| 权限与敏感资料只读复审 | PASS,聚焦 107/107;统计权限 4/4 | `system_admin` 与经理/CEO/财务/超级管理员分离;工资/绩效汇总使用独立敏感权限,拒绝路径先于敏感查询或文件读取。 |
| 付款状态机只读复审 | PASS,聚焦 36/36;重试回归 27/27 | maker-checker、锁顺序、直付关闭、失败项重试、取消/回单审计均无 P1。 |
| 候选 workflow 只读复审 | `PASS_WITH_FOLLOWUPS`,follow-up 已修复 | witness 来源、结果 parser、loopback、模式标签与无历史 Issue CSV 依赖均有反例。 |
| migration 只读复审 | PASS,无 P1/P2 未结项 | 两项加法 migration;`system_admin` 位于 `admin` 前;可空取消原因;回滚前置已记录。 |
| 服务端全量 | PASS,161/161 文件、985/985 测试 | 包含用户导入 8/8、付款批次 27/27、员工统计权限 4/4。 |
| 前端静态门禁 | PASS | typecheck 与 Vite production build 通过;仅既有 Browserslist 提示。 |
| 默认完整基础门禁 | PASS,exit 0 | `bash scripts/check-minimal.sh` 的历史流程基线、984 项服务端测试、前端 build 与功能矩阵全部通过;本机 stub 使用回环权限。 |
| 路由与候选反例 | PASS,52/52 | 71 条路由全部分类;10/10 核心流程束、10/10 witness、7/7 acceptance、0 blocking gap。 |
| 隔离 PostgreSQL | PASS,exit 0 | 当前 61 项 migration 从空库应用;五组付款竞态 PASS;审计/通知唯一;临时库残留 0。 |
| Playwright focused | `FOCUSED_BROWSER_PASS`,44/44 | 0 failed、0 skipped、0 unexpected;10 个 witness 均来自指定 spec。 |
| Playwright/full 候选 | `LOCAL_CANDIDATE_PASS`,exit 0 | 2026-07-18 13:27 CST 完成;44/44,0 failed、0 skipped、0 unexpected;基础层与结果门禁同时通过。 |
默认门禁第一次在禁止回环监听的沙箱内运行时,两个本机 stub 测试未能产生完整结果;允许本机-only stub 后聚焦 12/12 通过,随后原命令完整 exit 0。该诊断未访问公网,也未作为 PASS 替代原命令。
## PostgreSQL 竞态与迁移证据
命令:`bash scripts/qa/run-web-gates.sh payment-concurrency-local`。
- 双执行:一个成功、一个确定性 409;付款/执行审计和内部通知各唯一。
- 执行与取消:本轮执行成功、取消 409;无重复状态或副作用。
- 双创建:一个 201、一个 409;仅一个有效批次/项及一条创建审计。
- 直接付款与批次付款:范围外均拒绝且零副作用;范围内直付 409、批次执行成功。
- 缺失制单人:复核 409,批次保持 draft,零复核审计。
- 空库 migration 后只读确认枚举相邻顺序为 `system_admin` 后接 `admin`。
- runner 与独立检查结束后,`hyb_oa_payment_%` 临时数据库计数为 0。
## 十个核心流程 witness
每项均断言最终路径、唯一 H1、合成角色、允许/禁止动作,以及 `unhandled API / page error / 非白名单 console error = 0`。
| 流程束 | 最终路径 | H1 | 角色 fixture | 允许动作 | 禁止动作 |
| --- | --- | --- | --- | --- | --- |
| 登录退出 | `/login` | 海音达 OA | employee | 登录 | 应急超级管理员 |
| 报销 | `/reimbursements` | 我的报销 | employee | 新建报销 | 导出 |
| 审批 | `/workflow/tasks` | 待我处理 | manager | 处理 | 通过 |
| 票据 | `/invoice-pool` | 票据 | employee | 上传票据 | 作废 |
| 采购 | `/purchase-requests` | 采购 | employee | 新建采购 | 通过 |
| 项目/合同 | `/contracts-registry` | 合同 | manager | 新增合同 | 执行付款 |
| 付款/AP | `/payment-batches` | 付款 | finance | 新建批次 | 复核批次 |
| 人员/审计 | `/system/users` | 系统用户管理 | system_admin | 新建用户 | 应急超级管理员 |
| 考勤/归档 | `/mobile/attendance` | 考勤 | employee | 修正申请 | 提交应急修正 |
| 移动工作台 | `/mobile` | 今天待办 | employee | 新建报销 | 财务 |
## 浏览器视口与截图
- 移动:375×812,覆盖移动考勤、归档空态、长字段详情、换行、底栏遮挡与页面横溢。
- PC:768×1024、1024×768,覆盖项目、资产、合同、付款、报销五类表格;局部横滚可用、右侧操作可命中、document 横溢不超过 2px。
- 截图均使用合成身份和合成业务记录,已人工复核未见真实资料、内部 Secret、完整账号、银行资料或原始 payload。
- 产物目录为 `output/playwright/07-18-small-team-internal-readiness/`;最终 full 复跑后的 SHA-256 如下。
| 截图 | SHA-256 |
| --- | --- |
| `assets-1024x768.png` | `d3d0ff0d9dd413521a068ca1c87ad6978d12d08e836d6fe3c1048559f013014b` |
| `assets-768x1024.png` | `1a5c9b07617479cfa14fa9a4525bbdf32fac6eb74635d8dbd7641e319871abbc` |
| `contracts-registry-1024x768.png` | `f318e1637f3eb65eb853f3d0436c1825efca4fcc9336b80a6cccb6ab7236b3b9` |
| `contracts-registry-768x1024.png` | `5ce2b667aef2fd24cdc918729fe998327991246417477d6301f8314406bde7dd` |
| `desktop-archive-1024x768.png` | `9d099e825e897f37383ee7d40622322946356a9dd89b8893d9531120da6750f6` |
| `mobile-archive-detail-375x812.png` | `6a1359c4bae284cd68872faa148755e2b6fc53b7be41b1739ae2aff0090a9436` |
| `mobile-archive-empty-375x812.png` | `9b6fb325287e4436de7a71f80cf5b75c3a4e5d4f183b8919af5ffa59cf1070ca` |
| `mobile-attendance-375x812.png` | `01f75c88be01b4e4ab0696bb1de39f211eaaee402377757bf7384a278c197a7d` |
| `payment-batches-1024x768.png` | `0d8f69b360a7fbfcf5a0774aa031a0c6845dac6f468a28486c72a575db0a8db1` |
| `payment-batches-768x1024.png` | `72554a7d3a6778547a69732a5a2c1cf49bfdb2e1394acb3f465782becd559ef1` |
| `projects-1024x768.png` | `cf925dede4124e1780c8a9e67e82bf596e96278131fd03c1a115e6d45dd22eba` |
| `projects-768x1024.png` | `da5dd4b0842b407d715b91e5025f8b4e6c68cb7d7b5a87c0e460630a95f40308` |
| `reimbursements-1024x768.png` | `30726bbdba0d8dc0e80d8aa269c57996d72ce0564c84c333b7cb8a97e832e096` |
| `reimbursements-768x1024.png` | `46a146af17f1f69069492b1232071de4ae55f81f73a36ed95d5672f56e887621` |
## Finding 生命周期边界
- 原 finding 台账已按本证据更新本地修复状态,未改写发现时严重度、置信度或发现状态。
- `05-31-unified-page-header-actions` 的权限显隐、资产旧 E2E、七页面标题动作及项目/资产视觉验收已闭合,并归档至 `archive/2026-07/`。
- 本地验证通过的条目只更新 `当前修复状态=LOCAL_FIX_VERIFIED`。
- 未部署的运行时条目继续 `DEPLOYMENT_PENDING`;企业微信/角色/公网行为继续 `EXTERNAL_BEHAVIOR_PENDING`。
- 纯测试/门禁条目使用 `NOT_APPLICABLE` 部署状态;不需要外部行为的条目使用 `NOT_REQUIRED`。
- 移动通知定位只闭合考勤入口增量;其他对象定位不在本任务范围,`MOB-TARGET-001` 整体保持开放。
## 后续授权边界
真实账号职责分配、超级管理员托管、内部地址部署、migration 执行、备份恢复、部署来源、公网角色、企业微信真机和新 Day 1 连续试运行均由后续获批 Trellis task 承担。
@@ -0,0 +1,8 @@
{"file":".trellis/spec/hyb-oa/project-governance.md","reason":"任务、审批、迁移、复审和提交边界"}
{"file":".trellis/spec/hyb-oa/backend-fastify-prisma.md","reason":"Fastify、Prisma、事务、迁移和服务端测试规则"}
{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"React/Vite 页面、权限显隐、响应式和视觉验证规则"}
{"file":".trellis/spec/hyb-oa/approval-workflow.md","reason":"经理、公司负责人、自审和审批链合同"}
{"file":".trellis/spec/hyb-oa/reimbursement-invoice-ocr.md","reason":"报销、付款、附件和回单业务边界"}
{"file":".trellis/spec/hyb-oa/security-privacy.md","reason":"员工敏感数据、审计脱敏和附件安全边界"}
{"file":".trellis/spec/hyb-oa/deployment-trialrun.md","reason":"LOCAL_CANDIDATE 与部署/公网结论分离"}
{"file":".trellis/tasks/archive/2026-07/07-16-system-multidimensional-audit/research/findings.md","reason":"本任务承接 finding 生命周期与控制目标"}
@@ -0,0 +1,105 @@
# 五人团队正式内用最小收口
## Goal
在不部署、不连接真实数据、不进行公网或企业微信真机验证的前提下,把当前代码收敛为适合约 5 人公司内部使用的本地候选版本。角色上明确分离业务管理、财务和 OA 系统运维;业务上保证付款状态、敏感资料和高影响操作可控;工程上建立可信的本地候选门禁。
本任务承接用户在 2026-07-17 批准的 `07-17-small-team-internal-readiness` 方案。由于任务实际创建日期为 2026-07-18,Trellis 目录按规则使用 `07-18-small-team-internal-readiness`。
## Scope
- Backend:Fastify 权限、员工/账号/工资/合同和付款批次路由,Prisma 加法迁移,聚焦与并发测试。
- Frontend:角色管理、敏感资料与付款页面、共享表格/dirty guard、移动考勤与归档、聚焦 E2E/视觉断言。
- QA / governance:路由分类、流程覆盖扫描、`local-candidate` runner、当前 task、必要 Trellis spec/现有手册及 finding 生命周期。
- 只使用本地合成数据和隔离测试资源;不改变生产配置或真实数据。
## Requirements
### 角色、权限与敏感资料
- 所有账号保留员工基础能力;日常职责可显式兼任 `manager`、`ceo`、`finance` 和新增 `system_admin`。
- `manager` 与员工 `direct_manager_id` 分开维护;经理身份不自动建立汇报关系。
- 新增 `RoleCode.system_admin`。现有 `admin` 继续作为通配超级管理员,但只用于初始化和应急,不作为普通预设。
- 普通用户管理界面显示员工、直属经理、公司负责人、财务、OA 系统管理员;`finance_mgr/hr/administration` 保留为高级兼容职责;普通界面不可授予 `admin`。
- `system_admin` 可管理其他普通账号、业务角色、系统配置、集成和审计;不得修改自己的角色,不得授予或撤销 `admin/system_admin`,所有变更写脱敏审计。
- `system_admin` 不获得工资、银行、证件、员工合同、付款执行或业务审批权限。
- 付款复核要求显式具有 `manager` 或 `ceo`,不能仅凭 `admin` 或 `system_admin` 进入正常审批路径。
- 新增细分权限:`system.accounts.manage`、`system.roles.manage`、`payroll.manage`、`employee.bank.read`、`employee.bank.manage`、`employee.identity.manage`、`employee.contract.manage`、`payment.batch.review`。
- `/employees` 对业务选择调用者只返回窄 DTO;完整员工档案仅本人或人员资料管理员可读,权限拒绝发生在敏感查询或文件读取前。
- 财务只读银行字段,不读取证件或修改敏感资料;未授权字段从响应中省略。
- 员工合同列表、元数据、附件下载和写操作使用独立权限;DTO 不返回内部路径,成功访问和变更写脱敏审计。
- 前端按权限隐藏动作;后端仍为最终授权边界。
### 付款一致性与最小审计
- 固定“财务制单/执行 + 经理或公司负责人复核”;创建者不得复核自己的批次。
- 所有金额只需一次独立复核;总金额达到 50000 元时增加警示和强确认,不增加第三人审批。
- 批次状态为 `draft -> reviewed -> executed|failed|cancelled`;`failed` 只重试失败项或取消剩余项;`executed/cancelled` 为终态。
- 创建、复核、执行、取消、付款元数据更新和回单作废在事务内锁定批次;执行再按稳定顺序锁定源单并复查状态和金额。
- 并发竞争返回确定性 409,不重复更新付款状态、审计或通知。
- 正式范围只接受已审批、未付款且未进入其他有效批次的报销;`payment_batch_v1` 启用时直接报销付款端点不得形成第二付款路径。
- 新增付款候选查询,页面按业务编号、人员、金额和日期选择,不要求粘贴内部 UUID;创建接口继续权威复查。
- 取消必须填写 2–500 字原因;取消失败批次只终止未付款项,不表示退款或冲正。
- 回单采用有理由的留痕作废,不物理删除;已复核后的付款元数据变更写 before/after 审计。
### 核心页面与本地候选门禁
- 项目、资产、合同、付款、报销表格在 768×1024 和 1024×768 下操作可达;容器可控横滚,页面整体不横向溢出。
- 报销、采购、合同、资产长表单共享未保存保护,覆盖站内跳转、记录切换、浏览器返回和刷新。
- 高敏附件删除和付款回单作废显示对象、后果和理由确认;普通可恢复动作不升级为强确认。
- 新增 `/mobile/attendance` 并消除移动考勤自循环;修复 375×812 下移动归档长字段裁切和底栏遮挡。
- 完成付款、合同、票据的权限显隐;修正资产旧 E2E,补齐项目/资产标题动作视觉覆盖;通过后回填并归档 `05-31-unified-page-header-actions`。
- 所有注册路由分类为 `core`、`supported-noncore`、`deferred` 或 `frozen`。未分类、核心行为测试缺失、仅由 `manual/live` 测试覆盖时门禁非零退出。
- 本地核心矩阵覆盖:登录退出、报销、审批、票据、采购、项目/合同、付款/AP、人员/审计、考勤/归档、移动工作台。
- 建立仓库内可移植的 `local-candidate` 门禁,不依赖公网、生产配置、历史 Issue CSV 或个人绝对路径。
## Acceptance Criteria
- [x] Prisma enum 包含 `system_admin`,加法迁移可验证且有回滚说明;既有角色与历史账号不自动迁移。
- [x] 角色矩阵证明业务管理、财务、系统管理员和超级管理员职责分离,系统管理员不能自我提权。
- [x] 员工目录、完整档案、银行、证件、工资和员工合同权限满足字段级与对象级边界;拒绝路径无敏感 DB/file 副作用。
- [x] 付款批次创建、复核、执行、失败重试、取消和回单作废满足状态机、maker-checker、原因与审计合同。
- [x] 合成本地 PostgreSQL 并发验证覆盖双执行、执行与取消、双创建、直接付款与批次付款竞争,结果唯一且不重复审计/通知。
- [x] 付款页面使用业务候选选择器,不要求普通用户输入 UUID;高额仅增加警示和确认。
- [x] 五类表格、四类未保存表单、不可逆动作、移动考勤和移动归档通过聚焦浏览器验证。
- [x] 付款、合同、票据权限显隐与后端拒绝一致;`05-31-unified-page-header-actions` 剩余测试和视觉证据闭合后才归档。
- [x] 路由清单无未分类项,门禁反例对 unmapped、manual-only、核心缺失行为/视觉均能失败。
- [x] 十个核心流程束在本地合成环境实际运行,记录最终 URL、H1、角色、主操作和页面错误断言。
- [x] 完整本地质量门禁通过;权限、状态机、workflow 和迁移变更完成独立只读复审。
- [x] 收口结论只写 `LOCAL_CANDIDATE_PASS`,不写已部署、可公网测试或企业微信真机通过。
## Definition of Done
- 代码、迁移、测试、现有规范/手册和 finding 生命周期同步更新,未新建重复审查报告。
- `bash scripts/check-minimal.sh`、Prisma 校验、服务端聚焦及全量测试、前端 typecheck/build、聚焦 Playwright、task validate、Trellis governance、敏感扫描、文档 registry 和 `git diff --check` 通过。
- 三个实施阶段分别复核,最后以中文 commit 引用本任务;不部署、不 push。
- `05-31-unified-page-header-actions` 仅在其剩余验收确实闭合后归档;当前任务完成后按 Trellis finish-work 归档并记录 journal。
## Test Plan
- 静态与单元:Prisma validate、触及服务端文件 `node --check`、权限/DTO/审计/付款路由 Vitest、前端 typecheck/build。
- 集成:隔离合成 PostgreSQL 应用已提交 migration,运行付款四类并发竞争,结束后清理数据库;不连接共享开发库或生产库。
- 浏览器:本地 Vite dev 与合成 fixture 实际运行十个核心流程;PC 768×1024/1024×768 和 WeCom UA 375×812 记录 URL、H1、角色、错误及溢出指标。
- 门禁可信度:为 unclassified、manual-only、core behavior missing、visualRequired missing 提供反例测试并断言非零退出;Playwright `--list` 只证明资产可解析。
- 全量收口:`bash scripts/check-minimal.sh`、task validate、Trellis governance、敏感扫描、文档 registry、`git diff --check`;权限/状态机/workflow/迁移另做只读 reviewer 复核。
## Decision (ADR-lite)
**Context**:公司约 5 人,需要低配置成本,但系统维护者不一定是领导,付款与高敏资料仍需要最小职责分离。
**Decision**:保留现有多角色引擎和业务职责,新增受限 `system_admin`,保留隐藏的应急 `admin`;付款使用数据库行锁和 maker-checker,不建设通用权限平台、事件平台或多级审批系统;发布只增加本地候选模式。
**Consequences**:日常界面简单且允许一人兼任多个职责;数据库仅做加法迁移;至少两个不同账号才能闭环付款;真实账号分配和内部环境验证必须在后续授权任务完成。
## Out of Scope
- 部署、公网检查、生产配置、真实账号分配、真实数据库写入、迁移 deploy、数据库重置、备份恢复演练。
- 企业微信真机、真实 OCR、真实付款、银行直连/流水、退款冲正、ERP/总账、差旅付款恢复。
- 通用 RBAC 平台、权限表达式设计器、全局幂等平台、事件总线、分布式锁、可配置多级金额审批。
- 全量可访问性、搜索直达、复杂离线数据生命周期、所有通知精准定位、非核心路由视觉重做。
## Technical Notes
- 规划基线:`58c8dec`;实现开始前若 HEAD 变化,更新 task notes 并重跑全部门禁。
- 发现来源:归档任务 `07-16-system-multidimensional-audit` 中 `DATA-EMP-001`、`DATA-HR-ATT-001`、`PAY-STATE-001`、`PAY-AUDIT-001`、`PAY-UX-001`、`UX-SAFE-001`、`UX-FORM-001`、`MOB-ATTEND-001`、`RESP-TABLE-001`、`REL-GATE-001`、`UI-PERM-001`、`MOB-ARCHIVE-001`。
- 页面可靠性沿用现有设计语言,不做新视觉风格裁决;当前会话没有可用的 `frontend-design` skill。
@@ -0,0 +1,26 @@
{
"id": "small-team-internal-readiness",
"name": "small-team-internal-readiness",
"title": "五人团队正式内用最小收口",
"description": "面向约 5 人公司,分离业务管理与 OA 系统运维,收敛敏感资料、付款一致性、核心页面和本地候选门禁。",
"status": "in_progress",
"dev_type": null,
"scope": "fullstack",
"package": null,
"priority": "P1",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-07-18",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "承接用户批准的 07-17 方案;实际创建日期为 2026-07-18,规划基线 58c8dec。2026-07-18 当前工作提交通过 full 本地候选门禁(44/44 浏览器、10/10 witness、0 skipped/failed/unexpected)、服务端 985/985、隔离 PostgreSQL 61 migrations 与五组竞态、权限/付款/workflow/migration 只读复审;复审发现的员工统计敏感汇总权限漏口已修复并以 system_admin 零敏感查询回归验证,结论为 LOCAL_CANDIDATE_PASS。本轮未部署、未 push、未访问公网/生产/真实数据、未做企微真机或真实付款;后续内部部署另行授权。",
"meta": {}
}
@@ -79,7 +79,7 @@
- [x] 规则写入前端设计规范,后续页面可复用。
- [x] 标题、筛选、列表列、空状态、详情/编辑操作区至少完成一致性审计,形成后续改造清单。
- [x] 前端 typecheck/build 通过。
- [ ] 聚焦 Playwright/视觉复检覆盖主要业务页。
- [x] 聚焦 Playwright/视觉复检覆盖主要业务页。
## Test Plan
@@ -114,3 +114,11 @@ node scripts/qa/check-trellis-governance.mjs
- `HeaderActionGroup` 目前只统一布局,不约束动作数量;缺少七页面统一标题动作合同测试,现有截图缺项目和资产列表。
上述 finding 需要独立修复 task;在修复并补齐视觉/回归覆盖前,本任务保持 `in_progress`,不得虚报完成或归档。
## Final Closeout Verification (2026-07-18)
- 承接任务 `07-18-small-team-internal-readiness` 已闭合上述 follow-up:付款复核角色看不到新建批次,应付账款导出按权限隐藏,合同只读角色看不到新增/编辑/续约/归档,票据无上传权限时 fail-closed;对应合成 Playwright 5/5 纳入最终候选门禁。
- `assets-flow.spec.ts` 和历史 manual 资产用例已改为通过标题区 `更多 -> 资产分类`;资产流程 spec 可解析,当前菜单行为由实际浏览器回归覆盖。
- `r103-cross-page-ui-consistency.spec.ts` 扩展为 8 项:七个页面标题区严格执行“一主操作 + 最多一个更多”,并覆盖项目/资产代表视觉、资产“更多 -> 资产分类”及核心页面动作合同。
- 当前 checkout 的 `bash scripts/qa/run-local-candidate.sh full` 最终为 44/44 passed、0 skipped/failed/unexpected;项目、资产、合同、付款、报销双断点截图及权限显隐 spec 均包含在候选 acceptance 集合。
- 本任务仅完成本地前端验收并归档;未部署、未公网复检,不能据此宣称线上可测。
@@ -3,7 +3,7 @@
"name": "unified-page-header-actions",
"title": "统一页面右上角操作区规划",
"description": "统一 PC 页面标题右上角操作区规划,收敛主操作、更多、筛选、导出、刷新等按钮位置。",
"status": "in_progress",
"status": "completed",
"dev_type": null,
"scope": "frontend",
"package": null,
@@ -11,7 +11,7 @@
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-05-31",
"completedAt": null,
"completedAt": "2026-07-18",
"branch": null,
"base_branch": "main",
"worktree_path": null,
@@ -21,6 +21,6 @@
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "2026-07-16 收口验证:typecheck/build 和 r103 3/3 通过,标题迁移 6 项验收已证实;仍有权限显隐、资产旧测试和项目/资产视觉覆盖 finding,需独立修复 task 后才能归档。",
"notes": "2026-07-18 由 07-18-small-team-internal-readiness 闭合剩余项:付款/合同/票据权限显隐合成 Playwright 5/5,资产旧 E2E 改用更多菜单,r103 扩展为 8 项并覆盖七页面标题动作及项目/资产视觉;最终 local-candidate full 为 44/44、0 skipped/failed/unexpected。本任务仅本地验收,未部署或公网复检,可归档。",
"meta": {}
}
@@ -3,7 +3,7 @@
## 台账口径
- 本文件是当前任务唯一的分级与生命周期事实源。分域报告保留发现证据,但不再各自决定当前严重度或状态。
- 发现基线为 `bbb5c89`;当前本地增量目标为 `8f8606c`。发现时严重度不因后续修复而改写,收敛校准会在单元格中显式注明。
- 发现基线为 `bbb5c89`;原审查收口增量目标为 `8f8606c`。后续修复任务 `07-18-small-team-internal-readiness` 的规划基线为 `58c8dec`,其本地验证以该任务 `evidence.md` 所在工作提交为准。发现时严重度不因后续修复而改写,收敛校准会在单元格中显式注明。
- 发现状态、当前修复状态、部署状态、外部验证状态彼此独立。
- `NO_FIX_TO_DEPLOY` 表示尚无修复产物,不等于待部署;`NOT_APPLICABLE` 仅用于非运行时代码/纯测试合同。
- `ACTIVE_TASK_OPEN` 表示该项仍开放但已由现有活动 task 承载;本轮只做去重,不另建 task。`FOLLOW_UP_RECOMMENDED` 表示持续治理建议,不是已修复。
@@ -15,24 +15,24 @@
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `SEC-AUTH-001` | 认证 / 代理信任 | P0 | 高 | `CONFIRMED` | `LOCAL_FIX_VERIFIED` | `DEPLOYMENT_PENDING` | `EXTERNAL_BEHAVIOR_PENDING` | `business-security-review.md#bsr-sec-001`;归档任务 `07-17-auth-challenge-trusted-proxy-hardening` | 认证边界不足可能导致账号被未授权控制。 | 挑战单次有效、并发一致、来源可信且可审计。 | 当前 HEAD 聚焦负路径、完整服务端与隔离并发验证通过。 | 另行授权:部署与公网复核 |
| `SEC-ATT-001` | 业务附件 / 浏览器隔离 | P0 | 高 | `CONFIRMED` | `LOCAL_FIX_VERIFIED` | `DEPLOYMENT_PENDING` | `EXTERNAL_BEHAVIOR_PENDING` | `business-security-review.md#bsr-sec-002`;归档任务 `07-17-purchase-attachment-xss-hardening` | 主动内容可能破坏 OA 同源会话与业务数据边界。 | 实际内容验证、重新授权/审计、非执行下载。 | 当前 HEAD 策略/路由、完整服务端与聚焦浏览器验证通过。 | 另行授权:部署与公网复核 |
| `DATA-EMP-001` | 员工敏感数据授权 | P1 | 高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `EXTERNAL_BEHAVIOR_PENDING` | `business-security-review.md#bsr-authz-001` | 广泛能力可能读取超出岗位需要的员工敏感字段。 | 字段级授权与人员范围同时最小化。 | 只读跨层检查确认权限粒度不足,未读取真实资料。 | WP-1 员工敏感数据范围授权 |
| `PAY-STATE-001` | 付款状态机 / 并发 | P1 | 高 | `LIKELY` | `OPEN` | `NO_FIX_TO_DEPLOY` | `EXTERNAL_BEHAVIOR_PENDING` | `business-security-review.md#bsr-data-001` | 并发状态变化可能造成重复结果或证据覆盖。 | 条件更新/锁、幂等和不可覆盖审计保证单一结果。 | 静态事务检查发现保护证据不足,未连接数据库或执行付款。 | WP-2 付款状态一致性与审计 |
| `DATA-HR-ATT-001` | 员工合同附件 | P1 | 高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `EXTERNAL_BEHAVIOR_PENDING` | `business-security-review.md#bsr-priv-001` | 高敏合同资料可能受过宽权限控制且缺访问用途审计。 | 独立最小权限、对象级授权、安全 DTO 和下载审计。 | 只读路由/测试合同确认缺口,未读取真实合同。 | WP-3 员工合同附件权限与访问审计 |
| `DATA-EMP-001` | 员工敏感数据授权 | P1 | 高 | `CONFIRMED` | `LOCAL_FIX_VERIFIED` | `DEPLOYMENT_PENDING` | `EXTERNAL_BEHAVIOR_PENDING` | `business-security-review.md#bsr-authz-001`;任务 `07-18-small-team-internal-readiness/evidence.md` | 广泛能力可能读取超出岗位需要的员工敏感字段。 | 字段级授权与人员范围同时最小化。 | 当前工作提交的窄目录 DTO、字段/对象权限、员工统计敏感汇总独立授权、零敏感副作用矩阵与全量服务端门禁通过;未读取真实资料。 | WP-1 员工敏感数据范围授权 |
| `PAY-STATE-001` | 付款状态机 / 并发 | P1 | 高 | `LIKELY` | `LOCAL_FIX_VERIFIED` | `DEPLOYMENT_PENDING` | `EXTERNAL_BEHAVIOR_PENDING` | `business-security-review.md#bsr-data-001`;任务 `07-18-small-team-internal-readiness/evidence.md` | 并发状态变化可能造成重复结果或证据覆盖。 | 条件更新/锁、幂等和不可覆盖审计保证单一结果。 | 空隔离 PostgreSQL 应用 61 项 migration,五组竞态、审计/通知唯一性和临时库零残留通过;未执行真实付款。 | WP-2 付款状态一致性与审计 |
| `DATA-HR-ATT-001` | 员工合同附件 | P1 | 高 | `CONFIRMED` | `LOCAL_FIX_VERIFIED` | `DEPLOYMENT_PENDING` | `EXTERNAL_BEHAVIOR_PENDING` | `business-security-review.md#bsr-priv-001`;任务 `07-18-small-team-internal-readiness/evidence.md` | 高敏合同资料可能受过宽权限控制且缺访问用途审计。 | 独立最小权限、对象级授权、安全 DTO 和下载审计。 | 当前工作提交的独立权限、无内部路径 DTO、内容复验、审计先于响应及拒绝零文件读取测试通过;未读取真实合同。 | WP-3 员工合同附件权限与访问审计 |
| `DATA-EXPORT-001` | 统一导出 | P1 | 高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `NOT_REQUIRED` | `business-security-review.md#bsr-sec-003` | 导出内容可能在表格软件中形成非预期活动行为。 | 全部 CSV/表格出口统一安全编码并有合成负例。 | 静态检查确认缺少统一中和合同,未使用真实导出。 | WP-5 客户端数据生命周期与统一导出 |
| `PRIV-OFFLINE-001` | 客户端数据生命周期 | P1 | 高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `EXTERNAL_BEHAVIOR_PENDING` | `business-security-review.md#bsr-priv-002` | 退出或换账号后残留身份、审批或草稿可能被后续使用者读取。 | 先确认离线必要性,再按账号隔离、最小缓存并可靠清除。 | 静态检查确认持久化和清理未闭合,未检查真实设备。 | WP-5 客户端数据生命周期与统一导出 |
| `DATA-DOWNLOAD-001` | 下载凭证 / 日志面 | P2 | 中高 | `LIKELY` | `OPEN` | `NO_FIX_TO_DEPLOY` | `EXTERNAL_GATED` | `business-security-review.md#bsr-sec-004` | 短期访问凭据可能进入常规浏览器或边缘记录面。 | 使用当前认证上下文下载并最小化 URL、日志和缓存暴露。 | 代码存在风险可能性;真实边缘日志未获授权检查。 | WP-5 客户端数据生命周期与统一导出 |
| `DATA-ACL-001` | 受限资料规则 | P2 | 中高 | `LIKELY` | `OPEN` | `NO_FIX_TO_DEPLOY` | `NOT_REQUIRED` | `business-security-review.md#bsr-authz-002` | 已保存但未执行的规则会造成受限资料边界误判。 | 产品明确其为 ACL 或废弃元数据;保留则由后端强制执行。 | 静态检查确认规则与执行证据不一致。 | WP-5 客户端数据生命周期与统一导出 |
| `PAY-AUDIT-001` | 付款取消审计 | P2 | 高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `NOT_REQUIRED` | `business-security-review.md#bsr-data-002` | 取消责任与前后状态难以完整还原。 | 记录原因、操作者、前后状态与并发条件。 | 静态路由/模型检查确认缺口,未执行真实状态变化。 | WP-2 付款状态一致性与审计 |
| `PAY-AUDIT-001` | 付款取消审计 | P2 | 高 | `CONFIRMED` | `LOCAL_FIX_VERIFIED` | `DEPLOYMENT_PENDING` | `NOT_REQUIRED` | `business-security-review.md#bsr-data-002`;任务 `07-18-small-team-internal-readiness/evidence.md` | 取消责任与前后状态难以完整还原。 | 记录原因、操作者、前后状态与并发条件。 | 可空取消原因 migration、2–500 字原因、保留已执行项、回单留痕作废及 before/after 审计回归通过。 | WP-2 付款状态一致性与审计 |
| `WEB-DEFENSE-001` | 浏览器防御纵深 | P2 | 高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `EXTERNAL_BEHAVIOR_PENDING` | `business-security-review.md#bsr-sec-005` | 缺少内容执行限制会降低未知缺陷的纵深保护。 | 基于资源清单分阶段收紧 CSP 并保持附件独立沙箱。 | 本地配置确认当前未启用全站策略;未推断具体利用路径。 | WP-5 客户端数据生命周期与统一导出 |
| `UI-PERM-001` | PC 写动作显隐 | P2(收敛校准;原分域报告 P1/P2 不一) | 高 | `CONFIRMED` | `ACTIVE_TASK_OPEN` | `NO_FIX_TO_DEPLOY` | `NOT_REQUIRED` | `page-static-review.md#既有-finding-复核与去重`;`BSR-UX-001`;活动任务 `05-31-unified-page-header-actions` | 后端虽拒绝写入,PC 用户仍可能进入最终失败的流程。 | PC 显隐与后端拒绝共享同一能力合同,后端保持权威。 | 付款、合同、票据三处既有 PC finding 完成只读去重;移动导航由 `MOB-PERM-001` 单列。 | WP-6 页面权限显隐及旧标题任务收口 |
| `PAY-UX-001` | 付款业务可用性 | P1 | 高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `NOT_REQUIRED` | `page-static-review.md#page-p1-001` | 财务人员难以凭业务信息选择待付对象,可能错选或无法创建。 | 以待付池和业务编号/人员/金额选择对象,内部标识仅由系统传递。 | 静态页面与 E2E 合同确认普通流程依赖内部标识。 | WP-2 付款状态一致性与审计 |
| `UX-SAFE-001` | 不可逆证据 / 资金动作 | P1(收敛后边界) | 高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `NOT_REQUIRED` | `page-static-review.md#page-p1-002` | 误触可能删除不可逆证据或改变资金结果。 | 强确认展示对象、后果、可逆性与必要理由。 | 静态检查确认代表性高影响动作在 mutation 前缺少强确认。 | WP-6 页面权限显隐及旧标题任务收口 |
| `UI-PERM-001` | PC 写动作显隐 | P2(收敛校准;原分域报告 P1/P2 不一) | 高 | `CONFIRMED` | `LOCAL_FIX_VERIFIED` | `DEPLOYMENT_PENDING` | `NOT_REQUIRED` | `page-static-review.md#既有-finding-复核与去重`;`BSR-UX-001`;归档任务 `05-31-unified-page-header-actions`;任务 `07-18-small-team-internal-readiness/evidence.md` | 后端虽拒绝写入,PC 用户仍可能进入最终失败的流程。 | PC 显隐与后端拒绝共享同一能力合同,后端保持权威。 | 付款、合同、票据正负显隐合成 Playwright 5/5 与后端拒绝测试通过;旧标题任务已完成本地验收并归档。 | WP-6 页面权限显隐及旧标题任务收口 |
| `PAY-UX-001` | 付款业务可用性 | P1 | 高 | `CONFIRMED` | `LOCAL_FIX_VERIFIED` | `DEPLOYMENT_PENDING` | `NOT_REQUIRED` | `page-static-review.md#page-p1-001`;任务 `07-18-small-team-internal-readiness/evidence.md` | 财务人员难以凭业务信息选择待付对象,可能错选或无法创建。 | 以待付池和业务编号/人员/金额选择对象,内部标识仅由系统传递。 | 当前工作提交的候选接口权威复查及合成浏览器业务选择器通过,普通页面不要求粘贴内部 UUID。 | WP-2 付款状态一致性与审计 |
| `UX-SAFE-001` | 不可逆证据 / 资金动作 | P1(收敛后边界) | 高 | `CONFIRMED` | `LOCAL_FIX_VERIFIED` | `DEPLOYMENT_PENDING` | `NOT_REQUIRED` | `page-static-review.md#page-p1-002`;任务 `07-18-small-team-internal-readiness/evidence.md` | 误触可能删除不可逆证据或改变资金结果。 | 强确认展示对象、后果、可逆性与必要理由。 | 合成浏览器证明高敏附件删除与回单作废展示对象、后果和理由;取消或理由不足时零 mutation。 | WP-6 页面权限显隐及旧标题任务收口 |
| `UX-SAFE-002` | 可恢复管理动作 | P2(由原 PAGE-P1-002 拆分校准) | 高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `NOT_REQUIRED` | `page-static-review.md#page-p1-002` | 普通启停等可恢复动作存在误触和反馈不清。 | 使用轻确认或可撤销反馈,按可逆性分级。 | 只读检查确认部分可恢复动作仍直接执行。 | WP-6 页面权限显隐及旧标题任务收口 |
| `UX-FORM-001` | 核心长表单可靠性 | P1 | 高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `NOT_REQUIRED` | `page-static-review.md#page-p1-003` | 采购、报销、合同、资产等长输入可能因离开页面丢失。 | 共享 dirty-state 合同覆盖路由离开、刷新和记录切换。 | 静态检查未找到未保存保护实现。 | WP-6 页面权限显隐及旧标题任务收口 |
| `MOB-ATTEND-001` | 移动考勤入口 | P1(由原 PAGE-P1-004 拆分) | 高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `EXTERNAL_BEHAVIOR_PENDING` | `page-static-review.md#page-p1-004` | 企业微信/触控小屏考勤入口会回到原移动首页。 | 提供明确移动镜像或允许只读响应式页,不形成自循环。 | 静态路由解析确认自循环;真机点击待另行授权。 | WP-6 页面权限显隐及旧标题任务收口 |
| `MOB-TARGET-001` | 移动通知定位 | P2(由原 PAGE-P1-004 拆分校准) | 高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `EXTERNAL_BEHAVIOR_PENDING` | `page-static-review.md#page-p1-004` | 部分通知不能定位原业务对象,用户需重新查找。 | 每种通知保留稳定对象标识并验证最终 URL/内容。 | 静态链接映射确认目标参数不完整;真实通知待授权。 | WP-6 页面权限显隐及旧标题任务收口 |
| `UX-FORM-001` | 核心长表单可靠性 | P1 | 高 | `CONFIRMED` | `LOCAL_FIX_VERIFIED` | `DEPLOYMENT_PENDING` | `NOT_REQUIRED` | `page-static-review.md#page-p1-003`;任务 `07-18-small-team-internal-readiness/evidence.md` | 采购、报销、合同、资产等长输入可能因离开页面丢失。 | 共享 dirty-state 合同覆盖路由离开、刷新和记录切换。 | data router / `useBlocker` 共享保护在报销、采购、合同、资产中覆盖站内离开、返回/刷新和记录切换,合成浏览器通过。 | WP-6 页面权限显隐及旧标题任务收口 |
| `MOB-ATTEND-001` | 移动考勤入口 | P1(由原 PAGE-P1-004 拆分) | 高 | `CONFIRMED` | `LOCAL_FIX_VERIFIED` | `DEPLOYMENT_PENDING` | `EXTERNAL_BEHAVIOR_PENDING` | `page-static-review.md#page-p1-004`;任务 `07-18-small-team-internal-readiness/evidence.md` | 企业微信/触控小屏考勤入口会回到原移动首页。 | 提供明确移动镜像或允许只读响应式页,不形成自循环。 | `/mobile/attendance` 的 375×812 最终 URL、H1、角色、动作、错误和溢出断言通过;真机点击待另行授权。 | WP-6 页面权限显隐及旧标题任务收口 |
| `MOB-TARGET-001` | 移动通知定位 | P2(由原 PAGE-P1-004 拆分校准) | 高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `EXTERNAL_BEHAVIOR_PENDING` | `page-static-review.md#page-p1-004`;任务 `07-18-small-team-internal-readiness/evidence.md` | 部分通知不能定位原业务对象,用户需重新查找。 | 每种通知保留稳定对象标识并验证最终 URL/内容。 | 考勤目标增量已实现并验证;资产/盘点等其他对象定位未在本任务闭合,整体保持开放,真实通知待授权。 | WP-6 页面权限显隐及旧标题任务收口 |
| `SEARCH-NAV-001` | 全局搜索导航 | P2(收敛校准;原报告 P1) | 高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `NOT_REQUIRED` | `page-static-review.md#page-p1-005` | 点击部分搜索结果只到列表,不能确认命中对象。 | 对象型结果提供稳定详情或定位/高亮合同。 | 静态 href 与现有单实体 E2E 确认覆盖不完整。 | WP-6 页面权限显隐及旧标题任务收口 |
| `RESP-TABLE-001` | 中间断点表格 | P1 | 高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `NOT_REQUIRED` | `page-static-review.md#page-p1-006`;`browser-review.md` | 共享表格隐藏超出宽度,多个核心列表的右侧操作不可达。 | 操作列可见或容器可控横滚,页面本身不溢出。 | 768 下容器缺 58px、1024 下缺 106px 且 `overflow-x:hidden`;代表页面操作命中失败。 | WP-6 页面权限显隐及旧标题任务收口 |
| `RESP-TABLE-001` | 中间断点表格 | P1 | 高 | `CONFIRMED` | `LOCAL_FIX_VERIFIED` | `DEPLOYMENT_PENDING` | `NOT_REQUIRED` | `page-static-review.md#page-p1-006`;`browser-review.md`;任务 `07-18-small-team-internal-readiness/evidence.md` | 共享表格隐藏超出宽度,多个核心列表的右侧操作不可达。 | 操作列可见或容器可控横滚,页面本身不溢出。 | 项目、资产、合同、付款、报销在 768×1024 与 1024×768 下局部横滚、右侧操作命中、document 无横溢断言与脱敏截图通过。 | WP-6 页面权限显隐及旧标题任务收口 |
| `A11Y-FORM-001` | 表单可访问性 | P2(收敛校准;原报告 P1) | 高 | `CONFIRMED` | `FOLLOW_UP_RECOMMENDED` | `NO_FIX_TO_DEPLOY` | `NOT_REQUIRED` | `page-static-review.md#page-p1-007` | 辅助技术用户较难识别字段和动态错误。 | 共享字段/错误组件提供名称、关联、错误状态和 live feedback。 | 静态样例确认语义缺口;无行为证据证明核心流程完全不可完成。 | WP-6 页面权限显隐及旧标题任务收口 |
| `NAV-META-001` | 路由 / 标题元数据 | P2 | 高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `NOT_REQUIRED` | `page-static-review.md#page-p2-001` | 标题、最近访问和浏览器 tab 语义漂移。 | 统一 typed route manifest 与页面可控覆写。 | 静态映射确认缺失、误匹配与固定浏览器标题。 | WP-6 页面权限显隐及旧标题任务收口 |
| `ERR-RECOVERY-001` | 错误恢复 | P2 | 高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `NOT_REQUIRED` | `page-static-review.md#page-p2-002` | 403/404/渲染错误可能把用户带向隐藏旧入口。 | 所有恢复动作回到稳定工作台或安全上一页。 | 静态链接确认仍指向旧路径。 | WP-6 页面权限显隐及旧标题任务收口 |
@@ -40,13 +40,13 @@
| `UX-ERROR-001` | 普通页面错误文案 | P2 | 高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `NOT_REQUIRED` | `page-static-review.md#page-p2-004` | 用户先看到内部码/枚举,难以理解原因与下一步。 | 普通界面展示自然语言和恢复动作,诊断码仅进受控区域。 | 静态样例确认普通页面直接组合技术信息。 | WP-6 页面权限显隐及旧标题任务收口 |
| `A11Y-TABLE-001` | 表格键盘可达 | P2 | 高 | `CONFIRMED` | `FOLLOW_UP_RECOMMENDED` | `NO_FIX_TO_DEPLOY` | `NOT_REQUIRED` | `page-static-review.md#page-p2-005` | 键盘用户无法等价选择部分表格记录。 | 使用真实按钮/链接和明确选中状态。 | 静态样例确认行点击缺少键盘等价入口。 | WP-6 页面权限显隐及旧标题任务收口 |
| `A11Y-VISUAL-001` | 对比度 / 减弱动效 | P2 | 高 | `CONFIRMED` | `FOLLOW_UP_RECOMMENDED` | `NO_FIX_TO_DEPLOY` | `NOT_REQUIRED` | `page-static-review.md#page-p2-006` | 低视力与减弱动效用户体验下降。 | 共享 token 达到既定对比度并尊重 reduced motion。 | 静态 token/样式检查确认当前合同不足。 | WP-6 页面权限显隐及旧标题任务收口 |
| `QA-VISUAL-001` | 视觉证据可信度 | P2 | 高 | `CONFIRMED` | `OPEN` | `NOT_APPLICABLE` | `NOT_REQUIRED` | `page-static-review.md#page-p2-007` | 报告可能遗漏真实路由/中间断点却给出过强信心。 | 显式路由计划、最终 URL/标题/错误页断言和代表断点。 | 静态矩阵确认通配、旧路由和断点缺口。 | WP-4 发布门禁可信化 |
| `QA-VISUAL-001` | 视觉证据可信度 | P2 | 高 | `CONFIRMED` | `LOCAL_FIX_VERIFIED` | `NOT_APPLICABLE` | `NOT_REQUIRED` | `page-static-review.md#page-p2-007`;任务 `07-18-small-team-internal-readiness/evidence.md` | 报告可能遗漏真实路由/中间断点却给出过强信心。 | 显式路由计划、最终 URL/标题/错误页断言和代表断点。 | 71 条路由分类、10 个 source-bound witness、7 个 acceptance spec、代表断点和结果 parser 反例均通过。 | WP-4 发布门禁可信化 |
| `MOB-PERM-001` | 移动导航权限 | P2 | 中高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `NOT_REQUIRED` | `page-static-review.md#page-p2-008` | 最小权限角色看到无效入口或进入 403。 | 移动入口统一复用能力谓词,未授权默认隐藏。 | 静态导航与守卫对照确认不一致。 | WP-6 页面权限显隐及旧标题任务收口 |
| `QA-ASSET-001` | 资产 E2E 漂移 | P2 | 高 | `DUPLICATE` | `ACTIVE_TASK_OPEN` | `NOT_APPLICABLE` | `NOT_REQUIRED` | `page-static-review.md#page-dup-p2-001`;活动任务 `05-31-unified-page-header-actions` | 自动化仍按旧交互执行,不能证明当前页面。 | 测试跟随当前“更多”菜单交互,不为旧测试恢复旧按钮。 | 只读测试/页面对照确认重复项。 | WP-6 页面权限显隐及旧标题任务收口 |
| `QA-HEADER-001` | 标题动作合同 / 视觉覆盖 | P2 | 高 | `DUPLICATE` | `ACTIVE_TASK_OPEN` | `NOT_APPLICABLE` | `NOT_REQUIRED` | `page-static-review.md#page-dup-p2-002`;活动任务 `05-31-unified-page-header-actions` | 标题区可能继续堆叠动作且现有回归不报警。 | 可测试的主操作/更多槽位及项目、资产代表性视觉断言。 | 静态组件与测试矩阵确认重复项。 | WP-6 页面权限显隐及旧标题任务收口 |
| `QA-ASSET-001` | 资产 E2E 漂移 | P2 | 高 | `DUPLICATE` | `LOCAL_FIX_VERIFIED` | `NOT_APPLICABLE` | `NOT_REQUIRED` | `page-static-review.md#page-dup-p2-001`;归档任务 `05-31-unified-page-header-actions`;任务 `07-18-small-team-internal-readiness/evidence.md` | 自动化仍按旧交互执行,不能证明当前页面。 | 测试跟随当前“更多”菜单交互,不为旧测试恢复旧按钮。 | 资产 E2E 已改为“更多→资产分类”,spec 可解析且实际菜单行为/代表视觉由候选浏览器回归通过。 | WP-6 页面权限显隐及旧标题任务收口 |
| `QA-HEADER-001` | 标题动作合同 / 视觉覆盖 | P2 | 高 | `DUPLICATE` | `LOCAL_FIX_VERIFIED` | `NOT_APPLICABLE` | `NOT_REQUIRED` | `page-static-review.md#page-dup-p2-002`;归档任务 `05-31-unified-page-header-actions`;任务 `07-18-small-team-internal-readiness/evidence.md` | 标题区可能继续堆叠动作且现有回归不报警。 | 可测试的主操作/更多槽位及项目、资产代表性视觉断言。 | 七页面“一主操作+最多一个更多”合同、项目/资产代表视觉和资产菜单行为纳入 44/44 候选回归。 | WP-6 页面权限显隐及旧标题任务收口 |
| `QA-COVERAGE-001` | 风险型测试覆盖 | P2 | 高 | `CONFIRMED` | `OPEN` | `NOT_APPLICABLE` | `NOT_REQUIRED` | `engineering-review.md#测试资产与实测覆盖率` | 全量用例通过仍不能防止关键 worker/脚本覆盖退化。 | 对认证、资金、worker、迁移/恢复入口设置分层防回退门禁。 | 旧基线 coverage 与入口清单确认关键运行面无阈值。 | WP-4 发布门禁可信化 |
| `REL-GATE-001` | 发布门禁可信度 | P1 | 高 | `CONFIRMED` | `OPEN` | `NOT_APPLICABLE` | `NOT_REQUIRED` | `engineering-review.md#流程覆盖扫描可信度`、`#项目级发布门禁可执行性`;`current-head-delta.md` | 标准门禁不可移植且会把大量未映射路由报告成无高风险缺口。 | 仓库内可执行、显式映射/豁免、排除 manual/live、缺口非零退出并自测反例。 | 当前 HEAD 扫描 68 路由仍有 39 未映射却无 gap;标准 runner 依赖失效外部前置。 | WP-4 发布门禁可信化 |
| `MOB-ARCHIVE-001` | 移动归档详情响应式 | P2 | 高 | `CONFIRMED` | `OPEN` | `NO_FIX_TO_DEPLOY` | `EXTERNAL_BEHAVIOR_PENDING` | `baseline.md#移动路由静态合同`;`browser-review.md` | 长标识使详情内部内容被隐藏,用户无法横向找回。 | 保持路由/卡片/底栏基线并让详情长字段安全换行或可控滚动。 | 375×812 的 URL、H1、空态、列表和底栏通过;长详情 panel 351/457,约 105px 被 `overflow-x:hidden` 裁切。 | WP-6 页面权限显隐及旧标题任务收口 |
| `REL-GATE-001` | 发布门禁可信度 | P1 | 高 | `CONFIRMED` | `LOCAL_FIX_VERIFIED` | `NOT_APPLICABLE` | `NOT_REQUIRED` | `engineering-review.md#流程覆盖扫描可信度`、`#项目级发布门禁可执行性`;`current-head-delta.md`;任务 `07-18-small-team-internal-readiness/evidence.md` | 标准门禁不可移植且会把大量未映射路由报告成无高风险缺口。 | 仓库内可执行、显式映射/豁免、排除 manual/live、缺口非零退出并自测反例。 | `full` 门禁在仓库内 exit 0;71 路由、10/10 witness、7/7 acceptance、52 个反例与 44/44 浏览器结果通过,且 full/focused/list 标签分离。 | WP-4 发布门禁可信化 |
| `MOB-ARCHIVE-001` | 移动归档详情响应式 | P2 | 高 | `CONFIRMED` | `LOCAL_FIX_VERIFIED` | `DEPLOYMENT_PENDING` | `EXTERNAL_BEHAVIOR_PENDING` | `baseline.md#移动路由静态合同`;`browser-review.md`;任务 `07-18-small-team-internal-readiness/evidence.md` | 长标识使详情内部内容被隐藏,用户无法横向找回。 | 保持路由/卡片/底栏基线并让详情长字段安全换行或可控滚动。 | 375×812 的空态、记录、长详情、底栏与 document 横溢断言及脱敏截图通过;企业微信真机仍待授权。 | WP-6 页面权限显隐及旧标题任务收口 |
## 不进入 Finding 的观察与外部补证
+1 -2
查看文件
@@ -7,8 +7,7 @@ export type ApprovalRoleCode =
| "finance"
| "finance_mgr"
| "administration"
| "hr"
| "admin";
| "hr";
export type ApprovalOwnerSelfFallbackRoleCode = "finance_mgr";
export type ApprovalRule = {
+5 -4
查看文件
@@ -164,7 +164,7 @@ export type EmployeeContract = {
startDate: string | null;
endDate: string | null;
terminatedDate: string | null;
attachmentPath: string | null;
hasAttachment: boolean;
attachmentUrl: string | null;
status: EmployeeContractStatus;
createdAt: string | null;
@@ -285,6 +285,7 @@ export type FetchEmployeesParams = {
hireDateFrom?: string;
hireDateTo?: string;
employmentStatus?: EmployeeEmploymentStatus;
scope?: "directory" | "management";
};
export type BatchOperationResult = {
@@ -384,6 +385,7 @@ export async function fetchEmployees(params: FetchEmployeesParams) {
if (params.hireDateFrom) search.set("hireDateFrom", params.hireDateFrom);
if (params.hireDateTo) search.set("hireDateTo", params.hireDateTo);
if (params.employmentStatus) search.set("employmentStatus", params.employmentStatus);
if (params.scope) search.set("scope", params.scope);
return apiRequestData<EmployeeListResponse>(`/api/employees?${search.toString()}`);
}
@@ -547,7 +549,6 @@ export async function createEmployeeContract(
startDate: string;
endDate: string;
terminatedDate?: string | null;
attachmentPath?: string | null;
},
) {
return apiRequestData<EmployeeContract>(`/api/employees/${employeeId}/contracts`, {
@@ -565,7 +566,6 @@ export async function patchEmployeeContract(
startDate: string;
endDate: string;
terminatedDate: string | null;
attachmentPath: string | null;
}>,
) {
return apiRequestData<EmployeeContract>(`/api/employees/${employeeId}/contracts/${contractId}`, {
@@ -588,9 +588,10 @@ export async function downloadEmployeeContractAttachment(attachmentUrl: string):
return apiRequestBlob(attachmentUrl);
}
export async function deleteEmployeeContract(employeeId: string, contractId: string) {
export async function deleteEmployeeContract(employeeId: string, contractId: string, reason: string) {
return apiRequestData<{ contractId: string; deleted: boolean }>(`/api/employees/${employeeId}/contracts/${contractId}`, {
method: "DELETE",
body: JSON.stringify({ reason }),
});
}
+17 -9
查看文件
@@ -138,7 +138,23 @@ export function mapApiErrorMessage(
}
if (details?.payment === "already_paid_conflict") {
return "该报销单已支付且付款信息冲突,请刷新后确认。";
return "该单据已由其他付款动作处理,请刷新后核对付款记录。";
}
if (details?.payment === "source_amount_changed") {
return "源单金额已发生变化,本次付款未执行,请刷新后重新制单。";
}
if (details?.payment === "payment_batch_v1_required") {
return "付款批次已启用,请从付款页面完成制单、复核和执行。";
}
if (details?.entityIds === "contains_active_payment_batch") {
return "所选报销已进入其他有效付款批次,请刷新候选列表。";
}
if (details?.receiptAttachment === "void_required") {
return "银行回单不能直接删除,请填写原因后作废。";
}
if (details?.status === "invalid_pay_status") {
@@ -149,14 +165,6 @@ export function mapApiErrorMessage(
return "复核人与批次创建人不能是同一人。";
}
if (details?.review === "second_reviewer_conflict") {
return "二次复核人不能与首个复核人相同。";
}
if (details?.review === "high_risk_second_review_required") {
return "当前为高风险付款批次,完成二次复核后才能执行。";
}
if (details?.review === "batch_review_required") {
return "当前批次尚未完成复核,不能执行。";
}
+39 -7
查看文件
@@ -39,7 +39,7 @@ export type PaymentBatch = {
thresholdAmount: string;
requiredReviewCount: number;
completedReviewCount: number;
nextStep: "first_review" | "second_review" | "ready_for_execute";
nextStep: "first_review" | "ready_for_execute";
makerCheckerRequired: boolean;
reviewerPermission: string;
};
@@ -57,9 +57,32 @@ export type PaymentBatch = {
executedBy: string | null;
cancelledAt: string | null;
cancelledBy: string | null;
cancelledReason: string | null;
items: PaymentBatchItem[];
};
export type PaymentBatchCandidate = {
entityId: string;
entityType: "reimbursement";
businessNo: string;
employee: {
id: string;
employeeNo: string;
name: string;
};
amount: string;
currencyCode: "CNY";
businessDate: string | null;
summary: string | null;
};
export type PaymentBatchCandidatesResponse = {
items: PaymentBatchCandidate[];
page: number;
pageSize: number;
total: number;
};
export type PaymentBatchReceiptAttachment = {
id: string;
originalName: string;
@@ -89,6 +112,15 @@ export async function fetchPaymentBatchDetail(batchId: string) {
return apiRequestData<{ item: PaymentBatch }>(`/api/payment-batches/${batchId}`);
}
export async function fetchPaymentBatchCandidates(query: { q?: string; page?: number; pageSize?: number } = {}) {
const params = new URLSearchParams();
if (query.q?.trim()) params.set("q", query.q.trim());
if (query.page) params.set("page", String(query.page));
if (query.pageSize) params.set("pageSize", String(query.pageSize));
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiRequestData<PaymentBatchCandidatesResponse>(`/api/payment-batches/candidates${suffix}`);
}
export async function createPaymentBatch(payload: {
entityType: PaymentBatchEntityType;
name: string;
@@ -131,10 +163,10 @@ export async function executePaymentBatch(batchId: string) {
});
}
export async function cancelPaymentBatch(batchId: string) {
export async function cancelPaymentBatch(batchId: string, reason: string) {
return apiRequestData<{ item: PaymentBatch }>(`/api/payment-batches/${batchId}/cancel`, {
method: "POST",
body: JSON.stringify({}),
body: JSON.stringify({ reason }),
});
}
@@ -170,9 +202,9 @@ export async function downloadPaymentBatchReceiptAttachment(
return apiRequestBlob(buildAttachmentFileUrl(paymentBatchReceiptAttachmentFileUrl(batchId, attachmentId), reason));
}
export async function deletePaymentBatchReceiptAttachment(batchId: string, attachmentId: string) {
return apiRequestData<{ attachmentId: string; outcome: "deleted" }>(
`/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}`,
{ method: "DELETE" },
export async function voidPaymentBatchReceiptAttachment(batchId: string, attachmentId: string, reason: string) {
return apiRequestData<{ attachmentId: string; outcome: "voided"; voidedAt: string }>(
`/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}/void`,
{ method: "POST", body: JSON.stringify({ reason }) },
);
}
+2 -1
查看文件
@@ -377,11 +377,12 @@ export async function uploadPurchaseAttachment(purchaseRequestId: string, file:
});
}
export async function deletePurchaseAttachment(purchaseRequestId: string, attachmentId: string) {
export async function deletePurchaseAttachment(purchaseRequestId: string, attachmentId: string, reason: string) {
return apiRequestData<{ outcome: "deleted"; attachmentId: string }>(
`/api/purchase-requests/${purchaseRequestId}/attachments/${attachmentId}`,
{
method: "DELETE",
body: JSON.stringify({ reason }),
},
);
}
+2 -1
查看文件
@@ -505,11 +505,12 @@ export async function uploadReimbursementAttachments(reimbursementId: string, fi
);
}
export async function deleteReimbursementAttachment(reimbursementId: string, attachmentId: string) {
export async function deleteReimbursementAttachment(reimbursementId: string, attachmentId: string, reason: string) {
return apiRequestData<{ attachmentId: string; outcome: string }>(
`/api/reimbursements/${reimbursementId}/attachments/${attachmentId}`,
{
method: "DELETE",
body: JSON.stringify({ reason }),
},
);
}
+22
查看文件
@@ -65,6 +65,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"project.manage",
"budget.view",
"budget.manage",
"payment.batch.review",
],
ceo: [
...EMPLOYEE_BASE_PERMISSIONS,
@@ -82,6 +83,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"asset.view_all",
"report.dashboard.view",
"budget.view",
"payment.batch.review",
],
finance: [
...EMPLOYEE_BASE_PERMISSIONS,
@@ -97,6 +99,8 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"procurement.view_all",
"asset.view_all",
"attendance.view_all",
"employee.bank.read",
"payroll.manage",
],
finance_mgr: [
...EMPLOYEE_BASE_PERMISSIONS,
@@ -127,6 +131,8 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"attendance.view_all",
"attendance.manage",
"attendance.sync",
"payroll.manage",
"employee.bank.read",
],
administration: [
"expense.proxy_submit",
@@ -152,9 +158,25 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"attendance.sync",
"system.users",
"system.users_sensitive",
"system.accounts.manage",
"system.roles.manage",
"employee.bank.manage",
"employee.identity.manage",
"employee.contract.manage",
"expense.view_all",
"report.dashboard.view",
],
system_admin: [
...EMPLOYEE_BASE_PERMISSIONS,
...PROCUREMENT_SELF_SERVICE_PERMISSIONS,
...ASSET_SELF_SERVICE_PERMISSIONS,
"system.accounts.manage",
"system.roles.manage",
"system.settings",
"integration.manage",
"notification.channel.manage",
"system.audit.view",
],
admin: ["*"],
};
@@ -0,0 +1,99 @@
import { type ReactNode, useEffect, useState } from "react";
import { Button } from "../ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Textarea } from "../ui/textarea";
type AttachmentDeleteConfirmDialogProps = {
open: boolean;
targetName?: string;
title?: string;
description?: ReactNode;
pending?: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (reason: string) => void | Promise<void>;
};
export function AttachmentDeleteConfirmDialog({
open,
targetName,
title = "确认删除附件?",
description,
pending = false,
onOpenChange,
onConfirm,
}: AttachmentDeleteConfirmDialogProps) {
const [reason, setReason] = useState("");
const [error, setError] = useState("");
useEffect(() => {
if (!open) {
setReason("");
setError("");
}
}, [open]);
const confirm = async () => {
const normalizedReason = reason.trim();
if (normalizedReason.length < 2) {
setError("请填写至少 2 个字的删除理由");
return;
}
await onConfirm(normalizedReason);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md p-5" data-testid="attachment-delete-confirm-dialog">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{description ?? (
<>
{targetName ? `附件:${targetName}。` : "该附件"}
删除后将从当前业务单据移除,且无法在 OA 内恢复。
</>
)}
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-2">
<Textarea
aria-label="删除理由"
value={reason}
rows={3}
maxLength={200}
placeholder="例如:重复上传、文件有误,需重新提供"
onChange={(event) => {
setReason(event.target.value);
if (error) setError("");
}}
/>
<div className="flex items-center justify-between text-xs">
<span className={error ? "text-red-600" : "text-zinc-500"}>{error || "对象、后果和理由会随删除操作留痕。"}</span>
<span className="text-zinc-400">{reason.trim().length}/200</span>
</div>
</div>
<DialogFooter>
<Button type="button" variant="secondary" disabled={pending} onClick={() => onOpenChange(false)}>
取消
</Button>
<Button
type="button"
variant="destructive"
disabled={pending}
data-testid="attachment-delete-confirm-submit"
onClick={() => void confirm()}
>
{pending ? "删除中..." : "确认删除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,159 @@
import {
createContext,
type PropsWithChildren,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useBlocker } from "react-router-dom";
import { ConfirmDialog } from "./ConfirmDialog";
type GuardRegistration = {
dirty: boolean;
label: string;
};
type UnsavedChangesContextValue = {
register: (id: string, registration: GuardRegistration | null) => void;
runGuarded: (action: () => void) => void;
};
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
export function UnsavedChangesProvider({ children }: PropsWithChildren) {
const registrationsRef = useRef(new Map<string, GuardRegistration>());
const pendingActionRef = useRef<(() => void) | null>(null);
const allowNextNavigationRef = useRef(false);
const confirmingRef = useRef(false);
const [revision, setRevision] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const dirtyRegistrations = useMemo(
() => [...registrationsRef.current.values()].filter((registration) => registration.dirty),
[revision],
);
const hasDirtyChanges = dirtyRegistrations.length > 0;
const dirtyLabel = dirtyRegistrations[0]?.label || "当前表单";
const hasDirtyChangesRef = useRef(hasDirtyChanges);
useEffect(() => {
hasDirtyChangesRef.current = hasDirtyChanges;
}, [hasDirtyChanges]);
const shouldBlock = useCallback(
() => hasDirtyChangesRef.current && !allowNextNavigationRef.current,
[],
);
const blocker = useBlocker(shouldBlock);
useEffect(() => {
document.documentElement.dataset.unsavedChanges = hasDirtyChanges ? "true" : "false";
return () => {
delete document.documentElement.dataset.unsavedChanges;
};
}, [hasDirtyChanges]);
const register = useCallback((id: string, registration: GuardRegistration | null) => {
if (registration) {
registrationsRef.current.set(id, registration);
} else {
registrationsRef.current.delete(id);
}
setRevision((current) => current + 1);
}, []);
const runGuarded = useCallback((action: () => void) => {
if (!hasDirtyChangesRef.current) {
action();
return;
}
pendingActionRef.current = action;
setDialogOpen(true);
}, []);
useEffect(() => {
if (blocker.state === "blocked") {
setDialogOpen(true);
}
}, [blocker.state]);
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!hasDirtyChangesRef.current) return;
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, []);
const value = useMemo(() => ({ register, runGuarded }), [register, runGuarded]);
return (
<UnsavedChangesContext.Provider value={value}>
{children}
<ConfirmDialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (open) return;
if (confirmingRef.current) {
confirmingRef.current = false;
return;
}
pendingActionRef.current = null;
if (blocker.state === "blocked") blocker.reset();
}}
title="放弃未保存的修改?"
description={`${dirtyLabel}尚未保存。离开后,本次修改不会保留。`}
confirmLabel="放弃并离开"
cancelLabel="继续编辑"
variant="destructive"
testId="unsaved-changes-dialog"
confirmTestId="unsaved-changes-discard"
cancelTestId="unsaved-changes-continue"
onConfirm={() => {
confirmingRef.current = true;
const action = pendingActionRef.current;
pendingActionRef.current = null;
if (blocker.state === "blocked") {
blocker.proceed();
return;
}
if (!action) return;
allowNextNavigationRef.current = true;
try {
action();
} finally {
queueMicrotask(() => {
allowNextNavigationRef.current = false;
});
}
}}
/>
</UnsavedChangesContext.Provider>
);
}
export function useUnsavedChangesGuard(dirty: boolean, label: string) {
const context = useContext(UnsavedChangesContext);
const id = useId();
if (!context) {
throw new Error("useUnsavedChangesGuard 必须在 UnsavedChangesProvider 内使用");
}
useLayoutEffect(() => {
context.register(id, { dirty, label });
return () => context.register(id, null);
}, [context, dirty, id, label]);
return context.runGuarded;
}
+12 -4
查看文件
@@ -33,8 +33,8 @@ export const NAV_ITEMS: NavItem[] = [
{ to: "/purchase-requests", label: "采购", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
{ to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] },
{ to: "/budgets", label: "预算管理", icon: "account_balance", anyOf: ["budget.view", "budget.manage"] },
{ to: "/payment-batches", label: "付款", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage"] },
{ to: "/ap-operations", label: "应付账款", icon: "account_balance_wallet", anyOf: ["payment.batch.manage", "risk.review.manage"] },
{ to: "/payment-batches", label: "付款", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage", "payment.batch.review"] },
{ to: "/ap-operations", label: "应付账款", icon: "account_balance_wallet", anyOf: ["payment.batch.manage", "risk.review.manage", "payment.batch.review"] },
{ to: "/invoice-pool", label: "票据", icon: "receipt_long", anyOf: ["invoice.manage"] },
{ to: "/inventory-checks", label: "资产盘点", icon: "inventory", anyOf: ["asset.inventory"] },
{ to: "/dashboard", label: "数据看板", icon: "dashboard", anyOf: ["report.dashboard.view"] },
@@ -65,6 +65,12 @@ const MANAGEMENT_ACTIVE_PATHS = [
"/audit-logs",
];
const TEAM_NAVIGATION_ROLE_CODES = ["manager", "ceo", "finance_mgr", "hr", "admin"];
const PAYMENT_REVIEWER_ROLE_CODES = ["manager", "ceo"];
function hasPaymentReviewRole(roleCodes: string[], permissions: string[]) {
return roleCodes.some((roleCode) => PAYMENT_REVIEWER_ROLE_CODES.includes(roleCode)) &&
hasAnyPermission(permissions, ["payment.batch.review"]);
}
type ResolvedNavItem = {
key?: string;
@@ -142,13 +148,15 @@ function renderBackupSection(items: ResolvedNavItem[], pathname: string, onNavig
}
function buildFallbackItems(permissions: string[], roleCodes: string[]) {
const financeOnlyPaths = new Set(["/payment-batches", "/ap-operations", "/invoice-pool"]);
const paymentPaths = new Set(["/payment-batches", "/ap-operations"]);
const financeOnlyPaths = new Set(["/invoice-pool"]);
const backupOnlyPaths = new Set(BACKUP_NAV_ORDER);
const mapItems = (paths: string[]) =>
NAV_ITEMS.filter((item) => paths.includes(item.to))
.filter((item) => item.to !== "/workflow/tasks" || hasBusinessApproverRole(roleCodes, permissions))
.filter((item) => item.to !== "/team" || roleCodes.some((roleCode) => TEAM_NAVIGATION_ROLE_CODES.includes(roleCode)))
.filter((item) => !financeOnlyPaths.has(item.to) || hasFinanceRole(roleCodes, permissions) || roleCodes.includes("admin"))
.filter((item) => !paymentPaths.has(item.to) || hasFinanceRole(roleCodes, permissions) || hasPaymentReviewRole(roleCodes, permissions))
.filter((item) => !financeOnlyPaths.has(item.to) || hasFinanceRole(roleCodes, permissions))
.filter((item) => !item.anyOf || hasAnyPermission(permissions, item.anyOf))
.filter((item) => paths === BACKUP_NAV_ORDER || !backupOnlyPaths.has(item.to))
.map((item) => ({ key: item.to, href: item.to, label: item.label, icon: item.icon }));
@@ -90,7 +90,17 @@ export function ActionToolbar({ className, children }: WorkspaceChildrenProps) {
}
export function PrimaryTable({ className, children }: WorkspaceChildrenProps) {
return <section className={cn("table-container ops-table-focus p-0", className)}>{children}</section>;
return (
<section
className={cn("table-container ops-table-focus max-w-full p-0", className)}
role="region"
aria-label="数据表格,可横向滚动"
tabIndex={0}
data-testid="business-table-scroll"
>
{children}
</section>
);
}
export function WorkspaceEmptyState({
+2 -1
查看文件
@@ -547,7 +547,8 @@
.table-container {
border-color: rgba(226, 232, 240, 0.92);
background: var(--color-bg-surface-raised);
@apply overflow-hidden rounded-lg border shadow-sm backdrop-blur;
scrollbar-gutter: stable;
@apply max-w-full overflow-x-auto overflow-y-hidden overscroll-x-contain rounded-lg border shadow-sm backdrop-blur;
}
.table-header {
+1 -1
查看文件
@@ -65,7 +65,7 @@ export function resolveMobileNotificationTargetLink(item: NotificationTarget) {
}
if (item.targetType === "attendance_correction") {
return "/attendance";
return "/mobile/attendance";
}
if (item.targetType === "purchase_request") {
+4 -1
查看文件
@@ -87,6 +87,10 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
return "/mobile/capture";
}
if (normalizedPath === "/attendance") {
return "/mobile/attendance";
}
const workflowTaskMatch = normalizedPath.match(/^\/workflow\/tasks\/([^/]+)$/);
if (workflowTaskMatch?.[1]) {
return `/mobile/approvals/${encodeSegment(workflowTaskMatch[1])}`;
@@ -119,7 +123,6 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
normalizedPath.startsWith("/weekly-reports") ||
normalizedPath.startsWith("/todos") ||
normalizedPath.startsWith("/team") ||
normalizedPath.startsWith("/attendance") ||
normalizedPath.startsWith("/budgets") ||
normalizedPath.startsWith("/announcements") ||
normalizedPath.startsWith("/delegations")
+3 -8
查看文件
@@ -1,12 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import { AuthProvider } from "./auth/AuthContext";
import { RouterProvider } from "react-router-dom";
import { ErrorBoundary } from "./components/common/ErrorBoundary";
import { Toaster } from "./components/ui/sonner";
import { queryClient } from "./query/queryClient";
import { AppRouter } from "./router";
import { appRouter } from "./router";
import { clearPwaRuntimeCaches, registerPwaIfEnabled } from "./pwa";
import "./index.css";
@@ -114,11 +113,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<AppRouter />
</AuthProvider>
</BrowserRouter>
<RouterProvider router={appRouter} />
</QueryClientProvider>
</ErrorBoundary>
<Toaster />
+79 -52
查看文件
@@ -241,10 +241,14 @@ function SectionError({ message }: { message: string }) {
export function ApOperationsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { permissions } = useAuth();
const { me, permissions } = useAuth();
const canManageBatch = hasPermission(permissions, "payment.batch.manage");
const canReviewRisk = hasPermission(permissions, "risk.review.manage");
const canExportAp = canManageBatch || canReviewRisk;
const canReviewPayment =
hasPermission(permissions, "payment.batch.review") &&
(me?.roleCodes.includes("manager") || me?.roleCodes.includes("ceo"));
const canManageTravelPayments = hasPermission(permissions, "travel.payment.manage");
const [activeTab, setActiveTab] = useState<OperationsTab>("pending");
@@ -264,6 +268,7 @@ export function ApOperationsPage() {
const [reviewTarget, setReviewTarget] = useState<PaymentBatch | null>(null);
const [reviewComment, setReviewComment] = useState("");
const [cancelTarget, setCancelTarget] = useState<PaymentBatch | null>(null);
const [cancelReason, setCancelReason] = useState("");
const [retryTarget, setRetryTarget] = useState<FailedPaymentItemRow | null>(null);
const [failedDetailTarget, setFailedDetailTarget] = useState<FailedPaymentItemRow | null>(null);
@@ -445,11 +450,7 @@ export function ApOperationsPage() {
await refreshWorkbench();
setReviewTarget(null);
setReviewComment("");
toast.success(
item.reviewRequirement.nextStep === "ready_for_execute"
? "付款批次复核完成"
: "付款批次已完成首轮复核",
);
toast.success(item.reviewRequirement.nextStep === "ready_for_execute" ? "付款批次复核完成" : "付款批次复核已提交");
},
onError: (error) => {
toast.error(resolveErrorMessage(error, "快捷复核失败"));
@@ -457,10 +458,11 @@ export function ApOperationsPage() {
});
const cancelMutation = useMutation({
mutationFn: (batchId: string) => cancelPaymentBatch(batchId),
mutationFn: ({ batchId, reason }: { batchId: string; reason: string }) => cancelPaymentBatch(batchId, reason),
onSuccess: async () => {
await refreshWorkbench();
setCancelTarget(null);
setCancelReason("");
toast.success("付款批次已取消");
},
onError: (error) => {
@@ -628,27 +630,29 @@ export function ApOperationsPage() {
>
刷新
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="ap-export"
type="button"
size="sm"
variant="secondary"
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem data-testid="ap-export-csv" onSelect={() => exportMutation.mutate("csv")}>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem data-testid="ap-export-xlsx" onSelect={() => exportMutation.mutate("xlsx")}>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{canExportAp ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="ap-export"
type="button"
size="sm"
variant="secondary"
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem data-testid="ap-export-csv" onSelect={() => exportMutation.mutate("csv")}>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem data-testid="ap-export-xlsx" onSelect={() => exportMutation.mutate("xlsx")}>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
@@ -939,11 +943,9 @@ export function ApOperationsPage() {
{pendingBatches.length ? (
pendingBatches.map((item) => {
const canReviewSelectedBatch =
canReviewRisk &&
((item.status === "draft" &&
item.reviewRequirement.nextStep === "first_review") ||
(item.status === "reviewed" &&
item.reviewRequirement.nextStep === "second_review"));
canReviewPayment &&
item.status === "draft" &&
item.reviewRequirement.nextStep === "first_review";
const canCancelSelectedBatch =
canManageBatch && !["executed", "cancelled"].includes(item.status);
@@ -984,7 +986,7 @@ export function ApOperationsPage() {
>
查看批次
</Button>
{canReviewRisk ? (
{canReviewPayment ? (
<Button
type="button"
size="sm"
@@ -1003,7 +1005,10 @@ export function ApOperationsPage() {
variant="secondary"
size="sm"
disabled={!canCancelSelectedBatch}
onClick={() => setCancelTarget(item)}
onClick={() => {
setCancelTarget(item);
setCancelReason("");
}}
>
快捷取消
</Button>
@@ -1326,7 +1331,7 @@ export function ApOperationsPage() {
</DialogHeader>
{reviewTarget?.highRisk ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
此批次为高风险,需二次复核。
此批次金额较高,请重点核对付款对象、金额和回单;本次独立复核后不再增加第二审批层级。
</div>
) : null}
<textarea
@@ -1568,28 +1573,50 @@ export function ApOperationsPage() {
</DialogContent>
</Dialog>
<ConfirmDialog
<Dialog
open={Boolean(cancelTarget)}
onOpenChange={(open) => {
if (!open) {
setCancelTarget(null);
setCancelReason("");
}
}}
title="确认取消付款批次"
description={
cancelTarget
? `将取消 ${cancelTarget.batchNo},该操作与付款批次页保持一致。`
: undefined
}
variant="destructive"
confirmLabel="确认取消"
onConfirm={async () => {
if (!cancelTarget) {
return;
}
await cancelMutation.mutateAsync(cancelTarget.id);
}}
/>
>
<DialogContent>
<DialogHeader>
<DialogTitle>确认取消付款批次</DialogTitle>
<DialogDescription>
{cancelTarget
? `将取消 ${cancelTarget.batchNo} 的未执行付款项;已执行项不会回滚。`
: "请填写取消原因。"}
</DialogDescription>
</DialogHeader>
<textarea
className="min-h-28 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm"
aria-label="批次取消原因"
placeholder="请输入 2–500 字取消原因"
maxLength={500}
value={cancelReason}
onChange={(event) => setCancelReason(event.target.value)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => {
setCancelTarget(null);
setCancelReason("");
}}>
返回
</Button>
<Button
type="button"
variant="destructive"
disabled={!cancelTarget || cancelReason.trim().length < 2 || cancelMutation.isPending}
onClick={() => cancelTarget && cancelMutation.mutate({ batchId: cancelTarget.id, reason: cancelReason.trim() })}
>
{cancelMutation.isPending ? "取消中..." : "确认取消"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={Boolean(retryTarget)}
-1
查看文件
@@ -26,7 +26,6 @@ const ROLE_OPTIONS: Array<{ code: ApprovalRoleCode; label: string }> = [
{ code: "ceo", label: "ceo" },
{ code: "finance", label: "finance" },
{ code: "finance_mgr", label: "finance_mgr" },
{ code: "admin", label: "admin" },
];
const OWNER_SELF_FALLBACK_ROLE_OPTIONS = ROLE_OPTIONS.filter(
(role) => role.code === "finance_mgr",
+30 -13
查看文件
@@ -27,6 +27,7 @@ import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { DateTextInput } from "../components/common/DateTextInput";
import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import {
@@ -233,6 +234,7 @@ export function AssetRegistryPage() {
const [selectedAssetId, setSelectedAssetId] = useState(searchParams.get("id") ?? "");
const [isCreatingAsset, setIsCreatingAsset] = useState(false);
const [assetForm, setAssetForm] = useState(initialAssetForm);
const [savedAssetFormSignature, setSavedAssetFormSignature] = useState(() => JSON.stringify(initialAssetForm()));
const [assetFormError, setAssetFormError] = useState("");
const [movementEmployeeId, setMovementEmployeeId] = useState("");
const [movementLocation, setMovementLocation] = useState("");
@@ -331,7 +333,7 @@ export function AssetRegistryPage() {
return;
}
const asset = assetDetailQuery.data.asset;
setAssetForm({
const nextAssetForm = {
name: asset.name,
assetType: asset.assetType,
sensitivityLevel: asset.sensitivityLevel,
@@ -355,7 +357,9 @@ export function AssetRegistryPage() {
expiresAt: asset.expiresAt ?? "",
activatedAt: asset.activatedAt ?? "",
retiredAt: asset.retiredAt ?? "",
});
};
setAssetForm(nextAssetForm);
setSavedAssetFormSignature(JSON.stringify(nextAssetForm));
setMovementEmployeeId("");
setMovementLocation(asset.location ?? "");
setMovementNote("");
@@ -379,6 +383,10 @@ export function AssetRegistryPage() {
const currentAsset = assetDetailQuery.data?.asset ?? null;
const canEditCurrent = canManage && (isCreatingAsset || currentAsset?.permissions.canEdit);
const runGuarded = useUnsavedChangesGuard(
Boolean(isCreatingAsset || selectedAssetId) && Boolean(canEditCurrent) && JSON.stringify(assetForm) !== savedAssetFormSignature,
"资产表单",
);
const hasAdvancedAssetFilters = Boolean(
sensitivityFilter ||
categoryFilter ||
@@ -404,10 +412,14 @@ export function AssetRegistryPage() {
}
function resetAssetEditor() {
setIsCreatingAsset(true);
setSelectedAssetId("");
setAssetForm(initialAssetForm());
setAssetFormError("");
runGuarded(() => {
const nextAssetForm = initialAssetForm();
setIsCreatingAsset(true);
setSelectedAssetId("");
setAssetForm(nextAssetForm);
setSavedAssetFormSignature(JSON.stringify(nextAssetForm));
setAssetFormError("");
});
}
function parseAssetMetadata() {
@@ -555,6 +567,7 @@ export function AssetRegistryPage() {
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
]);
setAssetFormError("");
setSavedAssetFormSignature(JSON.stringify(assetForm));
setSelectedAssetId(data.asset.id);
setIsCreatingAsset(false);
toast.success(isCreatingAsset ? "资产卡片已创建" : "资产卡片已更新");
@@ -711,15 +724,19 @@ export function AssetRegistryPage() {
});
}, [assetItems]);
const selectAsset = (assetId: string) => {
setIsCreatingAsset(false);
setSelectedAssetId(assetId);
runGuarded(() => {
setIsCreatingAsset(false);
setSelectedAssetId(assetId);
});
};
const returnToAssetList = () => {
setIsCreatingAsset(false);
setSelectedAssetId("");
const next = new URLSearchParams(searchParams);
next.delete("id");
setSearchParams(next, { replace: true });
runGuarded(() => {
setIsCreatingAsset(false);
setSelectedAssetId("");
const next = new URLSearchParams(searchParams);
next.delete("id");
setSearchParams(next, { replace: true });
});
};
const isAssetDetailMode = isCreatingAsset || Boolean(selectedAssetId);
+82 -47
查看文件
@@ -22,6 +22,7 @@ import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { DateTextInput } from "../components/common/DateTextInput";
@@ -203,6 +204,7 @@ export function ContractsRegistryPage() {
const [searchParams] = useSearchParams();
const initialProjectId = searchParams.get("projectId") ?? "";
const canBrowseEmployees = hasAnyPermission(permissions, ["system.users", "report.dashboard.view"]);
const canManageContracts = hasAnyPermission(permissions, ["project.manage", "project.view_all", "system.settings"]);
const [filters, setFilters] = useState<FiltersState>({
projectId: initialProjectId,
@@ -214,6 +216,7 @@ export function ContractsRegistryPage() {
});
const [editorId, setEditorId] = useState<string | null>(null);
const [editor, setEditor] = useState<EditorState>(() => buildEmptyEditor(initialProjectId));
const [savedEditorSignature, setSavedEditorSignature] = useState(() => JSON.stringify(buildEmptyEditor(initialProjectId)));
const [showExpiring, setShowExpiring] = useState(false);
const [showTrialData, setShowTrialData] = useState(false);
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
@@ -262,6 +265,10 @@ export function ContractsRegistryPage() {
: "默认当前用户";
const amountInvalid = Boolean(editor.amount.trim()) && !AMOUNT_PATTERN.test(editor.amount.trim());
const expiringContractCount = expiringQuery.data?.total ?? expiringItems.length;
const runGuarded = useUnsavedChangesGuard(
showEditor && JSON.stringify(editor) !== savedEditorSignature,
"合同表单",
);
const refreshAll = async () => {
await Promise.all([
@@ -271,14 +278,20 @@ export function ContractsRegistryPage() {
};
const resetEditor = () => {
const nextEditor = buildEmptyEditor(filters.projectId);
setEditorId(null);
setEditor(buildEmptyEditor(filters.projectId));
setEditor(nextEditor);
setSavedEditorSignature(JSON.stringify(nextEditor));
};
const openEditor = (item: ContractRegistryItem) => {
setEditorId(item.id);
setEditor(loadEditorFromItem(item));
setShowEditor(true);
runGuarded(() => {
const nextEditor = loadEditorFromItem(item);
setEditorId(item.id);
setEditor(nextEditor);
setSavedEditorSignature(JSON.stringify(nextEditor));
setShowEditor(true);
});
};
const saveMutation = useMutation({
@@ -335,15 +348,19 @@ export function ContractsRegistryPage() {
title="合同"
actions={
<HeaderActionGroup>
<Button
type="button"
onClick={() => {
resetEditor();
setShowEditor(true);
}}
>
新增合同
</Button>
{canManageContracts ? (
<Button
type="button"
onClick={() => {
runGuarded(() => {
resetEditor();
setShowEditor(true);
});
}}
>
新增合同
</Button>
) : null}
</HeaderActionGroup>
}
/>
@@ -359,9 +376,11 @@ export function ContractsRegistryPage() {
<div className="truncate font-medium text-zinc-900">{item.contractNo} · {item.counterpartyName}</div>
<div className="text-xs text-zinc-500">到期日 {formatDisplayDate(item.expiryDate)} · 责任人 {item.owner?.name ?? "-"}</div>
</div>
<Button type="button" size="sm" variant="secondary" onClick={() => openEditor(item)}>
编辑
</Button>
{canManageContracts ? (
<Button type="button" size="sm" variant="secondary" onClick={() => openEditor(item)}>
编辑
</Button>
) : null}
</div>
))}
{!expiringQuery.isLoading && !expiringItems.length ? (
@@ -382,7 +401,7 @@ export function ContractsRegistryPage() {
</div>
<div className="flex flex-wrap gap-2">
{editorId ? (
<Button type="button" variant="secondary" size="sm" onClick={resetEditor}>
<Button type="button" variant="secondary" size="sm" onClick={() => runGuarded(resetEditor)}>
切换到新建
</Button>
) : null}
@@ -391,8 +410,10 @@ export function ContractsRegistryPage() {
variant="secondary"
size="sm"
onClick={() => {
setShowEditor(false);
resetEditor();
runGuarded(() => {
setShowEditor(false);
resetEditor();
});
}}
>
关闭
@@ -705,17 +726,21 @@ export function ContractsRegistryPage() {
type="button"
variant="secondary"
onClick={() => {
setFilters({
projectId: initialProjectId,
status: "",
contractType: "",
counterpartyName: "",
expiryDateFrom: "",
expiryDateTo: "",
runGuarded(() => {
const nextEditor = buildEmptyEditor(initialProjectId);
setFilters({
projectId: initialProjectId,
status: "",
contractType: "",
counterpartyName: "",
expiryDateFrom: "",
expiryDateTo: "",
});
setShowEditor(false);
setEditorId(null);
setEditor(nextEditor);
setSavedEditorSignature(JSON.stringify(nextEditor));
});
setShowEditor(false);
setEditorId(null);
setEditor(buildEmptyEditor(initialProjectId));
}}
>
重置筛选
@@ -810,9 +835,11 @@ export function ContractsRegistryPage() {
{item.amount ? ` · ${item.amount} ${item.currency}` : ""}
{item.expiryDate ? ` · ${formatDisplayDate(item.expiryDate)}` : ""}
</div>
<Button className="mt-3" type="button" size="sm" variant="secondary" onClick={() => openEditor(item)}>
编辑
</Button>
{canManageContracts ? (
<Button className="mt-3" type="button" size="sm" variant="secondary" onClick={() => openEditor(item)}>
编辑
</Button>
) : null}
</article>
))}
{!contractsQuery.isLoading && !visibleContracts.length ? (
@@ -860,9 +887,12 @@ export function ContractsRegistryPage() {
</td>
<td className="table-td">
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => openEditor(item)}>
编辑
</Button>
{canManageContracts ? (
<Button type="button" size="sm" variant="secondary" onClick={() => openEditor(item)}>
编辑
</Button>
) : null}
{canManageContracts || item.project ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" size="sm" variant="secondary">
@@ -870,25 +900,30 @@ export function ContractsRegistryPage() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={renewMutation.isPending || !["active", "expired"].includes(item.status)}
onSelect={() => renewMutation.mutate(item)}
>
续约
</DropdownMenuItem>
{canManageContracts ? (
<DropdownMenuItem
disabled={renewMutation.isPending || !["active", "expired"].includes(item.status)}
onSelect={() => renewMutation.mutate(item)}
>
续约
</DropdownMenuItem>
) : null}
{item.project ? (
<DropdownMenuItem asChild>
<Link to={`/projects/${item.project.id}`}>查看项目</Link>
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={toggleStatusMutation.isPending}
onSelect={() => setConfirmStatusTarget(item)}
>
{item.persistedStatus === "archived" ? "恢复生效" : "归档"}
</DropdownMenuItem>
{canManageContracts ? (
<DropdownMenuItem
disabled={toggleStatusMutation.isPending}
onSelect={() => setConfirmStatusTarget(item)}
>
{item.persistedStatus === "archived" ? "恢复生效" : "归档"}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</td>
</tr>
+23 -18
查看文件
@@ -37,6 +37,7 @@ import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
import { PageHeader } from "../components/common/PageHeader";
import { EmployeePerformancePanel } from "../components/people/EmployeePerformancePanel";
import { EmployeeSalaryPanel } from "../components/people/EmployeeSalaryPanel";
@@ -184,6 +185,8 @@ export function EmployeeProfilePage() {
});
const canManageEmployeeProfile = hasPermission(permissions, "system.users");
const canManageSensitiveHr = hasPermission(permissions, "system.settings");
const canManageEmployeeContracts = hasPermission(permissions, "employee.contract.manage");
const canManagePayroll = hasPermission(permissions, "payroll.manage");
const canEditSelfProfile = me?.id === employeeId;
const canEditBaseFields = canManageEmployeeProfile || canEditSelfProfile;
const canEditExtendedFields = canManageEmployeeProfile;
@@ -215,7 +218,7 @@ export function EmployeeProfilePage() {
});
const contractsQuery = useQuery({
enabled: Boolean(employeeId) && canManageEmployeeProfile,
enabled: Boolean(employeeId) && canManageEmployeeContracts,
queryKey: ["employee-contracts", employeeId],
queryFn: () => fetchEmployeeContracts(employeeId),
});
@@ -267,7 +270,7 @@ export function EmployeeProfilePage() {
}, [employeeId, requestedTab]);
useEffect(() => {
if (activeTab === "salary" && !canManageSensitiveHr && !canEditSelfProfile) {
if (activeTab === "salary" && !canManagePayroll && !canEditSelfProfile) {
updateActiveTab("profile");
return;
}
@@ -275,11 +278,11 @@ export function EmployeeProfilePage() {
updateActiveTab("profile");
return;
}
if (canManageEmployeeProfile || canEditSelfProfile || activeTab === "profile") {
if (canManageEmployeeProfile || canManageEmployeeContracts || canEditSelfProfile || activeTab === "profile") {
return;
}
updateActiveTab("profile");
}, [activeTab, canEditSelfProfile, canManageEmployeeProfile, canManageSensitiveHr]);
}, [activeTab, canEditSelfProfile, canManageEmployeeContracts, canManageEmployeeProfile, canManagePayroll, canManageSensitiveHr]);
useEffect(() => {
setAvatarFile(null);
@@ -464,10 +467,11 @@ export function EmployeeProfilePage() {
});
const deleteContractMutation = useMutation({
mutationFn: (contractId: string) => deleteEmployeeContract(employeeId, contractId),
onSuccess: async (_result, contractId) => {
mutationFn: ({ contractId, reason }: { contractId: string; reason: string }) => deleteEmployeeContract(employeeId, contractId, reason),
onSuccess: async (_result, variables) => {
await queryClient.invalidateQueries({ queryKey: ["employee-contracts", employeeId] });
if (editingContractId === contractId) {
setDeleteContractTarget(null);
if (editingContractId === variables.contractId) {
setEditingContractId(null);
setContractForm(buildEmptyContractForm());
setContractAttachmentFile(null);
@@ -538,7 +542,7 @@ export function EmployeeProfilePage() {
const performanceOnlyMode = activeTab === "performance" && profilePermissionDenied && Boolean(performanceQuery.data);
const showPerformanceTab =
canManageSensitiveHr || canEditSelfProfile || performanceOnlyMode || performanceQuery.data?.viewerRole === "manager";
const showSalaryTab = canManageSensitiveHr || canEditSelfProfile;
const showSalaryTab = canManagePayroll || canEditSelfProfile;
const performanceContent =
performanceQuery.isLoading ? (
@@ -555,7 +559,7 @@ export function EmployeeProfilePage() {
]}
/>
);
const salaryContent = <EmployeeSalaryPanel employeeId={employeeId} canManage={canManageSensitiveHr} isSelf={canEditSelfProfile} />;
const salaryContent = <EmployeeSalaryPanel employeeId={employeeId} canManage={canManagePayroll} isSelf={canEditSelfProfile} />;
if (profileQuery.isLoading || (activeTab === "performance" && profilePermissionDenied && performanceQuery.isLoading)) {
return <div className="card-std p-6 text-sm text-zinc-500">加载员工档案中...</div>;
@@ -737,7 +741,7 @@ export function EmployeeProfilePage() {
<Tabs value={activeTab} onValueChange={(value) => updateActiveTab(value as ProfileTab)}>
<TabsList className="flex w-full flex-wrap justify-start gap-1">
<TabsTrigger value="profile">档案</TabsTrigger>
{canManageEmployeeProfile ? <TabsTrigger value="contracts">合同</TabsTrigger> : null}
{canManageEmployeeContracts ? <TabsTrigger value="contracts">合同</TabsTrigger> : null}
{canManageEmployeeProfile ? <TabsTrigger value="trainingCertificates">培训与证书</TabsTrigger> : null}
{showPerformanceTab ? <TabsTrigger value="performance">绩效</TabsTrigger> : null}
{showSalaryTab ? <TabsTrigger value="salary">薪资</TabsTrigger> : null}
@@ -1064,7 +1068,7 @@ export function EmployeeProfilePage() {
</div>
</TabsContent>
{canManageEmployeeProfile ? (
{canManageEmployeeContracts ? (
<TabsContent value="contracts" className="space-y-6">
{contractsQuery.isError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
@@ -1115,7 +1119,7 @@ export function EmployeeProfilePage() {
<TableCell className="max-w-[220px]">
{contract.attachmentUrl ? (
<div className="space-y-2">
<div className="truncate text-xs text-zinc-500">{contract.attachmentPath ?? "-"}</div>
<div className="text-xs text-zinc-500">{contract.hasAttachment ? "已上传附件" : "未上传附件"}</div>
<Button
type="button"
size="sm"
@@ -1203,7 +1207,7 @@ export function EmployeeProfilePage() {
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-4">
<div className="text-sm font-medium text-zinc-900">附件管理</div>
<div className="mt-1 text-xs text-zinc-500">
当前附件:{contracts.find((item) => item.id === editingContractId)?.attachmentPath ?? "未上传"}
当前附件:{contracts.find((item) => item.id === editingContractId)?.hasAttachment ? "已上传" : "未上传"}
</div>
<div className="mt-3 grid gap-3">
<Input
@@ -1520,17 +1524,18 @@ export function EmployeeProfilePage() {
deleteAvatarMutation.mutate();
}}
/>
<ActionConfirmDialog
<AttachmentDeleteConfirmDialog
open={Boolean(deleteContractTarget)}
onOpenChange={(open) => {
if (!open) setDeleteContractTarget(null);
}}
kind="delete"
title="确认删除员工合同?"
targetName={deleteContractTarget ? `合同 ${deleteContractTarget.contractNo}` : undefined}
description="删除后该合同记录和附件引用将从员工档案中移除,生效中的合同不能删除。"
onConfirm={async () => {
description={deleteContractTarget ? `对象:合同 ${deleteContractTarget.contractNo}。删除后合同记录与附件引用将从员工档案移除,且无法在 OA 内恢复。` : undefined}
pending={deleteContractMutation.isPending}
onConfirm={async (reason) => {
if (deleteContractTarget) {
deleteContractMutation.mutate(deleteContractTarget.id);
deleteContractMutation.mutate({ contractId: deleteContractTarget.id, reason });
}
}}
/>
+18 -14
查看文件
@@ -482,12 +482,14 @@ export function InvoicePoolPage() {
</Button>
) : (
<>
<Button
type="button"
onClick={() => setInvoiceActionPanel("upload")}
>
上传票据
</Button>
{canUploadInvoice ? (
<Button
type="button"
onClick={() => setInvoiceActionPanel("upload")}
>
上传票据
</Button>
) : null}
</>
)}
</HeaderActionGroup>
@@ -547,14 +549,16 @@ export function InvoicePoolPage() {
listQuery={listQuery}
actions={
<>
<Button
type="button"
size="sm"
variant={invoiceActionPanel === "bind" ? "default" : "secondary"}
onClick={() => setInvoiceActionPanel((current) => (current === "bind" ? null : "bind"))}
>
绑定
</Button>
{canUploadInvoice || canManageTravelPayments ? (
<Button
type="button"
size="sm"
variant={invoiceActionPanel === "bind" ? "default" : "secondary"}
onClick={() => setInvoiceActionPanel((current) => (current === "bind" ? null : "bind"))}
>
绑定
</Button>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
+1 -1
查看文件
@@ -360,7 +360,7 @@ export function MobilePortalPage() {
</Button>
) : null}
<Button asChild className="w-full" size="sm" type="button" variant="secondary">
<Link to="/attendance">查看考勤汇总</Link>
<Link to="/mobile/attendance">查看考勤汇总</Link>
</Button>
</div>
</section>
+1
查看文件
@@ -43,6 +43,7 @@ function roleLabel(roleCode: string) {
employee: "员工",
finance: "财务",
finance_mgr: "财务负责人",
system_admin: "OA 系统管理员",
hr: "人事",
manager: "审批人",
};
@@ -32,6 +32,7 @@ import type { PrecheckResponse } from "../api/feeControl";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { MobileIcon } from "../components/mobile/MobileIcon";
@@ -211,6 +212,7 @@ export function MobileReimbursementDetailPage() {
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
const [confirmDeleteInvoiceId, setConfirmDeleteInvoiceId] = useState("");
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; name: string } | null>(null);
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
const [invoicePanel, setInvoicePanel] = useState<InvoicePanel>("upload");
@@ -692,9 +694,11 @@ export function MobileReimbursementDetailPage() {
});
const deleteAttachmentMutation = useMutation({
mutationFn: (attachmentId: string) => deleteReimbursementAttachment(reimbursementId, attachmentId),
mutationFn: ({ attachmentId, reason }: { attachmentId: string; reason: string }) =>
deleteReimbursementAttachment(reimbursementId, attachmentId, reason),
onSuccess: async () => {
await invalidateDetail();
setDeleteAttachmentTarget(null);
toast.success("附件材料已删除");
},
onError: (error) => {
@@ -1157,7 +1161,7 @@ export function MobileReimbursementDetailPage() {
variant="secondary"
type="button"
disabled={deleteAttachmentMutation.isPending}
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, name: attachment.originalName })}
>
删除
</Button>
@@ -1561,6 +1565,20 @@ export function MobileReimbursementDetailPage() {
confirmLabel="删除草稿"
onConfirm={() => deleteReimbursementMutation.mutate()}
/>
<AttachmentDeleteConfirmDialog
open={Boolean(deleteAttachmentTarget)}
targetName={deleteAttachmentTarget?.name}
pending={deleteAttachmentMutation.isPending}
onOpenChange={(open) => {
if (!open) setDeleteAttachmentTarget(null);
}}
onConfirm={(reason) => {
if (deleteAttachmentTarget) {
deleteAttachmentMutation.mutate({ attachmentId: deleteAttachmentTarget.id, reason });
}
}}
/>
</PageScaffold>
);
}
+206 -71
查看文件
@@ -4,17 +4,17 @@ import { useSearchParams } from "react-router-dom";
import {
cancelPaymentBatch,
createPaymentBatch,
deletePaymentBatchReceiptAttachment,
downloadPaymentBatchReceiptAttachment,
executePaymentBatch,
fetchPaymentBatchCandidates,
fetchPaymentBatchReceiptAttachments,
fetchPaymentBatches,
reviewPaymentBatch,
updatePaymentBatchItem,
uploadPaymentBatchReceiptAttachments,
voidPaymentBatchReceiptAttachment,
type PaymentBatch,
type PaymentBatchReceiptAttachment,
type PaymentBatchEntityType,
type PaymentChannel,
} from "../api/paymentBatches";
import { isApiError } from "../api/types";
@@ -33,6 +33,15 @@ import { useAttachmentDownloadReasonDialog } from "../components/common/useAttac
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { Textarea } from "../components/ui/textarea";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { StatusBadge } from "../components/common/StatusBadge";
import { DateTextInput } from "../components/common/DateTextInput";
@@ -78,8 +87,7 @@ function paymentItemStatusLabel(value: string) {
}
function reviewNextStepLabel(value: PaymentBatch["reviewRequirement"]["nextStep"]) {
if (value === "first_review") return "等待首轮复核";
if (value === "second_review") return "等待二次复核";
if (value === "first_review") return "等待独立复核";
return "可执行付款标记";
}
@@ -105,13 +113,15 @@ function resolveError(error: unknown) {
export function PaymentBatchesPage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
const { me, permissions } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const canManageBatch = hasPermission(permissions, "payment.batch.manage");
const canReviewBatch = hasPermission(permissions, "risk.review.manage");
const [entityType, setEntityType] = useState<PaymentBatchEntityType>("reimbursement");
const canReviewBatch =
hasPermission(permissions, "payment.batch.review") &&
(me?.roleCodes.includes("manager") || me?.roleCodes.includes("ceo"));
const [name, setName] = useState("");
const [entityIds, setEntityIds] = useState("");
const [candidateSearch, setCandidateSearch] = useState("");
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
const [defaultPaymentDate, setDefaultPaymentDate] = useState("");
const [defaultPaymentChannel, setDefaultPaymentChannel] = useState<PaymentChannel | "">("");
const [voucherPrefix, setVoucherPrefix] = useState("");
@@ -120,15 +130,28 @@ export function PaymentBatchesPage() {
const [error, setError] = useState("");
const [showCreatePanel, setShowCreatePanel] = useState(false);
const [confirmPaymentAction, setConfirmPaymentAction] = useState<{
kind: "financeReview" | "financePayment" | "cancel";
kind: "financeReview" | "financePayment";
batchId: string;
batchNo: string;
highRisk: boolean;
totalAmount: string;
} | null>(null);
const [reasonAction, setReasonAction] = useState<
| { kind: "cancel"; batchId: string; batchNo: string }
| { kind: "voidReceipt"; batchId: string; batchNo: string; attachmentId: string; attachmentName: string }
| null
>(null);
const [actionReason, setActionReason] = useState("");
const listQuery = useQuery({
queryKey: ["payment-batches"],
queryFn: () => fetchPaymentBatches(),
});
const candidatesQuery = useQuery({
queryKey: ["payment-batch-candidates", candidateSearch],
enabled: showCreatePanel && canManageBatch,
queryFn: () => fetchPaymentBatchCandidates({ q: candidateSearch, page: 1, pageSize: 50 }),
});
const selectedBatchId = searchParams.get("id") ?? "";
const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]);
@@ -148,13 +171,12 @@ export function PaymentBatchesPage() {
const canEditReceiptAttachments =
canManageBatch &&
!!selectedBatch &&
!["executed", "cancelled"].includes(selectedBatch.status);
selectedBatch.status !== "cancelled";
const canReviewSelectedBatch =
canReviewBatch &&
!!selectedBatch &&
(
(selectedBatch.status === "draft" && selectedBatch.reviewRequirement.nextStep === "first_review") ||
(selectedBatch.status === "reviewed" && selectedBatch.reviewRequirement.nextStep === "second_review")
selectedBatch.status === "draft" && selectedBatch.reviewRequirement.nextStep === "first_review"
);
const canExecuteSelectedBatch =
canManageBatch &&
@@ -168,6 +190,7 @@ export function PaymentBatchesPage() {
const refresh = async () => {
await queryClient.invalidateQueries({ queryKey: ["payment-batches"] });
await queryClient.invalidateQueries({ queryKey: ["payment-batch-candidates"] });
await queryClient.invalidateQueries({ queryKey: ["payment-batch-receipts"] });
await queryClient.invalidateQueries({ queryKey: ["ap-operations"] });
};
@@ -181,15 +204,17 @@ export function PaymentBatchesPage() {
const createMutation = useMutation({
mutationFn: async () =>
createPaymentBatch({
entityType,
name: name.trim() || `${entityType}-batch`,
entityIds: entityIds.split(/[\s,]+/).map((item) => item.trim()).filter(Boolean),
entityType: "reimbursement",
name: name.trim() || "报销付款批次",
entityIds: selectedCandidateIds,
defaultPaymentDate: defaultPaymentDate || undefined,
defaultPaymentChannel: defaultPaymentChannel || undefined,
voucherPrefix: voucherPrefix || undefined,
}),
onSuccess: async ({ item }) => {
setError("");
setSelectedCandidateIds([]);
setCandidateSearch("");
const next = new URLSearchParams(searchParams);
next.set("id", item.id);
setSearchParams(next, { replace: true });
@@ -236,9 +261,11 @@ export function PaymentBatchesPage() {
});
const cancelMutation = useMutation({
mutationFn: cancelPaymentBatch,
mutationFn: ({ batchId, reason }: { batchId: string; reason: string }) => cancelPaymentBatch(batchId, reason),
onSuccess: async () => {
setError("");
setReasonAction(null);
setActionReason("");
await refresh();
toast.success("付款批次已取消");
},
@@ -322,17 +349,16 @@ export function PaymentBatchesPage() {
downloadReceiptMutation.mutate({ attachment, reason });
}
const deleteReceiptMutation = useMutation({
mutationFn: async (attachmentId: string) => {
if (!selectedBatch) {
throw new Error("请先选择付款批次");
}
return deletePaymentBatchReceiptAttachment(selectedBatch.id, attachmentId);
const voidReceiptMutation = useMutation({
mutationFn: async ({ batchId, attachmentId, reason }: { batchId: string; attachmentId: string; reason: string }) => {
return voidPaymentBatchReceiptAttachment(batchId, attachmentId, reason);
},
onSuccess: async () => {
setError("");
setReasonAction(null);
setActionReason("");
await refresh();
toast.success("银行回单附件已删除");
toast.success("银行回单已留痕作废");
},
onError: (err) => {
const message = resolveError(err);
@@ -353,7 +379,7 @@ export function PaymentBatchesPage() {
返回列表
</Button>
) : null}
{!selectedBatchId ? (
{!selectedBatchId && canManageBatch ? (
<Button type="button" onClick={() => setShowCreatePanel(true)}>
新建批次
</Button>
@@ -374,41 +400,64 @@ export function PaymentBatchesPage() {
当前账号仅可查看批次并执行复核,创建与编辑入口仅对付款批次管理员开放。
</div>
) : null}
<div className="grid gap-3 lg:grid-cols-[180px_minmax(0,1fr)]">
<div className="space-y-2">
<Label>实体类型</Label>
<select
className="select-std"
data-testid="payment-batch-create-entity-type"
value={entityType}
disabled={!canManageBatch}
onChange={(event) => setEntityType(event.target.value as PaymentBatchEntityType)}
>
<option value="reimbursement">报销付款</option>
<option value="travel_order">差旅订单付款</option>
</select>
</div>
<div className="space-y-2">
<Label>批次名称</Label>
<Input
data-testid="payment-batch-create-name"
value={name}
disabled={!canManageBatch}
onChange={(event) => setName(event.target.value)}
placeholder="例如:2026Q2-财务付款批次-01"
/>
</div>
<div className="space-y-2">
<Label>批次名称</Label>
<Input
data-testid="payment-batch-create-name"
value={name}
disabled={!canManageBatch}
onChange={(event) => setName(event.target.value)}
placeholder="例如:2026Q2-财务付款批次-01"
/>
</div>
<div className="space-y-2">
<Label>待付款单据 ID</Label>
<textarea
className="min-h-24 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm disabled:bg-zinc-50"
data-testid="payment-batch-create-entity-ids"
value={entityIds}
<Label>选择待付款报销</Label>
<Input
data-testid="payment-batch-candidate-search"
value={candidateSearch}
disabled={!canManageBatch}
onChange={(event) => setEntityIds(event.target.value)}
placeholder="输入报销或差旅待付款单据 ID,多个用逗号或换行分隔"
onChange={(event) => setCandidateSearch(event.target.value)}
placeholder="按报销编号、人员或摘要搜索"
/>
<div className="max-h-72 space-y-2 overflow-y-auto rounded-lg border border-zinc-200 p-2" data-testid="payment-batch-candidates">
{candidatesQuery.isLoading ? <div className="px-2 py-3 text-sm text-zinc-500">正在加载待付款报销...</div> : null}
{candidatesQuery.isError ? <div className="px-2 py-3 text-sm text-red-600">{resolveError(candidatesQuery.error)}</div> : null}
{(candidatesQuery.data?.items ?? []).map((candidate) => {
const selected = selectedCandidateIds.includes(candidate.entityId);
return (
<label
key={candidate.entityId}
className="flex cursor-pointer items-start gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-3 hover:border-zinc-300"
data-testid="payment-batch-candidate-row"
>
<input
type="checkbox"
className="mt-1 h-4 w-4"
checked={selected}
onChange={(event) => setSelectedCandidateIds((current) =>
event.target.checked
? Array.from(new Set([...current, candidate.entityId]))
: current.filter((id) => id !== candidate.entityId),
)}
/>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center justify-between gap-2 text-sm font-medium text-zinc-900">
<span>{candidate.businessNo} · {candidate.employee.name}</span>
<span>{candidate.amount} {candidate.currencyCode}</span>
</span>
<span className="mt-1 block text-xs text-zinc-500">
{formatDisplayDate(candidate.businessDate)}
{candidate.summary ? ` · ${candidate.summary}` : ""}
</span>
</span>
</label>
);
})}
{!candidatesQuery.isLoading && !candidatesQuery.isError && !(candidatesQuery.data?.items ?? []).length ? (
<div className="px-2 py-3 text-sm text-zinc-500">没有可加入批次的已审批报销。</div>
) : null}
</div>
<div className="text-xs text-zinc-500">已选择 {selectedCandidateIds.length} 项;创建时服务端会再次核对审批和付款状态。</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
<div className="space-y-2">
@@ -444,7 +493,7 @@ export function PaymentBatchesPage() {
/>
</div>
</div>
<Button type="button" disabled={createMutation.isPending || !canManageBatch} onClick={() => createMutation.mutate()}>
<Button type="button" disabled={createMutation.isPending || !canManageBatch || selectedCandidateIds.length === 0} onClick={() => createMutation.mutate()}>
{createMutation.isPending ? "创建中..." : "创建批次"}
</Button>
</div>
@@ -474,9 +523,11 @@ export function PaymentBatchesPage() {
kind: "financeReview",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
highRisk: selectedBatch.highRisk,
totalAmount: selectedBatch.totalAmount,
})}
>
{selectedBatch.reviewRequirement.nextStep === "second_review" ? "二次复核" : "复核"}
复核
</Button>
) : null}
{canManageBatch ? (
@@ -487,6 +538,8 @@ export function PaymentBatchesPage() {
kind: "financePayment",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
highRisk: selectedBatch.highRisk,
totalAmount: selectedBatch.totalAmount,
})}
>
执行
@@ -497,11 +550,14 @@ export function PaymentBatchesPage() {
type="button"
variant="secondary"
disabled={cancelMutation.isPending || !canCancelSelectedBatch}
onClick={() => setConfirmPaymentAction({
onClick={() => {
setActionReason("");
setReasonAction({
kind: "cancel",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
})}
});
}}
>
取消
</Button>
@@ -517,10 +573,11 @@ export function PaymentBatchesPage() {
<div className="detail-fact-card"><div className="detail-fact-card__label">复核进度</div><div className="detail-fact-card__value">{selectedBatch.reviewRequirement.completedReviewCount}/{selectedBatch.reviewRequirement.requiredReviewCount}</div></div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-600">
当前要求:{selectedBatch.reviewRequirement.requiredReviewCount === 2 ? `批次金额达到 ${selectedBatch.reviewRequirement.thresholdAmount},需两轮复核` : "单轮复核后可执行"}。
当前要求:单次独立复核后可执行。
{selectedBatch.highRisk ? ` 批次金额达到 ${selectedBatch.reviewRequirement.thresholdAmount},执行前需再次核对付款对象、金额和回单,但不增加审批层级。` : ""}
下一步:{reviewNextStepLabel(selectedBatch.reviewRequirement.nextStep)}。
{selectedBatch.reviewedBy ? ` 首轮复核人:${reviewerDisplayName(selectedBatch.reviewedBy)}` : ""}
{selectedBatch.secondaryReviewedBy ? ` 二次复核人:${reviewerDisplayName(selectedBatch.secondaryReviewedBy)}` : ""}
{selectedBatch.reviewedBy ? ` 复核人:${reviewerDisplayName(selectedBatch.reviewedBy)}` : ""}
{selectedBatch.cancelledReason ? ` 取消原因:${selectedBatch.cancelledReason}` : ""}
</div>
</div>
@@ -586,7 +643,7 @@ export function PaymentBatchesPage() {
type="button"
size="sm"
variant="secondary"
disabled={downloadReceiptMutation.isPending}
disabled={downloadReceiptMutation.isPending || Boolean(attachment.archivedAt)}
onClick={() => void handleDownloadReceipt(attachment)}
>
下载
@@ -596,10 +653,19 @@ export function PaymentBatchesPage() {
type="button"
size="sm"
variant="secondary"
disabled={!canEditReceiptAttachments || deleteReceiptMutation.isPending}
onClick={() => deleteReceiptMutation.mutate(attachment.id)}
disabled={selectedBatch.status === "cancelled" || Boolean(attachment.archivedAt) || voidReceiptMutation.isPending}
onClick={() => {
setActionReason("");
setReasonAction({
kind: "voidReceipt",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
attachmentId: attachment.id,
attachmentName: attachment.originalName,
});
}}
>
删除
{attachment.archivedAt ? "已作废" : "作废"}
</Button>
) : null}
</div>
@@ -764,18 +830,87 @@ export function PaymentBatchesPage() {
targetName={confirmPaymentAction?.batchNo}
description={
confirmPaymentAction?.kind === "financeReview"
? "确认后将写入付款批次复核记录;高风险批次仍需满足双人复核要求。"
: confirmPaymentAction?.kind === "cancel"
? "取消后该付款批次不能继续执行,历史记录会保留。"
: "确认后将执行付款批次并影响付款状态,请确认批次、金额和复核要求均已核对。"
? confirmPaymentAction.highRisk
? `该批次金额为 ${confirmPaymentAction.totalAmount},请核对付款对象和金额。确认后完成一次独立复核,不增加第二审批层级。`
: "确认后将完成一次独立复核并写入付款批次审计记录。"
: confirmPaymentAction?.highRisk
? `这是高额付款批次,金额 ${confirmPaymentAction.totalAmount}。请再次核对付款对象、金额、渠道和回单;确认后将更新源单付款状态。`
: "确认后将执行付款批次并影响付款状态,请确认批次、金额和回单均已核对。"
}
onConfirm={async () => {
if (!confirmPaymentAction) return;
if (confirmPaymentAction.kind === "financeReview") reviewMutation.mutate(confirmPaymentAction.batchId);
if (confirmPaymentAction.kind === "financePayment") executeMutation.mutate(confirmPaymentAction.batchId);
if (confirmPaymentAction.kind === "cancel") cancelMutation.mutate(confirmPaymentAction.batchId);
}}
/>
<Dialog
open={Boolean(reasonAction)}
onOpenChange={(open) => {
if (!open) {
setReasonAction(null);
setActionReason("");
}
}}
>
<DialogContent className="max-w-md p-5">
<DialogHeader>
<DialogTitle>{reasonAction?.kind === "voidReceipt" ? "作废银行回单" : "取消付款批次"}</DialogTitle>
<DialogDescription>
{reasonAction?.kind === "voidReceipt"
? `回单:${reasonAction.attachmentName}。文件记录会保留并标记为已作废。`
: reasonAction
? `批次:${reasonAction.batchNo}。只取消尚未执行的付款项,已执行项不会回滚。`
: "请填写原因。"}
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-2">
<Textarea
value={actionReason}
onChange={(event) => setActionReason(event.target.value)}
aria-label={reasonAction?.kind === "voidReceipt" ? "回单作废原因" : "批次取消原因"}
rows={4}
maxLength={500}
placeholder={reasonAction?.kind === "voidReceipt" ? "说明回单为何作废" : "说明批次为何不再继续"}
/>
<div className="flex items-center justify-between text-xs text-zinc-500">
<span>原因需填写 2–500 个字符,并写入审计记录。</span>
<span>{actionReason.trim().length}/500</span>
</div>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => {
setReasonAction(null);
setActionReason("");
}}>
返回
</Button>
<Button
type="button"
variant="destructive"
disabled={
actionReason.trim().length < 2 ||
cancelMutation.isPending ||
voidReceiptMutation.isPending
}
onClick={() => {
if (!reasonAction) return;
const reason = actionReason.trim();
if (reasonAction.kind === "cancel") {
cancelMutation.mutate({ batchId: reasonAction.batchId, reason });
} else {
voidReceiptMutation.mutate({
batchId: reasonAction.batchId,
attachmentId: reasonAction.attachmentId,
reason,
});
}
}}
>
{reasonAction?.kind === "voidReceipt" ? "确认作废" : "确认取消"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ListWorkspace>
);
}
+3 -1
查看文件
@@ -140,6 +140,7 @@ export function PeoplePage() {
const { permissions } = useAuth();
const queryClient = useQueryClient();
const canManageUsers = hasPermission(permissions, "system.users");
const canManagePayroll = hasPermission(permissions, "payroll.manage");
const canViewStats = canManageUsers || hasPermission(permissions, "report.dashboard.view");
const [q, setQ] = useState("");
@@ -185,6 +186,7 @@ export function PeoplePage() {
hireDateFrom: hireDateFrom || undefined,
hireDateTo: hireDateTo || undefined,
employmentStatus: employmentStatus || undefined,
scope: "management",
}),
});
@@ -429,7 +431,7 @@ export function PeoplePage() {
subtitle="按员工档案、账号状态和企业微信镜像做筛选、批量处理和审计留痕。"
actions={
<div className="flex flex-wrap gap-2">
{canManageUsers ? (
{canManagePayroll ? (
<Button asChild variant="secondary" type="button">
<Link to="/payroll">薪资管理</Link>
</Button>
+1
查看文件
@@ -50,6 +50,7 @@ function roleLabel(roleCode: string) {
employee: "员工",
finance: "财务",
finance_mgr: "财务负责人",
system_admin: "OA 系统管理员",
hr: "人事",
manager: "审批人",
};
+116 -23
查看文件
@@ -35,8 +35,10 @@ import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { DateTextInput } from "../components/common/DateTextInput";
import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
@@ -77,6 +79,21 @@ type DraftItem = {
isAssetCandidate: boolean;
};
function purchaseDraftSignature(value: {
title: string;
reason: string;
currencyCode: string;
legalEntityId: string;
costCenterId: string;
supplierId: string;
documentCategoryId: string;
projectId: string;
expectedDeliveryDate: string;
items: DraftItem[];
}) {
return JSON.stringify(value);
}
function resolveError(error: unknown) {
if (isApiError(error)) {
return mapApiErrorMessage(error.code, error.details, error.message);
@@ -318,9 +335,24 @@ export function PurchaseRequestsPage() {
const [projectId, setProjectId] = useState("");
const [expectedDeliveryDate, setExpectedDeliveryDate] = useState("");
const [items, setItems] = useState<DraftItem[]>([]);
const [savedDraftSignature, setSavedDraftSignature] = useState(() =>
purchaseDraftSignature({
title: "",
reason: "",
currencyCode: "CNY",
legalEntityId: "",
costCenterId: "",
supplierId: "",
documentCategoryId: "",
projectId: "",
expectedDeliveryDate: "",
items: [],
}),
);
const [archivePoNo, setArchivePoNo] = useState("");
const [archiveContractNo, setArchiveContractNo] = useState("");
const [selectedAttachmentName, setSelectedAttachmentName] = useState("");
const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null);
const [submitPrecheckConfirm, setSubmitPrecheckConfirm] = useState<string | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
@@ -403,17 +435,17 @@ export function PurchaseRequestsPage() {
if (!detailQuery.data || detailQuery.data.id !== selectedId || hydratedDetailId === detailQuery.data.id) {
return;
}
setTitle(detailQuery.data.title || "");
setReason(detailQuery.data.reason || "");
setCurrencyCode(detailQuery.data.currencyCode || "CNY");
setLegalEntityId(detailQuery.data.legalEntityId || "");
setCostCenterId(detailQuery.data.costCenterId || "");
setSupplierId(detailQuery.data.supplierId || "");
setDocumentCategoryId(detailQuery.data.documentCategoryId || "");
setProjectId(detailQuery.data.projectId || "");
setExpectedDeliveryDate(detailQuery.data.expectedDeliveryDate || "");
setItems(
detailQuery.data.items.map((item) => ({
const nextDraft = {
title: detailQuery.data.title || "",
reason: detailQuery.data.reason || "",
currencyCode: detailQuery.data.currencyCode || "CNY",
legalEntityId: detailQuery.data.legalEntityId || "",
costCenterId: detailQuery.data.costCenterId || "",
supplierId: detailQuery.data.supplierId || "",
documentCategoryId: detailQuery.data.documentCategoryId || "",
projectId: detailQuery.data.projectId || "",
expectedDeliveryDate: detailQuery.data.expectedDeliveryDate || "",
items: detailQuery.data.items.map((item) => ({
id: item.id,
description: item.description,
specification: item.specification || "",
@@ -421,7 +453,18 @@ export function PurchaseRequestsPage() {
unitPrice: item.unitPrice,
isAssetCandidate: item.isAssetCandidate,
})),
);
};
setTitle(nextDraft.title);
setReason(nextDraft.reason);
setCurrencyCode(nextDraft.currencyCode);
setLegalEntityId(nextDraft.legalEntityId);
setCostCenterId(nextDraft.costCenterId);
setSupplierId(nextDraft.supplierId);
setDocumentCategoryId(nextDraft.documentCategoryId);
setProjectId(nextDraft.projectId);
setExpectedDeliveryDate(nextDraft.expectedDeliveryDate);
setItems(nextDraft.items);
setSavedDraftSignature(purchaseDraftSignature(nextDraft));
setArchivePoNo(detailQuery.data.archiveOrder?.poNo || "");
setArchiveContractNo(detailQuery.data.archiveOrder?.contractNo || "");
setFormError("");
@@ -576,6 +619,7 @@ export function PurchaseRequestsPage() {
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
]);
setFormError("");
setSavedDraftSignature(currentDraftSignature);
toast.success("采购草稿已保存");
},
onError: (error) => {
@@ -716,14 +760,15 @@ export function PurchaseRequestsPage() {
});
const deleteAttachmentMutation = useMutation({
mutationFn: async (attachmentId: string) => {
mutationFn: async ({ attachmentId, reason }: { attachmentId: string; reason: string }) => {
if (!selectedId) {
throw new Error("请先选择采购单");
}
return deletePurchaseAttachment(selectedId, attachmentId);
return deletePurchaseAttachment(selectedId, attachmentId, reason);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] });
setDeleteAttachmentTarget(null);
toast.success("附件已删除");
},
onError: (error) => {
@@ -772,6 +817,7 @@ export function PurchaseRequestsPage() {
setFormError("");
validateBeforeSubmit();
await updatePurchaseRequest(selectedId, buildPayload());
setSavedDraftSignature(currentDraftSignature);
const precheck = await precheckPurchaseRequest(selectedId);
const message = buildPrecheckDialogMessage(precheck);
@@ -796,6 +842,37 @@ export function PurchaseRequestsPage() {
const showArchiveSection = Boolean(detail && (detail.status === "approved" || archiveOrder));
const canEditDraft = Boolean(detail?.permissions.canEditDraft);
const currentDraftSignature = useMemo(
() =>
purchaseDraftSignature({
title,
reason,
currencyCode,
legalEntityId,
costCenterId,
supplierId,
documentCategoryId,
projectId,
expectedDeliveryDate,
items,
}),
[
costCenterId,
currencyCode,
documentCategoryId,
expectedDeliveryDate,
items,
legalEntityId,
projectId,
reason,
supplierId,
title,
],
);
const runGuarded = useUnsavedChangesGuard(
Boolean(selectedId) && canEditDraft && currentDraftSignature !== savedDraftSignature,
"采购表单",
);
const canSubmit = Boolean(detail?.permissions.canSubmit);
const canWithdraw = Boolean(detail?.permissions.canWithdraw);
const canArchiveOrder = Boolean(detail?.permissions.canArchiveOrder && canManage);
@@ -813,15 +890,19 @@ export function PurchaseRequestsPage() {
const detailNextStepText = detail ? resolvePurchaseNextStep(detail) : "";
const isPurchaseDetailMode = Boolean(selectedId);
const openPurchaseDetail = (id: string) => {
setSelectedId(id);
setHydratedDetailId("");
runGuarded(() => {
setSelectedId(id);
setHydratedDetailId("");
});
};
const returnToPurchaseList = () => {
setSelectedId("");
setHydratedDetailId("");
const next = new URLSearchParams(searchParams);
next.delete("id");
setSearchParams(next, { replace: true });
runGuarded(() => {
setSelectedId("");
setHydratedDetailId("");
const next = new URLSearchParams(searchParams);
next.delete("id");
setSearchParams(next, { replace: true });
});
};
return (
@@ -1670,7 +1751,7 @@ export function PurchaseRequestsPage() {
size="sm"
variant="secondary"
disabled={!canEditDraft || deleteAttachmentMutation.isPending}
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName })}
>
删除
</Button>
@@ -1720,7 +1801,7 @@ export function PurchaseRequestsPage() {
size="sm"
variant="secondary"
disabled={!canEditDraft || deleteAttachmentMutation.isPending}
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName })}
>
删除
</Button>
@@ -1877,6 +1958,18 @@ export function PurchaseRequestsPage() {
submitMutation.mutate();
}}
/>
<AttachmentDeleteConfirmDialog
open={Boolean(deleteAttachmentTarget)}
targetName={deleteAttachmentTarget?.originalName}
pending={deleteAttachmentMutation.isPending}
onOpenChange={(open) => {
if (!open) setDeleteAttachmentTarget(null);
}}
onConfirm={(deleteReason) => {
if (!deleteAttachmentTarget) return;
deleteAttachmentMutation.mutate({ attachmentId: deleteAttachmentTarget.id, reason: deleteReason });
}}
/>
</ListWorkspace>
);
}
+40 -10
查看文件
@@ -34,7 +34,9 @@ import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { StatusBadge } from "../components/common/StatusBadge";
@@ -161,6 +163,7 @@ export function ReimbursementDetailPage() {
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null);
const [invoiceActionError, setInvoiceActionError] = useState("");
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
@@ -170,6 +173,13 @@ export function ReimbursementDetailPage() {
amount: "",
description: "",
});
const runGuarded = useUnsavedChangesGuard(
Boolean(itemEditor) ||
selectedInvoiceFiles.length > 0 ||
selectedAttachmentFiles.length > 0 ||
Object.keys(selectedInvoices).length > 0,
"报销表单",
);
const detailQuery = useQuery({
enabled: Boolean(reimbursementId),
@@ -665,9 +675,11 @@ export function ReimbursementDetailPage() {
});
const deleteAttachmentMutation = useMutation({
mutationFn: (attachmentId: string) => deleteReimbursementAttachment(reimbursementId, attachmentId),
mutationFn: ({ attachmentId, reason }: { attachmentId: string; reason: string }) =>
deleteReimbursementAttachment(reimbursementId, attachmentId, reason),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
setDeleteAttachmentTarget(null);
toast.success("附件材料已删除");
},
onError: (error) => {
@@ -796,15 +808,19 @@ export function ReimbursementDetailPage() {
};
const startCreateItem = () => {
setItemEditor({ mode: "create" });
setEditorInitialValues(computeEditorDefaults());
setItemError("");
runGuarded(() => {
setItemEditor({ mode: "create" });
setEditorInitialValues(computeEditorDefaults());
setItemError("");
});
};
const startEditItem = (item: ReimbursementItem) => {
setItemEditor({ mode: "edit", itemId: item.id });
setEditorInitialValues(computeEditorDefaults(item));
setItemError("");
runGuarded(() => {
setItemEditor({ mode: "edit", itemId: item.id });
setEditorInitialValues(computeEditorDefaults(item));
setItemError("");
});
};
const handleItemSave = (data: ItemEditorSaveData) => {
@@ -812,8 +828,10 @@ export function ReimbursementDetailPage() {
};
const handleItemCancel = () => {
setItemEditor(null);
setItemError("");
runGuarded(() => {
setItemEditor(null);
setItemError("");
});
};
const handleSubmitAction = async () => {
@@ -1539,7 +1557,7 @@ export function ReimbursementDetailPage() {
variant="destructive"
size="sm"
disabled={deleteAttachmentMutation.isPending}
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName })}
>
删除
</Button>
@@ -1991,6 +2009,18 @@ export function ReimbursementDetailPage() {
confirmLabel="删除草稿"
onConfirm={() => { deleteReimbursementMutation.mutate(); }}
/>
<AttachmentDeleteConfirmDialog
open={Boolean(deleteAttachmentTarget)}
targetName={deleteAttachmentTarget?.originalName}
pending={deleteAttachmentMutation.isPending}
onOpenChange={(open) => {
if (!open) setDeleteAttachmentTarget(null);
}}
onConfirm={(deleteReason) => {
if (!deleteAttachmentTarget) return;
deleteAttachmentMutation.mutate({ attachmentId: deleteAttachmentTarget.id, reason: deleteReason });
}}
/>
</PageScaffold>
);
}
+2 -2
查看文件
@@ -13,9 +13,9 @@ type ManagementItem = {
};
const ITEMS: ManagementItem[] = [
{ to: "/people", icon: "badge", title: "人员", description: "员工档案、入转调离和人员状态。", group: "人员与组织", anyOf: ["system.users", "report.dashboard.view"] },
{ to: "/people", icon: "badge", title: "人员", description: "员工档案、入转调离和人员状态。", group: "人员与组织", anyOf: ["system.users"] },
{ to: "/organization", icon: "account_tree", title: "组织", description: "组织结构、部门和汇报关系。", group: "人员与组织", anyOf: ["system.users", "report.dashboard.view"] },
{ to: "/system/users", icon: "manage_accounts", title: "账号", description: "账号、角色和登录权限。", group: "人员与组织", anyOf: ["system.users"] },
{ to: "/system/users", icon: "manage_accounts", title: "账号", description: "账号、角色和登录权限。", group: "人员与组织", anyOf: ["system.accounts.manage", "system.roles.manage"] },
{ to: "/system/departments", icon: "account_tree", title: "部门", description: "部门基础数据维护。", group: "人员与组织", anyOf: ["system.users"] },
{ to: "/system/approval-rules", icon: "rule", title: "审批规则", description: "金额段、审批层级和处理规则。", group: "流程与规则", anyOf: ["system.settings"] },
{ to: "/system/workflow-definitions", icon: "schema", title: "流程定义", description: "流程模板和节点治理。", group: "流程与规则", anyOf: ["system.settings"] },
+95 -47
查看文件
@@ -25,26 +25,50 @@ import { Pagination } from "../components/common/Pagination";
import { CreateUserDialog, type CreateUserFormData } from "./user-management/CreateUserDialog";
import { UserImportDialog } from "./user-management/UserImportDialog";
import { UserRoleDialog } from "./user-management/UserRoleDialog";
import { useAuth } from "../auth/AuthContext";
const PAGE_SIZE = 20;
const ROLE_OPTIONS: Array<{ code: string; label: string }> = [
{ code: "employee", label: "employee" },
{ code: "manager", label: "manager" },
{ code: "ceo", label: "ceo" },
{ code: "finance", label: "finance" },
{ code: "finance_mgr", label: "finance_mgr" },
{ code: "administration", label: "administration" },
{ code: "hr", label: "hr" },
{ code: "admin", label: "admin" },
];
const ROLE_LABELS: Record<string, string> = {
employee: "员工",
manager: "直属经理",
ceo: "公司负责人",
finance: "财务",
system_admin: "OA 系统管理员",
finance_mgr: "财务负责人(兼容)",
administration: "行政(兼容)",
hr: "人事(兼容)",
admin: "应急超级管理员",
};
const ORDINARY_ROLE_OPTIONS = [
"employee",
"manager",
"ceo",
"finance",
"system_admin",
"finance_mgr",
"administration",
"hr",
] as const;
function formatRoleCodes(roleCodes: string[]) {
return roleCodes.length ? roleCodes.join(", ") : "-";
return roleCodes.length ? roleCodes.map((roleCode) => ROLE_LABELS[roleCode] ?? roleCode).join("、") : "-";
}
function isProtectedUserAccount(user: UserSummary) {
return user.roleCodes.some((roleCode) => roleCode === "admin" || roleCode === "system_admin");
}
export function UserManagementPage() {
const { me } = useAuth();
const queryClient = useQueryClient();
const isEmergencyAdmin = (me?.roleCodes ?? []).includes("admin");
const roleOptions = ORDINARY_ROLE_OPTIONS.map((code) => ({
code,
label: ROLE_LABELS[code],
disabled: code === "system_admin" && !isEmergencyAdmin,
}));
const [status, setStatus] = useState<UserStatus | "">("");
const [q, setQ] = useState("");
const [page, setPage] = useState(1);
@@ -190,7 +214,7 @@ export function UserManagementPage() {
employeeNo: item.employeeNo,
})),
}}
roleOptions={ROLE_OPTIONS}
roleOptions={roleOptions}
/>
</div>
}
@@ -280,8 +304,16 @@ export function UserManagementPage() {
</TableHeader>
<TableBody>
{users.length ? (
users.map((item) => (
<TableRow data-testid="users-row" key={item.id}>
users.map((item) => {
const protectedAccount = isProtectedUserAccount(item);
const canManageAccount = isEmergencyAdmin || !protectedAccount;
const isCurrentAccount = item.id === me?.id;
const canToggleAccount = canManageAccount && !isCurrentAccount;
const canResetPassword = canManageAccount;
const canEditRoles = canManageAccount && !isCurrentAccount;
return (
<TableRow data-testid="users-row" key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">
{item.employeeNo} / {item.name}
@@ -296,42 +328,58 @@ export function UserManagementPage() {
<TableCell className="text-xs">{formatRoleCodes(item.roleCodes)}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button
data-testid="users-toggle"
size="sm"
variant="secondary"
disabled={toggleMutation.isPending}
onClick={() => toggleMutation.mutate(item)}
type="button"
>
{item.status === "active" ? "禁用" : "启用"}
</Button>
{canToggleAccount ? (
<Button
data-testid="users-toggle"
size="sm"
variant="secondary"
disabled={toggleMutation.isPending}
onClick={() => toggleMutation.mutate(item)}
type="button"
>
{item.status === "active" ? "禁用" : "启用"}
</Button>
) : null}
<Button
data-testid="users-reset-password"
size="sm"
variant="secondary"
disabled={resetMutation.isPending}
onClick={() => resetMutation.mutate(item)}
type="button"
>
重置密码
</Button>
{canResetPassword ? (
<Button
data-testid="users-reset-password"
size="sm"
variant="secondary"
disabled={resetMutation.isPending}
onClick={() => resetMutation.mutate(item)}
type="button"
>
重置密码
</Button>
) : null}
<Button
data-testid="users-edit-roles"
size="sm"
variant="secondary"
disabled={rolesMutation.isPending}
onClick={() => setRoleDialogUser(item)}
type="button"
>
角色
</Button>
{canEditRoles ? (
<Button
data-testid="users-edit-roles"
size="sm"
variant="secondary"
disabled={rolesMutation.isPending}
onClick={() => setRoleDialogUser(item)}
type="button"
>
{item.roleCodes.includes("admin") ? "应急账号" : "角色"}
</Button>
) : null}
{protectedAccount && !isEmergencyAdmin ? (
<span
className="inline-flex items-center text-xs text-zinc-500"
data-testid="users-protected-account"
>
受保护账号
</span>
) : null}
</div>
</TableCell>
</TableRow>
))
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={5} className="py-3 text-zinc-500">
@@ -359,7 +407,7 @@ export function UserManagementPage() {
}
}}
user={roleDialogUser}
roleOptions={ROLE_OPTIONS}
roleOptions={roleOptions}
onSubmit={(roles) => {
if (roleDialogUser) {
rolesMutation.mutate({ userId: roleDialogUser.id, roles });
+24 -24
查看文件
@@ -37,9 +37,9 @@ const STATUS_OPTIONS: Array<{ value: "" | WecomApprovalArchiveStatus; label: str
const FINDING_OPTIONS: Array<{ value: "" | WecomApprovalArchiveFinding; label: string }> = [
{ value: "", label: "全部对账" },
{ value: "open", label: "仅未关闭 finding" },
{ value: "resolved", label: "已关闭 finding" },
{ value: "any", label: "有 finding" },
{ value: "open", label: "仅未关闭问题" },
{ value: "resolved", label: "已关闭问题" },
{ value: "any", label: "有对账问题" },
];
function resolveError(error: unknown, fallback = "读取企微审批历史失败") {
@@ -121,7 +121,7 @@ function ArchiveRow({
<td className="px-3 py-3 align-top text-sm text-zinc-600">{formatTime(item.statusObservedAt || item.completedAt || item.appliedAt)}</td>
<td className="px-3 py-3 align-top text-sm text-zinc-600">
<div>{sourceLabel(item.lastSource)}</div>
{item.openFindingCount ? <div className="mt-1 text-xs text-amber-700">finding {item.openFindingCount} 项</div> : null}
{item.openFindingCount ? <div className="mt-1 text-xs text-amber-700">对账问题 {item.openFindingCount} 项</div> : null}
</td>
<td className="px-3 py-3 align-top text-right">
<Button type="button" variant="secondary" onClick={onSelect}>查看历史</Button>
@@ -156,7 +156,7 @@ export function WecomApprovalArchivePage() {
const detail = detailQuery.data?.item ?? null;
return (
<div className="page-shell space-y-6" data-testid="wecom-approval-archive-page">
<div className="page-shell min-w-0 max-w-full space-y-6" data-testid="wecom-approval-archive-page">
<PageHeader
eyebrow="企微审批历史"
title="企微审批历史"
@@ -219,7 +219,7 @@ export function WecomApprovalArchivePage() {
{listQuery.isError ? <div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{resolveError(listQuery.error)}</div> : null}
{!listQuery.isLoading && !listQuery.isError && !items.length ? (
<div className="rounded-md border border-dashed border-slate-300 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500">
暂无已归档的企微审批历史。若公网 OA DB 还没有留存数据,验收记录为 PARTIAL_ARCHIVE_NEEDS_DATA。
暂无已归档的企微审批历史;如数据尚未同步,可稍后重试或联系管理员核对归档状态。
</div>
) : null}
{!listQuery.isLoading && !listQuery.isError && items.length ? (
@@ -245,17 +245,17 @@ export function WecomApprovalArchivePage() {
</div>
<div className="divide-y divide-slate-100 md:hidden">
{items.map((item) => (
<article className="py-3" key={item.id}>
<article className="min-w-0 py-3" key={item.id}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium text-zinc-900">{typeLabel(item.templateType)}</div>
<div className="mt-1 text-xs text-zinc-500">单号前缀:{item.spNoPrefix || "-"}</div>
<div className="mt-1 break-all text-xs text-zinc-500">单号前缀:{item.spNoPrefix || "-"}</div>
</div>
<StatusBadge status={item.normalizedStatus} />
</div>
<div className="mt-2 text-sm text-zinc-600">{employeeLabel(item.applicant)} · {formatTime(item.statusObservedAt || item.completedAt || item.appliedAt)}</div>
<div className="mt-2 break-words text-sm text-zinc-600 [overflow-wrap:anywhere]">{employeeLabel(item.applicant)} · {formatTime(item.statusObservedAt || item.completedAt || item.appliedAt)}</div>
<div className="mt-2 flex items-center justify-between gap-3">
<span className="text-xs text-zinc-500">{sourceLabel(item.lastSource)}{item.openFindingCount ? ` · finding ${item.openFindingCount}` : ""}</span>
<span className="min-w-0 break-words text-xs text-zinc-500 [overflow-wrap:anywhere]">{sourceLabel(item.lastSource)}{item.openFindingCount ? ` · 对账问题 ${item.openFindingCount}` : ""}</span>
<Button type="button" variant="secondary" onClick={() => setSelectedId(item.id)}>查看历史</Button>
</div>
</article>
@@ -273,30 +273,30 @@ export function WecomApprovalArchivePage() {
{selectedId && detailQuery.isLoading ? <TableSkeleton columns={1} rows={3} /> : null}
{selectedId && detailQuery.isError ? <div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{resolveError(detailQuery.error)}</div> : null}
{detail ? (
<div className="grid gap-4 lg:grid-cols-3">
<div className="space-y-3 lg:col-span-1">
<div className="grid min-w-0 max-w-full gap-4 lg:grid-cols-3">
<div className="min-w-0 space-y-3 lg:col-span-1">
<div>
<div className="text-xs text-zinc-500">状态</div>
<div className="mt-1"><StatusBadge status={detail.normalizedStatus} /></div>
</div>
<div>
<div className="text-xs text-zinc-500">申请人</div>
<div className="mt-1 text-sm text-zinc-800">{employeeLabel(detail.applicant)}</div>
<div className="mt-1 break-words text-sm text-zinc-800 [overflow-wrap:anywhere]">{employeeLabel(detail.applicant)}</div>
</div>
<div>
<div className="text-xs text-zinc-500">单号前缀</div>
<div className="mt-1 text-sm text-zinc-800">{detail.spNoPrefix || "-"}</div>
<div className="mt-1 break-all text-sm text-zinc-800">{detail.spNoPrefix || "-"}</div>
</div>
{detail.singleTapApproveRisk ? <div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">企微同意可能单击即通过,OA 仅留存动作来源和历史审计。</div> : null}
</div>
<div className="space-y-4 lg:col-span-2">
<div className="min-w-0 space-y-4 lg:col-span-2">
<div>
<h4 className="text-sm font-semibold text-zinc-900">字段摘要</h4>
<div className="mt-2 divide-y divide-slate-100 rounded-md border border-slate-200">
{detail.fields.length ? detail.fields.map((field) => (
<div className="grid gap-1 px-3 py-2 text-sm md:grid-cols-3" key={field.id}>
<span className="font-medium text-zinc-800">{field.fieldName || field.fieldKey}</span>
<span className="text-zinc-500">{field.fieldType || "-"}</span>
<div className="grid min-w-0 gap-1 px-3 py-2 text-sm md:grid-cols-3" key={field.id}>
<span className="min-w-0 break-words font-medium text-zinc-800 [overflow-wrap:anywhere]">{field.fieldName || field.fieldKey}</span>
<span className="min-w-0 break-words text-zinc-500 [overflow-wrap:anywhere]">{field.fieldType || "-"}</span>
<span className="text-zinc-500">{field.sensitiveLevel === "restricted" ? "受限字段" : "普通字段"}</span>
</div>
)) : <div className="px-3 py-3 text-sm text-zinc-500">暂无字段摘要。</div>}
@@ -309,7 +309,7 @@ export function WecomApprovalArchivePage() {
{detail.participants.length ? detail.participants.map((item) => (
<div className="border-b border-slate-100 px-3 py-2 text-sm last:border-b-0" key={item.id}>
<span className="font-medium text-zinc-800">{roleLabel(item.role)}</span>
<span className="ml-2 text-zinc-600">{employeeLabel(item.employee)}</span>
<span className="ml-2 break-words text-zinc-600 [overflow-wrap:anywhere]">{employeeLabel(item.employee)}</span>
</div>
)) : <div className="px-3 py-3 text-sm text-zinc-500">暂无映射参与人。</div>}
</div>
@@ -317,7 +317,7 @@ export function WecomApprovalArchivePage() {
{detail.actions.length ? detail.actions.map((item) => (
<div className="border-b border-slate-100 px-3 py-2 text-sm last:border-b-0" key={item.id}>
<span className="font-medium text-zinc-800">{actionLabel(item.actionType)}</span>
<span className="ml-2 text-zinc-600">{employeeLabel(item.operator)}</span>
<span className="ml-2 break-words text-zinc-600 [overflow-wrap:anywhere]">{employeeLabel(item.operator)}</span>
<div className="mt-1 text-xs text-zinc-500">{formatTime(item.actionTime)} · {sourceLabel(item.source)}</div>
</div>
)) : <div className="px-3 py-3 text-sm text-zinc-500">暂无动作历史。</div>}
@@ -325,14 +325,14 @@ export function WecomApprovalArchivePage() {
</div>
</div>
<div>
<h4 className="text-sm font-semibold text-zinc-900">对账 finding</h4>
<h4 className="text-sm font-semibold text-zinc-900">对账问题</h4>
<div className="mt-2 divide-y divide-slate-100 rounded-md border border-slate-200">
{detail.findings.length ? detail.findings.map((item) => (
<div className="flex flex-wrap items-center justify-between gap-2 px-3 py-2 text-sm" key={item.id}>
<span className="font-medium text-zinc-800">{item.findingType}</span>
<span className="text-zinc-500">{item.status} · {item.severity} · {formatTime(item.detectedAt)}</span>
<span className="min-w-0 break-words font-medium text-zinc-800 [overflow-wrap:anywhere]">{item.findingType}</span>
<span className="min-w-0 break-words text-zinc-500 [overflow-wrap:anywhere]">{item.status} · {item.severity} · {formatTime(item.detectedAt)}</span>
</div>
)) : <div className="px-3 py-3 text-sm text-zinc-500">暂无对账 finding。</div>}
)) : <div className="px-3 py-3 text-sm text-zinc-500">暂无对账问题。</div>}
</div>
</div>
</div>
@@ -27,14 +27,14 @@ export interface CreateUserDialogProps {
isPending: boolean;
departments: { isLoading: boolean; items: Array<{ id: string; name: string }> };
managers: { isLoading: boolean; items: Array<{ id: string; name: string; employeeNo: string }> };
roleOptions: Array<{ code: string; label: string }>;
roleOptions: Array<{ code: string; label: string; disabled?: boolean }>;
}
const ROLE_PRESETS = [
{ key: "owner", label: "老板", roles: ["ceo"] },
{ key: "admin", label: "管理员", roles: ["admin"] },
{ key: "employee", label: "员工", roles: ["employee"] },
{ key: "manager", label: "主管", roles: ["manager"] },
{ key: "manager", label: "直属经理", roles: ["employee", "manager"] },
{ key: "owner", label: "公司负责人", roles: ["employee", "ceo"] },
{ key: "finance", label: "财务", roles: ["employee", "finance"] },
];
export function CreateUserDialog({
@@ -192,6 +192,7 @@ export function CreateUserDialog({
<input
data-testid={`users-create-role-${item.code}`}
type="checkbox"
disabled={item.disabled}
checked={createRoles.has(item.code)}
onChange={(event) => {
setCreateRoles((prev) => {
@@ -205,7 +206,7 @@ export function CreateUserDialog({
});
}}
/>
<span>{item.label}</span>
<span className={item.disabled ? "text-zinc-400" : undefined}>{item.label}</span>
</label>
))}
</div>
@@ -13,18 +13,25 @@ export interface UserRoleDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user: UserSummary | null;
roleOptions: Array<{ code: string; label: string }>;
roleOptions: Array<{ code: string; label: string; disabled?: boolean }>;
onSubmit: (roles: string[]) => void;
isPending: boolean;
}
const ROLE_PRESETS = [
{ key: "owner", label: "老板", roles: ["ceo"] },
{ key: "admin", label: "管理员", roles: ["admin"] },
{ key: "employee", label: "员工", roles: ["employee"] },
{ key: "manager", label: "主管", roles: ["manager"] },
{ key: "manager", label: "直属经理", roles: ["employee", "manager"] },
{ key: "owner", label: "公司负责人", roles: ["employee", "ceo"] },
{ key: "finance", label: "财务", roles: ["employee", "finance"] },
];
const DAILY_ROLE_CODES = new Set(["employee", "manager", "ceo", "finance"]);
export function mergeDailyRolePreset(currentRoles: Iterable<string>, presetRoles: string[]) {
const preservedRoles = Array.from(currentRoles).filter((roleCode) => !DAILY_ROLE_CODES.has(roleCode));
return new Set([...preservedRoles, ...presetRoles]);
}
export function UserRoleDialog({
open,
onOpenChange,
@@ -47,6 +54,10 @@ export function UserRoleDialog({
onOpenChange(false);
}
function applyPreset(roles: string[]) {
setRoleDraft((current) => mergeDailyRolePreset(current, roles));
}
return (
<Dialog
open={open}
@@ -67,7 +78,7 @@ export function UserRoleDialog({
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{ROLE_PRESETS.map((preset) => (
<Button key={preset.key} size="sm" variant="secondary" type="button" onClick={() => setRoleDraft(new Set(preset.roles))}>
<Button key={preset.key} size="sm" variant="secondary" type="button" onClick={() => applyPreset(preset.roles)}>
预设:{preset.label}
</Button>
))}
@@ -79,6 +90,7 @@ export function UserRoleDialog({
<input
data-testid={`users-role-${item.code}`}
type="checkbox"
disabled={item.disabled}
checked={roleDraft.has(item.code)}
onChange={(event) => {
setRoleDraft((prev) => {
@@ -92,7 +104,7 @@ export function UserRoleDialog({
});
}}
/>
<span>{item.label}</span>
<span className={item.disabled ? "text-zinc-400" : undefined}>{item.label}</span>
</label>
))}
</div>
+33 -12
查看文件
@@ -1,6 +1,8 @@
import { Suspense, type ReactNode } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { createBrowserRouter, createRoutesFromElements, Navigate, Outlet, Route } from "react-router-dom";
import { AuthProvider } from "../auth/AuthContext";
import { RoutePageFallback } from "../components/common/RoutePageFallback";
import { UnsavedChangesProvider } from "../components/common/UnsavedChangesGuard";
import { AppShellLayout } from "../layouts/AppShellLayout";
import { AuthLayout } from "../layouts/AuthLayout";
import { BudgetsPage } from "../pages/BudgetsPage";
@@ -77,9 +79,18 @@ function withRouteFallback(node: ReactNode) {
return <Suspense fallback={<RoutePageFallback />}>{node}</Suspense>;
}
export function AppRouter() {
function RouterRoot() {
return (
<Routes>
<UnsavedChangesProvider>
<AuthProvider>
<Outlet />
</AuthProvider>
</UnsavedChangesProvider>
);
}
const routeElements = (
<Route element={<RouterRoot />}>
<Route
path="/login"
element={
@@ -262,7 +273,7 @@ export function AppRouter() {
<Route
path="payment-batches"
element={
<RequirePermission anyOf={["payment.batch.manage", "risk.review.manage"]}>
<RequirePermission anyOf={["payment.batch.manage", "risk.review.manage", "payment.batch.review"]}>
{withRouteFallback(<PaymentBatchesPage />)}
</RequirePermission>
}
@@ -271,7 +282,7 @@ export function AppRouter() {
<Route
path="ap-operations"
element={
<RequirePermission anyOf={["payment.batch.manage", "risk.review.manage"]}>
<RequirePermission anyOf={["payment.batch.manage", "risk.review.manage", "payment.batch.review"]}>
{withRouteFallback(<ApOperationsPage />)}
</RequirePermission>
}
@@ -326,6 +337,15 @@ export function AppRouter() {
<Route path="mobile" element={withRouteFallback(<MobilePortalPage />)} />
<Route
path="mobile/attendance"
element={
<RequirePermission anyOf={["attendance.view_own", "attendance.view_all", "*"]}>
{withRouteFallback(<AttendancePage />)}
</RequirePermission>
}
/>
<Route path="mobile/me" element={withRouteFallback(<MobileMePage />)} />
<Route path="mobile/profile" element={withRouteFallback(<MobileProfilePage />)} />
@@ -482,7 +502,7 @@ export function AppRouter() {
<Route
path="payroll"
element={
<RequirePermission anyOf={["system.settings"]}>
<RequirePermission anyOf={["payroll.manage"]}>
{withRouteFallback(<PayrollPage />)}
</RequirePermission>
}
@@ -509,7 +529,7 @@ export function AppRouter() {
<Route
path="people"
element={
<RequirePermission anyOf={["system.users", "report.dashboard.view"]}>
<RequirePermission anyOf={["system.users"]}>
{withRouteFallback(<PeoplePage />)}
</RequirePermission>
}
@@ -518,7 +538,7 @@ export function AppRouter() {
<Route
path="people/stats"
element={
<RequirePermission anyOf={["system.users", "report.dashboard.view"]}>
<RequirePermission anyOf={["system.users"]}>
{withRouteFallback(<PeopleStatsPage />)}
</RequirePermission>
}
@@ -529,7 +549,7 @@ export function AppRouter() {
<Route
path="system/users"
element={
<RequirePermission anyOf={["system.users"]}>
<RequirePermission anyOf={["system.accounts.manage", "system.roles.manage"]}>
{withRouteFallback(<UserManagementPage />)}
</RequirePermission>
}
@@ -639,6 +659,7 @@ export function AppRouter() {
<Route path="/403" element={<ForbiddenPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
);
}
</Route>
);
export const appRouter = createBrowserRouter(createRoutesFromElements(routeElements));
+2 -1
查看文件
@@ -85,7 +85,8 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
await (await openNavLink(page, "资产")).click();
await expect(page.getByRole("heading", { name: "资产" })).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("asset-category-code")).toHaveCount(0);
await page.getByRole("button", { name: "资产分类" }).click();
await page.locator(".page-header-shell").getByRole("button", { name: "更多" }).click();
await page.getByRole("menuitem", { name: "资产分类" }).click();
await expect(page.getByTestId("asset-category-manager")).toBeVisible();
await expect(page.getByTestId("asset-category-code")).toBeVisible({ timeout: 30_000 });
@@ -0,0 +1,405 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import { expect, test, type Browser, type Page, type Route } from "@playwright/test";
const SCREENSHOT_DIR = path.resolve(process.cwd(), "../output/playwright/07-18-small-team-internal-readiness");
const WECOM_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
function envelope(data: unknown) {
return { requestId: "mock-internal-readiness-ui", data };
}
async function fulfillJson(route: Route, data: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(envelope(data)),
});
}
function screenshotPath(fileName: string) {
mkdirSync(SCREENSHOT_DIR, { recursive: true });
return path.join(SCREENSHOT_DIR, fileName);
}
function buildMe() {
return {
id: "employee-internal-readiness",
employeeNo: "SYN005",
name: "合成员工",
phone: "13800000000",
email: "[email protected]",
departmentId: "department-internal-readiness",
directManagerId: null,
status: "active",
notificationPushEnabled: true,
roleCodes: ["employee"],
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: false,
hasAvatar: false,
hasIdNumber: false,
score: 40,
},
wecomBinding: null,
};
}
function archiveItem() {
return {
id: "archive-internal-readiness",
spNoPrefix: "SYNTHETIC-ARCHIVE-PREFIX-WITH-A-DELIBERATELY-LONG-VALUE-20260718",
templateIdPrefix: "SYNTHETIC-TEMPLATE",
templateType: "leave",
spName: "合成请假归档记录(用于验证窄屏长文案安全换行)",
normalizedStatus: "approved",
applicant: {
id: "employee-internal-readiness",
employeeNo: "SYN005",
name: "合成长姓名用于验证移动端不会横向裁切",
},
appliedAt: "2026-07-17T01:00:00.000Z",
submittedAt: "2026-07-17T01:05:00.000Z",
completedAt: "2026-07-17T02:00:00.000Z",
statusObservedAt: "2026-07-17T02:00:00.000Z",
lastSource: "callback",
lastCallbackAt: "2026-07-17T02:00:00.000Z",
lastCompensationAt: null,
readabilityStatus: "readable",
singleTapApproveRisk: false,
templateAdjustmentRequired: false,
participantCount: 2,
actionCount: 1,
fieldCount: 2,
findingCount: 0,
openFindingCount: 0,
};
}
function archiveDetail() {
return {
...archiveItem(),
participants: [
{
id: "participant-internal-readiness",
role: "applicant",
nodeIndex: 0,
employee: archiveItem().applicant,
isCurrent: false,
mappingStatus: "matched",
},
],
actions: [
{
id: "action-internal-readiness",
actionType: "approve",
operator: { id: "manager-internal-readiness", employeeNo: "SYN006", name: "合成审批人" },
actionTime: "2026-07-17T02:00:00.000Z",
nodeIndex: 1,
source: "callback",
},
],
fields: [
{
id: "field-internal-readiness-1",
fieldKey: "long_business_field",
fieldIndex: 0,
fieldName: "这是一个用于验证移动归档长字段名称能够安全换行而不会撑破页面的合成字段",
fieldType: "text",
sensitiveLevel: "internal",
displayAllowed: true,
},
{
id: "field-internal-readiness-2",
fieldKey: "restricted_summary",
fieldIndex: 1,
fieldName: "受限资料摘要",
fieldType: "text",
sensitiveLevel: "restricted",
displayAllowed: false,
},
],
findings: [],
};
}
type MockState = {
archiveMode: "empty" | "record";
};
async function mockApis(page: Page, state: MockState) {
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const pathName = new URL(request.url()).pathname;
if (pathName === "/api/auth/capabilities") {
await fulfillJson(route, {
emailDelivery: { ready: false },
emailLoginCode: { enabled: false },
passwordReset: { enabled: false },
});
return;
}
if (pathName === "/api/auth/sso/providers") {
await fulfillJson(route, []);
return;
}
if (pathName === "/api/auth/wecom/config") {
await fulfillJson(route, {
enabled: false,
entryEnabled: false,
oauthEnabled: false,
corpId: "",
agentId: "",
loginUrl: null,
bindEnabled: false,
defaultReturnTo: "/mobile",
status: "disabled",
statusReasons: [],
});
return;
}
if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") {
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
body: JSON.stringify(envelope({ accessToken: "mock-internal-readiness-token", expiresIn: 900, mustChangePassword: false })),
});
return;
}
if (pathName === "/api/me") {
await fulfillJson(route, buildMe());
return;
}
if (pathName === "/api/system/navigation") {
await fulfillJson(route, {
workspace: {
id: "workspace-internal-readiness",
persisted: true,
profile: "small_team_internal",
orgMode: "single_company",
primaryEntryMode: "password",
notificationStrategy: "in_app",
devicePriority: "mobile",
moduleVisibility: {},
createdAt: null,
updatedAt: null,
updatedBy: null,
},
sections: { main: [], admin: [], backup: [] },
recommendedRolePresets: [],
});
return;
}
if (pathName === "/api/notifications") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, unreadOnly: true, unreadCount: 0, items: [] });
return;
}
if (pathName === "/api/portal/home") {
await fulfillJson(route, {
workspace: { id: "workspace-internal-readiness", persisted: true, profile: "small_team_internal" },
greeting: { name: "合成员工" },
cards: [],
pendingTasks: { count: 0, href: "/workflow/tasks" },
myApplications: [],
riskSummary: null,
announcements: [],
quickLinks: [],
todayPresence: null,
teamPresence: null,
weeklyReport: null,
weeklySummary: null,
todoSummary: null,
projectSummary: null,
peopleLifecycle: null,
adoptionSummary: null,
recentActivities: [],
teamStatusBoard: [],
});
return;
}
if (pathName === "/api/attendance/monthly") {
await fulfillJson(route, {
month: "2026-07",
summary: {
employeeCount: 1,
workDays: 1,
checkinDays: 1,
missingDays: 0,
approvedCorrectionCount: 0,
},
employees: [
{
employee: {
id: "employee-internal-readiness",
employeeNo: "SYN005",
name: "合成员工",
title: "工程师",
departmentId: "department-internal-readiness",
departmentName: "研发部",
},
days: [
{
date: "2026-07-17",
status: "normal",
explanation: "",
firstCheckinAt: "2026-07-17T01:00:00.000Z",
lastCheckinAt: "2026-07-17T10:00:00.000Z",
checkins: [
{
id: "checkin-internal-readiness",
checkinAt: "2026-07-17T01:00:00.000Z",
checkinDate: "2026-07-17",
checkinType: "on_duty",
result: "normal",
exceptionType: null,
sourceType: "wecom",
},
],
corrections: [],
leaveRequests: [],
},
],
},
],
});
return;
}
if (pathName === "/api/attendance-corrections") {
await fulfillJson(route, { page: 1, pageSize: 50, total: 0, items: [] });
return;
}
if (pathName === "/api/wecom/approval-archive") {
const items = state.archiveMode === "record" ? [archiveItem()] : [];
await fulfillJson(route, {
scope: "self",
requestedScope: "self",
canViewAll: false,
page: 1,
pageSize: 50,
total: items.length,
status: "OK",
items,
});
return;
}
if (pathName === "/api/wecom/approval-archive/archive-internal-readiness") {
await fulfillJson(route, { item: archiveDetail() });
return;
}
await fulfillJson(route, { message: `unhandled synthetic endpoint: ${pathName}` }, 404);
});
}
async function login(page: Page) {
await page.goto("/login");
const identifier = page.getByTestId("login-identifier");
await expect(identifier).toBeVisible();
await identifier.fill("[email protected]");
await page.getByTestId("login-password").fill("SyntheticPassword1234");
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/(?:portal|mobile)$/);
}
async function newWecomPage(browser: Browser, state: MockState) {
const context = await browser.newContext({
viewport: { width: 375, height: 812 },
isMobile: true,
hasTouch: true,
userAgent: WECOM_UA,
locale: "zh-CN",
serviceWorkers: "block",
});
const page = await context.newPage();
await mockApis(page, state);
await login(page);
return { context, page };
}
async function expectNarrowPageMetrics(page: Page) {
const metrics = await page.evaluate(() => {
const scrollContainer = document.querySelector<HTMLElement>("main.app-shell-main > div.flex-1");
const bottomNav = document.querySelector<HTMLElement>("[data-testid='mobile-bottom-nav']");
const navHeight = bottomNav?.getBoundingClientRect().height ?? 0;
const paddingBottom = scrollContainer ? Number.parseFloat(window.getComputedStyle(scrollContainer).paddingBottom) : 0;
return {
width: window.innerWidth,
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
navHeight,
paddingBottom,
};
});
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.width + 2);
expect(metrics.navHeight).toBeGreaterThan(0);
expect(metrics.paddingBottom).toBeGreaterThan(metrics.navHeight);
}
test.describe("五人团队本地候选移动证据", () => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据,不访问公网");
test("/mobile/attendance 在 375x812 下直达考勤且不再自循环", async ({ browser }) => {
const state: MockState = { archiveMode: "empty" };
const mobile = await newWecomPage(browser, state);
try {
await mobile.page.goto("/mobile/attendance");
await expect(mobile.page).toHaveURL(/\/mobile\/attendance$/);
await expect(mobile.page.getByRole("heading", { level: 1, name: "考勤" })).toBeVisible();
await expect(mobile.page.getByText("企业微信主办", { exact: true }).first()).toBeVisible();
await expect(mobile.page.getByText("合成员工", { exact: true }).first()).toBeVisible();
await expectNarrowPageMetrics(mobile.page);
await mobile.page.screenshot({ path: screenshotPath("mobile-attendance-375x812.png"), fullPage: true });
} finally {
await mobile.context.close();
}
});
test("/mobile/wecom-approval-archive 覆盖空态、有记录、详情和长字段", async ({ browser }) => {
const state: MockState = { archiveMode: "empty" };
const mobile = await newWecomPage(browser, state);
try {
await mobile.page.goto("/mobile/wecom-approval-archive");
await expect(mobile.page).toHaveURL(/\/mobile\/wecom-approval-archive$/);
await expect(mobile.page.getByRole("heading", { level: 1, name: "企微审批历史" })).toBeVisible();
await expect(mobile.page.getByText(/暂无已归档的企微审批历史/)).toBeVisible();
await expectNarrowPageMetrics(mobile.page);
await mobile.page.screenshot({ path: screenshotPath("mobile-archive-empty-375x812.png"), fullPage: true });
state.archiveMode = "record";
await mobile.page.reload();
const mobileRecord = mobile.page.getByRole("article").filter({
hasText: "SYNTHETIC-ARCHIVE-PREFIX-WITH-A-DELIBERATELY-LONG-VALUE",
});
await expect(mobileRecord).toBeVisible();
await mobileRecord.getByRole("button", { name: "查看历史" }).click();
const longFieldName = mobile.page.getByText(
"这是一个用于验证移动归档长字段名称能够安全换行而不会撑破页面的合成字段",
{ exact: true },
);
const restrictedPlaceholder = mobile.page.getByText("受限字段", { exact: true });
await longFieldName.scrollIntoViewIfNeeded();
await expect(longFieldName).toBeVisible();
await restrictedPlaceholder.scrollIntoViewIfNeeded();
await expect(restrictedPlaceholder).toBeVisible();
await expectNarrowPageMetrics(mobile.page);
await mobile.page.screenshot({ path: screenshotPath("mobile-archive-detail-375x812.png"), fullPage: true });
} finally {
await mobile.context.close();
}
});
test("/wecom-approval-archive 桌面路由保持只读历史入口", async ({ page }) => {
const state: MockState = { archiveMode: "record" };
await mockApis(page, state);
await login(page);
await page.setViewportSize({ width: 1024, height: 768 });
await page.goto("/wecom-approval-archive");
await expect(page).toHaveURL(/\/wecom-approval-archive$/);
await expect(page.getByRole("heading", { level: 1, name: "企微审批历史" })).toBeVisible();
await expect(page.getByRole("button", { name: "查看历史" })).toBeVisible();
await expect(page.getByRole("button", { name: /同意|驳回|撤销|终止/ })).toHaveCount(0);
await page.screenshot({ path: screenshotPath("desktop-archive-1024x768.png"), fullPage: true });
});
});
@@ -0,0 +1,538 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { expect, test, type Page, type Route } from "@playwright/test";
test.use({ serviceWorkers: "block" });
export const LOCAL_CANDIDATE_MANIFEST_DRIVEN_WITNESSES = true;
type RoleFixture = "employee" | "manager" | "ceo" | "finance" | "system_admin" | "admin";
type LocalCandidateFlow = {
bundle: string;
witnessRoute: string;
witnessTag: string;
expectedH1: string;
roleFixture: RoleFixture;
primaryAction: {
allowed: string;
forbidden: string;
};
witnessSpec: string;
};
type RouteManifest = {
localCandidateFlows: LocalCandidateFlow[];
};
type SyntheticRole = {
roleCodes: string[];
name: string;
employeeNo: string;
};
type MockState = {
sessionActive: boolean;
loginCalls: number;
logoutCalls: number;
meRequests: number;
unhandledApiCount: number;
};
type BrowserErrorState = {
pageErrorCount: number;
consoleErrorCount: number;
};
const MANIFEST_PATH = path.resolve(process.cwd(), "../scripts/qa/route-manifest.json");
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8")) as RouteManifest;
const localCandidateFlows = manifest.localCandidateFlows;
const ROLE_FIXTURES: Record<RoleFixture, SyntheticRole> = {
employee: { roleCodes: ["employee"], name: "合成员工", employeeNo: "SYN-LC-001" },
manager: { roleCodes: ["employee", "manager"], name: "合成经理", employeeNo: "SYN-LC-002" },
ceo: { roleCodes: ["employee", "ceo"], name: "合成负责人", employeeNo: "SYN-LC-003" },
finance: { roleCodes: ["employee", "finance"], name: "合成财务", employeeNo: "SYN-LC-004" },
system_admin: { roleCodes: ["employee", "system_admin"], name: "合成系统管理员", employeeNo: "SYN-LC-005" },
admin: { roleCodes: ["employee", "admin"], name: "合成应急管理员", employeeNo: "SYN-LC-006" },
};
const CONSOLE_ERROR_ALLOWLIST = [/^ResizeObserver loop/];
function envelope(data: unknown) {
return { requestId: "synthetic-local-candidate", data };
}
async function fulfillJson(route: Route, data: unknown, status = 200, headers: Record<string, string> = {}) {
await route.fulfill({
status,
contentType: "application/json",
headers,
body: JSON.stringify(envelope(data)),
});
}
async function fulfillAuthError(route: Route) {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({
requestId: "synthetic-local-candidate",
error: { code: "AUTH_001", message: "会话已失效" },
}),
});
}
function buildMe(role: SyntheticRole) {
return {
id: `synthetic-${role.employeeNo.toLowerCase()}`,
employeeNo: role.employeeNo,
name: role.name,
phone: "00000000000",
email: `${role.employeeNo.toLowerCase()}@example.test`,
departmentId: "synthetic-department",
directManagerId: null,
status: "active",
notificationPushEnabled: true,
roleCodes: role.roleCodes,
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: false,
hasAvatar: false,
hasIdNumber: false,
score: 40,
},
wecomBinding: null,
};
}
function buildPortalHome(role: SyntheticRole) {
return {
workspace: { id: "synthetic-workspace", persisted: true, profile: "small_team_internal" },
greeting: { name: role.name },
cards: [],
pendingTasks: { count: 0, href: "/workflow/tasks" },
myApplications: [],
riskSummary: null,
announcements: [],
quickLinks: [],
todayPresence: null,
teamPresence: null,
weeklyReport: null,
weeklySummary: null,
todoSummary: null,
projectSummary: null,
peopleLifecycle: null,
financeSummary: null,
adoptionSummary: null,
recentActivities: [],
teamStatusBoard: [],
};
}
function buildWorkflowTask(role: SyntheticRole) {
return {
id: "synthetic-workflow-task",
instanceId: "synthetic-workflow-instance",
stepId: "manager-review",
stepName: "经理复核",
assigneeId: `synthetic-${role.employeeNo.toLowerCase()}`,
assigneeRole: "manager",
status: "pending",
outcome: null,
createdAt: "2026-07-18T08:00:00.000Z",
completedAt: null,
workflowInstance: {
entityType: "reimbursement",
entityId: "synthetic-reimbursement",
currentStatus: "reviewing",
},
businessSummary: {
reference: "SYN-RB-001",
title: "合成报销复核",
applicantName: "合成申请人",
amount: "100.00",
duration: null,
businessStatus: "reviewing",
},
allowedActions: ["approve", "reject", "return"],
};
}
function buildAttendanceMonthly(role: SyntheticRole) {
return {
month: "2026-07",
summary: {
employeeCount: 1,
workDays: 1,
checkinDays: 1,
missingDays: 0,
approvedCorrectionCount: 0,
},
employees: [
{
employee: {
id: `synthetic-${role.employeeNo.toLowerCase()}`,
employeeNo: role.employeeNo,
name: role.name,
title: "合成岗位",
departmentId: "synthetic-department",
departmentName: "合成部门",
},
days: [
{
date: "2026-07-18",
status: "normal",
explanation: "",
firstCheckinAt: "2026-07-18T01:00:00.000Z",
lastCheckinAt: "2026-07-18T10:00:00.000Z",
checkins: [],
corrections: [],
leaveRequests: [],
},
],
},
],
};
}
async function installSyntheticApis(page: Page, roleFixture: RoleFixture) {
const role = ROLE_FIXTURES[roleFixture];
const state: MockState = {
sessionActive: false,
loginCalls: 0,
logoutCalls: 0,
meRequests: 0,
unhandledApiCount: 0,
};
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const url = new URL(request.url());
const pathName = url.pathname;
if (pathName === "/api/auth/capabilities") {
await fulfillJson(route, {
emailDelivery: { ready: false, statusReasons: ["synthetic_disabled"] },
emailLoginCode: { enabled: false },
passwordReset: { enabled: false },
});
return;
}
if (pathName === "/api/auth/sso/providers") {
await fulfillJson(route, []);
return;
}
if (pathName === "/api/auth/wecom/config") {
await fulfillJson(route, {
enabled: false,
entryEnabled: false,
oauthEnabled: false,
corpId: "",
agentId: "",
loginUrl: null,
bindEnabled: false,
defaultReturnTo: "/portal",
status: "disabled",
statusReasons: [],
});
return;
}
if (pathName === "/api/auth/login") {
state.sessionActive = true;
state.loginCalls += 1;
await fulfillJson(
route,
{ accessToken: "synthetic-local-candidate-token", expiresIn: 900, mustChangePassword: false },
200,
{ "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
);
return;
}
if (pathName === "/api/auth/refresh") {
if (!state.sessionActive) {
await fulfillAuthError(route);
return;
}
await fulfillJson(route, { accessToken: "synthetic-local-candidate-token", expiresIn: 900 });
return;
}
if (pathName === "/api/auth/logout") {
state.sessionActive = false;
state.logoutCalls += 1;
await fulfillJson(route, {}, 200, { "set-cookie": "has_session=; Path=/; Max-Age=0" });
return;
}
if (pathName === "/api/me") {
state.meRequests += 1;
await fulfillJson(route, buildMe(role));
return;
}
if (pathName === "/api/system/navigation") {
await fulfillJson(route, {
workspace: {
id: "synthetic-workspace",
persisted: true,
profile: "small_team_internal",
orgMode: "single_company",
primaryEntryMode: "password",
notificationStrategy: "in_app",
devicePriority: "desktop",
moduleVisibility: {},
createdAt: null,
updatedAt: null,
updatedBy: null,
},
sections: { main: [], admin: [], backup: [] },
recommendedRolePresets: [],
});
return;
}
if (pathName === "/api/notifications") {
await fulfillJson(route, {
page: 1,
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
total: 0,
unreadOnly: url.searchParams.get("unreadOnly") === "1",
unreadCount: 0,
items: [],
});
return;
}
if (pathName === "/api/portal/home") {
await fulfillJson(route, buildPortalHome(role));
return;
}
if (pathName === "/api/reimbursement-templates") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
if (pathName === "/api/reimbursements/linkable-travel-requests") {
await fulfillJson(route, { ownerEmployeeId: `synthetic-${role.employeeNo.toLowerCase()}`, items: [] });
return;
}
if (pathName === "/api/reimbursements") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, scope: "own", items: [] });
return;
}
if (pathName === "/api/workflow/tasks/summary") {
await fulfillJson(route, {
scope: "mine",
pendingTotal: 1,
overdueTotal: 0,
earliestPendingAt: "2026-07-18T08:00:00.000Z",
averagePendingAgeHours: 1,
byEntityType: [],
byStep: [],
byAssigneeRole: [],
});
return;
}
if (pathName === "/api/workflow/tasks") {
const items = url.searchParams.get("status") === "completed" ? [] : [buildWorkflowTask(role)];
await fulfillJson(route, { page: 1, pageSize: 20, total: items.length, items });
return;
}
if (pathName === "/api/invoice-pool") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, ownerScope: "own", items: [] });
return;
}
if (pathName === "/api/master-data/suppliers") {
await fulfillJson(route, { includeArchived: false, items: [] });
return;
}
if (pathName === "/api/master-data/legal-entities") {
await fulfillJson(route, { includeArchived: false, items: [] });
return;
}
if (pathName === "/api/master-data/cost-centers") {
await fulfillJson(route, { includeArchived: false, items: [] });
return;
}
if (pathName === "/api/master-data/document-categories") {
await fulfillJson(route, { includeArchived: false, items: [] });
return;
}
if (pathName === "/api/purchase-requests") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, mine: true, items: [] });
return;
}
if (pathName === "/api/projects") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
if (pathName === "/api/contracts-registry/expiring") {
await fulfillJson(route, { days: Number(url.searchParams.get("days") ?? 30), total: 0, items: [] });
return;
}
if (pathName === "/api/contracts-registry") {
await fulfillJson(route, { items: [] });
return;
}
if (pathName === "/api/payment-batches") {
await fulfillJson(route, { items: [] });
return;
}
if (pathName === "/api/users") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
if (pathName === "/api/departments") {
await fulfillJson(route, { includeArchived: false, items: [], tree: [] });
return;
}
if (pathName === "/api/employees") {
await fulfillJson(route, { page: 1, pageSize: 200, total: 0, items: [] });
return;
}
if (pathName === "/api/attendance/monthly") {
await fulfillJson(route, buildAttendanceMonthly(role));
return;
}
if (pathName === "/api/attendance-corrections") {
await fulfillJson(route, { page: 1, pageSize: 50, total: 0, items: [] });
return;
}
state.unhandledApiCount += 1;
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({
requestId: "synthetic-local-candidate",
error: { code: "NOT_FOUND", message: "合成接口未登记" },
}),
});
});
return { role, state };
}
function installBrowserErrorMonitor(page: Page): BrowserErrorState {
const state: BrowserErrorState = { pageErrorCount: 0, consoleErrorCount: 0 };
page.on("pageerror", () => {
state.pageErrorCount += 1;
});
page.on("console", (message) => {
if (message.type() !== "error") return;
if (CONSOLE_ERROR_ALLOWLIST.some((pattern) => pattern.test(message.text()))) return;
state.consoleErrorCount += 1;
});
return state;
}
async function loginToWitness(page: Page, witnessRoute: string) {
await page.goto(`/login?redirect=${encodeURIComponent(witnessRoute)}`);
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("SyntheticOnly1!");
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL((url) => url.pathname === witnessRoute);
}
async function expectUniqueH1(page: Page, expectedH1: string) {
const heading = page.getByRole("heading", { level: 1, name: expectedH1, exact: true });
await expect(heading).toHaveCount(1);
await expect(heading).toBeVisible();
}
async function expectSyntheticRole(page: Page, role: SyntheticRole, state: MockState, mobile: boolean) {
expect(state.meRequests).toBeGreaterThan(0);
if (!mobile) {
await expect(page.getByText(`${role.name} · ${role.employeeNo}`, { exact: true }).first()).toBeVisible();
}
}
async function expectNamedControlVisible(page: Page, name: string) {
const candidates = [
page.getByRole("button", { name, exact: true }),
page.getByRole("link", { name, exact: true }),
page.getByRole("tab", { name, exact: true }),
page.getByRole("menuitem", { name, exact: true }),
page.getByText(name, { exact: true }),
];
await expect.poll(async () => {
for (const candidate of candidates) {
for (let index = 0; index < await candidate.count(); index += 1) {
if (await candidate.nth(index).isVisible()) return true;
}
}
return false;
}).toBe(true);
}
async function expectPrimaryActionContract(page: Page, flow: LocalCandidateFlow) {
await expectNamedControlVisible(page, flow.primaryAction.allowed);
await expect(page.getByText(flow.primaryAction.forbidden, { exact: true })).toHaveCount(0);
}
async function expectSafeBrowserState(errorState: BrowserErrorState, state: MockState) {
expect(state.unhandledApiCount, "unhandled synthetic API count").toBe(0);
expect(errorState.pageErrorCount, "pageerror count").toBe(0);
expect(errorState.consoleErrorCount, "non-allowlisted console error count").toBe(0);
}
async function runLoginLogoutWitness(
page: Page,
flow: LocalCandidateFlow,
role: SyntheticRole,
state: MockState,
) {
await page.goto(flow.witnessRoute);
await expect(page).toHaveURL((url) => url.pathname === flow.witnessRoute);
await expectUniqueH1(page, flow.expectedH1);
await expectPrimaryActionContract(page, flow);
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("SyntheticOnly1!");
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL((url) => url.pathname === "/portal");
await expectSyntheticRole(page, role, state, false);
await page.getByTestId("topbar-profile-trigger").click();
await page.getByRole("menuitem", { name: "退出登录", exact: true }).click();
await expect(page).toHaveURL((url) => url.pathname === "/login");
await expectUniqueH1(page, flow.expectedH1);
expect(state.loginCalls).toBe(1);
expect(state.logoutCalls).toBe(1);
expect(state.sessionActive).toBe(false);
expect((await page.context().cookies()).some((cookie) => cookie.name === "has_session" && cookie.value)).toBe(false);
await expect.poll(() => page.evaluate(() => ({
sessionHint: document.cookie.includes("has_session=1"),
offlineSnapshot: window.localStorage.getItem("hyb-oa.auth_snapshot"),
mustChangePassword: window.localStorage.getItem("hyb-oa.must_change_password"),
}))).toEqual({ sessionHint: false, offlineSnapshot: null, mustChangePassword: null });
const meRequestsAfterLogout = state.meRequests;
await page.reload();
await expect(page).toHaveURL((url) => url.pathname === "/login");
await expectUniqueH1(page, flow.expectedH1);
expect(state.meRequests).toBe(meRequestsAfterLogout);
}
test.describe("五人团队本地候选可信 witness", () => {
for (const flow of localCandidateFlows) {
test(flow.witnessTag, async ({ page }) => {
expect(process.env.E2E_MODE).not.toBe("live");
const mobile = flow.witnessRoute.startsWith("/mobile");
if (mobile) {
await page.setViewportSize({ width: 375, height: 812 });
}
const errorState = installBrowserErrorMonitor(page);
const { role, state } = await installSyntheticApis(page, flow.roleFixture);
if (flow.bundle === "login-logout") {
await runLoginLogoutWitness(page, flow, role, state);
} else {
await loginToWitness(page, flow.witnessRoute);
await expect(page).toHaveURL((url) => url.pathname === flow.witnessRoute);
await expectUniqueH1(page, flow.expectedH1);
await expectSyntheticRole(page, role, state, mobile);
await expectPrimaryActionContract(page, flow);
}
await expectSafeBrowserState(errorState, state);
});
}
});
+9 -5
查看文件
@@ -1,5 +1,5 @@
import { test, expect } from "./helpers/fixtures";
import { ADMIN, EMPLOYEE, FINANCE, FINANCE_MGR, MANAGER, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
import { ADMIN, EMPLOYEE, FINANCE, MANAGER, apiGetData, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
import { ensureFeatureFlagOn } from "./helpers/featureFlags";
import { openNavLink } from "./helpers/navigation";
import { TEST_IDS } from "./helpers/testIds";
@@ -108,6 +108,8 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
await expect(page.getByTestId(TEST_IDS.reimbursement.pay)).toHaveCount(0);
const paymentDate = new Date().toISOString().slice(0, 10);
const reimbursement = await apiGetData<{ formNumber: string | null }>(page.request, `/api/reimbursements/${reimbursementId}`);
const reimbursementDisplayCode = reimbursement.formNumber ?? reimbursementId;
const batchName = `E2E-报销付款批次-${Date.now()}`;
await page.goto("/payment-batches");
await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0);
@@ -118,9 +120,11 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok();
});
await createCard.getByTestId("payment-batch-create-entity-type").selectOption("reimbursement");
await createCard.getByTestId("payment-batch-create-name").fill(batchName);
await createCard.getByTestId("payment-batch-create-entity-ids").fill(reimbursementId);
await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementDisplayCode);
const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first();
await expect(candidateRow).toBeVisible();
await candidateRow.getByRole("checkbox").check();
await createCard.getByTestId("payment-batch-create-payment-date").fill(paymentDate);
await createCard.getByTestId("payment-batch-create-payment-channel").selectOption("bank");
await createCard.getByTestId("payment-batch-create-voucher-prefix").fill("E2EPB");
@@ -132,7 +136,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
}
await logout(page, FINANCE.name);
await login(page, FINANCE_MGR);
await login(page, MANAGER);
await page.goto("/ap-operations");
await page.getByRole("tab", { name: /待付款/ }).click();
const pendingRow = page.locator("tr").filter({ hasText: batchName }).first();
@@ -143,7 +147,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
await page.getByRole("button", { name: "提交复核" }).click();
await expect(pendingRow).toContainText("复核 1/1");
await logout(page, FINANCE_MGR.name);
await logout(page, MANAGER.name);
await login(page, FINANCE);
await page.goto(`/payment-batches?id=${batchId}`);
const executedBatchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName });
@@ -162,7 +162,8 @@ test("ASSET1D 公网资产试录:FA/EA-SYS/EA-REPO/EA-BKP", async ({ page }) =
await login(page, admin);
await page.goto("/assets");
await page.getByRole("button", { name: "资产分类" }).click();
await page.locator(".page-header-shell").getByRole("button", { name: "更多" }).click();
await page.getByRole("menuitem", { name: "资产分类" }).click();
await expect(page.getByTestId("asset-category-code")).toBeVisible({ timeout: 30_000 });
const category = await createTrialCategory(page, suffix);
@@ -1174,16 +1174,19 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
}
await page.getByRole("button", { name: "新建批次" }).click();
const createCard = page.getByTestId("payment-batch-create-panel");
await expect(createCard.getByRole("button", { name: "创建批次" })).toBeEnabled();
await expect(createCard.getByRole("button", { name: "创建批次" })).toBeDisabled();
const createBatchResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/payment-batches";
});
await createCard.locator("select").nth(0).selectOption("reimbursement");
await createCard.getByPlaceholder("例如:2026Q2-财务付款批次-01").fill(batchName);
await createCard.getByPlaceholder("输入报销或差旅待付款单据 ID,多个用逗号或换行分隔").fill(state.reimbursementId);
const reimbursementCode = state.reimbursementCode ?? state.reimbursementId;
await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementCode);
const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementCode }).first();
await expect(candidateRow).toBeVisible();
await candidateRow.getByRole("checkbox").check();
await createCard.locator('input[type="date"]').fill(new Date().toISOString().slice(0, 10));
await createCard.locator("select").nth(1).selectOption("bank");
await createCard.locator("select").nth(0).selectOption("bank");
await createCard.getByPlaceholder("例如 PBX").fill(`${trialrunSafePrefix}-${marker.slice(-6)}`);
await createCard.getByRole("button", { name: "创建批次" }).click();
const createBatchResponse = await createBatchResponsePromise;
@@ -0,0 +1,430 @@
import { expect, test, type Page, type Route } from "@playwright/test";
test.use({ serviceWorkers: "block" });
type SyntheticActor = {
id: string;
employeeNo: string;
name: string;
email: string;
roleCodes: string[];
};
type SyntheticContract = {
id: string;
contractNo: string;
projectId: string | null;
contractType: "purchase" | "sales" | "service" | "framework" | "other";
counterpartyName: string;
summary: string;
amount: string | null;
currency: string;
signedDate: string | null;
effectiveDate: string | null;
expiryDate: string | null;
confidentialityLevel: "public" | "internal" | "confidential" | "restricted";
archiveLocation: string;
offlineArchiveNo: string | null;
ownerEmployeeId: string;
status: "draft" | "active" | "expired" | "archived";
persistedStatus: "draft" | "active" | "archived";
createdAt: string | null;
updatedAt: string | null;
owner: { id: string; employeeNo: string; name: string } | null;
project: { id: string; projectNo: string; name: string } | null;
};
type MockScenario = {
paymentCandidates?: Array<{
entityId: string;
entityType: "reimbursement";
businessNo: string;
employee: { id: string; employeeNo: string; name: string };
amount: string;
currencyCode: "CNY";
businessDate: string | null;
summary: string | null;
}>;
contracts?: SyntheticContract[];
onCreatePaymentBatch?: (payload: unknown) => void;
};
const REVIEWER_ACTOR: SyntheticActor = {
id: "employee-payment-reviewer",
employeeNo: "SYN-REVIEWER",
name: "合成付款复核人",
email: "[email protected]",
roleCodes: ["manager"],
};
const FINANCE_ACTOR: SyntheticActor = {
id: "employee-payment-finance",
employeeNo: "SYN-FINANCE",
name: "合成财务人员",
email: "[email protected]",
roleCodes: ["finance"],
};
const CONTRACT_READER_ACTOR: SyntheticActor = {
id: "employee-contract-reader",
employeeNo: "SYN-CONTRACT-READER",
name: "合成合同只读人员",
email: "[email protected]",
roleCodes: ["employee"],
};
const NO_INVOICE_UPLOAD_ACTOR: SyntheticActor = {
id: "employee-no-invoice-upload",
employeeNo: "SYN-NO-INVOICE-UPLOAD",
name: "合成无票据上传人员",
email: "[email protected]",
roleCodes: ["hr"],
};
const PAYMENT_CANDIDATE_ENTITY_ID = "11111111-1111-4111-8111-111111111111";
const CREATED_PAYMENT_BATCH_ID = "22222222-2222-4222-8222-222222222222";
const PAYMENT_CANDIDATE = {
entityId: PAYMENT_CANDIDATE_ENTITY_ID,
entityType: "reimbursement" as const,
businessNo: "BX-SYN-20260718-01",
employee: {
id: "employee-payment-candidate",
employeeNo: "SYN-PAYEE",
name: "合成付款员工",
},
amount: "1280.50",
currencyCode: "CNY" as const,
businessDate: "2026-07-18",
summary: "合成已审批报销",
};
const CONTRACT_ITEM: SyntheticContract = {
id: "contract-synthetic-readonly",
contractNo: "HT-SYN-READ-001",
projectId: "project-synthetic-contract",
contractType: "service",
counterpartyName: "合成合作方",
summary: "合成只读合同",
amount: "8000.00",
currency: "CNY",
signedDate: "2026-07-01",
effectiveDate: "2026-07-01",
expiryDate: "2027-06-30",
confidentialityLevel: "internal",
archiveLocation: "受控线下归档",
offlineArchiveNo: "SYN-ARCHIVE-001",
ownerEmployeeId: CONTRACT_READER_ACTOR.id,
status: "active",
persistedStatus: "active",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
owner: {
id: CONTRACT_READER_ACTOR.id,
employeeNo: CONTRACT_READER_ACTOR.employeeNo,
name: CONTRACT_READER_ACTOR.name,
},
project: {
id: "project-synthetic-contract",
projectNo: "XM-SYN-001",
name: "合成合同项目",
},
};
function envelope(data: unknown) {
return { requestId: "mock-payment-reviewer-permissions", data };
}
async function fulfillJson(route: Route, data: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(envelope(data)),
});
}
function capturePageErrors(page: Page) {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
return errors;
}
function expectNoPageErrors(errors: string[]) {
expect(errors, "页面不应产生未捕获异常").toEqual([]);
}
async function mockSyntheticApis(page: Page, actor: SyntheticActor, scenario: MockScenario = {}) {
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const pathName = new URL(request.url()).pathname;
if (pathName === "/api/auth/capabilities") {
await fulfillJson(route, {
emailDelivery: { ready: false },
emailLoginCode: { enabled: false },
passwordReset: { enabled: false },
});
return;
}
if (pathName === "/api/auth/sso/providers") {
await fulfillJson(route, []);
return;
}
if (pathName === "/api/auth/wecom/config") {
await fulfillJson(route, {
enabled: false,
entryEnabled: false,
oauthEnabled: false,
corpId: "",
agentId: "",
loginUrl: null,
bindEnabled: false,
defaultReturnTo: "/portal",
status: "disabled",
statusReasons: [],
});
return;
}
if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") {
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
body: JSON.stringify(envelope({
accessToken: `mock-token-${actor.employeeNo}`,
expiresIn: 900,
mustChangePassword: false,
})),
});
return;
}
if (pathName === "/api/me") {
await fulfillJson(route, {
...actor,
phone: null,
departmentId: `department-${actor.employeeNo}`,
directManagerId: null,
status: "active",
notificationPushEnabled: true,
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: false,
hasAvatar: false,
hasIdNumber: false,
score: 40,
},
wecomBinding: null,
});
return;
}
if (pathName === "/api/system/navigation") {
await fulfillJson(route, {
workspace: {
id: `workspace-${actor.employeeNo}`,
persisted: true,
profile: "small_team_internal",
orgMode: "single_company",
primaryEntryMode: "password",
notificationStrategy: "in_app",
devicePriority: "desktop",
moduleVisibility: {},
createdAt: null,
updatedAt: null,
updatedBy: null,
},
sections: { main: [], admin: [], backup: [] },
recommendedRolePresets: [],
});
return;
}
if (pathName === "/api/notifications") {
await fulfillJson(route, {
page: 1,
pageSize: 20,
total: 0,
unreadOnly: false,
unreadCount: 0,
items: [],
});
return;
}
if (pathName === "/api/portal/home") {
await fulfillJson(route, {
workspace: { id: `workspace-${actor.employeeNo}`, persisted: true, profile: "small_team_internal" },
greeting: { name: actor.name },
cards: [],
pendingTasks: { count: 0, href: "/workflow/tasks" },
myApplications: [],
riskSummary: null,
announcements: [],
quickLinks: [],
recentActivities: [],
teamStatusBoard: [],
});
return;
}
if (pathName === "/api/payment-batches/candidates") {
const items = scenario.paymentCandidates ?? [];
await fulfillJson(route, { items, page: 1, pageSize: 50, total: items.length });
return;
}
if (pathName === "/api/payment-batches" && request.method() === "POST") {
scenario.onCreatePaymentBatch?.(request.postDataJSON());
await fulfillJson(route, { item: { id: CREATED_PAYMENT_BATCH_ID } });
return;
}
if (pathName === "/api/payment-batches") {
await fulfillJson(route, { items: [] });
return;
}
if (pathName === "/api/risk-flags") {
await fulfillJson(route, { items: [] });
return;
}
if (pathName === "/api/ap-operations") {
await fulfillJson(route, {
summary: {
pendingPaymentCount: 0,
partialMatchCount: 0,
exceptionCount: 0,
budgetOverspendCount: 0,
},
pendingPaymentBatches: [],
failedPaymentBatchItems: [],
exceptionRiskFlags: [],
budgetOverspendRisks: [],
partialMatchItems: [],
});
return;
}
if (pathName === "/api/ap-operations/reimbursement-ledger") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
if (pathName === "/api/contracts-registry/expiring") {
await fulfillJson(route, { days: 30, total: 0, items: [] });
return;
}
if (pathName === "/api/contracts-registry") {
await fulfillJson(route, { items: scenario.contracts ?? [] });
return;
}
if (pathName === "/api/projects") {
await fulfillJson(route, { items: [] });
return;
}
await fulfillJson(route, { message: "unhandled synthetic endpoint" }, 404);
});
}
async function login(page: Page, actor: SyntheticActor) {
await page.goto("/login");
await page.getByTestId("login-identifier").fill(actor.email);
await page.getByTestId("login-password").fill("SyntheticPassword1234");
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/(?:portal|mobile)$/);
}
test("只有付款复核职责的经理可看应付账款但看不到导出", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
await mockSyntheticApis(page, REVIEWER_ACTOR);
await login(page, REVIEWER_ACTOR);
await page.goto("/ap-operations");
await expect(page).toHaveURL(/\/ap-operations$/);
await expect(page.getByRole("heading", { level: 1, name: "应付账款", exact: true })).toBeVisible();
await expect(page.getByTestId("ap-export")).toHaveCount(0);
expectNoPageErrors(pageErrors);
});
test("只有付款复核职责的经理看不到新建批次", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
await mockSyntheticApis(page, REVIEWER_ACTOR);
await login(page, REVIEWER_ACTOR);
await page.goto("/payment-batches");
await expect(page).toHaveURL(/\/payment-batches$/);
await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "新建批次", exact: true })).toHaveCount(0);
expectNoPageErrors(pageErrors);
});
test("合同只读人员看不到新增编辑续约和归档动作", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
await mockSyntheticApis(page, CONTRACT_READER_ACTOR, { contracts: [CONTRACT_ITEM] });
await login(page, CONTRACT_READER_ACTOR);
await page.goto("/contracts-registry");
await expect(page).toHaveURL(/\/contracts-registry$/);
await expect(page.getByRole("heading", { level: 1, name: "合同", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "新增合同", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "编辑", exact: true })).toHaveCount(0);
const contractRow = page
.getByTestId("contracts-registry-table")
.getByRole("row")
.filter({ hasText: CONTRACT_ITEM.contractNo });
await expect(contractRow).toBeVisible();
await contractRow.getByRole("button", { name: "更多", exact: true }).click();
await expect(page.getByRole("menuitem", { name: "查看项目", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "续约", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "归档", exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/contracts-registry$/);
expectNoPageErrors(pageErrors);
});
test("无票据上传权限的兼容角色直达票据页时失败关闭", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
await mockSyntheticApis(page, NO_INVOICE_UPLOAD_ACTOR);
await login(page, NO_INVOICE_UPLOAD_ACTOR);
await page.goto("/invoice-pool");
await expect(page).toHaveURL(/\/403$/);
await expect(page.getByRole("heading", { level: 1, name: "无权限访问", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "上传票据", exact: true })).toHaveCount(0);
expectNoPageErrors(pageErrors);
});
test("财务从业务候选选择报销并使用所选实体创建付款批次", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
let createPayload: unknown = null;
await mockSyntheticApis(page, FINANCE_ACTOR, {
paymentCandidates: [PAYMENT_CANDIDATE],
onCreatePaymentBatch: (payload) => {
createPayload = payload;
},
});
await login(page, FINANCE_ACTOR);
await page.goto("/payment-batches");
await expect(page).toHaveURL(/\/payment-batches$/);
await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible();
await page.getByRole("button", { name: "新建批次", exact: true }).click();
const candidateRow = page.getByTestId("payment-batch-candidate-row");
await expect(candidateRow).toContainText(PAYMENT_CANDIDATE.businessNo);
await expect(candidateRow).toContainText(PAYMENT_CANDIDATE.employee.name);
await expect(candidateRow).toContainText(`${PAYMENT_CANDIDATE.amount} CNY`);
await expect(page.getByText(PAYMENT_CANDIDATE_ENTITY_ID, { exact: true })).toHaveCount(0);
await expect(page.locator('input[placeholder*="UUID"], input[placeholder*="uuid"], input[placeholder*="entityId"], input[placeholder*="内部 ID"], input[placeholder*="内部ID"]')).toHaveCount(0);
await page.getByTestId("payment-batch-create-name").fill("合成付款批次");
await candidateRow.getByRole("checkbox").check();
await page.getByRole("button", { name: "创建批次", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/payment-batches\\?id=${CREATED_PAYMENT_BATCH_ID}$`));
await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible();
await expect(page.getByText("付款批次已创建", { exact: true })).toBeVisible();
expect(createPayload).toEqual({
entityType: "reimbursement",
name: "合成付款批次",
entityIds: [PAYMENT_CANDIDATE_ENTITY_ID],
});
expectNoPageErrors(pageErrors);
});
@@ -7,6 +7,10 @@ import { expect, test } from "./helpers/fixtures";
test.use({ serviceWorkers: "block" });
const outputDir = path.resolve(process.cwd(), "../output/playwright/pc-detail-mobile-visual-wave3");
const taskOutputDir = path.resolve(
process.cwd(),
"../output/playwright/07-18-small-team-internal-readiness",
);
const visualUser = {
identifier: "[email protected]",
@@ -400,6 +404,18 @@ const contractRegistryItem = {
project: { id: projectDetail.id, projectNo: projectDetail.projectNo, name: projectDetail.name },
};
const secondContractRegistryItem = {
...contractRegistryItem,
id: "contract-wave3-second",
contractNo: "HT-2026-W3-02",
counterpartyName: "上海合成技术服务有限公司",
summary: "用于验证合同记录切换保护的第二条合成台账",
amount: "18000.00",
expiryDate: "2026-07-20",
archiveLocation: "离线档案柜 A-02",
offlineArchiveNo: "OFF-2026-W3-02",
};
const purchaseDetail = {
id: "purchase-wave3",
requestNo: "CG20260527001",
@@ -510,6 +526,11 @@ function screenshotPath(fileName: string) {
return path.join(outputDir, fileName);
}
function taskScreenshotPath(fileName: string) {
mkdirSync(taskOutputDir, { recursive: true });
return path.join(taskOutputDir, fileName);
}
function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") {
return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null };
}
@@ -901,7 +922,7 @@ async function mockWave3Apis(page: Page) {
}
if (pathName === "/api/contracts-registry") {
await fulfillJson(route, { items: [contractRegistryItem] });
await fulfillJson(route, { items: [contractRegistryItem, secondContractRegistryItem] });
return;
}
@@ -1055,6 +1076,7 @@ test.beforeEach(async ({ page }) => {
});
test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
test.setTimeout(300_000);
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/reimbursements/reim-wave3");
@@ -1099,7 +1121,9 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.goto("/contracts-registry?projectId=project-wave3");
await expect(page.getByRole("heading", { name: "合同", exact: true })).toBeVisible();
await expect(page.getByTestId("contracts-registry-table").getByTestId("contracts-registry-row")).toBeVisible();
const contractRows = page.getByTestId("contracts-registry-table").getByTestId("contracts-registry-row");
await expect(contractRows).toHaveCount(2);
await expect(contractRows.first()).toBeVisible();
await page.locator(".ops-command-bar__actions").getByRole("button", { name: "更多" }).click();
await expect(page.getByRole("menuitem", { name: "导出 CSV" })).toBeVisible();
await page.keyboard.press("Escape");
@@ -1108,7 +1132,7 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.getByRole("button", { name: "新增合同" }).click();
await expect(page.getByTestId("contracts-editor-panel")).toBeVisible();
await page.getByRole("button", { name: "关闭" }).click();
await expect(page.getByTestId("contracts-registry-table").getByTestId("contracts-registry-row")).toBeVisible();
await expect(contractRows).toHaveCount(2);
await page.getByTestId("contracts-registry-table").getByRole("button", { name: "更多" }).first().click();
await page.getByRole("menuitem", { name: "归档" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认归档");
@@ -1117,6 +1141,59 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.screenshot({ path: screenshotPath("04-pc-contracts-registry.png"), fullPage: true });
});
test("/contracts-registry 在 768x1024 与 1024x768 下操作列可达", async ({ page }) => {
for (const viewport of [
{ width: 768, height: 1024 },
{ width: 1024, height: 768 },
]) {
await page.setViewportSize(viewport);
await page.goto("/contracts-registry");
await expect(page.getByRole("heading", { name: "合同", exact: true })).toBeVisible();
const table = page.getByTestId("contracts-registry-table");
await expect(table).toBeVisible();
const scrollRegion = table.locator("xpath=ancestor::*[@data-testid='business-table-scroll']").first();
const metrics = await scrollRegion.evaluate((element) => {
element.scrollLeft = element.scrollWidth;
return {
overflowX: window.getComputedStyle(element).overflowX,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
};
});
expect(["auto", "scroll"]).toContain(metrics.overflowX);
expect(metrics.scrollWidth).toBeGreaterThanOrEqual(metrics.clientWidth);
expect(metrics.documentOverflow).toBeLessThanOrEqual(2);
const operation = table.getByRole("button", { name: "更多" }).first();
await expect(operation).toBeVisible();
await operation.click({ trial: true });
await page.screenshot({
path: taskScreenshotPath(`contracts-registry-${viewport.width}x${viewport.height}.png`),
fullPage: true,
});
}
});
test("合同长表单从记录 A 切换到记录 B 时支持取消保持与确认放弃", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/contracts-registry");
const table = page.getByTestId("contracts-registry-table");
const editButtons = table.getByRole("button", { name: "编辑", exact: true });
await editButtons.first().click();
const counterparty = page.getByPlaceholder("合同对方名称");
await counterparty.fill("合成未保存合同相对方");
await editButtons.nth(1).click();
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("合同表单尚未保存");
await page.getByTestId("unsaved-changes-continue").click();
await expect(page).toHaveURL(/\/contracts-registry$/);
await expect(counterparty).toHaveValue("合成未保存合同相对方");
await editButtons.nth(1).click();
await page.getByTestId("unsaved-changes-discard").click();
await expect(counterparty).toHaveValue(secondContractRegistryItem.counterpartyName);
});
test("Wave3 移动关键页视觉截图", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
@@ -7,6 +7,10 @@ import { expect, test } from "./helpers/fixtures";
test.use({ serviceWorkers: "block" });
const outputDir = path.resolve(process.cwd(), "../output/playwright/pc-finance-admin-visual-wave4");
const taskOutputDir = path.resolve(
process.cwd(),
"../output/playwright/07-18-small-team-internal-readiness",
);
const visualUser = {
identifier: "[email protected]",
@@ -123,7 +127,7 @@ function paymentBatch(status: "draft" | "reviewed" | "failed", suffix: string) {
completedReviewCount: status === "draft" ? 0 : 1,
nextStep: status === "reviewed" ? "ready_for_execute" : "first_review",
makerCheckerRequired: false,
reviewerPermission: "risk.review.manage",
reviewerPermission: "payment.batch.review",
},
reviewComment: null,
secondaryReviewComment: null,
@@ -999,6 +1003,11 @@ async function capture(page: Page, name: string) {
await page.screenshot({ path: path.join(outputDir, `${name}.png`), fullPage: true });
}
async function captureTaskEvidence(page: Page, name: string) {
mkdirSync(taskOutputDir, { recursive: true });
await page.screenshot({ path: path.join(taskOutputDir, `${name}.png`), fullPage: true });
}
const pages = [
{ path: "/payment-batches", proof: "data-testid=payment-batches-table", screenshot: "01-payment-batches", expectStatusStrip: false },
{ path: "/ap-operations", proof: "text=异常付款", screenshot: "02-ap-operations", expectStatusStrip: false },
@@ -1058,6 +1067,39 @@ test("PC 财务列表使用明确动作入口", async ({ page }) => {
await expect(page.getByTestId("invoice-export-csv")).toBeVisible();
});
test("/payment-batches 在 768x1024 与 1024x768 下操作列可达", async ({ page }) => {
await mockWave4Apis(page);
await login(page);
for (const viewport of [
{ width: 768, height: 1024 },
{ width: 1024, height: 768 },
]) {
await page.setViewportSize(viewport);
await page.goto("/payment-batches");
await expect(page.getByRole("heading", { name: "付款", exact: true })).toBeVisible();
const table = page.getByTestId("payment-batches-table");
await expect(table).toBeVisible();
const scrollRegion = table.locator("xpath=ancestor::*[@data-testid='business-table-scroll']").first();
const metrics = await scrollRegion.evaluate((element) => {
element.scrollLeft = element.scrollWidth;
return {
overflowX: window.getComputedStyle(element).overflowX,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
};
});
expect(["auto", "scroll"]).toContain(metrics.overflowX);
expect(metrics.scrollWidth).toBeGreaterThanOrEqual(metrics.clientWidth);
expect(metrics.documentOverflow).toBeLessThanOrEqual(2);
const operation = table.getByRole("button").last();
await expect(operation).toBeVisible();
await operation.click({ trial: true });
await captureTaskEvidence(page, `payment-batches-${viewport.width}x${viewport.height}`);
}
});
test("PC 票据辅助核验动作可提交并重试", async ({ page }) => {
const failedVerification: MockVerificationRecord = {
...(invoiceListItem.verification as MockVerificationRecord),
@@ -537,7 +537,7 @@ test.describe("R3 权限直连审计", () => {
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/ap-operations", statuses: [403, 409], note: "主管不得直连 AP" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/ap-operations/reimbursement-ledger", statuses: [403, 409], note: "主管不得直连财务台账" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/payment-batches", statuses: [403, 409], note: "主管不得直连付款批次" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/payment-batches", statuses: [200, 409], note: "主管可读取待复核付款批次;409 表示功能门禁关闭" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "主管不得直连 ERP 管理" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/audit-logs", statuses: [403], note: "主管不得查看审计日志" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/budgets", statuses: [200], note: "主管可直连预算" });
@@ -379,4 +379,21 @@ test.describe("R102 reimbursement main flow", () => {
await expect(page.getByText(reimbursementId)).toHaveCount(0);
await expect(page.locator("table:visible")).toHaveCount(0);
});
test("报销明细记录切换时支持取消保持与确认放弃", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(`/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-item-edit").click();
const addItem = page.getByTestId("reimbursement-item-add");
await addItem.click();
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("报销表单尚未保存");
await page.getByTestId("unsaved-changes-continue").click();
await expect(page).toHaveURL(new RegExp(`/reimbursements/${reimbursementId}$`));
await expect(page.getByTestId("reimbursement-item-editor")).toContainText("编辑明细");
await addItem.click();
await page.getByTestId("unsaved-changes-discard").click();
await expect(page.getByTestId("reimbursement-item-editor")).toContainText("新增明细");
});
});
@@ -15,12 +15,21 @@ async function fulfillJson(route: Route, data: unknown, status = 200) {
}
const screenshotDir = path.resolve(process.cwd(), "../output/playwright/r103");
const taskScreenshotDir = path.resolve(
process.cwd(),
"../output/playwright/07-18-small-team-internal-readiness",
);
function screenshotPath(fileName: string) {
mkdirSync(screenshotDir, { recursive: true });
return path.join(screenshotDir, fileName);
}
function taskScreenshotPath(fileName: string) {
mkdirSync(taskScreenshotDir, { recursive: true });
return path.join(taskScreenshotDir, fileName);
}
const me = {
id: "employee-r103",
employeeNo: "HYD000103",
@@ -45,6 +54,8 @@ const me = {
const purchaseId = "purchase-r103";
const reimbursementId = "11111111-1111-4111-8111-111111111111";
const invoiceId = "invoice-r103";
const projectId = "project-r103";
const assetId = "asset-r103";
const safePurchaseAttachmentId = "attachment-safe-r103";
const quarantinedPurchaseAttachmentId = "attachment-quarantined-r103";
const safePngBuffer = Buffer.from(
@@ -210,7 +221,81 @@ function reimbursementForBind() {
};
}
async function mockApis(page: Page) {
function projectListItem() {
return {
id: projectId,
projectNo: "PRJ-20260718-001",
name: "内部候选项目",
clientName: "合成客户",
clientAlias: "合成客户",
status: "active",
startsAt: "2026-07-01",
targetEndAt: "2026-08-31",
actualEndAt: null,
summary: "用于验证中等宽度下项目操作列可达",
isConfidential: false,
owner: { id: me.id, employeeNo: me.employeeNo, name: me.name },
manager: null,
counts: {
members: 2,
milestones: 3,
tasks: 5,
risks: 1,
overdueMilestones: 0,
highPriorityTasks: 1,
openRisks: 1,
},
updatedAt: "2026-07-18T08:00:00.000Z",
};
}
function assetListItem() {
return {
id: assetId,
assetNo: "FA-20260718-000001",
name: "研发测试笔记本电脑",
assetType: "fixed_asset",
sensitivityLevel: "internal",
categoryId: "asset-category-r103",
legalEntityId: "legal-r103",
costCenterId: "cost-r103",
purchaseRequestId: null,
serialNo: "SYNTHETIC-R103",
location: "办公室 A 区",
status: "in_use",
assigneeEmployeeId: me.id,
ownerEmployeeId: me.id,
keeperEmployeeId: me.id,
archiveLocation: null,
externalRef: null,
sourceHash: null,
verificationNo: null,
confidentialityNote: null,
metadata: null,
lastVerifiedAt: "2026-07-17T08:00:00.000Z",
expiresAt: null,
activatedAt: "2026-07-01",
retiredAt: null,
createdAt: "2026-07-01T08:00:00.000Z",
updatedAt: "2026-07-18T08:00:00.000Z",
category: {
id: "asset-category-r103",
code: "COMPUTER",
name: "电脑设备",
documentCategoryId: null,
documentCategory: null,
},
legalEntity: { id: "legal-r103", code: "HYD", name: "海音达" },
costCenter: { id: "cost-r103", code: "RD", name: "研发部", legalEntityId: "legal-r103" },
purchaseRequest: null,
assignee: { id: me.id, employeeNo: me.employeeNo, name: me.name, department: { id: me.departmentId, name: "研发部" } },
owner: { id: me.id, employeeNo: me.employeeNo, name: me.name, department: { id: me.departmentId, name: "研发部" } },
keeper: { id: me.id, employeeNo: me.employeeNo, name: me.name, department: { id: me.departmentId, name: "研发部" } },
permissions: { canEdit: false },
};
}
async function mockApis(page: Page, options: { roleCodes?: string[] } = {}) {
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const url = new URL(request.url());
@@ -248,7 +333,7 @@ async function mockApis(page: Page) {
}
if (pathName === "/api/me") {
await fulfillJson(route, me);
await fulfillJson(route, { ...me, roleCodes: options.roleCodes ?? me.roleCodes });
return;
}
@@ -270,7 +355,10 @@ async function mockApis(page: Page) {
sections: {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("reimbursements", "/reimbursements", "报销", "receipt_long"),
navigationItem("purchase", "/purchase-requests", "采购", "shopping_cart"),
navigationItem("projects", "/projects", "项目", "topic"),
navigationItem("assets", "/assets", "资产", "inventory_2"),
navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"),
],
admin: [],
@@ -341,7 +429,84 @@ async function mockApis(page: Page) {
return;
}
if (pathName === `/api/purchase-requests/${purchaseId}`) {
if (pathName === "/api/projects" && request.method() === "GET") {
await fulfillJson(route, { items: [projectListItem()] });
return;
}
if (pathName === "/api/contracts-registry/expiring" && request.method() === "GET") {
await fulfillJson(route, { days: 30, total: 0, items: [] });
return;
}
if (pathName === "/api/contracts-registry" && request.method() === "GET") {
await fulfillJson(route, { items: [] });
return;
}
if (pathName === "/api/payment-batches" && request.method() === "GET") {
await fulfillJson(route, { items: [] });
return;
}
if (pathName === "/api/ap-operations" && request.method() === "GET") {
await fulfillJson(route, {
summary: {
pendingPaymentCount: 0,
partialMatchCount: 0,
exceptionCount: 0,
budgetOverspendCount: 0,
},
pendingPaymentBatches: [],
failedPaymentBatchItems: [],
exceptionRiskFlags: [],
budgetOverspendRisks: [],
partialMatchItems: [],
});
return;
}
if (pathName === "/api/ap-operations/reimbursement-ledger" && request.method() === "GET") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
if (pathName === "/api/risk-flags" && request.method() === "GET") {
await fulfillJson(route, { items: [] });
return;
}
if (pathName === "/api/asset-categories" && request.method() === "GET") {
await fulfillJson(route, {
includeArchived: false,
items: [
{
id: "asset-category-r103",
code: "COMPUTER",
name: "电脑设备",
documentCategoryId: null,
enabled: true,
archivedAt: null,
createdAt: "2026-07-01T08:00:00.000Z",
updatedAt: "2026-07-18T08:00:00.000Z",
documentCategory: null,
},
],
});
return;
}
if (pathName === "/api/assets" && request.method() === "GET") {
await fulfillJson(route, { mine: true, items: [assetListItem()] });
return;
}
if (pathName === `/api/purchase-requests/${purchaseId}` && request.method() === "PATCH") {
await fulfillJson(route, { ...purchaseDetail(), ...request.postDataJSON() });
return;
}
if (pathName === `/api/purchase-requests/${purchaseId}` && request.method() === "GET") {
await fulfillJson(route, purchaseDetail());
return;
}
@@ -428,12 +593,107 @@ async function login(page: Page) {
await expect(page).toHaveURL(/\/portal/);
}
async function expectResponsiveBusinessTable(
page: Page,
target: { path: string; heading: string; tableTestId: string; screenshotName: string },
) {
await page.goto(target.path);
await expect(page.getByRole("heading", { name: target.heading, exact: true })).toBeVisible();
const table = page.getByTestId(target.tableTestId);
await expect(table).toBeVisible();
const scrollRegion = table.locator("xpath=ancestor::*[@data-testid='business-table-scroll']").first();
await expect(scrollRegion).toBeVisible();
const metrics = await scrollRegion.evaluate((element) => {
const style = window.getComputedStyle(element);
element.scrollLeft = element.scrollWidth;
return {
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
overflowX: style.overflowX,
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
};
});
expect(["auto", "scroll"]).toContain(metrics.overflowX);
expect(metrics.scrollWidth).toBeGreaterThanOrEqual(metrics.clientWidth);
expect(metrics.documentOverflow).toBeLessThanOrEqual(2);
const operation = table.getByRole("link").or(table.getByRole("button")).last();
await expect(operation).toBeVisible();
await operation.click({ trial: true });
await page.screenshot({ path: taskScreenshotPath(target.screenshotName), fullPage: true });
}
async function expectLedgerHeaderActions(
page: Page,
target: { path: string; heading: string; expectedActions: string[] },
) {
await page.goto(target.path);
const heading = page.getByRole("heading", { level: 1, name: target.heading, exact: true });
await expect(heading).toBeVisible();
const header = heading.locator("xpath=ancestor::header[1]");
await expect(header).toHaveClass(/page-header-shell/);
const actionRegion = header.locator(":scope > .page-header-shell__actions");
if (target.expectedActions.length === 0) {
await expect(actionRegion).toHaveCount(0);
return;
}
await expect(actionRegion).toHaveCount(1);
const actionLabels = await actionRegion.locator("button, a").evaluateAll((elements) =>
elements.map((element) => (element.textContent ?? "").replace(/\s+/g, " ").trim()),
);
expect(actionLabels).toHaveLength(target.expectedActions.length);
expect(actionLabels).toEqual(target.expectedActions);
expect(actionLabels.length).toBeLessThanOrEqual(2);
expect(actionLabels.filter((label) => label === "更多").length).toBeLessThanOrEqual(1);
expect(actionLabels.filter((label) => label !== "更多").length).toBeLessThanOrEqual(1);
expect(actionLabels.filter((label) => /^(筛选|导出(?:\s.*)?|刷新|显示测试数据)$/.test(label))).toEqual([]);
}
test.describe("R103 cross page UI consistency", () => {
test.beforeEach(async ({ page }) => {
await mockApis(page);
test.beforeEach(async ({ page }, testInfo) => {
const roleCodes = testInfo.title.includes("七个主要业务台账")
? ["employee", "manager", "finance", "administration"]
: testInfo.title.includes("资产长表单")
? ["employee", "administration"]
: undefined;
await mockApis(page, {
roleCodes,
});
await login(page);
});
test("七个主要业务台账标题区保持一主操作加至多一个更多,列表工具不常驻", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
const targets = [
{ path: "/purchase-requests", heading: "采购", expectedActions: ["新建采购"] },
{ path: "/contracts-registry", heading: "合同", expectedActions: ["新增合同"] },
{ path: "/projects", heading: "项目", expectedActions: ["新建项目"] },
{ path: "/assets", heading: "资产", expectedActions: ["新增资产", "更多"] },
{ path: "/payment-batches", heading: "付款", expectedActions: ["新建批次"] },
{ path: "/ap-operations", heading: "应付账款", expectedActions: [] },
{ path: "/invoice-pool", heading: "票据", expectedActions: ["上传票据"] },
];
for (const target of targets) {
await expectLedgerHeaderActions(page, target);
if (target.path === "/assets") {
const assetHeader = page
.getByRole("heading", { level: 1, name: "资产", exact: true })
.locator("xpath=ancestor::header[1]");
await expect(assetHeader.getByRole("button", { name: "资产分类", exact: true })).toHaveCount(0);
await assetHeader.getByRole("button", { name: "更多", exact: true }).click();
await page.getByRole("menuitem", { name: "资产分类", exact: true }).click();
await expect(page.getByTestId("asset-category-manager")).toBeVisible();
}
}
});
test("采购页使用安全格式上传并通过认证 Blob 下载", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/purchase-requests");
@@ -519,4 +779,116 @@ test.describe("R103 cross page UI consistency", () => {
await expect(page.getByText("Choose File")).toHaveCount(0);
await page.screenshot({ path: screenshotPath("invoice-pool-mobile.png"), fullPage: true });
});
test("采购长表单切回记录列表时支持取消保持与确认放弃", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(`/purchase-requests?id=${purchaseId}`);
const title = page.getByTestId("purchase-title");
await expect(title).toBeVisible();
await title.fill("办公用品采购(未保存)");
const returnToList = page.getByRole("button", { name: "返回列表", exact: true });
await returnToList.click();
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("采购表单尚未保存");
await page.getByTestId("unsaved-changes-continue").click();
await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`));
await expect(title).toHaveValue("办公用品采购(未保存)");
await returnToList.click();
await page.getByTestId("unsaved-changes-discard").click();
await expect(page).toHaveURL(/\/purchase-requests$/);
await expect(page.getByTestId("purchase-row-open")).toBeVisible();
await page.goto(`/purchase-requests?id=${purchaseId}`);
await page.getByTestId("purchase-title").fill("办公用品采购(已保存)");
await page.getByTestId("purchase-save").click();
await expect(page.getByText("采购草稿已保存")).toBeVisible();
await page.getByRole("link", { name: "票据", exact: true }).first().click();
await expect(page).toHaveURL(/\/invoice-pool$/);
await expect(page.getByTestId("unsaved-changes-dialog")).toHaveCount(0);
});
test("资产长表单切回记录列表时支持取消保持与确认放弃", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/assets");
await page.getByTestId("asset-create").click();
const name = page.getByTestId("asset-name");
await expect(name).toBeVisible();
await name.fill("合成未保存资产");
const returnToList = page.getByRole("button", { name: "返回列表", exact: true });
await returnToList.click();
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("资产表单尚未保存");
await page.getByTestId("unsaved-changes-continue").click();
await expect(page).toHaveURL(/\/assets$/);
await expect(name).toHaveValue("合成未保存资产");
await returnToList.click();
await page.getByTestId("unsaved-changes-discard").click();
await expect(page).toHaveURL(/\/assets$/);
await expect(page.getByTestId("asset-row")).toBeVisible();
});
test("共享未保存保护覆盖浏览器返回取消、确认与刷新拦截", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.getByRole("link", { name: "采购", exact: true }).first().click();
await expect(page).toHaveURL(/\/purchase-requests$/);
await page.getByTestId("purchase-row-open").click();
await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`));
const title = page.getByTestId("purchase-title");
await title.fill("浏览器历史未保存验证");
await expect(page.locator("html")).toHaveAttribute("data-unsaved-changes", "true");
await page.goBack();
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("采购表单尚未保存");
await page.getByTestId("unsaved-changes-continue").click();
await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`));
await expect(title).toHaveValue("浏览器历史未保存验证");
const beforeUnloadPromise = page.waitForEvent("dialog");
const reloadPromise = page.reload({ waitUntil: "domcontentloaded", timeout: 10_000 }).catch(() => null);
const beforeUnload = await beforeUnloadPromise;
expect(beforeUnload.type()).toBe("beforeunload");
await beforeUnload.dismiss();
await reloadPromise;
await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`));
await expect(title).toHaveValue("浏览器历史未保存验证");
const acceptedBeforeUnloadPromise = page.waitForEvent("dialog");
const acceptedReloadPromise = page.reload({ waitUntil: "domcontentloaded", timeout: 10_000 });
const acceptedBeforeUnload = await acceptedBeforeUnloadPromise;
expect(acceptedBeforeUnload.type()).toBe("beforeunload");
await acceptedBeforeUnload.accept();
await acceptedReloadPromise;
await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`));
await expect(title).toHaveValue("办公用品采购");
await title.fill("浏览器历史再次未保存验证");
await page.goBack();
await expect(page.getByTestId("unsaved-changes-dialog")).toBeVisible();
await page.getByTestId("unsaved-changes-discard").click();
// 详情查询参数由列表页使用 replace 写入,因此它的真实上一项是进入采购页前的工作台。
await expect(page).toHaveURL(/\/portal$/);
});
test("/projects /assets /reimbursements 在中等宽度下操作列可达且页面不横溢", async ({ page }) => {
const targets = [
{ path: "/projects", heading: "项目", tableTestId: "projects-table", screenshotName: "projects-768x1024.png" },
{ path: "/assets", heading: "资产", tableTestId: "assets-table", screenshotName: "assets-768x1024.png" },
{ path: "/reimbursements", heading: "我的报销", tableTestId: "reimbursements-table", screenshotName: "reimbursements-768x1024.png" },
];
for (const viewport of [
{ width: 768, height: 1024 },
{ width: 1024, height: 768 },
]) {
await page.setViewportSize(viewport);
for (const target of targets) {
await expectResponsiveBusinessTable(page, {
...target,
screenshotName: target.screenshotName.replace("768x1024", `${viewport.width}x${viewport.height}`),
});
}
}
});
});
@@ -112,9 +112,11 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok();
});
await createCard.getByTestId("payment-batch-create-entity-type").selectOption("reimbursement");
await createCard.getByTestId("payment-batch-create-name").fill(batchName);
await createCard.getByTestId("payment-batch-create-entity-ids").fill(reimbursementId);
await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementDisplayCode);
const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first();
await expect(candidateRow).toBeVisible();
await candidateRow.getByRole("checkbox").check();
await createCard.getByTestId("payment-batch-create-payment-date").fill(paymentDate);
await createCard.getByTestId("payment-batch-create-payment-channel").selectOption("bank");
await createCard.getByTestId("payment-batch-create-voucher-prefix").fill("R78PB");
@@ -135,7 +137,7 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
await logout(page, FINANCE.name);
await login(page, FINANCE_MGR);
await login(page, MANAGER);
await page.goto("/ap-operations");
await page.getByRole("tab", { name: /待付款/ }).click();
const pendingRow = page.locator("tr").filter({ hasText: batchName }).first();
@@ -146,7 +148,7 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
await page.getByRole("button", { name: "提交复核" }).click();
await expect(pendingRow).toContainText("复核 1/1");
await logout(page, FINANCE_MGR.name);
await logout(page, MANAGER.name);
await login(page, FINANCE);
await page.goto(`/payment-batches?id=${batchId}`);
@@ -53,6 +53,8 @@ const me = {
const reimbursementId = "reimbursement-action-confirm";
const paymentBatchId = "payment-batch-action-confirm";
const reimbursementAttachmentId = "reimbursement-attachment-action-confirm";
const paymentReceiptId = "payment-receipt-action-confirm";
function reimbursementListItem() {
return {
@@ -78,6 +80,50 @@ function reimbursementListItem() {
};
}
function reimbursementDetail() {
return {
...reimbursementListItem(),
submitterId: me.id,
ownerId: me.id,
departmentId: "department-action-confirm",
department: { id: "department-action-confirm", name: "合成部门" },
submittedAt: null,
projectId: null,
linkedTravelRequestId: null,
linkedTravelRequest: null,
owner: { id: me.id, employee_no: me.employeeNo, name: me.name },
submitter: { id: me.id, employee_no: me.employeeNo, name: me.name },
items: [],
invoices: [],
attachments: [
{
id: reimbursementAttachmentId,
originalName: "合成报销敏感附件.pdf",
mimeType: "application/pdf",
fileSize: 1024,
fileSha256: "synthetic-attachment-hash",
governanceStatus: "approved",
sensitivityLevel: "confidential",
attachmentType: "supporting_document",
downloadPolicy: "reason_required",
reviewedBy: null,
reviewedAt: null,
reviewNote: null,
archivedBy: null,
archivedAt: null,
archiveNote: null,
uploadedBy: { id: me.id, employeeNo: me.employeeNo, name: me.name },
createdAt: "2026-05-15T08:05:00.000Z",
},
],
budgetHint: null,
pendingWorkflow: null,
workflowProgress: null,
approvalHistory: [],
riskFlags: [],
};
}
function paymentBatch(status: "draft" | "reviewed" = "reviewed") {
return {
id: paymentBatchId,
@@ -96,7 +142,7 @@ function paymentBatch(status: "draft" | "reviewed" = "reviewed") {
completedReviewCount: status === "draft" ? 0 : 1,
nextStep: status === "draft" ? "first_review" : "ready_for_execute",
makerCheckerRequired: false,
reviewerPermission: "risk.review.manage",
reviewerPermission: "payment.batch.review",
},
reviewComment: null,
secondaryReviewComment: null,
@@ -111,6 +157,7 @@ function paymentBatch(status: "draft" | "reviewed" = "reviewed") {
executedBy: null,
cancelledAt: null,
cancelledBy: null,
cancelledReason: null,
items: [
{
id: "payment-batch-item-action-confirm",
@@ -133,11 +180,20 @@ function paymentBatch(status: "draft" | "reviewed" = "reviewed") {
};
}
async function mockSystemActionApis(page: Page) {
async function mockSystemActionApis(
page: Page,
options: { batchStatus?: "draft" | "reviewed"; meOverride?: Partial<typeof me> } = {},
) {
const currentMe = { ...me, ...options.meOverride };
const batchStatus = options.batchStatus ?? "reviewed";
const calls = {
deleteReimbursement: 0,
deleteReimbursementAttachment: 0,
exportReimbursements: 0,
executePaymentBatch: 0,
voidPaymentReceipt: 0,
attachmentDeleteReasons: [] as string[],
receiptVoidReasons: [] as string[],
};
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
@@ -191,7 +247,7 @@ async function mockSystemActionApis(page: Page) {
}
if (path === "/api/me") {
await fulfillJson(route, me);
await fulfillJson(route, currentMe);
return;
}
@@ -220,6 +276,22 @@ async function mockSystemActionApis(page: Page) {
return;
}
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") {
await fulfillJson(route, reimbursementDetail());
return;
}
if (
path === `/api/reimbursements/${reimbursementId}/attachments/${reimbursementAttachmentId}` &&
request.method() === "DELETE"
) {
calls.deleteReimbursementAttachment += 1;
const body = request.postDataJSON() as { reason?: string };
calls.attachmentDeleteReasons.push(body.reason ?? "");
await fulfillJson(route, { attachmentId: reimbursementAttachmentId, outcome: "deleted" });
return;
}
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "DELETE") {
calls.deleteReimbursement += 1;
await fulfillJson(route, { id: reimbursementId, deleted: true });
@@ -250,7 +322,45 @@ async function mockSystemActionApis(page: Page) {
}
if (path === "/api/payment-batches" && request.method() === "GET") {
await fulfillJson(route, { items: [paymentBatch("reviewed")] });
await fulfillJson(route, { items: [paymentBatch(batchStatus)] });
return;
}
if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments` && request.method() === "GET") {
await fulfillJson(route, {
items: [
{
id: paymentReceiptId,
originalName: "合成银行回单.pdf",
mimeType: "application/pdf",
fileSize: 2048,
fileSha256: "synthetic-receipt-hash",
governanceStatus: "approved",
sensitivityLevel: "confidential",
attachmentType: "payment_receipt",
downloadPolicy: "reason_required",
reviewedBy: null,
reviewedAt: null,
reviewNote: null,
archivedBy: null,
archivedAt: null,
archiveNote: null,
uploadedBy: { id: me.id, employeeNo: me.employeeNo, name: me.name },
createdAt: "2026-05-15T08:20:00.000Z",
},
],
});
return;
}
if (
path === `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/void` &&
request.method() === "POST"
) {
calls.voidPaymentReceipt += 1;
const body = request.postDataJSON() as { reason?: string };
calls.receiptVoidReasons.push(body.reason ?? "");
await fulfillJson(route, { attachmentId: paymentReceiptId, outcome: "voided", voidedAt: "2026-05-15T08:30:00.000Z" });
return;
}
@@ -320,4 +430,69 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await page.getByTestId("action-confirm-submit").click();
await expect.poll(() => calls.executePaymentBatch).toBe(1);
});
test("高敏附件删除要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => {
const calls = await mockSystemActionApis(page);
await login(page, user);
await page.goto(`/reimbursements/${reimbursementId}`);
const attachment = page.locator(".detail-evidence-card").filter({ hasText: "合成报销敏感附件.pdf" });
await expect(attachment).toBeVisible();
await attachment.getByRole("button", { name: "删除" }).click();
const dialog = page.getByTestId("attachment-delete-confirm-dialog");
await expect(dialog).toContainText("合成报销敏感附件.pdf");
await expect(dialog).toContainText("无法在 OA 内恢复");
expect(calls.deleteReimbursementAttachment).toBe(0);
await page.getByTestId("attachment-delete-confirm-submit").click();
await expect(dialog).toContainText("请填写至少 2 个字的删除理由");
expect(calls.deleteReimbursementAttachment).toBe(0);
await dialog.getByRole("button", { name: "取消" }).click();
expect(calls.deleteReimbursementAttachment).toBe(0);
await attachment.getByRole("button", { name: "删除" }).click();
await page.getByRole("textbox", { name: "删除理由" }).fill("附件重复上传");
await page.getByTestId("attachment-delete-confirm-submit").click();
await expect.poll(() => calls.deleteReimbursementAttachment).toBe(1);
expect(calls.attachmentDeleteReasons).toEqual(["附件重复上传"]);
});
test("付款回单作废要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => {
const calls = await mockSystemActionApis(page);
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
const receipt = page.locator(".detail-evidence-card").filter({ hasText: "合成银行回单.pdf" });
await expect(receipt).toBeVisible();
await receipt.getByRole("button", { name: "作废" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toContainText("合成银行回单.pdf");
await expect(dialog).toContainText("文件记录会保留并标记为已作废");
await expect(page.getByRole("button", { name: "确认作废" })).toBeDisabled();
expect(calls.voidPaymentReceipt).toBe(0);
await page.getByRole("textbox", { name: "回单作废原因" }).fill("一");
await expect(page.getByRole("button", { name: "确认作废" })).toBeDisabled();
expect(calls.voidPaymentReceipt).toBe(0);
await dialog.getByRole("button", { name: "返回" }).click();
expect(calls.voidPaymentReceipt).toBe(0);
await receipt.getByRole("button", { name: "作废" }).click();
await page.getByRole("textbox", { name: "回单作废原因" }).fill("回单文件上传有误");
await page.getByRole("button", { name: "确认作废" }).click();
await expect.poll(() => calls.voidPaymentReceipt).toBe(1);
expect(calls.receiptVoidReasons).toEqual(["回单文件上传有误"]);
});
test("只有超级管理员职责且未兼任经理或负责人时不显示付款复核", async ({ page }) => {
await mockSystemActionApis(page, {
batchStatus: "draft",
meOverride: { roleCodes: ["admin"] },
});
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByText("PAY-ACTION-001").first()).toBeVisible();
await expect(page.getByRole("button", { name: "复核", exact: true })).toHaveCount(0);
});
});
+130 -3
查看文件
@@ -9,12 +9,18 @@ function envelope(data: unknown) {
function mockCommonAuth(page: import("@playwright/test").Page, options: {
mustChangePassword?: boolean;
meId?: string;
meName?: string;
meEmployeeNo?: string;
permissions?: string[];
roleCodes?: string[];
}) {
const mustChangePassword = options.mustChangePassword === true;
const meId = options.meId ?? "employee-admin-1";
const meName = options.meName ?? "Admin";
const meEmployeeNo = options.meEmployeeNo ?? "HYD000001";
const permissions = options.permissions ?? ["system.users", "system.accounts.manage", "system.roles.manage"];
const roleCodes = options.roleCodes ?? ["admin"];
return Promise.all([
page.route("**/api/auth/sso/providers", async (route) => {
@@ -64,7 +70,7 @@ function mockCommonAuth(page: import("@playwright/test").Page, options: {
contentType: "application/json",
body: JSON.stringify(
envelope({
id: "employee-admin-1",
id: meId,
employeeNo: meEmployeeNo,
name: meName,
phone: "13800000000",
@@ -73,8 +79,8 @@ function mockCommonAuth(page: import("@playwright/test").Page, options: {
directManagerId: null,
status: "active",
notificationPushEnabled: true,
roleCodes: ["admin"],
permissions: ["system.users"],
roleCodes,
permissions,
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: true,
@@ -135,6 +141,73 @@ function mockCommonAuth(page: import("@playwright/test").Page, options: {
]);
}
type MockUser = {
id: string;
employeeNo: string;
name: string;
phone: string;
email: string;
status: "active" | "archived";
departmentId: string;
title: string;
roleCodes: string[];
createdAt: string;
updatedAt: string;
};
function buildMockUser(id: string, name: string, roleCodes: string[]): MockUser {
return {
id,
employeeNo: `MOCK-${id}`,
name,
phone: "00000000000",
email: `${id}@example.test`,
status: "active",
departmentId: "dept-1",
title: "员工",
roleCodes,
createdAt: "2026-07-18T00:00:00.000Z",
updatedAt: "2026-07-18T00:00:00.000Z",
};
}
async function mockUserManagementData(
page: import("@playwright/test").Page,
items: MockUser[],
) {
await Promise.all([
page.route((url) => url.pathname === "/api/users", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(envelope({ page: 1, pageSize: 20, total: items.length, items })),
});
}),
page.route((url) => url.pathname === "/api/departments", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(envelope({ includeArchived: false, items: [], tree: [] })),
});
}),
page.route((url) => url.pathname === "/api/employees", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(envelope({ page: 1, pageSize: 200, total: 0, items: [] })),
});
}),
]);
}
async function loginToUserManagement(page: import("@playwright/test").Page) {
await page.goto("/login?redirect=%2Fsystem%2Fusers");
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("SyntheticPassword1234");
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/system\/users/);
}
test("E2E 用户开通:新建用户时由系统生成工号和一次性口令", async ({ page }) => {
let createPayload: Record<string, unknown> | null = null;
@@ -234,6 +307,60 @@ test("E2E 用户开通:新建用户时由系统生成工号和一次性口令"
await expect(page.getByTestId("users-reset-result")).toContainText("ResetA1abc123def456");
});
test("E2E 账号保护:OA 系统管理员只能操作普通账号", async ({ page }) => {
const currentId = "system-admin-current";
const ordinary = buildMockUser("ordinary-user", "普通员工", ["employee"]);
const protectedAdmin = buildMockUser("protected-admin", "应急账号甲", ["employee", "admin"]);
const protectedSystemAdmin = buildMockUser("protected-system-admin", "运维账号乙", ["employee", "system_admin"]);
const currentSystemAdmin = buildMockUser(currentId, "当前运维账号", ["employee", "system_admin"]);
await mockCommonAuth(page, {
meId: currentId,
meName: "当前运维账号",
roleCodes: ["system_admin"],
permissions: ["system.accounts.manage", "system.roles.manage"],
});
await mockUserManagementData(page, [ordinary, protectedAdmin, protectedSystemAdmin, currentSystemAdmin]);
await loginToUserManagement(page);
const ordinaryRow = page.getByTestId("users-row").filter({ hasText: ordinary.name });
await expect(ordinaryRow.getByTestId("users-toggle")).toHaveCount(1);
await expect(ordinaryRow.getByTestId("users-reset-password")).toHaveCount(1);
await expect(ordinaryRow.getByTestId("users-edit-roles")).toHaveCount(1);
for (const user of [protectedAdmin, protectedSystemAdmin, currentSystemAdmin]) {
const row = page.getByTestId("users-row").filter({ hasText: user.name });
await expect(row.getByTestId("users-toggle")).toHaveCount(0);
await expect(row.getByTestId("users-reset-password")).toHaveCount(0);
await expect(row.getByTestId("users-edit-roles")).toHaveCount(0);
await expect(row.getByTestId("users-protected-account")).toHaveText("受保护账号");
}
});
test("E2E 账号保护:应急超级管理员可操作其他受保护账号但不能停用或改自己的角色", async ({ page }) => {
const currentId = "employee-admin-1";
const otherAdmin = buildMockUser("other-admin", "其他应急账号", ["employee", "admin"]);
const otherSystemAdmin = buildMockUser("other-system-admin", "其他运维账号", ["employee", "system_admin"]);
const currentAdmin = buildMockUser(currentId, "当前应急账号", ["employee", "admin"]);
await mockCommonAuth(page, { meId: currentId, meName: currentAdmin.name });
await mockUserManagementData(page, [otherAdmin, otherSystemAdmin, currentAdmin]);
await loginToUserManagement(page);
for (const user of [otherAdmin, otherSystemAdmin]) {
const row = page.getByTestId("users-row").filter({ hasText: user.name });
await expect(row.getByTestId("users-toggle")).toHaveCount(1);
await expect(row.getByTestId("users-reset-password")).toHaveCount(1);
await expect(row.getByTestId("users-edit-roles")).toHaveCount(1);
await expect(row.getByTestId("users-protected-account")).toHaveCount(0);
}
const currentRow = page.getByTestId("users-row").filter({ hasText: currentAdmin.name });
await expect(currentRow.getByTestId("users-toggle")).toHaveCount(0);
await expect(currentRow.getByTestId("users-edit-roles")).toHaveCount(0);
await expect(currentRow.getByTestId("users-reset-password")).toHaveCount(1);
});
test("E2E 首次登录:一次性口令登录后强制改密", async ({ page }) => {
let changePasswordPayload: Record<string, unknown> | null = null;
+1 -1
查看文件
@@ -155,7 +155,7 @@
| `RULE-REIMB` | `docs/报销发票附件规则.md` | 需求事实源 | 报销发票附件规则 | `CURRENT` | product/finance | `RI6,BIZ8` | 报销回归 | none | 2026-05-13 | `rg` | high | 报销、发票、附件细则 |
| `REQ-WECOM` | `docs/需求-企业微信基础设施规约.md` | 需求事实源 | 企业微信边界规约 | `CURRENT` | product/backend | `R7E,ORG8,WF8,ATTV1,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WROAD2B,WAPPR1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE1,WTCLOSE2` | live smoke / 真机反馈 / Trellis task / 历史 Issue CSV | none | 2026-05-26 | `rg` | high | 企微优先承担 HR/考勤员工日常审批和人事/打卡能力;WARCH2 已提供请假/补卡正式只读历史查询,WARCH3 固定受控上线自动巡检,WCHECK1 固定打卡摘要留存,WEXT1 固定外出/加班 dry-run,WROAD2B 固定三天人工业务冒烟,WAPPR1/WTCLOSE1 固定审批规则规范化和小团队目标 A,WFULLSMOKE1 固定全域主路径测试边界,WTCLOSE2 固定最终测试号自动收口,WEXT1-FORMAL 需收口后另建合同;采购、合同、项目、报销和财务继续 OA 主办 |
| `REQ-ASSET` | `docs/需求-资产管理规约.md` | 需求事实源 | 统一资产台账规约 | `CURRENT` | product/backend | `ASSET1,OPOLICY1` | Trellis task / 本地门禁 / 历史 Issue CSV | none | 2026-05-26 | `rg` | high | 固定资产和电子资产统一台账;编号族 `FA/EA-DOC/EA-SYS/EA-REPO/EA-BKP`;`EA-ACC` 暂缓;Git 管源码,OA 只登记仓库元数据;OPOLICY1 后固定资料留存、备份资产、账号凭据禁入和 P2 冻结边界 |
| `RUNBOOK-TRIAL` | `docs/试运行与自动化验证手册.md` | 操作手册 | 试运行验证手册 | `CURRENT` | qa/ops | `TRELLIS2,R7E,QAOPS8,OAOPS1,OALAUNCH1` | Trellis task / live readiness | none | 2026-07-16 | `rg` | medium | Trellis-first;区分最后确认公网来源 `df26922`、7 月本地未部署基线和未来新连续试运行 |
| `RUNBOOK-TRIAL` | `docs/试运行与自动化验证手册.md` | 操作手册 | 试运行验证手册 | `CURRENT` | qa/ops | `TRELLIS2,R7E,QAOPS8,OAOPS1,OALAUNCH1,07-18-small-team-internal-readiness` | Trellis task / live readiness | none | 2026-07-18 | `rg` | medium | Trellis-first;区分最后确认公网来源 `df26922`、`LOCAL_CANDIDATE_PASS` 本地边界和未来另行授权的内部部署/连续试运行 |
| `RUNBOOK-RELEASE` | `docs/trialrun/发布试运行准备清单.md` | 操作手册 | 发布与连续试运行准备 | `CURRENT` | qa/ops | `TRELLIS2,OAOPS1,OALAUNCH1` | Trellis task / release gate | none | 2026-07-16 | `rg` | medium | 当前状态以 2026-07-16 基线说明为准;A1-R7E、QAOPS8 内容保留为历史证据 |
| `RUNBOOK-DEVICE` | `docs/trialrun/真机公网测试清单.md` | 操作手册 | 真机与企业微信验收 | `CURRENT` | qa/ops | `TRELLIS2,OAOPS1,OALAUNCH1` | Trellis task / 真机证据 | none | 2026-07-16 | `rg` | medium | 当前验证必须记录实际部署来源 SHA;2026-05 设备、账号和结果只作历史基线 |
| `MANUAL-ADMIN` | `docs/管理员手册.md` | 操作手册 | 管理员手册 | `CURRENT` | ops/admin | `ORG8,R7E,WFOC,DBWFOC,WFDG,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE1,WTCLOSE2,WREAL1P,ASSET1,OALAUNCH1,OPOLICY1,OAFLOW1` | 人工验收 | none | 2026-05-24 | `rg` | medium | 管理员上线、企微优先边界、Secret 矩阵、巡检、HR/考勤门禁、ASSET1、OALAUNCH1、OPOLICY1 和 OAFLOW1;明确制度参数、资料留存、用印台账、审批异常 SOP、制度签收、负责人本人报销兜底、备份恢复、权限矩阵和敏感资料不进公网 OA 的管理口径 |
+23 -2
查看文件
@@ -2,7 +2,7 @@
**文档状态**:当前操作手册,用于本地、公网、企业微信和真实 OCR 试运行验证。
**当前状态(2026-07-16)**:最后有部署和公网复检证据的来源基线为 `df26922`;未做当前公网只读检查时,不据此断言服务器此刻仍运行该提交。7 月 P0 可信度修复只在本地 `main`,尚未 push、部署或完成公网复检。旧 7 天试运行只有 Day 1–Day 4 后中断,缺人工业务闭环和 Day 7 Go/No-Go;下一轮必须待当前可信基线部署并复核后,新建 Trellis task,从 Day 1 连续启动。正式能力继续保持 PDF-only OCR,不接真实支付、不接 ERP、不开放图片 OCR、不导入银行流水。
**当前状态(2026-07-18)**:最后有部署和公网复检证据的来源基线仍为 `df26922`;未做当前公网只读检查时,不据此断言服务器此刻仍运行该提交。`07-18-small-team-internal-readiness` 只收敛本地候选,完成上限为 `LOCAL_CANDIDATE_PASS`;它不代表已部署、可公网测试、企业微信真机通过或真实账号/数据验证。旧 7 天试运行只有 Day 1–Day 4 后中断,缺人工业务闭环和 Day 7 Go/No-Go;下一轮必须待当前可信基线部署并复核后,新建 Trellis task,从 Day 1 连续启动。正式能力继续保持 PDF-only OCR,不接真实支付、不接 ERP、不开放图片 OCR、不导入银行流水。
本文用于 hyb-oa 本地、企业微信和公网试运行。默认策略是:能自动化的先自动化,自动化无法覆盖的才请求人工配合。
@@ -147,7 +147,7 @@ cd client && E2E_BASE_URL=https://oa.hyinda.com npx playwright test tests/e2e/dg
- 公网 `/ready`、PC 登录、移动/企业微信入口。
- 报销/PDF OCR、审批、请假、采购、通知、付款批次、AP 导出和审计日志。
- `payment_batch_v1` 未意外关闭,`erp_sync` 未意外开启。
- 出纳上传银行回单后再执行 OA 已付款标记;银行流水和回单原件由出纳从银行侧导出给代账,不导入 OA。
- 出纳在银行侧完成真实付款后,可将经授权的付款回单作为 OA 留存证据,再执行 OA 已付款标记;误传回单必须填写理由并留痕作废,不物理删除。银行流水不导入 OA,银行侧原始材料仍按受控财务流程交接。
- 当天问题按时间、角色、页面、步骤、影响等级和处理结论登记。
无法登录、无法审批、无法提交核心单据、付款状态错误、权限越权、`/ready` 异常或冻结项被误开启时,结论应为 Pause,并另建或更新最小修复 Trellis task;不得用重置数据库或生产恢复临时绕过。
@@ -225,6 +225,27 @@ PLAYWRIGHT_MANUAL=1 PLAYWRIGHT_OCR_LIVE=1 PLAYWRIGHT_OCR_LIVE_BATCH=1 PLAYWRIGHT
## 发布候选本地门禁
### 五人团队内部候选(2026-07-18)
内部部署规划前,先在当前 checkout 上执行两层本地门禁:
```bash
bash scripts/qa/run-web-gates.sh payment-concurrency-local
bash scripts/qa/run-local-candidate.sh full
```
- `payment-concurrency-local` 使用回环 PostgreSQL 和 `hyb_oa_payment_` 前缀的一次性合成数据库,执行当前全部 migration,并验证付款竞态与审计/通知唯一性;数据库清理失败即门禁失败。
- `run-local-candidate.sh full` 执行无历史 Issue CSV 依赖的基础门禁、路由/流程反例和本地 Vite 合成浏览器行为。只有该模式成功时才输出 `LOCAL_CANDIDATE_PASS`。
- `focused` 只重跑路由反例和浏览器行为,成功标记为 `FOCUSED_BROWSER_PASS`,不能替代 `full`;`list` 只证明 Playwright 资产可解析,标记为 `LIST_ONLY`。
- 浏览器入口只允许无凭据、无路径参数的 loopback HTTP origin;候选 spec 排除 `manual/live`,测试数据、角色、账号和材料必须为合成内容。
- 路由门禁要求所有注册路由完成分类,核心流程 witness 与 manifest 一一绑定,且全部候选用例不得 failed、skipped、unexpected 或来源漂移。
收口证据至少记录:本文件所在提交、命令与退出码、环境边界、migration/临时库清理、路由与流程统计、Playwright passed/skipped/failed 数、十个 witness 的最终 URL/H1/角色/允许与禁止动作、页面错误计数、代表 viewport/溢出指标、脱敏截图引用,以及 finding 的修复/部署/外部验证三层状态。所有路径使用仓库相对引用,不保留原始 payload、真实账号、银行资料或完整本地路径。
本地候选通过后仍只能表述为“本地候选通过,尚未部署”。真实账号分配、超级管理员托管、迁移执行、备份恢复、内部地址验收、公网检查和企业微信真机必须由后续获批 Trellis task 完成。
### 历史 A8 本地回归参数
首轮主线 A8 复跑本地门禁时,需要先用本地回归参数启动服务,再执行回归脚本。推荐参数:
```bash
二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 79 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 59 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 106 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 90 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 148 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 75 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 70 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 59 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 78 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 65 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 77 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 57 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 76 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 56 KiB

+25 -8
查看文件
@@ -2,12 +2,25 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MODE="${1:-default}"
case "$MODE" in
default|local-candidate) ;;
*)
echo "Usage: bash scripts/check-minimal.sh [local-candidate]" >&2
exit 2
;;
esac
echo "=== [1/7] Process Contract Gate ==="
(
cd "$ROOT_DIR"
node scripts/qa/check-process-contract.mjs --baseline
)
if [[ "$MODE" == "local-candidate" ]]; then
echo "[SKIP] local-candidate 不读取历史 Issue CSV 流程基线"
else
(
cd "$ROOT_DIR"
node scripts/qa/check-process-contract.mjs --baseline
)
fi
echo ""
echo "=== [2/7] Governance + Sensitive Content ==="
@@ -95,10 +108,14 @@ echo "=== [6/7] Client Typecheck + Build ==="
echo ""
echo "=== [7/7] Feature Matrix Consistency ==="
(
cd "$ROOT_DIR"
python3 scripts/validate-feature-matrix.py
)
if [[ "$MODE" == "local-candidate" ]]; then
echo "[SKIP] local-candidate 不读取历史 Issue CSV 功能矩阵"
else
(
cd "$ROOT_DIR"
python3 scripts/validate-feature-matrix.py
)
fi
echo ""
echo "[OK] Minimal checks passed"
可执行文件 -> 普通文件
+386 -251
查看文件
@@ -1,41 +1,49 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const rootDir = path.resolve(new URL("../..", import.meta.url).pathname);
const routerPath = path.join(rootDir, "client/src/router/index.tsx");
const navSchemaPath = path.join(rootDir, "client/src/features/navigation/schema.ts");
const flowMatrixPath = path.join(rootDir, "docs/trialrun/项目级流程覆盖矩阵.csv");
const serverRoutesDir = path.join(rootDir, "server/src/routes");
const e2eDir = path.join(rootDir, "client/tests/e2e");
const defaultRootDir = path.resolve(new URL("../..", import.meta.url).pathname);
const ROUTE_CLASSIFICATIONS = new Set(["core", "supported-noncore", "deferred", "frozen"]);
const ROLE_FIXTURES = new Set(["employee", "manager", "ceo", "finance", "system_admin", "admin"]);
function usage() {
console.log(`Usage: node scripts/qa/check-flow-coverage.mjs [--json] [--markdown <path>] [--fail-on-gaps]
console.log(`Usage: node scripts/qa/check-flow-coverage.mjs [options]
输出:
--json 输出 JSON 到 stdout
--markdown <path> 输出 Markdown 摘要到指定路径
--fail-on-gaps 存在 P0 missing_e2e 或 missing_visual 时以 1 退出
Options:
--json 输出 JSON 到 stdout
--markdown <path> 输出 Markdown 摘要
--fail-on-gaps 任一本地候选阻断缺口存在时以 1 退出
--local-candidate 等同 --fail-on-gaps,并要求核心流程束均有可信 witness
--root <path> 使用指定仓库/测试 fixture 根目录
--help 显示帮助
`);
}
function parseArgs() {
const args = process.argv.slice(2);
function parseArgs(argv = process.argv.slice(2)) {
const parsed = {
json: false,
markdownPath: "",
failOnGaps: false,
localCandidate: false,
rootDir: defaultRootDir,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--json") {
parsed.json = true;
} else if (arg === "--markdown") {
parsed.markdownPath = args[index + 1] ?? "";
parsed.markdownPath = argv[index + 1] ?? "";
index += 1;
} else if (arg === "--fail-on-gaps") {
parsed.failOnGaps = true;
} else if (arg === "--local-candidate") {
parsed.localCandidate = true;
parsed.failOnGaps = true;
} else if (arg === "--root") {
parsed.rootDir = path.resolve(argv[index + 1] ?? "");
index += 1;
} else if (arg === "--help" || arg === "-h") {
usage();
process.exit(0);
@@ -44,9 +52,12 @@ function parseArgs() {
}
}
if (args.includes("--markdown") && !parsed.markdownPath) {
if (argv.includes("--markdown") && !parsed.markdownPath) {
throw new Error("--markdown 需要指定输出路径");
}
if (argv.includes("--root") && !argv[argv.indexOf("--root") + 1]) {
throw new Error("--root 需要指定目录");
}
return parsed;
}
@@ -55,64 +66,18 @@ function readText(file) {
return readFileSync(file, "utf8");
}
function walkFiles(dir, predicate = () => true) {
const output = [];
if (!existsSync(dir)) return output;
for (const name of readdirSync(dir)) {
const file = path.join(dir, name);
const stat = statSync(file);
if (stat.isDirectory()) {
output.push(...walkFiles(file, predicate));
} else if (predicate(file)) {
output.push(file);
}
function readJson(file) {
try {
return JSON.parse(readText(file));
} catch (error) {
throw new Error(`${path.basename(file)} 不是有效 JSON:${error instanceof Error ? error.message : String(error)}`);
}
return output.sort();
}
function toPosix(file) {
return file.split(path.sep).join("/");
}
function parseCsvLine(line) {
const cells = [];
let current = "";
let quoted = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
const next = line[index + 1];
if (quoted && char === "\"" && next === "\"") {
current += "\"";
index += 1;
continue;
}
if (char === "\"") {
quoted = !quoted;
continue;
}
if (!quoted && char === ",") {
cells.push(current);
current = "";
continue;
}
current += char;
}
cells.push(current);
return cells;
}
function parseCsv(source) {
const lines = source.split(/\r?\n/).filter((line) => line.trim());
const header = parseCsvLine(lines[0] ?? "");
return lines.slice(1).map((line) => {
const cells = parseCsvLine(line);
return Object.fromEntries(header.map((key, index) => [key, cells[index] ?? ""]));
});
}
function normalizeRoute(route) {
if (!route) return "/";
if (route === "*") return "*";
@@ -120,24 +85,11 @@ function normalizeRoute(route) {
return prefixed.replace(/\/+/g, "/").replace(/\/$/, "") || "/";
}
function routeToKeyword(route) {
return normalizeRoute(route)
.replace(/^\/+/, "")
.replace(/:[^/]+/g, "")
.split("/")
.filter(Boolean)
.join(" ");
}
function parseRouterRoutes(routerPath) {
if (!existsSync(routerPath)) {
throw new Error(`路由文件不存在:${routerPath}`);
}
function routeToRegex(route) {
const normalized = normalizeRoute(route);
const escaped = normalized
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
.replace(/\\:[^/]+/g, "[^/]+");
return new RegExp(`(^|["'\`\\s])${escaped}($|["'\`?#\\s])`);
}
function parseRouterRoutes() {
const source = readText(routerPath);
const routeRegex = /<Route\s+([^>]*?)>/gs;
const routes = [];
@@ -149,159 +101,352 @@ function parseRouterRoutes() {
if (pathMatch || indexRoute) {
const rawPath = pathMatch?.[1] ?? pathMatch?.[2] ?? "/";
const normalized = indexRoute && !pathMatch ? "/" : normalizeRoute(rawPath);
if (normalized !== "*" && normalized !== "/403") {
routes.push({ path: normalized });
}
routes.push(normalized);
}
match = routeRegex.exec(source);
}
return [...new Map(routes.map((route) => [route.path, route])).values()].sort((a, b) => a.path.localeCompare(b.path));
return [...new Set(routes)].sort((a, b) => a.localeCompare(b));
}
function parsePageTitles() {
const source = existsSync(navSchemaPath) ? readText(navSchemaPath) : "";
const titleRegex = /\{\s*path:\s*"([^"]+)",\s*title:\s*"([^"]+)",\s*group:\s*"([^"]+)"/g;
const titles = new Map();
let match = titleRegex.exec(source);
while (match) {
titles.set(normalizeRoute(match[1]), { title: match[2], group: match[3] });
match = titleRegex.exec(source);
}
return titles;
}
function parseSpecs() {
return walkFiles(e2eDir, (file) => file.endsWith(".spec.ts")).map((file) => {
const relPath = toPosix(path.relative(rootDir, file));
const source = readText(file);
const lower = `${relPath}\n${source}`.toLowerCase();
return {
path: relPath,
source,
lower,
isMobile: /mobile|\/mobile|移动/.test(lower),
isWecom: /wecom|wxwork|企业微信|weixinwork/.test(lower),
isVisual: /visual|screenshot|tohavescreenshot|视觉/.test(lower),
isFlow: /login\(|createauthenticatedapiclient|api(post|get|patch)data|workflow|approve|submit|上传|审批|报销|请假/.test(lower),
};
});
}
function parseServerRoutes() {
return walkFiles(serverRoutesDir, (file) => file.endsWith(".js")).map((file) => toPosix(path.relative(rootDir, file)));
}
function splitList(value) {
return value.split("|").map((item) => item.trim()).filter(Boolean);
}
function routeTokenMatches(pattern, route) {
const normalizedPattern = normalizeRoute(pattern);
const normalizedRoute = normalizeRoute(route);
if (normalizedPattern === normalizedRoute) return true;
if (normalizedPattern.includes(":")) {
return routeToRegex(normalizedPattern).test(normalizedRoute);
}
return normalizedRoute === normalizedPattern || normalizedRoute.startsWith(`${normalizedPattern}/`);
}
function domainForRoute(route, domains) {
return domains.find((domain) => {
const routeTokens = [
...splitList(domain.PC_Routes ?? ""),
...splitList(domain.Mobile_WeCom_Routes ?? ""),
];
return routeTokens.some((token) => routeTokenMatches(token, route));
}) ?? null;
}
function specMatchesRoute(spec, route, domain) {
function routeWitness(route) {
const normalized = normalizeRoute(route);
const keyword = routeToKeyword(normalized);
const routeRegex = routeToRegex(normalized);
if (routeRegex.test(spec.source)) return true;
if (keyword && keyword.split(" ").some((part) => part.length >= 5 && spec.lower.includes(part.toLowerCase()))) {
return true;
}
if (!domain) return false;
const domainKeywords = [
domain.Domain,
...splitList(domain.PC_Routes ?? ""),
...splitList(domain.Mobile_WeCom_Routes ?? ""),
...splitList(domain.Server_Routes ?? "").map((item) => path.basename(item, ".js")),
]
.join(" ")
.toLowerCase()
.split(/[\s/|:_-]+/)
.filter((item) => item.length >= 4);
return domainKeywords.some((part) => spec.lower.includes(part));
if (!normalized.includes(":")) return normalized;
return normalized.replace(/\/:[^/]+(?=\/|$)/g, "/").replace(/\/$/, "");
}
function classifyRoute(route, specs, domain, titleMeta) {
const matchedSpecs = specs.filter((spec) => specMatchesRoute(spec, route.path, domain));
const coverage = {
pc: matchedSpecs.some((spec) => !spec.isMobile && !route.path.startsWith("/mobile")),
mobile: matchedSpecs.some((spec) => spec.isMobile) || route.path.startsWith("/mobile"),
wecom: matchedSpecs.some((spec) => spec.isWecom),
visual: matchedSpecs.some((spec) => spec.isVisual),
flow: matchedSpecs.some((spec) => spec.isFlow),
};
function isManualOrLiveSpec(relativePath) {
const normalized = toPosix(relativePath).toLowerCase();
return normalized.includes("/manual/") || /(^|\/|[-_.])live([-_.]|\/|$)/.test(normalized);
}
function inspectSpec(rootDir, relativePath, route, kind, { requireRouteWitness = true } = {}) {
const normalizedRelativePath = toPosix(String(relativePath ?? "").trim());
if (!normalizedRelativePath) {
return { path: normalizedRelativePath, valid: false, reason: "empty_spec_path" };
}
if (isManualOrLiveSpec(normalizedRelativePath)) {
return { path: normalizedRelativePath, valid: false, reason: "manual_or_live" };
}
const absolutePath = path.resolve(rootDir, normalizedRelativePath);
const allowedRoot = `${path.resolve(rootDir)}${path.sep}`;
if (!absolutePath.startsWith(allowedRoot) || !existsSync(absolutePath) || !statSync(absolutePath).isFile()) {
return { path: normalizedRelativePath, valid: false, reason: "missing_spec_file" };
}
const source = readText(absolutePath);
if (!/\btest(?:\.describe)?\s*\(/.test(source)) {
return { path: normalizedRelativePath, valid: false, reason: "not_executable_spec" };
}
const witness = routeWitness(route);
if (requireRouteWitness && witness !== "/" && !source.includes(witness)) {
return { path: normalizedRelativePath, valid: false, reason: "missing_route_witness" };
}
if (kind === "visual" && !/(?:toHaveScreenshot|\.screenshot\s*\(|视觉)/.test(source)) {
return { path: normalizedRelativePath, valid: false, reason: "not_visual_spec" };
}
return { path: normalizedRelativePath, valid: true, reason: "" };
}
function inspectWitnessSpec(rootDir, flow) {
const base = inspectSpec(rootDir, flow?.witnessSpec, "/", "behavior", { requireRouteWitness: false });
if (!base.valid) return base;
const source = readText(path.resolve(rootDir, base.path));
const manifestDrivenContract = [
"LOCAL_CANDIDATE_MANIFEST_DRIVEN_WITNESSES",
"test(flow.witnessTag",
"loginToWitness(page, flow.witnessRoute)",
"expectUniqueH1(page, flow.expectedH1)",
"installSyntheticApis(page, flow.roleFixture)",
"expectPrimaryActionContract(page, flow)",
"installBrowserErrorMonitor(page)",
].every((token) => source.includes(token));
const literalContract = [
flow?.witnessTag,
flow?.witnessRoute,
flow?.expectedH1,
flow?.roleFixture,
flow?.primaryAction?.allowed,
flow?.primaryAction?.forbidden,
].every((token) => typeof token === "string" && token.length > 0 && source.includes(token));
if (!manifestDrivenContract && !literalContract) {
return { ...base, valid: false, reason: "wrong_witness_association" };
}
return base;
}
function validateManifestShape(manifest) {
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
throw new Error("route-manifest 顶层必须是对象");
}
if (manifest.schemaVersion !== 1) {
throw new Error("route-manifest.schemaVersion 必须为 1");
}
if (!Array.isArray(manifest.coreBundles) || !manifest.coreBundles.length) {
throw new Error("route-manifest.coreBundles 必须是非空数组");
}
if (!manifest.coreBundles.every((bundle) => typeof bundle === "string" && bundle.trim())) {
throw new Error("route-manifest.coreBundles 只能包含非空字符串");
}
if (!Array.isArray(manifest.routes)) {
throw new Error("route-manifest.routes 必须是数组");
}
if (!Array.isArray(manifest.localCandidateFlows)) {
throw new Error("route-manifest.localCandidateFlows 必须是数组");
}
if (!Array.isArray(manifest.acceptanceSpecs) || !manifest.acceptanceSpecs.length) {
throw new Error("route-manifest.acceptanceSpecs 必须是非空数组");
}
}
function validateRoute(route, manifestEntry, rootDir, coreBundleSet) {
if (!manifestEntry) {
return {
path: route,
classification: "unclassified",
bundle: "",
visualRequired: false,
behaviorSpecs: [],
visualSpecs: [],
gaps: ["unclassified"],
};
}
const gaps = [];
const isP0 = domain?.Tier === "P0";
if (!matchedSpecs.length) gaps.push("missing_e2e");
if (route.path.startsWith("/mobile") && !coverage.wecom) gaps.push("missing_wecom_assertion");
if (isP0 && !coverage.visual) gaps.push("missing_visual");
if (isP0 && !coverage.flow) gaps.push("missing_flow");
const classification = manifestEntry.classification;
if (!ROUTE_CLASSIFICATIONS.has(classification)) gaps.push("invalid_classification");
const behaviorChecks = (manifestEntry.behaviorSpecs ?? []).map((spec) => inspectSpec(rootDir, spec, route, "behavior"));
const visualChecks = (manifestEntry.visualSpecs ?? []).map((spec) => inspectSpec(rootDir, spec, route, "visual"));
const validBehaviorSpecs = behaviorChecks.filter((item) => item.valid).map((item) => item.path);
const validVisualSpecs = visualChecks.filter((item) => item.valid).map((item) => item.path);
const invalidBehaviorReasons = [...new Set(behaviorChecks.filter((item) => !item.valid).map((item) => item.reason))];
const invalidVisualReasons = [...new Set(visualChecks.filter((item) => !item.valid).map((item) => item.reason))];
if (classification === "core") {
if (!manifestEntry.bundle || !coreBundleSet.has(manifestEntry.bundle)) gaps.push("missing_core_bundle");
if (!validBehaviorSpecs.length) {
gaps.push(invalidBehaviorReasons.includes("manual_or_live") ? "manual_only" : "missing_behavior");
}
}
if (manifestEntry.visualRequired && !validVisualSpecs.length) {
gaps.push("missing_visual");
}
return {
path: route.path,
title: titleMeta?.title ?? "",
group: titleMeta?.group ?? "",
domain: domain?.Domain ?? "未映射",
tier: domain?.Tier ?? "unmapped",
coverage,
specs: matchedSpecs.map((spec) => spec.path),
path: route,
classification,
bundle: manifestEntry.bundle ?? "",
visualRequired: Boolean(manifestEntry.visualRequired),
behaviorSpecs: validBehaviorSpecs,
visualSpecs: validVisualSpecs,
evidenceProblems: {
behavior: invalidBehaviorReasons,
visual: invalidVisualReasons,
},
gaps,
};
}
function summarize(routes, domains, serverRoutes) {
const gapCounts = new Map();
for (const route of routes) {
for (const gap of route.gaps) {
gapCounts.set(gap, (gapCounts.get(gap) ?? 0) + 1);
}
function validateLocalCandidateFlow(bundle, flow, context) {
const gaps = [];
if (!flow) {
return {
bundle,
witnessRoute: "",
witnessTag: `@local-candidate:${bundle}`,
expectedH1: "",
roleFixture: "",
primaryAction: null,
witnessSpec: "",
evidenceProblems: ["missing_flow"],
covered: false,
gaps: ["missing_local_candidate_witness"],
};
}
const domainRows = domains.map((domain) => {
const domainRoutes = routes.filter((route) => route.domain === domain.Domain);
const expectedTag = `@local-candidate:${bundle}`;
if (flow.witnessTag !== expectedTag) gaps.push("invalid_witness_tag");
if (typeof flow.expectedH1 !== "string" || !flow.expectedH1.trim()) gaps.push("missing_expected_h1");
if (!ROLE_FIXTURES.has(flow.roleFixture)) gaps.push("invalid_role_fixture");
const allowedAction = flow.primaryAction?.allowed;
const forbiddenAction = flow.primaryAction?.forbidden;
if (
typeof allowedAction !== "string" || !allowedAction.trim() ||
typeof forbiddenAction !== "string" || !forbiddenAction.trim() ||
allowedAction === forbiddenAction
) {
gaps.push("invalid_primary_action");
}
const witnessRoute = normalizeRoute(flow.witnessRoute);
const registered = context.registeredRouteSet.has(witnessRoute);
const witnessRouteEntry = context.manifestEntries.get(witnessRoute);
if (!registered || !witnessRouteEntry) {
gaps.push("orphan_witness_route");
} else if (witnessRouteEntry.classification !== "core") {
gaps.push("witness_route_not_core");
} else if (witnessRouteEntry.bundle !== bundle) {
gaps.push("wrong_witness_association");
}
const witnessCheck = inspectWitnessSpec(context.rootDir, flow);
if (!witnessCheck.valid) {
const reasonMap = {
manual_or_live: "manual_or_live_witness",
missing_spec_file: "missing_witness_spec",
empty_spec_path: "missing_witness_spec",
wrong_witness_association: "wrong_witness_association",
};
gaps.push(reasonMap[witnessCheck.reason] ?? "invalid_witness_spec");
}
return {
bundle,
witnessRoute,
witnessTag: flow.witnessTag ?? "",
expectedH1: flow.expectedH1 ?? "",
roleFixture: flow.roleFixture ?? "",
primaryAction: flow.primaryAction ?? null,
witnessSpec: witnessCheck.valid ? witnessCheck.path : "",
evidenceProblems: witnessCheck.valid ? [] : [witnessCheck.reason],
covered: gaps.length === 0,
gaps: [...new Set(gaps)],
};
}
function duplicates(values) {
const seen = new Set();
const duplicateValues = new Set();
for (const value of values) {
if (seen.has(value)) duplicateValues.add(value);
seen.add(value);
}
return [...duplicateValues].sort();
}
export function buildReport(rootDir) {
const routerPath = path.join(rootDir, "client/src/router/index.tsx");
const manifestPath = path.join(rootDir, "scripts/qa/route-manifest.json");
if (!existsSync(manifestPath)) {
throw new Error(`路由 manifest 不存在:${manifestPath}`);
}
const manifest = readJson(manifestPath);
validateManifestShape(manifest);
const registeredRoutes = parseRouterRoutes(routerPath);
const manifestEntries = new Map();
const duplicateManifestRoutes = [];
for (const entry of manifest.routes) {
const normalizedPath = normalizeRoute(entry?.path);
if (manifestEntries.has(normalizedPath)) duplicateManifestRoutes.push(normalizedPath);
manifestEntries.set(normalizedPath, { ...entry, path: normalizedPath });
}
const duplicateCoreBundles = duplicates(manifest.coreBundles);
const coreBundleSet = new Set(manifest.coreBundles);
const routes = registeredRoutes.map((route) => validateRoute(route, manifestEntries.get(route), rootDir, coreBundleSet));
const registeredRouteSet = new Set(registeredRoutes);
const orphanManifestRoutes = [...manifestEntries.keys()].filter((route) => !registeredRouteSet.has(route));
const coveredBundles = new Set(routes.filter((route) => route.classification === "core" && route.behaviorSpecs.length).map((route) => route.bundle));
const missingCoreBundles = manifest.coreBundles.filter((bundle) => !coveredBundles.has(bundle));
const duplicateLocalCandidateBundles = duplicates(manifest.localCandidateFlows.map((flow) => flow?.bundle ?? ""));
const duplicateWitnessTags = duplicates(manifest.localCandidateFlows.map((flow) => flow?.witnessTag ?? ""));
const localCandidateFlowMap = new Map(manifest.localCandidateFlows.map((flow) => [flow?.bundle, flow]));
const localCandidateFlows = manifest.coreBundles.map((bundle) =>
validateLocalCandidateFlow(bundle, localCandidateFlowMap.get(bundle), {
rootDir,
registeredRouteSet,
manifestEntries,
}),
);
const missingLocalCandidateBundles = localCandidateFlows.filter((flow) => !flow.covered).map((flow) => flow.bundle);
const unknownLocalCandidateBundles = [...localCandidateFlowMap.keys()].filter((bundle) => !coreBundleSet.has(bundle));
const duplicateAcceptanceSpecs = duplicates(manifest.acceptanceSpecs.map((spec) => toPosix(String(spec ?? ""))));
const candidateSpecPaths = [...new Set([
...manifest.localCandidateFlows.map((flow) => toPosix(String(flow?.witnessSpec ?? ""))),
...manifest.acceptanceSpecs.map((spec) => toPosix(String(spec ?? ""))),
].filter(Boolean))];
const duplicateCandidateSpecBasenames = duplicates(
candidateSpecPaths.map((spec) => path.posix.basename(spec)),
);
const acceptanceSpecs = manifest.acceptanceSpecs.map((spec) => {
const check = inspectSpec(rootDir, spec, "/", "behavior", { requireRouteWitness: false });
return {
domain: domain.Domain,
tier: domain.Tier,
routeCount: domainRoutes.length,
gapCount: domainRoutes.reduce((total, route) => total + route.gaps.length, 0),
routes: domainRoutes.map((route) => route.path),
knownGaps: domain.Known_Gaps,
nextAction: domain.Next_Action,
path: check.path,
valid: check.valid,
reason: check.reason,
gaps: check.valid ? [] : [check.reason === "manual_or_live" ? "manual_or_live_acceptance_spec" : "invalid_acceptance_spec"],
};
});
const globalGaps = [];
if (duplicateCoreBundles.length) globalGaps.push("duplicate_core_bundle");
if (duplicateManifestRoutes.length) globalGaps.push("duplicate_manifest_route");
if (orphanManifestRoutes.length) globalGaps.push("orphan_manifest_route");
if (missingCoreBundles.length) globalGaps.push("missing_core_bundle_behavior");
if (duplicateLocalCandidateBundles.length) globalGaps.push("duplicate_local_candidate_bundle");
if (duplicateWitnessTags.length) globalGaps.push("duplicate_witness_tag");
if (unknownLocalCandidateBundles.length) globalGaps.push("unknown_local_candidate_bundle");
if (duplicateAcceptanceSpecs.length) globalGaps.push("duplicate_acceptance_spec");
if (duplicateCandidateSpecBasenames.length) globalGaps.push("duplicate_candidate_spec_basename");
const allGapLists = [
...routes.map((route) => route.gaps),
...localCandidateFlows.map((flow) => flow.gaps),
...acceptanceSpecs.map((spec) => spec.gaps),
globalGaps,
];
const gapCounts = new Map();
for (const gaps of allGapLists) {
for (const gap of gaps) gapCounts.set(gap, (gapCounts.get(gap) ?? 0) + 1);
}
const classificationCounts = Object.fromEntries(
[...ROUTE_CLASSIFICATIONS].map((classification) => [classification, routes.filter((route) => route.classification === classification).length]),
);
const blockingGapCount = allGapLists.reduce((count, gaps) => count + gaps.length, 0);
return {
generatedAt: new Date().toISOString(),
manifest: toPosix(path.relative(rootDir, manifestPath)),
summary: {
routeCount: routes.length,
domainCount: domains.length,
serverRouteCount: serverRoutes.length,
classificationCounts,
coreBundleCount: manifest.coreBundles.length,
coveredCoreBundleCount: coveredBundles.size,
coveredWitnessCount: localCandidateFlows.filter((flow) => flow.covered).length,
acceptanceSpecCount: acceptanceSpecs.length,
validAcceptanceSpecCount: acceptanceSpecs.filter((spec) => spec.valid).length,
blockingGapCount,
gapCounts: Object.fromEntries([...gapCounts.entries()].sort()),
highRiskGapCount: routes.filter((route) => route.tier === "P0" && route.gaps.length).length,
},
domains: domainRows,
coreBundles: manifest.coreBundles.map((bundle) => ({ bundle, covered: coveredBundles.has(bundle) })),
localCandidateFlows,
acceptanceSpecs,
duplicateCoreBundles,
duplicateManifestRoutes,
duplicateLocalCandidateBundles,
duplicateWitnessTags,
duplicateAcceptanceSpecs,
duplicateCandidateSpecBasenames,
orphanManifestRoutes,
missingCoreBundles,
missingLocalCandidateBundles,
unknownLocalCandidateBundles,
globalGaps,
routes,
serverRoutes,
};
}
@@ -309,57 +454,50 @@ function renderMarkdown(report) {
const gapSummary = Object.entries(report.summary.gapCounts)
.map(([gap, count]) => `- ${gap}: ${count}`)
.join("\n") || "- none: 0";
const bundleRows = report.localCandidateFlows
.map((item) => `| ${item.bundle} | ${item.witnessTag} | ${item.witnessRoute} | ${item.roleFixture} | ${item.covered ? "Y" : "N"} |`)
.join("\n");
const routeRows = report.routes
.map((route) => `| ${route.path} | ${route.domain} | ${route.tier} | ${route.coverage.pc ? "Y" : "N"} | ${route.coverage.mobile ? "Y" : "N"} | ${route.coverage.wecom ? "Y" : "N"} | ${route.coverage.visual ? "Y" : "N"} | ${route.coverage.flow ? "Y" : "N"} | ${route.gaps.join("<br>") || "-"} |`)
.map((route) => `| ${route.path} | ${route.classification} | ${route.bundle || "-"} | ${route.behaviorSpecs.length} | ${route.visualSpecs.length} | ${route.gaps.join("<br>") || "-"} |`)
.join("\n");
const domainRows = report.domains
.map((domain) => `| ${domain.domain} | ${domain.tier} | ${domain.routeCount} | ${domain.gapCount} | ${domain.nextAction || "-"} |`)
.join("\n");
return `# 项目级流程覆盖扫描
return `# 本地候选路由与流程覆盖扫描
- 生成时间:${report.generatedAt}
- 前端路由数:${report.summary.routeCount}
- 业务域数:${report.summary.domainCount}
- 后端 route 文件数:${report.summary.serverRouteCount}
- P0 含缺口路由数:${report.summary.highRiskGapCount}
- 注册路由数:${report.summary.routeCount}
- 核心流程束:${report.summary.coveredCoreBundleCount}/${report.summary.coreBundleCount}
- 可信 witness:${report.summary.coveredWitnessCount}/${report.summary.coreBundleCount}
- 验收 specs:${report.summary.validAcceptanceSpecCount}/${report.summary.acceptanceSpecCount}
- 阻断缺口数:${report.summary.blockingGapCount}
## 缺口汇总
${gapSummary}
## 业务域
## 核心流程束 witness
| Domain | Tier | Routes | Gaps | Next Action |
| --- | --- | ---: | ---: | --- |
${domainRows}
| Bundle | Tag | Route | Role fixture | Covered |
| --- | --- | --- | --- | --- |
${bundleRows}
## 路由覆盖
| Route | Domain | Tier | PC | Mobile | WeCom | Visual | Flow | Gaps |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Route | Classification | Bundle | Behavior | Visual | Gaps |
| --- | --- | --- | ---: | ---: | --- |
${routeRows}
`;
}
export function hasBlockingGaps(report) {
return report.summary.blockingGapCount > 0;
}
function main() {
const args = parseArgs();
const domains = parseCsv(readText(flowMatrixPath));
const routes = parseRouterRoutes();
const titles = parsePageTitles();
const specs = parseSpecs();
const serverRoutes = parseServerRoutes();
const classifiedRoutes = routes.map((route) => {
const domain = domainForRoute(route.path, domains);
const titleMeta = titles.get(route.path) ?? titles.get(`${route.path}/`);
return classifyRoute(route, specs, domain, titleMeta);
});
const report = summarize(classifiedRoutes, domains, serverRoutes);
const report = buildReport(args.rootDir);
if (args.markdownPath) {
const outputPath = path.resolve(rootDir, args.markdownPath);
const outputPath = path.resolve(args.rootDir, args.markdownPath);
mkdirSync(path.dirname(outputPath), { recursive: true });
writeFileSync(outputPath, renderMarkdown(report), "utf8");
console.error(`[flow-coverage] markdown=${outputPath}`);
@@ -369,19 +507,16 @@ function main() {
console.log(JSON.stringify(report, null, 2));
}
if (args.failOnGaps) {
const hasBlockingGap = report.routes.some(
(route) => route.tier === "P0" && (route.gaps.includes("missing_e2e") || route.gaps.includes("missing_visual")),
);
if (hasBlockingGap) {
process.exit(1);
}
if (args.failOnGaps && hasBlockingGaps(report)) {
process.exitCode = 1;
}
}
try {
main();
} catch (error) {
console.error(`[flow-coverage][ERROR] ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
try {
main();
} catch (error) {
console.error(`[flow-coverage][ERROR] ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}
+189
查看文件
@@ -0,0 +1,189 @@
{
"schemaVersion": 1,
"coreBundles": [
"login-logout",
"reimbursement",
"approval",
"invoice",
"procurement",
"project-contract",
"payment-ap",
"people-audit",
"attendance-archive",
"mobile-workspace"
],
"acceptanceSpecs": [
"client/tests/e2e/r102-reimbursement-main-flow.spec.ts",
"client/tests/e2e/r103-cross-page-ui-consistency.spec.ts",
"client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts",
"client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts",
"client/tests/e2e/internal-readiness-ui.spec.ts",
"client/tests/e2e/system-action-confirmation.spec.ts",
"client/tests/e2e/payment-reviewer-permissions.spec.ts"
],
"localCandidateFlows": [
{
"bundle": "login-logout",
"witnessRoute": "/login",
"witnessTag": "@local-candidate:login-logout",
"expectedH1": "海音达 OA",
"roleFixture": "employee",
"primaryAction": { "allowed": "登录", "forbidden": "应急超级管理员" },
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
},
{
"bundle": "reimbursement",
"witnessRoute": "/reimbursements",
"witnessTag": "@local-candidate:reimbursement",
"expectedH1": "我的报销",
"roleFixture": "employee",
"primaryAction": { "allowed": "新建报销", "forbidden": "导出" },
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
},
{
"bundle": "approval",
"witnessRoute": "/workflow/tasks",
"witnessTag": "@local-candidate:approval",
"expectedH1": "待我处理",
"roleFixture": "manager",
"primaryAction": { "allowed": "处理", "forbidden": "通过" },
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
},
{
"bundle": "invoice",
"witnessRoute": "/invoice-pool",
"witnessTag": "@local-candidate:invoice",
"expectedH1": "票据",
"roleFixture": "employee",
"primaryAction": { "allowed": "上传票据", "forbidden": "作废" },
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
},
{
"bundle": "procurement",
"witnessRoute": "/purchase-requests",
"witnessTag": "@local-candidate:procurement",
"expectedH1": "采购",
"roleFixture": "employee",
"primaryAction": { "allowed": "新建采购", "forbidden": "通过" },
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
},
{
"bundle": "project-contract",
"witnessRoute": "/contracts-registry",
"witnessTag": "@local-candidate:project-contract",
"expectedH1": "合同",
"roleFixture": "manager",
"primaryAction": { "allowed": "新增合同", "forbidden": "执行付款" },
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
},
{
"bundle": "payment-ap",
"witnessRoute": "/payment-batches",
"witnessTag": "@local-candidate:payment-ap",
"expectedH1": "付款",
"roleFixture": "finance",
"primaryAction": { "allowed": "新建批次", "forbidden": "复核批次" },
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
},
{
"bundle": "people-audit",
"witnessRoute": "/system/users",
"witnessTag": "@local-candidate:people-audit",
"expectedH1": "系统用户管理",
"roleFixture": "system_admin",
"primaryAction": { "allowed": "新建用户", "forbidden": "应急超级管理员" },
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
},
{
"bundle": "attendance-archive",
"witnessRoute": "/mobile/attendance",
"witnessTag": "@local-candidate:attendance-archive",
"expectedH1": "考勤",
"roleFixture": "employee",
"primaryAction": { "allowed": "修正申请", "forbidden": "提交应急修正" },
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
},
{
"bundle": "mobile-workspace",
"witnessRoute": "/mobile",
"witnessTag": "@local-candidate:mobile-workspace",
"expectedH1": "今天待办",
"roleFixture": "employee",
"primaryAction": { "allowed": "新建报销", "forbidden": "财务" },
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
}
],
"routes": [
{ "path": "*", "classification": "supported-noncore" },
{ "path": "/", "classification": "supported-noncore" },
{ "path": "/403", "classification": "supported-noncore" },
{ "path": "/announcements", "classification": "supported-noncore" },
{ "path": "/ap-operations", "classification": "core", "bundle": "payment-ap", "behaviorSpecs": ["client/tests/e2e/reimbursement-payment-batch-flow.spec.ts"] },
{ "path": "/assets", "classification": "supported-noncore", "visualRequired": true, "visualSpecs": ["client/tests/e2e/r103-cross-page-ui-consistency.spec.ts"] },
{ "path": "/attendance", "classification": "core", "bundle": "attendance-archive", "behaviorSpecs": ["client/tests/e2e/r75-automation-gap-fill.spec.ts"] },
{ "path": "/audit-logs", "classification": "core", "bundle": "people-audit", "behaviorSpecs": ["client/tests/e2e/settings-governance.spec.ts"] },
{ "path": "/budgets", "classification": "supported-noncore" },
{ "path": "/change-password", "classification": "supported-noncore" },
{ "path": "/contracts-registry", "classification": "core", "bundle": "project-contract", "behaviorSpecs": ["client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts"] },
{ "path": "/dashboard", "classification": "deferred" },
{ "path": "/delegations", "classification": "supported-noncore" },
{ "path": "/forgot-password", "classification": "supported-noncore" },
{ "path": "/inventory-checks", "classification": "supported-noncore" },
{ "path": "/invoice-pool", "classification": "core", "bundle": "invoice", "behaviorSpecs": ["client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts"] },
{ "path": "/leave-requests", "classification": "supported-noncore" },
{ "path": "/login", "classification": "core", "bundle": "login-logout", "behaviorSpecs": ["client/tests/e2e/login-portal-ux.spec.ts"] },
{ "path": "/mobile", "classification": "core", "bundle": "mobile-workspace", "behaviorSpecs": ["client/tests/e2e/r124-mobile-entry-matrix.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/r108-mobile-wecom-visual.spec.ts"] },
{ "path": "/mobile/approvals", "classification": "core", "bundle": "approval", "behaviorSpecs": ["client/tests/e2e/mobile-approvals-offline.spec.ts"] },
{ "path": "/mobile/approvals/:taskId", "classification": "core", "bundle": "approval", "behaviorSpecs": ["client/tests/e2e/mobile-approvals-offline.spec.ts"] },
{ "path": "/mobile/assets/scan", "classification": "supported-noncore" },
{ "path": "/mobile/attendance", "classification": "core", "bundle": "attendance-archive", "behaviorSpecs": ["client/tests/e2e/internal-readiness-ui.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/internal-readiness-ui.spec.ts"] },
{ "path": "/mobile/capture", "classification": "core", "bundle": "invoice", "behaviorSpecs": ["client/tests/e2e/r124-mobile-entry-matrix.spec.ts"] },
{ "path": "/mobile/leave", "classification": "supported-noncore" },
{ "path": "/mobile/leave/:leaveRequestId", "classification": "supported-noncore" },
{ "path": "/mobile/me", "classification": "core", "bundle": "mobile-workspace", "behaviorSpecs": ["client/tests/e2e/r124-mobile-entry-matrix.spec.ts"] },
{ "path": "/mobile/notifications", "classification": "core", "bundle": "mobile-workspace", "behaviorSpecs": ["client/tests/e2e/dg7-mobile-information-dedupe.spec.ts"] },
{ "path": "/mobile/profile", "classification": "supported-noncore" },
{ "path": "/mobile/purchase-requests", "classification": "core", "bundle": "procurement", "behaviorSpecs": ["client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts"] },
{ "path": "/mobile/purchase-requests/:purchaseRequestId", "classification": "core", "bundle": "procurement", "behaviorSpecs": ["client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts"] },
{ "path": "/mobile/reimbursements", "classification": "core", "bundle": "reimbursement", "behaviorSpecs": ["client/tests/e2e/r102-reimbursement-main-flow.spec.ts"] },
{ "path": "/mobile/reimbursements/:reimbursementId", "classification": "core", "bundle": "reimbursement", "behaviorSpecs": ["client/tests/e2e/r102-reimbursement-main-flow.spec.ts"] },
{ "path": "/mobile/wecom-approval-archive", "classification": "core", "bundle": "attendance-archive", "behaviorSpecs": ["client/tests/e2e/internal-readiness-ui.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/internal-readiness-ui.spec.ts"] },
{ "path": "/notifications", "classification": "supported-noncore" },
{ "path": "/organization", "classification": "supported-noncore" },
{ "path": "/payment-batches", "classification": "core", "bundle": "payment-ap", "behaviorSpecs": ["client/tests/e2e/reimbursement-payment-batch-flow.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts"] },
{ "path": "/payroll", "classification": "supported-noncore" },
{ "path": "/people", "classification": "core", "bundle": "people-audit", "behaviorSpecs": ["client/tests/e2e/r75-automation-gap-fill.spec.ts"] },
{ "path": "/people/:employeeId", "classification": "core", "bundle": "people-audit", "behaviorSpecs": ["client/tests/e2e/r75-automation-gap-fill.spec.ts"] },
{ "path": "/people/stats", "classification": "supported-noncore" },
{ "path": "/performance", "classification": "supported-noncore" },
{ "path": "/portal", "classification": "core", "bundle": "login-logout", "behaviorSpecs": ["client/tests/e2e/login-portal-ux.spec.ts"] },
{ "path": "/projects", "classification": "core", "bundle": "project-contract", "behaviorSpecs": ["client/tests/e2e/project-workbench.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/r103-cross-page-ui-consistency.spec.ts"] },
{ "path": "/projects/:projectId", "classification": "core", "bundle": "project-contract", "behaviorSpecs": ["client/tests/e2e/project-workbench.spec.ts"] },
{ "path": "/purchase-requests", "classification": "core", "bundle": "procurement", "behaviorSpecs": ["client/tests/e2e/procurement-flow.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/r103-cross-page-ui-consistency.spec.ts"] },
{ "path": "/reimbursements", "classification": "core", "bundle": "reimbursement", "behaviorSpecs": ["client/tests/e2e/r102-reimbursement-main-flow.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/r103-cross-page-ui-consistency.spec.ts"] },
{ "path": "/reimbursements/:reimbursementId", "classification": "core", "bundle": "reimbursement", "behaviorSpecs": ["client/tests/e2e/r102-reimbursement-main-flow.spec.ts"] },
{ "path": "/reimbursements/:reimbursementId/print", "classification": "supported-noncore" },
{ "path": "/reimbursements/dashboard", "classification": "supported-noncore" },
{ "path": "/settings/profile", "classification": "supported-noncore" },
{ "path": "/system/announcements", "classification": "supported-noncore" },
{ "path": "/system/approval-rules", "classification": "supported-noncore" },
{ "path": "/system/cutover", "classification": "frozen" },
{ "path": "/system/departments", "classification": "supported-noncore" },
{ "path": "/system/expense-standards", "classification": "supported-noncore" },
{ "path": "/system/feature-flags", "classification": "supported-noncore" },
{ "path": "/system/master-data", "classification": "supported-noncore" },
{ "path": "/system/notification-channels", "classification": "supported-noncore" },
{ "path": "/system/reimbursement-templates", "classification": "supported-noncore" },
{ "path": "/system/settings", "classification": "supported-noncore" },
{ "path": "/system/users", "classification": "core", "bundle": "people-audit", "behaviorSpecs": ["client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts"] },
{ "path": "/system/workflow-definitions", "classification": "supported-noncore" },
{ "path": "/system/workspace", "classification": "supported-noncore" },
{ "path": "/team", "classification": "supported-noncore" },
{ "path": "/todos", "classification": "supported-noncore" },
{ "path": "/travel-requests", "classification": "frozen" },
{ "path": "/wecom-approval-archive", "classification": "core", "bundle": "attendance-archive", "behaviorSpecs": ["client/tests/e2e/internal-readiness-ui.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/internal-readiness-ui.spec.ts"] },
{ "path": "/weekly-reports", "classification": "supported-noncore" },
{ "path": "/workflow/tasks", "classification": "core", "bundle": "approval", "behaviorSpecs": ["client/tests/e2e/workflow-action-confirm-ui.spec.ts"] },
{ "path": "/workflow/tasks/:taskId", "classification": "core", "bundle": "approval", "behaviorSpecs": ["client/tests/e2e/workflow-action-confirm-ui.spec.ts"] }
]
}
+165
查看文件
@@ -0,0 +1,165 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CLIENT_DIR="$ROOT_DIR/client"
MODE="${1:-full}"
DEV_SERVER_PID=""
DEV_SERVER_LOG=""
PLAYWRIGHT_RESULT_FILE=""
PLAYWRIGHT_STDERR_LOG=""
usage() {
printf '%s\n' \
"Usage: bash scripts/qa/run-local-candidate.sh [full|focused|list]" \
"" \
" full 完整基础门禁 + 路由反例 + 本地 Vite 行为测试(默认)" \
" focused 路由反例 + 本地 Vite 行为测试,不代表完整候选通过" \
" list 只解析 Playwright 测试资产,不代表行为通过"
}
cleanup() {
local exit_code="$?"
if [[ -n "$DEV_SERVER_PID" ]] && kill -0 "$DEV_SERVER_PID" 2>/dev/null; then
kill "$DEV_SERVER_PID" 2>/dev/null || true
wait "$DEV_SERVER_PID" 2>/dev/null || true
fi
if [[ "$exit_code" -ne 0 ]]; then
local vite_log_lines=0
local playwright_log_lines=0
if [[ -n "$DEV_SERVER_LOG" && -f "$DEV_SERVER_LOG" ]]; then
vite_log_lines="$(wc -l < "$DEV_SERVER_LOG" | tr -d ' ')"
fi
if [[ -n "$PLAYWRIGHT_STDERR_LOG" && -f "$PLAYWRIGHT_STDERR_LOG" ]]; then
playwright_log_lines="$(wc -l < "$PLAYWRIGHT_STDERR_LOG" | tr -d ' ')"
fi
echo "[local-candidate][ERROR] safe_summary exit=${exit_code} vite_log_lines=${vite_log_lines} playwright_log_lines=${playwright_log_lines}" >&2
fi
[[ -z "$DEV_SERVER_LOG" ]] || rm -f "$DEV_SERVER_LOG"
[[ -z "$PLAYWRIGHT_RESULT_FILE" ]] || rm -f "$PLAYWRIGHT_RESULT_FILE"
[[ -z "$PLAYWRIGHT_STDERR_LOG" ]] || rm -f "$PLAYWRIGHT_STDERR_LOG"
return "$exit_code"
}
trap cleanup EXIT
case "$MODE" in
full|focused|list) ;;
help|-h|--help)
usage
exit 0
;;
*)
usage >&2
exit 2
;;
esac
cd "$ROOT_DIR"
echo "=== [1/4] 路由 manifest 与反例测试 ==="
node --test \
scripts/qa/tests/check-minimal-profile.test.mjs \
scripts/qa/tests/check-flow-coverage.test.mjs \
scripts/qa/tests/validate-loopback-url.test.mjs \
scripts/qa/tests/verify-local-candidate-results.test.mjs
node scripts/qa/check-flow-coverage.mjs --local-candidate --json
if [[ "$MODE" == "full" ]]; then
echo "=== [2/4] 完整本地基础门禁 ==="
bash scripts/check-minimal.sh local-candidate
else
echo "=== [2/4] 完整本地基础门禁(focused/list 跳过) ==="
fi
mapfile -t CANDIDATE_SPECS < <(
node -e '
const manifest = require("./scripts/qa/route-manifest.json");
const expected = new Set(manifest.coreBundles ?? []);
const flows = manifest.localCandidateFlows ?? [];
const observed = new Set(flows.map((flow) => flow.bundle));
if (expected.size !== 10 || expected.size !== observed.size || [...expected].some((bundle) => !observed.has(bundle))) {
throw new Error("localCandidateFlows 未完整覆盖 coreBundles");
}
const witnessSpecs = flows.map((flow) => flow.witnessSpec);
const specs = [...new Set([...witnessSpecs, ...(manifest.acceptanceSpecs ?? [])])];
for (const spec of specs) {
if (typeof spec !== "string" || !spec.startsWith("client/tests/e2e/") || spec.includes("/manual/") || /live/i.test(spec)) {
throw new Error(`非法本地候选 spec: ${String(spec)}`);
}
console.log(spec.slice("client/".length));
}
'
)
if [[ "${#CANDIDATE_SPECS[@]}" -eq 0 ]]; then
echo "[local-candidate][ERROR] route-manifest 未登记 localCandidateFlows" >&2
exit 1
fi
if [[ "$MODE" == "list" ]]; then
echo "=== [3/4] Playwright 资产解析(不代表行为通过) ==="
(
cd "$CLIENT_DIR"
npx playwright test "${CANDIDATE_SPECS[@]}" --list
)
echo "=== [4/4] LIST_ONLY ==="
exit 0
fi
BASE_URL="${E2E_BASE_URL:-}"
if [[ -z "$BASE_URL" ]]; then
PORT="$(node scripts/qa/validate-loopback-url.mjs --port "${LOCAL_CANDIDATE_PORT:-5178}")"
BASE_URL="$(node scripts/qa/validate-loopback-url.mjs "http://127.0.0.1:${PORT}")"
DEV_SERVER_LOG="${TMPDIR:-/tmp}/hyb-oa-local-candidate-vite-${PORT}-$$.log"
(
cd "$CLIENT_DIR"
npm run dev -- --host 127.0.0.1 --port "$PORT" --strictPort
) >"$DEV_SERVER_LOG" 2>&1 &
DEV_SERVER_PID="$!"
VITE_READY=0
for _ in $(seq 1 60); do
if curl --fail --silent --show-error "$BASE_URL/login" >/dev/null 2>&1; then
VITE_READY=1
break
fi
if ! kill -0 "$DEV_SERVER_PID" 2>/dev/null; then
echo "[local-candidate][ERROR] 本地 Vite 启动失败" >&2
exit 1
fi
sleep 0.25
done
if [[ "$VITE_READY" -ne 1 ]]; then
echo "[local-candidate][ERROR] 本地 Vite 就绪超时" >&2
exit 1
fi
else
BASE_URL="$(node scripts/qa/validate-loopback-url.mjs "$BASE_URL")"
fi
echo "=== [3/4] 本地合成浏览器行为 ==="
PLAYWRIGHT_RESULT_FILE="$(mktemp "${TMPDIR:-/tmp}/hyb-oa-local-candidate-results.XXXXXX.json")"
PLAYWRIGHT_STDERR_LOG="$(mktemp "${TMPDIR:-/tmp}/hyb-oa-local-candidate-playwright.XXXXXX.log")"
PLAYWRIGHT_STATUS=0
(
cd "$CLIENT_DIR"
E2E_MODE=local E2E_BASE_URL="$BASE_URL" \
npx playwright test "${CANDIDATE_SPECS[@]}" --workers=1 --reporter=json
) >"$PLAYWRIGHT_RESULT_FILE" 2>"$PLAYWRIGHT_STDERR_LOG" || PLAYWRIGHT_STATUS="$?"
RESULT_GATE_STATUS=0
node scripts/qa/verify-local-candidate-results.mjs \
--manifest scripts/qa/route-manifest.json \
--results "$PLAYWRIGHT_RESULT_FILE" || RESULT_GATE_STATUS="$?"
if [[ "$PLAYWRIGHT_STATUS" -ne 0 || "$RESULT_GATE_STATUS" -ne 0 ]]; then
echo "[local-candidate][ERROR] browser_exit=${PLAYWRIGHT_STATUS} witness_result_gate=${RESULT_GATE_STATUS}" >&2
exit 1
fi
if [[ "$MODE" == "full" ]]; then
echo "=== [4/4] LOCAL_CANDIDATE_PASS ==="
else
echo "=== [4/4] FOCUSED_BROWSER_PASS ==="
fi
+66 -7
查看文件
@@ -19,6 +19,8 @@ QA_DB_DOCKER_CONTAINER="${QA_DB_DOCKER_CONTAINER:-hyb-oa-trialrun-db}"
QA_DOCKER_USE_SG="${QA_DOCKER_USE_SG:-0}"
if [[ "$MODE" == "auth-security-local" ]]; then
DEFAULT_QA_DB_PREFIX="hyb_oa_auth"
elif [[ "$MODE" == "payment-concurrency-local" ]]; then
DEFAULT_QA_DB_PREFIX="hyb_oa_payment"
else
DEFAULT_QA_DB_PREFIX="hyb_oa_webgate"
fi
@@ -46,7 +48,7 @@ qa_require_cmd() {
qa_usage() {
cat <<USAGE
Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|auth-security-local|business-flow-local|project-wide-visual-local|qg6-visual-matrix-local|r70-assist-local|r106-visual-local|r108-mobile-local|r109-mobile-local|r110-mobile-local|smoke-live>
Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|auth-security-local|payment-concurrency-local|business-flow-local|project-wide-visual-local|qg6-visual-matrix-local|r70-assist-local|r106-visual-local|r108-mobile-local|r109-mobile-local|r110-mobile-local|smoke-live>
可选环境变量:
QA_PORT / QA_HOST
@@ -223,13 +225,9 @@ qa_dropdb() {
return 0
fi
if ! qa_psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${QA_DB_NAME}' AND pid <> pg_backend_pid();" >/dev/null; then
echo "[ERROR] 临时数据库连接清理失败" >&2
return 1
fi
if [[ "$QA_DB_CLIENT" == "docker" ]]; then
if ! qa_docker exec -e "PGPASSWORD=$QA_DB_PASSWORD" "$QA_DB_DOCKER_CONTAINER" \
dropdb --if-exists -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null; then
dropdb --force --if-exists -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null; then
echo "[ERROR] 临时数据库删除失败" >&2
return 1
fi
@@ -237,7 +235,7 @@ qa_dropdb() {
return
fi
if ! PGPASSWORD="$QA_DB_PASSWORD" dropdb --if-exists -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null; then
if ! PGPASSWORD="$QA_DB_PASSWORD" dropdb --force --if-exists -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null; then
echo "[ERROR] 临时数据库删除失败" >&2
return 1
fi
@@ -391,6 +389,59 @@ qa_run_auth_security_local() {
)
}
qa_run_payment_concurrency_local() {
case "$QA_DB_HOST" in
127.0.0.1|localhost|::1) ;;
*)
echo "[ERROR] payment-concurrency-local 只允许 loopback PostgreSQL" >&2
exit 1
;;
esac
if [[ ! "$QA_DB_NAME" =~ ^hyb_oa_payment_[A-Za-z0-9_]+$ ]]; then
echo "[ERROR] payment-concurrency-local 数据库名必须使用 hyb_oa_payment_ 前缀" >&2
exit 1
fi
qa_select_port
export JOBS_ENABLED=false
export RATE_LIMIT_ENABLED=false
export TRUST_PROXY_HOPS=0
export PAYMENT_BATCH_ENABLED=true
export ERP_SYNC_ENABLED=false
export SERVE_CLIENT_DIST=false
unset JWT_SECRET AUTH_CODE_PEPPER FIELD_ENCRYPTION_KEY
qa_export_app_env
export SERVE_CLIENT_DIST=false
export METRICS_ENABLED=false
export EMAIL_ENABLED=false
export SMTP_HOST=""
export SMTP_PORT=""
export SMTP_SECURE=false
export SMTP_USER=""
export SMTP_PASS=""
export IM_NOTIFIER_ENABLED=false
export WECOM_ENABLED=false
export WECOM_AGENT_SECRET=""
export WECOM_CONTACT_SECRET=""
export WECOM_HR_ASSISTANT_SECRET=""
export OIDC_ENABLED=false
export OIDC_CLIENT_SECRET=""
export STORAGE_DRIVER=local
JWT_SECRET="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))')"
AUTH_CODE_PEPPER="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))')"
FIELD_ENCRYPTION_KEY="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("base64"))')"
export JWT_SECRET AUTH_CODE_PEPPER FIELD_ENCRYPTION_KEY
export PAYMENT_CONCURRENCY_DATABASE_CONFIRM="$QA_DB_NAME"
qa_prepare_db
echo ">>> 执行付款真实 PostgreSQL 并发回归"
(
cd "$SERVER_DIR"
RUN_PAYMENT_POSTGRES_CONCURRENCY=1 node src/scripts/check-payment-postgres-concurrency.js
)
}
qa_prepare_db() {
echo ">>> 生成并校验 Prisma Client"
(
@@ -725,6 +776,14 @@ case "$MODE" in
trap qa_cleanup EXIT
qa_run_auth_security_local
;;
payment-concurrency-local)
qa_require_cmd curl
qa_require_db_client
qa_require_cmd node
qa_require_cmd npm
trap qa_cleanup EXIT
qa_run_payment_concurrency_local
;;
business-flow-local)
qa_require_cmd curl
qa_require_db_client
@@ -0,0 +1,310 @@
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { buildReport, hasBlockingGaps } from "../check-flow-coverage.mjs";
const WITNESS_SPEC = `
import { test } from "@playwright/test";
export const LOCAL_CANDIDATE_MANIFEST_DRIVEN_WITNESSES = true;
for (const flow of localCandidateFlows) {
test(flow.witnessTag, async ({ page }) => {
installBrowserErrorMonitor(page);
installSyntheticApis(page, flow.roleFixture);
await loginToWitness(page, flow.witnessRoute);
await expectUniqueH1(page, flow.expectedH1);
await expectPrimaryActionContract(page, flow);
});
}
`;
function candidateFlow(bundle = "core-flow", witnessRoute = "/core", overrides = {}) {
return {
bundle,
witnessRoute,
witnessTag: `@local-candidate:${bundle}`,
expectedH1: "核心页面",
roleFixture: "employee",
primaryAction: { allowed: "开始", forbidden: "越权操作" },
witnessSpec: "client/tests/e2e/local-candidate-witnesses.spec.ts",
...overrides,
};
}
function writeFixture({
routes = ["/core"],
coreBundles = ["core-flow"],
manifestRoutes = [
{
path: "/core",
classification: "core",
bundle: "core-flow",
behaviorSpecs: ["client/tests/e2e/core.spec.ts"],
},
],
localCandidateFlows = [candidateFlow()],
acceptanceSpecs = ["client/tests/e2e/acceptance.spec.ts"],
specs = {},
} = {}) {
const root = mkdtempSync(path.join(tmpdir(), "hyb-flow-coverage-"));
const routerPath = path.join(root, "client/src/router/index.tsx");
const manifestPath = path.join(root, "scripts/qa/route-manifest.json");
mkdirSync(path.dirname(routerPath), { recursive: true });
mkdirSync(path.dirname(manifestPath), { recursive: true });
writeFileSync(
routerPath,
`export function RouterFixture() { return <Routes>${routes.map((route) => `<Route path="${route}" element={<div />} />`).join("")}</Routes>; }\n`,
"utf8",
);
writeFileSync(
manifestPath,
`${JSON.stringify({
schemaVersion: 1,
coreBundles,
acceptanceSpecs,
localCandidateFlows,
routes: manifestRoutes,
}, null, 2)}\n`,
"utf8",
);
const defaultSpecs = {
"client/tests/e2e/core.spec.ts": 'import { test } from "@playwright/test";\ntest("core", async ({ page }) => { await page.goto("/core"); });\n',
"client/tests/e2e/acceptance.spec.ts": 'import { test } from "@playwright/test";\ntest("acceptance", async ({ page }) => { await page.goto("/core"); });\n',
"client/tests/e2e/local-candidate-witnesses.spec.ts": WITNESS_SPEC,
...specs,
};
for (const [relativePath, source] of Object.entries(defaultSpecs)) {
if (source == null) continue;
const file = path.join(root, relativePath);
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, source, "utf8");
}
return root;
}
function runFixture(root) {
const report = buildReport(root);
return { status: hasBlockingGaps(report) ? 1 : 0, report };
}
function withFixture(options, callback) {
const root = writeFixture(options);
try {
callback(root);
} finally {
rmSync(root, { recursive: true, force: true });
}
}
test("完整 manifest、acceptance spec 与可信 witness 通过", () => {
withFixture({}, (root) => {
const result = runFixture(root);
assert.equal(result.status, 0);
assert.equal(result.report.summary.blockingGapCount, 0);
assert.equal(result.report.summary.coveredCoreBundleCount, 1);
assert.equal(result.report.summary.coveredWitnessCount, 1);
assert.equal(result.report.summary.validAcceptanceSpecCount, 1);
});
});
test("未分类注册路由必须阻断", () => {
withFixture({ routes: ["/core", "/unmapped"] }, (root) => {
const result = runFixture(root);
assert.equal(result.status, 1);
assert.equal(result.report.summary.gapCounts.unclassified, 1);
});
});
test("403 与 wildcard 注册路由不能从分类扫描中豁免", () => {
withFixture({ routes: ["/core", "/403", "*"] }, (root) => {
const result = runFixture(root);
assert.equal(result.status, 1);
assert.equal(result.report.summary.routeCount, 3);
assert.equal(result.report.summary.gapCounts.unclassified, 2);
});
});
test("manual 或 live spec 不能充当核心行为证据", () => {
withFixture({
manifestRoutes: [
{
path: "/core",
classification: "core",
bundle: "core-flow",
behaviorSpecs: ["client/tests/e2e/manual/core.spec.ts"],
},
],
specs: {
"client/tests/e2e/manual/core.spec.ts": 'import { test } from "@playwright/test";\ntest("manual", async ({ page }) => { await page.goto("/core"); });\n',
},
}, (root) => {
const result = runFixture(root);
assert.equal(result.status, 1);
assert.equal(result.report.summary.gapCounts.manual_only, 1);
});
});
test("核心路由缺失行为测试必须阻断", () => {
withFixture({
manifestRoutes: [{ path: "/core", classification: "core", bundle: "core-flow" }],
}, (root) => {
const result = runFixture(root);
assert.equal(result.status, 1);
assert.equal(result.report.summary.gapCounts.missing_behavior, 1);
});
});
test("visualRequired 缺失可执行视觉 spec 必须阻断", () => {
withFixture({
manifestRoutes: [
{
path: "/core",
classification: "core",
bundle: "core-flow",
behaviorSpecs: ["client/tests/e2e/core.spec.ts"],
visualRequired: true,
},
],
}, (root) => {
const result = runFixture(root);
assert.equal(result.status, 1);
assert.equal(result.report.summary.gapCounts.missing_visual, 1);
});
});
test("核心 bundle 缺失 witness 必须阻断", () => {
withFixture({ localCandidateFlows: [] }, (root) => {
const result = runFixture(root);
assert.equal(result.status, 1);
assert.equal(result.report.summary.gapCounts.missing_local_candidate_witness, 1);
});
});
test("重复 bundle 与重复 witness tag 必须阻断", () => {
withFixture({ localCandidateFlows: [candidateFlow(), candidateFlow()] }, (root) => {
const result = runFixture(root);
assert.equal(result.status, 1);
assert.equal(result.report.summary.gapCounts.duplicate_local_candidate_bundle, 1);
assert.equal(result.report.summary.gapCounts.duplicate_witness_tag, 1);
});
});
test("未知 local-candidate bundle 必须阻断", () => {
withFixture({
localCandidateFlows: [candidateFlow(), candidateFlow("unknown-flow", "/core")],
}, (root) => {
const result = runFixture(root);
assert.equal(result.status, 1);
assert.equal(result.report.summary.gapCounts.unknown_local_candidate_bundle, 1);
});
});
test("重复 core bundle 必须阻断", () => {
withFixture({ coreBundles: ["core-flow", "core-flow"] }, (root) => {
const result = runFixture(root);
assert.equal(result.status, 1);
assert.equal(result.report.summary.gapCounts.duplicate_core_bundle, 1);
});
});
test("orphan manifest 路由必须阻断", () => {
withFixture({
manifestRoutes: [
{
path: "/core",
classification: "core",
bundle: "core-flow",
behaviorSpecs: ["client/tests/e2e/core.spec.ts"],
},
{ path: "/orphan", classification: "supported-noncore" },
],
}, (root) => {
const result = runFixture(root);
assert.equal(result.status, 1);
assert.equal(result.report.summary.gapCounts.orphan_manifest_route, 1);
});
});
test("witnessRoute 指向未注册路由必须阻断", () => {
withFixture({ localCandidateFlows: [candidateFlow("core-flow", "/missing")] }, (root) => {
const result = runFixture(root);
assert.equal(result.status, 1);
assert.equal(result.report.summary.gapCounts.orphan_witness_route, 1);
});
});
test("witnessRoute 绑定到其他 bundle 必须阻断", () => {
const otherSpec = 'import { test } from "@playwright/test";\ntest("other", async ({ page }) => { await page.goto("/other"); });\n';
withFixture({
routes: ["/core", "/other"],
coreBundles: ["core-flow", "other-flow"],
manifestRoutes: [
{
path: "/core",
classification: "core",
bundle: "core-flow",
behaviorSpecs: ["client/tests/e2e/core.spec.ts"],
},
{
path: "/other",
classification: "core",
bundle: "other-flow",
behaviorSpecs: ["client/tests/e2e/other.spec.ts"],
},
],
localCandidateFlows: [candidateFlow("core-flow", "/other"), candidateFlow("other-flow", "/other")],
specs: { "client/tests/e2e/other.spec.ts": otherSpec },
}, (root) => {
const result = runFixture(root);
assert.equal(result.status, 1);
assert.equal(result.report.summary.gapCounts.wrong_witness_association, 1);
});
});
test("witness 字段不完整或 tag 不稳定必须阻断", () => {
withFixture({
localCandidateFlows: [candidateFlow("core-flow", "/core", {
witnessTag: "@local-candidate:wrong",
expectedH1: "",
roleFixture: "unknown",
primaryAction: { allowed: "相同", forbidden: "相同" },
})],
}, (root) => {
const result = runFixture(root);
assert.equal(result.status, 1);
assert.equal(result.report.summary.gapCounts.invalid_witness_tag, 1);
assert.equal(result.report.summary.gapCounts.missing_expected_h1, 1);
assert.equal(result.report.summary.gapCounts.invalid_role_fixture, 1);
assert.equal(result.report.summary.gapCounts.invalid_primary_action, 1);
});
});
test("manual/live acceptance spec 必须阻断", () => {
withFixture({
acceptanceSpecs: ["client/tests/e2e/manual/acceptance.spec.ts"],
specs: {
"client/tests/e2e/manual/acceptance.spec.ts": 'import { test } from "@playwright/test";\ntest("manual", async () => {});\n',
},
}, (root) => {
const result = runFixture(root);
assert.equal(result.status, 1);
assert.equal(result.report.summary.gapCounts.manual_or_live_acceptance_spec, 1);
});
});
test("候选 spec 不同路径使用同一 basename 必须阻断", () => {
withFixture({
acceptanceSpecs: ["client/tests/e2e/nested/local-candidate-witnesses.spec.ts"],
specs: {
"client/tests/e2e/nested/local-candidate-witnesses.spec.ts":
'import { test } from "@playwright/test";\ntest("acceptance", async ({ page }) => { await page.goto("/core"); });\n',
},
}, (root) => {
const result = runFixture(root);
assert.equal(result.status, 1);
assert.deepEqual(result.report.duplicateCandidateSpecBasenames, ["local-candidate-witnesses.spec.ts"]);
assert.equal(result.report.summary.gapCounts.duplicate_candidate_spec_basename, 1);
});
});
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import {
chmodSync,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
const repoRoot = path.resolve(new URL("../../..", import.meta.url).pathname);
const sourceScript = path.join(repoRoot, "scripts/check-minimal.sh");
const localCandidateRunner = path.join(repoRoot, "scripts/qa/run-local-candidate.sh");
function writeExecutable(file, source) {
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, source, "utf8");
chmodSync(file, 0o755);
}
test("local-candidate 基础 profile 在没有 issues 目录时不调用历史 CSV 门禁", () => {
const fixtureRoot = mkdtempSync(path.join(tmpdir(), "hyb-local-candidate-profile-"));
try {
const scriptSource = readFileSync(sourceScript, "utf8");
const fixtureScript = path.join(fixtureRoot, "scripts/check-minimal.sh");
writeExecutable(fixtureScript, scriptSource);
mkdirSync(path.join(fixtureRoot, "server"), { recursive: true });
mkdirSync(path.join(fixtureRoot, "client"), { recursive: true });
for (const match of scriptSource.matchAll(/\bbash -n ([^\s]+)/gu)) {
const relativeFile = match[1];
for (const base of [fixtureRoot, path.join(fixtureRoot, "server")]) {
const file = path.join(base, relativeFile);
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, "#!/usr/bin/env bash\n", "utf8");
}
}
const binDir = path.join(fixtureRoot, "bin");
const commandLog = path.join(fixtureRoot, "commands.log");
writeExecutable(path.join(binDir, "node"), `#!/usr/bin/env bash
printf 'node %s\\n' "$*" >> "$CHECK_MINIMAL_COMMAND_LOG"
if [[ "$*" == *check-process-contract.mjs* ]]; then exit 91; fi
exit 0
`);
writeExecutable(path.join(binDir, "npm"), `#!/usr/bin/env bash
printf 'npm %s\\n' "$*" >> "$CHECK_MINIMAL_COMMAND_LOG"
exit 0
`);
writeExecutable(path.join(binDir, "python3"), `#!/usr/bin/env bash
printf 'python3 %s\\n' "$*" >> "$CHECK_MINIMAL_COMMAND_LOG"
exit 92
`);
const result = spawnSync("bash", [fixtureScript, "local-candidate"], {
cwd: fixtureRoot,
encoding: "utf8",
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH ?? ""}`,
CHECK_MINIMAL_COMMAND_LOG: commandLog,
},
});
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
const commands = readFileSync(commandLog, "utf8");
assert.doesNotMatch(commands, /check-process-contract|validate-feature-matrix|python3/u);
assert.equal(existsSync(path.join(fixtureRoot, "issues")), false);
} finally {
rmSync(fixtureRoot, { recursive: true, force: true });
}
});
test("local-candidate 结果标签严格区分 full、focused 与 list", () => {
const source = readFileSync(localCandidateRunner, "utf8");
const blockStart = source.lastIndexOf('if [[ "$MODE" == "full" ]]; then');
const blockEnd = source.indexOf("\nfi", blockStart);
assert.notEqual(blockStart, -1, "缺少 full/focused 结果标签分支");
assert.notEqual(blockEnd, -1, "full/focused 结果标签分支不完整");
const resultLabelBlock = source.slice(blockStart, blockEnd + "\nfi".length);
const runLabelBlock = (mode) => spawnSync("bash", ["-c", resultLabelBlock], {
encoding: "utf8",
env: { ...process.env, MODE: mode },
});
const full = runLabelBlock("full");
const focused = runLabelBlock("focused");
assert.equal(full.status, 0, full.stderr);
assert.match(full.stdout, /LOCAL_CANDIDATE_PASS/u);
assert.doesNotMatch(full.stdout, /FOCUSED_BROWSER_PASS/u);
assert.equal(focused.status, 0, focused.stderr);
assert.match(focused.stdout, /FOCUSED_BROWSER_PASS/u);
assert.doesNotMatch(focused.stdout, /LOCAL_CANDIDATE_PASS/u);
assert.equal(source.match(/LOCAL_CANDIDATE_PASS/gu)?.length, 1);
assert.match(
source,
/if \[\[ "\$MODE" == "list" \]\]; then[\s\S]*?LIST_ONLY[\s\S]*?exit 0[\s\S]*?\nfi/u,
);
});
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import test from "node:test";
import { validateLoopbackHttpUrl, validateTcpPort } from "../validate-loopback-url.mjs";
for (const [input, expected] of [
["http://127.0.0.1:5178", "http://127.0.0.1:5178"],
["http://localhost:3000/", "http://localhost:3000"],
["http://[::1]:4173", "http://[::1]:4173"],
]) {
test(`accepts loopback origin ${input}`, () => {
assert.equal(validateLoopbackHttpUrl(input), expected);
});
}
for (const input of ["1", "80", "5178", "65535"]) {
test(`accepts canonical TCP port ${input}`, () => {
assert.equal(validateTcpPort(input), input);
});
}
for (const input of ["", "0", "065", "65536", "5178/path", "[email protected]", "1.5", "-1"]) {
test(`rejects ambiguous or invalid TCP port ${input || "<empty>"}`, () => {
assert.throws(() => validateTcpPort(input));
});
}
for (const input of [
"https://127.0.0.1:5178",
"http://example.invalid:5178",
"http://localhost.example.invalid:5178",
"http://[email protected]:5178",
"http://remote.invalid@localhost:5178",
"http://user:[email protected]:5178",
"http://127.0.0.1:5178/path",
"not-a-url",
]) {
test(`rejects non-loopback or ambiguous target ${input}`, () => {
assert.throws(() => validateLoopbackHttpUrl(input));
});
}
@@ -0,0 +1,189 @@
import assert from "node:assert/strict";
import test from "node:test";
import { verifyLocalCandidateResults } from "../verify-local-candidate-results.mjs";
const bundles = [
"login-logout",
"reimbursement",
"approval",
"invoice",
"procurement",
"project-contract",
"payment-ap",
"people-audit",
"attendance-archive",
"mobile-workspace",
];
function manifest() {
return {
localCandidateFlows: bundles.map((bundle) => ({
bundle,
witnessTag: `@local-candidate:${bundle}`,
witnessSpec: "client/tests/e2e/synthetic-local-candidate.spec.ts",
})),
};
}
function spec(tag, status = "passed", overrides = {}) {
return {
title: tag,
file: "client/tests/e2e/synthetic-local-candidate.spec.ts",
line: 12,
tags: [],
tests: [
{
expectedStatus: "passed",
results: [{ status }],
},
],
...overrides,
};
}
function results(specs, overrides = {}) {
return {
suites: [{ title: "local candidate", specs }],
errors: [],
stats: { expected: specs.length, skipped: 0, unexpected: 0 },
...overrides,
};
}
test("10 个唯一 witness tag 全部 passed 时通过", () => {
const summary = verifyLocalCandidateResults(
manifest(),
results(bundles.map((bundle) => spec(`@local-candidate:${bundle}`))),
);
assert.equal(summary.ok, true);
assert.equal(summary.passedTagCount, 10);
assert.equal(summary.skippedTagCount, 0);
});
test("结果缺失 tag 必须阻断", () => {
const summary = verifyLocalCandidateResults(
manifest(),
results(bundles.slice(1).map((bundle) => spec(`@local-candidate:${bundle}`))),
);
assert.equal(summary.ok, false);
assert.deepEqual(summary.missingTags, ["@local-candidate:login-logout"]);
assert.ok(summary.issues.includes("missing_tag_result"));
});
test("重复 tag 结果必须阻断", () => {
const specs = bundles.map((bundle) => spec(`@local-candidate:${bundle}`));
specs.push(spec("@local-candidate:login-logout"));
const summary = verifyLocalCandidateResults(manifest(), results(specs));
assert.equal(summary.ok, false);
assert.deepEqual(summary.duplicateTags, ["@local-candidate:login-logout"]);
});
test("未知 tag 结果必须阻断", () => {
const specs = bundles.map((bundle) => spec(`@local-candidate:${bundle}`));
specs.push(spec("@local-candidate:unknown"));
const summary = verifyLocalCandidateResults(manifest(), results(specs));
assert.equal(summary.ok, false);
assert.deepEqual(summary.unknownTags, ["@local-candidate:unknown"]);
});
test("全部 witness skipped 必须阻断", () => {
const summary = verifyLocalCandidateResults(
manifest(),
results(bundles.map((bundle) => spec(`@local-candidate:${bundle}`, "skipped"))),
);
assert.equal(summary.ok, false);
assert.equal(summary.skippedTagCount, 10);
assert.equal(summary.passedTagCount, 0);
assert.ok(summary.issues.includes("skipped_tag_result"));
});
test("任一 witness 或整体 Playwright suite 失败必须阻断", () => {
const specs = bundles.map((bundle) =>
spec(`@local-candidate:${bundle}`, bundle === "payment-ap" ? "failed" : "passed"),
);
const summary = verifyLocalCandidateResults(
manifest(),
results(specs, { errors: [{}], stats: { expected: 9, skipped: 0, unexpected: 1 } }),
);
assert.equal(summary.ok, false);
assert.deepEqual(summary.failedTags, ["@local-candidate:payment-ap"]);
assert.ok(summary.issues.includes("playwright_suite_failed"));
assert.deepEqual(summary.failedTests, [{
file: "synthetic-local-candidate.spec.ts",
line: 12,
title: "@local-candidate:payment-ap",
status: "failed",
}]);
});
test("非 witness acceptance 用例 skipped 也必须阻断且诊断不泄露绝对路径", () => {
const specs = bundles.map((bundle) => spec(`@local-candidate:${bundle}`));
specs.push(spec("本地附加验收", "skipped", {
file: "/private/workspace/client/tests/e2e/acceptance.spec.ts",
line: 27,
}));
const summary = verifyLocalCandidateResults(
manifest(),
results(specs, { stats: { expected: 10, skipped: 1, unexpected: 0 } }),
);
assert.equal(summary.ok, false);
assert.ok(summary.issues.includes("playwright_suite_skipped"));
assert.equal(summary.suiteSkippedTestCount, 1);
assert.deepEqual(summary.skippedTests, [{
file: "acceptance.spec.ts",
line: 27,
title: "本地附加验收",
status: "skipped",
}]);
});
test("单一测试合并多个 witness tag 不能冒充十个独立流程", () => {
const mergedTitle = bundles.map((bundle) => `@local-candidate:${bundle}`).join(" ");
const summary = verifyLocalCandidateResults(manifest(), results([spec(mergedTitle)]));
assert.equal(summary.ok, false);
assert.equal(summary.totalTestResultCount, 1);
assert.ok(summary.issues.includes("multi_tag_test_result"));
assert.equal(summary.multiTagTests.length, 1);
});
test("witness tag 必须来自 manifest 指定的测试文件", () => {
const specs = bundles.map((bundle) => spec(`@local-candidate:${bundle}`));
specs[0] = spec("@local-candidate:login-logout", "passed", {
file: "/private/workspace/client/tests/e2e/unrelated.spec.ts",
});
const summary = verifyLocalCandidateResults(manifest(), results(specs));
assert.equal(summary.ok, false);
assert.ok(summary.issues.includes("wrong_witness_spec_result"));
assert.deepEqual(summary.wrongWitnessSpecs, [{
tag: "@local-candidate:login-logout",
expectedFile: "synthetic-local-candidate.spec.ts",
observedFile: "unrelated.spec.ts",
}]);
});
test("Playwright JSON 将 file 放在父 suite 时仍能核对 witness 来源", () => {
const specs = bundles.map((bundle) => {
const item = spec(`@local-candidate:${bundle}`);
delete item.file;
return item;
});
const report = results([], {
suites: [{
title: "local candidate witnesses",
file: "tests/e2e/synthetic-local-candidate.spec.ts",
specs,
}],
});
const summary = verifyLocalCandidateResults(manifest(), report);
assert.equal(summary.ok, true);
assert.deepEqual(summary.wrongWitnessSpecs, []);
});
test("Playwright JSON 只提供 spec basename 时按 manifest 唯一 basename 核对", () => {
const specs = bundles.map((bundle) => spec(`@local-candidate:${bundle}`, "passed", {
file: "synthetic-local-candidate.spec.ts",
}));
const summary = verifyLocalCandidateResults(manifest(), results(specs));
assert.equal(summary.ok, true);
assert.deepEqual(summary.wrongWitnessSpecs, []);
});
+44
查看文件
@@ -0,0 +1,44 @@
#!/usr/bin/env node
import path from "node:path";
import { pathToFileURL } from "node:url";
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
export function validateLoopbackHttpUrl(input) {
let url;
try {
url = new URL(String(input ?? ""));
} catch {
throw new Error("base_url_invalid");
}
if (url.protocol !== "http:") throw new Error("base_url_protocol_not_http");
if (url.username || url.password) throw new Error("base_url_credentials_forbidden");
if (!LOOPBACK_HOSTS.has(url.hostname)) throw new Error("base_url_host_not_loopback");
if ((url.pathname && url.pathname !== "/") || url.search || url.hash) {
throw new Error("base_url_must_be_origin_only");
}
return url.origin;
}
export function validateTcpPort(input) {
const raw = String(input ?? "");
if (!/^[1-9][0-9]{0,4}$/u.test(raw)) throw new Error("port_invalid");
const port = Number(raw);
if (!Number.isSafeInteger(port) || port > 65535) throw new Error("port_out_of_range");
return String(port);
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
try {
console.log(
process.argv[2] === "--port"
? validateTcpPort(process.argv[3])
: validateLoopbackHttpUrl(process.argv[2]),
);
} catch (error) {
console.error(`[local-candidate][ERROR] ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}
@@ -0,0 +1,199 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const WITNESS_TAG_PATTERN = /@local-candidate:[a-z0-9-]+/g;
const FAILED_STATUSES = new Set(["failed", "timedOut", "interrupted"]);
function collectSpecs(suites, output = [], inheritedFile = "") {
for (const suite of Array.isArray(suites) ? suites : []) {
const suiteFile = suite?.file ?? inheritedFile;
for (const spec of Array.isArray(suite?.specs) ? suite.specs : []) {
output.push({ ...spec, __sourceFile: spec?.file ?? suiteFile });
}
collectSpecs(suite?.suites, output, suiteFile);
}
return output;
}
function tagsForSpec(spec) {
const candidates = [
...(String(spec?.title ?? "").match(WITNESS_TAG_PATTERN) ?? []),
...(Array.isArray(spec?.tags) ? spec.tags : []).flatMap((tag) => String(tag).match(WITNESS_TAG_PATTERN) ?? []),
];
return [...new Set(candidates)];
}
function finalStatus(test) {
const results = Array.isArray(test?.results) ? test.results : [];
return results.at(-1)?.status ?? test?.status ?? "missing";
}
function safeDiagnosticText(value, maxLength = 160) {
return String(value ?? "")
.replace(/[\r\n\t]+/gu, " ")
.trim()
.slice(0, maxLength);
}
function safeSpecDiagnostic(spec, status) {
const line = Number(spec?.line);
return {
file: path.basename(safeDiagnosticText(spec?.__sourceFile ?? spec?.file, 240)) || "unknown-spec",
line: Number.isSafeInteger(line) && line > 0 ? line : null,
title: safeDiagnosticText(spec?.title) || "unknown-test",
status,
};
}
function normalizeWitnessSpecFile(file) {
const normalized = String(file ?? "").replaceAll("\\", "/");
if (normalized.startsWith("client/tests/e2e/")) return normalized;
if (normalized.startsWith("tests/e2e/")) return `client/${normalized}`;
const marker = "/client/tests/e2e/";
const markerIndex = normalized.lastIndexOf(marker);
if (markerIndex >= 0) return normalized.slice(markerIndex + 1);
if (/^[^/]+\.spec\.[cm]?[jt]sx?$/u.test(normalized)) return normalized;
return "";
}
function witnessSpecMatches(observedFile, expectedFile) {
if (!observedFile || !expectedFile) return false;
if (observedFile.includes("/")) return observedFile === expectedFile;
return observedFile === path.basename(expectedFile);
}
function duplicates(values) {
const counts = new Map();
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
return [...counts.entries()].filter(([, count]) => count > 1).map(([value]) => value).sort();
}
export function verifyLocalCandidateResults(manifest, results, { expectedTagCount = 10 } = {}) {
const flows = Array.isArray(manifest?.localCandidateFlows) ? manifest.localCandidateFlows : [];
const expectedTags = flows.map((flow) => flow?.witnessTag).filter((tag) => typeof tag === "string");
const expectedTagSet = new Set(expectedTags);
const expectedSpecByTag = new Map(
flows.map((flow) => [flow?.witnessTag, normalizeWitnessSpecFile(flow?.witnessSpec)]),
);
const manifestDuplicateTags = duplicates(expectedTags);
const manifestInvalidTags = flows
.filter((flow) => flow?.witnessTag !== `@local-candidate:${flow?.bundle ?? ""}`)
.map((flow) => String(flow?.witnessTag ?? ""))
.sort();
const observed = [];
const allTests = [];
const multiTagTests = [];
const wrongWitnessSpecs = [];
for (const spec of collectSpecs(results?.suites)) {
const tags = tagsForSpec(spec);
const specFile = normalizeWitnessSpecFile(spec?.__sourceFile ?? spec?.file);
const tests = Array.isArray(spec?.tests) ? spec.tests : [];
for (const test of tests) {
const status = finalStatus(test);
allTests.push(safeSpecDiagnostic(spec, status));
if (tags.length > 1) multiTagTests.push(safeSpecDiagnostic(spec, status));
for (const tag of tags) {
observed.push({ tag, status });
const expectedSpec = expectedSpecByTag.get(tag);
if (!witnessSpecMatches(specFile, expectedSpec)) {
wrongWitnessSpecs.push({
tag,
expectedFile: path.basename(expectedSpec || "unknown-spec"),
observedFile: path.basename(specFile || "unknown-spec"),
});
}
}
}
}
const observedTags = observed.map((item) => item.tag);
const duplicateTags = duplicates(observedTags);
const unknownTags = [...new Set(observedTags.filter((tag) => !expectedTagSet.has(tag)))].sort();
const missingTags = expectedTags.filter((tag) => !observedTags.includes(tag)).sort();
const skippedTags = [...new Set(observed.filter((item) => item.status === "skipped").map((item) => item.tag))].sort();
const failedTags = [...new Set(observed.filter((item) => item.status !== "passed" && item.status !== "skipped").map((item) => item.tag))].sort();
const passedTags = [...new Set(observed.filter((item) => item.status === "passed").map((item) => item.tag))].sort();
const suiteFailedTestCount = allTests.filter((item) => FAILED_STATUSES.has(item.status)).length;
const suiteSkippedTestCount = allTests.filter((item) => item.status === "skipped").length;
const suiteUnexpectedCount = Number.isFinite(results?.stats?.unexpected) ? Number(results.stats.unexpected) : 0;
const suiteReportedSkippedCount = Number.isFinite(results?.stats?.skipped) ? Number(results.stats.skipped) : 0;
const resultErrorCount = Array.isArray(results?.errors) ? results.errors.length : 0;
const failedTests = allTests.filter((item) => FAILED_STATUSES.has(item.status)).slice(0, 20);
const skippedTests = allTests.filter((item) => item.status === "skipped").slice(0, 20);
const issues = [];
if (expectedTags.length !== expectedTagCount) issues.push("unexpected_expected_tag_count");
if (manifestDuplicateTags.length) issues.push("manifest_duplicate_tag");
if (manifestInvalidTags.length) issues.push("manifest_invalid_tag");
if (missingTags.length) issues.push("missing_tag_result");
if (duplicateTags.length) issues.push("duplicate_tag_result");
if (unknownTags.length) issues.push("unknown_tag_result");
if (multiTagTests.length) issues.push("multi_tag_test_result");
if (wrongWitnessSpecs.length) issues.push("wrong_witness_spec_result");
if (skippedTags.length) issues.push("skipped_tag_result");
if (failedTags.length) issues.push("failed_tag_result");
if (passedTags.length !== expectedTags.length) issues.push("incomplete_passed_tag_set");
if (suiteFailedTestCount > 0 || suiteUnexpectedCount > 0 || resultErrorCount > 0) issues.push("playwright_suite_failed");
if (suiteSkippedTestCount > 0 || suiteReportedSkippedCount > 0) issues.push("playwright_suite_skipped");
return {
ok: issues.length === 0,
issues,
expectedTagCount: expectedTags.length,
passedTagCount: passedTags.length,
skippedTagCount: skippedTags.length,
failedTagCount: failedTags.length,
missingTags,
duplicateTags,
unknownTags,
skippedTags,
failedTags,
manifestDuplicateTags,
manifestInvalidTags,
multiTagTests,
wrongWitnessSpecs,
totalTestResultCount: allTests.length,
suiteFailedTestCount,
suiteSkippedTestCount,
suiteUnexpectedCount,
suiteReportedSkippedCount,
resultErrorCount,
failedTests,
skippedTests,
};
}
function readJson(file) {
return JSON.parse(readFileSync(file, "utf8"));
}
function parseArgs(argv = process.argv.slice(2)) {
const manifestIndex = argv.indexOf("--manifest");
const resultsIndex = argv.indexOf("--results");
if (manifestIndex < 0 || resultsIndex < 0 || !argv[manifestIndex + 1] || !argv[resultsIndex + 1]) {
throw new Error("missing_arguments");
}
return {
manifestPath: path.resolve(argv[manifestIndex + 1]),
resultsPath: path.resolve(argv[resultsIndex + 1]),
};
}
function main() {
try {
const args = parseArgs();
const summary = verifyLocalCandidateResults(readJson(args.manifestPath), readJson(args.resultsPath));
console.log(JSON.stringify(summary, null, 2));
if (!summary.ok) process.exitCode = 1;
} catch {
console.log(JSON.stringify({ ok: false, issues: ["invalid_or_missing_result_json"] }, null, 2));
process.exitCode = 1;
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main();
}
@@ -0,0 +1,2 @@
-- Add the restricted OA operator role without changing any existing account.
ALTER TYPE "RoleCode" ADD VALUE IF NOT EXISTS 'system_admin' BEFORE 'admin';
@@ -0,0 +1,2 @@
ALTER TABLE "payment_batches"
ADD COLUMN "cancelled_reason" TEXT;
+2
查看文件
@@ -14,6 +14,7 @@ enum RoleCode {
finance_mgr
administration
hr
system_admin
admin
}
@@ -1862,6 +1863,7 @@ model PaymentBatch {
executed_by String? @db.Uuid
cancelled_at DateTime?
cancelled_by String? @db.Uuid
cancelled_reason String?
updated_at DateTime @updatedAt
updated_by String? @db.Uuid
+22
查看文件
@@ -159,6 +159,7 @@ const ROLE_PERMISSIONS = {
"project.manage",
"budget.view",
"budget.manage",
"payment.batch.review",
],
ceo: [
...EMPLOYEE_BASE_PERMISSIONS,
@@ -176,6 +177,7 @@ const ROLE_PERMISSIONS = {
"asset.view_all",
"report.dashboard.view",
"budget.view",
"payment.batch.review",
],
finance: [
...EMPLOYEE_BASE_PERMISSIONS,
@@ -191,6 +193,8 @@ const ROLE_PERMISSIONS = {
"procurement.view_all",
"asset.view_all",
"attendance.view_all",
"employee.bank.read",
"payroll.manage",
],
finance_mgr: [
...EMPLOYEE_BASE_PERMISSIONS,
@@ -221,6 +225,8 @@ const ROLE_PERMISSIONS = {
"attendance.view_all",
"attendance.manage",
"attendance.sync",
"payroll.manage",
"employee.bank.read",
],
administration: [
"expense.proxy_submit",
@@ -246,9 +252,25 @@ const ROLE_PERMISSIONS = {
"attendance.sync",
"system.users",
"system.users_sensitive",
"system.accounts.manage",
"system.roles.manage",
"employee.bank.manage",
"employee.identity.manage",
"employee.contract.manage",
"expense.view_all",
"report.dashboard.view",
],
system_admin: [
...EMPLOYEE_BASE_PERMISSIONS,
...PROCUREMENT_SELF_SERVICE_PERMISSIONS,
...ASSET_SELF_SERVICE_PERMISSIONS,
"system.accounts.manage",
"system.roles.manage",
"system.settings",
"integration.manage",
"notification.channel.manage",
"system.audit.view",
],
admin: ["*"],
};
+1 -1
查看文件
@@ -10,7 +10,7 @@ const {
listFinanceLedger,
} = require("../services/financeLedger");
const FINANCE_LEDGER_PERMISSION_CODES = ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"];
const FINANCE_LEDGER_PERMISSION_CODES = ["payment.batch.manage", "payment.batch.review", "risk.review.manage", "expense.finance.pay"];
const FINANCE_LEDGER_QUERY_SCHEMA = z.object({
page: z.coerce.number().int().min(1).optional(),
+7 -4
查看文件
@@ -4,18 +4,21 @@ 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 ROLE_CODES = Object.keys(ROLE_PERMISSIONS);
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"),
);
const CREATE_BODY_SCHEMA = z.object({
name: z.string().trim().min(1).max(100),
minAmount: MONEY_STRING_SCHEMA,
maxAmount: MONEY_STRING_SCHEMA.nullable().optional(),
level1Role: z.enum(ROLE_CODES),
level2Role: z.enum(ROLE_CODES).nullable().optional(),
level3Role: z.enum(ROLE_CODES).nullable().optional(),
level1Role: z.enum(REIMBURSEMENT_APPROVAL_ROLE_CODES),
level2Role: z.enum(REIMBURSEMENT_APPROVAL_ROLE_CODES).nullable().optional(),
level3Role: z.enum(REIMBURSEMENT_APPROVAL_ROLE_CODES).nullable().optional(),
ownerSelfFallbackRole: z.enum(OWNER_SELF_FALLBACK_ROLE_CODES).nullable().optional(),
enabled: z.boolean().optional(),
priority: z.number().int().min(0).max(10000).optional(),

某些文件未显示,因为此 diff 中更改的文件太多 显示更多