From c840574d367ad8817f513039296add2a34fc40b0 Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 1 Sep 2026 20:32:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20B-2a=20=E6=8A=A5=E9=94=80?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E5=86=85=E5=AE=B9=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=EF=BC=88reimbursement-approval-content-workbench=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 | 18 +- .../check.jsonl | 1 + .../design.md | 76 ++++++ .../implement.jsonl | 1 + .../implement.md | 36 +++ .../prd.md | 113 +++++++++ .../task.json | 26 ++ .../check.jsonl | 1 + .../design.md | 21 ++ .../implement.jsonl | 1 + .../implement.md | 22 ++ .../prd.md | 71 ++++++ .../task.json | 26 ++ .../check.jsonl | 1 + .../design.md | 129 ++++++++++ .../implement.jsonl | 1 + .../implement.md | 25 ++ .../prd.md | 114 +++++++++ .../task.json | 29 +++ client/src/api/workflow.ts | 12 + .../ReimbursementItemEvidenceList.tsx | 183 ++++++++++++++ .../lib/reimbursementApprovalProjection.ts | 147 +++++++++++ client/src/lib/reimbursementPolicyHints.ts | 11 + client/src/mobile/workflowActionQueue.ts | 195 ++++++++++++++- client/src/pages/MobileApprovalsPage.tsx | 230 +++++++++++++++-- .../pages/MobileReimbursementDetailPage.tsx | 2 +- client/src/pages/ReimbursementDetailPage.tsx | 2 +- client/src/pages/WorkflowTaskDetailPage.tsx | 235 +++++++++++++++--- .../ReimbursementItemEditor.tsx | 13 +- .../e2e/mobile-approvals-offline.spec.ts | 178 +++++++++++++ .../e2e/workflow-action-confirm-ui.spec.ts | 118 ++++++++- server/src/routes/reimbursements.js | 46 +++- server/src/routes/workflow.js | 99 +++++++- .../unit/routes/reimbursements.detail.test.js | 52 +++- .../reimbursements.expense-standards.test.js | 34 +++ .../unit/routes/workflow.task-detail.test.js | 93 ++++++- 36 files changed, 2271 insertions(+), 91 deletions(-) create mode 100644 .trellis/tasks/09-01-reimbursement-approval-content-workbench/check.jsonl create mode 100644 .trellis/tasks/09-01-reimbursement-approval-content-workbench/design.md create mode 100644 .trellis/tasks/09-01-reimbursement-approval-content-workbench/implement.jsonl create mode 100644 .trellis/tasks/09-01-reimbursement-approval-content-workbench/implement.md create mode 100644 .trellis/tasks/09-01-reimbursement-approval-content-workbench/prd.md create mode 100644 .trellis/tasks/09-01-reimbursement-approval-content-workbench/task.json create mode 100644 .trellis/tasks/09-01-reimbursement-form-shared-component-migration/check.jsonl create mode 100644 .trellis/tasks/09-01-reimbursement-form-shared-component-migration/design.md create mode 100644 .trellis/tasks/09-01-reimbursement-form-shared-component-migration/implement.jsonl create mode 100644 .trellis/tasks/09-01-reimbursement-form-shared-component-migration/implement.md create mode 100644 .trellis/tasks/09-01-reimbursement-form-shared-component-migration/prd.md create mode 100644 .trellis/tasks/09-01-reimbursement-form-shared-component-migration/task.json create mode 100644 .trellis/tasks/09-01-reimbursement-one-page-approval-workbench/check.jsonl create mode 100644 .trellis/tasks/09-01-reimbursement-one-page-approval-workbench/design.md create mode 100644 .trellis/tasks/09-01-reimbursement-one-page-approval-workbench/implement.jsonl create mode 100644 .trellis/tasks/09-01-reimbursement-one-page-approval-workbench/implement.md create mode 100644 .trellis/tasks/09-01-reimbursement-one-page-approval-workbench/prd.md create mode 100644 .trellis/tasks/09-01-reimbursement-one-page-approval-workbench/task.json create mode 100644 client/src/components/reimbursements/ReimbursementItemEvidenceList.tsx create mode 100644 client/src/lib/reimbursementApprovalProjection.ts create mode 100644 client/src/lib/reimbursementPolicyHints.ts diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index b488ded8..276a3f1b 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -489,11 +489,11 @@ 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: Approval Task Business Detail Exit +## Scenario: Reimbursement Approval Content Workbench ### 1. Scope / Trigger -- Trigger: an approval task detail shows only a business summary while the approver must inspect line items, invoices, attachments or other evidence before acting. +- Trigger: a reimbursement approval task must expose the evidence needed for a decision without forcing the approver into a second page. ### 2. Signatures @@ -505,8 +505,11 @@ reference, not a copy of Feishu or Ant Design branding. ### 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, the task detail exposes an explicitly named `查看完整报销单` or `查看报销单` link near the reimbursement summary. -- Reuse the existing business detail route and its permissions. Do not duplicate reimbursement items, invoice or attachment viewers inside the approval page merely to add reachability. +- 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. +- 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 `更多`. @@ -517,12 +520,13 @@ reference, not a copy of Feishu or Ant Design branding. - 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. -- Business detail permission fails -> preserve the existing permission-denied behavior; do not expand roles from the frontend. +- 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. ### 5. Good/Base/Bad Cases -- Good: approver opens the task, follows `查看完整报销单`, inspects evidence, returns to the same task and then chooses an approval action. -- Base: a completed reimbursement task still provides the read-only business-detail exit when its existing detail permission allows it. +- 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. ### 6. Tests Required diff --git a/.trellis/tasks/09-01-reimbursement-approval-content-workbench/check.jsonl b/.trellis/tasks/09-01-reimbursement-approval-content-workbench/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/09-01-reimbursement-approval-content-workbench/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/09-01-reimbursement-approval-content-workbench/design.md b/.trellis/tasks/09-01-reimbursement-approval-content-workbench/design.md new file mode 100644 index 00000000..1afab8ef --- /dev/null +++ b/.trellis/tasks/09-01-reimbursement-approval-content-workbench/design.md @@ -0,0 +1,76 @@ +# Design:B-2a 报销审批内容工作台 + +## 1. Change Boundary + +- 扩展 workflow context 的显式预算快照投影,但不改 Prisma schema。 +- 审批页继续读取 workflow task;报销内容使用现有 reimbursement detail 只读 API。 +- 新建共享展示模型、分组纯函数和响应式明细证据组件。 +- PC/移动审批容器接入同一组件;审批 action API、状态机和 comment 存储不变。 + +## 2. Data Flow + +```text +GET workflow task + -> task/actions/history + -> submissionSnapshot { standardViolations, budgetCheck } + +GET reimbursement detail + -> current header/items/invoices/attachments + -> current invoice amount evidence/manual review + +pure projection + -> itemEvidenceModels + -> unlinkedInvoices + -> submissionCheckSummary + -> currentEvidenceSummary + +PC/Mobile approval shell + -> shared evidence component + -> existing material open handlers + -> existing approve/return/reject/transfer mutations +``` + +两个查询任一缺少关键数据时不允许“通过”。材料单项预览失败不清空已经加载的其他证据。 + +## 3. Proposed Modules + +- `client/src/components/reimbursements/ReimbursementItemEvidenceList.tsx`:共享响应式内容组件。 +- `client/src/lib/reimbursementApprovalProjection.ts`:最小输入的分组、快照映射、实时摘要和退回引用纯函数。 +- `client/src/pages/WorkflowTaskDetailPage.tsx`:PC 审批壳与右侧决策区。 +- `client/src/pages/MobileApprovalsPage.tsx`:移动审批壳与底部动作层级。 +- `client/src/api/workflow.ts`:显式 `budgetCheck` 类型。 +- `server/src/routes/reimbursements.js`:提交时把已计算预算结果写入 context。 +- `server/src/routes/workflow.js`:只投影允许的预算快照字段。 + +具体文件可在实施前按现有复用点微调,但不得把共享组件放进 workflow 页面目录。 + +## 4. Permission Matrix + +| 身份/状态 | 审批页内容 | 材料原件 | 报销维护 | 审批动作 | +| --- | --- | --- | --- | --- | +| 当前审批人 pending | 可读 | 按现有授权/审计 | 无 | 按 allowedActions | +| 已处理任务查看者 | 可读当前版本 + 历史提示 | 按现有授权/审计 | 无 | 无 | +| `expense.view_all` 财务 | 可读 | 按现有授权/审计 | 无 | 仅任务允许时 | +| 无任务/无查看资格 | 403 | 403 | 无 | 无 | + +## 5. Offline Boundary + +报销审批的完整证据不进入现有 workflow IndexedDB cache。`cacheWorkflowTaskDetail` 必须先把 task detail 投影为显式允许字段子集,只保留任务身份、业务公开摘要、节点/状态和必要历史;明确删除或拒绝明细、票据、附件、金额证据、材料访问数据及共享 component projection。不能把整个 DTO 透传给 IndexedDB。 + +离线时可查看已经允许缓存的摘要/历史,但报销审批的通过、退回、驳回和保存草稿全部不可用,并说明“需联网核对完整报销内容”。其他实体继续沿用当前离线草稿能力。 + +若未来需要离线报销审批,必须重新规划敏感数据缓存字段、过期、清除、加密/设备边界和附件不可离线预览,不得在 B-2a 实施中临时扩大缓存。 + +## 6. Test Contracts + +- projection 纯函数覆盖:零/一/多票、未归属、snapshot itemId 找不到、manual review、amount unavailable、历史预算缺失、退回引用去 UUID。 +- 后端覆盖:budgetCheck 与 workflow instance 同事务写入;null/旧 context 安全投影;不返回任意 context 字段。 +- PC/移动 E2E 使用同一 fixture 工厂,fixture 字段由 API type/服务端 route contract 共同约束,避免两端测试作者各造一套形状。 +- 完整流程断言必须点击真实可见的证据入口和审批动作;不能只断言 DOM 中存在文本。 +- 离线测试在在线打开合成报销审批后读取 IndexedDB 原始记录,断言禁存字段不存在;再断网验证报销动作/草稿全部阻断,同时保留非报销审批离线基线。 + +## 7. Rollback + +- 代码回滚恢复旧审批摘要和外链;workflow context 多出的 JSON 字段对旧代码无害。 +- 不含 migration、seed、flag 或生产数据改写。 +- 部署前/后按项目现有 build SHA、UI strict、公网 bundle 和合成审批任务流程核验。 diff --git a/.trellis/tasks/09-01-reimbursement-approval-content-workbench/implement.jsonl b/.trellis/tasks/09-01-reimbursement-approval-content-workbench/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/09-01-reimbursement-approval-content-workbench/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/09-01-reimbursement-approval-content-workbench/implement.md b/.trellis/tasks/09-01-reimbursement-approval-content-workbench/implement.md new file mode 100644 index 00000000..b398448f --- /dev/null +++ b/.trellis/tasks/09-01-reimbursement-approval-content-workbench/implement.md @@ -0,0 +1,36 @@ +# Implementation Plan:B-2a 报销审批内容工作台 + +## Before Development + +- [x] 用户批准父任务及 B-2a 最新 PRD/design/implement。 +- [x] 运行 `trellis-before-dev`,读取 frontend、approval workflow、reimbursement/OCR 和 cross-layer/code-reuse 规约。 +- [x] 用合成角色复核 workflow task 可见者读取 reimbursement detail 的权限矩阵。 +- [x] 冻结共享组件最小输入字段与 PC/移动页面结构。 + +## Implementation Order + +1. [x] 后端把现有 `budgetCheck` 最小字段写入 workflow context,并在任务详情显式投影;补旧数据兼容单测。 +2. [x] 建立共享 projection 纯函数与测试:明细分组、票据归属、快照/实时提示、退回引用。 +3. [x] 建立响应式共享明细证据组件,只实现 review/readonly 能力和材料打开回调。 +4. [x] PC 审批页接入双查询、主列内容和右侧 sticky 决策区;关键数据失败时 fail closed。 +5. [x] 移动审批页接入同一组件,调整底部主/次动作;按允许字段投影缓存 workflow 摘要,排除全部报销证据,并在离线/内容未加载时阻断报销审批动作和草稿。 +6. [x] 恢复提交时费用标准行内提示、预算快照、办公电子设备提示和当前票据检查。 +7. [x] 增加明细/票据引用到现有退回意见的交互,不改后端 comment 合同。 +8. [x] 更新冲突的 frontend spec:旧“仅提供详情出口”改为共享内容工作台合同;保留完整原始表单次要入口。 + +## Verification + +- [x] 后端聚焦 Vitest + `npm run prisma:validate` + 相关 `node --check`。 +- [x] 前端组件/纯函数测试、`npm run typecheck`、`npm run build`。 +- [ ] PC/移动 Playwright 覆盖完整证据查看、快照/实时冲突、加载失败、材料打开、退回引用和动作层级。(PC 合成 2/2 已通过;移动完整环境待跑) +- [x] 移动离线回归直接检查 IndexedDB 缓存字段,证明报销证据未落盘;既有非报销离线草稿用例保留在同一回归文件。 +- [x] 权限矩阵覆盖当前审批人、已处理查看、财务只读和无权身份。 +- [x] `node scripts/qa/check-ui-design-rules.mjs --strict` 与 `--self-test`。 +- [x] `node scripts/qa/check-process-contract.mjs --baseline --json`、Trellis governance、敏感内容检查、`git diff --check`。 +- [x] 实施后因触及审批 workflow context 与权限读取链,执行只读 reviewer 复核;inline 模式由主会话按规则完成只读复核,不派发实现/check 子代理。 + +## Deployment Boundary + +- [ ] 无用户单独部署授权时只提交本地代码与元数据,不 push、不部署。 +- [ ] 部署使用独立候选和既有生产回滚基线;无 migration/seed/flag。 +- [ ] 公网使用合成报销任务验证 PC/移动完整流程,不修改真人报销或审批状态。 diff --git a/.trellis/tasks/09-01-reimbursement-approval-content-workbench/prd.md b/.trellis/tasks/09-01-reimbursement-approval-content-workbench/prd.md new file mode 100644 index 00000000..8a618353 --- /dev/null +++ b/.trellis/tasks/09-01-reimbursement-approval-content-workbench/prd.md @@ -0,0 +1,113 @@ +# B-2a 报销审批内容工作台 + +## Goal + +先把主管和财务每天真实使用的报销审批页改成可独立完成核验与决策的一页式工作台,并产出后续填写态必须采用的共享“明细 + 票据”组件合同。 + +## Scope + +- PC `WorkflowTaskDetailPage` 和移动 `MobileApprovalsPage` 的报销任务详情。 +- 一个 PC/移动共用、审批/填写可迁移的报销明细证据组件。 +- 提交时费用标准/预算快照与当前票据事实的分层投影。 +- 票据/附件原件查看、材料合并预览入口、行内风险提示和审批动作布局。 +- 从具体明细或票据引用到现有退回意见的轻量交互。 +- 为新提交 workflow 补充最小预算快照;无 Prisma schema 或 migration。 + +## Requirements + +### R1 首屏按决策顺序组织 + +PC 主列顺序固定为: + +1. 报销摘要与事由; +2. 当前报销明细及所属票据; +3. 未归属票据与补充附件; +4. 提交时检查与当前票据检查; +5. 处理历史。 + +PC 右侧 sticky 区固定为:状态与当前节点、报销合计、合并检查摘要、审批意见、通过/退回/驳回/更多。提交快照与当前票据事实只有不一致时才拆出差异提示。完整审批流和处理历史始终放在主列证据之后。 + +PC 只保留这一处动作面板:`通过`为主按钮,`退回`、`驳回`为紧凑次操作,`转审`进入“更多”,不得在主列底部重复按钮或把动作竖向铺成卡片。窄屏降单列时,同一动作面板转到底部;移动端采用单列内容和底部单一主操作。 + +### R2 明细和票据必须同屏对应 + +- 每条明细显示费用类型、发生日期、金额、说明、assets 事前批准依据(如有)和所属票据。 +- 票据显示员工可理解的名称、日期、票面金额、状态和原件入口;不显示内部字段名或禁用术语。 +- `linkedItemId=null` 的票据进入醒目的“尚未对应明细”分组,不隐藏在整单材料列表中。 +- 补充附件作为整单证据单独展示,不伪造与某条明细的结构化关系。 +- “打开完整原始表单/打印视图”作为次要入口保留,不得替代上述内容。 + +### R3 两类检查不能混用 + +- `workflowInstance.contextData.standardViolations` 作为费用标准提交快照,按 `itemId` 就近映射。 +- 新提交把已经计算的 `budgetCheck` 以最小字段集写入 workflow context;审批端只读该快照。历史无快照时明确显示缺失,不调用实时预算结果冒充。 +- 当前票据归属、金额证据、人工核对状态和材料数量来自实时报销详情。 +- 当前页面不主动调用受参与人/`expense.view_all` 保护的 precheck 来重算费用标准;不得放宽 precheck 授权。 +- 办公用品达到既有阈值的非阻断提示由纯前端确定性规则直接就近显示。 + +### R4 数据加载失败必须 fail closed + +- workflow 任务摘要加载成功但实时报销内容加载失败时,页面显示可恢复错误和重试入口。 +- 在完整报销内容、当前票据检查和提交快照解析完成前,报销审批的通过、退回、驳回和审批草稿均不可执行;不得误称已经核验完整内容。 +- 单张票据原件加载失败只阻断该证据查看并给出重试/打开完整表单退路,不使整个页面崩溃。 + +### R5 退回定位复用自由文本合同 + +- 每条明细和每张票据可触发“引用到退回意见”,生成稳定、无 UUID 的员工可读引用。 +- 多个引用可以合并到同一退回意见;审批人仍可自由补充说明。 +- 实际提交仍调用现有退回接口和 comment 字段;不新增后端 reason 结构。 + +### R6 PC/移动与权限一致 + +- 当前审批人和具备既有查看资格的用户只读核验报销内容,不获得任何报销人维护动作。 +- PC 与移动端使用同一内容组件和数据分层函数;页面容器只负责导航、响应式布局及审批动作。 +- 发票/附件预览继续执行现有权限、用途、审计和短时 URL 合同。 +- 已处理任务仍可查看当前报销内容,但必须提示“当前版本可能包含退回后修改”;历史审批意见和提交时检查保持各自历史语义。 + +### R7 移动可访问性与操作层级 + +- 底部只保留一个主操作;退回、驳回、转审进入明确的次级入口和确认流程。 +- 所有证据入口和操作触控区域至少 44×44,键盘/读屏顺序与视觉顺序一致。 +- 警告不能只靠红/黄颜色区分,必须有文本状态和处理影响;固定操作区不遮挡最后一项内容。 + +### R8 移动离线缓存采用显式最小投影 + +- 报销审批必须联网加载完整明细与票据后才能通过、退回、驳回或保存审批草稿;离线时只显示摘要/历史和“需联网核对完整报销内容”的解释。 +- `cacheWorkflowTaskDetail` 不得无条件持久化扩展后的整个 task detail。缓存写入前必须构造允许字段子集,并明确排除报销明细、发票、附件、票面金额证据、材料 URL/访问参数和共享组件 projection。 +- 测试必须直接读取 IndexedDB 的 `workflow_task_cache` 记录,断言禁存字段不存在;不能只断言离线按钮禁用。 +- 非报销审批继续沿用当前离线草稿能力,避免本任务扩大为全审批离线重构。 + +## Acceptance Criteria + +- [ ] PC 报销审批详情不再只有四字段摘要;审批人无需跳转即可看到事由、全部明细、所属票据、未归属票据、附件、检查和历史。 +- [ ] 移动报销审批详情提供同等业务内容,字段顺序与 PC 一致,无桌面表格和横向滚动。 +- [ ] 明细、票据和提示按对象就近展示;`standardViolations` 和办公电子设备提示恢复到具体明细。 +- [ ] 新提交 workflow 保存最小预算快照;历史无快照明确显示缺失,审批页不以实时预算替代。 +- [ ] 票据关联和金额证据使用实时详情;退回补票/重绑后刷新即可看到最新事实。 +- [ ] workflow 摘要成功而报销详情失败时,“通过”不可执行并提供重试,不退回四字段审批。 +- [ ] 票据和附件可按既有授权查看;非参与审批人看不到编辑、删除、关联或解绑动作。 +- [ ] 审批人可从明细/票据引用到自由文本退回意见,最终后端存储合同不变。 +- [ ] PC 只有右侧单一 sticky 动作面板,主列底部无重复按钮;完整审批流/历史在主列靠后,动作横向紧凑且“转审”进入更多。移动底部操作在代表性视口不遮挡内容,状态不只靠颜色表达。 +- [ ] 报销审批离线时所有决策/草稿入口不可用,IndexedDB workflow 缓存不含明细、票据、附件或材料证据;其他审批类型离线基线保持成功。 +- [ ] 非报销审批详情、审批状态机、权限码、B2 门禁、付款和真实单据状态不变。 + +## Out of Scope + +- 把 PC/移动报销填写态迁入共享组件;该项属于 B-2b。 +- 票据抽屉中的新增/编辑/拖拽、由票生成明细和填写态提交前检查改造。 +- 结构化退回原因、数据库迁移和退回统计。 +- 新增财务代员工编辑或纠正票据归属的写入口。 +- 生产部署和真人数据验证。 + +## Test Plan + +- 后端:workflow 任务详情快照投影、提交时预算快照写入、历史缺失兼容、权限与状态零回归。 +- 前端单元/组件:明细—票据分组、未归属票据、快照/实时标签、历史版本提示、引用退回意见、加载失败 fail closed。 +- Playwright PC:从审批列表进入报销任务,直接查看明细/票据/附件,打开原件,引用退回意见,检查右侧 sticky 动作区。 +- Playwright 移动:同一数据合同、底部主操作、次级动作、44px 触控区、无横向溢出、固定区不遮挡;在线加载前/离线时不能形成报销审批动作或草稿。 +- 离线存储:在线打开合成报销审批后直接读取 `workflow_task_cache`,断言明细、票据、附件、金额证据和材料访问字段未持久化;非报销审批离线草稿回归继续通过。 +- 合成冲突用例:提交快照显示超标而当前实时票据已变化,断言两类信息都存在且标签正确。 +- 权限用例:审批人可读证据但无报销维护动作;无权用户仍 403。 +- 前端门禁:`cd client && npm run typecheck && npm run build`、聚焦 Playwright、`node scripts/qa/check-ui-design-rules.mjs --strict`、`--self-test`。 +- 后端门禁:相关 Vitest、`cd server && npm run prisma:validate`、必要的 `node --check`。 +- 治理:task validate、process contract、Trellis governance、敏感内容检查、`git diff --check`。 diff --git a/.trellis/tasks/09-01-reimbursement-approval-content-workbench/task.json b/.trellis/tasks/09-01-reimbursement-approval-content-workbench/task.json new file mode 100644 index 00000000..d6792cd2 --- /dev/null +++ b/.trellis/tasks/09-01-reimbursement-approval-content-workbench/task.json @@ -0,0 +1,26 @@ +{ + "id": "reimbursement-approval-content-workbench", + "name": "reimbursement-approval-content-workbench", + "title": "B-2a 报销审批内容工作台", + "description": "在 PC/移动审批详情中直接展示报销明细、所属票据、附件与快照/实时核验,并建立共享内容组件。", + "status": "in_progress", + "dev_type": null, + "scope": "client,server,spec", + "package": null, + "priority": "P1", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-09-01", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "09-01-reimbursement-one-page-approval-workbench", + "relatedFiles": [], + "notes": "父任务第一阶段;独立交付、独立部署。已确认 PC 右侧单一动作面板、主列靠后展示完整历史;报销审批必须联网且证据不得进入离线缓存。", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/check.jsonl b/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} 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 new file mode 100644 index 00000000..6881802c --- /dev/null +++ b/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/design.md @@ -0,0 +1,21 @@ +# Design:B-2b 报销填写态共享组件迁移 + +## Current Decision Boundary + +B-2b 不重新定义业务内容组件,而是采用 B-2a 已验证的展示模型、字段顺序和快照/实时标签;本阶段增加 edit capability、票据抽屉和提交前检查,并删除旧重复布局。 + +详细交互在 B-2a 真人反馈后收敛,但以下架构边界已经冻结: + +- 共享组件不直接拥有 API 查询或 mutation;填写页通过显式能力和回调注入新增、编辑、删除、票据关联与恢复动作。 +- review/readonly 模式不接收 edit capability,不能靠 CSS 隐藏维护动作。 +- PC/移动复用同一 projection 与语义 DOM;响应式容器决定抽屉/底部 sheet 形态。 +- 服务端 B2 门禁仍是提交权威,前端检查只做同合同的提前反馈。 +- 旧布局在迁移验证通过后删除,不保留永久 feature flag 双实现。 + +## Deferred Until Entry Conditions Are Met + +- 票据抽屉在 PC 使用侧栏还是对话框、移动使用 bottom sheet 还是全屏层。 +- 编辑入口的最终密度、明细卡片折叠规则和提交摘要的视觉权重。 +- B-2a 真人反馈要求的字段顺序微调。 + +这些是 B-2b 启动前要补齐的设计细节,不改变本 PRD 的迁移承诺和验收结果。 diff --git a/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/implement.jsonl b/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} 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 new file mode 100644 index 00000000..bb8b53c7 --- /dev/null +++ b/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/implement.md @@ -0,0 +1,22 @@ +# Implementation Plan:B-2b 报销填写态共享组件迁移 + +## Entry Gate + +- [ ] B-2a 已部署并取得至少一次真人审批反馈。 +- [ ] 回填本任务最终 drawer/sheet 交互与字段顺序。 +- [ ] 用户重新批准更新后的 B-2b planning summary。 + +## Planned Sequence + +1. [ ] 为 B-2a 共享组件接入 edit capability,保持 review/readonly 零维护动作。 +2. [ ] PC 填写态迁移明细、票据和就近检查。 +3. [ ] 移动填写态迁移相同语义结构与安全区操作。 +4. [ ] 接入票据抽屉和未归属票据恢复。 +5. [ ] 接入提交前摘要与服务端错误就近定位。 +6. [ ] 删除被替代的旧明细/材料/只读展示分支。 +7. [ ] 完成状态/权限矩阵、全量前端门禁和独立部署验证。 + +## Rollback + +- 独立于 B-2a 形成候选与回滚点。 +- 不改变 B2 服务端门禁或既有票据关联数据;代码回滚恢复旧填写 UI,不删除业务数据。 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 new file mode 100644 index 00000000..61a212a0 --- /dev/null +++ b/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/prd.md @@ -0,0 +1,71 @@ +# B-2b 报销填写态共享组件迁移 + +## Goal + +在 B-2a 共享内容组件经真实审批使用验证后,把 PC/移动报销填写态迁入同一业务结构,消除旧填写态、新审批态和旧只读态长期并存,并完成此前延期的票据抽屉与提交前检查体验。 + +## Entry Conditions + +- B-2a 已部署并通过 PC/移动公网核验。 +- 至少取得一次真实报销审批反馈,确认审批人能理解明细—票据对应、快照/实时标签和退回引用。 +- B-2a 共享组件 API 已冻结;若真实反馈要求改字段顺序,先回写 B-2a 组件合同再启动迁移。 + +## Scope + +- PC `ReimbursementDetailPage` 与移动 `MobileReimbursementDetailPage` 的 draft/rejected 填写态及非参与人只读内容投影。 +- B-2a 共享组件的 edit 能力、票据抽屉、提交前检查和错误恢复。 +- 删除被共享组件替代的旧明细/材料展示分支,并保持现有 API、权限和 B2 服务端门禁权威。 + +## Requirements + +### R1 填写态采用同一业务内容组件 + +- PC/移动填写态、审批态和非参与人只读态共享同一“明细 + 所属票据”组件及字段顺序。 +- 填写态的新增、编辑、删除、关联和恢复动作通过显式能力与回调注入;审批/只读模式不渲染这些能力。 +- 删除被共享组件替代的旧明细表格、旧材料对应展示或重复只读布局,不保留永久双实现兜底。 + +### R2 票据抽屉围绕当前明细工作 + +- 员工可从明细就近添加/选择 PDF 发票,查看已关联票据和解除自己可维护状态下的关联。 +- 整单补充附件仍独立,不伪装成明细票据。 +- 未归属票据有明确恢复入口;不得要求员工依赖操作顺序或跳到另一大区寻找。 + +### R3 提交前检查进入填写路径 + +- 在填写过程中持续显示明细完整性、票据关联、票面金额证据和需人工核对摘要。 +- 提交动作前展示与服务端 B2 一致的检查结果;服务端仍为权威,前端不得自行放宽。 +- 错误就近落到明细/票据,汇总区只告诉员工还有几项及如何到达,不只在页面顶部报错。 + +### R4 填写与审批语义一致 + +- 发生日期不默认等于发票日期;跨天/跨期同一业务可作为一条明细并写明起止日期。 +- 系统不自动判断明细是否应拆分,页面把它表达为填报要求和财务复核项。 +- 需票明细、无票补助、manual review 和金额不可用继续遵守现有业务事实源及 B2 合同。 + +## Acceptance Criteria + +- [ ] PC/移动填写态使用 B-2a 同一业务内容组件,审批/只读/编辑模式只在能力和动作上不同。 +- [ ] 员工可在明细上下文内完成票据添加、归属查看和错误恢复,不再依赖“先明细、后整单材料区”的隐含顺序。 +- [ ] 提交前检查与服务端 B2 结果一致,错误就近显示且再次提交链路可完成。 +- [ ] 旧重复布局和不再使用的展示分支被删除;父任务不存在三套并行内容结构。 +- [ ] PC/移动、参与人/非参与人、draft/rejected/reviewing 状态矩阵通过,既有票据/附件权限无回归。 +- [ ] 公网部署和真实员工重提反馈通过后,父任务才可归档。 + +## Out of Scope + +- 自动从票据推断发生日期或业务拆分正确性。 +- 结构化退回原因和统计模型。 +- 财务代员工编辑、批量改票或新审批状态。 +- 与 B-2a 同批部署;本任务必须是独立候选、独立回滚点。 + +## 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`;部署和公网验证另行批准。 + +## Planning Note + +本子任务当前只冻结迁移承诺和验收边界。详细 `design.md` / `implement.md` 在 B-2a 真人反馈回填后编写,避免在共享组件尚未验证前固化票据抽屉交互。 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 new file mode 100644 index 00000000..5f926c60 --- /dev/null +++ b/.trellis/tasks/09-01-reimbursement-form-shared-component-migration/task.json @@ -0,0 +1,26 @@ +{ + "id": "reimbursement-form-shared-component-migration", + "name": "reimbursement-form-shared-component-migration", + "title": "B-2b 报销填写态共享组件迁移", + "description": "在 B-2a 真人反馈后,把 PC/移动填写态迁入同一明细票据组件并删除重复旧布局。", + "status": "planning", + "dev_type": null, + "scope": "client,spec", + "package": null, + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-09-01", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "09-01-reimbursement-one-page-approval-workbench", + "relatedFiles": [], + "notes": "父任务第二阶段;B-2a 部署和真人反馈是启动条件,当前只冻结迁移承诺与验收边界。", + "meta": {} +} diff --git a/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/check.jsonl b/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} 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 new file mode 100644 index 00000000..bb70919c --- /dev/null +++ b/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/design.md @@ -0,0 +1,129 @@ +# Design:报销审批一页式工作台 + +## 1. Architecture + +父任务采用两阶段迁移,不做全页面一次性重写: + +```text +B-2a +Workflow task shell + + submission snapshot adapter + + live reimbursement detail query + + shared item/evidence component (review mode) + + approval decision panel + +B-2b +Existing reimbursement form shell + + same shared item/evidence component (edit mode) + + invoice drawer + + submit preflight summary + - retired legacy item/material duplication +``` + +共享边界选在“明细 + 挂在明细下的票据”,而不是复用整个旧 `ReimbursementDetailPage`。旧页面混有填写 mutation、权限判断、材料上传、处理进度和操作面板,直接嵌入审批页会把报销人维护能力与审批职责再次耦合。 + +## 2. Data Authority Matrix + +| 信息 | 权威来源 | 时间语义 | 展示标签 | +| --- | --- | --- | --- | +| 报销头、明细、票据归属、附件 | `GET /reimbursements/:id` | 当前事实 | 当前报销内容 | +| 票面金额证据/人工核对 | 报销详情返回的 amount evidence | 当前事实 | 当前票据检查 | +| 费用标准超标 | `workflowInstance.contextData.standardViolations` | 提交冻结 | 提交时检查 | +| 预算提示 | 新增最小 `contextData.budgetCheck` | 提交冻结 | 提交时预算 | +| 审批任务/历史 | workflow task/history | 流程历史 | 处理历史 | +| 办公电子设备提示 | 明细类型 + 既有固定阈值纯函数 | 当前确定性规则 | 选类提示 | + +不从审批页调用实时 precheck 替代快照。历史 context 缺 `budgetCheck` 时返回 null 并明确显示缺失,不做当前预算回填。 + +## 3. Shared Component Contract + +建议建立面向展示模型的共享组件,而不是传整个 workflow 对象: + +```ts +type ReimbursementItemEvidenceModel = { + item: ReimbursementItem; + invoices: ReimbursementInvoiceEvidence[]; + snapshotChecks: SubmissionCheck[]; + liveChecks: LiveEvidenceCheck[]; +}; + +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 再接入,避免审批页出现维护分支。 + +## 4. Responsive Page Structure + +### PC + +```text +审批详情:报销单号 / 状态 / 当前节点 +┌──────────────────────────────────────┬────────────────────┐ +│ 报销摘要与事由 │ 合计 │ +│ 明细 1 │ 当前节点/状态 │ +│ ├─ 费用/日期/金额/说明 │ 合并检查摘要 │ +│ └─ 发票 A / 发票 B / 原件入口 │ 审批意见 │ +│ 明细 2 ... │ [通过] │ +│ 尚未对应明细的票据 │ [退回][驳回][更多] │ +│ 补充附件 │ (sticky) │ +│ 处理历史 │ │ +└──────────────────────────────────────┴────────────────────┘ +``` + +右栏只放状态摘要和决策动作,不承载完整审批流。提交快照与当前票据检查默认合并为一块,只有二者不一致时在同一块内显示差异。动作使用一个主按钮、少量横向紧凑次按钮和“更多”,禁止竖向动作卡片。PC 主列底部不复制按钮;低于两栏断点时,同一个动作面板才进入内容底部。 + +### Mobile + +```text +状态/单号 +报销摘要 +明细 1 → 所属票据 +明细 2 → 所属票据 +未归属票据 +补充附件 +提交时检查 +当前票据检查 +处理历史 +底部:[通过] [更多] +``` + +移动“更多”承载退回、驳回和转审;理由输入与确认保持语义化焦点管理。内容底部预留 safe-area 和固定操作栏高度。 + +审批页与填写页的动作规则有意不同:审批是只读核验后的一次决策,保持单一固定入口;填写是长表单,B-2b 可基于填写动线另行确认是否需要表单末尾提交入口。这不改变共享业务内容组件。 + +## 5. Snapshot Contract + +报销提交已经形成 `budgetCheck`,B-2a 只把其最小、无敏感字段的值同时写入 workflow `context_data`:预算标识/代码、控制模式、使用比例、是否达到阈值、是否超预算。workflow task detail 对外投影显式字段,不原样返回任意 context JSON。 + +`standardViolations` 继续沿用当前快照。两者必须和创建 workflow instance 同一事务写入;退回、规则变更或预算后续变化不改写旧实例。 + +## 6. Live Detail Loading and Permissions + +B-2a 客户端在 workflow task 已授权后读取现有报销详情 API,复用既有只读投影、材料查看和审计合同,不复制 server select。该双查询把在线证据与 workflow 离线摘要分开,但不能只依赖 DTO 当前形状保证安全;`cacheWorkflowTaskDetail` 仍必须改为显式允许字段投影,防止未来 workflow DTO 扩展后把证据自动落盘。 + +若 workflow task 可见但报销详情因既有对象权限失败,页面 fail closed 并记录为权限合同缺口;本任务不得只在前端绕过。实施前用角色/委托矩阵证明当前审批人、财务和已处理查看者的读取基线,发现缺口时只设计“任务关联对象只读”最小授权,不授予 `expense.view_all` 写能力。 + +## 7. Return Reference Without Schema Change + +组件生成无 UUID 的可读引用,例如“住宿费|2026-08-25|¥680.00”或“发票|2026-08-25|¥680.00”。点击后把引用加入审批页本地待退回清单,退回确认时合成为普通 comment 文本。删除引用只改本地状态;服务端接口和数据库不变。 + +## 8. Compatibility and Migration + +- B-2a 保留完整原始表单入口作为降级路径;核心核验不得依赖它。 +- B-2a 不删除旧填写组件,只新增共享组件的 review/readonly 能力。 +- B-2b 接入 edit 能力后删除被替代布局;不长期维护双实现。 +- 旧 workflow 无预算快照兼容为 null。 +- 当前已处理任务展示实时内容时加版本提示,避免历史误读。 + +## 9. UI/UX Review Notes + +`ui-ux-pro-max` 的自动 design-system 搜索把本页误配为 FAQ/夸张极简风,未采用该视觉结果;本任务保留项目现有 OA 设计系统,仅采用其适用的通用规则:错误就近、恢复动作明确、语义 HTML、焦点管理、移动触控区域不小于 44px、警告不只靠颜色、sticky 区不遮挡内容。 + +布局本身不构成新视觉裁决,而是落实既有 `docs/产品设计规则.md:73` 与归档统一页面评审:证据居中、历史靠后、PC 右侧或移动底部单一动作面板、多个审批动作不得竖向铺卡片。 diff --git a/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/implement.jsonl b/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/implement.md b/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/implement.md new file mode 100644 index 00000000..52b4f84c --- /dev/null +++ b/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/implement.md @@ -0,0 +1,25 @@ +# Implementation Plan:报销审批一页式工作台 + +## Delivery Map + +1. 先实施并独立部署 B-2a。 +2. 收集至少一次真实报销审批反馈。 +3. 回填 B-2b 详细设计后独立实施和部署。 +4. B-2b 删除重复布局并通过真实填写/审批闭环后,父任务才可完成归档。 + +## Parent Integration Gates + +- [ ] B-2a 的共享组件 API 不依赖 workflow 页面对象。 +- [ ] 快照/实时数据标签和缺失兼容在 B-2a 固化。 +- [ ] PC 使用右侧单一动作面板且主列底部无重复动作;完整审批流/历史位于主列证据之后。 +- [ ] 移动 workflow 缓存使用显式允许字段投影,报销证据未落盘且离线报销动作全部阻断。 +- [ ] 真人反馈没有发现审批人仍需跳转取得核心内容。 +- [ ] B-2b 采用同一组件并删除被替代布局。 +- [ ] 两轮均有独立 commit、候选 SHA、回滚点和公网验证记录。 +- [ ] 更新 frontend spec,替换旧“只提供业务详情出口”的临时合同。 + +## Validation + +- 每个子任务分别运行其 PRD Test Plan。 +- 父任务收口运行 `task.py validate`(父与两个子任务)、Trellis governance、process contract、docs registry(如更新 docs)和 `git diff --check`。 +- 面向用户可测试的改动只有在各自部署和公网检查通过后才能通知测试。 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 new file mode 100644 index 00000000..7a64500e --- /dev/null +++ b/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/prd.md @@ -0,0 +1,114 @@ +# 报销审批一页式工作台 + +## Goal + +让主管和财务在同一审批页面内完成“看报销内容—核明细与票据—识别风险—填写意见—作出审批决定”,不再把“查看报销单”外链作为取得核心报销内容的必经入口;同时建立可复用的“报销明细 + 所属票据”业务内容组件,并承诺在后续填写态迁移后消除历史布局分叉。 + +## Background and Confirmed Facts + +- PC `WorkflowTaskDetailPage.tsx:302-305` 的报销投影只有报销单号、金额、提交人和报销人,随后提供“查看报销单”外链;移动审批详情同样只有摘要和“查看完整报销单”。 +- 完整报销详情已有实时明细、发票、附件、材料预览、票面金额证据和处理进度,但其布局以报销人填写/旧只读查看为中心,不按审批决策顺序组织。 +- 审批页的 `workflowInstance.contextData.standardViolations` 是提交时冻结快照;报销详情、票据关联及金额证据来自当前报销实时读取。两者语义不同,不能互相替代。 +- 报销提交事务已经计算 `budgetCheck`,但当前只写入提交响应,没有进入 workflow 快照;新提交若要让审批人看到提交时预算事实,需要补充 workflow context 快照合同。 +- 非参与人只读投影任务已明确:审批/财务详情不主动请求参与人专用 precheck,行内费用标准提示由本任务恢复;不得为恢复提示而放宽 precheck 对象权限。 +- 移动审批当前允许缓存任务摘要和离线保存通过/驳回草稿,但缓存不含完整报销明细与票据证据。 +- 现有前端 spec 的“审批页只提供业务详情出口、不复制报销内容”是旧阶段的临时可达性合同,本任务批准实施后应被新的共享内容组件合同取代,而不是继续并存。 +- 现有 `docs/产品设计规则.md:73` 已规定“证据居中、审批/处理历史靠后、当前动作面板固定在 PC 右侧或移动端底部”;归档统一设计评审还规定审批动作不得竖向铺成多个大卡片。当前报销审批页只有四字段和外链,未满足既有证据结构;本任务是回归既有规则,不另创页面范式。 + +## Delivery Strategy + +采用“先审批、后填写迁移”的 B 路线,并拆成两次可独立验收的交付: + +1. **B-2a 报销审批内容工作台**:建立共享的“明细 + 所属票据”只读/核验组件,先接入 PC 和移动审批页;审批人无需跳转即可取得决策所需内容。 +2. **B-2b 报销填写态共享组件迁移**:在 B-2a 真人使用反馈稳定后,把 PC/移动填写态迁移到同一组件,并完成票据抽屉、提交前检查和旧只读布局收口。 + +B-2a 完成时允许旧填写态暂时存在;B-2b 是本父任务的必交子任务,不得以“以后优化”关闭父任务。父任务只有在填写态迁移完成、旧重复内容布局被删除或明确退役后才能归档为完成。 + +## Scope + +- 父任务负责两阶段交付合同、共享组件边界、快照/实时数据口径、跨子任务验收和最终旧布局收口。 +- B-2a 负责 PC/移动审批一页式内容工作台与共享组件 review/readonly 能力。 +- B-2b 负责 PC/移动填写态迁移、票据抽屉、提交前检查和重复布局删除。 +- 两个子任务分别形成候选、部署和回滚点;父任务不直接承载业务代码实施。 + +## Product Requirements + +### R1 审批决策不再依赖二次跳转 + +- 报销审批详情直接展示报销事由、报销人/提交人、部门、日期、金额、明细、每条明细所属票据、未归属票据、补充附件、核验提示和审批历史。 +- “查看报销单”降级为“打开完整原始表单/打印视图”的次要入口,用于少见的完整字段追溯,不再承担核心内容可达性。 +- 非报销审批类型保持当前各自投影,本父任务不借机重构请假、采购、差旅或考勤审批。 + +### R2 共享内容组件必须有明确迁移终点 + +- B-2a 建立的共享件以业务数据和交互能力为边界,不依赖 `WorkflowTaskDetailPage` 或审批状态;至少覆盖“明细 + 关联票据 + 行内核验提示”。 +- PC 与移动审批使用同一语义组件和字段顺序,只允许响应式布局和操作容器不同。 +- B-2b 必须让 PC/移动填写态采用同一组件;编辑、删除、关联等维护动作通过显式能力/回调注入,不复制一套填写专用展示结构。 +- B-2b 完成后,不得继续保留“旧填写态、新审批态、旧只读态”三套并行布局。 + +### R3 快照事实与实时事实分层展示 + +- **提交时快照**:费用标准超标、预算提示及提交时形成的规则/风险结论。审批页面必须标注“提交时检查”,不得用当前 precheck 结果覆盖。 +- **当前实时事实**:报销头、明细、票据归属、票面金额证据、补充附件及当前可见的材料状态。审批页面必须标注“当前报销内容/当前票据检查”。 +- 退回补票、重绑或重提后,当前内容可以变化;历史审批任务查看到当前版本时必须给出版本语义,不得把当前内容伪装为旧任务处理当时的原始快照。 +- 历史 workflow 没有预算快照时显示“该次提交未记录预算快照”,不得回填或冒充当前预算结果。 + +### R4 恢复审批人需要的行内提示 + +- `standardViolations` 按 `itemId` 回到对应明细旁显示,并明确控制模式和审批影响。 +- 办公用品达到既有金额阈值时继续显示“通常应选择办公电子设备”的非阻断提示;不得把该阈值描述成会计固定资产标准。 +- 票据未归属、需票明细缺票、票面金额需人工核对或金额不可用等当前事实,必须就近显示到具体明细或票据;系统不校验日期粒度或明细拆分正确性,页面不得暗示会自动判断。 + +### R5 退回定位先解决可用性,不扩大数据模型 + +- B-2a 允许审批人从具体明细或票据发起“引用到退回意见”,生成员工可读的对象描述并带入现有自由文本意见。 +- 本轮不新增退回原因分类表、结构化 reason schema、migration 或新的 workflow 状态;最终仍通过现有 comment 字段存储自由文本。 +- 若后续真人使用证明需要统计型退回原因,再另建数据模型任务,不在 B-2a 顺手扩展。 + +### R6 审批动作与证据可见性绑定 + +- PC 宽屏右侧 sticky 区只展示状态摘要、报销合计、合并后的检查摘要、审批意见与决策动作;完整审批流和处理历史放在主列证据之后。 +- 提交时快照与当前票据事实默认合成一个短检查摘要,只有两者不一致时额外标出差异及时间语义,避免在窄栏并列两个难以区分的“检查摘要”。 +- PC 只有右侧一个审批动作面板,不在主列底部复制第二套按钮;窄屏降为单列时,同一动作面板才转到底部。 +- `通过`为唯一主按钮,`退回`、`驳回`为紧凑次操作,`转审`默认进入“更多”;禁止把多个处理动作竖向铺成大卡片。 +- 移动端按同一内容顺序单列展示,底部只保留一个主操作,退回、驳回和转审进入次级操作/确认流程;固定操作区不得遮挡内容。 +- 报销内容或关键核验数据加载失败时,不得仍允许审批人基于四字段摘要直接通过。 + +### R7 报销审批离线必须保护决策和证据 + +- 报销审批必须联网并成功加载完整明细、票据和关键检查后,才能通过、退回、驳回或保存审批草稿;离线摘要不得成为报销决策依据。 +- `cacheWorkflowTaskDetail` 必须使用显式脱敏/允许字段投影,排除报销明细、票据证据、附件元数据和材料访问数据;不能依赖“当前 workflow DTO 尚未包含这些字段”作为安全边界。 +- 其他审批类型维持现有离线能力,本任务不全局关闭移动审批离线草稿。 +- 完整报销内容只保留在在线查询生命周期内,不为 B-2a 扩展 IndexedDB workflow cache;如未来需要离线报销审批,必须另行规划敏感数据缓存、过期、清除和设备边界。 + +## Acceptance Criteria + +- [ ] B-2a 和 B-2b 作为两个独立子任务存在,前者可单独上线产生完整业务价值,后者是父任务关闭前的强制迁移项。 +- [ ] PC 与移动报销审批页无需跳转即可看到完整决策内容,并能打开票据原件和补充附件。 +- [ ] 每条明细与所属票据在同一区域展示;未归属票据和行内提示可直接定位,不依赖页面顶部汇总猜测对象。 +- [ ] 费用标准与预算使用提交时快照,票据关联与金额证据使用当前实时事实;页面文字明确区分两者。 +- [ ] 历史缺少预算快照时不做实时替代;退回后内容变化时不把当前版本冒充旧提交快照。 +- [ ] 非参与人只读权限、材料查看审计、审批状态机和 B2 票据门禁不因页面整合而放宽。 +- [ ] B-2a 恢复明细行内费用标准提示和既有办公电子设备提示。 +- [ ] 退回可以引用具体明细/票据到现有自由文本意见;本父任务不新增结构化退回原因数据模型。 +- [ ] PC 只有右侧单一审批动作面板,完整流程/历史位于主列靠后;窄屏和移动才把同一动作面板转到底部,多个动作不竖向铺卡片。 +- [ ] 报销审批离线时不能形成任何审批动作或草稿,且离线缓存中不存在明细、票据、附件或材料访问证据;其他审批类型离线合同不变。 +- [ ] B-2b 完成后,填写态、审批态和只读态共享同一业务内容组件,不再保留三套漂移布局。 +- [ ] 两次交付分别通过各自的前后端、PC/移动、无障碍、文案、权限和部署验证;生产部署与真人数据写入仍需单独批准。 + +## Out of Scope + +- 一次性同时重写审批态和填写态。 +- 请假、采购、差旅申请、考勤等其他业务的审批内容重构。 +- 结构化退回原因 schema、统计报表、数据库 migration。 +- 修改审批角色、审批规则、状态机、B2 flag 或付款流程。 +- 自动判断明细日期粒度、业务是否应拆分或费用是否真实合理。 +- 操作或读取三张真人 rejected 报销单;其业务处理与本任务并行推进。 +- 推送、部署、生产配置或生产数据写入。 + +## Test Plan + +- 分别执行 B-2a、B-2b PRD 的前后端、PC/移动、权限与部署验证。 +- 父任务收口时验证共享组件确实被审批态、填写态和只读态复用,并检查旧重复布局已删除或退役。 +- 运行父子三任务 `task.py validate`、process contract、Trellis governance、敏感内容检查和 `git diff --check`。 +- 如同步业务事实源,再运行 docs registry;未经部署和公网检查,不通知用户测试。 diff --git a/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/task.json b/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/task.json new file mode 100644 index 00000000..1674aee7 --- /dev/null +++ b/.trellis/tasks/09-01-reimbursement-one-page-approval-workbench/task.json @@ -0,0 +1,29 @@ +{ + "id": "reimbursement-one-page-approval-workbench", + "name": "reimbursement-one-page-approval-workbench", + "title": "报销审批一页式工作台", + "description": "拆分交付报销审批一页式内容工作台与填写态共享组件迁移,统一明细、票据和核验信息结构。", + "status": "planning", + "dev_type": null, + "scope": "trellis,client,server,spec", + "package": null, + "priority": "P1", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-09-01", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [ + "09-01-reimbursement-approval-content-workbench", + "09-01-reimbursement-form-shared-component-migration" + ], + "parent": null, + "relatedFiles": [], + "notes": "采用 B 路线:B-2a 先审批、B-2b 后迁移填写态;父任务仅在旧重复布局收口后完成。审批页回归既有右侧单一动作面板规则;移动报销审批要求联网且证据不进入离线缓存。规划阶段不编码、不部署、不处理真人单据。", + "meta": {} +} diff --git a/client/src/api/workflow.ts b/client/src/api/workflow.ts index 5aa81d34..4db4c11d 100644 --- a/client/src/api/workflow.ts +++ b/client/src/api/workflow.ts @@ -72,6 +72,15 @@ export type WorkflowHistoryItem = { createdAt: string | null; }; +export type ReimbursementBudgetCheckSnapshot = { + budgetId: string; + budgetCode: string; + controlMode: "warn" | "block"; + usagePercent: number; + exceededThreshold: boolean; + exceededBudget: boolean; +}; + export type WorkflowTaskDetail = { task: { id: string; @@ -93,6 +102,9 @@ export type WorkflowTaskDetail = { currentStepId: string; contextData: { standardViolations: ExpenseStandardCheck[]; + budgetCheck: ReimbursementBudgetCheckSnapshot | null; + budgetCheckStatus: "available" | "not_applicable" | "missing"; + parseStatus: "ready" | "invalid"; }; startedAt: string | null; completedAt: string | null; diff --git a/client/src/components/reimbursements/ReimbursementItemEvidenceList.tsx b/client/src/components/reimbursements/ReimbursementItemEvidenceList.tsx new file mode 100644 index 00000000..8f0fd210 --- /dev/null +++ b/client/src/components/reimbursements/ReimbursementItemEvidenceList.tsx @@ -0,0 +1,183 @@ +import { Button } from "../ui/button"; +import { formatDisplayDate, formatMoney } from "../../lib/displayFormat"; +import type { + ReimbursementApprovalEvidenceProjection, + ReimbursementAttachmentEvidence, + ReimbursementInvoiceEvidence, + ReimbursementItemEvidence, +} from "../../lib/reimbursementApprovalProjection"; + +export type ReimbursementEvidenceCapabilities = { + onOpenInvoice?: (invoice: ReimbursementInvoiceEvidence) => void; + onOpenAttachment?: (attachment: ReimbursementAttachmentEvidence) => void; + onOpenMaterialBundle?: () => void; + onReferenceItem?: (item: ReimbursementItemEvidence) => void; + onReferenceInvoice?: (invoice: ReimbursementInvoiceEvidence) => void; + onEditItem?: (item: ReimbursementItemEvidence) => void; + onDeleteItem?: (item: ReimbursementItemEvidence) => void; + onLinkInvoice?: (invoice: ReimbursementInvoiceEvidence) => void; + onUnlinkInvoice?: (invoice: ReimbursementInvoiceEvidence) => void; +}; + +type Props = { + evidence: ReimbursementApprovalEvidenceProjection; + capabilities?: ReimbursementEvidenceCapabilities; +}; + +function evidenceStatusCopy(status: ReimbursementInvoiceEvidence["amountEvidenceStatus"]) { + if (status === "verified") return "票面金额已读取"; + if (status === "ocr_failed") return "票面金额未能读取,暂时不能完成核验"; + return "票面金额需查看原件人工核对"; +} + +function InvoiceCard({ + invoice, + capabilities, +}: { + invoice: ReimbursementInvoiceEvidence; + capabilities: ReimbursementEvidenceCapabilities; +}) { + const title = invoice.invoiceNumber || invoice.originalName || "未命名发票"; + return ( +
+
+
+

