比较提交
2
次代码提交
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
842c61e696 | ||
|
|
3516d7ec04 |
@@ -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,21 @@
|
||||
# 隔离备份恢复演练实施计划
|
||||
|
||||
## 2026-09-15 简单配对恢复的集中批准边界(未执行)
|
||||
|
||||
不新增runner。已知可识别的历史输入为receipt-followup-release回滚备份:manifest `56a9addf57556580c46205ae8e68b01a8e23a45ae7757d4cfce9ec9c92660c86`、来源8d08d46d;对应镜像归档SHA256 `d17d7efaef073b5cc6b78f1ce2a73e938835e4910946c83e855b48e61780541b`,历史回读通过。它不是已核最新自然备份,不能报24小时内,也不能换成b7或当前本地代码假装同源恢复。
|
||||
|
||||
拟定单次范围:在ARM侧隔离的一次性Postgres16及匹配应用容器中恢复该固定源DB和storage,使用专用空卷、无端口映射的internal网络、禁OCR/通知/支付/ERP等外联和定时任务。实施前只读重核源manifest/包摘要/镜像身份、目标容量与生产ready基线;无法获得无业务副作用的应用启动配置则停在DB+附件结果,不把app记PASS。镜像加载、源副本、容器/卷创建和恢复均待专项批准。
|
||||
|
||||
验证仅限迁移集合/校验和、关键表聚合计数、附件引用存在性及可用大小/摘要、隔离应用健康与授权可读性;不回传原件或账号,不改生产引用。历史缺失附件仍无可定位的治理落库证据,若再次出现,分类报告NO_GO,不自动排除“测试件”。只删除本轮确实创建且身份可核的容器/网络/卷/临时副本,保留原备份、旧回滚镜像与生产服务;末尾复核ready及清理结果。
|
||||
|
||||
此固定历史源方案只回答“已备份的数据能否配对恢复”。最新自然批次/异机传输/当前生产灾备就绪是另一个只读时效核验,必须以执行时的新事实作结论,不能用此历史恢复代替。当前状态PREPARED_SCOPE_NOT_AUTHORIZED,不是恢复通过或完整生产就绪。
|
||||
|
||||
## 2026-09-15 统一收口接续
|
||||
|
||||
统一计划入口见09-08-reimbursement-payment-closeout/implement.md六项计划,本task负责C的配对恢复,ARM传输事实由08-28-production-arm-backup-transfer-incident核对。当前已知有历史DB恢复PASS和近期备份/镜像完整性PASS,不能回退说“从未恢复数据库”,也不能因此宣称DB+附件+应用恢复已完整通过。
|
||||
|
||||
先核09-04任务历史缺失附件的实际治理结果/批准范围,不能把“可能是测试”当可删除授权清单。准备同一manifest的DB/附件、匹配应用镜像、固定隔离目标/禁止生产连接和通知OCR外联、容量上限、清理范围与停止条件;形成一次准确受控恢复请求,不重复升级复杂runner。授权后一次简单恢复核迁移、关键表、附件引用及适用摘要、应用只读健康/可读性,原件内容不出日志。任何缺失归因分清备份不全、历史源缺失与工具误判,不强行删除引用通过门禁。
|
||||
|
||||
## 2026-09-11 配对恢复准备(未执行)
|
||||
|
||||
用户曾回传一次 DB_RESTORE_PASS:66 条迁移集合匹配、12 类表已验、隔离资源已清理。这是管理员回执,不是本轮独立复验;不能继续写“数据库从未成功恢复”。附件与应用配对恢复尚未证明。
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# 实施计划:审批退回结果通知缺失治理
|
||||
|
||||
## 2026-09-15 本地通知链再验(未发生产消息)
|
||||
|
||||
现有workflow.return-notifications随本轮联合19文件471项回归通过,额外运行`npm test -- tests/unit/routes/notifications.test.js tests/unit/services/imRouting.test.js tests/unit/services/notificationDispatcher.test.js`,3文件27/27通过,日志/tmp/followup-channels-tests.log。覆盖站内/派发编排、渠道管理和过滤逻辑,不代表真实企业微信接收成功。本轮未变routing、未修改生产配置,也未制造真人退回单或发送测试消息。
|
||||
|
||||
现有实现不需要重复开发;外部验收剩余是执行时只读核实际渠道开关/过滤矩阵与自然投递回执。若必须新建测试消息或改配置,按具体接收对象/消息数量/脱敏内容集中批准后再执行,不让员工承担软件回归。
|
||||
|
||||
## 2026-09-15 统一收口接续
|
||||
|
||||
统一计划入口见09-08-reimbursement-payment-closeout/implement.md六项计划,本task负责B的通知部分。2e69fe4e为已有提交且是当前候选祖先,不重复新增notification type;先核业务类型、投递routing、站内待办和消息delivery四层的已测/已发布/待验事实。对应09-11门户任务只读投影不依赖消息成功。
|
||||
|
||||
默认自主做现有实现和证据核对、合成退回/重提及失败容错测试;新生产routing或企微写入、真实员工消息验收列入集中操作清单后另批。不能为拿到PASS制造真实退回,不让员工承担软件基础回归;真实投递缺证据要分列,不以代码包含性作完结依据。
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. 在通知类型常量中新增 `leave.returned`、`purchase_request.returned`、`travel_request.returned`,加入 IM 支持表和领域映射。
|
||||
|
||||
@@ -1,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 个已证实产品故障。本轮主线修复不改到期日,不重复建立豁免台账。
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 业务闭环方案(分阶段实施,发布状态见执行记录)
|
||||
|
||||
## 2026-09-15 统一收口编排
|
||||
|
||||
以本task implement作为唯一协调清单,各模块原task仍为实施合同。执行顺序:事实对账→待办/通知缺口及错误原因并行准备、串行代码实施→配对恢复工具与受控操作包→合成联合验收→按范围分笔提交/整批发布→自然业务观察。一个模块的外部等待不暂停其他模块;不能为追求“一次完成”跳过独立审查或混合权限状态机改动。
|
||||
|
||||
对账依据优先使用当前发布候选b7d2285d及签名/摘要证据,再回溯Git和task历史。task.json.commit是历史锚点不是总完成凭证;共享文件按补丁集成,不覆盖整份spec。现有09-04两份文档、统一审批中心文档及当前09-08发布记录是已知未提交差异,不纳入新产品补丁而不说明。
|
||||
|
||||
待办只投影真实状态,不另建审批引擎;退回通知先核已提交实现与生产routing,再决定是否需要代码修复。错误治理保持服务端原因/客户端映射/断言同一变更单元;不全文件替换泛化错误。恢复沿最简单一次性DB+空附件目录+禁外联应用,先列固定源和目标身份、资源上限、清理列表和停止条件,再集中请求真实数据恢复授权;不继续扩复杂runner。
|
||||
|
||||
回单自然验收与密钥来源是事实/运维工作,不通过改生产记录制造测试结果。没有真实待处理批次时,先用合成职责矩阵验证maker/proposer/owner/submitter排除,实际参与人出现后再确认人选;不自动给admin/finance加复核权限。新证据只保存安全摘要,明确合成/真实、手动入队/自然调度、隔离/生产等边界。
|
||||
|
||||
## 2026-09-15 系统失败引导最小收口
|
||||
|
||||
从未归档附件投影是否存在system_failed/key_failed/snapshot_failed;仅在现有readiness未满足横幅中优先展示管理员排障引导,不修改canRegisterPaymentComplete或服务端资格。其他可用证据满足readiness时不因旧失败附件新增阻断。重新核对按钮仍只读refetch,不发OCR。恢复沿用修复前提后另批重新上传原件的现有路径,旧附件与审计保留,不引入新的管理员角色权限或批量SQL重排队。
|
||||
|
||||
@@ -1,5 +1,177 @@
|
||||
# 执行顺序(本地技术审查/修复已批准,真实业务动作另批)
|
||||
|
||||
## R1外部内容结论接回与R2修订
|
||||
|
||||
已收到新Claude会话的只读内容审查:有条件通过,F1动态文案退化阻断,F3/F4需登记。本轮按原范围修复F1/F2并以311项定向回归、真实登录JWT+隔离DB+浏览器缺字段修正专项证明;F4转审文案写明10至500字;F5补财务双端href及BX/PB均消失断言,22项浏览器重跑通过。F3共享日期解析及其消费者留待独立批次,无历史数据回填;F6批量错误透传为后续债务,F7防枚举声明仅限邮箱码链,详情见09-05记录。
|
||||
|
||||
R1保留,不将R1有条件通过称为R2已独立放行。新R2需完整/增量/两笔拆分重放一致并登记源摘要,再做增量内容复核;当前仍未提交/推送/部署。两笔分组继续产品修复与纯任务记录,三处旧差异排除不动。
|
||||
|
||||
## 2026-09-15 交审与集中操作边界(未提交/推送/部署)
|
||||
|
||||
本轮不再追加功能,冻结当前错误治理及六项收口记录到本地交审包error-explainability-review-20260915-r1。拟按两笔路径拆分:09-05产品原因码/日期校验/测试/规约/本任务执行记录;09-08及关联task的纯协调/验收/恢复计划记录。共享文件不重叠,两笔顺序重放须等于完整冻结树。既有09-04-reimbursement-state-gap-finance-view的design/implement与docs/需求-统一审批中心规约.md三处旧差异排除,不覆盖、不提交。
|
||||
|
||||
包内必须如实登记:未提交提交对象、未推送、未部署;主会话自检而非独立内容审查;471/27/22与构建为前阶段原日志复用,不新增测试通过计数,日志脱敏后单独摘要;冻结树只证明补丁/源一致,不回溯证明原测试时已冻结。当前规约/治理重验单独登记,不伪造新的业务验收。
|
||||
|
||||
集中操作清单:①只读内容复核与两笔提交/推送准备(本地包不自动批准提交);②复核通过后绑定实际新候选重新核生产版本/迁移/磁盘/备份/独立回滚制品,取得该候选发布例外再部署并由执行端验收;③08-23固定历史同源备份恢复单独授权,不与发布合并为默认副作用;④真实通知或额外OCR如确有需要,按接收对象/资料/次数单独批准;自然业务未出现则等待,不重开已付款历史单。旧候选b7d2285d的已消耗授权不覆盖本包。
|
||||
|
||||
## 2026-09-15 第三阶段统一接续(优先于旧“余项未实施”文字)
|
||||
|
||||
收口结果:D联合19文件471/471、通知三文件27/27、本轮构建双端浏览器22/22(零重试)通过;typecheck/build/AST/流程治理通过。B四交接本地验收矩阵已回填09-11 task,临时preview已停止。以上均是本地验证,不新增生产验收或独立复核通过计数。
|
||||
|
||||
剩余事项分层:本批代码待按路径收口及只读审查,取得明确提交/推送/新候选发布批准后才能上线;C固定历史源配对恢复方案已写入08-23 task,真实源恢复/镜像加载待专项授权,最新自然备份/传输另需新时点只读事实;E自然新回单没有对象不造业务;F来源UNKNOWN保留风险结论、不轮换。A的22行对账是已完成初核,不代表全部历史PRD的验收证据已补齐;其余模块到期治理保留原负责人和期限,不能用当前通过回归宣称全项目所有技术债清零。
|
||||
|
||||
D已补齐invoicePool主路由、查验查询以及workflow路由剩余原因码,目标路由遗留清零;全库695→612,仍有其他域和utils/workflow的遗留,不统一延期。发票日期负例同时暴露日期溢出问题,已本地补UTC回读校验,未改历史记录。最终证据归09-05原task;不能将本地修改称为b7d2285d已发布。
|
||||
|
||||
B沿已有四交接浏览器用例执行PC/手机重验,不重写已上线投影。通知路由/渠道/dispatcher三文件27/27通过:本地合成证明配置过滤及发送编排,不证明真实企微消息到达。自然新回单和真实通知缺对象/授权时如实等待,不让员工反复代测。
|
||||
|
||||
F闭环口径:生产字段加密密钥来源UNKNOWN;仓库中测试生成器/固定合成值不证明生产来源。现有受控单页OCR隔离队列PASS只证明那条运行链可用,不证明生产密钥高熵。不再追问用户,不读取密钥值/历史命令、不自行轮换;未来如需轮换,必须独立制定双密钥解密、存量指纹兼容、恢复与回退方案。身份矩阵沿当前资格重验回归;实际人工例外须在真实来源人与提案人确定后判断,不能提前保证有人可审。
|
||||
|
||||
提交/推送/新候选发布、真实配对恢复及消息发送仍是需集中明确授权的操作,不沿用旧授权。所有本地完成项与外部验收等待项最终一起汇总,不按task数量重复实现功能或批量归档。
|
||||
|
||||
## 2026-09-15 连续实施第二阶段(以此补充首批记录)
|
||||
|
||||
最终14文件同轮367/367本地回归通过,涵盖登录、付款账户、选票/文件授权、发票查验、审批输入/权限/转审/退回通知/详情、门户与回单独立复核。未重跑公网或生产通知,不把合成回归当真实交接;本轮不要求员工代测。AST及流程治理通过,具体命令见09-05执行记录。
|
||||
|
||||
D继续完成auth/me、发票查验错误及七个审批输入parser,连同首批共新增34个注册原因;最终AST legacy695→656,未延长任何到期日。权限、审批状态、付款资格、云端发送及数据库结构均未改。各轮针对性测试通过,最终同输入合并验证另记。实现/具体错误名单/证据归09-05原task,不把第一批“auth/me待做”误读为当前未实现,也不把全部错误治理当作已完成。
|
||||
|
||||
下一顺序仍是:完成invoicePool上传/识别与workflow余项的逐操作迁移和验收;核四交接PC/手机证据及通知投递边界;把恢复源/目标/历史附件处置凭据集中成可批准清单。没有真实自然回单不制造业务,来源未知的密钥不猜测、不向用户再次索要,也不自行轮换。缺失历史附件不能仅据“按已确认处置保留”的历史文字就认定已落库治理完成。
|
||||
|
||||
整个六项主线未完成,本地提示修复尚未发布。提交、发布、真实配对恢复分别需要明确范围授权;不为上述本地工作反复提问,不沿用已消费的部署/OCR授权。
|
||||
|
||||
## 2026-09-15 统一计划首批实施进度
|
||||
|
||||
A:已形成下方22项四栏事实表,区分当前发布包含性与全量验收缺证据,不批量归档或重复部署seed/门户。A仍有历史验收证据关联工作,不能只靠表格标全完成。
|
||||
|
||||
B/F:实际运行portal、workflow.return-notifications、paymentReceiptManualReview三文件47/47通过(4/4/39)。这是已有本地逻辑回归,不是PC/手机重测或生产企微投递;本轮未发现要重写的状态/资格逻辑。密钥来源仍未知,用户此前不知道内容/时间/方式,不再向用户索要,亦不以“管理员创建”作证据。
|
||||
|
||||
D:付款账户和发票读取的14个具体错误解释已实施,AST legacy695→687,日期未延长;四文件同轮207项通过,客户端typecheck通过。代码与证据细节归09-05-error-explainability-payment-readiness/implement.md。新提示尚未提交或发布,不沿用b7d2285d公网通过宣称已上线。
|
||||
|
||||
C:在本执行副本已查任务记录与现有qa证据入口,没有找到能闭合历史缺失附件实际标记/删除决定的记录,因此不能按“测试件”自动剔除。当前仍准备具体隔离恢复范围:优先选最新已核完整的同manifest源,绑定源镜像而非当前任意镜像;恢复前核源时效、容量、隔离身份、禁外联及精确清理范围,生产原记录不改。缺失治理决定与恢复写入须在集中授权清单明确;不新造复杂runner掩盖这个证据缺口。
|
||||
|
||||
E:真实单页隔离队列PASS保留,不重复消耗腾讯调用;自然生产新回单仍等正常业务对象。上述等待不阻止D后续auth/me、发票识别与审批错误迁移,但这几域尚未做完,不把第一批当整个统一计划完成。
|
||||
|
||||
## 2026-09-15 A阶段22项对账结果(发布基线b7d2285d)
|
||||
|
||||
下表“包含”只指已有代码锚点是发布候选祖先;无登记提交则交叉查Git,不以元数据空值推断未实现。未核全量验收的行明确保留等待,不批量改status或归档。当前代码差异另列D阶段,本表不将未提交提示修复称为上线。
|
||||
|
||||
| 原task | 代码事实 | 发布事实 | 业务/验收状态 | 剩余/等谁 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 08-23-isolated-backup-restore-drill | 有现有工具/合同,无task提交锚点 | 运维工具不以应用发布计完成 | 历史DB恢复有PASS,配对恢复未证实 | 执行端准备源/目标并集中申请恢复授权 |
|
||||
| 08-28-production-arm-backup-transfer-incident | 有只读调查及传输阶段诊断 | 旧调查不证明当前异机传输 | 当前自然批次待重新核验 | 执行端只读核传输/异机完整性 |
|
||||
| 08-28-reimbursement-detail-material-preview-fixes | 46bfa7e包含 | 包含于当前发布祖先 | 父task不能按子项计数直接结案 | 对齐部署子项与只读投影子项 |
|
||||
| 08-28-reimbursement-detail-material-preview-deploy | task无提交锚点,不等于未发布 | 与父实现发布证据交叉核对 | planning文字过期与否需按验收判断 | 补映射而非重复部署 |
|
||||
| 08-28-reimbursement-nonparticipant-readonly-projection | e8d6766包含 | 当前候选包含 | 原角色隔离验收不能由本次门户4项代替 | 核原测试/独立审查边界 |
|
||||
| 08-29-reimbursement-expense-policy-invoice-guard | 6e999d5包含 | 当前候选包含并有后续发票优先改动 | 不回退到旧流程重新实现 | 按最新合同关联后续验证 |
|
||||
| 08-30-reimbursement-expense-policy-invoice-guard-deploy | 无登记commit | 与父task代码及部署证据对齐 | 缺本轮全量证据映射 | 补发布/业务验收分栏 |
|
||||
| 08-30-reimbursement-assets-prior-approval-correction | 36c86f1包含 | 当前候选包含 | 本次未测试资产业务 | 不扩改资产规则,核旧验收 |
|
||||
| 09-01-reimbursement-one-page-approval-workbench | 父task,无登记commit | 子项有已包含代码 | 子项完成不等于父体验全部完成 | 核三子项最终验收 |
|
||||
| 09-01-reimbursement-approval-content-workbench | c840574包含 | 当前候选包含 | 本次不重做审批详情布局 | 补原UI证据映射 |
|
||||
| 09-01-reimbursement-form-shared-component-migration | 4b974fd包含 | 当前候选包含 | 本次未再做组件迁移 | 以原消费者回归核范围 |
|
||||
| 09-02-reimbursement-readonly-content-consistency | e48514d包含 | 当前候选包含 | 不能用付款页面验收覆盖只读页面 | 补只读角色及内容证据 |
|
||||
| 09-04-approval-return-notification-gap | 2e69fe4e包含 | 当前候选包含 | 本轮退回通知4项单测通过,不是生产投递 | 核routing及自然/受控投递,生产写入另批 |
|
||||
| 09-04-reimbursement-state-gap-finance-view | 后续229867f5等包含 | finance-owner-release已有发布/公网回执 | 当前代码已上线,不再“待部署” | 分开历史附件治理及未提交事实记录 |
|
||||
| 09-05-error-explainability-payment-readiness | a837912及后续原因码包含;本轮新增代码未提交 | 旧模块已包含,新一批提示未部署 | 本轮AST基线PASS,D阶段仍在实施 | 执行端按操作收口,不统一延期 |
|
||||
| 09-05-existing-deploy-db-lifecycle-isolation | f223bfb包含 | 本次部署证明DB未重启 | 本次运行证据不是首次初始化全路径测试 | 合并已存合同测试与发布回执 |
|
||||
| 09-05-trellis-upgrade-rule-cleanup | review状态,无登记commit | 本地工作流无需公网发布 | 本轮治理检查PASS不代替原改动审查 | 核既有规则清理差异,不重装Trellis |
|
||||
| 09-06-release-gate-integrity | 81102c26有提交 | 当前发布实际执行前后预检PASS | 当前pending=none,不伪造故障生产演练 | 故障断言沿原合成证据对齐 |
|
||||
| 09-07-manager-ap-access-scope | 有既有权限修订历史,task未登记commit | 不凭任务空字段宣称未上线 | 本轮未全量审角色/数据范围 | 查实际提交/矩阵,禁止顺手摘角色 |
|
||||
| 09-08-reimbursement-payment-closeout | 8d08d46d/b7d2285d | 部署与公网只读验收通过 | 43项隔离链及单页真实队列PASS | 自然生产新回单、密钥来源未知、例外职责按实际对象核 |
|
||||
| 09-09-existing-deploy-seed-separation | b87cc55a有提交 | 当前候选包含且existing发布执行成功 | 旧“本地未发布”只代表历史时点 | 不重复拆seed;对齐初始化与配置检查证据 |
|
||||
| 09-11-reimbursement-payment-handoffs | fedcaed1及后续身份取消修复包含 | 当前候选包含 | 本轮portal4项通过;PC手机历史66项不虚增重跑 | 身份切换原证据与实际交接验收分列 |
|
||||
|
||||
本轮已消除“22个活跃task=22项未开发”的误导;没有把以上待证据映射项记为全部完成。继续优先能影响当前报销付款业务的B/D/C/F,不为了归档历史大task扩做资产/采购新功能。
|
||||
|
||||
## 2026-09-15 六项统一执行计划(后续工作入口)
|
||||
|
||||
用户要求一次规划连续执行,非必要不提问。本表覆盖后续顺序,不覆盖下文已完成发布/测试证据;新产品实现归对应既有task,当前阶段不新建任务、不批量归档。执行端负责软件验收,真实资金决定仍由业务负责。
|
||||
|
||||
| 阶段 | 归属task | 本轮交付及完成条件 | 等待/批准边界 |
|
||||
| --- | --- | --- | --- |
|
||||
| A 事实对账 | 本task协调,各原task回填 | 22项逐个列代码、发布、业务验收、等待项;核候选祖先/实际文件及发布回执,旧文字加日期纠正,不抹历史 | 不用祖先关系代替全量PRD验收;归档/提交不自动执行 |
|
||||
| B 待办通知 | 09-11-reimbursement-payment-handoffs、09-04-approval-return-notification-gap | 四交接角色矩阵、计数/入口/完成消失、站内与外部通知边界;补确认缺陷及PC/手机、真实隔离HTTP回归 | 企微发送/生产routing变更另列一次操作清单;不为测试制造真人退回 |
|
||||
| C 恢复可靠性 | 08-23-isolated-backup-restore-drill、08-28-production-arm-backup-transfer-incident;历史附件治理归09-04 | 核当前自然备份/异机传输、缺失引用处理决定及证据;准备并在获批隔离目标完成DB+附件+应用只读恢复,逐项PASS/NO_GO和清理回执 | 当前只有备份完整性及既往DB恢复证据;具体真实源/目标恢复另批,未知附件不删,不覆盖生产 |
|
||||
| D 错误到期治理 | 09-05-error-explainability-payment-readiness | 重跑实际AST盘点,按操作修paymentAccounts、invoicePool、auth/me、workflow高频已启用路径,原因/映射/测试齐全;余项有理由、责任与复验点 | 不把673条基线当673个故障;不统一续期,不更改未启用模块的业务规则 |
|
||||
| E 自然回单验收 | 本task | 保留已PASS单页真实隔离队列;新业务自然出现后只读跟踪queued→匹配/异常→登记前资格,记录生产链实际覆盖 | 等正常业务对象;没有对象不重复OCR、不重新开已付单,不作为其他本地工作阻塞 |
|
||||
| F 密钥/例外职责 | 本task,角色范围参考09-07-manager-ap-access-scope | 只查非敏感来源/保管/恢复记录;未知有风险结论和后续处理方式;合成矩阵验证独立复核,实际例外按参与人核资格 | 用户明确不知道密钥内容/生成时间/方式,撤销“管理员创建已确认”的推断;不再追问、不换密钥或扩权 |
|
||||
|
||||
### 执行清单与里程碑
|
||||
|
||||
- [x] 确认官方指针09-08及当前生产候选b7d2285d,记录现有未提交差异,不跟随过期09-04注入换范围。
|
||||
- [x] 扫描22个task元数据及历史commit是否为当前候选祖先;只形成初核,不声称全量对账完成。
|
||||
- [x] 重新统计错误基线:673个entries,count合计695,expiresOn均2026-10-05;09-15距到期20天。计数不是当前源码仍命中的AST数量。
|
||||
- [ ] A逐项对齐验收与证据,优先已上线seed/preflight/AP/门户/付款,剩余页面和通知按实际commit核范围;缺证据项列出具体缺什么。
|
||||
- [ ] B确认业务路径实际缺口后做最小修复,不重复已上线的门户身份隔离;补站内/企微渠道分层验收。
|
||||
- [ ] D在09-18前完成首批高频操作迁移和定向验证;09-25复审余项,09-30前准备逐模块延期理由,10-02查漏。日期是检查节点,不声称已完成或自动计划任务。
|
||||
- [ ] C并行准备真实配对恢复清单,核旧附件治理结果;批准具体源/目标后执行一次简单恢复,不扩演练装置。
|
||||
- [ ] F先完成来源证据调查和职责合成矩阵;未知留结论,不以无限调查阻塞其他工作。
|
||||
- [ ] 一次联合检查:后端定向单测/真实隔离DB与HTTP、前端typecheck/build及PC/手机流程、文档与治理;权限/状态机差异要求只读复核,inline受限如实留待审。
|
||||
- [ ] 汇总需要提交/推送/部署/恢复/生产配置的准确范围及风险,一次呈报;批准后分笔提交、整批发布并由执行端公网验收,不逐按钮征询。
|
||||
- [ ] E等待自然业务对象时只读观察;最后交付已完成/外部等待/已知风险三栏,不用“全完成”掩盖自然业务或未知来源。
|
||||
|
||||
### 初核发现(不是全量验收)
|
||||
|
||||
22个task中12个登记的历史commit为HEAD祖先,10个未登记commit;未登记不等于没提交,登记旧SHA也不等于任务全完成。Git另查到seed=b87cc55a、预检=81102c26、门户交接=fedcaed1及后续身份修复、退回通知=2e69fe4e均有提交。当前发布包含性须结合后续差异核验,不据旧“未提交/未发布”段落重做。
|
||||
|
||||
错误基线首批候选计数:paymentAccounts 1;invoicePool主路由30/read7/verification7/shared schemas1;auth路由15+插件1、me2;workflow路由20+utils15。workflowDefinitions11单列配置域,不因名称相近扩入首批。下一实际AST扫描需识别已消除但未清理的基线,不能仅按这些数承诺修改量。
|
||||
|
||||
执行中只在范围/风险/验收实质改变、需要真实资金或新增生产敏感写入、无法从仓库确定的业务决策时提问。普通实现取舍、测试工具修正、同范围重验由执行端自主处理;外部等待转做其他已批项。不新建定时任务,不默认提交、归档、清理或使用旧发布授权。
|
||||
|
||||
## 2026-09-15 单页真实回单正式队列隔离验证通过
|
||||
|
||||
用户明确授权后,先以合成样本走同一工具。首轮worker已matched但验证工具错误把非唯一attachment_id用于findUnique,导致工具NO_GO;未调用腾讯且完整清理。修为按attachment_id及当前source_version查最新证据后合成PASS。此为工具修复,无产品源码变更,不消耗真实调用预算。
|
||||
|
||||
随后真实阶段仅执行一次:PASS_ISOLATED_QUEUE,externalCalls=1、pages=1、attachmentState=matched、claimedVersion=1、queueWorkerMatched=true、evidenceEncrypted=true。沿正式startReceiptPgBossJob→真实pg-boss消费→正式runReceiptOcrTick/recognizePaymentReceipt→腾讯BankSlipOCR→正式匹配/加密落库;原件与冻结付款信息来自此前批准的同一历史单页样本,金额与来源单金额在调用前核相等。normalized和账号/原件只存在主机管道及隔离tmpfs内,不输出普通日志,不保存至本地工作区。
|
||||
|
||||
paymentRecorded=false,隔离库claim为0、批次保持reviewed;productionBusinessWrites=false、productionRuntimeUnchanged=true、cleanupComplete=true。一次性数据库/runner容器及独立网络均删除,原件临时存储和加密证据随tmpfs消失。没有改生产executed批次、legacy附件、paid/claim或角色,没有额外调用和自动重试。
|
||||
|
||||
证据isolated-queue-synthetic.json与isolated-queue-real.json位于receipt-followup-release目录;运行器queue-isolated.cjs SHA256=d0fdcacbf7fb0bb0a23feaf7c466b3553aa2a9f298f7300fd971b52f3f06d36b,host脚本=fdf9abea5c70a40ac4b9f27f9ff0476be1bb45e6986e2fc45f44ec835689c7de。语法及diff检查通过。此轮未独立复核工具、不新增产品提交或发布。
|
||||
|
||||
结论边界:该真实单页逐笔回单的正式队列worker/腾讯/匹配/持久化适配已验证,不再列为纯合成未知。测试采用显式向隔离队列send一次(manuallyEnqueuedIsolatedJob=true),未证明自然cron触发、生产上传链或多笔汇总回单模型;银行验真/到账不在此证据范围。生产未来新回单正常上传后可只读跟踪自然消费,不能为了验收重新上传已付款历史。剩余密钥生成来源需要管理员提供生成方式/受控记录位置(不要密钥正文),实际人工例外复核资格须待具体批次/提案参与人出现后判定,不因当前无批次声称全部通过。
|
||||
|
||||
## 2026-09-15 授权单样本隔离队列验证
|
||||
|
||||
用户批准复用此前一份历史已付款回单、腾讯最多一次。实现为生产服务器上的一次性Postgres容器/独立Docker网络/当前候选镜像只读runner,数据库与原件临时存储均tmpfs;不绑定生产存储到runner。先以合成样本验证工具。真实阶段仅在生产容器执行固定原样本选择的只读事务及原件大小/SHA/单页校验,凭据/原件/解密候选仅经主机内存管道传入runner,不输出日志、不转移本地、不复制生产字段密钥;隔离库使用随机新密钥加密证据。
|
||||
|
||||
采用正式startReceiptPgBossJob注册真实pg-boss队列,显式向隔离队列send一次任务,由worker自行领取→正式recognizePaymentReceipt/Tencent→匹配及加密落库;不直接调用runReceiptOcrOnce,不调用付款登记。SDK方法加仅一次预算守卫,不替换真实阶段的返回值;单页条件在调用前强制。测试只能证明显式入队后的正式worker链,不冒称自然cron触发或生产上传链。真实尝试开始即留STARTED标记,失败/超时不能自动再发送。finally仅清理本轮容器/网络/tmpfs,另核生产服务身份未变;清理失败须报告,不删除旧制品。
|
||||
|
||||
## 2026-09-15 发布后业务就绪与队列再核
|
||||
|
||||
用户要求继续下一步,本轮执行只读元数据查询,无生产业务写入、原件读取或腾讯调用。北京时间16:14核到线上仍b7d2285d、Tencent/pgboss/jobs启用,最近30分钟payment.receipt.ocr任务30条completed,限定日志内tick失败0。有效附件仍两份legacy;reviewed/failed报销批次为空,因而不存在本轮可按真实来源/提案人核定独立复核人的对象。空集合不是“全部资格通过”,completed空转不等于真实回单消费。
|
||||
|
||||
密钥字段已配置,但受查现有部署指引/任务记录未提供生产密钥随机生成的来源凭证,不能通过长度、密钥摘要或测试脚本的randomBytes推断生产高熵;未读取密钥值、未查shell历史、未轮换。只读证据business-readiness-current.json与queue-current.json位于本轮release证据目录。
|
||||
|
||||
下一受控验证优先在隔离数据库/存储内运行正式pg-boss调度链,使用既有已授权样本的一份原件须重新明确读取/腾讯发送范围,最多一次provider调用;不得将已执行生产批次改回reviewed、重排legacy或伪造真实付款。允许范围未确认前仅准备方案,不实施真实原件重传。若未来实际业务产生新回单,可对正常业务自然消费做只读跟踪,避免额外发送。密钥来源需要管理员提供生成方式或受控记录位置,不发送密钥正文。当前仍未完成这两项事实验收,不新增任务、不将软件回归转交员工。
|
||||
|
||||
## 2026-09-15 b7d2285d发布及公网只读验收通过
|
||||
|
||||
授权发布b7d2285d8d1a2a912222917f7c74067f8bd9f810完成,部署命令退出0,build-init/run-init/build-server/build-switch-server均PASS,应用发布及自动备份合同刷新完成。发布前与发布后正式只读迁移预检pending=none。三份既有回单迁移finished=true/rolled_back=false且checksum逐一匹配候选SQL,无本次新增迁移。
|
||||
|
||||
公网/ready构建标识匹配、页面可达、bundle扫描105份资源/零finding;递归读取公网JS确认新的系统失败与受控重传文案已发布,匿名付款批次API返回401。运行版本精确等于候选,server/db均运行,db容器身份/启动时间不变,环境文件摘要不变。此为公网只读/资源验收,不伪称真实登录生产业务验收;完整密码JWT浏览器付款链证据仍为隔离合成测试43项。
|
||||
|
||||
队列只读回读payment.receipt.ocr已注册,jobsEnabled=true、Tencent provider、Asia/Shanghai每5秒调度,限定日志范围内无receipt tick失败;历史未归档附件仍两份legacy,未重传。本次检查不证明真实回单已消费,未代付、未改业务角色/银行资料或向腾讯新增发送原件。
|
||||
|
||||
证据位于artifacts/qa-layout/receipt-followup-release:deploy-fresh-preconditions.json、deploy-result.json、acceptance-result.json、runtime-after.json、queue-after.json。当前版本发布前备份与镜像归档已独立回读,未restore/load演练。可对外称本轮修复已部署且公网只读验收通过,不将项目全部任务、真实回单闭环、合格独立复核人或密钥来源核实标记完成。剩余:在明确原件范围授权下验证新回单真实队列消费及形态,按实际例外参与人确定合格复核人,并核实密钥生成来源;不让员工代测软件基础功能。
|
||||
|
||||
本次发布记录为b7d2285d提交后的本地task补记,尚未提交;候选内容未变,不自动创建额外记录提交或归档任务。
|
||||
|
||||
## 2026-09-15 当前版本恢复制品补齐与授权发布
|
||||
|
||||
用户明确批准当前线上版本备份和独立镜像归档写入,并承接b7d2285d本轮发布授权。复用已审查备份工具,固定8d08d46d构建与运行镜像、校验三份备份脚本SHA,持有自动备份互斥锁,只新增手工备份、不更新自动成功状态;数据库/存储大小与SHA核对后另连接扫描同源备份独立回读通过,manifest SHA256=56a9addf57556580c46205ae8e68b01a8e23a45ae7757d4cfce9ec9c92660c86。运行容器身份/启动时间未变,未声称恢复演练通过。
|
||||
|
||||
独立镜像归档1120237568字节、镜像配置和各层摘要验证通过,并另连接全文件SHA/权限回读PASS_IMAGE_INDEPENDENT_READBACK;同机Docker存储外制品,未docker load、未清理旧制品。证据位于artifacts/qa-layout/receipt-followup-release中create-backup-result/check-current-backups-result/save-image-result/image-readback/runtime-before-switch等JSON。
|
||||
|
||||
发布源为远端精确候选b7d2285d的干净克隆;发布脚本重新核验生产仍8d08d46d、手工同源备份24小时内且完整、部署身份归档匹配、剩余空间不少于15GiB、服务身份及环境摘要不变。预期迁移显式为空、SYNC_ENV=0、不开新worker/minio。仅所有条件通过才执行既有生产bootstrap部署;不自动退镜像、代付、发真实回单或改角色。实际发布/公网结果后记,不能将本准备记录称为已部署。
|
||||
|
||||
## 2026-09-15 b7d2285d已推送,发布停在备份与回滚前置
|
||||
|
||||
用户确认9路径提交范围,承接已同意的推送/发布顺序且前置不满足不部署。实际提交b7d2285d8d1a2a912222917f7c74067f8bd9f810,普通快进推送main并ls-remote精确回读;排除09-04两文件与统一审批中心文档。未amend、未归档。远端main克隆为独立干净发布源,不用脏工作树部署。
|
||||
|
||||
重新只读核验:线上仍为8d08d46d,服务运行且检查期间未重启;存储/数据库卷约30.64 GiB可用、使用率48%,无需清理。最新自动备份完成于2026-09-14T18:30:50Z,数据库/存储大小与SHA通过、manifest匹配,但备份build是229867f5,sourceImageMatches=false;不能视为当前8d08d46d同源恢复点。部署身份下独立回滚制品NO_MATCHING_ARTIFACT,旧版本归档不替代当前版本归档。
|
||||
|
||||
正式只读生产预检最终退出0、待迁移为空;首次包装参数误把无迁移写成none导致停止,改为显式空值后重新完整运行通过,不放宽迁移校验。证据目录artifacts/qa-layout/receipt-followup-release含两身份metadata、首次preflight.log和最终preflight-final.log;均无业务写入和部署。预检命令PASS不覆盖备份与回滚缺口,整体发布仍NO_GO。
|
||||
|
||||
下一步需补当前线上版本数据库/存储一致备份与独立镜像归档并分别回读完整性,再重核前置后发布;不删除旧制品、不自动回退或重传真实回单。本段为提交后的事实记录,尚未包含在b7d2285d中。
|
||||
|
||||
## 2026-09-15 浏览器同链与恢复指引收口
|
||||
|
||||
新增test-payment-receipt-browser.cjs作为既有DB脚本显式RUN_RECEIPT_BROWSER=1模式;要求RUN_ISOLATED_DB_TEST=1及RUN_RECEIPT_REAL_AUTH=1。正式buildApp/auth/me/付款路由、真实本地HTTP与Vite代理,不替换业务API。实际密码登录→上传合成PDF→真实文件读取及worker领取/匹配落库→UI明确登记→同库paid/claim/审计;另测错金额、缺密钥、重复交易不登记及财务退出→复核人登录→财务再登录。创建单据/批次复核预置,OCR provider合成,worker手动调用,不证明腾讯/pg-boss调度/生产全链。
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 报销付款与银行制单业务闭环
|
||||
|
||||
## 2026-09-15 六项统一收口合同(本轮最新)
|
||||
|
||||
用户采纳后续六项并要求一次规划、连续推进,非必要不提问。复用本任务承接报销付款闭环的统一协调,具体实施仍归已有模块task,不创建新总控系统或每个修复一个task。此增量是后续批准范围,历史阶段的未部署描述不代表当前状态。
|
||||
|
||||
目标是让员工按正常业务流程独立办完,让系统异常可解释、数据可恢复、交付状态可信;不是以一次提交囊括全部历史技术债。范围:22个活跃task的代码/发布/业务验收/等待项对账;四类报销付款待办与退回通知;数据库+附件+应用配对恢复;10-05错误豁免模块治理;新回单自然消费验收;密钥来源和人工例外职责核实。
|
||||
|
||||
共同验收:
|
||||
- 对账区分代码包含、局部已发布、任务全量验收完成,不因Git祖先关系自动勾完PRD;没有证据标待核,不让过期文字驱动重复开发。
|
||||
- 待办计数/列表/责任人/动作/处理后消失一致;通知失败不改变审批事实,站内待办不依赖企业微信成功;普通账号与身份切换不串数据。
|
||||
- 配对恢复在批准的隔离目标使用同一manifest,数据库、附件引用/摘要、应用只读检查均分开报结果;源备份不改,缺失件不可按“可能是测试”自行删除。
|
||||
- 错误治理先覆盖高频已启用业务操作,每项有注册原因码、客户端提示和行为断言;延期按项/模块说明原因和复验点,不统一改日期。
|
||||
- 当前已通过的真实单页回单隔离队列证据不重做;生产自然业务出现后执行端只读观察,不制造真实付款、重传历史或要求员工代做软件回归。
|
||||
- 密钥生成来源目前未知,用户不知道内容/时间/方式;先查非敏感部署证据,查不到如实记录,并列保管/恢复证据风险,不读取值、不查可能含Secret的shell历史、不轮换、不继续追问用户。
|
||||
|
||||
默认自主执行本地对账、明确范围内修复、合成验证、文档与证据整理。提交/推送/部署、新生产配置或数据写入、真实原件额外外调、恢复真实备份到具体隔离目标仍按明确范围另批;旧授权不无限继承。将需要批准的操作集中成一次清单,非实质变化不反复确认。等待真实业务或批准不阻塞其他可做模块,也不能伪称全部完成。
|
||||
|
||||
## 2026-09-15 后续收口范围
|
||||
|
||||
用户要求自主继续后续本地任务。本轮完成正式认证浏览器验证后,继续修复回单系统失败横幅与卡片引导矛盾,并将现有受控恢复路径写入操作手册。保持现有布局、权限、付款门禁和状态机;不新增管理员一键重排队或自动重传。失败前提由管理员定位修复,另获原件重传授权后由有上传权限的人员重新上传,旧失败证据保留。无法修复快照等前提时停止,不以重上传承诺恢复成功。
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# 执行清单
|
||||
|
||||
## R1复核F5与R2验收补充
|
||||
|
||||
新增断言已随R2新构建重跑visual-portal.spec.ts,22/22、零重试,日志/tmp/followup-r2-browser.log。PC与手机财务两段href、BX/PB处理后均消失本次实际断言通过;仅合成API/认证,不宣称公网或自然交接验收。
|
||||
|
||||
R1财务两段当时只断言文本/计数及PB消失,未覆盖href及BX消失;下表R1记录中相应“入口/处理后消失”覆盖声明过宽,按此更正,不改变原22项日志。R2已补PC/手机的BX/PB正确href及两项均消失断言,新的执行结果另记。employee/ceo的旧href及消失断言已存在,不虚报本次新增。
|
||||
|
||||
## 2026-09-15 四段交接双端重新验证(本地PASS)
|
||||
|
||||
使用本轮新构建与仅回环地址的vite preview,执行`E2E_MODE=local E2E_BASE_URL=http://127.0.0.1:4177 ./node_modules/.bin/playwright test tests/e2e/visual-portal.spec.ts --workers=2 --retries=0 --reporter=list --output=../artifacts/qa-layout/followup-stage3-browser`,22/22通过、零重试,日志/tmp/followup-stage3-browser.log。测试文件自身串行执行,输出实际1worker;不按命令参数声称并行。临时preview已停止。
|
||||
|
||||
| 业务交接 | 对象与双端验收 | 本轮结论 |
|
||||
| --- | --- | --- |
|
||||
| 退回后改重提 | employee冷进入、正确报销入口、处理后消失、错误可重试 | PC/手机PASS |
|
||||
| 已批未付组批 | finance事项、计数与入口、不显示旧泛化计数、处理后消失 | PC/手机PASS |
|
||||
| 付款独立复核 | ceo冷进入、正确付款入口、非财务不出现组批事项、处理后消失 | PC/手机PASS |
|
||||
| 付款与回单处理 | finance制单后待付款/回单事项、刷新后消失 | PC/手机PASS |
|
||||
|
||||
同时覆盖甲乙甲同文档切换的慢响应、失败、晚返回、迟到401取消及同身份401刷新正例,超过8条事项不截断。本次浏览器确实运行真实前端,但auth/API/事项是合成fixtures,未操作真实付款,也不是生产JWT+数据库同链新证据。测试未发现需重写门户的缺口,旧已发布身份隔离不重复开发。真实通知送达及自然新回单仍另列外部验收,不靠页面绿测替代。
|
||||
|
||||
## 2026-09-15 统一收口接续
|
||||
|
||||
本task承接统一计划B的待办投影部分,退回通知仍归09-04-approval-return-notification-gap;总顺序见09-08-reimbursement-payment-closeout/implement.md六项计划。Git已存在fedcaed1及后续门户身份隔离提交,历史“未提交/未部署”只代表当时,不能据此重做。本轮先逐验收点关联当前b7d2285d发布与先前证据,再决定真实缺口,不直接勾完独立复核/业务验收。
|
||||
|
||||
检查矩阵保持退回修改/财务待组批/独立付款复核/财务登记四阶段,核PC与手机计数列表、权限/责任人、入口动作、处理后消失、通知失败不遮蔽待办;重点复用迟到401取消和甲乙甲隔离回归。真实消息投递和真人退回另批,不把合成测试算自然业务完成;范围内最小修复与针对性验证连续执行。
|
||||
|
||||
## 2026-09-11 只读复核后取消旧请求(原范围接续,未提交/部署)
|
||||
|
||||
冻结树 dcde0d44 的双端合成浏览器复核发现:甲旧请求在乙登录后返回 401,会用乙 token 重放,并把乙结果写到甲 queryKey;甲再次登录等待新响应时显示乙待办。原 6/62/真实隔离通过记录不改写,它们未覆盖该分支。只读回执见 postpush-identity/readonly-review.md。本次沿原批准的门户隔离范围返回实施,不改变全局认证或业务权限,不新建任务。
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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": "票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。",
|
||||
|
||||
@@ -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("北京悦海堂餐饮管理有限公司");
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"}`;
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": "票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户