feat: 选票自动生成报销明细并支持集中分类(08-29-reimbursement-expense-policy-invoice-guard)
这个提交包含在:
@@ -7,6 +7,57 @@ duplicate detection or attachment behavior.
|
|||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
|
- 隔离串联的 `RUN_JOB_QUEUE=1` 必须运行真实 startPgBossJobs 与上传入口 enqueue,
|
||||||
|
等对应 invoiceId 的队列任务 completed,再校验发票状态和页面,不用直接调用
|
||||||
|
worker 冒充调度。只连接脚本新建数据库;非关注周期任务不得在测试期间改其他对象。
|
||||||
|
认证使用真实登录/JWT/refresh 和合成账号时,仍需区分导航壳适配、OAuth、
|
||||||
|
页面登录表单、云端 provider 与生产定时器时序的证据范围。
|
||||||
|
|
||||||
|
- 隔离串联可用 `RUN_ISOLATED_DB_TEST=1 RUN_BROWSER_TEST=1 node scripts/qa/test-invoice-first-http.cjs`,
|
||||||
|
前置为当前候选已构建 client/dist。浏览器业务请求必须走真实后端/数据库;
|
||||||
|
合成认证、mock provider、手动 worker、真实云端识别分别报告,不能互相替代。
|
||||||
|
刷新持久化验证先等保存响应、再查库、再刷新定位可见明细,避免隐藏的批量
|
||||||
|
分类文字或同名“编辑”按钮造成误报;上传与 OCR 完成不能触发隐式绑定。
|
||||||
|
|
||||||
|
- 所有报销 bind/unbind 与提交遵循“报销单锁 → 发票锁”,不能只让新动作
|
||||||
|
遵循而保留旧路径反序。解绑先读关联快照定位报销单,取单锁后再取票锁;
|
||||||
|
若票据当前关联已变化,409 拒绝,不按旧快照解绑也不转锁其他单据。
|
||||||
|
验证命令:`RUN_ISOLATED_DB_TEST=1 node scripts/qa/test-invoice-first-http.cjs`,
|
||||||
|
用真实 PostgreSQL 锁屏障复现并发;正常基线为同单重试仅一条明细,
|
||||||
|
反例为旧反序路径产生 500,修复后重试/解绑均成功。该脚本合成认证与
|
||||||
|
OCR 字段不构成真实登录、OCR 调用或完整审批验收证据。
|
||||||
|
|
||||||
|
- 报销“我的发票”面板上传/OCR 完成仅默认勾选,点击添加才创建明细或占用。
|
||||||
|
用户取消的票不得被回调重新勾选,手改目标不得被回调覆盖;退出不删除池票。
|
||||||
|
已有明细入口只传 linkedItemId,返回整单入口恢复创建新明细的默认值,
|
||||||
|
成功关闭与手动关闭均须清理目标。部分失败只保留失败项,重试不重复发送成功票。
|
||||||
|
- 浏览器接口拦截回归证明交互和请求合同,不证明真实 OCR、Prisma 锁或数据库
|
||||||
|
回滚;这些证据需分别验证,不能把页面成功提示视为业务全链路通过。
|
||||||
|
|
||||||
|
- 新选票动作显式传 `createItem: true`,与 linkedItemId 互斥;不复用旧绑定
|
||||||
|
的单明细/同金额猜测。新动作先锁报销单再锁票据,事务内重验参与人、状态、
|
||||||
|
票据归属与可用性,一票建一明细并占用;同单重试重验权限与现有明细归属,
|
||||||
|
不再次创建或覆盖已编辑数据。旧接口未默认切换,页面接入完成前不宣称生效。
|
||||||
|
- 无票面项目依据时可保存空说明草稿,不用文件名或商户名假装业务用途;
|
||||||
|
预检和提交集中提示缺少说明的明细。编辑器保存空字符串必须明确传空,
|
||||||
|
不能转成 undefined 导致 PATCH 没有清除旧值。
|
||||||
|
- 批量设类必须按用户选择的明细 ID 集合和单据 ID 双重限定、同事务全量校验,
|
||||||
|
任一异单/失效明细或停用类型拒绝整批;不顺手改金额、日期、说明、批准依据
|
||||||
|
或发票关系,不扩大对象维护权限。既有资产提示/审批规则仍按实际代码处理,
|
||||||
|
不以旧文档声称存在未实现的硬门禁。
|
||||||
|
|
||||||
|
- 自动建明细后类型无法可靠确定时,允许 draft/rejected 明细保存空费用类型,
|
||||||
|
不填任意启用类型或“其他”占位。API PATCH 的 null 与省略字段必须区分;
|
||||||
|
DTO 与 PC/移动编辑器均保留待补值,不因打开编辑器默认选中第一项。
|
||||||
|
- 待补或已停用费用类型必须在报销提交进入费用、预算、审批服务前按明细 ID
|
||||||
|
阻断;不能用可选链把缺少类型解释为免票。分类来自持久化 OCR 证据及启用
|
||||||
|
类型,不来自客户端票种、销售方关键词、文件名、金额或历史 provider 默认值。
|
||||||
|
- 类型可空迁移不放宽金额、日期和外键约束,也不授权生产应用。保存过待补数据
|
||||||
|
后回滚旧应用需评估非空假设;恢复 NOT NULL 前必须核实空值,不得猜类型回填。
|
||||||
|
- 本地结构验收使用显式开启的一次性 PostgreSQL:
|
||||||
|
`RUN_ISOLATED_DB_TEST=1 bash scripts/qa/test-reimbursement-draft-migration.sh`。
|
||||||
|
该测试验证迁移和合成记录,不证明浏览器、真实 OCR 或生产恢复通过。
|
||||||
|
|
||||||
- PDF invoice OCR is the maintained main path.
|
- PDF invoice OCR is the maintained main path.
|
||||||
- Image OCR remains frozen unless a later Trellis task explicitly reopens it.
|
- Image OCR remains frozen unless a later Trellis task explicitly reopens it.
|
||||||
- Do not write raw invoice images, sensitive form values or full OCR payloads to
|
- Do not write raw invoice images, sensitive form values or full OCR payloads to
|
||||||
|
|||||||
@@ -1,5 +1,79 @@
|
|||||||
# 设计:报销费用口径与票据明细门禁
|
# 设计:报销费用口径与票据明细门禁
|
||||||
|
|
||||||
|
## 最新合同:选票建明细与待补分类(2026-09-09,已实施待复核)
|
||||||
|
|
||||||
|
当前状态以本节与 implement 顶部为准。下文实施前方案对比、B1/B2 数据模型与硬门禁描述保留为历史设计,不构成本次已实现或已验收声明;尤其 assets 批准依据没有现行硬门禁,旧版“应用回滚仅关闭 flag”不适用于本次可空类型数据。
|
||||||
|
|
||||||
|
### 最小实施与已批准的本地持久化合同
|
||||||
|
|
||||||
|
本阶段绑定接口增量:`POST /invoice-pool/:id/bind` 的报销分支增加互斥的 `createItem: true`(不能同时传 linkedItemId),明确一票一明细。旧绑定调用保持原合同。金额及开票日期从锁定的票据读取;可靠铁路票种默认火车票类型,其余类型 null。当前 OCR 没有票面项目时说明保存空字符串(数据库列仍非空),不以商户/文件名充当用途;提交前集中要求补说明,普通编辑仍可修正。不把开票日期声称为已核实发生日期。
|
||||||
|
|
||||||
|
同单重试仅在权限、参与人、单据可编辑状态、发票归属以及现有 linked_item_id 属于该单全部通过后复用现有绑定,不覆盖已编辑的类型/金额/说明;跨单占用仍拒绝。新动作先锁单再锁票(与提交的顺序保持一致),新创建、占用及整单金额重算在同一事务;旧绑定入口不默认切换。真实并发与死锁重试边界另做隔离验证,不以模拟 Prisma 测试证明锁正确。
|
||||||
|
|
||||||
|
真实隔离验证补充:绑定与旧解绑混合并发已复现 500;锁顺序合同扩至所有报销 bind/unbind。绑定按 body 的 reimbursementId 先锁单;解绑先读 linked_reimbursement_id 快照、锁单后锁票,取锁后关联与快照不一致则拒绝 409,不能去锁另一张单继续操作。旧绑定业务语义不改,仅统一取锁顺序;差旅分支不额外引入报销单锁。
|
||||||
|
|
||||||
|
批量分类接口:`POST /reimbursements/:id/items/classify`,请求 `{ itemIds, expenseTypeId }`,上限 100 条、ID 去重,先锁单并重验维护权限/状态,再确认所有 ID 属于该单和类型启用,事务内只更新所选明细类型与更新人。保留金额/说明/日期/批准依据/票据;部分无效整批拒绝。实查当前 assets 路径允许无批准依据保存/提交,现有测试对此有明确断言;本次不另造资产硬门禁,也不声称已有硬门禁,保留批准依据与实际提示规则。
|
||||||
|
|
||||||
|
- 已实现纯分类函数 `suggestInvoiceExpenseType(invoice, expenseTypes)`:仅从服务端持久化的已支持票种读取依据;铁路电子客票匹配启用的 `travel_train`,其余返回 null,不修改输入、不自动兜底其他类型。已接入新绑定意图与双端页面;真实隔离后端集成和指定样票腾讯 OCR 分层验证已完成,完整证据与未覆盖边界见 implement,不代表已可发布。
|
||||||
|
- 当前持久化 OCR 不含票面项目,VAT 本身不足以区分住宿、办公或招待;不拿合成酒店名称替代真实分类证据。类型函数将作为新动作的服务端判定输入,不能接收客户端伪造票种。
|
||||||
|
- 为满足“先生成明细、未知分类之后集中批量补、刷新保留”,候选最小模型变更是仅将 `ReimbursementItem.expense_type_id` 和其 Prisma relation 改为可空,既有分类不回填、不改写;仅草稿/退回可保存待补类型。金额和日期没有可靠值的票仍留待处理,不放宽这两列。
|
||||||
|
- 未知类型必须在调用费用标准、预算、审批等服务前由提交门禁集中阻断;不得用可选链把它当作免票类型放行。详情与编辑 DTO、PC/移动端、材料导出和批量设类需同时支持待补态。空说明如用于草稿也必须有独立提交前校验,不能填虚构业务用途。
|
||||||
|
- 单票创建明细与发票占用同事务;批量设类只处理用户指定明细并重验权限、状态、类型启用,保留 assets 批准依据字段,不新增依据必填门禁,不改金额、日期或发票关系。
|
||||||
|
- 用户已明确批准本项字段调整的本地实施与隔离验证。新增迁移仅 DROP NOT NULL,不回填已有记录;生产迁移与部署仍单独授权。API 中 null 表示明确待补,PATCH 的 undefined 表示保留原值,不得用 `??` 混淆两者。
|
||||||
|
- 回滚风险:一旦保存了空类型明细,旧应用对非空的依赖可能报错,不能只换旧镜像就宣称安全。发布方案须包含待补数据兼容策略;恢复 NOT NULL 前必须确认不存在空值,不得猜类型回填来完成回滚。
|
||||||
|
|
||||||
|
默认一事一单,保留后台明细费用类型,不要求员工逐条手选。选票自动生成可编辑明细;可靠类型自动填,未知类型集中提示并支持对选中的同类明细一次批量设置,不新增关联或确认步骤。既有明细入口只添票,不改写原明细。
|
||||||
|
|
||||||
|
批量操作仅作用于用户明确选中的明细,不暗中覆盖整单或其他手改项。后台重验参与人、可编辑状态、启用类型,保留 assets 批准依据字段与实际提示;提交重验剩余缺失信息。刷新或 OCR 回调不得覆盖手改结果,待处理信息不能仅保存在易丢失的前端状态。
|
||||||
|
|
||||||
|
不新增必填报销单类别或会计科目;“差旅/日常”等宽泛场景不能充当具体费用类型。餐饮用途等票面无法证明的信息不得自动断言,不填“其他”占位。
|
||||||
|
|
||||||
|
历史推进顺序曾撤回“先批准类型可空迁移”的建议;之后按已核定交互批准了上文最小可空方案的本地实施。下文两路径对比仅用于追溯,不再表示当前迁移方案未定,生产授权仍独立。
|
||||||
|
|
||||||
|
### 公开产品调研依据
|
||||||
|
|
||||||
|
- 分贝通官方费用报销介绍:https://www.fenbeitong.com/product/rembursement.php ,支持“一张报销单多类核销”“多笔费用一起报销”及“一事一报”。
|
||||||
|
- 汇联易官方费用管理介绍:https://www.huilianyi.com/products/helios ,支持根据发票信息自动推荐费用类型。
|
||||||
|
- 证据限于公开说明读取,不是登录实操或行业统计;不据此承诺本项目 OCR 能识别全部业务用途。
|
||||||
|
|
||||||
|
## 2026-09-09 后续设计:用户选票触发自动建明细
|
||||||
|
|
||||||
|
这是已确认产品取向的规划增量,替换早先手工关联默认方案;原 B1/B2 设计为已实施历史,不直接恢复其关闭门禁时的旧自动匹配逻辑。用户已批准同范围实施;生产配置、部署与新增迁移风险仍须单独确认。
|
||||||
|
|
||||||
|
### 实施前核查:不完整明细的实际约束(2026-09-09)
|
||||||
|
|
||||||
|
- 独立执行副本基于已发布 `b87cc55`,仅承接本 task 的 PRD/design/implement 增量,未混入付款、退回删除或工作流治理改动。
|
||||||
|
- `server/prisma/schema.prisma` 的 `ReimbursementItem` 要求费用类型、发生日期、金额和说明非空;新增明细 API 还要求说明非空字符串。不能仅取消前端选择明细的禁用条件就实现选票建明细。
|
||||||
|
- `server/src/services/ocr/tencent.js` 当前提取票号、日期、金额与购销方,不提取票面项目;`services/ocr/index.js` 持久化白名单也不包含票面项目。VAT 票种本身不能区分住宿、办公等业务分类;销售方或文件名不能充当可靠类型依据。旧缓存也不会因新增解析代码自动获得项目字段。
|
||||||
|
- `routes/invoicePool.js` 旧自动绑定会复用唯一明细、按金额匹配、选择任意启用类型并以报销日期或当天兜底。新动作不能复用这些猜测。费用类型与说明缺失必须明确处理。
|
||||||
|
- 两条可行路径:A 保持 schema,对无法可靠识别的票在选票面板内只补必要字段,再一次添加;识别完整的票不二次确认。B 支持先生成待补分类的明细,再在报销页编辑补齐,需要迁移及跨层可空兼容,符合“先生成再编辑”的体验但扩大技术范围。
|
||||||
|
- 推荐 B 的体验方向,但不据此自行批准迁移。第一阶段可只允许费用类型待补;金额或日期没有可靠初值的票继续留在待处理集合,不扩成通用空值草稿引擎。具体迁移、说明初值和提交阻断合同需在范围确认后完善,不能将本建议当作已批准设计。
|
||||||
|
- 扩展类型可空需一起检查明细新增/编辑/DTO、提交门禁、费用标准/预算/审批消费者、PC/移动端、导出与历史兼容。现有代码直接访问 `item.expense_type.requires_invoice`,不能只把数据库列改为可空。
|
||||||
|
- 腾讯 OCR 项目解析如需补充,只保存受限业务必需字段,不保存原始响应,不自动重跑真实历史发票;真实样票调用仍须获准材料与用途。
|
||||||
|
|
||||||
|
历史决策点(已被最新裁决替代):曾请求先确认待补类型的草稿迁移,现已撤回该推进顺序,后续按最新分类交互核定实现。
|
||||||
|
|
||||||
|
### 交互与数据边界
|
||||||
|
|
||||||
|
- 复用发票池、上传/OCR 队列与报销明细模型。用户入口称“我的发票”,不强制迁移全部导航命名;提前存票与报销内上传进入同一份数据。退出选票面板不删除已入池发票,也不凭上传动作抢占为本单材料。
|
||||||
|
- 面板区分整单创建明细和指定已有明细两种上下文,不用不同页面串跳。整单默认一票一明细;指定明细多选票只关联,不自动追加金额或改写用户已填内容。添加前显示本次生成条数或目标明细,点击一次即完成,不增加二次关联确认。
|
||||||
|
- 新票识别成功默认勾选,但必须尊重用户取消选择;轮询/OCR 回调不得重新勾上已取消票或代点添加。池中不属于用户、已报销、被其他单占用的票不进入可添加集合;界面禁用不能替代提交时再校验。
|
||||||
|
- 返回单据后明细与发票一起展示。用户只补识别缺失、分类不确定等必要字段;开票日期预填标明来源且可修改,不等于业务发生日期。OCR 金额缺失或无法确定可靠初值时,不写 0/今天/虚构类型使测试通过;明确保留待处理票或可表达的不完整草稿,用户就地补齐,不得伪装已成功生成完整明细。
|
||||||
|
|
||||||
|
### 服务端事务与兼容核查
|
||||||
|
|
||||||
|
- 优先评估复用/扩展现有绑定事务;新动作必须显式表达“由所选票创建明细”或“加入已有明细”,不能让普通绑定接口根据金额暗猜意图。实施前在本文件补接口形状、字段映射、OCR 金额口径及费用类型判定规则,规则之外不新增分类模型或 LLM 服务。
|
||||||
|
- 权威 OCR 字段从服务端读取;客户端不能传伪造票面金额绕过票据校验。每票创建明细和占用关联在同一事务完成,使用既有锁/唯一约束防双击、超时重试及跨单竞态。批量按票返回成功/失败,单票不产生半成品孤立明细;只有失败项可重试,成功项保持幂等且不再次 OCR。
|
||||||
|
- 如果现有 schema 的必填项不支持必要的待补状态,先说明候选实现/迁移影响再交审;不得隐式填占位业务数据。不为轻量流程搭建通用草稿引擎。
|
||||||
|
- 历史未关联材料保留异常修复入口,不批量重建现有明细或关联;提交/删除与添加并发必须重新锁定校验单据状态。与 09-04 删除阶段共用一致的发票释放合同,不能让已删除单据通过旧链接恢复占用。
|
||||||
|
- 既有 feature flag 的金额/缺票/权限门禁保持;新建明细来自用户明确选票动作,不通过关 gate 获得功能。OCR off 的历史兼容实现不是本次交付成功标准,生产配置修复单独授权。
|
||||||
|
|
||||||
|
### 必须验证
|
||||||
|
|
||||||
|
真实后端 E2E 以空单为起点:上传→OCR→默认勾选/取消→添加→自动明细→刷新→补必要项→提交;另跑提前存票后选择、多选相同金额但不同支出、已有明细多票、部分失败、关闭面板/超时重试、双击与跨单并发、金额人工调整、缺字段/错误类型、OCR 不可用及非参与人拒绝。没有额外关联步骤,正常路径不展示关联标签;错误必须定位到票或明细。
|
||||||
|
|
||||||
|
真实腾讯 OCR 单独保留获准样票、provider/调用/结果的脱敏证据,不能用 mock 或空的 OCR 配置替代。桌面/窄屏浏览器覆盖原件预览、键盘/焦点返回及不丢草稿;现有 frontend-design skill 缺失时仅沿用项目面板和组件样式。跨任务验证/发布边界仍见付款任务同日综合方案,执行端负责,不交给员工补测。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
Task B-1 合并交付费用主数据政策和提交门禁代码,但运行时分成两级:B1 数据模型/主数据/表单字段随部署生效,B2 强关联和金额阻断由默认关闭的双闸 feature flag 控制。B3 财务一页式另建任务,等待 B2 在真实或专用数据上验证后开始。
|
Task B-1 合并交付费用主数据政策和提交门禁代码,但运行时分成两级:B1 数据模型/主数据/表单字段随部署生效,B2 强关联和金额阻断由默认关闭的双闸 feature flag 控制。B3 财务一页式另建任务,等待 B2 在真实或专用数据上验证后开始。
|
||||||
|
|||||||
@@ -1,5 +1,156 @@
|
|||||||
# 实施清单:报销费用口径与票据明细门禁
|
# 实施清单:报销费用口径与票据明细门禁
|
||||||
|
|
||||||
|
## 当前接续点:只读复核与发布候选准备
|
||||||
|
|
||||||
|
### 用户复核与提交授权(2026-09-09)
|
||||||
|
|
||||||
|
- 用户回复“通过”,接受本次复核材料;这是用户复核结论,不改写为执行端独立 reviewer 已运行。
|
||||||
|
- 用户随后明确授权仅本 task 的 28 个文件提交并推送,冻结新候选;未授权部署、生产迁移或线上写入验证。
|
||||||
|
- 提交前 fetch 核对远端 main 仍与执行副本基线 `b87cc55` 一致。本次以独立副本的提交快进推送 main,不切换或覆盖主工作树的其他未提交任务;不强推、不新建分支。
|
||||||
|
- 下文“未提交/待独立复核”为此前阶段记录。提交与推送结果以 Git 对象及远端回读为准;发布仍待后续明确批准,task 保持 in_progress。
|
||||||
|
|
||||||
|
### 2026-09-09 最终收口与复核入口
|
||||||
|
|
||||||
|
- 执行副本:`artifacts/isolated-worktrees/20260909-invoice-first`,基线 `b87cc55`,官方 current 指向本 task;基线不是包含本次修复的候选 SHA。当前产品增量未提交,主工作树其他任务不得整体合入。
|
||||||
|
- 范围为 28 个文件(21 个 tracked 修改、7 个新增文件)。复核必须同时读取 `git diff HEAD` 和 `git ls-files --others --exclude-standard` 列出的新文件,尤其真实 HTTP 测试脚本、迁移、分类服务与共享组件;只看 diff stat 会漏新增文件。此记录是复核入口,不是已冻结的哈希清单。
|
||||||
|
- 本轮核对服务端事务、提交拦截、双端选票/批量分类和最新合同,纠正 PRD/design 的历史状态冲突:assets 无现行硬门禁,不能声称保留了不存在的校验;可空迁移已批准本地实施,不再列为方案未定。产品代码未因这次文档收口变化,沿用下方同输入的分层验证结果,不重复累加测试数量。
|
||||||
|
- 本地验证已覆盖:可空迁移及反向约束、同单幂等/跨单竞争/失败回滚/绑定解绑锁顺序、真实认证与队列、桌面/手机上传到审批完成。provider mock 的端到端与真实腾讯单次样票验证分开记录;付款、企业微信 OAuth、真实腾讯与候选队列浏览器同一次整链仍未覆盖。
|
||||||
|
- 待独立只读复核:权限与参与人、草稿/退回状态、提交前分类和说明阻断、null 与省略的区别、所有报销 bind/unbind 先单后票、批量分类原子范围、OCR 取消/目标选择不被回调覆盖、隔离测试不接触共享数据库及退出清理。当前 inline 未有独立 reviewer,不记独立 PASS。
|
||||||
|
- 发布前置仍需:复核后的明确文件集合提交并冻结新 SHA;重新核生产运行版本/变化、migration 状态、证据时效、备份和独立回滚制品。备份完整性不等于恢复能力;本次一旦产生空分类,旧镜像可能不兼容,不能承诺仅镜像回退。若需恢复 NOT NULL,先确认 null 为零,禁止猜分类回填。
|
||||||
|
- 下一动作由执行端提供本入口供只读复核并处理 findings;复核后按明确授权提交/推送,再提出发布方案。生产迁移、部署及线上合成写入验证另批。上线验证仍由执行端承担,不把真实员工试错当验收。
|
||||||
|
- 退回删除与付款中文文件名/下载理由/进度布局继续留在原 task,本阶段未实施,不归档本 task,也不叠加第三笔工作。
|
||||||
|
|
||||||
|
### 2026-09-09 真实认证与 pg-boss 补验(最新)
|
||||||
|
|
||||||
|
- 已移除业务请求的 x-synthetic-actor 身份注入,改为真实 auth routes、auth plugin、密码哈希、登录接口发 JWT、Cookie 会话和浏览器启动时的 refresh;账号和密码只在隔离环境合成。员工/经理/CEO/财务各自登录,业务权限从真实 employee_roles 读取,不在测试中直接授予审批权限。
|
||||||
|
- 无令牌/伪造令牌访问被 401 拒绝,token_version 增加后旧令牌也被 401 拒绝。浏览器测试通过真实登录接口获取 Cookie,未伪造 has_session 或 accessToken;个人信息/导航/通知展示壳仍是测试适配,企业微信 OAuth 和登录表单交互不据此声称已测。
|
||||||
|
- `RUN_ISOLATED_DB_TEST=1 RUN_BROWSER_TEST=1 node scripts/qa/test-invoice-first-http.cjs`:真实认证+手动 worker 模式 14 组 PASS。
|
||||||
|
- `RUN_ISOLATED_DB_TEST=1 RUN_BROWSER_TEST=1 RUN_JOB_QUEUE=1 node scripts/qa/test-invoice-first-http.cjs`:真实认证+真实 pg-boss 模式 14 组 PASS;真实 startPgBossJobs 注册实际 OCR 队列和消费者,上传路由自动 enqueue,未手动调用 worker;独立查询队列中对应 invoiceId 的 ocr.invoice job 已 completed,且发票 available。桌面/手机均完成添加、分类、保存刷新、提交、同单三段审批及只读回读。
|
||||||
|
- 队列测试只连接一次性库 synthetic_jobs schema;非本次关注的周期任务设置年度时间避免在测试期间执行,验的是 OCR 即时投递/消费,不宣称生产 cron 时序已验。provider 仍为 mock,不复制腾讯凭据、不使用真实票据;先前指定样票的真实腾讯 PASS 保留为独立证据。
|
||||||
|
- 两种模式均已清理 pg-boss、连接池、HTTP/preview、浏览器、临时上传目录和数据库容器。JS 语法、git diff --check、Trellis 治理 PASS;本轮只扩测试,无新增产品修复,未提交、推送或部署。
|
||||||
|
- 本地实现与分层验证可进入只读复核准备;独立 reviewer 仍未执行(当前 inline 模式不派发 implement/check 子代理),不把主会话验证记作独立通过。发布候选需包含本次可空分类 migration 及回滚约束,继续另批,不复用旧部署授权。
|
||||||
|
|
||||||
|
### 2026-09-09 桌面/手机串联验证接续(最新)
|
||||||
|
|
||||||
|
- 在既有隔离脚本增加 `RUN_BROWSER_TEST=1`:使用当前候选的 client/dist、Vite localhost preview 和 Chromium;业务 API 透传至真实 Fastify/Prisma/一次性 PostgreSQL,未拦截伪造报销详情、上传、状态轮询、分类、保存、提交或审批响应。
|
||||||
|
- 认证/个人信息/导航/通知壳使用合成适配器,权限来自实际 ROLE_PERMISSIONS。OCR worker 使用真实 runOcrForInvoiceId、存储读取、领取锁、结果落库与 available 状态转换,但 provider=mock 且手动触发 worker;不能据此宣称腾讯网络、pg-boss/cron 调度或真实登录的同环境整链通过。
|
||||||
|
- 合成空白 PDF 由脚本生成,桌面/手机分别新上传不同哈希,轮询识别完成默认勾选,添加前真实数据库明细数为 0;一次添加后批量分类、编辑说明、等待 PATCH 200、查库并刷新核对,再从页面提交生成经理待办。双端到待办阶段已通过。
|
||||||
|
- 测试开发失败均如实保留原因:Vite CJS 导入形状、未限定明细区域的“编辑”/隐藏文本定位、手机按钮带数量、保存未等响应即刷新;未因这些测试适配问题修改产品合同或放宽断言。现在同时验保存响应、真实数据库值与可见明细内容。
|
||||||
|
- 同一张浏览器提交单的经理/CEO/财务三段 HTTP 审批及审批完成后双端只读回读最终通过;真实库状态 approved、页面不再出现添加发票入口。复现命令(先确保 client/dist 来自当前候选构建):`RUN_ISOLATED_DB_TEST=1 RUN_BROWSER_TEST=1 node scripts/qa/test-invoice-first-http.cjs`。最终 12 组检查 PASS;测试数据库容器、合成文件目录、浏览器、preview/HTTP 服务和连接池均已清理。JS 语法、Trellis 治理及 diff 检查 PASS。保留 Node punycode/Vite CJS 弃用提示,不扩依赖升级。
|
||||||
|
- 此前真实腾讯样票 PASS 仍是独立证据,不将其与本节合成 provider 串联结果拼成一次真实云端整链。未重用真实票据、未连接生产、未提交或部署。独立复核和正式调度/认证边界仍待收口。
|
||||||
|
|
||||||
|
### 2026-09-09 用户指定样票的真实腾讯 OCR 验证(最新)
|
||||||
|
|
||||||
|
- 用户指定部署服务器 OCR 环境及一份 PDF 样票,授权本次受控识别;材料仅以 `SAMPLE-PDF-INVOICE-FIRST` 记录,不保存原文件名、票面内容、账号或完整路径。
|
||||||
|
- 先做只读前置核验:运行 build `b87cc55aca0003130cf8a5bbe38d0dd15528389c`;OCR_PROVIDER=tencent、腾讯凭据存在。远端既有 `check-ocr-provider-samples.mjs`、OCR index/tencent 三文件摘要与执行副本逐一一致,识别相关代码本 task 无差异。
|
||||||
|
- 指定 PDF 经 SSH/stdin 传入现有容器中的 `check-ocr-provider-samples.mjs --alias=SAMPLE-PDF-INVOICE-FIRST`,使用现有环境凭据,调用一次腾讯接口;不把凭据复制到本地、不生成服务器样票副本、不调用业务上传/写库/队列接口、不改配置、不部署。
|
||||||
|
- 结果 `PASS`,`transportMode=pdf_native`,`elapsedMs=826`,`documentCategory=full_vat_invoice`,`documentSubtype=VatElectronicInvoiceFull`,`documentCount=1`。号码、日期、金额、税额、合计、销售方、购买方字段均命中;invoiceCode 未命中但不是全电发票必需字段;missingRequiredFields 为空、errorCode=null。
|
||||||
|
- 此证据证明指定样票通过真实腾讯调用、归一化与必填字段检查;没有人工逐字核对识别精度,也不证明所有票种或实际费用分类正确。该票种不提供可靠费用类型推断,仍按既定规则集中补分类,不从商户名称猜用途。
|
||||||
|
- 真实 OCR 专项前置缺口已关闭;下节“待指定环境/样票”是历史状态。剩余是候选版本上传→队列识别→浏览器选票→提交审批的同环境串联验收,以及独立只读复核,不能将分层 PASS 相加成整链通过。未提交、推送、部署、写生产业务数据或真实付款。
|
||||||
|
|
||||||
|
### 2026-09-09 上传与审批链补验(最新)
|
||||||
|
|
||||||
|
- `RUN_ISOLATED_DB_TEST=1 node scripts/qa/test-invoice-first-http.cjs` 最终 10 组检查通过,真实 PostgreSQL/Prisma/HTTP;本轮只扩测试,未改审批产品代码。
|
||||||
|
- 真实 multipart 上传合成空白 PDF:文件落入专用临时目录,发票保存 pending_ocr 且未绑定;再次上传同文件复用原记录,不新增票。此用例只验证上传/落盘/待识别状态,不启动 OCR worker,也没有把空白 PDF 当作能识别出票面字段的样票。
|
||||||
|
- 使用真实角色权限常量、合成经理/CEO/财务账号与三级规则:提交人自审 403;return 回 draft 后可修改重提;reject 回 rejected 后可修改重提;重提创建新实例;经理→CEO→财务逐步审批最终 approved,重复审批 409,无遗留 pending 任务,不标为 paid。
|
||||||
|
- 测试设计初次把 return 当作 rejected、把 CEO/财务角色待办当作个人待办,产生断言失败;核对既有实现后按 draft/角色分派分别断言,保留 reject 独立测试,未为通过测试修改生产状态或审批合同。
|
||||||
|
- 所有数据库容器、HTTP 服务、连接池及合成上传目录已清理。认证仍是合成测试适配器,真实登录、腾讯识别、队列消费与真实付款不在这次通过结论内。
|
||||||
|
- 真实 OCR 前置:当前进程没有注入腾讯凭据;仅检查主工作树常规本地 env 的键是否存在,结果未配置,不读取回传密钥值,不据此推断生产关闭 OCR。专用执行副本没有已确认获准样票;现有 `check-ocr-provider-samples.mjs` 空输入演练返回 `HOLD/sample_empty`,不是实际腾讯调用。下一步需指定获准 OCR 测试环境及脱敏 PDF 样票别名,不从生产复制凭据或擅自发送真实票据。
|
||||||
|
- 仍未提交、推送、部署;独立只读复核待完成,旧“仅到派单”是上一阶段证据,已被本节三级审批补验扩展,但不等于上传→腾讯 OCR→浏览器→审批的完整集成验收。
|
||||||
|
|
||||||
|
### 2026-09-09 真实隔离数据库 / HTTP 验证
|
||||||
|
|
||||||
|
- 使用 `RUN_ISOLATED_DB_TEST=1 node scripts/qa/test-invoice-first-http.cjs`:启动一次性 PostgreSQL 16(仅随机 localhost 端口、tmpfs),执行历史 SQL 与本次迁移,使用独立 Prisma 客户端和真实 Fastify HTTP;不读取共享 DATABASE_URL,不连接生产。结束关闭 HTTP/连接池并删除容器。
|
||||||
|
- 已验证空单从已识别合成票添加、未知类型持久化、类型/说明缺失提交阻断、HTTP 批量分类与编辑、正式 submit 路由创建 reviewing 状态和经理待办;重复提交不重复生成实例。认证是合成测试适配器,不冒充真实登录验证;OCR 输入是合成持久化字段,不是腾讯调用证据。
|
||||||
|
- 同单 4 请求并发只生成 1 条明细,重试不覆盖人工修改;跨单抢票只有一方成功且无孤立明细;数据库触发器注入绑定更新失败后,明细/占用/总额完整回滚,解除故障重试成功。
|
||||||
|
- 实测发现:外部锁屏障下,createItem 重试先锁单后锁票,旧 unbind 先锁票后锁单,两者并发返回 `[200,500]`,不能用顺序模拟测试证明混合路径安全。修复为所有报销绑定先锁单;解绑先读关联快照、锁单再锁票,取得票锁后关联变化则 409,不操作新关联。修复后的真实 HTTP 并发为 `[200,200]`。
|
||||||
|
- 扩展路由/服务回归 18 文件 349/349 通过(随后新增一条解绑快照变化反例,单文件另验);JS 语法、错误码 strict 合同与 diff 检查同步执行。初次集成测试漏配合成账号的 expense.edit_own 导致断言失败,修正测试权限后通过,未放宽产品权限。
|
||||||
|
- 当前只证明到提交/派单,不证明经理实际审批、付款、真实 OCR 或上传到 OCR 队列全链路完成。独立复核仍待完成,当前 inline 主会话无独立 reviewer。未提交、推送、部署或真实数据写入。
|
||||||
|
|
||||||
|
最终补测:真实 DB/HTTP 脚本 8 组检查全部通过(含同金额票不误合并、已有明细加票不改金额、混入异单 ID 的分类整批回滚);解绑快照变化反例加入后 `cd server && npm test -- tests/unit/routes/invoicePool.travel-bind.test.js` 20/20 通过。此前扩展 349 用例与这 20 条有重叠,不累加成独立总数。所有自建数据库容器、HTTP 服务和连接池已关闭;没有真实业务数据参与。
|
||||||
|
|
||||||
|
### 2026-09-09 双端面板接续
|
||||||
|
|
||||||
|
- [x] 桌面/手机“我的发票”上传和 OCR 完成只默认勾选;点击添加才提交,整单传 createItem,已有明细入口传 linkedItemId,目标可修改。
|
||||||
|
- [x] 返回报销单不占用、不删除池票;关闭清理本次选择。已成功添加从选择中移除,部分失败保留失败项重试。
|
||||||
|
- [x] 双端接入共享批量分类组件:只更新用户选中的明细,未知类型集中提示,已分类项可编辑,刷新详情与移动列表缓存。
|
||||||
|
- [x] 补桌面添加成功后的目标清理,避免从明细入口添加后,下次整单入口仍指向旧明细。
|
||||||
|
- [x] 真实 Prisma + HTTP + 隔离 PostgreSQL 验证同单幂等、跨单竞争、事务失败回滚与空单提交/派单;真实 OCR/上传和完整审批仍按上节留缺口。
|
||||||
|
- [ ] 获准合成样票的真实腾讯 OCR 验证;不发送真实业务票据,不以 mock 证明 OCR 可用。
|
||||||
|
- [ ] 独立只读复核(当前 inline 执行未由独立 reviewer 验证);未提交、未推送、未部署,不交真实员工补测。
|
||||||
|
|
||||||
|
本节为最新状态,下文“尚未接入页面”等表述保留为历史阶段记录,不代表当前实施状态。退回删除和付款体验仍未启动,继续保持任务隔离。
|
||||||
|
|
||||||
|
本轮浏览器与治理证据(均在 invoice-first 执行副本):
|
||||||
|
|
||||||
|
- 最终页面输入 `cd client && npm run typecheck && npm run build` 通过,2422 模块构建完成;保留 Browserslist 数据老旧提示,不扩依赖升级。自建本地 Vite 验证服务已停止。
|
||||||
|
- `E2E_BASE_URL=http://127.0.0.1:5187 npx playwright test tests/e2e/r111-invoice-reimbursement-guard.spec.ts --workers=1 --reporter=line --timeout=90000`:12/12 通过。
|
||||||
|
- 同命令追加 `--grep '已有明细入口|多票添加'`:4/4 通过,包含补充的多票部分失败仅重试失败票,以及成功后重开整单不残留旧目标。
|
||||||
|
- 同命令追加 `--grep '选票优先空单'`:2/2 通过,补充分类响应后的界面更新、刷新保留断言。三次有用例重叠,不计作 18 个独立业务场景。
|
||||||
|
- 使用真实浏览器和合成 API 拦截,覆盖桌面、手机与企业微信布局、空单上传不绑定、退出留池、一次选票添加、已有明细目标、刷新与局部重试。未覆盖真实腾讯 OCR、真实 Prisma 事务并发或完整审批链。
|
||||||
|
- `node scripts/qa/check-trellis-governance.mjs`、`node scripts/qa/check-error-reason-contract.mjs --strict`、`git diff --check` 通过;`node scripts/qa/audit-docs.mjs --check-registry` errors=0,保留既有 22 个未注册文件 warning。
|
||||||
|
|
||||||
|
### 接续阶段:选票创建与批量设类服务端
|
||||||
|
|
||||||
|
- [x] 明确 `createItem: true` 的新绑定意图,和 linkedItemId 互斥;门禁开/关均允许用户显式创建,旧绑定合同保留。
|
||||||
|
- [x] 新动作先锁单再锁票,权威金额/日期、可靠类型或 null、未知说明空字符串;新明细+占用+总额重算在事务内,不按相同金额或已有唯一明细匹配。
|
||||||
|
- [x] 同单重试经权限/状态/归属重验复用已占用明细,拒绝跨单占用/非参与人/缺金额日期,不重复写入。
|
||||||
|
- [x] 新增批量设类接口,只改选中 ID 的类型和更新人;异单/失效 ID、停用类型、非参与人或不可编辑状态整批拒绝。保留批准依据,不宣称既有 assets 路径存在硬阻断。
|
||||||
|
- [x] 空说明草稿允许编辑;预检/提交按明细集中阻断,编辑器明确传空字符串。当前 OCR 没有票面项目,不以销售方/文件名伪造支出用途。
|
||||||
|
- [ ] 下一步先接 PC/移动“我的发票”面板:上传/OCR 完成只勾选,不自动添加;整单入口传 createItem,已有明细入口传 linkedItemId。接批量分类控件,再做真实后端并发与空单浏览器回归;本阶段不发布。
|
||||||
|
|
||||||
|
本轮验证:服务端新绑定/草稿分类/费用门禁/错误码镜像 4 文件 219/219 通过;扩展报销路由组前一轮为 12 文件 311/311(之后仅增加空说明预检/提交及相应测试,相关组已重跑)。测试均为合成 Fastify 注入+模拟 Prisma,不据此声称已验证真实锁/事务回滚。旧一组 SQL 隔离验证只覆盖结构迁移,不覆盖本轮 API 并发。未调用腾讯真实 OCR、未改生产、未提交推送;独立复核及完整前端验收待后续。
|
||||||
|
|
||||||
|
本轮最终扩展回归:`cd server && npm test -- tests/unit/routes/invoicePool.*.test.js tests/unit/routes/reimbursements.*.test.js tests/unit/services/invoiceExpenseClassification.test.js tests/unit/scripts/error-reason-client.test.js` 为 18 文件 349/349 通过。`cd client && npm run typecheck`、错误码 strict 合同、Trellis 治理检查、JS 语法与 `git diff --check` 均通过。本轮未重跑浏览器或生产构建,不继承上一阶段构建为新页面验收(新页面尚未接入)。
|
||||||
|
|
||||||
|
### 已批准字段调整:本地阶段
|
||||||
|
|
||||||
|
- [x] 用户明确批准仅费用类型可空的本地实施与隔离验证,生产迁移和部署仍未授权。
|
||||||
|
- [x] 新增可空字段迁移,API 在 draft/rejected 接受明确 null;PATCH 省略类型保留原值,明确 null 清空类型,不调用空 ID 查询。
|
||||||
|
- [x] 报销提交及该单预检在财务计算前拒绝缺失/停用分类并返回明细 ID;保留身份与状态检查。补齐前不启动预算占用或审批。
|
||||||
|
- [x] DTO 与 PC/移动编辑器保留待补值,可保存其他字段,不因打开编辑器默认选中第一类型;金额、日期约束未放宽。
|
||||||
|
- [x] 一次性 PostgreSQL 16、无网络、tmpfs 库执行历史 SQL 建立基线,再验证新迁移:旧分类不变、空分类持久化、外键及金额/日期非空约束保留;存在空值时逆迁移拒绝、合成记录明确设类后可恢复非空。测试退出删除专用容器,不连接共享开发库或生产;这是 SQL/约束测试,不是 Prisma migrate deploy 或完整业务 E2E。
|
||||||
|
- [ ] 接续实现显式选票建明细事务、批量设类 API 与面板,完成真实后端/浏览器验证。当前仍不是可发布的完整发票流程;暂不叠加退回删除和付款改动。
|
||||||
|
- 只读独立复核待完成:当前 inline 模式仅由主会话检查,没有虚报独立 reviewer。
|
||||||
|
|
||||||
|
本阶段最终验证:
|
||||||
|
|
||||||
|
- `cd server && npm test -- tests/unit/routes/reimbursements.*.test.js tests/unit/services/invoiceExpenseClassification.test.js tests/unit/scripts/error-reason-client.test.js`:12 文件,295/295 通过;详情测试初次漏传合成认证头产生 401,补齐测试头后单文件 37/37 及全组通过,不曾放宽产品认证。
|
||||||
|
- `prisma:validate`、独立依赖目录中的 `prisma:generate` 通过。生成 schema 与 TypeScript unchecked-create 输入确认类型可空、金额/日期必填。最初使用 DMMF 的旧 `isRequired` 元数据断言不适用于当前客户端,未将该失败归为产品缺陷,改为核验实际生成产物后通过。
|
||||||
|
- `cd client && npm run typecheck && npm run build` 通过;Browserslist 数据老旧提示保留,不混入依赖升级。
|
||||||
|
- `RUN_ISOLATED_DB_TEST=1 bash scripts/qa/test-reimbursement-draft-migration.sh` 通过;仅使用现有本地 Postgres 镜像与合成记录,无网络专用库退出已清理。
|
||||||
|
- `node scripts/qa/check-error-reason-contract.mjs --strict`、`node scripts/qa/check-trellis-governance.mjs`、`git diff --check`、改动 JS 的 `node --check` 与新脚本的 `bash -n` 通过。
|
||||||
|
- 路由测试使用 Fastify 注入与模拟 Prisma;SQL 测试使用真实隔离 PostgreSQL。二者互补,但不等于真实 Prisma+HTTP+浏览器端到端验收。实际选票/OCR/批量分类仍未接通,独立复核及桌面/窄屏浏览器验收待后续阶段,不能据本阶段发布。
|
||||||
|
|
||||||
|
### 2026-09-09 首批代码验证
|
||||||
|
|
||||||
|
- 已新增纯分类服务和 10 条合成单元测试,尚未接入 API/页面。仅认可持久化铁路电子客票与启用火车票类型,覆盖未知 VAT、销售方/文件名误导、类型停用/缺失、历史 provider 默认值、客户端字段冒充及输入不变性。
|
||||||
|
- 测试先运行得到预期模块缺失失败;实现后 `cd server && npm test -- tests/unit/services/invoiceExpenseClassification.test.js tests/unit/services/invoiceDocumentMeta.test.js tests/unit/services/ocr.tencent.test.js` 为 3 文件、22/22 通过。`node --check src/services/invoiceExpenseClassification.js` 与 `git diff --check` 通过。
|
||||||
|
- OCR 测试为合成单元测试,不是腾讯真实调用证据;未测完整浏览器流程、真实数据库并发或生产 OCR。未独立复核、未提交、未部署。
|
||||||
|
- 当前需确认具体持久化范围:只放宽明细费用类型列以保存待补草稿,提交前强制补齐;已将跨层兼容及旧镜像回滚风险补入 design。分类逻辑不要求用户每条手填,也不把本次开始实施视为生产迁移授权。
|
||||||
|
|
||||||
|
- [x] 用户采纳“一事一单、自动分类为主、同类批量设置、只处理例外”,同步本 task PRD/design,不新建任务。
|
||||||
|
- [ ] 核定自动类型的可靠输入与批量更新接口,审计草稿保存、刷新及提交约束;不再以逐条手选分类作为数据库非空字段的默认解决方案。
|
||||||
|
- [ ] 在空单流程回归中加入混合类型、同类批量设类、未知项集中处理、用户编辑保留与停用类型拒绝。
|
||||||
|
- 代码、发布、业务验收:本轮仅更新需求和验收合同,未实现分类功能、未部署、未宣称业务验证通过。
|
||||||
|
- 下一步由执行端完成最小技术方案和针对性回归,不等待员工代测;如确需迁移,应给出具体差异和兼容风险,不能复用早先概括性迁移建议。
|
||||||
|
|
||||||
|
## 2026-09-09 后续阶段清单(已进入实施前核查)
|
||||||
|
|
||||||
|
后续实施授权:用户明确“自动填充的明细不需要用户二次确认,但允许再次编辑”,并批准按上述方案执行。该取向已补入 PRD,可继续同范围的最小技术设计、代码实施与本地验证,不因补录合同重复索取授权;产品代码尚未实施。迁移或范围/风险发生实质变化仍先报告,提交、推送、部署、生产配置与真实票据发送按既有单独授权边界执行。
|
||||||
|
|
||||||
|
- [x] 按用户确认更新 PRD/design:选票一次触发 OCR 辅助生成明细及自动关联;上一版手工关联默认方案撤回,不新建 task。
|
||||||
|
- [x] 从已发布 `b87cc55` 建立独立执行副本 `artifacts/isolated-worktrees/20260909-invoice-first`,通过官方 task start/current 对齐本 task;仅承接本 task 三份规划文件。
|
||||||
|
- [x] 核查上传/OCR/绑定/费用类型和 schema,记录不可空类型与 OCR 缺票面项目的实际约束;旧自动匹配不能作为新入口实现。
|
||||||
|
- [ ] 按已采纳分类交互补齐最小接口事务设计与稳定类型判定规则,再审计具体持久化影响;不擅自新增迁移或改成强制先填分类。
|
||||||
|
- [ ] 核实真实 OCR 启用及测试样票/调用权限的可用条件;仅回传脱敏状态,不读回或暴露密钥,不自动修改生产配置。
|
||||||
|
- [ ] 建立空单、已有明细、多选和异常重试的失败用例;实现用户明确选票触发的自动建明细,不调用旧静默金额匹配作为替代。
|
||||||
|
- [ ] 实施双入口面板与后端单票原子事务/重试幂等,完成与删除阶段的释放协议检查;不伪造必填字段、不批量修真实单据。
|
||||||
|
- [ ] 完成真实后端角色 E2E、腾讯 OCR 专项、浏览器桌面/窄屏及反例,保留每层验证与缺口。
|
||||||
|
- [ ] 只读独立复核与规则文档同步;按明确授权提交、推送、部署和专用线上合成检查,旧发布授权不复用。
|
||||||
|
|
||||||
|
下文为历史 B1/B2 已完成阶段,不把其勾选继承为本次自动建明细功能已实现或已验证。
|
||||||
|
|
||||||
## 1. 规划门禁
|
## 1. 规划门禁
|
||||||
|
|
||||||
- [x] 固定 B1+B2 同包开发、分级启用;B2 默认关闭。
|
- [x] 固定 B1+B2 同包开发、分级启用;B2 默认关闭。
|
||||||
|
|||||||
@@ -2,6 +2,41 @@
|
|||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
|
### 当前交付口径(2026-09-09,最终自检收口)
|
||||||
|
|
||||||
|
- 本次选票自动建明细与集中分类已在独立副本实现并完成分层本地验证;不是下文早期记录所述“尚未实现”。尚未独立复核、提交或部署。
|
||||||
|
- 已批准本地实现费用类型可空迁移,未知类型与空说明只用于可编辑草稿,提交前阻断;生产迁移仍须单独批准。最新接口与回滚合同见 design 的“最小实施与已批准的本地持久化合同”。
|
||||||
|
- assets 事前批准依据目前是字段与提示,不是保存/提交硬门禁。本次保持实际行为,不新增硬门禁。下文 R3、R5 及旧验收勾选中的硬门禁表述属于历史设计/记录,不能作为当前已实现或已验结论;本次批量分类验收是保留依据字段、不改金额及票据关系。
|
||||||
|
- 下文 B1/B2 历史清单不代表本次验收。当前证据分为真实隔离数据库/认证/队列/浏览器(OCR provider 为 mock),以及指定样票单独调用腾讯 OCR;没有同一次真实腾讯到候选浏览器的整链证据。付款和退回删除属于其他既有 task,未在此完成。
|
||||||
|
|
||||||
|
### 2026-09-09 分类交互补充(用户已采纳)
|
||||||
|
|
||||||
|
- 一事一单:同一次出差可在同一张报销单包含火车票和住宿,不为费用分类强制拆单。报销单场景与明细费用类型是不同层级,不把“日常报销”等宽泛场景当作精确费用类型。
|
||||||
|
- 后台保留逐明细费用类型,用于材料要求、费用标准与财务统计;前台不得要求每条明细重复手选,不让员工选择会计科目。
|
||||||
|
- 可靠识别的类型自动填入已启用类型,生成的明细可编辑,无逐条确认、整批二次确认或已确认标记。
|
||||||
|
- 无法确定的类型集中提示并支持对用户选中的同类明细一次批量设置。正常已分类明细不被未识别项拖入重复填写;用户手工修正后不被 OCR 回调、刷新或后续默认值覆盖。
|
||||||
|
- 只有用户明确选择的具体费用类型才可作为相应所选明细的默认值;不把“差旅”“日常”或销售方名称推断为精确费用类型,不因餐饮票面就直接判为业务招待。
|
||||||
|
- 添加与分类是不同动作:分类不得增加一次发票关联步骤,也不要求用户逐字段确认 OCR 已填值。提交前检查仍缺少的必要信息,保留原审批、费用标准、assets 批准依据字段/提示与票面金额门禁,不宣称 assets 已有硬门禁。
|
||||||
|
- 先落实交互再裁定持久化设计;撤回“先请用户批准类型可空迁移”的推进顺序。本次产品取向确认不是数据库迁移、生产写入或部署授权。
|
||||||
|
|
||||||
|
验收增加:同单火车票加住宿、同类多条一次设类、混合类型不整单覆盖、未知类型集中处理、手改类型刷新保留、已识别项零确认、停用类型与非参与人拒绝、批量改为 assets 时保留批准依据字段及实际提示规则。仍需验证刷新后的待处理信息与服务端提交阻断,不能仅有前端临时状态就宣称完整支持。
|
||||||
|
|
||||||
|
### 2026-09-09 已确认后续流程:选票自动生成明细
|
||||||
|
|
||||||
|
用户明确选择省操作的默认流程,并已批准按确认范围实施。本节替换此前“待关联区域→手工建明细→再关联”的默认方案;不新建 task。当前产品代码尚未实现,提交、部署与生产写入不由此自动授权。下文原 B1/B2 要求与历史已验结果保留;涉及自动建明细的后续目标以本节及最新分类补充为准,不能拿旧禁令否定已确认的新交互,也不能恢复旧版按金额猜测的静默自动匹配。
|
||||||
|
|
||||||
|
- 报销页点击“添加发票”打开“我的发票”面板,桌面弹窗、手机全屏面板,不跳离报销单、不丢填写内容。票据中心/发票池是同一数据来源,不复制第二份资料;允许提前存票,报销时再选。
|
||||||
|
- 面板支持多选已有可用票、批量上传新 PDF;腾讯真实 OCR 默认启用,上传自动识别。新上传且识别成功、可用的票默认勾选,用户可取消;未点“添加到报销单”前不创建明细,不能因 OCR 后台完成或刷新自动提交选择。
|
||||||
|
- 从整单入口点击“添加到报销单”后,一张所选发票生成一条可编辑费用明细并自动关联;多选批量生成,不按相同金额或票面多行擅自合并/拆分,不让员工再手工关联一次。
|
||||||
|
- 用户补充确认:自动填充的明细直接保存为可编辑草稿,不增加逐条确认或整批二次确认,不设置“已确认”标记作为提交前置;用户可主动修正,只有必填缺失或现有业务校验不通过时提示处理。该要求不取消报销本身的“提交审批”动作。
|
||||||
|
- 价税合计预填报销金额;开票日期可作为发生日期的可编辑初值,不能假定必然相同;票面项目生成可编辑说明。费用类型仅可靠匹配启用类型时推荐,不确定时只要求补必要字段,不凭空填其他费用类型或编码,不强迫逐字段重复确认。
|
||||||
|
- 从已有明细入口添加发票时,明确目标,直接关联到该明细且不创建另一条明细;允许同一笔支出的多票关联一条明细,不把同一票分配多条明细,不擅自改写原明细日期/类型/申报金额。
|
||||||
|
- 正常页面在明细内显示“发票 N 张”和预览入口,不展示绑定/关联技术状态。只有识别异常、历史未关联、需票明细缺票等需要动作时显示具体提示和就地补救,手动修复不是默认必经步骤。
|
||||||
|
- 上传成功不等于 OCR 成功,OCR 成功不等于用户已把票加入报销。识别中/失败/缺字段/重复/占用均要逐票可恢复;刷新或返回能找到已上传票和本单已保存明细,不要求重传或重复 OCR。
|
||||||
|
- 归属、状态、查重、有效占用、明细归属、申报金额与原提交门禁保持服务端强约束。批量、双击、超时重试、跨单并发不得产生重复明细、孤立明细或一票多占用。
|
||||||
|
- OCR 关闭/误配视为需要处理的系统异常,不作为本轮正常交付路径;是否实际启用及识别成功须核配置和调用证据,不以用户陈述或旧数据库 provider 默认值冒充已验证。不自动启用 mock,不擅自变更生产密钥、开关或发送未经授权票据。
|
||||||
|
- 必测空单开始的“上传新票/选择旧票→自动生成明细并关联→核对补齐→提交”及提前存票、多选、刷新、已有明细加票、异常重试、退回修改、删除后重新使用;保留非参与人/重复票/已报销/并发等反例。
|
||||||
|
|
||||||
把现有自创或含糊的费用类型收敛为财务可理解的企业费用口径,明确发票要求和 assets 个人报销例外条件;同时建立可核验的票据—明细关系与申报金额门禁,避免多日期、多支出、多张发票被合并为无法复核的一条笼统明细,并通过默认关闭的双闸门保护现有唯一真实报销链路。
|
把现有自创或含糊的费用类型收敛为财务可理解的企业费用口径,明确发票要求和 assets 个人报销例外条件;同时建立可核验的票据—明细关系与申报金额门禁,避免多日期、多支出、多张发票被合并为无法复核的一条笼统明细,并通过默认关闭的双闸门保护现有唯一真实报销链路。
|
||||||
|
|
||||||
## Background and Confirmed Facts
|
## Background and Confirmed Facts
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
export const ERROR_REASON_MESSAGES: Record<string, string> = {
|
export const ERROR_REASON_MESSAGES: Record<string, string> = {
|
||||||
|
"reimbursement.invoice_prefill_incomplete": "票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。",
|
||||||
|
"reimbursement.items_description_required": "请补充明细的支出说明后再提交。",
|
||||||
"reimbursement.material_preview_limit": "报销材料较多,暂时无法连续预览,请分批查看材料或联系管理员协助。",
|
"reimbursement.material_preview_limit": "报销材料较多,暂时无法连续预览,请分批查看材料或联系管理员协助。",
|
||||||
"purchase_attachment.unsupported_type": "仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。",
|
"purchase_attachment.unsupported_type": "仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。",
|
||||||
"reimbursement.invoice_amount_required": "票据金额尚未确认,无法自动生成明细,请先核对票据金额或手动选择明细。",
|
"reimbursement.invoice_amount_required": "票据金额尚未确认,无法自动生成明细,请先核对票据金额或手动选择明细。",
|
||||||
"reimbursement.expense_type_required": "当前没有可用费用类型,请联系管理员维护后再添加票据。",
|
"reimbursement.expense_type_required": "当前没有可用费用类型,请联系管理员维护后再添加票据。",
|
||||||
|
"reimbursement.items_expense_type_required": "请补充明细的费用类型;同类明细可批量设置,已停用的类型请重新选择。",
|
||||||
"reimbursement.invoice_not_bound": "该票据已不再关联业务单据,请刷新票据列表后确认。",
|
"reimbursement.invoice_not_bound": "该票据已不再关联业务单据,请刷新票据列表后确认。",
|
||||||
"reimbursement.invoice_unbind_status_invalid": "报销单当前不能移出票据,请先核实审批和付款状态。",
|
"reimbursement.invoice_unbind_status_invalid": "报销单当前不能移出票据,请先核实审批和付款状态。",
|
||||||
"reimbursement.invoice_bind_status_invalid": "报销单当前不能添加票据,请刷新并确认单据状态。",
|
"reimbursement.invoice_bind_status_invalid": "报销单当前不能添加票据,请刷新并确认单据状态。",
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export type InvoiceBindPayload =
|
|||||||
| {
|
| {
|
||||||
reimbursementId: string;
|
reimbursementId: string;
|
||||||
linkedItemId?: string;
|
linkedItemId?: string;
|
||||||
|
createItem?: true;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
travelOrderId: string;
|
travelOrderId: string;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export type ReimbursementStatus =
|
|||||||
|
|
||||||
export type ReimbursementItem = {
|
export type ReimbursementItem = {
|
||||||
id: string;
|
id: string;
|
||||||
expenseTypeId: string;
|
expenseTypeId: string | null;
|
||||||
expenseDate: string | null;
|
expenseDate: string | null;
|
||||||
amount: string;
|
amount: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
@@ -346,7 +346,7 @@ export type LinkableTravelRequestListResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ReimbursementItemPayload = {
|
export type ReimbursementItemPayload = {
|
||||||
expenseTypeId: string;
|
expenseTypeId: string | null;
|
||||||
expenseDate: string;
|
expenseDate: string;
|
||||||
amount: string;
|
amount: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -356,7 +356,7 @@ export type ReimbursementItemPayload = {
|
|||||||
export type ReimbursementItemMutationResponse = {
|
export type ReimbursementItemMutationResponse = {
|
||||||
item: {
|
item: {
|
||||||
id: string;
|
id: string;
|
||||||
expenseTypeId: string;
|
expenseTypeId: string | null;
|
||||||
expenseDate: string;
|
expenseDate: string;
|
||||||
amount: string;
|
amount: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequestData } from "../../api/http";
|
||||||
|
import type { ExpenseType } from "../../api/expenseTypes";
|
||||||
|
import type { ReimbursementItem } from "../../api/reimbursements";
|
||||||
|
import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
|
export function ReimbursementBatchClassification({ reimbursementId, items, expenseTypes }: {
|
||||||
|
reimbursementId: string;
|
||||||
|
items: ReimbursementItem[];
|
||||||
|
expenseTypes: ExpenseType[];
|
||||||
|
}) {
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
const [expenseTypeId, setExpenseTypeId] = useState("");
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const visibleIds = selectedIds.filter((id) => items.some((item) => item.id === id));
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => apiRequestData(`/api/reimbursements/${reimbursementId}/items/classify`, {
|
||||||
|
method: "POST", body: JSON.stringify({ itemIds: visibleIds, expenseTypeId }),
|
||||||
|
}),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setSelectedIds([]);
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mobile", "reimbursements"] }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!items.length) return null;
|
||||||
|
const unclassified = items.filter((item) => !item.expenseType || !item.expenseType.enabled).length;
|
||||||
|
return (
|
||||||
|
<details className="rounded-lg border border-zinc-200 p-3" open={unclassified > 0 ? true : undefined}>
|
||||||
|
<summary className="cursor-pointer text-sm font-medium">费用分类{unclassified ? `:${unclassified} 条待补充` : "(可批量修改)"}</summary>
|
||||||
|
<p className="my-2 text-xs text-zinc-500">选中同类明细,一次设置;未选中的明细不会修改。</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<label key={item.id} className="flex items-start gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={visibleIds.includes(item.id)} disabled={mutation.isPending}
|
||||||
|
onChange={(event) => setSelectedIds((previous) => event.target.checked ? [...new Set([...previous, item.id])] : previous.filter((id) => id !== item.id))} />
|
||||||
|
<span>明细 {index + 1} · {item.expenseType?.name ?? "待补充类型"} · ¥{item.amount} · {item.description || "待补充说明"}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<select aria-label="批量费用类型" className="select-std w-full" value={expenseTypeId} disabled={mutation.isPending}
|
||||||
|
onChange={(event) => setExpenseTypeId(event.target.value)}>
|
||||||
|
<option value="">选择所选明细的费用类型</option>
|
||||||
|
{expenseTypes.filter((type) => type.enabled).map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<Button type="button" disabled={!visibleIds.length || visibleIds.length > 100 || !expenseTypeId || mutation.isPending}
|
||||||
|
onClick={() => mutation.mutate()}>{mutation.isPending ? "设置中…" : `设置所选 ${visibleIds.length} 条分类`}</Button>
|
||||||
|
{visibleIds.length > 100 ? <p role="alert">每次最多设置 100 条,请分批选择。</p> : null}
|
||||||
|
{mutation.isError ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(mutation.error, "分类设置失败,请刷新后重试")}</p> : null}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export type ReimbursementEvidenceCapabilities = {
|
|||||||
onOpenMaterialBundle?: () => void;
|
onOpenMaterialBundle?: () => void;
|
||||||
onDeleteAttachment?: (attachment: ReimbursementAttachmentEvidence) => void;
|
onDeleteAttachment?: (attachment: ReimbursementAttachmentEvidence) => void;
|
||||||
onEditItem?: (item: ReimbursementItemEvidence) => void;
|
onEditItem?: (item: ReimbursementItemEvidence) => void;
|
||||||
|
onAddInvoices?: (item: ReimbursementItemEvidence) => void;
|
||||||
onLinkInvoice?: (invoice: ReimbursementInvoiceEvidence, linkedItemId: string) => void;
|
onLinkInvoice?: (invoice: ReimbursementInvoiceEvidence, linkedItemId: string) => void;
|
||||||
linkInvoicePending?: boolean;
|
linkInvoicePending?: boolean;
|
||||||
linkInvoiceErrors?: Record<string, string>;
|
linkInvoiceErrors?: Record<string, string>;
|
||||||
@@ -95,7 +96,7 @@ function ExpenseItemsTable({ amountSummary, items, capabilities }: { amountSumma
|
|||||||
<td className="px-3 py-3 align-top text-zinc-700"><p>{item.description || "未填写明细说明"}</p><ItemIssues item={item} /></td>
|
<td className="px-3 py-3 align-top text-zinc-700"><p>{item.description || "未填写明细说明"}</p><ItemIssues item={item} /></td>
|
||||||
<td className="px-3 py-3 text-right align-top font-medium tabular-nums text-zinc-900">{formatMoney(item.amount)}</td>
|
<td className="px-3 py-3 text-right align-top font-medium tabular-nums text-zinc-900">{formatMoney(item.amount)}</td>
|
||||||
<td className={hasBlockingIssue ? "px-3 py-3 align-top font-medium text-red-700" : "px-3 py-3 align-top text-zinc-600"}>{itemInvoiceStatus(item)}</td>
|
<td className={hasBlockingIssue ? "px-3 py-3 align-top font-medium text-red-700" : "px-3 py-3 align-top text-zinc-600"}>{itemInvoiceStatus(item)}</td>
|
||||||
{editable ? <td className="px-3 py-2 text-right align-top"><Button className="min-h-11" onClick={() => capabilities.onEditItem?.(item)} size="sm" type="button" variant="secondary">编辑</Button></td> : null}
|
{editable ? <td className="px-3 py-2 text-right align-top"><Button className="min-h-11" onClick={() => capabilities.onEditItem?.(item)} size="sm" type="button" variant="secondary">编辑</Button>{capabilities.onAddInvoices ? <Button className="min-h-11" onClick={() => capabilities.onAddInvoices?.(item)} size="sm" type="button" variant="secondary">为明细添加发票</Button> : null}</td> : null}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -125,6 +126,7 @@ function ExpenseItemCards({ amountSummary, items, capabilities }: { amountSummar
|
|||||||
<p className={hasBlockingIssue ? "mt-2 text-sm font-medium text-red-700" : "mt-2 text-sm text-zinc-600"}>票据:{itemInvoiceStatus(item)}</p>
|
<p className={hasBlockingIssue ? "mt-2 text-sm font-medium text-red-700" : "mt-2 text-sm text-zinc-600"}>票据:{itemInvoiceStatus(item)}</p>
|
||||||
<ItemIssues item={item} />
|
<ItemIssues item={item} />
|
||||||
{capabilities.onEditItem ? <Button className="mt-3 min-h-11 w-full" onClick={() => capabilities.onEditItem?.(item)} type="button" variant="secondary">编辑</Button> : null}
|
{capabilities.onEditItem ? <Button className="mt-3 min-h-11 w-full" onClick={() => capabilities.onEditItem?.(item)} type="button" variant="secondary">编辑</Button> : null}
|
||||||
|
{capabilities.onAddInvoices ? <Button className="mt-2 min-h-11 w-full" onClick={() => capabilities.onAddInvoices?.(item)} type="button" variant="secondary">为明细添加发票</Button> : null}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -110,10 +110,10 @@ export function useInvoiceOcrTracker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasLongWait) {
|
if (hasLongWait) {
|
||||||
return `${pendingInvoiceIds.length} 张发票仍在识别,系统会继续自动刷新,完成后会自动添加。`;
|
return `${pendingInvoiceIds.length} 张发票仍在识别,系统会继续自动刷新,完成后默认勾选,请点击添加到报销单。`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${pendingInvoiceIds.length} 张发票正在识别,正在自动刷新,完成后会自动添加。`;
|
return `${pendingInvoiceIds.length} 张发票正在识别,正在自动刷新,完成后默认勾选,请点击添加到报销单。`;
|
||||||
}, [hasLongWait, pendingInvoiceIds.length]);
|
}, [hasLongWait, pendingInvoiceIds.length]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { ReimbursementBatchClassification } from "../components/reimbursements/ReimbursementBatchClassification";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -178,6 +179,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
|
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
|
||||||
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
|
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
|
||||||
const [invoiceUploadLinkedItemId, setInvoiceUploadLinkedItemId] = useState("");
|
const [invoiceUploadLinkedItemId, setInvoiceUploadLinkedItemId] = useState("");
|
||||||
|
const deselectedInvoiceIds = useRef(new Set<string>());
|
||||||
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
|
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
|
||||||
const [invoiceActionError, setInvoiceActionError] = useState("");
|
const [invoiceActionError, setInvoiceActionError] = useState("");
|
||||||
const [invoiceActionMessage, setInvoiceActionMessage] = useState("");
|
const [invoiceActionMessage, setInvoiceActionMessage] = useState("");
|
||||||
@@ -327,7 +329,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
setItemForm({
|
setItemForm({
|
||||||
mode: "edit",
|
mode: "edit",
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
expenseTypeId: item.expenseTypeId,
|
expenseTypeId: item.expenseTypeId ?? "",
|
||||||
expenseDate: item.expenseDate ?? detail?.reimbursementDate ?? todayDate(),
|
expenseDate: item.expenseDate ?? detail?.reimbursementDate ?? todayDate(),
|
||||||
amount: item.amount,
|
amount: item.amount,
|
||||||
description: item.description ?? "",
|
description: item.description ?? "",
|
||||||
@@ -342,10 +344,10 @@ export function MobileReimbursementDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
expenseTypeId: form.expenseTypeId,
|
expenseTypeId: form.expenseTypeId || null,
|
||||||
expenseDate: form.expenseDate,
|
expenseDate: form.expenseDate,
|
||||||
amount: form.amount,
|
amount: form.amount,
|
||||||
description: form.description.trim() || undefined,
|
description: form.description.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (form.mode === "edit" && form.itemId) {
|
if (form.mode === "edit" && form.itemId) {
|
||||||
@@ -428,7 +430,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
try {
|
try {
|
||||||
await bindInvoice(selection.invoiceId, {
|
await bindInvoice(selection.invoiceId, {
|
||||||
reimbursementId,
|
reimbursementId,
|
||||||
...(selection.linkedItemId ? { linkedItemId: selection.linkedItemId } : {}),
|
...(selection.linkedItemId ? { linkedItemId: selection.linkedItemId } : { createItem: true as const }),
|
||||||
});
|
});
|
||||||
results.push({ invoiceId: selection.invoiceId, success: true });
|
results.push({ invoiceId: selection.invoiceId, success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -479,14 +481,18 @@ export function MobileReimbursementDetailPage() {
|
|||||||
if (!invoiceIds.length) {
|
if (!invoiceIds.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bindInvoicesMutation.mutate(
|
void queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||||
invoiceIds.map((invoiceId) => ({
|
setSelectedInvoices((previous) => {
|
||||||
invoiceId,
|
const next = { ...previous };
|
||||||
linkedItemId: pendingOcrLinkedItemIds[invoiceId] || undefined,
|
for (const invoiceId of invoiceIds) {
|
||||||
})),
|
if (!deselectedInvoiceIds.current.has(invoiceId) && !(invoiceId in next)) {
|
||||||
);
|
next[invoiceId] = pendingOcrLinkedItemIds[invoiceId] || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[bindInvoicesMutation, pendingOcrLinkedItemIds],
|
[pendingOcrLinkedItemIds, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null }>) => {
|
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null }>) => {
|
||||||
@@ -585,7 +591,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
const failures: Array<{ file: File; message: string }> = [];
|
const failures: Array<{ file: File; message: string }> = [];
|
||||||
const pendingIds: string[] = [];
|
const pendingIds: string[] = [];
|
||||||
let uploadedCount = 0;
|
let uploadedCount = 0;
|
||||||
let boundCount = 0;
|
const availableIds: string[] = [];
|
||||||
|
|
||||||
setInvoiceUploadProgress({ completed: 0, total: files.length });
|
setInvoiceUploadProgress({ completed: 0, total: files.length });
|
||||||
|
|
||||||
@@ -602,15 +608,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
message: resolveInvoiceUserMessage(uploadResult),
|
message: resolveInvoiceUserMessage(uploadResult),
|
||||||
});
|
});
|
||||||
} else if (uploadResult.status === "available") {
|
} else if (uploadResult.status === "available") {
|
||||||
try {
|
availableIds.push(uploadResult.invoicePoolId);
|
||||||
await bindInvoice(uploadResult.invoicePoolId, {
|
|
||||||
reimbursementId,
|
|
||||||
...(detail.invoiceGuardEnabled ? { linkedItemId: invoiceUploadLinkedItemId } : {}),
|
|
||||||
});
|
|
||||||
boundCount += 1;
|
|
||||||
} catch (error) {
|
|
||||||
failures.push({ file, message: `上传成功,添加失败:${resolveErrorMessage(error)}` });
|
|
||||||
}
|
|
||||||
} else if (uploadResult.status === "pending_ocr") {
|
} else if (uploadResult.status === "pending_ocr") {
|
||||||
pendingIds.push(uploadResult.invoicePoolId);
|
pendingIds.push(uploadResult.invoicePoolId);
|
||||||
} else if (uploadResult.status === "ocr_failed") {
|
} else if (uploadResult.status === "ocr_failed") {
|
||||||
@@ -625,14 +623,14 @@ export function MobileReimbursementDetailPage() {
|
|||||||
setInvoiceUploadProgress({ completed: index + 1, total: files.length });
|
setInvoiceUploadProgress({ completed: index + 1, total: files.length });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { uploadedCount, boundCount, pendingIds, failures };
|
return { uploadedCount, availableIds, pendingIds, failures };
|
||||||
},
|
},
|
||||||
onSuccess: async (result) => {
|
onSuccess: async (result) => {
|
||||||
await invalidateDetail();
|
await invalidateDetail();
|
||||||
await bindableInvoicesQuery.refetch();
|
await bindableInvoicesQuery.refetch();
|
||||||
|
|
||||||
setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...result.pendingIds])));
|
setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...result.pendingIds])));
|
||||||
if (detail?.invoiceGuardEnabled && invoiceUploadLinkedItemId) {
|
if (invoiceUploadLinkedItemId) {
|
||||||
setPendingOcrLinkedItemIds((previous) => ({
|
setPendingOcrLinkedItemIds((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
...Object.fromEntries(result.pendingIds.map((invoiceId) => [invoiceId, invoiceUploadLinkedItemId])),
|
...Object.fromEntries(result.pendingIds.map((invoiceId) => [invoiceId, invoiceUploadLinkedItemId])),
|
||||||
@@ -646,8 +644,13 @@ export function MobileReimbursementDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messages: string[] = [];
|
const messages: string[] = [];
|
||||||
if (result.boundCount) {
|
setSelectedInvoices((previous) => ({
|
||||||
messages.push(`${result.boundCount} 张已添加`);
|
...Object.fromEntries(result.availableIds.filter((id) => !deselectedInvoiceIds.current.has(id)).map((id) => [id, invoiceUploadLinkedItemId])),
|
||||||
|
...previous,
|
||||||
|
}));
|
||||||
|
setInvoicePanel("library");
|
||||||
|
if (result.availableIds.length) {
|
||||||
|
messages.push(`${result.availableIds.length} 张已入我的发票,请选择后添加`);
|
||||||
}
|
}
|
||||||
if (result.pendingIds.length) {
|
if (result.pendingIds.length) {
|
||||||
messages.push(`已上传 ${result.pendingIds.length} 张,正在识别`);
|
messages.push(`已上传 ${result.pendingIds.length} 张,正在识别`);
|
||||||
@@ -855,6 +858,8 @@ export function MobileReimbursementDetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleInvoiceSelection = (invoice: BindableInvoiceSummary, checked: boolean) => {
|
const toggleInvoiceSelection = (invoice: BindableInvoiceSummary, checked: boolean) => {
|
||||||
|
if (checked) deselectedInvoiceIds.current.delete(invoice.id);
|
||||||
|
else deselectedInvoiceIds.current.add(invoice.id);
|
||||||
setSelectedInvoices((previous) => {
|
setSelectedInvoices((previous) => {
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
const next = { ...previous };
|
const next = { ...previous };
|
||||||
@@ -863,7 +868,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...previous,
|
...previous,
|
||||||
[invoice.id]: previous[invoice.id] ?? "",
|
[invoice.id]: previous[invoice.id] ?? invoiceUploadLinkedItemId,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -911,6 +916,10 @@ export function MobileReimbursementDetailPage() {
|
|||||||
});
|
});
|
||||||
const workspaceModel = buildReimbursementWorkspaceModel(detail, workspaceEvidence);
|
const workspaceModel = buildReimbursementWorkspaceModel(detail, workspaceEvidence);
|
||||||
const workspaceCapabilities = {
|
const workspaceCapabilities = {
|
||||||
|
onAddInvoices: canOpenInvoiceDialog ? (item: { id: string }) => {
|
||||||
|
closeInvoiceDialog(true);
|
||||||
|
setInvoiceUploadLinkedItemId(item.id);
|
||||||
|
} : undefined,
|
||||||
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
|
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
|
||||||
onOpenAttachment: (attachment: { id: string }) => {
|
onOpenAttachment: (attachment: { id: string }) => {
|
||||||
const sourceAttachment = detail.attachments.find((item) => item.id === attachment.id);
|
const sourceAttachment = detail.attachments.find((item) => item.id === attachment.id);
|
||||||
@@ -929,6 +938,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
};
|
};
|
||||||
const workspaceHeaderDetails = (
|
const workspaceHeaderDetails = (
|
||||||
<>
|
<>
|
||||||
|
{canMaintainReimbursement ? <ReimbursementBatchClassification key={reimbursementId} reimbursementId={reimbursementId} items={detail.items} expenseTypes={expenseTypes} /> : null}
|
||||||
{!isParticipant ? <p className="text-xs text-zinc-500">只读查看{detail.status === "draft" ? " · 等待报销人完善" : detail.status === "rejected" ? " · 等待报销人修改后重提" : ""}</p> : null}
|
{!isParticipant ? <p className="text-xs text-zinc-500">只读查看{detail.status === "draft" ? " · 等待报销人完善" : detail.status === "rejected" ? " · 等待报销人修改后重提" : ""}</p> : null}
|
||||||
{canMaintainReimbursement ? <ReimbursementHeaderEditor reimbursementId={reimbursementId} reimbursementDate={detail.reimbursementDate} description={detail.description} editable mobile showDescription={!isTravelReimbursement(detail)} /> : null}
|
{canMaintainReimbursement ? <ReimbursementHeaderEditor reimbursementId={reimbursementId} reimbursementDate={detail.reimbursementDate} description={detail.description} editable mobile showDescription={!isTravelReimbursement(detail)} /> : null}
|
||||||
<ReimbursementTravelSummary detail={detail} reimbursementId={reimbursementId} editable={canMaintainReimbursement} mobile embedded />
|
<ReimbursementTravelSummary detail={detail} reimbursementId={reimbursementId} editable={canMaintainReimbursement} mobile embedded />
|
||||||
@@ -952,12 +962,12 @@ export function MobileReimbursementDetailPage() {
|
|||||||
<input ref={attachmentInputRef} className="sr-only" type="file" accept="application/pdf,image/jpeg,image/png,image/webp" multiple onChange={(event) => { const files=Array.from(event.target.files ?? []); if(files.length>MAX_REIMBURSEMENT_ATTACHMENT_FILES){ setSelectedAttachmentFiles([]); event.target.value=""; toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`); return;} setSelectedAttachmentFiles(files); }} />
|
<input ref={attachmentInputRef} className="sr-only" type="file" accept="application/pdf,image/jpeg,image/png,image/webp" multiple onChange={(event) => { const files=Array.from(event.target.files ?? []); if(files.length>MAX_REIMBURSEMENT_ATTACHMENT_FILES){ setSelectedAttachmentFiles([]); event.target.value=""; toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`); return;} setSelectedAttachmentFiles(files); }} />
|
||||||
{itemForm ? (
|
{itemForm ? (
|
||||||
<div className="space-y-3 rounded-lg border border-primary-100 bg-primary-50/40 p-3">
|
<div className="space-y-3 rounded-lg border border-primary-100 bg-primary-50/40 p-3">
|
||||||
<label className="block space-y-1 text-sm text-zinc-700"><span>费用类型</span><select className="select-std w-full" disabled={!expenseTypes.length || itemSaveMutation.isPending} value={itemForm.expenseTypeId} onChange={(event) => setItemForm((current) => current ? { ...current, expenseTypeId: event.target.value } : current)}>{!expenseTypes.length ? <option value="">暂无可用类型</option> : null}{expenseTypes.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</select></label>
|
<label className="block space-y-1 text-sm text-zinc-700"><span>费用类型</span><select className="select-std w-full" disabled={!expenseTypes.length || itemSaveMutation.isPending} value={itemForm.expenseTypeId} onChange={(event) => setItemForm((current) => current ? { ...current, expenseTypeId: event.target.value } : current)}><option value="">待补充费用类型</option>{expenseTypes.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</select></label>
|
||||||
<label className="block space-y-1 text-sm text-zinc-700"><span>日期</span><Input disabled={itemSaveMutation.isPending} type="date" value={itemForm.expenseDate} onChange={(event) => setItemForm((current) => current ? { ...current, expenseDate: event.target.value } : current)} /></label>
|
<label className="block space-y-1 text-sm text-zinc-700"><span>日期</span><Input disabled={itemSaveMutation.isPending} type="date" value={itemForm.expenseDate} onChange={(event) => setItemForm((current) => current ? { ...current, expenseDate: event.target.value } : current)} /></label>
|
||||||
<label className="block space-y-1 text-sm text-zinc-700"><span>金额</span><Input disabled={itemSaveMutation.isPending} inputMode="decimal" value={itemForm.amount} onChange={(event) => setItemForm((current) => current ? { ...current, amount: event.target.value } : current)} /></label>
|
<label className="block space-y-1 text-sm text-zinc-700"><span>金额</span><Input disabled={itemSaveMutation.isPending} inputMode="decimal" value={itemForm.amount} onChange={(event) => setItemForm((current) => current ? { ...current, amount: event.target.value } : current)} /></label>
|
||||||
<label className="block space-y-1 text-sm text-zinc-700"><span>说明</span><Textarea disabled={itemSaveMutation.isPending} rows={2} value={itemForm.description} onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)} /></label>
|
<label className="block space-y-1 text-sm text-zinc-700"><span>说明</span><Textarea disabled={itemSaveMutation.isPending} rows={2} value={itemForm.description} onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)} /></label>
|
||||||
{shouldShowOfficeElectronicsHint(expenseTypes.find((type) => type.id === itemForm.expenseTypeId), itemForm.amount) ? <p className="text-xs text-amber-700">该金额通常应选择办公电子设备。</p> : null}
|
{shouldShowOfficeElectronicsHint(expenseTypes.find((type) => type.id === itemForm.expenseTypeId), itemForm.amount) ? <p className="text-xs text-amber-700">该金额通常应选择办公电子设备。</p> : null}
|
||||||
<div className="grid grid-cols-2 gap-2"><Button disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending} onClick={() => itemSaveMutation.mutate(itemForm)} type="button">保存</Button><Button disabled={itemSaveMutation.isPending} onClick={() => setItemForm(null)} type="button" variant="secondary">取消</Button>{itemForm.mode === "edit" && itemForm.itemId ? <Button className="col-span-2" data-testid="reimbursement-item-delete" disabled={itemSaveMutation.isPending} onClick={() => setConfirmDeleteItemId(itemForm.itemId ?? "")} type="button" variant="destructive">删除明细</Button> : null}</div>
|
<div className="grid grid-cols-2 gap-2"><Button disabled={!itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending} onClick={() => itemSaveMutation.mutate(itemForm)} type="button">保存</Button><Button disabled={itemSaveMutation.isPending} onClick={() => setItemForm(null)} type="button" variant="secondary">取消</Button>{itemForm.mode === "edit" && itemForm.itemId ? <Button className="col-span-2" data-testid="reimbursement-item-delete" disabled={itemSaveMutation.isPending} onClick={() => setConfirmDeleteItemId(itemForm.itemId ?? "")} type="button" variant="destructive">删除明细</Button> : null}</div>
|
||||||
</div>
|
</div>
|
||||||
) : <p className="text-xs text-zinc-500">选择上方动作维护明细、发票或补充附件。</p>}
|
) : <p className="text-xs text-zinc-500">选择上方动作维护明细、发票或补充附件。</p>}
|
||||||
</section>
|
</section>
|
||||||
@@ -979,14 +989,15 @@ export function MobileReimbursementDetailPage() {
|
|||||||
<ReimbursementMobileWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} fallbackTo={reimbursementFallback(detail.status)} slots={{ status: <span className={cn("rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>{STATUS_LABEL[detail.status]}</span>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, mobileActions }} />
|
<ReimbursementMobileWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} fallbackTo={reimbursementFallback(detail.status)} slots={{ status: <span className={cn("rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>{STATUS_LABEL[detail.status]}</span>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, mobileActions }} />
|
||||||
<Dialog open={isInvoiceDialogOpen} onOpenChange={closeInvoiceDialog}>
|
<Dialog open={isInvoiceDialogOpen} onOpenChange={closeInvoiceDialog}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="overflow-y-auto p-4"
|
className="left-0 top-0 w-screen max-w-none translate-x-0 translate-y-0 overflow-y-auto rounded-none p-4"
|
||||||
data-testid={TEST_IDS.reimbursement.invoiceDialog}
|
data-testid={TEST_IDS.reimbursement.invoiceDialog}
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
style={{ maxHeight: "min(86vh, calc(var(--mobile-viewport-height, 100vh) - 3rem))" }}
|
style={{ height: "var(--mobile-viewport-height, 100dvh)" }}
|
||||||
>
|
>
|
||||||
<DialogHeader className="text-left">
|
<DialogHeader className="text-left">
|
||||||
<DialogTitle>添加发票</DialogTitle>
|
<DialogTitle>我的发票</DialogTitle>
|
||||||
<DialogDescription>可一次上传多张,识别完成后会自动添加。</DialogDescription>
|
<Button type="button" variant="secondary" onClick={() => closeInvoiceDialog(false)}>返回报销单</Button>
|
||||||
|
<DialogDescription>可一次上传多张,识别完成后默认勾选,请点击添加到报销单。</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="mobile-two-action-grid mt-4 rounded-lg bg-zinc-100 p-1">
|
<div className="mobile-two-action-grid mt-4 rounded-lg bg-zinc-100 p-1">
|
||||||
@@ -1062,14 +1073,14 @@ export function MobileReimbursementDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{detail.invoiceGuardEnabled ? (
|
{detail.items.length > 0 ? (
|
||||||
<select
|
<select
|
||||||
aria-label="新上传发票关联明细"
|
aria-label="新发票添加位置"
|
||||||
className="select-std w-full"
|
className="select-std w-full"
|
||||||
value={invoiceUploadLinkedItemId}
|
value={invoiceUploadLinkedItemId}
|
||||||
onChange={(event) => setInvoiceUploadLinkedItemId(event.target.value)}
|
onChange={(event) => setInvoiceUploadLinkedItemId(event.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">请选择新上传发票对应的费用明细</option>
|
<option value="">自动创建新明细</option>
|
||||||
{detail.items.map((detailItem) => (
|
{detail.items.map((detailItem) => (
|
||||||
<option key={detailItem.id} value={detailItem.id}>
|
<option key={detailItem.id} value={detailItem.id}>
|
||||||
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
|
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
|
||||||
@@ -1082,7 +1093,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
|
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!selectedInvoiceFiles.length || (detail.invoiceGuardEnabled && !invoiceUploadLinkedItemId) || uploadInvoiceMutation.isPending}
|
disabled={!selectedInvoiceFiles.length || uploadInvoiceMutation.isPending}
|
||||||
onClick={() => uploadInvoiceMutation.mutate(selectedInvoiceFiles)}
|
onClick={() => uploadInvoiceMutation.mutate(selectedInvoiceFiles)}
|
||||||
>
|
>
|
||||||
{uploadInvoiceMutation.isPending
|
{uploadInvoiceMutation.isPending
|
||||||
@@ -1175,17 +1186,17 @@ export function MobileReimbursementDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{isAvailable && checked ? (
|
{isAvailable && checked ? (
|
||||||
<div className="mt-3 rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700">
|
<div className="mt-3 rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700">
|
||||||
{detail.invoiceGuardEnabled ? "请选择该发票对应的费用明细" : "添加后自动生成或匹配报销明细"}
|
点击添加后生成可编辑明细;也可选择加入已有明细。
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isAvailable && checked && detail.invoiceGuardEnabled ? (
|
{isAvailable && checked && detail.items.length > 0 ? (
|
||||||
<select
|
<select
|
||||||
aria-label="关联费用明细"
|
aria-label="添加到哪条明细"
|
||||||
className="select-std mt-3 w-full"
|
className="select-std mt-3 w-full"
|
||||||
value={selectedInvoices[invoice.id] ?? ""}
|
value={selectedInvoices[invoice.id] ?? ""}
|
||||||
onChange={(event) => setSelectedInvoices((previous) => ({ ...previous, [invoice.id]: event.target.value }))}
|
onChange={(event) => setSelectedInvoices((previous) => ({ ...previous, [invoice.id]: event.target.value }))}
|
||||||
>
|
>
|
||||||
<option value="">请选择费用明细</option>
|
<option value="">自动创建新明细</option>
|
||||||
{detail.items.map((detailItem) => (
|
{detail.items.map((detailItem) => (
|
||||||
<option key={detailItem.id} value={detailItem.id}>
|
<option key={detailItem.id} value={detailItem.id}>
|
||||||
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
|
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
|
||||||
@@ -1211,7 +1222,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!selectedInvoiceEntries.length || (detail.invoiceGuardEnabled && selectedInvoiceEntries.some(([, linkedItemId]) => !linkedItemId)) || bindInvoicesMutation.isPending}
|
disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
bindInvoicesMutation.mutate(
|
bindInvoicesMutation.mutate(
|
||||||
selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({
|
selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({
|
||||||
@@ -1221,7 +1232,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{bindInvoicesMutation.isPending ? "添加中..." : `添加选中发票 ${selectedInvoiceEntries.length}`}
|
{bindInvoicesMutation.isPending ? "添加中..." : `添加到报销单 ${selectedInvoiceEntries.length}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
|
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { ReimbursementBatchClassification } from "../components/reimbursements/ReimbursementBatchClassification";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
@@ -62,7 +63,6 @@ import { SectionPanel } from "../components/patterns/SectionPanel";
|
|||||||
import {
|
import {
|
||||||
formatInvoiceDisplayTitle,
|
formatInvoiceDisplayTitle,
|
||||||
resolveInvoicePartyText,
|
resolveInvoicePartyText,
|
||||||
resolveInvoiceNextStepText,
|
|
||||||
resolveInvoiceUserMessage,
|
resolveInvoiceUserMessage,
|
||||||
} from "../lib/invoiceStatus";
|
} from "../lib/invoiceStatus";
|
||||||
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
||||||
@@ -121,6 +121,7 @@ export function ReimbursementDetailPage() {
|
|||||||
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
|
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
|
||||||
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
|
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
|
||||||
const [invoiceUploadLinkedItemId, setInvoiceUploadLinkedItemId] = useState("");
|
const [invoiceUploadLinkedItemId, setInvoiceUploadLinkedItemId] = useState("");
|
||||||
|
const deselectedInvoiceIds = useRef(new Set<string>());
|
||||||
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
|
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
|
||||||
const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null);
|
const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null);
|
||||||
const [invoiceActionError, setInvoiceActionError] = useState("");
|
const [invoiceActionError, setInvoiceActionError] = useState("");
|
||||||
@@ -284,10 +285,10 @@ export function ReimbursementDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
expenseTypeId: data.expenseTypeId,
|
expenseTypeId: data.expenseTypeId || null,
|
||||||
expenseDate: data.expenseDate,
|
expenseDate: data.expenseDate,
|
||||||
amount: data.amount,
|
amount: data.amount,
|
||||||
description: data.description.trim() || undefined,
|
description: data.description.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (itemEditor.mode === "create") {
|
if (itemEditor.mode === "create") {
|
||||||
@@ -371,7 +372,7 @@ export function ReimbursementDetailPage() {
|
|||||||
try {
|
try {
|
||||||
await bindInvoice(selection.invoiceId, {
|
await bindInvoice(selection.invoiceId, {
|
||||||
reimbursementId,
|
reimbursementId,
|
||||||
...(selection.linkedItemId ? { linkedItemId: selection.linkedItemId } : {}),
|
...(selection.linkedItemId ? { linkedItemId: selection.linkedItemId } : { createItem: true as const }),
|
||||||
});
|
});
|
||||||
results.push({ invoiceId: selection.invoiceId, success: true });
|
results.push({ invoiceId: selection.invoiceId, success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -406,6 +407,7 @@ export function ReimbursementDetailPage() {
|
|||||||
if (!failedResults.length) {
|
if (!failedResults.length) {
|
||||||
setInvoiceActionError("");
|
setInvoiceActionError("");
|
||||||
setIsInvoiceDialogOpen(false);
|
setIsInvoiceDialogOpen(false);
|
||||||
|
setInvoiceUploadLinkedItemId("");
|
||||||
setInvoiceSearchInput("");
|
setInvoiceSearchInput("");
|
||||||
setInvoiceSearch("");
|
setInvoiceSearch("");
|
||||||
setSelectedInvoices({});
|
setSelectedInvoices({});
|
||||||
@@ -442,14 +444,18 @@ export function ReimbursementDetailPage() {
|
|||||||
if (!invoiceIds.length) {
|
if (!invoiceIds.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bindInvoicesMutation.mutate(
|
void queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||||
invoiceIds.map((invoiceId) => ({
|
setSelectedInvoices((previous) => {
|
||||||
invoiceId,
|
const next = { ...previous };
|
||||||
linkedItemId: pendingOcrLinkedItemIds[invoiceId] || undefined,
|
for (const invoiceId of invoiceIds) {
|
||||||
})),
|
if (!deselectedInvoiceIds.current.has(invoiceId) && !(invoiceId in next)) {
|
||||||
);
|
next[invoiceId] = pendingOcrLinkedItemIds[invoiceId] || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[bindInvoicesMutation, pendingOcrLinkedItemIds],
|
[pendingOcrLinkedItemIds, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null; ocrLastError?: string | null }>) => {
|
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null; ocrLastError?: string | null }>) => {
|
||||||
@@ -532,7 +538,7 @@ export function ReimbursementDetailPage() {
|
|||||||
const availableIds: string[] = [];
|
const availableIds: string[] = [];
|
||||||
const ocrFailures: string[] = [];
|
const ocrFailures: string[] = [];
|
||||||
const bindFailures: string[] = [];
|
const bindFailures: string[] = [];
|
||||||
let boundCount = 0;
|
|
||||||
setInvoiceUploadProgress({ completed: 0, total: files.length });
|
setInvoiceUploadProgress({ completed: 0, total: files.length });
|
||||||
|
|
||||||
for (const [index, file] of files.entries()) {
|
for (const [index, file] of files.entries()) {
|
||||||
@@ -562,29 +568,22 @@ export function ReimbursementDetailPage() {
|
|||||||
setPendingOcrInvoiceIds((previous) =>
|
setPendingOcrInvoiceIds((previous) =>
|
||||||
Array.from(new Set([...previous, ...Array.from(pendingById.keys())])),
|
Array.from(new Set([...previous, ...Array.from(pendingById.keys())])),
|
||||||
);
|
);
|
||||||
if (detail.invoiceGuardEnabled && invoiceUploadLinkedItemId) {
|
if (invoiceUploadLinkedItemId) {
|
||||||
setPendingOcrLinkedItemIds((previous) => ({
|
setPendingOcrLinkedItemIds((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
...Object.fromEntries(Array.from(pendingById.keys()).map((invoiceId) => [invoiceId, invoiceUploadLinkedItemId])),
|
...Object.fromEntries(Array.from(pendingById.keys()).map((invoiceId) => [invoiceId, invoiceUploadLinkedItemId])),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const invoiceId of availableIds) {
|
setSelectedInvoices((previous) => ({
|
||||||
try {
|
...Object.fromEntries(availableIds.filter((id) => !deselectedInvoiceIds.current.has(id)).map((id) => [id, invoiceUploadLinkedItemId])),
|
||||||
await bindInvoice(invoiceId, {
|
...previous,
|
||||||
reimbursementId,
|
}));
|
||||||
...(detail.invoiceGuardEnabled ? { linkedItemId: invoiceUploadLinkedItemId } : {}),
|
|
||||||
});
|
|
||||||
boundCount += 1;
|
|
||||||
} catch (error) {
|
|
||||||
bindFailures.push(`新上传发票:${buildActionErrorMessage(error, "添加发票失败")}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uploadedCount: files.length - uploadFailures.length,
|
uploadedCount: files.length - uploadFailures.length,
|
||||||
uploadFailures,
|
uploadFailures,
|
||||||
boundCount,
|
|
||||||
bindFailures,
|
bindFailures,
|
||||||
pendingIds: Array.from(pendingById.keys()),
|
pendingIds: Array.from(pendingById.keys()),
|
||||||
ocrFailures,
|
ocrFailures,
|
||||||
@@ -616,10 +615,10 @@ export function ReimbursementDetailPage() {
|
|||||||
|
|
||||||
setInvoiceActionError("");
|
setInvoiceActionError("");
|
||||||
if (result.pendingIds.length) {
|
if (result.pendingIds.length) {
|
||||||
toast.success(`已上传 ${result.pendingIds.length} 张发票,识别完成后会自动添加`);
|
toast.success(`已上传 ${result.pendingIds.length} 张发票,识别完成后默认勾选,请点击添加到报销单`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success(`发票上传完成:${result.boundCount || result.uploadedCount} 张已添加`);
|
toast.success(`发票上传完成:${result.uploadedCount} 张已入我的发票,请选择后添加`);
|
||||||
},
|
},
|
||||||
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
|
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -811,7 +810,7 @@ export function ReimbursementDetailPage() {
|
|||||||
new Date().toISOString().slice(0, 10);
|
new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
const defaultExpenseTypeId =
|
const defaultExpenseTypeId =
|
||||||
item?.expenseTypeId ?? expenseTypes[0]?.id ?? "";
|
item ? item.expenseTypeId ?? "" : expenseTypes[0]?.id ?? "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
expenseDate: defaultDate,
|
expenseDate: defaultDate,
|
||||||
@@ -903,6 +902,8 @@ export function ReimbursementDetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleInvoiceSelection = (invoiceId: string, checked: boolean) => {
|
const toggleInvoiceSelection = (invoiceId: string, checked: boolean) => {
|
||||||
|
if (checked) deselectedInvoiceIds.current.delete(invoiceId);
|
||||||
|
else deselectedInvoiceIds.current.add(invoiceId);
|
||||||
setSelectedInvoices((previous) => {
|
setSelectedInvoices((previous) => {
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
const next = { ...previous };
|
const next = { ...previous };
|
||||||
@@ -912,7 +913,7 @@ export function ReimbursementDetailPage() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...previous,
|
...previous,
|
||||||
[invoiceId]: previous[invoiceId] ?? "",
|
[invoiceId]: previous[invoiceId] ?? invoiceUploadLinkedItemId,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1055,6 +1056,10 @@ export function ReimbursementDetailPage() {
|
|||||||
});
|
});
|
||||||
const workspaceModel = buildReimbursementWorkspaceModel(detail, workspaceEvidence);
|
const workspaceModel = buildReimbursementWorkspaceModel(detail, workspaceEvidence);
|
||||||
const workspaceCapabilities = {
|
const workspaceCapabilities = {
|
||||||
|
onAddInvoices: canOpenInvoiceBindDialog ? (item: { id: string }) => {
|
||||||
|
handleInvoiceDialogOpenChange(true);
|
||||||
|
setInvoiceUploadLinkedItemId(item.id);
|
||||||
|
} : undefined,
|
||||||
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
|
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
|
||||||
onOpenAttachment: (attachment: { id: string }) => {
|
onOpenAttachment: (attachment: { id: string }) => {
|
||||||
const sourceAttachment = detail.attachments.find((item) => item.id === attachment.id);
|
const sourceAttachment = detail.attachments.find((item) => item.id === attachment.id);
|
||||||
@@ -1074,6 +1079,7 @@ export function ReimbursementDetailPage() {
|
|||||||
};
|
};
|
||||||
const workspaceHeaderDetails = canMaintainReimbursement || isTravelReimbursement(detail) || detail.project || detail.linkedTravelRequest ? (
|
const workspaceHeaderDetails = canMaintainReimbursement || isTravelReimbursement(detail) || detail.project || detail.linkedTravelRequest ? (
|
||||||
<SectionPanel title="补充信息">
|
<SectionPanel title="补充信息">
|
||||||
|
{canMaintainReimbursement ? <ReimbursementBatchClassification key={reimbursementId} reimbursementId={reimbursementId} items={detail.items} expenseTypes={expenseTypes} /> : null}
|
||||||
{canMaintainReimbursement ? (
|
{canMaintainReimbursement ? (
|
||||||
<ReimbursementHeaderEditor reimbursementId={reimbursementId} reimbursementDate={detail.reimbursementDate} description={detail.description} editable showDescription={!isTravelReimbursement(detail)} />
|
<ReimbursementHeaderEditor reimbursementId={reimbursementId} reimbursementDate={detail.reimbursementDate} description={detail.description} editable showDescription={!isTravelReimbursement(detail)} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1116,7 +1122,8 @@ export function ReimbursementDetailPage() {
|
|||||||
data-testid={TEST_IDS.reimbursement.invoiceDialog}
|
data-testid={TEST_IDS.reimbursement.invoiceDialog}
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>添加发票</DialogTitle>
|
<DialogTitle>我的发票</DialogTitle>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => handleInvoiceDialogOpenChange(false)}>返回报销单</Button>
|
||||||
<DialogDescription>选择可添加发票,或上传新发票。</DialogDescription>
|
<DialogDescription>选择可添加发票,或上传新发票。</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -1153,7 +1160,6 @@ export function ReimbursementDetailPage() {
|
|||||||
) : (bindableInvoicesQuery.data?.items ?? []).length ? (
|
) : (bindableInvoicesQuery.data?.items ?? []).length ? (
|
||||||
bindableInvoicesQuery.data?.items.map((item) => {
|
bindableInvoicesQuery.data?.items.map((item) => {
|
||||||
const isSelected = Object.prototype.hasOwnProperty.call(selectedInvoices, item.id);
|
const isSelected = Object.prototype.hasOwnProperty.call(selectedInvoices, item.id);
|
||||||
const invoiceHint = resolveInvoiceNextStepText({ ...item, status: "available" });
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -1179,17 +1185,17 @@ export function ReimbursementDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-[220px] rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700 md:max-w-[240px]">
|
<div className="min-w-[220px] rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700 md:max-w-[240px]">
|
||||||
{detail.invoiceGuardEnabled ? "请选择该发票对应的费用明细。" : `${invoiceHint} 添加后自动生成或匹配报销明细。`}
|
点击添加后生成可编辑明细;也可选择加入已有明细。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isSelected && detail.invoiceGuardEnabled ? (
|
{isSelected && detail.items.length > 0 ? (
|
||||||
<select
|
<select
|
||||||
aria-label="关联费用明细"
|
aria-label="添加到哪条明细"
|
||||||
className="select-std mt-3 w-full"
|
className="select-std mt-3 w-full"
|
||||||
value={selectedInvoices[item.id] ?? ""}
|
value={selectedInvoices[item.id] ?? ""}
|
||||||
onChange={(event) => setSelectedInvoices((previous) => ({ ...previous, [item.id]: event.target.value }))}
|
onChange={(event) => setSelectedInvoices((previous) => ({ ...previous, [item.id]: event.target.value }))}
|
||||||
>
|
>
|
||||||
<option value="">请选择费用明细</option>
|
<option value="">自动创建新明细</option>
|
||||||
{detail.items.map((detailItem) => (
|
{detail.items.map((detailItem) => (
|
||||||
<option key={detailItem.id} value={detailItem.id}>
|
<option key={detailItem.id} value={detailItem.id}>
|
||||||
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
|
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
|
||||||
@@ -1211,10 +1217,10 @@ export function ReimbursementDetailPage() {
|
|||||||
<div className="text-sm text-zinc-600">已选 {selectedInvoiceEntries.length} 张发票</div>
|
<div className="text-sm text-zinc-600">已选 {selectedInvoiceEntries.length} 张发票</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!selectedInvoiceEntries.length || (detail.invoiceGuardEnabled && selectedInvoiceEntries.some(([, linkedItemId]) => !linkedItemId)) || bindInvoicesMutation.isPending}
|
disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending}
|
||||||
onClick={handleBindSelectedInvoices}
|
onClick={handleBindSelectedInvoices}
|
||||||
>
|
>
|
||||||
{bindInvoicesMutation.isPending ? "添加中..." : "添加选中发票"}
|
{bindInvoicesMutation.isPending ? "添加中..." : "添加到报销单"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1250,7 +1256,7 @@ export function ReimbursementDetailPage() {
|
|||||||
<Button
|
<Button
|
||||||
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
|
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!selectedInvoiceFiles.length || (detail.invoiceGuardEnabled && !invoiceUploadLinkedItemId) || uploadInvoiceMutation.isPending}
|
disabled={!selectedInvoiceFiles.length || uploadInvoiceMutation.isPending}
|
||||||
onClick={() => selectedInvoiceFiles.length && uploadInvoiceMutation.mutate(selectedInvoiceFiles)}
|
onClick={() => selectedInvoiceFiles.length && uploadInvoiceMutation.mutate(selectedInvoiceFiles)}
|
||||||
>
|
>
|
||||||
{uploadInvoiceMutation.isPending
|
{uploadInvoiceMutation.isPending
|
||||||
@@ -1259,14 +1265,14 @@ export function ReimbursementDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detail.invoiceGuardEnabled ? (
|
{detail.items.length > 0 ? (
|
||||||
<select
|
<select
|
||||||
aria-label="新上传发票关联明细"
|
aria-label="新发票添加位置"
|
||||||
className="select-std mt-3 w-full"
|
className="select-std mt-3 w-full"
|
||||||
value={invoiceUploadLinkedItemId}
|
value={invoiceUploadLinkedItemId}
|
||||||
onChange={(event) => setInvoiceUploadLinkedItemId(event.target.value)}
|
onChange={(event) => setInvoiceUploadLinkedItemId(event.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">请选择新上传发票对应的费用明细</option>
|
<option value="">自动创建新明细</option>
|
||||||
{detail.items.map((detailItem) => (
|
{detail.items.map((detailItem) => (
|
||||||
<option key={detailItem.id} value={detailItem.id}>
|
<option key={detailItem.id} value={detailItem.id}>
|
||||||
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
|
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function ReimbursementItemEditor({
|
|||||||
onChange={(event) => setExpenseTypeId(event.target.value)}
|
onChange={(event) => setExpenseTypeId(event.target.value)}
|
||||||
value={expenseTypeId}
|
value={expenseTypeId}
|
||||||
>
|
>
|
||||||
{expenseTypes.length === 0 ? <option value="">暂无可用费用类型</option> : null}
|
<option value="">待补充费用类型</option>
|
||||||
{expenseTypes.map((type) => (
|
{expenseTypes.map((type) => (
|
||||||
<option key={type.id} value={type.id}>
|
<option key={type.id} value={type.id}>
|
||||||
{type.name}
|
{type.name}
|
||||||
@@ -199,7 +199,6 @@ export function ReimbursementItemEditor({
|
|||||||
data-testid="reimbursement-item-save"
|
data-testid="reimbursement-item-save"
|
||||||
disabled={
|
disabled={
|
||||||
isPending ||
|
isPending ||
|
||||||
!expenseTypeId ||
|
|
||||||
!expenseDate ||
|
!expenseDate ||
|
||||||
!amount.trim() ||
|
!amount.trim() ||
|
||||||
!hasPositiveAmount
|
!hasPositiveAmount
|
||||||
|
|||||||
@@ -585,6 +585,9 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
|||||||
if (pathName === "/api/invoice-pool/status") {
|
if (pathName === "/api/invoice-pool/status") {
|
||||||
state.statusPollCount += 1;
|
state.statusPollCount += 1;
|
||||||
const stillPending = state.statusPollCount === 1;
|
const stillPending = state.statusPollCount === 1;
|
||||||
|
if (!stillPending && !state.availableInvoices.some((invoice) => invoice.id === pendingInvoiceId)) {
|
||||||
|
state.availableInvoices.push({ ...buildAvailableInvoice(), id: pendingInvoiceId, invoiceNumber: "12345678" });
|
||||||
|
}
|
||||||
await fulfillJson(route, {
|
await fulfillJson(route, {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@@ -835,7 +838,135 @@ async function exerciseInvoiceGuardRecoveryAfterPrecheckConfirm(
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("R111 国内报销发票与附件双端体验", () => {
|
test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||||
test("PC 报销详情上传发票后自动添加,并提供附件材料", async ({ page }) => {
|
for (const mobile of [false, true]) {
|
||||||
|
test(`选票优先空单 ${mobile ? "手机" : "桌面"} 上传不占用、一次建明细并批量分类`, async ({ page }) => {
|
||||||
|
const state = buildReimbursementState();
|
||||||
|
state.detailItems = [];
|
||||||
|
state.invoices = [];
|
||||||
|
state.availableInvoices = [];
|
||||||
|
state.failedInvoices = [];
|
||||||
|
state.invoiceGuardEnabled = true;
|
||||||
|
await mockApis(page, state);
|
||||||
|
let bindCount = 0;
|
||||||
|
let classificationCount = 0;
|
||||||
|
await page.route("**/api/invoice-pool/upload", async (route) => {
|
||||||
|
state.availableInvoices = [buildAvailableInvoice()];
|
||||||
|
await fulfillJson(route, { invoicePoolId: availableInvoiceId, status: "available", uploadAction: "create" });
|
||||||
|
});
|
||||||
|
await page.route(`**/api/invoice-pool/${availableInvoiceId}/bind`, async (route) => {
|
||||||
|
expect(route.request().postDataJSON()).toEqual({ reimbursementId, createItem: true });
|
||||||
|
bindCount += 1;
|
||||||
|
state.detailItems = [{ id: "item-auto", expenseTypeId: null, expenseType: null, expenseDate: "2026-05-08", amount: "1683.00", description: "" }];
|
||||||
|
state.availableInvoices = [];
|
||||||
|
await fulfillJson(route, { id: availableInvoiceId, status: "bound", linkedItemId: "item-auto" });
|
||||||
|
});
|
||||||
|
await page.route(`**/api/reimbursements/${reimbursementId}/items/classify`, async (route) => {
|
||||||
|
expect(route.request().postDataJSON()).toEqual({ itemIds: ["item-auto"], expenseTypeId: "expense-type-office-supplies-r111" });
|
||||||
|
classificationCount += 1;
|
||||||
|
const expenseType = buildExpenseTypes().items.find((type) => type.id === "expense-type-office-supplies-r111")!;
|
||||||
|
state.detailItems = state.detailItems!.map((item) => ({ ...item, expenseTypeId: expenseType.id, expenseType }));
|
||||||
|
await fulfillJson(route, {});
|
||||||
|
});
|
||||||
|
await login(page);
|
||||||
|
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 });
|
||||||
|
await page.goto(`${mobile ? "/mobile" : ""}/reimbursements/${reimbursementId}`);
|
||||||
|
await page.getByTestId("reimbursement-invoice-add").click();
|
||||||
|
await expect(page.getByRole("dialog")).toContainText("我的发票");
|
||||||
|
await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({ name: "synthetic.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4 synthetic") });
|
||||||
|
await page.getByTestId("reimbursement-invoice-upload-submit").click();
|
||||||
|
const dialog = page.getByRole("dialog");
|
||||||
|
const checkbox = dialog.getByRole("checkbox").first();
|
||||||
|
await expect(checkbox).toBeChecked();
|
||||||
|
expect(bindCount).toBe(0);
|
||||||
|
await dialog.getByRole("button", { name: "返回报销单" }).click();
|
||||||
|
expect(bindCount).toBe(0);
|
||||||
|
await page.getByTestId("reimbursement-invoice-add").click();
|
||||||
|
if (mobile) await dialog.getByRole("button", { name: "已有发票", exact: true }).click();
|
||||||
|
await expect(checkbox).not.toBeChecked();
|
||||||
|
await checkbox.check();
|
||||||
|
await checkbox.uncheck();
|
||||||
|
await expect(dialog.getByRole("button", { name: /添加到报销单/ })).toBeDisabled();
|
||||||
|
expect(bindCount).toBe(0);
|
||||||
|
await checkbox.check();
|
||||||
|
await dialog.getByRole("button", { name: /添加到报销单/ }).click();
|
||||||
|
await expect.poll(() => bindCount).toBe(1);
|
||||||
|
if (mobile) await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByText("费用分类:1 条待补充")).toBeVisible();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByText("费用分类:1 条待补充")).toBeVisible();
|
||||||
|
await page.getByRole("checkbox", { name: /明细 1/ }).check();
|
||||||
|
await page.getByLabel("批量费用类型").selectOption("expense-type-office-supplies-r111");
|
||||||
|
await page.getByRole("button", { name: "设置所选 1 条分类" }).click();
|
||||||
|
await expect.poll(() => classificationCount).toBe(1);
|
||||||
|
await expect(page.getByText("费用分类:1 条待补充")).toHaveCount(0);
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByText("费用分类(可批量修改)")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`已有明细入口 ${mobile ? "手机" : "桌面"} 选旧票只发送指定明细`, async ({ page }) => {
|
||||||
|
const state = buildReimbursementState();
|
||||||
|
await mockApis(page, state);
|
||||||
|
const requests: unknown[] = [];
|
||||||
|
await page.route(`**/api/invoice-pool/${availableInvoiceId}/bind`, async (route) => {
|
||||||
|
requests.push(route.request().postDataJSON());
|
||||||
|
await fulfillJson(route, { id: availableInvoiceId, linkedItemId: "item-r111" });
|
||||||
|
});
|
||||||
|
await login(page);
|
||||||
|
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 });
|
||||||
|
await page.goto(`${mobile ? "/mobile" : ""}/reimbursements/${reimbursementId}`);
|
||||||
|
await page.getByRole("button", { name: "为明细添加发票" }).first().click();
|
||||||
|
const dialog = page.getByRole("dialog");
|
||||||
|
if (mobile) await dialog.getByRole("button", { name: "已有发票", exact: true }).click();
|
||||||
|
await dialog.getByRole("checkbox").first().check();
|
||||||
|
await expect(dialog.getByLabel("添加到哪条明细")).toHaveValue("item-r111");
|
||||||
|
await dialog.getByRole("button", { name: /添加到报销单/ }).click();
|
||||||
|
await expect.poll(() => requests.length).toBe(1);
|
||||||
|
expect(requests[0]).toEqual({ reimbursementId, linkedItemId: "item-r111" });
|
||||||
|
if (mobile) await dialog.getByRole("button", { name: "返回报销单" }).click();
|
||||||
|
await expect(dialog).not.toBeVisible();
|
||||||
|
await page.getByTestId("reimbursement-invoice-add").click();
|
||||||
|
if (mobile) await dialog.getByRole("button", { name: "已有发票", exact: true }).click();
|
||||||
|
await dialog.getByRole("checkbox").first().check();
|
||||||
|
await expect(dialog.getByLabel("添加到哪条明细")).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`多票添加 ${mobile ? "手机" : "桌面"} 部分失败仅重试失败票`, async ({ page }) => {
|
||||||
|
const state = buildReimbursementState();
|
||||||
|
state.failedInvoices = [];
|
||||||
|
state.availableInvoices = [buildAvailableInvoice(), { ...buildAvailableInvoice(), id: pendingInvoiceId, invoiceNumber: "87654321" }];
|
||||||
|
await mockApis(page, state);
|
||||||
|
const attempts: Record<string, number> = {};
|
||||||
|
await page.route("**/api/invoice-pool/*/bind", async (route) => {
|
||||||
|
const invoiceId = new URL(route.request().url()).pathname.split("/").at(-2)!;
|
||||||
|
expect(route.request().postDataJSON()).toEqual({ reimbursementId, createItem: true });
|
||||||
|
attempts[invoiceId] = (attempts[invoiceId] ?? 0) + 1;
|
||||||
|
if (invoiceId === pendingInvoiceId && attempts[invoiceId] === 1) {
|
||||||
|
await route.fulfill({ status: 409, contentType: "application/json", body: JSON.stringify({ error: { code: "VALID_001", message: "validation failed", details: { reason: "invoice_prefill_incomplete" } } }) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.availableInvoices = state.availableInvoices.filter((invoice) => invoice.id !== invoiceId);
|
||||||
|
await fulfillJson(route, { id: invoiceId, status: "bound" });
|
||||||
|
});
|
||||||
|
await login(page);
|
||||||
|
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 });
|
||||||
|
await page.goto(`${mobile ? "/mobile" : ""}/reimbursements/${reimbursementId}`);
|
||||||
|
await page.getByTestId("reimbursement-invoice-add").click();
|
||||||
|
const dialog = page.getByRole("dialog");
|
||||||
|
if (mobile) await dialog.getByRole("button", { name: "已有发票", exact: true }).click();
|
||||||
|
await dialog.getByRole("checkbox").nth(0).check();
|
||||||
|
await dialog.getByRole("checkbox").nth(1).check();
|
||||||
|
await dialog.getByRole("button", { name: /添加到报销单/ }).click();
|
||||||
|
await expect.poll(() => attempts[pendingInvoiceId]).toBe(1);
|
||||||
|
await expect(dialog.getByRole("checkbox")).toHaveCount(1);
|
||||||
|
await expect(dialog.getByRole("checkbox")).toBeChecked();
|
||||||
|
await expect(dialog.getByRole("button", { name: /添加到报销单/ })).toBeEnabled();
|
||||||
|
await dialog.getByRole("button", { name: /添加到报销单/ }).click();
|
||||||
|
await expect.poll(() => attempts[pendingInvoiceId]).toBe(2);
|
||||||
|
expect(attempts[availableInvoiceId]).toBe(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("PC 报销详情上传发票后选中添加,并提供附件材料", async ({ page }) => {
|
||||||
const state = buildReimbursementState();
|
const state = buildReimbursementState();
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const openCalls: unknown[][] = [];
|
const openCalls: unknown[][] = [];
|
||||||
@@ -868,6 +999,9 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
|||||||
buffer: Buffer.from("%PDF r111 invoice"),
|
buffer: Buffer.from("%PDF r111 invoice"),
|
||||||
});
|
});
|
||||||
await page.getByTestId("reimbursement-invoice-upload-submit").click();
|
await page.getByTestId("reimbursement-invoice-upload-submit").click();
|
||||||
|
await expect.poll(() => state.statusPollCount).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(state.bindRequests).toHaveLength(0);
|
||||||
|
await page.getByRole("dialog").getByRole("button", { name: "添加到报销单" }).click();
|
||||||
await expect(page.getByText("已添加 1 张发票")).toBeVisible({ timeout: 15_000 });
|
await expect(page.getByText("已添加 1 张发票")).toBeVisible({ timeout: 15_000 });
|
||||||
expect(state.statusPollCount).toBeGreaterThanOrEqual(1);
|
expect(state.statusPollCount).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
@@ -946,10 +1080,10 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
|||||||
await page.getByTestId("reimbursement-invoice-add").click();
|
await page.getByTestId("reimbursement-invoice-add").click();
|
||||||
const invoiceDialog = page.getByTestId("reimbursement-invoice-dialog");
|
const invoiceDialog = page.getByTestId("reimbursement-invoice-dialog");
|
||||||
await invoiceDialog.locator("input[type='checkbox']").first().check();
|
await invoiceDialog.locator("input[type='checkbox']").first().check();
|
||||||
await expect(invoiceDialog.getByText("请选择该发票对应的费用明细。")).toBeVisible();
|
await expect(invoiceDialog.getByText("点击添加后生成可编辑明细;也可选择加入已有明细。")).toBeVisible();
|
||||||
const bindButton = invoiceDialog.getByRole("button", { name: "添加选中发票" });
|
const bindButton = invoiceDialog.getByRole("button", { name: "添加到报销单" });
|
||||||
await expect(bindButton).toBeDisabled();
|
await expect(bindButton).toBeEnabled();
|
||||||
await invoiceDialog.getByLabel("关联费用明细").selectOption("item-r111");
|
await invoiceDialog.getByLabel("添加到哪条明细").selectOption("item-r111");
|
||||||
await expect(bindButton).toBeEnabled();
|
await expect(bindButton).toBeEnabled();
|
||||||
|
|
||||||
await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
|
await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
|
||||||
@@ -957,8 +1091,12 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
|||||||
mimeType: "application/pdf",
|
mimeType: "application/pdf",
|
||||||
buffer: Buffer.from("%PDF r111 guarded invoice"),
|
buffer: Buffer.from("%PDF r111 guarded invoice"),
|
||||||
});
|
});
|
||||||
await invoiceDialog.getByLabel("新上传发票关联明细").selectOption("item-r111");
|
await invoiceDialog.getByLabel("新发票添加位置").selectOption("item-r111");
|
||||||
|
await invoiceDialog.locator("input[type='checkbox']").first().uncheck();
|
||||||
await page.getByTestId("reimbursement-invoice-upload-submit").click();
|
await page.getByTestId("reimbursement-invoice-upload-submit").click();
|
||||||
|
await expect.poll(() => state.statusPollCount).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(state.bindRequests).toHaveLength(0);
|
||||||
|
await bindButton.click();
|
||||||
await expect.poll(() => state.bindRequests).toContainEqual({
|
await expect.poll(() => state.bindRequests).toContainEqual({
|
||||||
reimbursementId,
|
reimbursementId,
|
||||||
linkedItemId: "item-r111",
|
linkedItemId: "item-r111",
|
||||||
@@ -969,10 +1107,10 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
|||||||
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
|
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
|
||||||
await expect(page.getByText(/该金额通常应选择办公电子设备/)).toBeVisible();
|
await expect(page.getByText(/该金额通常应选择办公电子设备/)).toBeVisible();
|
||||||
await page.getByRole("button", { name: "新增明细", exact: true }).click();
|
await page.getByRole("button", { name: "新增明细", exact: true }).click();
|
||||||
await page.getByLabel("费用类型").selectOption("expense-type-office-supplies-r111");
|
await page.getByLabel(/^费用类型/).selectOption("expense-type-office-supplies-r111");
|
||||||
await page.getByRole("textbox", { name: "金额", exact: true }).fill("2000.00");
|
await page.getByRole("textbox", { name: "金额", exact: true }).fill("2000.00");
|
||||||
await expect(page.getByText(/该金额通常应选择办公电子设备/)).toHaveCount(2);
|
await expect(page.getByText(/该金额通常应选择办公电子设备/)).toHaveCount(2);
|
||||||
await page.getByLabel("费用类型").selectOption("expense-type-assets-r111");
|
await page.getByLabel(/^费用类型/).selectOption("expense-type-assets-r111");
|
||||||
await expect(page.getByText("事前批准依据", { exact: true })).toHaveCount(0);
|
await expect(page.getByText("事前批准依据", { exact: true })).toHaveCount(0);
|
||||||
await page.getByRole("textbox", { name: "说明", exact: true }).fill("合成设备,测试员工使用,测试位置");
|
await page.getByRole("textbox", { name: "说明", exact: true }).fill("合成设备,测试员工使用,测试位置");
|
||||||
await page.getByRole("button", { name: "保存", exact: true }).click();
|
await page.getByRole("button", { name: "保存", exact: true }).click();
|
||||||
@@ -1251,6 +1389,9 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
|||||||
await mobile.page.getByTestId("reimbursement-invoice-upload-submit").click();
|
await mobile.page.getByTestId("reimbursement-invoice-upload-submit").click();
|
||||||
await expect(mobile.page.getByTestId("reimbursement-invoice-pending-hint")).toHaveCount(1);
|
await expect(mobile.page.getByTestId("reimbursement-invoice-pending-hint")).toHaveCount(1);
|
||||||
await expect(mobile.page.getByTestId("reimbursement-invoice-dialog")).not.toContainText("识别完成后可在");
|
await expect(mobile.page.getByTestId("reimbursement-invoice-dialog")).not.toContainText("识别完成后可在");
|
||||||
|
await expect.poll(() => state.statusPollCount).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(state.bindRequests).toHaveLength(0);
|
||||||
|
await invoiceDialog.getByRole("button", { name: /添加到报销单/ }).click();
|
||||||
await expect(
|
await expect(
|
||||||
mobile.page.getByTestId("reimbursement-invoice-dialog").getByText("已添加 1 张发票"),
|
mobile.page.getByTestId("reimbursement-invoice-dialog").getByText("已添加 1 张发票"),
|
||||||
).toBeVisible({ timeout: 15_000 });
|
).toBeVisible({ timeout: 15_000 });
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
const { randomUUID } = require("node:crypto");
|
||||||
|
const { readFileSync, readdirSync, mkdtempSync, rmSync } = require("node:fs");
|
||||||
|
const { tmpdir } = require("node:os");
|
||||||
|
const { createRequire } = require("node:module");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { setTimeout: delay } = require("node:timers/promises");
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "../..");
|
||||||
|
const serverRequire = createRequire(path.join(root, "server/package.json"));
|
||||||
|
const docker = (args, input) => execFileSync("docker", args, { input, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 60000 }).trim();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
assert.equal(process.env.RUN_ISOLATED_DB_TEST, "1", "Explicit disposable database opt-in required");
|
||||||
|
const password = randomUUID();
|
||||||
|
let container;
|
||||||
|
let prisma;
|
||||||
|
let pool;
|
||||||
|
let app;
|
||||||
|
let storageDir;
|
||||||
|
let browser;
|
||||||
|
let preview;
|
||||||
|
const passed = [];
|
||||||
|
try {
|
||||||
|
container = docker(["run", "-d", "--rm", "--pull=never", "--tmpfs", "/var/lib/postgresql/data", "-p", "127.0.0.1::5432", "-e", `POSTGRES_PASSWORD=${password}`, "postgres:16-alpine"]);
|
||||||
|
let ready = false;
|
||||||
|
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||||
|
try { docker(["exec", container, "pg_isready", "-U", "postgres"]); ready = true; break; } catch { await delay(1000); }
|
||||||
|
}
|
||||||
|
assert.ok(ready, "Disposable PostgreSQL readiness");
|
||||||
|
const migrationDir = path.join(root, "server/prisma/migrations");
|
||||||
|
for (const directory of readdirSync(migrationDir).sort()) {
|
||||||
|
if (directory === "migration_lock.toml") continue;
|
||||||
|
docker(["exec", "-i", container, "psql", "-X", "-q", "-v", "ON_ERROR_STOP=1", "-U", "postgres"], readFileSync(path.join(migrationDir, directory, "migration.sql")));
|
||||||
|
}
|
||||||
|
const address = docker(["port", container, "5432/tcp"]);
|
||||||
|
assert.match(address, /^127\.0\.0\.1:\d+$/);
|
||||||
|
process.env.DATABASE_URL = `postgresql://postgres:${password}@${address}/postgres`;
|
||||||
|
({ prisma, pool } = serverRequire("./src/prismaClient").createPrismaClient());
|
||||||
|
const department = await prisma.department.create({ data: { name: "synthetic-http" } });
|
||||||
|
const employee = await prisma.employee.create({ data: {
|
||||||
|
name: "synthetic", email: "[email protected]", employee_no: "synthetic-http",
|
||||||
|
department_id: department.id, title: "synthetic", hire_date: new Date("2026-09-09"),
|
||||||
|
bank_account_encrypted: "synthetic-not-account", bank_name: "synthetic", password_hash: "not-a-login-hash",
|
||||||
|
} });
|
||||||
|
const expenseType = await prisma.expenseType.create({ data: { code: "synthetic_http", name: "synthetic", category: "office", sort_order: 1 } });
|
||||||
|
const manager = await prisma.employee.create({ data: {
|
||||||
|
name: "synthetic-manager", email: "[email protected]", employee_no: "synthetic-manager",
|
||||||
|
department_id: department.id, title: "synthetic", hire_date: new Date("2026-09-09"),
|
||||||
|
bank_account_encrypted: "synthetic-not-account", bank_name: "synthetic", password_hash: "not-a-login-hash",
|
||||||
|
} });
|
||||||
|
await prisma.employee.update({ where: { id: employee.id }, data: { direct_manager_id: manager.id } });
|
||||||
|
const approvers = { manager };
|
||||||
|
for (const role of ["ceo", "finance"]) {
|
||||||
|
approvers[role] = await prisma.employee.create({ data: {
|
||||||
|
name: `synthetic-${role}`, email: `invoice-${role}@example.invalid`, employee_no: `synthetic-${role}`,
|
||||||
|
department_id: department.id, title: "synthetic", hire_date: new Date("2026-09-09"),
|
||||||
|
bank_account_encrypted: "synthetic-not-account", bank_name: "synthetic", password_hash: "not-a-login-hash",
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
for (const [role, actor] of Object.entries(approvers)) {
|
||||||
|
await prisma.employeeRole.create({ data: { employee_id: actor.id, role_code: role } });
|
||||||
|
}
|
||||||
|
await prisma.employeeRole.create({ data: { employee_id: employee.id, role_code: "employee" } });
|
||||||
|
const loginPassword = `Synthetic!${randomUUID()}`;
|
||||||
|
const passwordHash = await serverRequire("./src/utils/crypto").hashPassword(loginPassword);
|
||||||
|
await prisma.employee.updateMany({ data: { password_hash: passwordHash, must_change_password: false } });
|
||||||
|
await prisma.workflowDefinition.create({ data: { name: "synthetic-reimbursement", entity_type: "reimbursement", definition_json: { steps: [] }, version: 1 } });
|
||||||
|
await prisma.approvalRule.create({ data: { name: "synthetic-three-step", min_amount: 0, level1_role: "manager", level2_role: "ceo", level3_role: "finance", priority: 1 } });
|
||||||
|
await prisma.featureFlag.create({ data: { flag_key: "reimbursement_invoice_guard_v1", override_mode: "force_on", rollout_type: "global", department_ids: [] } });
|
||||||
|
const makeForm = () => prisma.reimbursementForm.create({ data: { form_number: `SYN-${randomUUID()}`, owner_id: employee.id, submitter_id: employee.id, department_id: department.id, reimbursement_date: new Date("2026-09-09") } });
|
||||||
|
const makeInvoice = (overrides = {}) => prisma.invoicePool.create({ data: {
|
||||||
|
owner_employee_id: employee.id, uploaded_by_employee_id: employee.id, status: "available",
|
||||||
|
storage_path: "synthetic-unused.pdf", original_name: "synthetic.pdf", mime_type: "application/pdf",
|
||||||
|
file_size: 1, file_sha256: randomUUID(), invoice_number: randomUUID(),
|
||||||
|
invoice_date: new Date("2026-09-09"), total_amount: "100.00", ocr_attempts: 1, ocr_completed_at: new Date("2026-09-09"), ...overrides,
|
||||||
|
} });
|
||||||
|
app = serverRequire("fastify")({ logger: false });
|
||||||
|
app.decorate("prisma", prisma);
|
||||||
|
storageDir = mkdtempSync(path.join(tmpdir(), "invoice-http-"));
|
||||||
|
app.decorate("config", { storageDir, storageDriver: "local", maxUploadSizeBytes: 10 * 1024 * 1024, ocrProvider: process.env.RUN_JOB_QUEUE === "1" ? "mock" : "tencent", reimbursementInvoiceGuardEnabled: true, appTimezone: "Asia/Shanghai", jwtSecret: randomUUID(), accessTokenTtl: "15m", refreshTtlSeconds: 3600, refreshCookieName: "synthetic_refresh", cookieSecure: false, loginFailMaxAttempts: 5, loginFailWindowSeconds: 300, loginLockSeconds: 60 });
|
||||||
|
await app.register(serverRequire("@fastify/cookie"));
|
||||||
|
await app.register(serverRequire("./src/plugins/auth"));
|
||||||
|
await app.register(serverRequire("./src/routes/auth"), { prefix: "/api/auth" });
|
||||||
|
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } }));
|
||||||
|
const envelope = (data) => ({ requestId: "synthetic-http", data });
|
||||||
|
app.get("/api/me", { preHandler: app.authenticate }, async () => envelope({ id: employee.id, employeeNo: employee.employee_no, name: "synthetic", email: employee.email, departmentId: department.id, directManagerId: manager.id, status: "active", roleCodes: ["employee"], notificationPushEnabled: false }));
|
||||||
|
app.get("/api/system/navigation", async () => envelope({ workspace: { id: "synthetic", profile: "small_team_internal", orgMode: "single_company", moduleVisibility: {} }, sections: { main: [], admin: [], backup: [] }, recommendedRolePresets: [] }));
|
||||||
|
app.get("/api/notifications", async () => envelope({ items: [], total: 0, unreadCount: 0, page: 1, pageSize: 20 }));
|
||||||
|
await app.register(serverRequire("./src/routes/expenseTypes"), { prefix: "/api" });
|
||||||
|
await app.register(serverRequire("@fastify/multipart"), { limits: { fileSize: 10 * 1024 * 1024 } });
|
||||||
|
await app.register(serverRequire("./src/routes/invoicePool"), { prefix: "/api" });
|
||||||
|
await app.register(serverRequire("./src/routes/reimbursements"), { prefix: "/api" });
|
||||||
|
await app.register(serverRequire("./src/routes/workflow"), { prefix: "/api" });
|
||||||
|
if (process.env.RUN_JOB_QUEUE === "1") {
|
||||||
|
Object.assign(app.config, { jobDriver: "pgboss", pgBossSchema: "synthetic_jobs", ocrMaxAttempts: 3, ocrPollCron: "0 0 1 1 *", delegationRevokeCron: "0 0 1 1 *", invoiceArchiveCron: "0 0 1 1 *", integrationOutboxCron: "0 0 1 1 *" });
|
||||||
|
await serverRequire("./src/jobs/pgBossJobs").startPgBossJobs(app);
|
||||||
|
}
|
||||||
|
const origin = await app.listen({ host: "127.0.0.1", port: 0 });
|
||||||
|
const tokens = new Map();
|
||||||
|
for (const actor of [employee, ...Object.values(approvers)]) {
|
||||||
|
const login = await fetch(`${origin}/api/auth/login`, { method: "POST", headers: { "content-type": "application/json", "x-device-fingerprint": `synthetic-${actor.id}` }, body: JSON.stringify({ identifier: actor.email, password: loginPassword }) });
|
||||||
|
assert.equal(login.status, 200, "password login");
|
||||||
|
tokens.set(actor.id, (await login.json()).data.accessToken);
|
||||||
|
}
|
||||||
|
assert.equal((await fetch(`${origin}/api/me`)).status, 401);
|
||||||
|
assert.equal((await fetch(`${origin}/api/me`, { headers: { authorization: "Bearer invalid-synthetic-token" } })).status, 401);
|
||||||
|
passed.push("real password login issues tokens; absent and invalid JWT rejected");
|
||||||
|
const post = async (url, body, actor, method = "POST") => {
|
||||||
|
const token = tokens.get(actor ?? employee.id) ?? app.issueAccessToken({ id: actor, token_version: 0 });
|
||||||
|
const response = await fetch(`${origin}/api${url}`, { method, headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify(body), signal: AbortSignal.timeout(15000) });
|
||||||
|
return { status: response.status, body: await response.json() };
|
||||||
|
};
|
||||||
|
const bind = (invoice, form, actor) => post(`/invoice-pool/${invoice.id}/bind`, { reimbursementId: form.id, createItem: true }, actor);
|
||||||
|
const objects = [
|
||||||
|
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||||
|
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||||
|
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R >>",
|
||||||
|
"<< /Length 0 >>\nstream\n\nendstream",
|
||||||
|
];
|
||||||
|
let pdf = "%PDF-1.4\n";
|
||||||
|
const offsets = [0];
|
||||||
|
for (const [index, content] of objects.entries()) {
|
||||||
|
offsets.push(Buffer.byteLength(pdf));
|
||||||
|
pdf += `${index + 1} 0 obj\n${content}\nendobj\n`;
|
||||||
|
}
|
||||||
|
const xrefOffset = Buffer.byteLength(pdf);
|
||||||
|
pdf += `xref\n0 5\n0000000000 65535 f \n${offsets.slice(1).map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("")}trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||||
|
const upload = async () => {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", new Blob([pdf], { type: "application/pdf" }), "synthetic-blank.pdf");
|
||||||
|
body.append("ocr_type", "vat");
|
||||||
|
const response = await fetch(`${origin}/api/invoice-pool/upload`, { method: "POST", headers: { authorization: `Bearer ${tokens.get(employee.id)}` }, body, signal: AbortSignal.timeout(15000) });
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
return (await response.json()).data;
|
||||||
|
};
|
||||||
|
const uploaded = await upload();
|
||||||
|
assert.equal(uploaded.status, "pending_ocr");
|
||||||
|
const uploadedRecord = await prisma.invoicePool.findUnique({ where: { id: uploaded.invoicePoolId } });
|
||||||
|
assert.equal(uploadedRecord.linked_reimbursement_id, null);
|
||||||
|
assert.deepEqual(readFileSync(path.join(storageDir, uploadedRecord.storage_path)), Buffer.from(pdf));
|
||||||
|
assert.equal((await upload()).invoicePoolId, uploaded.invoicePoolId);
|
||||||
|
assert.equal(await prisma.invoicePool.count(), 1);
|
||||||
|
passed.push("multipart PDF upload persists unbound pending OCR; duplicate upload reuses invoice without extra record");
|
||||||
|
if (process.env.RUN_BROWSER_TEST === "1") {
|
||||||
|
const clientRequire = createRequire(path.join(root, "client/package.json"));
|
||||||
|
const { pathToFileURL } = require("node:url");
|
||||||
|
const vite = await import(pathToFileURL(clientRequire.resolve("vite")).href);
|
||||||
|
preview = await (vite.default ?? vite).preview({ configFile: false, root: path.join(root, "client"), preview: { host: "127.0.0.1", port: 0, proxy: { "/api": origin } } });
|
||||||
|
const uiOrigin = `http://127.0.0.1:${preview.httpServer.address().port}`;
|
||||||
|
app.config.appOrigin = uiOrigin;
|
||||||
|
const { chromium, expect } = clientRequire("@playwright/test");
|
||||||
|
browser = await chromium.launch({ headless: true });
|
||||||
|
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"}`;
|
||||||
|
await context.addCookies([{ name: "hyb_oa_device_fingerprint", value: fingerprint, url: uiOrigin }]);
|
||||||
|
const browserLogin = await context.request.post(`${uiOrigin}/api/auth/login`, { headers: { "x-device-fingerprint": fingerprint }, data: { identifier: employee.email, password: loginPassword } });
|
||||||
|
assert.equal(browserLogin.status(), 200);
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(20000);
|
||||||
|
const browserForm = await makeForm();
|
||||||
|
await page.goto(`${uiOrigin}${mobile ? "/mobile" : ""}/reimbursements/${browserForm.id}`);
|
||||||
|
await page.getByTestId("reimbursement-invoice-add").click();
|
||||||
|
const browserPdf = pdf.replace("300 200", mobile ? "302 200" : "301 200");
|
||||||
|
await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({ name: "synthetic-blank.pdf", mimeType: "application/pdf", buffer: Buffer.from(browserPdf) });
|
||||||
|
const [uploadResponse] = await Promise.all([
|
||||||
|
page.waitForResponse((response) => response.request().method() === "POST" && response.url().endsWith("/api/invoice-pool/upload")),
|
||||||
|
page.getByTestId("reimbursement-invoice-upload-submit").click(),
|
||||||
|
]);
|
||||||
|
assert.equal(uploadResponse.status(), 200);
|
||||||
|
const browserInvoiceId = (await uploadResponse.json()).data.invoicePoolId;
|
||||||
|
if (process.env.RUN_JOB_QUEUE === "1") {
|
||||||
|
await expect.poll(async () => (await prisma.invoicePool.findUnique({ where: { id: browserInvoiceId } })).status, { timeout: 20000 }).toBe("available");
|
||||||
|
await expect.poll(async () => Number((await pool.query("SELECT count(*) FROM synthetic_jobs.job WHERE name = 'ocr.invoice' AND data->>'invoiceId' = $1 AND state = 'completed'", [browserInvoiceId])).rows[0].count), { timeout: 20000 }).toBe(1);
|
||||||
|
} else {
|
||||||
|
const workerConfig = { ...app.config, ocrProvider: "mock", ocrMaxAttempts: 3 };
|
||||||
|
const workerResult = await serverRequire("./src/jobs/ocrJob").runOcrForInvoiceId(prisma, workerConfig, app.log, browserInvoiceId, "synthetic-browser-worker");
|
||||||
|
assert.equal(workerResult.available, 1);
|
||||||
|
}
|
||||||
|
await expect(page.getByRole("dialog").getByRole("checkbox").first()).toBeChecked({ timeout: 20000 });
|
||||||
|
assert.equal(await prisma.reimbursementItem.count({ where: { reimbursement_id: browserForm.id } }), 0);
|
||||||
|
await page.getByRole("dialog").getByRole("button", { name: mobile ? "添加到报销单 1" : "添加到报销单", exact: true }).click();
|
||||||
|
if (mobile) await page.getByRole("dialog").getByRole("button", { name: "返回报销单" }).click();
|
||||||
|
await expect(page.getByText("费用分类:1 条待补充")).toBeVisible();
|
||||||
|
await page.getByRole("checkbox", { name: /明细 1/ }).check();
|
||||||
|
await page.getByLabel("批量费用类型").selectOption(expenseType.id);
|
||||||
|
await page.getByRole("button", { name: "设置所选 1 条分类" }).click();
|
||||||
|
await expect(page.getByText("费用分类:1 条待补充")).toHaveCount(0);
|
||||||
|
await page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: "编辑", exact: true }).first().click();
|
||||||
|
await (mobile ? page.getByLabel("说明", { exact: true }) : page.getByTestId("reimbursement-item-description")).fill("synthetic browser purpose");
|
||||||
|
const [savedItemResponse] = await Promise.all([
|
||||||
|
page.waitForResponse((response) => response.request().method() === "PATCH" && response.url().includes(`/reimbursements/${browserForm.id}/items/`)),
|
||||||
|
(mobile ? page.getByRole("button", { name: "保存", exact: true }) : page.getByTestId("reimbursement-item-save")).click(),
|
||||||
|
]);
|
||||||
|
assert.equal(savedItemResponse.status(), 200);
|
||||||
|
assert.equal((await prisma.reimbursementItem.findFirst({ where: { reimbursement_id: browserForm.id } })).description, "synthetic browser purpose");
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByTestId("reimbursement-approval-evidence").getByText("synthetic browser purpose").first()).toBeVisible();
|
||||||
|
await page.getByTestId("reimbursement-submit").click();
|
||||||
|
await expect.poll(async () => (await prisma.reimbursementForm.findUnique({ where: { id: browserForm.id } })).status, { timeout: 15000 }).toBe("reviewing");
|
||||||
|
const browserWorkflow = await prisma.workflowInstance.findFirst({ where: { reimbursement_id: browserForm.id } });
|
||||||
|
assert.equal(await prisma.workflowTask.count({ where: { instance_id: browserWorkflow.id, assignee_id: manager.id, status: "pending" } }), 1);
|
||||||
|
for (const role of ["manager", "ceo", "finance"]) {
|
||||||
|
const approvalTask = await prisma.workflowTask.findFirst({ where: { instance_id: browserWorkflow.id, status: "pending" } });
|
||||||
|
assert.ok(approvalTask);
|
||||||
|
assert.equal((await post(`/workflow/tasks/${approvalTask.id}/approve`, {}, approvers[role].id)).status, 200);
|
||||||
|
}
|
||||||
|
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: browserForm.id } })).status, "approved");
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("reimbursement-invoice-add")).toHaveCount(0);
|
||||||
|
await context.close();
|
||||||
|
passed.push(`${mobile ? "mobile" : "desktop"} browser upload -> real OCR worker with mock provider -> select -> classify/edit -> submit -> three HTTP approvals -> read-only page`);
|
||||||
|
};
|
||||||
|
await runBrowserFlow(false);
|
||||||
|
await runBrowserFlow(true);
|
||||||
|
}
|
||||||
|
const form = await makeForm();
|
||||||
|
const invoice = await makeInvoice();
|
||||||
|
const repeated = await Promise.all(Array.from({ length: 4 }, () => bind(invoice, form)));
|
||||||
|
assert.deepEqual(repeated.map((result) => result.status), [200, 200, 200, 200]);
|
||||||
|
let items = await prisma.reimbursementItem.findMany({ where: { reimbursement_id: form.id } });
|
||||||
|
assert.equal(items.length, 1);
|
||||||
|
assert.equal(items[0].expense_type_id, null);
|
||||||
|
assert.equal(items[0].amount.toFixed(2), "100.00");
|
||||||
|
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: form.id } })).total_amount.toFixed(2), "100.00");
|
||||||
|
passed.push("same-form concurrent retry creates one persisted draft item");
|
||||||
|
|
||||||
|
const missingClassification = await post(`/reimbursements/${form.id}/submit`, {});
|
||||||
|
assert.equal(missingClassification.status, 400);
|
||||||
|
assert.equal(missingClassification.body.error.details.reasonCode, "reimbursement.items_expense_type_required");
|
||||||
|
|
||||||
|
const classification = await post(`/reimbursements/${form.id}/items/classify`, { itemIds: [items[0].id], expenseTypeId: expenseType.id });
|
||||||
|
assert.equal(classification.status, 200);
|
||||||
|
const missingDescription = await post(`/reimbursements/${form.id}/submit`, {});
|
||||||
|
assert.equal(missingDescription.body.error.details.reasonCode, "reimbursement.items_description_required");
|
||||||
|
assert.equal((await post(`/reimbursements/${form.id}/items/${items[0].id}`, { description: "synthetic edited purpose", amount: "90.00" }, null, "PATCH")).status, 200);
|
||||||
|
assert.equal((await bind(invoice, form)).status, 200);
|
||||||
|
const edited = await prisma.reimbursementItem.findUnique({ where: { id: items[0].id } });
|
||||||
|
assert.equal(edited.expense_type_id, expenseType.id);
|
||||||
|
assert.equal(edited.description, "synthetic edited purpose");
|
||||||
|
assert.equal(edited.amount.toFixed(2), "90.00");
|
||||||
|
passed.push("batch classification persists and retry preserves edits");
|
||||||
|
|
||||||
|
const submit = await post(`/reimbursements/${form.id}/submit`, {});
|
||||||
|
assert.equal(submit.status, 200, `submit: ${submit.status} ${submit.body.error?.details?.reasonCode ?? submit.body.error?.code ?? ""}`);
|
||||||
|
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: form.id } })).status, "reviewing");
|
||||||
|
const workflow = await prisma.workflowInstance.findFirst({ where: { reimbursement_id: form.id } });
|
||||||
|
assert.ok(workflow);
|
||||||
|
assert.equal(await prisma.workflowTask.count({ where: { instance_id: workflow.id, assignee_id: manager.id } }), 1);
|
||||||
|
assert.equal((await post(`/reimbursements/${form.id}/submit`, {})).status, 409);
|
||||||
|
assert.equal(await prisma.workflowInstance.count({ where: { reimbursement_id: form.id } }), 1);
|
||||||
|
passed.push("empty draft to classified edited submission assigns manager once");
|
||||||
|
|
||||||
|
const firstTask = await prisma.workflowTask.findFirst({ where: { instance_id: workflow.id, status: "pending" } });
|
||||||
|
assert.equal((await post(`/workflow/tasks/${firstTask.id}/approve`, {})).status, 403);
|
||||||
|
assert.equal((await post(`/workflow/tasks/${firstTask.id}/return`, { comment: "synthetic correction required" }, manager.id)).status, 200);
|
||||||
|
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: form.id } })).status, "draft");
|
||||||
|
assert.equal((await prisma.invoicePool.findUnique({ where: { id: invoice.id } })).linked_reimbursement_id, form.id);
|
||||||
|
assert.equal((await post(`/reimbursements/${form.id}/items/${items[0].id}`, { description: "synthetic corrected purpose" }, null, "PATCH")).status, 200);
|
||||||
|
const returnedResubmit = await post(`/reimbursements/${form.id}/submit`, {});
|
||||||
|
assert.equal(returnedResubmit.status, 200);
|
||||||
|
const rejectTask = await prisma.workflowTask.findFirst({ where: { instance_id: returnedResubmit.body.data.workflowInstanceId, status: "pending" } });
|
||||||
|
assert.equal((await post(`/workflow/tasks/${rejectTask.id}/reject`, { comment: "synthetic rejected correction" }, manager.id)).status, 200);
|
||||||
|
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: form.id } })).status, "rejected");
|
||||||
|
assert.equal((await post(`/reimbursements/${form.id}/items/${items[0].id}`, { description: "synthetic rejected item corrected" }, null, "PATCH")).status, 200);
|
||||||
|
const resubmit = await post(`/reimbursements/${form.id}/submit`, {});
|
||||||
|
assert.equal(resubmit.status, 200);
|
||||||
|
const newWorkflowId = resubmit.body.data.workflowInstanceId;
|
||||||
|
assert.notEqual(newWorkflowId, workflow.id);
|
||||||
|
for (const role of ["manager", "ceo", "finance"]) {
|
||||||
|
const task = await prisma.workflowTask.findFirst({ where: { instance_id: newWorkflowId, status: "pending" } });
|
||||||
|
assert.ok(task, `pending task for ${role}`);
|
||||||
|
if (role === "manager") assert.equal(task.assignee_id, manager.id);
|
||||||
|
else { assert.equal(task.assignee_id, null); assert.equal(task.assignee_role, role); }
|
||||||
|
const approval = await post(`/workflow/tasks/${task.id}/approve`, {}, approvers[role].id);
|
||||||
|
assert.equal(approval.status, 200, `${role} approval: ${approval.status} ${approval.body.error?.code ?? ""}`);
|
||||||
|
assert.equal((await post(`/workflow/tasks/${task.id}/approve`, {}, approvers[role].id)).status, 409);
|
||||||
|
}
|
||||||
|
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: form.id } })).status, "approved");
|
||||||
|
assert.equal(await prisma.workflowTask.count({ where: { instance_id: newWorkflowId, status: "pending" } }), 0);
|
||||||
|
assert.equal((await prisma.workflowInstance.findUnique({ where: { id: newWorkflowId } })).current_status, "approved");
|
||||||
|
passed.push("owner cannot self-approve; return/edit/resubmit and manager/ceo/finance approval complete without payment");
|
||||||
|
|
||||||
|
const competingForms = await Promise.all([makeForm(), makeForm()]);
|
||||||
|
const sharedInvoice = await makeInvoice();
|
||||||
|
const competition = await Promise.all(competingForms.map((candidate) => bind(sharedInvoice, candidate)));
|
||||||
|
assert.deepEqual(competition.map((result) => result.status).sort(), [200, 409]);
|
||||||
|
assert.equal(await prisma.reimbursementItem.count({ where: { reimbursement_id: { in: competingForms.map((candidate) => candidate.id) } } }), 1);
|
||||||
|
passed.push("cross-form competition has one winner and no orphan item");
|
||||||
|
|
||||||
|
const multipleForm = await makeForm();
|
||||||
|
const equalInvoices = await Promise.all([makeInvoice(), makeInvoice()]);
|
||||||
|
assert.deepEqual((await Promise.all(equalInvoices.map((candidate) => bind(candidate, multipleForm)))).map((result) => result.status), [200, 200]);
|
||||||
|
const multipleItems = await prisma.reimbursementItem.findMany({ where: { reimbursement_id: multipleForm.id } });
|
||||||
|
assert.equal(multipleItems.length, 2);
|
||||||
|
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: multipleForm.id } })).total_amount.toFixed(2), "200.00");
|
||||||
|
const invalidBatch = await post(`/reimbursements/${multipleForm.id}/items/classify`, { itemIds: [multipleItems[0].id, items[0].id], expenseTypeId: expenseType.id });
|
||||||
|
assert.equal(invalidBatch.status, 409);
|
||||||
|
assert.equal((await prisma.reimbursementItem.findUnique({ where: { id: multipleItems[0].id } })).expense_type_id, null);
|
||||||
|
const additionalInvoice = await makeInvoice();
|
||||||
|
assert.equal((await post(`/invoice-pool/${additionalInvoice.id}/bind`, { reimbursementId: multipleForm.id, linkedItemId: multipleItems[0].id })).status, 200);
|
||||||
|
assert.equal(await prisma.reimbursementItem.count({ where: { reimbursement_id: multipleForm.id } }), 2);
|
||||||
|
assert.equal((await prisma.reimbursementItem.findUnique({ where: { id: multipleItems[0].id } })).amount.toFixed(2), "100.00");
|
||||||
|
passed.push("equal-amount invoices stay separate; existing-item attachment preserves amount; invalid classification is atomic");
|
||||||
|
|
||||||
|
const emptyForm = await makeForm();
|
||||||
|
const incompleteInvoice = await makeInvoice({ total_amount: null });
|
||||||
|
assert.equal((await bind(incompleteInvoice, emptyForm)).status, 409);
|
||||||
|
assert.equal(await prisma.reimbursementItem.count({ where: { reimbursement_id: emptyForm.id } }), 0);
|
||||||
|
assert.equal((await prisma.invoicePool.findUnique({ where: { id: incompleteInvoice.id } })).status, "available");
|
||||||
|
assert.equal((await bind(await makeInvoice(), emptyForm, randomUUID())).status, 403);
|
||||||
|
await prisma.reimbursementForm.update({ where: { id: emptyForm.id }, data: { status: "approved" } });
|
||||||
|
assert.equal((await bind(await makeInvoice(), emptyForm)).status, 409);
|
||||||
|
passed.push("missing OCR amount, nonparticipant and immutable state reject without items");
|
||||||
|
|
||||||
|
const rollbackForm = await makeForm();
|
||||||
|
const rollbackInvoice = await makeInvoice();
|
||||||
|
await prisma.$executeRawUnsafe("CREATE FUNCTION synthetic_reject_bind() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.status = 'bound' THEN RAISE EXCEPTION 'synthetic injected failure'; END IF; RETURN NEW; END $$");
|
||||||
|
await prisma.$executeRawUnsafe("CREATE TRIGGER synthetic_reject_bind BEFORE UPDATE ON invoice_pool FOR EACH ROW EXECUTE FUNCTION synthetic_reject_bind()");
|
||||||
|
assert.equal((await bind(rollbackInvoice, rollbackForm)).status, 500);
|
||||||
|
assert.equal(await prisma.reimbursementItem.count({ where: { reimbursement_id: rollbackForm.id } }), 0);
|
||||||
|
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: rollbackForm.id } })).total_amount.toFixed(2), "0.00");
|
||||||
|
assert.equal((await prisma.invoicePool.findUnique({ where: { id: rollbackInvoice.id } })).status, "available");
|
||||||
|
await prisma.$executeRawUnsafe("DROP TRIGGER synthetic_reject_bind ON invoice_pool");
|
||||||
|
assert.equal((await bind(rollbackInvoice, rollbackForm)).status, 200);
|
||||||
|
passed.push("database failure rolls back item, occupancy and total; retry succeeds");
|
||||||
|
|
||||||
|
const blocker = await pool.connect();
|
||||||
|
const waitForLockedRequests = async (count) => {
|
||||||
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||||
|
const result = await pool.query("SELECT count(*)::int AS count FROM pg_stat_activity WHERE datname = current_database() AND wait_event_type = 'Lock' AND query LIKE '%reimbursement_forms%'");
|
||||||
|
if (result.rows[0].count >= count) return;
|
||||||
|
await delay(20);
|
||||||
|
}
|
||||||
|
assert.fail("synthetic lock barrier timed out");
|
||||||
|
};
|
||||||
|
let retryRequest;
|
||||||
|
let unbindRequest;
|
||||||
|
try {
|
||||||
|
await blocker.query("BEGIN");
|
||||||
|
await blocker.query("SELECT id FROM reimbursement_forms WHERE id = $1 FOR UPDATE", [rollbackForm.id]);
|
||||||
|
retryRequest = bind(rollbackInvoice, rollbackForm);
|
||||||
|
await waitForLockedRequests(1);
|
||||||
|
unbindRequest = post(`/invoice-pool/${rollbackInvoice.id}/unbind`, {});
|
||||||
|
await waitForLockedRequests(2);
|
||||||
|
} finally {
|
||||||
|
await blocker.query("ROLLBACK");
|
||||||
|
blocker.release();
|
||||||
|
}
|
||||||
|
const simultaneous = await Promise.all([retryRequest, unbindRequest]);
|
||||||
|
assert.deepEqual(simultaneous.map((result) => result.status), [200, 200], "retry and unbind must not deadlock");
|
||||||
|
passed.push("create retry and legacy unbind share a consistent lock order");
|
||||||
|
await prisma.employee.update({ where: { id: employee.id }, data: { token_version: { increment: 1 } } });
|
||||||
|
assert.equal((await fetch(`${origin}/api/me`, { headers: { authorization: `Bearer ${tokens.get(employee.id)}` } })).status, 401);
|
||||||
|
passed.push("revoked token version rejected by real auth plugin");
|
||||||
|
console.log(JSON.stringify({ status: "PASS_REAL_DB_HTTP", passed, authentication: "REAL_PASSWORD_LOGIN_JWT_SYNTHETIC_ACCOUNTS", liveOcr: "NOT_TESTED", ocrWorker: process.env.RUN_JOB_QUEUE === "1" ? "REAL_PGBOSS_MOCK_PROVIDER" : process.env.RUN_BROWSER_TEST === "1" ? "REAL_WORKER_MOCK_PROVIDER_MANUAL_DISPATCH" : "NOT_TESTED", approvalFlow: "RETURN_RESUBMIT_THREE_STEP_APPROVAL", payment: "NOT_TESTED" }, null, 2));
|
||||||
|
} finally {
|
||||||
|
if (browser) await browser.close().catch(() => { process.exitCode = 1; console.error("Browser cleanup failed"); });
|
||||||
|
if (preview) await new Promise((resolve, reject) => preview.httpServer.close((error) => error ? reject(error) : resolve())).catch(() => { process.exitCode = 1; console.error("Preview cleanup failed"); });
|
||||||
|
try { if (app) await app.close(); }
|
||||||
|
finally {
|
||||||
|
try { if (prisma) await prisma.$disconnect(); }
|
||||||
|
finally {
|
||||||
|
try { if (pool) await pool.end(); }
|
||||||
|
finally {
|
||||||
|
try { if (container) { docker(["rm", "-f", container]); console.log("Disposable database removed"); } }
|
||||||
|
finally { if (storageDir) { rmSync(storageDir, { recursive: true, force: true }); console.log("Synthetic upload storage removed"); } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => { console.error("Isolated HTTP verification failed:", error.code ?? error.name, error.code === "ERR_ASSERTION" ? error.message : "", error.stack?.match(/test-invoice-first-http\.cjs:\d+:\d+/)?.[0] ?? ""); process.exitCode = 1; });
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
[[ "${RUN_ISOLATED_DB_TEST:-}" == "1" ]] || { printf '%s\n' 'Set RUN_ISOLATED_DB_TEST=1 to run a disposable local database test.'; exit 1; }
|
||||||
|
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
migration_name=20260909210000_reimbursement_draft_expense_type
|
||||||
|
container_id="$(docker run -d --rm --network none --tmpfs /var/lib/postgresql/data -e POSTGRES_HOST_AUTH_METHOD=trust postgres:16-alpine)"
|
||||||
|
trap 'docker rm -f "$container_id" >/dev/null' EXIT
|
||||||
|
ready=false
|
||||||
|
for attempt in $(seq 1 30); do
|
||||||
|
if docker exec "$container_id" pg_isready -U postgres >/dev/null 2>&1; then ready=true; break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
[[ "$ready" == true ]] || { printf '%s\n' 'Disposable database did not become ready.'; exit 1; }
|
||||||
|
run_sql() { docker exec -i "$container_id" psql -X -q -v ON_ERROR_STOP=1 -U postgres; }
|
||||||
|
for migration in "$project_root"/server/prisma/migrations/*/migration.sql; do
|
||||||
|
[[ "$migration" == *"/$migration_name/"* ]] && continue
|
||||||
|
run_sql < "$migration" >/dev/null
|
||||||
|
done
|
||||||
|
run_sql <<'SQL'
|
||||||
|
INSERT INTO departments (id, name) VALUES ('10000000-0000-4000-8000-000000000001', 'synthetic-department');
|
||||||
|
INSERT INTO employees (id, employee_no, name, email, department_id, title, hire_date, bank_account_encrypted, bank_name, password_hash)
|
||||||
|
VALUES ('10000000-0000-4000-8000-000000000002', 'synthetic-migration', 'synthetic', '[email protected]', '10000000-0000-4000-8000-000000000001', 'synthetic', '2026-09-09', 'not-real-bank-data', 'synthetic', 'not-a-login-hash');
|
||||||
|
INSERT INTO expense_types (id, code, name, category, sort_order)
|
||||||
|
VALUES ('10000000-0000-4000-8000-000000000003', 'synthetic_migration', 'synthetic', 'office', 999);
|
||||||
|
INSERT INTO reimbursement_forms (id, owner_id, submitter_id, department_id, reimbursement_date)
|
||||||
|
VALUES ('10000000-0000-4000-8000-000000000004', '10000000-0000-4000-8000-000000000002', '10000000-0000-4000-8000-000000000002', '10000000-0000-4000-8000-000000000001', '2026-09-09');
|
||||||
|
INSERT INTO reimbursement_items (id, reimbursement_id, expense_type_id, expense_date, amount, description)
|
||||||
|
VALUES ('10000000-0000-4000-8000-000000000005', '10000000-0000-4000-8000-000000000004', '10000000-0000-4000-8000-000000000003', '2026-09-09', 100, 'synthetic');
|
||||||
|
DO $$ BEGIN
|
||||||
|
BEGIN
|
||||||
|
UPDATE reimbursement_items SET expense_type_id = NULL;
|
||||||
|
RAISE EXCEPTION 'baseline unexpectedly accepts null classification';
|
||||||
|
EXCEPTION WHEN not_null_violation THEN NULL;
|
||||||
|
END;
|
||||||
|
END $$;
|
||||||
|
SQL
|
||||||
|
run_sql < "$project_root/server/prisma/migrations/$migration_name/migration.sql"
|
||||||
|
run_sql <<'SQL'
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM reimbursement_items WHERE expense_type_id = '10000000-0000-4000-8000-000000000003' AND amount = 100) THEN
|
||||||
|
RAISE EXCEPTION 'existing item changed';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
INSERT INTO reimbursement_items (id, reimbursement_id, expense_type_id, expense_date, amount, description)
|
||||||
|
VALUES ('10000000-0000-4000-8000-000000000006', '10000000-0000-4000-8000-000000000004', NULL, '2026-09-09', 50, 'synthetic unclassified');
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM reimbursement_items WHERE id = '10000000-0000-4000-8000-000000000006' AND expense_type_id IS NULL) THEN
|
||||||
|
RAISE EXCEPTION 'unclassified item not persisted';
|
||||||
|
END IF;
|
||||||
|
BEGIN
|
||||||
|
UPDATE reimbursement_items SET expense_type_id = '10000000-0000-4000-8000-000000000099';
|
||||||
|
RAISE EXCEPTION 'foreign key no longer enforced';
|
||||||
|
EXCEPTION WHEN foreign_key_violation THEN NULL;
|
||||||
|
END;
|
||||||
|
BEGIN
|
||||||
|
UPDATE reimbursement_items SET amount = NULL;
|
||||||
|
RAISE EXCEPTION 'amount became nullable';
|
||||||
|
EXCEPTION WHEN not_null_violation THEN NULL;
|
||||||
|
END;
|
||||||
|
BEGIN
|
||||||
|
UPDATE reimbursement_items SET expense_date = NULL;
|
||||||
|
RAISE EXCEPTION 'date became nullable';
|
||||||
|
EXCEPTION WHEN not_null_violation THEN NULL;
|
||||||
|
END;
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE reimbursement_items ALTER COLUMN expense_type_id SET NOT NULL;
|
||||||
|
RAISE EXCEPTION 'unsafe rollback unexpectedly succeeded';
|
||||||
|
EXCEPTION WHEN not_null_violation THEN NULL;
|
||||||
|
END;
|
||||||
|
END $$;
|
||||||
|
UPDATE reimbursement_items SET expense_type_id = '10000000-0000-4000-8000-000000000003' WHERE expense_type_id IS NULL;
|
||||||
|
ALTER TABLE reimbursement_items ALTER COLUMN expense_type_id SET NOT NULL;
|
||||||
|
SQL
|
||||||
|
printf '%s\n' 'PASS: baseline, persisted null classification, unchanged existing data, FK, required amount/date, guarded rollback.'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "reimbursement_items" ALTER COLUMN "expense_type_id" DROP NOT NULL;
|
||||||
@@ -1497,7 +1497,7 @@ model ReimbursementForm {
|
|||||||
model ReimbursementItem {
|
model ReimbursementItem {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
reimbursement_id String @db.Uuid
|
reimbursement_id String @db.Uuid
|
||||||
expense_type_id String @db.Uuid
|
expense_type_id String? @db.Uuid
|
||||||
expense_date DateTime @db.Date
|
expense_date DateTime @db.Date
|
||||||
amount Decimal @db.Decimal(14, 2)
|
amount Decimal @db.Decimal(14, 2)
|
||||||
description String
|
description String
|
||||||
@@ -1509,7 +1509,7 @@ model ReimbursementItem {
|
|||||||
updated_by String? @db.Uuid
|
updated_by String? @db.Uuid
|
||||||
|
|
||||||
reimbursement_form ReimbursementForm @relation(fields: [reimbursement_id], references: [id], onDelete: Cascade)
|
reimbursement_form ReimbursementForm @relation(fields: [reimbursement_id], references: [id], onDelete: Cascade)
|
||||||
expense_type ExpenseType @relation(fields: [expense_type_id], references: [id], onDelete: Restrict)
|
expense_type ExpenseType? @relation(fields: [expense_type_id], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
invoice_pool_items InvoicePool[]
|
invoice_pool_items InvoicePool[]
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
const ERROR_REASONS = [
|
const ERROR_REASONS = [
|
||||||
|
{"code":"reimbursement.invoice_prefill_incomplete","domain":"reimbursement","audience":"user","defaultMessage":"票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。","recovery":"follow_guidance"},
|
||||||
|
{"code":"reimbursement.items_description_required","domain":"reimbursement","audience":"user","defaultMessage":"请补充明细的支出说明后再提交。","recovery":"follow_guidance"},
|
||||||
{"code":"reimbursement.material_preview_limit","domain":"reimbursement","audience":"user","defaultMessage":"报销材料较多,暂时无法连续预览,请分批查看材料或联系管理员协助。","recovery":"view_individually"},
|
{"code":"reimbursement.material_preview_limit","domain":"reimbursement","audience":"user","defaultMessage":"报销材料较多,暂时无法连续预览,请分批查看材料或联系管理员协助。","recovery":"view_individually"},
|
||||||
{"code":"purchase_attachment.unsupported_type","domain":"purchase_attachment","audience":"user","defaultMessage":"仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。","recovery":"choose_file"},
|
{"code":"purchase_attachment.unsupported_type","domain":"purchase_attachment","audience":"user","defaultMessage":"仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。","recovery":"choose_file"},
|
||||||
{"code":"reimbursement.invoice_amount_required","domain":"reimbursement","audience":"user","defaultMessage":"票据金额尚未确认,无法自动生成明细,请先核对票据金额或手动选择明细。","recovery":"follow_guidance"},
|
{"code":"reimbursement.invoice_amount_required","domain":"reimbursement","audience":"user","defaultMessage":"票据金额尚未确认,无法自动生成明细,请先核对票据金额或手动选择明细。","recovery":"follow_guidance"},
|
||||||
{"code":"reimbursement.expense_type_required","domain":"reimbursement","audience":"user","defaultMessage":"当前没有可用费用类型,请联系管理员维护后再添加票据。","recovery":"follow_guidance"},
|
{"code":"reimbursement.expense_type_required","domain":"reimbursement","audience":"user","defaultMessage":"当前没有可用费用类型,请联系管理员维护后再添加票据。","recovery":"follow_guidance"},
|
||||||
|
{"code":"reimbursement.items_expense_type_required","domain":"reimbursement","audience":"user","defaultMessage":"请补充明细的费用类型;同类明细可批量设置,已停用的类型请重新选择。","recovery":"follow_guidance"},
|
||||||
{"code":"reimbursement.invoice_not_bound","domain":"reimbursement","audience":"user","defaultMessage":"该票据已不再关联业务单据,请刷新票据列表后确认。","recovery":"follow_guidance"},
|
{"code":"reimbursement.invoice_not_bound","domain":"reimbursement","audience":"user","defaultMessage":"该票据已不再关联业务单据,请刷新票据列表后确认。","recovery":"follow_guidance"},
|
||||||
{"code":"reimbursement.invoice_unbind_status_invalid","domain":"reimbursement","audience":"user","defaultMessage":"报销单当前不能移出票据,请先核实审批和付款状态。","recovery":"follow_guidance"},
|
{"code":"reimbursement.invoice_unbind_status_invalid","domain":"reimbursement","audience":"user","defaultMessage":"报销单当前不能移出票据,请先核实审批和付款状态。","recovery":"follow_guidance"},
|
||||||
{"code":"reimbursement.invoice_bind_status_invalid","domain":"reimbursement","audience":"user","defaultMessage":"报销单当前不能添加票据,请刷新并确认单据状态。","recovery":"follow_guidance"},
|
{"code":"reimbursement.invoice_bind_status_invalid","domain":"reimbursement","audience":"user","defaultMessage":"报销单当前不能添加票据,请刷新并确认单据状态。","recovery":"follow_guidance"},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||||
|
const { suggestInvoiceExpenseType } = require("../services/invoiceExpenseClassification");
|
||||||
const crypto = require("node:crypto");
|
const crypto = require("node:crypto");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
|
||||||
@@ -37,8 +38,10 @@ const bindBodySchema = z.union([
|
|||||||
.object({
|
.object({
|
||||||
reimbursementId: z.string().uuid(),
|
reimbursementId: z.string().uuid(),
|
||||||
linkedItemId: z.string().uuid().optional(),
|
linkedItemId: z.string().uuid().optional(),
|
||||||
|
createItem: z.literal(true).optional(),
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict()
|
||||||
|
.refine((body) => !(body.createItem && body.linkedItemId)),
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
travelOrderId: z.string().uuid(),
|
travelOrderId: z.string().uuid(),
|
||||||
@@ -264,7 +267,31 @@ async function recomputeReimbursementTotal(tx, reimbursementId, actorId) {
|
|||||||
return nextTotal;
|
return nextTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveInvoiceLinkedItem(tx, { reimbursementId, reimbursementDate, invoice, linkedItemId, actorId }) {
|
async function resolveInvoiceLinkedItem(tx, { reimbursementId, reimbursementDate, invoice, linkedItemId, actorId, createItem }) {
|
||||||
|
if (createItem) {
|
||||||
|
const expenseDate = toDateOnlyFromDate(invoice.invoice_date);
|
||||||
|
if (!expenseDate || !Number.isFinite(expenseDate.getTime()) || invoice.total_amount == null || Number(invoice.total_amount) <= 0 || !Number.isFinite(Number(invoice.total_amount))) {
|
||||||
|
throw buildBusinessValidationError("reimbursement.invoice_prefill_incomplete", { invoice: "missing_date_or_positive_amount" }, 409);
|
||||||
|
}
|
||||||
|
const expenseTypes = await tx.expenseType.findMany({
|
||||||
|
where: { enabled: true, code: "travel_train" },
|
||||||
|
select: { id: true, code: true, enabled: true },
|
||||||
|
});
|
||||||
|
const suggestion = suggestInvoiceExpenseType(invoice, expenseTypes);
|
||||||
|
const created = await tx.reimbursementItem.create({
|
||||||
|
data: {
|
||||||
|
reimbursement_id: reimbursementId,
|
||||||
|
expense_type_id: suggestion?.expenseTypeId ?? null,
|
||||||
|
expense_date: expenseDate,
|
||||||
|
amount: toMoneyString(invoice.total_amount),
|
||||||
|
description: suggestion?.source === "document_category" ? "铁路交通费用" : "",
|
||||||
|
created_by: actorId,
|
||||||
|
updated_by: actorId,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return { linkedItemId: created.id, createdItem: true };
|
||||||
|
}
|
||||||
if (linkedItemId) {
|
if (linkedItemId) {
|
||||||
const linkedItem = await tx.reimbursementItem.findFirst({
|
const linkedItem = await tx.reimbursementItem.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -1016,6 +1043,16 @@ async function invoicePoolRoutes(fastify) {
|
|||||||
let responseInvoice = null;
|
let responseInvoice = null;
|
||||||
|
|
||||||
await fastify.prisma.$transaction(async (tx) => {
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
const bindingSnapshot = await tx.invoicePool.findUnique({
|
||||||
|
where: { id: invoiceId },
|
||||||
|
select: { linked_reimbursement_id: true },
|
||||||
|
});
|
||||||
|
if (bindingSnapshot?.linked_reimbursement_id) {
|
||||||
|
await tx.$queryRaw`
|
||||||
|
SELECT id FROM "reimbursement_forms"
|
||||||
|
WHERE id = ${bindingSnapshot.linked_reimbursement_id}::uuid FOR UPDATE
|
||||||
|
`;
|
||||||
|
}
|
||||||
const invoiceRows = await tx.$queryRaw`
|
const invoiceRows = await tx.$queryRaw`
|
||||||
SELECT id, status, linked_reimbursement_id, travel_order_id
|
SELECT id, status, linked_reimbursement_id, travel_order_id
|
||||||
FROM "invoice_pool"
|
FROM "invoice_pool"
|
||||||
@@ -1032,6 +1069,9 @@ async function invoicePoolRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const invoice = invoiceRows[0];
|
const invoice = invoiceRows[0];
|
||||||
|
if ((invoice.linked_reimbursement_id ?? null) !== (bindingSnapshot?.linked_reimbursement_id ?? null)) {
|
||||||
|
throw buildBusinessValidationError("reimbursement.invoice_unbind_status_invalid", { invoice: "binding_changed" }, 409);
|
||||||
|
}
|
||||||
if (invoice.status !== "bound" || (!invoice.linked_reimbursement_id && !invoice.travel_order_id)) {
|
if (invoice.status !== "bound" || (!invoice.linked_reimbursement_id && !invoice.travel_order_id)) {
|
||||||
throw buildBusinessValidationError("reimbursement.invoice_not_bound", { invoice: "not_bound" }, 409);
|
throw buildBusinessValidationError("reimbursement.invoice_not_bound", { invoice: "not_bound" }, 409);
|
||||||
}
|
}
|
||||||
@@ -1440,6 +1480,23 @@ async function invoicePoolRoutes(fastify) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await fastify.prisma.$transaction(async (tx) => {
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
if ("reimbursementId" in parsedBody.data) {
|
||||||
|
if (!fastify.hasPermission(request.employee, "invoice.upload")) {
|
||||||
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||||
|
}
|
||||||
|
const forms = await tx.$queryRaw`
|
||||||
|
SELECT id, owner_id, submitter_id, status FROM "reimbursement_forms"
|
||||||
|
WHERE id = ${parsedBody.data.reimbursementId}::uuid FOR UPDATE
|
||||||
|
`;
|
||||||
|
const form = forms[0];
|
||||||
|
if (!form) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||||
|
if (form.owner_id !== request.employee.id && form.submitter_id !== request.employee.id) {
|
||||||
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||||
|
}
|
||||||
|
if (!["draft", "rejected"].includes(form.status)) {
|
||||||
|
throw buildBusinessValidationError("reimbursement.invoice_bind_status_invalid", { reimbursement: "invalid_status_for_bind" }, 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
const invoiceRows = await tx.$queryRaw`
|
const invoiceRows = await tx.$queryRaw`
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM "invoice_pool"
|
FROM "invoice_pool"
|
||||||
@@ -1472,6 +1529,7 @@ async function invoicePoolRoutes(fastify) {
|
|||||||
travel_order_id: true,
|
travel_order_id: true,
|
||||||
archived_reason: true,
|
archived_reason: true,
|
||||||
is_duplicate_override: true,
|
is_duplicate_override: true,
|
||||||
|
ocr_result: { select: { result_json: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1483,7 +1541,10 @@ async function invoicePoolRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invoice.status === "bound" || invoice.linked_reimbursement_id || invoice.travel_order_id) {
|
const sameFormRetry = parsedBody.data.createItem === true &&
|
||||||
|
invoice.status === "bound" && !invoice.travel_order_id &&
|
||||||
|
invoice.linked_reimbursement_id === parsedBody.data.reimbursementId && Boolean(invoice.linked_item_id);
|
||||||
|
if (!sameFormRetry && (invoice.status === "bound" || invoice.linked_reimbursement_id || invoice.travel_order_id)) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
code: "VALID_005",
|
code: "VALID_005",
|
||||||
message: "发票已被其他业务单据占用/绑定",
|
message: "发票已被其他业务单据占用/绑定",
|
||||||
@@ -1491,7 +1552,7 @@ async function invoicePoolRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invoice.status !== "available") {
|
if (!sameFormRetry && invoice.status !== "available") {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
code: "VALID_005",
|
code: "VALID_005",
|
||||||
message: "发票已被其他业务单据占用/绑定",
|
message: "发票已被其他业务单据占用/绑定",
|
||||||
@@ -1500,7 +1561,7 @@ async function invoicePoolRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!invoice.is_duplicate_override) {
|
if (!sameFormRetry && !invoice.is_duplicate_override) {
|
||||||
const duplicateBlocker = await resolveInvoiceDedupBlocker(tx, {
|
const duplicateBlocker = await resolveInvoiceDedupBlocker(tx, {
|
||||||
excludeInvoiceId: invoiceId,
|
excludeInvoiceId: invoiceId,
|
||||||
fileSha256: invoice.file_sha256,
|
fileSha256: invoice.file_sha256,
|
||||||
@@ -1533,7 +1594,7 @@ async function invoicePoolRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { reimbursementId, linkedItemId } = parsedBody.data;
|
const { reimbursementId, linkedItemId, createItem } = parsedBody.data;
|
||||||
const reimbursementRows = await tx.$queryRaw`
|
const reimbursementRows = await tx.$queryRaw`
|
||||||
SELECT id, owner_id, submitter_id, department_id, status, reimbursement_date
|
SELECT id, owner_id, submitter_id, department_id, status, reimbursement_date
|
||||||
FROM "reimbursement_forms"
|
FROM "reimbursement_forms"
|
||||||
@@ -1568,6 +1629,13 @@ async function invoicePoolRoutes(fastify) {
|
|||||||
throw buildBusinessValidationError("reimbursement.invoice_owner_mismatch", { invoice: "owner_mismatch" }, 409);
|
throw buildBusinessValidationError("reimbursement.invoice_owner_mismatch", { invoice: "owner_mismatch" }, 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sameFormRetry) {
|
||||||
|
await resolveInvoiceLinkedItem(tx, {
|
||||||
|
reimbursementId, invoice, linkedItemId: invoice.linked_item_id, actorId: request.employee.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const invoiceGuardGate = await evaluateFeatureFlag({
|
const invoiceGuardGate = await evaluateFeatureFlag({
|
||||||
prisma: tx,
|
prisma: tx,
|
||||||
config: fastify.config,
|
config: fastify.config,
|
||||||
@@ -1575,7 +1643,7 @@ async function invoicePoolRoutes(fastify) {
|
|||||||
departmentId: reimbursement.department_id ?? null,
|
departmentId: reimbursement.department_id ?? null,
|
||||||
strictDepartment: true,
|
strictDepartment: true,
|
||||||
});
|
});
|
||||||
if (invoiceGuardGate.enabled && !linkedItemId) {
|
if (invoiceGuardGate.enabled && !linkedItemId && !createItem) {
|
||||||
throw buildBusinessValidationError("reimbursement.invoice_item_required", { linkedItemId: "required_when_invoice_guard_enabled" }, 400);
|
throw buildBusinessValidationError("reimbursement.invoice_item_required", { linkedItemId: "required_when_invoice_guard_enabled" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1585,6 +1653,7 @@ async function invoicePoolRoutes(fastify) {
|
|||||||
invoice,
|
invoice,
|
||||||
linkedItemId,
|
linkedItemId,
|
||||||
actorId: request.employee.id,
|
actorId: request.employee.id,
|
||||||
|
createItem,
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.invoicePool.update({
|
await tx.invoicePool.update({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||||
|
const { ensureClassifiedReimbursementItems, ensureReimbursementItemDescriptions } = require("../services/invoiceExpenseClassification");
|
||||||
const { Prisma } = require("@prisma/client");
|
const { Prisma } = require("@prisma/client");
|
||||||
const crypto = require("node:crypto");
|
const crypto = require("node:crypto");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
@@ -178,10 +179,10 @@ const REIMBURSEMENT_CREATE_BODY_SCHEMA = z
|
|||||||
});
|
});
|
||||||
|
|
||||||
const REIMBURSEMENT_ITEM_BODY_SCHEMA = z.object({
|
const REIMBURSEMENT_ITEM_BODY_SCHEMA = z.object({
|
||||||
expenseTypeId: z.string().uuid(),
|
expenseTypeId: z.string().uuid().nullable(),
|
||||||
expenseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
expenseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
amount: z.string().regex(/^\d+(\.\d{1,2})?$/),
|
amount: z.string().regex(/^\d+(\.\d{1,2})?$/),
|
||||||
description: z.string().trim().min(1).max(500),
|
description: z.string().trim().max(500),
|
||||||
assetPriorApprovalBasis: z.string().trim().max(500).nullable().optional(),
|
assetPriorApprovalBasis: z.string().trim().max(500).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3511,11 +3512,11 @@ async function reimbursementRoutes(fastify) {
|
|||||||
throw buildBusinessValidationError("reimbursement.status_invalid_item_edit_status", { status: "invalid_item_edit_status" }, 409);
|
throw buildBusinessValidationError("reimbursement.status_invalid_item_edit_status", { status: "invalid_item_edit_status" }, 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
const expenseType = await tx.expenseType.findUnique({
|
const expenseType = body.expenseTypeId === null ? null : await tx.expenseType.findUnique({
|
||||||
where: { id: body.expenseTypeId },
|
where: { id: body.expenseTypeId },
|
||||||
select: { id: true, enabled: true, category: true },
|
select: { id: true, enabled: true, category: true },
|
||||||
});
|
});
|
||||||
if (!expenseType || !expenseType.enabled) {
|
if (body.expenseTypeId !== null && (!expenseType || !expenseType.enabled)) {
|
||||||
throw buildBusinessValidationError("reimbursement.expense_type_id_invalid_or_disabled", { expenseTypeId: "invalid_or_disabled" }, 409);
|
throw buildBusinessValidationError("reimbursement.expense_type_id_invalid_or_disabled", { expenseTypeId: "invalid_or_disabled" }, 409);
|
||||||
}
|
}
|
||||||
const created = await tx.reimbursementItem.create({
|
const created = await tx.reimbursementItem.create({
|
||||||
@@ -3526,7 +3527,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
amount: body.amount,
|
amount: body.amount,
|
||||||
description: body.description,
|
description: body.description,
|
||||||
asset_prior_approval_basis:
|
asset_prior_approval_basis:
|
||||||
expenseType.category === "assets" ? body.assetPriorApprovalBasis?.trim() || null : null,
|
expenseType?.category === "assets" ? body.assetPriorApprovalBasis?.trim() || null : null,
|
||||||
created_by: request.employee.id,
|
created_by: request.employee.id,
|
||||||
updated_by: request.employee.id,
|
updated_by: request.employee.id,
|
||||||
},
|
},
|
||||||
@@ -3562,6 +3563,52 @@ async function reimbursementRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
"/reimbursements/:id/items/classify",
|
||||||
|
{ preHandler: fastify.requirePermission("expense.edit_own") },
|
||||||
|
async (request, reply) => {
|
||||||
|
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||||
|
const parsed = z.object({
|
||||||
|
itemIds: z.array(UUID_SCHEMA).min(1).max(100),
|
||||||
|
expenseTypeId: UUID_SCHEMA,
|
||||||
|
}).strict().safeParse(request.body ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw buildBusinessValidationError("reimbursement.invalid_input", parsed.error.flatten(), 400);
|
||||||
|
}
|
||||||
|
const itemIds = [...new Set(parsed.data.itemIds)];
|
||||||
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
const rows = await tx.$queryRaw`
|
||||||
|
SELECT id, owner_id, submitter_id, status FROM "reimbursement_forms"
|
||||||
|
WHERE id = ${reimbursementId}::uuid FOR UPDATE
|
||||||
|
`;
|
||||||
|
const reimbursement = rows[0];
|
||||||
|
if (!reimbursement) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||||
|
ensureReimbursementParticipant(request, reimbursement);
|
||||||
|
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||||
|
throw buildBusinessValidationError("reimbursement.status_invalid_item_edit_status", { status: "invalid_item_edit_status" }, 409);
|
||||||
|
}
|
||||||
|
const items = await tx.reimbursementItem.findMany({
|
||||||
|
where: { reimbursement_id: reimbursementId, id: { in: itemIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (items.length !== itemIds.length) {
|
||||||
|
throw buildBusinessValidationError("reimbursement.linked_item_id_item_not_in_reimbursement", { itemIds: "item_not_in_reimbursement" }, 409);
|
||||||
|
}
|
||||||
|
const expenseType = await tx.expenseType.findUnique({
|
||||||
|
where: { id: parsed.data.expenseTypeId }, select: { id: true, enabled: true },
|
||||||
|
});
|
||||||
|
if (!expenseType?.enabled) {
|
||||||
|
throw buildBusinessValidationError("reimbursement.expense_type_id_invalid_or_disabled", { expenseTypeId: "invalid_or_disabled" }, 409);
|
||||||
|
}
|
||||||
|
await tx.reimbursementItem.updateMany({
|
||||||
|
where: { reimbursement_id: reimbursementId, id: { in: itemIds } },
|
||||||
|
data: { expense_type_id: expenseType.id, updated_by: request.employee.id },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return sendData(reply, request, { itemIds, expenseTypeId: parsed.data.expenseTypeId });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
fastify.patch(
|
fastify.patch(
|
||||||
"/reimbursements/:id/items/:itemId",
|
"/reimbursements/:id/items/:itemId",
|
||||||
{
|
{
|
||||||
@@ -3627,12 +3674,14 @@ async function reimbursementRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetExpenseTypeId = parsedBody.data.expenseTypeId ?? item.expense_type_id;
|
const targetExpenseTypeId = parsedBody.data.expenseTypeId === undefined
|
||||||
const expenseType = await tx.expenseType.findUnique({
|
? item.expense_type_id
|
||||||
|
: parsedBody.data.expenseTypeId;
|
||||||
|
const expenseType = targetExpenseTypeId === null ? null : await tx.expenseType.findUnique({
|
||||||
where: { id: targetExpenseTypeId },
|
where: { id: targetExpenseTypeId },
|
||||||
select: { id: true, enabled: true, category: true },
|
select: { id: true, enabled: true, category: true },
|
||||||
});
|
});
|
||||||
if (!expenseType || !expenseType.enabled) {
|
if (targetExpenseTypeId !== null && (!expenseType || !expenseType.enabled)) {
|
||||||
throw buildBusinessValidationError("reimbursement.expense_type_id_invalid_or_disabled", { expenseTypeId: "invalid_or_disabled" }, 409);
|
throw buildBusinessValidationError("reimbursement.expense_type_id_invalid_or_disabled", { expenseTypeId: "invalid_or_disabled" }, 409);
|
||||||
}
|
}
|
||||||
const targetApprovalBasis =
|
const targetApprovalBasis =
|
||||||
@@ -3642,7 +3691,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
const updated = await tx.reimbursementItem.update({
|
const updated = await tx.reimbursementItem.update({
|
||||||
where: { id: parsedItemId.data },
|
where: { id: parsedItemId.data },
|
||||||
data: {
|
data: {
|
||||||
...(typeof parsedBody.data.expenseTypeId === "string"
|
...(parsedBody.data.expenseTypeId !== undefined
|
||||||
? { expense_type_id: parsedBody.data.expenseTypeId }
|
? { expense_type_id: parsedBody.data.expenseTypeId }
|
||||||
: {}),
|
: {}),
|
||||||
...(typeof parsedBody.data.expenseDate === "string"
|
...(typeof parsedBody.data.expenseDate === "string"
|
||||||
@@ -3655,7 +3704,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
? { description: parsedBody.data.description.trim() }
|
? { description: parsedBody.data.description.trim() }
|
||||||
: {}),
|
: {}),
|
||||||
asset_prior_approval_basis:
|
asset_prior_approval_basis:
|
||||||
expenseType.category === "assets"
|
expenseType?.category === "assets"
|
||||||
? targetApprovalBasis?.trim() || null
|
? targetApprovalBasis?.trim() || null
|
||||||
: null,
|
: null,
|
||||||
updated_by: request.employee.id,
|
updated_by: request.employee.id,
|
||||||
@@ -3847,12 +3896,17 @@ async function reimbursementRoutes(fastify) {
|
|||||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||||
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
|
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
|
||||||
where: { id: reimbursementId },
|
where: { id: reimbursementId },
|
||||||
select: { id: true, owner_id: true, submitter_id: true, status: true },
|
select: {
|
||||||
|
id: true, owner_id: true, submitter_id: true, status: true,
|
||||||
|
items: { select: { id: true, description: true, expense_type: { select: { enabled: true } } } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!reimbursement) {
|
if (!reimbursement) {
|
||||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||||
}
|
}
|
||||||
await ensureOwnOrCanViewAll(fastify, request, reimbursement);
|
await ensureOwnOrCanViewAll(fastify, request, reimbursement);
|
||||||
|
ensureClassifiedReimbursementItems(reimbursement.items ?? []);
|
||||||
|
ensureReimbursementItemDescriptions(reimbursement.items ?? []);
|
||||||
const precheck = await resolveEntityPrecheck({
|
const precheck = await resolveEntityPrecheck({
|
||||||
prisma: fastify.prisma,
|
prisma: fastify.prisma,
|
||||||
config: fastify.config,
|
config: fastify.config,
|
||||||
@@ -3903,8 +3957,9 @@ async function reimbursementRoutes(fastify) {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
amount: true,
|
amount: true,
|
||||||
|
description: true,
|
||||||
expense_type: {
|
expense_type: {
|
||||||
select: { code: true, category: true, requires_invoice: true },
|
select: { code: true, category: true, requires_invoice: true, enabled: true },
|
||||||
},
|
},
|
||||||
invoice_pool_items: {
|
invoice_pool_items: {
|
||||||
where: { status: "bound" },
|
where: { status: "bound" },
|
||||||
@@ -3922,6 +3977,8 @@ async function reimbursementRoutes(fastify) {
|
|||||||
if (!reimbursementItems.length) {
|
if (!reimbursementItems.length) {
|
||||||
throw buildBusinessValidationError("reimbursement.items_at_least_one_required", { items: "at_least_one_required" }, 400);
|
throw buildBusinessValidationError("reimbursement.items_at_least_one_required", { items: "at_least_one_required" }, 400);
|
||||||
}
|
}
|
||||||
|
ensureClassifiedReimbursementItems(reimbursementItems);
|
||||||
|
ensureReimbursementItemDescriptions(reimbursementItems);
|
||||||
const invalidAmountItems = reimbursementItems
|
const invalidAmountItems = reimbursementItems
|
||||||
.filter((item) => !Number.isFinite(Number(item.amount)) || Number(item.amount) <= 0)
|
.filter((item) => !Number.isFinite(Number(item.amount)) || Number(item.amount) <= 0)
|
||||||
.map((item) => item.id);
|
.map((item) => item.id);
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
const { mapInvoiceDocumentMeta } = require("./invoiceDocumentMeta");
|
||||||
|
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||||
|
|
||||||
|
function suggestInvoiceExpenseType(invoice, expenseTypes) {
|
||||||
|
const { documentCategory } = mapInvoiceDocumentMeta(invoice);
|
||||||
|
if (documentCategory !== "electronic_train_ticket") return null;
|
||||||
|
|
||||||
|
const expenseType = expenseTypes.find((type) => type.enabled === true && type.code === "travel_train");
|
||||||
|
if (!expenseType) return null;
|
||||||
|
|
||||||
|
return { expenseTypeId: expenseType.id, source: "document_category" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureClassifiedReimbursementItems(items) {
|
||||||
|
const itemIds = items
|
||||||
|
.filter((item) => !item.expense_type || item.expense_type.enabled === false)
|
||||||
|
.map((item) => item.id);
|
||||||
|
if (itemIds.length) {
|
||||||
|
throw buildBusinessValidationError("reimbursement.items_expense_type_required", { itemIds }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureReimbursementItemDescriptions(items) {
|
||||||
|
const itemIds = items.filter((item) => !item.description?.trim()).map((item) => item.id);
|
||||||
|
if (itemIds.length) {
|
||||||
|
throw buildBusinessValidationError("reimbursement.items_description_required", { itemIds }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { suggestInvoiceExpenseType, ensureClassifiedReimbursementItems, ensureReimbursementItemDescriptions };
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
{
|
{
|
||||||
|
"reimbursement.invoice_prefill_incomplete": "票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。",
|
||||||
|
"reimbursement.items_description_required": "请补充明细的支出说明后再提交。",
|
||||||
"reimbursement.material_preview_limit": "报销材料较多,暂时无法连续预览,请分批查看材料或联系管理员协助。",
|
"reimbursement.material_preview_limit": "报销材料较多,暂时无法连续预览,请分批查看材料或联系管理员协助。",
|
||||||
"purchase_attachment.unsupported_type": "仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。",
|
"purchase_attachment.unsupported_type": "仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。",
|
||||||
"reimbursement.invoice_amount_required": "票据金额尚未确认,无法自动生成明细,请先核对票据金额或手动选择明细。",
|
"reimbursement.invoice_amount_required": "票据金额尚未确认,无法自动生成明细,请先核对票据金额或手动选择明细。",
|
||||||
"reimbursement.expense_type_required": "当前没有可用费用类型,请联系管理员维护后再添加票据。",
|
"reimbursement.expense_type_required": "当前没有可用费用类型,请联系管理员维护后再添加票据。",
|
||||||
|
"reimbursement.items_expense_type_required": "请补充明细的费用类型;同类明细可批量设置,已停用的类型请重新选择。",
|
||||||
"reimbursement.invoice_not_bound": "该票据已不再关联业务单据,请刷新票据列表后确认。",
|
"reimbursement.invoice_not_bound": "该票据已不再关联业务单据,请刷新票据列表后确认。",
|
||||||
"reimbursement.invoice_unbind_status_invalid": "报销单当前不能移出票据,请先核实审批和付款状态。",
|
"reimbursement.invoice_unbind_status_invalid": "报销单当前不能移出票据,请先核实审批和付款状态。",
|
||||||
"reimbursement.invoice_bind_status_invalid": "报销单当前不能添加票据,请刷新并确认单据状态。",
|
"reimbursement.invoice_bind_status_invalid": "报销单当前不能添加票据,请刷新并确认单据状态。",
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ function buildPrisma({
|
|||||||
},
|
},
|
||||||
expenseType: {
|
expenseType: {
|
||||||
findFirst: vi.fn().mockResolvedValue(expenseType),
|
findFirst: vi.fn().mockResolvedValue(expenseType),
|
||||||
|
findMany: vi.fn().mockResolvedValue([{ id: "train-type", code: "travel_train", enabled: true }]),
|
||||||
},
|
},
|
||||||
auditLog: {
|
auditLog: {
|
||||||
create: vi.fn().mockResolvedValue({ id: "audit-1" }),
|
create: vi.fn().mockResolvedValue({ id: "audit-1" }),
|
||||||
@@ -147,6 +148,110 @@ async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"], e
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("invoicePool travel-order bind routes", () => {
|
describe("invoicePool travel-order bind routes", () => {
|
||||||
|
const explicitFormId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||||
|
const explicitInvoiceId = "11111111-1111-4111-8111-111111111111";
|
||||||
|
function explicitInput(overrides = {}) {
|
||||||
|
return {
|
||||||
|
invoice: {
|
||||||
|
id: explicitInvoiceId, status: "available", linked_reimbursement_id: null,
|
||||||
|
linked_item_id: null, travel_order_id: null, total_amount: "168.00",
|
||||||
|
...overrides.invoice,
|
||||||
|
},
|
||||||
|
reimbursement: {
|
||||||
|
id: explicitFormId, owner_id: "actor-1", submitter_id: "actor-1", status: "draft",
|
||||||
|
...overrides.reimbursement,
|
||||||
|
},
|
||||||
|
reimbursementItems: [{ id: "existing-same-amount", amount: "168.00" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function explicitRequest(payload = {}) {
|
||||||
|
return {
|
||||||
|
method: "POST", url: `/api/invoice-pool/${explicitInvoiceId}/bind`,
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
payload: { reimbursementId: explicitFormId, createItem: true, ...payload },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each([false, true])("explicitly creates an unclassified item without guessing or matching (guard=%s)", async (invoiceGuardEnabled) => {
|
||||||
|
const { prisma } = buildPrisma(explicitInput());
|
||||||
|
const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"], invoiceGuardEnabled });
|
||||||
|
try {
|
||||||
|
const response = await app.inject(explicitRequest());
|
||||||
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
|
expect(prisma.reimbursementItem.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
reimbursement_id: explicitFormId, expense_type_id: null,
|
||||||
|
expense_date: new Date("2026-05-08T00:00:00Z"), amount: "168.00", description: "",
|
||||||
|
created_by: "actor-1", updated_by: "actor-1",
|
||||||
|
}, select: { id: true },
|
||||||
|
});
|
||||||
|
expect(prisma.reimbursementItem.findMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.expenseType.findFirst).not.toHaveBeenCalled();
|
||||||
|
expect(response.json().data.linkedItemId).toBe("item-auto-1");
|
||||||
|
} finally { await app.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses persisted train metadata and keeps same-form retries write-free", async () => {
|
||||||
|
const { prisma, state } = buildPrisma(explicitInput({ invoice: {
|
||||||
|
ocr_result: { result_json: { documentCategory: "electronic_train_ticket" } },
|
||||||
|
} }));
|
||||||
|
const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"], invoiceGuardEnabled: true });
|
||||||
|
try {
|
||||||
|
const first = await app.inject(explicitRequest());
|
||||||
|
expect(first.statusCode, first.body).toBe(200);
|
||||||
|
expect(prisma.reimbursementItem.create.mock.calls[0][0].data).toMatchObject({
|
||||||
|
expense_type_id: "train-type", description: "铁路交通费用",
|
||||||
|
});
|
||||||
|
prisma.reimbursementItem.findFirst.mockResolvedValue({ id: state.invoice.linked_item_id });
|
||||||
|
const retry = await app.inject(explicitRequest());
|
||||||
|
expect(retry.statusCode, retry.body).toBe(200);
|
||||||
|
expect(prisma.reimbursementItem.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.invoicePool.update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.reimbursementForm.update).toHaveBeenCalledTimes(1);
|
||||||
|
state.reimbursement.status = "reviewing";
|
||||||
|
expect((await app.inject(explicitRequest())).statusCode).toBe(409);
|
||||||
|
} finally { await app.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ invoice_date: null }, 409],
|
||||||
|
[{ total_amount: null }, 409],
|
||||||
|
[{ total_amount: "0.00" }, 409],
|
||||||
|
[{ status: "pending_ocr" }, 409],
|
||||||
|
[{ owner_employee_id: "other-owner" }, 409],
|
||||||
|
[{ status: "bound", linked_reimbursement_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" }, 409],
|
||||||
|
])("rejects invalid or unavailable evidence without creating an item: %j", async (invoice, expectedStatus) => {
|
||||||
|
const { prisma } = buildPrisma(explicitInput({ invoice }));
|
||||||
|
const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"] });
|
||||||
|
try {
|
||||||
|
expect((await app.inject(explicitRequest())).statusCode).toBe(expectedStatus);
|
||||||
|
expect(prisma.reimbursementItem.create).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.invoicePool.update).not.toHaveBeenCalled();
|
||||||
|
} finally { await app.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects conflicting create and existing-item intents before querying", async () => {
|
||||||
|
const { prisma } = buildPrisma(explicitInput());
|
||||||
|
const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"] });
|
||||||
|
try {
|
||||||
|
expect((await app.inject(explicitRequest({ linkedItemId: explicitInvoiceId }))).statusCode).toBe(400);
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
} finally { await app.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a nonparticipant retry even when the invoice already points to this form", async () => {
|
||||||
|
const { prisma } = buildPrisma(explicitInput({
|
||||||
|
invoice: { status: "bound", linked_reimbursement_id: explicitFormId, linked_item_id: explicitInvoiceId },
|
||||||
|
reimbursement: { owner_id: "other", submitter_id: "other" },
|
||||||
|
}));
|
||||||
|
const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"] });
|
||||||
|
try {
|
||||||
|
expect((await app.inject(explicitRequest())).statusCode).toBe(403);
|
||||||
|
expect(prisma.reimbursementItem.create).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.invoicePool.update).not.toHaveBeenCalled();
|
||||||
|
} finally { await app.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
it("requires an explicit reimbursement item while the invoice guard is enabled", async () => {
|
it("requires an explicit reimbursement item while the invoice guard is enabled", async () => {
|
||||||
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||||
const { prisma, state } = buildPrisma({
|
const { prisma, state } = buildPrisma({
|
||||||
@@ -229,6 +334,22 @@ describe("invoicePool travel-order bind routes", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refuses to unbind when the form changed after the binding snapshot", async () => {
|
||||||
|
const { prisma } = buildPrisma(explicitInput({ invoice: {
|
||||||
|
status: "bound", linked_reimbursement_id: explicitFormId, linked_item_id: "item-1",
|
||||||
|
} }));
|
||||||
|
prisma.invoicePool.findUnique.mockResolvedValueOnce({ linked_reimbursement_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" });
|
||||||
|
const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"] });
|
||||||
|
try {
|
||||||
|
const response = await app.inject({ method: "POST", url: `/api/invoice-pool/${explicitInvoiceId}/unbind`, headers: { authorization: "Bearer test" }, payload: {} });
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(response.json().error.details.reasonCode).toBe("reimbursement.invoice_unbind_status_invalid");
|
||||||
|
expect(prisma.invoicePool.update).not.toHaveBeenCalled();
|
||||||
|
const firstLock = prisma.$queryRaw.mock.calls[0][0].join("?");
|
||||||
|
expect(firstLock).toContain('FROM "reimbursement_forms"');
|
||||||
|
} finally { await app.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a view-all nonparticipant before unbinding a reimbursement invoice", async () => {
|
it("rejects a view-all nonparticipant before unbinding a reimbursement invoice", async () => {
|
||||||
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||||
const { prisma, state } = buildPrisma({
|
const { prisma, state } = buildPrisma({
|
||||||
|
|||||||
@@ -337,6 +337,18 @@ async function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("GET /api/reimbursements/:id detail enhancements", () => {
|
describe("GET /api/reimbursements/:id detail enhancements", () => {
|
||||||
|
it("reads a saved unclassified draft without inventing an expense type", async () => {
|
||||||
|
const record = buildReimbursementRecord({ status: "draft", workflow_instances: [] });
|
||||||
|
record.items[0].expense_type_id = null;
|
||||||
|
record.items[0].expense_type = null;
|
||||||
|
const { app } = await buildApp({ reimbursement: record, latestWorkflowInstance: null });
|
||||||
|
try {
|
||||||
|
const response = await app.inject({ method: "GET", url: `/api/reimbursements/${REIMBURSEMENT_ID}`, headers: { authorization: "Bearer test" } });
|
||||||
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
|
expect(response.json().data.items[0]).toMatchObject({ expenseTypeId: null, expenseType: null, amount: "188.80" });
|
||||||
|
} finally { await app.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
it("allows an assigned workflow reviewer to read evidence without expense view permissions", async () => {
|
it("allows an assigned workflow reviewer to read evidence without expense view permissions", async () => {
|
||||||
const { app, prisma } = await buildApp({
|
const { app, prisma } = await buildApp({
|
||||||
employeeId: "99999999-9999-4999-8999-999999999999",
|
employeeId: "99999999-9999-4999-8999-999999999999",
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
const Fastify = require("fastify");
|
||||||
|
const reimbursementRoutes = require("../../../src/routes/reimbursements");
|
||||||
|
|
||||||
|
const FORM_ID = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const ACTOR_ID = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const ITEM_ID = "33333333-3333-4333-8333-333333333333";
|
||||||
|
const TYPE_ID = "44444444-4444-4444-8444-444444444444";
|
||||||
|
|
||||||
|
async function buildApp({ status = "draft", participant = true, expenseTypeId = null, missingItem = false, typeEnabled = true } = {}) {
|
||||||
|
const item = {
|
||||||
|
id: ITEM_ID,
|
||||||
|
reimbursement_id: FORM_ID,
|
||||||
|
expense_type_id: expenseTypeId,
|
||||||
|
expense_date: new Date("2026-09-09T00:00:00Z"),
|
||||||
|
amount: "100.00",
|
||||||
|
description: "合成测试支出",
|
||||||
|
};
|
||||||
|
const tx = {
|
||||||
|
$queryRaw: vi.fn(async () => [{
|
||||||
|
id: FORM_ID, owner_id: participant ? ACTOR_ID : TYPE_ID, submitter_id: TYPE_ID, status,
|
||||||
|
}]),
|
||||||
|
expenseType: { findUnique: vi.fn(async () => ({ id: TYPE_ID, enabled: typeEnabled, category: "office" })) },
|
||||||
|
reimbursementItem: {
|
||||||
|
findFirst: vi.fn(async () => item),
|
||||||
|
findMany: vi.fn(async () => missingItem ? [] : [item]),
|
||||||
|
updateMany: vi.fn(async () => ({ count: 1 })),
|
||||||
|
create: vi.fn(async ({ data }) => ({ id: ITEM_ID, ...data })),
|
||||||
|
update: vi.fn(async ({ data }) => Object.assign(item, data)),
|
||||||
|
aggregate: vi.fn(async () => ({ _sum: { amount: "100.00" } })),
|
||||||
|
},
|
||||||
|
reimbursementForm: { update: vi.fn(async () => ({})) },
|
||||||
|
};
|
||||||
|
const app = Fastify();
|
||||||
|
app.decorate("config", {});
|
||||||
|
app.decorate("prisma", { $transaction: async (callback) => callback(tx) });
|
||||||
|
app.decorate("requirePermission", () => async (request) => { request.employee = { id: ACTOR_ID }; });
|
||||||
|
app.decorate("authenticate", async (request) => { request.employee = { id: ACTOR_ID }; });
|
||||||
|
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({
|
||||||
|
error: { code: error.code, details: error.details },
|
||||||
|
}));
|
||||||
|
await app.register(reimbursementRoutes, { prefix: "/api" });
|
||||||
|
return { app, tx };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("draft reimbursement classification", () => {
|
||||||
|
it("classifies only explicitly selected items without changing amount, date, description or approval basis", async () => {
|
||||||
|
const { app, tx } = await buildApp();
|
||||||
|
try {
|
||||||
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${FORM_ID}/items/classify`,
|
||||||
|
payload: { itemIds: [ITEM_ID, ITEM_ID], expenseTypeId: TYPE_ID } });
|
||||||
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
|
expect(tx.reimbursementItem.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { reimbursement_id: FORM_ID, id: { in: [ITEM_ID] } },
|
||||||
|
data: { expense_type_id: TYPE_ID, updated_by: ACTOR_ID },
|
||||||
|
});
|
||||||
|
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
||||||
|
} finally { await app.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ missingItem: true }, 409], [{ typeEnabled: false }, 409],
|
||||||
|
[{ participant: false }, 403], [{ status: "reviewing" }, 409],
|
||||||
|
])("rejects a whole classification batch without partial writes: %j", async (options, expectedStatus) => {
|
||||||
|
const { app, tx } = await buildApp(options);
|
||||||
|
try {
|
||||||
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${FORM_ID}/items/classify`,
|
||||||
|
payload: { itemIds: [ITEM_ID], expenseTypeId: TYPE_ID } });
|
||||||
|
expect(response.statusCode, response.body).toBe(expectedStatus);
|
||||||
|
expect(tx.reimbursementItem.updateMany).not.toHaveBeenCalled();
|
||||||
|
} finally { await app.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["draft", "rejected"])("saves an explicitly unclassified item in %s without guessing a type", async (status) => {
|
||||||
|
const { app, tx } = await buildApp({ status });
|
||||||
|
try {
|
||||||
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${FORM_ID}/items`, payload: {
|
||||||
|
expenseTypeId: null, expenseDate: "2026-09-09", amount: "100.00", description: "",
|
||||||
|
} });
|
||||||
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
|
expect(response.json().data.item.expenseTypeId).toBeNull();
|
||||||
|
expect(tx.expenseType.findUnique).not.toHaveBeenCalled();
|
||||||
|
expect(tx.reimbursementItem.create.mock.calls[0][0].data.expense_type_id).toBeNull();
|
||||||
|
} finally { await app.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[null, { description: "修正合成说明" }, null],
|
||||||
|
[TYPE_ID, { expenseTypeId: null }, null],
|
||||||
|
[null, { expenseTypeId: TYPE_ID }, TYPE_ID],
|
||||||
|
])("edits classification without nullish fallback or fabricated defaults", async (expenseTypeId, payload, expected) => {
|
||||||
|
const { app, tx } = await buildApp({ expenseTypeId });
|
||||||
|
try {
|
||||||
|
const response = await app.inject({ method: "PATCH", url: `/api/reimbursements/${FORM_ID}/items/${ITEM_ID}`, payload });
|
||||||
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
|
expect(response.json().data.item.expenseTypeId).toBe(expected);
|
||||||
|
if (expected === null) expect(tx.expenseType.findUnique).not.toHaveBeenCalled();
|
||||||
|
} finally { await app.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ participant: false }, 403],
|
||||||
|
[{ status: "reviewing" }, 409],
|
||||||
|
[{ status: "approved" }, 409],
|
||||||
|
[{ status: "paid" }, 409],
|
||||||
|
])("does not widen maintenance permissions or states: %j", async (options, statusCode) => {
|
||||||
|
const { app, tx } = await buildApp(options);
|
||||||
|
try {
|
||||||
|
const response = await app.inject({ method: "PATCH", url: `/api/reimbursements/${FORM_ID}/items/${ITEM_ID}`, payload: { expenseTypeId: null } });
|
||||||
|
expect(response.statusCode, response.body).toBe(statusCode);
|
||||||
|
expect(tx.reimbursementItem.update).not.toHaveBeenCalled();
|
||||||
|
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
||||||
|
} finally { await app.close(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -117,6 +117,7 @@ async function buildApp(options = {}) {
|
|||||||
{ id: "item-1", amount: reimbursement.total_amount },
|
{ id: "item-1", amount: reimbursement.total_amount },
|
||||||
]).map((item) => ({
|
]).map((item) => ({
|
||||||
asset_prior_approval_basis: null,
|
asset_prior_approval_basis: null,
|
||||||
|
description: "合成支出",
|
||||||
expense_type: { code: "office_supplies", category: "office", requires_invoice: true },
|
expense_type: { code: "office_supplies", category: "office", requires_invoice: true },
|
||||||
invoice_pool_items: [{ id: "invoice-1", total_amount: item.amount }],
|
invoice_pool_items: [{ id: "invoice-1", total_amount: item.amount }],
|
||||||
...item,
|
...item,
|
||||||
@@ -211,6 +212,11 @@ async function buildApp(options = {}) {
|
|||||||
status: reimbursement.status,
|
status: reimbursement.status,
|
||||||
form_number: reimbursement.form_number,
|
form_number: reimbursement.form_number,
|
||||||
total_amount: reimbursement.total_amount,
|
total_amount: reimbursement.total_amount,
|
||||||
|
items: (options.reimbursementItems ?? []).map((item) => ({
|
||||||
|
expense_type: { enabled: true },
|
||||||
|
description: "合成支出",
|
||||||
|
...item,
|
||||||
|
})),
|
||||||
owner: {
|
owner: {
|
||||||
id: EMPLOYEE_ID,
|
id: EMPLOYEE_ID,
|
||||||
name: "申请人",
|
name: "申请人",
|
||||||
@@ -322,6 +328,50 @@ describe("reimbursements routes expense standard integration", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([null, { code: "office_supplies", enabled: false }])("blocks unclassified or disabled types before financial and approval side effects: %j", async (expenseType) => {
|
||||||
|
const { app, tx } = await buildApp({
|
||||||
|
reimbursementItems: [
|
||||||
|
{ id: "unclassified-item", amount: "20.00", expense_type: expenseType },
|
||||||
|
{ id: "classified-item", amount: "30.00" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||||
|
expect(response.statusCode, response.body).toBe(400);
|
||||||
|
expect(response.json().error.details).toEqual({
|
||||||
|
itemIds: ["unclassified-item"],
|
||||||
|
reasonCode: "reimbursement.items_expense_type_required",
|
||||||
|
});
|
||||||
|
expect(resolveEntityPrecheckMock).not.toHaveBeenCalled();
|
||||||
|
expect(tx.approvalRule.findFirst).not.toHaveBeenCalled();
|
||||||
|
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
||||||
|
expect(tx.budgetLedgerEntry.create).not.toHaveBeenCalled();
|
||||||
|
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks an unclassified precheck before financial evaluation", async () => {
|
||||||
|
const { app } = await buildApp({ reimbursementItems: [{ id: "unclassified-item", expense_type: null }] });
|
||||||
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/precheck` });
|
||||||
|
expect(response.statusCode, response.body).toBe(400);
|
||||||
|
expect(response.json().error.details).toEqual({
|
||||||
|
itemIds: ["unclassified-item"], reasonCode: "reimbursement.items_expense_type_required",
|
||||||
|
});
|
||||||
|
expect(resolveEntityPrecheckMock).not.toHaveBeenCalled();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["submit", "precheck"])("requires a real description for an auto-created item before %s", async (action) => {
|
||||||
|
const { app, tx } = await buildApp({ reimbursementItems: [
|
||||||
|
{ id: "no-description", amount: "100.00", description: " " },
|
||||||
|
] });
|
||||||
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/${action}` });
|
||||||
|
expect(response.statusCode, response.body).toBe(400);
|
||||||
|
expect(response.json().error.details).toEqual({ itemIds: ["no-description"], reasonCode: "reimbursement.items_description_required" });
|
||||||
|
expect(resolveEntityPrecheckMock).not.toHaveBeenCalled();
|
||||||
|
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects submit and identifies items with non-positive amounts", async () => {
|
it("rejects submit and identifies items with non-positive amounts", async () => {
|
||||||
const { app, tx } = await buildApp({
|
const { app, tx } = await buildApp({
|
||||||
reimbursementItems: [
|
reimbursementItems: [
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const { suggestInvoiceExpenseType } = require("../../../src/services/invoiceExpenseClassification");
|
||||||
|
|
||||||
|
const enabledTypes = [
|
||||||
|
{ id: "train-type", code: "travel_train", enabled: true },
|
||||||
|
{ id: "hotel-type", code: "travel_hotel", enabled: true },
|
||||||
|
{ id: "meal-type", code: "business_client_meal", enabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
function invoiceWithCategory(documentCategory, extra = {}) {
|
||||||
|
return { ocr_result: { result_json: { documentCategory } }, ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("invoice expense classification", () => {
|
||||||
|
it("suggests the enabled train type from a recognized electronic train ticket", () => {
|
||||||
|
expect(suggestInvoiceExpenseType(invoiceWithCategory("electronic_train_ticket"), enabledTypes))
|
||||||
|
.toEqual({ expenseTypeId: "train-type", source: "document_category" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["vat_invoice", "full_vat_invoice", "unknown", null])(
|
||||||
|
"does not guess an expense type from category %s, seller, filename or amount",
|
||||||
|
(category) => {
|
||||||
|
expect(suggestInvoiceExpenseType(invoiceWithCategory(category, {
|
||||||
|
seller_name: "合成酒店餐饮公司",
|
||||||
|
original_name: "办公用品.pdf",
|
||||||
|
total_amount: "100.00",
|
||||||
|
}), enabledTypes)).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("does not substitute another enabled type when the matching type is disabled", () => {
|
||||||
|
const expenseTypes = enabledTypes.map((type) => ({ ...type, enabled: type.code !== "travel_train" }));
|
||||||
|
expect(suggestInvoiceExpenseType(invoiceWithCategory("electronic_train_ticket"), expenseTypes)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not substitute the first enabled type when the matching type is absent", () => {
|
||||||
|
expect(suggestInvoiceExpenseType(invoiceWithCategory("electronic_train_ticket"), enabledTypes.slice(1)))
|
||||||
|
.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not infer from the historical provider or generic upload type", () => {
|
||||||
|
expect(suggestInvoiceExpenseType({ ocr_provider: "tencent", ocr_type: "vat" }, enabledTypes)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not infer from client-facing category fields instead of persisted OCR metadata", () => {
|
||||||
|
expect(suggestInvoiceExpenseType({ documentCategory: "electronic_train_ticket" }, enabledTypes)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate invoice data or expense types", () => {
|
||||||
|
const invoice = invoiceWithCategory("electronic_train_ticket");
|
||||||
|
const before = JSON.stringify({ invoice, enabledTypes });
|
||||||
|
suggestInvoiceExpenseType(invoice, enabledTypes);
|
||||||
|
expect(JSON.stringify({ invoice, enabledTypes })).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户