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
### 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
- 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
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`
and is asserted against both registry and client rendering; do not replace
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,
occurrence count, owner, explanation and expiry date (UTC). Whitespace, quotes,
indentation, trailing commas and parenthesization must not change identity.
@@ -1,5 +1,67 @@
# 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 到期治理接续(不统一续期)
本次只读复核现有 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" });
}
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;
}
if (code === "AUTH_001") {
+73
查看文件
@@ -1,4 +1,77 @@
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_required": "财务本人报销只能由非申请人的在职总经理审批或接收转审,不能委托其他角色代批。",
"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-set-available").check();
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-seller-name").fill("北京悦海堂餐饮管理有限公司");
+5
查看文件
@@ -216,6 +216,10 @@ test.describe("visual portal", () => {
await page.goto(mobile ? "/mobile" : "/portal");
await expect(page.getByText("报销待组建付款批次 · BX-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("异常付款 1 项", { exact: true })).toHaveCount(0);
if (mobile) await expect(page.getByTestId("mobile-business-handoffs")).toContainText("2 项");
@@ -225,6 +229,7 @@ test.describe("visual portal", () => {
items = [];
await page.reload();
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);
});
}
@@ -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/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/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":"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"},
@@ -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":"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/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":"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"},
@@ -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":"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/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":"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"},
@@ -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":"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/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":"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"},
@@ -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":"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/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":"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"},
+35
查看文件
@@ -13,6 +13,10 @@ const docker = (args, input) => execFileSync("docker", args, { input, encoding:
async function main() {
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 ownerApprovalTest = process.env.RUN_OWNER_APPROVAL_TEST === "1";
assert.ok(!ownerApprovalTest || managerIsCeo, "Owner approval checks require the small-team fixture");
@@ -265,6 +269,37 @@ async function main() {
app.config.appOrigin = uiOrigin;
const { chromium, expect } = clientRequire("@playwright/test");
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 context = await browser.newContext({ serviceWorkers: "block", viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 } });
const fingerprint = `synthetic-browser-${mobile ? "mobile" : "desktop"}`;
+73
查看文件
@@ -1,4 +1,77 @@
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_required","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 { z } = require("zod");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const {
@@ -251,21 +252,11 @@ function hashEmployeeAuthCode(config, employeeId, code, purpose) {
}
function emailLoginCodeError() {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { code: "invalid_or_expired" },
});
return buildBusinessValidationError("auth.code_invalid_or_expired", { code: "invalid_or_expired" });
}
function passwordResetCodeError() {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { code: "invalid_or_expired" },
});
return buildBusinessValidationError("auth.code_invalid_or_expired", { code: "invalid_or_expired" });
}
function isFutureDate(value, now) {
@@ -579,22 +570,12 @@ async function authRoutes(fastify, options = {}) {
fastify.post("/login", { preHandler: authRateLimit }, async (request, reply) => {
const parsed = loginBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
throw buildBusinessValidationError("auth.login_input_invalid", parsed.error.flatten());
}
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
if (!deviceFingerprint) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { "x-device-fingerprint": "missing" },
});
throw buildBusinessValidationError("auth.device_context_missing", { "x-device-fingerprint": "missing" });
}
const { identifier, password } = parsed.data;
@@ -797,12 +778,7 @@ async function authRoutes(fastify, options = {}) {
fastify.post("/email-login/code", { preHandler: authRateLimit }, async (request, reply) => {
const parsed = emailLoginCodeRequestBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
throw buildBusinessValidationError("auth.email_input_invalid", parsed.error.flatten());
}
assertEmailDeliveryReady(fastify.config, resolveEmailDeliveryStatus);
@@ -889,22 +865,12 @@ async function authRoutes(fastify, options = {}) {
fastify.post("/email-login/verify", { preHandler: authRateLimit }, async (request, reply) => {
const parsed = emailLoginVerifyBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
throw buildBusinessValidationError("auth.email_code_input_invalid", parsed.error.flatten());
}
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
if (!deviceFingerprint) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { "x-device-fingerprint": "missing" },
});
throw buildBusinessValidationError("auth.device_context_missing", { "x-device-fingerprint": "missing" });
}
const { email, code } = parsed.data;
@@ -1036,12 +1002,7 @@ async function authRoutes(fastify, options = {}) {
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
if (!deviceFingerprint) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { "x-device-fingerprint": "missing" },
});
throw buildBusinessValidationError("auth.device_context_missing", { "x-device-fingerprint": "missing" });
}
const refreshTokenHash = sha256Hex(refreshToken);
@@ -1243,12 +1204,7 @@ async function authRoutes(fastify, options = {}) {
const payload = verified.payload && typeof verified.payload === "object" ? verified.payload : {};
const targetPath = typeof payload.targetPath === "string" ? payload.targetPath.trim() : "";
if (!targetPath || !targetPath.startsWith("/") || targetPath.startsWith("//")) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { targetPath: "invalid_target_path" },
});
throw buildBusinessValidationError("auth.deeplink_target_invalid", { targetPath: "invalid_target_path" });
}
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) => {
const parsed = changePasswordBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
throw buildBusinessValidationError("auth.password_change_input_invalid", parsed.error.flatten());
}
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
if (!deviceFingerprint) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { "x-device-fingerprint": "missing" },
});
throw buildBusinessValidationError("auth.device_context_missing", { "x-device-fingerprint": "missing" });
}
const { oldPassword, newPassword } = parsed.data;
const policyErrors = validatePassword(newPassword);
if (policyErrors.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { password: policyErrors },
});
throw buildBusinessValidationError("auth.password_policy_failed", { password: policyErrors });
}
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) => {
const parsed = forgotPasswordBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
throw buildBusinessValidationError("auth.email_input_invalid", parsed.error.flatten());
}
assertEmailDeliveryReady(fastify.config, resolveEmailDeliveryStatus);
@@ -1796,24 +1732,14 @@ async function authRoutes(fastify, options = {}) {
fastify.post("/reset-password", { preHandler: authRateLimit }, async (request, reply) => {
const parsed = resetPasswordBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
throw buildBusinessValidationError("auth.password_reset_input_invalid", parsed.error.flatten());
}
const { email, code, newPassword } = parsed.data;
const policyErrors = validatePassword(newPassword);
if (policyErrors.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { password: policyErrors },
});
throw buildBusinessValidationError("auth.password_policy_failed", { password: policyErrors });
}
const outcome = await fastify.prisma.$transaction(async (tx) => {
+48 -192
查看文件
@@ -201,7 +201,11 @@ function parseDateOnlyString(value) {
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) {
@@ -381,55 +385,37 @@ async function invoicePoolRoutes(fastify) {
for await (const part of request.parts()) {
if (part.type === "file") {
if (fileBuffer) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "only_single_file_supported" },
});
throw buildBusinessValidationError("invoice_pool.single_file_required", { file: "only_single_file_supported" }, 400);
}
fileName = part.filename ?? "upload";
fileMimeType = part.mimetype;
if (!isPdfInvoiceUpload(fileName, fileMimeType)) {
throw new AppError({
code: "VALID_001",
message: "请上传 PDF 发票文件",
statusCode: 400,
details: { file: "pdf_invoice_required" },
});
const error = buildBusinessValidationError("invoice_pool.pdf_required", { file: "pdf_invoice_required" });
error.message = "请上传 PDF 发票文件";
throw error;
}
const chunks = [];
for await (const chunk of part.file) {
fileSize += chunk.length;
if (fileSize > invoiceUploadMaxBytes) {
throw new AppError({
code: "VALID_001",
message:
invoiceUploadMaxBytes === OCR_PDF_MAX_BYTES
? "PDF 发票不能超过 7 MiB"
: "参数校验失败",
statusCode: 400,
details: {
file:
invoiceUploadMaxBytes === OCR_PDF_MAX_BYTES
? "ocr_pdf_too_large"
: "file_too_large",
maxBytes: invoiceUploadMaxBytes,
},
if (invoiceUploadMaxBytes === OCR_PDF_MAX_BYTES) {
const error = buildBusinessValidationError("invoice_pool.ocr_pdf_too_large", {
file: "ocr_pdf_too_large", maxBytes: invoiceUploadMaxBytes,
});
error.message = "PDF 发票不能超过 7 MiB";
throw error;
}
throw buildBusinessValidationError("invoice_pool.upload_too_large", {
file: "file_too_large", maxBytes: invoiceUploadMaxBytes,
});
}
chunks.push(chunk);
}
if (fileSize === 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "empty_file" },
});
throw buildBusinessValidationError("invoice_pool.empty_file", { file: "empty_file" }, 400);
}
fileBuffer = Buffer.concat(chunks);
@@ -440,23 +426,13 @@ async function invoicePoolRoutes(fastify) {
}
} catch (error) {
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "file_too_large" },
});
throw buildBusinessValidationError("invoice_pool.upload_too_large", { file: "file_too_large" }, 400);
}
throw error;
}
if (!fileBuffer) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "missing" },
});
throw buildBusinessValidationError("invoice_pool.file_required", { file: "missing" }, 400);
}
const ownerEmployeeId =
@@ -503,23 +479,13 @@ async function invoicePoolRoutes(fastify) {
select: { id: true, status: true },
});
if (!ownerEmployee || ownerEmployee.status !== "active") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { owner_employee_id: "invalid_owner" },
});
throw buildBusinessValidationError("invoice_pool.owner_unavailable", { owner_employee_id: "invalid_owner" }, 409);
}
}
const ocrType = fields.ocr_type ?? "general";
if (!OCR_TYPE_ALLOWLIST.has(ocrType)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { ocr_type: "invalid" },
});
throw buildBusinessValidationError("invoice_pool.ocr_type_invalid", { ocr_type: "invalid" }, 400);
}
const fileSha256 = sha256Hex(fileBuffer);
@@ -633,31 +599,16 @@ async function invoicePoolRoutes(fastify) {
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
}
const parsedBody = invoiceEditBodySchema.safeParse(request.body ?? {});
if (!parsedBody.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsedBody.error.flatten(),
});
throw buildBusinessValidationError("invoice_pool.edit_input_invalid", parsedBody.error.flatten());
}
if (Object.keys(parsedBody.data).length === 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { body: "empty_patch" },
});
throw buildBusinessValidationError("invoice_pool.edit_empty", { body: "empty_patch" }, 400);
}
const invoiceId = parsedInvoiceId.data;
@@ -704,12 +655,7 @@ async function invoicePoolRoutes(fastify) {
}
if (!["available", "ocr_failed"].includes(current.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_status_for_edit" },
});
throw buildBusinessValidationError("invoice_pool.not_editable", { status: "invalid_status_for_edit" }, 409);
}
const input = parsedBody.data;
@@ -730,12 +676,7 @@ async function invoicePoolRoutes(fastify) {
if ("invoiceDate" in input) {
const parsedDate = parseDateOnlyString(input.invoiceDate);
if (parsedDate === undefined) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { invoiceDate: "invalid_date" },
});
throw buildBusinessValidationError("invoice_pool.date_invalid", { invoiceDate: "invalid_date" }, 400);
}
updateData.invoice_date = parsedDate;
}
@@ -751,12 +692,7 @@ async function invoicePoolRoutes(fastify) {
if ("amount" in input) {
const parsedAmount = normalizeMoneyInput(input.amount);
if (parsedAmount === undefined) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { amount: "invalid_money" },
});
throw buildBusinessValidationError("invoice_pool.amount_invalid", { amount: "invalid_money" }, 400);
}
updateData.amount = parsedAmount;
}
@@ -764,12 +700,7 @@ async function invoicePoolRoutes(fastify) {
if ("taxAmount" in input) {
const parsedAmount = normalizeMoneyInput(input.taxAmount);
if (parsedAmount === undefined) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { taxAmount: "invalid_money" },
});
throw buildBusinessValidationError("invoice_pool.tax_amount_invalid", { taxAmount: "invalid_money" }, 400);
}
updateData.tax_amount = parsedAmount;
}
@@ -777,12 +708,7 @@ async function invoicePoolRoutes(fastify) {
if ("totalAmount" in input) {
const parsedAmount = normalizeMoneyInput(input.totalAmount);
if (parsedAmount === undefined) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { totalAmount: "invalid_money" },
});
throw buildBusinessValidationError("invoice_pool.total_amount_invalid", { totalAmount: "invalid_money" }, 400);
}
updateData.total_amount = parsedAmount;
}
@@ -804,15 +730,10 @@ async function invoicePoolRoutes(fastify) {
};
const missingFields = getMissingRequiredOcrFields(current.ocr_type, nextSnapshot);
if (missingFields.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
ocr: "missing_required_fields",
missingFields,
},
});
throw buildBusinessValidationError("invoice_pool.ocr_fields_missing", {
ocr: "missing_required_fields",
missingFields,
}, 409);
}
updateData.status = "available";
@@ -821,22 +742,12 @@ async function invoicePoolRoutes(fastify) {
updateData.ocr_locked_at = null;
updateData.ocr_lock_owner = null;
} else {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_status_transition" },
});
throw buildBusinessValidationError("invoice_pool.status_transition_invalid", { status: "invalid_status_transition" }, 409);
}
}
if (Object.keys(updateData).length === 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { body: "empty_patch" },
});
throw buildBusinessValidationError("invoice_pool.edit_empty", { body: "empty_patch" }, 400);
}
updateData.updated_by = request.employee.id;
@@ -913,12 +824,7 @@ async function invoicePoolRoutes(fastify) {
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
}
const invoiceId = parsedInvoiceId.data;
@@ -973,12 +879,7 @@ async function invoicePoolRoutes(fastify) {
}
if (current.linked_reimbursement_id || current.travel_order_id) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { invoice: "cannot_archive_bound_invoice" },
});
throw buildBusinessValidationError("invoice_pool.archive_bound", { invoice: "cannot_archive_bound_invoice" }, 409);
}
if (current.status === "archived" && current.archived_reason === "manual") {
@@ -989,12 +890,7 @@ async function invoicePoolRoutes(fastify) {
}
if (!["available", "ocr_failed"].includes(current.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_status_for_archive" },
});
throw buildBusinessValidationError("invoice_pool.archive_status_invalid", { status: "invalid_status_for_archive" }, 409);
}
const now = new Date();
@@ -1142,12 +1038,7 @@ async function invoicePoolRoutes(fastify) {
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
}
const invoiceId = parsedInvoiceId.data;
@@ -1202,21 +1093,11 @@ async function invoicePoolRoutes(fastify) {
}
if (current.status !== "ocr_failed") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_status_for_retry" },
});
throw buildBusinessValidationError("invoice_pool.ocr_retry_status_invalid", { status: "invalid_status_for_retry" }, 409);
}
if (!isOcrEnabledForType(fastify.config, current.ocr_type)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { ocr: "disabled" },
});
throw buildBusinessValidationError("invoice_pool.ocr_unavailable", { ocr: "disabled" }, 409);
}
const maxAttempts = Number(fastify.config.ocrMaxAttempts ?? 3);
@@ -1289,22 +1170,12 @@ async function invoicePoolRoutes(fastify) {
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
}
const parsedBody = overrideDuplicateBodySchema.safeParse(request.body ?? {});
if (!parsedBody.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsedBody.error.flatten(),
});
throw buildBusinessValidationError("invoice_pool.override_input_invalid", parsedBody.error.flatten());
}
const invoiceId = parsedInvoiceId.data;
@@ -1348,30 +1219,15 @@ async function invoicePoolRoutes(fastify) {
}
if (current.linked_reimbursement_id || current.travel_order_id) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { invoice: "already_bound" },
});
throw buildBusinessValidationError("invoice_pool.override_bound", { invoice: "already_bound" }, 409);
}
if (!["available", "ocr_failed"].includes(current.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_status_for_override" },
});
throw buildBusinessValidationError("invoice_pool.override_status_invalid", { status: "invalid_status_for_override" }, 409);
}
if (!current.file_sha256 && !(current.invoice_code && current.invoice_number)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { invoice: "missing_dedup_key" },
});
throw buildBusinessValidationError("invoice_pool.dedup_identity_missing", { invoice: "missing_dedup_key" }, 409);
}
const dedupFilters = [];
+8 -42
查看文件
@@ -1,4 +1,5 @@
const { z } = require("zod");
const { buildBusinessValidationError } = require("../../utils/businessValidationError");
const { AppError } = require("../../utils/AppError");
const { sendData } = require("../../utils/reply");
@@ -36,12 +37,7 @@ const invoiceStatusQuerySchema = z.object({
function parseInvoiceListQuery(query) {
const parsed = invoiceListQuerySchema.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
throw buildBusinessValidationError("invoice_pool.list_query_invalid", parsed.error.flatten());
}
return {
@@ -55,12 +51,7 @@ function parseInvoiceListQuery(query) {
function parseBindableInvoiceQuery(query) {
const parsed = bindableInvoiceQuerySchema.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
throw buildBusinessValidationError("invoice_pool.selection_query_invalid", parsed.error.flatten());
}
return {
@@ -73,12 +64,7 @@ function parseBindableInvoiceQuery(query) {
function parseInvoiceStatusQuery(query) {
const parsed = invoiceStatusQuerySchema.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
throw buildBusinessValidationError("invoice_pool.status_query_invalid", parsed.error.flatten());
}
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)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { ids: "invalid_uuid_list" },
});
throw buildBusinessValidationError("invoice_pool.status_query_invalid", { ids: "invalid_uuid_list" });
}
return { ids };
@@ -291,12 +272,7 @@ function registerInvoicePoolReadRoutes(fastify) {
}
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_bind" },
});
throw buildBusinessValidationError("invoice_pool.reimbursement_not_editable", { reimbursement: "invalid_status_for_bind" }, 409);
}
const where = {
@@ -431,12 +407,7 @@ function registerInvoicePoolReadRoutes(fastify) {
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" });
}
const invoice = await fastify.prisma.invoicePool.findUnique({
@@ -477,12 +448,7 @@ function registerInvoicePoolReadRoutes(fastify) {
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" });
}
const invoice = await fastify.prisma.invoicePool.findUnique({
@@ -1,6 +1,6 @@
const { z } = require("zod");
const { AppError } = require("../../../utils/AppError");
const { buildBusinessValidationError } = require("../../../utils/businessValidationError");
const verificationListQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(50).optional(),
@@ -9,12 +9,7 @@ const verificationListQuerySchema = z.object({
function parseVerificationListQuery(query) {
const parsed = verificationListQuerySchema.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
throw buildBusinessValidationError("invoice_verification.list_query_invalid", parsed.error.flatten());
}
return {
+7 -42
查看文件
@@ -33,12 +33,7 @@ const RETRY_RESPONSE_SCHEMA = {
};
function throwVerificationDisabled(reason) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "disabled", featureFlag: reason },
});
throw buildBusinessValidationError("invoice_verification.disabled", { verification: "disabled", featureFlag: reason }, 409);
}
async function resolveVerificationDepartmentContext(tx, request, invoiceId) {
@@ -141,12 +136,7 @@ function registerInvoiceVerificationRoutes(fastify) {
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
}
const invoiceId = parsedInvoiceId.data;
@@ -187,12 +177,7 @@ function registerInvoiceVerificationRoutes(fastify) {
});
if (latest?.status === "pending") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "already_pending" },
});
throw buildBusinessValidationError("invoice_verification.already_pending", { verification: "already_pending" }, 409);
}
if (latest?.requested_at) {
@@ -284,12 +269,7 @@ function registerInvoiceVerificationRoutes(fastify) {
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
}
const query = parseVerificationListQuery(request.query);
@@ -391,12 +371,7 @@ function registerInvoiceVerificationRoutes(fastify) {
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
throw buildBusinessValidationError("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
}
const invoiceId = parsedInvoiceId.data;
@@ -422,21 +397,11 @@ function registerInvoiceVerificationRoutes(fastify) {
});
if (!latest) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "not_found" },
});
throw buildBusinessValidationError("invoice_verification.record_missing", { verification: "not_found" }, 409);
}
if (latest.status === "pending") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "already_pending" },
});
throw buildBusinessValidationError("invoice_verification.already_pending", { verification: "already_pending" }, 409);
}
const cooldownMs =
+2 -10
查看文件
@@ -1,4 +1,5 @@
const { z } = require("zod");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
@@ -11,19 +12,10 @@ const NOTIFICATION_PREFERENCES_SCHEMA = z
})
.strict();
function buildValidationError(details) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details,
});
}
function parseNotificationPreferences(body) {
const parsed = NOTIFICATION_PREFERENCES_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("profile.notification_preferences_invalid", parsed.error.flatten());
}
return parsed.data;
+13 -11
查看文件
@@ -1,4 +1,5 @@
const { z } = require("zod");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { decryptStringOrThrow, encryptStringOrThrow, maskBankAccount } = require("../services/crypto");
const { createNotifications } = require("../services/notifications");
@@ -22,14 +23,15 @@ const BANK_BODY_SCHEMA = z.object({
}).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) {
const result = schema.safeParse(value ?? {});
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;
}
@@ -37,7 +39,7 @@ function parse(schema, value) {
function parseId(params) {
const result = UUID_SCHEMA.safeParse(params?.id);
if (!result.success) {
throw validationError({ id: "invalid_uuid" });
throw buildBusinessValidationError("payment_account.id_invalid", { id: "invalid_uuid" });
}
return result.data;
}
@@ -96,8 +98,8 @@ async function ensureReferences(tx, body) {
select: { id: true, code: true, name: true },
}),
]);
if (!legalEntity) throw validationError({ legalEntityId: "not_available" }, 409);
if (!bankInstitution) throw validationError({ bankInstitutionId: "not_available" }, 409);
if (!legalEntity) throw buildBusinessValidationError("payment_account.legal_entity_unavailable", { legalEntityId: "not_available" }, 409);
if (!bankInstitution) throw buildBusinessValidationError("payment_account.bank_unavailable", { bankInstitutionId: "not_available" }, 409);
return { legalEntity, bankInstitution };
}
@@ -177,7 +179,7 @@ async function paymentAccountsRoutes(fastify) {
});
} catch (error) {
if (error?.code === "P2002") {
throw validationError({ code: "already_exists" }, 409);
throw buildBusinessValidationError("payment_account.bank_code_exists", { code: "already_exists" }, 409);
}
throw error;
}
@@ -231,7 +233,7 @@ async function paymentAccountsRoutes(fastify) {
const inUse = await tx.paymentAccount.count({
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();
await tx.bankInstitution.update({
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.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);
const now = new Date();
+26 -124
查看文件
@@ -180,12 +180,7 @@ const APPROVER_ROLE_CODES = BUSINESS_APPROVAL_ROLE_CODES.filter((roleCode) =>
function parseRequiredComment(body) {
const parsed = REQUIRED_COMMENT_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
throw buildBusinessValidationError("workflow.comment_invalid", parsed.error.flatten());
}
return parsed.data.comment;
@@ -194,12 +189,7 @@ function parseRequiredComment(body) {
function parseTransferBody(body) {
const parsed = TRANSFER_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
throw buildBusinessValidationError("workflow.transfer_input_invalid", parsed.error.flatten());
}
const reason = parsed.data.reason.trim();
@@ -221,12 +211,7 @@ function parseTransferBody(body) {
function parseTaskId(taskId) {
const parsed = TASK_ID_SCHEMA.safeParse(taskId);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { taskId: "invalid_uuid" },
});
throw buildBusinessValidationError("workflow.task_id_invalid", { taskId: "invalid_uuid" });
}
return parsed.data;
@@ -235,12 +220,7 @@ function parseTaskId(taskId) {
function parseBatchApproveBody(body) {
const parsed = BATCH_APPROVE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
throw buildBusinessValidationError("workflow.batch_approve_input_invalid", parsed.error.flatten());
}
return {
@@ -252,12 +232,7 @@ function parseBatchApproveBody(body) {
function parseBatchRejectBody(body) {
const parsed = BATCH_REJECT_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
throw buildBusinessValidationError("workflow.batch_reject_input_invalid", parsed.error.flatten());
}
return {
@@ -269,12 +244,7 @@ function parseBatchRejectBody(body) {
function parseRemindTaskBody(body) {
const parsed = REMIND_TASK_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
throw buildBusinessValidationError("workflow.remind_input_invalid", parsed.error.flatten());
}
return {
@@ -285,12 +255,7 @@ function parseRemindTaskBody(body) {
function parseTaskSummaryQuery(query) {
const parsed = TASK_SUMMARY_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
throw buildBusinessValidationError("workflow.summary_query_invalid", parsed.error.flatten());
}
return {
@@ -384,12 +349,7 @@ async function handlePurchaseApprovalErpSync({
function parseTaskListQuery(query) {
const parsed = TASK_LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
throw buildBusinessValidationError("workflow.task_list_query_invalid", parsed.error.flatten(), 400);
}
return {
@@ -403,12 +363,7 @@ function parseTaskListQuery(query) {
function parseApproverListQuery(query) {
const parsed = APPROVER_LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
throw buildBusinessValidationError("workflow.approver_query_invalid", parsed.error.flatten(), 400);
}
return {
@@ -734,12 +689,7 @@ async function lockWorkflowTask(tx, taskId) {
function ensurePendingTask(task) {
if (task.status !== "pending") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { task: "already_processed" },
});
throw buildBusinessValidationError("workflow.task_already_processed", { task: "already_processed" }, 409);
}
}
@@ -1049,12 +999,7 @@ async function loadWorkflowApplicantIds(tx, workflowInstance) {
);
if (!record) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { workflow: "entity_not_processable" },
});
throw buildBusinessValidationError("workflow.entity_unavailable", { workflow: "entity_not_processable" }, 409);
}
return collectWorkflowApplicantIds(workflowInstance, record);
@@ -1065,12 +1010,7 @@ function ensureNoSelfApproval(actorIds, applicantIds) {
return;
}
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { workflow: "self_approval_not_allowed" },
});
throw buildBusinessValidationError("workflow.self_approval_forbidden", { workflow: "self_approval_not_allowed" }, 409);
}
async function ensureFinanceOwnerCeoActors(tx, workflowInstance, actorIds) {
@@ -1364,22 +1304,12 @@ async function ensureTransferTargetEligible(tx, toApproverId, approvePermission)
});
if (!target || target.status !== "active") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { toApproverId: "invalid_target" },
});
throw buildBusinessValidationError("workflow.transfer_target_unavailable", { toApproverId: "invalid_target" }, 409);
}
const roleCodes = target.roles.map((item) => item.role_code);
if (!roleCodes.some((roleCode) => roleHasBusinessApprovePermission(roleCode, approvePermission))) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { toApproverId: "target_not_approver" },
});
throw buildBusinessValidationError("workflow.transfer_target_ineligible", { toApproverId: "target_not_approver" }, 409);
}
}
@@ -1408,16 +1338,11 @@ function ensureWorkflowEntityType(workflowInstance, entityType) {
return;
}
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
throw buildBusinessValidationError("workflow.batch_entity_unsupported", {
task: "unsupported_entity_type",
entityType: workflowInstance.entity_type,
expectedEntityType: entityType,
},
});
}, 409);
}
async function ensureWorkflowReminderAllowed(tx, workflowInstance, task, request) {
@@ -1436,12 +1361,7 @@ async function ensureWorkflowReminderAllowed(tx, workflowInstance, task, request
assigneeRole: task.assignee_role,
});
if (recipientEmployeeIds.includes(request.employee.id)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { workflow: "self_remind_not_allowed" },
});
throw buildBusinessValidationError("workflow.self_remind_forbidden", { workflow: "self_remind_not_allowed" }, 409);
}
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);
throw new AppError({
code: "VALID_001",
message: "催办过于频繁",
statusCode: 409,
details: {
workflow: "remind_too_frequent",
nextAllowedAt: nextAllowedAt.toISOString(),
},
});
const error = buildBusinessValidationError("workflow.remind_cooldown", {
workflow: "remind_too_frequent",
nextAllowedAt: nextAllowedAt.toISOString(),
}, 409);
error.message = "催办过于频繁";
throw error;
}
async function executeRemindWorkflowTask({
@@ -1586,12 +1503,7 @@ async function executeApproveWorkflowTask({
const steps = getWorkflowStepsSnapshot(workflowInstance);
const currentStepIndex = steps.findIndex((step) => step.id === workflowInstance.current_step_id);
if (currentStepIndex < 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { workflow: "current_step_not_found" },
});
throw buildBusinessValidationError("workflow.step_missing", { workflow: "current_step_not_found" }, 409);
}
const now = new Date();
@@ -3215,12 +3127,7 @@ async function workflowRoutes(fastify) {
"approve",
);
if (!approvePermission) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { transfer: "unsupported_entity_type" },
});
throw buildBusinessValidationError("workflow.transfer_unsupported", { transfer: "unsupported_entity_type" }, 409);
}
const applicantIds = await ensureWorkflowApprovalAllowed(tx, workflowInstance, request, assignment);
const transferCount = await ensureTransferChainNotExceeded(tx, taskId);
@@ -3229,12 +3136,7 @@ async function workflowRoutes(fastify) {
await ensureFinanceOwnerCeoActors(tx, workflowInstance, [toApproverId]);
if (toApproverId === request.employee.id) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { transfer: "same_assignee_not_allowed" },
});
throw buildBusinessValidationError("workflow.transfer_same_assignee", { transfer: "same_assignee_not_allowed" }, 409);
}
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_required": "财务本人报销只能由非申请人的在职总经理审批或接收转审,不能委托其他角色代批。",
"reimbursement.invoice_prefill_incomplete": "票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。",
+41 -6
查看文件
@@ -214,6 +214,41 @@ async function buildApp(passwordHash) {
}
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 () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app } = await buildApp(passwordHash);
@@ -268,7 +303,7 @@ describe("auth login route", () => {
});
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();
});
@@ -293,7 +328,7 @@ describe("auth login route", () => {
});
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();
});
@@ -566,7 +601,7 @@ describe("auth login route", () => {
});
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({
where: { id: "email-code-1", used_at: null },
data: { attempts_count: { increment: 1 } },
@@ -870,7 +905,7 @@ describe("auth login route", () => {
});
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(emailLoginCodeUpdateMany).not.toHaveBeenCalled();
expect(auditCreate).not.toHaveBeenCalled();
@@ -966,7 +1001,7 @@ describe("auth login route", () => {
});
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({
where: { id: EMPLOYEE_ID },
data: expect.objectContaining({
@@ -1008,7 +1043,7 @@ describe("auth login route", () => {
});
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(passwordResetCodeUpdateMany).not.toHaveBeenCalled();
expect(auditCreate).not.toHaveBeenCalled();
@@ -106,6 +106,23 @@ function buildApp({
}
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 () => {
const bindableItems = [
{
@@ -199,7 +216,7 @@ describe("GET /api/invoice-pool/bindable", () => {
});
it("rejects reimbursements not in draft or rejected status", async () => {
const { app } = await buildApp({
const { app, prisma } = await buildApp({
reimbursement: {
id: REIMBURSEMENT_ID,
owner_id: OWNER_ID,
@@ -215,7 +232,8 @@ describe("GET /api/invoice-pool/bindable", () => {
});
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();
});
@@ -84,7 +84,7 @@ function buildPrisma(invoiceOverrides = {}) {
return { prisma, state };
}
async function buildApp(invoiceOverrides = {}) {
async function buildApp(invoiceOverrides = {}, options = {}) {
const { prisma, state } = buildPrisma(invoiceOverrides);
const app = Fastify();
@@ -94,7 +94,7 @@ async function buildApp(invoiceOverrides = {}) {
appOrigin: "http://localhost:5173",
maxUploadSizeBytes: 10 * 1024 * 1024,
invoiceArchiveUnusedDays: 30,
ocrProvider: "mock",
ocrProvider: options.ocrProvider ?? "mock",
});
app.decorate("authenticate", async (request) => {
if (!request.headers.authorization) {
@@ -102,7 +102,7 @@ async function buildApp(invoiceOverrides = {}) {
}
request.employee = {
id: OWNER_ID,
permissionCodes: ["invoice.upload"],
permissionCodes: options.permissionCodes ?? ["invoice.upload"],
roleCodes: ["employee"],
};
});
@@ -141,6 +141,53 @@ async function buildApp(invoiceOverrides = {}) {
}
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 () => {
const { app, state } = await buildApp();
@@ -130,6 +130,28 @@ async function buildApp({
}
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 () => {
const { app, prisma, tempDir } = await buildApp();
@@ -261,7 +283,7 @@ describe("invoicePool upload route", () => {
expect.objectContaining({
code: "VALID_001",
message: "请上传 PDF 发票文件",
details: { file: "pdf_invoice_required" },
details: { file: "pdf_invoice_required", reasonCode: "invoice_pool.pdf_required" },
}),
);
expect(prisma.invoicePool.create).not.toHaveBeenCalled();
@@ -302,6 +324,7 @@ describe("invoicePool upload route", () => {
message: "PDF 发票不能超过 7 MiB",
details: {
file: "ocr_pdf_too_large",
reasonCode: "invoice_pool.ocr_pdf_too_large",
maxBytes: 7 * 1024 * 1024,
},
}),
@@ -204,6 +204,58 @@ async function buildApp(options = {}) {
}
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(() => {
vi.clearAllMocks();
});
@@ -268,7 +320,7 @@ describe("invoicePool verification routes", () => {
expect(response.statusCode).toBe(409);
expect(response.json().error).toMatchObject({
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(app.boss.send).not.toHaveBeenCalled();
@@ -290,7 +342,7 @@ describe("invoicePool verification routes", () => {
});
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();
} finally {
await app.close();
+13
查看文件
@@ -188,4 +188,17 @@ describe('me routes', () => {
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 ?? {} } }));
await app.register(paymentAccountsRoutes, { prefix: "/api" });
return { app, auditCreate, notificationCreateMany, paymentAccountCreate };
return { app, tx, auditCreate, notificationCreateMany, paymentAccountCreate };
}
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 () => {
const testApp = await buildApp(["payment.account.read"]);
const response = await testApp.app.inject({ method: "GET", url: "/api/payment-accounts" });
@@ -196,6 +196,32 @@ async function buildApp(options = {}) {
}
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 () => {
const { app, state } = await buildApp({
reimbursementOwnerId: ACTOR_ID,
@@ -212,6 +238,7 @@ describe("workflow batch routes", () => {
code: "VALID_001",
details: expect.objectContaining({
workflow: "self_approval_not_allowed",
reasonCode: "workflow.self_approval_forbidden",
}),
}),
);
@@ -271,6 +271,7 @@ describe("workflow transfer route", () => {
code: "VALID_001",
details: expect.objectContaining({
toApproverId: "target_not_approver",
reasonCode: "workflow.transfer_target_ineligible",
}),
}),
);
@@ -306,6 +307,7 @@ describe("workflow transfer route", () => {
expect(response.json().error.details).toEqual(
expect.objectContaining({
toApproverId: "target_not_approver",
reasonCode: "workflow.transfer_target_ineligible",
}),
);
expect(tx.workflowTask.update).not.toHaveBeenCalled();
@@ -341,6 +343,7 @@ describe("workflow transfer route", () => {
code: "VALID_001",
details: expect.objectContaining({
workflow: "self_approval_not_allowed",
reasonCode: "workflow.self_approval_forbidden",
}),
}),
);
@@ -34,6 +34,29 @@ describe("client business error projection", () => {
.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) => {
expect(resolveErrorMessage(new ApiError({ status: 400, code: "VALID_001", message: "private payload", details: { reasonCode, internal: "/private/secret" } }))).toBe(fallback);
});