比较提交

...
3 次代码提交
作者 SHA1 备注 提交日期
wangsli 566c37679a chore(reimbursement-payment-batch-deployment): 启动受控部署任务
ci-minimal / check-minimal (push) Canceled after 0s
ci-e2e / e2e (push) Canceled after 0s
2026-08-05 10:37:48 +08:00
wangsli 3659d38bbe chore(reimbursement-small-team-form-validation): 归档任务 2026-08-05 01:03:48 +08:00
wangsli d4400116c5 feat(reimbursement-small-team-form-validation): 完善付款批次核对与回单预览 2026-08-05 01:02:51 +08:00
共修改 26 个文件,包含 1010 行新增和 87 行删除
@@ -514,6 +514,7 @@ const label = status === "draft" ? "草稿" : status;
- A reviewed bank batch with no active receipt distinguishes no order vs order generated; an active receipt changes guidance to registration. Payment-order count affects copy only when `requiresBankReceipt=true`.
- `failed` uses `exception`. A cancellation before review makes the review stage `exception` and payment stage `terminated`; a cancellation after review completes review and makes payment `exception`. `terminated` is a neutral stopped state, not pending, completed or failed.
- `actorSource=creator|reviewer|executor|canceller` is an internal semantic reference. The shared service never queries employees; each response assembler resolves only a nullable actor name and never exposes actor IDs inside progress.
- When an authorized reimbursement has entered a batch, its processing progress exposes at most one batch link. `draft` and cancellation before review attach it to `付款复核`; `reviewed|executed|failed` and cancellation after review attach it to `付款完成`. Unauthorized actors receive neither link.
### 4. Validation & Error Matrix
@@ -537,6 +538,7 @@ const label = status === "draft" ? "草稿" : status;
- Pure table-driven unit tests cover all five statuses, both cancellation positions, bank/non-bank channels, order count, receipt count and actorSource.
- A structural test extracts both route assembler functions and rejects route-local payment status matrices or duplicate `付款复核` / `付款完成` label definitions.
- Route tests assert actor name/time projection, list/detail field separation and absence of snapshot, bank ciphertext and actor IDs inside progress.
- Reimbursement route tests assert the five batch statuses, both cancellation positions and the no-permission case never produce duplicate batch links.
- PC/mobile E2E uses synthetic responses for all five states, verifies `terminated`, bank/non-bank copy, one visible primary action per viewport and upload-to-register in-place switching.
- Reimbursement E2E asserts the four-block order and that submit, print, remind, withdraw and delete retain their state/permission, reason/confirmation, request and refresh contracts after action-panel migration.
@@ -915,3 +915,92 @@ const needsReceipt = batches.filter((batch) =>
batch.items.some((item) => (item.paymentChannelOverride ?? batch.defaultPaymentChannel) === "bank"),
).length;
```
## Scenario: Payment Batch Expense Evidence And Receipt Preview
### 1. Scope / Trigger
- Trigger: changing the reimbursement facts shown in payment-batch detail or
the authenticated online-view path for bank receipts.
- This is a read-only evidence surface. It must not change reimbursement totals,
frozen batch amounts, payment state, maker-checker rules or receipt upload policy.
### 2. Signatures
- `GET /api/payment-batches/:id` adds item-level `expenseItems`,
`expenseItemsTotal` and `expenseItemsAmountMatches` only in the detail DTO.
- Each expense item contains only `id`, `expenseTypeName`, `expenseDate`,
`description` and money-string `amount`.
- `GET /api/payment-batches/:id/receipt-attachments/:attachmentId/file?mode=preview`
returns an authenticated Blob response; omitted mode remains download.
### 3. Contracts
- Load reimbursement items and expense types once for all entities in the
selected batch. List, create, review, execute and cancel responses do not add
expense evidence fields.
- `PaymentBatchItem.amount` remains the frozen payment fact. Expense item totals
are reconciliation evidence; a mismatch produces a warning and never rewrites
either source.
- Payment detail orders each card as reimbursement summary, expense evidence and
subtotal, payment information, then result/receipt. It links back to the source
reimbursement instead of copying invoice or attachment operations.
- Historical `travel_order` batches keep their own title and payment facts. They
do not render reimbursement expense evidence or a fabricated reimbursement link.
- Preview uses the fixed server purpose `付款批次内在线预览`, audits before bytes,
and permits only revalidated PDF/JPEG/PNG/WebP. Download still requires a user
purpose when governance policy is `reason_required`.
- The client fetches the preview through the authenticated API, displays the Blob
in the existing dialog and revokes every object URL on close, replacement or
unmount.
### 4. Validation & Error Matrix
- Batch/list reader lacks permission or department scope -> existing `403`/scope
rejection; no expense or receipt bytes.
- Reimbursement has no readable items -> detail returns an empty array and `0.00`;
UI shows the missing-evidence warning but not a second mismatch warning.
- Expense subtotal differs from frozen item amount ->
`expenseItemsAmountMatches=false`; UI directs the reviewer to the source form.
- Receipt is voided, quarantined, missing, oversized, corrupt or has unsupported
preview MIME -> no bytes; existing blocked audit/error contract applies.
- Preview audit write fails -> non-success response with no file body.
### 5. Good/Base/Bad Cases
- Good: one batch detail query loads three reimbursements' items in one batched
read and displays each subtotal beside its masked payment facts.
- Base: normal totals match and no warning is shown; an authorized PDF receipt
opens inline while its later download asks for a purpose.
- Base: a historical travel-order batch remains readable without an empty
reimbursement-evidence warning or a link to `/reimbursements/:id`.
- Bad: label masked payee facts as “报销明细”, attach expense arrays to the batch
list, trust stored MIME without byte validation, or expose a storage URL.
### 6. Tests Required
- Route tests assert detail DTO fields, batched loading, money totals, mismatch,
empty evidence and privacy-negative fields; list and write responses must omit
expense evidence.
- Receipt route tests assert PDF/image inline MIME, fixed-purpose preview audit,
download-purpose rejection, permission/scope, void/quarantine/size/content and
audit-before-send failures.
- Frontend typecheck/build and focused Playwright assert responsive expense cards,
subtotal/source link, empty/mismatch copy, preview without a purpose dialog,
download with a purpose dialog and object-URL cleanup behavior.
### 7. Wrong vs Correct
#### Wrong
```js
return mapBatch(batch, { expenseItems: await loadItemsForEveryListRow() });
```
#### Correct
```js
return mapBatchForResponse(prisma, batch, riskFlags, config, {
includeExpenseItems: true, // detail route only
});
```
+21 -3
查看文件
@@ -276,9 +276,11 @@ return decryptStringOrThrow(config, encrypted);
- Browser downloads of OA-stored attachments must use a currently
authenticated request, preferably an API-local `POST` with optional JSON
`{ "reason": "..." }`, rather than a query bearer URL. The only approved
exception is the at-most-120-second, purpose/object/actor-bound grant used by
the official WeCom `previewFile` native layer; follow `wecom-boundary.md` and
do not reuse that grant for ordinary browser downloads or other integrations.
query-grant exception is the at-most-120-second, purpose/object/actor-bound
grant used by the official WeCom `previewFile` native layer; follow
`wecom-boundary.md` and do not reuse that grant for ordinary browser downloads
or other integrations. Payment-batch receipt preview is a separate,
authenticated domain route described below; it never uses a bearer query URL.
### 3. Contracts
@@ -313,6 +315,15 @@ return decryptStringOrThrow(config, encrypted);
query-token download routes. The WeCom-native reimbursement invoice/attachment
exception retains the domain-specific `inline` response and must still audit
before bytes, use no-store/no-referrer and reject expired or mismatched grants.
- The only ordinary-browser inline exception is
`GET /api/payment-batches/:id/receipt-attachments/:attachmentId/file?mode=preview`.
It remains batch- and department-authorized, revalidates bounded actual bytes,
accepts only PDF/JPEG/PNG/WebP, writes `PAYMENT_BATCH_RECEIPT_PREVIEWED` with
fixed reason `付款批次内在线预览` before sending bytes, and returns the validated
MIME with `inline`, `nosniff`, private no-store, no-referrer, sandbox CSP and
same-origin resource policy. Default/download mode keeps forced binary download
and reason-required audit behavior. Do not generalize this exception into a
reusable file-center preview route.
### 4. Validation & Error Matrix
@@ -330,6 +341,8 @@ return decryptStringOrThrow(config, encrypted);
- Explicitly quarantined, missing, oversized, corrupt or unverifiable stored
object -> do not return file bytes; write a fixed blocked-reason audit where
possible.
- Payment receipt preview with a MIME outside PDF/JPEG/PNG/WebP -> blocked audit
with fixed reason code; no inline bytes.
- Successful-download audit write fails -> return 5xx before download headers
or file bytes.
@@ -337,6 +350,8 @@ return decryptStringOrThrow(config, encrypted);
- Good: a bank receipt upload returns `confidential`, `receipt`,
`reason_required`; download first asks for用途 and writes an audit log.
- Good: an authorized payment-detail preview uses the fixed server purpose and
writes its preview audit; downloading the same receipt still asks for用途.
- Base: a reimbursement supporting material upload defaults to `internal`,
`supporting_material`, `standard` and remains downloadable by authorized
participants.
@@ -364,6 +379,9 @@ return decryptStringOrThrow(config, encrypted);
before storage/DB writes, current authentication and object authorization,
historical revalidation, blocked-audit reasons, audit-before-send and the
complete forced-download header contract.
- Payment receipt tests additionally assert preview MIME/disposition/security
headers, fixed-purpose audit-before-send, PDF/image allowlist, blocked files,
and that default download still rejects a missing reason.
- Frontend typecheck/build after API type changes.
- Focused UI verification for pages that add attachment governance labels or
download-reason interactions.
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
@@ -0,0 +1,47 @@
# 报销付款批次改进部署:技术设计
## 1. Release boundary
本任务只发布已提交代码,不新增产品行为。目标来源为完成任务后的 `main` HEAD;应用代码核心提交为 `d440011`,部署任务启动记录会在发布前一并提交并推送。部署从 Git archive 的已验证提交构建,不从脏工作区同步。
## 2. Controlled deployment path
```text
clean local main
-> commit/start deployment task
-> push origin/main
-> resolve existing controlled SSH identities without echo
-> deploy-existing aggregate preflight
-> deploy.sh with SYNC_ENV=0 + existing data contract
-> runtime/build/smoke checks
-> forced-command backup contract refresh
-> public read-only verification
```
- 部署身份和 refresh 身份必须来自本机既有 SSH 配置,分别通过普通 `true` 与固定 `probe` 行为识别;不把候选值写入任务、日志或聊天。
- `deploy-existing.sh` 是唯一发布入口。它读取 root-owned systemd unit 中的六项 allowlist 合同,内部调用 `deploy.sh` 并固定 `DEPLOY_DATA_MODE=existing`、`SYNC_ENV=0`。
- 若无法唯一解析两个身份,停止并向用户请求本机配置指引;不猜测远端地址。
## 3. Data and compatibility
- 无 schema 或 migration 变化;部署脚本仍执行标准迁移状态检查,但预期零新增迁移。
- 生产 `.env`、PGDATA、附件存储和备份合同原样保留。
- 旧客户端/服务端兼容由原功能任务验证;本任务不拆分前后端发布。
## 4. Verification contract
- 部署脚本必须确认远端运行 build、公开 `/ready` 和前端短 SHA。
- 公网额外执行无凭据基础 smoke 和资源文案扫描;专用测试凭据存在时才执行登录态 smoke。
- 本轮不在生产创建合成报销或回单。关键业务交互继续引用已通过的本地 19 项合成 E2E,公网只证明目标 build 和前端合同已发布。
## 5. Failure and rollback
- 推送、来源、预检失败:尚未发布,修复后重试。
- 构建或应用 readiness 失败:按部署脚本失败阶段处理,必要时使用同一入口发布上一已知提交。
- 应用已成功、refresh 失败:不重跑应用发布,只修复并重跑 forced-command refresh。
- 回滚不更改数据库结构、生产 `.env`、PGDATA 或附件存储。
## 6. Security boundary
- 任务记录只保存提交、PASS/FAIL/SKIP 摘要,不保存 SSH target、用户名、远端绝对路径、真实账号、回单、银行信息或原始响应。
- 所有公网检查只读;不调用回单在线预览,以免对真实材料产生访问审计或暴露敏感文件。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
@@ -0,0 +1,40 @@
# 报销付款批次改进部署:实施计划
## 1. Activation and publication
- [ ] 用户批准本计划后启动 Trellis task。
- [ ] 提交任务启动记录,确认工作区干净,将 `main` 推送到 `origin/main`。
- [ ] 记录明确发布提交并确认无 schema、migration、配置键或部署脚本变化。
## 2. Preflight
- [ ] 运行环境合同、Trellis 治理、敏感信息和 Git 来源检查。
- [ ] 从本机 SSH 配置无回显解析唯一部署身份与 refresh forced-command 身份。
- [ ] 运行 `check-existing-deployment.sh`,要求 13/13 聚合合同全部 PASS。
## 3. Deployment
- [ ] 使用 `DEPLOY_SERVER`、`DEPLOY_REFRESH_SERVER` 调用 `deploy-existing.sh`。
- [ ] 确认 `SYNC_ENV=0`、existing data contract、无生产 `.env` 覆盖、无新增 migration。
- [ ] 确认应用发布和备份合同 refresh 均通过;若部分成功,按设计中的失败分类停止。
## 4. Public read-only verification
- [ ] 验证公网 `/ready`、运行时 build SHA 和前端短 SHA命中发布提交。
- [ ] 运行基础公网 smoke、外部依赖/功能开关检查和公开资源扫描。
- [ ] 仅在专用 `LIVE_SMOKE_*` 凭据已配置时运行登录态 smoke;否则记录明确 SKIP。
- [ ] 扫描公开前端资源确认本轮关键文案/入口合同已发布,不读取真实回单或银行资料。
## 5. Closeout
- [ ] 将脱敏部署结果写回本任务,不新建平行报告。
- [ ] 运行 task validate、Trellis 治理、敏感信息和 `git diff --check`。
- [ ] 使用中文提交结果记录并引用 `reimbursement-payment-batch-deployment`。
- [ ] 归档任务并仅在全部公网门禁通过后告知用户可以测试。
## Rollback points
- 发布前:任何来源或预检失败均停止,不改变远端。
- 发布中:应用未健康时使用既有入口回退上一已发布提交。
- 发布后:仅备份合同 refresh 失败时不重复部署应用,单独恢复合同。
- 全程不修改生产 `.env`、数据库结构、PGDATA、附件存储或真实业务数据。
@@ -0,0 +1,70 @@
# 报销付款批次改进部署
## Goal
将 `reimbursement-small-team-form-validation` 已完成并本地验证的报销详情、付款批次费用明细和银行回单在线预览改进,安全发布到既有公网 OA,并用只读、脱敏证据确认活动版本和关键页面合同生效。
## Background and confirmed facts
- 功能提交为 `d440011`,任务归档提交为 `3659d38`;当前 `main` 工作区干净,但相对 `origin/main` 领先 2 个提交,部署前必须先发布到上游。
- 本轮应用改动不包含 Prisma schema、migration、生产环境键或部署脚本变化。
- 本地已完成 Prisma、Node 语法、前端 typecheck/build、19 项 PC/移动 Playwright、Trellis/文档/敏感信息门禁;后端聚焦测试唯一失败是既有 BIFF8 本机依赖缺口,与本轮代码无关。
- 既有生产部署使用 `deploy/trialrun/deploy-existing.sh`,固定 `SYNC_ENV=0`,从受控 systemd unit 读取部署合同;不得覆盖生产 `.env` 或从 Docker 标签猜测目录。
- 当前进程环境未直接注入两个部署 SSH target;执行阶段从本机受控 SSH 配置探测既有部署身份和 forced-command refresh 身份,探测过程不回显目标、账号或路径。
- 用户已授权本次公网部署,但未授权真实报销、审批、付款、银行、ERP、企业微信或生产配置写入。
## Scope
- 将已验证的 `main` 提交推送到现有上游并通过既有来源校验。
- 运行既有部署聚合预检,使用 `deploy-existing.sh` 发布到现有公网环境。
- 验证活动 build、`/ready`、公开前端资源、基础 smoke、外部依赖和功能开关。
- 记录脱敏部署结果、可解释 SKIP、回滚边界和 Trellis 收尾证据。
## Requirements
### R1 发布来源与预检
- 部署候选必须是干净、已提交并包含在 `origin/main` 的明确提交;部署前先推送本任务的启动记录和既有功能提交。
- 使用既有聚合预检,要求部署身份、六项合同、仓库、环境文件、数据目录、运行实例、build、存储/PGDATA 挂载、备份 build、refresh 身份和 forced-command 全部通过。
- 环境合同、来源校验或远端聚合预检任一失败即停止,不使用 unpublished override,不绕过备份合同。
### R2 受控发布
- 仅使用 `deploy-existing.sh` 发布,保持 `SYNC_ENV=0` 和既有数据目录;不执行数据库重置、不修改 migration、不覆盖生产 `.env`。
- 发布过程中允许既有部署脚本执行构建、容器更新、已提交 migration 检查、运行时 smoke 和备份合同刷新;本轮预期无待执行 migration。
- 不创建或修改真实报销、付款批次、回单或银行数据,不执行真实付款。
### R3 公网只读验收
- 公网 `/ready`、运行时 build SHA 和前端公开短 SHA 必须与发布提交一致。
- 运行既有基础公网 smoke、外部依赖/功能开关检查和前端资源扫描;有专用 `LIVE_SMOKE_*` 凭据时才运行登录态 smoke,缺失时记录为明确 SKIP,不读取或输出真实账号密码。
- 对本轮功能只做无业务写入的验证:确认公开 bundle 包含新文案/入口合同,服务端路由已随目标 build 发布;不得用真实回单或真实付款数据制造验证样本。
### R4 结果与回滚
- 记录发布提交、部署合同总结果、`/ready`、公网 smoke、登录态 smoke 是否执行及任何可解释 SKIP。
- 若应用发布成功但备份合同 refresh 失败,按“已部署但合同刷新失败”处理,不重复发布应用;先恢复合同。
- 应用或 readiness 失败时,使用既有部署入口回滚到上一个已发布提交,不修改数据库结构或生产环境文件。
## Acceptance Criteria
- [ ] AC1:候选提交已推送到 `origin/main`,工作区干净,来源和部署前聚合体检通过。
- [ ] AC2:`deploy-existing.sh` 完整通过,生产 `.env` 未同步或覆盖,数据库无新增 migration,未发生真实业务或付款写入。
- [ ] AC3:公网 `/ready`、运行时 build 与前端短 SHA 均命中本次发布提交。
- [ ] AC4:基础公网 smoke、部署运行时 smoke、外部依赖/功能开关和前端资源扫描通过;登录态 smoke 按凭据实际状态记录 PASS 或明确 SKIP。
- [ ] AC5:公开资源可确认差旅事由去重、付款批次费用明细/小计和“下载需说明用途”等新前端合同已发布,不暴露真实回单、账号或存储资料。
- [ ] AC6:任务记录、Trellis 治理、敏感信息检查和 `git diff --check` 通过;只在全部部署与公网门禁通过后告知用户可以测试。
## Out of Scope
- 修改本轮功能代码、数据库模型、migration、生产环境变量或部署脚本。
- 真实报销提交、审批、付款批次操作、回单上传/预览、完整银行账号查看或真实付款。
- 企业微信写入、ERP、银行直连、生产数据库重置或人工修复真实业务数据。
- 为部署缺口启用 unpublished override、跳过聚合预检或弱化附件权限/审计。
## Test Plan
- Local/preflight:环境合同、任务校验、Trellis 治理、敏感信息、`git diff --check`、Git 来源/上游包含关系和既有部署 13 项聚合合同。
- Deploy:`deploy-existing.sh` 内置的构建、Compose、migration 状态、运行时 smoke、活动 build、公开 build 和备份合同 refresh。
- Public:`/ready`、基础无凭据 Playwright/readiness、外部依赖与功能开关检查、前端资源关键文案扫描;登录态 smoke 仅在专用凭据存在时运行。
- Negative/rollback:来源、预检或 readiness 任一失败即停止;应用已发布但 refresh 失败时不重复部署应用。
@@ -0,0 +1,30 @@
{
"id": "reimbursement-payment-batch-deployment",
"name": "reimbursement-payment-batch-deployment",
"title": "报销付款批次改进部署",
"description": "将已完成并验证的报销、付款批次和银行回单改进发布到既有公网环境,并完成受控只读验收。",
"status": "in_progress",
"dev_type": "deployment",
"scope": "deploy/trialrun、公网只读 smoke、Trellis 部署证据",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-08-05",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
".trellis/tasks/archive/2026-08/08-04-reimbursement-small-team-form-validation",
"deploy/trialrun/deploy-existing.sh",
".trellis/spec/hyb-oa/deployment-trialrun.md"
],
"notes": "",
"meta": {}
}
@@ -2,7 +2,7 @@
## 1. Design boundary
本任务在现有报销详情和预检链路内增量实现,不新增数据库表,不建设通用规则引擎,不改变审批、付款和资产状态机。
本任务在现有报销详情、预检和付款批次详情链路内增量实现,不新增数据库表,不建设通用规则引擎,不改变审批、付款写状态机和资产状态机。
核心数据流:
@@ -93,7 +93,7 @@ type ReimbursementAssetHint = {
- API 仅新增可选响应字段并收紧非法普通报销提交,现有合法报销兼容。
- 历史草稿中的零金额明细可以继续查看和修改,但必须修正后才能提交。
- 已提交、已通过和已付款记录保持只读,不回填时效或资产状态。
- 现有附件仍为整单材料,现有发票绑定、OCR、查重和连续预览不变。
- 现有报销附件仍为整单材料,发票绑定、OCR、查重和连续预览不变;付款回单只增加授权在线查看,不改变上传、作废和下载审计边界。
## 4. Risks and mitigations
@@ -110,3 +110,57 @@ type ReimbursementAssetHint = {
- 先部署服务端兼容字段和校验,再部署前端展示;旧前端可继续工作。
- 回滚前端不会破坏服务端数据;回滚服务端仅移除新提示和正金额收紧,无迁移回滚。
- 公网验收覆盖普通办公用品、宽带/技术服务、IT 硬件三类合成记录,不使用真实票据内容。
## 6. Follow-up: reason and payment-batch information architecture
### 6.1 Travel reason display
- 继续用现有 `isTravelReimbursement(detail)` 判断差旅单,不新增数据库字段或另一套分类条件。
- `ReimbursementHeaderEditor` 增加明确的显示模式:普通报销编辑日期和报销事由;差旅报销只编辑报销日期。
- `ReimbursementTravelSummary` 继续独占差旅事由、地点和日期范围。PC 与移动端使用同一条件,避免一端去重、另一端仍重复。
- `description` 与 `travel_purpose` 不互写、不迁移。工作流标题、列表搜索和既有通知仍消费原字段;本轮只收敛详情页面向用户的主事由。
### 6.2 Single payment-batch link
- 共享付款语义仍负责 `review/payment` 两阶段 state,不新增第二套状态 switch。
- 报销详情组装层先填充两个阶段事实,再把 `targetHref` 只赋给一个阶段:
- `review=current|exception` 且 `payment=pending|terminated` 时挂到付款复核;
- 其他已有批次状态挂到付款完成,包括 reviewed、executed、failed 和复核后取消。
- 无付款批次读取权限时两个阶段均无链接;付款批次详情自身不生成自指链接。
### 6.3 Payment-batch detail DTO
- 付款批次列表 DTO 保持现状;只有 `GET /payment-batches/:id` 的详情 DTO 为每个报销批次项增加 `expenseItems`。
- `expenseItems` 只返回 `id / expenseTypeName / expenseDate / description / amount`,按发生日期与稳定 ID 排序;不返回发票、附件、OCR、完整账号、密文或原始快照。
- 详情路由按本批次全部 reimbursement entity ID 一次批量读取报销明细和费用类型,组装到对应批次项,避免逐项查询。
- 批次冻结金额继续以 `PaymentBatchItem.amount` 为付款权威值;费用明细只作复核证据。服务端同时给出明细合计,测试锁定其与正常报销单金额一致;若历史异常不一致,页面显示警告,不自动改写批次或报销数据。
### 6.4 Payment-batch UI
- 主章节标题改为“本批次报销单”。每张卡按“报销单摘要 → 费用明细 → 付款信息 → 付款结果/风险”排列,并增加返回原报销详情的链接。
- 费用明细使用移动优先的响应式卡片/紧凑列表:PC 可按列对齐,移动端改为纵向事实,不引入会横向溢出的宽表。
- 每张报销单显示费用条数和明细小计;批次顶部继续显示整批总金额。明细为空或读取失败时显示明确降级提示,不伪装成零费用。
- 查看原报销单、查看回单和下载按钮保持至少 44px 触控高度、可见焦点和加载反馈。
## 7. Follow-up: bank-receipt preview and governance
### 7.1 Server contract
- 复用现有回单文件路由和完整验证链,增加受控 `mode=preview`;默认仍为 download,保持旧调用兼容。
- download 模式继续要求用户提供非空用途,写 `PAYMENT_BATCH_RECEIPT_DOWNLOADED`,返回附件下载响应。
- preview 模式不接受用户用途,服务端固定记录“付款批次内在线预览”,写独立 `PAYMENT_BATCH_RECEIPT_PREVIEWED` 审计;仍执行批次读取权限、部门范围、作废、隔离、大小、存储存在性和内容校验。
- preview 只允许校验后的 PDF/JPEG/PNG/WebP,按安全 MIME 返回 `inline`;HTML、SVG 和其他主动内容不进入预览。响应保持 `private, no-store`、`nosniff`、严格 CSP 和同源策略。
### 7.2 Client interaction
- 回单卡增加“在线查看”和“下载”两个明确动作。在线查看不弹用途框;下载继续复用现有用途对话框。
- 在线查看通过认证 API 获取 Blob,在现有 Dialog 内展示 PDF 或图片;加载中、失败和关闭都有明确反馈,弹窗具备标题、关闭按钮、焦点管理和 Escape 退出。
- 图片提供与文件名等价的替代文本;PDF 提供文件名和无法内嵌时的下载提示。对象 URL 在关闭、切换文件或组件卸载时立即 revoke。
- 通用治理徽标把“需填用途”改为“下载需说明用途”。这只是文案澄清,不改变合同、采购等其他附件的下载策略。
## 8. Follow-up compatibility, rollout and rollback
- API 只为付款批次详情增加字段并为文件路由增加可选 mode,旧客户端和列表接口兼容;无 Prisma 迁移。
- 先部署服务端兼容字段与 preview,再部署前端。若前端回滚,服务端新增能力无人调用但不影响下载。
- 第一回滚点是回单在线查看与 preview mode;第二回滚点是付款详情费用明细;第三回滚点是两个纯展示条件。回滚均不修改付款、报销或附件数据。
- 公网检查只使用脱敏合成文件或既有授权测试材料摘要,不在截图、日志或任务文档保留真实回单和银行信息。
@@ -1,56 +1,107 @@
# 小团队报销填报与校验完善:实施计划
## 已交付基线(2026-08-04)
以下 1–6 节已由原任务实施、验证和部署;本次恢复任务后只需回归,不重复开发。
## 1. Backend contracts and focused tests
- [ ] 在配置层增加并测试 `REIMBURSEMENT_MAX_AGE_DAYS=180|365`,默认 365,非法配置启动失败。
- [ ] 在报销预检服务增加证据摘要、时效检查和资产提示,复用 date-only/金额工具。
- [ ] 为默认期限、180 天、阈值边界、跨年度、未关联发票、整单附件和 `assets` 类别补单测。
- [x] 在配置层增加并测试 `REIMBURSEMENT_MAX_AGE_DAYS=180|365`,默认 365,非法配置启动失败。
- [x] 在报销预检服务增加证据摘要、时效检查和资产提示,复用 date-only/金额工具。
- [x] 为默认期限、180 天、阈值边界、跨年度、未关联发票、整单附件和 `assets` 类别补单测。
## 2. Authoritative amount validation
- [ ] 收紧手工 item POST/PATCH:金额必须大于零,返回可定位错误。
- [ ] submit 行锁事务读取明细,拒绝空明细和非正金额明细。
- [ ] submit 在预算与审批规则前重新汇总并使用权威总额。
- [ ] 补充空单、零金额模板草稿、修改后提交、陈旧总额修复和审批金额选择回归测试。
- [x] 收紧手工 item POST/PATCH:金额必须大于零,返回可定位错误。
- [x] submit 行锁事务读取明细,拒绝空明细和非正金额明细。
- [x] submit 在预算与审批规则前重新汇总并使用权威总额。
- [x] 补充空单、零金额模板草稿、修改后提交、陈旧总额修复和审批金额选择回归测试。
## 3. Shared frontend API and header editing
- [ ] 扩展 reimbursement API 类型与通用 PATCH 函数。
- [ ] PC 草稿/驳回详情增加报销事由和日期编辑。
- [ ] 手机草稿/驳回详情增加适合小屏的同等编辑能力。
- [ ] 保存后刷新 detail/list/precheck,并验证非编辑状态和无权限用户只读。
- [x] 扩展 reimbursement API 类型与通用 PATCH 函数。
- [x] PC 草稿/驳回详情增加报销事由和日期编辑。
- [x] 手机草稿/驳回详情增加适合小屏的同等编辑能力。
- [x] 保存后刷新 detail/list/precheck,并验证非编辑状态和无权限用户只读。
## 4. Totals, evidence and asset hints
- [ ] PC 明细表尾增加条数与报销总额,概况标签改为“报销总额”。
- [ ] 手机明细列表尾增加条数与报销总额。
- [ ] PC/手机展示明细发票关联状态、整单附件数、时效警告和资产人工建卡提醒。
- [ ] 资产入口沿用现有权限;打印页合计不回归。
- [x] PC 明细表尾增加条数与报销总额,概况标签改为“报销总额”。
- [x] 手机明细列表尾增加条数与报销总额。
- [x] PC/手机展示明细发票关联状态、整单附件数、时效警告和资产人工建卡提醒。
- [x] 资产入口沿用现有权限;打印页合计不回归。
## 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,重点检查金额竞争、模板兼容和权限边界。
- [x] `cd server && npm run prisma:validate`
- [x] `cd server && node --check src/config.js && node --check src/services/feeControl.js && node --check src/routes/reimbursements.js`
- [x] 运行本任务新增的后端 Vitest 及现有 reimbursement detail/create/expense-standard 聚焦回归。
- [x] `cd client && npm run typecheck && npm run build`
- [x] 运行 PC 与 mobile/wecom 报销详情 E2E,覆盖合计、编辑、时效、证据和资产提醒。
- [x] 运行 `node scripts/qa/check-process-contract.mjs --baseline --json`。
- [x] 运行 `node scripts/qa/audit-docs.mjs --check-registry` 和 `node scripts/qa/check-trellis-governance.mjs`。
- [x] `git diff --check`
- [x] 因提交校验和跨层预检发生变化,实施后进行一次只读 review,重点检查金额竞争、模板兼容和权限边界。
## 6. Documentation, deployment and rollback
- [ ] 仅在形成稳定规则时更新现有报销事实源或 Trellis spec,不新建平行文档。
- [ ] 记录环境配置默认值、生产取值和回滚方式,不记录 Secret 或真实票据。
- [ ] 用户批准后启动任务;实现、检查、必要 spec 更新完成后使用中文 commit,并引用任务名。
- [ ] 若需用户线上测试,完成部署、公网健康检查和合成数据关键路径冒烟后再通知。
- [x] 仅在形成稳定规则时更新现有报销事实源或 Trellis spec,不新建平行文档。
- [x] 记录环境配置默认值、生产取值和回滚方式,不记录 Secret 或真实票据。
- [x] 用户批准后启动任务;实现、检查、必要 spec 更新完成后使用中文 commit,并引用任务名。
- [x] 若需用户线上测试,完成部署、公网健康检查和合成数据关键路径冒烟后再通知。
## 本次后续复核实施计划(待批准)
## 7. 事由与付款批次入口
- [x] PC、移动报销详情复用 `isTravelReimbursement`:普通报销显示日期和报销事由,差旅报销只在头部编辑日期并由差旅摘要独占出差事由。
- [x] 核对列表、打印、搜索、工作流标题和通知对 `description/travel_purpose` 的消费,保证本轮不迁移、不丢失既有数据。
- [x] 报销进度组装完成两阶段语义后,只给实际当前/终止/最终付款阶段设置付款批次链接。
- [x] 增加普通/差旅 PC 与移动断言,以及 draft/reviewed/executed/failed/cancelled、取消前后和无权限的链接矩阵测试。
## 8. 付款批次费用明细
- [x] 扩展付款批次详情查询,一次批量加载批次内报销费用明细及费用类型;列表接口保持轻量。
- [x] 详情 DTO 增加最小 `expenseItems` 和服务端明细合计,补隐私负向断言与历史空数据降级。
- [x] 页面章节改为“本批次报销单”,每张卡依次显示报销摘要、费用明细/小计、付款信息、付款结果和原报销单入口。
- [x] PC 使用紧凑对齐布局,移动端使用纵向事实卡;不得产生横向溢出,按钮保持触控尺寸和可见焦点。
## 9. 银行回单在线查看
- [x] 文件路由增加兼容的 preview mode:固定预览用途、独立审计动作、安全 inline MIME,同时保留下载用途门禁和既有下载响应。
- [x] 复用并回归权限、部门数据范围、作废、隔离、文件不存在、大小、内容类型和存储校验;拒绝 HTML/SVG/未知主动内容。
- [x] 前端增加认证 Blob 在线预览弹窗,支持 PDF/图片、加载/失败/关闭反馈、键盘与焦点管理,并可靠释放对象 URL。
- [x] 下载继续弹用途框;通用徽标文案改为“下载需说明用途”,回归采购、合同、报销等既有消费者的语义和视觉。
## 10. 验证、只读审查与文档
- [x] 运行付款语义、报销详情、付款批次、回单附件治理聚焦 Vitest;记录并区分本机 BIFF8 固定 Python 依赖缺失基线。
- [x] 运行 Prisma validate、所改 Node 文件语法检查、客户端 typecheck/build。
- [x] 运行 PC/移动合成 E2E,覆盖事由去重、唯一批次入口、费用明细与小计、PDF/图片预览、下载用途和无权限/异常文件。
- [x] 运行 Trellis task validate、流程合同、文档注册、Trellis 治理、敏感内容和 `git diff --check`。
- [x] 按项目约束执行一次只读 review,重点检查付款状态投影、批次详情隐私、回单权限/审计与 PC/移动一致性;reviewer 不改代码。
- [x] 仅将稳定规则更新到现有 `docs/报销发票附件规则.md`、`docs/产品设计规则.md` 或对应 Trellis spec,不新建平行文档。
## 11. Commit and deployment gate
- [x] 全部本地门禁通过后使用中文 commit,并引用 `reimbursement-small-team-form-validation`。
- [ ] 本次规划不包含部署授权;代码完成后另行申请公网部署许可。
- [ ] 获得部署授权后使用既有部署入口,验证 build、`/ready`、数据库无迁移、回单预览与付款详情脱敏冒烟;通过后才通知用户测试。
## Rollback points
- 配置/预检字段为第一回滚点:可独立移除,不影响数据库。
- 正金额提交门禁为第二回滚点:若发现合法流程被误阻断,先回滚该提交校验,不绕过发票/权限门禁。
- 前端编辑与提示为第三回滚点:可回滚 UI,服务端新增字段保持向后兼容。
- 后续复核新增回滚点:先关闭前端回单预览,再回滚详情 DTO,再回滚纯展示条件;均不改写业务数据。
## 后续复核执行记录(2026-08-05)
- 差旅详情已收敛为单一“出差事由”;日期编辑请求不发送普通报销 `description`。报销进度的付款批次链接已按五状态和取消前后分支唯一投影。
- 付款批次详情仅在详情 DTO 批量加载最小费用明细,列表及创建、复核、执行、取消响应不携带明细;空明细不重复显示金额不一致警告。
- PDF 与 PNG 回单预览均通过实际内容复检、固定用途审计和安全 inline 响应验证;下载仍要求用途。前端 19 项 PC/移动合成 E2E 全部通过。
- Prisma、Node 语法、前端 typecheck/build、Trellis task、流程、文档、治理、敏感信息和差异门禁通过。后端聚焦 80 项中 79 项通过;唯一失败为实施前已存在的 BIFF8 银行制单用例,本机缺固定 Python 依赖导致预期 200、实际 409,与本轮改动无关。
- 已完成主会话只读复核并修正四处边界:写接口不附带费用明细、空明细不重复告警、差旅日期编辑不回传普通事由、历史差旅付款批次不冒充报销明细或生成错误报销入口。未执行部署、生产配置、真实数据或付款操作。
## 执行记录(2026-08-04)
@@ -2,7 +2,7 @@
## Goal
面向公司内部 5–10 人使用场景,在不扩展为通用费控产品的前提下,完善普通报销填报、提交预检和证据提示,使员工容易填写、审批人看得懂、财务能够核对、付款后能够追溯。
面向公司内部 5–10 人使用场景,在不扩展为通用费控产品的前提下,完善报销填报、提交预检、付款批次核对和回单查看,使员工容易填写、审批人看得懂、财务能够核对、付款后能够追溯。
## Background and confirmed facts
@@ -13,6 +13,8 @@
- 发票已有可选明细关联;补充附件当前属于整单。本任务不引入发票多对多关系或附件明细关联。
- 费用类型已有 `assets` 类别;资产事实源要求“资产候选 + 管理员人工建卡”,不得因报销提交、审批或付款自动生成资产卡。
- 本轮讨论已决定不增加“历史费用补报”标识。票据新旧由费用日期、发票日期和提交日期推导。
- 后续真实使用复核发现:差旅报销同时展示整单“报销事由”和“出差事由”;有权限用户在付款复核与付款完成两个阶段都看到同一付款批次链接;付款批次的“报销明细”实际只有报销单级付款摘要;银行回单只有强制下载路径。
- 代码核实确认上述四项均由当前实现稳定产生,并非截图或浏览器偶发现象:前两项属于展示条件/阶段链接组装问题,后两项分别存在详情 API 数据缺口和附件查看能力缺口。
## Scope
@@ -22,6 +24,7 @@
- 明细发票关联、整单附件数量的材料完整性提示。
- `assets` 类别的人工资产建卡提醒和授权入口。
- 所需的前后端类型、聚焦测试、现有事实源/spec 更新及部署配置说明。
- 报销详情、付款批次详情和银行回单之间的只读信息展示与交互复核;不改变付款写状态机、maker-checker 或真实付款边界。
## Requirements
@@ -68,18 +71,48 @@
- 不自动创建资产卡、不自动判断会计固定资产、不复制资产建卡表单到报销页。
- 有资产台账查看权限的用户可从提醒进入资产模块;其他用户只看到下一步说明。
### R7 差旅报销事由单一主语义
- 普通报销继续展示并编辑“报销事由”。
- 差旅报销以“出差事由”为唯一面向用户的主事由,不在同一区域重复展示空泛的“报销事由”。
- 既有 `description` 与 `travel_purpose` 数据暂不迁移、不互相覆盖;打印、搜索、通知标题等非详情消费者需在设计阶段核对兼容语义。
### R8 付款批次入口只出现一次
- 有付款批次读取权限且报销已进入批次时,处理进度中只显示一个“查看所在付款批次”入口。
- 入口随批次状态挂到实际承载当前或最终结果的阶段:待复核挂在“付款复核”,复核后、付款失败或付款完成挂在“付款完成”;取消状态挂在实际终止阶段。
- 普通员工仍不获得付款批次读取权限或内部批次入口。
### R9 付款批次区分报销单与费用明细
- 付款批次中的一张卡片首先表示一张待付款报销单,应使用“本批次报销单”或同等准确标题,不把收款账户摘要冒充费用明细。
- 每张报销单展示其费用明细的只读摘要,至少包含费用类型、发生日期、说明和金额,并显示明细合计;同时保留收款人、开户银行、脱敏账号、付款渠道和付款结果。
- 付款批次提供返回原报销详情的入口;不在付款页复制发票、补充附件编辑和完整报销操作。
- 费用明细只加入付款批次详情 DTO,列表响应继续保持轻量;不得返回完整银行账号、加密值或原始快照。
### R10 银行回单在线查看与用途治理
- PDF 和支持的图片回单在付款批次详情中提供在线查看;不再要求用户必须先下载到本地才能核对。
- 在线查看继续经过服务端权限、批次数据范围、文件类型/大小/隔离状态校验,并写审计;不得直接暴露存储路径。
- 回单继续标记为“保密”。授权人员在批次上下文内在线查看时使用系统固定用途并自动审计,不要求重复手填;下载到本地仍要求手填用途并写审计。
- “需填用途”若仅针对下载,应改为更准确的“下载需说明用途”,避免误解为回单本身资料不完整。
## 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:若改动面向公网用户,部署、公网冒烟和关键页面检查通过后才告知可以测试。
- [x] AC1:PC 和手机的报销明细区域均显示与服务端 `totalAmount` 一致的明细数量和合计;新增、修改、删除后自动刷新。
- [x] AC2:手工新增/修改 `0` 金额明细被服务端拒绝;空明细或含非正金额明细的报销无法提交;模板零金额草稿仍可创建和编辑。
- [x] AC3:提交事务重新汇总权威总额,审批规则和后续提交结果不使用陈旧总额。
- [x] AC4:草稿/驳回报销可在 PC 和手机编辑报销事由和日期;其他状态不可编辑,权限边界不放宽。
- [x] AC5:默认 365 天配置有效,可切换为 180 天;超期和跨年度产生可理解警告但不阻断,date-only 边界无偏移。
- [x] AC6:预检及详情能显示每条明细的发票关联状态和整单附件数;现有整单发票门禁、OCR、查重和附件权限不回归。
- [x] AC7:`assets` 类别触发人工资产登记提醒,不产生任何资产卡写入;无权限用户不会获得资产数据或管理入口。
- [x] AC8:PC、手机和打印页使用一致的金额语义;普通用户页面不出现内部技术术语。
- [x] AC9:相关后端测试、前端 typecheck/build、PC/移动报销 E2E、Trellis 治理和 `git diff --check` 全部通过。
- [x] AC10:若改动面向公网用户,部署、公网冒烟和关键页面检查通过后才告知可以测试。
- [x] AC11:普通报销显示“报销事由”,差旅报销详情只显示一个主事由入口且沿用“出差事由”;既有差旅数据不丢失。
- [x] AC12:同一报销的处理进度最多出现一个付款批次入口,五种批次状态及取消前后分支均落在正确阶段,权限边界不放宽。
- [x] AC13:付款批次明确区分报销单、费用明细和付款信息;费用明细金额合计与报销单金额一致,并可返回原报销详情。
- [x] AC14:PDF/图片银行回单可在授权页面在线查看且自动记录固定用途;下载到本地仍要求手填用途,隔离、作废、超限和无权限文件不可查看或下载。
## Out of Scope
@@ -87,10 +120,12 @@
- 通用费用政策中心、复杂预算、银企直连、自动付款、自动记账或完整电子会计档案系统。
- 发票与明细多对多、补充附件关联具体明细、发票验真能力扩展。
- 自动创建资产卡、折旧/残值/会计科目判断。
- 改造采购流程、付款状态机、审批角色或权限模型。
- 改造采购流程、付款写状态机、审批角色或权限模型。
- 把发票、补充附件编辑或完整报销详情复制进付款批次,或新增通用文档预览中心。
## Test Plan
- Backend:Prisma validate、`node --check`、报销详情/创建/费用标准相关 Vitest,并补充本任务聚焦用例。
- Frontend:`npm run typecheck`、`npm run build`、PC 和 mobile/wecom 报销详情 E2E。
- Frontend:`npm run typecheck`、`npm run build`、PC 和 mobile/wecom 报销详情及付款批次详情 E2E。
- Process:Trellis task validate、流程合同、治理、文档注册审计和 `git diff --check`。
- Follow-up focused:差旅/普通报销事由显示、付款批次入口五状态矩阵、付款批次详情 DTO 隐私负向断言、费用明细合计、回单 PDF/图片在线查看及用途/审计/隔离回归。
@@ -2,7 +2,7 @@
"id": "reimbursement-small-team-form-validation",
"name": "reimbursement-small-team-form-validation",
"title": "小团队报销填报与校验完善",
"description": "完善小团队内部报销的明细合计、正金额校验、事由编辑、票据时效提示、材料完整性提示和资产登记提醒。",
"description": "完善小团队内部报销填报,并复核报销详情、付款批次和银行回单之间的信息展示与交互。",
"status": "completed",
"dev_type": "feature",
"scope": "server/client/docs/tests",
@@ -14,13 +14,13 @@
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"commit": "d440011",
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"notes": "已完成报销事由去重、付款批次唯一入口、费用明细核对和银行回单受控在线预览;本轮未部署。",
"meta": {},
"completedAt": "2026-08-04"
}
"completedAt": "2026-08-05"
}
+17
查看文件
@@ -25,6 +25,15 @@ export type PaymentBatchItem = {
failureMessage: string | null;
createdAt: string | null;
openRiskFlags: RiskFlag[];
expenseItems?: Array<{
id: string;
expenseTypeName: string;
expenseDate: string | null;
description: string;
amount: string;
}>;
expenseItemsTotal?: string;
expenseItemsAmountMatches?: boolean;
};
export type PaymentBatch = {
@@ -235,6 +244,14 @@ export async function downloadPaymentBatchReceiptAttachment(
return apiRequestBlob(buildAttachmentFileUrl(paymentBatchReceiptAttachmentFileUrl(batchId, attachmentId), reason));
}
export async function previewPaymentBatchReceiptAttachment(
batchId: string,
attachmentId: string,
): Promise<ApiBlobResponse> {
const params = new URLSearchParams({ mode: "preview" });
return apiRequestBlob(`${paymentBatchReceiptAttachmentFileUrl(batchId, attachmentId)}?${params.toString()}`);
}
export async function voidPaymentBatchReceiptAttachment(batchId: string, attachmentId: string, reason: string) {
return apiRequestData<{ attachmentId: string; outcome: "voided"; voidedAt: string }>(
`/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}/void`,
@@ -40,7 +40,7 @@ export function AttachmentGovernanceBadges({
<span className={sensitivityClass(attachment.sensitivityLevel)}>
{SENSITIVITY_LEVEL_LABEL[attachment.sensitivityLevel] ?? attachment.sensitivityLevel}
</span>
{attachmentRequiresDownloadReason(attachment) ? <span className="badge-warning">需填用途</span> : null}
{attachmentRequiresDownloadReason(attachment) ? <span className="badge-warning">下载需说明用途</span> : null}
</div>
);
}
@@ -14,6 +14,7 @@ type Props = {
description: string | null;
editable: boolean;
mobile?: boolean;
showDescription?: boolean;
};
export function ReimbursementHeaderEditor({
@@ -22,14 +23,18 @@ export function ReimbursementHeaderEditor({
description,
editable,
mobile = false,
showDescription = true,
}: 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, "报销事由和日期");
const dirty = editing && (
date !== (reimbursementDate ?? "") ||
(showDescription && reason !== (description ?? ""))
);
const runGuarded = useUnsavedChangesGuard(dirty, showDescription ? "报销事由和日期" : "报销日期");
useEffect(() => {
if (editing) return;
@@ -40,7 +45,7 @@ export function ReimbursementHeaderEditor({
const mutation = useMutation({
mutationFn: () => updateReimbursement(reimbursementId, {
reimbursementDate: date,
description: reason.trim() || null,
...(showDescription ? { description: reason.trim() || null } : {}),
}),
onSuccess: async () => {
setError("");
@@ -59,16 +64,19 @@ export function ReimbursementHeaderEditor({
});
if (!editing) {
if (!showDescription && !editable) return null;
return (
<div className={mobile ? "mt-3" : "mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2"}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-sm leading-6 text-zinc-600">
<span className="font-medium text-zinc-800">报销事由:</span>
{description || "未填写"}
</div>
{showDescription ? (
<div className="min-w-0 text-sm leading-6 text-zinc-600">
<span className="font-medium text-zinc-800">报销事由:</span>
{description || "未填写"}
</div>
) : <div className="min-w-0 text-sm leading-6 text-zinc-600">报销日期可在草稿阶段修改</div>}
{editable ? (
<Button size="sm" type="button" variant="secondary" onClick={() => setEditing(true)}>
编辑
{showDescription ? "编辑" : "编辑报销日期"}
</Button>
) : null}
</div>
@@ -78,15 +86,17 @@ export function ReimbursementHeaderEditor({
return (
<div className="mt-3 space-y-3 rounded-lg border border-primary-100 bg-primary-50/40 p-3" data-testid="reimbursement-header-editor">
<div className={mobile ? "space-y-3" : "grid gap-3 md:grid-cols-[12rem_1fr]"}>
<div className={mobile || !showDescription ? "space-y-3" : "grid gap-3 md:grid-cols-[12rem_1fr]"}>
<label className="space-y-1 text-sm text-zinc-700">
<span>报销日期</span>
<Input type="date" value={date} disabled={mutation.isPending} onChange={(event) => setDate(event.target.value)} />
</label>
<label className="space-y-1 text-sm text-zinc-700">
<span>报销事由</span>
<Textarea rows={2} maxLength={500} value={reason} disabled={mutation.isPending} onChange={(event) => setReason(event.target.value)} />
</label>
{showDescription ? (
<label className="space-y-1 text-sm text-zinc-700">
<span>报销事由</span>
<Textarea rows={2} maxLength={500} value={reason} disabled={mutation.isPending} onChange={(event) => setReason(event.target.value)} />
</label>
) : null}
</div>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<div className="flex gap-2">
@@ -45,6 +45,7 @@ import { MobileIcon } from "../components/mobile/MobileIcon";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { ReimbursementHeaderEditor } from "../components/reimbursements/ReimbursementHeaderEditor";
import { isTravelReimbursement } from "../lib/reimbursementTravel";
import { ReimbursementOperationalHints } from "../components/reimbursements/ReimbursementOperationalHints";
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import { PageScaffold } from "../components/patterns/PageScaffold";
@@ -934,6 +935,7 @@ export function MobileReimbursementDetailPage() {
description={detail.description}
editable={Boolean(canEdit && isParticipant)}
mobile
showDescription={!isTravelReimbursement(detail)}
/>
<ReimbursementTravelSummary
detail={detail}
+114 -5
查看文件
@@ -1,6 +1,6 @@
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import {
cancelPaymentBatch,
createPaymentBatch,
@@ -11,6 +11,7 @@ import {
fetchPaymentBatchDetail,
fetchPaymentBatchReceiptAttachments,
fetchPaymentBatches,
previewPaymentBatchReceiptAttachment,
reviewPaymentBatch,
revealPaymentBatchPayeeBank,
updatePaymentBatchItem,
@@ -173,6 +174,17 @@ export function PaymentBatchesPage() {
>(null);
const [actionReason, setActionReason] = useState("");
const [revealedPayeeAccount, setRevealedPayeeAccount] = useState("");
const [receiptPreview, setReceiptPreview] = useState<{
attachment: PaymentBatchReceiptAttachment;
objectUrl: string;
mimeType: string;
} | null>(null);
useEffect(() => {
return () => {
if (receiptPreview?.objectUrl) URL.revokeObjectURL(receiptPreview.objectUrl);
};
}, [receiptPreview?.objectUrl]);
const listQuery = useQuery({
queryKey: ["payment-batches"],
@@ -421,6 +433,26 @@ export function PaymentBatchesPage() {
downloadReceiptMutation.mutate({ attachment, reason });
}
const previewReceiptMutation = useMutation({
mutationFn: async (attachment: PaymentBatchReceiptAttachment) => {
if (!selectedBatch) throw new Error("请先选择付款批次");
const response = await previewPaymentBatchReceiptAttachment(selectedBatch.id, attachment.id);
return { attachment, response };
},
onSuccess: ({ attachment, response }) => {
setReceiptPreview({
attachment,
objectUrl: URL.createObjectURL(response.blob),
mimeType: response.blob.type || attachment.mimeType,
});
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
const voidReceiptMutation = useMutation({
mutationFn: async ({ batchId, attachmentId, reason }: { batchId: string; attachmentId: string; reason: string }) => {
return voidPaymentBatchReceiptAttachment(batchId, attachmentId, reason);
@@ -704,7 +736,9 @@ export function PaymentBatchesPage() {
</div>
<div className="finance-command-panel space-y-4" data-testid="payment-batch-items-panel">
<h3 className="text-base font-semibold text-zinc-900">报销明细</h3>
<h3 className="text-base font-semibold text-zinc-900">
{selectedBatch.entityType === "reimbursement" ? "本批次报销单" : "本批次差旅订单"}
</h3>
<div className="space-y-4">
{selectedBatch.items.map((item) => (
<div key={item.id} className="detail-evidence-card">
@@ -713,9 +747,42 @@ export function PaymentBatchesPage() {
<div className="font-medium text-zinc-900">{item.entityCode}</div>
<div className="text-sm text-zinc-600">{item.entityTitle}</div>
</div>
<div className="text-sm text-zinc-600">{formatMoney(item.amount)} · {paymentItemStatusLabel(item.status)}</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-zinc-600">
<span>{formatMoney(item.amount)} · {paymentItemStatusLabel(item.status)}</span>
{selectedBatch.entityType === "reimbursement" ? (
<Link className="inline-flex min-h-11 items-center font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2" to={`/reimbursements/${item.entityId}`}>
查看报销单
</Link>
) : null}
</div>
</div>
<div className="mt-3 detail-fact-grid text-sm md:grid-cols-4">
{selectedBatch.entityType === "reimbursement" ? (
<div className="mt-3 rounded-lg border border-zinc-200 bg-zinc-50/60 p-3" data-testid="payment-batch-expense-items">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span className="font-medium text-zinc-900">费用明细 · {item.expenseItems?.length ?? 0} 条</span>
<span className="font-medium text-zinc-900">明细小计 {formatMoney(item.expenseItemsTotal ?? "0.00")}</span>
</div>
{item.expenseItems?.length ? (
<div className="mt-3 space-y-2">
{item.expenseItems.map((expenseItem) => (
<div key={expenseItem.id} className="grid gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm md:grid-cols-[10rem_8rem_minmax(0,1fr)_8rem] md:items-center">
<div><span className="text-xs text-zinc-500 md:hidden">费用类型:</span>{expenseItem.expenseTypeName}</div>
<div><span className="text-xs text-zinc-500 md:hidden">发生日期:</span>{formatDisplayDate(expenseItem.expenseDate)}</div>
<div className="min-w-0 break-words"><span className="text-xs text-zinc-500 md:hidden">说明:</span>{expenseItem.description || "-"}</div>
<div className="font-medium text-zinc-900 md:text-right"><span className="text-xs font-normal text-zinc-500 md:hidden">金额:</span>{formatMoney(expenseItem.amount)}</div>
</div>
))}
</div>
) : (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">未读取到费用明细,请进入原报销单核对后再付款。</div>
)}
{(item.expenseItems?.length ?? 0) > 0 && item.expenseItemsAmountMatches === false ? (
<div className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">费用明细小计与批次付款金额不一致,请进入原报销单核对。</div>
) : null}
</div>
) : null}
<div className="mt-3 text-sm font-medium text-zinc-900">付款信息</div>
<div className="mt-2 detail-fact-grid text-sm md:grid-cols-4">
<div className="detail-fact-card"><div className="detail-fact-card__label">收款人</div><div className="detail-fact-card__value">{item.payeeName || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">开户银行</div><div className="detail-fact-card__value">{item.bankName || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">银行账户</div><div className="detail-fact-card__value">{item.bankAccountMasked || "-"}</div>{canRevealPayeeBank && ["reviewed", "failed"].includes(selectedBatch.status) && selectedBatch.paymentAccount ? <Button type="button" size="sm" variant="secondary" className="mt-2" onClick={() => { setActionReason(""); setRevealedPayeeAccount(""); setReasonAction({ kind: "payeeBank", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, itemId: item.id, entityCode: item.entityCode }); }}>查看完整账号</Button> : null}</div>
@@ -827,6 +894,17 @@ export function PaymentBatchesPage() {
type="button"
size="sm"
variant="secondary"
className="min-h-11"
disabled={previewReceiptMutation.isPending || Boolean(attachment.archivedAt)}
onClick={() => previewReceiptMutation.mutate(attachment)}
>
{previewReceiptMutation.isPending ? "加载中..." : "在线查看"}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
className="min-h-11"
disabled={downloadReceiptMutation.isPending || Boolean(attachment.archivedAt)}
onClick={() => void handleDownloadReceipt(attachment)}
>
@@ -1040,6 +1118,37 @@ export function PaymentBatchesPage() {
if (confirmPaymentAction.kind === "financePayment") executeMutation.mutate(confirmPaymentAction.batchId);
}}
/>
<Dialog
open={Boolean(receiptPreview)}
onOpenChange={(open) => {
if (!open) setReceiptPreview(null);
}}
>
<DialogContent className="flex max-h-[92vh] max-w-5xl flex-col p-5" data-testid="payment-batch-receipt-preview-dialog">
<DialogHeader>
<DialogTitle>在线查看银行回单</DialogTitle>
<DialogDescription>{receiptPreview?.attachment.originalName ?? "银行回单"} · 本次查看已自动写入审计记录。</DialogDescription>
</DialogHeader>
<div className="mt-4 min-h-0 flex-1 overflow-auto rounded-lg border border-zinc-200 bg-zinc-100 p-2">
{receiptPreview?.mimeType === "application/pdf" ? (
<iframe
className="h-[68vh] min-h-[28rem] w-full rounded bg-white"
src={receiptPreview.objectUrl}
title={`银行回单 ${receiptPreview.attachment.originalName}`}
/>
) : receiptPreview ? (
<img
alt={`银行回单 ${receiptPreview.attachment.originalName}`}
className="mx-auto max-h-[68vh] max-w-full rounded bg-white object-contain"
src={receiptPreview.objectUrl}
/>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="secondary" className="min-h-11" onClick={() => setReceiptPreview(null)}>关闭</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(reasonAction)}
onOpenChange={(open) => {
+2 -1
查看文件
@@ -36,7 +36,7 @@ import {
import { toast } from "sonner";
import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import { hasCompleteTravelSummary } from "../lib/reimbursementTravel";
import { hasCompleteTravelSummary, isTravelReimbursement } from "../lib/reimbursementTravel";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
@@ -1137,6 +1137,7 @@ export function ReimbursementDetailPage() {
reimbursementDate={detail.reimbursementDate}
description={detail.description}
editable={Boolean(canEditItems && isReimbursementParticipant)}
showDescription={!isTravelReimbursement(detail)}
/>
<ReimbursementTravelSummary
detail={detail}
@@ -80,7 +80,7 @@ function reimbursementListItem() {
};
}
function reimbursementDetail(status: "draft" | "reviewing" | "paid" = "draft") {
function reimbursementDetail(status: "draft" | "reviewing" | "paid" = "draft", isTravel = false) {
const workflowTaskId = "workflow-task-action-confirm";
return {
...reimbursementListItem(),
@@ -90,7 +90,9 @@ function reimbursementDetail(status: "draft" | "reviewing" | "paid" = "draft") {
departmentId: "department-action-confirm",
department: { id: "department-action-confirm", name: "合成部门" },
submittedAt: null,
travelSummary: { isTravel: false, purpose: null, location: null, startDate: null, endDate: null },
travelSummary: isTravel
? { isTravel: true, purpose: "拜访客户讨论需求", location: "西安", startDate: "2026-05-14", endDate: "2026-05-15" }
: { isTravel: false, purpose: null, location: null, startDate: null, endDate: null },
projectId: null,
linkedTravelRequestId: null,
linkedTravelRequest: null,
@@ -103,7 +105,9 @@ function reimbursementDetail(status: "draft" | "reviewing" | "paid" = "draft") {
expenseDate: "2026-05-15",
amount: "88.00",
description: "合成报销动作回归",
expenseType: { id: "expense-type-action-confirm", code: "office", name: "办公费", category: "office", enabled: true },
expenseType: isTravel
? { id: "expense-type-action-confirm", code: "travel_train", name: "火车票", category: "travel", enabled: true }
: { id: "expense-type-action-confirm", code: "office", name: "办公费", category: "office", enabled: true },
},
],
invoices: [
@@ -192,11 +196,12 @@ function paymentBatch(
paymentChannel: "bank" | "cash" = "bank",
canExportPaymentOrder = false,
paymentOrderExportCount = 0,
entityType: "reimbursement" | "travel_order" = "reimbursement",
) {
return {
id: paymentBatchId,
batchNo: "PAY-ACTION-001",
entityType: "reimbursement",
entityType,
name: "R7E 高风险付款确认",
status,
defaultPaymentDate: "2026-05-15",
@@ -250,6 +255,17 @@ function paymentBatch(
failureMessage: null,
createdAt: "2026-05-15T08:00:00.000Z",
openRiskFlags: [],
expenseItems: [
{
id: "expense-item-action-confirm",
expenseTypeName: "办公用品",
expenseDate: "2026-05-14",
description: "项目讨论资料",
amount: "88.00",
},
],
expenseItemsTotal: "88.00",
expenseItemsAmountMatches: true,
},
],
};
@@ -265,7 +281,9 @@ async function mockSystemActionApis(
paymentChannel?: "bank" | "cash";
canExportPaymentOrder?: boolean;
paymentOrderExportCount?: number;
paymentEntityType?: "reimbursement" | "travel_order";
reimbursementStatus?: "draft" | "reviewing" | "paid";
travelReimbursement?: boolean;
meOverride?: Partial<typeof me>;
} = {},
) {
@@ -279,10 +297,12 @@ async function mockSystemActionApis(
exportReimbursements: 0,
executePaymentBatch: 0,
paymentBatchDetail: 0,
previewPaymentReceipt: 0,
uploadPaymentReceipt: 0,
voidPaymentReceipt: 0,
attachmentDeleteReasons: [] as string[],
receiptVoidReasons: [] as string[],
reimbursementUpdates: [] as Array<Record<string, unknown>>,
submitReimbursement: 0,
withdrawReimbursement: 0,
remindWorkflow: 0,
@@ -370,7 +390,17 @@ async function mockSystemActionApis(
}
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") {
await fulfillJson(route, reimbursementDetail(currentReimbursementStatus));
await fulfillJson(route, reimbursementDetail(currentReimbursementStatus, options.travelReimbursement));
return;
}
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "PATCH") {
const body = request.postDataJSON() as Record<string, unknown>;
calls.reimbursementUpdates.push(body);
await fulfillJson(route, {
...reimbursementDetail(currentReimbursementStatus, options.travelReimbursement),
reimbursementDate: body.reimbursementDate ?? "2026-05-14",
});
return;
}
@@ -460,6 +490,7 @@ async function mockSystemActionApis(
options.paymentChannel,
options.canExportPaymentOrder,
options.paymentOrderExportCount,
options.paymentEntityType,
);
await fulfillJson(route, {
items: [{
@@ -485,6 +516,7 @@ async function mockSystemActionApis(
options.paymentChannel,
options.canExportPaymentOrder,
options.paymentOrderExportCount,
options.paymentEntityType,
),
});
return;
@@ -528,6 +560,21 @@ async function mockSystemActionApis(
return;
}
if (
path === `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/file` &&
request.method() === "GET" &&
url.searchParams.get("mode") === "preview"
) {
calls.previewPaymentReceipt += 1;
await route.fulfill({
status: 200,
contentType: "application/pdf",
headers: { "content-disposition": "inline; filename=synthetic-receipt.pdf" },
body: Buffer.from("%PDF-1.4\nsynthetic preview\n%%EOF\n", "utf8"),
});
return;
}
if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments` && request.method() === "POST") {
calls.uploadPaymentReceipt += 1;
hasReceipt = true;
@@ -644,6 +691,26 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await expect(page).toHaveURL(new RegExp(`/reimbursements/${reimbursementId}/print$`));
});
test("差旅报销在 PC 和移动详情只展示出差事由", async ({ page }) => {
const calls = await mockSystemActionApis(page, { travelReimbursement: true });
await login(page, user);
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByText("出差事由", { exact: true })).toHaveCount(1);
await expect(page.getByText("拜访客户讨论需求", { exact: true })).toBeVisible();
await expect(page.getByText("报销事由", { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: "编辑报销日期" }).click();
await page.getByTestId("reimbursement-header-editor").locator('input[type="date"]').fill("2026-05-13");
await page.getByTestId("reimbursement-header-editor").getByRole("button", { name: "保存" }).click();
await expect.poll(() => calls.reimbursementUpdates).toHaveLength(1);
expect(calls.reimbursementUpdates[0]).toEqual({ reimbursementDate: "2026-05-13" });
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
await expect(page.getByText("出差事由", { exact: true })).toHaveCount(1);
await expect(page.getByText("拜访客户讨论需求", { exact: true })).toBeVisible();
await expect(page.getByText("报销事由", { exact: true })).toHaveCount(0);
});
test("付款批次执行必须确认后才调用接口", async ({ page }) => {
const calls = await mockSystemActionApis(page);
page.on("dialog", (dialog) => {
@@ -748,6 +815,45 @@ test.describe("R7E 全站高风险操作二次确认", () => {
expect(responsiveContract.actionPosition).toBe("sticky");
});
test("付款详情展示费用明细并支持回单在线查看,下载仍要求用途", async ({ page }) => {
const calls = await mockSystemActionApis(page, { hasReceipt: true });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
const itemsPanel = page.getByTestId("payment-batch-items-panel");
await expect(itemsPanel).toContainText("本批次报销单");
await expect(itemsPanel).toContainText("办公用品");
await expect(itemsPanel).toContainText("项目讨论资料");
await expect(itemsPanel).toContainText("明细小计 ¥88.00");
await expect(itemsPanel.getByRole("link", { name: "查看报销单" })).toHaveAttribute("href", `/reimbursements/${reimbursementId}`);
const receipt = page.locator(".detail-evidence-card").filter({ hasText: "合成银行回单.pdf" });
await expect(receipt).toContainText("下载需说明用途");
await receipt.getByRole("button", { name: "在线查看" }).click();
await expect.poll(() => calls.previewPaymentReceipt).toBe(1);
const preview = page.getByTestId("payment-batch-receipt-preview-dialog");
await expect(preview).toBeVisible();
await expect(preview).toContainText("本次查看已自动写入审计记录");
await expect(preview.locator("iframe")).toHaveAttribute("title", /银行回单/);
await preview.getByRole("button", { name: "关闭" }).click();
await expect(preview).toBeHidden();
await receipt.getByRole("button", { name: "下载" }).click();
await expect(page.getByRole("dialog")).toContainText("填写下载用途");
});
test("历史差旅付款批次不冒充报销明细或生成错误报销入口", async ({ page }) => {
await mockSystemActionApis(page, { paymentEntityType: "travel_order" });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
const itemsPanel = page.getByTestId("payment-batch-items-panel");
await expect(itemsPanel).toContainText("本批次差旅订单");
await expect(itemsPanel.getByTestId("payment-batch-expense-items")).toHaveCount(0);
await expect(itemsPanel.getByRole("link", { name: "查看报销单" })).toHaveCount(0);
await expect(itemsPanel).toContainText("付款信息");
});
test("付款三阶段时间线覆盖五种状态并正确终止", async ({ page }) => {
const cases: Array<[SyntheticPaymentBatchStatus, string]> = [
["draft", "待复核"],
+2
查看文件
@@ -64,6 +64,8 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台
付款相关动作集中在 `付款` / 财务处理详情执行。报销、采购、合同、项目等列表和普通详情只显示付款状态、回单状态、`查看付款`、`补回单` 或进入付款记录入口,不直接放 `标记已付款`、`取消付款`、`批量付款`、`执行付款`;付款动作前必须核对金额、对象、线下付款说明、证据和回单。
付款批次详情以“报销单摘要 → 费用明细与小计 → 付款信息 → 付款结果/回单”为信息顺序,收款账户不得冒充报销明细。原报销单和付款批次之间只提供摘要与跳转,不复制对方完整流程。授权财务可在批次上下文内在线查看 PDF/图片回单并由系统自动记录固定用途;下载到本地仍需说明用途。报销处理进度中的付款批次入口只出现一次,并随实际付款节点展示。
跨模块关联采用“摘要 + 跳转”边界。项目、采购、合同、报销、资产、付款等页面可以展示关联对象的业务编号、名称、状态、金额/数量、负责人、证据完整性和下一步摘要,但不能复制对方模块的完整流程、审批动作、付款动作、发票/OCR 处理、合同正文下载或高敏附件操作;需要处理时跳转到归属模块详情、审批任务或财务处理页,并继续按原模块权限和审计控制。
全系统长表单默认支持草稿和清晰提交。保存草稿不触发审批、付款、发布或外部同步;提交前必须做业务校验和摘要确认;离开未保存页面要提示;错误提示必须定位到字段、明细、发票、附件或材料区,并保留用户已输入内容。移动端表单只保留必要字段、材料上传和底部主动作,不承接复杂配置表单。
+9
查看文件
@@ -131,6 +131,15 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
- 报销页只显示付款状态、回单状态、查看付款或补回单入口;标记已付款、取消付款、批量付款和执行付款必须进入付款/财务处理详情,不能放在报销列表或普通报销详情里直接执行。
- 报销页展示项目、采购、合同、资产候选或付款记录时只显示摘要和跳转,不复制对方模块的审批、付款、合同正文下载、资产建卡或高敏附件操作。
## 付款批次核对与银行回单
- 普通报销详情显示“报销事由”;差旅报销详情以“出差事由”为唯一主事由,不在同一区域重复显示普通报销事由。
- 一张报销进入付款批次后,处理进度中只显示一个“查看所在付款批次”入口:待复核时归在“付款复核”,复核后、付款失败、付款完成或复核后取消时归在“付款完成”;无付款批次读取权限的员工不显示入口。
- 付款批次详情按报销单分组,先显示报销单摘要,再显示费用类型、发生日期、说明、金额和明细小计,最后显示脱敏收款信息与付款结果。费用明细只用于复核,不改写冻结的批次付款金额;二者不一致时提示返回原报销单核对。
- 付款批次列表不加载费用明细、回单文件名或其他高敏材料,只在授权详情中按需读取。
- PDF、JPG、PNG、WEBP 银行回单可在付款批次详情中在线查看。在线查看由系统记录固定用途“付款批次内在线预览”并写审计,不要求用户重复填写。
- 回单下载到本地仍要求填写用途并写下载审计;治理标签使用“下载需说明用途”。在线查看和下载都必须重新检查权限、部门范围、作废/隔离状态、大小、存储对象和实际文件内容,不直接暴露存储路径。
## 差旅实报实销与支付证明
首笔差旅报销按“实报实销明细—正式票据—个人支付证明”和“包干补助—天数—单价”分别复核。
+90 -10
查看文件
@@ -83,6 +83,11 @@ const REASON_SCHEMA = z.object({
reason: z.string().trim().min(2).max(500),
}).strict();
const RECEIPT_FILE_QUERY_SCHEMA = z.object({
mode: z.enum(["download", "preview"]).default("download"),
reason: z.string().max(500).optional(),
}).strict();
const HIGH_RISK_BATCH_THRESHOLD_AMOUNT = 50000;
const PAYMENT_BATCH_DAILY_SEQUENCE_MAX = 999;
@@ -740,8 +745,45 @@ function paymentOrderExportBlockers(batch) {
return blockers;
}
async function loadReimbursementExpenseItems(tx, batch) {
if (batch.entity_type !== "reimbursement") return new Map();
const entityIds = Array.from(new Set((batch.items ?? []).map((item) => item.entity_id)));
if (!entityIds.length) return new Map();
const reimbursements = await tx.reimbursementForm.findMany({
where: { id: { in: entityIds } },
select: {
id: true,
items: {
orderBy: [{ expense_date: "asc" }, { id: "asc" }],
select: {
id: true,
expense_date: true,
description: true,
amount: true,
expense_type: { select: { name: true } },
},
},
},
});
return new Map(reimbursements.map((reimbursement) => {
const expenseItems = (reimbursement.items ?? []).map((item) => ({
id: item.id,
expenseTypeName: item.expense_type?.name ?? "未分类费用",
expenseDate: item.expense_date?.toISOString?.()?.slice(0, 10) ?? null,
description: item.description,
amount: toMoneyString(item.amount),
}));
return [reimbursement.id, {
expenseItems,
expenseItemsTotal: toMoneyString(expenseItems.reduce((sum, item) => sum + toMoneyNumber(item.amount), 0)),
}];
}));
}
function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options = {}) {
const includePayee = options.includePayee === true;
const includeExpenseItems = options.includeExpenseItems === true;
const expenseItemsByEntityId = options.expenseItemsByEntityId ?? new Map();
const batchRisk = summarizeBatchRisk(batch);
const riskMap = new Map();
for (const item of openRiskFlags) {
@@ -791,7 +833,11 @@ function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options =
const payee = includePayee
? readSnapshotPayee(item.snapshot_json) ?? legacyPayees.get(item.entity_id) ?? {}
: {};
return {
const expenseDetail = expenseItemsByEntityId.get(item.entity_id) ?? {
expenseItems: [],
expenseItemsTotal: "0.00",
};
const mappedItem = {
id: item.id,
entityId: item.entity_id,
entityCode: item.entity_code,
@@ -810,6 +856,12 @@ function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options =
createdAt: item.created_at?.toISOString?.() ?? null,
openRiskFlags: riskMap.get(item.entity_id) ?? [],
};
if (includeExpenseItems) {
mappedItem.expenseItems = expenseDetail.expenseItems;
mappedItem.expenseItemsTotal = expenseDetail.expenseItemsTotal;
mappedItem.expenseItemsAmountMatches = expenseDetail.expenseItemsTotal === toMoneyString(item.amount);
}
return mappedItem;
}),
};
}
@@ -853,8 +905,12 @@ function buildPaymentBatchProcessingProgress(batch, actorNames) {
};
}
async function mapBatchForResponse(tx, batch, openRiskFlags, config) {
const legacyPayees = await loadLegacyPayees(tx, batch, config);
async function mapBatchForResponse(tx, batch, openRiskFlags, config, options = {}) {
const includeExpenseItems = options.includeExpenseItems === true && batch.entity_type === "reimbursement";
const [legacyPayees, expenseItemsByEntityId] = await Promise.all([
loadLegacyPayees(tx, batch, config),
includeExpenseItems ? loadReimbursementExpenseItems(tx, batch) : Promise.resolve(new Map()),
]);
const actorIds = Array.from(new Set([
batch.created_by,
batch.reviewed_by,
@@ -869,7 +925,11 @@ async function mapBatchForResponse(tx, batch, openRiskFlags, config) {
: [];
const actorNames = new Map(actors.map((actor) => [actor.id, actor.name]));
return {
...mapBatch(batch, openRiskFlags, legacyPayees, { includePayee: true }),
...mapBatch(batch, openRiskFlags, legacyPayees, {
includePayee: true,
includeExpenseItems,
expenseItemsByEntityId,
}),
processingProgress: buildPaymentBatchProcessingProgress(batch, actorNames),
};
}
@@ -1620,7 +1680,9 @@ async function paymentBatchesRoutes(fastify) {
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, batch);
return sendData(reply, request, {
item: await mapBatchForResponse(fastify.prisma, batch, openRiskFlags, fastify.config),
item: await mapBatchForResponse(fastify.prisma, batch, openRiskFlags, fastify.config, {
includeExpenseItems: true,
}),
});
});
@@ -1907,6 +1969,9 @@ async function paymentBatchesRoutes(fastify) {
await ensureFeatureEnabled(fastify);
const batchId = parseId(request.params?.id);
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
const queryParsed = RECEIPT_FILE_QUERY_SCHEMA.safeParse(request.query ?? {});
if (!queryParsed.success) throw buildValidationError(queryParsed.error.flatten());
const previewMode = queryParsed.data.mode === "preview";
const batch = await loadBatchWithItems(fastify.prisma, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
const maxBytes = resolveReceiptAttachmentMaxBytes(fastify.config);
@@ -1935,8 +2000,10 @@ async function paymentBatchesRoutes(fastify) {
if (attachment.archived_at) {
throw buildValidationError({ receiptAttachment: "voided" }, 409);
}
const downloadReason = sanitizeDownloadReason(request.query?.reason);
if (requiresDownloadReason(attachment) && !downloadReason) {
const downloadReason = previewMode
? "付款批次内在线预览"
: sanitizeDownloadReason(queryParsed.data.reason);
if (!previewMode && requiresDownloadReason(attachment) && !downloadReason) {
throw buildValidationError({ reason: "required" });
}
@@ -2028,10 +2095,23 @@ async function paymentBatchesRoutes(fastify) {
throw buildReceiptAttachmentBlockedError(error.reasonCode);
}
const previewMimeTypes = new Set(["application/pdf", "image/jpeg", "image/png", "image/webp"]);
if (previewMode && !previewMimeTypes.has(validated.mimeType)) {
await auditBlockedReceiptDownload({
fastify,
request,
batchId,
attachment,
reasonCode: "preview_type_not_allowed",
downloadReason,
});
throw buildReceiptAttachmentBlockedError("preview_type_not_allowed");
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_RECEIPT_DOWNLOADED",
action: previewMode ? "PAYMENT_BATCH_RECEIPT_PREVIEWED" : "PAYMENT_BATCH_RECEIPT_DOWNLOADED",
targetType: "payment_batch",
targetId: batchId,
extra: {
@@ -2044,10 +2124,10 @@ async function paymentBatchesRoutes(fastify) {
}),
});
reply.type("application/octet-stream");
reply.type(previewMode ? validated.mimeType : "application/octet-stream");
reply.header(
"content-disposition",
buildAttachmentContentDisposition(validated.originalName),
buildAttachmentContentDisposition(validated.originalName, previewMode ? { disposition: "inline" } : undefined),
);
reply.header("x-content-type-options", "nosniff");
reply.header("cache-control", "private, no-store, max-age=0");
+6 -1
查看文件
@@ -549,9 +549,14 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
result: source.result,
comment: source.comment,
detail: source.detail,
targetHref: batchTargetHref,
targetHref: null,
});
}
if (batchTargetHref) {
const reviewOwnsBatchLink = ["current", "exception"].includes(batchReviewStage.state) &&
["pending", "terminated"].includes(paymentStage.state);
(reviewOwnsBatchLink ? batchReviewStage : paymentStage).targetHref = batchTargetHref;
}
}
if (!batch && reimbursement.status === "paid") {
@@ -56,6 +56,10 @@ const BANK_INSTITUTION_ID = "13131313-1313-4131-8131-131313131313";
const PAYMENT_ACCOUNT_ID = "14141414-1414-4141-8141-141414141414";
const TRAVEL_ORDER_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const SYNTHETIC_RECEIPT_PDF = Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\n%%EOF\n", "ascii");
const SYNTHETIC_RECEIPT_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
const SYNTHETIC_BANK_ACCOUNT = `6222${"0".repeat(8)}5678`;
@@ -168,6 +172,15 @@ function buildReimbursement(overrides = {}) {
reimbursement_date: new Date("2026-04-28T00:00:00.000Z"),
description: "研发报销",
total_amount: "1683.00",
items: [
{
id: "15151515-1515-4151-8151-151515151515",
expense_date: new Date("2026-04-27T00:00:00.000Z"),
description: "研发设备采购",
amount: "1683.00",
expense_type: { name: "办公设备" },
},
],
payment_voucher_no: null,
payment_date: null,
payment_channel: null,
@@ -652,6 +665,7 @@ describe("payment batch reimbursement routes", () => {
expect(response.statusCode).toBe(200);
expect(response.json().data.item.status).toBe("executed");
expect(response.json().data.item.items[0]).not.toHaveProperty("expenseItems");
expect(state.batch.status).toBe("executed");
expect(state.batch.items[0].status).toBe("executed");
expect(state.reimbursement.status).toBe("paid");
@@ -983,6 +997,7 @@ describe("payment batch reimbursement routes", () => {
bankAccountMasked: null,
});
expect(listResponse.json().data.items[0].items[0]).not.toHaveProperty("snapshotJson");
expect(listResponse.json().data.items[0].items[0]).not.toHaveProperty("expenseItems");
expect(listResponse.json().data.items[0]).not.toHaveProperty("processingProgress");
expect(tx.reimbursementForm.findMany).toHaveBeenCalledTimes(1);
@@ -997,6 +1012,14 @@ describe("payment batch reimbursement routes", () => {
payeeName: "测试员工",
bankName: "中国建设银行",
bankAccountMasked: "6222********5678",
expenseItemsTotal: "1683.00",
expenseItemsAmountMatches: true,
expenseItems: [expect.objectContaining({
expenseTypeName: "办公设备",
expenseDate: "2026-04-27",
description: "研发设备采购",
amount: "1683.00",
})],
});
expect(detailResponse.json().data.item.paymentOrderExportBlockers).toEqual(expect.arrayContaining([
{ code: "legacy_payment_account_snapshot_missing" },
@@ -1166,6 +1189,7 @@ describe("payment batch reimbursement routes", () => {
});
expect(response.statusCode).toBe(201);
expect(response.json().data.item.items[0]).not.toHaveProperty("expenseItems");
expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith(
tx,
expect.objectContaining({
@@ -1911,6 +1935,28 @@ describe("payment batch reimbursement routes", () => {
expect(missingReasonResponse.statusCode).toBe(400);
expect(missingReasonResponse.json().error.details).toEqual({ reason: "required" });
const previewResponse = await app.inject({
method: "GET",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}/file?mode=preview`,
headers: { authorization: "Bearer test" },
});
expect(previewResponse.statusCode).toBe(200);
expect(Buffer.from(previewResponse.rawPayload)).toEqual(SYNTHETIC_RECEIPT_PDF);
expect(previewResponse.headers["content-type"]).toContain("application/pdf");
expect(previewResponse.headers["content-disposition"]).toContain("inline;");
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "PAYMENT_BATCH_RECEIPT_PREVIEWED",
target_id: BATCH_ID,
extra: expect.objectContaining({
attachmentId,
reason: "付款批次内在线预览",
}),
}),
}),
);
const downloadResponse = await app.inject({
method: "GET",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}/file?reason=付款复核`,
@@ -1977,6 +2023,52 @@ describe("payment batch reimbursement routes", () => {
},
);
it("previews a validated image receipt inline with the fixed audit purpose", async () => {
const { app, prisma, tempDir } = await buildApp({ receiptAttachments: [] });
try {
const requestPayload = buildMultipartRequest({
files: [{
filename: "bank-receipt.png",
contentType: "image/png",
content: SYNTHETIC_RECEIPT_PNG,
}],
});
const uploadResponse = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`,
headers: { authorization: "Bearer test", ...requestPayload.headers },
payload: requestPayload.payload,
});
expect(uploadResponse.statusCode).toBe(200);
const attachmentId = uploadResponse.json().data.items[0].id;
const previewResponse = await app.inject({
method: "GET",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}/file?mode=preview`,
headers: { authorization: "Bearer test" },
});
expect(previewResponse.statusCode).toBe(200);
expect(previewResponse.headers["content-type"]).toContain("image/png");
expect(previewResponse.headers["content-disposition"]).toContain("inline;");
expect(Buffer.from(previewResponse.rawPayload).subarray(0, 8)).toEqual(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
);
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "PAYMENT_BATCH_RECEIPT_PREVIEWED",
extra: expect.objectContaining({ reason: "付款批次内在线预览" }),
}),
}),
);
} finally {
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it.each(["draft", "executed", "cancelled"])(
"rejects receipt upload and void in %s without attachment side effects",
async (status) => {
@@ -688,6 +688,58 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
await app.close();
});
it("attaches the payment-batch link to exactly one status-owning stage", async () => {
const cases = [
{ status: "draft", reviewedAt: null, targetIndex: 3 },
{ status: "reviewed", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 4 },
{ status: "executed", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 4 },
{ status: "failed", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 4 },
{ status: "cancelled", reviewedAt: null, targetIndex: 3 },
{ status: "cancelled", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 4 },
];
for (const item of cases) {
const { app } = await buildApp({
permissionCodes: ["expense.view_own", "payment.batch.review"],
paymentBatchItem: {
id: "batch-item-1",
payment_batch: {
id: "batch-1",
status: item.status,
entity_type: "reimbursement",
default_payment_channel: "bank",
payment_order_export_count: 0,
created_at: new Date("2026-04-11T07:00:00.000Z"),
created_by: "maker-1",
reviewed_at: item.reviewedAt,
reviewed_by: item.reviewedAt ? "reviewer-1" : null,
executed_at: ["executed", "failed"].includes(item.status) ? new Date("2026-04-12T08:00:00.000Z") : null,
executed_by: ["executed", "failed"].includes(item.status) ? "executor-1" : null,
cancelled_at: item.status === "cancelled" ? new Date("2026-04-11T09:00:00.000Z") : null,
cancelled_by: item.status === "cancelled" ? "reviewer-1" : null,
cancelled_reason: item.status === "cancelled" ? "批次资料需调整" : null,
items: [{ status: item.status === "executed" ? "executed" : "pending", payment_channel_override: null }],
_count: { receipt_attachments: item.status === "executed" ? 1 : 0 },
},
},
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
const stages = response.json().data.processingProgress.stages;
const linkedStageIndexes = stages
.map((stage, index) => stage.targetHref ? index : -1)
.filter((index) => index >= 0);
expect(linkedStageIndexes).toEqual([item.targetIndex]);
expect(stages[item.targetIndex].targetHref).toBe("/payment-batches?id=batch-1");
await app.close();
}
});
it("marks finance batch creation as the current responsibility after workflow approval", async () => {
const { app } = await buildApp({
reimbursement: buildReimbursementRecord({ status: "approved", workflow_instances: [] }),