比较提交

...
2 次代码提交
作者 SHA1 备注 提交日期
wangsli 842c61e696 docs: 核对业务交接与后续恢复计划(09-08-reimbursement-payment-closeout)
ci-minimal / check-minimal (push) Canceled after 0s
ci-e2e / e2e (push) Canceled after 0s
2026-09-16 12:56:27 +08:00
wangsli 3516d7ec04 fix: 完善动态错误指引与发票日期校验(09-05-error-explainability-payment-readiness) 2026-09-16 12:56:27 +08:00
共修改 34 个文件,包含 1042 行新增和 616 行删除
@@ -18,6 +18,17 @@ QA script patterns over new abstractions.
## Common Checks ## Common Checks
### Payment Account And Invoice Read Errors
- Payment-account maintenance and invoice-pool read validation use registered
`payment_account.*` and `invoice_pool.*` reasons. Keep HTTP status, top-level
error code and legacy detail fields; add `reasonCode` rather than changing
permissions, queries or transaction order to make validation pass.
- Invalid input and unavailable references must fail before successful writes,
audit or notifications. Retain masked-account behavior and never echo input
bank accounts in error messages. Reason registry, client mapping and exact
message fixtures change together; remove only the migrated AST baseline entries.
### Receipt OCR Matching Service Boundary ### Receipt OCR Matching Service Boundary
- Failed evidence persistence is not OCR failure. In a separate bounded transaction, - Failed evidence persistence is not OCR failure. In a separate bounded transaction,
@@ -187,9 +198,30 @@ Wrong: treat a notice count or pending-batch aggregate as the complete finance w
domain-scoped `details.reasonCode`; do not infer business identity from a domain-scoped `details.reasonCode`; do not infer business identity from a
details key alone. The builder rejects unregistered reasons. details key alone. The builder rejects unregistered reasons.
- **迁移一条遗留原因码 = 服务端注册 + 客户端映射 + 具体内容断言,三项缺一不可。** - **迁移一条遗留原因码 = 服务端注册 + 客户端映射 + 具体内容断言,三项缺一不可。**
Dynamic guidance requires an additional payload test containing both reasonCode
and its legacy details (missing fields, retry timestamps, failed policy rules).
A reason-only fixture cannot prove preservation of actionable details. Project
only allowlisted fields for that reason; malformed or cross-domain details must
fall back safely instead of bypassing the registered message contract.
Fixed expected text lives in `server/tests/unit/fixtures/error-reason-messages.json` 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 and is asserted against both registry and client rendering; do not replace
these assertions with broad message regexes or expectations derived at runtime. these assertions with broad message regexes or expectations derived at runtime.
- Authentication explanations must retain the same `auth.code_invalid_or_expired`
reason for missing, inactive, locked and wrong-code accounts; do not introduce
account-enumerating recovery messages or include submitted secrets in errors.
- Invoice verification availability is distinct from OCR availability. Explain a
disabled verification gate without claiming OCR is disabled or enabling either
provider. Invalid or duplicate requests must not enqueue work or write records.
- Workflow input-error migration preserves parsers, permission order and transaction
boundaries. Invalid comments, task identifiers and batch requests must fail before
business transactions; changing error metadata does not authorize state-machine edits.
- Invoice date-only input must survive UTC date round-trip unchanged. Reject invalid
timestamps and calendar overflow (including non-leap February 29) before updates
or audits; JavaScript Date construction alone is not calendar validation.
- Preserve pre-existing actionable top-level API messages when migrating a reason.
Use a fixed registered builder call, retaining legacy details/status and then the
original message if necessary; do not bypass the AST gate with direct AppError
literals or dynamic, unvalidated reason names.
- Legacy entries are versioned AST-shape SHA-256 fingerprints plus relative file/operation, - Legacy entries are versioned AST-shape SHA-256 fingerprints plus relative file/operation,
occurrence count, owner, explanation and expiry date (UTC). Whitespace, quotes, occurrence count, owner, explanation and expiry date (UTC). Whitespace, quotes,
indentation, trailing commas and parenthesization must not change identity. indentation, trailing commas and parenthesization must not change identity.
@@ -1,5 +1,21 @@
# 隔离备份恢复演练实施计划 # 隔离备份恢复演练实施计划
## 2026-09-15 简单配对恢复的集中批准边界(未执行)
不新增runner。已知可识别的历史输入为receipt-followup-release回滚备份:manifest `56a9addf57556580c46205ae8e68b01a8e23a45ae7757d4cfce9ec9c92660c86`、来源8d08d46d;对应镜像归档SHA256 `d17d7efaef073b5cc6b78f1ce2a73e938835e4910946c83e855b48e61780541b`,历史回读通过。它不是已核最新自然备份,不能报24小时内,也不能换成b7或当前本地代码假装同源恢复。
拟定单次范围:在ARM侧隔离的一次性Postgres16及匹配应用容器中恢复该固定源DB和storage,使用专用空卷、无端口映射的internal网络、禁OCR/通知/支付/ERP等外联和定时任务。实施前只读重核源manifest/包摘要/镜像身份、目标容量与生产ready基线;无法获得无业务副作用的应用启动配置则停在DB+附件结果,不把app记PASS。镜像加载、源副本、容器/卷创建和恢复均待专项批准。
验证仅限迁移集合/校验和、关键表聚合计数、附件引用存在性及可用大小/摘要、隔离应用健康与授权可读性;不回传原件或账号,不改生产引用。历史缺失附件仍无可定位的治理落库证据,若再次出现,分类报告NO_GO,不自动排除“测试件”。只删除本轮确实创建且身份可核的容器/网络/卷/临时副本,保留原备份、旧回滚镜像与生产服务;末尾复核ready及清理结果。
此固定历史源方案只回答“已备份的数据能否配对恢复”。最新自然批次/异机传输/当前生产灾备就绪是另一个只读时效核验,必须以执行时的新事实作结论,不能用此历史恢复代替。当前状态PREPARED_SCOPE_NOT_AUTHORIZED,不是恢复通过或完整生产就绪。
## 2026-09-15 统一收口接续
统一计划入口见09-08-reimbursement-payment-closeout/implement.md六项计划,本task负责C的配对恢复,ARM传输事实由08-28-production-arm-backup-transfer-incident核对。当前已知有历史DB恢复PASS和近期备份/镜像完整性PASS,不能回退说“从未恢复数据库”,也不能因此宣称DB+附件+应用恢复已完整通过。
先核09-04任务历史缺失附件的实际治理结果/批准范围,不能把“可能是测试”当可删除授权清单。准备同一manifest的DB/附件、匹配应用镜像、固定隔离目标/禁止生产连接和通知OCR外联、容量上限、清理范围与停止条件;形成一次准确受控恢复请求,不重复升级复杂runner。授权后一次简单恢复核迁移、关键表、附件引用及适用摘要、应用只读健康/可读性,原件内容不出日志。任何缺失归因分清备份不全、历史源缺失与工具误判,不强行删除引用通过门禁。
## 2026-09-11 配对恢复准备(未执行) ## 2026-09-11 配对恢复准备(未执行)
用户曾回传一次 DB_RESTORE_PASS:66 条迁移集合匹配、12 类表已验、隔离资源已清理。这是管理员回执,不是本轮独立复验;不能继续写“数据库从未成功恢复”。附件与应用配对恢复尚未证明。 用户曾回传一次 DB_RESTORE_PASS:66 条迁移集合匹配、12 类表已验、隔离资源已清理。这是管理员回执,不是本轮独立复验;不能继续写“数据库从未成功恢复”。附件与应用配对恢复尚未证明。
@@ -1,5 +1,17 @@
# 实施计划:审批退回结果通知缺失治理 # 实施计划:审批退回结果通知缺失治理
## 2026-09-15 本地通知链再验(未发生产消息)
现有workflow.return-notifications随本轮联合19文件471项回归通过,额外运行`npm test -- tests/unit/routes/notifications.test.js tests/unit/services/imRouting.test.js tests/unit/services/notificationDispatcher.test.js`,3文件27/27通过,日志/tmp/followup-channels-tests.log。覆盖站内/派发编排、渠道管理和过滤逻辑,不代表真实企业微信接收成功。本轮未变routing、未修改生产配置,也未制造真人退回单或发送测试消息。
现有实现不需要重复开发;外部验收剩余是执行时只读核实际渠道开关/过滤矩阵与自然投递回执。若必须新建测试消息或改配置,按具体接收对象/消息数量/脱敏内容集中批准后再执行,不让员工承担软件回归。
## 2026-09-15 统一收口接续
统一计划入口见09-08-reimbursement-payment-closeout/implement.md六项计划,本task负责B的通知部分。2e69fe4e为已有提交且是当前候选祖先,不重复新增notification type;先核业务类型、投递routing、站内待办和消息delivery四层的已测/已发布/待验事实。对应09-11门户任务只读投影不依赖消息成功。
默认自主做现有实现和证据核对、合成退回/重提及失败容错测试;新生产routing或企微写入、真实员工消息验收列入集中操作清单后另批。不能为拿到PASS制造真实退回,不让员工承担软件基础回归;真实投递缺证据要分列,不以代码包含性作完结依据。
## 实施顺序 ## 实施顺序
1. 在通知类型常量中新增 `leave.returned`、`purchase_request.returned`、`travel_request.returned`,加入 IM 支持表和领域映射。 1. 在通知类型常量中新增 `leave.returned`、`purchase_request.returned`、`travel_request.returned`,加入 IM 支持表和领域映射。
@@ -1,5 +1,67 @@
# Implementation Plan:错误可解释性与付款就绪治理 # Implementation Plan:错误可解释性与付款就绪治理
## R1内容复核处置(R2修订,未提交/部署)
R2结果:`npm test -- tests/unit/scripts/error-reason-client.test.js tests/unit/routes/auth.login.test.js tests/unit/routes/workflow.transfer.test.js tests/unit/routes/invoicePool.patch.test.js`四文件311/311(244条客户端契约);typecheck与vite build退出0。修订后visual-portal.spec.ts实际22/22零重试,含新增财务两段href和BX/PB均消失。日志/tmp/followup-r2-unit.log、typecheck/build/browser同前缀;不是R1的471项整批重跑。
`RUN_ISOLATED_DB_TEST=1 RUN_BROWSER_TEST=1 RUN_JOB_QUEUE=1 RUN_ERROR_GUIDANCE_TEST=1 node scripts/qa/test-invoice-first-http.cjs`通过PASS_ERROR_GUIDANCE_REAL_AUTH_DB_BROWSER:真实密码登录/JWT、实际发票PATCH携原因码与missingFields、页面显示发票代码/销售方缺失、失败保留ocr_failed,补齐后数据库和页面均变available。fixture是隔离库预置ocr_failed,不声称本次由OCR产生;mock provider无真实云调用。原invoice-ocr-flow全套seed用例本轮未原样运行,其对应修正子流程由此专项覆盖。隔离数据库/合成存储已清理,浏览器及临时preview均关闭,生产未接触。
外部新会话结论有条件通过,未执行测试;包完整性另已核,不能合并成独立运行验收。F1/F2按阻断修复:客户端仅对三个已注册原因投影其允许的动态字段,再复用原文案分支;无动态数据保留注册fallback,不将其他域details带入递归。OCR字段/密码策略使用已知清单,时间只接受有效UTC时间戳,不直接回显未知payload。补同时带reasonCode与真实legacy键的精确内容测试,以及未知/跨域details不泄露反例;invoice-ocr-flow原断言改为实际缺失两字段的完整文案。
F3登记未修债务:共享utils/date日期解析及报销/差旅/资产/付款消费者仍需逐模块审查严格日历校验;本次只修发票的局部函数,不能据此声称共享解析安全。归本任务后续日期输入批次,负责人shunli,09-25复审时给出消费者矩阵、拒绝溢出/闰日/月末/null/时区边界、无写入回归及历史异常只读盘点方案;不改历史记录、不混进本次R2的阻断修复,不延长错误豁免日期。
F4已修:转审输入原因文案同步注册/客户端/固定夹具为10至500字;转审实际守卫未变。F5补财务两段href断言和刷新后BX/PB均消失断言,旧R1表格超出当时实际断言的部分以复核结论为准,新增断言重跑后另记。F6批量逐条错误不携reasonCode登记后续投影债务,不破坏已有跳过键;F7密码登录的401/403/423为既有可区分设计,本次防枚举声明仅适用于邮箱验证码/重置链,不扩称全部登录路径。
R1包及其manifest不变;本轮代码已偏离R1,必须重冻R2后交增量复核。471/27/22是R1记录,不能冒称本次输入通过。
## 2026-09-15 第三阶段连续收口(本地实施与单元/构建验证通过,未提交/部署)
最终本地结果:19文件471/471通过(/tmp/followup-stage3-tests.log),其中包含发票上传/编辑/选票/文件/查验/差旅关联、七份workflow路由回归、门户、回单独立复核、auth/me/付款账户及237条客户端原因契约。命令为`cd server && npm test --`后逐个传入上述日志首行登记的19个测试文件;不是全仓测试,也未发送真实OCR。通知管理/IM路由/dispatcher另轮27/27,不能声称实际通知已发送。`cd client && npm run typecheck && ./node_modules/.bin/vite build`退出0;本轮修改后构建,不复用旧dist。AST229注册/612遗留/零finding、Trellis、process baseline及diff检查通过;docs registry零错误,仍有既有22项未登记告警。浏览器交接测试另记结果。
到期清单收口:paymentAccounts、auth/me、invoicePool主路由及read/verification/shared-schemas、workflow路由的本批VALID_001均已迁移,不为这些路径保留豁免。剩余591条entries/count612仍由原baseline逐条保留owner及2026-10-05到期;utils/workflow15条涉及路由推导,需独立按流程负例评审,workflowDefinitions11条属配置域,不与本次输入修复混交。其他模块保持原逐项责任与期限;09-25复审、09-30有理由的延期提案、10-02查漏均是计划而非已安排自动任务,不允许统一延期,也不把全部技术债宣称完成。
按“尽量一次完成、不重复询问”继续原范围:invoicePool主路由与查验查询schema、workflow路由剩余操作级原因迁移。不是全库612条遗留统一改名,不动workflowDefinitions配置域及utils/workflow审批路由算法。当前AST PASS registered229/migrated316/legacy612;本轮开始695,累计减少83次遗留命中。上述目标路由已无遗留命中,领域workflow仍有utils/workflow等15条,不能把路由清零写成全审批域清零。
发票覆盖上传文件/大小/类型/所属人、修改输入/金额/日期/必填OCR信息、归档、OCR重试和重复例外前置。原PDF专用顶层文案与兼容details保留,原因码走注册builder;不修改OCR默认值、不开启查验、不发送真实发票。审批剩余提示区分已处理、本人不能自审、目标人不可用/不合格、节点异常、业务类型不支持、催办冷却等,守卫条件/顺序/锁/状态不改。
新增日期负例发现真实缺陷:JS Date会把2月30日滚动为3月,且无效月份可能生成Invalid Date。沿本task发票字段输入校验补有限时间值与UTC日期回读一致性;增加非闰年2月29日、2月30日、4月31日、13月拒绝且无写入,以及真实闰日/月末正例。此项是实际输入验证修复,不冒充纯文案更改;不回填历史日期。
中途测试发现builder固定通用顶层message,直接替换PDF两处分支会改变旧API文案;已恢复专用文案且保留原因码。静态门禁不接受动态原因码或直接AppError伪迁移,因此分别使用固定注册builder,不修改门禁或增加豁免。最终联合测试、浏览器交接与构建结果完成后补记,不复用中途失败作为通过。
## 2026-09-15 后续三域实施(当前权威增量,未提交/未部署)
最终客户端`npm run typecheck && ./node_modules/.bin/vite build`退出0(/tmp/followup-final-typecheck.log、followup-final-build.log),构建包含本节所有34个新增原因映射。保留既有Browserslist/chunk提示,不新加依赖、不把构建成功称为已发布。
最终同输入回归:`cd server && npm test -- tests/unit/routes/auth.login.test.js tests/unit/routes/me.test.js tests/unit/routes/paymentAccounts.test.js tests/unit/routes/invoicePool.bindable.test.js tests/unit/routes/invoicePool.file.test.js tests/unit/routes/invoicePool.verification.test.js tests/unit/routes/workflow.batch.test.js tests/unit/routes/workflow.transfer.test.js tests/unit/routes/workflow.permissions.test.js tests/unit/routes/workflow.return-notifications.test.js tests/unit/routes/workflow.task-detail.test.js tests/unit/routes/portal.test.js tests/unit/services/paymentReceiptManualReview.test.js tests/unit/scripts/error-reason-client.test.js`,14文件367/367通过,日志/tmp/followup-final-tests.log。覆盖实际客户端错误解析、认证负例与原业务守卫的合成回归,不是公网/browser/真实JWT新验收。AST、node语法、Trellis治理、process baseline、diff检查通过;docs registry零错误,既有22项未登记告警未扩修。
在已批准D阶段继续完成auth/me、发票查验及审批输入,不另建task。auth/me新增10个原因:输入格式、设备上下文、密码策略、无效内部入口、统一无效验证码及通知设置。缺失/停用/锁定账户与验证码不正确仍返回相同原因,不新增账号枚举;登录、刷新、密码修改与会话事务顺序不变。三条旧测试精确details断言漏加原因码导致初跑失败,修正断言后auth/me连同首批六文件259/259通过,不改业务逻辑绕过测试。
发票查验新增disabled/already_pending/record_missing三个原因并复用invoice_pool.id_invalid;不是OCR开关变更,不启用查验、不新增云端调用。补不存在查验记录、重复排队与无效标识的无写入/无队列反例;两文件201/201通过(含当时191条客户端契约)。审批迁移七个输入parser:意见、转审输入、任务ID、批量同意/退回、催办与统计范围;权限门、状态机、事务与转审资格不变。七个HTTP输入反例均在事务前拒绝;六文件243/243通过(含当时198条客户端契约),包含既有转审、权限、退回通知及详情回归。
最终AST检查PASS:registered190、migrated271、legacy656、findings0;基线634条entries/count656,较本轮开始695减少39次AST遗留命中,日期仍全部2026-10-05。不是已经清完656条;保留invoicePool.js30、shared/schemas1、workflow.js13,workflowDefinitions11仍为未启用配置域,不扩改审批状态/资格。这些余项继续按操作与负例迁移,禁止整文件批量替换或统一延期。
验证日志:/tmp/followup-errors-combined.log、followup-verification-tests.log、followup-workflow-tests.log、followup-final-scan.json。最终同输入合并回归、typecheck/build另记录结果;以上不同轮测试含重叠,不累加宣称独立用例数。没有提交、推送、部署或生产写入;不是独立复核结论。
## 2026-09-15 第一批实施与验证(未提交/未部署)
按统一计划实际运行既有AST检查器,修复前PASS:registered156、migrated225、legacy695。第一批仅迁移paymentAccounts与invoicePool/read,不改auth/审批状态机或生产数据。
付款账户9个原因涵盖账号/银行输入、完整账号查看理由、无效标识、主体/银行不可用、重复银行编码、银行仍被使用、已有账户不能改公司。发票读取5个原因涵盖筛选分页、选票查询、状态批量查询、不可编辑报销、无效票据标识。保留HTTP status、VALID_001和legacy details;客户端真实resolveErrorMessage与固定中文夹具一起验。未修改解密/查看权限、文件授权、状态或付款逻辑。
移除实际已消除的8条AST豁免,当前665个entries/count687,日期仍全部2026-10-05;新增注册14项,检查PASS registered170/legacy687/findings0。不是只删豁免隐藏旧调用,不把公共helper的一条AST记录当作只有一条业务分支。
最终同轮四文件207/207通过:paymentAccounts13、invoicePool.bindable新增输入负例及既有行为、invoicePool.file文件授权/审计、error-reason-client178;付款账户反例确保未创建/更新/成功审计/通知且不泄漏合成账号。中途发票测试漏解构prisma导致一项测试代码失败,修正后完整四文件重跑207通过,未改业务逻辑绕过失败。客户端typecheck通过,构建另记结果。原auth/me、发票上传/识别核验主路由和workflow仍待后续批次,未声明本task完成。
证据安全日志本机/tmp/followup-error-scan.json、followup-error-after.json、followup-error-wave1-tests.log。未新增真实OCR、生产写入或部署,其他任务旧差异继续隔离;下一批仍沿本task操作级范围,不新建任务。
前端typecheck与vite生产构建最终均退出0,原有chunk体积/Browserslist提示不扩修。node语法、git diff、Trellis治理及process baseline通过;docs registry零错误,保留既有22项未登记文档告警。此轮提示映射由真实客户端resolveErrorMessage测试覆盖,不宣称重跑全部浏览器;权限/状态机未修改。测试/构建日志分别为/tmp/followup-typecheck.log、followup-build.log,产品补丁保持未提交状态。
## 2026-09-15 统一收口接续
用户采纳一次规划连续实施,协调入口为09-08-reimbursement-payment-closeout/implement.md“六项统一执行计划”。本task承接D,不新建豁免管理系统。已只读重统计:基线673个entries、count合计695、均2026-10-05到期,距09-15为20天;不是695个当前AST命中或产品缺陷。
先用既有检查器区分当前命中/已消除/变动指纹,再逐操作处理paymentAccounts、invoicePool已启用上传/读取/识别核验、auth/me、workflow审批动作;workflowDefinitions配置域不自动纳入。每次同时注册安全原因码、客户端映射、具体提示及禁止副作用断言。09-18首批验证、09-25复审、09-30逐模块延期建议、10-02查漏沿用原节点。延期需具体理由、负责人与验收,不在本轮规划中修改expiresOn。范围内本地实现/重验不逐项询问,最终提交/发布范围集中审批。
## 2026-09-11 到期治理接续(不统一续期) ## 2026-09-11 到期治理接续(不统一续期)
本次只读复核现有 AST 基线为 673 条记录,到期仍为 2026-10-05,距 09-11 为 24 天;不是 673 个已证实产品故障。本轮主线修复不改到期日,不重复建立豁免台账。 本次只读复核现有 AST 基线为 673 条记录,到期仍为 2026-10-05,距 09-11 为 24 天;不是 673 个已证实产品故障。本轮主线修复不改到期日,不重复建立豁免台账。
@@ -1,5 +1,15 @@
# 业务闭环方案(分阶段实施,发布状态见执行记录) # 业务闭环方案(分阶段实施,发布状态见执行记录)
## 2026-09-15 统一收口编排
以本task implement作为唯一协调清单,各模块原task仍为实施合同。执行顺序:事实对账→待办/通知缺口及错误原因并行准备、串行代码实施→配对恢复工具与受控操作包→合成联合验收→按范围分笔提交/整批发布→自然业务观察。一个模块的外部等待不暂停其他模块;不能为追求“一次完成”跳过独立审查或混合权限状态机改动。
对账依据优先使用当前发布候选b7d2285d及签名/摘要证据,再回溯Git和task历史。task.json.commit是历史锚点不是总完成凭证;共享文件按补丁集成,不覆盖整份spec。现有09-04两份文档、统一审批中心文档及当前09-08发布记录是已知未提交差异,不纳入新产品补丁而不说明。
待办只投影真实状态,不另建审批引擎;退回通知先核已提交实现与生产routing,再决定是否需要代码修复。错误治理保持服务端原因/客户端映射/断言同一变更单元;不全文件替换泛化错误。恢复沿最简单一次性DB+空附件目录+禁外联应用,先列固定源和目标身份、资源上限、清理列表和停止条件,再集中请求真实数据恢复授权;不继续扩复杂runner。
回单自然验收与密钥来源是事实/运维工作,不通过改生产记录制造测试结果。没有真实待处理批次时,先用合成职责矩阵验证maker/proposer/owner/submitter排除,实际参与人出现后再确认人选;不自动给admin/finance加复核权限。新证据只保存安全摘要,明确合成/真实、手动入队/自然调度、隔离/生产等边界。
## 2026-09-15 系统失败引导最小收口 ## 2026-09-15 系统失败引导最小收口
从未归档附件投影是否存在system_failed/key_failed/snapshot_failed;仅在现有readiness未满足横幅中优先展示管理员排障引导,不修改canRegisterPaymentComplete或服务端资格。其他可用证据满足readiness时不因旧失败附件新增阻断。重新核对按钮仍只读refetch,不发OCR。恢复沿用修复前提后另批重新上传原件的现有路径,旧附件与审计保留,不引入新的管理员角色权限或批量SQL重排队。 从未归档附件投影是否存在system_failed/key_failed/snapshot_failed;仅在现有readiness未满足横幅中优先展示管理员排障引导,不修改canRegisterPaymentComplete或服务端资格。其他可用证据满足readiness时不因旧失败附件新增阻断。重新核对按钮仍只读refetch,不发OCR。恢复沿用修复前提后另批重新上传原件的现有路径,旧附件与审计保留,不引入新的管理员角色权限或批量SQL重排队。
@@ -1,5 +1,177 @@
# 执行顺序(本地技术审查/修复已批准,真实业务动作另批) # 执行顺序(本地技术审查/修复已批准,真实业务动作另批)
## R1外部内容结论接回与R2修订
已收到新Claude会话的只读内容审查:有条件通过,F1动态文案退化阻断,F3/F4需登记。本轮按原范围修复F1/F2并以311项定向回归、真实登录JWT+隔离DB+浏览器缺字段修正专项证明;F4转审文案写明10至500字;F5补财务双端href及BX/PB均消失断言,22项浏览器重跑通过。F3共享日期解析及其消费者留待独立批次,无历史数据回填;F6批量错误透传为后续债务,F7防枚举声明仅限邮箱码链,详情见09-05记录。
R1保留,不将R1有条件通过称为R2已独立放行。新R2需完整/增量/两笔拆分重放一致并登记源摘要,再做增量内容复核;当前仍未提交/推送/部署。两笔分组继续产品修复与纯任务记录,三处旧差异排除不动。
## 2026-09-15 交审与集中操作边界(未提交/推送/部署)
本轮不再追加功能,冻结当前错误治理及六项收口记录到本地交审包error-explainability-review-20260915-r1。拟按两笔路径拆分:09-05产品原因码/日期校验/测试/规约/本任务执行记录;09-08及关联task的纯协调/验收/恢复计划记录。共享文件不重叠,两笔顺序重放须等于完整冻结树。既有09-04-reimbursement-state-gap-finance-view的design/implement与docs/需求-统一审批中心规约.md三处旧差异排除,不覆盖、不提交。
包内必须如实登记:未提交提交对象、未推送、未部署;主会话自检而非独立内容审查;471/27/22与构建为前阶段原日志复用,不新增测试通过计数,日志脱敏后单独摘要;冻结树只证明补丁/源一致,不回溯证明原测试时已冻结。当前规约/治理重验单独登记,不伪造新的业务验收。
集中操作清单:①只读内容复核与两笔提交/推送准备(本地包不自动批准提交);②复核通过后绑定实际新候选重新核生产版本/迁移/磁盘/备份/独立回滚制品,取得该候选发布例外再部署并由执行端验收;③08-23固定历史同源备份恢复单独授权,不与发布合并为默认副作用;④真实通知或额外OCR如确有需要,按接收对象/资料/次数单独批准;自然业务未出现则等待,不重开已付款历史单。旧候选b7d2285d的已消耗授权不覆盖本包。
## 2026-09-15 第三阶段统一接续(优先于旧“余项未实施”文字)
收口结果:D联合19文件471/471、通知三文件27/27、本轮构建双端浏览器22/22(零重试)通过;typecheck/build/AST/流程治理通过。B四交接本地验收矩阵已回填09-11 task,临时preview已停止。以上均是本地验证,不新增生产验收或独立复核通过计数。
剩余事项分层:本批代码待按路径收口及只读审查,取得明确提交/推送/新候选发布批准后才能上线;C固定历史源配对恢复方案已写入08-23 task,真实源恢复/镜像加载待专项授权,最新自然备份/传输另需新时点只读事实;E自然新回单没有对象不造业务;F来源UNKNOWN保留风险结论、不轮换。A的22行对账是已完成初核,不代表全部历史PRD的验收证据已补齐;其余模块到期治理保留原负责人和期限,不能用当前通过回归宣称全项目所有技术债清零。
D已补齐invoicePool主路由、查验查询以及workflow路由剩余原因码,目标路由遗留清零;全库695→612,仍有其他域和utils/workflow的遗留,不统一延期。发票日期负例同时暴露日期溢出问题,已本地补UTC回读校验,未改历史记录。最终证据归09-05原task;不能将本地修改称为b7d2285d已发布。
B沿已有四交接浏览器用例执行PC/手机重验,不重写已上线投影。通知路由/渠道/dispatcher三文件27/27通过:本地合成证明配置过滤及发送编排,不证明真实企微消息到达。自然新回单和真实通知缺对象/授权时如实等待,不让员工反复代测。
F闭环口径:生产字段加密密钥来源UNKNOWN;仓库中测试生成器/固定合成值不证明生产来源。现有受控单页OCR隔离队列PASS只证明那条运行链可用,不证明生产密钥高熵。不再追问用户,不读取密钥值/历史命令、不自行轮换;未来如需轮换,必须独立制定双密钥解密、存量指纹兼容、恢复与回退方案。身份矩阵沿当前资格重验回归;实际人工例外须在真实来源人与提案人确定后判断,不能提前保证有人可审。
提交/推送/新候选发布、真实配对恢复及消息发送仍是需集中明确授权的操作,不沿用旧授权。所有本地完成项与外部验收等待项最终一起汇总,不按task数量重复实现功能或批量归档。
## 2026-09-15 连续实施第二阶段(以此补充首批记录)
最终14文件同轮367/367本地回归通过,涵盖登录、付款账户、选票/文件授权、发票查验、审批输入/权限/转审/退回通知/详情、门户与回单独立复核。未重跑公网或生产通知,不把合成回归当真实交接;本轮不要求员工代测。AST及流程治理通过,具体命令见09-05执行记录。
D继续完成auth/me、发票查验错误及七个审批输入parser,连同首批共新增34个注册原因;最终AST legacy695→656,未延长任何到期日。权限、审批状态、付款资格、云端发送及数据库结构均未改。各轮针对性测试通过,最终同输入合并验证另记。实现/具体错误名单/证据归09-05原task,不把第一批“auth/me待做”误读为当前未实现,也不把全部错误治理当作已完成。
下一顺序仍是:完成invoicePool上传/识别与workflow余项的逐操作迁移和验收;核四交接PC/手机证据及通知投递边界;把恢复源/目标/历史附件处置凭据集中成可批准清单。没有真实自然回单不制造业务,来源未知的密钥不猜测、不向用户再次索要,也不自行轮换。缺失历史附件不能仅据“按已确认处置保留”的历史文字就认定已落库治理完成。
整个六项主线未完成,本地提示修复尚未发布。提交、发布、真实配对恢复分别需要明确范围授权;不为上述本地工作反复提问,不沿用已消费的部署/OCR授权。
## 2026-09-15 统一计划首批实施进度
A:已形成下方22项四栏事实表,区分当前发布包含性与全量验收缺证据,不批量归档或重复部署seed/门户。A仍有历史验收证据关联工作,不能只靠表格标全完成。
B/F:实际运行portal、workflow.return-notifications、paymentReceiptManualReview三文件47/47通过(4/4/39)。这是已有本地逻辑回归,不是PC/手机重测或生产企微投递;本轮未发现要重写的状态/资格逻辑。密钥来源仍未知,用户此前不知道内容/时间/方式,不再向用户索要,亦不以“管理员创建”作证据。
D:付款账户和发票读取的14个具体错误解释已实施,AST legacy695→687,日期未延长;四文件同轮207项通过,客户端typecheck通过。代码与证据细节归09-05-error-explainability-payment-readiness/implement.md。新提示尚未提交或发布,不沿用b7d2285d公网通过宣称已上线。
C:在本执行副本已查任务记录与现有qa证据入口,没有找到能闭合历史缺失附件实际标记/删除决定的记录,因此不能按“测试件”自动剔除。当前仍准备具体隔离恢复范围:优先选最新已核完整的同manifest源,绑定源镜像而非当前任意镜像;恢复前核源时效、容量、隔离身份、禁外联及精确清理范围,生产原记录不改。缺失治理决定与恢复写入须在集中授权清单明确;不新造复杂runner掩盖这个证据缺口。
E:真实单页隔离队列PASS保留,不重复消耗腾讯调用;自然生产新回单仍等正常业务对象。上述等待不阻止D后续auth/me、发票识别与审批错误迁移,但这几域尚未做完,不把第一批当整个统一计划完成。
## 2026-09-15 A阶段22项对账结果(发布基线b7d2285d)
下表“包含”只指已有代码锚点是发布候选祖先;无登记提交则交叉查Git,不以元数据空值推断未实现。未核全量验收的行明确保留等待,不批量改status或归档。当前代码差异另列D阶段,本表不将未提交提示修复称为上线。
| 原task | 代码事实 | 发布事实 | 业务/验收状态 | 剩余/等谁 |
| --- | --- | --- | --- | --- |
| 08-23-isolated-backup-restore-drill | 有现有工具/合同,无task提交锚点 | 运维工具不以应用发布计完成 | 历史DB恢复有PASS,配对恢复未证实 | 执行端准备源/目标并集中申请恢复授权 |
| 08-28-production-arm-backup-transfer-incident | 有只读调查及传输阶段诊断 | 旧调查不证明当前异机传输 | 当前自然批次待重新核验 | 执行端只读核传输/异机完整性 |
| 08-28-reimbursement-detail-material-preview-fixes | 46bfa7e包含 | 包含于当前发布祖先 | 父task不能按子项计数直接结案 | 对齐部署子项与只读投影子项 |
| 08-28-reimbursement-detail-material-preview-deploy | task无提交锚点,不等于未发布 | 与父实现发布证据交叉核对 | planning文字过期与否需按验收判断 | 补映射而非重复部署 |
| 08-28-reimbursement-nonparticipant-readonly-projection | e8d6766包含 | 当前候选包含 | 原角色隔离验收不能由本次门户4项代替 | 核原测试/独立审查边界 |
| 08-29-reimbursement-expense-policy-invoice-guard | 6e999d5包含 | 当前候选包含并有后续发票优先改动 | 不回退到旧流程重新实现 | 按最新合同关联后续验证 |
| 08-30-reimbursement-expense-policy-invoice-guard-deploy | 无登记commit | 与父task代码及部署证据对齐 | 缺本轮全量证据映射 | 补发布/业务验收分栏 |
| 08-30-reimbursement-assets-prior-approval-correction | 36c86f1包含 | 当前候选包含 | 本次未测试资产业务 | 不扩改资产规则,核旧验收 |
| 09-01-reimbursement-one-page-approval-workbench | 父task,无登记commit | 子项有已包含代码 | 子项完成不等于父体验全部完成 | 核三子项最终验收 |
| 09-01-reimbursement-approval-content-workbench | c840574包含 | 当前候选包含 | 本次不重做审批详情布局 | 补原UI证据映射 |
| 09-01-reimbursement-form-shared-component-migration | 4b974fd包含 | 当前候选包含 | 本次未再做组件迁移 | 以原消费者回归核范围 |
| 09-02-reimbursement-readonly-content-consistency | e48514d包含 | 当前候选包含 | 不能用付款页面验收覆盖只读页面 | 补只读角色及内容证据 |
| 09-04-approval-return-notification-gap | 2e69fe4e包含 | 当前候选包含 | 本轮退回通知4项单测通过,不是生产投递 | 核routing及自然/受控投递,生产写入另批 |
| 09-04-reimbursement-state-gap-finance-view | 后续229867f5等包含 | finance-owner-release已有发布/公网回执 | 当前代码已上线,不再“待部署” | 分开历史附件治理及未提交事实记录 |
| 09-05-error-explainability-payment-readiness | a837912及后续原因码包含;本轮新增代码未提交 | 旧模块已包含,新一批提示未部署 | 本轮AST基线PASS,D阶段仍在实施 | 执行端按操作收口,不统一延期 |
| 09-05-existing-deploy-db-lifecycle-isolation | f223bfb包含 | 本次部署证明DB未重启 | 本次运行证据不是首次初始化全路径测试 | 合并已存合同测试与发布回执 |
| 09-05-trellis-upgrade-rule-cleanup | review状态,无登记commit | 本地工作流无需公网发布 | 本轮治理检查PASS不代替原改动审查 | 核既有规则清理差异,不重装Trellis |
| 09-06-release-gate-integrity | 81102c26有提交 | 当前发布实际执行前后预检PASS | 当前pending=none,不伪造故障生产演练 | 故障断言沿原合成证据对齐 |
| 09-07-manager-ap-access-scope | 有既有权限修订历史,task未登记commit | 不凭任务空字段宣称未上线 | 本轮未全量审角色/数据范围 | 查实际提交/矩阵,禁止顺手摘角色 |
| 09-08-reimbursement-payment-closeout | 8d08d46d/b7d2285d | 部署与公网只读验收通过 | 43项隔离链及单页真实队列PASS | 自然生产新回单、密钥来源未知、例外职责按实际对象核 |
| 09-09-existing-deploy-seed-separation | b87cc55a有提交 | 当前候选包含且existing发布执行成功 | 旧“本地未发布”只代表历史时点 | 不重复拆seed;对齐初始化与配置检查证据 |
| 09-11-reimbursement-payment-handoffs | fedcaed1及后续身份取消修复包含 | 当前候选包含 | 本轮portal4项通过;PC手机历史66项不虚增重跑 | 身份切换原证据与实际交接验收分列 |
本轮已消除“22个活跃task=22项未开发”的误导;没有把以上待证据映射项记为全部完成。继续优先能影响当前报销付款业务的B/D/C/F,不为了归档历史大task扩做资产/采购新功能。
## 2026-09-15 六项统一执行计划(后续工作入口)
用户要求一次规划连续执行,非必要不提问。本表覆盖后续顺序,不覆盖下文已完成发布/测试证据;新产品实现归对应既有task,当前阶段不新建任务、不批量归档。执行端负责软件验收,真实资金决定仍由业务负责。
| 阶段 | 归属task | 本轮交付及完成条件 | 等待/批准边界 |
| --- | --- | --- | --- |
| A 事实对账 | 本task协调,各原task回填 | 22项逐个列代码、发布、业务验收、等待项;核候选祖先/实际文件及发布回执,旧文字加日期纠正,不抹历史 | 不用祖先关系代替全量PRD验收;归档/提交不自动执行 |
| B 待办通知 | 09-11-reimbursement-payment-handoffs、09-04-approval-return-notification-gap | 四交接角色矩阵、计数/入口/完成消失、站内与外部通知边界;补确认缺陷及PC/手机、真实隔离HTTP回归 | 企微发送/生产routing变更另列一次操作清单;不为测试制造真人退回 |
| C 恢复可靠性 | 08-23-isolated-backup-restore-drill、08-28-production-arm-backup-transfer-incident;历史附件治理归09-04 | 核当前自然备份/异机传输、缺失引用处理决定及证据;准备并在获批隔离目标完成DB+附件+应用只读恢复,逐项PASS/NO_GO和清理回执 | 当前只有备份完整性及既往DB恢复证据;具体真实源/目标恢复另批,未知附件不删,不覆盖生产 |
| D 错误到期治理 | 09-05-error-explainability-payment-readiness | 重跑实际AST盘点,按操作修paymentAccounts、invoicePool、auth/me、workflow高频已启用路径,原因/映射/测试齐全;余项有理由、责任与复验点 | 不把673条基线当673个故障;不统一续期,不更改未启用模块的业务规则 |
| E 自然回单验收 | 本task | 保留已PASS单页真实隔离队列;新业务自然出现后只读跟踪queued→匹配/异常→登记前资格,记录生产链实际覆盖 | 等正常业务对象;没有对象不重复OCR、不重新开已付单,不作为其他本地工作阻塞 |
| F 密钥/例外职责 | 本task,角色范围参考09-07-manager-ap-access-scope | 只查非敏感来源/保管/恢复记录;未知有风险结论和后续处理方式;合成矩阵验证独立复核,实际例外按参与人核资格 | 用户明确不知道密钥内容/生成时间/方式,撤销“管理员创建已确认”的推断;不再追问、不换密钥或扩权 |
### 执行清单与里程碑
- [x] 确认官方指针09-08及当前生产候选b7d2285d,记录现有未提交差异,不跟随过期09-04注入换范围。
- [x] 扫描22个task元数据及历史commit是否为当前候选祖先;只形成初核,不声称全量对账完成。
- [x] 重新统计错误基线:673个entries,count合计695,expiresOn均2026-10-05;09-15距到期20天。计数不是当前源码仍命中的AST数量。
- [ ] A逐项对齐验收与证据,优先已上线seed/preflight/AP/门户/付款,剩余页面和通知按实际commit核范围;缺证据项列出具体缺什么。
- [ ] B确认业务路径实际缺口后做最小修复,不重复已上线的门户身份隔离;补站内/企微渠道分层验收。
- [ ] D在09-18前完成首批高频操作迁移和定向验证;09-25复审余项,09-30前准备逐模块延期理由,10-02查漏。日期是检查节点,不声称已完成或自动计划任务。
- [ ] C并行准备真实配对恢复清单,核旧附件治理结果;批准具体源/目标后执行一次简单恢复,不扩演练装置。
- [ ] F先完成来源证据调查和职责合成矩阵;未知留结论,不以无限调查阻塞其他工作。
- [ ] 一次联合检查:后端定向单测/真实隔离DB与HTTP、前端typecheck/build及PC/手机流程、文档与治理;权限/状态机差异要求只读复核,inline受限如实留待审。
- [ ] 汇总需要提交/推送/部署/恢复/生产配置的准确范围及风险,一次呈报;批准后分笔提交、整批发布并由执行端公网验收,不逐按钮征询。
- [ ] E等待自然业务对象时只读观察;最后交付已完成/外部等待/已知风险三栏,不用“全完成”掩盖自然业务或未知来源。
### 初核发现(不是全量验收)
22个task中12个登记的历史commit为HEAD祖先,10个未登记commit;未登记不等于没提交,登记旧SHA也不等于任务全完成。Git另查到seed=b87cc55a、预检=81102c26、门户交接=fedcaed1及后续身份修复、退回通知=2e69fe4e均有提交。当前发布包含性须结合后续差异核验,不据旧“未提交/未发布”段落重做。
错误基线首批候选计数:paymentAccounts 1;invoicePool主路由30/read7/verification7/shared schemas1;auth路由15+插件1、me2;workflow路由20+utils15。workflowDefinitions11单列配置域,不因名称相近扩入首批。下一实际AST扫描需识别已消除但未清理的基线,不能仅按这些数承诺修改量。
执行中只在范围/风险/验收实质改变、需要真实资金或新增生产敏感写入、无法从仓库确定的业务决策时提问。普通实现取舍、测试工具修正、同范围重验由执行端自主处理;外部等待转做其他已批项。不新建定时任务,不默认提交、归档、清理或使用旧发布授权。
## 2026-09-15 单页真实回单正式队列隔离验证通过
用户明确授权后,先以合成样本走同一工具。首轮worker已matched但验证工具错误把非唯一attachment_id用于findUnique,导致工具NO_GO;未调用腾讯且完整清理。修为按attachment_id及当前source_version查最新证据后合成PASS。此为工具修复,无产品源码变更,不消耗真实调用预算。
随后真实阶段仅执行一次:PASS_ISOLATED_QUEUE,externalCalls=1、pages=1、attachmentState=matched、claimedVersion=1、queueWorkerMatched=true、evidenceEncrypted=true。沿正式startReceiptPgBossJob→真实pg-boss消费→正式runReceiptOcrTick/recognizePaymentReceipt→腾讯BankSlipOCR→正式匹配/加密落库;原件与冻结付款信息来自此前批准的同一历史单页样本,金额与来源单金额在调用前核相等。normalized和账号/原件只存在主机管道及隔离tmpfs内,不输出普通日志,不保存至本地工作区。
paymentRecorded=false,隔离库claim为0、批次保持reviewed;productionBusinessWrites=false、productionRuntimeUnchanged=true、cleanupComplete=true。一次性数据库/runner容器及独立网络均删除,原件临时存储和加密证据随tmpfs消失。没有改生产executed批次、legacy附件、paid/claim或角色,没有额外调用和自动重试。
证据isolated-queue-synthetic.json与isolated-queue-real.json位于receipt-followup-release目录;运行器queue-isolated.cjs SHA256=d0fdcacbf7fb0bb0a23feaf7c466b3553aa2a9f298f7300fd971b52f3f06d36b,host脚本=fdf9abea5c70a40ac4b9f27f9ff0476be1bb45e6986e2fc45f44ec835689c7de。语法及diff检查通过。此轮未独立复核工具、不新增产品提交或发布。
结论边界:该真实单页逐笔回单的正式队列worker/腾讯/匹配/持久化适配已验证,不再列为纯合成未知。测试采用显式向隔离队列send一次(manuallyEnqueuedIsolatedJob=true),未证明自然cron触发、生产上传链或多笔汇总回单模型;银行验真/到账不在此证据范围。生产未来新回单正常上传后可只读跟踪自然消费,不能为了验收重新上传已付款历史。剩余密钥生成来源需要管理员提供生成方式/受控记录位置(不要密钥正文),实际人工例外复核资格须待具体批次/提案参与人出现后判定,不因当前无批次声称全部通过。
## 2026-09-15 授权单样本隔离队列验证
用户批准复用此前一份历史已付款回单、腾讯最多一次。实现为生产服务器上的一次性Postgres容器/独立Docker网络/当前候选镜像只读runner,数据库与原件临时存储均tmpfs;不绑定生产存储到runner。先以合成样本验证工具。真实阶段仅在生产容器执行固定原样本选择的只读事务及原件大小/SHA/单页校验,凭据/原件/解密候选仅经主机内存管道传入runner,不输出日志、不转移本地、不复制生产字段密钥;隔离库使用随机新密钥加密证据。
采用正式startReceiptPgBossJob注册真实pg-boss队列,显式向隔离队列send一次任务,由worker自行领取→正式recognizePaymentReceipt/Tencent→匹配及加密落库;不直接调用runReceiptOcrOnce,不调用付款登记。SDK方法加仅一次预算守卫,不替换真实阶段的返回值;单页条件在调用前强制。测试只能证明显式入队后的正式worker链,不冒称自然cron触发或生产上传链。真实尝试开始即留STARTED标记,失败/超时不能自动再发送。finally仅清理本轮容器/网络/tmpfs,另核生产服务身份未变;清理失败须报告,不删除旧制品。
## 2026-09-15 发布后业务就绪与队列再核
用户要求继续下一步,本轮执行只读元数据查询,无生产业务写入、原件读取或腾讯调用。北京时间16:14核到线上仍b7d2285d、Tencent/pgboss/jobs启用,最近30分钟payment.receipt.ocr任务30条completed,限定日志内tick失败0。有效附件仍两份legacy;reviewed/failed报销批次为空,因而不存在本轮可按真实来源/提案人核定独立复核人的对象。空集合不是“全部资格通过”,completed空转不等于真实回单消费。
密钥字段已配置,但受查现有部署指引/任务记录未提供生产密钥随机生成的来源凭证,不能通过长度、密钥摘要或测试脚本的randomBytes推断生产高熵;未读取密钥值、未查shell历史、未轮换。只读证据business-readiness-current.json与queue-current.json位于本轮release证据目录。
下一受控验证优先在隔离数据库/存储内运行正式pg-boss调度链,使用既有已授权样本的一份原件须重新明确读取/腾讯发送范围,最多一次provider调用;不得将已执行生产批次改回reviewed、重排legacy或伪造真实付款。允许范围未确认前仅准备方案,不实施真实原件重传。若未来实际业务产生新回单,可对正常业务自然消费做只读跟踪,避免额外发送。密钥来源需要管理员提供生成方式或受控记录位置,不发送密钥正文。当前仍未完成这两项事实验收,不新增任务、不将软件回归转交员工。
## 2026-09-15 b7d2285d发布及公网只读验收通过
授权发布b7d2285d8d1a2a912222917f7c74067f8bd9f810完成,部署命令退出0,build-init/run-init/build-server/build-switch-server均PASS,应用发布及自动备份合同刷新完成。发布前与发布后正式只读迁移预检pending=none。三份既有回单迁移finished=true/rolled_back=false且checksum逐一匹配候选SQL,无本次新增迁移。
公网/ready构建标识匹配、页面可达、bundle扫描105份资源/零finding;递归读取公网JS确认新的系统失败与受控重传文案已发布,匿名付款批次API返回401。运行版本精确等于候选,server/db均运行,db容器身份/启动时间不变,环境文件摘要不变。此为公网只读/资源验收,不伪称真实登录生产业务验收;完整密码JWT浏览器付款链证据仍为隔离合成测试43项。
队列只读回读payment.receipt.ocr已注册,jobsEnabled=true、Tencent provider、Asia/Shanghai每5秒调度,限定日志范围内无receipt tick失败;历史未归档附件仍两份legacy,未重传。本次检查不证明真实回单已消费,未代付、未改业务角色/银行资料或向腾讯新增发送原件。
证据位于artifacts/qa-layout/receipt-followup-release:deploy-fresh-preconditions.json、deploy-result.json、acceptance-result.json、runtime-after.json、queue-after.json。当前版本发布前备份与镜像归档已独立回读,未restore/load演练。可对外称本轮修复已部署且公网只读验收通过,不将项目全部任务、真实回单闭环、合格独立复核人或密钥来源核实标记完成。剩余:在明确原件范围授权下验证新回单真实队列消费及形态,按实际例外参与人确定合格复核人,并核实密钥生成来源;不让员工代测软件基础功能。
本次发布记录为b7d2285d提交后的本地task补记,尚未提交;候选内容未变,不自动创建额外记录提交或归档任务。
## 2026-09-15 当前版本恢复制品补齐与授权发布
用户明确批准当前线上版本备份和独立镜像归档写入,并承接b7d2285d本轮发布授权。复用已审查备份工具,固定8d08d46d构建与运行镜像、校验三份备份脚本SHA,持有自动备份互斥锁,只新增手工备份、不更新自动成功状态;数据库/存储大小与SHA核对后另连接扫描同源备份独立回读通过,manifest SHA256=56a9addf57556580c46205ae8e68b01a8e23a45ae7757d4cfce9ec9c92660c86。运行容器身份/启动时间未变,未声称恢复演练通过。
独立镜像归档1120237568字节、镜像配置和各层摘要验证通过,并另连接全文件SHA/权限回读PASS_IMAGE_INDEPENDENT_READBACK;同机Docker存储外制品,未docker load、未清理旧制品。证据位于artifacts/qa-layout/receipt-followup-release中create-backup-result/check-current-backups-result/save-image-result/image-readback/runtime-before-switch等JSON。
发布源为远端精确候选b7d2285d的干净克隆;发布脚本重新核验生产仍8d08d46d、手工同源备份24小时内且完整、部署身份归档匹配、剩余空间不少于15GiB、服务身份及环境摘要不变。预期迁移显式为空、SYNC_ENV=0、不开新worker/minio。仅所有条件通过才执行既有生产bootstrap部署;不自动退镜像、代付、发真实回单或改角色。实际发布/公网结果后记,不能将本准备记录称为已部署。
## 2026-09-15 b7d2285d已推送,发布停在备份与回滚前置
用户确认9路径提交范围,承接已同意的推送/发布顺序且前置不满足不部署。实际提交b7d2285d8d1a2a912222917f7c74067f8bd9f810,普通快进推送main并ls-remote精确回读;排除09-04两文件与统一审批中心文档。未amend、未归档。远端main克隆为独立干净发布源,不用脏工作树部署。
重新只读核验:线上仍为8d08d46d,服务运行且检查期间未重启;存储/数据库卷约30.64 GiB可用、使用率48%,无需清理。最新自动备份完成于2026-09-14T18:30:50Z,数据库/存储大小与SHA通过、manifest匹配,但备份build是229867f5,sourceImageMatches=false;不能视为当前8d08d46d同源恢复点。部署身份下独立回滚制品NO_MATCHING_ARTIFACT,旧版本归档不替代当前版本归档。
正式只读生产预检最终退出0、待迁移为空;首次包装参数误把无迁移写成none导致停止,改为显式空值后重新完整运行通过,不放宽迁移校验。证据目录artifacts/qa-layout/receipt-followup-release含两身份metadata、首次preflight.log和最终preflight-final.log;均无业务写入和部署。预检命令PASS不覆盖备份与回滚缺口,整体发布仍NO_GO。
下一步需补当前线上版本数据库/存储一致备份与独立镜像归档并分别回读完整性,再重核前置后发布;不删除旧制品、不自动回退或重传真实回单。本段为提交后的事实记录,尚未包含在b7d2285d中。
## 2026-09-15 浏览器同链与恢复指引收口 ## 2026-09-15 浏览器同链与恢复指引收口
新增test-payment-receipt-browser.cjs作为既有DB脚本显式RUN_RECEIPT_BROWSER=1模式;要求RUN_ISOLATED_DB_TEST=1及RUN_RECEIPT_REAL_AUTH=1。正式buildApp/auth/me/付款路由、真实本地HTTP与Vite代理,不替换业务API。实际密码登录→上传合成PDF→真实文件读取及worker领取/匹配落库→UI明确登记→同库paid/claim/审计;另测错金额、缺密钥、重复交易不登记及财务退出→复核人登录→财务再登录。创建单据/批次复核预置,OCR provider合成,worker手动调用,不证明腾讯/pg-boss调度/生产全链。 新增test-payment-receipt-browser.cjs作为既有DB脚本显式RUN_RECEIPT_BROWSER=1模式;要求RUN_ISOLATED_DB_TEST=1及RUN_RECEIPT_REAL_AUTH=1。正式buildApp/auth/me/付款路由、真实本地HTTP与Vite代理,不替换业务API。实际密码登录→上传合成PDF→真实文件读取及worker领取/匹配落库→UI明确登记→同库paid/claim/审计;另测错金额、缺密钥、重复交易不登记及财务退出→复核人登录→财务再登录。创建单据/批次复核预置,OCR provider合成,worker手动调用,不证明腾讯/pg-boss调度/生产全链。
@@ -1,5 +1,21 @@
# 报销付款与银行制单业务闭环 # 报销付款与银行制单业务闭环
## 2026-09-15 六项统一收口合同(本轮最新)
用户采纳后续六项并要求一次规划、连续推进,非必要不提问。复用本任务承接报销付款闭环的统一协调,具体实施仍归已有模块task,不创建新总控系统或每个修复一个task。此增量是后续批准范围,历史阶段的未部署描述不代表当前状态。
目标是让员工按正常业务流程独立办完,让系统异常可解释、数据可恢复、交付状态可信;不是以一次提交囊括全部历史技术债。范围:22个活跃task的代码/发布/业务验收/等待项对账;四类报销付款待办与退回通知;数据库+附件+应用配对恢复;10-05错误豁免模块治理;新回单自然消费验收;密钥来源和人工例外职责核实。
共同验收:
- 对账区分代码包含、局部已发布、任务全量验收完成,不因Git祖先关系自动勾完PRD;没有证据标待核,不让过期文字驱动重复开发。
- 待办计数/列表/责任人/动作/处理后消失一致;通知失败不改变审批事实,站内待办不依赖企业微信成功;普通账号与身份切换不串数据。
- 配对恢复在批准的隔离目标使用同一manifest,数据库、附件引用/摘要、应用只读检查均分开报结果;源备份不改,缺失件不可按“可能是测试”自行删除。
- 错误治理先覆盖高频已启用业务操作,每项有注册原因码、客户端提示和行为断言;延期按项/模块说明原因和复验点,不统一改日期。
- 当前已通过的真实单页回单隔离队列证据不重做;生产自然业务出现后执行端只读观察,不制造真实付款、重传历史或要求员工代做软件回归。
- 密钥生成来源目前未知,用户不知道内容/时间/方式;先查非敏感部署证据,查不到如实记录,并列保管/恢复证据风险,不读取值、不查可能含Secret的shell历史、不轮换、不继续追问用户。
默认自主执行本地对账、明确范围内修复、合成验证、文档与证据整理。提交/推送/部署、新生产配置或数据写入、真实原件额外外调、恢复真实备份到具体隔离目标仍按明确范围另批;旧授权不无限继承。将需要批准的操作集中成一次清单,非实质变化不反复确认。等待真实业务或批准不阻塞其他可做模块,也不能伪称全部完成。
## 2026-09-15 后续收口范围 ## 2026-09-15 后续收口范围
用户要求自主继续后续本地任务。本轮完成正式认证浏览器验证后,继续修复回单系统失败横幅与卡片引导矛盾,并将现有受控恢复路径写入操作手册。保持现有布局、权限、付款门禁和状态机;不新增管理员一键重排队或自动重传。失败前提由管理员定位修复,另获原件重传授权后由有上传权限的人员重新上传,旧失败证据保留。无法修复快照等前提时停止,不以重上传承诺恢复成功。 用户要求自主继续后续本地任务。本轮完成正式认证浏览器验证后,继续修复回单系统失败横幅与卡片引导矛盾,并将现有受控恢复路径写入操作手册。保持现有布局、权限、付款门禁和状态机;不新增管理员一键重排队或自动重传。失败前提由管理员定位修复,另获原件重传授权后由有上传权限的人员重新上传,旧失败证据保留。无法修复快照等前提时停止,不以重上传承诺恢复成功。
@@ -1,5 +1,30 @@
# 执行清单 # 执行清单
## R1复核F5与R2验收补充
新增断言已随R2新构建重跑visual-portal.spec.ts,22/22、零重试,日志/tmp/followup-r2-browser.log。PC与手机财务两段href、BX/PB处理后均消失本次实际断言通过;仅合成API/认证,不宣称公网或自然交接验收。
R1财务两段当时只断言文本/计数及PB消失,未覆盖href及BX消失;下表R1记录中相应“入口/处理后消失”覆盖声明过宽,按此更正,不改变原22项日志。R2已补PC/手机的BX/PB正确href及两项均消失断言,新的执行结果另记。employee/ceo的旧href及消失断言已存在,不虚报本次新增。
## 2026-09-15 四段交接双端重新验证(本地PASS)
使用本轮新构建与仅回环地址的vite preview,执行`E2E_MODE=local E2E_BASE_URL=http://127.0.0.1:4177 ./node_modules/.bin/playwright test tests/e2e/visual-portal.spec.ts --workers=2 --retries=0 --reporter=list --output=../artifacts/qa-layout/followup-stage3-browser`,22/22通过、零重试,日志/tmp/followup-stage3-browser.log。测试文件自身串行执行,输出实际1worker;不按命令参数声称并行。临时preview已停止。
| 业务交接 | 对象与双端验收 | 本轮结论 |
| --- | --- | --- |
| 退回后改重提 | employee冷进入、正确报销入口、处理后消失、错误可重试 | PC/手机PASS |
| 已批未付组批 | finance事项、计数与入口、不显示旧泛化计数、处理后消失 | PC/手机PASS |
| 付款独立复核 | ceo冷进入、正确付款入口、非财务不出现组批事项、处理后消失 | PC/手机PASS |
| 付款与回单处理 | finance制单后待付款/回单事项、刷新后消失 | PC/手机PASS |
同时覆盖甲乙甲同文档切换的慢响应、失败、晚返回、迟到401取消及同身份401刷新正例,超过8条事项不截断。本次浏览器确实运行真实前端,但auth/API/事项是合成fixtures,未操作真实付款,也不是生产JWT+数据库同链新证据。测试未发现需重写门户的缺口,旧已发布身份隔离不重复开发。真实通知送达及自然新回单仍另列外部验收,不靠页面绿测替代。
## 2026-09-15 统一收口接续
本task承接统一计划B的待办投影部分,退回通知仍归09-04-approval-return-notification-gap;总顺序见09-08-reimbursement-payment-closeout/implement.md六项计划。Git已存在fedcaed1及后续门户身份隔离提交,历史“未提交/未部署”只代表当时,不能据此重做。本轮先逐验收点关联当前b7d2285d发布与先前证据,再决定真实缺口,不直接勾完独立复核/业务验收。
检查矩阵保持退回修改/财务待组批/独立付款复核/财务登记四阶段,核PC与手机计数列表、权限/责任人、入口动作、处理后消失、通知失败不遮蔽待办;重点复用迟到401取消和甲乙甲隔离回归。真实消息投递和真人退回另批,不把合成测试算自然业务完成;范围内最小修复与针对性验证连续执行。
## 2026-09-11 只读复核后取消旧请求(原范围接续,未提交/部署) ## 2026-09-11 只读复核后取消旧请求(原范围接续,未提交/部署)
冻结树 dcde0d44 的双端合成浏览器复核发现:甲旧请求在乙登录后返回 401,会用乙 token 重放,并把乙结果写到甲 queryKey;甲再次登录等待新响应时显示乙待办。原 6/62/真实隔离通过记录不改写,它们未覆盖该分支。只读回执见 postpush-identity/readonly-review.md。本次沿原批准的门户隔离范围返回实施,不改变全局认证或业务权限,不新建任务。 冻结树 dcde0d44 的双端合成浏览器复核发现:甲旧请求在乙登录后返回 401,会用乙 token 重放,并把乙结果写到甲 queryKey;甲再次登录等待新响应时显示乙待办。原 6/62/真实隔离通过记录不改写,它们未覆盖该分支。只读回执见 postpush-identity/readonly-review.md。本次沿原批准的门户隔离范围返回实施,不改变全局认证或业务权限,不新建任务。
+24
查看文件
@@ -77,6 +77,30 @@ export function mapApiErrorMessage(
return mapApiErrorMessage(code, { ...details, reasonCode: undefined, items: "invoice_item_guard_failed" }); return mapApiErrorMessage(code, { ...details, reasonCode: undefined, items: "invoice_item_guard_failed" });
} }
if (code === "VALID_001" && details?.reasonCode !== undefined) { if (code === "VALID_001" && details?.reasonCode !== undefined) {
if (details.reasonCode === "invoice_pool.ocr_fields_missing") {
const missingFields = readStringList(details.missingFields).filter((field) =>
["invoiceCode", "invoiceNumber", "invoiceDate", "totalAmount", "sellerName"].includes(field));
if (missingFields.length) {
return mapApiErrorMessage(code, { ocr: "missing_required_fields", missingFields });
}
}
if (details.reasonCode === "workflow.remind_cooldown") {
const nextAllowedAt = details.nextAllowedAt;
if (typeof nextAllowedAt === "string"
&& /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(nextAllowedAt)
&& Number.isFinite(new Date(nextAllowedAt).getTime())) {
return mapApiErrorMessage(code, { workflow: "remind_too_frequent", nextAllowedAt });
}
}
if (details.reasonCode === "auth.password_policy_failed") {
const password = readStringList(details.password).filter((rule) => [
"密码长度必须 ≥ 12", "密码必须包含小写字母", "密码必须包含大写字母",
"密码必须包含数字", "密码过于常见/弱",
].includes(rule));
if (password.length) {
return mapApiErrorMessage(code, { password });
}
}
return resolveErrorReasonMessage(details.reasonCode) ?? UNKNOWN_VALIDATION_MESSAGE; return resolveErrorReasonMessage(details.reasonCode) ?? UNKNOWN_VALIDATION_MESSAGE;
} }
if (code === "AUTH_001") { if (code === "AUTH_001") {
+73
查看文件
@@ -1,4 +1,77 @@
export const ERROR_REASON_MESSAGES: Record<string, string> = { export const ERROR_REASON_MESSAGES: Record<string, string> = {
"workflow.task_already_processed": "此审批任务已处理,请刷新待办查看最新进度。",
"workflow.entity_unavailable": "审批对应的单据不存在或当前不可处理,请联系管理员核查。",
"workflow.self_approval_forbidden": "不能审批本人申请或代提交的单据,请由符合资格的其他审批人处理。",
"workflow.transfer_target_unavailable": "转审人不存在或已停用,请重新选择在职审批人。",
"workflow.transfer_target_ineligible": "所选人员没有这类业务的审批资格,请重新选择审批人。",
"workflow.self_remind_forbidden": "当前待办由你处理,无需催办自己,请直接处理待办。",
"workflow.step_missing": "审批流程节点信息异常,请联系管理员核查,不要重复提交。",
"workflow.transfer_unsupported": "此类业务暂不支持转审,请按原审批流程处理。",
"workflow.transfer_same_assignee": "不能转审给自己,请选择其他符合资格的审批人。",
"workflow.task_list_query_invalid": "待办筛选或分页条件无效,请重新选择筛选条件后重试。",
"workflow.approver_query_invalid": "审批人查询条件无效,请缩短关键词并检查分页条件后重试。",
"workflow.batch_entity_unsupported": "批量审批仅支持当前指定业务类型,请取消其他类型任务的选择。",
"workflow.remind_cooldown": "此任务24小时内已由你催办,请勿重复催办,稍后再试。",
"invoice_pool.pdf_required": "请上传 PDF 发票文件",
"invoice_pool.ocr_pdf_too_large": "PDF 发票不能超过 7 MiB",
"invoice_verification.list_query_invalid": "查验记录查询数量无效,请选择1至50条后重试。",
"invoice_pool.single_file_required": "请每次上传一份PDF发票,可分次添加多张发票。",
"invoice_pool.empty_file": "发票文件为空,请重新选择有效的PDF文件。",
"invoice_pool.upload_too_large": "发票文件超过当前上传大小限制,请压缩PDF后重试;仍失败请联系管理员。",
"invoice_pool.file_required": "请先选择要上传的PDF发票。",
"invoice_pool.owner_unavailable": "发票所属员工不存在或已停用,请重新选择在职员工。",
"invoice_pool.ocr_type_invalid": "发票识别类型无效,请重新选择票据类型后上传。",
"invoice_pool.edit_empty": "没有可保存的修改,请修改发票信息后再保存。",
"invoice_pool.not_editable": "此发票当前不能编辑,请刷新查看状态;已关联的发票需先按报销流程解除占用。",
"invoice_pool.date_invalid": "开票日期无效,请填写真实有效的日期。",
"invoice_pool.amount_invalid": "发票金额格式无效,请填写有效金额后重试。",
"invoice_pool.tax_amount_invalid": "发票税额格式无效,请填写有效税额后重试。",
"invoice_pool.total_amount_invalid": "价税合计格式无效,请填写有效金额后重试。",
"invoice_pool.status_transition_invalid": "此发票不能按当前方式更改状态,请刷新页面后重试。",
"invoice_pool.archive_bound": "发票已关联业务单据,不能归档;请先完成业务处理或解除关联。",
"invoice_pool.archive_status_invalid": "此发票当前不能归档,请等待识别完成或刷新查看状态。",
"invoice_pool.ocr_retry_status_invalid": "只有识别失败的发票可以重新识别,请刷新查看当前结果。",
"invoice_pool.ocr_unavailable": "发票OCR识别服务当前不可用,请联系管理员恢复服务后重试。",
"invoice_pool.override_bound": "发票已关联业务单据,不能设置重复票据例外;请先处理原关联。",
"invoice_pool.override_status_invalid": "此发票当前不能设置重复票据例外,请刷新查看状态。",
"invoice_pool.dedup_identity_missing": "发票缺少查重标识,请补全发票代码和号码;仍无法处理请联系管理员。",
"invoice_pool.edit_input_invalid": "发票信息格式无效,请检查日期、金额及必填字段后保存。",
"invoice_pool.override_input_invalid": "请填写有效的关联报销编号和重复票据例外原因。",
"invoice_pool.ocr_fields_missing": "发票识别信息不完整,请补全票种要求的代码、号码、日期、金额及销售方等信息后保存。",
"workflow.comment_invalid": "请填写处理意见,长度为1至500字。",
"workflow.transfer_input_invalid": "请选择有效的转审人,并填写10至500字的转审原因。",
"workflow.batch_approve_input_invalid": "请选择1至50条有效审批任务,审批意见不超过500字。",
"workflow.batch_reject_input_invalid": "请选择1至50条有效审批任务,并填写1至500字的退回意见。",
"workflow.remind_input_invalid": "催办说明不能超过200字,请修改后重试。",
"workflow.summary_query_invalid": "待办统计范围无效,请从待办页面重新进入。",
"workflow.task_id_invalid": "审批任务标识无效,请从待办列表重新打开。",
"invoice_verification.disabled": "发票查验服务暂未启用,请联系管理员确认;发票OCR识别是独立服务。",
"invoice_verification.already_pending": "发票正在查验,请稍后刷新结果,不要重复提交。",
"invoice_verification.record_missing": "此发票还没有查验记录,请先发起查验,再按需重试。",
"auth.login_input_invalid": "请填写登录账号和密码后重试。",
"auth.email_input_invalid": "请输入格式正确的邮箱地址后重试。",
"auth.email_code_input_invalid": "请检查邮箱地址和验证码的填写格式后重试。",
"auth.password_change_input_invalid": "请完整填写原密码和新密码后重试。",
"auth.password_reset_input_invalid": "请完整填写邮箱、验证码和新密码后重试。",
"auth.device_context_missing": "登录设备信息缺失,请刷新登录页面后重试;仍无法登录请联系管理员。",
"auth.password_policy_failed": "新密码需至少12位,包含大小写字母和数字,并避免常见弱密码。",
"auth.deeplink_target_invalid": "此入口的目标地址无效,请从系统首页重新进入对应业务。",
"auth.code_invalid_or_expired": "验证码无效或已过期,请重新获取后重试。",
"profile.notification_preferences_invalid": "通知设置无效,请通过页面开关选择是否接收通知后重试。",
"invoice_pool.list_query_invalid": "票据筛选或分页参数无效,请重置筛选后重试。",
"invoice_pool.selection_query_invalid": "选择发票的报销单或查询条件无效,请返回报销单重新打开“我的发票”。",
"invoice_pool.status_query_invalid": "待查询票据列表无效,请刷新后重试;一次最多查询50张票据。",
"invoice_pool.reimbursement_not_editable": "该报销单当前不可添加发票,请刷新确认状态;仅草稿或退回待修改的单据可继续选票。",
"invoice_pool.id_invalid": "票据标识无效,请返回“我的发票”重新选择。",
"payment_account.reveal_reason_required": "请填写2至500字的查看理由后再查看完整付款账号。",
"payment_account.bank_input_invalid": "请检查银行名称;新增银行还需填写2至16位字母、数字或下划线组成的银行编码。",
"payment_account.account_input_invalid": "请检查所属公司、开户银行、户名及账号;账号需为6至32位数字。",
"payment_account.id_invalid": "账户或银行标识无效,请返回列表重新选择。",
"payment_account.legal_entity_unavailable": "所选公司不存在或已停用,请刷新后选择可用公司;如无可选公司,请联系管理员。",
"payment_account.bank_unavailable": "所选银行不存在或已停用,请刷新后选择真实开户银行,不要为通过检查改填其他银行。",
"payment_account.bank_code_exists": "该银行编码已存在,请使用已有银行或核实编码后重试。",
"payment_account.bank_in_use": "该银行仍有启用的付款账户,暂不能停用;请先核实相关账户的使用安排。",
"payment_account.legal_entity_change_forbidden": "已有付款账户不能直接更换所属公司,请保留原公司,并为其他公司另行维护账户。",
"reimbursement.finance_owner_ceo_unavailable": "财务本人报销需要非申请人的总经理审批,请联系管理员核实审批配置。", "reimbursement.finance_owner_ceo_unavailable": "财务本人报销需要非申请人的总经理审批,请联系管理员核实审批配置。",
"reimbursement.finance_owner_ceo_required": "财务本人报销只能由非申请人的在职总经理审批或接收转审,不能委托其他角色代批。", "reimbursement.finance_owner_ceo_required": "财务本人报销只能由非申请人的在职总经理审批或接收转审,不能委托其他角色代批。",
"reimbursement.invoice_prefill_incomplete": "票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。", "reimbursement.invoice_prefill_incomplete": "票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。",
+1 -1
查看文件
@@ -75,7 +75,7 @@ test("E2E 轻交互 OCR:票据缺关键字段后可在当前页修正并标记
await page.getByTestId("invoice-edit-open").click(); await page.getByTestId("invoice-edit-open").click();
await page.getByTestId("invoice-edit-set-available").check(); await page.getByTestId("invoice-edit-set-available").check();
await page.getByTestId("invoice-edit-save").click(); await page.getByTestId("invoice-edit-save").click();
await expect(page.locator("main").getByText(/票据仍缺少关键字段/)).toBeVisible({ timeout: 15_000 }); await expect(page.locator("main").getByText("票据仍缺少关键字段:发票代码、销售方,补齐后才能标记为可用。", { exact: true })).toBeVisible({ timeout: 15_000 });
await page.getByTestId("invoice-edit-code").fill("044003204111"); await page.getByTestId("invoice-edit-code").fill("044003204111");
await page.getByTestId("invoice-edit-seller-name").fill("北京悦海堂餐饮管理有限公司"); await page.getByTestId("invoice-edit-seller-name").fill("北京悦海堂餐饮管理有限公司");
+5
查看文件
@@ -216,6 +216,10 @@ test.describe("visual portal", () => {
await page.goto(mobile ? "/mobile" : "/portal"); await page.goto(mobile ? "/mobile" : "/portal");
await expect(page.getByText("报销待组建付款批次 · BX-SYN", { exact: true })).toBeVisible(); await expect(page.getByText("报销待组建付款批次 · BX-SYN", { exact: true })).toBeVisible();
await expect(page.getByText("付款与回单待处理 · PB-SYN", { exact: true })).toBeVisible(); await expect(page.getByText("付款与回单待处理 · PB-SYN", { exact: true })).toBeVisible();
await expect(page.getByRole("link").filter({ hasText: "报销待组建付款批次 · BX-SYN" }))
.toHaveAttribute("href", mobile ? "/mobile/reimbursements/synthetic" : "/reimbursements/synthetic");
await expect(page.getByRole("link").filter({ hasText: "付款与回单待处理 · PB-SYN" }))
.toHaveAttribute("href", mobile ? "/mobile/payment-batches?id=synthetic" : "/payment-batches?id=synthetic");
await expect(page.getByText("待付款批次 9 个", { exact: true })).toHaveCount(0); await expect(page.getByText("待付款批次 9 个", { exact: true })).toHaveCount(0);
await expect(page.getByText("异常付款 1 项", { exact: true })).toHaveCount(0); await expect(page.getByText("异常付款 1 项", { exact: true })).toHaveCount(0);
if (mobile) await expect(page.getByTestId("mobile-business-handoffs")).toContainText("2 项"); if (mobile) await expect(page.getByTestId("mobile-business-handoffs")).toContainText("2 项");
@@ -225,6 +229,7 @@ test.describe("visual portal", () => {
items = []; items = [];
await page.reload(); await page.reload();
await expect(page.getByText("付款与回单待处理 · PB-SYN", { exact: true })).toHaveCount(0); await expect(page.getByText("付款与回单待处理 · PB-SYN", { exact: true })).toHaveCount(0);
await expect(page.getByText("报销待组建付款批次 · BX-SYN", { exact: true })).toHaveCount(0);
await expect(page.getByText("待付款批次 9 个", { exact: true })).toHaveCount(0); await expect(page.getByText("待付款批次 9 个", { exact: true })).toHaveCount(0);
}); });
} }
@@ -71,21 +71,6 @@
{"file":"server/src/routes/attendance.js","fingerprint":"3bc8430a72bd41e66bb2de2fc5b38cb78b7306a353e0beb21adf56eefce42840","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/attendance/admin/backfill"}, {"file":"server/src/routes/attendance.js","fingerprint":"3bc8430a72bd41e66bb2de2fc5b38cb78b7306a353e0beb21adf56eefce42840","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/attendance/admin/backfill"},
{"file":"server/src/routes/audit.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"}, {"file":"server/src/routes/audit.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"},
{"file":"server/src/routes/audit.js","fingerprint":"510d8ad88dde0075df37838cd0b7ff6241145d700817ea5f203086abbae5b568","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"}, {"file":"server/src/routes/audit.js","fingerprint":"510d8ad88dde0075df37838cd0b7ff6241145d700817ea5f203086abbae5b568","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"},
{"file":"server/src/routes/auth.js","fingerprint":"b7c458d92f0ef3e6a3d2246d397cb09b60253fd5e8d0d83e8298d1b341c54c50","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:emailLoginCodeError"},
{"file":"server/src/routes/auth.js","fingerprint":"b7c458d92f0ef3e6a3d2246d397cb09b60253fd5e8d0d83e8298d1b341c54c50","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:passwordResetCodeError"},
{"file":"server/src/routes/auth.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/login"},
{"file":"server/src/routes/auth.js","fingerprint":"dfe43a919ec5e4c8e4f579fe990e6b4aaa6e59fc8a60a4f5c30586434d6d1f12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/login"},
{"file":"server/src/routes/auth.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/email-login/code"},
{"file":"server/src/routes/auth.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/email-login/verify"},
{"file":"server/src/routes/auth.js","fingerprint":"dfe43a919ec5e4c8e4f579fe990e6b4aaa6e59fc8a60a4f5c30586434d6d1f12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/email-login/verify"},
{"file":"server/src/routes/auth.js","fingerprint":"dfe43a919ec5e4c8e4f579fe990e6b4aaa6e59fc8a60a4f5c30586434d6d1f12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/refresh"},
{"file":"server/src/routes/auth.js","fingerprint":"961c7d1a4eaf9137354b9e70d31fb0496fc2be2d130d9a9967507b6a0ca1f5ad","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/deeplink"},
{"file":"server/src/routes/auth.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/change-password"},
{"file":"server/src/routes/auth.js","fingerprint":"dfe43a919ec5e4c8e4f579fe990e6b4aaa6e59fc8a60a4f5c30586434d6d1f12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/change-password"},
{"file":"server/src/routes/auth.js","fingerprint":"cb6e800502eb1c1b8117b9ae62a8c894756d2a062c3ea24b52a369af9a3be7a7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/change-password"},
{"file":"server/src/routes/auth.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/forgot-password"},
{"file":"server/src/routes/auth.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/reset-password"},
{"file":"server/src/routes/auth.js","fingerprint":"cb6e800502eb1c1b8117b9ae62a8c894756d2a062c3ea24b52a369af9a3be7a7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/reset-password"},
{"file":"server/src/routes/budgetControlRules.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, {"file":"server/src/routes/budgetControlRules.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/budgetControlRules.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseRuleId"}, {"file":"server/src/routes/budgetControlRules.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseRuleId"},
{"file":"server/src/routes/budgetControlRules.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"}, {"file":"server/src/routes/budgetControlRules.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"},
@@ -252,50 +237,6 @@
{"file":"server/src/routes/inventoryChecks.js","fingerprint":"0492f0a2dab5df4df9bdd0504d07e51d03f2c56d6e0e516abb2c5ecac159c545","count":3,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureAssetInScope"}, {"file":"server/src/routes/inventoryChecks.js","fingerprint":"0492f0a2dab5df4df9bdd0504d07e51d03f2c56d6e0e516abb2c5ecac159c545","count":3,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureAssetInScope"},
{"file":"server/src/routes/inventoryChecks.js","fingerprint":"5a020ee415b339c0a8064f07afa8fd5c6e3b305186e24d2d1653cdc3143f1277","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/inventory-checks/:id/items"}, {"file":"server/src/routes/inventoryChecks.js","fingerprint":"5a020ee415b339c0a8064f07afa8fd5c6e3b305186e24d2d1653cdc3143f1277","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/inventory-checks/:id/items"},
{"file":"server/src/routes/inventoryChecks.js","fingerprint":"466c3a313730aa3a9720bb5b37447aa5d1fa274961d8f64838c66b2780e69ef3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/inventory-checks/:id/items"}, {"file":"server/src/routes/inventoryChecks.js","fingerprint":"466c3a313730aa3a9720bb5b37447aa5d1fa274961d8f64838c66b2780e69ef3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/inventory-checks/:id/items"},
{"file":"server/src/routes/invoicePool/read.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseInvoiceListQuery"},
{"file":"server/src/routes/invoicePool/read.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBindableInvoiceQuery"},
{"file":"server/src/routes/invoicePool/read.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseInvoiceStatusQuery"},
{"file":"server/src/routes/invoicePool/read.js","fingerprint":"ec1e58e420efffb320f1456a4fb3931dd289f2aa0f6a9f14c555cae9042326fb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseInvoiceStatusQuery"},
{"file":"server/src/routes/invoicePool/read.js","fingerprint":"1ae6e5bbcf72222ccc4bc1c05914feaab32cf287ae1b775ea3cdf21aa1b2d841","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/bindable"},
{"file":"server/src/routes/invoicePool/read.js","fingerprint":"eafa3d7b708b3d3b0052c67493ee3cf3f1ee62121cdbc3612f6d0c16b057eddc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id"},
{"file":"server/src/routes/invoicePool/read.js","fingerprint":"eafa3d7b708b3d3b0052c67493ee3cf3f1ee62121cdbc3612f6d0c16b057eddc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id/signed-url"},
{"file":"server/src/routes/invoicePool/shared/schemas.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseVerificationListQuery"},
{"file":"server/src/routes/invoicePool/verification.js","fingerprint":"4cedb22d4287fa69cd3d72c48ea82eb6afec9f21178d4cf779c73190e47c3acc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:throwVerificationDisabled"},
{"file":"server/src/routes/invoicePool/verification.js","fingerprint":"eafa3d7b708b3d3b0052c67493ee3cf3f1ee62121cdbc3612f6d0c16b057eddc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id/verify"},
{"file":"server/src/routes/invoicePool/verification.js","fingerprint":"a09086681b58bdc8f82ade2e261299b2b3f870dda6095179fd1bb0a7d29a07d7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id/verify"},
{"file":"server/src/routes/invoicePool/verification.js","fingerprint":"eafa3d7b708b3d3b0052c67493ee3cf3f1ee62121cdbc3612f6d0c16b057eddc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id/verification"},
{"file":"server/src/routes/invoicePool/verification.js","fingerprint":"eafa3d7b708b3d3b0052c67493ee3cf3f1ee62121cdbc3612f6d0c16b057eddc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id/verification/retry"},
{"file":"server/src/routes/invoicePool/verification.js","fingerprint":"d68782bf13b2a21884d9d0201289a6a7c31ed5b96dfe2fe42c1b402eddc59860","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id/verification/retry"},
{"file":"server/src/routes/invoicePool/verification.js","fingerprint":"a09086681b58bdc8f82ade2e261299b2b3f870dda6095179fd1bb0a7d29a07d7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id/verification/retry"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"b25d67f027d2694c50084f1e0711f2ced9e00f7df8200ee4027a71d08a1e8ccf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/upload"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"10705bd1e81bbfea513d812195a089c4bdd941e1f605f79e385c289a33b40fa1","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/upload"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"e2ff709dad27fe2e1b4729f73cf486907d2b63fc737309db2f4e722cdc09d54a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/upload"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"a351e1777344b858d6c66ba287c5b97ded0b99f3fda956cd42c05e5902425dbc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/upload"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"e4e5f0ca126ee9f9628b83f636647163895316df68e471e73948e8f721fc0c6d","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/upload"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"209e6cf90ca2987da39a2f283be0e303f6b3d04cdaa768ac8516352c8b43e43f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/upload"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"323050fa9f5d706ccb2fbfc571c65db89da2a0abd86bd83d76247e6eed8825a3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/upload"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"1fd8947fdbb80a0662debcc3fcb5a190bac9b9faae25d32f786c58290bbf21cb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/upload"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"eafa3d7b708b3d3b0052c67493ee3cf3f1ee62121cdbc3612f6d0c16b057eddc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"4ea167a563f4638e4a6374c75b583eb0b9cb2e9a44972a7a2a918f66a3fb6479","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"996fd561bfa6933b8a36273a25fecfecc94d0b366da830685336ddc7f1212eca","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"4c88143346d3120b6288493bff1f7b1058e83024267177d6a040536ee522ef6f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"6b7b1d70009de6e6ebc036a19d7c609d391e389c796e66917f8b052cd3733cd5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"aaa74bc004c1950f6bec6c292f64fac1bfd272711d91b63e0dae05fe60726112","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"755bc115dd1b0bfe92dafbf208638cfbdd64cb9c3397efd445f4951330c6b42c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"aaa1935c5f3309a25eab1efcd31752ef61b87a21b0a0e31cd2453025079b0861","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"26fae04e6caa2559ff1618111dc5eb4efa38c8844a4a6c1b9907408e578f825f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"1c0abb2c2a9c5e68ab6d30a6bd151040f763e19ba4954f286ce5590b4c352cc1","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"eafa3d7b708b3d3b0052c67493ee3cf3f1ee62121cdbc3612f6d0c16b057eddc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id/archive"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"dd7712f7af206606ec186567d5aa5d472ba35e16d6c358bcb083fd59bac37e2d","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id/archive"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"5901b98322f4641858b8140c0e996c83b377520c04839a5c0172203d336c19ba","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id/archive"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"eafa3d7b708b3d3b0052c67493ee3cf3f1ee62121cdbc3612f6d0c16b057eddc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id/retry-ocr"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"0935512f403e99598a11968a8df9bebb90ce269d3c912b1651788dde85a56d55","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id/retry-ocr"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"8ef9867968f4f4798471c123d10bb20e52622b9c47f6029b4e3fb2f4ace11061","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id/retry-ocr"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"eafa3d7b708b3d3b0052c67493ee3cf3f1ee62121cdbc3612f6d0c16b057eddc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id/override-duplicate"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"4ea167a563f4638e4a6374c75b583eb0b9cb2e9a44972a7a2a918f66a3fb6479","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id/override-duplicate"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"ccbeddf84642069d880ee7c3dc6fe2553cd272ae1ef05d8462e8645d8e2186af","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id/override-duplicate"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"e21d267cf852d71361e8b4ae849a80a9e5aadfb610f8a16522a5cc6b3c532f76","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id/override-duplicate"},
{"file":"server/src/routes/invoicePool.js","fingerprint":"f5cfabfacf1f557b5bc7812e5c88e8b1901712b2de6dfb31a97b8b26d4ced7e7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/invoice-pool/:id/override-duplicate"},
{"file":"server/src/routes/leave-types.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, {"file":"server/src/routes/leave-types.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/leave-types.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTypeId"}, {"file":"server/src/routes/leave-types.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTypeId"},
{"file":"server/src/routes/leave-types.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTypeQuery"}, {"file":"server/src/routes/leave-types.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTypeQuery"},
@@ -353,8 +294,6 @@
{"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/suppliers"}, {"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/suppliers"},
{"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"c57bb2bf24ae236cb93cf16651897e8c0b3da2be2744c6e5421311359c385c12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/suppliers/:id"}, {"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"c57bb2bf24ae236cb93cf16651897e8c0b3da2be2744c6e5421311359c385c12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/suppliers/:id"},
{"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/suppliers/:id"}, {"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/suppliers/:id"},
{"file":"server/src/routes/me.js","fingerprint":"91b404962c52c958cb17eba0f9c855f35e008307aae5b8e19e6a849f5ff36b0e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/me.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseNotificationPreferences"},
{"file":"server/src/routes/migrationRuns.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, {"file":"server/src/routes/migrationRuns.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/migrationRuns.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"}, {"file":"server/src/routes/migrationRuns.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"},
{"file":"server/src/routes/migrationRuns.js","fingerprint":"43aaacd8708bb5c07aa913145a5dfa84cd402038b1fee3ada860ee249f8ce9c3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"}, {"file":"server/src/routes/migrationRuns.js","fingerprint":"43aaacd8708bb5c07aa913145a5dfa84cd402038b1fee3ada860ee249f8ce9c3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"},
@@ -367,7 +306,6 @@
{"file":"server/src/routes/notifications.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, {"file":"server/src/routes/notifications.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/notifications.js","fingerprint":"57c60265b18cb39c11ef9a4e5ef528ea29ffae46c51f52b11e64a44564993d74","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:handleNotificationChannelWriteError"}, {"file":"server/src/routes/notifications.js","fingerprint":"57c60265b18cb39c11ef9a4e5ef528ea29ffae46c51f52b11e64a44564993d74","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:handleNotificationChannelWriteError"},
{"file":"server/src/routes/notifications.js","fingerprint":"8413330dba32e6adedc5c566d53c40d6e596465bf96637bcd76a272a9940c24f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:handleNotificationChannelWriteError"}, {"file":"server/src/routes/notifications.js","fingerprint":"8413330dba32e6adedc5c566d53c40d6e596465bf96637bcd76a272a9940c24f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:handleNotificationChannelWriteError"},
{"file":"server/src/routes/paymentAccounts.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validationError"},
{"file":"server/src/routes/payroll.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, {"file":"server/src/routes/payroll.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/payroll.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"}, {"file":"server/src/routes/payroll.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"},
{"file":"server/src/routes/payroll.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, {"file":"server/src/routes/payroll.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
@@ -582,26 +520,6 @@
{"file":"server/src/routes/weeklyReports.js","fingerprint":"c467510c074544d5abb39af6d212600918c78bae7e46bb8c1837b82b1eb16ccb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:weeklyReportsRoutes"}, {"file":"server/src/routes/weeklyReports.js","fingerprint":"c467510c074544d5abb39af6d212600918c78bae7e46bb8c1837b82b1eb16ccb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:weeklyReportsRoutes"},
{"file":"server/src/routes/weeklyReports.js","fingerprint":"d3e6e1b5646c7292abc574c53b3894ca82620c1f2cb70d3c0f968e0bea4764df","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:weeklyReportsRoutes"}, {"file":"server/src/routes/weeklyReports.js","fingerprint":"d3e6e1b5646c7292abc574c53b3894ca82620c1f2cb70d3c0f968e0bea4764df","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:weeklyReportsRoutes"},
{"file":"server/src/routes/weeklyReports.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/weekly-reports/team"}, {"file":"server/src/routes/weeklyReports.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/weekly-reports/team"},
{"file":"server/src/routes/workflow.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseRequiredComment"},
{"file":"server/src/routes/workflow.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTransferBody"},
{"file":"server/src/routes/workflow.js","fingerprint":"25e610a1c5a3132b78c8415455bdb93b3bd9c914260ba91f3a024e3c7daa9baf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTaskId"},
{"file":"server/src/routes/workflow.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBatchApproveBody"},
{"file":"server/src/routes/workflow.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBatchRejectBody"},
{"file":"server/src/routes/workflow.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseRemindTaskBody"},
{"file":"server/src/routes/workflow.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTaskSummaryQuery"},
{"file":"server/src/routes/workflow.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTaskListQuery"},
{"file":"server/src/routes/workflow.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseApproverListQuery"},
{"file":"server/src/routes/workflow.js","fingerprint":"62b930e24657a4fa191f8eb1a42f94f756fb2fb753d8107b7fff4f869b963cfc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensurePendingTask"},
{"file":"server/src/routes/workflow.js","fingerprint":"1fd5b3afeebac7940f58367d8416482429745f220faa980d888d2fb839ab2675","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:loadWorkflowApplicantIds"},
{"file":"server/src/routes/workflow.js","fingerprint":"6ba473aef620919735b77748bb35859436274ea27837e76d065c6dd25bba0c76","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureNoSelfApproval"},
{"file":"server/src/routes/workflow.js","fingerprint":"14397be7e0b880038157ab116206bdb75936bf78db446e399c28f380969107f6","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureTransferTargetEligible"},
{"file":"server/src/routes/workflow.js","fingerprint":"32ceca17a6cc314683a6f2a1c22eab24df1b295620797e7be26259eeb05210f8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureTransferTargetEligible"},
{"file":"server/src/routes/workflow.js","fingerprint":"b3753b0ea33a451bea3240fa5fb5fbd456cf8a8998c3330f4744ea7cf7d77cb1","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureWorkflowEntityType"},
{"file":"server/src/routes/workflow.js","fingerprint":"4a23ae0c571add78e35c3497b671980724a9f24e196c0ebf52fa037851212237","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureWorkflowReminderAllowed"},
{"file":"server/src/routes/workflow.js","fingerprint":"54e37689f6b2e7a59fdd73829ca7fbf5dc92808e6034d7387eded9bcc279991c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureWorkflowReminderCooldown"},
{"file":"server/src/routes/workflow.js","fingerprint":"21e9a7c8e6efa358a778df97819620a4a2d4959ec8644e049793fb5b0d2e5a69","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:executeApproveWorkflowTask"},
{"file":"server/src/routes/workflow.js","fingerprint":"928259183e400603e036b96ba45612554560c9ba37cf8975a99f040231aa8393","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/workflow/tasks/:taskId/transfer"},
{"file":"server/src/routes/workflow.js","fingerprint":"d92b664e7497a7bf4f353e457cc7484fe9d9dbf3fa8328002a724f18f9f432e6","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/workflow/tasks/:taskId/transfer"},
{"file":"server/src/routes/workflowDefinitions.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, {"file":"server/src/routes/workflowDefinitions.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/workflowDefinitions.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"}, {"file":"server/src/routes/workflowDefinitions.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"},
{"file":"server/src/routes/workflowDefinitions.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, {"file":"server/src/routes/workflowDefinitions.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
+35
查看文件
@@ -13,6 +13,10 @@ const docker = (args, input) => execFileSync("docker", args, { input, encoding:
async function main() { async function main() {
assert.equal(process.env.RUN_ISOLATED_DB_TEST, "1", "Explicit disposable database opt-in required"); assert.equal(process.env.RUN_ISOLATED_DB_TEST, "1", "Explicit disposable database opt-in required");
if (process.env.RUN_ERROR_GUIDANCE_TEST === "1") {
assert.equal(process.env.RUN_BROWSER_TEST, "1", "Error guidance requires browser verification");
assert.equal(process.env.RUN_JOB_QUEUE, "1", "Error guidance requires mock OCR provider");
}
const managerIsCeo = process.env.RUN_MANAGER_IS_CEO_TEST === "1"; const managerIsCeo = process.env.RUN_MANAGER_IS_CEO_TEST === "1";
const ownerApprovalTest = process.env.RUN_OWNER_APPROVAL_TEST === "1"; const ownerApprovalTest = process.env.RUN_OWNER_APPROVAL_TEST === "1";
assert.ok(!ownerApprovalTest || managerIsCeo, "Owner approval checks require the small-team fixture"); assert.ok(!ownerApprovalTest || managerIsCeo, "Owner approval checks require the small-team fixture");
@@ -265,6 +269,37 @@ async function main() {
app.config.appOrigin = uiOrigin; app.config.appOrigin = uiOrigin;
const { chromium, expect } = clientRequire("@playwright/test"); const { chromium, expect } = clientRequire("@playwright/test");
browser = await chromium.launch({ headless: true }); browser = await chromium.launch({ headless: true });
if (process.env.RUN_ERROR_GUIDANCE_TEST === "1") {
assert.equal(process.env.RUN_JOB_QUEUE, "1", "Error guidance requires mock OCR provider");
const invoice = await makeInvoice({ status: "ocr_failed", ocr_type: "vat", invoice_code: null, seller_name: null });
const context = await browser.newContext({ serviceWorkers: "block" });
try {
const fingerprint = `synthetic-guidance-${randomUUID()}`;
await context.addCookies([{ name: "hyb_oa_device_fingerprint", value: fingerprint, url: uiOrigin }]);
const login = await context.request.post(`${uiOrigin}/api/auth/login`, { headers: { "x-device-fingerprint": fingerprint }, data: { identifier: employee.email, password: loginPassword } });
assert.equal(login.status(), 200);
const page = await context.newPage();
await page.goto(`${uiOrigin}/invoice-pool?id=${invoice.id}`);
await page.getByTestId("invoice-edit-open").click();
await page.getByTestId("invoice-edit-set-available").check();
const failed = page.waitForResponse((response) => response.request().method() === "PATCH" && response.url().endsWith(`/invoice-pool/${invoice.id}`));
await page.getByTestId("invoice-edit-save").click();
const failure = await failed;
assert.equal(failure.status(), 409);
assert.equal((await failure.json()).error.details.reasonCode, "invoice_pool.ocr_fields_missing");
await expect(page.locator("main").getByText("票据仍缺少关键字段:发票代码、销售方,补齐后才能标记为可用。", { exact: true })).toBeVisible();
assert.equal((await prisma.invoicePool.findUniqueOrThrow({ where: { id: invoice.id } })).status, "ocr_failed");
await page.getByTestId("invoice-edit-code").fill("044003204111");
await page.getByTestId("invoice-edit-seller-name").fill("synthetic seller");
await page.getByTestId("invoice-edit-save").click();
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:可用");
assert.equal((await prisma.invoicePool.findUniqueOrThrow({ where: { id: invoice.id } })).status, "available");
console.log(JSON.stringify({ status: "PASS_ERROR_GUIDANCE_REAL_AUTH_DB_BROWSER", missingFieldsShown: true, failureRetainsState: true, correctedSavePersists: true, liveOcr: "NOT_CALLED", fixture: "SEEDED_OCR_FAILED_SYNTHETIC_INVOICE" }));
} finally {
await context.close();
}
return;
}
const runBrowserFlow = async (mobile) => { const runBrowserFlow = async (mobile) => {
const context = await browser.newContext({ serviceWorkers: "block", viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 } }); const context = await browser.newContext({ serviceWorkers: "block", viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 } });
const fingerprint = `synthetic-browser-${mobile ? "mobile" : "desktop"}`; const fingerprint = `synthetic-browser-${mobile ? "mobile" : "desktop"}`;
+73
查看文件
@@ -1,4 +1,77 @@
const ERROR_REASONS = [ const ERROR_REASONS = [
{"code":"workflow.task_already_processed","domain":"workflow","audience":"user","defaultMessage":"此审批任务已处理,请刷新待办查看最新进度。","recovery":"follow_guidance"},
{"code":"workflow.entity_unavailable","domain":"workflow","audience":"user","defaultMessage":"审批对应的单据不存在或当前不可处理,请联系管理员核查。","recovery":"follow_guidance"},
{"code":"workflow.self_approval_forbidden","domain":"workflow","audience":"user","defaultMessage":"不能审批本人申请或代提交的单据,请由符合资格的其他审批人处理。","recovery":"follow_guidance"},
{"code":"workflow.transfer_target_unavailable","domain":"workflow","audience":"user","defaultMessage":"转审人不存在或已停用,请重新选择在职审批人。","recovery":"follow_guidance"},
{"code":"workflow.transfer_target_ineligible","domain":"workflow","audience":"user","defaultMessage":"所选人员没有这类业务的审批资格,请重新选择审批人。","recovery":"follow_guidance"},
{"code":"workflow.self_remind_forbidden","domain":"workflow","audience":"user","defaultMessage":"当前待办由你处理,无需催办自己,请直接处理待办。","recovery":"follow_guidance"},
{"code":"workflow.step_missing","domain":"workflow","audience":"user","defaultMessage":"审批流程节点信息异常,请联系管理员核查,不要重复提交。","recovery":"follow_guidance"},
{"code":"workflow.transfer_unsupported","domain":"workflow","audience":"user","defaultMessage":"此类业务暂不支持转审,请按原审批流程处理。","recovery":"follow_guidance"},
{"code":"workflow.transfer_same_assignee","domain":"workflow","audience":"user","defaultMessage":"不能转审给自己,请选择其他符合资格的审批人。","recovery":"follow_guidance"},
{"code":"workflow.task_list_query_invalid","domain":"workflow","audience":"user","defaultMessage":"待办筛选或分页条件无效,请重新选择筛选条件后重试。","recovery":"follow_guidance"},
{"code":"workflow.approver_query_invalid","domain":"workflow","audience":"user","defaultMessage":"审批人查询条件无效,请缩短关键词并检查分页条件后重试。","recovery":"follow_guidance"},
{"code":"workflow.batch_entity_unsupported","domain":"workflow","audience":"user","defaultMessage":"批量审批仅支持当前指定业务类型,请取消其他类型任务的选择。","recovery":"follow_guidance"},
{"code":"workflow.remind_cooldown","domain":"workflow","audience":"user","defaultMessage":"此任务24小时内已由你催办,请勿重复催办,稍后再试。","recovery":"follow_guidance"},
{"code":"invoice_pool.pdf_required","domain":"invoice_pool","audience":"user","defaultMessage":"请上传 PDF 发票文件","recovery":"follow_guidance"},
{"code":"invoice_pool.ocr_pdf_too_large","domain":"invoice_pool","audience":"user","defaultMessage":"PDF 发票不能超过 7 MiB","recovery":"follow_guidance"},
{"code":"invoice_verification.list_query_invalid","domain":"invoice_verification","audience":"user","defaultMessage":"查验记录查询数量无效,请选择1至50条后重试。","recovery":"follow_guidance"},
{"code":"invoice_pool.single_file_required","domain":"invoice_pool","audience":"user","defaultMessage":"请每次上传一份PDF发票,可分次添加多张发票。","recovery":"follow_guidance"},
{"code":"invoice_pool.empty_file","domain":"invoice_pool","audience":"user","defaultMessage":"发票文件为空,请重新选择有效的PDF文件。","recovery":"follow_guidance"},
{"code":"invoice_pool.upload_too_large","domain":"invoice_pool","audience":"user","defaultMessage":"发票文件超过当前上传大小限制,请压缩PDF后重试;仍失败请联系管理员。","recovery":"follow_guidance"},
{"code":"invoice_pool.file_required","domain":"invoice_pool","audience":"user","defaultMessage":"请先选择要上传的PDF发票。","recovery":"follow_guidance"},
{"code":"invoice_pool.owner_unavailable","domain":"invoice_pool","audience":"user","defaultMessage":"发票所属员工不存在或已停用,请重新选择在职员工。","recovery":"follow_guidance"},
{"code":"invoice_pool.ocr_type_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"发票识别类型无效,请重新选择票据类型后上传。","recovery":"follow_guidance"},
{"code":"invoice_pool.edit_empty","domain":"invoice_pool","audience":"user","defaultMessage":"没有可保存的修改,请修改发票信息后再保存。","recovery":"follow_guidance"},
{"code":"invoice_pool.not_editable","domain":"invoice_pool","audience":"user","defaultMessage":"此发票当前不能编辑,请刷新查看状态;已关联的发票需先按报销流程解除占用。","recovery":"follow_guidance"},
{"code":"invoice_pool.date_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"开票日期无效,请填写真实有效的日期。","recovery":"follow_guidance"},
{"code":"invoice_pool.amount_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"发票金额格式无效,请填写有效金额后重试。","recovery":"follow_guidance"},
{"code":"invoice_pool.tax_amount_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"发票税额格式无效,请填写有效税额后重试。","recovery":"follow_guidance"},
{"code":"invoice_pool.total_amount_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"价税合计格式无效,请填写有效金额后重试。","recovery":"follow_guidance"},
{"code":"invoice_pool.status_transition_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"此发票不能按当前方式更改状态,请刷新页面后重试。","recovery":"follow_guidance"},
{"code":"invoice_pool.archive_bound","domain":"invoice_pool","audience":"user","defaultMessage":"发票已关联业务单据,不能归档;请先完成业务处理或解除关联。","recovery":"follow_guidance"},
{"code":"invoice_pool.archive_status_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"此发票当前不能归档,请等待识别完成或刷新查看状态。","recovery":"follow_guidance"},
{"code":"invoice_pool.ocr_retry_status_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"只有识别失败的发票可以重新识别,请刷新查看当前结果。","recovery":"follow_guidance"},
{"code":"invoice_pool.ocr_unavailable","domain":"invoice_pool","audience":"user","defaultMessage":"发票OCR识别服务当前不可用,请联系管理员恢复服务后重试。","recovery":"follow_guidance"},
{"code":"invoice_pool.override_bound","domain":"invoice_pool","audience":"user","defaultMessage":"发票已关联业务单据,不能设置重复票据例外;请先处理原关联。","recovery":"follow_guidance"},
{"code":"invoice_pool.override_status_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"此发票当前不能设置重复票据例外,请刷新查看状态。","recovery":"follow_guidance"},
{"code":"invoice_pool.dedup_identity_missing","domain":"invoice_pool","audience":"user","defaultMessage":"发票缺少查重标识,请补全发票代码和号码;仍无法处理请联系管理员。","recovery":"follow_guidance"},
{"code":"invoice_pool.edit_input_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"发票信息格式无效,请检查日期、金额及必填字段后保存。","recovery":"follow_guidance"},
{"code":"invoice_pool.override_input_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"请填写有效的关联报销编号和重复票据例外原因。","recovery":"follow_guidance"},
{"code":"invoice_pool.ocr_fields_missing","domain":"invoice_pool","audience":"user","defaultMessage":"发票识别信息不完整,请补全票种要求的代码、号码、日期、金额及销售方等信息后保存。","recovery":"follow_guidance"},
{"code":"workflow.comment_invalid","domain":"workflow","audience":"user","defaultMessage":"请填写处理意见,长度为1至500字。","recovery":"follow_guidance"},
{"code":"workflow.transfer_input_invalid","domain":"workflow","audience":"user","defaultMessage":"请选择有效的转审人,并填写10至500字的转审原因。","recovery":"follow_guidance"},
{"code":"workflow.batch_approve_input_invalid","domain":"workflow","audience":"user","defaultMessage":"请选择1至50条有效审批任务,审批意见不超过500字。","recovery":"follow_guidance"},
{"code":"workflow.batch_reject_input_invalid","domain":"workflow","audience":"user","defaultMessage":"请选择1至50条有效审批任务,并填写1至500字的退回意见。","recovery":"follow_guidance"},
{"code":"workflow.remind_input_invalid","domain":"workflow","audience":"user","defaultMessage":"催办说明不能超过200字,请修改后重试。","recovery":"follow_guidance"},
{"code":"workflow.summary_query_invalid","domain":"workflow","audience":"user","defaultMessage":"待办统计范围无效,请从待办页面重新进入。","recovery":"follow_guidance"},
{"code":"workflow.task_id_invalid","domain":"workflow","audience":"user","defaultMessage":"审批任务标识无效,请从待办列表重新打开。","recovery":"follow_guidance"},
{"code":"invoice_verification.disabled","domain":"invoice_verification","audience":"user","defaultMessage":"发票查验服务暂未启用,请联系管理员确认;发票OCR识别是独立服务。","recovery":"follow_guidance"},
{"code":"invoice_verification.already_pending","domain":"invoice_verification","audience":"user","defaultMessage":"发票正在查验,请稍后刷新结果,不要重复提交。","recovery":"follow_guidance"},
{"code":"invoice_verification.record_missing","domain":"invoice_verification","audience":"user","defaultMessage":"此发票还没有查验记录,请先发起查验,再按需重试。","recovery":"follow_guidance"},
{"code":"auth.login_input_invalid","domain":"auth","audience":"user","defaultMessage":"请填写登录账号和密码后重试。","recovery":"follow_guidance"},
{"code":"auth.email_input_invalid","domain":"auth","audience":"user","defaultMessage":"请输入格式正确的邮箱地址后重试。","recovery":"follow_guidance"},
{"code":"auth.email_code_input_invalid","domain":"auth","audience":"user","defaultMessage":"请检查邮箱地址和验证码的填写格式后重试。","recovery":"follow_guidance"},
{"code":"auth.password_change_input_invalid","domain":"auth","audience":"user","defaultMessage":"请完整填写原密码和新密码后重试。","recovery":"follow_guidance"},
{"code":"auth.password_reset_input_invalid","domain":"auth","audience":"user","defaultMessage":"请完整填写邮箱、验证码和新密码后重试。","recovery":"follow_guidance"},
{"code":"auth.device_context_missing","domain":"auth","audience":"user","defaultMessage":"登录设备信息缺失,请刷新登录页面后重试;仍无法登录请联系管理员。","recovery":"follow_guidance"},
{"code":"auth.password_policy_failed","domain":"auth","audience":"user","defaultMessage":"新密码需至少12位,包含大小写字母和数字,并避免常见弱密码。","recovery":"follow_guidance"},
{"code":"auth.deeplink_target_invalid","domain":"auth","audience":"user","defaultMessage":"此入口的目标地址无效,请从系统首页重新进入对应业务。","recovery":"follow_guidance"},
{"code":"auth.code_invalid_or_expired","domain":"auth","audience":"user","defaultMessage":"验证码无效或已过期,请重新获取后重试。","recovery":"follow_guidance"},
{"code":"profile.notification_preferences_invalid","domain":"profile","audience":"user","defaultMessage":"通知设置无效,请通过页面开关选择是否接收通知后重试。","recovery":"follow_guidance"},
{"code":"invoice_pool.list_query_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"票据筛选或分页参数无效,请重置筛选后重试。","recovery":"follow_guidance"},
{"code":"invoice_pool.selection_query_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"选择发票的报销单或查询条件无效,请返回报销单重新打开“我的发票”。","recovery":"follow_guidance"},
{"code":"invoice_pool.status_query_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"待查询票据列表无效,请刷新后重试;一次最多查询50张票据。","recovery":"follow_guidance"},
{"code":"invoice_pool.reimbursement_not_editable","domain":"invoice_pool","audience":"user","defaultMessage":"该报销单当前不可添加发票,请刷新确认状态;仅草稿或退回待修改的单据可继续选票。","recovery":"follow_guidance"},
{"code":"invoice_pool.id_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"票据标识无效,请返回“我的发票”重新选择。","recovery":"follow_guidance"},
{"code":"payment_account.reveal_reason_required","domain":"payment_account","audience":"user","defaultMessage":"请填写2至500字的查看理由后再查看完整付款账号。","recovery":"follow_guidance"},
{"code":"payment_account.bank_input_invalid","domain":"payment_account","audience":"user","defaultMessage":"请检查银行名称;新增银行还需填写2至16位字母、数字或下划线组成的银行编码。","recovery":"follow_guidance"},
{"code":"payment_account.account_input_invalid","domain":"payment_account","audience":"user","defaultMessage":"请检查所属公司、开户银行、户名及账号;账号需为6至32位数字。","recovery":"follow_guidance"},
{"code":"payment_account.id_invalid","domain":"payment_account","audience":"user","defaultMessage":"账户或银行标识无效,请返回列表重新选择。","recovery":"follow_guidance"},
{"code":"payment_account.legal_entity_unavailable","domain":"payment_account","audience":"user","defaultMessage":"所选公司不存在或已停用,请刷新后选择可用公司;如无可选公司,请联系管理员。","recovery":"follow_guidance"},
{"code":"payment_account.bank_unavailable","domain":"payment_account","audience":"user","defaultMessage":"所选银行不存在或已停用,请刷新后选择真实开户银行,不要为通过检查改填其他银行。","recovery":"follow_guidance"},
{"code":"payment_account.bank_code_exists","domain":"payment_account","audience":"user","defaultMessage":"该银行编码已存在,请使用已有银行或核实编码后重试。","recovery":"follow_guidance"},
{"code":"payment_account.bank_in_use","domain":"payment_account","audience":"user","defaultMessage":"该银行仍有启用的付款账户,暂不能停用;请先核实相关账户的使用安排。","recovery":"follow_guidance"},
{"code":"payment_account.legal_entity_change_forbidden","domain":"payment_account","audience":"user","defaultMessage":"已有付款账户不能直接更换所属公司,请保留原公司,并为其他公司另行维护账户。","recovery":"follow_guidance"},
{"code":"reimbursement.finance_owner_ceo_unavailable","domain":"reimbursement","audience":"user","defaultMessage":"财务本人报销需要非申请人的总经理审批,请联系管理员核实审批配置。","recovery":"follow_guidance"}, {"code":"reimbursement.finance_owner_ceo_unavailable","domain":"reimbursement","audience":"user","defaultMessage":"财务本人报销需要非申请人的总经理审批,请联系管理员核实审批配置。","recovery":"follow_guidance"},
{"code":"reimbursement.finance_owner_ceo_required","domain":"reimbursement","audience":"user","defaultMessage":"财务本人报销只能由非申请人的在职总经理审批或接收转审,不能委托其他角色代批。","recovery":"follow_guidance"}, {"code":"reimbursement.finance_owner_ceo_required","domain":"reimbursement","audience":"user","defaultMessage":"财务本人报销只能由非申请人的在职总经理审批或接收转审,不能委托其他角色代批。","recovery":"follow_guidance"},
{"code":"reimbursement.invoice_prefill_incomplete","domain":"reimbursement","audience":"user","defaultMessage":"票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。","recovery":"follow_guidance"}, {"code":"reimbursement.invoice_prefill_incomplete","domain":"reimbursement","audience":"user","defaultMessage":"票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。","recovery":"follow_guidance"},
+16 -90
查看文件
@@ -1,6 +1,7 @@
const crypto = require("node:crypto"); const crypto = require("node:crypto");
const { z } = require("zod"); const { z } = require("zod");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { AppError } = require("../utils/AppError"); const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit"); const { buildAuditLogData } = require("../utils/audit");
const { const {
@@ -251,21 +252,11 @@ function hashEmployeeAuthCode(config, employeeId, code, purpose) {
} }
function emailLoginCodeError() { function emailLoginCodeError() {
return new AppError({ return buildBusinessValidationError("auth.code_invalid_or_expired", { code: "invalid_or_expired" });
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { code: "invalid_or_expired" },
});
} }
function passwordResetCodeError() { function passwordResetCodeError() {
return new AppError({ return buildBusinessValidationError("auth.code_invalid_or_expired", { code: "invalid_or_expired" });
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { code: "invalid_or_expired" },
});
} }
function isFutureDate(value, now) { function isFutureDate(value, now) {
@@ -579,22 +570,12 @@ async function authRoutes(fastify, options = {}) {
fastify.post("/login", { preHandler: authRateLimit }, async (request, reply) => { fastify.post("/login", { preHandler: authRateLimit }, async (request, reply) => {
const parsed = loginBodySchema.safeParse(request.body); const parsed = loginBodySchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildBusinessValidationError("auth.login_input_invalid", parsed.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint"); const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
if (!deviceFingerprint) { if (!deviceFingerprint) {
throw new AppError({ throw buildBusinessValidationError("auth.device_context_missing", { "x-device-fingerprint": "missing" });
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { "x-device-fingerprint": "missing" },
});
} }
const { identifier, password } = parsed.data; const { identifier, password } = parsed.data;
@@ -797,12 +778,7 @@ async function authRoutes(fastify, options = {}) {
fastify.post("/email-login/code", { preHandler: authRateLimit }, async (request, reply) => { fastify.post("/email-login/code", { preHandler: authRateLimit }, async (request, reply) => {
const parsed = emailLoginCodeRequestBodySchema.safeParse(request.body); const parsed = emailLoginCodeRequestBodySchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildBusinessValidationError("auth.email_input_invalid", parsed.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
assertEmailDeliveryReady(fastify.config, resolveEmailDeliveryStatus); assertEmailDeliveryReady(fastify.config, resolveEmailDeliveryStatus);
@@ -889,22 +865,12 @@ async function authRoutes(fastify, options = {}) {
fastify.post("/email-login/verify", { preHandler: authRateLimit }, async (request, reply) => { fastify.post("/email-login/verify", { preHandler: authRateLimit }, async (request, reply) => {
const parsed = emailLoginVerifyBodySchema.safeParse(request.body); const parsed = emailLoginVerifyBodySchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildBusinessValidationError("auth.email_code_input_invalid", parsed.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint"); const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
if (!deviceFingerprint) { if (!deviceFingerprint) {
throw new AppError({ throw buildBusinessValidationError("auth.device_context_missing", { "x-device-fingerprint": "missing" });
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { "x-device-fingerprint": "missing" },
});
} }
const { email, code } = parsed.data; const { email, code } = parsed.data;
@@ -1036,12 +1002,7 @@ async function authRoutes(fastify, options = {}) {
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint"); const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
if (!deviceFingerprint) { if (!deviceFingerprint) {
throw new AppError({ throw buildBusinessValidationError("auth.device_context_missing", { "x-device-fingerprint": "missing" });
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { "x-device-fingerprint": "missing" },
});
} }
const refreshTokenHash = sha256Hex(refreshToken); const refreshTokenHash = sha256Hex(refreshToken);
@@ -1243,12 +1204,7 @@ async function authRoutes(fastify, options = {}) {
const payload = verified.payload && typeof verified.payload === "object" ? verified.payload : {}; const payload = verified.payload && typeof verified.payload === "object" ? verified.payload : {};
const targetPath = typeof payload.targetPath === "string" ? payload.targetPath.trim() : ""; const targetPath = typeof payload.targetPath === "string" ? payload.targetPath.trim() : "";
if (!targetPath || !targetPath.startsWith("/") || targetPath.startsWith("//")) { if (!targetPath || !targetPath.startsWith("/") || targetPath.startsWith("//")) {
throw new AppError({ throw buildBusinessValidationError("auth.deeplink_target_invalid", { targetPath: "invalid_target_path" });
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { targetPath: "invalid_target_path" },
});
} }
const origin = String(fastify.config.appOrigin || "").split(",")[0].trim().replace(/\/+$/, ""); const origin = String(fastify.config.appOrigin || "").split(",")[0].trim().replace(/\/+$/, "");
@@ -1496,34 +1452,19 @@ async function authRoutes(fastify, options = {}) {
fastify.post("/change-password", { preHandler: fastify.authenticate }, async (request, reply) => { fastify.post("/change-password", { preHandler: fastify.authenticate }, async (request, reply) => {
const parsed = changePasswordBodySchema.safeParse(request.body); const parsed = changePasswordBodySchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildBusinessValidationError("auth.password_change_input_invalid", parsed.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint"); const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
if (!deviceFingerprint) { if (!deviceFingerprint) {
throw new AppError({ throw buildBusinessValidationError("auth.device_context_missing", { "x-device-fingerprint": "missing" });
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { "x-device-fingerprint": "missing" },
});
} }
const { oldPassword, newPassword } = parsed.data; const { oldPassword, newPassword } = parsed.data;
const policyErrors = validatePassword(newPassword); const policyErrors = validatePassword(newPassword);
if (policyErrors.length) { if (policyErrors.length) {
throw new AppError({ throw buildBusinessValidationError("auth.password_policy_failed", { password: policyErrors });
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { password: policyErrors },
});
} }
const employee = await fastify.prisma.employee.findUnique({ const employee = await fastify.prisma.employee.findUnique({
@@ -1655,12 +1596,7 @@ async function authRoutes(fastify, options = {}) {
fastify.post("/forgot-password", { preHandler: authRateLimit }, async (request, reply) => { fastify.post("/forgot-password", { preHandler: authRateLimit }, async (request, reply) => {
const parsed = forgotPasswordBodySchema.safeParse(request.body); const parsed = forgotPasswordBodySchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildBusinessValidationError("auth.email_input_invalid", parsed.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
assertEmailDeliveryReady(fastify.config, resolveEmailDeliveryStatus); assertEmailDeliveryReady(fastify.config, resolveEmailDeliveryStatus);
@@ -1796,24 +1732,14 @@ async function authRoutes(fastify, options = {}) {
fastify.post("/reset-password", { preHandler: authRateLimit }, async (request, reply) => { fastify.post("/reset-password", { preHandler: authRateLimit }, async (request, reply) => {
const parsed = resetPasswordBodySchema.safeParse(request.body); const parsed = resetPasswordBodySchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildBusinessValidationError("auth.password_reset_input_invalid", parsed.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
const { email, code, newPassword } = parsed.data; const { email, code, newPassword } = parsed.data;
const policyErrors = validatePassword(newPassword); const policyErrors = validatePassword(newPassword);
if (policyErrors.length) { if (policyErrors.length) {
throw new AppError({ throw buildBusinessValidationError("auth.password_policy_failed", { password: policyErrors });
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { password: policyErrors },
});
} }
const outcome = await fastify.prisma.$transaction(async (tx) => { const outcome = await fastify.prisma.$transaction(async (tx) => {
+48 -192
查看文件
@@ -201,7 +201,11 @@ function parseDateOnlyString(value) {
return undefined; return undefined;
} }
return new Date(`${trimmed}T00:00:00.000Z`); const parsed = new Date(`${trimmed}T00:00:00.000Z`);
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== trimmed) {
return undefined;
}
return parsed;
} }
function isPdfInvoiceUpload(fileName, mimeType) { function isPdfInvoiceUpload(fileName, mimeType) {
@@ -381,55 +385,37 @@ async function invoicePoolRoutes(fastify) {
for await (const part of request.parts()) { for await (const part of request.parts()) {
if (part.type === "file") { if (part.type === "file") {
if (fileBuffer) { if (fileBuffer) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.single_file_required", { file: "only_single_file_supported" }, 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "only_single_file_supported" },
});
} }
fileName = part.filename ?? "upload"; fileName = part.filename ?? "upload";
fileMimeType = part.mimetype; fileMimeType = part.mimetype;
if (!isPdfInvoiceUpload(fileName, fileMimeType)) { if (!isPdfInvoiceUpload(fileName, fileMimeType)) {
throw new AppError({ const error = buildBusinessValidationError("invoice_pool.pdf_required", { file: "pdf_invoice_required" });
code: "VALID_001", error.message = "请上传 PDF 发票文件";
message: "请上传 PDF 发票文件", throw error;
statusCode: 400,
details: { file: "pdf_invoice_required" },
});
} }
const chunks = []; const chunks = [];
for await (const chunk of part.file) { for await (const chunk of part.file) {
fileSize += chunk.length; fileSize += chunk.length;
if (fileSize > invoiceUploadMaxBytes) { if (fileSize > invoiceUploadMaxBytes) {
throw new AppError({ if (invoiceUploadMaxBytes === OCR_PDF_MAX_BYTES) {
code: "VALID_001", const error = buildBusinessValidationError("invoice_pool.ocr_pdf_too_large", {
message: file: "ocr_pdf_too_large", maxBytes: invoiceUploadMaxBytes,
invoiceUploadMaxBytes === OCR_PDF_MAX_BYTES });
? "PDF 发票不能超过 7 MiB" error.message = "PDF 发票不能超过 7 MiB";
: "参数校验失败", throw error;
statusCode: 400, }
details: { throw buildBusinessValidationError("invoice_pool.upload_too_large", {
file: file: "file_too_large", maxBytes: invoiceUploadMaxBytes,
invoiceUploadMaxBytes === OCR_PDF_MAX_BYTES
? "ocr_pdf_too_large"
: "file_too_large",
maxBytes: invoiceUploadMaxBytes,
},
}); });
} }
chunks.push(chunk); chunks.push(chunk);
} }
if (fileSize === 0) { if (fileSize === 0) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.empty_file", { file: "empty_file" }, 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "empty_file" },
});
} }
fileBuffer = Buffer.concat(chunks); fileBuffer = Buffer.concat(chunks);
@@ -440,23 +426,13 @@ async function invoicePoolRoutes(fastify) {
} }
} catch (error) { } catch (error) {
if (error?.code === "FST_REQ_FILE_TOO_LARGE") { if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.upload_too_large", { file: "file_too_large" }, 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "file_too_large" },
});
} }
throw error; throw error;
} }
if (!fileBuffer) { if (!fileBuffer) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.file_required", { file: "missing" }, 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "missing" },
});
} }
const ownerEmployeeId = const ownerEmployeeId =
@@ -503,23 +479,13 @@ async function invoicePoolRoutes(fastify) {
select: { id: true, status: true }, select: { id: true, status: true },
}); });
if (!ownerEmployee || ownerEmployee.status !== "active") { if (!ownerEmployee || ownerEmployee.status !== "active") {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.owner_unavailable", { owner_employee_id: "invalid_owner" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { owner_employee_id: "invalid_owner" },
});
} }
} }
const ocrType = fields.ocr_type ?? "general"; const ocrType = fields.ocr_type ?? "general";
if (!OCR_TYPE_ALLOWLIST.has(ocrType)) { if (!OCR_TYPE_ALLOWLIST.has(ocrType)) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.ocr_type_invalid", { ocr_type: "invalid" }, 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { ocr_type: "invalid" },
});
} }
const fileSha256 = sha256Hex(fileBuffer); const fileSha256 = sha256Hex(fileBuffer);
@@ -633,31 +599,16 @@ async function invoicePoolRoutes(fastify) {
async (request, reply) => { async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) { if (!parsedInvoiceId.success) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
} }
const parsedBody = invoiceEditBodySchema.safeParse(request.body ?? {}); const parsedBody = invoiceEditBodySchema.safeParse(request.body ?? {});
if (!parsedBody.success) { if (!parsedBody.success) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.edit_input_invalid", parsedBody.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsedBody.error.flatten(),
});
} }
if (Object.keys(parsedBody.data).length === 0) { if (Object.keys(parsedBody.data).length === 0) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.edit_empty", { body: "empty_patch" }, 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { body: "empty_patch" },
});
} }
const invoiceId = parsedInvoiceId.data; const invoiceId = parsedInvoiceId.data;
@@ -704,12 +655,7 @@ async function invoicePoolRoutes(fastify) {
} }
if (!["available", "ocr_failed"].includes(current.status)) { if (!["available", "ocr_failed"].includes(current.status)) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.not_editable", { status: "invalid_status_for_edit" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_status_for_edit" },
});
} }
const input = parsedBody.data; const input = parsedBody.data;
@@ -730,12 +676,7 @@ async function invoicePoolRoutes(fastify) {
if ("invoiceDate" in input) { if ("invoiceDate" in input) {
const parsedDate = parseDateOnlyString(input.invoiceDate); const parsedDate = parseDateOnlyString(input.invoiceDate);
if (parsedDate === undefined) { if (parsedDate === undefined) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.date_invalid", { invoiceDate: "invalid_date" }, 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { invoiceDate: "invalid_date" },
});
} }
updateData.invoice_date = parsedDate; updateData.invoice_date = parsedDate;
} }
@@ -751,12 +692,7 @@ async function invoicePoolRoutes(fastify) {
if ("amount" in input) { if ("amount" in input) {
const parsedAmount = normalizeMoneyInput(input.amount); const parsedAmount = normalizeMoneyInput(input.amount);
if (parsedAmount === undefined) { if (parsedAmount === undefined) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.amount_invalid", { amount: "invalid_money" }, 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { amount: "invalid_money" },
});
} }
updateData.amount = parsedAmount; updateData.amount = parsedAmount;
} }
@@ -764,12 +700,7 @@ async function invoicePoolRoutes(fastify) {
if ("taxAmount" in input) { if ("taxAmount" in input) {
const parsedAmount = normalizeMoneyInput(input.taxAmount); const parsedAmount = normalizeMoneyInput(input.taxAmount);
if (parsedAmount === undefined) { if (parsedAmount === undefined) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.tax_amount_invalid", { taxAmount: "invalid_money" }, 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { taxAmount: "invalid_money" },
});
} }
updateData.tax_amount = parsedAmount; updateData.tax_amount = parsedAmount;
} }
@@ -777,12 +708,7 @@ async function invoicePoolRoutes(fastify) {
if ("totalAmount" in input) { if ("totalAmount" in input) {
const parsedAmount = normalizeMoneyInput(input.totalAmount); const parsedAmount = normalizeMoneyInput(input.totalAmount);
if (parsedAmount === undefined) { if (parsedAmount === undefined) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.total_amount_invalid", { totalAmount: "invalid_money" }, 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { totalAmount: "invalid_money" },
});
} }
updateData.total_amount = parsedAmount; updateData.total_amount = parsedAmount;
} }
@@ -804,15 +730,10 @@ async function invoicePoolRoutes(fastify) {
}; };
const missingFields = getMissingRequiredOcrFields(current.ocr_type, nextSnapshot); const missingFields = getMissingRequiredOcrFields(current.ocr_type, nextSnapshot);
if (missingFields.length) { if (missingFields.length) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.ocr_fields_missing", {
code: "VALID_001", ocr: "missing_required_fields",
message: "参数校验失败", missingFields,
statusCode: 409, }, 409);
details: {
ocr: "missing_required_fields",
missingFields,
},
});
} }
updateData.status = "available"; updateData.status = "available";
@@ -821,22 +742,12 @@ async function invoicePoolRoutes(fastify) {
updateData.ocr_locked_at = null; updateData.ocr_locked_at = null;
updateData.ocr_lock_owner = null; updateData.ocr_lock_owner = null;
} else { } else {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.status_transition_invalid", { status: "invalid_status_transition" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_status_transition" },
});
} }
} }
if (Object.keys(updateData).length === 0) { if (Object.keys(updateData).length === 0) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.edit_empty", { body: "empty_patch" }, 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { body: "empty_patch" },
});
} }
updateData.updated_by = request.employee.id; updateData.updated_by = request.employee.id;
@@ -913,12 +824,7 @@ async function invoicePoolRoutes(fastify) {
async (request, reply) => { async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) { if (!parsedInvoiceId.success) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
} }
const invoiceId = parsedInvoiceId.data; const invoiceId = parsedInvoiceId.data;
@@ -973,12 +879,7 @@ async function invoicePoolRoutes(fastify) {
} }
if (current.linked_reimbursement_id || current.travel_order_id) { if (current.linked_reimbursement_id || current.travel_order_id) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.archive_bound", { invoice: "cannot_archive_bound_invoice" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { invoice: "cannot_archive_bound_invoice" },
});
} }
if (current.status === "archived" && current.archived_reason === "manual") { if (current.status === "archived" && current.archived_reason === "manual") {
@@ -989,12 +890,7 @@ async function invoicePoolRoutes(fastify) {
} }
if (!["available", "ocr_failed"].includes(current.status)) { if (!["available", "ocr_failed"].includes(current.status)) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.archive_status_invalid", { status: "invalid_status_for_archive" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_status_for_archive" },
});
} }
const now = new Date(); const now = new Date();
@@ -1142,12 +1038,7 @@ async function invoicePoolRoutes(fastify) {
async (request, reply) => { async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) { if (!parsedInvoiceId.success) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
} }
const invoiceId = parsedInvoiceId.data; const invoiceId = parsedInvoiceId.data;
@@ -1202,21 +1093,11 @@ async function invoicePoolRoutes(fastify) {
} }
if (current.status !== "ocr_failed") { if (current.status !== "ocr_failed") {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.ocr_retry_status_invalid", { status: "invalid_status_for_retry" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_status_for_retry" },
});
} }
if (!isOcrEnabledForType(fastify.config, current.ocr_type)) { if (!isOcrEnabledForType(fastify.config, current.ocr_type)) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.ocr_unavailable", { ocr: "disabled" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { ocr: "disabled" },
});
} }
const maxAttempts = Number(fastify.config.ocrMaxAttempts ?? 3); const maxAttempts = Number(fastify.config.ocrMaxAttempts ?? 3);
@@ -1289,22 +1170,12 @@ async function invoicePoolRoutes(fastify) {
async (request, reply) => { async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) { if (!parsedInvoiceId.success) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
} }
const parsedBody = overrideDuplicateBodySchema.safeParse(request.body ?? {}); const parsedBody = overrideDuplicateBodySchema.safeParse(request.body ?? {});
if (!parsedBody.success) { if (!parsedBody.success) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.override_input_invalid", parsedBody.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsedBody.error.flatten(),
});
} }
const invoiceId = parsedInvoiceId.data; const invoiceId = parsedInvoiceId.data;
@@ -1348,30 +1219,15 @@ async function invoicePoolRoutes(fastify) {
} }
if (current.linked_reimbursement_id || current.travel_order_id) { if (current.linked_reimbursement_id || current.travel_order_id) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.override_bound", { invoice: "already_bound" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { invoice: "already_bound" },
});
} }
if (!["available", "ocr_failed"].includes(current.status)) { if (!["available", "ocr_failed"].includes(current.status)) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.override_status_invalid", { status: "invalid_status_for_override" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_status_for_override" },
});
} }
if (!current.file_sha256 && !(current.invoice_code && current.invoice_number)) { if (!current.file_sha256 && !(current.invoice_code && current.invoice_number)) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.dedup_identity_missing", { invoice: "missing_dedup_key" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { invoice: "missing_dedup_key" },
});
} }
const dedupFilters = []; const dedupFilters = [];
+8 -42
查看文件
@@ -1,4 +1,5 @@
const { z } = require("zod"); const { z } = require("zod");
const { buildBusinessValidationError } = require("../../utils/businessValidationError");
const { AppError } = require("../../utils/AppError"); const { AppError } = require("../../utils/AppError");
const { sendData } = require("../../utils/reply"); const { sendData } = require("../../utils/reply");
@@ -36,12 +37,7 @@ const invoiceStatusQuerySchema = z.object({
function parseInvoiceListQuery(query) { function parseInvoiceListQuery(query) {
const parsed = invoiceListQuerySchema.safeParse(query ?? {}); const parsed = invoiceListQuerySchema.safeParse(query ?? {});
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.list_query_invalid", parsed.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
return { return {
@@ -55,12 +51,7 @@ function parseInvoiceListQuery(query) {
function parseBindableInvoiceQuery(query) { function parseBindableInvoiceQuery(query) {
const parsed = bindableInvoiceQuerySchema.safeParse(query ?? {}); const parsed = bindableInvoiceQuerySchema.safeParse(query ?? {});
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.selection_query_invalid", parsed.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
return { return {
@@ -73,12 +64,7 @@ function parseBindableInvoiceQuery(query) {
function parseInvoiceStatusQuery(query) { function parseInvoiceStatusQuery(query) {
const parsed = invoiceStatusQuerySchema.safeParse(query ?? {}); const parsed = invoiceStatusQuerySchema.safeParse(query ?? {});
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.status_query_invalid", parsed.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
const ids = Array.from( const ids = Array.from(
@@ -91,12 +77,7 @@ function parseInvoiceStatusQuery(query) {
); );
if (!ids.length || ids.length > 50 || ids.some((id) => !z.string().uuid().safeParse(id).success)) { if (!ids.length || ids.length > 50 || ids.some((id) => !z.string().uuid().safeParse(id).success)) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.status_query_invalid", { ids: "invalid_uuid_list" });
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { ids: "invalid_uuid_list" },
});
} }
return { ids }; return { ids };
@@ -291,12 +272,7 @@ function registerInvoicePoolReadRoutes(fastify) {
} }
if (!["draft", "rejected"].includes(reimbursement.status)) { if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.reimbursement_not_editable", { reimbursement: "invalid_status_for_bind" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_bind" },
});
} }
const where = { const where = {
@@ -431,12 +407,7 @@ function registerInvoicePoolReadRoutes(fastify) {
async (request, reply) => { async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) { if (!parsedInvoiceId.success) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" });
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
} }
const invoice = await fastify.prisma.invoicePool.findUnique({ const invoice = await fastify.prisma.invoicePool.findUnique({
@@ -477,12 +448,7 @@ function registerInvoicePoolReadRoutes(fastify) {
async (request, reply) => { async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) { if (!parsedInvoiceId.success) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" });
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
} }
const invoice = await fastify.prisma.invoicePool.findUnique({ const invoice = await fastify.prisma.invoicePool.findUnique({
@@ -1,6 +1,6 @@
const { z } = require("zod"); const { z } = require("zod");
const { AppError } = require("../../../utils/AppError"); const { buildBusinessValidationError } = require("../../../utils/businessValidationError");
const verificationListQuerySchema = z.object({ const verificationListQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(50).optional(), limit: z.coerce.number().int().min(1).max(50).optional(),
@@ -9,12 +9,7 @@ const verificationListQuerySchema = z.object({
function parseVerificationListQuery(query) { function parseVerificationListQuery(query) {
const parsed = verificationListQuerySchema.safeParse(query ?? {}); const parsed = verificationListQuerySchema.safeParse(query ?? {});
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildBusinessValidationError("invoice_verification.list_query_invalid", parsed.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
return { return {
+7 -42
查看文件
@@ -33,12 +33,7 @@ const RETRY_RESPONSE_SCHEMA = {
}; };
function throwVerificationDisabled(reason) { function throwVerificationDisabled(reason) {
throw new AppError({ throw buildBusinessValidationError("invoice_verification.disabled", { verification: "disabled", featureFlag: reason }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "disabled", featureFlag: reason },
});
} }
async function resolveVerificationDepartmentContext(tx, request, invoiceId) { async function resolveVerificationDepartmentContext(tx, request, invoiceId) {
@@ -141,12 +136,7 @@ function registerInvoiceVerificationRoutes(fastify) {
async (request, reply) => { async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) { if (!parsedInvoiceId.success) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
} }
const invoiceId = parsedInvoiceId.data; const invoiceId = parsedInvoiceId.data;
@@ -187,12 +177,7 @@ function registerInvoiceVerificationRoutes(fastify) {
}); });
if (latest?.status === "pending") { if (latest?.status === "pending") {
throw new AppError({ throw buildBusinessValidationError("invoice_verification.already_pending", { verification: "already_pending" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "already_pending" },
});
} }
if (latest?.requested_at) { if (latest?.requested_at) {
@@ -284,12 +269,7 @@ function registerInvoiceVerificationRoutes(fastify) {
async (request, reply) => { async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) { if (!parsedInvoiceId.success) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
} }
const query = parseVerificationListQuery(request.query); const query = parseVerificationListQuery(request.query);
@@ -391,12 +371,7 @@ function registerInvoiceVerificationRoutes(fastify) {
async (request, reply) => { async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) { if (!parsedInvoiceId.success) {
throw new AppError({ throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
} }
const invoiceId = parsedInvoiceId.data; const invoiceId = parsedInvoiceId.data;
@@ -422,21 +397,11 @@ function registerInvoiceVerificationRoutes(fastify) {
}); });
if (!latest) { if (!latest) {
throw new AppError({ throw buildBusinessValidationError("invoice_verification.record_missing", { verification: "not_found" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "not_found" },
});
} }
if (latest.status === "pending") { if (latest.status === "pending") {
throw new AppError({ throw buildBusinessValidationError("invoice_verification.already_pending", { verification: "already_pending" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "already_pending" },
});
} }
const cooldownMs = const cooldownMs =
+2 -10
查看文件
@@ -1,4 +1,5 @@
const { z } = require("zod"); const { z } = require("zod");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { AppError } = require("../utils/AppError"); const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit"); const { buildAuditLogData } = require("../utils/audit");
@@ -11,19 +12,10 @@ const NOTIFICATION_PREFERENCES_SCHEMA = z
}) })
.strict(); .strict();
function buildValidationError(details) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details,
});
}
function parseNotificationPreferences(body) { function parseNotificationPreferences(body) {
const parsed = NOTIFICATION_PREFERENCES_SCHEMA.safeParse(body ?? {}); const parsed = NOTIFICATION_PREFERENCES_SCHEMA.safeParse(body ?? {});
if (!parsed.success) { if (!parsed.success) {
throw buildValidationError(parsed.error.flatten()); throw buildBusinessValidationError("profile.notification_preferences_invalid", parsed.error.flatten());
} }
return parsed.data; return parsed.data;
+13 -11
查看文件
@@ -1,4 +1,5 @@
const { z } = require("zod"); const { z } = require("zod");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { decryptStringOrThrow, encryptStringOrThrow, maskBankAccount } = require("../services/crypto"); const { decryptStringOrThrow, encryptStringOrThrow, maskBankAccount } = require("../services/crypto");
const { createNotifications } = require("../services/notifications"); const { createNotifications } = require("../services/notifications");
@@ -22,14 +23,15 @@ const BANK_BODY_SCHEMA = z.object({
}).strict(); }).strict();
const BANK_RENAME_BODY_SCHEMA = z.object({ name: z.string().trim().min(1).max(100) }).strict(); const BANK_RENAME_BODY_SCHEMA = z.object({ name: z.string().trim().min(1).max(100) }).strict();
function validationError(details, statusCode = 400) {
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
}
function parse(schema, value) { function parse(schema, value) {
const result = schema.safeParse(value ?? {}); const result = schema.safeParse(value ?? {});
if (!result.success) { if (!result.success) {
throw validationError(result.error.flatten()); const details = result.error.flatten();
if (schema === REVEAL_BODY_SCHEMA) throw buildBusinessValidationError("payment_account.reveal_reason_required", details);
if (schema === BANK_BODY_SCHEMA || schema === BANK_RENAME_BODY_SCHEMA) {
throw buildBusinessValidationError("payment_account.bank_input_invalid", details);
}
throw buildBusinessValidationError("payment_account.account_input_invalid", details);
} }
return result.data; return result.data;
} }
@@ -37,7 +39,7 @@ function parse(schema, value) {
function parseId(params) { function parseId(params) {
const result = UUID_SCHEMA.safeParse(params?.id); const result = UUID_SCHEMA.safeParse(params?.id);
if (!result.success) { if (!result.success) {
throw validationError({ id: "invalid_uuid" }); throw buildBusinessValidationError("payment_account.id_invalid", { id: "invalid_uuid" });
} }
return result.data; return result.data;
} }
@@ -96,8 +98,8 @@ async function ensureReferences(tx, body) {
select: { id: true, code: true, name: true }, select: { id: true, code: true, name: true },
}), }),
]); ]);
if (!legalEntity) throw validationError({ legalEntityId: "not_available" }, 409); if (!legalEntity) throw buildBusinessValidationError("payment_account.legal_entity_unavailable", { legalEntityId: "not_available" }, 409);
if (!bankInstitution) throw validationError({ bankInstitutionId: "not_available" }, 409); if (!bankInstitution) throw buildBusinessValidationError("payment_account.bank_unavailable", { bankInstitutionId: "not_available" }, 409);
return { legalEntity, bankInstitution }; return { legalEntity, bankInstitution };
} }
@@ -177,7 +179,7 @@ async function paymentAccountsRoutes(fastify) {
}); });
} catch (error) { } catch (error) {
if (error?.code === "P2002") { if (error?.code === "P2002") {
throw validationError({ code: "already_exists" }, 409); throw buildBusinessValidationError("payment_account.bank_code_exists", { code: "already_exists" }, 409);
} }
throw error; throw error;
} }
@@ -231,7 +233,7 @@ async function paymentAccountsRoutes(fastify) {
const inUse = await tx.paymentAccount.count({ const inUse = await tx.paymentAccount.count({
where: { bank_institution_id: bankId, enabled: true, archived_at: null }, where: { bank_institution_id: bankId, enabled: true, archived_at: null },
}); });
if (inUse > 0) throw validationError({ bankInstitution: "active_payment_account_exists" }, 409); if (inUse > 0) throw buildBusinessValidationError("payment_account.bank_in_use", { bankInstitution: "active_payment_account_exists" }, 409);
const now = new Date(); const now = new Date();
await tx.bankInstitution.update({ await tx.bankInstitution.update({
where: { id: bankId }, where: { id: bankId },
@@ -319,7 +321,7 @@ async function paymentAccountsRoutes(fastify) {
}); });
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
if (current.legal_entity_id !== body.legalEntityId) { if (current.legal_entity_id !== body.legalEntityId) {
throw validationError({ legalEntityId: "must_match_current_account" }); throw buildBusinessValidationError("payment_account.legal_entity_change_forbidden", { legalEntityId: "must_match_current_account" });
} }
await ensureReferences(tx, body); await ensureReferences(tx, body);
const now = new Date(); const now = new Date();
+26 -124
查看文件
@@ -180,12 +180,7 @@ const APPROVER_ROLE_CODES = BUSINESS_APPROVAL_ROLE_CODES.filter((roleCode) =>
function parseRequiredComment(body) { function parseRequiredComment(body) {
const parsed = REQUIRED_COMMENT_SCHEMA.safeParse(body ?? {}); const parsed = REQUIRED_COMMENT_SCHEMA.safeParse(body ?? {});
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildBusinessValidationError("workflow.comment_invalid", parsed.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
return parsed.data.comment; return parsed.data.comment;
@@ -194,12 +189,7 @@ function parseRequiredComment(body) {
function parseTransferBody(body) { function parseTransferBody(body) {
const parsed = TRANSFER_BODY_SCHEMA.safeParse(body ?? {}); const parsed = TRANSFER_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildBusinessValidationError("workflow.transfer_input_invalid", parsed.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
const reason = parsed.data.reason.trim(); const reason = parsed.data.reason.trim();
@@ -221,12 +211,7 @@ function parseTransferBody(body) {
function parseTaskId(taskId) { function parseTaskId(taskId) {
const parsed = TASK_ID_SCHEMA.safeParse(taskId); const parsed = TASK_ID_SCHEMA.safeParse(taskId);
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildBusinessValidationError("workflow.task_id_invalid", { taskId: "invalid_uuid" });
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { taskId: "invalid_uuid" },
});
} }
return parsed.data; return parsed.data;
@@ -235,12 +220,7 @@ function parseTaskId(taskId) {
function parseBatchApproveBody(body) { function parseBatchApproveBody(body) {
const parsed = BATCH_APPROVE_BODY_SCHEMA.safeParse(body ?? {}); const parsed = BATCH_APPROVE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildBusinessValidationError("workflow.batch_approve_input_invalid", parsed.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
return { return {
@@ -252,12 +232,7 @@ function parseBatchApproveBody(body) {
function parseBatchRejectBody(body) { function parseBatchRejectBody(body) {
const parsed = BATCH_REJECT_BODY_SCHEMA.safeParse(body ?? {}); const parsed = BATCH_REJECT_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildBusinessValidationError("workflow.batch_reject_input_invalid", parsed.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
return { return {
@@ -269,12 +244,7 @@ function parseBatchRejectBody(body) {
function parseRemindTaskBody(body) { function parseRemindTaskBody(body) {
const parsed = REMIND_TASK_BODY_SCHEMA.safeParse(body ?? {}); const parsed = REMIND_TASK_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildBusinessValidationError("workflow.remind_input_invalid", parsed.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
return { return {
@@ -285,12 +255,7 @@ function parseRemindTaskBody(body) {
function parseTaskSummaryQuery(query) { function parseTaskSummaryQuery(query) {
const parsed = TASK_SUMMARY_QUERY_SCHEMA.safeParse(query ?? {}); const parsed = TASK_SUMMARY_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildBusinessValidationError("workflow.summary_query_invalid", parsed.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
return { return {
@@ -384,12 +349,7 @@ async function handlePurchaseApprovalErpSync({
function parseTaskListQuery(query) { function parseTaskListQuery(query) {
const parsed = TASK_LIST_QUERY_SCHEMA.safeParse(query ?? {}); const parsed = TASK_LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildBusinessValidationError("workflow.task_list_query_invalid", parsed.error.flatten(), 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
return { return {
@@ -403,12 +363,7 @@ function parseTaskListQuery(query) {
function parseApproverListQuery(query) { function parseApproverListQuery(query) {
const parsed = APPROVER_LIST_QUERY_SCHEMA.safeParse(query ?? {}); const parsed = APPROVER_LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildBusinessValidationError("workflow.approver_query_invalid", parsed.error.flatten(), 400);
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
return { return {
@@ -734,12 +689,7 @@ async function lockWorkflowTask(tx, taskId) {
function ensurePendingTask(task) { function ensurePendingTask(task) {
if (task.status !== "pending") { if (task.status !== "pending") {
throw new AppError({ throw buildBusinessValidationError("workflow.task_already_processed", { task: "already_processed" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { task: "already_processed" },
});
} }
} }
@@ -1049,12 +999,7 @@ async function loadWorkflowApplicantIds(tx, workflowInstance) {
); );
if (!record) { if (!record) {
throw new AppError({ throw buildBusinessValidationError("workflow.entity_unavailable", { workflow: "entity_not_processable" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { workflow: "entity_not_processable" },
});
} }
return collectWorkflowApplicantIds(workflowInstance, record); return collectWorkflowApplicantIds(workflowInstance, record);
@@ -1065,12 +1010,7 @@ function ensureNoSelfApproval(actorIds, applicantIds) {
return; return;
} }
throw new AppError({ throw buildBusinessValidationError("workflow.self_approval_forbidden", { workflow: "self_approval_not_allowed" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { workflow: "self_approval_not_allowed" },
});
} }
async function ensureFinanceOwnerCeoActors(tx, workflowInstance, actorIds) { async function ensureFinanceOwnerCeoActors(tx, workflowInstance, actorIds) {
@@ -1364,22 +1304,12 @@ async function ensureTransferTargetEligible(tx, toApproverId, approvePermission)
}); });
if (!target || target.status !== "active") { if (!target || target.status !== "active") {
throw new AppError({ throw buildBusinessValidationError("workflow.transfer_target_unavailable", { toApproverId: "invalid_target" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { toApproverId: "invalid_target" },
});
} }
const roleCodes = target.roles.map((item) => item.role_code); const roleCodes = target.roles.map((item) => item.role_code);
if (!roleCodes.some((roleCode) => roleHasBusinessApprovePermission(roleCode, approvePermission))) { if (!roleCodes.some((roleCode) => roleHasBusinessApprovePermission(roleCode, approvePermission))) {
throw new AppError({ throw buildBusinessValidationError("workflow.transfer_target_ineligible", { toApproverId: "target_not_approver" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { toApproverId: "target_not_approver" },
});
} }
} }
@@ -1408,16 +1338,11 @@ function ensureWorkflowEntityType(workflowInstance, entityType) {
return; return;
} }
throw new AppError({ throw buildBusinessValidationError("workflow.batch_entity_unsupported", {
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
task: "unsupported_entity_type", task: "unsupported_entity_type",
entityType: workflowInstance.entity_type, entityType: workflowInstance.entity_type,
expectedEntityType: entityType, expectedEntityType: entityType,
}, }, 409);
});
} }
async function ensureWorkflowReminderAllowed(tx, workflowInstance, task, request) { async function ensureWorkflowReminderAllowed(tx, workflowInstance, task, request) {
@@ -1436,12 +1361,7 @@ async function ensureWorkflowReminderAllowed(tx, workflowInstance, task, request
assigneeRole: task.assignee_role, assigneeRole: task.assignee_role,
}); });
if (recipientEmployeeIds.includes(request.employee.id)) { if (recipientEmployeeIds.includes(request.employee.id)) {
throw new AppError({ throw buildBusinessValidationError("workflow.self_remind_forbidden", { workflow: "self_remind_not_allowed" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { workflow: "self_remind_not_allowed" },
});
} }
return applicantIds; return applicantIds;
@@ -1468,15 +1388,12 @@ async function ensureWorkflowReminderCooldown(tx, taskId, actorId, now) {
} }
const nextAllowedAt = new Date(existing.created_at.getTime() + WORKFLOW_REMINDER_COOLDOWN_MS); const nextAllowedAt = new Date(existing.created_at.getTime() + WORKFLOW_REMINDER_COOLDOWN_MS);
throw new AppError({ const error = buildBusinessValidationError("workflow.remind_cooldown", {
code: "VALID_001", workflow: "remind_too_frequent",
message: "催办过于频繁", nextAllowedAt: nextAllowedAt.toISOString(),
statusCode: 409, }, 409);
details: { error.message = "催办过于频繁";
workflow: "remind_too_frequent", throw error;
nextAllowedAt: nextAllowedAt.toISOString(),
},
});
} }
async function executeRemindWorkflowTask({ async function executeRemindWorkflowTask({
@@ -1586,12 +1503,7 @@ async function executeApproveWorkflowTask({
const steps = getWorkflowStepsSnapshot(workflowInstance); const steps = getWorkflowStepsSnapshot(workflowInstance);
const currentStepIndex = steps.findIndex((step) => step.id === workflowInstance.current_step_id); const currentStepIndex = steps.findIndex((step) => step.id === workflowInstance.current_step_id);
if (currentStepIndex < 0) { if (currentStepIndex < 0) {
throw new AppError({ throw buildBusinessValidationError("workflow.step_missing", { workflow: "current_step_not_found" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { workflow: "current_step_not_found" },
});
} }
const now = new Date(); const now = new Date();
@@ -3215,12 +3127,7 @@ async function workflowRoutes(fastify) {
"approve", "approve",
); );
if (!approvePermission) { if (!approvePermission) {
throw new AppError({ throw buildBusinessValidationError("workflow.transfer_unsupported", { transfer: "unsupported_entity_type" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { transfer: "unsupported_entity_type" },
});
} }
const applicantIds = await ensureWorkflowApprovalAllowed(tx, workflowInstance, request, assignment); const applicantIds = await ensureWorkflowApprovalAllowed(tx, workflowInstance, request, assignment);
const transferCount = await ensureTransferChainNotExceeded(tx, taskId); const transferCount = await ensureTransferChainNotExceeded(tx, taskId);
@@ -3229,12 +3136,7 @@ async function workflowRoutes(fastify) {
await ensureFinanceOwnerCeoActors(tx, workflowInstance, [toApproverId]); await ensureFinanceOwnerCeoActors(tx, workflowInstance, [toApproverId]);
if (toApproverId === request.employee.id) { if (toApproverId === request.employee.id) {
throw new AppError({ throw buildBusinessValidationError("workflow.transfer_same_assignee", { transfer: "same_assignee_not_allowed" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { transfer: "same_assignee_not_allowed" },
});
} }
await tx.workflowTask.update({ await tx.workflowTask.update({
@@ -1,4 +1,77 @@
{ {
"workflow.task_already_processed": "此审批任务已处理,请刷新待办查看最新进度。",
"workflow.entity_unavailable": "审批对应的单据不存在或当前不可处理,请联系管理员核查。",
"workflow.self_approval_forbidden": "不能审批本人申请或代提交的单据,请由符合资格的其他审批人处理。",
"workflow.transfer_target_unavailable": "转审人不存在或已停用,请重新选择在职审批人。",
"workflow.transfer_target_ineligible": "所选人员没有这类业务的审批资格,请重新选择审批人。",
"workflow.self_remind_forbidden": "当前待办由你处理,无需催办自己,请直接处理待办。",
"workflow.step_missing": "审批流程节点信息异常,请联系管理员核查,不要重复提交。",
"workflow.transfer_unsupported": "此类业务暂不支持转审,请按原审批流程处理。",
"workflow.transfer_same_assignee": "不能转审给自己,请选择其他符合资格的审批人。",
"workflow.task_list_query_invalid": "待办筛选或分页条件无效,请重新选择筛选条件后重试。",
"workflow.approver_query_invalid": "审批人查询条件无效,请缩短关键词并检查分页条件后重试。",
"workflow.batch_entity_unsupported": "批量审批仅支持当前指定业务类型,请取消其他类型任务的选择。",
"workflow.remind_cooldown": "此任务24小时内已由你催办,请勿重复催办,稍后再试。",
"invoice_pool.pdf_required": "请上传 PDF 发票文件",
"invoice_pool.ocr_pdf_too_large": "PDF 发票不能超过 7 MiB",
"invoice_verification.list_query_invalid": "查验记录查询数量无效,请选择1至50条后重试。",
"invoice_pool.single_file_required": "请每次上传一份PDF发票,可分次添加多张发票。",
"invoice_pool.empty_file": "发票文件为空,请重新选择有效的PDF文件。",
"invoice_pool.upload_too_large": "发票文件超过当前上传大小限制,请压缩PDF后重试;仍失败请联系管理员。",
"invoice_pool.file_required": "请先选择要上传的PDF发票。",
"invoice_pool.owner_unavailable": "发票所属员工不存在或已停用,请重新选择在职员工。",
"invoice_pool.ocr_type_invalid": "发票识别类型无效,请重新选择票据类型后上传。",
"invoice_pool.edit_empty": "没有可保存的修改,请修改发票信息后再保存。",
"invoice_pool.not_editable": "此发票当前不能编辑,请刷新查看状态;已关联的发票需先按报销流程解除占用。",
"invoice_pool.date_invalid": "开票日期无效,请填写真实有效的日期。",
"invoice_pool.amount_invalid": "发票金额格式无效,请填写有效金额后重试。",
"invoice_pool.tax_amount_invalid": "发票税额格式无效,请填写有效税额后重试。",
"invoice_pool.total_amount_invalid": "价税合计格式无效,请填写有效金额后重试。",
"invoice_pool.status_transition_invalid": "此发票不能按当前方式更改状态,请刷新页面后重试。",
"invoice_pool.archive_bound": "发票已关联业务单据,不能归档;请先完成业务处理或解除关联。",
"invoice_pool.archive_status_invalid": "此发票当前不能归档,请等待识别完成或刷新查看状态。",
"invoice_pool.ocr_retry_status_invalid": "只有识别失败的发票可以重新识别,请刷新查看当前结果。",
"invoice_pool.ocr_unavailable": "发票OCR识别服务当前不可用,请联系管理员恢复服务后重试。",
"invoice_pool.override_bound": "发票已关联业务单据,不能设置重复票据例外;请先处理原关联。",
"invoice_pool.override_status_invalid": "此发票当前不能设置重复票据例外,请刷新查看状态。",
"invoice_pool.dedup_identity_missing": "发票缺少查重标识,请补全发票代码和号码;仍无法处理请联系管理员。",
"invoice_pool.edit_input_invalid": "发票信息格式无效,请检查日期、金额及必填字段后保存。",
"invoice_pool.override_input_invalid": "请填写有效的关联报销编号和重复票据例外原因。",
"invoice_pool.ocr_fields_missing": "发票识别信息不完整,请补全票种要求的代码、号码、日期、金额及销售方等信息后保存。",
"workflow.comment_invalid": "请填写处理意见,长度为1至500字。",
"workflow.transfer_input_invalid": "请选择有效的转审人,并填写10至500字的转审原因。",
"workflow.batch_approve_input_invalid": "请选择1至50条有效审批任务,审批意见不超过500字。",
"workflow.batch_reject_input_invalid": "请选择1至50条有效审批任务,并填写1至500字的退回意见。",
"workflow.remind_input_invalid": "催办说明不能超过200字,请修改后重试。",
"workflow.summary_query_invalid": "待办统计范围无效,请从待办页面重新进入。",
"workflow.task_id_invalid": "审批任务标识无效,请从待办列表重新打开。",
"invoice_verification.disabled": "发票查验服务暂未启用,请联系管理员确认;发票OCR识别是独立服务。",
"invoice_verification.already_pending": "发票正在查验,请稍后刷新结果,不要重复提交。",
"invoice_verification.record_missing": "此发票还没有查验记录,请先发起查验,再按需重试。",
"auth.login_input_invalid": "请填写登录账号和密码后重试。",
"auth.email_input_invalid": "请输入格式正确的邮箱地址后重试。",
"auth.email_code_input_invalid": "请检查邮箱地址和验证码的填写格式后重试。",
"auth.password_change_input_invalid": "请完整填写原密码和新密码后重试。",
"auth.password_reset_input_invalid": "请完整填写邮箱、验证码和新密码后重试。",
"auth.device_context_missing": "登录设备信息缺失,请刷新登录页面后重试;仍无法登录请联系管理员。",
"auth.password_policy_failed": "新密码需至少12位,包含大小写字母和数字,并避免常见弱密码。",
"auth.deeplink_target_invalid": "此入口的目标地址无效,请从系统首页重新进入对应业务。",
"auth.code_invalid_or_expired": "验证码无效或已过期,请重新获取后重试。",
"profile.notification_preferences_invalid": "通知设置无效,请通过页面开关选择是否接收通知后重试。",
"invoice_pool.list_query_invalid": "票据筛选或分页参数无效,请重置筛选后重试。",
"invoice_pool.selection_query_invalid": "选择发票的报销单或查询条件无效,请返回报销单重新打开“我的发票”。",
"invoice_pool.status_query_invalid": "待查询票据列表无效,请刷新后重试;一次最多查询50张票据。",
"invoice_pool.reimbursement_not_editable": "该报销单当前不可添加发票,请刷新确认状态;仅草稿或退回待修改的单据可继续选票。",
"invoice_pool.id_invalid": "票据标识无效,请返回“我的发票”重新选择。",
"payment_account.reveal_reason_required": "请填写2至500字的查看理由后再查看完整付款账号。",
"payment_account.bank_input_invalid": "请检查银行名称;新增银行还需填写2至16位字母、数字或下划线组成的银行编码。",
"payment_account.account_input_invalid": "请检查所属公司、开户银行、户名及账号;账号需为6至32位数字。",
"payment_account.id_invalid": "账户或银行标识无效,请返回列表重新选择。",
"payment_account.legal_entity_unavailable": "所选公司不存在或已停用,请刷新后选择可用公司;如无可选公司,请联系管理员。",
"payment_account.bank_unavailable": "所选银行不存在或已停用,请刷新后选择真实开户银行,不要为通过检查改填其他银行。",
"payment_account.bank_code_exists": "该银行编码已存在,请使用已有银行或核实编码后重试。",
"payment_account.bank_in_use": "该银行仍有启用的付款账户,暂不能停用;请先核实相关账户的使用安排。",
"payment_account.legal_entity_change_forbidden": "已有付款账户不能直接更换所属公司,请保留原公司,并为其他公司另行维护账户。",
"reimbursement.finance_owner_ceo_unavailable": "财务本人报销需要非申请人的总经理审批,请联系管理员核实审批配置。", "reimbursement.finance_owner_ceo_unavailable": "财务本人报销需要非申请人的总经理审批,请联系管理员核实审批配置。",
"reimbursement.finance_owner_ceo_required": "财务本人报销只能由非申请人的在职总经理审批或接收转审,不能委托其他角色代批。", "reimbursement.finance_owner_ceo_required": "财务本人报销只能由非申请人的在职总经理审批或接收转审,不能委托其他角色代批。",
"reimbursement.invoice_prefill_incomplete": "票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。", "reimbursement.invoice_prefill_incomplete": "票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。",
+41 -6
查看文件
@@ -214,6 +214,41 @@ async function buildApp(passwordHash) {
} }
describe("auth login route", () => { describe("auth login route", () => {
it.each([
["/login", {}, "login_input_invalid"],
["/email-login/code", { email: "invalid" }, "email_input_invalid"],
["/email-login/verify", {}, "email_code_input_invalid"],
["/change-password", {}, "password_change_input_invalid"],
["/forgot-password", { email: "invalid" }, "email_input_invalid"],
["/reset-password", {}, "password_reset_input_invalid"],
["/change-password", { oldPassword: "SyntheticOld1234", newPassword: "aaaaaaaaaaaa" }, "password_policy_failed"],
])("explains invalid input at %s without authentication side effects", async (url, payload, reason) => {
const state = await buildApp("unused");
try {
const response = await state.app.inject({ method: "POST", url: `/api/auth${url}`, payload,
headers: { "x-device-fingerprint": "synthetic-input-validation" } });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.reasonCode).toBe(`auth.${reason}`);
expect(state.employeeUpdate).not.toHaveBeenCalled();
expect(state.authSessionCreate).not.toHaveBeenCalled();
expect(state.emailLoginCodeCreate).not.toHaveBeenCalled();
expect(state.passwordResetCodeCreate).not.toHaveBeenCalled();
expect(state.auditCreate).not.toHaveBeenCalled();
expect(JSON.stringify(response.json())).not.toContain("SyntheticOld1234");
} finally { await state.app.close(); }
});
it("explains missing device context before looking up an account", async () => {
const state = await buildApp("unused");
try {
const response = await state.app.inject({ method: "POST", url: "/api/auth/login",
payload: { identifier: "[email protected]", password: "SyntheticPassword1234" } });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.reasonCode).toBe("auth.device_context_missing");
expect(state.employeeFindFirst).not.toHaveBeenCalled();
expect(state.authSessionCreate).not.toHaveBeenCalled();
} finally { await state.app.close(); }
});
it("keeps signed deeplink usable after legacy exp and redirects normal browsers to target", async () => { it("keeps signed deeplink usable after legacy exp and redirects normal browsers to target", async () => {
const passwordHash = await hashPassword("AdminPassword1234"); const passwordHash = await hashPassword("AdminPassword1234");
const { app } = await buildApp(passwordHash); const { app } = await buildApp(passwordHash);
@@ -268,7 +303,7 @@ describe("auth login route", () => {
}); });
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ targetPath: "invalid_target_path" }); expect(response.json().error.details).toEqual({ targetPath: "invalid_target_path", reasonCode: "auth.deeplink_target_invalid" });
await app.close(); await app.close();
}); });
@@ -293,7 +328,7 @@ describe("auth login route", () => {
}); });
expect(absoluteUrlResponse.statusCode).toBe(400); expect(absoluteUrlResponse.statusCode).toBe(400);
expect(absoluteUrlResponse.json().error.details).toEqual({ targetPath: "invalid_target_path" }); expect(absoluteUrlResponse.json().error.details).toEqual({ targetPath: "invalid_target_path", reasonCode: "auth.deeplink_target_invalid" });
await app.close(); await app.close();
}); });
@@ -566,7 +601,7 @@ describe("auth login route", () => {
}); });
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" }); expect(response.json().error.details).toEqual({ code: "invalid_or_expired", reasonCode: "auth.code_invalid_or_expired" });
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({ expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
where: { id: "email-code-1", used_at: null }, where: { id: "email-code-1", used_at: null },
data: { attempts_count: { increment: 1 } }, data: { attempts_count: { increment: 1 } },
@@ -870,7 +905,7 @@ describe("auth login route", () => {
}); });
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" }); expect(response.json().error.details).toEqual({ code: "invalid_or_expired", reasonCode: "auth.code_invalid_or_expired" });
expect(employeeUpdate).not.toHaveBeenCalled(); expect(employeeUpdate).not.toHaveBeenCalled();
expect(emailLoginCodeUpdateMany).not.toHaveBeenCalled(); expect(emailLoginCodeUpdateMany).not.toHaveBeenCalled();
expect(auditCreate).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled();
@@ -966,7 +1001,7 @@ describe("auth login route", () => {
}); });
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" }); expect(response.json().error.details).toEqual({ code: "invalid_or_expired", reasonCode: "auth.code_invalid_or_expired" });
expect(employeeUpdate).toHaveBeenCalledWith({ expect(employeeUpdate).toHaveBeenCalledWith({
where: { id: EMPLOYEE_ID }, where: { id: EMPLOYEE_ID },
data: expect.objectContaining({ data: expect.objectContaining({
@@ -1008,7 +1043,7 @@ describe("auth login route", () => {
}); });
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" }); expect(response.json().error.details).toEqual({ code: "invalid_or_expired", reasonCode: "auth.code_invalid_or_expired" });
expect(employeeUpdate).not.toHaveBeenCalled(); expect(employeeUpdate).not.toHaveBeenCalled();
expect(passwordResetCodeUpdateMany).not.toHaveBeenCalled(); expect(passwordResetCodeUpdateMany).not.toHaveBeenCalled();
expect(auditCreate).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled();
@@ -106,6 +106,23 @@ function buildApp({
} }
describe("GET /api/invoice-pool/bindable", () => { describe("GET /api/invoice-pool/bindable", () => {
it.each([
["GET", "/invoice-pool?page=0", "list_query_invalid"],
["GET", "/invoice-pool/bindable?reimbursementId=invalid", "selection_query_invalid"],
["GET", "/invoice-pool/status", "status_query_invalid"],
["GET", "/invoice-pool/status?ids=invalid", "status_query_invalid"],
["GET", "/invoice-pool/invalid", "id_invalid"],
["POST", "/invoice-pool/invalid/signed-url", "id_invalid"],
])("explains invalid invoice request %s %s before data access", async (method, url, reason) => {
const { app, prisma } = await buildApp();
try {
const response = await app.inject({ method, url: `/api${url}`, headers: { authorization: "Bearer test" } });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.reasonCode).toBe(`invoice_pool.${reason}`);
expect(prisma.invoicePool.findMany).not.toHaveBeenCalled();
expect(prisma.reimbursementForm.findUnique).not.toHaveBeenCalled();
} finally { await app.close(); }
});
it("returns bindable invoices for reimbursement submitter and uses reimbursement owner for filtering", async () => { it("returns bindable invoices for reimbursement submitter and uses reimbursement owner for filtering", async () => {
const bindableItems = [ const bindableItems = [
{ {
@@ -199,7 +216,7 @@ describe("GET /api/invoice-pool/bindable", () => {
}); });
it("rejects reimbursements not in draft or rejected status", async () => { it("rejects reimbursements not in draft or rejected status", async () => {
const { app } = await buildApp({ const { app, prisma } = await buildApp({
reimbursement: { reimbursement: {
id: REIMBURSEMENT_ID, id: REIMBURSEMENT_ID,
owner_id: OWNER_ID, owner_id: OWNER_ID,
@@ -215,7 +232,8 @@ describe("GET /api/invoice-pool/bindable", () => {
}); });
expect(response.statusCode).toBe(409); expect(response.statusCode).toBe(409);
expect(response.json().error.details).toEqual({ reimbursement: "invalid_status_for_bind" }); expect(response.json().error.details).toEqual({ reimbursement: "invalid_status_for_bind", reasonCode: "invoice_pool.reimbursement_not_editable" });
expect(prisma.invoicePool.findMany).not.toHaveBeenCalled();
await app.close(); await app.close();
}); });
@@ -84,7 +84,7 @@ function buildPrisma(invoiceOverrides = {}) {
return { prisma, state }; return { prisma, state };
} }
async function buildApp(invoiceOverrides = {}) { async function buildApp(invoiceOverrides = {}, options = {}) {
const { prisma, state } = buildPrisma(invoiceOverrides); const { prisma, state } = buildPrisma(invoiceOverrides);
const app = Fastify(); const app = Fastify();
@@ -94,7 +94,7 @@ async function buildApp(invoiceOverrides = {}) {
appOrigin: "http://localhost:5173", appOrigin: "http://localhost:5173",
maxUploadSizeBytes: 10 * 1024 * 1024, maxUploadSizeBytes: 10 * 1024 * 1024,
invoiceArchiveUnusedDays: 30, invoiceArchiveUnusedDays: 30,
ocrProvider: "mock", ocrProvider: options.ocrProvider ?? "mock",
}); });
app.decorate("authenticate", async (request) => { app.decorate("authenticate", async (request) => {
if (!request.headers.authorization) { if (!request.headers.authorization) {
@@ -102,7 +102,7 @@ async function buildApp(invoiceOverrides = {}) {
} }
request.employee = { request.employee = {
id: OWNER_ID, id: OWNER_ID,
permissionCodes: ["invoice.upload"], permissionCodes: options.permissionCodes ?? ["invoice.upload"],
roleCodes: ["employee"], roleCodes: ["employee"],
}; };
}); });
@@ -141,6 +141,53 @@ async function buildApp(invoiceOverrides = {}) {
} }
describe("PATCH /api/invoice-pool/:id", () => { describe("PATCH /api/invoice-pool/:id", () => {
it.each(["2024-02-29", "2026-04-30"])("preserves valid calendar date %s", async (invoiceDate) => {
const { app, state } = await buildApp();
try {
const response = await app.inject({ method: "PATCH", url: `/api/invoice-pool/${INVOICE_ID}`, headers: { authorization: "Bearer test" }, payload: { invoiceDate } });
expect(response.statusCode).toBe(200);
expect(state.invoice.invoice_date.toISOString().slice(0, 10)).toBe(invoiceDate);
} finally {
await app.close();
}
});
it.each([
["PATCH", "", {}, {}, "edit_empty", 400],
["PATCH", "", { amount: 12 }, {}, "edit_input_invalid", 400],
["PATCH", "", { invoiceDate: "2026-02-30" }, {}, "date_invalid", 400],
["PATCH", "", { invoiceDate: "2026-02-29" }, {}, "date_invalid", 400],
["PATCH", "", { invoiceDate: "2026-13-01" }, {}, "date_invalid", 400],
["PATCH", "", { invoiceDate: "2026-04-31" }, {}, "date_invalid", 400],
["PATCH", "", { amount: "invalid" }, {}, "amount_invalid", 400],
["PATCH", "", { taxAmount: "invalid" }, {}, "tax_amount_invalid", 400],
["PATCH", "", { totalAmount: "invalid" }, {}, "total_amount_invalid", 400],
["PATCH", "", { remarks: "synthetic" }, { status: "bound" }, "not_editable", 409],
["PATCH", "", { status: "available" }, { status: "available" }, "status_transition_invalid", 409],
["PATCH", "", { status: "available" }, {}, "ocr_fields_missing", 409],
["POST", "/archive", {}, { linked_reimbursement_id: "occupied" }, "archive_bound", 409],
["POST", "/archive", {}, { status: "pending_ocr" }, "archive_status_invalid", 409],
["POST", "/retry-ocr", {}, { status: "available" }, "ocr_retry_status_invalid", 409],
["POST", "/retry-ocr", {}, {}, "ocr_unavailable", 409],
["POST", "/override-duplicate", {}, {}, "override_input_invalid", 400],
])("explains %s %s %s without writes", async (method, suffix, payload, invoiceOverrides, reason, statusCode) => {
const { app, prisma, state } = await buildApp(invoiceOverrides, {
ocrProvider: "off",
permissionCodes: ["invoice.upload", "expense.finance.override_duplicate"],
});
const before = JSON.stringify(state.invoice);
try {
const response = await app.inject({ method, url: `/api/invoice-pool/${INVOICE_ID}${suffix}`, headers: { authorization: "Bearer test" }, payload });
expect(response.statusCode).toBe(statusCode);
expect(response.json().error.details.reasonCode).toBe(`invoice_pool.${reason}`);
expect(prisma.invoicePool.update).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
expect(JSON.stringify(state.invoice)).toBe(before);
} finally {
await app.close();
}
});
it("accepts valid invoiceDate and allows marking OCR-failed VAT invoice as available", async () => { it("accepts valid invoiceDate and allows marking OCR-failed VAT invoice as available", async () => {
const { app, state } = await buildApp(); const { app, state } = await buildApp();
@@ -130,6 +130,28 @@ async function buildApp({
} }
describe("invoicePool upload route", () => { describe("invoicePool upload route", () => {
it.each([
["", [], 1024, "empty_file"],
["%PDF-1.4 synthetic", [["ocr_type", "unsupported"]], 1024, "ocr_type_invalid"],
["%PDF-1.4 synthetic", [], 8, "upload_too_large"],
])("explains invalid upload without files, records or jobs: %s", async (content, fields, maxUploadSizeBytes, reason) => {
const bossSend = vi.fn();
const { app, prisma, tempDir } = await buildApp({ ocrProvider: "mock", jobDriver: "pgboss", bossSend, maxUploadSizeBytes });
try {
const input = buildMultipartRequest({ filename: "synthetic.pdf", contentType: "application/pdf", content, fields });
const response = await app.inject({ method: "POST", url: "/api/invoice-pool/upload", headers: { authorization: "Bearer test", ...input.headers }, payload: input.payload });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.reasonCode).toBe(`invoice_pool.${reason}`);
expect(prisma.invoicePool.create).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
expect(bossSend).not.toHaveBeenCalled();
expect(await fs.readdir(tempDir)).toEqual([]);
} finally {
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("consumes the file stream before reading trailing multipart fields", async () => { it("consumes the file stream before reading trailing multipart fields", async () => {
const { app, prisma, tempDir } = await buildApp(); const { app, prisma, tempDir } = await buildApp();
@@ -261,7 +283,7 @@ describe("invoicePool upload route", () => {
expect.objectContaining({ expect.objectContaining({
code: "VALID_001", code: "VALID_001",
message: "请上传 PDF 发票文件", message: "请上传 PDF 发票文件",
details: { file: "pdf_invoice_required" }, details: { file: "pdf_invoice_required", reasonCode: "invoice_pool.pdf_required" },
}), }),
); );
expect(prisma.invoicePool.create).not.toHaveBeenCalled(); expect(prisma.invoicePool.create).not.toHaveBeenCalled();
@@ -302,6 +324,7 @@ describe("invoicePool upload route", () => {
message: "PDF 发票不能超过 7 MiB", message: "PDF 发票不能超过 7 MiB",
details: { details: {
file: "ocr_pdf_too_large", file: "ocr_pdf_too_large",
reasonCode: "invoice_pool.ocr_pdf_too_large",
maxBytes: 7 * 1024 * 1024, maxBytes: 7 * 1024 * 1024,
}, },
}), }),
@@ -204,6 +204,58 @@ async function buildApp(options = {}) {
} }
describe("invoicePool verification routes", () => { describe("invoicePool verification routes", () => {
it("explains invalid verification limits without reading records", async () => {
const { app, tx } = await buildApp();
try {
const response = await app.inject({ method: "GET", url: `/api/invoice-pool/${INVOICE_ID}/verification?limit=51`, headers: { authorization: "Bearer test" } });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.reasonCode).toBe("invoice_verification.list_query_invalid");
expect(tx.invoiceVerificationRecord.findMany).not.toHaveBeenCalled();
expect(app.boss.send).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it.each([
[null, "invoice_verification.record_missing"],
[buildVerificationRecord({ status: "pending" }), "invoice_verification.already_pending"],
])("explains blocked retry without writing or enqueueing: %s", async (latestRecord, reasonCode) => {
const { app, tx } = await buildApp({ latestRecord });
try {
const response = await app.inject({
method: "POST",
url: `/api/invoice-pool/${INVOICE_ID}/verification/retry`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.reasonCode).toBe(reasonCode);
expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
expect(app.boss.send).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it("explains invalid history identifiers before reading records", async () => {
const { app, tx } = await buildApp();
try {
const response = await app.inject({
method: "GET",
url: "/api/invoice-pool/invalid/verification",
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ id: "invalid_uuid", reasonCode: "invoice_pool.id_invalid" });
expect(tx.invoicePool.findUnique).not.toHaveBeenCalled();
expect(tx.invoiceVerificationRecord.findMany).not.toHaveBeenCalled();
expect(app.boss.send).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -268,7 +320,7 @@ describe("invoicePool verification routes", () => {
expect(response.statusCode).toBe(409); expect(response.statusCode).toBe(409);
expect(response.json().error).toMatchObject({ expect(response.json().error).toMatchObject({
code: "VALID_001", code: "VALID_001",
details: { verification: "disabled", featureFlag: "env_hard_off" }, details: { verification: "disabled", featureFlag: "env_hard_off", reasonCode: "invoice_verification.disabled" },
}); });
expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled(); expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled();
expect(app.boss.send).not.toHaveBeenCalled(); expect(app.boss.send).not.toHaveBeenCalled();
@@ -290,7 +342,7 @@ describe("invoicePool verification routes", () => {
}); });
expect(response.statusCode).toBe(409); expect(response.statusCode).toBe(409);
expect(response.json().error.details).toEqual({ verification: "already_pending" }); expect(response.json().error.details).toEqual({ verification: "already_pending", reasonCode: "invoice_verification.already_pending" });
expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled(); expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled();
} finally { } finally {
await app.close(); await app.close();
+13
查看文件
@@ -188,4 +188,17 @@ describe('me routes', () => {
await app.close(); await app.close();
}); });
it('explains invalid notification settings without changing preferences', async () => {
const { prisma, auditEntries } = createPrismaState();
const app = await buildApp(prisma);
try {
const response = await app.inject({ method: 'PATCH', url: '/api/me/notification-preferences',
headers: { authorization: 'Bearer test-token' }, payload: { notificationPushEnabled: 'false' } });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.reasonCode).toBe('profile.notification_preferences_invalid');
expect(prisma.employee.update).not.toHaveBeenCalled();
expect(auditEntries).toHaveLength(0);
} finally { await app.close(); }
});
}); });
@@ -65,10 +65,45 @@ async function buildApp(permissionCodes) {
}); });
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ requestId: request.id, error: { code: error.code ?? "SYS_001", message: error.message, details: error.details ?? {} } })); app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ requestId: request.id, error: { code: error.code ?? "SYS_001", message: error.message, details: error.details ?? {} } }));
await app.register(paymentAccountsRoutes, { prefix: "/api" }); await app.register(paymentAccountsRoutes, { prefix: "/api" });
return { app, auditCreate, notificationCreateMany, paymentAccountCreate }; return { app, tx, auditCreate, notificationCreateMany, paymentAccountCreate };
} }
describe("payment account routes", () => { describe("payment account routes", () => {
const validBody = { legalEntityId: LEGAL_ENTITY_ID, bankInstitutionId: BANK_ID, accountName: "合成测试公司", accountNumber: SYNTHETIC_ACCOUNT };
it.each([
["account_input_invalid", "/payment-accounts", { ...validBody, accountNumber: "invalid" }, 400],
["bank_input_invalid", "/bank-institutions", { code: "!", name: "" }, 400],
["reveal_reason_required", `/payment-accounts/${ACCOUNT_ID}/reveal`, { reason: "" }, 400],
["id_invalid", "/payment-accounts/invalid/reveal", { reason: "核对资料" }, 400],
["legal_entity_unavailable", "/payment-accounts", validBody, 409],
["bank_unavailable", "/payment-accounts", validBody, 409],
["legal_entity_change_forbidden", `/payment-accounts/${ACCOUNT_ID}/replace`, { ...validBody, legalEntityId: ACTOR_ID }, 400],
["bank_code_exists", "/bank-institutions", { code: "CCB", name: "合成银行" }, 409],
["bank_in_use", `/bank-institutions/${BANK_ID}/disable`, {}, 409],
])("explains %s without successful mutation or sensitive output", async (reason, url, payload, status) => {
const testApp = await buildApp(["*"]);
try {
if (reason === "legal_entity_unavailable") testApp.tx.legalEntity.findFirst.mockResolvedValue(null);
if (reason === "bank_unavailable") testApp.tx.bankInstitution.findFirst.mockResolvedValue(null);
if (reason === "bank_code_exists") testApp.tx.bankInstitution.create = vi.fn().mockRejectedValue({ code: "P2002" });
if (reason === "bank_in_use") {
testApp.tx.bankInstitution.findUnique = vi.fn().mockResolvedValue({ id: BANK_ID });
testApp.tx.paymentAccount.count = vi.fn().mockResolvedValue(1);
}
const response = await testApp.app.inject({ method: "POST", url: `/api${url}`, payload });
expect(response.statusCode).toBe(status);
expect(response.json().error.code).toBe("VALID_001");
expect(response.json().error.details.reasonCode).toBe(`payment_account.${reason}`);
const { getErrorReason } = require("../../../src/constants/errorReasons");
const expected = require("../fixtures/error-reason-messages.json");
expect(getErrorReason(`payment_account.${reason}`).defaultMessage).toBe(expected[`payment_account.${reason}`]);
expect(JSON.stringify(response.json())).not.toContain(SYNTHETIC_ACCOUNT);
expect(testApp.paymentAccountCreate).not.toHaveBeenCalled();
expect(testApp.tx.paymentAccount.update).not.toHaveBeenCalled();
expect(testApp.auditCreate).not.toHaveBeenCalled();
expect(testApp.notificationCreateMany).not.toHaveBeenCalled();
} finally { await testApp.app.close(); }
});
it("returns only a masked account on normal reads", async () => { it("returns only a masked account on normal reads", async () => {
const testApp = await buildApp(["payment.account.read"]); const testApp = await buildApp(["payment.account.read"]);
const response = await testApp.app.inject({ method: "GET", url: "/api/payment-accounts" }); const response = await testApp.app.inject({ method: "GET", url: "/api/payment-accounts" });
@@ -196,6 +196,32 @@ async function buildApp(options = {}) {
} }
describe("workflow batch routes", () => { describe("workflow batch routes", () => {
it.each([
["POST", "/workflow/tasks/invalid/approve", {}, "workflow.task_id_invalid"],
["POST", "/workflow/tasks/11111111-1111-4111-8111-111111111111/reject", { comment: " " }, "workflow.comment_invalid"],
["POST", "/workflow/tasks/11111111-1111-4111-8111-111111111111/transfer", {}, "workflow.transfer_input_invalid"],
["POST", "/workflow/batch-approve", { taskIds: [] }, "workflow.batch_approve_input_invalid"],
["POST", "/workflow/batch-reject", { taskIds: [] }, "workflow.batch_reject_input_invalid"],
["POST", "/workflow/tasks/11111111-1111-4111-8111-111111111111/remind", { comment: "长".repeat(201) }, "workflow.remind_input_invalid"],
["GET", "/workflow/tasks/summary?scope=invalid", undefined, "workflow.summary_query_invalid"],
["GET", "/workflow/tasks?page=0", undefined, "workflow.task_list_query_invalid"],
["GET", "/workflow/approvers?page=0", undefined, "workflow.approver_query_invalid"],
])("rejects malformed %s %s before starting a business transaction", async (method, path, payload, reasonCode) => {
const { app, prisma, state } = await buildApp({
actor: { id: ACTOR_ID, roleCodes: ["manager"], permissionCodes: ["expense.approve", "expense.transfer"] },
});
const before = JSON.stringify([...state.tasks]);
try {
const response = await app.inject({ method, url: `/api${path}`, ...(payload === undefined ? {} : { payload }) });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.reasonCode).toBe(reasonCode);
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(JSON.stringify([...state.tasks])).toBe(before);
} finally {
await app.close();
}
});
it("rejects self approval on a single task action", async () => { it("rejects self approval on a single task action", async () => {
const { app, state } = await buildApp({ const { app, state } = await buildApp({
reimbursementOwnerId: ACTOR_ID, reimbursementOwnerId: ACTOR_ID,
@@ -212,6 +238,7 @@ describe("workflow batch routes", () => {
code: "VALID_001", code: "VALID_001",
details: expect.objectContaining({ details: expect.objectContaining({
workflow: "self_approval_not_allowed", workflow: "self_approval_not_allowed",
reasonCode: "workflow.self_approval_forbidden",
}), }),
}), }),
); );
@@ -271,6 +271,7 @@ describe("workflow transfer route", () => {
code: "VALID_001", code: "VALID_001",
details: expect.objectContaining({ details: expect.objectContaining({
toApproverId: "target_not_approver", toApproverId: "target_not_approver",
reasonCode: "workflow.transfer_target_ineligible",
}), }),
}), }),
); );
@@ -306,6 +307,7 @@ describe("workflow transfer route", () => {
expect(response.json().error.details).toEqual( expect(response.json().error.details).toEqual(
expect.objectContaining({ expect.objectContaining({
toApproverId: "target_not_approver", toApproverId: "target_not_approver",
reasonCode: "workflow.transfer_target_ineligible",
}), }),
); );
expect(tx.workflowTask.update).not.toHaveBeenCalled(); expect(tx.workflowTask.update).not.toHaveBeenCalled();
@@ -341,6 +343,7 @@ describe("workflow transfer route", () => {
code: "VALID_001", code: "VALID_001",
details: expect.objectContaining({ details: expect.objectContaining({
workflow: "self_approval_not_allowed", workflow: "self_approval_not_allowed",
reasonCode: "workflow.self_approval_forbidden",
}), }),
}), }),
); );
@@ -34,6 +34,29 @@ describe("client business error projection", () => {
.toBe("报销人尚未维护所属公司,请联系管理员补充后再安排付款。"); .toBe("报销人尚未维护所属公司,请联系管理员补充后再安排付款。");
}); });
it.each([
[{ reasonCode: "invoice_pool.ocr_fields_missing", ocr: "missing_required_fields", missingFields: ["invoiceCode", "sellerName"] }, "票据仍缺少关键字段:发票代码、销售方,补齐后才能标记为可用。"],
[{ reasonCode: "workflow.remind_cooldown", workflow: "remind_too_frequent", nextAllowedAt: "2026-09-16T08:30:00.000Z" }, "24 小时内已催办过,请在 2026-09-16 08:30:00 后再试。"],
[{ reasonCode: "auth.password_policy_failed", password: ["密码长度必须 ≥ 12", "密码必须包含数字"] }, "新密码不符合要求:密码长度必须 ≥ 12;密码必须包含数字。"],
])("preserves scoped dynamic guidance alongside reasonCode: %j", (details, expected) => {
expect(resolveErrorMessage(new ApiError({ status: 400, code: "VALID_001", message: "private payload", details }))).toBe(expected);
});
it.each([
{ reasonCode: "invoice_pool.ocr_fields_missing", missingFields: ["/private/secret", "__proto__"], password: ["密码必须包含数字"] },
{ reasonCode: "workflow.remind_cooldown", nextAllowedAt: "/private/secret", password: ["密码必须包含数字"] },
{ reasonCode: "auth.password_policy_failed", password: ["/private/secret"], ocr: "missing_required_fields", missingFields: ["invoiceCode"] },
])("falls back safely for malformed or cross-domain details: %j", (details) => {
expect(resolveErrorMessage(new ApiError({ status: 400, code: "VALID_001", message: "private payload", details })))
.toBe(expectedMessages[details.reasonCode]);
});
it("does not expose unknown fields mixed with known OCR labels", () => {
const details = { reasonCode: "invoice_pool.ocr_fields_missing", missingFields: ["invoiceCode", "/private/secret"], password: ["private payload"] };
expect(resolveErrorMessage(new ApiError({ status: 409, code: "VALID_001", message: "private payload", details })))
.toBe("票据仍缺少关键字段:发票代码,补齐后才能标记为可用。");
});
it.each([undefined, "unknown.reason", "__proto__", "constructor"])("fails safely for unknown reason %s", (reasonCode) => { it.each([undefined, "unknown.reason", "__proto__", "constructor"])("fails safely for unknown reason %s", (reasonCode) => {
expect(resolveErrorMessage(new ApiError({ status: 400, code: "VALID_001", message: "private payload", details: { reasonCode, internal: "/private/secret" } }))).toBe(fallback); expect(resolveErrorMessage(new ApiError({ status: 400, code: "VALID_001", message: "private payload", details: { reasonCode, internal: "/private/secret" } }))).toBe(fallback);
}); });