From 50c14c1b85119e19135600fd537b7b5f222495c5 Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 4 Aug 2026 23:28:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(reimbursement-small-team-form-validation):?= =?UTF-8?q?=20=E5=AE=8C=E5=96=84=E5=B0=8F=E5=9B=A2=E9=98=9F=E6=8A=A5?= =?UTF-8?q?=E9=94=80=E5=A1=AB=E6=8A=A5=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spec/hyb-oa/reimbursement-invoice-ocr.md | 72 +++++++++ .../check.jsonl | 1 + .../design.md | 112 ++++++++++++++ .../implement.jsonl | 1 + .../implement.md | 62 ++++++++ .../prd.md | 96 ++++++++++++ .../task.json | 26 ++++ client/src/api/errorMessages.ts | 12 ++ client/src/api/feeControl.ts | 18 +++ client/src/api/reimbursements.ts | 27 +++- .../ReimbursementHeaderEditor.tsx | 102 ++++++++++++ .../ReimbursementOperationalHints.tsx | 72 +++++++++ .../pages/MobileReimbursementDetailPage.tsx | 31 +++- client/src/pages/ReimbursementDetailPage.tsx | 32 +++- .../ReimbursementItemEditor.tsx | 4 +- .../e2e/r102-reimbursement-main-flow.spec.ts | 88 ++++++++++- deploy/trialrun/.env.example | 1 + .../trialrun/env-recovery-classification.json | 1 + docs/报销发票附件规则.md | 9 ++ server/.env.example | 1 + server/README.md | 1 + server/src/config.js | 9 ++ server/src/routes/reimbursements.js | 38 ++++- server/src/services/feeControl.js | 146 +++++++++++++++++- .../unit/app.auth-security-config.test.js | 21 +++ .../reimbursements.expense-standards.test.js | 52 +++++++ .../env-recovery-classification.test.js | 2 +- .../reimbursementOperationalChecks.test.js | 88 +++++++++++ 28 files changed, 1095 insertions(+), 30 deletions(-) create mode 100644 .trellis/tasks/08-04-reimbursement-small-team-form-validation/check.jsonl create mode 100644 .trellis/tasks/08-04-reimbursement-small-team-form-validation/design.md create mode 100644 .trellis/tasks/08-04-reimbursement-small-team-form-validation/implement.jsonl create mode 100644 .trellis/tasks/08-04-reimbursement-small-team-form-validation/implement.md create mode 100644 .trellis/tasks/08-04-reimbursement-small-team-form-validation/prd.md create mode 100644 .trellis/tasks/08-04-reimbursement-small-team-form-validation/task.json create mode 100644 client/src/components/reimbursements/ReimbursementHeaderEditor.tsx create mode 100644 client/src/components/reimbursements/ReimbursementOperationalHints.tsx create mode 100644 server/tests/unit/services/reimbursementOperationalChecks.test.js diff --git a/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md b/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md index 8ebea450..a38f54d3 100644 --- a/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md +++ b/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md @@ -780,6 +780,78 @@ extra = { }; ``` +## Scenario: Small-Team Reimbursement Form And Submit Validation + +### 1. Scope / Trigger + +- Trigger: changing ordinary reimbursement item editing, total display, submit validation, expense-date warnings, evidence summary or asset-category hints. +- This is an internal 5–10 person workflow. Do not turn these rules into a configurable policy center or automatic accounting/asset platform. + +### 2. Signatures + +- Runtime env: `REIMBURSEMENT_MAX_AGE_DAYS=180|365`, default `365`. +- Header edit: `PATCH /api/reimbursements/:id` accepts `reimbursementDate` and nullable `description` for participant-owned `draft|rejected` forms. +- Item edit: `POST /api/reimbursements/:id/items` and `PATCH /api/reimbursements/:id/items/:itemId` require decimal `amount > 0`. +- Submit: `POST /api/reimbursements/:id/submit` locks the reimbursement, reads every item, validates amounts and persists the recomputed `total_amount` before budget and workflow resolution. +- Reimbursement precheck adds `evidenceSummary`, `timelinessChecks` and `assetHint`; these operational fields remain active when the budget-precheck feature flag is off. + +### 3. Contracts + +- PC, mobile and print display the server `totalAmount`; clients never calculate or submit an editable form total. +- A template or travel conversion may keep zero-value placeholders in a draft, but submission requires at least one item and every item amount strictly greater than zero. +- Date age uses `APP_TIMEZONE` business date plus date-only UTC arithmetic. Exactly 180/365 days is within policy; the first later day is overdue. +- Overdue and cross-calendar-year are independent non-blocking warnings. One item may carry both flags and both user messages. +- Evidence summary counts bound invoices, whole-form supporting attachments and bound invoices per item. An unlinked item warns but does not add a new block because allowances and whole-form evidence remain valid cases. +- `expense_type.category=assets` returns only candidate count and manual-registration guidance. It never creates an asset card; the `/assets` link remains permission-gated. +- Do not add a “历史费用补报” field, badge or process. Age and calendar-year facts provide the traceable signal. + +### 4. Validation & Error Matrix + +- Unsupported `REIMBURSEMENT_MAX_AGE_DAYS` -> application startup fails with `Invalid REIMBURSEMENT_MAX_AGE_DAYS`. +- Manual item amount `<= 0` -> `400 VALID_001 / amount=must_be_positive`. +- Submit with zero items -> `400 VALID_001 / items=at_least_one_required`. +- Submit with any item amount `<= 0` -> `400 VALID_001 / items=positive_amount_required` plus affected item IDs. +- Expense date older than policy or in another calendar year -> precheck `decision=warn`; submission remains possible after confirmation. +- Asset candidate without asset permission -> show manual next step only, never the asset-module link or asset data. + +### 5. Good/Base/Bad Cases + +- Good: three positive items display one server total; submit repairs a stale stored total before selecting the approval rule. +- Good: a 366-day-old asset item under the 365-day policy shows overdue, cross-year, evidence and manual-asset warnings without creating an asset. +- Base: a current office-supply item with one linked invoice passes existing gates and displays its linked count. +- Bad: sum item amounts in React, trust a stale `ReimbursementForm.total_amount` during submit, block all old invoices, or auto-create asset cards. + +### 6. Tests Required + +- Config tests cover default 365, allowed 180/365 and unsupported values. +- Service tests freeze the business time and assert threshold-day, first-overdue-day, cross-year, per-item invoice count, attachment count and asset candidate behavior. +- Route tests assert empty/non-positive submit errors and that the recomputed amount reaches approval-rule selection. +- Frontend typecheck/build plus PC/mobile Playwright assert header editing, item count, server total, evidence wording and absence of internal terms. +- Run Prisma validation, reimbursement focused regression, process/docs/Trellis gates and `git diff --check`. + +### 7. Wrong vs Correct + +#### Wrong + +```js +const total = items.reduce((sum, item) => sum + Number(item.amount), 0); +await selectApprovalRule(reimbursement.total_amount); // may be stale +``` + +#### Correct + +```js +const items = await tx.reimbursementItem.findMany({ + where: { reimbursement_id: reimbursementId }, + select: { id: true, amount: true }, +}); +if (!items.length || items.some((item) => Number(item.amount) <= 0)) { + throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400 }); +} +reimbursement.total_amount = await recomputeTotalAmount(tx, reimbursementId, actorId); +await selectApprovalRule(reimbursement.total_amount); +``` + ## Scenario: Payment Batch Receipt Attention Count ### 1. Scope / Trigger diff --git a/.trellis/tasks/08-04-reimbursement-small-team-form-validation/check.jsonl b/.trellis/tasks/08-04-reimbursement-small-team-form-validation/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-04-reimbursement-small-team-form-validation/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/08-04-reimbursement-small-team-form-validation/design.md b/.trellis/tasks/08-04-reimbursement-small-team-form-validation/design.md new file mode 100644 index 00000000..749c320c --- /dev/null +++ b/.trellis/tasks/08-04-reimbursement-small-team-form-validation/design.md @@ -0,0 +1,112 @@ +# 小团队报销填报与校验完善:技术设计 + +## 1. Design boundary + +本任务在现有报销详情和预检链路内增量实现,不新增数据库表,不建设通用规则引擎,不改变审批、付款和资产状态机。 + +核心数据流: + +```text +明细/发票/附件数据库事实 + -> reimbursement precheck 聚合 + -> GET detail / POST precheck API + -> PC 与手机详情呈现 + -> submit 事务重新汇总 + -> 现有审批规则、预算与工作流 +``` + +服务端负责金额、日期和证据状态的权威判断;前端只负责输入约束、可理解呈现和刷新。 + +## 2. Contracts + +### 2.1 Runtime configuration + +- 在 `server/src/config.js` 增加 `reimbursementMaxAgeDays`。 +- 环境变量:`REIMBURSEMENT_MAX_AGE_DAYS`。 +- 默认:`365`;允许:`180 | 365`;非法值在启动时失败,不静默回退。 +- 配置只影响警告阈值,不改变数据、审批或付款状态。 + +### 2.2 Reimbursement precheck extension + +在现有预检响应中追加向后兼容字段: + +```ts +type ReimbursementEvidenceSummary = { + invoiceCount: number; + attachmentCount: number; + items: Array<{ + itemId: string; + linkedInvoiceCount: number; + invoiceState: "linked" | "unlinked"; + }>; +}; + +type ReimbursementTimelinessCheck = { + itemId: string; + expenseDate: string; + ageDays: number; + maxAgeDays: 180 | 365; + overdue: boolean; + crossCalendarYear: boolean; +}; + +type ReimbursementAssetHint = { + required: boolean; + itemCount: number; +}; +``` + +- 新字段缺失时旧客户端继续使用既有预检字段。 +- 超期、跨年度、明细未关联发票和资产候选通过现有 `warnings`/`nextActions` 合并,并保留结构化摘要供 UI 定位。 +- 明细未关联发票是警告而非新阻断,因为当前存在整单发票、OCR 未完成和合法无票差旅补助等兼容场景。 + +### 2.3 Amount validation + +- 手工 item POST/PATCH 将 `amount=0` 视为无效。 +- 模板/差旅创建仍可内部写入零金额草稿,不经过手工 item POST 门禁。 +- submit 在同一事务和行锁内读取所有明细:无明细或任一金额 `<= 0` 时返回定位明确的校验错误。 +- 校验通过后调用现有重算逻辑,将返回值写回本次提交上下文;后续预算和审批规则只使用该值。 +- 不依赖前端合计,不接受客户端提交整单总额。 + +### 2.4 Header editing + +- 扩展前端 API 为通用 `updateReimbursement`,旅行摘要函数可继续包装或迁移到该函数,避免重复 HTTP 合同。 +- PC 与手机复用小型编辑块:报销日期、报销事由、保存、取消。 +- 保存成功后刷新 detail、list 和 precheck 查询;使用现有未保存编辑保护模式,避免刷新丢失。 + +### 2.5 Totals and evidence UI + +- PC 表格 footer 显示条数与 `detail.totalAmount`;手机列表尾显示相同事实。 +- 概况标签统一为“报销总额”。打印页保留现有合计行。 +- 证据提示在明细与材料区使用同一预检摘要,不在前端重新推导业务规则。 +- 当 OCR off 或发票金额为空时,不显示误导性的“发票金额差额”;本任务只展示数量和关联状态。 + +### 2.6 Asset reminder + +- 服务端基于明细费用类型 `category=assets` 生成结构化提示。 +- UI 只显示候选数量、人工核对说明和授权后的资产模块入口。 +- 不调用资产创建 API,不保存报销侧资产状态。 + +## 3. Compatibility and migration + +- 无 Prisma schema 或数据库迁移。 +- API 仅新增可选响应字段并收紧非法普通报销提交,现有合法报销兼容。 +- 历史草稿中的零金额明细可以继续查看和修改,但必须修正后才能提交。 +- 已提交、已通过和已付款记录保持只读,不回填时效或资产状态。 +- 现有附件仍为整单材料,现有发票绑定、OCR、查重和连续预览不变。 + +## 4. Risks and mitigations + +- **日期偏移**:使用既有 date-only 工具和 `APP_TIMEZONE`,测试阈值当天、闰年和跨年边界。 +- **预检字段重复计算**:在服务端一次加载报销证据上下文,避免 PC/手机各自实现规则。 +- **模板回归**:只在手工明细接口和提交阶段收紧正金额,保留模板草稿零金额。 +- **既有未绑定发票**:只警告不阻断,保持现有整单至少一张发票门禁。 +- **资产权限泄露**:资产入口按现有权限决定,预检不返回资产卡或敏感字段。 +- **提交总额竞争**:继续使用报销行锁,并在同一事务内重算后再做预算和审批规则选择。 + +## 5. Rollout and rollback + +- 部署前明确 `REIMBURSEMENT_MAX_AGE_DAYS`;缺省为 365。 +- 先部署服务端兼容字段和校验,再部署前端展示;旧前端可继续工作。 +- 回滚前端不会破坏服务端数据;回滚服务端仅移除新提示和正金额收紧,无迁移回滚。 +- 公网验收覆盖普通办公用品、宽带/技术服务、IT 硬件三类合成记录,不使用真实票据内容。 diff --git a/.trellis/tasks/08-04-reimbursement-small-team-form-validation/implement.jsonl b/.trellis/tasks/08-04-reimbursement-small-team-form-validation/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-04-reimbursement-small-team-form-validation/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/08-04-reimbursement-small-team-form-validation/implement.md b/.trellis/tasks/08-04-reimbursement-small-team-form-validation/implement.md new file mode 100644 index 00000000..6807ff33 --- /dev/null +++ b/.trellis/tasks/08-04-reimbursement-small-team-form-validation/implement.md @@ -0,0 +1,62 @@ +# 小团队报销填报与校验完善:实施计划 + +## 1. Backend contracts and focused tests + +- [ ] 在配置层增加并测试 `REIMBURSEMENT_MAX_AGE_DAYS=180|365`,默认 365,非法配置启动失败。 +- [ ] 在报销预检服务增加证据摘要、时效检查和资产提示,复用 date-only/金额工具。 +- [ ] 为默认期限、180 天、阈值边界、跨年度、未关联发票、整单附件和 `assets` 类别补单测。 + +## 2. Authoritative amount validation + +- [ ] 收紧手工 item POST/PATCH:金额必须大于零,返回可定位错误。 +- [ ] submit 行锁事务读取明细,拒绝空明细和非正金额明细。 +- [ ] submit 在预算与审批规则前重新汇总并使用权威总额。 +- [ ] 补充空单、零金额模板草稿、修改后提交、陈旧总额修复和审批金额选择回归测试。 + +## 3. Shared frontend API and header editing + +- [ ] 扩展 reimbursement API 类型与通用 PATCH 函数。 +- [ ] PC 草稿/驳回详情增加报销事由和日期编辑。 +- [ ] 手机草稿/驳回详情增加适合小屏的同等编辑能力。 +- [ ] 保存后刷新 detail/list/precheck,并验证非编辑状态和无权限用户只读。 + +## 4. Totals, evidence and asset hints + +- [ ] PC 明细表尾增加条数与报销总额,概况标签改为“报销总额”。 +- [ ] 手机明细列表尾增加条数与报销总额。 +- [ ] PC/手机展示明细发票关联状态、整单附件数、时效警告和资产人工建卡提醒。 +- [ ] 资产入口沿用现有权限;打印页合计不回归。 + +## 5. Validation and review gates + +- [ ] `cd server && npm run prisma:validate` +- [ ] `cd server && node --check src/config.js && node --check src/services/feeControl.js && node --check src/routes/reimbursements.js` +- [ ] 运行本任务新增的后端 Vitest 及现有 reimbursement detail/create/expense-standard 聚焦回归。 +- [ ] `cd client && npm run typecheck && npm run build` +- [ ] 运行 PC 与 mobile/wecom 报销详情 E2E,覆盖合计、编辑、时效、证据和资产提醒。 +- [ ] 运行 `node scripts/qa/check-process-contract.mjs --baseline --json`。 +- [ ] 运行 `node scripts/qa/audit-docs.mjs --check-registry` 和 `node scripts/qa/check-trellis-governance.mjs`。 +- [ ] `git diff --check` +- [ ] 因提交校验和跨层预检发生变化,实施后进行一次只读 review,重点检查金额竞争、模板兼容和权限边界。 + +## 6. Documentation, deployment and rollback + +- [ ] 仅在形成稳定规则时更新现有报销事实源或 Trellis spec,不新建平行文档。 +- [ ] 记录环境配置默认值、生产取值和回滚方式,不记录 Secret 或真实票据。 +- [ ] 用户批准后启动任务;实现、检查、必要 spec 更新完成后使用中文 commit,并引用任务名。 +- [ ] 若需用户线上测试,完成部署、公网健康检查和合成数据关键路径冒烟后再通知。 + +## Rollback points + +- 配置/预检字段为第一回滚点:可独立移除,不影响数据库。 +- 正金额提交门禁为第二回滚点:若发现合法流程被误阻断,先回滚该提交校验,不绕过发票/权限门禁。 +- 前端编辑与提示为第三回滚点:可回滚 UI,服务端新增字段保持向后兼容。 + +## 执行记录(2026-08-04) + +- 服务端聚焦回归 9 个文件、86 个用例全部通过;Prisma 与所改 Node 文件语法检查通过。 +- 前端 `typecheck`、生产构建通过;R102 合成 API 浏览器回归覆盖 PC/手机合计、事由日期编辑和材料提示,原 5 个用例通过,新增/修正用例单独复跑通过。 +- 任务、环境契约、流程合同、文档注册、Trellis 治理、敏感内容和 `git diff --check` 均通过;文档注册保留既有 22 个未注册文件 warning,无新增错误。 +- 全量后端 1228 个用例中 1224 个通过;3 个既有付款 BIFF8 导出用例因本机未安装 `server/requirements/payment-order-biff8.txt` 固定 Python 依赖失败,另 1 个本任务引起的环境键计数已修正并复跑通过。 +- 已完成主会话只读复核,修正“已完成报销不显示资产提醒”和“超期且跨年只显示一个提示”两处跨层遗漏。 +- 未执行生产部署、生产配置写入或公网数据操作;如需线上测试,须按项目规则另行取得明确部署授权。 diff --git a/.trellis/tasks/08-04-reimbursement-small-team-form-validation/prd.md b/.trellis/tasks/08-04-reimbursement-small-team-form-validation/prd.md new file mode 100644 index 00000000..02ff53a6 --- /dev/null +++ b/.trellis/tasks/08-04-reimbursement-small-team-form-validation/prd.md @@ -0,0 +1,96 @@ +# 小团队报销填报与校验完善 + +## Goal + +面向公司内部 5–10 人使用场景,在不扩展为通用费控产品的前提下,完善普通报销填报、提交预检和证据提示,使员工容易填写、审批人看得懂、财务能够核对、付款后能够追溯。 + +## Background and confirmed facts + +- 系统已有报销单、明细、PDF 发票、补充附件、审批、付款批次和资产台账主链路。 +- `ReimbursementForm.total_amount` 已由明细新增、修改、删除操作自动重算;打印页已有明细合计,但 PC 和手机详情的明细区域没有就地合计。 +- 普通报销 PATCH 已支持修改 `reimbursementDate` 和 `description`,前端目前没有通用的事由/日期编辑入口。 +- 当前提交只校验总金额非负和整单至少一张发票,不能在服务端可靠阻止空明细、零金额明细或陈旧总额进入审批。 +- 发票已有可选明细关联;补充附件当前属于整单。本任务不引入发票多对多关系或附件明细关联。 +- 费用类型已有 `assets` 类别;资产事实源要求“资产候选 + 管理员人工建卡”,不得因报销提交、审批或付款自动生成资产卡。 +- 本轮讨论已决定不增加“历史费用补报”标识。票据新旧由费用日期、发票日期和提交日期推导。 + +## Scope + +- 报销详情 PC、手机的明细合计和普通报销事由/日期编辑。 +- 报销明细正金额校验、提交时权威总额重算。 +- 180/365 天票据时效配置及超期、跨年度非阻断提示。 +- 明细发票关联、整单附件数量的材料完整性提示。 +- `assets` 类别的人工资产建卡提醒和授权入口。 +- 所需的前后端类型、聚焦测试、现有事实源/spec 更新及部署配置说明。 + +## Requirements + +### R1 明细合计就地展示 + +- PC 报销明细表尾显示明细数量和报销合计。 +- 手机报销明细卡片列表底部显示明细数量和报销合计。 +- 概况区将含糊的“金额”统一为“报销总额”或“合计金额”。 +- 展示使用服务端返回的 `totalAmount`,不增加可编辑总额字段。 + +### R2 普通报销正金额与提交一致性 + +- 员工手工新增或修改普通报销明细时,金额必须大于零。 +- 提交时服务端必须确认至少一条明细,且不存在零金额或负金额明细。 +- 提交事务内重新按明细汇总并持久化总金额,审批规则、预算检查和工作流快照使用该权威金额。 +- 差旅申请或模板创建阶段产生的零金额占位明细仍可保存为草稿,但补齐正金额前不得提交。 +- 错误返回可定位到明细或总金额,前端保留已填内容并给出中文提示。 + +### R3 普通报销事由和报销日期编辑 + +- 草稿和驳回状态的参与人可在 PC、手机详情编辑“报销事由”和“报销日期”。 +- 复用现有报销 PATCH 和权限/状态约束,不新增独立保存接口。 +- 事由不是“历史费用”分类;员工按真实用途填写,例如设备采购、办公网络或办公用品采购。 + +### R4 票据时效提示 + +- 服务端配置 `REIMBURSEMENT_MAX_AGE_DAYS`,默认 `365`,只允许 `180` 或 `365`,部署时可调整。 +- 以每条明细的费用发生日期为主计算距当前业务日期的天数;发票日期只作为已有材料摘要,不替代费用日期。 +- 超过配置期限或费用日期与提交日期跨自然年度时,在预检中产生中文警告,但本任务不自动阻断提交。 +- 页面说明实际天数、当前期限和需要财务确认的下一步,不出现“历史费用补报”标签。 +- 日期计算使用 `APP_TIMEZONE` 和 date-only 语义,不因 UTC 转换偏移一天。 + +### R5 明细材料完整性提示 + +- 预检返回整单发票数、整单补充附件数,以及每条明细已关联发票数。 +- PC、手机详情明确区分“已关联发票”“待关联发票”和“整单补充材料”。 +- 普通员工文案不显示 `invoice pool`、OCR 内部码、UUID 或数据库字段。 +- 本任务保持现有“整单至少一张发票”提交门禁;缺少某条明细关联发票先作为可确认警告,不新建费用材料政策中心。 +- 本任务不改变 PDF 发票、补充附件、付款回单和资产材料的既有上传边界。 + +### R6 设备资产登记提醒 + +- 报销包含 `assets` 类别明细时,PC、手机详情和提交预检显示“可能形成资产,报销完成后由管理员核对并手动建卡”的非阻断提醒。 +- 不自动创建资产卡、不自动判断会计固定资产、不复制资产建卡表单到报销页。 +- 有资产台账查看权限的用户可从提醒进入资产模块;其他用户只看到下一步说明。 + +## Acceptance Criteria + +- [ ] AC1:PC 和手机的报销明细区域均显示与服务端 `totalAmount` 一致的明细数量和合计;新增、修改、删除后自动刷新。 +- [ ] AC2:手工新增/修改 `0` 金额明细被服务端拒绝;空明细或含非正金额明细的报销无法提交;模板零金额草稿仍可创建和编辑。 +- [ ] AC3:提交事务重新汇总权威总额,审批规则和后续提交结果不使用陈旧总额。 +- [ ] AC4:草稿/驳回报销可在 PC 和手机编辑报销事由和日期;其他状态不可编辑,权限边界不放宽。 +- [ ] AC5:默认 365 天配置有效,可切换为 180 天;超期和跨年度产生可理解警告但不阻断,date-only 边界无偏移。 +- [ ] AC6:预检及详情能显示每条明细的发票关联状态和整单附件数;现有整单发票门禁、OCR、查重和附件权限不回归。 +- [ ] AC7:`assets` 类别触发人工资产登记提醒,不产生任何资产卡写入;无权限用户不会获得资产数据或管理入口。 +- [ ] AC8:PC、手机和打印页使用一致的金额语义;普通用户页面不出现内部技术术语。 +- [ ] AC9:相关后端测试、前端 typecheck/build、PC/移动报销 E2E、Trellis 治理和 `git diff --check` 全部通过。 +- [ ] AC10:若改动面向公网用户,部署、公网冒烟和关键页面检查通过后才告知可以测试。 + +## Out of Scope + +- “历史费用补报”字段、标识、筛选或独立流程。 +- 通用费用政策中心、复杂预算、银企直连、自动付款、自动记账或完整电子会计档案系统。 +- 发票与明细多对多、补充附件关联具体明细、发票验真能力扩展。 +- 自动创建资产卡、折旧/残值/会计科目判断。 +- 改造采购流程、付款状态机、审批角色或权限模型。 + +## Test Plan + +- Backend:Prisma validate、`node --check`、报销详情/创建/费用标准相关 Vitest,并补充本任务聚焦用例。 +- Frontend:`npm run typecheck`、`npm run build`、PC 和 mobile/wecom 报销详情 E2E。 +- Process:Trellis task validate、流程合同、治理、文档注册审计和 `git diff --check`。 diff --git a/.trellis/tasks/08-04-reimbursement-small-team-form-validation/task.json b/.trellis/tasks/08-04-reimbursement-small-team-form-validation/task.json new file mode 100644 index 00000000..d97145bc --- /dev/null +++ b/.trellis/tasks/08-04-reimbursement-small-team-form-validation/task.json @@ -0,0 +1,26 @@ +{ + "id": "reimbursement-small-team-form-validation", + "name": "reimbursement-small-team-form-validation", + "title": "小团队报销填报与校验完善", + "description": "完善小团队内部报销的明细合计、正金额校验、事由编辑、票据时效提示、材料完整性提示和资产登记提醒。", + "status": "in_progress", + "dev_type": "feature", + "scope": "server/client/docs/tests", + "package": null, + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-08-04", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index 66da0205..d42d02af 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -119,6 +119,18 @@ export function mapApiErrorMessage( } if (code === "VALID_001") { + if (details?.amount === "must_be_positive") { + return "明细金额必须大于 0。"; + } + + if (details?.items === "at_least_one_required") { + return "请先添加至少 1 条报销明细。"; + } + + if (details?.items === "positive_amount_required") { + return "所有报销明细金额都必须大于 0,请修改后再提交。"; + } + const passwordErrors = readStringList(details?.password); if (passwordErrors.length) { return `新密码不符合要求:${passwordErrors.join(";")}。`; diff --git a/client/src/api/feeControl.ts b/client/src/api/feeControl.ts index 9329e0f3..c409a85e 100644 --- a/client/src/api/feeControl.ts +++ b/client/src/api/feeControl.ts @@ -46,6 +46,24 @@ export type PrecheckResponse = { } | null; budgetHint: Record | null; standardChecks: ExpenseStandardCheck[]; + evidenceSummary?: { + invoiceCount: number; + attachmentCount: number; + items: Array<{ + itemId: string; + linkedInvoiceCount: number; + invoiceState: "linked" | "unlinked"; + }>; + }; + timelinessChecks?: Array<{ + itemId: string; + expenseDate: string; + ageDays: number; + maxAgeDays: 180 | 365; + overdue: boolean; + crossCalendarYear: boolean; + }>; + assetHint?: { required: boolean; itemCount: number }; warnings: Array<{ code: string; title: string; detail: string }>; blockingReasons: Array<{ code: string; title: string; detail: string }>; riskFlags: Array<{ code: string; level: "low" | "medium" | "high"; title: string; detail: string; willOpenOnSubmit: boolean }>; diff --git a/client/src/api/reimbursements.ts b/client/src/api/reimbursements.ts index 32591a56..c47bdaed 100644 --- a/client/src/api/reimbursements.ts +++ b/client/src/api/reimbursements.ts @@ -301,6 +301,11 @@ export type ReimbursementTravelSummaryPayload = { travelEndDate: string | null; }; +export type ReimbursementUpdatePayload = Partial & { + reimbursementDate?: string; + description?: string | null; +}; + export type ReimbursementCreateResponse = { id: string; formNumber: string | null; @@ -486,10 +491,24 @@ export async function updateReimbursementTravelSummary( reimbursementId: string, payload: ReimbursementTravelSummaryPayload, ) { - return apiRequestData<{ travelSummary: Omit }>( - `/api/reimbursements/${reimbursementId}`, - { method: "PATCH", body: JSON.stringify(payload) }, - ); + return updateReimbursement(reimbursementId, payload); +} + +export async function updateReimbursement( + reimbursementId: string, + payload: ReimbursementUpdatePayload, +) { + return apiRequestData<{ + id: string; + status: ReimbursementStatus; + reimbursementDate: string; + description: string | null; + totalAmount: string; + travelSummary: Omit; + }>(`/api/reimbursements/${reimbursementId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); } export async function createReimbursementItem( diff --git a/client/src/components/reimbursements/ReimbursementHeaderEditor.tsx b/client/src/components/reimbursements/ReimbursementHeaderEditor.tsx new file mode 100644 index 00000000..691e7281 --- /dev/null +++ b/client/src/components/reimbursements/ReimbursementHeaderEditor.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { updateReimbursement } from "../../api/reimbursements"; +import { isApiError } from "../../api/types"; +import { mapApiErrorMessage } from "../../api/errorMessages"; +import { useUnsavedChangesGuard } from "../common/UnsavedChangesGuard"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Textarea } from "../ui/textarea"; + +type Props = { + reimbursementId: string; + reimbursementDate: string | null; + description: string | null; + editable: boolean; + mobile?: boolean; +}; + +export function ReimbursementHeaderEditor({ + reimbursementId, + reimbursementDate, + description, + editable, + mobile = false, +}: Props) { + const queryClient = useQueryClient(); + const [editing, setEditing] = useState(false); + const [date, setDate] = useState(reimbursementDate ?? ""); + const [reason, setReason] = useState(description ?? ""); + const [error, setError] = useState(""); + const dirty = editing && (date !== (reimbursementDate ?? "") || reason !== (description ?? "")); + const runGuarded = useUnsavedChangesGuard(dirty, "报销事由和日期"); + + useEffect(() => { + if (editing) return; + setDate(reimbursementDate ?? ""); + setReason(description ?? ""); + }, [description, editing, reimbursementDate]); + + const mutation = useMutation({ + mutationFn: () => updateReimbursement(reimbursementId, { + reimbursementDate: date, + description: reason.trim() || null, + }), + onSuccess: async () => { + setError(""); + setEditing(false); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), + queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }), + queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), + ]); + }, + onError: (mutationError) => { + setError(isApiError(mutationError) + ? mapApiErrorMessage(mutationError.code, mutationError.details, mutationError.message) + : "保存报销信息失败,请重试"); + }, + }); + + if (!editing) { + return ( +
+
+
+ 报销事由: + {description || "未填写"} +
+ {editable ? ( + + ) : null} +
+
+ ); + } + + return ( +
+
+ +