实现报销费用政策与票据明细门禁(08-29-reimbursement-expense-policy-invoice-guard)
这个提交包含在:
@@ -969,6 +969,144 @@ reimbursement.total_amount = await recomputeTotalAmount(tx, reimbursementId, act
|
|||||||
await selectApprovalRule(reimbursement.total_amount);
|
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
|
## Scenario: Payment Batch Receipt Attention Count
|
||||||
|
|
||||||
### 1. Scope / Trigger
|
### 1. Scope / Trigger
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
|
||||||
@@ -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 一页式审批和财务科目表治理不混入本轮核心门禁。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
|
||||||
@@ -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 明细。
|
||||||
@@ -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`。
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export type ExpenseType = {
|
|||||||
category: string;
|
category: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
accountCodeDefault: string | null;
|
accountCodeDefault: string | null;
|
||||||
|
requiresInvoice: boolean;
|
||||||
isSystemDefault: boolean;
|
isSystemDefault: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
@@ -28,4 +29,3 @@ export async function fetchExpenseTypes(query: { enabled?: boolean } = {}) {
|
|||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return apiRequestData<ExpenseTypeListResponse>(`/api/expense-types${qs ? `?${qs}` : ""}`);
|
return apiRequestData<ExpenseTypeListResponse>(`/api/expense-types${qs ? `?${qs}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ export type ReimbursementItem = {
|
|||||||
expenseDate: string | null;
|
expenseDate: string | null;
|
||||||
amount: string;
|
amount: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
assetPriorApprovalBasis?: string | null;
|
||||||
expenseType: {
|
expenseType: {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
category: string;
|
category: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
requiresInvoice?: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -222,6 +224,7 @@ export type ReimbursementDetail = {
|
|||||||
processingProgress: ReimbursementProcessingProgress;
|
processingProgress: ReimbursementProcessingProgress;
|
||||||
approvalHistory: ReimbursementApprovalHistoryItem[];
|
approvalHistory: ReimbursementApprovalHistoryItem[];
|
||||||
riskFlags: RiskFlag[];
|
riskFlags: RiskFlag[];
|
||||||
|
invoiceGuardEnabled?: boolean;
|
||||||
items: ReimbursementItem[];
|
items: ReimbursementItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -345,6 +348,7 @@ export type ReimbursementItemPayload = {
|
|||||||
expenseDate: string;
|
expenseDate: string;
|
||||||
amount: string;
|
amount: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
assetPriorApprovalBasis?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReimbursementItemMutationResponse = {
|
export type ReimbursementItemMutationResponse = {
|
||||||
@@ -354,6 +358,7 @@ export type ReimbursementItemMutationResponse = {
|
|||||||
expenseDate: string;
|
expenseDate: string;
|
||||||
amount: string;
|
amount: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
assetPriorApprovalBasis?: string | null;
|
||||||
};
|
};
|
||||||
totalAmount: string;
|
totalAmount: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
|||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { isWecomWebView } from "../lib/runtimeContext";
|
import { isWecomWebView } from "../lib/runtimeContext";
|
||||||
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||||
|
import { shouldShowOfficeElectronicsHint } from "./reimbursement-detail/ReimbursementItemEditor";
|
||||||
|
|
||||||
type ItemFormState = {
|
type ItemFormState = {
|
||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
@@ -88,6 +89,7 @@ type ItemFormState = {
|
|||||||
expenseDate: string;
|
expenseDate: string;
|
||||||
amount: string;
|
amount: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
assetPriorApprovalBasis: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SelectedInvoiceMap = Record<string, string>;
|
type SelectedInvoiceMap = Record<string, string>;
|
||||||
@@ -177,11 +179,13 @@ export function MobileReimbursementDetailPage() {
|
|||||||
const [invoiceSearch, setInvoiceSearch] = useState("");
|
const [invoiceSearch, setInvoiceSearch] = useState("");
|
||||||
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 [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
|
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
|
||||||
const [invoiceActionError, setInvoiceActionError] = useState("");
|
const [invoiceActionError, setInvoiceActionError] = useState("");
|
||||||
const [materialPreviewError, setMaterialPreviewError] = useState("");
|
const [materialPreviewError, setMaterialPreviewError] = useState("");
|
||||||
const [invoiceActionMessage, setInvoiceActionMessage] = useState("");
|
const [invoiceActionMessage, setInvoiceActionMessage] = useState("");
|
||||||
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
|
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
|
||||||
|
const [pendingOcrLinkedItemIds, setPendingOcrLinkedItemIds] = useState<Record<string, string>>({});
|
||||||
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
|
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
|
||||||
|
|
||||||
const detailQuery = useQuery({
|
const detailQuery = useQuery({
|
||||||
@@ -327,6 +331,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
expenseDate: detail?.reimbursementDate ?? todayDate(),
|
expenseDate: detail?.reimbursementDate ?? todayDate(),
|
||||||
amount: "",
|
amount: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
assetPriorApprovalBasis: "",
|
||||||
});
|
});
|
||||||
setItemError("");
|
setItemError("");
|
||||||
};
|
};
|
||||||
@@ -339,6 +344,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
expenseDate: item.expenseDate ?? detail?.reimbursementDate ?? todayDate(),
|
expenseDate: item.expenseDate ?? detail?.reimbursementDate ?? todayDate(),
|
||||||
amount: item.amount,
|
amount: item.amount,
|
||||||
description: item.description ?? "",
|
description: item.description ?? "",
|
||||||
|
assetPriorApprovalBasis: item.assetPriorApprovalBasis ?? "",
|
||||||
});
|
});
|
||||||
setItemError("");
|
setItemError("");
|
||||||
};
|
};
|
||||||
@@ -354,6 +360,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
expenseDate: form.expenseDate,
|
expenseDate: form.expenseDate,
|
||||||
amount: form.amount,
|
amount: form.amount,
|
||||||
description: form.description.trim() || undefined,
|
description: form.description.trim() || undefined,
|
||||||
|
assetPriorApprovalBasis: form.assetPriorApprovalBasis.trim() || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (form.mode === "edit" && form.itemId) {
|
if (form.mode === "edit" && form.itemId) {
|
||||||
@@ -458,6 +465,9 @@ export function MobileReimbursementDetailPage() {
|
|||||||
setSelectedInvoices((previous) =>
|
setSelectedInvoices((previous) =>
|
||||||
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
|
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) {
|
if (!failed.length) {
|
||||||
setInvoiceActionError("");
|
setInvoiceActionError("");
|
||||||
@@ -487,9 +497,14 @@ export function MobileReimbursementDetailPage() {
|
|||||||
if (!invoiceIds.length) {
|
if (!invoiceIds.length) {
|
||||||
return;
|
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 }>) => {
|
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null }>) => {
|
||||||
@@ -498,6 +513,10 @@ export function MobileReimbursementDetailPage() {
|
|||||||
.join(";");
|
.join(";");
|
||||||
setInvoiceActionError(message);
|
setInvoiceActionError(message);
|
||||||
setInvoiceActionMessage("");
|
setInvoiceActionMessage("");
|
||||||
|
const unavailableIds = new Set(items.map((item) => item.id));
|
||||||
|
setPendingOcrLinkedItemIds((previous) =>
|
||||||
|
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !unavailableIds.has(invoiceId))),
|
||||||
|
);
|
||||||
toast.error("部分发票需要处理");
|
toast.error("部分发票需要处理");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -577,7 +596,10 @@ export function MobileReimbursementDetailPage() {
|
|||||||
});
|
});
|
||||||
} else if (uploadResult.status === "available") {
|
} else if (uploadResult.status === "available") {
|
||||||
try {
|
try {
|
||||||
await bindInvoice(uploadResult.invoicePoolId, { reimbursementId });
|
await bindInvoice(uploadResult.invoicePoolId, {
|
||||||
|
reimbursementId,
|
||||||
|
...(detail.invoiceGuardEnabled ? { linkedItemId: invoiceUploadLinkedItemId } : {}),
|
||||||
|
});
|
||||||
boundCount += 1;
|
boundCount += 1;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failures.push({ file, message: `上传成功,添加失败:${resolveErrorMessage(error)}` });
|
failures.push({ file, message: `上传成功,添加失败:${resolveErrorMessage(error)}` });
|
||||||
@@ -603,6 +625,12 @@ export function MobileReimbursementDetailPage() {
|
|||||||
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) {
|
||||||
|
setPendingOcrLinkedItemIds((previous) => ({
|
||||||
|
...previous,
|
||||||
|
...Object.fromEntries(result.pendingIds.map((invoiceId) => [invoiceId, invoiceUploadLinkedItemId])),
|
||||||
|
}));
|
||||||
|
}
|
||||||
setSelectedInvoiceFiles(result.failures.map((item) => item.file));
|
setSelectedInvoiceFiles(result.failures.map((item) => item.file));
|
||||||
if (!result.failures.length) {
|
if (!result.failures.length) {
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
@@ -771,7 +799,8 @@ export function MobileReimbursementDetailPage() {
|
|||||||
toast.error(message);
|
toast.error(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!detail.invoices.length) {
|
const requiresAnyInvoice = detail.items.some((item) => item.expenseType?.requiresInvoice !== false);
|
||||||
|
if (requiresAnyInvoice && !detail.invoices.length) {
|
||||||
const message = "请先添加至少 1 张发票";
|
const message = "请先添加至少 1 张发票";
|
||||||
setActionError(message);
|
setActionError(message);
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
@@ -809,6 +838,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
setInvoiceSearch("");
|
setInvoiceSearch("");
|
||||||
setSelectedInvoices({});
|
setSelectedInvoices({});
|
||||||
setSelectedInvoiceFiles([]);
|
setSelectedInvoiceFiles([]);
|
||||||
|
setInvoiceUploadLinkedItemId("");
|
||||||
setInvoiceActionError("");
|
setInvoiceActionError("");
|
||||||
setInvoiceActionMessage("");
|
setInvoiceActionMessage("");
|
||||||
setConfirmDeleteInvoiceId("");
|
setConfirmDeleteInvoiceId("");
|
||||||
@@ -1029,6 +1059,11 @@ export function MobileReimbursementDetailPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.description ? (
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
{expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.description}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</label>
|
</label>
|
||||||
<label className="block space-y-1 text-sm text-zinc-700">
|
<label className="block space-y-1 text-sm text-zinc-700">
|
||||||
<span>日期</span>
|
<span>日期</span>
|
||||||
@@ -1058,11 +1093,29 @@ export function MobileReimbursementDetailPage() {
|
|||||||
onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)}
|
onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.category === "assets" ? (
|
||||||
|
<label className="col-span-2 block space-y-1 text-sm text-zinc-700">
|
||||||
|
<span>事前批准依据</span>
|
||||||
|
<Textarea
|
||||||
|
disabled={itemSaveMutation.isPending}
|
||||||
|
placeholder="填写批准人、批准时间和简短依据"
|
||||||
|
rows={2}
|
||||||
|
value={itemForm.assetPriorApprovalBasis}
|
||||||
|
onChange={(event) => setItemForm((current) => current ? { ...current, assetPriorApprovalBasis: event.target.value } : current)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
{shouldShowOfficeElectronicsHint(
|
||||||
|
expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.code,
|
||||||
|
itemForm.amount,
|
||||||
|
) ? (
|
||||||
|
<p className="col-span-2 text-xs text-amber-600">该金额通常应选择办公电子设备并填写事前批准依据。</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mobile-two-action-grid">
|
<div className="mobile-two-action-grid">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending}
|
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || (expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.category === "assets" && !itemForm.assetPriorApprovalBasis.trim()) || itemSaveMutation.isPending}
|
||||||
onClick={() => itemSaveMutation.mutate(itemForm)}
|
onClick={() => itemSaveMutation.mutate(itemForm)}
|
||||||
>
|
>
|
||||||
{itemSaveMutation.isPending ? "保存中..." : "保存"}
|
{itemSaveMutation.isPending ? "保存中..." : "保存"}
|
||||||
@@ -1086,6 +1139,12 @@ export function MobileReimbursementDetailPage() {
|
|||||||
<div className="shrink-0 text-base font-semibold text-zinc-900">{formatMoney(item.amount)}</div>
|
<div className="shrink-0 text-base font-semibold text-zinc-900">{formatMoney(item.amount)}</div>
|
||||||
</div>
|
</div>
|
||||||
{item.description ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.description}</p> : null}
|
{item.description ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.description}</p> : null}
|
||||||
|
{shouldShowOfficeElectronicsHint(item.expenseType?.code, item.amount) ? (
|
||||||
|
<p className="mt-2 text-xs text-amber-600">该金额通常应选择办公电子设备并填写事前批准依据。</p>
|
||||||
|
) : null}
|
||||||
|
{item.assetPriorApprovalBasis ? (
|
||||||
|
<p className="mt-2 text-xs text-zinc-500">事前批准依据:{item.assetPriorApprovalBasis}</p>
|
||||||
|
) : null}
|
||||||
{canMaintainReimbursement ? (
|
{canMaintainReimbursement ? (
|
||||||
<div className="mobile-two-action-grid mt-3">
|
<div className="mobile-two-action-grid mt-3">
|
||||||
<Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}>
|
<Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}>
|
||||||
@@ -1443,11 +1502,27 @@ export function MobileReimbursementDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{detail.invoiceGuardEnabled ? (
|
||||||
|
<select
|
||||||
|
aria-label="新上传发票关联明细"
|
||||||
|
className="select-std w-full"
|
||||||
|
value={invoiceUploadLinkedItemId}
|
||||||
|
onChange={(event) => setInvoiceUploadLinkedItemId(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">请选择新上传发票对应的费用明细</option>
|
||||||
|
{detail.items.map((detailItem) => (
|
||||||
|
<option key={detailItem.id} value={detailItem.id}>
|
||||||
|
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
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 || uploadInvoiceMutation.isPending}
|
disabled={!selectedInvoiceFiles.length || (detail.invoiceGuardEnabled && !invoiceUploadLinkedItemId) || uploadInvoiceMutation.isPending}
|
||||||
onClick={() => uploadInvoiceMutation.mutate(selectedInvoiceFiles)}
|
onClick={() => uploadInvoiceMutation.mutate(selectedInvoiceFiles)}
|
||||||
>
|
>
|
||||||
{uploadInvoiceMutation.isPending
|
{uploadInvoiceMutation.isPending
|
||||||
@@ -1540,9 +1615,24 @@ 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 ? (
|
||||||
|
<select
|
||||||
|
aria-label="关联费用明细"
|
||||||
|
className="select-std mt-3 w-full"
|
||||||
|
value={selectedInvoices[invoice.id] ?? ""}
|
||||||
|
onChange={(event) => setSelectedInvoices((previous) => ({ ...previous, [invoice.id]: event.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">请选择费用明细</option>
|
||||||
|
{detail.items.map((detailItem) => (
|
||||||
|
<option key={detailItem.id} value={detailItem.id}>
|
||||||
|
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
className="mt-3 w-full"
|
className="mt-3 w-full"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1561,12 +1651,12 @@ export function MobileReimbursementDetailPage() {
|
|||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending}
|
disabled={!selectedInvoiceEntries.length || (detail.invoiceGuardEnabled && selectedInvoiceEntries.some(([, linkedItemId]) => !linkedItemId)) || bindInvoicesMutation.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
bindInvoicesMutation.mutate(
|
bindInvoicesMutation.mutate(
|
||||||
selectedInvoiceEntries.map(([invoiceId]) => ({
|
selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({
|
||||||
invoiceId,
|
invoiceId,
|
||||||
linkedItemId: undefined,
|
linkedItemId: linkedItemId || undefined,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFi
|
|||||||
import { ReimbursementActions } from "./reimbursement-detail/ReimbursementActions";
|
import { ReimbursementActions } from "./reimbursement-detail/ReimbursementActions";
|
||||||
import {
|
import {
|
||||||
ReimbursementItemEditor,
|
ReimbursementItemEditor,
|
||||||
|
shouldShowOfficeElectronicsHint,
|
||||||
type ItemEditorInitialValues,
|
type ItemEditorInitialValues,
|
||||||
type ItemEditorSaveData,
|
type ItemEditorSaveData,
|
||||||
} from "./reimbursement-detail/ReimbursementItemEditor";
|
} from "./reimbursement-detail/ReimbursementItemEditor";
|
||||||
@@ -122,17 +123,20 @@ export function ReimbursementDetailPage() {
|
|||||||
const [invoiceSearch, setInvoiceSearch] = useState("");
|
const [invoiceSearch, setInvoiceSearch] = useState("");
|
||||||
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 [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("");
|
||||||
const [materialPreviewError, setMaterialPreviewError] = useState("");
|
const [materialPreviewError, setMaterialPreviewError] = useState("");
|
||||||
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
|
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
|
||||||
|
const [pendingOcrLinkedItemIds, setPendingOcrLinkedItemIds] = useState<Record<string, string>>({});
|
||||||
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
|
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
|
||||||
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
|
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
|
||||||
expenseTypeId: "",
|
expenseTypeId: "",
|
||||||
expenseDate: "",
|
expenseDate: "",
|
||||||
amount: "",
|
amount: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
assetPriorApprovalBasis: "",
|
||||||
});
|
});
|
||||||
const currentDetailSource = buildPcDetailSource(location.pathname, location.search);
|
const currentDetailSource = buildPcDetailSource(location.pathname, location.search);
|
||||||
const navigationState = location.state as PcDetailNavigationState | null;
|
const navigationState = location.state as PcDetailNavigationState | null;
|
||||||
@@ -287,6 +291,7 @@ export function ReimbursementDetailPage() {
|
|||||||
expenseDate: data.expenseDate,
|
expenseDate: data.expenseDate,
|
||||||
amount: data.amount,
|
amount: data.amount,
|
||||||
description: data.description.trim() || undefined,
|
description: data.description.trim() || undefined,
|
||||||
|
assetPriorApprovalBasis: data.assetPriorApprovalBasis.trim() || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (itemEditor.mode === "create") {
|
if (itemEditor.mode === "create") {
|
||||||
@@ -411,6 +416,9 @@ export function ReimbursementDetailPage() {
|
|||||||
setSelectedInvoices((previous) =>
|
setSelectedInvoices((previous) =>
|
||||||
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
|
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
|
||||||
);
|
);
|
||||||
|
setPendingOcrLinkedItemIds((previous) =>
|
||||||
|
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
|
||||||
|
);
|
||||||
|
|
||||||
if (!failedResults.length) {
|
if (!failedResults.length) {
|
||||||
setInvoiceActionError("");
|
setInvoiceActionError("");
|
||||||
@@ -451,9 +459,14 @@ export function ReimbursementDetailPage() {
|
|||||||
if (!invoiceIds.length) {
|
if (!invoiceIds.length) {
|
||||||
return;
|
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; ocrLastError?: string | null }>) => {
|
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null; ocrLastError?: string | null }>) => {
|
||||||
@@ -461,6 +474,10 @@ export function ReimbursementDetailPage() {
|
|||||||
.map((item) => `${formatInvoiceDisplayTitle(item)}:${resolveInvoiceUserMessage(item)}`)
|
.map((item) => `${formatInvoiceDisplayTitle(item)}:${resolveInvoiceUserMessage(item)}`)
|
||||||
.join(";");
|
.join(";");
|
||||||
setInvoiceActionError(message);
|
setInvoiceActionError(message);
|
||||||
|
const unavailableIds = new Set(items.map((item) => item.id));
|
||||||
|
setPendingOcrLinkedItemIds((previous) =>
|
||||||
|
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !unavailableIds.has(invoiceId))),
|
||||||
|
);
|
||||||
toast.error("部分发票需要处理");
|
toast.error("部分发票需要处理");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -537,10 +554,19 @@ 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) {
|
||||||
|
setPendingOcrLinkedItemIds((previous) => ({
|
||||||
|
...previous,
|
||||||
|
...Object.fromEntries(Array.from(pendingById.keys()).map((invoiceId) => [invoiceId, invoiceUploadLinkedItemId])),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
for (const invoiceId of availableIds) {
|
for (const invoiceId of availableIds) {
|
||||||
try {
|
try {
|
||||||
await bindInvoice(invoiceId, { reimbursementId });
|
await bindInvoice(invoiceId, {
|
||||||
|
reimbursementId,
|
||||||
|
...(detail.invoiceGuardEnabled ? { linkedItemId: invoiceUploadLinkedItemId } : {}),
|
||||||
|
});
|
||||||
boundCount += 1;
|
boundCount += 1;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
bindFailures.push(`新上传发票:${buildActionErrorMessage(error, "添加发票失败")}`);
|
bindFailures.push(`新上传发票:${buildActionErrorMessage(error, "添加发票失败")}`);
|
||||||
@@ -758,7 +784,8 @@ export function ReimbursementDetailPage() {
|
|||||||
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
|
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
|
||||||
const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
|
const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
|
||||||
const missingInvoiceSubmitMessage = "提交前需先添加至少 1 张发票。";
|
const missingInvoiceSubmitMessage = "提交前需先添加至少 1 张发票。";
|
||||||
const submitInvoiceNotice = canMaintainReimbursement && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : "";
|
const requiresAnyInvoice = detail.items.some((item) => item.expenseType?.requiresInvoice !== false);
|
||||||
|
const submitInvoiceNotice = canMaintainReimbursement && requiresAnyInvoice && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : "";
|
||||||
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
||||||
|
|
||||||
function handleInvoicePdfSelection(files: FileList | null) {
|
function handleInvoicePdfSelection(files: FileList | null) {
|
||||||
@@ -787,6 +814,7 @@ export function ReimbursementDetailPage() {
|
|||||||
expenseTypeId: defaultExpenseTypeId,
|
expenseTypeId: defaultExpenseTypeId,
|
||||||
amount: item?.amount ?? "",
|
amount: item?.amount ?? "",
|
||||||
description: item?.description ?? "",
|
description: item?.description ?? "",
|
||||||
|
assetPriorApprovalBasis: item?.assetPriorApprovalBasis ?? "",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -830,7 +858,7 @@ export function ReimbursementDetailPage() {
|
|||||||
toast.error(message);
|
toast.error(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (detail.invoices.length < 1) {
|
if (requiresAnyInvoice && detail.invoices.length < 1) {
|
||||||
setActionError(missingInvoiceSubmitMessage);
|
setActionError(missingInvoiceSubmitMessage);
|
||||||
toast.error(missingInvoiceSubmitMessage);
|
toast.error(missingInvoiceSubmitMessage);
|
||||||
return;
|
return;
|
||||||
@@ -864,6 +892,7 @@ export function ReimbursementDetailPage() {
|
|||||||
setInvoiceSearch("");
|
setInvoiceSearch("");
|
||||||
setSelectedInvoices({});
|
setSelectedInvoices({});
|
||||||
setSelectedInvoiceFiles([]);
|
setSelectedInvoiceFiles([]);
|
||||||
|
setInvoiceUploadLinkedItemId("");
|
||||||
setInvoiceActionError("");
|
setInvoiceActionError("");
|
||||||
setPendingOcrInvoiceIds([]);
|
setPendingOcrInvoiceIds([]);
|
||||||
}
|
}
|
||||||
@@ -890,9 +919,9 @@ export function ReimbursementDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bindInvoicesMutation.mutate(
|
bindInvoicesMutation.mutate(
|
||||||
selectedInvoiceEntries.map(([invoiceId]) => ({
|
selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({
|
||||||
invoiceId,
|
invoiceId,
|
||||||
linkedItemId: undefined,
|
linkedItemId: linkedItemId || undefined,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -928,7 +957,7 @@ export function ReimbursementDetailPage() {
|
|||||||
if (!itemCount) {
|
if (!itemCount) {
|
||||||
return "补报销明细";
|
return "补报销明细";
|
||||||
}
|
}
|
||||||
if (!invoiceCount) {
|
if (requiresAnyInvoice && !invoiceCount) {
|
||||||
return "添加发票";
|
return "添加发票";
|
||||||
}
|
}
|
||||||
if (hasBlockingPrecheck) {
|
if (hasBlockingPrecheck) {
|
||||||
@@ -962,7 +991,7 @@ export function ReimbursementDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!invoiceCount) {
|
if (requiresAnyInvoice && !invoiceCount) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
data-testid="reimbursement-primary-action"
|
data-testid="reimbursement-primary-action"
|
||||||
@@ -1248,6 +1277,16 @@ export function ReimbursementDetailPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div>{item.description ?? "-"}</div>
|
<div>{item.description ?? "-"}</div>
|
||||||
|
{shouldShowOfficeElectronicsHint(item.expenseType?.code, item.amount) ? (
|
||||||
|
<div className="mt-1 text-xs text-amber-600">
|
||||||
|
该金额通常应选择办公电子设备并填写事前批准依据。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{item.assetPriorApprovalBasis ? (
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
|
事前批准依据:{item.assetPriorApprovalBasis}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{precheckStandardChecks
|
{precheckStandardChecks
|
||||||
.filter((check) => check.itemId === item.id && check.exceeded)
|
.filter((check) => check.itemId === item.id && check.exceeded)
|
||||||
.map((check) => (
|
.map((check) => (
|
||||||
@@ -1617,9 +1656,24 @@ 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]">
|
||||||
{invoiceHint} 添加后自动生成或匹配报销明细。
|
{detail.invoiceGuardEnabled ? "请选择该发票对应的费用明细。" : `${invoiceHint} 添加后自动生成或匹配报销明细。`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isSelected && detail.invoiceGuardEnabled ? (
|
||||||
|
<select
|
||||||
|
aria-label="关联费用明细"
|
||||||
|
className="select-std mt-3 w-full"
|
||||||
|
value={selectedInvoices[item.id] ?? ""}
|
||||||
|
onChange={(event) => setSelectedInvoices((previous) => ({ ...previous, [item.id]: event.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">请选择费用明细</option>
|
||||||
|
{detail.items.map((detailItem) => (
|
||||||
|
<option key={detailItem.id} value={detailItem.id}>
|
||||||
|
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -1634,7 +1688,7 @@ 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 || bindInvoicesMutation.isPending}
|
disabled={!selectedInvoiceEntries.length || (detail.invoiceGuardEnabled && selectedInvoiceEntries.some(([, linkedItemId]) => !linkedItemId)) || bindInvoicesMutation.isPending}
|
||||||
onClick={handleBindSelectedInvoices}
|
onClick={handleBindSelectedInvoices}
|
||||||
>
|
>
|
||||||
{bindInvoicesMutation.isPending ? "添加中..." : "添加选中发票"}
|
{bindInvoicesMutation.isPending ? "添加中..." : "添加选中发票"}
|
||||||
@@ -1673,7 +1727,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 || uploadInvoiceMutation.isPending}
|
disabled={!selectedInvoiceFiles.length || (detail.invoiceGuardEnabled && !invoiceUploadLinkedItemId) || uploadInvoiceMutation.isPending}
|
||||||
onClick={() => selectedInvoiceFiles.length && uploadInvoiceMutation.mutate(selectedInvoiceFiles)}
|
onClick={() => selectedInvoiceFiles.length && uploadInvoiceMutation.mutate(selectedInvoiceFiles)}
|
||||||
>
|
>
|
||||||
{uploadInvoiceMutation.isPending
|
{uploadInvoiceMutation.isPending
|
||||||
@@ -1682,6 +1736,22 @@ export function ReimbursementDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{detail.invoiceGuardEnabled ? (
|
||||||
|
<select
|
||||||
|
aria-label="新上传发票关联明细"
|
||||||
|
className="select-std mt-3 w-full"
|
||||||
|
value={invoiceUploadLinkedItemId}
|
||||||
|
onChange={(event) => setInvoiceUploadLinkedItemId(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">请选择新上传发票对应的费用明细</option>
|
||||||
|
{detail.items.map((detailItem) => (
|
||||||
|
<option key={detailItem.id} value={detailItem.id}>
|
||||||
|
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{selectedInvoiceFiles.length ? (
|
{selectedInvoiceFiles.length ? (
|
||||||
<div className="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-xs text-zinc-500">
|
<div className="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-xs text-zinc-500">
|
||||||
{selectedInvoiceFiles.slice(0, 5).map((file) => (
|
{selectedInvoiceFiles.slice(0, 5).map((file) => (
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import { Button } from "../../components/ui/button";
|
|||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Textarea } from "../../components/ui/textarea";
|
import { Textarea } from "../../components/ui/textarea";
|
||||||
|
|
||||||
|
export const OFFICE_ELECTRONICS_HINT_THRESHOLD = 2000;
|
||||||
|
|
||||||
|
export function shouldShowOfficeElectronicsHint(expenseTypeCode: string | null | undefined, amount: string | number) {
|
||||||
|
const numericAmount = Number(amount);
|
||||||
|
return expenseTypeCode === "office_supplies"
|
||||||
|
&& Number.isFinite(numericAmount)
|
||||||
|
&& numericAmount >= OFFICE_ELECTRONICS_HINT_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
export type ItemEditorMode = "create" | "edit";
|
export type ItemEditorMode = "create" | "edit";
|
||||||
|
|
||||||
export type ItemEditorInitialValues = {
|
export type ItemEditorInitialValues = {
|
||||||
@@ -13,6 +22,7 @@ export type ItemEditorInitialValues = {
|
|||||||
expenseDate: string;
|
expenseDate: string;
|
||||||
amount: string;
|
amount: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
assetPriorApprovalBasis: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ItemEditorSaveData = {
|
export type ItemEditorSaveData = {
|
||||||
@@ -20,6 +30,7 @@ export type ItemEditorSaveData = {
|
|||||||
expenseDate: string;
|
expenseDate: string;
|
||||||
amount: string;
|
amount: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
assetPriorApprovalBasis: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ReimbursementItemEditorProps {
|
export interface ReimbursementItemEditorProps {
|
||||||
@@ -49,6 +60,7 @@ export function ReimbursementItemEditor({
|
|||||||
const [expenseDate, setExpenseDate] = useState(initialValues.expenseDate);
|
const [expenseDate, setExpenseDate] = useState(initialValues.expenseDate);
|
||||||
const [amount, setAmount] = useState(initialValues.amount);
|
const [amount, setAmount] = useState(initialValues.amount);
|
||||||
const [description, setDescription] = useState(initialValues.description);
|
const [description, setDescription] = useState(initialValues.description);
|
||||||
|
const [assetPriorApprovalBasis, setAssetPriorApprovalBasis] = useState(initialValues.assetPriorApprovalBasis);
|
||||||
|
|
||||||
// Reset internal state when initialValues change (e.g. switching between create/edit)
|
// Reset internal state when initialValues change (e.g. switching between create/edit)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -56,7 +68,14 @@ export function ReimbursementItemEditor({
|
|||||||
setExpenseDate(initialValues.expenseDate);
|
setExpenseDate(initialValues.expenseDate);
|
||||||
setAmount(initialValues.amount);
|
setAmount(initialValues.amount);
|
||||||
setDescription(initialValues.description);
|
setDescription(initialValues.description);
|
||||||
}, [initialValues.expenseTypeId, initialValues.expenseDate, initialValues.amount, initialValues.description]);
|
setAssetPriorApprovalBasis(initialValues.assetPriorApprovalBasis);
|
||||||
|
}, [initialValues.expenseTypeId, initialValues.expenseDate, initialValues.amount, initialValues.description, initialValues.assetPriorApprovalBasis]);
|
||||||
|
|
||||||
|
const selectedExpenseType = expenseTypes.find((item) => item.id === expenseTypeId) ?? null;
|
||||||
|
const isAssetExpense = selectedExpenseType?.category === "assets";
|
||||||
|
const numericAmount = Number(amount || 0);
|
||||||
|
const shouldWarnOfficeEquipment =
|
||||||
|
shouldShowOfficeElectronicsHint(selectedExpenseType?.code, numericAmount);
|
||||||
|
|
||||||
const standardsQuery = useQuery({
|
const standardsQuery = useQuery({
|
||||||
enabled: Boolean(expenseTypeId),
|
enabled: Boolean(expenseTypeId),
|
||||||
@@ -77,14 +96,13 @@ export function ReimbursementItemEditor({
|
|||||||
);
|
);
|
||||||
}, [departmentId, standardsQuery.data?.items]);
|
}, [departmentId, standardsQuery.data?.items]);
|
||||||
|
|
||||||
const numericAmount = Number(amount || 0);
|
|
||||||
const numericMaxAmount = Number(activeStandard?.maxAmount ?? 0);
|
const numericMaxAmount = Number(activeStandard?.maxAmount ?? 0);
|
||||||
const isExceeded = Boolean(activeStandard) && Number.isFinite(numericAmount) && numericAmount > numericMaxAmount;
|
const isExceeded = Boolean(activeStandard) && Number.isFinite(numericAmount) && numericAmount > numericMaxAmount;
|
||||||
const exceededAmount = isExceeded ? (numericAmount - numericMaxAmount).toFixed(2) : "0.00";
|
const exceededAmount = isExceeded ? (numericAmount - numericMaxAmount).toFixed(2) : "0.00";
|
||||||
const hasPositiveAmount = Number.isFinite(numericAmount) && numericAmount > 0;
|
const hasPositiveAmount = Number.isFinite(numericAmount) && numericAmount > 0;
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
onSave({ expenseTypeId, expenseDate, amount, description });
|
onSave({ expenseTypeId, expenseDate, amount, description, assetPriorApprovalBasis });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -121,6 +139,9 @@ export function ReimbursementItemEditor({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{selectedExpenseType?.description ? (
|
||||||
|
<p className="text-xs text-zinc-500">{selectedExpenseType.description}</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -162,8 +183,30 @@ export function ReimbursementItemEditor({
|
|||||||
超出费用标准 ¥{activeStandard?.maxAmount},超出 ¥{exceededAmount}
|
超出费用标准 ¥{activeStandard?.maxAmount},超出 ¥{exceededAmount}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{shouldWarnOfficeEquipment ? (
|
||||||
|
<p className="text-xs text-amber-600">
|
||||||
|
该金额通常应选择办公电子设备并填写事前批准依据。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isAssetExpense ? (
|
||||||
|
<div className="space-y-1 md:col-span-4">
|
||||||
|
<label className="text-xs text-zinc-600" htmlFor="assetPriorApprovalBasis">
|
||||||
|
事前批准依据
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
data-testid="reimbursement-item-asset-approval-basis"
|
||||||
|
disabled={isPending}
|
||||||
|
id="assetPriorApprovalBasis"
|
||||||
|
onChange={(event) => setAssetPriorApprovalBasis(event.target.value)}
|
||||||
|
placeholder="填写批准人、批准时间和简短依据"
|
||||||
|
rows={2}
|
||||||
|
value={assetPriorApprovalBasis}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-1 md:col-span-4">
|
<div className="space-y-1 md:col-span-4">
|
||||||
<label className="text-xs text-zinc-600" htmlFor="itemDescription">
|
<label className="text-xs text-zinc-600" htmlFor="itemDescription">
|
||||||
说明
|
说明
|
||||||
@@ -187,7 +230,8 @@ export function ReimbursementItemEditor({
|
|||||||
!expenseTypeId ||
|
!expenseTypeId ||
|
||||||
!expenseDate ||
|
!expenseDate ||
|
||||||
!amount.trim() ||
|
!amount.trim() ||
|
||||||
!hasPositiveAmount
|
!hasPositiveAmount ||
|
||||||
|
(isAssetExpense && !assetPriorApprovalBasis.trim())
|
||||||
}
|
}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { mkdirSync } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { expect, test, type Browser, type Page, type Route } from "@playwright/test";
|
import { expect, test, type Browser, type Page, type Route } from "@playwright/test";
|
||||||
|
|
||||||
|
test.use({ serviceWorkers: "block" });
|
||||||
|
|
||||||
const ROOT_DIR = path.resolve(process.cwd(), "..");
|
const ROOT_DIR = path.resolve(process.cwd(), "..");
|
||||||
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r111-invoice-reimbursement");
|
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r111-invoice-reimbursement");
|
||||||
const WECOM_UA =
|
const WECOM_UA =
|
||||||
@@ -63,6 +65,26 @@ function buildExpenseTypes() {
|
|||||||
code: "business_client_meal",
|
code: "business_client_meal",
|
||||||
name: "业务招待",
|
name: "业务招待",
|
||||||
category: "expense",
|
category: "expense",
|
||||||
|
description: null,
|
||||||
|
requiresInvoice: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "expense-type-office-supplies-r111",
|
||||||
|
code: "office_supplies",
|
||||||
|
name: "办公用品",
|
||||||
|
category: "office",
|
||||||
|
description: "按本条明细金额判断,多件同类分开记。",
|
||||||
|
requiresInvoice: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "expense-type-assets-r111",
|
||||||
|
code: "assets_office_electronics",
|
||||||
|
name: "办公电子设备",
|
||||||
|
category: "assets",
|
||||||
|
description: "2,000 元及以上通常选本类型,不等同于会计固定资产标准。",
|
||||||
|
requiresInvoice: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -163,6 +185,10 @@ function buildReimbursementState() {
|
|||||||
availableInvoices: [buildAvailableInvoice()],
|
availableInvoices: [buildAvailableInvoice()],
|
||||||
failedInvoices: [buildFailedInvoice()],
|
failedInvoices: [buildFailedInvoice()],
|
||||||
statusPollCount: 0,
|
statusPollCount: 0,
|
||||||
|
bindRequests: [] as Array<{ reimbursementId?: string; linkedItemId?: string }>,
|
||||||
|
submitRequestCount: 0,
|
||||||
|
invoiceGuardEnabled: false,
|
||||||
|
detailItems: null as null | Array<Record<string, unknown>>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +235,8 @@ function buildReimbursementDetail(state: ReturnType<typeof buildReimbursementSta
|
|||||||
},
|
},
|
||||||
approvalHistory: [],
|
approvalHistory: [],
|
||||||
riskFlags: [],
|
riskFlags: [],
|
||||||
items: [
|
invoiceGuardEnabled: state.invoiceGuardEnabled,
|
||||||
|
items: state.detailItems ?? [
|
||||||
{
|
{
|
||||||
id: "item-r111",
|
id: "item-r111",
|
||||||
expenseTypeId: "expense-type-r111",
|
expenseTypeId: "expense-type-r111",
|
||||||
@@ -360,13 +387,23 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
|||||||
if (pathName === `/api/reimbursements/${reimbursementId}/precheck`) {
|
if (pathName === `/api/reimbursements/${reimbursementId}/precheck`) {
|
||||||
await fulfillJson(route, {
|
await fulfillJson(route, {
|
||||||
decision: "pass",
|
decision: "pass",
|
||||||
|
matchedBudgetControlRule: null,
|
||||||
standardChecks: [],
|
standardChecks: [],
|
||||||
budget: null,
|
budget: null,
|
||||||
riskFlags: [],
|
riskFlags: [],
|
||||||
|
warnings: [],
|
||||||
|
blockingReasons: [],
|
||||||
|
nextActions: ["可直接提交"],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathName === `/api/reimbursements/${reimbursementId}/submit` && request.method() === "POST") {
|
||||||
|
state.submitRequestCount += 1;
|
||||||
|
await fulfillJson(route, { id: reimbursementId, status: "reviewing" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (pathName === `/api/reimbursements/${reimbursementId}/attachments` && request.method() === "POST") {
|
if (pathName === `/api/reimbursements/${reimbursementId}/attachments` && request.method() === "POST") {
|
||||||
state.attachments = [buildAttachment()];
|
state.attachments = [buildAttachment()];
|
||||||
await fulfillJson(route, { items: state.attachments });
|
await fulfillJson(route, { items: state.attachments });
|
||||||
@@ -483,6 +520,7 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pathName === `/api/invoice-pool/${pendingInvoiceId}/bind`) {
|
if (pathName === `/api/invoice-pool/${pendingInvoiceId}/bind`) {
|
||||||
|
state.bindRequests.push(request.postDataJSON());
|
||||||
state.invoices = [buildInvoice(), buildUnlinkedInvoice()];
|
state.invoices = [buildInvoice(), buildUnlinkedInvoice()];
|
||||||
await fulfillJson(route, {
|
await fulfillJson(route, {
|
||||||
id: pendingInvoiceId,
|
id: pendingInvoiceId,
|
||||||
@@ -697,6 +735,91 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
|||||||
await page.screenshot({ path: screenshotPath("pc-reimbursement-detail.png"), fullPage: true });
|
await page.screenshot({ path: screenshotPath("pc-reimbursement-detail.png"), fullPage: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("B1/B2 在 PC 和移动端显示设备提示并要求明确关联明细", async ({ page }) => {
|
||||||
|
const state = buildReimbursementState();
|
||||||
|
state.invoiceGuardEnabled = true;
|
||||||
|
state.detailItems = [{
|
||||||
|
id: "item-r111",
|
||||||
|
expenseTypeId: "expense-type-office-supplies-r111",
|
||||||
|
expenseDate: "2026-05-08",
|
||||||
|
amount: "2000.00",
|
||||||
|
description: "合成办公配件",
|
||||||
|
assetPriorApprovalBasis: null,
|
||||||
|
expenseType: {
|
||||||
|
id: "expense-type-office-supplies-r111",
|
||||||
|
code: "office_supplies",
|
||||||
|
name: "办公用品",
|
||||||
|
category: "office",
|
||||||
|
enabled: true,
|
||||||
|
requiresInvoice: true,
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
await mockApis(page, state);
|
||||||
|
await login(page);
|
||||||
|
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||||
|
|
||||||
|
await expect(page.getByText("该金额通常应选择办公电子设备并填写事前批准依据。")).toBeVisible();
|
||||||
|
await page.getByTestId("reimbursement-item-add").click();
|
||||||
|
await page.locator("#expenseTypeId").selectOption("expense-type-assets-r111");
|
||||||
|
await expect(page.getByTestId("reimbursement-item-asset-approval-basis")).toBeVisible();
|
||||||
|
await expect(page.getByText("2,000 元及以上通常选本类型,不等同于会计固定资产标准。")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("reimbursement-invoice-add").click();
|
||||||
|
const invoiceDialog = page.getByTestId("reimbursement-invoice-dialog");
|
||||||
|
await invoiceDialog.locator("input[type='checkbox']").first().check();
|
||||||
|
await expect(invoiceDialog.getByText("请选择该发票对应的费用明细。")).toBeVisible();
|
||||||
|
const bindButton = invoiceDialog.getByRole("button", { name: "添加选中发票" });
|
||||||
|
await expect(bindButton).toBeDisabled();
|
||||||
|
await invoiceDialog.getByLabel("关联费用明细").selectOption("item-r111");
|
||||||
|
await expect(bindButton).toBeEnabled();
|
||||||
|
|
||||||
|
await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
|
||||||
|
name: "待识别办公发票.pdf",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
buffer: Buffer.from("%PDF r111 guarded invoice"),
|
||||||
|
});
|
||||||
|
await invoiceDialog.getByLabel("新上传发票关联明细").selectOption("item-r111");
|
||||||
|
await page.getByTestId("reimbursement-invoice-upload-submit").click();
|
||||||
|
await expect.poll(() => state.bindRequests).toContainEqual({
|
||||||
|
reimbursementId,
|
||||||
|
linkedItemId: "item-r111",
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
|
||||||
|
await expect(page.getByText("该金额通常应选择办公电子设备并填写事前批准依据。")).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "新增", exact: true }).click();
|
||||||
|
await page.getByLabel("费用类型").selectOption("expense-type-assets-r111");
|
||||||
|
await expect(page.getByText("事前批准依据", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
state.invoiceGuardEnabled = false;
|
||||||
|
state.invoices = [];
|
||||||
|
state.detailItems = [{
|
||||||
|
id: "allowance-item-r111",
|
||||||
|
expenseTypeId: "expense-type-allowance-r111",
|
||||||
|
expenseDate: "2026-05-08",
|
||||||
|
amount: "100.00",
|
||||||
|
description: "1 天 × 100 元/天",
|
||||||
|
assetPriorApprovalBasis: null,
|
||||||
|
expenseType: {
|
||||||
|
id: "expense-type-allowance-r111",
|
||||||
|
code: "travel_meal",
|
||||||
|
name: "伙食补助费",
|
||||||
|
category: "travel",
|
||||||
|
enabled: true,
|
||||||
|
requiresInvoice: false,
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
await page.setViewportSize({ width: 1440, height: 960 });
|
||||||
|
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||||
|
await expect(page.getByRole("button", { name: "提交报销", exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("提交审批", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("reimbursement-overview").getByText("添加发票", { exact: true })).toHaveCount(0);
|
||||||
|
await page.getByRole("button", { name: "提交报销", exact: true }).click();
|
||||||
|
await expect.poll(() => state.submitRequestCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test("企业微信手机端保持移动布局,发票和附件能力与 PC 一致", async ({ browser }) => {
|
test("企业微信手机端保持移动布局,发票和附件能力与 PC 一致", async ({ browser }) => {
|
||||||
const state = buildReimbursementState();
|
const state = buildReimbursementState();
|
||||||
const mobile = await newWecomPage(browser);
|
const mobile = await newWecomPage(browser);
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ INVOICE_ARCHIVE_BATCH_SIZE=200
|
|||||||
# ===== 预算 / 费控 =====
|
# ===== 预算 / 费控 =====
|
||||||
BUDGET_CONTROL_MODE=warn
|
BUDGET_CONTROL_MODE=warn
|
||||||
BUDGET_PRECHECK_ENABLED=false
|
BUDGET_PRECHECK_ENABLED=false
|
||||||
|
REIMBURSEMENT_INVOICE_GUARD_ENABLED=false
|
||||||
PAYMENT_BATCH_ENABLED=true
|
PAYMENT_BATCH_ENABLED=true
|
||||||
|
|
||||||
# ===== 周边集成 / ERP =====
|
# ===== 周边集成 / ERP =====
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"AUTH_LOGIN_LOCK_SECONDS",
|
"AUTH_LOGIN_LOCK_SECONDS",
|
||||||
"BUDGET_CONTROL_MODE",
|
"BUDGET_CONTROL_MODE",
|
||||||
"BUDGET_PRECHECK_ENABLED",
|
"BUDGET_PRECHECK_ENABLED",
|
||||||
|
"REIMBURSEMENT_INVOICE_GUARD_ENABLED",
|
||||||
"CLIENT_DIST_DIR",
|
"CLIENT_DIST_DIR",
|
||||||
"CONFIDENTIAL_DOC_REFS_ENABLED",
|
"CONFIDENTIAL_DOC_REFS_ENABLED",
|
||||||
"COOKIE_SECURE",
|
"COOKIE_SECURE",
|
||||||
|
|||||||
+7
@@ -187,3 +187,10 @@
|
|||||||
- 前端按单公司表达公司信息和企业银行账户:正常状态不要求用户选择公司,也不展示无业务价值的多公司配置复杂度。
|
- 前端按单公司表达公司信息和企业银行账户:正常状态不要求用户选择公司,也不展示无业务价值的多公司配置复杂度。
|
||||||
- `LegalEntity` 及其外键继续作为成本中心、供应商、付款账户等内部账务和主数据关联锚点;单公司体验不等于删除后端关系模型或迁移历史数据。
|
- `LegalEntity` 及其外键继续作为成本中心、供应商、付款账户等内部账务和主数据关联锚点;单公司体验不等于删除后端关系模型或迁移历史数据。
|
||||||
- 异常存在多条启用公司信息时必须停止自动关联并提示管理员治理,不得静默选择、自动合并或删除记录。
|
- 异常存在多条启用公司信息时必须停止自动关联并提示管理员治理,不得静默选择、自动合并或删除记录。
|
||||||
|
|
||||||
|
### R-7 报销费用类型与票据门禁
|
||||||
|
- “客户招待”统一为“业务招待费”;“培训会议”停止新选并拆为“会议费”“培训费”;“IT 硬件”停止新选,低值配件使用“办公用品”,需登记的选择“办公电子设备”。停用类型保留历史,不迁移旧明细语义。
|
||||||
|
- 办公用品与办公电子设备按单条明细金额 `2,000 元`作选类提示;该金额不等同于会计固定资产确认标准。达到金额仍选择办公用品时只提示员工和财务复核,不自动改类或阻断提交。
|
||||||
|
- assets 个人报销属于经事前批准的例外;明细必须填写批准人、批准时间和简短依据。当前不强制关联采购申请,也不自动建资产卡。
|
||||||
|
- 伙食补助费和市内交通补助费不要求发票;其他首期启用费用类型默认需要有效票据。免票不等于免除补助算式、业务说明、审批或必要附件。
|
||||||
|
- 票据—明细强关联和票面金额提交门禁使用独立双闸逐步启用。开启时每张有效发票必须明确选择明细,仅对需票明细校验缺票及申报金额高于票面合计;关闭只回退门禁,不删除已建立的关联数据。
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
|
|||||||
- 已退款、公司直接支付或由他人承担的金额不得重复报销。发生部分退款时,只能申报扣除退款后的个人实际承担金额,并附情况说明。
|
- 已退款、公司直接支付或由他人承担的金额不得重复报销。发生部分退款时,只能申报扣除退款后的个人实际承担金额,并附情况说明。
|
||||||
- 确实无法提供支付截图时,员工必须上传情况说明;财务可以退回补充,也可以在有其他充分证据时例外接受并留痕。不得伪造、补造或借用他人支付凭证。
|
- 确实无法提供支付截图时,员工必须上传情况说明;财务可以退回补充,也可以在有其他充分证据时例外接受并留痕。不得伪造、补造或借用他人支付凭证。
|
||||||
- OCR 只辅助识别发票字段、匹配和查重,不判断支付真实性、费用合理性或发票法律状态。财务复核时必须同时查看明细金额、PDF 原件和个人支付证明;三者不一致时退回更正或补充说明。
|
- OCR 只辅助识别发票字段、匹配和查重,不判断支付真实性、费用合理性或发票法律状态。财务复核时必须同时查看明细金额、PDF 原件和个人支付证明;三者不一致时退回更正或补充说明。
|
||||||
|
- 每张有效发票应关联到一条明确费用明细。一条明细只表达同一费用类型、同一发生日期和同一业务支出;采购物品还应是一件或一批同类物品,不同日期、不同支出或需要分别判断设备提示的多件物品应拆分。
|
||||||
|
- 票据—明细门禁启用后,需票明细没有有效发票或申报金额高于关联票面合计时阻断提交;两项包干补助不参加票面比较。关闭门禁不会删除或解绑已经建立的关联关系。
|
||||||
- 财务付款凭证号、付款日期、付款渠道和付款回单属于审批通过后的财务处理,不是员工提交报销时填写的个人支付证明。
|
- 财务付款凭证号、付款日期、付款渠道和付款回单属于审批通过后的财务处理,不是员工提交报销时填写的个人支付证明。
|
||||||
|
|
||||||
### 差旅补助规定
|
### 差旅补助规定
|
||||||
|
|||||||
+1
-1
@@ -163,7 +163,7 @@ BIZ8 阶段的费用提示只作为填报辅助和审批关注提醒,不扩展
|
|||||||
|
|
||||||
- 报销单只有一条明细时,发票自动加入该明细。
|
- 报销单只有一条明细时,发票自动加入该明细。
|
||||||
- 报销单有多条明细时,优先按发票金额唯一匹配明细金额。
|
- 报销单有多条明细时,优先按发票金额唯一匹配明细金额。
|
||||||
- 金额无法唯一匹配但发票金额存在时,可自动创建一条新明细,并提示用户确认费用类型和说明。
|
- 票据—明细门禁关闭时,金额无法唯一匹配但发票金额存在可沿用自动创建明细的兼容路径,并提示用户确认费用类型和说明;门禁开启后必须由员工明确选择已有明细,不再静默自动创建。
|
||||||
- 发票金额缺失时,不自动创建明细,提示用户先补全发票信息或手工创建明细。
|
- 发票金额缺失时,不自动创建明细,提示用户先补全发票信息或手工创建明细。
|
||||||
- 一张发票可按业务规则关联一条或多条明细;首批优先支持一张发票绑定一条明细,复杂拆分后续单独设计。
|
- 一张发票可按业务规则关联一条或多条明细;首批优先支持一张发票绑定一条明细,复杂拆分后续单独设计。
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ INTEGRATION_OUTBOX_RETRY_SECONDS="120"
|
|||||||
|
|
||||||
# R12 lite feature gates
|
# R12 lite feature gates
|
||||||
BUDGET_PRECHECK_ENABLED=false
|
BUDGET_PRECHECK_ENABLED=false
|
||||||
|
REIMBURSEMENT_INVOICE_GUARD_ENABLED=false
|
||||||
PAYMENT_BATCH_ENABLED=false
|
PAYMENT_BATCH_ENABLED=false
|
||||||
PORTAL_HOME_ENABLED=false
|
PORTAL_HOME_ENABLED=false
|
||||||
GLOBAL_SEARCH_ENABLED=false
|
GLOBAL_SEARCH_ENABLED=false
|
||||||
|
|||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE "expense_types"
|
||||||
|
ADD COLUMN "requires_invoice" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
ALTER TABLE "reimbursement_items"
|
||||||
|
ADD COLUMN "asset_prior_approval_basis" TEXT;
|
||||||
|
|
||||||
|
UPDATE "expense_types"
|
||||||
|
SET "requires_invoice" = false
|
||||||
|
WHERE "code" IN ('travel_meal', 'travel_local_transport_allowance');
|
||||||
@@ -892,6 +892,7 @@ model ExpenseType {
|
|||||||
category ExpenseCategory
|
category ExpenseCategory
|
||||||
sort_order Int
|
sort_order Int
|
||||||
account_code_default String?
|
account_code_default String?
|
||||||
|
requires_invoice Boolean @default(true)
|
||||||
is_system_default Boolean @default(false)
|
is_system_default Boolean @default(false)
|
||||||
enabled Boolean @default(true)
|
enabled Boolean @default(true)
|
||||||
|
|
||||||
@@ -1500,6 +1501,7 @@ model ReimbursementItem {
|
|||||||
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
|
||||||
|
asset_prior_approval_basis String?
|
||||||
|
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
created_by String? @db.Uuid
|
created_by String? @db.Uuid
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ function getConfig() {
|
|||||||
|
|
||||||
budgetControlMode: process.env.BUDGET_CONTROL_MODE ?? "warn",
|
budgetControlMode: process.env.BUDGET_CONTROL_MODE ?? "warn",
|
||||||
budgetPrecheckEnabled: process.env.BUDGET_PRECHECK_ENABLED === "true",
|
budgetPrecheckEnabled: process.env.BUDGET_PRECHECK_ENABLED === "true",
|
||||||
|
reimbursementInvoiceGuardEnabled: process.env.REIMBURSEMENT_INVOICE_GUARD_ENABLED === "true",
|
||||||
paymentBatchEnabled: process.env.PAYMENT_BATCH_ENABLED === "true",
|
paymentBatchEnabled: process.env.PAYMENT_BATCH_ENABLED === "true",
|
||||||
portalHomeEnabled: process.env.PORTAL_HOME_ENABLED === "true",
|
portalHomeEnabled: process.env.PORTAL_HOME_ENABLED === "true",
|
||||||
globalSearchEnabled: process.env.GLOBAL_SEARCH_ENABLED === "true",
|
globalSearchEnabled: process.env.GLOBAL_SEARCH_ENABLED === "true",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const CREATE_BODY_SCHEMA = z.object({
|
|||||||
category: z.enum(["travel", "rd", "production", "office", "business", "assets", "other"]),
|
category: z.enum(["travel", "rd", "production", "office", "business", "assets", "other"]),
|
||||||
sortOrder: z.number().int().min(0).max(10000),
|
sortOrder: z.number().int().min(0).max(10000),
|
||||||
accountCodeDefault: z.string().trim().max(50).optional(),
|
accountCodeDefault: z.string().trim().max(50).optional(),
|
||||||
|
requiresInvoice: z.boolean().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ function mapExpenseType(item) {
|
|||||||
category: item.category,
|
category: item.category,
|
||||||
sortOrder: item.sort_order,
|
sortOrder: item.sort_order,
|
||||||
accountCodeDefault: item.account_code_default,
|
accountCodeDefault: item.account_code_default,
|
||||||
|
requiresInvoice: item.requires_invoice,
|
||||||
isSystemDefault: item.is_system_default,
|
isSystemDefault: item.is_system_default,
|
||||||
enabled: item.enabled,
|
enabled: item.enabled,
|
||||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||||
@@ -108,6 +110,7 @@ async function expenseTypesRoutes(fastify) {
|
|||||||
category: true,
|
category: true,
|
||||||
sort_order: true,
|
sort_order: true,
|
||||||
account_code_default: true,
|
account_code_default: true,
|
||||||
|
requires_invoice: true,
|
||||||
is_system_default: true,
|
is_system_default: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
created_at: true,
|
created_at: true,
|
||||||
@@ -142,6 +145,7 @@ async function expenseTypesRoutes(fastify) {
|
|||||||
category: body.category,
|
category: body.category,
|
||||||
sort_order: body.sortOrder,
|
sort_order: body.sortOrder,
|
||||||
account_code_default: body.accountCodeDefault ?? null,
|
account_code_default: body.accountCodeDefault ?? null,
|
||||||
|
requires_invoice: body.requiresInvoice ?? true,
|
||||||
enabled: body.enabled ?? true,
|
enabled: body.enabled ?? true,
|
||||||
created_by: request.employee.id,
|
created_by: request.employee.id,
|
||||||
updated_by: request.employee.id,
|
updated_by: request.employee.id,
|
||||||
@@ -159,6 +163,7 @@ async function expenseTypesRoutes(fastify) {
|
|||||||
extra: {
|
extra: {
|
||||||
code: expenseType.code,
|
code: expenseType.code,
|
||||||
name: expenseType.name,
|
name: expenseType.name,
|
||||||
|
requiresInvoice: expenseType.requires_invoice,
|
||||||
enabled: expenseType.enabled,
|
enabled: expenseType.enabled,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -241,6 +246,9 @@ async function expenseTypesRoutes(fastify) {
|
|||||||
if (typeof body.enabled === "boolean" && body.enabled !== current.enabled) {
|
if (typeof body.enabled === "boolean" && body.enabled !== current.enabled) {
|
||||||
changes.enabled = { before: current.enabled, after: body.enabled };
|
changes.enabled = { before: current.enabled, after: body.enabled };
|
||||||
}
|
}
|
||||||
|
if (typeof body.requiresInvoice === "boolean" && body.requiresInvoice !== current.requires_invoice) {
|
||||||
|
changes.requiresInvoice = { before: current.requires_invoice, after: body.requiresInvoice };
|
||||||
|
}
|
||||||
|
|
||||||
const expenseType = await tx.expenseType.update({
|
const expenseType = await tx.expenseType.update({
|
||||||
where: { id: expenseTypeId },
|
where: { id: expenseTypeId },
|
||||||
@@ -253,6 +261,9 @@ async function expenseTypesRoutes(fastify) {
|
|||||||
? { account_code_default: body.accountCodeDefault }
|
? { account_code_default: body.accountCodeDefault }
|
||||||
: {}),
|
: {}),
|
||||||
...(typeof body.enabled === "boolean" ? { enabled: body.enabled } : {}),
|
...(typeof body.enabled === "boolean" ? { enabled: body.enabled } : {}),
|
||||||
|
...(typeof body.requiresInvoice === "boolean"
|
||||||
|
? { requires_invoice: body.requiresInvoice }
|
||||||
|
: {}),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
updated_by: request.employee.id,
|
updated_by: request.employee.id,
|
||||||
},
|
},
|
||||||
@@ -293,4 +304,3 @@ async function expenseTypesRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = expenseTypesRoutes;
|
module.exports = expenseTypesRoutes;
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const { registerInvoicePoolReadRoutes } = require("./invoicePool/read");
|
|||||||
const { normalizeInvoiceResponse } = require("./invoicePool/shared/mappers");
|
const { normalizeInvoiceResponse } = require("./invoicePool/shared/mappers");
|
||||||
const { buildInvoiceDetailSelect } = require("./invoicePool/shared/selects");
|
const { buildInvoiceDetailSelect } = require("./invoicePool/shared/selects");
|
||||||
const { writeBuffer } = require("../services/storage");
|
const { writeBuffer } = require("../services/storage");
|
||||||
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||||
const {
|
const {
|
||||||
mapInvoiceUnavailableReason,
|
mapInvoiceUnavailableReason,
|
||||||
resolveInvoiceAvailability,
|
resolveInvoiceAvailability,
|
||||||
@@ -1573,7 +1574,7 @@ async function invoicePoolRoutes(fastify) {
|
|||||||
|
|
||||||
const { reimbursementId, linkedItemId } = parsedBody.data;
|
const { reimbursementId, linkedItemId } = parsedBody.data;
|
||||||
const reimbursementRows = await tx.$queryRaw`
|
const reimbursementRows = await tx.$queryRaw`
|
||||||
SELECT id, owner_id, submitter_id, status, reimbursement_date
|
SELECT id, owner_id, submitter_id, department_id, status, reimbursement_date
|
||||||
FROM "reimbursement_forms"
|
FROM "reimbursement_forms"
|
||||||
WHERE id = ${reimbursementId}::uuid
|
WHERE id = ${reimbursementId}::uuid
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
@@ -1616,6 +1617,22 @@ async function invoicePoolRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const invoiceGuardGate = await evaluateFeatureFlag({
|
||||||
|
prisma: tx,
|
||||||
|
config: fastify.config,
|
||||||
|
flagKey: FEATURE_FLAG_KEYS.reimbursementInvoiceGuard,
|
||||||
|
departmentId: reimbursement.department_id ?? null,
|
||||||
|
strictDepartment: true,
|
||||||
|
});
|
||||||
|
if (invoiceGuardGate.enabled && !linkedItemId) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { linkedItemId: "required_when_invoice_guard_enabled" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const resolvedLink = await resolveInvoiceLinkedItem(tx, {
|
const resolvedLink = await resolveInvoiceLinkedItem(tx, {
|
||||||
reimbursementId,
|
reimbursementId,
|
||||||
reimbursementDate: reimbursement.reimbursement_date,
|
reimbursementDate: reimbursement.reimbursement_date,
|
||||||
|
|||||||
+145
-16
@@ -168,6 +168,7 @@ const REIMBURSEMENT_ITEM_BODY_SCHEMA = z.object({
|
|||||||
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().min(1).max(500),
|
||||||
|
assetPriorApprovalBasis: z.string().trim().max(500).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const DATE_ONLY_QUERY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
const DATE_ONLY_QUERY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
@@ -1094,6 +1095,7 @@ function mapCreatedReimbursementItem(item) {
|
|||||||
expenseDate: toDateOnlyString(item.expense_date),
|
expenseDate: toDateOnlyString(item.expense_date),
|
||||||
amount: toMoneyString(item.amount),
|
amount: toMoneyString(item.amount),
|
||||||
description: item.description ?? null,
|
description: item.description ?? null,
|
||||||
|
assetPriorApprovalBasis: item.asset_prior_approval_basis ?? null,
|
||||||
expenseType: item.expense_type
|
expenseType: item.expense_type
|
||||||
? {
|
? {
|
||||||
id: item.expense_type.id,
|
id: item.expense_type.id,
|
||||||
@@ -1101,6 +1103,7 @@ function mapCreatedReimbursementItem(item) {
|
|||||||
name: item.expense_type.name,
|
name: item.expense_type.name,
|
||||||
category: item.expense_type.category,
|
category: item.expense_type.category,
|
||||||
enabled: item.expense_type.enabled,
|
enabled: item.expense_type.enabled,
|
||||||
|
requiresInvoice: item.expense_type.requires_invoice,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
@@ -2409,6 +2412,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
expense_date: true,
|
expense_date: true,
|
||||||
amount: true,
|
amount: true,
|
||||||
description: true,
|
description: true,
|
||||||
|
asset_prior_approval_basis: true,
|
||||||
expense_type: {
|
expense_type: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -2416,6 +2420,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
name: true,
|
name: true,
|
||||||
category: true,
|
category: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
requires_invoice: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2645,6 +2650,13 @@ async function reimbursementRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
const travelItems = reimbursement.items.filter((item) => item.expense_type?.category === "travel");
|
const travelItems = reimbursement.items.filter((item) => item.expense_type?.category === "travel");
|
||||||
const isTravelReimbursement = Boolean(reimbursement.linked_travel_request_id || travelItems.length);
|
const isTravelReimbursement = Boolean(reimbursement.linked_travel_request_id || travelItems.length);
|
||||||
|
const invoiceGuardGate = await evaluateFeatureFlag({
|
||||||
|
prisma: fastify.prisma,
|
||||||
|
config: fastify.config,
|
||||||
|
flagKey: FEATURE_FLAG_KEYS.reimbursementInvoiceGuard,
|
||||||
|
departmentId: reimbursement.department_id ?? null,
|
||||||
|
strictDepartment: true,
|
||||||
|
});
|
||||||
|
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
id: reimbursement.id,
|
id: reimbursement.id,
|
||||||
@@ -2696,6 +2708,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
expenseDate: item.expense_date?.toISOString?.()?.slice(0, 10) ?? null,
|
expenseDate: item.expense_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||||
amount: toMoneyString(item.amount),
|
amount: toMoneyString(item.amount),
|
||||||
description: item.description,
|
description: item.description,
|
||||||
|
assetPriorApprovalBasis: item.asset_prior_approval_basis ?? null,
|
||||||
expenseType: item.expense_type
|
expenseType: item.expense_type
|
||||||
? {
|
? {
|
||||||
id: item.expense_type.id,
|
id: item.expense_type.id,
|
||||||
@@ -2703,6 +2716,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
name: item.expense_type.name,
|
name: item.expense_type.name,
|
||||||
category: item.expense_type.category,
|
category: item.expense_type.category,
|
||||||
enabled: item.expense_type.enabled,
|
enabled: item.expense_type.enabled,
|
||||||
|
requiresInvoice: item.expense_type.requires_invoice,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
})),
|
})),
|
||||||
@@ -2740,6 +2754,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
}),
|
}),
|
||||||
approvalHistory: buildApprovalHistory(latestWorkflowInstance, approvalHistoryRows),
|
approvalHistory: buildApprovalHistory(latestWorkflowInstance, approvalHistoryRows),
|
||||||
riskFlags: riskFlags.map(buildPersistedRiskFlag),
|
riskFlags: riskFlags.map(buildPersistedRiskFlag),
|
||||||
|
invoiceGuardEnabled: invoiceGuardGate.enabled,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -3642,7 +3657,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
|
|
||||||
const expenseType = await tx.expenseType.findUnique({
|
const expenseType = await tx.expenseType.findUnique({
|
||||||
where: { id: body.expenseTypeId },
|
where: { id: body.expenseTypeId },
|
||||||
select: { id: true, enabled: true },
|
select: { id: true, enabled: true, category: true },
|
||||||
});
|
});
|
||||||
if (!expenseType || !expenseType.enabled) {
|
if (!expenseType || !expenseType.enabled) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
@@ -3652,6 +3667,14 @@ async function reimbursementRoutes(fastify) {
|
|||||||
details: { expenseTypeId: "invalid_or_disabled" },
|
details: { expenseTypeId: "invalid_or_disabled" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (expenseType.category === "assets" && !body.assetPriorApprovalBasis?.trim()) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { assetPriorApprovalBasis: "required_for_assets" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const created = await tx.reimbursementItem.create({
|
const created = await tx.reimbursementItem.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -3660,6 +3683,8 @@ async function reimbursementRoutes(fastify) {
|
|||||||
expense_date: parseDateOnlyString(body.expenseDate),
|
expense_date: parseDateOnlyString(body.expenseDate),
|
||||||
amount: body.amount,
|
amount: body.amount,
|
||||||
description: body.description,
|
description: body.description,
|
||||||
|
asset_prior_approval_basis:
|
||||||
|
expenseType.category === "assets" ? body.assetPriorApprovalBasis.trim() : null,
|
||||||
created_by: request.employee.id,
|
created_by: request.employee.id,
|
||||||
updated_by: request.employee.id,
|
updated_by: request.employee.id,
|
||||||
},
|
},
|
||||||
@@ -3669,6 +3694,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
expense_date: true,
|
expense_date: true,
|
||||||
amount: true,
|
amount: true,
|
||||||
description: true,
|
description: true,
|
||||||
|
asset_prior_approval_basis: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3687,6 +3713,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
expenseDate: result.item.expense_date.toISOString().slice(0, 10),
|
expenseDate: result.item.expense_date.toISOString().slice(0, 10),
|
||||||
amount: toMoneyString(result.item.amount),
|
amount: toMoneyString(result.item.amount),
|
||||||
description: result.item.description,
|
description: result.item.description,
|
||||||
|
assetPriorApprovalBasis: result.item.asset_prior_approval_basis ?? null,
|
||||||
},
|
},
|
||||||
totalAmount: toMoneyString(result.totalAmount),
|
totalAmount: toMoneyString(result.totalAmount),
|
||||||
});
|
});
|
||||||
@@ -3773,7 +3800,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
id: parsedItemId.data,
|
id: parsedItemId.data,
|
||||||
reimbursement_id: reimbursementId,
|
reimbursement_id: reimbursementId,
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true, expense_type_id: true, asset_prior_approval_basis: true },
|
||||||
});
|
});
|
||||||
if (!item) {
|
if (!item) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
@@ -3783,19 +3810,30 @@ async function reimbursementRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof parsedBody.data.expenseTypeId === "string") {
|
const targetExpenseTypeId = parsedBody.data.expenseTypeId ?? item.expense_type_id;
|
||||||
const expenseType = await tx.expenseType.findUnique({
|
const expenseType = await tx.expenseType.findUnique({
|
||||||
where: { id: parsedBody.data.expenseTypeId },
|
where: { id: targetExpenseTypeId },
|
||||||
select: { id: true, enabled: true },
|
select: { id: true, enabled: true, category: true },
|
||||||
|
});
|
||||||
|
if (!expenseType || !expenseType.enabled) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 409,
|
||||||
|
details: { expenseTypeId: "invalid_or_disabled" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const targetApprovalBasis =
|
||||||
|
parsedBody.data.assetPriorApprovalBasis === undefined
|
||||||
|
? item.asset_prior_approval_basis
|
||||||
|
: parsedBody.data.assetPriorApprovalBasis;
|
||||||
|
if (expenseType.category === "assets" && !(targetApprovalBasis ?? "").trim()) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { assetPriorApprovalBasis: "required_for_assets" },
|
||||||
});
|
});
|
||||||
if (!expenseType || !expenseType.enabled) {
|
|
||||||
throw new AppError({
|
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 409,
|
|
||||||
details: { expenseTypeId: "invalid_or_disabled" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await tx.reimbursementItem.update({
|
const updated = await tx.reimbursementItem.update({
|
||||||
@@ -3813,6 +3851,10 @@ async function reimbursementRoutes(fastify) {
|
|||||||
...(typeof parsedBody.data.description === "string"
|
...(typeof parsedBody.data.description === "string"
|
||||||
? { description: parsedBody.data.description.trim() }
|
? { description: parsedBody.data.description.trim() }
|
||||||
: {}),
|
: {}),
|
||||||
|
asset_prior_approval_basis:
|
||||||
|
expenseType.category === "assets"
|
||||||
|
? targetApprovalBasis.trim()
|
||||||
|
: null,
|
||||||
updated_by: request.employee.id,
|
updated_by: request.employee.id,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -3821,6 +3863,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
expense_date: true,
|
expense_date: true,
|
||||||
amount: true,
|
amount: true,
|
||||||
description: true,
|
description: true,
|
||||||
|
asset_prior_approval_basis: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3839,6 +3882,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
expenseDate: result.item.expense_date.toISOString().slice(0, 10),
|
expenseDate: result.item.expense_date.toISOString().slice(0, 10),
|
||||||
amount: toMoneyString(result.item.amount),
|
amount: toMoneyString(result.item.amount),
|
||||||
description: result.item.description,
|
description: result.item.description,
|
||||||
|
assetPriorApprovalBasis: result.item.asset_prior_approval_basis ?? null,
|
||||||
},
|
},
|
||||||
totalAmount: toMoneyString(result.totalAmount),
|
totalAmount: toMoneyString(result.totalAmount),
|
||||||
});
|
});
|
||||||
@@ -3982,7 +4026,18 @@ async function reimbursementRoutes(fastify) {
|
|||||||
const reimbursementItems = await tx.reimbursementItem.findMany({
|
const reimbursementItems = await tx.reimbursementItem.findMany({
|
||||||
where: { reimbursement_id: reimbursementId },
|
where: { reimbursement_id: reimbursementId },
|
||||||
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
||||||
select: { id: true, amount: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
amount: true,
|
||||||
|
asset_prior_approval_basis: true,
|
||||||
|
expense_type: {
|
||||||
|
select: { code: true, category: true, requires_invoice: true },
|
||||||
|
},
|
||||||
|
invoice_pool_items: {
|
||||||
|
where: { status: "bound" },
|
||||||
|
select: { id: true, total_amount: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!reimbursementItems.length) {
|
if (!reimbursementItems.length) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
@@ -4003,6 +4058,77 @@ async function reimbursementRoutes(fastify) {
|
|||||||
details: { items: "positive_amount_required", itemIds: invalidAmountItems },
|
details: { items: "positive_amount_required", itemIds: invalidAmountItems },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const missingAssetApprovalItemIds = reimbursementItems
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.expense_type.category === "assets" &&
|
||||||
|
!item.asset_prior_approval_basis?.trim(),
|
||||||
|
)
|
||||||
|
.map((item) => item.id);
|
||||||
|
if (missingAssetApprovalItemIds.length) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: {
|
||||||
|
items: "asset_prior_approval_basis_required",
|
||||||
|
itemIds: missingAssetApprovalItemIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoiceGuardGate = await evaluateFeatureFlag({
|
||||||
|
prisma: tx,
|
||||||
|
config: fastify.config,
|
||||||
|
flagKey: FEATURE_FLAG_KEYS.reimbursementInvoiceGuard,
|
||||||
|
departmentId: reimbursement.department_id ?? null,
|
||||||
|
strictDepartment: true,
|
||||||
|
});
|
||||||
|
if (invoiceGuardGate.enabled) {
|
||||||
|
const unlinkedBoundInvoiceCount = await tx.invoicePool.count({
|
||||||
|
where: {
|
||||||
|
linked_reimbursement_id: reimbursementId,
|
||||||
|
linked_item_id: null,
|
||||||
|
status: "bound",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const missingInvoiceItemIds = [];
|
||||||
|
const amountExceededItems = [];
|
||||||
|
for (const item of reimbursementItems) {
|
||||||
|
if (!item.expense_type.requires_invoice) continue;
|
||||||
|
const validInvoices = item.invoice_pool_items.filter(
|
||||||
|
(invoice) => invoice.total_amount != null,
|
||||||
|
);
|
||||||
|
if (!validInvoices.length) {
|
||||||
|
missingInvoiceItemIds.push(item.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const invoiceTotal = validInvoices.reduce(
|
||||||
|
(sum, invoice) => sum.plus(invoice.total_amount),
|
||||||
|
new Prisma.Decimal(0),
|
||||||
|
);
|
||||||
|
if (new Prisma.Decimal(item.amount).gt(invoiceTotal)) {
|
||||||
|
amountExceededItems.push({
|
||||||
|
itemId: item.id,
|
||||||
|
declaredAmount: toMoneyString(item.amount),
|
||||||
|
invoiceTotal: invoiceTotal.toFixed(2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (unlinkedBoundInvoiceCount || missingInvoiceItemIds.length || amountExceededItems.length) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: {
|
||||||
|
items: "invoice_item_guard_failed",
|
||||||
|
unlinkedInvoiceCount: unlinkedBoundInvoiceCount,
|
||||||
|
missingInvoiceItemIds,
|
||||||
|
amountExceededItems,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
reimbursement.total_amount = await recomputeTotalAmount(
|
reimbursement.total_amount = await recomputeTotalAmount(
|
||||||
tx,
|
tx,
|
||||||
@@ -4058,7 +4184,10 @@ async function reimbursementRoutes(fastify) {
|
|||||||
status: "bound",
|
status: "bound",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (boundInvoiceCount < 1) {
|
const requiresAnyInvoice = reimbursementItems.some(
|
||||||
|
(item) => item.expense_type.requires_invoice,
|
||||||
|
);
|
||||||
|
if (!invoiceGuardGate.enabled && requiresAnyInvoice && boundInvoiceCount < 1) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
code: "VALID_001",
|
code: "VALID_001",
|
||||||
message: "参数校验失败",
|
message: "参数校验失败",
|
||||||
|
|||||||
@@ -3,54 +3,69 @@ require("dotenv/config");
|
|||||||
const { createPrismaClient } = require("../prismaClient");
|
const { createPrismaClient } = require("../prismaClient");
|
||||||
|
|
||||||
const ENABLED_EXPENSE_TYPES = [
|
const ENABLED_EXPENSE_TYPES = [
|
||||||
{ code: "travel_airfare", name: "机票", category: "travel" },
|
{ code: "travel_airfare", name: "机票", category: "travel", description: "出差乘坐飞机发生的费用;请上传正式票据并填写对应行程。" },
|
||||||
{ code: "travel_train", name: "火车票", category: "travel" },
|
{ code: "travel_train", name: "火车票", category: "travel", description: "出差乘坐火车发生的费用;请上传正式票据并填写对应行程。" },
|
||||||
{ code: "travel_taxi", name: "打车费", category: "travel" },
|
{ code: "travel_taxi", name: "打车费", category: "travel", description: "出差期间必要的出租车或网约车费用;请上传正式票据并说明行程。" },
|
||||||
{ code: "travel_hotel", name: "住宿费", category: "travel" },
|
{ code: "travel_hotel", name: "住宿费", category: "travel", description: "出差住宿费用;请上传正式票据并填写住宿日期。" },
|
||||||
{ code: "travel_bus", name: "大巴车票", category: "travel" },
|
{ code: "travel_bus", name: "大巴车票", category: "travel", description: "出差乘坐城际大巴或客车发生的费用;请上传正式票据并填写对应行程。" },
|
||||||
{ code: "travel_meal", name: "伙食补助费", category: "travel" },
|
{ code: "travel_meal", name: "伙食补助费", category: "travel", description: "按符合条件的出差天数计算,无需发票;说明中填写日期范围、天数、标准和算式。", requiresInvoice: false },
|
||||||
{
|
{
|
||||||
code: "travel_local_transport_allowance",
|
code: "travel_local_transport_allowance",
|
||||||
name: "市内交通补助费",
|
name: "市内交通补助费",
|
||||||
category: "travel",
|
category: "travel",
|
||||||
|
description: "按符合条件的出差天数计算,无需发票;说明中填写日期范围、天数、标准和算式。",
|
||||||
|
requiresInvoice: false,
|
||||||
},
|
},
|
||||||
{ code: "travel_fuel", name: "油费", category: "travel" },
|
{ code: "travel_fuel", name: "油费", category: "travel", description: "经批准自驾或公务用车发生的燃油费用;请上传正式票据并说明行程。" },
|
||||||
{ code: "travel_toll", name: "过路费", category: "travel" },
|
{ code: "travel_toll", name: "过路费", category: "travel", description: "经批准自驾或公务用车发生的通行费用;请上传正式票据并说明行程。" },
|
||||||
{ code: "travel_parking", name: "停车费", category: "travel" },
|
{ code: "travel_parking", name: "停车费", category: "travel", description: "出差或公务期间发生的停车费用;请上传正式票据并说明事由。" },
|
||||||
{ code: "travel_visa", name: "签证费", category: "travel" },
|
{ code: "travel_visa", name: "签证费", category: "travel", description: "经批准的出境公务签证费用;请上传正式票据并说明对应事项。" },
|
||||||
{ code: "rd_materials", name: "研发材料", category: "rd" },
|
{ code: "rd_materials", name: "研发材料", category: "rd", description: "研发活动直接使用的材料费用;请上传正式票据并说明项目和用途。" },
|
||||||
{ code: "rd_testing", name: "测试费用", category: "rd" },
|
{ code: "rd_testing", name: "测试费用", category: "rd", description: "研发测试、检验或认证费用;请上传正式票据并说明项目和测试内容。" },
|
||||||
{ code: "rd_prototype", name: "样机制作", category: "rd" },
|
{ code: "rd_prototype", name: "样机制作", category: "rd", description: "研发样机、试制件制作费用;请上传正式票据并说明项目和用途。" },
|
||||||
{ code: "rd_software", name: "软件订阅", category: "rd" },
|
{ code: "rd_software", name: "软件订阅", category: "rd", description: "研发使用的软件许可或订阅费用;请上传正式票据并说明使用期限和用途。" },
|
||||||
{ code: "rd_external_service", name: "外部技术服务", category: "rd" },
|
{ code: "rd_external_service", name: "外部技术服务", category: "rd", description: "研发相关的外部技术服务费用;请上传正式票据并说明项目、服务内容和验收依据。" },
|
||||||
{ code: "production_raw_material", name: "生产原材料", category: "production" },
|
{ code: "production_raw_material", name: "生产原材料", category: "production", description: "生产直接使用的原材料费用;请上传正式票据并说明用途。" },
|
||||||
{ code: "production_processing", name: "加工费", category: "production" },
|
{ code: "production_processing", name: "加工费", category: "production", description: "委外加工或生产工序费用;请上传正式票据并说明加工内容。" },
|
||||||
{ code: "production_quality", name: "质量检测", category: "production" },
|
{ code: "production_quality", name: "质量检测", category: "production", description: "生产质量检测、检验或认证费用;请上传正式票据并说明检测对象。" },
|
||||||
{ code: "production_maintenance", name: "设备维护", category: "production" },
|
{ code: "production_maintenance", name: "设备维护", category: "production", description: "生产设备维修、保养或维护费用;请上传正式票据并说明设备和事项。" },
|
||||||
{ code: "production_energy", name: "生产能耗", category: "production" },
|
{ code: "production_energy", name: "生产能耗", category: "production", description: "可归属生产活动的能源费用;请上传正式票据并说明期间和用途。" },
|
||||||
{ code: "office_stationery", name: "文具耗材", category: "office" },
|
{ code: "office_stationery", name: "文具耗材", category: "office", description: "日常办公文具和消耗品;请上传正式票据并说明用途。" },
|
||||||
{ code: "office_supplies", name: "办公用品", category: "office" },
|
{
|
||||||
{ code: "office_property_fee", name: "物业费用", category: "office" },
|
code: "office_supplies",
|
||||||
{ code: "office_communication", name: "通讯费用", category: "office" },
|
name: "办公用品",
|
||||||
{ code: "office_postal", name: "邮寄快递", category: "office" },
|
category: "office",
|
||||||
{ code: "office_cleaning", name: "保洁服务", category: "office" },
|
description: "单条明细金额低于 2,000 元的办公用品或低值配件;多件同类物品请分开记载。2,000 元及以上通常选择办公电子设备。",
|
||||||
{ code: "business_client_meal", name: "客户招待", category: "business" },
|
},
|
||||||
{ code: "business_gift", name: "商务礼品", category: "business" },
|
{ code: "office_property_fee", name: "物业费用", category: "office", description: "办公场所物业管理等费用;请上传正式票据并说明费用期间。" },
|
||||||
{ code: "business_event", name: "活动执行", category: "business" },
|
{ code: "office_communication", name: "通讯费用", category: "office", description: "经批准的办公通讯费用;请上传正式票据并说明使用人和期间。" },
|
||||||
{ code: "business_marketing", name: "营销投放", category: "business" },
|
{ code: "office_postal", name: "邮寄快递", category: "office", description: "公务文件或物品的邮寄快递费用;请上传正式票据并说明寄送事项。" },
|
||||||
{ code: "business_training", name: "培训会议", category: "business" },
|
{ code: "office_cleaning", name: "保洁服务", category: "office", description: "办公场所保洁服务费用;请上传正式票据并说明服务期间。" },
|
||||||
{ code: "assets_equipment", name: "设备采购", category: "assets" },
|
{ code: "business_client_meal", name: "业务招待费", category: "business", description: "因业务接洽发生的招待费用;请上传正式票据并说明事由和参与对象。" },
|
||||||
{ code: "assets_furniture", name: "家具采购", category: "assets" },
|
{ code: "business_gift", name: "商务礼品", category: "business", description: "经事前批准的商务礼品费用;请上传正式票据并说明用途和对象。" },
|
||||||
{ code: "assets_it_hardware", name: "IT 硬件", category: "assets" },
|
{ code: "business_event", name: "活动执行", category: "business", description: "经批准的业务活动执行费用;请上传正式票据并说明活动和用途。" },
|
||||||
{ code: "utilities", name: "水电费", category: "other" },
|
{ code: "business_marketing", name: "营销投放", category: "business", description: "经批准的营销推广或广告投放费用;请上传正式票据并说明渠道和期间。" },
|
||||||
{ code: "rent", name: "房租租赁费", category: "other" },
|
{ code: "business_meeting", name: "会议费", category: "business", description: "经批准举办或参加会议发生的费用;请上传正式票据并说明会议事项。" },
|
||||||
{ code: "other_misc", name: "其他杂项", category: "other" },
|
{ code: "business_training_fee", name: "培训费", category: "business", description: "经批准参加或组织培训发生的费用;请上传正式票据并说明培训事项。" },
|
||||||
|
{ code: "assets_equipment", name: "设备采购", category: "assets", description: "经事前批准由个人垫付的通用设备采购;请填写批准人、批准时间和可核验依据。" },
|
||||||
|
{ code: "assets_furniture", name: "家具采购", category: "assets", description: "经事前批准由个人垫付的办公家具采购;请填写批准人、批准时间和可核验依据。" },
|
||||||
|
{
|
||||||
|
code: "assets_office_electronics",
|
||||||
|
name: "办公电子设备",
|
||||||
|
category: "assets",
|
||||||
|
description: "单条明细金额达到 2,000 元的办公电子设备通常选择本类型,并填写事前批准依据。该门槛不等同于会计固定资产确认标准。",
|
||||||
|
},
|
||||||
|
{ code: "utilities", name: "水电费", category: "other", description: "办公或生产经营相关的水电费用;请上传正式票据并说明费用期间。" },
|
||||||
|
{ code: "rent", name: "房租租赁费", category: "other", description: "经批准的场地、设备或其他租赁费用;请上传正式票据并说明租赁事项和期间。" },
|
||||||
|
{ code: "other_misc", name: "其他杂项", category: "other", description: "仅用于无法归入现有类型的必要支出;请上传正式票据并完整说明事由。" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DISABLED_LEGACY_EXPENSE_TYPES = [
|
const DISABLED_LEGACY_EXPENSE_TYPES = [
|
||||||
{ code: "transport", name: "legacy-交通费", category: "travel" },
|
{ code: "transport", name: "legacy-交通费", category: "travel" },
|
||||||
{ code: "meal", name: "legacy-餐费", category: "business" },
|
{ code: "meal", name: "legacy-餐费", category: "business" },
|
||||||
{ code: "rd", name: "legacy-研发费", category: "rd" },
|
{ code: "rd", name: "legacy-研发费", category: "rd" },
|
||||||
|
{ code: "business_training", name: "培训会议", category: "business" },
|
||||||
|
{ code: "assets_it_hardware", name: "IT 硬件", category: "assets" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_APPROVAL_RULES = [
|
const DEFAULT_APPROVAL_RULES = [
|
||||||
@@ -281,6 +296,13 @@ const DEFAULT_FEATURE_FLAGS = [
|
|||||||
department_ids: [],
|
department_ids: [],
|
||||||
note: "bootstrap default: force_off",
|
note: "bootstrap default: force_off",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
flag_key: "reimbursement_invoice_guard_v1",
|
||||||
|
override_mode: "force_off",
|
||||||
|
rollout_type: "global",
|
||||||
|
department_ids: [],
|
||||||
|
note: "bootstrap default: force_off",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
flag_key: "payment_batch_v1",
|
flag_key: "payment_batch_v1",
|
||||||
override_mode: "force_on",
|
override_mode: "force_on",
|
||||||
@@ -540,10 +562,10 @@ async function seedExpenseTypes(prisma) {
|
|||||||
where: { code: item.code },
|
where: { code: item.code },
|
||||||
update: {
|
update: {
|
||||||
name: item.name,
|
name: item.name,
|
||||||
description: null,
|
description: item.description ?? null,
|
||||||
category: item.category,
|
category: item.category,
|
||||||
sort_order: index + 1,
|
sort_order: index + 1,
|
||||||
account_code_default: null,
|
requires_invoice: item.requiresInvoice ?? true,
|
||||||
is_system_default: true,
|
is_system_default: true,
|
||||||
enabled: item.enabled,
|
enabled: item.enabled,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
@@ -552,10 +574,11 @@ async function seedExpenseTypes(prisma) {
|
|||||||
create: {
|
create: {
|
||||||
code: item.code,
|
code: item.code,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
description: null,
|
description: item.description ?? null,
|
||||||
category: item.category,
|
category: item.category,
|
||||||
sort_order: index + 1,
|
sort_order: index + 1,
|
||||||
account_code_default: null,
|
account_code_default: null,
|
||||||
|
requires_invoice: item.requiresInvoice ?? true,
|
||||||
is_system_default: true,
|
is_system_default: true,
|
||||||
enabled: item.enabled,
|
enabled: item.enabled,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const FEATURE_FLAG_KEYS = {
|
|||||||
invoiceVerification: "invoice_verification",
|
invoiceVerification: "invoice_verification",
|
||||||
leaveHolidayCalendar: "leave_holiday_calendar",
|
leaveHolidayCalendar: "leave_holiday_calendar",
|
||||||
budgetPrecheck: "budget_precheck_v1",
|
budgetPrecheck: "budget_precheck_v1",
|
||||||
|
reimbursementInvoiceGuard: "reimbursement_invoice_guard_v1",
|
||||||
paymentBatch: "payment_batch_v1",
|
paymentBatch: "payment_batch_v1",
|
||||||
portalHome: "portal_home_v1",
|
portalHome: "portal_home_v1",
|
||||||
globalSearch: "global_search_v1",
|
globalSearch: "global_search_v1",
|
||||||
@@ -98,6 +99,10 @@ function resolveEnvGate(config, flagKey) {
|
|||||||
return Boolean(config.budgetPrecheckEnabled);
|
return Boolean(config.budgetPrecheckEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (flagKey === FEATURE_FLAG_KEYS.reimbursementInvoiceGuard) {
|
||||||
|
return Boolean(config.reimbursementInvoiceGuardEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
if (flagKey === FEATURE_FLAG_KEYS.paymentBatch) {
|
if (flagKey === FEATURE_FLAG_KEYS.paymentBatch) {
|
||||||
return Boolean(config.paymentBatchEnabled);
|
return Boolean(config.paymentBatchEnabled);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
const Fastify = require("fastify");
|
||||||
|
|
||||||
|
const { AppError } = require("../../../src/utils/AppError");
|
||||||
|
const expenseTypesRoutes = require("../../../src/routes/expenseTypes");
|
||||||
|
|
||||||
|
const ACTOR_ID = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const EXPENSE_TYPE_ID = "22222222-2222-4222-8222-222222222222";
|
||||||
|
|
||||||
|
function buildRecord(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: EXPENSE_TYPE_ID,
|
||||||
|
code: "travel_meal",
|
||||||
|
name: "伙食补助费",
|
||||||
|
description: "按公司标准填写补助算式",
|
||||||
|
category: "travel",
|
||||||
|
sort_order: 10,
|
||||||
|
account_code_default: null,
|
||||||
|
requires_invoice: false,
|
||||||
|
is_system_default: true,
|
||||||
|
enabled: true,
|
||||||
|
created_at: new Date("2026-08-29T00:00:00.000Z"),
|
||||||
|
updated_at: new Date("2026-08-29T00:00:00.000Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildApp() {
|
||||||
|
const app = Fastify();
|
||||||
|
let record = buildRecord();
|
||||||
|
const auditRows = [];
|
||||||
|
const tx = {
|
||||||
|
expenseType: {
|
||||||
|
create: vi.fn(async ({ data }) => {
|
||||||
|
record = buildRecord({ ...data, id: EXPENSE_TYPE_ID, is_system_default: false });
|
||||||
|
return record;
|
||||||
|
}),
|
||||||
|
findUnique: vi.fn(async () => record),
|
||||||
|
update: vi.fn(async ({ data }) => {
|
||||||
|
record = { ...record, ...data };
|
||||||
|
return record;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
auditLog: {
|
||||||
|
create: vi.fn(async ({ data }) => {
|
||||||
|
auditRows.push(data);
|
||||||
|
return { id: `audit-${auditRows.length}` };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const prisma = {
|
||||||
|
expenseType: {
|
||||||
|
findMany: vi.fn(async () => [record]),
|
||||||
|
},
|
||||||
|
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||||
|
};
|
||||||
|
|
||||||
|
app.decorate("prisma", prisma);
|
||||||
|
app.decorate("authenticate", async (request) => {
|
||||||
|
request.employee = { id: ACTOR_ID, permissionCodes: ["system.settings"] };
|
||||||
|
});
|
||||||
|
app.decorate("requirePermission", () => async (request) => {
|
||||||
|
request.employee = { id: ACTOR_ID, permissionCodes: ["system.settings"] };
|
||||||
|
});
|
||||||
|
app.setErrorHandler((error, request, reply) => {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return reply.status(error.statusCode).send({
|
||||||
|
requestId: request.id,
|
||||||
|
error: { code: error.code, message: error.message, details: error.details ?? null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reply.status(500).send({ requestId: request.id, error: { code: "SYS_001" } });
|
||||||
|
});
|
||||||
|
await app.register(expenseTypesRoutes, { prefix: "/api" });
|
||||||
|
return { app, prisma, tx, auditRows, getRecord: () => record };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("expense type invoice requirement contract", () => {
|
||||||
|
it("maps requiresInvoice in list responses", async () => {
|
||||||
|
const { app, prisma } = await buildApp();
|
||||||
|
const response = await app.inject({ method: "GET", url: "/api/expense-types?enabled=true" });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data.items).toEqual([
|
||||||
|
expect.objectContaining({ code: "travel_meal", requiresInvoice: false }),
|
||||||
|
]);
|
||||||
|
expect(prisma.expenseType.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
select: expect.objectContaining({ requires_invoice: true }),
|
||||||
|
}));
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists and audits requiresInvoice on create and update", async () => {
|
||||||
|
const { app, tx, auditRows, getRecord } = await buildApp();
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/expense-types",
|
||||||
|
payload: {
|
||||||
|
code: "travel_meal",
|
||||||
|
name: "伙食补助费",
|
||||||
|
category: "travel",
|
||||||
|
sortOrder: 10,
|
||||||
|
requiresInvoice: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
expect(createResponse.json().data.requiresInvoice).toBe(false);
|
||||||
|
expect(tx.expenseType.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ requires_invoice: false }),
|
||||||
|
}));
|
||||||
|
expect(auditRows[0].extra).toEqual(expect.objectContaining({ requiresInvoice: false }));
|
||||||
|
|
||||||
|
const updateResponse = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/expense-types/${EXPENSE_TYPE_ID}`,
|
||||||
|
payload: { requiresInvoice: true },
|
||||||
|
});
|
||||||
|
expect(updateResponse.statusCode).toBe(200);
|
||||||
|
expect(updateResponse.json().data.requiresInvoice).toBe(true);
|
||||||
|
expect(getRecord().requires_invoice).toBe(true);
|
||||||
|
expect(auditRows[1].extra.changes.requiresInvoice).toEqual({ before: false, after: true });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -80,6 +80,19 @@ function buildPrisma({
|
|||||||
auditLog: {
|
auditLog: {
|
||||||
create: vi.fn().mockResolvedValue({ id: "audit-1" }),
|
create: vi.fn().mockResolvedValue({ id: "audit-1" }),
|
||||||
},
|
},
|
||||||
|
featureFlag: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "99999999-9999-4999-8999-999999999999",
|
||||||
|
flag_key: "reimbursement_invoice_guard_v1",
|
||||||
|
override_mode: "force_on",
|
||||||
|
rollout_type: "global",
|
||||||
|
department_ids: [],
|
||||||
|
note: "test",
|
||||||
|
version: 1,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
$queryRaw: buildQueryRawHandler(state),
|
$queryRaw: buildQueryRawHandler(state),
|
||||||
$transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)),
|
$transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)),
|
||||||
};
|
};
|
||||||
@@ -87,7 +100,7 @@ function buildPrisma({
|
|||||||
return { prisma, state };
|
return { prisma, state };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"], employeeId = "actor-1" }) {
|
async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"], employeeId = "actor-1", invoiceGuardEnabled = false }) {
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
|
|
||||||
app.decorate("prisma", prisma);
|
app.decorate("prisma", prisma);
|
||||||
@@ -96,6 +109,7 @@ async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"], e
|
|||||||
appOrigin: "http://localhost:5173",
|
appOrigin: "http://localhost:5173",
|
||||||
maxUploadSizeBytes: 10 * 1024 * 1024,
|
maxUploadSizeBytes: 10 * 1024 * 1024,
|
||||||
invoiceArchiveUnusedDays: 30,
|
invoiceArchiveUnusedDays: 30,
|
||||||
|
reimbursementInvoiceGuardEnabled: invoiceGuardEnabled,
|
||||||
});
|
});
|
||||||
app.decorate("authenticate", async (request) => {
|
app.decorate("authenticate", async (request) => {
|
||||||
if (!request.headers.authorization) {
|
if (!request.headers.authorization) {
|
||||||
@@ -133,6 +147,47 @@ async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"], e
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("invoicePool travel-order bind routes", () => {
|
describe("invoicePool travel-order bind routes", () => {
|
||||||
|
it("requires an explicit reimbursement item while the invoice guard is enabled", async () => {
|
||||||
|
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||||
|
const { prisma, state } = buildPrisma({
|
||||||
|
invoice: {
|
||||||
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
status: "available",
|
||||||
|
linked_reimbursement_id: null,
|
||||||
|
linked_item_id: null,
|
||||||
|
travel_order_id: null,
|
||||||
|
total_amount: "1683.00",
|
||||||
|
},
|
||||||
|
reimbursement: {
|
||||||
|
id: reimbursementId,
|
||||||
|
owner_id: "actor-1",
|
||||||
|
submitter_id: "actor-1",
|
||||||
|
status: "draft",
|
||||||
|
reimbursement_date: new Date("2026-05-08T00:00:00.000Z"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const app = await buildApp({
|
||||||
|
prisma,
|
||||||
|
permissionCodes: ["invoice.upload"],
|
||||||
|
invoiceGuardEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/bind",
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
payload: { reimbursementId },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().error.details).toEqual({
|
||||||
|
linkedItemId: "required_when_invoice_guard_enabled",
|
||||||
|
});
|
||||||
|
expect(state.invoice.status).toBe("available");
|
||||||
|
expect(prisma.reimbursementItem.create).not.toHaveBeenCalled();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a view-all nonparticipant before binding a reimbursement invoice", async () => {
|
it("rejects a view-all nonparticipant before binding 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({
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const TRAIN_TYPE_ID = "22222222-2222-4222-8222-222222222222";
|
|||||||
const BUS_TYPE_ID = "33333333-3333-4333-8333-333333333333";
|
const BUS_TYPE_ID = "33333333-3333-4333-8333-333333333333";
|
||||||
const TAXI_TYPE_ID = "44444444-4444-4444-8444-444444444444";
|
const TAXI_TYPE_ID = "44444444-4444-4444-8444-444444444444";
|
||||||
const TRANSPORT_TYPE_ID = "55555555-5555-4555-8555-555555555555";
|
const TRANSPORT_TYPE_ID = "55555555-5555-4555-8555-555555555555";
|
||||||
|
const MEAL_ALLOWANCE_TYPE_ID = "66666666-6666-4666-8666-666666666666";
|
||||||
|
|
||||||
function cloneDate(value) {
|
function cloneDate(value) {
|
||||||
return value ? new Date(value) : value;
|
return value ? new Date(value) : value;
|
||||||
@@ -130,6 +131,7 @@ async function buildApp(options = {}) {
|
|||||||
{ id: BUS_TYPE_ID, code: "travel_bus", name: "大巴车票", category: "travel", enabled: true },
|
{ id: BUS_TYPE_ID, code: "travel_bus", name: "大巴车票", category: "travel", enabled: true },
|
||||||
{ id: TAXI_TYPE_ID, code: "travel_taxi", name: "打车费", category: "travel", enabled: true },
|
{ id: TAXI_TYPE_ID, code: "travel_taxi", name: "打车费", category: "travel", enabled: true },
|
||||||
{ id: TRANSPORT_TYPE_ID, code: "transport", name: "legacy-交通费", category: "travel", enabled: true },
|
{ id: TRANSPORT_TYPE_ID, code: "transport", name: "legacy-交通费", category: "travel", enabled: true },
|
||||||
|
{ id: MEAL_ALLOWANCE_TYPE_ID, code: "travel_meal", name: "伙食补助费", category: "travel", enabled: true, requires_invoice: false },
|
||||||
],
|
],
|
||||||
travelRequests: (options.travelRequests ?? [buildTravelRequest()]).map((item) => cloneTravelRequest(item)),
|
travelRequests: (options.travelRequests ?? [buildTravelRequest()]).map((item) => cloneTravelRequest(item)),
|
||||||
auditLogs: [],
|
auditLogs: [],
|
||||||
@@ -195,6 +197,9 @@ async function buildApp(options = {}) {
|
|||||||
if (where?.enabled === true && !item.enabled) {
|
if (where?.enabled === true && !item.enabled) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (where?.requires_invoice === true && item.requires_invoice !== true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (ids && !ids.includes(item.id)) {
|
if (ids && !ids.includes(item.id)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -464,6 +469,34 @@ describe("reimbursement create enhancements", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps invoice-free allowance types available to reimbursement templates", async () => {
|
||||||
|
const { app, state } = await buildApp({
|
||||||
|
templates: [buildTemplate({
|
||||||
|
items: [{
|
||||||
|
expenseTypeId: MEAL_ALLOWANCE_TYPE_ID,
|
||||||
|
description: "伙食补助",
|
||||||
|
defaultAmount: "300.00",
|
||||||
|
}],
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/reimbursements",
|
||||||
|
payload: { templateId: TEMPLATE_ID, reimbursementDate: "2026-04-09" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data.items).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
expenseTypeId: MEAL_ALLOWANCE_TYPE_ID,
|
||||||
|
amount: "300.00",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(state.reimbursementItems).toHaveLength(1);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects templateId and linkedTravelRequestId when both are provided", async () => {
|
it("rejects templateId and linkedTravelRequestId when both are provided", async () => {
|
||||||
const { app } = await buildApp();
|
const { app } = await buildApp();
|
||||||
|
|
||||||
|
|||||||
@@ -97,13 +97,22 @@ async function buildApp(options = {}) {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
invoicePool: {
|
invoicePool: {
|
||||||
count: vi.fn(async () => 1),
|
count: vi.fn(async ({ where } = {}) => (
|
||||||
|
where?.linked_item_id === null
|
||||||
|
? (options.unlinkedBoundInvoiceCount ?? 0)
|
||||||
|
: (options.boundInvoiceCount ?? 1)
|
||||||
|
)),
|
||||||
},
|
},
|
||||||
reimbursementItem: {
|
reimbursementItem: {
|
||||||
findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)),
|
findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)),
|
||||||
findMany: vi.fn(async () => options.reimbursementItems ?? [
|
findMany: vi.fn(async () => (options.reimbursementItems ?? [
|
||||||
{ id: "item-1", amount: reimbursement.total_amount },
|
{ id: "item-1", amount: reimbursement.total_amount },
|
||||||
]),
|
]).map((item) => ({
|
||||||
|
asset_prior_approval_basis: null,
|
||||||
|
expense_type: { code: "office_supplies", category: "office", requires_invoice: true },
|
||||||
|
invoice_pool_items: [{ id: "invoice-1", total_amount: item.amount }],
|
||||||
|
...item,
|
||||||
|
}))),
|
||||||
aggregate: vi.fn(async () => ({
|
aggregate: vi.fn(async () => ({
|
||||||
_sum: { amount: options.authoritativeTotal ?? reimbursement.total_amount },
|
_sum: { amount: options.authoritativeTotal ?? reimbursement.total_amount },
|
||||||
})),
|
})),
|
||||||
@@ -169,6 +178,19 @@ async function buildApp(options = {}) {
|
|||||||
auditLog: {
|
auditLog: {
|
||||||
create: vi.fn(async () => ({ id: "audit-1" })),
|
create: vi.fn(async () => ({ id: "audit-1" })),
|
||||||
},
|
},
|
||||||
|
featureFlag: {
|
||||||
|
findUnique: vi.fn(async () => options.invoiceGuardEnabled ? {
|
||||||
|
id: "88888888-8888-4888-8888-888888888888",
|
||||||
|
flag_key: "reimbursement_invoice_guard_v1",
|
||||||
|
override_mode: "force_on",
|
||||||
|
rollout_type: "global",
|
||||||
|
department_ids: [],
|
||||||
|
note: "test",
|
||||||
|
version: 1,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
} : null),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const prisma = {
|
const prisma = {
|
||||||
@@ -194,6 +216,7 @@ async function buildApp(options = {}) {
|
|||||||
budgetControlMode: "warn",
|
budgetControlMode: "warn",
|
||||||
imNotifierEnabled: false,
|
imNotifierEnabled: false,
|
||||||
emailEnabled: false,
|
emailEnabled: false,
|
||||||
|
reimbursementInvoiceGuardEnabled: options.invoiceGuardEnabled ?? false,
|
||||||
});
|
});
|
||||||
app.decorate("hasPermission", (employee, permissionCode) => {
|
app.decorate("hasPermission", (employee, permissionCode) => {
|
||||||
const permissionCodes = employee?.permissionCodes ?? [];
|
const permissionCodes = employee?.permissionCodes ?? [];
|
||||||
@@ -306,6 +329,142 @@ describe("reimbursements routes expense standard integration", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires prior approval basis for assets items", async () => {
|
||||||
|
const { app, tx } = await buildApp({
|
||||||
|
reimbursementItems: [{
|
||||||
|
id: "asset-item",
|
||||||
|
amount: "2200.00",
|
||||||
|
expense_type: { code: "assets_office_electronics", category: "assets", requires_invoice: true },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().error.details).toEqual({
|
||||||
|
items: "asset_prior_approval_basis_required",
|
||||||
|
itemIds: ["asset-item"],
|
||||||
|
});
|
||||||
|
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips invoice amount checks for invoice-free allowance items when the guard is on", async () => {
|
||||||
|
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
||||||
|
const { app } = await buildApp({
|
||||||
|
invoiceGuardEnabled: true,
|
||||||
|
boundInvoiceCount: 0,
|
||||||
|
reimbursementItems: [{
|
||||||
|
id: "allowance-item",
|
||||||
|
amount: "180.00",
|
||||||
|
expense_type: { code: "travel_meal", category: "travel", requires_invoice: false },
|
||||||
|
invoice_pool_items: [],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an invoice-free allowance-only reimbursement when the guard is off", async () => {
|
||||||
|
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
||||||
|
const { app } = await buildApp({
|
||||||
|
invoiceGuardEnabled: false,
|
||||||
|
boundInvoiceCount: 0,
|
||||||
|
reimbursementItems: [{
|
||||||
|
id: "allowance-item",
|
||||||
|
amount: "180.00",
|
||||||
|
expense_type: { code: "travel_meal", category: "travel", requires_invoice: false },
|
||||||
|
invoice_pool_items: [],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the legacy whole-form invoice requirement for mixed or required items when the guard is off", async () => {
|
||||||
|
const { app, tx } = await buildApp({
|
||||||
|
invoiceGuardEnabled: false,
|
||||||
|
boundInvoiceCount: 0,
|
||||||
|
reimbursementItems: [
|
||||||
|
{
|
||||||
|
id: "allowance-item",
|
||||||
|
amount: "180.00",
|
||||||
|
expense_type: { code: "travel_meal", category: "travel", requires_invoice: false },
|
||||||
|
invoice_pool_items: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "required-item",
|
||||||
|
amount: "100.00",
|
||||||
|
expense_type: { code: "office_supplies", category: "office", requires_invoice: true },
|
||||||
|
invoice_pool_items: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().error.details).toEqual({ invoices: "bound_invoice_required" });
|
||||||
|
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks required-invoice items without linked invoices when the guard is on", async () => {
|
||||||
|
const { app, tx } = await buildApp({
|
||||||
|
invoiceGuardEnabled: true,
|
||||||
|
reimbursementItems: [{ id: "missing-invoice", amount: "180.00", invoice_pool_items: [] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().error.details).toEqual(expect.objectContaining({
|
||||||
|
items: "invoice_item_guard_failed",
|
||||||
|
missingInvoiceItemIds: ["missing-invoice"],
|
||||||
|
}));
|
||||||
|
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks bound invoices that have no explicit item link when the guard is on", async () => {
|
||||||
|
const { app, tx } = await buildApp({
|
||||||
|
invoiceGuardEnabled: true,
|
||||||
|
unlinkedBoundInvoiceCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().error.details).toEqual(expect.objectContaining({
|
||||||
|
items: "invoice_item_guard_failed",
|
||||||
|
unlinkedInvoiceCount: 1,
|
||||||
|
}));
|
||||||
|
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks declared amounts above the linked invoice total when the guard is on", async () => {
|
||||||
|
const { app, tx } = await buildApp({
|
||||||
|
invoiceGuardEnabled: true,
|
||||||
|
reimbursementItems: [{
|
||||||
|
id: "amount-over",
|
||||||
|
amount: "180.00",
|
||||||
|
invoice_pool_items: [{ id: "invoice-low", total_amount: "120.00" }],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().error.details.amountExceededItems).toEqual([{
|
||||||
|
itemId: "amount-over",
|
||||||
|
declaredAmount: "180.00",
|
||||||
|
invoiceTotal: "120.00",
|
||||||
|
}]);
|
||||||
|
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("recomputes a stale total before selecting the approval rule", async () => {
|
it("recomputes a stale total before selecting the approval rule", async () => {
|
||||||
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
||||||
const { app, tx } = await buildApp({ authoritativeTotal: "320.00" });
|
const { app, tx } = await buildApp({ authoritativeTotal: "320.00" });
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe("environment recovery classification contract", () => {
|
|||||||
|
|
||||||
it("classifies every env contract key exactly once", async () => {
|
it("classifies every env contract key exactly once", async () => {
|
||||||
const result = await validate();
|
const result = await validate();
|
||||||
expect(result.stdout).toContain("环境恢复分类键数: 165");
|
expect(result.stdout).toContain("环境恢复分类键数: 166");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips tracked paths deleted in the current worktree before reading metadata", async () => {
|
it("skips tracked paths deleted in the current worktree before reading metadata", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const MIGRATION_PATH = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../../prisma/migrations/20260829120000_reimbursement_expense_policy_invoice_guard/migration.sql",
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("reimbursement expense policy migration", () => {
|
||||||
|
it("adds backward-compatible defaults before applying allowance overrides", () => {
|
||||||
|
const source = fs.readFileSync(MIGRATION_PATH, "utf8");
|
||||||
|
|
||||||
|
const requiresInvoiceColumn = source.indexOf(
|
||||||
|
'ADD COLUMN "requires_invoice" BOOLEAN NOT NULL DEFAULT true',
|
||||||
|
);
|
||||||
|
const approvalBasisColumn = source.indexOf(
|
||||||
|
'ADD COLUMN "asset_prior_approval_basis" TEXT',
|
||||||
|
);
|
||||||
|
const allowanceOverride = source.indexOf(
|
||||||
|
'SET "requires_invoice" = false',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(requiresInvoiceColumn).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(approvalBasisColumn).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(allowanceOverride).toBeGreaterThan(approvalBasisColumn);
|
||||||
|
expect(source).toContain("'travel_meal'");
|
||||||
|
expect(source).toContain("'travel_local_transport_allowance'");
|
||||||
|
expect(source).not.toMatch(/DROP\s+(TABLE|COLUMN)/i);
|
||||||
|
expect(source).not.toMatch(/ALTER\s+COLUMN\s+[^;]+NOT\s+NULL/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -48,6 +48,49 @@ describe("default travel expense types", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("reimbursement expense policy defaults", () => {
|
||||||
|
it("keeps only the two allowances invoice-free and replaces ambiguous selectable types", () => {
|
||||||
|
expect(ENABLED_EXPENSE_TYPES).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ code: "business_client_meal", name: "业务招待费" }),
|
||||||
|
expect.objectContaining({ code: "business_meeting", name: "会议费" }),
|
||||||
|
expect.objectContaining({ code: "business_training_fee", name: "培训费" }),
|
||||||
|
expect.objectContaining({ code: "assets_office_electronics", name: "办公电子设备" }),
|
||||||
|
expect.objectContaining({ code: "travel_meal", requiresInvoice: false }),
|
||||||
|
expect.objectContaining({ code: "travel_local_transport_allowance", requiresInvoice: false }),
|
||||||
|
]));
|
||||||
|
expect(ENABLED_EXPENSE_TYPES.some((item) => item.code === "business_training")).toBe(false);
|
||||||
|
expect(ENABLED_EXPENSE_TYPES.some((item) => item.code === "assets_it_hardware")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides employee-readable guidance for every selectable expense type", () => {
|
||||||
|
for (const expenseType of ENABLED_EXPENSE_TYPES) {
|
||||||
|
expect(expenseType.description, expenseType.code).toEqual(expect.any(String));
|
||||||
|
expect(expenseType.description.trim().length, expenseType.code).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds the invoice guard disabled by default", () => {
|
||||||
|
expect(DEFAULT_FEATURE_FLAGS).toContainEqual({
|
||||||
|
flag_key: "reimbursement_invoice_guard_v1",
|
||||||
|
override_mode: "force_off",
|
||||||
|
rollout_type: "global",
|
||||||
|
department_ids: [],
|
||||||
|
note: "bootstrap default: force_off",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves finance-maintained account mappings during repeated init", async () => {
|
||||||
|
const upsert = vi.fn().mockResolvedValue({});
|
||||||
|
|
||||||
|
await seedExpenseTypes({ expenseType: { upsert } });
|
||||||
|
|
||||||
|
for (const call of upsert.mock.calls) {
|
||||||
|
expect(call[0].update).not.toHaveProperty("account_code_default");
|
||||||
|
expect(call[0].create).toEqual(expect.objectContaining({ account_code_default: null }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("small-team default reimbursement approval rules", () => {
|
describe("small-team default reimbursement approval rules", () => {
|
||||||
it("uses finance for every owner fallback and finance review node", () => {
|
it("uses finance for every owner fallback and finance review node", () => {
|
||||||
expect(DEFAULT_APPROVAL_RULES).toEqual([
|
expect(DEFAULT_APPROVAL_RULES).toEqual([
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户