{title}

+

+ {invoice.sellerName || invoice.documentSubtype || invoice.documentCategory || "票据"} + {" · "}{formatDisplayDate(invoice.invoiceDate)} + {" · "}{formatMoney(invoice.totalAmount)} +

+

+ {evidenceStatusCopy(invoice.amountEvidenceStatus)} +

+
+
+ {capabilities.onOpenInvoice ? ( + + ) : null} + {capabilities.onReferenceInvoice ? ( + + ) : null} + {capabilities.onLinkInvoice && !invoice.linkedItemId ? ( + + ) : null} + {capabilities.onUnlinkInvoice && invoice.linkedItemId ? ( + + ) : null} +
+
+
+ ); +} + +export function ReimbursementItemEvidenceList({ evidence, capabilities = {} }: Props) { + return ( +
+
+ {evidence.items.length ? evidence.items.map((item, index) => ( +
+
+
+

明细 {index + 1}

+

{item.expenseType?.name ?? "未分类费用"}

+

+ {formatDisplayDate(item.expenseDate)} · {formatMoney(item.amount)} +

+
+ {capabilities.onReferenceItem ? ( + + ) : null} + {capabilities.onEditItem ? ( + + ) : null} + {capabilities.onDeleteItem ? ( + + ) : null} +
+

{item.description || "未填写明细说明"}

+ {item.assetPriorApprovalBasis ? ( +

+ 事前批准依据:{item.assetPriorApprovalBasis} +

+ ) : null} + {item.standardViolations.map((violation) => ( +

+ 提交时费用标准提示:申报 {formatMoney(violation.amount)},标准 {formatMoney(violation.maxAmount)},超出 {formatMoney(violation.exceededAmount)}。审批时请核对原因。 +

+ ))} + {item.showOfficeElectronicsHint ? ( +

+ 该金额通常应选择办公电子设备,请核对费用类型和事前批准依据。 +

+ ) : null} + {item.amountExceeded ? ( +

+ 当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。 +

+ ) : null} +
+

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

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

+ 这条明细尚无对应票据,请在作出审批决定前核对是否需要补充。 +

+ )} +
+
+ )) : ( +

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

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

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

+

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

+
+ {evidence.unlinkedInvoices.map((invoice) => ( + + ))} +
+ ) : null} + +
+
+

