feat(reimbursement-small-team-form-validation): 完善付款批次核对与回单预览
这个提交包含在:
@@ -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
|
||||
});
|
||||
```
|
||||
|
||||
@@ -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,166 @@
|
||||
# 小团队报销填报与校验完善:技术设计
|
||||
|
||||
## 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 硬件三类合成记录,不使用真实票据内容。
|
||||
|
||||
## 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;第二回滚点是付款详情费用明细;第三回滚点是两个纯展示条件。回滚均不修改付款、报销或附件数据。
|
||||
- 公网检查只使用脱敏合成文件或既有授权测试材料摘要,不在截图、日志或任务文档保留真实回单和银行信息。
|
||||
@@ -0,0 +1,129 @@
|
||||
# 小团队报销填报与校验完善:实施计划
|
||||
|
||||
## 已交付基线(2026-08-04)
|
||||
|
||||
以下 1–6 节已由原任务实施、验证和部署;本次恢复任务后只需回归,不重复开发。
|
||||
|
||||
## 1. Backend contracts and focused tests
|
||||
|
||||
- [x] 在配置层增加并测试 `REIMBURSEMENT_MAX_AGE_DAYS=180|365`,默认 365,非法配置启动失败。
|
||||
- [x] 在报销预检服务增加证据摘要、时效检查和资产提示,复用 date-only/金额工具。
|
||||
- [x] 为默认期限、180 天、阈值边界、跨年度、未关联发票、整单附件和 `assets` 类别补单测。
|
||||
|
||||
## 2. Authoritative amount validation
|
||||
|
||||
- [x] 收紧手工 item POST/PATCH:金额必须大于零,返回可定位错误。
|
||||
- [x] submit 行锁事务读取明细,拒绝空明细和非正金额明细。
|
||||
- [x] submit 在预算与审批规则前重新汇总并使用权威总额。
|
||||
- [x] 补充空单、零金额模板草稿、修改后提交、陈旧总额修复和审批金额选择回归测试。
|
||||
|
||||
## 3. Shared frontend API and header editing
|
||||
|
||||
- [x] 扩展 reimbursement API 类型与通用 PATCH 函数。
|
||||
- [x] PC 草稿/驳回详情增加报销事由和日期编辑。
|
||||
- [x] 手机草稿/驳回详情增加适合小屏的同等编辑能力。
|
||||
- [x] 保存后刷新 detail/list/precheck,并验证非编辑状态和无权限用户只读。
|
||||
|
||||
## 4. Totals, evidence and asset hints
|
||||
|
||||
- [x] PC 明细表尾增加条数与报销总额,概况标签改为“报销总额”。
|
||||
- [x] 手机明细列表尾增加条数与报销总额。
|
||||
- [x] PC/手机展示明细发票关联状态、整单附件数、时效警告和资产人工建卡提醒。
|
||||
- [x] 资产入口沿用现有权限;打印页合计不回归。
|
||||
|
||||
## 5. Validation and review gates
|
||||
|
||||
- [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
|
||||
|
||||
- [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)
|
||||
|
||||
- 服务端聚焦回归 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 个本任务引起的环境键计数已修正并复跑通过。
|
||||
- 已完成主会话只读复核,修正“已完成报销不显示资产提醒”和“超期且跨年只显示一个提示”两处跨层遗漏。
|
||||
- 未执行生产部署、生产配置写入或公网数据操作;如需线上测试,须按项目规则另行取得明确部署授权。
|
||||
|
||||
## 部署续办(2026-08-04,已授权)
|
||||
|
||||
- [x] 确认工作提交已发布到部署入口要求的上游分支,并执行已有部署只读预检。
|
||||
- [x] 按 `deploy-existing.sh` 发布已批准提交,不同步或覆盖生产 `.env`,不执行真实业务写入。
|
||||
- [x] 验证活动服务 build、`/ready`、公网只读 smoke、PC/手机关键页面及 180/365 配置生效状态。
|
||||
- [x] 记录脱敏结果与回滚边界;如应用发布成功但备份合同刷新失败,按既有部分成功分类处理,不重复部署应用。
|
||||
|
||||
### 部署结果
|
||||
|
||||
- 发布提交为 `3aebfc5e046d`;Git 来源已发布,部署前和部署后远端合同均为 13/13 PASS。
|
||||
- 生产 `.env` 未同步或覆盖;数据库无待执行迁移,初始化只保留既有配置和账号口令,不执行真实报销、审批或付款写入。
|
||||
- 应用、外部依赖、运行时功能开关、公网 build 标识和前端资源文案扫描通过;备份合同刷新完整通过,无部分成功状态。
|
||||
- 公网 `/ready` 返回当前 build;生产运行时 `REIMBURSEMENT_MAX_AGE_DAYS` 生效值为默认 365,业务时区已配置。
|
||||
- 公网基础 Playwright smoke 按企微 `ready` 状态复跑通过。管理员/员工登录态 smoke 因本机未配置专用 `LIVE_SMOKE_*` 凭据未执行;未读取、复制或输出生产账号密码。
|
||||
- PC/手机报销关键交互已由发布前合成 API 浏览器回归覆盖;公网部署后的对应前端资源已命中当前 build 并通过扫描。回滚继续使用既有部署入口切换到上一已发布提交,不修改数据库结构或生产环境文件。
|
||||
+48
-13
@@ -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/图片在线查看及用途/审计/隔离回归。
|
||||
+3
-3
@@ -2,8 +2,8 @@
|
||||
"id": "reimbursement-small-team-form-validation",
|
||||
"name": "reimbursement-small-team-form-validation",
|
||||
"title": "小团队报销填报与校验完善",
|
||||
"description": "完善小团队内部报销的明细合计、正金额校验、事由编辑、票据时效提示、材料完整性提示和资产登记提醒。",
|
||||
"status": "completed",
|
||||
"description": "完善小团队内部报销填报,并复核报销详情、付款批次和银行回单之间的信息展示与交互。",
|
||||
"status": "in_progress",
|
||||
"dev_type": "feature",
|
||||
"scope": "server/client/docs/tests",
|
||||
"package": null,
|
||||
@@ -22,5 +22,5 @@
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {},
|
||||
"completedAt": "2026-08-04"
|
||||
"completedAt": null
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
# 小团队报销填报与校验完善:技术设计
|
||||
|
||||
## 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 硬件三类合成记录,不使用真实票据内容。
|
||||
@@ -1,78 +0,0 @@
|
||||
# 小团队报销填报与校验完善:实施计划
|
||||
|
||||
## 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 个本任务引起的环境键计数已修正并复跑通过。
|
||||
- 已完成主会话只读复核,修正“已完成报销不显示资产提醒”和“超期且跨年只显示一个提示”两处跨层遗漏。
|
||||
- 未执行生产部署、生产配置写入或公网数据操作;如需线上测试,须按项目规则另行取得明确部署授权。
|
||||
|
||||
## 部署续办(2026-08-04,已授权)
|
||||
|
||||
- [x] 确认工作提交已发布到部署入口要求的上游分支,并执行已有部署只读预检。
|
||||
- [x] 按 `deploy-existing.sh` 发布已批准提交,不同步或覆盖生产 `.env`,不执行真实业务写入。
|
||||
- [x] 验证活动服务 build、`/ready`、公网只读 smoke、PC/手机关键页面及 180/365 配置生效状态。
|
||||
- [x] 记录脱敏结果与回滚边界;如应用发布成功但备份合同刷新失败,按既有部分成功分类处理,不重复部署应用。
|
||||
|
||||
### 部署结果
|
||||
|
||||
- 发布提交为 `3aebfc5e046d`;Git 来源已发布,部署前和部署后远端合同均为 13/13 PASS。
|
||||
- 生产 `.env` 未同步或覆盖;数据库无待执行迁移,初始化只保留既有配置和账号口令,不执行真实报销、审批或付款写入。
|
||||
- 应用、外部依赖、运行时功能开关、公网 build 标识和前端资源文案扫描通过;备份合同刷新完整通过,无部分成功状态。
|
||||
- 公网 `/ready` 返回当前 build;生产运行时 `REIMBURSEMENT_MAX_AGE_DAYS` 生效值为默认 365,业务时区已配置。
|
||||
- 公网基础 Playwright smoke 按企微 `ready` 状态复跑通过。管理员/员工登录态 smoke 因本机未配置专用 `LIVE_SMOKE_*` 凭据未执行;未读取、复制或输出生产账号密码。
|
||||
- PC/手机报销关键交互已由发布前合成 API 浏览器回归覆盖;公网部署后的对应前端资源已命中当前 build 并通过扫描。回滚继续使用既有部署入口切换到上一已发布提交,不修改数据库结构或生产环境文件。
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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", "待复核"],
|
||||
|
||||
@@ -64,6 +64,8 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台
|
||||
|
||||
付款相关动作集中在 `付款` / 财务处理详情执行。报销、采购、合同、项目等列表和普通详情只显示付款状态、回单状态、`查看付款`、`补回单` 或进入付款记录入口,不直接放 `标记已付款`、`取消付款`、`批量付款`、`执行付款`;付款动作前必须核对金额、对象、线下付款说明、证据和回单。
|
||||
|
||||
付款批次详情以“报销单摘要 → 费用明细与小计 → 付款信息 → 付款结果/回单”为信息顺序,收款账户不得冒充报销明细。原报销单和付款批次之间只提供摘要与跳转,不复制对方完整流程。授权财务可在批次上下文内在线查看 PDF/图片回单并由系统自动记录固定用途;下载到本地仍需说明用途。报销处理进度中的付款批次入口只出现一次,并随实际付款节点展示。
|
||||
|
||||
跨模块关联采用“摘要 + 跳转”边界。项目、采购、合同、报销、资产、付款等页面可以展示关联对象的业务编号、名称、状态、金额/数量、负责人、证据完整性和下一步摘要,但不能复制对方模块的完整流程、审批动作、付款动作、发票/OCR 处理、合同正文下载或高敏附件操作;需要处理时跳转到归属模块详情、审批任务或财务处理页,并继续按原模块权限和审计控制。
|
||||
|
||||
全系统长表单默认支持草稿和清晰提交。保存草稿不触发审批、付款、发布或外部同步;提交前必须做业务校验和摘要确认;离开未保存页面要提示;错误提示必须定位到字段、明细、发票、附件或材料区,并保留用户已输入内容。移动端表单只保留必要字段、材料上传和底部主动作,不承接复杂配置表单。
|
||||
|
||||
@@ -131,6 +131,15 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
|
||||
- 报销页只显示付款状态、回单状态、查看付款或补回单入口;标记已付款、取消付款、批量付款和执行付款必须进入付款/财务处理详情,不能放在报销列表或普通报销详情里直接执行。
|
||||
- 报销页展示项目、采购、合同、资产候选或付款记录时只显示摘要和跳转,不复制对方模块的审批、付款、合同正文下载、资产建卡或高敏附件操作。
|
||||
|
||||
## 付款批次核对与银行回单
|
||||
|
||||
- 普通报销详情显示“报销事由”;差旅报销详情以“出差事由”为唯一主事由,不在同一区域重复显示普通报销事由。
|
||||
- 一张报销进入付款批次后,处理进度中只显示一个“查看所在付款批次”入口:待复核时归在“付款复核”,复核后、付款失败、付款完成或复核后取消时归在“付款完成”;无付款批次读取权限的员工不显示入口。
|
||||
- 付款批次详情按报销单分组,先显示报销单摘要,再显示费用类型、发生日期、说明、金额和明细小计,最后显示脱敏收款信息与付款结果。费用明细只用于复核,不改写冻结的批次付款金额;二者不一致时提示返回原报销单核对。
|
||||
- 付款批次列表不加载费用明细、回单文件名或其他高敏材料,只在授权详情中按需读取。
|
||||
- PDF、JPG、PNG、WEBP 银行回单可在付款批次详情中在线查看。在线查看由系统记录固定用途“付款批次内在线预览”并写审计,不要求用户重复填写。
|
||||
- 回单下载到本地仍要求填写用途并写下载审计;治理标签使用“下载需说明用途”。在线查看和下载都必须重新检查权限、部门范围、作废/隔离状态、大小、存储对象和实际文件内容,不直接暴露存储路径。
|
||||
|
||||
## 差旅实报实销与支付证明
|
||||
|
||||
首笔差旅报销按“实报实销明细—正式票据—个人支付证明”和“包干补助—天数—单价”分别复核。
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: [] }),
|
||||
|
||||
在新工单中引用
屏蔽一个用户