实现报销费用政策与票据明细门禁(08-29-reimbursement-expense-policy-invoice-guard)
这个提交包含在:
@@ -969,6 +969,144 @@ reimbursement.total_amount = await recomputeTotalAmount(tx, reimbursementId, act
|
||||
await selectApprovalRule(reimbursement.total_amount);
|
||||
```
|
||||
|
||||
## Scenario: Expense-Type Invoice Policy And Item-Level Guard
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: changing expense-type invoice policy, asset reimbursement evidence,
|
||||
invoice-to-item binding, or reimbursement submission gates.
|
||||
- B1 schema/master-data fields are always active after migration. B2 explicit
|
||||
binding and amount blocks are separately guarded and deploy hard-off.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- DB: `ExpenseType.requires_invoice Boolean @default(true)` and nullable
|
||||
`ReimbursementItem.asset_prior_approval_basis String?`.
|
||||
- Runtime env: `REIMBURSEMENT_INVOICE_GUARD_ENABLED=false`; feature key:
|
||||
`reimbursement_invoice_guard_v1`; bootstrap DB override: `force_off`.
|
||||
- Expense-type API uses camel-case `requiresInvoice` on list/create/update.
|
||||
- Item create/update accepts nullable `assetPriorApprovalBasis`; detail returns
|
||||
that field plus `expenseType.requiresInvoice`.
|
||||
- Reimbursement detail returns `invoiceGuardEnabled` for the form department.
|
||||
- `POST /api/invoice-pool/:id/bind` requires `linkedItemId` while the guard is
|
||||
enabled; `POST /api/reimbursements/:id/submit` enforces the final gate.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- Only `travel_meal` and `travel_local_transport_allowance` are invoice-free in
|
||||
the initial policy. Invoice-free means skip invoice presence/amount checks;
|
||||
it does not remove explanation, formula, approval or attachment obligations.
|
||||
- Every `assets` item requires a trimmed approval basis on create, update and
|
||||
submit. Switching an item to a non-assets type clears the stored basis.
|
||||
- `office_supplies` at or above CNY 2,000 per item shows the same non-blocking
|
||||
office-electronics hint on editable PC/mobile forms and read-only finance
|
||||
detail. It does not change the type or decide accounting fixed-asset status.
|
||||
- Seed may update controlled names, descriptions, enabled state and invoice
|
||||
policy, but its update branch must preserve finance-maintained
|
||||
`account_code_default`; new types start with null until finance supplies a
|
||||
real mapping.
|
||||
- Evaluate the B2 flag with the reimbursement's `department_id`, never the
|
||||
actor's department. Env hard-off wins over DB state; the key must exist in
|
||||
config, the feature-key allowlist and the DB seed.
|
||||
- With B2 on, binding requires an existing item in the same reimbursement and
|
||||
may not reach the legacy auto-create branch. If an upload waits for OCR, keep
|
||||
the user-selected item ID with the pending invoice and send it when OCR later
|
||||
makes the invoice bindable. Submit blocks any bound invoice with no item
|
||||
link, each required-invoice item with no amount-bearing bound invoice, and
|
||||
each declared amount greater than its linked invoice total.
|
||||
- Sum and compare money with `Prisma.Decimal`. Invoice-free items are excluded.
|
||||
A lower declared amount remains allowed for finance to reconcile.
|
||||
- With B2 off, retain legacy auto-match/create compatibility. Keep the legacy
|
||||
whole-form invoice gate only when at least one item requires an invoice;
|
||||
allowance-only forms remain invoice-free because that is an always-active B1
|
||||
policy. Turning the flag off never deletes or unbinds relationships already
|
||||
written while it was on.
|
||||
- Form maintenance and invoice bind/unbind remain owner-or-submitter writes;
|
||||
`expense.view_all` and admin roles remain read-only for another employee's
|
||||
reimbursement.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Assets item without basis -> `400 VALID_001` with
|
||||
`assetPriorApprovalBasis=required_for_assets`; submit returns affected item
|
||||
IDs under `asset_prior_approval_basis_required`.
|
||||
- Guard-on bind without `linkedItemId` -> `400 VALID_001` with
|
||||
`required_when_invoice_guard_enabled`; an item outside the form remains
|
||||
`item_not_in_reimbursement` with zero writes.
|
||||
- Guard-on submit with unlinked bound invoices -> `invoice_item_guard_failed`
|
||||
plus `unlinkedInvoiceCount`.
|
||||
- Required item without an amount-bearing bound invoice -> same error plus
|
||||
`missingInvoiceItemIds`.
|
||||
- Declared amount above linked total -> same error plus entries containing only
|
||||
`itemId`, canonical `declaredAmount` and `invoiceTotal`.
|
||||
- Invoice-free allowance-only form with no invoice -> may submit with the guard
|
||||
either on or off when all other existing checks pass.
|
||||
- Env hard-off or DB force-off -> B2 item-level blocks are skipped; the legacy
|
||||
whole-form gate remains only for forms containing a required-invoice item.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: one required item explicitly links several same-purpose invoices whose
|
||||
total covers the declaration; submit passes with the guard on.
|
||||
- Good: a positive meal allowance-only form has no invoice and passes with the
|
||||
guard either on or off.
|
||||
- Base: guard off preserves an older draft and its auto-created link; later
|
||||
disabling the guard preserves links created during a guarded rollout.
|
||||
- Bad: compare every positive item to a zero invoice total, silently create an
|
||||
item while explicit selection is required, use JavaScript floating-point
|
||||
sums for money, or let repeated init erase finance account mappings.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Migration contract asserts additive columns, default true before the two
|
||||
allowance overrides, and no destructive DDL; production/local application
|
||||
still follows the explicit migration approval gate.
|
||||
- Expense-type route tests assert list mapping, create/update persistence and
|
||||
audit changes for `requiresInvoice`; seed tests assert the two allowances,
|
||||
disabled legacy types, default-off flag and account-code preservation.
|
||||
- Item/submit route tests cover assets create/update/submit, invoice-free zero
|
||||
invoice, missing link, unlinked bound invoice, declared-over-invoice and
|
||||
participant/nonparticipant side effects.
|
||||
- Bind tests cover flag on/off, missing/foreign/successful explicit links and
|
||||
prove the guard-on path does not create a reimbursement item.
|
||||
- PC/mobile Playwright covers descriptions, approval-basis input, the CNY 2,000
|
||||
warning, allowance-only submit projection, disabled add action until every
|
||||
selected invoice has an item, and pending-OCR binding retaining that item.
|
||||
- Run full backend Vitest with the pinned BIFF8 Python dependencies, Prisma
|
||||
validate/generate check, frontend typecheck/build, focused Playwright,
|
||||
process/docs/Trellis governance and `git diff --check`.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```js
|
||||
if (item.amount > linkedInvoices.reduce((sum, invoice) => sum + Number(invoice.total_amount), 0)) {
|
||||
block(); // also blocks invoice-free allowances and risks floating-point drift
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
update: { account_code_default: null }; // erases finance configuration on init
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```js
|
||||
if (item.expense_type.requires_invoice) {
|
||||
const total = linkedInvoices.reduce(
|
||||
(sum, invoice) => sum.plus(invoice.total_amount),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
if (new Prisma.Decimal(item.amount).gt(total)) blockItem(item.id, total);
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
update: { name, description, requires_invoice }; // preserve account_code_default
|
||||
create: { name, description, requires_invoice, account_code_default: null };
|
||||
```
|
||||
|
||||
## Scenario: Payment Batch Receipt Attention Count
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
在新工单中引用
屏蔽一个用户