fix: 完善动态错误指引与发票日期校验(09-05-error-explainability-payment-readiness)

这个提交包含在:
2026-09-16 12:56:27 +08:00
父节点 b7d2285d8d
当前提交 3516d7ec04
共修改 28 个文件,包含 791 行新增和 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,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 个已证实产品故障。本轮主线修复不改到期日,不重复建立豁免台账。
+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);
}); });