From abdd2503254982e7f6a6cfe2fae471bd300b6284 Mon Sep 17 00:00:00 2001 From: shunli Date: Wed, 2 Sep 2026 07:35:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20B-2b=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=8A=A5=E9=94=80=E5=B7=A5=E4=BD=9C=E5=8F=B0=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=EF=BC=88reimbursement-form-shared-component-migration=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spec/hyb-oa/frontend-react-vite-design.md | 76 +- .../design.md | 171 +++- .../implement.md | 78 +- .../prd.md | 152 +++- .../task.json | 10 +- .../design.md | 18 +- .../prd.md | 20 +- .../ReimbursementDesktopWorkspaceShell.tsx | 49 + .../ReimbursementItemEvidenceList.tsx | 94 +- .../ReimbursementMobileWorkspaceShell.tsx | 58 ++ client/src/lib/reimbursementWorkspace.ts | 96 ++ client/src/pages/MobileApprovalsPage.tsx | 211 +---- .../pages/MobileReimbursementDetailPage.tsx | 754 ++-------------- .../src/pages/MobileWorkflowTaskRoutePage.tsx | 64 ++ client/src/pages/ReimbursementDetailPage.tsx | 781 ++-------------- client/src/pages/WorkflowTaskDetailPage.tsx | 838 ++---------------- client/src/pages/WorkflowTaskRoutePage.tsx | 35 + .../ReimbursementApprovalAdapter.tsx | 446 ++++++++++ client/src/router/index.tsx | 7 +- .../lazy/MobileWorkflowTaskRoutePage.ts | 1 + .../src/router/lazy/WorkflowTaskDetailPage.ts | 1 - .../src/router/lazy/WorkflowTaskRoutePage.ts | 1 + client/src/router/lazy/index.ts | 3 +- .../e2e/dg7-mobile-information-dedupe.spec.ts | 70 +- .../interaction-pattern-unification.spec.ts | 34 + .../e2e/mobile-approvals-offline.spec.ts | 4 +- .../e2e/pc-detail-mobile-visual-wave3.spec.ts | 125 +-- .../e2e/r102-reimbursement-main-flow.spec.ts | 43 +- ...r109-mobile-reimbursement-feedback.spec.ts | 2 +- .../tests/e2e/r110-mobile-pc-routing.spec.ts | 2 +- .../r111-invoice-reimbursement-guard.spec.ts | 133 ++- .../r5-real-device-feedback-mobile-ux.spec.ts | 2 +- ...r6-mobile-responsive-system-hotfix.spec.ts | 2 +- .../r6-mobile-unified-design-rollout.spec.ts | 2 +- ...6-wecom-mobile-rendering-hardening.spec.ts | 2 +- .../e2e/r89-mobile-login-navigation.spec.ts | 73 +- .../e2e/workflow-action-confirm-ui.spec.ts | 20 +- scripts/qa/check-ui-design-rules.mjs | 95 ++ 38 files changed, 1850 insertions(+), 2723 deletions(-) create mode 100644 client/src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx create mode 100644 client/src/components/reimbursements/ReimbursementMobileWorkspaceShell.tsx create mode 100644 client/src/lib/reimbursementWorkspace.ts create mode 100644 client/src/pages/MobileWorkflowTaskRoutePage.tsx create mode 100644 client/src/pages/WorkflowTaskRoutePage.tsx create mode 100644 client/src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx create mode 100644 client/src/router/lazy/MobileWorkflowTaskRoutePage.ts delete mode 100644 client/src/router/lazy/WorkflowTaskDetailPage.ts create mode 100644 client/src/router/lazy/WorkflowTaskRoutePage.ts diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index 276a3f1b..9bc58691 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -489,70 +489,82 @@ reference, not a copy of Feishu or Ant Design branding. | `请假记录` / `考勤` | 业务台账列表 or record view | Enterprise WeChat-owned records, mirror status, exceptions and audit | Ordinary users do not see OA daily leave/punch approval creation; OA emergency entry only for authorized emergency/admin scope. | | Mobile routes | Mobile expression of the same template | Top status/focus, card list or sectioned detail | One bottom safe-area primary action; secondary actions in `更多` or confirmation. | -## Scenario: Reimbursement Approval Content Workbench +## Scenario: Unified Reimbursement Object Workbench ### 1. Scope / Trigger -- Trigger: a reimbursement approval task must expose the evidence needed for a decision without forcing the approver into a second page. +- Trigger: changing PC/mobile reimbursement edit, read-only or approval detail, or changing workflow task routing for a reimbursement entity. +- Purpose: keep one interactive business-object structure while allowing different desktop and mobile layout skeletons and different injected capabilities. ### 2. Signatures -- Mobile reimbursement target: `/mobile/reimbursements/:reimbursementId`. -- PC reimbursement target: `/reimbursements/:reimbursementId`. -- Mobile source state: ``. -- PC source state: ``; `currentPcSource` is the trusted internal `pathname + search` of the current reimbursement list or approval task. +- Business routes: `/reimbursements/:reimbursementId` and `/mobile/reimbursements/:reimbursementId`. +- Approval routes: `/workflow/tasks/:taskId` and `/mobile/approvals/:taskId`. +- Dispatchers: `WorkflowTaskRoutePage` and `MobileWorkflowTaskRoutePage` fetch one authorized `WorkflowTaskDetail`, then select the reimbursement adapter or the generic non-reimbursement detail. +- Pure layout shells: `ReimbursementDesktopWorkspaceShell` and `ReimbursementMobileWorkspaceShell`. +- Shared model/capability types: `ReimbursementWorkspaceModel`, `ReimbursementWorkspaceSlots` and `ReimbursementEvidenceCapabilities`. +- Structural preflight: `node scripts/qa/check-ui-design-rules.mjs --strict`. ### 3. Contracts -- The pending-task list card continues to open `/mobile/approvals/:taskId`; do not confuse task selection with the separate business-detail exit inside the task detail. -- When `WorkflowTaskDetail.reimbursement` exists, PC and mobile task details load the current reimbursement detail and render the shared `ReimbursementItemEvidenceList`; the complete original form remains a secondary exit rather than the only evidence path. -- The shared component receives a minimal projection. Review/open/reference and future edit/delete/link behavior are explicit callbacks; it never infers capabilities from roles. +- Approval links keep the task URL. A dispatcher recognizes `entityType=reimbursement` and renders the reimbursement approval adapter directly; it never mounts the generic detail first and never requires a `查看报销单` detour for core evidence. +- A task dispatcher owns the single task-detail query and passes the same result plus controlled refetch to the selected renderer. The reimbursement adapter may fetch current reimbursement detail but must not fetch the same task detail again. +- Desktop and mobile shells share the same model, section order, `ReimbursementItemEvidenceList` and capability contract. They may use different DOM/layout skeletons: desktop main evidence plus one sticky command panel; mobile focus cards plus a bottom safe-area action surface. +- Shells compose ready data and slots only. They contain no `useQuery`, `useMutation`, API/fetch call, role/permission inference, workflow/reimbursement state-machine decision, `mode === ...` comparison or `isMobile` business branch. `check-ui-design-rules.mjs --strict` enforces this boundary and rejects a dormant reimbursement branch in the generic approval pages. +- The shared evidence component receives a minimal projection. Open/reference/edit/delete/link/unlink behavior exists only through callbacks; the component never infers capabilities from roles. Approval injects view/reference/decision only, read-only injects view only, and edit injects maintenance/submission capabilities. - Submission-time fee-standard and budget facts come from the workflow context snapshot. Current item/invoice grouping, amount evidence and attachments come from the reimbursement detail. Approval pages do not call reimbursement precheck or present current budget data as a submission snapshot. - Reimbursement approval actions fail closed until workflow snapshot parsing and current reimbursement evidence are ready. Historical missing budget snapshots are explained; malformed snapshots disable all decision paths. - Mobile workflow caching uses an explicit persisted projection. Reimbursement items, invoices, attachments, amount evidence, material access data, workflow snapshot checks and shared component projections do not enter IndexedDB. Offline reimbursement approve/reject/return/draft is forbidden, while other workflow entity offline behavior remains unchanged. -- Mobile navigation passes the current approval task URL through the existing `state.from` convention so the business detail back button returns to that task. This is a one-level source contract, not a new nested return stack. -- PC reimbursement list and task-detail exits use the same one-level `state.from` contract. The detail page accepts only `/reimbursements` list URLs and `/workflow/tasks/:id`; absolute/protocol-relative URLs, hashes, other modules and the current page are rejected. A direct open or refresh safely falls back to `/reimbursements`. -- PC detail return is a visible text action immediately before the detail hero, uses `replace`, and is not duplicated inside the business primary-action group or `更多`. -- Approval actions remain on the approval task page. Opening the business detail does not approve, reject, return or mutate the task. +- The information order is compact header and reason, item with owned invoices, unlinked invoices when present, supporting attachments, real issues/differences when present, then history/progress. Normal state creates no `0 条` check card. Issue copy identifies the object, reason, impact and next step rather than exposing internal counters alone. +- `MobileStickyActionBar` and `MOBILE_STICKY_ACTION_PAGE_CLASS` are one contract. Add the page class only when `mobileActions` exists; omitting it lets `page-scaffold` transform capture the fixed bar, while adding it without a bar leaves false bottom padding. +- `/reimbursements/:id/print` remains a purpose-built print/PDF view. It is not a fourth interactive workbench and receives no workbench mutation. ### 4. Validation & Error Matrix -- Task has a reimbursement -> show the reimbursement-detail link. -- Task is another entity type -> do not show a reimbursement link; add that entity's route only in a scoped task. -- Reimbursement summary is absent -> render no broken or guessed target. -- Current or historical assigned workflow reviewers may read the reimbursement evidence projection through server-side authorization; unrelated employees remain forbidden. Preview/download endpoints continue their own permission, governance, audit and short-lived URL checks. +- Authorized task resolves to reimbursement -> dispatcher renders the approval adapter from the one task-detail result. +- Task resolves to leave/attendance/purchase/travel -> generic detail remains active, including its existing mobile offline approve/reject draft behavior. +- Current reimbursement detail, critical check or snapshot parsing is absent/invalid -> all reimbursement decision entry points are disabled and the action handler rejects a direct call with natural Chinese recovery copy. +- Mobile reimbursement approval is offline -> no evidence is loaded from IndexedDB and no approval draft is allowed; an explained disabled state remains visible. +- Mobile edit/approval has a bottom action -> fixed bar is visible at the viewport bottom and final evidence remains reachable above it. No bottom action -> no fixed-action page padding. +- Current or historical assigned reviewers may read the authorized current evidence; unrelated employees remain forbidden. Material endpoints continue their own permission, governance, audit and short-lived WeCom grant checks. ### 5. Good/Base/Bad Cases -- Good: approver opens the task, reviews each item with its invoices and inline submission warnings, references an object into the return comment, then acts from the single action panel. -- Base: a completed reimbursement task shows the current read-only evidence with a clear warning that it may contain post-return changes; historical comments and submission snapshots retain historical meaning. -- Bad: the task shows four summary fields plus a mandatory `查看报销单` detour, caches invoice evidence offline, or enables a decision when current evidence failed to load. -- Bad: make a static-looking summary card secretly clickable, change the pending-list click handler, or copy a partial invoice/attachment viewer into the task page. +- Good: edit, read-only and approval display the same item/invoice grouping; only the adapter-provided actions differ. The approver reads evidence and acts from one task URL. +- Base: a completed task shows the latest authorized read-only evidence with a clear time-semantics note while history and submission snapshots remain historical. +- Bad: keep a dormant reimbursement branch in `WorkflowTaskDetailPage`/`MobileApprovalsPage`, query task detail in both dispatcher and adapter, or put `if (mode === "approval")` around API calls in a shell. +- Bad: cache evidence/context data offline, show normal `0 条` summaries, render two copies of the reimbursement header, or leave a mobile fixed action captured by an animated ancestor. ### 6. Tests Required -- Mobile E2E: task detail -> reimbursement detail -> items/evidence visible -> back to the same task -> approval actions still visible. -- PC E2E: filtered reimbursement list -> task/detail -> visible source-aware return; the list path retains its query string, task detail returns to the same task, and direct-open detail falls back to the reimbursement list. -- Assert non-reimbursement task details do not receive a reimbursement target. -- Run `cd client && npm run typecheck && npm run build`. +- UI strict/self-test proves shell and legacy-branch rules are active and reports zero findings. +- Source/structure tests prove both task dispatchers contain one `fetchWorkflowTaskDetail` call, the approval adapter contains none, and both shells share the model/slots/capability contract and section order. +- PC/mobile E2E covers edit/read-only/approval, no-issue suppression, fail-closed, item-local invoice recovery, submit-confirm failure recovery, task deep links/back behavior and mobile fixed-action geometry. +- IndexedDB tests inspect the stored record and reject reimbursement item/invoice/attachment/context/material fields plus synthetic sentinel values. Non-reimbursement mobile offline draft create/sync/conflict remains green. +- Run `cd client && npm run typecheck && npm run build`, focused workflow/reimbursement Playwright, route/flow gates, Trellis governance and `git diff --check`. ### 7. Wrong vs Correct #### Wrong ```tsx -
handleTaskSelection(task.id)}>报销摘要
+export function ReimbursementWorkspaceShell({ mode, taskId }) { + const task = useQuery({ queryFn: () => fetchWorkflowTaskDetail(taskId) }); + return mode === "approval" ? : ; +} ``` #### Correct ```tsx - - 查看完整报销单 - +const taskQuery = useQuery({ + queryKey: ["workflow", "task", taskId], + queryFn: () => fetchWorkflowTaskDetail(taskId), +}); + +return taskQuery.data.workflowInstance?.entityType === "reimbursement" + ? + : ; ``` **Implementation order for design refactors**: diff --git a/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/design.md b/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/design.md index 6881802c..15ecf0a3 100644 --- a/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/design.md +++ b/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/design.md @@ -1,21 +1,164 @@ -# Design:B-2b 报销填写态共享组件迁移 +# Design:B-2b 统一报销工作台迁移 -## Current Decision Boundary +## 1. Architecture Decision -B-2b 不重新定义业务内容组件,而是采用 B-2a 已验证的展示模型、字段顺序和快照/实时标签;本阶段增加 edit capability、票据抽屉和提交前检查,并删除旧重复布局。 +采用“任务路由分派 + 唯一报销工作台内容合同 + PC/移动布局壳 + 三个模式适配器”。不把审批模式继续塞进现有大型详情页,也不让通用 workflow 页面继续拥有报销业务布局。 -详细交互在 B-2a 真人反馈后收敛,但以下架构边界已经冻结: +```text +/workflow/tasks/:taskId /mobile/approvals/:taskId + │ │ + └──── authorized task route dispatcher ┘ + │ + entityType=reimbursement + │ + ReimbursementApprovalAdapter + │ + ReimbursementDesktopWorkspaceShell + (共享内容合同) -- 共享组件不直接拥有 API 查询或 mutation;填写页通过显式能力和回调注入新增、编辑、删除、票据关联与恢复动作。 -- review/readonly 模式不接收 edit capability,不能靠 CSS 隐藏维护动作。 -- PC/移动复用同一 projection 与语义 DOM;响应式容器决定抽屉/底部 sheet 形态。 -- 服务端 B2 门禁仍是提交权威,前端检查只做同合同的提前反馈。 -- 旧布局在迁移验证通过后删除,不保留永久 feature flag 双实现。 +/reimbursements/:reimbursementId + │ + reimbursement route selector + ├─ participant + draft/rejected -> ReimbursementEditAdapter + └─ otherwise authorized -> ReimbursementReadonlyAdapter + │ + Desktop / Mobile WorkspaceShell + (共享内容合同) +``` -## Deferred Until Entry Conditions Are Met +任务分派器通过唯一一次已授权 task detail 查询选择实体渲染器,并把同一份 query result(含受控 refetch 能力)移交给所选渲染器。它不重复查询、不渲染报销摘要、不计算报销检查、不持有审批表单状态。非报销实体继续进入现有通用详情。 -- 票据抽屉在 PC 使用侧栏还是对话框、移动使用 bottom sheet 还是全屏层。 -- 编辑入口的最终密度、明细卡片折叠规则和提交摘要的视觉权重。 -- B-2a 真人反馈要求的字段顺序微调。 +## 2. Shell Contract -这些是 B-2b 启动前要补齐的设计细节,不改变本 PRD 的迁移承诺和验收结果。 +PC/移动布局壳都是纯组合组件,共享模型和 capability 合同,但不强求同一份 DOM,也不接收 `mode` 用于业务判断: + +```ts +type ReimbursementWorkspaceModel = { + header: ReimbursementWorkspaceHeaderModel; + evidence: ReimbursementWorkspaceEvidenceProjection; + issueSummary: ReimbursementWorkspaceIssueSummary | null; + timeline: ReimbursementWorkspaceTimelineModel; +}; + +type ReimbursementWorkspaceCapabilities = { + item?: ReimbursementEvidenceItemCapabilities; + invoice?: ReimbursementEvidenceInvoiceCapabilities; + attachment?: ReimbursementEvidenceAttachmentCapabilities; + decision?: ReimbursementDecisionCapabilities; +}; + +type ReimbursementWorkspaceShellProps = { + model: ReimbursementWorkspaceModel; + capabilities: ReimbursementWorkspaceCapabilities; + primaryActions?: ReactNode; + secondaryActions?: ReactNode; + statusSummary?: ReactNode; + transientFeedback?: ReactNode; +}; +``` + +`ReimbursementDesktopWorkspaceShell` 与 `ReimbursementMobileWorkspaceShell` 可以根据数据是否存在组合区块,也可以渲染注入的 slot;两者必须保持同一业务内容顺序和语义子组件,但可分别实现 PC 右栏与移动卡片/底部安全区布局。它们不能根据角色、permission、workflow 状态、报销状态或模式推导能力,不能用 `isMobile` 业务分支互相模拟。壳内禁止 `useQuery`、`useMutation`、API client 和业务 mutation state。纯视觉展开优先使用语义 HTML;确需本地视觉状态时不得承载业务事实。 + +## 3. Adapter Responsibilities + +### Edit Adapter + +- 获取/刷新报销详情、费用类型、项目、票据候选和 participant precheck。 +- 拥有表头、明细、票据、附件、提交、删除、OCR 状态与错误恢复 mutation/state。 +- 将可用动作投影为 capabilities;状态或权限不允许时不注入,不让壳二次判断。 +- 服务端提交/B2 结果覆盖客户端乐观判断。 + +### Approval Adapter + +- 消费路由边界移交的 workflow task detail/query result,不为同一 task 再发一份分派查询;另行获取实时报销详情。 +- 拥有 task detail 的业务解释和刷新策略、allowedActions、提交快照、处理历史、审批 mutation、退回引用、材料查看、联网状态和 fail-closed。 +- 只注入证据查看、引用和审批决策能力;编辑/关联/解绑 capability 必须不存在。 +- 任务、快照、实时报销内容任一关键部分未就绪时,输出不可决策模型和原因。 + +### Readonly Adapter + +- 只获取既有授权允许的报销详情、处理进度和材料查看能力。 +- 不请求参与人专用 precheck,不持有任何业务 mutation。 +- 不注入编辑、提交或 workflow 决策能力。 + +共享查询键、材料打开 helper、展示投影和格式化函数可以复用;适配器不能通过一个巨大 hook 返回所有模式的 mutation 再由页面隐藏。 + +## 4. Information Architecture + +### PC + +```text +报销单号 + 状态 +报销人 · 日期 · 合计 +事由 +┌────────────────────────────────────┬────────────────────┐ +│ 明细及所属票据 │ 当前节点/状态 │ +│ 未归属票据(仅有时) │ 可决策状态 │ +│ 补充附件 │ 异常锚点(仅有时) │ +│ 异常/快照差异(仅有时) │ 审批/提交动作 │ +│ 处理历史/进度 │ sticky │ +└────────────────────────────────────┴────────────────────┘ +``` + +### Mobile + +标题/状态 → 报销人/日期/合计 → 事由 → 明细/票据 → 未归属(仅有时)→ 附件 → 异常/差异(仅有时)→ 历史/进度 → 底部动作。 + +正常检查不生成卡片。异常模型必须带对象引用和锚点: + +```ts +type ReimbursementWorkspaceIssue = { + key: string; + target: { kind: "item" | "invoice" | "submission"; displayLabel: string }; + severity: "blocking" | "review" | "info"; + reason: string; + impact: string; + nextStep?: string; + anchorId: string; +}; +``` + +右栏只投影非零、可定位的问题。快照缺失是 `info`,视觉低于 blocking/review;快照与实时事实一致时不生成独立差异区。 + +## 5. Route and Navigation + +- PC `/workflow/tasks/:taskId` 与移动 `/mobile/approvals/:taskId` 保持不变,避免破坏通知、待办、刷新和深链。 +- 分派期间显示统一加载骨架;不得先挂载 `WorkflowTaskDetailPage`/通用移动详情再切换报销工作台。 +- `/reimbursements/:id` 继续是报销记录入口。返回目标由合法、受限的 navigation state/source helper 解析;审批工作台不再提供“返回审批详情”的自循环外链。 +- 新增或重分类路由时同步 `scripts/qa/route-manifest.json`;如果只是同一路由内分派,也要为 reimbursement renderer 增加行为证据。 + +## 6. Legacy Removal + +最终候选删除: + +- `WorkflowTaskDetailPage` 的报销 DetailField、报销证据/检查区、报销右栏摘要与报销专用动作分支。 +- `MobileApprovalsPage` 的报销内容、报销检查、报销离线解释和报销动作业务分支;通用列表/非报销详情可保留。 +- `ReimbursementDetailPage`/`MobileReimbursementDetailPage` 中被工作台替代的旧明细表格和旧材料分组。 +- 旧报销专用文案、测试 fixture 与只为休眠路径服务的 helper/import。 + +允许在实现过程的未提交工作区短暂双接线;业务候选提交前必须用结构测试/`rg` allowlist 证明旧分支为 0。不得保留 feature flag 双轨。 + +上述壳层禁用项与通用审批旧分支清零必须实现为 `scripts/qa/check-ui-design-rules.mjs` 的 error 规则,并通过既有 `--strict` 部署 preflight;单元测试只补行为和类型证据,不能替代该门禁。 + +## 7. Data and Permission Contracts + +- 不改后端 schema。现有 reimbursement detail、workflow detail、材料预览和动作 API 保持权威。 +- 提交时费用标准/预算来自 workflow context 快照;当前票据/附件来自 reimbursement detail。 +- approval adapter 复用 B-2a IndexedDB 白名单投影;工作台 model/evidence 不进入 cache。 +- edit adapter 继续要求参与人 + `draft/rejected` + 现有 permission;readonly/approval 不因 UI 复用获得写能力。 +- 材料 URL、访问参数和原始 payload 不进入 task、日志、浏览器持久缓存或错误文案。 + +## 8. Print Boundary + +`/reimbursements/:id/print` 继续作为专用打印/PDF 视图,允许独立的纸面布局。它不参与三模式交互工作台,不持有工作台 mutation,也不作为审批取得核心内容的入口。本轮只做回归,避免字段删除误伤打印;不迁移或重新设计打印结构。 + +## 9. Rollout and Rollback + +- 单一独立候选上线,不新增 migration、seed、flag 或生产数据写入。 +- 切换前必须完成 PC/移动三模式和非报销回归;上线后使用公网真实 bundle + 合成 API 验收,不操作真人单据。 +- 回滚是完整回到 B-2a 已部署版本;不保留运行时双壳开关。 +- 真实三张 rejected 单据可与开发并行,任何反馈只通过新的规划修订进入本任务,不直接现场改产品代码。 + +## 10. UI/UX Basis + +本任务不引入新视觉系统。采用 `ui-ux-pro-max` 对本场景适用的通用原则:一致标题层级、组合优于模式继承、异常不只靠颜色、反馈明确、触控目标至少 44×44、渐进披露避免把所有解释预先铺开。其数据库没有命中特定“审批工作台”React 模板,因此不采用不存在的模板结论,继续以项目现有组件和 `docs/产品设计规则.md` 的信息架构为基线。 diff --git a/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/implement.md b/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/implement.md index bb8b53c7..98b7d8db 100644 --- a/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/implement.md +++ b/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/implement.md @@ -1,22 +1,68 @@ -# Implementation Plan:B-2b 报销填写态共享组件迁移 +# Implementation Plan:B-2b 统一报销工作台迁移 -## Entry Gate +## Before Development -- [ ] B-2a 已部署并取得至少一次真人审批反馈。 -- [ ] 回填本任务最终 drawer/sheet 交互与字段顺序。 -- [ ] 用户重新批准更新后的 B-2b planning summary。 +- [x] 用户批准最新 `prd.md`、`design.md`、`implement.md`。 +- [x] 切换当前 Trellis task 到 B-2b 并运行 `task.py start`;实施前加载 `trellis-before-dev`。 +- [x] 读取 frontend、approval workflow、reimbursement/OCR、security/privacy、code reuse 与 route manifest 规约。 +- [x] 冻结现有 PC/移动三模式、权限/状态、路由/返回和打印页回归矩阵。 +- [x] 记录 B-2a 当前 DOM/test-id、公共 projection、IndexedDB 投影与可回滚提交基线。 -## Planned Sequence +## Implementation Order -1. [ ] 为 B-2a 共享组件接入 edit capability,保持 review/readonly 零维护动作。 -2. [ ] PC 填写态迁移明细、票据和就近检查。 -3. [ ] 移动填写态迁移相同语义结构与安全区操作。 -4. [ ] 接入票据抽屉和未归属票据恢复。 -5. [ ] 接入提交前摘要与服务端错误就近定位。 -6. [ ] 删除被替代的旧明细/材料/只读展示分支。 -7. [ ] 完成状态/权限矩阵、全量前端门禁和独立部署验证。 +1. [x] 先在 `scripts/qa/check-ui-design-rules.mjs` 建立 error 级结构门禁:PC/移动布局壳禁止 query/mutation/API/role/status/mode 请求分支;通用审批页最终禁止报销业务渲染;旧分支允许清单仅覆盖分派器,并补 self-test。 +2. [x] 抽取统一报销头、异常模型、快照/实时差异和三模式共享展示 projection 纯函数;补正常/异常/缺失边界测试。 +3. [x] 建立无业务状态的 PC/移动布局壳,共享模型、区块顺序、`ReimbursementItemEvidenceList` 与 capability 合同;PC 实现右栏 sticky,移动实现卡片 + 底部安全区,不复用同一 DOM 或引入 `isMobile` 业务分支。 +4. [x] 在 PC/移动 task route 建立唯一 task detail 查询和授权后实体分派;把同一 query result/refetch 入口移交渲染器,报销进入 approval adapter,非报销继续通用详情;避免重复 task 查询和通用页闪现。 +5. [x] 建立 approval adapter:消费已移交的 task detail,另取实时报销 detail,迁移 allowedActions、快照、task 刷新策略、fail-closed、退回引用、材料查看与 IndexedDB/离线合同。 +6. [x] 删除 `WorkflowTaskDetailPage` 与 `MobileApprovalsPage` 的报销业务内容、检查、右栏摘要和休眠兜底;立即跑非报销审批回归。 +7. [x] 建立 readonly adapter,把获准只读报销详情、材料查看和处理进度接入同一工作台,不请求参与人 precheck,不注入 mutation。 +8. [x] 建立 edit adapter,迁移表头/明细/票据/附件维护、票据抽屉、未归属恢复、提交前检查和 B2 阻断后再次提交;所有能力通过 capability 注入。 +9. [x] 删除 PC/移动旧明细表格、旧报销材料分组和被替代 helper/test fixture;确保最终候选无双实现或 feature flag 兜底。 +10. [x] 落实七条信息收敛:紧凑头、唯一事由、正常项隐藏、异常就近、差异条件显示、右栏去重、动作解释按需;补可访问性和锚点/focus 回归。 +11. [x] 回归 `/reimbursements/:id/print`,证明打印页未被工作台拆分误伤且没有获得交互 mutation。 +12. [x] 更新与新合同冲突的父任务、frontend/approval/reimbursement spec 或产品事实源;不顺带处理无关治理债。 -## Rollback +## Verification Matrix -- 独立于 B-2a 形成候选与回滚点。 -- 不改变 B2 服务端门禁或既有票据关联数据;代码回滚恢复旧填写 UI,不删除业务数据。 +### Structural + +- [x] `check-ui-design-rules.mjs --strict` 对 PC/移动壳中的 `useQuery|useMutation|api/|roleCodes|hasPermission|workflow status|reimbursement status|mode ===` 命中报 error(仅显式视觉 allowlist 可例外),并由 self-test 证明规则有效。 +- [x] task route 每次详情加载只发一份授权 task detail 查询;approval adapter 复用该结果和 refetch 入口,不产生同 key 重复请求。 +- [x] 三适配器 capability 快照准确:edit 有维护/提交、approval 有查看/引用/决策、readonly 只有查看。 +- [x] 通用 PC/移动审批详情不再导入报销证据组件或包含报销摘要/检查文案;唯一 `reimbursement` 判断位于授权分派层允许清单。 +- [x] 旧明细表/材料布局与休眠测试 fixture 删除,没有双实现 flag。 + +### Behavior + +- [x] PC/移动 edit:draft/rejected 新增/编辑/删除明细、PDF 发票上传/选择、关联/解绑、未归属恢复、附件维护、提交预检、B2 阻断、修复后再次提交。 +- [x] PC/移动 readonly:参与人 reviewing 与非参与人获准查看;无维护、无审批能力;材料权限/审计不变。 +- [x] PC/移动 approval:pending/已处理、快照 ready/missing/invalid、detail failure、材料单项 failure、退回引用、approve/return/reject/transfer 和联网 fail-closed。 +- [x] 非报销 workflow:请假、补卡、采购、差旅通用详情和移动离线草稿保持基线。 +- [x] 路由:列表进入、通知深链、刷新、后退、移动分流、无权 403/不泄露、无返回循环。 +- [x] 信息:正常 fixture 无“0 条”检查/常驻动作解释;异常 fixture 每项都有对象、原因、影响、下一步和可聚焦锚点;只有快照/实时不一致时出现差异。 + +### Commands + +- [x] 相关前端单元/组件/Playwright;`cd client && npm run typecheck && npm run build`。 +- [x] `node scripts/qa/check-ui-design-rules.mjs --strict` 与 `--self-test`。 +- [x] route manifest/candidate gate、相关后端 B2/workflow/detail/material tests、`cd server && npm run prisma:validate`。 +- [x] `python3 ./.trellis/scripts/task.py validate 09-01-reimbursement-form-shared-component-migration`。 +- [x] `node scripts/qa/check-process-contract.mjs --baseline --json`、`node scripts/qa/check-trellis-governance.mjs`、敏感内容检查、docs registry(如需)、`git diff --check`。 +- [x] 因涉及 workflow 分派、权限/状态和报销动作,实施后执行只读 review;inline 模式由主会话完成,不派发 implement/check 子代理。 + +验证说明:移动离线专项的默认 iPhone 设备配置会选择本机未安装的 WebKit;本轮按既有口径改用 Chromium + iPhone 视口执行,3/3 通过。该结果验证 IndexedDB、离线分流和 fail-closed 合同,不替代 Safari/WebView 渲染验收。 + +## Commit and Deployment Boundary + +- [ ] 代码检查通过后按中文 task 引用提交;task 元数据回填独立提交,不 amend。 +- [ ] 未经明确授权不 push、不部署、不写生产数据。 +- [ ] 部署候选无 migration/schema/flag/seed;使用既有生产入口与独立回滚基线。 +- [ ] 公网用合成三模式任务验证,不碰 `0016/0017/0018` 或其他真人单据。 +- [ ] 部署与真人反馈完成前,B-2b 和父任务都不得归档。 + +## Rollback Points + +- 适配器接线或非报销回归失败:停止在本地,不删除旧分支,修正后重新验证。 +- 最终旧分支删除后检查失败:回到本任务候选前提交,不引入运行时双轨 flag。 +- 公网验收失败:回滚到 B-2a 生产基线,保留数据库与业务数据不变。 diff --git a/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/prd.md b/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/prd.md index 61a212a0..8c0ebfeb 100644 --- a/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/prd.md +++ b/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/prd.md @@ -1,71 +1,139 @@ -# B-2b 报销填写态共享组件迁移 +# B-2b 统一报销工作台迁移 ## Goal -在 B-2a 共享内容组件经真实审批使用验证后,把 PC/移动报销填写态迁入同一业务结构,消除旧填写态、新审批态和旧只读态长期并存,并完成此前延期的票据抽屉与提交前检查体验。 +把报销从“填写详情页、审批详情页、旧只读详情页分别组织同一业务对象”收敛为唯一的交互式报销工作台。工作台以报销内容为主体,通过 `edit`、`readonly`、`approval` 三个模式适配器注入数据、状态和能力;审批只是叠加在报销对象上的流程与决策能力,不再复制一套报销页面壳。 + +本任务优先服务当前唯一有真实流转的“报销 → 审批 → 付款”链路。请假/补卡继续以企业微信为主,采购/差旅保持冻结的低频能力,不因追求形式一致而同步重构。 + +## Background and Confirmed Facts + +- B-2a 已部署到生产并通过公网真实 bundle 的 PC/移动合成验收,解决了审批人必须跳转才能取得明细、票据和附件的问题。 +- B-2a 是经批准的过渡交付:先恢复核心内容可达性并产出 `ReimbursementItemEvidenceList`、快照投影、离线白名单与 fail-closed 合同;B-2b 负责根据真人目视反馈收敛页面结构。 +- 真人目视反馈确认:通用审批壳的流程摘要、报销六字段摘要、新证据区和右侧检查计数存在重复;解释性文字按系统内部状态平铺,审批人需要的“哪条明细、什么问题、是否阻断、如何处理”反而不突出。 +- `ReimbursementDetailPage.tsx` 与 `MobileReimbursementDetailPage.tsx` 已是高状态、高 mutation 的大型页面。继续在其中加入 `if (approvalMode)` 会把查询、权限、编辑和审批职责耦合成更难维护的巨型组件。 +- 项目定位决定采用方案 A:只有报销任务进入业务对象工作台的审批模式;其他 workflow 实体继续使用通用审批详情。 +- `/workflow/tasks/:taskId` 与 `/mobile/approvals/:taskId` 仍是审批深链和任务身份入口;报销任务在任务元数据解析后交给报销审批适配器,不要求用户再跳到另一业务路由。 +- B-2a 的提交时费用标准/预算快照与当前票据事实仍是两类不同时间语义,不因页面合并而混用。 ## Entry Conditions -- B-2a 已部署并通过 PC/移动公网核验。 -- 至少取得一次真实报销审批反馈,确认审批人能理解明细—票据对应、快照/实时标签和退回引用。 -- B-2a 共享组件 API 已冻结;若真实反馈要求改字段顺序,先回写 B-2a 组件合同再启动迁移。 +- B-2a 已部署并通过生产 `/ready`、公网 bundle 与合成业务验收。 +- 已取得一次真人目视反馈,并据此冻结方案 A、信息收敛规则和壳/适配器边界。 +- 本 PRD、`design.md` 与 `implement.md` 经用户复核批准后,B-2b 才可进入实施。 +- 三张真实 `rejected` 单据的员工修改与反馈可以并行推进,不是本任务启动或完成的阻塞条件;反馈如改变已批准产品行为,必须回到规划阶段修订。 ## Scope -- PC `ReimbursementDetailPage` 与移动 `MobileReimbursementDetailPage` 的 draft/rejected 填写态及非参与人只读内容投影。 -- B-2a 共享组件的 edit 能力、票据抽屉、提交前检查和错误恢复。 -- 删除被共享组件替代的旧明细/材料展示分支,并保持现有 API、权限和 B2 服务端门禁权威。 +- 建立 PC/移动共享内容合同的唯一交互式报销工作台;PC 与移动允许使用各自布局壳,但必须消费同一展示模型、区块顺序、语义组件和 capability 契约。 +- 建立 `edit`、`readonly`、`approval` 三类模式适配器;查询、业务状态、权限判断、mutation、错误恢复和 fail-closed 均归适配器。 +- `/reimbursements/:reimbursementId` 的参与人填写态和非参与人只读态迁入工作台。 +- `/workflow/tasks/:taskId` 与 `/mobile/approvals/:taskId` 的报销任务进入同一工作台的审批适配器;其他实体继续走通用审批详情。 +- 复用并扩展 B-2a 的明细—票据证据组件、票据抽屉、提交前检查和未归属恢复。 +- 删除 PC/移动通用审批页中的报销业务分支,以及被工作台替代的旧报销明细/材料布局。 +- 收敛顶部摘要、检查提示、右侧动作摘要和退回/驳回解释。 -## Requirements +## Product Requirements -### R1 填写态采用同一业务内容组件 +### R1 一个业务对象只保留一个交互式工作台 -- PC/移动填写态、审批态和非参与人只读态共享同一“明细 + 所属票据”组件及字段顺序。 -- 填写态的新增、编辑、删除、关联和恢复动作通过显式能力与回调注入;审批/只读模式不渲染这些能力。 -- 删除被共享组件替代的旧明细表格、旧材料对应展示或重复只读布局,不保留永久双实现兜底。 +- 报销详情的主内容顺序在 `edit`、`readonly`、`approval` 三种模式中一致:紧凑报销头 → 明细及所属票据 → 未归属票据 → 补充附件 → 必要检查/差异 → 处理历史或处理进度。 +- PC 宽屏使用主列证据 + 右侧单一 sticky 动作区;窄屏和移动采用同一内容顺序的卡片/分区详情布局,动作区转到底部且不遮挡内容。PC 与移动不强求同一份 DOM,不得用密集的 `isMobile` 业务分支把两种布局重新耦合成巨型壳。 +- `/workflow/tasks/:taskId` 保留为审批入口和浏览器地址。任务解析为 `reimbursement` 后由报销审批适配器渲染工作台;不得先渲染通用报销摘要再嵌入工作台,也不得要求用户点击“查看报销单”才能取得核心内容。 +- `/reimbursements/:reimbursementId` 使用同一工作台。参与人处于 `draft/rejected` 时获得编辑能力;其他获准查看者只得到只读能力。 +- “打开完整原始表单”不再作为独立核心内容页面入口;打印/PDF 仍通过专用打印路由提供。 -### R2 票据抽屉围绕当前明细工作 +### R2 壳与模式适配器必须严格分层 -- 员工可从明细就近添加/选择 PDF 发票,查看已关联票据和解除自己可维护状态下的关联。 -- 整单补充附件仍独立,不伪装成明细票据。 -- 未归属票据有明确恢复入口;不得要求员工依赖操作顺序或跳到另一大区寻找。 +- PC/移动布局壳只接收同一份已经计算完成的展示模型、区块内容和 capability/action slots,分别负责各端布局、响应式结构和语义标题;内容顺序和业务组件合同不得分叉。 +- 工作台壳不得调用 `useQuery`、`useMutation` 或 API;不得持有编辑、审批、权限、状态机、票据上传或错误恢复等业务状态;不得根据 `mode` 决定是否发请求。 +- 壳内不以 `roleCodes`、permission code、workflow 状态、报销状态或 `if (mode === ...)` 推导业务能力。模式差异由适配器注入了什么数据、capability 和 action slot 体现。 +- `edit` 适配器拥有参与人判断、`draft/rejected` 状态、表头/明细/票据/附件维护状态和 mutation、提交前检查及 B2 错误恢复。 +- 任务路由边界唯一获取用于授权和实体分派的 workflow task detail,并把同一份查询结果及受控刷新入口移交给 `approval` 适配器;不得为分派和审批内容各发一次相同 task detail 请求。 +- `approval` 适配器拥有 workflow task detail 的业务解释、allowed actions、提交快照、审批 mutation、刷新策略、fail-closed、退回引用、处理历史与联网要求;不得获得报销维护 mutation。 +- `readonly` 适配器只执行获准的只读查询和材料查看,不注入新增、编辑、删除、关联、解绑、提交或审批能力。 +- 共享投影和展示组件接收显式最小字段或纯展示模型,不接收完整 Prisma/API 记录,也不在组件内部判断角色。 -### R3 提交前检查进入填写路径 +### R3 通用审批页的报销旧分支必须删除 -- 在填写过程中持续显示明细完整性、票据关联、票面金额证据和需人工核对摘要。 -- 提交动作前展示与服务端 B2 一致的检查结果;服务端仍为权威,前端不得自行放宽。 -- 错误就近落到明细/票据,汇总区只告诉员工还有几项及如何到达,不只在页面顶部报错。 +- PC `WorkflowTaskDetailPage` 中报销专用的六字段 `DetailField`、报销证据区、提交/当前检查区、报销右栏计数和报销审批动作接线,在新工作台路由生效后全部删除。 +- 移动 `MobileApprovalsPage` 中对应的报销详情、证据、检查和报销 fail-closed UI 分支迁入审批适配器后删除。 +- 通用审批页只保留请假、补卡、采购、差旅等现有实体的通用投影与动作,不保留休眠报销路径、隐藏兜底或复制文案“以防万一”。 +- 最终结构检查必须证明通用审批页不再导入报销证据组件、不再包含报销检查文案,也不再根据 `entityType === "reimbursement"` 渲染业务内容。该检查与壳层禁用 API/权限/模式判断的检查必须实现于 `scripts/qa/check-ui-design-rules.mjs`,由部署 preflight 的 `--strict` 门禁执行,不得仅存在于单测。 +- 任务类型分派层可以识别 `reimbursement` 并选择适配器;它只负责路由,不得重新实现报销摘要或决策逻辑。 -### R4 填写与审批语义一致 +### R4 信息必须按审批人决策需要收敛 -- 发生日期不默认等于发票日期;跨天/跨期同一业务可作为一条明细并写明起止日期。 -- 系统不自动判断明细是否应拆分,页面把它表达为填报要求和财务复核项。 -- 需票明细、无票补助、manual review 和金额不可用继续遵守现有业务事实源及 B2 合同。 +1. 顶部只显示报销单号、状态、报销人、日期、合计;事由作为紧接标题的正文展示。不得再用六张字段卡重复下面已经出现的信息,也不显示“业务类型=报销”等自明字段。 +2. 主列直接进入明细和所属票据;附件随后展示。相同报销头、事由、票据或检查事实不得在不同区块重复完整表达。 +3. 正常项不解释、不计数。只有存在异常或人工决策需要时才显示提示,并就近说明“对象、原因、影响、下一步”;不得默认平铺“0 条”状态或仅显示无法定位对象的总数。 +4. 提交时快照与当前票据事实一致时,不并排展示两个“检查摘要”;只有出现异常、快照缺失或时间语义差异时才显示必要内容。 +5. 历史提交缺少预算快照时,以低强调度说明“该次提交未留存预算快照”;它不得使用告警视觉冒充当前业务异常,也不得用实时预算结果补写历史。 +6. PC 右栏只保留当前节点/状态、是否可决策、异常锚点和审批动作。报销合计如已在顶部清晰可见,不在右栏重复;创建/发起/完成等时间进入处理历史或按需展开,不平铺为决策摘要。 +7. “退回”和“驳回”的后果说明只在用户展开相应动作或进入确认时出现;默认右栏不常驻解释卡。错误和提交结果继续提供明确反馈,不能为了降噪删除必要恢复信息。 +- 警告不得只依赖颜色;标题层级、DOM 顺序和读屏顺序必须与视觉顺序一致。 + +### R5 编辑态围绕明细完成票据维护与提交恢复 + +- `edit` 适配器向同一明细—票据组件注入新增、编辑、删除、上传/选择 PDF 发票、关联、解绑和未归属恢复能力。 +- 员工可在当前明细上下文内查看和维护所属票据;未归属票据直接提供选择明细的恢复入口,不依赖口头操作顺序或跳到独立材料区。 +- 整单补充附件继续作为独立证据,不伪造与某条明细的结构化关系。 +- 填写过程中持续计算可解释的提交前检查;汇总区只提示仍有多少可操作问题并提供锚点,具体错误就近落到明细或票据。 +- 服务端 B2 门禁继续为权威。前端不得因工作台迁移放宽 `ocr_failed`、manual review、金额超票面、缺票或未归属合同。 +- 发生日期不取发票日期;系统不自动决定业务是否应拆分,页面继续按既有业务事实源提示员工和财务人工复核。 + +### R6 审批态保留快照、实时事实与 fail-closed + +- 费用标准/预算使用 workflow 提交快照;票据归属、金额证据和附件使用当前实时报销详情。两者不得互相替代。 +- 完整报销内容、关键当前检查或提交快照解析未就绪时,通过、退回、驳回、转审和报销离线草稿均不可执行;动作入口除禁用外仍有中文原因。 +- 审批适配器只注入查看原件、查看附件、引用到退回意见及 workflow 决策能力,不注入报销维护能力。 +- 报销证据继续排除在移动 IndexedDB workflow cache 之外;其他审批类型的离线草稿能力保持不变。 +- 已处理任务查看当前报销版本时保留时间语义提示;历史意见和提交快照不被当前内容覆盖。 + +### R7 路由、权限与回退必须保持稳定 + +- 通用任务路由以一次已授权 task detail 查询选择报销或非报销渲染器,并把该查询结果移交所选渲染器继续使用。非参与人不能从分派错误、报销加载状态或材料错误推断无权对象信息。 +- 报销详情、票据、附件、短时材料 URL 和审批动作继续使用既有后端授权、用途审计和状态机;本任务不以纯前端隐藏替代服务端校验。 +- PC/移动从待办列表进入、刷新、浏览器返回、深链打开和已处理任务查看均保持可预测;工作台返回目标由入口上下文决定,不形成“返回审批详情 → 又回到同一工作台”的循环。 +- 页面级加载失败显示重试和安全返回;单个材料加载失败只影响该材料,不清空已加载证据。 ## Acceptance Criteria -- [ ] PC/移动填写态使用 B-2a 同一业务内容组件,审批/只读/编辑模式只在能力和动作上不同。 -- [ ] 员工可在明细上下文内完成票据添加、归属查看和错误恢复,不再依赖“先明细、后整单材料区”的隐含顺序。 -- [ ] 提交前检查与服务端 B2 结果一致,错误就近显示且再次提交链路可完成。 -- [ ] 旧重复布局和不再使用的展示分支被删除;父任务不存在三套并行内容结构。 -- [ ] PC/移动、参与人/非参与人、draft/rejected/reviewing 状态矩阵通过,既有票据/附件权限无回归。 -- [ ] 公网部署和真实员工重提反馈通过后,父任务才可归档。 +- [ ] AC1:PC 与移动的 `edit`、`readonly`、`approval` 使用同一展示模型、区块顺序、明细—票据语义组件和 capability 契约;PC 可使用右栏 sticky 布局壳,移动可使用卡片 + 底部动作布局壳,不要求同一份 DOM,模式差异只来自适配器注入的数据与能力。 +- [ ] AC2:PC/移动布局壳中 `useQuery`、`useMutation`、API 调用、角色/权限判断、workflow/报销状态机判断和 `if (mode === ...)` 业务分支均为 0;`scripts/qa/check-ui-design-rules.mjs --strict` 静态扫描并报 error,且该命令作为部署 preflight 执行。 +- [ ] AC3:`WorkflowTaskDetailPage` 与 `MobileApprovalsPage` 的报销业务渲染分支、六字段摘要、重复检查区和休眠兜底全部删除;通用审批页仍通过非报销回归。 +- [ ] AC4:报销审批首屏只有一套紧凑报销头;事由、明细、所属票据、附件、异常和历史各有唯一主展示位置,不再出现两套摘要或四处检查计数。 +- [ ] AC5:无异常时不显示“0 条”检查卡或常驻退回/驳回解释;异常时每条提示可定位到具体明细/票据,包含原因、影响和下一步。快照/实时不一致时才额外显示差异。 +- [ ] AC6:PC 右栏只含决策所需状态、异常锚点和单一动作区;窄屏/移动动作区不遮挡内容,触控区域不小于 44×44,键盘和读屏顺序正确。 +- [ ] AC7:员工可在明细上下文完成新增/编辑/删除、PDF 发票添加、关联/解绑、未归属恢复、提交阻断修复和再次提交;服务端 B2 结果仍为最终权威。 +- [ ] AC8:审批内容或快照失败时所有报销决策 fail-closed;审批适配器没有任何报销维护能力;readonly 适配器没有编辑或审批能力。 +- [ ] AC9:报销证据不进入 IndexedDB;非报销审批离线草稿基线、PC/移动非报销详情和 workflow 状态机均无回归。 +- [ ] AC10:参与人/非参与人、`draft/rejected/reviewing`、pending/已处理任务和材料权限矩阵通过;没有新增 schema、migration、flag、seed 或生产数据改写。 +- [ ] AC11:生产部署后使用公网真实 bundle + 合成 API 完成三模式、失败关闭和离线验收;真实员工重提反馈记录后,父任务才允许归档。 ## Out of Scope -- 自动从票据推断发生日期或业务拆分正确性。 -- 结构化退回原因和统计模型。 -- 财务代员工编辑、批量改票或新审批状态。 -- 与 B-2a 同批部署;本任务必须是独立候选、独立回滚点。 +- 请假、补卡、采购、差旅改造成“业务对象页 + 审批模式”;它们继续使用通用审批详情。 +- `/reimbursements/:id/print` 打印/PDF 页面重构。该页面是格式化交付视图,不是交互式工作台;本任务有意保留其独立布局,但不得把它当作编辑/只读/审批的第四套交互实现,也不得向其中复制工作台 mutation。 +- 自动从发票推断发生日期、自动判断业务拆分正确性或替财务作真实性结论。 +- 结构化退回原因、统计模型、财务代员工编辑、批量改票或新审批状态。 +- 修改 workflow 状态机、B2 flag、付款流程、数据库 schema、生产配置或真人单据。 +- 为当前冗余页面单独发布临时删减版;信息收敛随 B-2b 独立候选一次交付。 ## Test Plan -- 组件/纯函数:review/readonly/edit 三模式字段顺序一致,能力注入不向只读视角泄漏维护动作。 -- Playwright:PC/移动 participant 与 nonparticipant 的 draft/rejected/reviewing 矩阵,覆盖票据抽屉、未归属恢复、提交阻断和再次提交。 -- 后端:复跑 B2 票据门禁、报销参与人授权、票据 bind/unbind/link 与材料预览聚焦套件;本任务原则上不改后端合同。 -- 前端:typecheck/build、UI strict/self-test、代表性视口、键盘/读屏焦点和固定操作区遮挡检查。 -- 治理:task validate、process contract、Trellis governance、敏感内容检查、`git diff --check`;部署和公网验证另行批准。 +- 结构边界:`check-ui-design-rules.mjs --strict` 扫描 PC/移动布局壳,禁止 query/mutation/API/角色/状态机/mode 请求分支,并扫描通用审批页报销分支和旧文案为 0;三适配器能力集合另做组件/类型验证。 +- 纯函数/组件:统一报销头、明细—票据投影、异常过滤、快照/实时差异、异常锚点和 capability 注入覆盖正常/异常/缺失边界。 +- PC/移动 Playwright:三模式内容顺序;无异常降噪;异常就近文案;右栏/底部动作;票据抽屉;未归属恢复;B2 阻断后修复并再次提交;退回引用;深链/返回。 +- 权限/状态:participant/nonparticipant × draft/rejected/reviewing;pending/已处理审批;无权 task、跨单材料、无维护能力和并发关联回归。 +- 离线:直接读取 IndexedDB 禁存字段/哨兵值;报销动作和草稿 fail-closed;非报销审批离线草稿仍可形成和同步。 +- 后端:原则上不改合同;复跑 B2 门禁、报销详情只读投影、workflow task detail、材料查看审计、票据 bind/unbind/link 和审批动作聚焦测试。 +- 前端/治理:typecheck、build、UI strict/self-test、route manifest、相关 Playwright、process contract、Trellis governance、敏感内容检查、docs registry(如改事实源)和 `git diff --check`。 +- 部署:另行批准独立候选;切换前 UI strict;部署后核 `/ready`、bundle marker、公网文案、三模式合成路径、B2 状态和真人单据状态不变。 -## Planning Note +## Risks and Deferred Items -本子任务当前只冻结迁移承诺和验收边界。详细 `design.md` / `implement.md` 在 B-2a 真人反馈回填后编写,避免在共享组件尚未验证前固化票据抽屉交互。 +- 大型旧页面拆分容易在中间阶段形成双实现;允许在开发分支短暂并存,但最终候选必须通过旧分支为 0 的结构门禁。 +- task 路由先取元数据再分派可能产生加载闪烁;设计必须用稳定骨架而不是先渲染通用页再切换。 +- 打印页继续独立,未来如字段语义漂移可另建“打印投影复用”任务;本轮不扩大交付面。 +- 全局错误色 token、部署脚本 `--no-deps`、`.claude` gitignore 和其他治理债不并入本任务。 diff --git a/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/task.json b/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/task.json index 5f926c60..d4c41ef3 100644 --- a/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/task.json +++ b/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/task.json @@ -1,11 +1,11 @@ { "id": "reimbursement-form-shared-component-migration", "name": "reimbursement-form-shared-component-migration", - "title": "B-2b 报销填写态共享组件迁移", - "description": "在 B-2a 真人反馈后,把 PC/移动填写态迁入同一明细票据组件并删除重复旧布局。", - "status": "planning", + "title": "B-2b 统一报销工作台迁移", + "description": "把 PC/移动报销编辑、只读、审批收敛为一个工作台壳和三个能力适配器,并删除通用审批页的报销旧分支。", + "status": "in_progress", "dev_type": null, - "scope": "client,spec", + "scope": "client,workflow,spec", "package": null, "priority": "P2", "creator": "shunli", @@ -21,6 +21,6 @@ "children": [], "parent": "09-01-reimbursement-one-page-approval-workbench", "relatedFiles": [], - "notes": "父任务第二阶段;B-2a 部署和真人反馈是启动条件,当前只冻结迁移承诺与验收边界。", + "notes": "父任务第二阶段;B-2a 已部署并取得真人目视反馈。规划采用方案 A:仅报销进入业务对象工作台审批模式,其他 workflow 实体保持通用详情。实现与本地全量门禁已完成,待用户最终 diff 复核后提交;尚未 push、部署或触碰真人单据。", "meta": {} } diff --git a/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/design.md b/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/design.md index bb70919c..221fd5f9 100644 --- a/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/design.md +++ b/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/design.md @@ -13,14 +13,16 @@ Workflow task shell + approval decision panel B-2b -Existing reimbursement form shell - + same shared item/evidence component (edit mode) - + invoice drawer - + submit preflight summary +Authorized task/reimbursement route dispatch + -> edit / readonly / approval adapters + -> one stateless reimbursement workspace shell + + same shared item/evidence component + + invoice drawer + submit preflight + - generic workflow reimbursement branches - retired legacy item/material duplication ``` -共享边界选在“明细 + 挂在明细下的票据”,而不是复用整个旧 `ReimbursementDetailPage`。旧页面混有填写 mutation、权限判断、材料上传、处理进度和操作面板,直接嵌入审批页会把报销人维护能力与审批职责再次耦合。 +共享边界在 B-2a 先选为“明细 + 挂在明细下的票据”;真人目视反馈后,B-2b 把它上升为唯一交互式报销工作台。旧 `ReimbursementDetailPage` 的查询、mutation、权限判断和编辑状态进入 edit adapter,workflow task、审批 mutation 与 fail-closed 进入 approval adapter,只读查询进入 readonly adapter;工作台壳只负责编排已经计算好的展示模型和 capability/action slots。 ## 2. Data Authority Matrix @@ -50,14 +52,13 @@ type ReimbursementItemEvidenceModel = { type ReimbursementItemEvidenceListProps = { models: ReimbursementItemEvidenceModel[]; unlinkedInvoices: ReimbursementInvoiceEvidence[]; - mode: "review" | "edit" | "readonly"; onOpenInvoice: (invoiceId: string) => void; onReferenceReturn?: (reference: ReturnReference) => void; editCapabilities?: ReimbursementEvidenceEditCapabilities; }; ``` -分组、文案和检查映射由纯函数完成,页面容器只负责查询、mutation、导航和动作面板。`editCapabilities` 在 B-2a 不启用,B-2b 再接入,避免审批页出现维护分支。 +分组、文案和检查映射由纯函数完成。B-2a 页面容器暂时负责查询、mutation、导航和动作面板;B-2b 将这些职责迁入三种适配器,唯一工作台壳不得拥有 query/mutation/角色/状态机判断。`editCapabilities` 在 B-2a 不启用,B-2b 只由 edit adapter 接入,避免审批/只读视角出现维护分支。 ## 4. Responsive Page Structure @@ -118,7 +119,8 @@ B-2a 客户端在 workflow task 已授权后读取现有报销详情 API,复 - B-2a 保留完整原始表单入口作为降级路径;核心核验不得依赖它。 - B-2a 不删除旧填写组件,只新增共享组件的 review/readonly 能力。 -- B-2b 接入 edit 能力后删除被替代布局;不长期维护双实现。 +- B-2b 接入三种适配器后删除被替代布局,并删除 PC/移动通用审批页的报销业务分支;不长期维护双实现或运行时 feature flag。 +- `/reimbursements/:id/print` 保持专用打印布局,不纳入三模式交互壳,只做不误伤回归。 - 旧 workflow 无预算快照兼容为 null。 - 当前已处理任务展示实时内容时加版本提示,避免历史误读。 diff --git a/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/prd.md b/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/prd.md index 7a64500e..ad364cc4 100644 --- a/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/prd.md +++ b/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/prd.md @@ -20,15 +20,15 @@ 采用“先审批、后填写迁移”的 B 路线,并拆成两次可独立验收的交付: 1. **B-2a 报销审批内容工作台**:建立共享的“明细 + 所属票据”只读/核验组件,先接入 PC 和移动审批页;审批人无需跳转即可取得决策所需内容。 -2. **B-2b 报销填写态共享组件迁移**:在 B-2a 真人使用反馈稳定后,把 PC/移动填写态迁移到同一组件,并完成票据抽屉、提交前检查和旧只读布局收口。 +2. **B-2b 统一报销工作台迁移**:根据 B-2a 真人目视反馈,把 PC/移动编辑、只读、审批收敛为一个纯布局工作台壳和三个能力适配器,并完成票据抽屉、提交前检查、信息降噪及旧页面分支删除。 -B-2a 完成时允许旧填写态暂时存在;B-2b 是本父任务的必交子任务,不得以“以后优化”关闭父任务。父任务只有在填写态迁移完成、旧重复内容布局被删除或明确退役后才能归档为完成。 +B-2a 完成时允许两套交互式页面壳暂时存在;B-2b 是本父任务的必交子任务,不得以“以后优化”关闭父任务。父任务只有在三种模式使用唯一报销工作台、通用审批页报销分支和旧重复布局均被删除后才能归档为完成。专用打印/PDF 页面是有意排除的格式化交付视图,不计作第四种交互模式。 ## Scope -- 父任务负责两阶段交付合同、共享组件边界、快照/实时数据口径、跨子任务验收和最终旧布局收口。 +- 父任务负责两阶段交付合同、唯一工作台边界、快照/实时数据口径、跨子任务验收和最终旧布局收口。 - B-2a 负责 PC/移动审批一页式内容工作台与共享组件 review/readonly 能力。 -- B-2b 负责 PC/移动填写态迁移、票据抽屉、提交前检查和重复布局删除。 +- B-2b 负责唯一工作台壳、edit/readonly/approval 适配器、票据抽屉、提交前检查、通用审批页报销分支删除和信息收敛。 - 两个子任务分别形成候选、部署和回滚点;父任务不直接承载业务代码实施。 ## Product Requirements @@ -39,12 +39,13 @@ B-2a 完成时允许旧填写态暂时存在;B-2b 是本父任务的必交子 - “查看报销单”降级为“打开完整原始表单/打印视图”的次要入口,用于少见的完整字段追溯,不再承担核心内容可达性。 - 非报销审批类型保持当前各自投影,本父任务不借机重构请假、采购、差旅或考勤审批。 -### R2 共享内容组件必须有明确迁移终点 +### R2 共享内容组件必须收敛为唯一工作台 - B-2a 建立的共享件以业务数据和交互能力为边界,不依赖 `WorkflowTaskDetailPage` 或审批状态;至少覆盖“明细 + 关联票据 + 行内核验提示”。 - PC 与移动审批使用同一语义组件和字段顺序,只允许响应式布局和操作容器不同。 -- B-2b 必须让 PC/移动填写态采用同一组件;编辑、删除、关联等维护动作通过显式能力/回调注入,不复制一套填写专用展示结构。 -- B-2b 完成后,不得继续保留“旧填写态、新审批态、旧只读态”三套并行布局。 +- B-2b 必须让 PC/移动编辑、只读、审批采用同一纯布局工作台壳和同一业务内容顺序;查询、状态、mutation、权限和 fail-closed 全部归各模式适配器。 +- 工作台壳不得根据 mode、角色、权限或业务状态发请求或推导能力;编辑、删除、关联、审批等动作只通过显式 capability/action slot 注入。 +- B-2b 完成后,不得继续保留“旧填写态、新审批态、旧只读态”三套并行布局,也不得在 PC/移动通用审批页保留休眠报销业务分支。 ### R3 快照事实与实时事实分层展示 @@ -93,13 +94,14 @@ B-2a 完成时允许旧填写态暂时存在;B-2b 是本父任务的必交子 - [ ] 退回可以引用具体明细/票据到现有自由文本意见;本父任务不新增结构化退回原因数据模型。 - [ ] PC 只有右侧单一审批动作面板,完整流程/历史位于主列靠后;窄屏和移动才把同一动作面板转到底部,多个动作不竖向铺卡片。 - [ ] 报销审批离线时不能形成任何审批动作或草稿,且离线缓存中不存在明细、票据、附件或材料访问证据;其他审批类型离线合同不变。 -- [ ] B-2b 完成后,填写态、审批态和只读态共享同一业务内容组件,不再保留三套漂移布局。 +- [ ] B-2b 完成后,编辑、审批和只读共享同一无业务状态的工作台壳;通用 PC/移动审批页报销分支和旧重复布局为 0。 - [ ] 两次交付分别通过各自的前后端、PC/移动、无障碍、文案、权限和部署验证;生产部署与真人数据写入仍需单独批准。 ## Out of Scope -- 一次性同时重写审批态和填写态。 +- 把已上线的 B-2a 与 B-2b 合并成一次候选或一次回滚点;B-2b 仍必须独立规划、验证和部署。 - 请假、采购、差旅申请、考勤等其他业务的审批内容重构。 +- `/reimbursements/:id/print` 打印/PDF 视图重构;它保持独立纸面布局,但不得承担交互式工作台职责。 - 结构化退回原因 schema、统计报表、数据库 migration。 - 修改审批角色、审批规则、状态机、B2 flag 或付款流程。 - 自动判断明细日期粒度、业务是否应拆分或费用是否真实合理。 diff --git a/client/src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx b/client/src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx new file mode 100644 index 00000000..1a8bfd0d --- /dev/null +++ b/client/src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx @@ -0,0 +1,49 @@ +import type { ReimbursementEvidenceCapabilities } from "./ReimbursementItemEvidenceList"; +import { ReimbursementItemEvidenceList } from "./ReimbursementItemEvidenceList"; +import type { ReimbursementWorkspaceModel, ReimbursementWorkspaceSlots } from "../../lib/reimbursementWorkspace"; +import { formatDisplayDate, formatMoney } from "../../lib/displayFormat"; +import { BusinessDetailAside, BusinessDetailMain, BusinessDetailWorkspace } from "../patterns/BusinessDetailTemplate"; +import { EntityOverviewHero } from "../patterns/EntityOverviewHero"; +import { PageScaffold } from "../patterns/PageScaffold"; +import { SectionPanel } from "../patterns/SectionPanel"; + +type Props = { + model: ReimbursementWorkspaceModel; + capabilities?: ReimbursementEvidenceCapabilities; + slots: ReimbursementWorkspaceSlots; +}; + +export function ReimbursementDesktopWorkspaceShell({ model, capabilities, slots }: Props) { + return ( + + {slots.backAction} + + {slots.status} + 报销人:{model.header.ownerName} + 日期:{formatDisplayDate(model.header.reimbursementDate)} + 合计:{formatMoney(model.header.totalAmount)} + + )} + /> + {slots.transientFeedback} + + + {slots.headerDetails} + {slots.beforeEvidence} + +
+ +
+
+ {slots.afterEvidence} + {slots.timeline} +
+ {slots.actionPanel} +
+
+ ); +} diff --git a/client/src/components/reimbursements/ReimbursementItemEvidenceList.tsx b/client/src/components/reimbursements/ReimbursementItemEvidenceList.tsx index 8f0fd210..a60adb85 100644 --- a/client/src/components/reimbursements/ReimbursementItemEvidenceList.tsx +++ b/client/src/components/reimbursements/ReimbursementItemEvidenceList.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Button } from "../ui/button"; import { formatDisplayDate, formatMoney } from "../../lib/displayFormat"; import type { @@ -11,35 +12,48 @@ export type ReimbursementEvidenceCapabilities = { onOpenInvoice?: (invoice: ReimbursementInvoiceEvidence) => void; onOpenAttachment?: (attachment: ReimbursementAttachmentEvidence) => void; onOpenMaterialBundle?: () => void; + onDeleteAttachment?: (attachment: ReimbursementAttachmentEvidence) => void; onReferenceItem?: (item: ReimbursementItemEvidence) => void; onReferenceInvoice?: (invoice: ReimbursementInvoiceEvidence) => void; onEditItem?: (item: ReimbursementItemEvidence) => void; onDeleteItem?: (item: ReimbursementItemEvidence) => void; - onLinkInvoice?: (invoice: ReimbursementInvoiceEvidence) => void; + onLinkInvoice?: (invoice: ReimbursementInvoiceEvidence, linkedItemId: string) => void; + linkInvoicePending?: boolean; + linkInvoiceErrors?: Record; onUnlinkInvoice?: (invoice: ReimbursementInvoiceEvidence) => void; }; type Props = { evidence: ReimbursementApprovalEvidenceProjection; capabilities?: ReimbursementEvidenceCapabilities; + mobile?: boolean; }; function evidenceStatusCopy(status: ReimbursementInvoiceEvidence["amountEvidenceStatus"]) { - if (status === "verified") return "票面金额已读取"; - if (status === "ocr_failed") return "票面金额未能读取,暂时不能完成核验"; - return "票面金额需查看原件人工核对"; + if (status === "ocr_failed") return "该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。"; + if (status === "manual_review") return "票面金额需财务查看原件核对;这是一项审批提示,不会阻止提交。"; + return null; } function InvoiceCard({ invoice, capabilities, + linkTargets, }: { invoice: ReimbursementInvoiceEvidence; capabilities: ReimbursementEvidenceCapabilities; + linkTargets: ReimbursementItemEvidence[]; }) { + const [selectedItemId, setSelectedItemId] = useState(""); const title = invoice.invoiceNumber || invoice.originalName || "未命名发票"; + const hasBlockingIssue = !invoice.linkedItemId || invoice.amountEvidenceStatus === "ocr_failed"; return ( -
+

{title}

@@ -48,13 +62,15 @@ function InvoiceCard({ {" · "}{formatDisplayDate(invoice.invoiceDate)} {" · "}{formatMoney(invoice.totalAmount)}

-

- {evidenceStatusCopy(invoice.amountEvidenceStatus)} -

+ {evidenceStatusCopy(invoice.amountEvidenceStatus) ? ( +

+ {evidenceStatusCopy(invoice.amountEvidenceStatus)} +

+ ) : null}
{capabilities.onOpenInvoice ? ( - ) : null} @@ -64,7 +80,34 @@ function InvoiceCard({ ) : null} {capabilities.onLinkInvoice && !invoice.linkedItemId ? ( - +
+ + + + {capabilities.linkInvoiceErrors?.[invoice.id] ? ( +

{capabilities.linkInvoiceErrors[invoice.id]}

+ ) : null} +
) : null} {capabilities.onUnlinkInvoice && invoice.linkedItemId ? ( @@ -75,12 +118,14 @@ function InvoiceCard({ ); } -export function ReimbursementItemEvidenceList({ evidence, capabilities = {} }: Props) { +export function ReimbursementItemEvidenceList({ evidence, capabilities = {}, mobile = false }: Props) { return (
- {evidence.items.length ? evidence.items.map((item, index) => ( -
+ {evidence.items.length ? evidence.items.map((item, index) => { + const hasBlockingIssue = item.amountExceeded || item.invoices.some((invoice) => invoice.amountEvidenceStatus === "ocr_failed") || (item.expenseType?.requiresInvoice !== false && item.invoices.length === 0); + return ( +

明细 {index + 1}

@@ -127,30 +172,30 @@ export function ReimbursementItemEvidenceList({ evidence, capabilities = {} }: P 当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。

) : null} -
+ {item.invoices.length || item.expenseType?.requiresInvoice !== false ?

所属票据({item.invoices.length})

{item.invoices.length ? item.invoices.map((invoice) => ( - + )) : (

- 这条明细尚无对应票据,请在作出审批决定前核对是否需要补充。 + 这条明细尚无对应票据,暂时无法提交;请添加票据并选择这条明细。

)} -
+
: null}
- )) : ( + ); }) : (

当前报销单没有可核验的明细。

)}
{evidence.unlinkedInvoices.length ? ( -
+

尚未对应明细的票据({evidence.unlinkedInvoices.length})

-

这些票据当前没有明确费用归属,审批前需要申请人处理或在意见中说明。

+

这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。

{evidence.unlinkedInvoices.map((invoice) => ( - + ))}
) : null} @@ -171,10 +216,15 @@ export function ReimbursementItemEvidenceList({ evidence, capabilities = {} }: P

{attachment.mimeType} · {(attachment.fileSize / 1024).toFixed(1)} KB

{capabilities.onOpenAttachment ? ( - ) : null} + {capabilities.onDeleteAttachment ? ( + + ) : null}
)) :

未上传补充附件

} diff --git a/client/src/components/reimbursements/ReimbursementMobileWorkspaceShell.tsx b/client/src/components/reimbursements/ReimbursementMobileWorkspaceShell.tsx new file mode 100644 index 00000000..af277e9e --- /dev/null +++ b/client/src/components/reimbursements/ReimbursementMobileWorkspaceShell.tsx @@ -0,0 +1,58 @@ +import type { ReimbursementEvidenceCapabilities } from "./ReimbursementItemEvidenceList"; +import { ReimbursementItemEvidenceList } from "./ReimbursementItemEvidenceList"; +import type { ReimbursementWorkspaceModel, ReimbursementWorkspaceSlots } from "../../lib/reimbursementWorkspace"; +import { formatDisplayDate, formatMoney } from "../../lib/displayFormat"; +import { MobileDetailHeader } from "../mobile/MobileDetailHeader"; +import { MOBILE_STICKY_ACTION_PAGE_CLASS } from "../patterns/MobileStickyActionBar"; +import { PageScaffold } from "../patterns/PageScaffold"; + +type Props = { + model: ReimbursementWorkspaceModel; + capabilities?: ReimbursementEvidenceCapabilities; + slots: ReimbursementWorkspaceSlots; + fallbackTo: string; +}; + +export function ReimbursementMobileWorkspaceShell({ model, capabilities, slots, fallbackTo }: Props) { + return ( + + + {slots.transientFeedback} +
+
+

报销合计

+

{formatMoney(model.header.totalAmount)}

+
+
+
+

报销人

+

{model.header.ownerName}

+
+
+

报销日期

+

{formatDisplayDate(model.header.reimbursementDate)}

+
+
+
+

事由

+

{model.header.description || "未填写事由"}

+
+ {slots.headerDetails} +
+ {slots.beforeEvidence} +
+

明细与票据

+ +
+ {slots.afterEvidence} + {slots.timeline} + {slots.mobileActions} +
+ ); +} diff --git a/client/src/lib/reimbursementWorkspace.ts b/client/src/lib/reimbursementWorkspace.ts new file mode 100644 index 00000000..3d5e6f0e --- /dev/null +++ b/client/src/lib/reimbursementWorkspace.ts @@ -0,0 +1,96 @@ +import type { ReactNode } from "react"; +import type { ReimbursementDetail } from "../api/reimbursements"; +import type { ReimbursementApprovalEvidenceProjection } from "./reimbursementApprovalProjection"; +import { resolveReimbursementDisplayCode } from "./reimbursementDisplay"; + +export type ReimbursementWorkspaceHeaderModel = { + displayCode: string; + ownerName: string; + reimbursementDate: string | null; + totalAmount: string | null; + description: string | null; +}; + +export type ReimbursementWorkspaceIssueAnchor = { + id: string; + label: string; + severity: "blocking" | "review" | "info"; +}; + +export type ReimbursementWorkspaceModel = { + header: ReimbursementWorkspaceHeaderModel; + evidence: ReimbursementApprovalEvidenceProjection; + issueAnchors: ReimbursementWorkspaceIssueAnchor[]; +}; + +export type ReimbursementWorkspaceSlots = { + status: ReactNode; + backAction?: ReactNode; + headerDetails?: ReactNode; + beforeEvidence?: ReactNode; + afterEvidence?: ReactNode; + timeline?: ReactNode; + actionPanel?: ReactNode; + mobileActions?: ReactNode; + transientFeedback?: ReactNode; +}; + +export function buildReimbursementWorkspaceHeader( + detail: Pick, +): ReimbursementWorkspaceHeaderModel { + return { + displayCode: resolveReimbursementDisplayCode(detail), + ownerName: detail.owner?.name ?? "-", + reimbursementDate: detail.reimbursementDate, + totalAmount: detail.totalAmount, + description: detail.description, + }; +} + +export function buildReimbursementWorkspaceIssueAnchors( + evidence: ReimbursementApprovalEvidenceProjection, + options: { snapshotMissing?: boolean; snapshotInvalid?: boolean } = {}, +): ReimbursementWorkspaceIssueAnchor[] { + const anchors: ReimbursementWorkspaceIssueAnchor[] = []; + if (options.snapshotInvalid) { + anchors.push({ id: "reimbursement-workspace-snapshot", label: "提交记录无法读取", severity: "blocking" }); + } else if (options.snapshotMissing) { + anchors.push({ id: "reimbursement-workspace-snapshot", label: "未留存预算快照", severity: "info" }); + } + if (evidence.currentCheck.unlinkedInvoiceCount) { + anchors.push({ + id: "reimbursement-workspace-unlinked", + label: `${evidence.currentCheck.unlinkedInvoiceCount} 张票据尚未选择明细`, + severity: "blocking", + }); + } + if (evidence.currentCheck.missingInvoiceItemCount) { + anchors.push({ + id: "reimbursement-workspace-evidence", + label: `${evidence.currentCheck.missingInvoiceItemCount} 条明细缺少票据`, + severity: "review", + }); + } + if (evidence.currentCheck.amountUnavailableItemCount) { + anchors.push({ + id: "reimbursement-workspace-evidence", + label: `${evidence.currentCheck.amountUnavailableItemCount} 条明细的票面金额未能读取`, + severity: "blocking", + }); + } + if (evidence.currentCheck.manualReviewItemCount) { + anchors.push({ + id: "reimbursement-workspace-evidence", + label: `${evidence.currentCheck.manualReviewItemCount} 条明细需查看原件核对金额`, + severity: "review", + }); + } + if (evidence.currentCheck.amountExceededItemCount) { + anchors.push({ + id: "reimbursement-workspace-evidence", + label: `${evidence.currentCheck.amountExceededItemCount} 条明细申报额高于票面金额`, + severity: "blocking", + }); + } + return anchors; +} diff --git a/client/src/pages/MobileApprovalsPage.tsx b/client/src/pages/MobileApprovalsPage.tsx index b5139009..65e2e55b 100644 --- a/client/src/pages/MobileApprovalsPage.tsx +++ b/client/src/pages/MobileApprovalsPage.tsx @@ -1,19 +1,14 @@ import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage"; import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { fetchLeaveRequests, type LeaveRequestItem } from "../api/leave"; import { fetchPurchaseRequests, type PurchaseRequestSummary } from "../api/purchaseRequests"; import { - downloadReimbursementAttachment, - fetchReimbursementAttachmentPreviewUrl, - fetchReimbursementDetail, - fetchReimbursementMaterialPreviewBundle, fetchReimbursements, type ReimbursementListItem, } from "../api/reimbursements"; -import { fetchInvoiceSignedUrl } from "../api/invoicePool"; import { isApiError } from "../api/types"; import { approveWorkflowTask, @@ -29,8 +24,6 @@ import { import { useAuth } from "../auth/AuthContext"; import { hasAnyPermission } from "../auth/permissions"; import { ConfirmDialog } from "../components/common/ConfirmDialog"; -import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog"; -import { ReimbursementItemEvidenceList } from "../components/reimbursements/ReimbursementItemEvidenceList"; import { PageHeader } from "../components/common/PageHeader"; import { StatusBadge } from "../components/common/StatusBadge"; import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader"; @@ -46,13 +39,6 @@ import { MOBILE_APPROVAL_PERMISSIONS } from "../features/mobile/navigation"; import { resolvePurchaseDisplayCode, workflowActionLabel } from "../lib/approvalDisplay"; import { businessPrimaryLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay"; import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; -import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance"; -import { - buildReimbursementApprovalEvidence, - buildReimbursementReturnReference, -} from "../lib/reimbursementApprovalProjection"; -import { isWecomWebView } from "../lib/runtimeContext"; -import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview"; import { cn } from "../lib/utils"; import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../lib/workflowActionConfirm"; import { @@ -362,14 +348,15 @@ function toEmployeeProgressCards(input: { return [...reimbursementCards, ...leaveCards, ...purchaseCards].slice(0, 8); } -export function MobileApprovalsPage() { +export function MobileApprovalsPage({ preloadedTaskDetail = null }: { preloadedTaskDetail?: WorkflowTaskDetail | null } = {}) { const queryClient = useQueryClient(); const navigate = useNavigate(); const location = useLocation(); const routeParams = useParams(); const { me, permissions, accessToken, offlineAccessMode } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); - const deepLinkTaskId = routeParams.taskId?.trim() || searchParams.get("taskId")?.trim() || ""; + const legacyQueryTaskId = routeParams.taskId ? "" : searchParams.get("taskId")?.trim() || ""; + const deepLinkTaskId = routeParams.taskId?.trim() || legacyQueryTaskId; const isPushDetail = Boolean(deepLinkTaskId); const [selectedTaskId, setSelectedTaskId] = useState(deepLinkTaskId); @@ -469,7 +456,7 @@ export function MobileApprovalsPage() { }); const detailQuery = useQuery({ - enabled: Boolean(selectedTaskId) && canUseRemote, + enabled: Boolean(selectedTaskId) && !legacyQueryTaskId && canUseRemote && !preloadedTaskDetail, queryKey: ["mobile", "workflow", "task-detail", selectedTaskId], queryFn: () => fetchWorkflowTaskDetail(selectedTaskId), }); @@ -560,56 +547,10 @@ export function MobileApprovalsPage() { [filteredTaskCards, selectedTaskId], ); - const selectedTask = detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail; - const mobileReimbursementId = selectedTask?.reimbursement?.id ?? ""; - const reimbursementDetailQuery = useQuery({ - enabled: Boolean(mobileReimbursementId) && canUseRemote, - queryKey: ["reimbursements", mobileReimbursementId], - queryFn: () => fetchReimbursementDetail(mobileReimbursementId), - }); - const reimbursementEvidence = useMemo(() => { - const snapshot = selectedTask?.workflowInstance?.contextData; - if (!reimbursementDetailQuery.data || !snapshot) return null; - return buildReimbursementApprovalEvidence(reimbursementDetailQuery.data, snapshot); - }, [reimbursementDetailQuery.data, selectedTask?.workflowInstance?.contextData]); - const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog(); - const openInvoiceMutation = useMutation({ - mutationFn: async (invoiceId: string) => { - const result = await fetchInvoiceSignedUrl(invoiceId); - const openedInWecom = await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize }); - if (!openedInWecom) openFileInBrowser(result.signedUrl); - }, - onError: (actionError) => toast.error(resolveUserErrorMessage(actionError, "打开发票失败,请重试")), - }); - const openAttachmentMutation = useMutation({ - mutationFn: async ({ attachment, reason }: { - attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields; - reason?: string; - }) => { - if (isWecomWebView()) { - const result = await fetchReimbursementAttachmentPreviewUrl(mobileReimbursementId, attachment.id, reason); - await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize }); - return null; - } - return downloadReimbursementAttachment(mobileReimbursementId, attachment.id, reason); - }, - onSuccess: (result) => { - if (!result) return; - const url = URL.createObjectURL(result.blob); - window.open(url, "_blank", "noopener,noreferrer"); - window.setTimeout(() => URL.revokeObjectURL(url), 60_000); - }, - onError: (actionError) => toast.error(resolveUserErrorMessage(actionError, "打开附件失败,请重试")), - }); - const materialPreviewMutation = useMutation({ - mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(mobileReimbursementId, reason), - onSuccess: (bundle) => openFileInBrowser(bundle.signedUrl), - onError: (actionError) => toast.error(resolveUserErrorMessage(actionError, "打开完整材料失败,可逐份查看原件")), - }); + const selectedTask = preloadedTaskDetail ?? detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail; const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null; const selectedDisplayReference = selectedTaskCard?.reference ?? publicReference(selectedReference?.reference); const selectedDetailReference = selectedDisplayReference ?? - publicReference(selectedTask?.reimbursement?.formNumber) ?? publicReference(selectedTask?.purchaseRequest?.requestNo) ?? publicReference(selectedTask?.travelRequest?.requestNo); const selectedDetailTitle = selectedTask @@ -620,24 +561,18 @@ export function MobileApprovalsPage() { }) : ""; const selectedApplicantName = selectedTaskCard?.applicantName ?? - selectedTask?.reimbursement?.submitter?.name ?? selectedTask?.leaveRequest?.owner?.name ?? selectedTask?.attendanceCorrection?.owner?.name ?? selectedTask?.purchaseRequest?.applicant?.name ?? selectedTask?.travelRequest?.applicant?.name ?? null; const selectedMetric = selectedTaskCard?.metric ?? - selectedTask?.reimbursement?.totalAmount ?? (selectedTask?.leaveRequest ? `${selectedTask.leaveRequest.requestedDays} 天` : null) ?? (selectedTask?.attendanceCorrection ? selectedTask.attendanceCorrection.workDate : null) ?? selectedTask?.purchaseRequest?.totalAmount ?? selectedTask?.travelRequest?.estimatedAmount ?? null; const selectedTaskAllowsActions = Boolean(taskTab === "pending" && selectedTask?.task.status === "pending"); - const isSelectedReimbursement = selectedTask?.workflowInstance?.entityType === "reimbursement"; - const reimbursementDecisionReady = !isSelectedReimbursement || Boolean( - canUseRemote && reimbursementEvidence && selectedTask?.workflowInstance?.contextData.parseStatus === "ready", - ); const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote; const employeeProgressCards = useMemo( () => @@ -666,10 +601,6 @@ export function MobileApprovalsPage() { }); function requestMobileWorkflowAction(action: WorkflowConfirmAction) { - if (isSelectedReimbursement && !reimbursementDecisionReady) { - setError("报销审批需联网加载完整明细、票据和提交时检查后才能处理。"); - return; - } if (!canUseRemote && action === "return") { setError("当前离线不支持退回,请联网后再处理。"); return; @@ -689,31 +620,6 @@ export function MobileApprovalsPage() { setConfirmAction(action); } - function appendReturnReference(reference: string) { - if (!reference) return; - setComment((current) => current.includes(reference) ? current : `${current.trim()}\n${reference}`.trim()); - setShowSecondaryActions(true); - setError(""); - } - - async function handleOpenAttachment(attachmentId: string) { - const attachment = reimbursementDetailQuery.data?.attachments.find((item) => item.id === attachmentId); - if (!attachment) { - toast.error("附件信息已变化,请刷新后重试"); - return; - } - const reason = await requestDownloadReason(attachment); - if (reason === null) return; - openAttachmentMutation.mutate({ attachment, reason }); - } - - async function handleOpenAllMaterials() { - const governedAttachment = reimbursementDetailQuery.data?.attachments.find(attachmentRequiresDownloadReason); - const reason = governedAttachment ? await requestDownloadReason(governedAttachment) : undefined; - if (reason === null) return; - materialPreviewMutation.mutate(reason); - } - async function refreshDraftQueueCache() { if (!ownerEmployeeId) { return []; @@ -737,9 +643,6 @@ export function MobileApprovalsPage() { if (!selectedTask) { throw new Error("当前审批任务尚未缓存,请联网加载后再操作"); } - if (selectedTask.workflowInstance?.entityType === "reimbursement") { - throw new Error("报销审批不能保存离线草稿,请联网核对完整内容后处理"); - } if (!selectedTask.allowedActions.includes(action)) { throw new Error("当前审批任务不允许该动作"); } @@ -782,9 +685,6 @@ export function MobileApprovalsPage() { if (!selectedTask) { throw new Error("当前审批任务尚未缓存,请联网加载后再操作"); } - if (selectedTask.workflowInstance?.entityType === "reimbursement" && !reimbursementDecisionReady) { - throw new Error("完整报销内容或提交时检查尚未就绪,暂时不能处理"); - } if (!selectedTask.allowedActions.includes(action)) { throw new Error("当前审批任务不允许该动作"); } @@ -944,14 +844,16 @@ export function MobileApprovalsPage() { : "最近 30 天已处理审批用于复核,不支持再次审批。"; const selectedActionText = selectedTask ? selectedTaskAllowsActions - ? isSelectedReimbursement && !reimbursementDecisionReady - ? "报销审批需联网加载完整明细、票据和提交时检查;内容就绪前不能通过、退回、驳回或保存草稿。" - : canUseRemote + ? canUseRemote ? "核对摘要和历史后,可在底部通过、驳回或退回。" - : "当前离线,仅支持保存通过或驳回草稿,联网后再同步。" + : "" : "当前任务已处理,本页只展示业务摘要和处理历史。" : ""; + if (legacyQueryTaskId) { + return ; + } + return ( <> 当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"} - {isSelectedReimbursement - ? "当前报销任务只显示摘要,不能保存审批草稿;请联网核对完整内容。" - : "仅支持查看已缓存审批任务,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。"} + 仅支持查看已缓存审批任务,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。

) : null} @@ -1216,7 +1116,7 @@ export function MobileApprovalsPage() { <>
下一步
-
{selectedActionText}
+ {selectedActionText ?
{selectedActionText}
: null}
@@ -1261,77 +1161,6 @@ export function MobileApprovalsPage() {
) : null}
- {selectedTask.reimbursement ? ( -
-
- {joinBusinessMeta([`报销日期:${selectedTask.reimbursement.reimbursementDate ?? "-"}`, selectedTask.reimbursement.description])} -
- - {!canUseRemote ? ( -

- 当前只显示允许离线保存的摘要。请联网加载完整明细和票据后再处理该报销审批。 -

- ) : reimbursementDetailQuery.isLoading ? ( -

- 正在加载完整报销内容;加载完成前所有审批动作均不可用。 -

- ) : reimbursementDetailQuery.isError ? ( -
-

{resolveUserErrorMessage(reimbursementDetailQuery.error, "完整报销内容加载失败,暂时不能审批。")}

- -
- ) : null} - {selectedTask.workflowInstance?.contextData.parseStatus === "invalid" ? ( -

- 提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。 -

- ) : null} - {reimbursementEvidence ? ( - <> - {selectedTask.task.status !== "pending" ? ( -

- 当前版本可能包含申请人在退回后所做的修改;历史审批意见和提交时检查仍对应当时版本。 -

- ) : null} - openInvoiceMutation.mutate(invoice.id), - onOpenAttachment: (attachment) => void handleOpenAttachment(attachment.id), - onOpenMaterialBundle: () => void handleOpenAllMaterials(), - onReferenceItem: (item) => appendReturnReference(buildReimbursementReturnReference({ item })), - onReferenceInvoice: (invoice) => appendReturnReference(buildReimbursementReturnReference({ invoice })), - }} - /> -
-
-

提交时检查

- {selectedTask.workflowInstance?.contextData.budgetCheckStatus === "missing" ? ( -

该次提交未记录预算快照。

- ) : selectedTask.workflowInstance?.contextData.budgetCheckStatus === "not_applicable" ? ( -

提交时未匹配适用预算。

- ) : selectedTask.workflowInstance?.contextData.budgetCheck ? ( -

预算 {selectedTask.workflowInstance.contextData.budgetCheck.budgetCode},提交后使用率 {selectedTask.workflowInstance.contextData.budgetCheck.usagePercent}%

- ) : null} -

费用标准提示 {selectedTask.workflowInstance?.contextData.standardViolations.length ?? 0} 条

-
-
-

当前票据检查

-

票据 {reimbursementEvidence.currentCheck.invoiceCount} 张,尚未对应明细 {reimbursementEvidence.currentCheck.unlinkedInvoiceCount} 张;需人工核对 {reimbursementEvidence.currentCheck.manualReviewItemCount} 条,申报额超出票面 {reimbursementEvidence.currentCheck.amountExceededItemCount} 条。

-
-
- - ) : null} -
- ) : null} {selectedTask.leaveRequest ? (
{joinBusinessMeta([`假期类型:${selectedTask.leaveRequest.leaveType?.name ?? "-"}`, `${selectedTask.leaveRequest.startDate ?? "-"} 至 ${selectedTask.leaveRequest.endDate ?? "-"}`])} @@ -1393,7 +1222,6 @@ export function MobileApprovalsPage() {