diff --git a/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md b/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md index a9dd3c1d..700db36a 100644 --- a/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md +++ b/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md @@ -969,6 +969,144 @@ reimbursement.total_amount = await recomputeTotalAmount(tx, reimbursementId, act await selectApprovalRule(reimbursement.total_amount); ``` +## Scenario: Expense-Type Invoice Policy And Item-Level Guard + +### 1. Scope / Trigger + +- Trigger: changing expense-type invoice policy, asset reimbursement evidence, + invoice-to-item binding, or reimbursement submission gates. +- B1 schema/master-data fields are always active after migration. B2 explicit + binding and amount blocks are separately guarded and deploy hard-off. + +### 2. Signatures + +- DB: `ExpenseType.requires_invoice Boolean @default(true)` and nullable + `ReimbursementItem.asset_prior_approval_basis String?`. +- Runtime env: `REIMBURSEMENT_INVOICE_GUARD_ENABLED=false`; feature key: + `reimbursement_invoice_guard_v1`; bootstrap DB override: `force_off`. +- Expense-type API uses camel-case `requiresInvoice` on list/create/update. +- Item create/update accepts nullable `assetPriorApprovalBasis`; detail returns + that field plus `expenseType.requiresInvoice`. +- Reimbursement detail returns `invoiceGuardEnabled` for the form department. +- `POST /api/invoice-pool/:id/bind` requires `linkedItemId` while the guard is + enabled; `POST /api/reimbursements/:id/submit` enforces the final gate. + +### 3. Contracts + +- Only `travel_meal` and `travel_local_transport_allowance` are invoice-free in + the initial policy. Invoice-free means skip invoice presence/amount checks; + it does not remove explanation, formula, approval or attachment obligations. +- Every `assets` item requires a trimmed approval basis on create, update and + submit. Switching an item to a non-assets type clears the stored basis. +- `office_supplies` at or above CNY 2,000 per item shows the same non-blocking + office-electronics hint on editable PC/mobile forms and read-only finance + detail. It does not change the type or decide accounting fixed-asset status. +- Seed may update controlled names, descriptions, enabled state and invoice + policy, but its update branch must preserve finance-maintained + `account_code_default`; new types start with null until finance supplies a + real mapping. +- Evaluate the B2 flag with the reimbursement's `department_id`, never the + actor's department. Env hard-off wins over DB state; the key must exist in + config, the feature-key allowlist and the DB seed. +- With B2 on, binding requires an existing item in the same reimbursement and + may not reach the legacy auto-create branch. If an upload waits for OCR, keep + the user-selected item ID with the pending invoice and send it when OCR later + makes the invoice bindable. Submit blocks any bound invoice with no item + link, each required-invoice item with no amount-bearing bound invoice, and + each declared amount greater than its linked invoice total. +- Sum and compare money with `Prisma.Decimal`. Invoice-free items are excluded. + A lower declared amount remains allowed for finance to reconcile. +- With B2 off, retain legacy auto-match/create compatibility. Keep the legacy + whole-form invoice gate only when at least one item requires an invoice; + allowance-only forms remain invoice-free because that is an always-active B1 + policy. Turning the flag off never deletes or unbinds relationships already + written while it was on. +- Form maintenance and invoice bind/unbind remain owner-or-submitter writes; + `expense.view_all` and admin roles remain read-only for another employee's + reimbursement. + +### 4. Validation & Error Matrix + +- Assets item without basis -> `400 VALID_001` with + `assetPriorApprovalBasis=required_for_assets`; submit returns affected item + IDs under `asset_prior_approval_basis_required`. +- Guard-on bind without `linkedItemId` -> `400 VALID_001` with + `required_when_invoice_guard_enabled`; an item outside the form remains + `item_not_in_reimbursement` with zero writes. +- Guard-on submit with unlinked bound invoices -> `invoice_item_guard_failed` + plus `unlinkedInvoiceCount`. +- Required item without an amount-bearing bound invoice -> same error plus + `missingInvoiceItemIds`. +- Declared amount above linked total -> same error plus entries containing only + `itemId`, canonical `declaredAmount` and `invoiceTotal`. +- Invoice-free allowance-only form with no invoice -> may submit with the guard + either on or off when all other existing checks pass. +- Env hard-off or DB force-off -> B2 item-level blocks are skipped; the legacy + whole-form gate remains only for forms containing a required-invoice item. + +### 5. Good/Base/Bad Cases + +- Good: one required item explicitly links several same-purpose invoices whose + total covers the declaration; submit passes with the guard on. +- Good: a positive meal allowance-only form has no invoice and passes with the + guard either on or off. +- Base: guard off preserves an older draft and its auto-created link; later + disabling the guard preserves links created during a guarded rollout. +- Bad: compare every positive item to a zero invoice total, silently create an + item while explicit selection is required, use JavaScript floating-point + sums for money, or let repeated init erase finance account mappings. + +### 6. Tests Required + +- Migration contract asserts additive columns, default true before the two + allowance overrides, and no destructive DDL; production/local application + still follows the explicit migration approval gate. +- Expense-type route tests assert list mapping, create/update persistence and + audit changes for `requiresInvoice`; seed tests assert the two allowances, + disabled legacy types, default-off flag and account-code preservation. +- Item/submit route tests cover assets create/update/submit, invoice-free zero + invoice, missing link, unlinked bound invoice, declared-over-invoice and + participant/nonparticipant side effects. +- Bind tests cover flag on/off, missing/foreign/successful explicit links and + prove the guard-on path does not create a reimbursement item. +- PC/mobile Playwright covers descriptions, approval-basis input, the CNY 2,000 + warning, allowance-only submit projection, disabled add action until every + selected invoice has an item, and pending-OCR binding retaining that item. +- Run full backend Vitest with the pinned BIFF8 Python dependencies, Prisma + validate/generate check, frontend typecheck/build, focused Playwright, + process/docs/Trellis governance and `git diff --check`. + +### 7. Wrong vs Correct + +#### Wrong + +```js +if (item.amount > linkedInvoices.reduce((sum, invoice) => sum + Number(invoice.total_amount), 0)) { + block(); // also blocks invoice-free allowances and risks floating-point drift +} +``` + +```js +update: { account_code_default: null }; // erases finance configuration on init +``` + +#### Correct + +```js +if (item.expense_type.requires_invoice) { + const total = linkedInvoices.reduce( + (sum, invoice) => sum.plus(invoice.total_amount), + new Prisma.Decimal(0), + ); + if (new Prisma.Decimal(item.amount).gt(total)) blockItem(item.id, total); +} +``` + +```js +update: { name, description, requires_invoice }; // preserve account_code_default +create: { name, description, requires_invoice, account_code_default: null }; +``` + ## Scenario: Payment Batch Receipt Attention Count ### 1. Scope / Trigger diff --git a/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/check.jsonl b/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/design.md b/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/design.md new file mode 100644 index 00000000..b6356efd --- /dev/null +++ b/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/design.md @@ -0,0 +1,50 @@ +# 设计:报销费用口径与票据明细门禁 + +## 边界 + +Task B-1 合并交付费用主数据政策和提交门禁代码,但运行时分成两级:B1 数据模型/主数据/表单字段随部署生效,B2 强关联和金额阻断由默认关闭的双闸 feature flag 控制。B3 财务一页式另建任务,等待 B2 在真实或专用数据上验证后开始。 + +## 数据模型 + +- `ExpenseType.requires_invoice Boolean @default(true)`:用 migration 为既有类型回填 true,再定向把两项补助设为 false。 +- `ReimbursementItem.asset_prior_approval_basis String?`:仅 assets 明细要求,保存批准人、批准时间和简短依据;非 assets 明细应保持 null 或忽略空白值。 +- 不为 `account_code_default` 新增字段;财务提供映射前保持 null。重复执行 seed/init 的 update 分支不覆盖已有科目映射,避免后续部署抹掉财务回填。 +- 费用类型停用只改 `enabled`,不删除记录、不迁移历史明细外键。 + +## 费用类型迁移 + +| 当前类型 | 动作 | 新提交口径 | 历史口径 | +| --- | --- | --- | --- | +| `business_client_meal` 客户招待 | 原 code 改名 | 业务招待费 | 追溯显示新名称 | +| `business_training` 培训会议 | 停用旧类型并新建两个 code | 会议费、培训费 | 保持培训会议 | +| `assets_it_hardware` IT 硬件 | 停用旧类型并新建 code | 低值配件选办公用品;需登记的选择办公电子设备 | 保持 IT 硬件 | + +新 code 在实施前按现有命名规则固定并写入 migration/seed 测试,不复用旧 code 承载不同语义。 + +## 写入和提交数据流 + +1. 员工建立或编辑明细,服务端读取权威 ExpenseType。 +2. assets 类型必须校验 `asset_prior_approval_basis`;前端提示只是辅助,服务端是最终门禁。 +3. 办公用品明细金额达到 `2,000 元`时生成非阻断类型提示,员工端和财务审核视图消费同一服务端事实;该阈值不作为会计资产确认结论。 +4. 发票上传/绑定显式携带目标明细 ID。B2 flag 开启时禁止无法唯一匹配后的自动建明细。 +5. 提交事务重新读取明细、有效发票及关联关系,按明细计算票面合计。 +6. `requires_invoice=false` 跳过票面比较;true 类型执行缺票和超票面阻断。 +7. flag 关闭只选择旧提交/匹配路径,关联表中的既有数据不做逆向写入。 + +## Feature Flag + +- 新增独立 key,不复用 `budget_precheck_v1` 或 `project_linking_v1`。 +- 配置层新增 env hard gate,默认 false;服务白名单注册 key;seed/受控配置提供 DB `force_off` 初值。 +- 后续开启先使用专用账号或部门 allowlist(如现场组织结构允许),再全局开启。 +- flag 状态必须进入诊断/审计输出,但不向普通员工显示内部 key。 + +## 兼容和回滚 + +- schema 只做向前兼容的 nullable/default 字段新增,不在应用回滚时逆迁移。 +- 旧草稿保留;B2 开启后提交时补齐,不批量伪造关联或批准依据。 +- 应用异常时先关闭 B2 flag;这不会删除既有关系。B1 名称/字段回滚只通过新 migration 或后续主数据动作,不直接删列。 +- OCR 仍可给出候选匹配,但 B2 开启时最终绑定必须由用户确认,不能静默创建业务明细。 + +## 延后项 + +- PurchaseRequest 强关联、资产自动建卡、数量/单价、B3 一页式审批和财务科目表治理不混入本轮核心门禁。 diff --git a/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/implement.jsonl b/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/implement.md b/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/implement.md new file mode 100644 index 00000000..f9faf111 --- /dev/null +++ b/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/implement.md @@ -0,0 +1,54 @@ +# 实施清单:报销费用口径与票据明细门禁 + +## 1. 规划门禁 + +- [x] 固定 B1+B2 同包开发、分级启用;B2 默认关闭。 +- [x] 选择 assets “事前批准依据”字段方案,不在本轮关联 PurchaseRequest。 +- [x] 用户批准以 `2,000 元/条明细`作为办公用品/办公电子设备选类提示阈值,并明确不等同于会计固定资产标准。 +- [x] 财务负责人为会计科目映射回填责任角色;应在 B1 生产启用前提供,目标日期 `2026-09-05`。届时仍无正式科目表则保持 null,不得由开发猜测。 +- [x] PRD、design、implement 经用户审查并明确批准实施。 + +## 2. Schema 与主数据 + +- [x] 新增 `ExpenseType.requires_invoice` 和 `ReimbursementItem.asset_prior_approval_basis` migration,验证既有数据默认值。 +- [x] 逐条实现改名、停用和新建类型;同步 seed、API DTO、管理页和历史兼容测试。 +- [x] 为费用类型补员工可读 description;将 `2,000 元/条明细`阈值和多件同类分开记载要求写入办公用品/办公电子设备提示。 + +## 3. B1 表单与服务端合同 + +- [x] PC/移动端展示材料要求和 assets 事前批准依据字段。 +- [x] 服务端按权威费用类型校验 assets 字段,覆盖新增、编辑、类型切换后的清理和提交复核。 +- [x] 基于服务端权威费用类型与金额生成 `2,000 元`办公用品非阻断提示,PC 员工端、移动端和财务审核视图保持一致。 +- [x] 两项补助保持无票但需要说明/算式的现有业务合同;flag 开关两种状态均覆盖纯补助无票基线。 + +## 4. B2 双闸和关联 + +- [x] 新增 env config、feature key、DB 初始记录和诊断/审计测试,默认 hard-off/force-off。 +- [x] flag 开启时要求发票显式选择明细并关闭自动新建;关闭时保持兼容路径。 +- [x] 提交事务按明细聚合有效票面金额,仅对 `requires_invoice=true` 执行缺票/超额门禁。 +- [x] 错误返回可定位到明细并使用员工可理解文案;财务保留差异和风险核验信息。 + +## 5. 验证与上线 + +- [x] 执行 PRD Test Plan 和 Trellis quality gate,做权限/状态/历史草稿/flag 回退专项复核。 +- [ ] B1 部署前明确与 `BX-20260828-0017` 的业务处置顺序;建议先完成该单退回、正式 PDF 重传和重提,避免停用 `assets_it_hardware` 与新增事前批准依据门禁叠加。 +- [ ] B1 部署前只读核对生产 draft/rejected 报销中是否仍有其他 assets 明细,并据实制定逐单处置,不批量伪造事前批准依据。 +- [ ] 首次部署只启用 B1;B2 保持关闭,完成 owner 基线和无票补助验证。 +- [ ] B2 开启必须单独获得生产配置批准;先专用账号/部门灰度,再按证据决定全局。 +- [ ] 关闭 B2 后复核旧提交路径恢复且关联数据未被删除或解绑。 + +## 6. 交接 B3 + +- [ ] 汇总真实关联、差额和错误分布的脱敏结论,作为 B3 一页式信息结构输入。 +- [ ] 明确交接财务行内费用标准提示、票据—明细对应关系和差额解释,不在 B1 中提前重做审批页面。 + +## 7. 本地实施与验证记录(2026-08-29) + +- Schema、B1、B2 代码均已实现;B2 env hard gate 和 DB 初值仍为关闭,未执行任何生产配置写入。 +- `prisma validate`、`prisma generate`、generated client 检查通过;只读 `prisma migrate status` 正确识别待应用 migration `20260829120000_reimbursement_expense_policy_invoice_guard`。本地回归库未获 migration 写入批准,因此未执行 `migrate deploy`;existing-data/default/backfill 由静态 migration 合同测试覆盖。 +- 后端聚焦回归 `59/59` 通过;使用固定 BIFF8 Python 依赖的全量 Vitest 为 `183/183 files`、`1329/1329 tests` 通过。系统 Python 直接运行会产生既有的 3 个付款指令环境型失败,不属于本次 diff。 +- 前端 `typecheck`、production build 通过;r102 普通报销回归 `6/6`,r111 B1/B2/PC/移动/企业微信回归 `3/3` 通过。 +- Trellis task validate、governance、process contract、docs registry audit 和 `git diff --check` 通过;docs audit 保留既有的 `22` 个未注册文件 warning,exit 0。 +- 权限、migration、feature flag、历史兼容和 seed 科目保护已完成三轮只读专项复核,最终 PASS。复核发现并推动修复:pending OCR 关联丢失、flag-off 纯无票补助服务端门禁、PC 无票补助主操作投影。 +- 未部署 B1、未应用生产 migration、未开启 B2、未做真实数据写入;本 task 保持 `in_progress`,上线与 B3 交接项不得提前勾选。 +- 新增部署排期门:`BX-20260828-0017` 当前使用即将停用的 `assets_it_hardware`。部署前需明确先处置该单还是先上 B1;当前建议先让该单按原口径完成退回、正式 PDF 重传和重提,并只读扫描其他存量 draft/rejected assets 明细。 diff --git a/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/prd.md b/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/prd.md new file mode 100644 index 00000000..e3122f10 --- /dev/null +++ b/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/prd.md @@ -0,0 +1,99 @@ +# 报销费用口径与票据明细门禁 + +## Goal + +把现有自创或含糊的费用类型收敛为财务可理解的企业费用口径,明确发票要求和 assets 个人报销例外条件;同时建立可核验的票据—明细关系与申报金额门禁,避免多日期、多支出、多张发票被合并为无法复核的一条笼统明细,并通过默认关闭的双闸门保护现有唯一真实报销链路。 + +## Background and Confirmed Facts + +- `ExpenseType` 已有 `enabled`、`description`、`account_code_default`,但没有 `requires_invoice`;本任务必须新增 migration。 +- 当前种子费用类型的 `account_code_default` 全部为空,不能由开发人员自行编造会计科目编码。 +- 现有 feature flag 是 env、`FEATURE_FLAG_KEYS` 白名单和 DB 记录三件套;env 关闭时 DB `force_on` 也不能生效。 +- 当前多明细发票匹配允许金额无法唯一匹配时自动创建明细;这与“员工明确选择明细、每张有效票据可核验”目标冲突。 +- 伙食补助费和市内交通补助费按天包干且不要求发票;数量/天数乘算能力不在本任务内。 +- assets 类当前只提示报销完成后人工登记资产,没有“事前批准”结构化证据。 + +## Scope + +- B1:费用类型 schema、历史兼容主数据、员工可读说明、`requires_invoice` 与 assets 事前批准依据。 +- B2:明确票据—明细关系、停止静默自动建明细、按需票明细执行金额门禁,以及默认关闭的分级启用机制。 +- PC、移动端、服务端、Prisma migration、种子/配置、测试与现有业务事实源同步纳入同一交付合同。 + +## Requirements + +### R1 费用类型与历史兼容 + +- `business_client_meal` 保持 code 不变,将名称从“客户招待”改为“业务招待费”;历史单据追溯显示新名称,视为同一概念的术语纠正。 +- 停用 `business_training`“培训会议”,新建“会议费”和“培训费”两个独立类型;历史单据继续显示原类型,新报销不能再选择已停用类型。 +- 停用 `assets_it_hardware`“IT 硬件”;低值配件使用既有“办公用品”,需登记资产的办公电子设备使用新类型“办公电子设备”。历史单据保持原类型名称。 +- 停用均复用 `enabled=false` 模式,不删除类型、不重写历史明细外键。 +- 逐项补充面向员工的 `description`,说明适用范围和必要材料;不得把内部 category/code 当作员工文案。 + +### R2 发票要求与会计科目 + +- `ExpenseType` 新增 `requires_invoice`,默认 `true`;“伙食补助费”和“市内交通补助费”明确为 `false`,其余首期启用类型为 `true`。 +- `requires_invoice=false` 只免除发票要求,不免除业务说明、补助算式、审批或其他必要附件。 +- `account_code_default` 不填猜测值。财务负责人应在 B1 生产启用前提供现行会计科目映射;若届时仍无法提供,本任务保留 null 并在同一 task 留下具名责任角色、缺口和回填日期,不以虚构编码阻塞材料门禁。 + +### R3 assets 个人报销例外 + +- assets 类允许个人报销,但属于经事前批准的例外,不作为普通日常采购入口。 +- 采用低成本方案:`ReimbursementItem` 增加单一结构化“事前批准依据”字段;选择 assets 类时必须填写批准人、批准时间和可核验的简短依据,服务端在保存和提交时校验,PC/移动端合同一致。 +- 采购申请关联不纳入本任务;未来如需替代自由文本,另行设计与 `PurchaseRequest` 的审计关联。 +- 员工按单条明细金额区分“办公用品”和“办公电子设备”:`2,000 元`及以上通常选择“办公电子设备”,低于 `2,000 元`通常选择“办公用品”。该阈值只用于内部选类和资产登记提示,不等同于会计固定资产确认标准。 +- 阈值同时写入费用类型 `description` 和表单提示;在数量/单价模型暂缓期间,一条明细应是一件或一批同日、同类、同一业务支出的物品,多件同类物品应分开记载,以便按本条明细金额判断。 +- 明细金额达到 `2,000 元`但选择“办公用品”时,员工端和财务审核视图均显示非阻断提示:“该金额通常应选择办公电子设备并填写事前批准依据”;提示不得自动改类型或阻断提交,财务仍按实际物品性质复核。 + +### R4 票据—明细强关联 + +- 每张有效发票必须显式关联到一条费用明细;员工在上传/绑定时明确选择目标明细。 +- B2 启用后关闭“无法唯一匹配时自动新建明细”;服务端不得在前端要求明确选择的同时继续静默建明细。 +- 一条明细只表达同一费用类型、同一发生日期和同一业务支出;采购物品还应是一件或一批同类物品。不同日期、不同支出或需要分别判断 `2,000 元`设备提示的多件物品必须拆分。多张同日、同类型且同一业务支出的发票可以关联同一明细。 +- 发票、明细及关联变更保留既有审计和权限边界;非参与人不得借新入口维护他人报销。 + +### R5 申报金额门禁 + +- 仅对 `requires_invoice=true` 的明细比较申报金额与其关联有效发票票面合计。 +- 无票补助不参与票面金额比较,不得因票面合计为 0 阻断正数补助。 +- 需票明细未关联有效发票,或申报金额高于关联票面合计时,B2 启用状态下阻断提交并返回逐条可操作提示。 +- 申报金额低于票面合计允许提交,但必须保留现有差异说明/财务核验信息;退款、折扣、他人承担等不得重复报销。 +- 历史草稿不批量改写;在 B2 开启后再次提交时按新门禁补齐关联、差异说明和 assets 批准依据。 + +### R6 分级启用与回退 + +- B1 schema、主数据、字段和展示随代码部署生效,但不得主动阻断不涉及 assets 的既有提交路径。 +- B2 的自动建明细关闭、强关联和金额提交门禁由新增 feature flag 控制,必须接入 env 配置、`FEATURE_FLAG_KEYS` 白名单和 DB 记录,初始保持 env hard-off / DB force-off。 +- 开启前必须满足:owner 正常提交基线;两项无票补助不被阻断;多票关联正确;申报金额超票面能准确阻断;历史草稿可补齐后提交;关闭 flag 能立即恢复旧提交门禁;合成或专用账号验证无越权副作用。 +- 关闭 flag 只回退提交门禁和自动匹配行为选择,不删除、不解绑、不回退已经建立的票据—明细关联数据。 +- B2 的生产开启属于独立配置写入,必须单独批准并完成角色化公网检查。 + +## Acceptance Criteria + +- [ ] migration 安全新增 `ExpenseType.requires_invoice` 和明细事前批准依据字段,既有数据有确定默认值且 Prisma validate/status 通过。 +- [x] 费用类型逐条按“改名”或“停用+新建”执行;历史单据无丢失,新建/编辑列表不显示停用类型。 +- [x] assets 明细在 PC、移动端和服务端均强制填写事前批准依据,提示明确 `2,000 元/条明细`是选类门槛而非会计固定资产标准。 +- [x] 办公用品明细金额达到 `2,000 元`时,员工端和财务审核视图均显示非阻断类型不一致提示,提交仍可继续且服务端不自动改类。 +- [x] 两项补助 `requires_invoice=false`,其他首期启用类型为 true;无票补助可提交且不会触发票面金额门禁。 +- [x] B2 flag 三件套完整、默认关闭;关闭状态保持当前提交兼容,开启状态关闭自动建明细并执行强关联/金额门禁。 +- [x] 每张有效发票关联一条明确明细,不同日期/支出必须拆分;同日同类型同一支出允许多票关联。 +- [x] 需票明细缺票或申报高于关联票面合计时逐条阻断,低于票面时保留差异核验信息。 +- [x] flag 关闭后既有关联数据保持不变,不进行数据回滚。 +- [x] owner、submitter、财务/管理员只读边界和 `precheck` 对象授权不回归。 +- [ ] 财务科目映射由财务负责人提供并在 task 内记录回填结果;没有正式科目表时保持 null,不虚构编码。 +- [x] 聚焦单测、API 集成、PC/移动 Playwright、Prisma validate、前后端类型/构建、Trellis governance 和 migration 静态/只读检查通过。 + +## Out of Scope + +- 数量、天数、单价自动乘算和差旅主线扩建。 +- B3 财务一页式审批工作台及其行内费用标准提示恢复。 +- 强制关联采购申请、自动建资产卡、会计凭证、总账或 ERP 对接。 +- 处理 `BX-20260828-0017` 真实单据或其他生产报销。 +- 在本任务部署授权中推定 B2 flag 开启授权。 + +## Test Plan + +- Prisma:migration review、`npm run prisma:validate`、existing-data migration fixture、pre/post migration status。 +- 后端:费用类型映射、assets 字段校验、bind/submit 强关联、金额边界、无票补助、flag on/off、历史草稿和权限负例。 +- 前端:PC/移动费用类型提示、事前批准依据、明确选择明细、逐条错误展示和非参与人只读回归。 +- 回归:报销创建/编辑/提交、invoice pool bind/unbind、OCR 自动匹配、差旅补助、材料预览、付款准备相关聚焦套件。 +- 全局:server/client typecheck/build、Trellis governance、docs audit、`git diff --check`。 diff --git a/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/task.json b/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/task.json new file mode 100644 index 00000000..92be69ed --- /dev/null +++ b/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/task.json @@ -0,0 +1,38 @@ +{ + "id": "reimbursement-expense-policy-invoice-guard", + "name": "reimbursement-expense-policy-invoice-guard", + "title": "报销费用口径与票据明细门禁", + "description": "统一企业报销费用类型与材料政策,并以默认关闭的双闸门逐步启用票据—明细强关联和申报金额门禁。", + "status": "in_progress", + "dev_type": null, + "scope": "expense type policy/schema/master data; reimbursement item invoice relation; staged submit guard", + "package": "server,client,docs", + "priority": "P1", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-08-29", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "server/prisma/schema.prisma", + "server/src/scripts/seed-config.js", + "server/src/services/featureFlags/index.js", + "server/src/config.js", + "server/src/routes/reimbursements.js", + "server/src/routes/invoicePool.js", + "client/src/pages/ReimbursementDetailPage.tsx", + "client/src/pages/MobileReimbursementDetailPage.tsx", + "docs/产品口径.md", + "docs/报销发票附件规则.md", + "docs/需求-报销发票OCR规约.md" + ], + "notes": "本地实施和只读专项复核已完成;B2 env/DB 双闸仍默认关闭,未部署、未应用生产 migration、未写生产配置。2,000 元/条明细的选类提示阈值已批准。会计科目映射由财务负责人在 B1 生产启用前提供,目标回填日期 2026-09-05;未取得正式科目表时保持 null,不由开发人员猜测。B1 部署前必须决定与 BX-20260828-0017 的处置顺序,并只读核对其他存量 draft/rejected assets 明细;当前建议先完成 0017 退回、正式 PDF 重传和重提。B3 财务一页式及行内费用标准提示恢复仍为后续任务。", + "meta": {} +} diff --git a/client/src/api/expenseTypes.ts b/client/src/api/expenseTypes.ts index 88a4f9aa..9414468a 100644 --- a/client/src/api/expenseTypes.ts +++ b/client/src/api/expenseTypes.ts @@ -8,6 +8,7 @@ export type ExpenseType = { category: string; sortOrder: number; accountCodeDefault: string | null; + requiresInvoice: boolean; isSystemDefault: boolean; enabled: boolean; createdAt: string | null; @@ -28,4 +29,3 @@ export async function fetchExpenseTypes(query: { enabled?: boolean } = {}) { const qs = params.toString(); return apiRequestData(`/api/expense-types${qs ? `?${qs}` : ""}`); } - diff --git a/client/src/api/reimbursements.ts b/client/src/api/reimbursements.ts index c47bdaed..8beba107 100644 --- a/client/src/api/reimbursements.ts +++ b/client/src/api/reimbursements.ts @@ -17,12 +17,14 @@ export type ReimbursementItem = { expenseDate: string | null; amount: string; description: string | null; + assetPriorApprovalBasis?: string | null; expenseType: { id: string; code: string; name: string; category: string; enabled: boolean; + requiresInvoice?: boolean; } | null; }; @@ -222,6 +224,7 @@ export type ReimbursementDetail = { processingProgress: ReimbursementProcessingProgress; approvalHistory: ReimbursementApprovalHistoryItem[]; riskFlags: RiskFlag[]; + invoiceGuardEnabled?: boolean; items: ReimbursementItem[]; }; @@ -345,6 +348,7 @@ export type ReimbursementItemPayload = { expenseDate: string; amount: string; description?: string; + assetPriorApprovalBasis?: string | null; }; export type ReimbursementItemMutationResponse = { @@ -354,6 +358,7 @@ export type ReimbursementItemMutationResponse = { expenseDate: string; amount: string; description: string | null; + assetPriorApprovalBasis?: string | null; }; totalAmount: string; }; diff --git a/client/src/pages/MobileReimbursementDetailPage.tsx b/client/src/pages/MobileReimbursementDetailPage.tsx index 08db0690..1e473b22 100644 --- a/client/src/pages/MobileReimbursementDetailPage.tsx +++ b/client/src/pages/MobileReimbursementDetailPage.tsx @@ -80,6 +80,7 @@ import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker"; import { cn } from "../lib/utils"; import { isWecomWebView } from "../lib/runtimeContext"; import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview"; +import { shouldShowOfficeElectronicsHint } from "./reimbursement-detail/ReimbursementItemEditor"; type ItemFormState = { mode: "create" | "edit"; @@ -88,6 +89,7 @@ type ItemFormState = { expenseDate: string; amount: string; description: string; + assetPriorApprovalBasis: string; }; type SelectedInvoiceMap = Record; @@ -177,11 +179,13 @@ export function MobileReimbursementDetailPage() { const [invoiceSearch, setInvoiceSearch] = useState(""); const [selectedInvoices, setSelectedInvoices] = useState({}); const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState([]); + const [invoiceUploadLinkedItemId, setInvoiceUploadLinkedItemId] = useState(""); const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState([]); const [invoiceActionError, setInvoiceActionError] = useState(""); const [materialPreviewError, setMaterialPreviewError] = useState(""); const [invoiceActionMessage, setInvoiceActionMessage] = useState(""); const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState([]); + const [pendingOcrLinkedItemIds, setPendingOcrLinkedItemIds] = useState>({}); const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 }); const detailQuery = useQuery({ @@ -327,6 +331,7 @@ export function MobileReimbursementDetailPage() { expenseDate: detail?.reimbursementDate ?? todayDate(), amount: "", description: "", + assetPriorApprovalBasis: "", }); setItemError(""); }; @@ -339,6 +344,7 @@ export function MobileReimbursementDetailPage() { expenseDate: item.expenseDate ?? detail?.reimbursementDate ?? todayDate(), amount: item.amount, description: item.description ?? "", + assetPriorApprovalBasis: item.assetPriorApprovalBasis ?? "", }); setItemError(""); }; @@ -354,6 +360,7 @@ export function MobileReimbursementDetailPage() { expenseDate: form.expenseDate, amount: form.amount, description: form.description.trim() || undefined, + assetPriorApprovalBasis: form.assetPriorApprovalBasis.trim() || null, }; if (form.mode === "edit" && form.itemId) { @@ -458,6 +465,9 @@ export function MobileReimbursementDetailPage() { setSelectedInvoices((previous) => Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))), ); + setPendingOcrLinkedItemIds((previous) => + Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))), + ); if (!failed.length) { setInvoiceActionError(""); @@ -487,9 +497,14 @@ export function MobileReimbursementDetailPage() { if (!invoiceIds.length) { return; } - bindInvoicesMutation.mutate(invoiceIds.map((invoiceId) => ({ invoiceId }))); + bindInvoicesMutation.mutate( + invoiceIds.map((invoiceId) => ({ + invoiceId, + linkedItemId: pendingOcrLinkedItemIds[invoiceId] || undefined, + })), + ); }, - [bindInvoicesMutation], + [bindInvoicesMutation, pendingOcrLinkedItemIds], ); const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null }>) => { @@ -498,6 +513,10 @@ export function MobileReimbursementDetailPage() { .join(";"); setInvoiceActionError(message); setInvoiceActionMessage(""); + const unavailableIds = new Set(items.map((item) => item.id)); + setPendingOcrLinkedItemIds((previous) => + Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !unavailableIds.has(invoiceId))), + ); toast.error("部分发票需要处理"); }, []); @@ -577,7 +596,10 @@ export function MobileReimbursementDetailPage() { }); } else if (uploadResult.status === "available") { try { - await bindInvoice(uploadResult.invoicePoolId, { reimbursementId }); + await bindInvoice(uploadResult.invoicePoolId, { + reimbursementId, + ...(detail.invoiceGuardEnabled ? { linkedItemId: invoiceUploadLinkedItemId } : {}), + }); boundCount += 1; } catch (error) { failures.push({ file, message: `上传成功,添加失败:${resolveErrorMessage(error)}` }); @@ -603,6 +625,12 @@ export function MobileReimbursementDetailPage() { await bindableInvoicesQuery.refetch(); setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...result.pendingIds]))); + if (detail?.invoiceGuardEnabled && invoiceUploadLinkedItemId) { + setPendingOcrLinkedItemIds((previous) => ({ + ...previous, + ...Object.fromEntries(result.pendingIds.map((invoiceId) => [invoiceId, invoiceUploadLinkedItemId])), + })); + } setSelectedInvoiceFiles(result.failures.map((item) => item.file)); if (!result.failures.length) { if (fileInputRef.current) { @@ -771,7 +799,8 @@ export function MobileReimbursementDetailPage() { toast.error(message); return; } - if (!detail.invoices.length) { + const requiresAnyInvoice = detail.items.some((item) => item.expenseType?.requiresInvoice !== false); + if (requiresAnyInvoice && !detail.invoices.length) { const message = "请先添加至少 1 张发票"; setActionError(message); toast.error(message); @@ -809,6 +838,7 @@ export function MobileReimbursementDetailPage() { setInvoiceSearch(""); setSelectedInvoices({}); setSelectedInvoiceFiles([]); + setInvoiceUploadLinkedItemId(""); setInvoiceActionError(""); setInvoiceActionMessage(""); setConfirmDeleteInvoiceId(""); @@ -1029,6 +1059,11 @@ export function MobileReimbursementDetailPage() { ))} + {expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.description ? ( + + {expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.description} + + ) : null} + {expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.category === "assets" ? ( +