收紧报销草稿读取边界 [09-04-reimbursement-state-gap-finance-view][R2]

这个提交包含在:
2026-09-05 09:13:53 +08:00
父节点 1b120e0829
当前提交 8e0af8235e
共修改 14 个文件,包含 704 行新增和 120 行删除
@@ -686,3 +686,56 @@ await prisma.$transaction(async (tx) => {
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_TERMINATED", id, { reason });
});
```
## Scenario: Reimbursement Draft Business-Fact Visibility
### 1. Scope / Trigger
- Trigger: changing reimbursement list/detail/material/search/export/statistics read scope or adding a new record-level read surface.
- `expense.view_all` covers submitted business facts; it does not turn another employee's never-submitted draft into a shared ledger record.
### 2. Signatures
- Shared builder: `buildReadableReimbursementWhere({ employee, scope, filters })` in `server/src/services/reimbursementVisibility.js`.
- Object check: `canReadReimbursementBusinessFact(prisma, reimbursement, employeeId)`.
- Workflow fact: `workflow_instances.entity_type='reimbursement' AND entity_id=<reimbursement-id>`; Prisma relation filters may use `workflow_instances: { some: {} }` because the relation is reimbursement-specific.
### 3. Contracts
- Owner or submitter keeps existing access to their draft. A non-draft keeps the existing role/team scope. Another employee's draft is readable only if at least one reimbursement workflow instance exists.
- Returned and withdrawn drafts remain business facts regardless of the latest workflow status or outcome. Never replace existence with a pending/latest/outcome whitelist.
- Apply the shared condition before pagination/counting to list, global search, reimbursement exports and dashboard/statistics. Raw SQL uses an indexed `EXISTS` on `entity_type + entity_id`.
- Detail, attachment file, attachment preview URL, material bundle and read-only `precheck` fail closed through the same fact check. A current or historical workflow reviewer remains independently readable through task assignment and is not granted edit permission.
### 4. Validation & Error Matrix
- Other employee + `draft` + no workflow -> excluded from collections and `403 PERM_001` on known-ID reads without revealing why.
- Other employee + `draft` + workflow exists -> existing global/team/reviewer scope applies.
- Owner/submitter draft -> existing own permission applies without requiring workflow history.
- Missing status/object or failed fact lookup -> deny; never assume a missing value is non-draft.
### 5. Good/Base/Bad Cases
- Good: finance sees a returned draft but not a colleague's unfinished first draft; the same result holds in list, search, export, statistics and materials.
- Base: a reviewer without `expense.view_all` opens the reimbursement attached to a current or historical assigned task.
- Bad: hide drafts only in React, filter every `draft`, or let `precheck` expose policy/risk facts for an otherwise unreadable draft.
### 6. Tests Required
- Table-driven global/team/own scope and owner/submitter/non-draft/returned/withdrawn/never-submitted classification.
- Route tests cover all five object-level read entries, including `precheck`, plus a reviewer non-regression.
- Structural and route tests keep list, search, both reimbursement exports, dashboard/statistics and raw SQL on the shared fact contract.
### 7. Wrong vs Correct
#### Wrong
```js
if (hasPermission(employee, "expense.view_all")) return reimbursement;
```
#### Correct
```js
const where = buildReadableReimbursementWhere({ employee, scope: "all", filters });
await canReadReimbursementBusinessFact(prisma, reimbursement, employee.id);
```