docs(08-05-business-detail-routing): 记录验证结果与路由迁移规范

这个提交包含在:
2026-08-07 11:01:10 +08:00
父节点 1118bfadd7
当前提交 7c44e05ad6
共修改 5 个文件,包含 312 行新增和 104 行删除
@@ -1234,6 +1234,118 @@ const blocker = useBlocker(isDirty);
// reset to keep editing; proceed only after explicit discard.
```
## Scenario: Business Ledger List-To-Detail Route Migration
### 1. Scope / Trigger
- Trigger: an existing business ledger keeps a selected record inside the list
route through `?id=`, local selected-ID state or a list/detail split, and the
current Trellis task migrates that record to an independent detail route.
- Current examples are procurement and assets; the same contract applies to a
later contract, payment or other ledger migration only when that task has an
already-defined detail information structure.
### 2. Signatures
- Canonical detail path: `/<ledger>/:entityId`, built through one shared path
helper that trims the ID and encodes the path segment.
- Optional creation path: `/<ledger>/new`; register and test it as a static
route, never as a value of `:entityId`.
- Safe return state: `location.state.from = pathname + search`, accepted only
through a module-specific path allowlist.
- Legacy entry: `/<ledger>?id=<entityId>` performs a `replace` redirect to the
canonical detail and carries a normalized list source without `id`.
### 3. Contracts
- The list route owns URL-backed filters, pagination, list queries, creation
entry and row navigation. It must not request or render a selected entity's
detail merely because legacy `id` is present.
- The detail route owns the entity query, edit/actions/evidence/history and
dirty-form registration. It must not request the complete ledger list.
- Parse legacy `?id=` before mounting list queries. Empty or whitespace IDs stay
on the list; a non-empty ID redirects with `replace` so the old URL is not a
durable history entry.
- List filters use the URL as their single state source. Invalid enums, pages
and unauthorized scopes fall back to safe defaults before API request
construction; frontend scope controls never widen backend authorization.
- Row navigation uses `push` and carries the current normalized list source.
An explicit detail return uses the validated source with `replace`; browser
back remains native and must not reopen the detail after an explicit return.
- A create route that has no entity ID before submit uses its own page. On
successful creation, `replace` it with the new detail while preserving the
original list source so browser back cannot reopen a submitted form.
- New detail routes copy the original ledger read guard exactly. A create route
may use a narrower write permission, but must never reuse broad list-read
permission as create authority. Server object scope remains authoritative.
- Object-level `403` and `404` share one non-enumerating Chinese error state;
do not reveal whether the object exists or expose a partial cached summary.
- Every new application route must be classified in
`scripts/qa/route-manifest.json` with executable behavior/visual evidence as
required by the route's classification.
### 4. Validation & Error Matrix
- Missing/blank legacy ID -> render the list and issue no detail request.
- Valid legacy ID -> one `replace` to canonical detail; issue no list request
before the redirect.
- Invalid filter or unauthorized `scope=all` -> safe default / own-scope API
query; never an all-scope request.
- Direct detail refresh -> load the entity only and use the module list as the
fallback return target.
- Unsafe `state.from` (external, protocol-relative, hash, current page or
cross-module path) -> reject it and use the module list fallback.
- Dirty form + route/back/explicit-return navigation -> one shared unsaved
dialog; cancel preserves URL and values, discard performs one navigation.
- Static `/new` matched as `:entityId` or a route missing from the manifest ->
acceptance failure.
### 5. Good/Base/Bad Cases
- Good: a filtered procurement list opens a canonical detail, explicit return
restores the filters, and a legacy bookmark reaches the same detail without
first requesting the list.
- Base: a direct asset detail has no trusted source and returns safely to the
asset list; a mobile-preferred runtime may map it to an existing mobile
fallback without adding mobile detail capability.
- Bad: keep list and detail state synchronized through two effects, let
`/assets/new` call `/api/assets/new`, or grant create access because the user
can read the asset ledger.
### 6. Tests Required
- Source/unit contract for path encoding, blank ID, filter normalization,
unauthorized scope, legacy source normalization and return-source allowlist.
- Focused Playwright for list/detail request isolation, direct deep link,
legacy replace, filter return, static `/new`, create permission, `403/404`,
dirty cancel/discard and create-success history replacement.
- Consumer coverage for project relations, global search, notifications, page
metadata and mobile-runtime mapping when those entry points exist.
- `cd client && npm run typecheck && npm run build`.
- `node scripts/qa/check-flow-coverage.mjs --json --fail-on-gaps` and
`bash scripts/qa/run-local-candidate.sh list` after adding routes.
### 7. Wrong vs Correct
#### Wrong
```tsx
const [selectedId, setSelectedId] = useState(searchParams.get("id") ?? "");
useEffect(() => setSearchParams({ id: selectedId }, { replace: true }), [selectedId]);
<Route path="assets/:assetId" element={<AssetPage />} />
<Route path="assets/new" element={<AssetCreatePage />} />
```
#### Correct
```tsx
const legacy = resolveAssetLegacyRedirect(searchParams);
if (legacy) return <Navigate replace to={legacy.to} state={{ from: legacy.from }} />;
<Route path="assets/new" element={<RequirePermission anyOf={["asset.manage"]}>...</RequirePermission>} />
<Route path="assets/:assetId" element={<RequirePermission anyOf={ASSET_READ_PERMISSIONS}>...</RequirePermission>} />
```
## Convention: Detail Evidence Layout
**What**: Detail pages use one evidence-first structure so users can understand