feat(reimbursement-small-team-form-validation): 完善小团队报销填报校验
这个提交包含在:
@@ -780,6 +780,78 @@ extra = {
|
||||
};
|
||||
```
|
||||
|
||||
## Scenario: Small-Team Reimbursement Form And Submit Validation
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: changing ordinary reimbursement item editing, total display, submit validation, expense-date warnings, evidence summary or asset-category hints.
|
||||
- This is an internal 5–10 person workflow. Do not turn these rules into a configurable policy center or automatic accounting/asset platform.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- Runtime env: `REIMBURSEMENT_MAX_AGE_DAYS=180|365`, default `365`.
|
||||
- Header edit: `PATCH /api/reimbursements/:id` accepts `reimbursementDate` and nullable `description` for participant-owned `draft|rejected` forms.
|
||||
- Item edit: `POST /api/reimbursements/:id/items` and `PATCH /api/reimbursements/:id/items/:itemId` require decimal `amount > 0`.
|
||||
- Submit: `POST /api/reimbursements/:id/submit` locks the reimbursement, reads every item, validates amounts and persists the recomputed `total_amount` before budget and workflow resolution.
|
||||
- Reimbursement precheck adds `evidenceSummary`, `timelinessChecks` and `assetHint`; these operational fields remain active when the budget-precheck feature flag is off.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- PC, mobile and print display the server `totalAmount`; clients never calculate or submit an editable form total.
|
||||
- A template or travel conversion may keep zero-value placeholders in a draft, but submission requires at least one item and every item amount strictly greater than zero.
|
||||
- Date age uses `APP_TIMEZONE` business date plus date-only UTC arithmetic. Exactly 180/365 days is within policy; the first later day is overdue.
|
||||
- Overdue and cross-calendar-year are independent non-blocking warnings. One item may carry both flags and both user messages.
|
||||
- Evidence summary counts bound invoices, whole-form supporting attachments and bound invoices per item. An unlinked item warns but does not add a new block because allowances and whole-form evidence remain valid cases.
|
||||
- `expense_type.category=assets` returns only candidate count and manual-registration guidance. It never creates an asset card; the `/assets` link remains permission-gated.
|
||||
- Do not add a “历史费用补报” field, badge or process. Age and calendar-year facts provide the traceable signal.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Unsupported `REIMBURSEMENT_MAX_AGE_DAYS` -> application startup fails with `Invalid REIMBURSEMENT_MAX_AGE_DAYS`.
|
||||
- Manual item amount `<= 0` -> `400 VALID_001 / amount=must_be_positive`.
|
||||
- Submit with zero items -> `400 VALID_001 / items=at_least_one_required`.
|
||||
- Submit with any item amount `<= 0` -> `400 VALID_001 / items=positive_amount_required` plus affected item IDs.
|
||||
- Expense date older than policy or in another calendar year -> precheck `decision=warn`; submission remains possible after confirmation.
|
||||
- Asset candidate without asset permission -> show manual next step only, never the asset-module link or asset data.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: three positive items display one server total; submit repairs a stale stored total before selecting the approval rule.
|
||||
- Good: a 366-day-old asset item under the 365-day policy shows overdue, cross-year, evidence and manual-asset warnings without creating an asset.
|
||||
- Base: a current office-supply item with one linked invoice passes existing gates and displays its linked count.
|
||||
- Bad: sum item amounts in React, trust a stale `ReimbursementForm.total_amount` during submit, block all old invoices, or auto-create asset cards.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Config tests cover default 365, allowed 180/365 and unsupported values.
|
||||
- Service tests freeze the business time and assert threshold-day, first-overdue-day, cross-year, per-item invoice count, attachment count and asset candidate behavior.
|
||||
- Route tests assert empty/non-positive submit errors and that the recomputed amount reaches approval-rule selection.
|
||||
- Frontend typecheck/build plus PC/mobile Playwright assert header editing, item count, server total, evidence wording and absence of internal terms.
|
||||
- Run Prisma validation, reimbursement focused regression, process/docs/Trellis gates and `git diff --check`.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```js
|
||||
const total = items.reduce((sum, item) => sum + Number(item.amount), 0);
|
||||
await selectApprovalRule(reimbursement.total_amount); // may be stale
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```js
|
||||
const items = await tx.reimbursementItem.findMany({
|
||||
where: { reimbursement_id: reimbursementId },
|
||||
select: { id: true, amount: true },
|
||||
});
|
||||
if (!items.length || items.some((item) => Number(item.amount) <= 0)) {
|
||||
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400 });
|
||||
}
|
||||
reimbursement.total_amount = await recomputeTotalAmount(tx, reimbursementId, actorId);
|
||||
await selectApprovalRule(reimbursement.total_amount);
|
||||
```
|
||||
|
||||
## Scenario: Payment Batch Receipt Attention Count
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
@@ -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,112 @@
|
||||
# 小团队报销填报与校验完善:技术设计
|
||||
|
||||
## 1. Design boundary
|
||||
|
||||
本任务在现有报销详情和预检链路内增量实现,不新增数据库表,不建设通用规则引擎,不改变审批、付款和资产状态机。
|
||||
|
||||
核心数据流:
|
||||
|
||||
```text
|
||||
明细/发票/附件数据库事实
|
||||
-> reimbursement precheck 聚合
|
||||
-> GET detail / POST precheck API
|
||||
-> PC 与手机详情呈现
|
||||
-> submit 事务重新汇总
|
||||
-> 现有审批规则、预算与工作流
|
||||
```
|
||||
|
||||
服务端负责金额、日期和证据状态的权威判断;前端只负责输入约束、可理解呈现和刷新。
|
||||
|
||||
## 2. Contracts
|
||||
|
||||
### 2.1 Runtime configuration
|
||||
|
||||
- 在 `server/src/config.js` 增加 `reimbursementMaxAgeDays`。
|
||||
- 环境变量:`REIMBURSEMENT_MAX_AGE_DAYS`。
|
||||
- 默认:`365`;允许:`180 | 365`;非法值在启动时失败,不静默回退。
|
||||
- 配置只影响警告阈值,不改变数据、审批或付款状态。
|
||||
|
||||
### 2.2 Reimbursement precheck extension
|
||||
|
||||
在现有预检响应中追加向后兼容字段:
|
||||
|
||||
```ts
|
||||
type ReimbursementEvidenceSummary = {
|
||||
invoiceCount: number;
|
||||
attachmentCount: number;
|
||||
items: Array<{
|
||||
itemId: string;
|
||||
linkedInvoiceCount: number;
|
||||
invoiceState: "linked" | "unlinked";
|
||||
}>;
|
||||
};
|
||||
|
||||
type ReimbursementTimelinessCheck = {
|
||||
itemId: string;
|
||||
expenseDate: string;
|
||||
ageDays: number;
|
||||
maxAgeDays: 180 | 365;
|
||||
overdue: boolean;
|
||||
crossCalendarYear: boolean;
|
||||
};
|
||||
|
||||
type ReimbursementAssetHint = {
|
||||
required: boolean;
|
||||
itemCount: number;
|
||||
};
|
||||
```
|
||||
|
||||
- 新字段缺失时旧客户端继续使用既有预检字段。
|
||||
- 超期、跨年度、明细未关联发票和资产候选通过现有 `warnings`/`nextActions` 合并,并保留结构化摘要供 UI 定位。
|
||||
- 明细未关联发票是警告而非新阻断,因为当前存在整单发票、OCR 未完成和合法无票差旅补助等兼容场景。
|
||||
|
||||
### 2.3 Amount validation
|
||||
|
||||
- 手工 item POST/PATCH 将 `amount=0` 视为无效。
|
||||
- 模板/差旅创建仍可内部写入零金额草稿,不经过手工 item POST 门禁。
|
||||
- submit 在同一事务和行锁内读取所有明细:无明细或任一金额 `<= 0` 时返回定位明确的校验错误。
|
||||
- 校验通过后调用现有重算逻辑,将返回值写回本次提交上下文;后续预算和审批规则只使用该值。
|
||||
- 不依赖前端合计,不接受客户端提交整单总额。
|
||||
|
||||
### 2.4 Header editing
|
||||
|
||||
- 扩展前端 API 为通用 `updateReimbursement`,旅行摘要函数可继续包装或迁移到该函数,避免重复 HTTP 合同。
|
||||
- PC 与手机复用小型编辑块:报销日期、报销事由、保存、取消。
|
||||
- 保存成功后刷新 detail、list 和 precheck 查询;使用现有未保存编辑保护模式,避免刷新丢失。
|
||||
|
||||
### 2.5 Totals and evidence UI
|
||||
|
||||
- PC 表格 footer 显示条数与 `detail.totalAmount`;手机列表尾显示相同事实。
|
||||
- 概况标签统一为“报销总额”。打印页保留现有合计行。
|
||||
- 证据提示在明细与材料区使用同一预检摘要,不在前端重新推导业务规则。
|
||||
- 当 OCR off 或发票金额为空时,不显示误导性的“发票金额差额”;本任务只展示数量和关联状态。
|
||||
|
||||
### 2.6 Asset reminder
|
||||
|
||||
- 服务端基于明细费用类型 `category=assets` 生成结构化提示。
|
||||
- UI 只显示候选数量、人工核对说明和授权后的资产模块入口。
|
||||
- 不调用资产创建 API,不保存报销侧资产状态。
|
||||
|
||||
## 3. Compatibility and migration
|
||||
|
||||
- 无 Prisma schema 或数据库迁移。
|
||||
- API 仅新增可选响应字段并收紧非法普通报销提交,现有合法报销兼容。
|
||||
- 历史草稿中的零金额明细可以继续查看和修改,但必须修正后才能提交。
|
||||
- 已提交、已通过和已付款记录保持只读,不回填时效或资产状态。
|
||||
- 现有附件仍为整单材料,现有发票绑定、OCR、查重和连续预览不变。
|
||||
|
||||
## 4. Risks and mitigations
|
||||
|
||||
- **日期偏移**:使用既有 date-only 工具和 `APP_TIMEZONE`,测试阈值当天、闰年和跨年边界。
|
||||
- **预检字段重复计算**:在服务端一次加载报销证据上下文,避免 PC/手机各自实现规则。
|
||||
- **模板回归**:只在手工明细接口和提交阶段收紧正金额,保留模板草稿零金额。
|
||||
- **既有未绑定发票**:只警告不阻断,保持现有整单至少一张发票门禁。
|
||||
- **资产权限泄露**:资产入口按现有权限决定,预检不返回资产卡或敏感字段。
|
||||
- **提交总额竞争**:继续使用报销行锁,并在同一事务内重算后再做预算和审批规则选择。
|
||||
|
||||
## 5. Rollout and rollback
|
||||
|
||||
- 部署前明确 `REIMBURSEMENT_MAX_AGE_DAYS`;缺省为 365。
|
||||
- 先部署服务端兼容字段和校验,再部署前端展示;旧前端可继续工作。
|
||||
- 回滚前端不会破坏服务端数据;回滚服务端仅移除新提示和正金额收紧,无迁移回滚。
|
||||
- 公网验收覆盖普通办公用品、宽带/技术服务、IT 硬件三类合成记录,不使用真实票据内容。
|
||||
@@ -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,62 @@
|
||||
# 小团队报销填报与校验完善:实施计划
|
||||
|
||||
## 1. Backend contracts and focused tests
|
||||
|
||||
- [ ] 在配置层增加并测试 `REIMBURSEMENT_MAX_AGE_DAYS=180|365`,默认 365,非法配置启动失败。
|
||||
- [ ] 在报销预检服务增加证据摘要、时效检查和资产提示,复用 date-only/金额工具。
|
||||
- [ ] 为默认期限、180 天、阈值边界、跨年度、未关联发票、整单附件和 `assets` 类别补单测。
|
||||
|
||||
## 2. Authoritative amount validation
|
||||
|
||||
- [ ] 收紧手工 item POST/PATCH:金额必须大于零,返回可定位错误。
|
||||
- [ ] submit 行锁事务读取明细,拒绝空明细和非正金额明细。
|
||||
- [ ] submit 在预算与审批规则前重新汇总并使用权威总额。
|
||||
- [ ] 补充空单、零金额模板草稿、修改后提交、陈旧总额修复和审批金额选择回归测试。
|
||||
|
||||
## 3. Shared frontend API and header editing
|
||||
|
||||
- [ ] 扩展 reimbursement API 类型与通用 PATCH 函数。
|
||||
- [ ] PC 草稿/驳回详情增加报销事由和日期编辑。
|
||||
- [ ] 手机草稿/驳回详情增加适合小屏的同等编辑能力。
|
||||
- [ ] 保存后刷新 detail/list/precheck,并验证非编辑状态和无权限用户只读。
|
||||
|
||||
## 4. Totals, evidence and asset hints
|
||||
|
||||
- [ ] PC 明细表尾增加条数与报销总额,概况标签改为“报销总额”。
|
||||
- [ ] 手机明细列表尾增加条数与报销总额。
|
||||
- [ ] PC/手机展示明细发票关联状态、整单附件数、时效警告和资产人工建卡提醒。
|
||||
- [ ] 资产入口沿用现有权限;打印页合计不回归。
|
||||
|
||||
## 5. Validation and review gates
|
||||
|
||||
- [ ] `cd server && npm run prisma:validate`
|
||||
- [ ] `cd server && node --check src/config.js && node --check src/services/feeControl.js && node --check src/routes/reimbursements.js`
|
||||
- [ ] 运行本任务新增的后端 Vitest 及现有 reimbursement detail/create/expense-standard 聚焦回归。
|
||||
- [ ] `cd client && npm run typecheck && npm run build`
|
||||
- [ ] 运行 PC 与 mobile/wecom 报销详情 E2E,覆盖合计、编辑、时效、证据和资产提醒。
|
||||
- [ ] 运行 `node scripts/qa/check-process-contract.mjs --baseline --json`。
|
||||
- [ ] 运行 `node scripts/qa/audit-docs.mjs --check-registry` 和 `node scripts/qa/check-trellis-governance.mjs`。
|
||||
- [ ] `git diff --check`
|
||||
- [ ] 因提交校验和跨层预检发生变化,实施后进行一次只读 review,重点检查金额竞争、模板兼容和权限边界。
|
||||
|
||||
## 6. Documentation, deployment and rollback
|
||||
|
||||
- [ ] 仅在形成稳定规则时更新现有报销事实源或 Trellis spec,不新建平行文档。
|
||||
- [ ] 记录环境配置默认值、生产取值和回滚方式,不记录 Secret 或真实票据。
|
||||
- [ ] 用户批准后启动任务;实现、检查、必要 spec 更新完成后使用中文 commit,并引用任务名。
|
||||
- [ ] 若需用户线上测试,完成部署、公网健康检查和合成数据关键路径冒烟后再通知。
|
||||
|
||||
## Rollback points
|
||||
|
||||
- 配置/预检字段为第一回滚点:可独立移除,不影响数据库。
|
||||
- 正金额提交门禁为第二回滚点:若发现合法流程被误阻断,先回滚该提交校验,不绕过发票/权限门禁。
|
||||
- 前端编辑与提示为第三回滚点:可回滚 UI,服务端新增字段保持向后兼容。
|
||||
|
||||
## 执行记录(2026-08-04)
|
||||
|
||||
- 服务端聚焦回归 9 个文件、86 个用例全部通过;Prisma 与所改 Node 文件语法检查通过。
|
||||
- 前端 `typecheck`、生产构建通过;R102 合成 API 浏览器回归覆盖 PC/手机合计、事由日期编辑和材料提示,原 5 个用例通过,新增/修正用例单独复跑通过。
|
||||
- 任务、环境契约、流程合同、文档注册、Trellis 治理、敏感内容和 `git diff --check` 均通过;文档注册保留既有 22 个未注册文件 warning,无新增错误。
|
||||
- 全量后端 1228 个用例中 1224 个通过;3 个既有付款 BIFF8 导出用例因本机未安装 `server/requirements/payment-order-biff8.txt` 固定 Python 依赖失败,另 1 个本任务引起的环境键计数已修正并复跑通过。
|
||||
- 已完成主会话只读复核,修正“已完成报销不显示资产提醒”和“超期且跨年只显示一个提示”两处跨层遗漏。
|
||||
- 未执行生产部署、生产配置写入或公网数据操作;如需线上测试,须按项目规则另行取得明确部署授权。
|
||||
@@ -0,0 +1,96 @@
|
||||
# 小团队报销填报与校验完善
|
||||
|
||||
## Goal
|
||||
|
||||
面向公司内部 5–10 人使用场景,在不扩展为通用费控产品的前提下,完善普通报销填报、提交预检和证据提示,使员工容易填写、审批人看得懂、财务能够核对、付款后能够追溯。
|
||||
|
||||
## Background and confirmed facts
|
||||
|
||||
- 系统已有报销单、明细、PDF 发票、补充附件、审批、付款批次和资产台账主链路。
|
||||
- `ReimbursementForm.total_amount` 已由明细新增、修改、删除操作自动重算;打印页已有明细合计,但 PC 和手机详情的明细区域没有就地合计。
|
||||
- 普通报销 PATCH 已支持修改 `reimbursementDate` 和 `description`,前端目前没有通用的事由/日期编辑入口。
|
||||
- 当前提交只校验总金额非负和整单至少一张发票,不能在服务端可靠阻止空明细、零金额明细或陈旧总额进入审批。
|
||||
- 发票已有可选明细关联;补充附件当前属于整单。本任务不引入发票多对多关系或附件明细关联。
|
||||
- 费用类型已有 `assets` 类别;资产事实源要求“资产候选 + 管理员人工建卡”,不得因报销提交、审批或付款自动生成资产卡。
|
||||
- 本轮讨论已决定不增加“历史费用补报”标识。票据新旧由费用日期、发票日期和提交日期推导。
|
||||
|
||||
## Scope
|
||||
|
||||
- 报销详情 PC、手机的明细合计和普通报销事由/日期编辑。
|
||||
- 报销明细正金额校验、提交时权威总额重算。
|
||||
- 180/365 天票据时效配置及超期、跨年度非阻断提示。
|
||||
- 明细发票关联、整单附件数量的材料完整性提示。
|
||||
- `assets` 类别的人工资产建卡提醒和授权入口。
|
||||
- 所需的前后端类型、聚焦测试、现有事实源/spec 更新及部署配置说明。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1 明细合计就地展示
|
||||
|
||||
- PC 报销明细表尾显示明细数量和报销合计。
|
||||
- 手机报销明细卡片列表底部显示明细数量和报销合计。
|
||||
- 概况区将含糊的“金额”统一为“报销总额”或“合计金额”。
|
||||
- 展示使用服务端返回的 `totalAmount`,不增加可编辑总额字段。
|
||||
|
||||
### R2 普通报销正金额与提交一致性
|
||||
|
||||
- 员工手工新增或修改普通报销明细时,金额必须大于零。
|
||||
- 提交时服务端必须确认至少一条明细,且不存在零金额或负金额明细。
|
||||
- 提交事务内重新按明细汇总并持久化总金额,审批规则、预算检查和工作流快照使用该权威金额。
|
||||
- 差旅申请或模板创建阶段产生的零金额占位明细仍可保存为草稿,但补齐正金额前不得提交。
|
||||
- 错误返回可定位到明细或总金额,前端保留已填内容并给出中文提示。
|
||||
|
||||
### R3 普通报销事由和报销日期编辑
|
||||
|
||||
- 草稿和驳回状态的参与人可在 PC、手机详情编辑“报销事由”和“报销日期”。
|
||||
- 复用现有报销 PATCH 和权限/状态约束,不新增独立保存接口。
|
||||
- 事由不是“历史费用”分类;员工按真实用途填写,例如设备采购、办公网络或办公用品采购。
|
||||
|
||||
### R4 票据时效提示
|
||||
|
||||
- 服务端配置 `REIMBURSEMENT_MAX_AGE_DAYS`,默认 `365`,只允许 `180` 或 `365`,部署时可调整。
|
||||
- 以每条明细的费用发生日期为主计算距当前业务日期的天数;发票日期只作为已有材料摘要,不替代费用日期。
|
||||
- 超过配置期限或费用日期与提交日期跨自然年度时,在预检中产生中文警告,但本任务不自动阻断提交。
|
||||
- 页面说明实际天数、当前期限和需要财务确认的下一步,不出现“历史费用补报”标签。
|
||||
- 日期计算使用 `APP_TIMEZONE` 和 date-only 语义,不因 UTC 转换偏移一天。
|
||||
|
||||
### R5 明细材料完整性提示
|
||||
|
||||
- 预检返回整单发票数、整单补充附件数,以及每条明细已关联发票数。
|
||||
- PC、手机详情明确区分“已关联发票”“待关联发票”和“整单补充材料”。
|
||||
- 普通员工文案不显示 `invoice pool`、OCR 内部码、UUID 或数据库字段。
|
||||
- 本任务保持现有“整单至少一张发票”提交门禁;缺少某条明细关联发票先作为可确认警告,不新建费用材料政策中心。
|
||||
- 本任务不改变 PDF 发票、补充附件、付款回单和资产材料的既有上传边界。
|
||||
|
||||
### R6 设备资产登记提醒
|
||||
|
||||
- 报销包含 `assets` 类别明细时,PC、手机详情和提交预检显示“可能形成资产,报销完成后由管理员核对并手动建卡”的非阻断提醒。
|
||||
- 不自动创建资产卡、不自动判断会计固定资产、不复制资产建卡表单到报销页。
|
||||
- 有资产台账查看权限的用户可从提醒进入资产模块;其他用户只看到下一步说明。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] AC1:PC 和手机的报销明细区域均显示与服务端 `totalAmount` 一致的明细数量和合计;新增、修改、删除后自动刷新。
|
||||
- [ ] AC2:手工新增/修改 `0` 金额明细被服务端拒绝;空明细或含非正金额明细的报销无法提交;模板零金额草稿仍可创建和编辑。
|
||||
- [ ] AC3:提交事务重新汇总权威总额,审批规则和后续提交结果不使用陈旧总额。
|
||||
- [ ] AC4:草稿/驳回报销可在 PC 和手机编辑报销事由和日期;其他状态不可编辑,权限边界不放宽。
|
||||
- [ ] AC5:默认 365 天配置有效,可切换为 180 天;超期和跨年度产生可理解警告但不阻断,date-only 边界无偏移。
|
||||
- [ ] AC6:预检及详情能显示每条明细的发票关联状态和整单附件数;现有整单发票门禁、OCR、查重和附件权限不回归。
|
||||
- [ ] AC7:`assets` 类别触发人工资产登记提醒,不产生任何资产卡写入;无权限用户不会获得资产数据或管理入口。
|
||||
- [ ] AC8:PC、手机和打印页使用一致的金额语义;普通用户页面不出现内部技术术语。
|
||||
- [ ] AC9:相关后端测试、前端 typecheck/build、PC/移动报销 E2E、Trellis 治理和 `git diff --check` 全部通过。
|
||||
- [ ] AC10:若改动面向公网用户,部署、公网冒烟和关键页面检查通过后才告知可以测试。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- “历史费用补报”字段、标识、筛选或独立流程。
|
||||
- 通用费用政策中心、复杂预算、银企直连、自动付款、自动记账或完整电子会计档案系统。
|
||||
- 发票与明细多对多、补充附件关联具体明细、发票验真能力扩展。
|
||||
- 自动创建资产卡、折旧/残值/会计科目判断。
|
||||
- 改造采购流程、付款状态机、审批角色或权限模型。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Backend:Prisma validate、`node --check`、报销详情/创建/费用标准相关 Vitest,并补充本任务聚焦用例。
|
||||
- Frontend:`npm run typecheck`、`npm run build`、PC 和 mobile/wecom 报销详情 E2E。
|
||||
- Process:Trellis task validate、流程合同、治理、文档注册审计和 `git diff --check`。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "reimbursement-small-team-form-validation",
|
||||
"name": "reimbursement-small-team-form-validation",
|
||||
"title": "小团队报销填报与校验完善",
|
||||
"description": "完善小团队内部报销的明细合计、正金额校验、事由编辑、票据时效提示、材料完整性提示和资产登记提醒。",
|
||||
"status": "in_progress",
|
||||
"dev_type": "feature",
|
||||
"scope": "server/client/docs/tests",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-08-04",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -119,6 +119,18 @@ export function mapApiErrorMessage(
|
||||
}
|
||||
|
||||
if (code === "VALID_001") {
|
||||
if (details?.amount === "must_be_positive") {
|
||||
return "明细金额必须大于 0。";
|
||||
}
|
||||
|
||||
if (details?.items === "at_least_one_required") {
|
||||
return "请先添加至少 1 条报销明细。";
|
||||
}
|
||||
|
||||
if (details?.items === "positive_amount_required") {
|
||||
return "所有报销明细金额都必须大于 0,请修改后再提交。";
|
||||
}
|
||||
|
||||
const passwordErrors = readStringList(details?.password);
|
||||
if (passwordErrors.length) {
|
||||
return `新密码不符合要求:${passwordErrors.join(";")}。`;
|
||||
|
||||
@@ -46,6 +46,24 @@ export type PrecheckResponse = {
|
||||
} | null;
|
||||
budgetHint: Record<string, unknown> | null;
|
||||
standardChecks: ExpenseStandardCheck[];
|
||||
evidenceSummary?: {
|
||||
invoiceCount: number;
|
||||
attachmentCount: number;
|
||||
items: Array<{
|
||||
itemId: string;
|
||||
linkedInvoiceCount: number;
|
||||
invoiceState: "linked" | "unlinked";
|
||||
}>;
|
||||
};
|
||||
timelinessChecks?: Array<{
|
||||
itemId: string;
|
||||
expenseDate: string;
|
||||
ageDays: number;
|
||||
maxAgeDays: 180 | 365;
|
||||
overdue: boolean;
|
||||
crossCalendarYear: boolean;
|
||||
}>;
|
||||
assetHint?: { required: boolean; itemCount: number };
|
||||
warnings: Array<{ code: string; title: string; detail: string }>;
|
||||
blockingReasons: Array<{ code: string; title: string; detail: string }>;
|
||||
riskFlags: Array<{ code: string; level: "low" | "medium" | "high"; title: string; detail: string; willOpenOnSubmit: boolean }>;
|
||||
|
||||
@@ -301,6 +301,11 @@ export type ReimbursementTravelSummaryPayload = {
|
||||
travelEndDate: string | null;
|
||||
};
|
||||
|
||||
export type ReimbursementUpdatePayload = Partial<ReimbursementTravelSummaryPayload> & {
|
||||
reimbursementDate?: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type ReimbursementCreateResponse = {
|
||||
id: string;
|
||||
formNumber: string | null;
|
||||
@@ -486,10 +491,24 @@ export async function updateReimbursementTravelSummary(
|
||||
reimbursementId: string,
|
||||
payload: ReimbursementTravelSummaryPayload,
|
||||
) {
|
||||
return apiRequestData<{ travelSummary: Omit<ReimbursementDetail["travelSummary"], "isTravel"> }>(
|
||||
`/api/reimbursements/${reimbursementId}`,
|
||||
{ method: "PATCH", body: JSON.stringify(payload) },
|
||||
);
|
||||
return updateReimbursement(reimbursementId, payload);
|
||||
}
|
||||
|
||||
export async function updateReimbursement(
|
||||
reimbursementId: string,
|
||||
payload: ReimbursementUpdatePayload,
|
||||
) {
|
||||
return apiRequestData<{
|
||||
id: string;
|
||||
status: ReimbursementStatus;
|
||||
reimbursementDate: string;
|
||||
description: string | null;
|
||||
totalAmount: string;
|
||||
travelSummary: Omit<ReimbursementDetail["travelSummary"], "isTravel">;
|
||||
}>(`/api/reimbursements/${reimbursementId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createReimbursementItem(
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { updateReimbursement } from "../../api/reimbursements";
|
||||
import { isApiError } from "../../api/types";
|
||||
import { mapApiErrorMessage } from "../../api/errorMessages";
|
||||
import { useUnsavedChangesGuard } from "../common/UnsavedChangesGuard";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
|
||||
type Props = {
|
||||
reimbursementId: string;
|
||||
reimbursementDate: string | null;
|
||||
description: string | null;
|
||||
editable: boolean;
|
||||
mobile?: boolean;
|
||||
};
|
||||
|
||||
export function ReimbursementHeaderEditor({
|
||||
reimbursementId,
|
||||
reimbursementDate,
|
||||
description,
|
||||
editable,
|
||||
mobile = false,
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [date, setDate] = useState(reimbursementDate ?? "");
|
||||
const [reason, setReason] = useState(description ?? "");
|
||||
const [error, setError] = useState("");
|
||||
const dirty = editing && (date !== (reimbursementDate ?? "") || reason !== (description ?? ""));
|
||||
const runGuarded = useUnsavedChangesGuard(dirty, "报销事由和日期");
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) return;
|
||||
setDate(reimbursementDate ?? "");
|
||||
setReason(description ?? "");
|
||||
}, [description, editing, reimbursementDate]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => updateReimbursement(reimbursementId, {
|
||||
reimbursementDate: date,
|
||||
description: reason.trim() || null,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
setError("");
|
||||
setEditing(false);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||||
]);
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setError(isApiError(mutationError)
|
||||
? mapApiErrorMessage(mutationError.code, mutationError.details, mutationError.message)
|
||||
: "保存报销信息失败,请重试");
|
||||
},
|
||||
});
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<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>
|
||||
{editable ? (
|
||||
<Button size="sm" type="button" variant="secondary" onClick={() => setEditing(true)}>
|
||||
编辑
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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]"}>
|
||||
<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>
|
||||
</div>
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" disabled={!date || mutation.isPending} onClick={() => mutation.mutate()}>
|
||||
{mutation.isPending ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" disabled={mutation.isPending} onClick={() => runGuarded(() => setEditing(false))}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import type { PrecheckResponse } from "../../api/feeControl";
|
||||
import type { ReimbursementItem } from "../../api/reimbursements";
|
||||
|
||||
type Props = {
|
||||
precheck: PrecheckResponse | null | undefined;
|
||||
items: ReimbursementItem[];
|
||||
canAccessAssets: boolean;
|
||||
mobile?: boolean;
|
||||
};
|
||||
|
||||
export function ReimbursementOperationalHints({ precheck, items, canAccessAssets, mobile = false }: Props) {
|
||||
const evidence = precheck?.evidenceSummary;
|
||||
const timeliness = precheck?.timelinessChecks ?? [];
|
||||
const relevantDates = timeliness.filter((item) => item.overdue || item.crossCalendarYear);
|
||||
const itemById = new Map(items.map((item) => [item.id, item]));
|
||||
|
||||
if (!evidence && !relevantDates.length && !precheck?.assetHint?.required) return null;
|
||||
|
||||
return (
|
||||
<div className={`${mobile ? "mt-3" : "mt-4"} space-y-3`} data-testid={mobile ? "mobile-reimbursement-operational-hints" : "reimbursement-operational-hints"}>
|
||||
{evidence ? (
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-700">
|
||||
<div className="font-medium text-zinc-900">材料对应情况</div>
|
||||
<p className="mt-1 text-xs leading-5 text-zinc-500">
|
||||
已添加发票 {evidence.invoiceCount} 张,整单补充材料 {evidence.attachmentCount} 份。
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1 text-xs leading-5">
|
||||
{evidence.items.map((entry) => {
|
||||
const item = itemById.get(entry.itemId);
|
||||
return (
|
||||
<li key={entry.itemId}>
|
||||
{item?.expenseType?.name ?? "报销明细"}:
|
||||
{entry.linkedInvoiceCount > 0
|
||||
? `已关联 ${entry.linkedInvoiceCount} 张发票`
|
||||
: "待关联发票或补充无票说明"}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{relevantDates.length ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-800">
|
||||
<div className="font-medium">费用日期提示</div>
|
||||
<ul className="mt-1 space-y-1 text-xs leading-5">
|
||||
{relevantDates.map((check) => (
|
||||
<li key={check.itemId}>
|
||||
{itemById.get(check.itemId)?.expenseType?.name ?? "报销明细"}({check.expenseDate}):
|
||||
{[
|
||||
check.overdue ? `距今 ${check.ageDays} 天,超过当前 ${check.maxAgeDays} 天期限` : "",
|
||||
check.crossCalendarYear ? "与当前日期跨自然年度" : "",
|
||||
].filter(Boolean).join(";")},请与财务确认。
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{precheck?.assetHint?.required ? (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-3 text-sm text-blue-800">
|
||||
<div className="font-medium">资产登记提醒</div>
|
||||
<p className="mt-1 text-xs leading-5">
|
||||
{precheck.assetHint.itemCount} 条明细可能形成资产,报销完成后由管理员核对并手动建卡。
|
||||
</p>
|
||||
{canAccessAssets ? <Link className="mt-2 inline-flex text-xs font-medium text-primary hover:underline" to="/assets">查看资产台账</Link> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
import { remindWorkflowTask } from "../api/workflow";
|
||||
import type { PrecheckResponse } from "../api/feeControl";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { hasAnyPermission, hasPermission } from "../auth/permissions";
|
||||
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
|
||||
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
|
||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||
@@ -44,6 +44,8 @@ import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||||
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 { ReimbursementOperationalHints } from "../components/reimbursements/ReimbursementOperationalHints";
|
||||
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Button } from "../components/ui/button";
|
||||
@@ -197,6 +199,7 @@ export function MobileReimbursementDetailPage() {
|
||||
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
|
||||
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
||||
const canSubmitExpense = hasPermission(permissions, "expense.submit");
|
||||
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
||||
const isParticipant = Boolean(detail && me?.id && [detail.ownerId, detail.submitterId].includes(me.id));
|
||||
const canEdit = isEditableStatus(detail?.status);
|
||||
const pendingTaskId = detail?.pendingWorkflow?.task?.id ?? null;
|
||||
@@ -212,6 +215,12 @@ export function MobileReimbursementDetailPage() {
|
||||
const canManageAttachments = Boolean(canEdit && (isParticipant || canViewAllExpense));
|
||||
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
||||
|
||||
const reimbursementPrecheckQuery = useQuery({
|
||||
enabled: Boolean(reimbursementId && canSubmitExpense && detail),
|
||||
queryKey: ["reimbursements", reimbursementId, "precheck"],
|
||||
queryFn: () => precheckReimbursement(reimbursementId),
|
||||
});
|
||||
|
||||
function handleInvoicePdfSelection(files: FileList | null) {
|
||||
const { accepted, rejected } = splitPdfFiles(files);
|
||||
setSelectedInvoiceFiles(accepted);
|
||||
@@ -919,7 +928,13 @@ export function MobileReimbursementDetailPage() {
|
||||
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.submitter?.name ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
{detail.description ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
|
||||
<ReimbursementHeaderEditor
|
||||
reimbursementId={reimbursementId}
|
||||
reimbursementDate={detail.reimbursementDate}
|
||||
description={detail.description}
|
||||
editable={Boolean(canEdit && isParticipant)}
|
||||
mobile
|
||||
/>
|
||||
<ReimbursementTravelSummary
|
||||
detail={detail}
|
||||
reimbursementId={reimbursementId}
|
||||
@@ -1037,7 +1052,7 @@ export function MobileReimbursementDetailPage() {
|
||||
<div className="mobile-two-action-grid">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !itemForm.amount.trim() || itemSaveMutation.isPending}
|
||||
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending}
|
||||
onClick={() => itemSaveMutation.mutate(itemForm)}
|
||||
>
|
||||
{itemSaveMutation.isPending ? "保存中..." : "保存"}
|
||||
@@ -1085,6 +1100,16 @@ export function MobileReimbursementDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between rounded-lg bg-zinc-50 px-3 py-3 text-sm font-medium text-zinc-900">
|
||||
<span>共 {detail.items.length} 条明细</span>
|
||||
<span>报销合计 {formatMoney(detail.totalAmount)}</span>
|
||||
</div>
|
||||
<ReimbursementOperationalHints
|
||||
precheck={reimbursementPrecheckQuery.data}
|
||||
items={detail.items}
|
||||
canAccessAssets={canAccessAssets}
|
||||
mobile
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mobile-card" data-testid="mobile-reimbursement-materials">
|
||||
|
||||
@@ -38,7 +38,7 @@ import { isWecomWebView } from "../lib/runtimeContext";
|
||||
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||
import { hasCompleteTravelSummary } from "../lib/reimbursementTravel";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { hasAnyPermission, hasPermission } from "../auth/permissions";
|
||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard";
|
||||
@@ -47,6 +47,8 @@ import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDe
|
||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||||
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
|
||||
import { ReimbursementHeaderEditor } from "../components/reimbursements/ReimbursementHeaderEditor";
|
||||
import { ReimbursementOperationalHints } from "../components/reimbursements/ReimbursementOperationalHints";
|
||||
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { Button } from "../components/ui/button";
|
||||
@@ -174,9 +176,10 @@ export function ReimbursementDetailPage() {
|
||||
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
|
||||
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
|
||||
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
||||
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
||||
|
||||
const reimbursementPrecheckQuery = useQuery({
|
||||
enabled: Boolean(reimbursementId) && canSubmitExpense && ["draft", "rejected"].includes(detailQuery.data?.status ?? ""),
|
||||
enabled: Boolean(reimbursementId) && canSubmitExpense && Boolean(detailQuery.data),
|
||||
queryKey: ["reimbursements", reimbursementId, "precheck"],
|
||||
queryFn: () => precheckReimbursement(reimbursementId),
|
||||
});
|
||||
@@ -1101,7 +1104,7 @@ export function ReimbursementDetailPage() {
|
||||
<div data-testid="reimbursement-business-summary">
|
||||
<div className="detail-fact-grid xl:grid-cols-3" data-testid="reimbursement-summary">
|
||||
<div className="detail-fact-card">
|
||||
<div className="detail-fact-card__label">金额</div>
|
||||
<div className="detail-fact-card__label">报销总额</div>
|
||||
<div className="detail-fact-card__value">{formatMoney(detail.totalAmount)}</div>
|
||||
</div>
|
||||
<div className="detail-fact-card">
|
||||
@@ -1129,11 +1132,12 @@ export function ReimbursementDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{detail.description ? (
|
||||
<div className="mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
|
||||
{detail.description}
|
||||
</div>
|
||||
) : null}
|
||||
<ReimbursementHeaderEditor
|
||||
reimbursementId={reimbursementId}
|
||||
reimbursementDate={detail.reimbursementDate}
|
||||
description={detail.description}
|
||||
editable={Boolean(canEditItems && isReimbursementParticipant)}
|
||||
/>
|
||||
<ReimbursementTravelSummary
|
||||
detail={detail}
|
||||
reimbursementId={reimbursementId}
|
||||
@@ -1339,8 +1343,20 @@ export function ReimbursementDetailPage() {
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
<tfoot className="border-t border-zinc-200 bg-zinc-50 font-medium text-zinc-900">
|
||||
<tr>
|
||||
<TableCell colSpan={2}>共 {detail.items.length} 条明细</TableCell>
|
||||
<TableCell>{formatMoney(detail.totalAmount)}</TableCell>
|
||||
<TableCell colSpan={2}>报销合计</TableCell>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</Table>
|
||||
</div>
|
||||
<ReimbursementOperationalHints
|
||||
precheck={reimbursementPrecheckQuery.data}
|
||||
items={detail.items}
|
||||
canAccessAssets={canAccessAssets}
|
||||
/>
|
||||
|
||||
{itemEditor ? (
|
||||
<ReimbursementItemEditor
|
||||
|
||||
@@ -81,6 +81,7 @@ export function ReimbursementItemEditor({
|
||||
const numericMaxAmount = Number(activeStandard?.maxAmount ?? 0);
|
||||
const isExceeded = Boolean(activeStandard) && Number.isFinite(numericAmount) && numericAmount > numericMaxAmount;
|
||||
const exceededAmount = isExceeded ? (numericAmount - numericMaxAmount).toFixed(2) : "0.00";
|
||||
const hasPositiveAmount = Number.isFinite(numericAmount) && numericAmount > 0;
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({ expenseTypeId, expenseDate, amount, description });
|
||||
@@ -185,7 +186,8 @@ export function ReimbursementItemEditor({
|
||||
isPending ||
|
||||
!expenseTypeId ||
|
||||
!expenseDate ||
|
||||
!amount.trim()
|
||||
!amount.trim() ||
|
||||
!hasPositiveAmount
|
||||
}
|
||||
onClick={handleSave}
|
||||
type="button"
|
||||
|
||||
@@ -66,7 +66,7 @@ function reimbursementListItem() {
|
||||
};
|
||||
}
|
||||
|
||||
function reimbursementDetail() {
|
||||
function reimbursementDetail(overrides: { reimbursementDate?: string; description?: string } = {}) {
|
||||
return {
|
||||
id: reimbursementId,
|
||||
formNumber,
|
||||
@@ -77,9 +77,9 @@ function reimbursementDetail() {
|
||||
status: "draft",
|
||||
createdAt: "2026-05-06T09:00:00.000Z",
|
||||
submittedAt: null,
|
||||
reimbursementDate: "2026-05-06",
|
||||
reimbursementDate: overrides.reimbursementDate ?? "2026-05-06",
|
||||
totalAmount: "168.30",
|
||||
description: "客户拜访交通费",
|
||||
description: overrides.description ?? "客户拜访交通费",
|
||||
projectId: null,
|
||||
linkedTravelRequestId: null,
|
||||
linkedTravelRequest: null,
|
||||
@@ -113,6 +113,8 @@ function reimbursementDetail() {
|
||||
|
||||
async function mockApis(page: Page) {
|
||||
let draftExists = true;
|
||||
let detailReimbursementDate = "2026-05-06";
|
||||
let detailDescription = "客户拜访交通费";
|
||||
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request();
|
||||
@@ -245,7 +247,25 @@ async function mockApis(page: Page) {
|
||||
}
|
||||
|
||||
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") {
|
||||
await fulfillJson(route, reimbursementDetail());
|
||||
await fulfillJson(route, reimbursementDetail({
|
||||
reimbursementDate: detailReimbursementDate,
|
||||
description: detailDescription,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "PATCH") {
|
||||
const body = request.postDataJSON() as { reimbursementDate?: string; description?: string | null };
|
||||
detailReimbursementDate = body.reimbursementDate ?? detailReimbursementDate;
|
||||
detailDescription = body.description ?? "";
|
||||
await fulfillJson(route, {
|
||||
id: reimbursementId,
|
||||
status: "draft",
|
||||
reimbursementDate: detailReimbursementDate,
|
||||
description: detailDescription || null,
|
||||
totalAmount: "168.30",
|
||||
travelSummary: { purpose: null, location: null, startDate: null, endDate: null },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -256,7 +276,25 @@ async function mockApis(page: Page) {
|
||||
}
|
||||
|
||||
if (path === `/api/reimbursements/${reimbursementId}/precheck`) {
|
||||
await fulfillJson(route, { decision: "pass", standardChecks: [], riskFlags: [] });
|
||||
await fulfillJson(route, {
|
||||
entityType: "reimbursement",
|
||||
entityId: reimbursementId,
|
||||
decision: "warn",
|
||||
matchedBudgetControlRule: null,
|
||||
budgetHint: null,
|
||||
standardChecks: [],
|
||||
evidenceSummary: {
|
||||
invoiceCount: 0,
|
||||
attachmentCount: 0,
|
||||
items: [{ itemId, linkedInvoiceCount: 0, invoiceState: "unlinked" }],
|
||||
},
|
||||
timelinessChecks: [],
|
||||
assetHint: { required: false, itemCount: 0 },
|
||||
warnings: [],
|
||||
blockingReasons: [],
|
||||
riskFlags: [],
|
||||
nextActions: ["确认提示后可继续提交"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -369,6 +407,26 @@ test.describe("R102 reimbursement main flow", () => {
|
||||
await expect(page.getByTestId("reimbursement-invoice-dialog")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("PC 详情显示权威合计并可编辑报销日期和事由", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
|
||||
await expect(page.getByTestId("reimbursement-summary")).toContainText("报销总额");
|
||||
await expect(page.getByTestId("reimbursement-items-table")).toContainText("共 1 条明细");
|
||||
await expect(page.getByTestId("reimbursement-items-table")).toContainText("报销合计");
|
||||
await expect(page.getByTestId("reimbursement-operational-hints")).toContainText("待关联发票或补充无票说明");
|
||||
|
||||
await page.getByTestId("reimbursement-business-summary").getByRole("button", { name: "编辑", exact: true }).click();
|
||||
const editor = page.getByTestId("reimbursement-header-editor");
|
||||
await editor.getByLabel("报销日期").fill("2026-05-07");
|
||||
await editor.getByLabel("报销事由").fill("办公网络费用");
|
||||
await editor.getByRole("button", { name: "保存", exact: true }).click();
|
||||
|
||||
await expect(page.getByTestId("reimbursement-header-editor")).toHaveCount(0);
|
||||
await expect(page.getByTestId("reimbursement-business-summary")).toContainText("办公网络费用");
|
||||
await expect(page.getByTestId("reimbursement-summary")).toContainText("2026-05-07");
|
||||
});
|
||||
|
||||
test("移动报销卡片显示业务编号", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const listRequest = page.waitForRequest((request) => {
|
||||
@@ -386,6 +444,10 @@ test.describe("R102 reimbursement main flow", () => {
|
||||
await expect(page.getByText(formNumber)).toBeVisible();
|
||||
await expect(page.getByText(reimbursementId)).toHaveCount(0);
|
||||
await expect(page.locator("table:visible")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("mobile-reimbursement-card").getByRole("link").click();
|
||||
await expect(page.getByTestId("mobile-reimbursement-items")).toContainText("共 1 条明细");
|
||||
await expect(page.getByTestId("mobile-reimbursement-items")).toContainText("报销合计 ¥168.30");
|
||||
});
|
||||
|
||||
test("移动管理员默认查看本人并可切换全部报销", async ({ page }) => {
|
||||
@@ -402,6 +464,22 @@ test.describe("R102 reimbursement main flow", () => {
|
||||
await page.route("**/api/me", async (route) => {
|
||||
await fulfillJson(route, adminMe);
|
||||
});
|
||||
await page.route(`**/api/reimbursements/${otherReimbursementId}`, async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, {
|
||||
...reimbursementDetail(),
|
||||
id: otherReimbursementId,
|
||||
formNumber: "BX-20260731-0002",
|
||||
status: "reviewing",
|
||||
ownerId: "employee-other-r102",
|
||||
submitterId: "employee-other-r102",
|
||||
owner: { id: "employee-other-r102", employee_no: "E103", name: "其他员工" },
|
||||
submitter: { id: "employee-other-r102", employee_no: "E103", name: "其他员工" },
|
||||
});
|
||||
});
|
||||
await page.route((url) => url.pathname === "/api/reimbursements", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() !== "GET") {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
NODE_ENV=production
|
||||
APP_ORIGIN=https://oa.example.com
|
||||
APP_TIMEZONE=Asia/Shanghai
|
||||
REIMBURSEMENT_MAX_AGE_DAYS=365
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
# 宿主机回环端口;容器内仍固定监听 PORT,Caddy 必须指向此端口。
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"RATE_LIMIT_ENABLED",
|
||||
"RATE_LIMIT_LOGIN_MAX",
|
||||
"RATE_LIMIT_LOGIN_WINDOW",
|
||||
"REIMBURSEMENT_MAX_AGE_DAYS",
|
||||
"S3_BUCKET",
|
||||
"S3_ENDPOINT",
|
||||
"S3_FORCE_PATH_STYLE",
|
||||
|
||||
@@ -157,6 +157,15 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
|
||||
|
||||
## PC 与手机体验
|
||||
|
||||
小团队填报与提交规则:
|
||||
|
||||
- 普通报销明细金额必须大于 0;差旅或模板可以生成零金额草稿占位,但补齐正金额前不能提交。
|
||||
- 报销总额由服务端按明细汇总,PC、手机和打印页只展示该权威总额,不提供手工填写总额。
|
||||
- 提交事务必须重新读取全部明细并重算总额,再据此执行预算检查、审批规则选择和工作流快照。
|
||||
- 费用日期默认可追溯 365 天,部署时可调整为 180 天;超过期限或跨自然年度只提示员工与财务确认,不自动阻断,也不增加“历史费用补报”标签。
|
||||
- 详情显示整单发票数、补充附件数和每条明细的发票关联状态;未逐条关联发票先提示补充关联或无票说明,不取代现有整单票据门禁。
|
||||
- `assets` 类明细只触发人工资产登记提醒。报销完成后由管理员核对并手动建卡,系统不因报销提交、审批或付款自动创建资产卡。
|
||||
|
||||
PC 端:
|
||||
|
||||
- 适合明细编辑、批量查看、财务复核和台账导出。
|
||||
|
||||
@@ -26,6 +26,7 @@ AUTH_CODE_PEPPER="<generate-a-separate-random-secret>"
|
||||
# EMAIL_LOGIN_CODE_LOCK_SECONDS="1800"
|
||||
# DEV_SHOW_PASSWORD_RESET_CODE="false"
|
||||
APP_TIMEZONE="Asia/Shanghai"
|
||||
# REIMBURSEMENT_MAX_AGE_DAYS="365" # 仅允许 180 或 365;超期只提示,不自动阻断
|
||||
STORAGE_DIR="generated/storage"
|
||||
# STORAGE_DRIVER="local" # local|s3(MinIO/S3 兼容)
|
||||
# S3_ENDPOINT="" # 例如:http://127.0.0.1:9000(MinIO)
|
||||
|
||||
@@ -383,6 +383,7 @@ npm run job:ocr-once
|
||||
- `JWT_SECRET`: JWT & signed-url token 签名密钥
|
||||
- `FIELD_ENCRYPTION_KEY`: 敏感字段加密密钥(AES-256-GCM;base64 32 bytes;用于银行卡/身份证等)
|
||||
- `APP_TIMEZONE`: 默认 `Asia/Shanghai`
|
||||
- `REIMBURSEMENT_MAX_AGE_DAYS`: 报销费用日期提示期限,默认 `365`,仅允许 `180` 或 `365`;超期和跨自然年度只预警,不自动阻断
|
||||
- `STORAGE_DIR`: 默认 `generated/storage`(相对 `server/`)
|
||||
- `SERVE_CLIENT_DIST`: 是否由 server 托管 `client/dist`(默认 `false`)
|
||||
- `CLIENT_DIST_DIR`: 前端构建产物目录(默认 `../client/dist`,相对 `server/`)
|
||||
|
||||
@@ -64,6 +64,14 @@ function getConfig() {
|
||||
min: nodeEnv === "production" ? 1 : 0,
|
||||
max: nodeEnv === "production" ? 1 : 8,
|
||||
});
|
||||
const reimbursementMaxAgeDays = parseBoundedInteger(
|
||||
"REIMBURSEMENT_MAX_AGE_DAYS",
|
||||
process.env.REIMBURSEMENT_MAX_AGE_DAYS ?? 365,
|
||||
{ min: 180, max: 365 },
|
||||
);
|
||||
if (![180, 365].includes(reimbursementMaxAgeDays)) {
|
||||
throw new Error("Invalid REIMBURSEMENT_MAX_AGE_DAYS");
|
||||
}
|
||||
|
||||
const passwordResetCodeTtlSeconds = parseBoundedInteger(
|
||||
"PASSWORD_RESET_CODE_TTL_SECONDS",
|
||||
@@ -192,6 +200,7 @@ function getConfig() {
|
||||
wecomApprovalCallbackEvidenceFile: process.env.WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE ?? "",
|
||||
|
||||
appTimezone: process.env.APP_TIMEZONE ?? "Asia/Shanghai",
|
||||
reimbursementMaxAgeDays,
|
||||
storageDir: path.resolve(serverRoot, storageDir),
|
||||
storageDriver: process.env.STORAGE_DRIVER ?? "local",
|
||||
s3Endpoint: process.env.S3_ENDPOINT ?? "",
|
||||
|
||||
@@ -928,12 +928,12 @@ function parseItemBody(body) {
|
||||
}
|
||||
|
||||
const amountNumber = Number(parsed.data.amount);
|
||||
if (!Number.isFinite(amountNumber) || amountNumber < 0) {
|
||||
if (!Number.isFinite(amountNumber) || amountNumber <= 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { amount: "invalid" },
|
||||
details: { amount: "must_be_positive" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3712,12 +3712,12 @@ async function reimbursementRoutes(fastify) {
|
||||
|
||||
if (typeof parsedBody.data.amount === "string") {
|
||||
const amountNumber = Number(parsedBody.data.amount);
|
||||
if (!Number.isFinite(amountNumber) || amountNumber < 0) {
|
||||
if (!Number.isFinite(amountNumber) || amountNumber <= 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { amount: "invalid" },
|
||||
details: { amount: "must_be_positive" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3952,6 +3952,36 @@ async function reimbursementRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const reimbursementItems = await tx.reimbursementItem.findMany({
|
||||
where: { reimbursement_id: reimbursementId },
|
||||
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
||||
select: { id: true, amount: true },
|
||||
});
|
||||
if (!reimbursementItems.length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { items: "at_least_one_required" },
|
||||
});
|
||||
}
|
||||
const invalidAmountItems = reimbursementItems
|
||||
.filter((item) => !Number.isFinite(Number(item.amount)) || Number(item.amount) <= 0)
|
||||
.map((item) => item.id);
|
||||
if (invalidAmountItems.length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { items: "positive_amount_required", itemIds: invalidAmountItems },
|
||||
});
|
||||
}
|
||||
|
||||
reimbursement.total_amount = await recomputeTotalAmount(
|
||||
tx,
|
||||
reimbursementId,
|
||||
request.employee.id,
|
||||
);
|
||||
ensureNonNegativeAmount(reimbursement.total_amount);
|
||||
|
||||
const travelItem = await tx.reimbursementItem.findFirst({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { resolveApplicableBudgetPoolByContext } = require("./budgetPool");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("./featureFlags");
|
||||
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||
const {
|
||||
buildApprovalWorkflowSnapshot,
|
||||
parseWorkflowSteps,
|
||||
@@ -79,6 +80,130 @@ function buildDisabledPrecheck(entityType, entityId) {
|
||||
};
|
||||
}
|
||||
|
||||
function dateOnlyDiffDays(laterDate, earlierDate) {
|
||||
return Math.floor(
|
||||
(parseDateOnlyString(laterDate).getTime() - parseDateOnlyString(earlierDate).getTime())
|
||||
/ (24 * 60 * 60 * 1000),
|
||||
);
|
||||
}
|
||||
|
||||
async function buildReimbursementOperationalChecks(tx, { reimbursementId, config }) {
|
||||
const reimbursement = await tx.reimbursementForm.findUnique({
|
||||
where: { id: reimbursementId },
|
||||
select: {
|
||||
id: true,
|
||||
items: {
|
||||
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
expense_date: true,
|
||||
expense_type: { select: { category: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!reimbursement) {
|
||||
throw buildNotFoundError();
|
||||
}
|
||||
|
||||
const [invoices, attachmentCount] = await Promise.all([
|
||||
tx.invoicePool.findMany({
|
||||
where: { linked_reimbursement_id: reimbursementId, status: "bound" },
|
||||
select: { linked_item_id: true },
|
||||
}),
|
||||
tx.reimbursementAttachment.count({ where: { reimbursement_id: reimbursementId } }),
|
||||
]);
|
||||
const linkedInvoiceCounts = new Map();
|
||||
for (const invoice of invoices) {
|
||||
if (!invoice.linked_item_id) continue;
|
||||
linkedInvoiceCounts.set(
|
||||
invoice.linked_item_id,
|
||||
(linkedInvoiceCounts.get(invoice.linked_item_id) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
const businessDate = currentDateString(config.appTimezone);
|
||||
const businessYear = businessDate.slice(0, 4);
|
||||
const maxAgeDays = config.reimbursementMaxAgeDays ?? 365;
|
||||
const timelinessChecks = reimbursement.items.map((item) => {
|
||||
const expenseDate = item.expense_date.toISOString().slice(0, 10);
|
||||
const ageDays = dateOnlyDiffDays(businessDate, expenseDate);
|
||||
return {
|
||||
itemId: item.id,
|
||||
expenseDate,
|
||||
ageDays,
|
||||
maxAgeDays,
|
||||
overdue: ageDays > maxAgeDays,
|
||||
crossCalendarYear: expenseDate.slice(0, 4) !== businessYear,
|
||||
};
|
||||
});
|
||||
const evidenceItems = reimbursement.items.map((item) => {
|
||||
const linkedInvoiceCount = linkedInvoiceCounts.get(item.id) ?? 0;
|
||||
return {
|
||||
itemId: item.id,
|
||||
linkedInvoiceCount,
|
||||
invoiceState: linkedInvoiceCount > 0 ? "linked" : "unlinked",
|
||||
};
|
||||
});
|
||||
const assetItemCount = reimbursement.items.filter(
|
||||
(item) => item.expense_type?.category === "assets",
|
||||
).length;
|
||||
const warnings = [];
|
||||
|
||||
for (const check of timelinessChecks) {
|
||||
if (check.overdue) {
|
||||
warnings.push(buildIssue(
|
||||
`expense_date_overdue_${check.itemId}`,
|
||||
"费用日期超过当前报销期限",
|
||||
`该明细距今 ${check.ageDays} 天,当前期限为 ${check.maxAgeDays} 天,请提交前与财务确认。`,
|
||||
));
|
||||
}
|
||||
if (check.crossCalendarYear) {
|
||||
warnings.push(buildIssue(
|
||||
`expense_date_cross_year_${check.itemId}`,
|
||||
"费用日期跨自然年度",
|
||||
`该明细发生于 ${check.expenseDate},请提交前与财务确认年度归属。`,
|
||||
));
|
||||
}
|
||||
}
|
||||
const unlinkedCount = evidenceItems.filter((item) => item.invoiceState === "unlinked").length;
|
||||
if (unlinkedCount > 0) {
|
||||
warnings.push(buildIssue(
|
||||
"reimbursement_items_without_linked_invoice",
|
||||
"部分明细尚未关联发票",
|
||||
`共有 ${unlinkedCount} 条明细未关联发票;如票据属于整单或无需发票,请在报销说明中写明。`,
|
||||
));
|
||||
}
|
||||
if (assetItemCount > 0) {
|
||||
warnings.push(buildIssue(
|
||||
"reimbursement_asset_registration_required",
|
||||
"报销完成后需核对资产登记",
|
||||
`共有 ${assetItemCount} 条明细可能形成资产,报销完成后由管理员核对并手动建卡。`,
|
||||
));
|
||||
}
|
||||
|
||||
return {
|
||||
evidenceSummary: {
|
||||
invoiceCount: invoices.length,
|
||||
attachmentCount,
|
||||
items: evidenceItems,
|
||||
},
|
||||
timelinessChecks,
|
||||
assetHint: { required: assetItemCount > 0, itemCount: assetItemCount },
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeReimbursementOperationalChecks(precheck, operationalChecks) {
|
||||
return {
|
||||
...precheck,
|
||||
evidenceSummary: operationalChecks.evidenceSummary,
|
||||
timelinessChecks: operationalChecks.timelinessChecks,
|
||||
assetHint: operationalChecks.assetHint,
|
||||
warnings: [...precheck.warnings, ...operationalChecks.warnings],
|
||||
};
|
||||
}
|
||||
|
||||
function buildIssue(code, title, detail) {
|
||||
return {
|
||||
code,
|
||||
@@ -1029,8 +1154,14 @@ async function resolveEntityPrecheck({
|
||||
if (entityType !== "reimbursement") {
|
||||
return disabledPrecheck;
|
||||
}
|
||||
const standardChecks = await checkExpenseStandards(prisma, { reimbursementId: entityId });
|
||||
return mergeReimbursementPrecheck(disabledPrecheck, standardChecks);
|
||||
const [standardChecks, operationalChecks] = await Promise.all([
|
||||
checkExpenseStandards(prisma, { reimbursementId: entityId }),
|
||||
buildReimbursementOperationalChecks(prisma, { reimbursementId: entityId, config }),
|
||||
]);
|
||||
return mergeReimbursementPrecheck(
|
||||
mergeReimbursementOperationalChecks(disabledPrecheck, operationalChecks),
|
||||
standardChecks,
|
||||
);
|
||||
}
|
||||
|
||||
const precheck = await buildEntityPrecheck(prisma, {
|
||||
@@ -1044,8 +1175,14 @@ async function resolveEntityPrecheck({
|
||||
return precheck;
|
||||
}
|
||||
|
||||
const standardChecks = await checkExpenseStandards(prisma, { reimbursementId: entityId });
|
||||
return mergeReimbursementPrecheck(precheck, standardChecks);
|
||||
const [standardChecks, operationalChecks] = await Promise.all([
|
||||
checkExpenseStandards(prisma, { reimbursementId: entityId }),
|
||||
buildReimbursementOperationalChecks(prisma, { reimbursementId: entityId, config }),
|
||||
]);
|
||||
return mergeReimbursementPrecheck(
|
||||
mergeReimbursementOperationalChecks(precheck, operationalChecks),
|
||||
standardChecks,
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -1062,6 +1199,7 @@ module.exports = {
|
||||
createRiskFlagsFromPrecheck,
|
||||
buildEntityPrecheck,
|
||||
buildReimbursementPrecheck,
|
||||
buildReimbursementOperationalChecks,
|
||||
buildPurchaseRequestPrecheck,
|
||||
buildTravelRequestPrecheck,
|
||||
checkExpenseStandards,
|
||||
|
||||
@@ -131,6 +131,27 @@ describe("auth security configuration", () => {
|
||||
expect(() => getConfig()).toThrow(`Invalid ${name}`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined, 365],
|
||||
["180", 180],
|
||||
["365", 365],
|
||||
])("accepts the configured reimbursement age policy", (value, expected) => {
|
||||
stubBaseEnv();
|
||||
vi.stubEnv("REIMBURSEMENT_MAX_AGE_DAYS", value);
|
||||
|
||||
expect(getConfig().reimbursementMaxAgeDays).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(["0", "181", "366", "invalid"])(
|
||||
"rejects unsupported reimbursement age policy %s",
|
||||
(value) => {
|
||||
stubBaseEnv();
|
||||
vi.stubEnv("REIMBURSEMENT_MAX_AGE_DAYS", value);
|
||||
|
||||
expect(() => getConfig()).toThrow("Invalid REIMBURSEMENT_MAX_AGE_DAYS");
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores a forged X-Forwarded-For header by default", async () => {
|
||||
stubBaseEnv();
|
||||
const app = await buildIpProbeApp();
|
||||
|
||||
@@ -101,6 +101,12 @@ async function buildApp(options = {}) {
|
||||
},
|
||||
reimbursementItem: {
|
||||
findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)),
|
||||
findMany: vi.fn(async () => options.reimbursementItems ?? [
|
||||
{ id: "item-1", amount: reimbursement.total_amount },
|
||||
]),
|
||||
aggregate: vi.fn(async () => ({
|
||||
_sum: { amount: options.authoritativeTotal ?? reimbursement.total_amount },
|
||||
})),
|
||||
},
|
||||
budgetLedgerEntry: {
|
||||
create: vi.fn(async () => ({ id: "budget-ledger-1" })),
|
||||
@@ -245,6 +251,52 @@ describe("reimbursements routes expense standard integration", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects submit when the reimbursement has no items", async () => {
|
||||
const { app, tx } = await buildApp({ reimbursementItems: [] });
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ items: "at_least_one_required" });
|
||||
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects submit and identifies items with non-positive amounts", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
reimbursementItems: [
|
||||
{ id: "item-zero", amount: "0.00" },
|
||||
{ id: "item-valid", amount: "20.00" },
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({
|
||||
items: "positive_amount_required",
|
||||
itemIds: ["item-zero"],
|
||||
});
|
||||
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("recomputes a stale total before selecting the approval rule", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
||||
const { app, tx } = await buildApp({ authoritativeTotal: "320.00" });
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(tx.reimbursementForm.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ total_amount: "320.00" }),
|
||||
}));
|
||||
expect(tx.approvalRule.findFirst).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ min_amount: { lte: "320.00" } }),
|
||||
}));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns standardChecks from reimbursement precheck", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(
|
||||
buildPrecheck({
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("environment recovery classification contract", () => {
|
||||
|
||||
it("classifies every env contract key exactly once", async () => {
|
||||
const result = await validate();
|
||||
expect(result.stdout).toContain("环境恢复分类键数: 164");
|
||||
expect(result.stdout).toContain("环境恢复分类键数: 165");
|
||||
});
|
||||
|
||||
it("skips tracked paths deleted in the current worktree before reading metadata", async () => {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
const {
|
||||
buildReimbursementOperationalChecks,
|
||||
} = require("../../../src/services/feeControl");
|
||||
|
||||
const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function buildTx() {
|
||||
return {
|
||||
reimbursementForm: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: REIMBURSEMENT_ID,
|
||||
items: [
|
||||
{
|
||||
id: "item-office",
|
||||
expense_date: new Date("2026-02-05T00:00:00.000Z"),
|
||||
expense_type: { category: "office" },
|
||||
},
|
||||
{
|
||||
id: "item-asset",
|
||||
expense_date: new Date("2025-08-03T00:00:00.000Z"),
|
||||
expense_type: { category: "assets" },
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
invoicePool: {
|
||||
findMany: vi.fn(async () => [
|
||||
{ linked_item_id: "item-office" },
|
||||
{ linked_item_id: "item-office" },
|
||||
{ linked_item_id: null },
|
||||
]),
|
||||
},
|
||||
reimbursementAttachment: {
|
||||
count: vi.fn(async () => 2),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildReimbursementOperationalChecks", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-04T04:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("summarizes evidence, timeliness and asset candidates without blocking", async () => {
|
||||
const result = await buildReimbursementOperationalChecks(buildTx(), {
|
||||
reimbursementId: REIMBURSEMENT_ID,
|
||||
config: { appTimezone: "Asia/Shanghai", reimbursementMaxAgeDays: 365 },
|
||||
});
|
||||
|
||||
expect(result.evidenceSummary).toEqual({
|
||||
invoiceCount: 3,
|
||||
attachmentCount: 2,
|
||||
items: [
|
||||
{ itemId: "item-office", linkedInvoiceCount: 2, invoiceState: "linked" },
|
||||
{ itemId: "item-asset", linkedInvoiceCount: 0, invoiceState: "unlinked" },
|
||||
],
|
||||
});
|
||||
expect(result.timelinessChecks).toEqual([
|
||||
expect.objectContaining({ itemId: "item-office", ageDays: 180, overdue: false, crossCalendarYear: false }),
|
||||
expect.objectContaining({ itemId: "item-asset", ageDays: 366, overdue: true, crossCalendarYear: true }),
|
||||
]);
|
||||
expect(result.assetHint).toEqual({ required: true, itemCount: 1 });
|
||||
expect(result.warnings.map((item) => item.code)).toEqual(expect.arrayContaining([
|
||||
"expense_date_overdue_item-asset",
|
||||
"expense_date_cross_year_item-asset",
|
||||
"reimbursement_items_without_linked_invoice",
|
||||
"reimbursement_asset_registration_required",
|
||||
]));
|
||||
});
|
||||
|
||||
it("uses the 180-day policy and keeps the boundary day eligible", async () => {
|
||||
const result = await buildReimbursementOperationalChecks(buildTx(), {
|
||||
reimbursementId: REIMBURSEMENT_ID,
|
||||
config: { appTimezone: "Asia/Shanghai", reimbursementMaxAgeDays: 180 },
|
||||
});
|
||||
|
||||
expect(result.timelinessChecks[0]).toEqual(expect.objectContaining({
|
||||
ageDays: 180,
|
||||
maxAgeDays: 180,
|
||||
overdue: false,
|
||||
}));
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户