feat(reimbursement-small-team-form-validation): 完善小团队报销填报校验

这个提交包含在:
2026-08-04 23:28:56 +08:00
父节点 f33c67eb20
当前提交 50c14c1b85
共修改 28 个文件,包含 1095 行新增和 30 行删除
@@ -780,6 +780,78 @@ extra = {
};
```
## Scenario: Small-Team Reimbursement Form And Submit Validation
### 1. Scope / Trigger
- Trigger: changing ordinary reimbursement item editing, total display, submit validation, expense-date warnings, evidence summary or asset-category hints.
- This is an internal 5–10 person workflow. Do not turn these rules into a configurable policy center or automatic accounting/asset platform.
### 2. Signatures
- Runtime env: `REIMBURSEMENT_MAX_AGE_DAYS=180|365`, default `365`.
- Header edit: `PATCH /api/reimbursements/:id` accepts `reimbursementDate` and nullable `description` for participant-owned `draft|rejected` forms.
- Item edit: `POST /api/reimbursements/:id/items` and `PATCH /api/reimbursements/:id/items/:itemId` require decimal `amount > 0`.
- Submit: `POST /api/reimbursements/:id/submit` locks the reimbursement, reads every item, validates amounts and persists the recomputed `total_amount` before budget and workflow resolution.
- Reimbursement precheck adds `evidenceSummary`, `timelinessChecks` and `assetHint`; these operational fields remain active when the budget-precheck feature flag is off.
### 3. Contracts
- PC, mobile and print display the server `totalAmount`; clients never calculate or submit an editable form total.
- A template or travel conversion may keep zero-value placeholders in a draft, but submission requires at least one item and every item amount strictly greater than zero.
- Date age uses `APP_TIMEZONE` business date plus date-only UTC arithmetic. Exactly 180/365 days is within policy; the first later day is overdue.
- Overdue and cross-calendar-year are independent non-blocking warnings. One item may carry both flags and both user messages.
- Evidence summary counts bound invoices, whole-form supporting attachments and bound invoices per item. An unlinked item warns but does not add a new block because allowances and whole-form evidence remain valid cases.
- `expense_type.category=assets` returns only candidate count and manual-registration guidance. It never creates an asset card; the `/assets` link remains permission-gated.
- Do not add a “历史费用补报” field, badge or process. Age and calendar-year facts provide the traceable signal.
### 4. Validation & Error Matrix
- Unsupported `REIMBURSEMENT_MAX_AGE_DAYS` -> application startup fails with `Invalid REIMBURSEMENT_MAX_AGE_DAYS`.
- Manual item amount `<= 0` -> `400 VALID_001 / amount=must_be_positive`.
- Submit with zero items -> `400 VALID_001 / items=at_least_one_required`.
- Submit with any item amount `<= 0` -> `400 VALID_001 / items=positive_amount_required` plus affected item IDs.
- Expense date older than policy or in another calendar year -> precheck `decision=warn`; submission remains possible after confirmation.
- Asset candidate without asset permission -> show manual next step only, never the asset-module link or asset data.
### 5. Good/Base/Bad Cases
- Good: three positive items display one server total; submit repairs a stale stored total before selecting the approval rule.
- Good: a 366-day-old asset item under the 365-day policy shows overdue, cross-year, evidence and manual-asset warnings without creating an asset.
- Base: a current office-supply item with one linked invoice passes existing gates and displays its linked count.
- Bad: sum item amounts in React, trust a stale `ReimbursementForm.total_amount` during submit, block all old invoices, or auto-create asset cards.
### 6. Tests Required
- Config tests cover default 365, allowed 180/365 and unsupported values.
- Service tests freeze the business time and assert threshold-day, first-overdue-day, cross-year, per-item invoice count, attachment count and asset candidate behavior.
- Route tests assert empty/non-positive submit errors and that the recomputed amount reaches approval-rule selection.
- Frontend typecheck/build plus PC/mobile Playwright assert header editing, item count, server total, evidence wording and absence of internal terms.
- Run Prisma validation, reimbursement focused regression, process/docs/Trellis gates and `git diff --check`.
### 7. Wrong vs Correct
#### Wrong
```js
const total = items.reduce((sum, item) => sum + Number(item.amount), 0);
await selectApprovalRule(reimbursement.total_amount); // may be stale
```
#### Correct
```js
const items = await tx.reimbursementItem.findMany({
where: { reimbursement_id: reimbursementId },
select: { id: true, amount: true },
});
if (!items.length || items.some((item) => Number(item.amount) <= 0)) {
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400 });
}
reimbursement.total_amount = await recomputeTotalAmount(tx, reimbursementId, actorId);
await selectApprovalRule(reimbursement.total_amount);
```
## Scenario: Payment Batch Receipt Attention Count
### 1. Scope / Trigger