补充附件({evidence.attachments.length})

+ {capabilities.onOpenMaterialBundle ? ( + + ) : null} +
+ {evidence.attachments.length ? evidence.attachments.map((attachment) => ( +
+
+

{attachment.originalName}

+

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

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

未上传补充附件

} +
+
+ ); +} diff --git a/client/src/lib/reimbursementApprovalProjection.ts b/client/src/lib/reimbursementApprovalProjection.ts new file mode 100644 index 00000000..f1ecd081 --- /dev/null +++ b/client/src/lib/reimbursementApprovalProjection.ts @@ -0,0 +1,147 @@ +import type { ExpenseStandardCheck } from "../api/feeControl"; +import type { ReimbursementDetail } from "../api/reimbursements"; +import type { ReimbursementBudgetCheckSnapshot } from "../api/workflow"; +import { shouldShowOfficeElectronicsHint } from "./reimbursementPolicyHints"; + +type DetailItem = ReimbursementDetail["items"][number]; +type DetailInvoice = ReimbursementDetail["invoices"][number]; +type DetailAttachment = ReimbursementDetail["attachments"][number]; +export type ReimbursementAttachmentEvidence = Pick< + DetailAttachment, + "id" | "originalName" | "mimeType" | "fileSize" | "createdAt" +>; + +export type ReimbursementInvoiceEvidence = Pick< + DetailInvoice, + "id" | "invoiceCode" | "invoiceNumber" | "invoiceDate" | "sellerName" | + "documentCategory" | "documentSubtype" | "totalAmount" | "originalName" | + "mimeType" | "linkedItemId" | "amountEvidenceStatus" +>; + +export type ReimbursementItemEvidence = Pick< + DetailItem, + "id" | "expenseDate" | "amount" | "description" | "assetPriorApprovalBasis" | "expenseType" +> & { + invoices: ReimbursementInvoiceEvidence[]; + standardViolations: ExpenseStandardCheck[]; + showOfficeElectronicsHint: boolean; + amountExceeded: boolean; +}; + +export type ReimbursementApprovalEvidenceProjection = { + items: ReimbursementItemEvidence[]; + unlinkedInvoices: ReimbursementInvoiceEvidence[]; + attachments: ReimbursementAttachmentEvidence[]; + currentCheck: { + invoiceCount: number; + unlinkedInvoiceCount: number; + missingInvoiceItemCount: number; + amountUnavailableItemCount: number; + manualReviewItemCount: number; + amountExceededItemCount: number; + }; +}; + +export type ReimbursementSubmissionSnapshot = { + standardViolations: ExpenseStandardCheck[]; + budgetCheck: ReimbursementBudgetCheckSnapshot | null; + budgetCheckStatus: "available" | "not_applicable" | "missing"; + parseStatus: "ready" | "invalid"; +}; + +function toInvoiceEvidence(invoice: DetailInvoice): ReimbursementInvoiceEvidence { + return { + id: invoice.id, + invoiceCode: invoice.invoiceCode, + invoiceNumber: invoice.invoiceNumber, + invoiceDate: invoice.invoiceDate, + sellerName: invoice.sellerName, + documentCategory: invoice.documentCategory, + documentSubtype: invoice.documentSubtype, + totalAmount: invoice.totalAmount, + originalName: invoice.originalName, + mimeType: invoice.mimeType, + linkedItemId: invoice.linkedItemId, + amountEvidenceStatus: invoice.amountEvidenceStatus, + }; +} + +export function buildReimbursementApprovalEvidence( + detail: Pick, + snapshot: ReimbursementSubmissionSnapshot, +): ReimbursementApprovalEvidenceProjection { + const invoicesByItem = new Map(); + const unlinkedInvoices: ReimbursementInvoiceEvidence[] = []; + + for (const invoice of detail.invoices) { + const evidence = toInvoiceEvidence(invoice); + if (!invoice.linkedItemId) { + unlinkedInvoices.push(evidence); + continue; + } + const itemInvoices = invoicesByItem.get(invoice.linkedItemId) ?? []; + itemInvoices.push(evidence); + invoicesByItem.set(invoice.linkedItemId, itemInvoices); + } + + const violationsByItem = new Map(); + for (const violation of snapshot.standardViolations) { + const itemViolations = violationsByItem.get(violation.itemId) ?? []; + itemViolations.push(violation); + violationsByItem.set(violation.itemId, itemViolations); + } + + const items = detail.items.map((item) => { + const invoices = invoicesByItem.get(item.id) ?? []; + const allAmountsVerified = invoices.length > 0 && invoices.every((invoice) => invoice.amountEvidenceStatus === "verified"); + const invoiceTotal = allAmountsVerified + ? invoices.reduce((sum, invoice) => sum + Number(invoice.totalAmount ?? 0), 0) + : 0; + return { + id: item.id, + expenseDate: item.expenseDate, + amount: item.amount, + description: item.description, + assetPriorApprovalBasis: item.assetPriorApprovalBasis, + expenseType: item.expenseType, + invoices, + standardViolations: violationsByItem.get(item.id) ?? [], + showOfficeElectronicsHint: shouldShowOfficeElectronicsHint(item.expenseType, item.amount), + amountExceeded: allAmountsVerified && Number.isFinite(invoiceTotal) && Number(item.amount) > invoiceTotal, + }; + }); + + return { + items, + unlinkedInvoices, + attachments: detail.attachments.map((attachment) => ({ + id: attachment.id, + originalName: attachment.originalName, + mimeType: attachment.mimeType, + fileSize: attachment.fileSize, + createdAt: attachment.createdAt, + })), + currentCheck: { + invoiceCount: detail.invoices.length, + unlinkedInvoiceCount: unlinkedInvoices.length, + missingInvoiceItemCount: items.filter((item) => item.expenseType?.requiresInvoice && item.invoices.length === 0).length, + amountUnavailableItemCount: items.filter((item) => item.invoices.some((invoice) => invoice.amountEvidenceStatus === "ocr_failed")).length, + manualReviewItemCount: items.filter((item) => item.invoices.some((invoice) => invoice.amountEvidenceStatus === "manual_review")).length, + amountExceededItemCount: items.filter((item) => item.amountExceeded).length, + }, + }; +} + +export function buildReimbursementReturnReference(input: { + item?: ReimbursementItemEvidence; + invoice?: ReimbursementInvoiceEvidence; +}) { + if (input.invoice) { + const invoiceName = input.invoice.invoiceNumber || input.invoice.originalName || "未命名发票"; + return `【发票:${invoiceName}】`; + } + if (input.item) { + return `【明细:${input.item.expenseType?.name ?? "未分类费用"} · ${input.item.expenseDate ?? "日期未填"} · ¥${input.item.amount}】`; + } + return ""; +} diff --git a/client/src/lib/reimbursementPolicyHints.ts b/client/src/lib/reimbursementPolicyHints.ts new file mode 100644 index 00000000..0733142d --- /dev/null +++ b/client/src/lib/reimbursementPolicyHints.ts @@ -0,0 +1,11 @@ +export const OFFICE_ELECTRONICS_HINT_THRESHOLD = 2000; + +export function shouldShowOfficeElectronicsHint( + expenseType: { code: string } | null | undefined, + amount: string | number, +) { + const numericAmount = Number(amount); + return expenseType?.code === "office_supplies" + && Number.isFinite(numericAmount) + && numericAmount >= OFFICE_ELECTRONICS_HINT_THRESHOLD; +} diff --git a/client/src/mobile/workflowActionQueue.ts b/client/src/mobile/workflowActionQueue.ts index d4fa02b3..a040504d 100644 --- a/client/src/mobile/workflowActionQueue.ts +++ b/client/src/mobile/workflowActionQueue.ts @@ -24,11 +24,19 @@ export type WorkflowDraftQueueItem = { taskSnapshot: WorkflowTaskReferenceSnapshot; }; -type CachedWorkflowTaskDetail = { +type CachedWorkflowTaskDetailProjection = Omit & { + workflowInstance: Omit, "contextData"> | null; +}; + +type PersistedWorkflowTaskDetail = { id: string; ownerEmployeeId: string | null; savedAt: string; taskSnapshot: WorkflowTaskReferenceSnapshot; + detail: CachedWorkflowTaskDetailProjection; +}; + +export type CachedWorkflowTaskDetail = Omit & { detail: WorkflowTaskDetail; }; @@ -71,13 +79,185 @@ export function resolveWorkflowTaskReferenceSnapshot(detail: WorkflowTaskDetail) }; } +export function projectWorkflowTaskDetailForCache(detail: WorkflowTaskDetail): CachedWorkflowTaskDetailProjection { + const reimbursement = detail.reimbursement + ? { + id: detail.reimbursement.id, + formNumber: detail.reimbursement.formNumber, + status: detail.reimbursement.status, + reimbursementDate: detail.reimbursement.reimbursementDate, + totalAmount: detail.reimbursement.totalAmount, + description: detail.reimbursement.description, + owner: detail.reimbursement.owner ? { + id: detail.reimbursement.owner.id, + employeeNo: detail.reimbursement.owner.employeeNo, + name: detail.reimbursement.owner.name, + } : null, + submitter: detail.reimbursement.submitter ? { + id: detail.reimbursement.submitter.id, + employeeNo: detail.reimbursement.submitter.employeeNo, + name: detail.reimbursement.submitter.name, + } : null, + } + : null; + + return { + task: { + id: detail.task.id, + instanceId: detail.task.instanceId, + stepId: detail.task.stepId, + stepName: detail.task.stepName, + assigneeId: detail.task.assigneeId, + assigneeRole: detail.task.assigneeRole, + status: detail.task.status, + outcome: detail.task.outcome, + comment: detail.task.comment, + createdAt: detail.task.createdAt, + completedAt: detail.task.completedAt, + }, + workflowInstance: detail.workflowInstance + ? { + id: detail.workflowInstance.id, + entityType: detail.workflowInstance.entityType, + currentStatus: detail.workflowInstance.currentStatus, + currentStepId: detail.workflowInstance.currentStepId, + startedAt: detail.workflowInstance.startedAt, + completedAt: detail.workflowInstance.completedAt, + } + : null, + reimbursement, + leaveRequest: detail.leaveRequest ? { + id: detail.leaveRequest.id, + status: detail.leaveRequest.status, + startDate: detail.leaveRequest.startDate, + endDate: detail.leaveRequest.endDate, + startSession: detail.leaveRequest.startSession, + endSession: detail.leaveRequest.endSession, + requestedDays: detail.leaveRequest.requestedDays, + reason: detail.leaveRequest.reason, + attachment: detail.leaveRequest.attachment ? { + originalName: detail.leaveRequest.attachment.originalName, + mimeType: detail.leaveRequest.attachment.mimeType, + } : null, + leaveType: detail.leaveRequest.leaveType ? { + id: detail.leaveRequest.leaveType.id, + code: detail.leaveRequest.leaveType.code, + name: detail.leaveRequest.leaveType.name, + } : null, + owner: detail.leaveRequest.owner ? { + id: detail.leaveRequest.owner.id, + employeeNo: detail.leaveRequest.owner.employeeNo, + name: detail.leaveRequest.owner.name, + } : null, + } : null, + attendanceCorrection: detail.attendanceCorrection ? { + id: detail.attendanceCorrection.id, + status: detail.attendanceCorrection.status, + kind: detail.attendanceCorrection.kind, + workDate: detail.attendanceCorrection.workDate, + missedPunchAt: detail.attendanceCorrection.missedPunchAt, + startAt: detail.attendanceCorrection.startAt, + endAt: detail.attendanceCorrection.endAt, + reason: detail.attendanceCorrection.reason, + locationSummary: detail.attendanceCorrection.locationSummary, + compensationPolicy: detail.attendanceCorrection.compensationPolicy, + owner: detail.attendanceCorrection.owner ? { + id: detail.attendanceCorrection.owner.id, + employeeNo: detail.attendanceCorrection.owner.employeeNo, + name: detail.attendanceCorrection.owner.name, + } : null, + } : null, + purchaseRequest: detail.purchaseRequest ? { + id: detail.purchaseRequest.id, + requestNo: detail.purchaseRequest.requestNo, + status: detail.purchaseRequest.status, + title: detail.purchaseRequest.title, + reason: detail.purchaseRequest.reason, + currencyCode: detail.purchaseRequest.currencyCode, + totalAmount: detail.purchaseRequest.totalAmount, + expectedDeliveryDate: detail.purchaseRequest.expectedDeliveryDate, + applicant: detail.purchaseRequest.applicant ? { + id: detail.purchaseRequest.applicant.id, employeeNo: detail.purchaseRequest.applicant.employeeNo, name: detail.purchaseRequest.applicant.name, + } : null, + supplier: detail.purchaseRequest.supplier ? { + id: detail.purchaseRequest.supplier.id, code: detail.purchaseRequest.supplier.code, name: detail.purchaseRequest.supplier.name, + } : null, + legalEntity: detail.purchaseRequest.legalEntity ? { + id: detail.purchaseRequest.legalEntity.id, code: detail.purchaseRequest.legalEntity.code, name: detail.purchaseRequest.legalEntity.name, + } : null, + costCenter: detail.purchaseRequest.costCenter ? { + id: detail.purchaseRequest.costCenter.id, code: detail.purchaseRequest.costCenter.code, name: detail.purchaseRequest.costCenter.name, + } : null, + documentCategory: detail.purchaseRequest.documentCategory ? { + id: detail.purchaseRequest.documentCategory.id, code: detail.purchaseRequest.documentCategory.code, name: detail.purchaseRequest.documentCategory.name, + } : null, + } : null, + travelRequest: detail.travelRequest ? { + id: detail.travelRequest.id, + requestNo: detail.travelRequest.requestNo, + status: detail.travelRequest.status, + purpose: detail.travelRequest.purpose, + tripType: detail.travelRequest.tripType, + startDate: detail.travelRequest.startDate, + endDate: detail.travelRequest.endDate, + estimatedAmount: detail.travelRequest.estimatedAmount, + applicant: detail.travelRequest.applicant ? { + id: detail.travelRequest.applicant.id, employeeNo: detail.travelRequest.applicant.employeeNo, name: detail.travelRequest.applicant.name, + } : null, + legalEntity: detail.travelRequest.legalEntity ? { + id: detail.travelRequest.legalEntity.id, code: detail.travelRequest.legalEntity.code, name: detail.travelRequest.legalEntity.name, + } : null, + costCenter: detail.travelRequest.costCenter ? { + id: detail.travelRequest.costCenter.id, code: detail.travelRequest.costCenter.code, name: detail.travelRequest.costCenter.name, + } : null, + segments: detail.travelRequest.segments.map((segment) => ({ + id: segment.id, + sortOrder: segment.sortOrder, + segmentRole: segment.segmentRole, + transportType: segment.transportType, + fromCity: segment.fromCity, + toCity: segment.toCity, + })), + } : null, + allowedActions: [...detail.allowedActions], + history: detail.history.map((item) => ({ + id: item.id, + taskId: item.taskId, + actorId: item.actorId, + actorName: item.actorName, + actorEmployeeNo: item.actorEmployeeNo, + action: item.action, + comment: item.comment, + payload: null, + createdAt: item.createdAt, + })), + }; +} + +function hydrateCachedWorkflowTaskDetail(detail: CachedWorkflowTaskDetailProjection): WorkflowTaskDetail { + return { + ...detail, + workflowInstance: detail.workflowInstance + ? { + ...detail.workflowInstance, + contextData: { + standardViolations: [], + budgetCheck: null, + budgetCheckStatus: "missing", + parseStatus: "ready", + }, + } + : null, + }; +} + export async function cacheWorkflowTaskDetail(detail: WorkflowTaskDetail, ownerEmployeeId: string | null) { - const item: CachedWorkflowTaskDetail = { + const item: PersistedWorkflowTaskDetail = { id: detail.task.id, ownerEmployeeId, savedAt: new Date().toISOString(), taskSnapshot: resolveWorkflowTaskReferenceSnapshot(detail), - detail, + detail: projectWorkflowTaskDetailForCache(detail), }; await database.withStore(DETAIL_CACHE_STORE_NAME, "readwrite", async (store) => { @@ -90,20 +270,21 @@ export async function cacheWorkflowTaskDetail(detail: WorkflowTaskDetail, ownerE export async function getCachedWorkflowTaskDetail(taskId: string, ownerEmployeeId: string | null) { return database.withStore(DETAIL_CACHE_STORE_NAME, "readonly", async (store) => { - const item = (await requestPromise(store.get(taskId))) as CachedWorkflowTaskDetail | undefined; + const item = (await requestPromise(store.get(taskId))) as PersistedWorkflowTaskDetail | undefined; if (!item || !matchesOwner(item.ownerEmployeeId, ownerEmployeeId)) { return null; } - return item; + return { ...item, detail: hydrateCachedWorkflowTaskDetail(item.detail) }; }); } export async function listCachedWorkflowTaskDetails(ownerEmployeeId: string | null) { return database.withStore(DETAIL_CACHE_STORE_NAME, "readonly", async (store) => { - const records = (await requestPromise(store.getAll())) as CachedWorkflowTaskDetail[]; + const records = (await requestPromise(store.getAll())) as PersistedWorkflowTaskDetail[]; return records .filter((item) => matchesOwner(item.ownerEmployeeId, ownerEmployeeId)) - .sort((a, b) => b.savedAt.localeCompare(a.savedAt)); + .sort((a, b) => b.savedAt.localeCompare(a.savedAt)) + .map((item) => ({ ...item, detail: hydrateCachedWorkflowTaskDetail(item.detail) })); }); } diff --git a/client/src/pages/MobileApprovalsPage.tsx b/client/src/pages/MobileApprovalsPage.tsx index fc4a2ee2..b5139009 100644 --- a/client/src/pages/MobileApprovalsPage.tsx +++ b/client/src/pages/MobileApprovalsPage.tsx @@ -5,7 +5,15 @@ import { Link, useLocation, useNavigate, useParams, useSearchParams } from "reac import { toast } from "sonner"; import { fetchLeaveRequests, type LeaveRequestItem } from "../api/leave"; import { fetchPurchaseRequests, type PurchaseRequestSummary } from "../api/purchaseRequests"; -import { fetchReimbursements, type ReimbursementListItem } from "../api/reimbursements"; +import { + downloadReimbursementAttachment, + fetchReimbursementAttachmentPreviewUrl, + fetchReimbursementDetail, + fetchReimbursementMaterialPreviewBundle, + fetchReimbursements, + type ReimbursementListItem, +} from "../api/reimbursements"; +import { fetchInvoiceSignedUrl } from "../api/invoicePool"; import { isApiError } from "../api/types"; import { approveWorkflowTask, @@ -21,6 +29,8 @@ 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"; @@ -36,6 +46,13 @@ 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 { @@ -370,6 +387,7 @@ export function MobileApprovalsPage() { const [transferReason, setTransferReason] = useState(""); const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false); const [confirmAction, setConfirmAction] = useState(null); + const [showSecondaryActions, setShowSecondaryActions] = useState(false); const [isOnline, setIsOnline] = useState(true); const autoSyncRequestedRef = useRef(false); @@ -543,6 +561,51 @@ export function MobileApprovalsPage() { ); 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 selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null; const selectedDisplayReference = selectedTaskCard?.reference ?? publicReference(selectedReference?.reference); const selectedDetailReference = selectedDisplayReference ?? @@ -571,6 +634,10 @@ export function MobileApprovalsPage() { 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( () => @@ -599,6 +666,10 @@ export function MobileApprovalsPage() { }); function requestMobileWorkflowAction(action: WorkflowConfirmAction) { + if (isSelectedReimbursement && !reimbursementDecisionReady) { + setError("报销审批需联网加载完整明细、票据和提交时检查后才能处理。"); + return; + } if (!canUseRemote && action === "return") { setError("当前离线不支持退回,请联网后再处理。"); return; @@ -618,6 +689,31 @@ 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 []; @@ -641,6 +737,9 @@ export function MobileApprovalsPage() { if (!selectedTask) { throw new Error("当前审批任务尚未缓存,请联网加载后再操作"); } + if (selectedTask.workflowInstance?.entityType === "reimbursement") { + throw new Error("报销审批不能保存离线草稿,请联网核对完整内容后处理"); + } if (!selectedTask.allowedActions.includes(action)) { throw new Error("当前审批任务不允许该动作"); } @@ -683,6 +782,9 @@ 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("当前审批任务不允许该动作"); } @@ -842,7 +944,9 @@ export function MobileApprovalsPage() { : "最近 30 天已处理审批用于复核,不支持再次审批。"; const selectedActionText = selectedTask ? selectedTaskAllowsActions - ? canUseRemote + ? isSelectedReimbursement && !reimbursementDecisionReady + ? "报销审批需联网加载完整明细、票据和提交时检查;内容就绪前不能通过、退回、驳回或保存草稿。" + : canUseRemote ? "核对摘要和历史后,可在底部通过、驳回或退回。" : "当前离线,仅支持保存通过或驳回草稿,联网后再同步。" : "当前任务已处理,本页只展示业务摘要和处理历史。" @@ -877,7 +981,10 @@ export function MobileApprovalsPage() { {!isOnline && canAccessRemoteApprovals ? (

- 当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存审批任务,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。 + 当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"} + {isSelectedReimbursement + ? "当前报销任务只显示摘要,不能保存审批草稿;请联网核对完整内容。" + : "仅支持查看已缓存审批任务,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。"}

) : null} @@ -1168,6 +1275,61 @@ export function MobileApprovalsPage() { 查看完整报销单 + {!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 ? ( @@ -1231,6 +1393,7 @@ export function MobileApprovalsPage() {