From a83791263833547fdab7ba6910f190fcccd5e34b Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 5 Sep 2026 19:00:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0=E5=90=88=E5=90=8C=E4=B8=8E=E4=BB=98=E6=AC=BE?= =?UTF-8?q?=E5=B0=B1=E7=BB=AA=E6=8F=90=E7=A4=BA=EF=BC=8809-05-error-explai?= =?UTF-8?q?nability-payment-readiness=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spec/hyb-oa/backend-fastify-prisma.md | 89 +++ .../spec/hyb-oa/frontend-react-vite-design.md | 65 ++ .../check.jsonl | 1 + .../design.md | 126 ++++ .../implement.jsonl | 1 + .../implement.md | 150 ++++ .../prd.md | 94 +++ .../task.json | 33 + client/src/api/errorMessages.ts | 11 +- client/src/api/errorReasons.ts | 152 ++++ client/src/api/paymentBatches.ts | 20 +- client/src/lib/paymentBatchDisplay.ts | 17 +- client/src/lib/resolveErrorMessage.ts | 10 +- client/src/pages/PaymentBatchesPage.tsx | 72 +- .../e2e/payment-reviewer-permissions.spec.ts | 147 +++- .../e2e/pc-finance-admin-visual-wave4.spec.ts | 7 +- .../r103-cross-page-ui-consistency.spec.ts | 2 +- .../r111-invoice-reimbursement-guard.spec.ts | 4 +- scripts/qa/check-error-reason-contract.mjs | 255 +++++++ scripts/qa/error-reason-legacy-baseline.json | 678 ++++++++++++++++++ scripts/qa/run-release-gate.sh | 2 + server/src/constants/errorReasons.js | 155 ++++ server/src/routes/attendanceCorrections.js | 27 +- server/src/routes/invoicePool.js | 85 +-- server/src/routes/invoicePool/verification.js | 22 +- server/src/routes/leave.js | 69 +- server/src/routes/paymentBatches.js | 347 ++++----- server/src/routes/purchaseRequests.js | 2 + server/src/routes/reimbursements.js | 556 +++----------- server/src/services/leave.js | 27 +- server/src/services/paymentBatchReadiness.js | 60 ++ server/src/utils/businessValidationError.js | 14 + .../unit/fixtures/error-reason-messages.json | 144 ++++ .../routes/invoicePool.travel-bind.test.js | 3 +- .../routes/invoicePool.verification.test.js | 4 +- .../paymentBatches.reimbursement.test.js | 124 +++- .../routes/reimbursements.attachments.test.js | 6 +- .../reimbursements.expense-standards.test.js | 5 +- .../unit/scripts/error-reason-client.test.js | 66 ++ server/tests/unit/services/leave.test.js | 4 +- .../services/paymentBatchReadiness.test.js | 65 ++ .../utils/businessValidationError.test.js | 22 + 42 files changed, 2867 insertions(+), 876 deletions(-) create mode 100644 .trellis/tasks/09-05-error-explainability-payment-readiness/check.jsonl create mode 100644 .trellis/tasks/09-05-error-explainability-payment-readiness/design.md create mode 100644 .trellis/tasks/09-05-error-explainability-payment-readiness/implement.jsonl create mode 100644 .trellis/tasks/09-05-error-explainability-payment-readiness/implement.md create mode 100644 .trellis/tasks/09-05-error-explainability-payment-readiness/prd.md create mode 100644 .trellis/tasks/09-05-error-explainability-payment-readiness/task.json create mode 100644 client/src/api/errorReasons.ts create mode 100644 scripts/qa/check-error-reason-contract.mjs create mode 100644 scripts/qa/error-reason-legacy-baseline.json create mode 100644 server/src/constants/errorReasons.js create mode 100644 server/src/services/paymentBatchReadiness.js create mode 100644 server/src/utils/businessValidationError.js create mode 100644 server/tests/unit/fixtures/error-reason-messages.json create mode 100644 server/tests/unit/scripts/error-reason-client.test.js create mode 100644 server/tests/unit/services/paymentBatchReadiness.test.js create mode 100644 server/tests/unit/utils/businessValidationError.test.js diff --git a/.trellis/spec/hyb-oa/backend-fastify-prisma.md b/.trellis/spec/hyb-oa/backend-fastify-prisma.md index 4eb5717b..0df6aefc 100644 --- a/.trellis/spec/hyb-oa/backend-fastify-prisma.md +++ b/.trellis/spec/hyb-oa/backend-fastify-prisma.md @@ -23,6 +23,95 @@ QA script patterns over new abstractions. - `cd server && ./scripts/regression-workflow-actions.sh` - `cd server && ./scripts/regression-all.sh` +## Scenario: Business Error Reasons And Payment Creation Readiness + +### 1. Scope / Trigger + +- Adding or changing user-facing business validation, especially reimbursement, + invoice binding/unbinding, payment batches, leave and attendance correction. +- Root cause addressed by task `09-05-error-explainability-payment-readiness`: + server details and UI text evolved independently; successful static coverage + was incorrectly inferred from shared details keys or string counts. + +### 2. Signatures + +- Registry: `server/src/constants/errorReasons.js`, `ERROR_REASONS`. +- Builder: `buildBusinessValidationError(reasonCode, legacyDetails, statusCode)`. +- Client map: `client/src/api/errorReasons.ts`. +- Gate: `node scripts/qa/check-error-reason-contract.mjs --strict`; negative + subprocess fixtures: the same command with `--self-test`. +- Candidate API accepts `paymentChannel`; each candidate adds + `readiness: { ready, blockers }`. +- `POST /api/payment-batches/readiness` accepts only `entityType`, `entityIds`, + and optional `defaultPaymentChannel`; this is authenticated, manage-authorized + and read-only. Creation remains independently authoritative. + +### 3. Contracts + +- Keep HTTP status, top-level error code and legacy details. Add the registered + domain-scoped `details.reasonCode`; do not infer business identity from a + details key alone. The builder rejects unregistered reasons. +- **迁移一条遗留原因码 = 服务端注册 + 客户端映射 + 具体内容断言,三项缺一不可。** + Fixed expected text lives in `server/tests/unit/fixtures/error-reason-messages.json` + and is asserted against both registry and client rendering; do not replace + these assertions with broad message regexes or expectations derived at runtime. +- Legacy entries are versioned AST-shape SHA-256 fingerprints plus relative file/operation, + occurrence count, owner, explanation and expiry date (UTC). Whitespace, quotes, + indentation, trailing commas and parenthesization must not change identity. + A changed node is a new obligation; one deletion cannot offset one addition. +- The first-wave route files and invoice bind/unbind/shared item resolver are + zero-exception operations. Do not baseline their new or old generic errors. +- Candidate rows, combination preflight and the locked creation transaction all + consume `resolvePaymentBatchCreationReadiness` from `paymentBatchReadiness.js`. + `loadPaymentCreationFacts` loads sources, reservations, accounts and reviewers + in batches. Department flag reads are memoized only inside that fact load; + do not cache readiness across requests or write readiness to the database. +- Creation takes source locks, reloads facts, runs the resolver and only then + allocates/creates a batch, items, success audit and notifications. Blocked + preflight/create must not emit any of those success side effects. +- Public blockers contain only reason code and optional source association. + Neither decrypted account numbers nor account snapshots enter readiness DTOs. + Bank-channel constraints must not silently become constraints on cash or other + already-supported channels. No bank capability, role or maker-checker changes. +- Execution keeps persisted `failure_code/failure_message` and their API fields + compatible. Store only the registered reason code in the existing execution-risk + `payload_json`, with a safe detail; refresh that open risk's explanatory metadata + inside the same transaction without changing status, creator or notification + rules. This is not a readiness field or a new server logging mechanism. + +### 4. Validation & Error Matrix + +- Missing source -> `404 VALID_001` on create; invalid input -> `400 VALID_001`. +- Status, occupancy, department, missing company/payee/account, decryption, + cross-company and missing independent reviewer -> safe blockers; create `409`. +- Three synthetic missing fields return three distinct actionable blockers. +- Missing mapping/content fixture, typo, duplicate, changed/new/stale fingerprint, + expired exception or primary-operation exception -> gate non-zero exit. + +### 5. Good/Base/Bad Cases + +- Good: individually ready records from different companies produce a blocked + combination; changing source/account facts changes the next response. +- Base: complete bank fixture produces `{ ready: true, blockers: [] }` and can + create; a competing reservation after preflight is refused under the lock. +- Bad: hide invalid candidates, copy create conditions into an API/UI, approve + permanent path exemptions, or mark deployment acceptance from local fixtures. + +### 6. Tests Required + +- Gate self-test must launch failing subprocesses and assert their exit codes, + including formatting stability, one-added/one-removed and primary exceptions. +- Focused route tests assert exact reason, HTTP status and retained details. +- Shared readiness matrix plus route tests cover batch loading, repaired facts, + combination rules, post-lock changes and zero batch/item/audit/notification. +- Keep existing B2, invoice, leave, correction, receipt and maker-checker tests. + +### 7. Wrong vs Correct + +- Wrong: count generic error strings, or `details.status` as the cross-domain key. +- Correct: register `payment_batch.source_status_invalid`, map and assert its + concrete safe text, and consume the shared readiness result at every boundary. + ## Scenario: Prisma Client Defaults In Raw SQL ### 1. Scope / Trigger diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index 1c991194..eff03684 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -12,6 +12,71 @@ This spec keeps hyb-oa-specific React/Vite, shared-component, business-boundary and verification contracts. Do not copy broad aesthetic guidance from `frontend-design` into this file; use the skill directly for design judgment. +## Scenario: Actionable Validation And Selectable Payment Channels + +### 1. Scope / Trigger + +- New validation feedback, disabled selections, or payment creation controls. + +### 2. Signatures + +- `resolveErrorMessage(ApiError)` prefers the explicit domain-scoped reason map. +- `PaymentReadiness` is `{ ready, blockers: [{ reasonCode, entityId?, entityCode? }] }`. +- Candidate query identity includes payment channel; combination query identity + includes selected IDs and channel. Never reuse another selection's readiness. + +### 3. Contracts + +- Unknown `VALID_001` uses actionable safe fallback, with a bounded diagnostic + request ID only when present. Do not render raw message/details, IDs or paths. +- Keep reason registration, client map and fixed concrete-text tests together. + Structured invoice-count rendering remains narrowed and must tolerate malformed + fields without exposing payloads. +- Blocker text stays beside its row/action; use persistent text, `role=alert` + or `aria-live`, and `aria-describedby` on disabled actions. Toast supplements, + never replaces, the persistent explanation. Normal rows add no success noise. +- The payment channel selector is available before any selection, otherwise + bank-blocked rows make other supported channels unreachable. +- A not-ready unselected row cannot be selected; a previously selected row that + becomes invalid can still be deselected. Pending writes may disable edits. +- Pending/error/not-ready combination state disables creation, and the mutation + handler checks the same result. Server create rechecks independently. +- Persisted payment failure summaries are not UI copy. Use the failure projection + in `paymentBatchDisplay.ts`, associating risk reasons by batch ID and item ID; + historical/unknown failure text and execution-risk details use safe fallback. + Do not announce complete success when the execution result is `failed`. +- Only authorized maintainers may receive maintenance links; other roles receive + recovery-owner text. Do not instruct users to falsify their actual bank details. + +### 4. Validation & Error Matrix + +- Missing company/bank/card or unsupported bank -> exact local row explanation. +- Cross-company combination -> action-local explanation and disabled button. +- Concurrent create refusal -> remain on the page with action-local safe text. +- Unknown reason -> safe fallback, optional request ID, no empty identifier label. + +### 5. Good/Base/Bad Cases + +- Good: switch to an existing non-bank channel before selection; deselect a row + whose readiness changed without losing the rest of the selection. +- Base: normal bank fixture remains selectable and submits for independent review. +- Bad: hide the channel selector until a selectable bank row is chosen, or disable + an already-selected invalid checkbox permanently. + +### 6. Tests Required + +- Concrete message fixtures, unknown reasons with/without request IDs, and malformed + structured invoice details; no broad message regex substitutions. +- Local synthetic Playwright covers omissions, channel limits, combination block, + concurrent failure, keyboard selection, accessible descriptions, channel-before- + selection and deselection after invalidation. No real payment data is required. + +### 7. Wrong vs Correct + +- Wrong: `disabled={!candidateReady}` for every checkbox. +- Correct: block only new invalid selections while preserving deselection, and + share the reason mapping between row, combination and create errors. + ## Scenario: PC Shell And Detail Breakpoint Coordination ### 1. Scope / Trigger diff --git a/.trellis/tasks/09-05-error-explainability-payment-readiness/check.jsonl b/.trellis/tasks/09-05-error-explainability-payment-readiness/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/09-05-error-explainability-payment-readiness/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/09-05-error-explainability-payment-readiness/design.md b/.trellis/tasks/09-05-error-explainability-payment-readiness/design.md new file mode 100644 index 00000000..dcb09cb1 --- /dev/null +++ b/.trellis/tasks/09-05-error-explainability-payment-readiness/design.md @@ -0,0 +1,126 @@ +# Design:错误可解释性与付款就绪治理 + +## 1. 总体边界 + +本任务不把全部 `VALID_001` 重构成新异常体系。保留 HTTP status、顶层 code 和既有 details,在已迁移错误中增加稳定 `details.reasonCode`,用注册表、遗留指纹基线和 focused 测试形成只增不退的 ratchet。 + +```text +服务端业务事实 + -> 原因码注册表 / domain validator + -> AppError { code, details: { reasonCode, ...legacyDetails } } + -> ApiError(保留 requestId) + -> 原因码中文映射 / 页面专用投影 + -> 就近提示 + 动作禁用 + 安全兜底 +``` + +## 2. 原因码注册表 + +服务端建立唯一原因码注册表模块,例如 `server/src/constants/errorReasons.js`。每项至少包含: + +```js +{ + code: "payment_batch.legal_entity_required", + domain: "payment_batch", + audience: "user", + defaultMessage: "报销人尚未维护所属公司,请联系管理员补充后再安排付款。", + recovery: "contact_admin", +} +``` + +客户端维护显式、可测试的 reasonCode 映射。静态提示可使用注册表默认中文;涉及数量、页面定位或权限差异的提示由窄化 renderer 处理,但必须有默认安全提示。服务端使用常量或经注册表校验的 builder,不散落裸原因码。 + +兼容响应继续使用 `VALID_001`,并同时保留 legacy details: + +```json +{ + "error": { + "code": "VALID_001", + "message": "参数校验失败", + "details": { + "reasonCode": "payment_batch.legal_entity_required", + "legalEntity": "single_legal_entity_required" + } + }, + "requestId": "..." +} +``` + +新 UI 不使用该 message 作为用户文案来源。未知 reasonCode 走安全兜底,并由现有 `ApiError.requestId` 附加排障编号。 + +## 3. Parity 与遗留基线 + +新增 `scripts/qa/check-error-reason-contract.mjs`,使用仓库已有 parser 解析 JS/TS,而不是按 key 或行号做字符串计数: + +1. 注册表唯一、命名和元数据完整;`audience=user` 必须有客户端映射。 +2. reason-aware builder 的原因均已注册;客户端无孤儿映射。 +3. 泛化 `buildValidationError` / `new AppError(...VALID_001...)` 调用若没有 reasonCode,必须命中版本化遗留调用指纹。 + +遗留基线放在 QA 合同附近,例如 `scripts/qa/error-reason-legacy-baseline.json`。按业务域/文件组记录 owner、reason、expiresOn 和规范化 AST 节点形状指纹;指纹必须忽略空白、缩进、换行和纯格式化差异,源码文本与行号仅用于报告,不参与身份计算。首批主链路 operation 单列为 zero-exception,命中遗留也失败。self-test 用临时 fixture 验证新增、删除、拼写、结构性指纹变化、纯格式化不变、过期和同 key 跨域。 + +这避免两类假门禁:只比数量可被“一增一删”绕过;只比 details key 会把不同业务语义误判为同一覆盖。 + +## 4. 首批迁移边界 + +首批原因域: + +- `reimbursement.*`:提交、草稿编辑、票据关联/恢复。 +- `payment_batch.*`:候选/组合 readiness、创建、复核、回单和登记。 +- `leave.*`:员工提交路径。 +- `attendance_correction.*`:员工/授权应急提交路径。 + +同一业务后果跨入口复用同一码;不同业务后果即使 legacy key 相同也使用不同码。20 处旧字符串先形成 task-local 处置表:客户端 mock、主链路服务测试、内部/持久化摘要、延期域分别处理。迁移测试断言 exact reasonCode、HTTP status、兼容字段和准确中文,不使用模糊 message 正则。 + +## 5. 付款 readiness 服务 + +抽取无 HTTP 展示依赖的 `resolvePaymentBatchCreationReadiness`。输入包括 entity type、entity IDs、付款渠道、maker ID 和当前配置;输出只含安全原因: + +```ts +type PaymentReadiness = { + ready: boolean; + blockers: Array<{ + reasonCode: PaymentBatchReasonCode; + entityId?: string; // 只用于关联,不直接渲染 + entityCode?: string; + }>; +}; +``` + +解析器复用快照、部门 gate、活动占用、付款账户和 reviewer 事实。敏感账户只做存在性、银行机构和可解密判断,响应不带密文、完整账号或原始 employee。 + +```text +GET candidates + -> 单条 resolver + -> readiness/blockers 决定可选性和提示 + +POST readiness(只读) + -> 当前多选集合的组合 resolver + -> 跨公司/占用/复核人等组合提示 + +POST create + -> 事务 + 锁后复用同一 resolver + -> ready=false 抛相同 reasonCode + -> ready=true 才创建 batch/items/audit/notification +``` + +候选查询必须批量载入必要关系、占用、付款账户和复核人事实,避免在候选循环中产生 N+1。readiness 不落库、不进入长期缓存;资料补齐后刷新查询即可得到新事实。 + +## 6. UI 投影 + +依据 `ui-ux-pro-max` 的表单反馈规则:错误就近、提供恢复路径、toast 不作唯一反馈。 + +- 正常候选保持紧凑,不显示“资料已齐全”。 +- 异常候选仍出现在列表中,避免再次形成不可见状态空隙;复选框 disabled,行内展示必要缺项。 +- 缺员工资料时说明需管理员维护;仅有维护权限者看到对应入口。 +- 非建行时说明当前银行付款渠道限制,不引导伪填。 +- 组合 readiness 紧贴主动作,使用 `role=alert`/`aria-live`,按钮以 `aria-describedby` 关联原因。 +- create 并发失败复用相同映射留在动作附近;toast 只补充“未创建付款安排”。 +- 未知错误显示可行动兜底及可用 request ID,不回显原始 details。 + +## 7. 兼容、隐私与回滚 + +- API 是加法变更;旧 code/details 保留,旧客户端继续工作。 +- 不记录银行卡、密文、完整账号或真实 payload。原因码是否进入脱敏服务端日志延期评估。 +- 不改变付款状态机、maker-checker、角色、通知对象、回单门禁或 B1/B2。 +- 无 migration/schema/seed/flag/routing。生产资料补录不属于部署步骤。 +- 建议按“原因码/parity、首批迁移、付款 readiness/UI”形成逻辑提交;付款提交可独立回滚且无数据库补偿。 +- 部署另行批准;公网只用合成 fixture 验证。不得自动创建、复核或付款 0014。 diff --git a/.trellis/tasks/09-05-error-explainability-payment-readiness/implement.jsonl b/.trellis/tasks/09-05-error-explainability-payment-readiness/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/09-05-error-explainability-payment-readiness/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/09-05-error-explainability-payment-readiness/implement.md b/.trellis/tasks/09-05-error-explainability-payment-readiness/implement.md new file mode 100644 index 00000000..f7006594 --- /dev/null +++ b/.trellis/tasks/09-05-error-explainability-payment-readiness/implement.md @@ -0,0 +1,150 @@ +# Implementation Plan:错误可解释性与付款就绪治理 + +## 0. 开工门 + +- [x] 用户复核并批准 PRD、design、implement。 +- [x] 批准后运行 `task.py start`;inline 模式实施前加载 `trellis-before-dev`,不派 implement/check sub-agent。 +- [x] 记录开工基线、工作区状态和当前 20 处旧字符串测试命中清单。 + +## 1. 原因码合同与 parity + +1. [x] 新增原因码注册表和 reason-aware validation helper;保留顶层 `VALID_001` 与 legacy details。 +2. [x] 新增客户端 reasonCode 映射与未知错误安全兜底,接入 `ApiError.requestId`。 +3. [x] 建立 AST/结构化 parity 检查和版本化遗留指纹:基于规范化 AST 节点形状而非源码文本,纯格式化不得改变指纹;注册唯一性、服务端使用、客户端覆盖、孤儿映射、新增遗留、过期例外全部 fail closed。 +4. [x] self-test 覆盖同 key 跨域、未注册、拼写、缺映射、一增一删、结构性指纹变化、纯格式化不变、到期和主链路试图加例外。 +5. [x] 接入既有治理入口;报告只输出相对文件、原因码/域、计数和稳定 finding。 + +## 2. 首批主链路迁移 + +1. [x] 迁移报销提交/编辑/票据关联的用户可达校验。 +2. [x] 迁移付款创建/复核/回单/登记的用户可达校验。 +3. [x] 迁移请假提交、考勤修正提交的用户可达校验。 +4. [x] 主链路达到零例外;其他存量按域记录 owner/reason/expiresOn,不使用永久目录豁免。 +5. [x] 逐项处置 20 处旧字符串: + - 客户端 4 个 mock 改为 reasonCode,并断言具体提示/安全兜底; + - 主链路服务测试断言 exact reasonCode、status 和兼容 details; + - workflow utility、ERP procurement、budget 等逐项迁移或登记有效遗留; + - `failure_message`、批处理 reason 等持久化/内部摘要单独验证兼容,不与 UI 文案混同。 + +## 3. 付款 readiness + +1. [x] 抽取共享 resolver,复用快照、部门 gate、账户、占用和 reviewer 事实;搬移 create 条件而非复制。 +2. [x] 候选批量计算单条 readiness,避免 N+1;异常候选仍返回但不可选择。 +3. [x] 增加多选组合只读 readiness API;严格输入,响应仅含安全 blocker。 +4. [x] create 在锁和事务内复用 resolver;失败零 batch/item/成功 audit/notification 副作用。 +5. [x] 覆盖资料或账户变化后的即时重算,不新增持久字段或长期缓存。 + +## 4. 付款页面 + +1. [x] 扩展 API types;候选行显示就近 blocker,正常行无解释噪音。 +2. [x] 异常候选不可选择并具备可访问原因;有维护权限才显示维护入口。 +3. [x] 多选变化查询组合 readiness;提示紧贴主按钮,按钮禁用、handler 和 create 二次拦截消费同一原因映射。 +4. [x] 并发失败留在动作附近;toast 为补充;未知原因使用 request ID 安全兜底。 +5. [x] PC 覆盖主路径;移动端保持现有能力边界且不显示技术错误。 + +## 5. 验证 + +- [x] `node scripts/qa/check-error-reason-contract.mjs --self-test` 与 strict 正常扫描。 +- [x] Server:registry/helper、付款 readiness/route、报销/B2、请假、考勤 focused Vitest;涉及 workflow/状态机时做只读 review。 +- [x] Client:error mapping 测试;付款 Playwright 覆盖正常、三类 0014 缺项、非建行、跨公司、占用、无 reviewer、并发、未知 request ID fallback、键盘/ARIA。 +- [x] `cd server && npm run prisma:validate`;触达 JS 运行 `node --check`。 +- [x] `cd client && npm run typecheck && npm run build`。 +- [x] UI strict/self-test、Trellis governance、敏感信息扫描、`git diff --check`。 +- [x] 核对 schema/migration/seed/flag/routing 和生产数据修改为零。 + +## 6. Spec、提交与部署 + +- [x] 实现验证稳定后,把“原因码 + 遗留指纹 + 主链路零例外”和“共享 readiness 多消费者”沉淀到现有 spec,不新建泛化设计文档。 +- [x] spec 明确迁移一条遗留原因码的完整合同:服务端注册、客户端映射、具体内容断言三件缺一不可。 +- [ ] 提交建议:A 原因码/parity;B 首批迁移;C 付款 readiness/UI;task 元数据另行回填。 +- [ ] commit message 使用中文并引用 `[09-05-error-explainability-payment-readiness]`。 +- [ ] commit、push、provenance、部署分别批准;切换前再次运行 UI strict。 +- [ ] 部署保持 `SYNC_ENV=0`、existing 数据合同,无 migration/schema/seed/flag/routing/资料补录。 +- [ ] 部署后核版本、公网文案、合成 readiness/错误投影、B2、maker-checker 和真人单据状态。 +- [ ] 不自动创建 0014 批次、不复核、不登记付款;真实业务由授权人员完成。 +- [x] 回滚无需数据库补偿;新客户端遇旧服务端未知码仍须显示可行动兜底。 + +## 7. 开工基线与 20 处旧断言处置 + +2026-09-05 恢复实施;开工 HEAD 为 `ab82fe3`,仅当前 task 目录未跟踪并已保留。 +依据交接中的明确批准幂等执行 `task.py start`;本会话主会话实施/检查,没有派 implement/check agent。 +原生 `update_plan` 未在工具集中提供,未创建替代 plan;继续以原已批准清单执行。 + +下表行号是开工前定位,不以当前字符串总数作为完成证据。兼容响应的原始 message +仍可以出现在测试 fixture;用户展示断言必须具体,不能用宽泛正则掩盖。 +延期域均由 `shunli` 负责,遗留 AST 基线到期日为 **2026-10-05(UTC)**;未增添主链路例外。 + +| 编号 | 开工前测试定位 | 逐项处置 | +| --- | --- | --- | +| T01 | `client/tests/e2e/r103-cross-page-ui-consistency.spec.ts:544` | 采购附件 mock 增加 `purchase_attachment.unsupported_type`;对应服务端分支、固定中文断言和安全上传浏览器断言一并保留。 | +| T02 | `client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts:448` | 提交 mock 改为 `reimbursement.items_invoice_item_guard_failed`;保留四类精确数量解释、异常字段安全兜底与双端就近提示断言。 | +| T03 | `client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts:481` | 明细关联 mock 改为 `reimbursement.invoice_invalid_unlinked_item_state`;保留准确中文和原子关联断言。 | +| T04 | `client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts:83` | 核验 mock 分别登记频控/次数上限原因码;服务端注册、客户端映射、固定内容和 route details 精确断言齐全;旧 `VALID_001:` UI 前缀断言改为无技术前缀的准确中文,不用正则。 | +| T05 | `server/tests/unit/utils/workflow.test.js:64` | workflow 配置工具延期;保留原准确消息断言,对应 `utils/workflow.js` 到期指纹。 | +| T06 | `server/tests/unit/utils/workflow.test.js:65` | workflow 空步骤配置延期;保留原断言,对应同文件独立调用指纹。 | +| T07 | `server/tests/unit/utils/workflow.test.js:87` | approval-rule 配置工具延期;保留原断言及到期指纹。 | +| T08 | `server/tests/unit/utils/workflow.test.js:94` | approval-rule 无效配置分支延期;保留原断言及到期指纹。 | +| T09 | `server/tests/unit/utils/workflow.test.js:112` | approval-rule 结构配置分支延期;保留原断言及到期指纹。 | +| T10 | `server/tests/unit/utils/workflow.test.js:331` | 缺少 assignee rule 配置延期;保留原断言及到期指纹。 | +| T11 | `server/tests/unit/utils/workflow.test.js:332` | 未知 assignee rule 类型延期;保留原断言及到期指纹。 | +| T12 | `server/tests/unit/services/leave.test.js:50` | 改为精确 `VALID_001`、422、`leave.leave_invalid_range` 与兼容 `leave: invalid_range`。 | +| T13 | `server/tests/unit/services/leave.test.js:98` | 改为精确 `VALID_001`、422、`leave.leave_non_working_range` 与兼容 `leave: non_working_range`。 | +| T14 | `server/tests/unit/routes/integrations.erp.procurement.test.js:49` | ERP procurement 非首批域延期;保留原消息断言与 integrations 模块到期指纹。 | +| T15 | `server/tests/unit/routes/integrations.erp.procurement.test.js:57` | ERP procurement 另一资格分支延期;保留原断言,不扩展集成能力。 | +| T16 | `server/tests/unit/routes/paymentBatches.reimbursement.test.js:2341` | 内部持久化 `failure_code/failure_message` 及 API 字段兼容断言保留;执行风险既有 JSON 仅附加注册原因码、刷新安全说明,不改风险状态;明细/风险卡经专用 helper 展示,历史与未知原因安全兜底,不直接展示原摘要。 | +| T17 | `server/tests/unit/routes/workflow.batch.test.js:377` | 内部批处理 reason 兼容断言原样保留;不把批处理摘要冒充用户恢复文案。 | +| T18 | `server/tests/unit/routes/workflow.permissions.test.js:172` | workflow 管理查询域延期;保留准确消息与 `routes/workflow.js` 到期指纹。 | +| T19 | `server/tests/unit/budgetPool.service.test.js:28` | budget scope 配置域延期;保留原断言与 `services/budgetPool.js` 到期指纹。 | +| T20 | `server/tests/unit/budgetPool.service.test.js:75` | budget pool 候选配置域延期;保留原断言及到期指纹,不变更预算规则。 | + +## 8. 实施与只读复核记录 + +- 原因码合同接入既有 `run-release-gate.sh` static 阶段;主链路包含 invoice bind/unbind + 与共享 item resolver,不借整目录永久豁免绕过。 +- 候选、组合预检和创建事务复用 `loadPaymentCreationFacts` 与 + `resolvePaymentBatchCreationReadiness`;候选关系、占用、公司账户、复核人批量读取。 + 部门 gate 仅在单次事实加载内复用底层 flag 查询,仍调用原判定实现;没有修改 flag。 +- 只读 reviewer 首轮发现两项 P2:未选中时无法切换付款渠道、失效已选项无法取消。 + 已分别移出渠道选择器的选中条件、允许失效已选项取消,并补浏览器回归。 + 第二轮只读 review 确认这两项闭环及请求内 gate 查询复用;无新增高置信问题。 +- 系统 Python 缺 `xlwt` 导致既有银行模板导出测试失败;使用现有隔离测试环境提供的 + Python、通过 `PAYMENT_ORDER_PYTHON_BIN` 运行后恢复通过。未改依赖清单或生产配置。 +- 首轮扩大浏览器运行另包含一个合同只读用例超时,未完成该项归因,未改合同产品逻辑或该断言; + 付款、B2、采购与本轮核验反馈改为明确 focused 集重跑,不把无关失败标成通过。 +- 已更新现有 backend/frontend spec;根因归类为跨层合同和传播缺失,而非真实资料补录。 +- 本轮不提交、不 push、不部署、不归档;真实资料和 0014 的任何业务写入均未执行。 + 部署后 AC12 仍须独立批准与公网证据,不能用本地合成结果替代。 +- 最终检查进一步发现两处付款明细直出旧失败摘要,已按 T16 补齐安全展示转换; + 新原因只保存在既有执行风险 JSON 元数据中,未新增 schema、readiness 持久字段或日志机制。 + 追加只读 review 确认历史兼容、事务和风险状态边界,无新增高置信问题。 + +## 9. 本地验证结果(2026-09-05) + +| 验证 | 结果与边界 | +| --- | --- | +| AST strict | PASS;142 注册原因、199 已迁移使用点、695 遗留调用、主链路例外 0、finding 0。遗留身份同时包含相对文件、operation 和规范化 AST 形状,不能靠移动操作或一增一删抵消。 | +| AST self-test | 20 项 PASS;正常跨域与纯格式化退出 0,其余拒绝场景退出 1,包括六类必查失败、主链路加例外、同数替换、别名、具体内容断言缺失、operation 移动、条件式通用码和不安全文案。 | +| 扩展服务端 focused | 23 文件 / 407 用例 PASS:报销、票据、请假、考勤、付款、readiness、registry/helper 和客户端投影。 | +| 后续兼容/新增边界 focused | 8 文件 / 309 用例 PASS:包含当轮 75 个付款 route 用例、149 个具体内容/安全投影用例、采购附件和延期域/内部摘要兼容测试;与上一行有交集,不合并虚报数量。 | +| 最终摘要投影 focused | 4 文件 / 244 用例 PASS:76 付款 route、150 内容/投影、16 readiness、2 helper;新增既有风险元数据更新与历史摘要保护,和前述测试有交集。 | +| Playwright focused | 18 个不同用例 PASS(原 17 项 + 最后追加的失败摘要保护 1 项):银行正常提交、现金渠道、三类缺项、非建行、跨公司、占用、无 reviewer、创建并发、移动未知原因排障编号、键盘/ARIA、渠道可达与失效项取消、B2 双端、采购附件、核验错误、PC/移动视口历史失败明细及风险保护。仅本地全合成 API mock。 | +| 客户端工程 | `npm run typecheck`、`npm run build` PASS;未部署构建产物。 | +| 服务端工程 | Prisma validate、触达 JS/MJS `node --check` PASS;没有执行 migration 或数据库重置。 | +| 治理 | UI strict/self-test、process baseline、docs registry、Trellis validate/governance、敏感扫描、`git diff --check` PASS。 | +| 只读 review | 首轮两项 P2 已修复;第二轮确认闭环;追加摘要投影与风险元数据复核无新增高置信问题。Reviewer 未写文件、未操作真实数据。 | +| 边界核对 | schema/migration/seed/flag 实现、前端 routing、真实资料与真实付款写入均为 0;0014 未操作。 | + +复跑入口:`node scripts/qa/check-error-reason-contract.mjs --strict` 与 `--self-test`; +服务端测试中的银行模板导出需把 `PAYMENT_ORDER_PYTHON_BIN` 指向满足现有锁定依赖的隔离测试 Python。 +浏览器 focused 文件为 `payment-reviewer-permissions.spec.ts`、`r111-invoice-reimbursement-guard.spec.ts`、 +`r103-cross-page-ui-consistency.spec.ts` 和 `pc-finance-admin-visual-wave4.spec.ts`,筛选本表相关用例; +不得对公网或真实账户套用这些 mock 场景。 + +## 10. 独立复核、遗留验证事项与发布授权(2026-09-05) + +- 用户独立复核 PASS:真实调用点注入未注册原因码时 strict 退出 1;向主链路新增例外时 strict 退出 1;确认 readiness 三处共用、20 项 self-test 及格式化指纹稳定。用户确认探针全部还原,基线校验与备份一致。 +- 两个 task 已按完整文件物理分离;本目录仅保留错误可解释性与付款 readiness,Trellis 升级在独立 worktree 待单独审查。原治理文件均与开工 HEAD 一致,分离后原因码及治理门禁通过。 +- 未解决验证事项:首轮扩大浏览器运行的合同只读用例超时,**未归因、不计入通过结果**,不是已确认的产品缺陷或可永久忽略的 flaky。后续合同域验证前必须单独复现,补充精确用例、失败步骤及环境证据后归类;本任务不得通过放宽断言、重试至绿或混入 focused PASS 关闭它。 +- 用户明确批准本任务提交、push、部署;部署后核验 `/ready` 与 bundle marker、文案扫描零 findings、0014 候选禁用及缺项原因、创建接口 fail-closed、B2 保持 force_on + global。 +- 生产核验只使用最小脱敏结果,不读取或修改真实银行卡资料;不补录 0014,不执行真实付款。若前置条件变化可能导致创建成功,停止强行创建探针。 +- Task 保持 `in_progress`;独立复核通过不等于公网 AC12 完成。发布和公网证据待实际执行后回填,不提前标记 PASS,不归档 Trellis 升级任务。 diff --git a/.trellis/tasks/09-05-error-explainability-payment-readiness/prd.md b/.trellis/tasks/09-05-error-explainability-payment-readiness/prd.md new file mode 100644 index 00000000..a7a6a701 --- /dev/null +++ b/.trellis/tasks/09-05-error-explainability-payment-readiness/prd.md @@ -0,0 +1,94 @@ +# 错误可解释性与付款就绪治理 + +## Goal + +把“系统知道为什么不能继续、真人只看到参数校验失败”的缺陷从逐页补文案改成可持续的错误合同,并以付款批次为第一个完整样板:财务在点击前就知道付款资料是否齐全、缺什么、由谁补;点击后的服务端二次校验与页面提示必须来自同一份实时判断。 + +## Background + +- 2026-09-05,真实单据 `0014` 在生产付款页已能进入待处理队列,但财务选择银行转账并点击“创建并提交复核”后只看到“参数校验失败”。只读诊断确认请求未创建付款批次;报销人缺所属公司、开户银行和银行卡号,公司侧已有有效建行付款账户,经理/CEO 复核人存在。 +- 服务端创建前已检查来源状态、公司归属、收款账户、公司付款账户、活动批次占用和复核人;客户端未覆盖付款原因,因此回退到通用技术文案。 +- 这是十一天内第三次“参数校验失败”阻断真人主流程。此前 B2 已验证“一份判断同时驱动展示、点击拦截和 mutation 二次拦截”能防漂移;本任务把该模式推广到付款就绪。 +- 当前文本基线:服务端同一通用校验文案 365 处,`buildValidationError(` 518 次,测试目录中该字符串 20 次。数量用于确定渐进迁移边界,不代表独立业务原因数量。 +- `details key` 不能作为原因合同:同一个 `legalEntity`、`status` 或 `review` 在不同业务域含义不同。稳定合同必须是带业务域的原因码,例如 `payment_batch.legal_entity_required`。 +- 生产资料补录和真实付款继续由授权人员操作;本任务不得读取、记录或修改真实银行卡号,不把伪填建行资料作为恢复办法。 + +## Scope + +本 task 包含三项互相支撑但可独立回滚的交付:稳定业务原因码及增量 parity 门禁、首批真人主链路错误迁移、付款批次实时 readiness 与就近反馈。它不建设统一待办,也不扩展银行付款能力。 + +## Requirements + +### R1 稳定原因码与渐进 parity 门禁 + +1. 新增服务端业务错误原因注册表。每个原因至少记录稳定 `reasonCode`、业务域、普通用户是否可达、默认安全中文提示/恢复方向;原因码采用 `.`,不得复用任意 `details key` 冒充合同。 +2. 原有 HTTP status、顶层 `error.code` 和兼容 `details` 保持;已迁移错误在 `details.reasonCode` 增加稳定原因码。旧客户端可继续依赖既有字段,新客户端优先消费原因码。 +3. 新增 parity 检查:已注册用户原因必须有客户端安全映射;服务端使用的原因必须已注册;新增或修改通用校验不得绕过原因码;客户端不得有孤儿/拼写漂移;未知原因走安全兜底且不展示原始 details、内部路径或 UUID。 +4. 存量通用错误使用版本化遗留基线渐进收敛。例外按业务域/文件组记录原因、负责人、到期日和行号无关的调用指纹;新增指纹默认失败。不得以整目录永久忽略、只比较数量或模糊正则代替指纹门禁。 +5. 首批真人主链路不得进入例外:报销提交/编辑/票据关联,付款创建/复核/登记,请假提交和考勤修正提交。范围内每个用户可达校验分支必须有注册原因码、准确中文提示和测试。 +6. 遗留例外是迁移债,不是管理页永久展示技术文案的许可。到期后门禁必须失败;续期需要新理由和明确期限。 + +### R2 用户可行动的错误反馈 + +1. `VALID_001` 未映射时不得再把服务端的“参数校验失败”直接展示给普通用户。兜底必须说明操作未完成、建议核对页面提示或联系管理员,并附现有 request ID;没有 request ID 时不展示空编号。 +2. 具体提示必须回答“哪里不满足、下一步怎么恢复”,并位于相关字段、条目或动作附近。页面顶部错误和 toast 只作补充。 +3. 错误不得只靠红色表达;动态错误使用可访问告警语义。禁用动作的原因必须在按钮附近持续可见,并通过 `aria-describedby` 或等价语义关联。 +4. 20 处现有测试字符串逐处处置,不用宽泛正则消除失败:客户端 4 处模拟响应改为原因码 fixture;首批主链路改断言 reasonCode、兼容 details 和 HTTP status;非本批路径明确对应遗留例外和到期日;持久化失败摘要/批处理 reason 单独判断兼容性。 + +### R3 付款批次实时 readiness + +1. 抽取唯一服务端付款就绪解析器,返回 `{ ready, blockers }`。blocker 使用 `payment_batch.*` 原因码和最小安全定位信息,不返回完整银行卡、密文或原始员工对象。 +2. readiness 至少覆盖:来源状态/已付款、付款能力部门门禁、活动批次占用、单一公司归属、收款人银行资料、公司付款账户及解密可用性、非制单人的合格经理/CEO复核人。 +3. 候选列表对单条候选实时计算 readiness;缺资料的记录仍可被发现,但不可选择,并就近显示缺项与恢复责任。财务无员工维护权限时只提示联系管理员,不暴露无权入口。 +4. 多选付款必须用同一解析器计算组合 readiness,覆盖跨公司、并发占用和复核人等组合条件;不能因为每条单独就绪就推断组合一定就绪。 +5. 创建接口在事务、必要锁和最新数据下再次调用同一解析器并 fail closed。候选、组合预检和创建不得各写一套判断;并发变化返回同一原因码。 +6. readiness 每次按当前事实派生,不新增数据库字段、不落库、不长期缓存。资料或账户变化后,下次查询自然反映最新结果。 +7. 银行转账只按真实资料判断。开户行不是建行时不得建议伪填;是否支持跨行付款属于独立产品能力。 + +### R4 付款页面反馈 + +1. 正常候选不增加解释性噪音;有 blocker 时在候选行展示精确缺项,复选框不可用且原因可被键盘和读屏取得。 +2. 已选组合 readiness 位于主动作紧邻上方;阻断时主动作禁用,提示与服务端二次拦截来自同一组 reasonCode 投影。 +3. 正常银行付款 fixture 保持可选择、可创建并提交复核;新增门禁不得误伤资料与复核人完整的单据。 +4. PC 是批量付款主路径;移动端不新增复杂组批能力,但已有移动付款路径不得显示原始技术错误或回归。 + +## Acceptance Criteria + +- [x] AC1:存在可机器读取的原因码注册表;同一 details key 在两个业务域使用不同 reasonCode,parity 分别验证,证明不是按 key 虚假覆盖。 +- [x] AC2:未注册原因、缺客户端映射、拼错 reasonCode、新增无原因码调用、遗留指纹变化、例外过期六类 self-test 均非零退出;正常注册与有效遗留通过。 +- [x] AC3:报销提交/编辑/票据、付款创建/复核/登记、请假提交、考勤修正提交的用户可达错误为零例外;报告按域输出注册、已迁移、遗留和过期数量。 +- [x] AC4:未知 `VALID_001` 在 PC/移动显示安全可行动兜底;有 request ID 时展示编号,无编号时无空占位;“参数校验失败”、原始 details、UUID、接口路径不出现在 UI。 +- [x] AC5:20 处既有测试字符串有逐项处置清单;主链路断言具体原因码/中文,非本批项对应有效例外,无宽泛正则掩盖差异。 +- [x] AC6:付款单条与组合 readiness 覆盖正常、状态异常、活动批次、缺公司、缺/非建行收款资料、缺/不可解密公司账户、跨公司、无复核人;准确返回原因码,正常 fixture `ready=true`。 +- [x] AC7:候选、组合预检和创建事务调用同一 readiness 实现;并发变化时创建再次阻断且不生成 batch、item、成功 audit 或通知。 +- [x] AC8:正常候选无 readiness 噪音;异常候选不可选择且缺项就近可读;组合原因紧贴禁用主动作,toast 非唯一反馈,键盘/读屏语义通过。 +- [x] AC9:员工资料或公司账户变化后下一次 readiness 立即变化;schema、migration 和持久化 readiness 字段为零。 +- [x] AC10:用受控 fixture 复现 0014 三类缺项,分别提示所属公司、开户银行/银行卡和渠道限制;不使用或修改真实银行卡资料。 +- [x] AC11:相关服务端、客户端单元/Playwright、typecheck/build、Prisma validate、UI strict/self-test、Trellis governance、敏感扫描和 `git diff --check` 全部通过。 +- [ ] AC12(部署后):`/ready`、bundle marker、公网文案扫描通过;合成 API/页面证明 readiness 与原因码解析生效。真实资料补录和 0014 重试由授权人员完成,未取得真实证据时不得冒充 PASS。 + +## Test Plan + +- 原因码合同:运行 parity strict 检查及 self-test,覆盖注册表唯一性、同一 details key 跨域、未注册原因、缺客户端映射、孤儿/拼写漂移、新增无原因码调用、遗留指纹变化、例外过期,以及首批主链路试图加入例外时 fail closed。 +- 首批主链路:对报销提交/编辑/票据关联、付款创建/复核/登记、请假提交、考勤修正提交运行 focused 服务端测试;逐分支断言 HTTP status、稳定 `reasonCode`、兼容 details 和准确中文映射,并验证主链路遗留例外为零。 +- 付款 readiness:用受控 fixture 覆盖正常、状态异常、活动批次占用、缺所属公司、缺开户银行/银行卡、非建行、缺或不可解密公司账户、跨公司、无合格复核人;分别验证单条和组合投影,并以正常 fixture 证明可选择、可创建和提交复核不被误伤。 +- 事务与实时性:模拟候选读取后事实变化,确认创建事务重新校验、失败时不产生 batch/item/成功 audit/notification;修改受控员工资料或公司账户后重新查询,确认 readiness 即时变化且无持久化字段或缓存依赖。 +- 客户端与可访问性:逐项处置现有 20 处“参数校验失败”测试命中;断言具体原因就近显示、禁用按钮关联 `aria-describedby` 或等价语义、toast 非唯一反馈、未知原因不泄露 details/UUID/路径,有 request ID 时显示排障编号、无编号时不留空占位;不得以宽泛正则替代精确断言。 +- 工程门禁:运行相关 server/client 单元测试与 Playwright、`npm run prisma:validate`、前端 typecheck/build、UI strict/self-test、Trellis governance、敏感信息扫描和 `git diff --check`,并确认 schema/migration/seed/flag/routing 与生产数据修改均为零。 +- 部署后验收:在另行批准部署后核对 `/ready`、bundle marker 和公网文案扫描;仅用合成 API/页面样本验证三类 0014 缺项、正常付款路径及 B2/maker-checker 回归。真实银行卡资料、0014 建批/复核/登记必须由授权人员操作;缺少真实链路证据的项目明确记为 BLOCKED 或待自然样本验证,不以合成测试冒充 PASS。 + +## Out of Scope + +- 统一首页待办、通知推导和角色行动项模型;等待真实付款链路记录后另行规划。 +- 一次性迁移全部 365 处通用文案或 518 个调用文本;非首批路径只建立可到期遗留基线。 +- 支持跨行付款、银行直连、自动付款,或改变 maker-checker、付款角色、复核规则。 +- 自动补录或读取生产员工公司、开户银行、银行卡号和公司账户。 +- 修改业务状态机,新增 schema/migration/seed/flag/routing。 +- 把完整 details、payload、UUID、账号或内部路径写入日志、任务材料或普通页面。 + +## Risks and Deferred Items + +- 静态扫描只证明合同登记;focused 分支测试证明真实返回,二者缺一不可。 +- 单条与组合 readiness 语义不同;只做前者仍留下跨公司等点击后失败路径,因此组合预检是必交项。 +- request ID 可帮助定位,但当前 AppError details 不进服务端日志。是否增加脱敏 reasonCode 日志另行评估,本任务不记录原始 details。 +- 0014 只证明资料未维护,不证明员工没有建行卡;真实开户行由本人和授权管理员确认。 diff --git a/.trellis/tasks/09-05-error-explainability-payment-readiness/task.json b/.trellis/tasks/09-05-error-explainability-payment-readiness/task.json new file mode 100644 index 00000000..f4076347 --- /dev/null +++ b/.trellis/tasks/09-05-error-explainability-payment-readiness/task.json @@ -0,0 +1,33 @@ +{ + "id": "error-explainability-payment-readiness", + "name": "error-explainability-payment-readiness", + "title": "错误可解释性与付款就绪治理", + "description": "建立稳定业务错误原因码与渐进 parity 门禁,并以付款批次实时 readiness 消除点击后才暴露的资料阻断。", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P0", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-09-05", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "server/src/routes/paymentBatches.js", + "server/src/utils/AppError.js", + "client/src/api/errorMessages.ts", + "client/src/lib/resolveErrorMessage.ts", + "client/src/pages/PaymentBatchesPage.tsx", + "scripts/qa" + ], + "notes": "来源:真实单据 0014 创建付款批次时第三次出现‘参数校验失败’;生产只读诊断确认未创建批次,员工付款资料缺失且错误未被客户端解释。2026-09-05 最终规划已获用户批准实施;交接复核重点:门禁六类 self-test 真失败、主链路零例外且禁止新增例外、付款候选与创建共享同一 readiness resolver、20 处旧断言逐项处置且不得使用宽泛正则。0014 真实资料补录与付款不属于本 task 自动操作范围。", + "meta": {} +} \ No newline at end of file diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index d45f2e4e..4b90ceed 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -1,4 +1,5 @@ import { formatInvoiceOcrFieldLabels } from "../lib/invoiceOcr"; +import { resolveErrorReasonMessage, UNKNOWN_VALIDATION_MESSAGE } from "./errorReasons"; function readStringList(value: unknown): string[] { if (!Array.isArray(value)) { @@ -71,7 +72,13 @@ export function mapApiErrorMessage( code: string, details?: Record, fallbackMessage?: string, -) { +): string { + if (code === "VALID_001" && details?.reasonCode === "reimbursement.items_invoice_item_guard_failed") { + return mapApiErrorMessage(code, { ...details, reasonCode: undefined, items: "invoice_item_guard_failed" }); + } + if (code === "VALID_001" && details?.reasonCode !== undefined) { + return resolveErrorReasonMessage(details.reasonCode) ?? UNKNOWN_VALIDATION_MESSAGE; + } if (code === "AUTH_001") { return "账号或密码错误,请检查后重试。"; } @@ -512,5 +519,5 @@ export function mapApiErrorMessage( } } - return fallbackMessage || "请求失败,请稍后重试。"; + return code === "VALID_001" ? UNKNOWN_VALIDATION_MESSAGE : fallbackMessage || "请求失败,请稍后重试。"; } diff --git a/client/src/api/errorReasons.ts b/client/src/api/errorReasons.ts new file mode 100644 index 00000000..86fc7177 --- /dev/null +++ b/client/src/api/errorReasons.ts @@ -0,0 +1,152 @@ +export const ERROR_REASON_MESSAGES: Record = { + "reimbursement.material_preview_limit": "报销材料较多,暂时无法连续预览,请分批查看材料或联系管理员协助。", + "purchase_attachment.unsupported_type": "仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。", + "reimbursement.invoice_amount_required": "票据金额尚未确认,无法自动生成明细,请先核对票据金额或手动选择明细。", + "reimbursement.expense_type_required": "当前没有可用费用类型,请联系管理员维护后再添加票据。", + "reimbursement.invoice_not_bound": "该票据已不再关联业务单据,请刷新票据列表后确认。", + "reimbursement.invoice_unbind_status_invalid": "报销单当前不能移出票据,请先核实审批和付款状态。", + "reimbursement.invoice_bind_status_invalid": "报销单当前不能添加票据,请刷新并确认单据状态。", + "reimbursement.invoice_owner_mismatch": "票据所属人与报销人不一致,请选择本人对应的票据。", + "reimbursement.invoice_item_required": "请为该票据选择对应的报销明细后再添加。", + "reimbursement.travel_invoice_bind_status_invalid": "差旅订单当前不支持添加票据,请核实订单状态。", + "invoice_verification.too_frequent": "验真触发过于频繁,请稍后重试。", + "invoice_verification.attempts_exceeded": "验真重试次数已达上限,请联系财务人工核验。", + "reimbursement.invalid_input": "报销内容不完整或格式不正确,请核对页面必填项和日期后重试。", + "reimbursement.invalid_uuid": "记录标识已失效,请刷新页面后重新选择。", + "reimbursement.body_empty": "尚未填写修改内容,请补充后再保存。", + "reimbursement.date_to_must_be_after_date_from": "结束日期不能早于开始日期,请调整查询日期范围。", + "reimbursement.amount_must_be_positive": "明细金额必须大于 0。", + "reimbursement.template": "模板中的明细格式已不适用,请联系管理员修正模板后重试。", + "reimbursement.owner_employee_id_invalid_owner": "所选报销人不可用,请重新选择有效员工。", + "reimbursement.total_amount_invalid": "报销合计金额不正确,请核对各项明细金额。", + "reimbursement.total_amount_negative_not_allowed": "报销合计不能为负数,请核对各项明细金额。", + "reimbursement.workflow_missing_approval_rule": "尚未配置适用的报销审批规则,请联系管理员维护后重试。", + "reimbursement.precheck": "报销提交检查未通过,请按页面检查清单逐项处理后重试。", + "reimbursement.template_id_invalid_template": "所选报销模板已失效,请重新选择有效模板。", + "reimbursement.template_id_contains_disabled_expense_type": "模板包含已停用的费用类型,请联系管理员修正模板后重试。", + "reimbursement.linked_travel_request_id_not_found": "所选出差申请已不存在,请刷新后重新选择。", + "reimbursement.linked_travel_request_id_not_approved": "所选出差申请尚未获批,请先完成出差审批。", + "reimbursement.linked_travel_request_id_owner_mismatch": "出差申请与报销人不一致,请选择本人对应的出差申请。", + "reimbursement.linked_travel_request_id_already_linked": "该出差申请已关联其他报销单,请核对已有报销记录。", + "reimbursement.linked_travel_request_id_missing_expense_type_mapping": "该出差项目尚未配置对应费用类型,请联系管理员维护后重试。", + "reimbursement.status_invalid_edit_status": "当前报销单状态不允许编辑,请刷新并确认审批进度。", + "reimbursement.travel_end_date_invalid_date_range": "出差结束日期不能早于开始日期,请调整日期。", + "reimbursement.status_invalid_delete_status": "当前报销单状态不允许删除,请核实审批和付款进度。", + "reimbursement.status_invalid_attachment_edit_status": "当前报销单不能修改附件,请刷新并确认单据状态。", + "reimbursement.file_unsupported_mime_type": "附件类型不支持,请选择允许的图片或文档格式。", + "reimbursement.file_file_too_large": "附件超过大小限制,请压缩或更换文件后重试。", + "reimbursement.file_empty_file": "所选附件为空,请重新选择有效文件。", + "reimbursement.file_too_many_files": "一次最多上传 5 个附件,请减少数量后重试。", + "reimbursement.file_missing": "请先选择需要上传的附件。", + "reimbursement.reason_required": "读取该材料需要填写用途,请补充访问原因。", + "reimbursement.status_invalid_item_edit_status": "当前报销单不能修改明细,请刷新并确认单据状态。", + "reimbursement.expense_type_id_invalid_or_disabled": "所选费用类型已失效或停用,请重新选择。", + "reimbursement.reimbursement_invalid_status_for_invoice_item_link": "当前报销单状态已变化,不能再关联发票,请刷新页面。", + "reimbursement.invoice_invalid_unlinked_item_state": "这张发票的关联状态已变化,请刷新后重试。", + "reimbursement.linked_item_id_item_not_in_reimbursement": "所选明细已不存在或不属于本单,请刷新后重新选择。", + "reimbursement.status_invalid_submit_status": "当前报销单状态不允许提交,请刷新并确认是否已提交。", + "reimbursement.items_at_least_one_required": "请先添加至少 1 条报销明细。", + "reimbursement.items_positive_amount_required": "所有报销明细金额都必须大于 0,请修改后再提交。", + "reimbursement.items_invoice_item_guard_failed": "票据检查未通过,请核对每条明细与发票的关联和金额后重试。", + "reimbursement.travel_summary_incomplete": "出差汇总信息尚未填写完整,请补充页面提示的出差信息后提交。", + "reimbursement.submit_already_in_review": "该报销单已经在审批中,请勿重复提交,可查看审批进度。", + "reimbursement.invoices_bound_invoice_required": "报销单需要有效发票,请先添加并关联发票后提交。", + "reimbursement.budget_exceeded_block_mode": "本次报销超出可用预算,请调整金额或联系预算管理员处理。", + "reimbursement.workflow_missing_definition": "尚未配置可用报销审批流程,请联系管理员维护后重试。", + "reimbursement.workflow_no_actionable_approver": "当前没有可处理该报销的审批人,请联系管理员核实审批配置。", + "reimbursement.status_invalid_withdraw_status": "当前报销单不能撤回,请刷新并核实审批进度。", + "reimbursement.withdraw_no_pending_instance": "当前报销单没有可撤回的审批,请刷新并核实进度。", + "reimbursement.withdraw_already_processed_by_approver": "审批人已处理该报销单,不能撤回,请联系审批人确认后续处理。", + "reimbursement.feature_flag_project_linking_v1_disabled": "项目关联暂未开放,请联系管理员确认处理方式。", + "reimbursement.reimbursement_invalid_status_for_project_link": "当前报销单不能修改关联项目,请刷新并确认单据状态。", + "reimbursement.payment_payment_batch_v1_required": "该报销单须通过付款批次安排付款,请前往付款批次查看或创建付款安排。", + "reimbursement.status_invalid_pay_status": "当前报销单尚不满足付款条件,请核实审批和已有付款记录。", + "reimbursement.payment_already_paid_conflict": "该报销单已有不同的付款记录,请核实原付款,勿重复登记。", + "payment_batch.invalid_identifier": "付款记录标识已失效,请刷新后重新选择。", + "payment_batch.invalid_input": "付款操作内容不完整或格式不正确,请核对页面必填项后重试。", + "payment_batch.bank_receipt_required": "银行付款登记前必须上传有效银行回单,请先补充回单。", + "payment_batch.department_unavailable": "当前单据所属部门暂不能使用付款批次,请联系管理员确认处理方式。", + "payment_batch.source_not_found": "部分来源单据已不存在或不可用,请刷新后重新选择。", + "payment_batch.daily_sequence_exhausted": "今日付款批次编号已用完,请联系管理员处理,勿重复提交。", + "payment_batch.payee_account_unavailable": "收款账户资料暂不可用,请联系管理员检查后重试。", + "payment_batch.source_status_invalid": "来源单据尚未满足付款条件或已经付款,请刷新并核实审批与付款状态。", + "payment_batch.already_paid_conflict": "来源单据已有不同的付款记录,请核实已有记录,勿重复付款。", + "payment_batch.source_amount_changed": "来源单据金额已变化,请核对单据后重新安排付款。", + "payment_batch.settled_transaction_required": "尚未找到已结算的交易,请确认结算记录后再登记付款。", + "payment_batch.receipt_security_failed": "付款回单未通过安全检查,请联系管理员核实并重新上传有效回单。", + "payment_batch.receipt_field_invalid": "请通过回单上传入口重新选择文件。", + "payment_batch.receipt_too_large": "回单文件超过大小限制,请压缩后重试。", + "payment_batch.receipt_too_many": "上传回单数量超过限制,请减少文件数量后重试。", + "payment_batch.receipt_upload_limit": "回单上传超过请求限制,请减少文件数量或大小后重试。", + "payment_batch.receipt_file_invalid": "回单文件不符合上传要求,请核对格式、大小和文件内容后重新上传。", + "payment_batch.receipt_required": "请先选择有效的付款回单文件。", + "payment_batch.travel_payment_frozen": "差旅付款组批已暂停,请联系财务确认处理方式。", + "payment_batch.account_reveal_unavailable": "当前批次不支持查看收款账户,请核实批次类型和状态。", + "payment_batch.payee_snapshot_missing": "历史批次缺少收款资料快照,请联系管理员核实,不要据此重复付款。", + "payment_batch.receipt_not_editable": "当前付款状态不允许修改回单,请刷新并确认批次进度。", + "payment_batch.receipt_voided": "该回单已作废,请选择有效回单。", + "payment_batch.download_reason_required": "下载该回单需要填写用途,请补充下载原因。", + "payment_batch.receipt_void_required": "回单不能直接删除,请使用作废操作并说明原因。", + "payment_batch.receipt_already_voided": "该回单已经作废,请刷新列表,勿重复操作。", + "payment_batch.metadata_not_editable": "当前批次状态不允许修改付款信息,请刷新并核实进度。", + "payment_batch.item_not_editable": "只能修改尚未完成的付款明细,请核实该明细状态。", + "payment_batch.batch_not_reviewable": "当前批次状态不允许复核,请刷新并确认是否已处理。", + "payment_batch.items_not_pending": "部分明细已处理,不能复核该批次,请刷新并核实明细状态。", + "payment_batch.maker_required": "该批次缺少制单人信息,请联系管理员核实后再复核。", + "payment_batch.maker_checker_conflict": "制单人不能复核自己的付款批次,请由另一位有权限的经理或负责人复核。", + "payment_batch.risk_resolution_required": "还有未处理的风险提示,请逐项确认并处理后再复核。", + "payment_batch.batch_not_executable": "只有已复核或待重试的批次才能登记付款,请先确认批次状态。", + "payment_batch.review_required": "该批次尚未完成有效复核,请先由独立复核人完成复核。", + "payment_batch.batch_not_cancellable": "该批次当前不能取消,请核实是否已有付款结果并联系财务处理。", + "leave.invalid_uuid": "记录标识已失效,请刷新页面后重新选择。", + "leave.invalid_input": "提交内容不完整或格式不正确,请核对必填项和日期后重试。", + "leave.date_from_invalid": "开始日期不正确,请重新选择。", + "leave.date_to_invalid": "结束日期不正确,请重新选择。", + "leave.date_to_must_be_after_date_from": "结束日期不能早于开始日期,请调整日期范围。", + "leave.department_id_scope_all_only": "当前查询范围不支持指定部门,请调整查询范围。", + "leave.date_invalid": "日期格式不正确,请重新选择日期。", + "leave.file_only_single_file_supported": "每次只能上传一个证明文件,请重新选择。", + "leave.file_file_too_large": "文件超过上传大小限制,请压缩或更换文件后重试。", + "leave.leave_type_id_missing_active_policy": "当前假种尚未配置有效策略,请联系人事维护后重试。", + "leave.file_required": "该假种需要证明材料,请上传后再提交。", + "leave.file_unsupported_mime_type": "文件类型不支持,请选择允许的证明材料格式。", + "leave.status_invalid_edit_status": "当前单据状态不允许编辑,请刷新并确认审批进度。", + "leave.status_invalid_submit_status": "当前单据状态不允许提交,请刷新并确认是否已提交。", + "leave.submit_already_in_review": "该申请已经在审批中,请勿重复提交,可查看审批进度。", + "leave.leave_conflict_range": "该时段已有请假申请,请调整日期或先处理已有申请。", + "leave.leave_insufficient_balance": "可用假期余额不足,请调整天数或联系人事核实。", + "leave.workflow_missing_definition": "尚未配置可用审批流程,请联系管理员维护后重试。", + "leave.workflow_no_actionable_approver": "当前没有可处理该申请的审批人,请联系管理员核实审批配置。", + "leave.status_invalid_withdraw_status": "当前单据状态不允许撤回,请刷新并确认审批进度。", + "leave.withdraw_no_pending_instance": "当前没有可撤回的审批,请刷新并确认审批进度。", + "leave.withdraw_already_processed_by_approver": "审批人已处理该申请,不能再撤回,请联系审批人确认后续处理。", + "leave.session_invalid": "请选择有效的上午或下午时段。", + "leave.leave_invalid_range": "请假结束时间早于开始时间,请调整请假时段。", + "leave.leave_non_working_range": "所选时段没有可请假的工作日,请调整请假日期。", + "leave.leave_missing_reserve_ledger": "请假额度预留记录缺失,请联系人事核实后处理。", + "leave.file_empty": "所选文件为空,请重新选择有效文件。", + "attendance_correction.invalid_input": "提交内容不完整或格式不正确,请核对必填项和日期后重试。", + "attendance_correction.invalid_uuid": "记录标识已失效,请刷新页面后重新选择。", + "attendance_correction.datetime_invalid": "时间格式不正确,请重新选择发生时间。", + "attendance_correction.feature_flag_attendance_unified_v1_disabled": "考勤修正暂未开放,请联系管理员确认处理方式。", + "attendance_correction.workflow_missing_definition": "尚未配置可用审批流程,请联系管理员维护后重试。", + "attendance_correction.workflow_no_actionable_approver": "当前没有可处理该申请的审批人,请联系管理员核实审批配置。", + "attendance_correction.status_invalid_cancel_status": "当前申请已不能取消,请刷新并确认审批结果。", + "payment_batch.legal_entity_required": "报销人尚未维护所属公司,请联系管理员补充后再安排付款。", + "payment_batch.multiple_legal_entities": "所选单据属于不同公司,请按公司分别创建付款安排。", + "payment_batch.payee_bank_required": "收款人尚未维护开户银行,请联系管理员核实真实开户行后补充。", + "payment_batch.payee_account_required": "收款人尚未维护银行卡,请联系管理员补充真实收款资料。", + "payment_batch.payee_bank_unsupported": "当前银行付款仅支持建行收款账户,请停止本次银行付款并联系财务确认处理方式,不要修改真实开户行。", + "payment_batch.payment_account_required": "所属公司尚无可用的建行付款账户,请联系账户管理员维护后重试。", + "payment_batch.payment_account_unavailable": "公司付款账户暂不可用,请联系管理员检查账户配置后重试。", + "payment_batch.active_batch_exists": "所选单据已进入其他付款安排,请刷新列表并查看已有批次。", + "payment_batch.reviewer_required": "当前没有可复核的经理或负责人,制单人不能复核自己的批次,请联系管理员安排独立复核人。", +}; + +export const UNKNOWN_VALIDATION_MESSAGE = "操作未完成,请核对页面提示;如仍无法继续,请联系管理员协助处理。"; + +export function resolveErrorReasonMessage(reasonCode: unknown): string | undefined { + return typeof reasonCode === "string" && Object.hasOwn(ERROR_REASON_MESSAGES, reasonCode) + ? ERROR_REASON_MESSAGES[reasonCode] + : undefined; +} diff --git a/client/src/api/paymentBatches.ts b/client/src/api/paymentBatches.ts index dcb05794..a569d5b2 100644 --- a/client/src/api/paymentBatches.ts +++ b/client/src/api/paymentBatches.ts @@ -89,7 +89,13 @@ export type PaymentBatchDetail = PaymentBatch & { processingProgress: ProcessingProgress; }; +export type PaymentReadiness = { + ready: boolean; + blockers: Array<{ reasonCode: string; entityId?: string; entityCode?: string }>; +}; + export type PaymentBatchCandidate = { + readiness: PaymentReadiness; entityId: string; entityType: "reimbursement"; businessNo: string; @@ -140,15 +146,27 @@ export async function fetchPaymentBatchDetail(batchId: string) { return apiRequestData<{ item: PaymentBatchDetail }>(`/api/payment-batches/${batchId}`); } -export async function fetchPaymentBatchCandidates(query: { q?: string; page?: number; pageSize?: number } = {}) { +export async function fetchPaymentBatchCandidates(query: { q?: string; page?: number; pageSize?: number; paymentChannel?: PaymentChannel } = {}) { 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)); + if (query.paymentChannel) params.set("paymentChannel", query.paymentChannel); const suffix = params.toString() ? `?${params.toString()}` : ""; return apiRequestData(`/api/payment-batches/candidates${suffix}`); } +export async function fetchPaymentBatchReadiness(payload: { + entityType: PaymentBatchEntityType; + entityIds: string[]; + defaultPaymentChannel: PaymentChannel; +}) { + return apiRequestData("/api/payment-batches/readiness", { + method: "POST", + body: JSON.stringify(payload), + }); +} + export async function createPaymentBatch(payload: { entityType: PaymentBatchEntityType; name?: string; diff --git a/client/src/lib/paymentBatchDisplay.ts b/client/src/lib/paymentBatchDisplay.ts index f96b8561..9c64400d 100644 --- a/client/src/lib/paymentBatchDisplay.ts +++ b/client/src/lib/paymentBatchDisplay.ts @@ -1,4 +1,19 @@ -import type { PaymentBatchStatus } from "../api/paymentBatches"; +import type { PaymentBatchItem, PaymentBatchStatus } from "../api/paymentBatches"; +import type { RiskFlag } from "../api/feeControl"; +import { resolveErrorReasonMessage, UNKNOWN_VALIDATION_MESSAGE } from "../api/errorReasons"; + +export function paymentItemFailureMessage(item: Pick, batchId: string) { + if (item.status !== "failed" && !item.failureCode && !item.failureMessage) return null; + const flag = item.openRiskFlags.find((risk) => risk.riskCode === "payment_batch_execute_failed" + && risk.payloadJson?.batchId === batchId && risk.payloadJson?.itemId === item.id); + return resolveErrorReasonMessage(flag?.payloadJson?.reasonCode) ?? UNKNOWN_VALIDATION_MESSAGE; +} + +export function paymentRiskFlagsForDisplay(flags: RiskFlag[]) { + return flags.map((flag) => flag.riskCode === "payment_batch_execute_failed" ? { + ...flag, detail: resolveErrorReasonMessage(flag.payloadJson?.reasonCode) ?? UNKNOWN_VALIDATION_MESSAGE, + } : flag); +} const PAYMENT_BATCH_STATUS_LABELS: Record = { draft: "待付款复核", diff --git a/client/src/lib/resolveErrorMessage.ts b/client/src/lib/resolveErrorMessage.ts index d4d8853d..3d43ab81 100644 --- a/client/src/lib/resolveErrorMessage.ts +++ b/client/src/lib/resolveErrorMessage.ts @@ -1,5 +1,6 @@ import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; +import { UNKNOWN_VALIDATION_MESSAGE } from "../api/errorReasons"; function toUserSafeMessage(message: string | undefined, fallback: string) { const normalized = (message ?? "") @@ -8,9 +9,11 @@ function toUserSafeMessage(message: string | undefined, fallback: string) { if (!normalized) { return fallback; } + if (normalized === "参数校验失败") return UNKNOWN_VALIDATION_MESSAGE; const exposesTechnicalDetail = !/[\u3400-\u9fff]/.test(normalized) || + /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i.test(normalized) || /https?:\/\/|\/api\/|(?:^|\s)\/[A-Za-z0-9._-]+(?:\/[^\s]*)?|[A-Za-z]:\\|(?:^|\n)\s*at\s|\b(?:GET|POST|PUT|PATCH|DELETE)\s+\/|^(?:\{|\[)/.test(normalized); return exposesTechnicalDetail ? fallback : normalized; @@ -21,8 +24,13 @@ export function resolveErrorMessage( fallback = "操作失败,请稍后重试", ): string { if (isApiError(error)) { + const message = mapApiErrorMessage(error.code, error.details, error.message); + if (message === UNKNOWN_VALIDATION_MESSAGE) { + const requestId = /^[A-Za-z0-9_-]{1,80}$/.test(error.requestId) ? error.requestId : ""; + return requestId ? `${message} 排障编号:${requestId}` : message; + } return toUserSafeMessage( - mapApiErrorMessage(error.code, error.details, error.message), + message, fallback, ); } diff --git a/client/src/pages/PaymentBatchesPage.tsx b/client/src/pages/PaymentBatchesPage.tsx index 4571bd7e..d98d4304 100644 --- a/client/src/pages/PaymentBatchesPage.tsx +++ b/client/src/pages/PaymentBatchesPage.tsx @@ -1,4 +1,5 @@ import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage"; +import { resolveErrorReasonMessage, UNKNOWN_VALIDATION_MESSAGE } from "../api/errorReasons"; import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useSearchParams } from "react-router-dom"; @@ -9,6 +10,7 @@ import { downloadPaymentOrder, executePaymentBatch, fetchPaymentBatchCandidates, + fetchPaymentBatchReadiness, fetchPaymentBatchDetail, fetchPaymentBatchReceiptAttachments, fetchPaymentBatches, @@ -62,7 +64,7 @@ import { StatusBadge } from "../components/common/StatusBadge"; import { DateTextInput } from "../components/common/DateTextInput"; import { downloadBlob } from "../lib/download"; import { formatDisplayDate, formatDisplayDateTime, formatMoney } from "../lib/displayFormat"; -import { paymentBatchStatusLabel } from "../lib/paymentBatchDisplay"; +import { paymentBatchStatusLabel, paymentItemFailureMessage, paymentRiskFlagsForDisplay } from "../lib/paymentBatchDisplay"; import { toast } from "sonner"; const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"]; @@ -149,6 +151,7 @@ export function PaymentBatchesPage() { Boolean(me?.roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode))); const [selectedCandidateIds, setSelectedCandidateIds] = useState([]); const [selectedPaymentChannel, setSelectedPaymentChannel] = useState("bank"); + const [creationError, setCreationError] = useState(""); const receiptInputRef = useRef(null); const [error, setError] = useState(""); const [confirmPaymentAction, setConfirmPaymentAction] = useState<{ @@ -211,15 +214,33 @@ export function PaymentBatchesPage() { queryFn: () => fetchPaymentBatches(), }); const candidatesQuery = useQuery({ - queryKey: ["payment-batch-candidates", candidateSearch, candidatePage, candidatePageSize], + queryKey: ["payment-batch-candidates", candidateSearch, candidatePage, candidatePageSize, selectedPaymentChannel], enabled: canManageBatch, queryFn: () => fetchPaymentBatchCandidates({ q: candidateSearch, page: candidatePage, pageSize: candidatePageSize, + paymentChannel: selectedPaymentChannel, }), }); + const creationReadinessQuery = useQuery({ + queryKey: ["payment-batch-readiness", [...selectedCandidateIds].sort(), selectedPaymentChannel], + enabled: canManageBatch && selectedCandidateIds.length > 0 && activeView === "pending", + queryFn: () => fetchPaymentBatchReadiness({ + entityType: "reimbursement", entityIds: selectedCandidateIds, defaultPaymentChannel: selectedPaymentChannel, + }), + staleTime: 0, + }); + const creationBlocker = creationReadinessQuery.isFetching || creationReadinessQuery.isPending + ? "正在核对付款条件,请稍候。" + : creationReadinessQuery.isError + ? resolveUserErrorMessage(creationReadinessQuery.error) + : creationReadinessQuery.data?.ready !== true + ? [...new Set((creationReadinessQuery.data?.blockers ?? []).map((blocker) => resolveErrorReasonMessage(blocker.reasonCode) ?? UNKNOWN_VALIDATION_MESSAGE))].join(" ") || UNKNOWN_VALIDATION_MESSAGE + : ""; + useEffect(() => { setCreationError(""); }, [selectedCandidateIds, selectedPaymentChannel]); + const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]); const candidateTotal = candidatesQuery.data?.total ?? 0; const candidateTotalPages = Math.max(1, Math.ceil(candidateTotal / candidatePageSize)); @@ -344,12 +365,15 @@ export function PaymentBatchesPage() { }; const createMutation = useMutation({ - mutationFn: async () => - createPaymentBatch({ + mutationFn: async () => { + if (!selectedCandidateIds.length || creationBlocker) throw new Error(creationBlocker || "请先选择可付款的单据。"); + return createPaymentBatch({ entityType: "reimbursement", entityIds: selectedCandidateIds, defaultPaymentChannel: selectedPaymentChannel, - }), + }); + }, + onMutate: () => { setCreationError(""); }, onSuccess: async ({ item }) => { setError(""); setSelectedCandidateIds([]); @@ -365,8 +389,10 @@ export function PaymentBatchesPage() { }, onError: (err) => { const message = resolveError(err); - setError(message); - toast.error(message); + setCreationError(message); + void candidatesQuery.refetch(); + void creationReadinessQuery.refetch(); + toast.error("未创建付款安排,请查看动作旁的提示。"); }, }); @@ -392,10 +418,11 @@ export function PaymentBatchesPage() { const executeMutation = useMutation({ mutationFn: executePaymentBatch, - onSuccess: async () => { + onSuccess: async ({ item }) => { setError(""); await refresh(); - toast.success("付款已登记完成"); + if (item.status === "failed") toast.error("部分付款登记未完成,请查看明细中的原因后重试。"); + else toast.success("付款已登记完成"); }, onError: (err) => { const message = resolveError(err); @@ -697,6 +724,9 @@ export function PaymentBatchesPage() { ) : null} {(candidatesQuery.data?.items ?? []).map((candidate) => { const selected = selectedCandidateIds.includes(candidate.entityId); + const candidateReady = candidate.readiness?.ready === true; + const blockerText = [...new Set((candidate.readiness?.blockers ?? []).map((blocker) => resolveErrorReasonMessage(blocker.reasonCode) ?? UNKNOWN_VALIDATION_MESSAGE))].join(" ") || UNKNOWN_VALIDATION_MESSAGE; + const reasonId = `payment-candidate-reason-${candidate.entityId}`; return ( ); @@ -739,8 +772,6 @@ export function PaymentBatchesPage() { ) : null} - {selectedCandidateIds.length ? ( - <>