取消报销资产事前批准强制(08-30-reimbursement-assets-prior-approval-correction)
这个提交包含在:
@@ -30,6 +30,10 @@ duplicate detection or attachment behavior.
|
|||||||
require the same own-or-`expense.view_all` guard as reimbursement detail reads
|
require the same own-or-`expense.view_all` guard as reimbursement detail reads
|
||||||
before invoking the precheck service; an ordinary nonparticipant must receive
|
before invoking the precheck service; an ordinary nonparticipant must receive
|
||||||
403 without service-side evaluation.
|
403 without service-side evaluation.
|
||||||
|
- 取消或放宽必填校验时,必须继续审计该校验之后的代码是否依赖它提供的
|
||||||
|
非空、类型或状态保证,并为生产真实存在的空值路径补回归测试。不得只
|
||||||
|
删除前置错误分支;可空字段的 `.trim()`、`.length`、字符串拼接和传给
|
||||||
|
非空参数的调用都应逐处确认。
|
||||||
|
|
||||||
## Scenario: OCR Disabled Without Mock Data
|
## Scenario: OCR Disabled Without Mock Data
|
||||||
|
|
||||||
@@ -996,8 +1000,10 @@ await selectApprovalRule(reimbursement.total_amount);
|
|||||||
- Only `travel_meal` and `travel_local_transport_allowance` are invoice-free in
|
- Only `travel_meal` and `travel_local_transport_allowance` are invoice-free in
|
||||||
the initial policy. Invoice-free means skip invoice presence/amount checks;
|
the initial policy. Invoice-free means skip invoice presence/amount checks;
|
||||||
it does not remove explanation, formula, approval or attachment obligations.
|
it does not remove explanation, formula, approval or attachment obligations.
|
||||||
- Every `assets` item requires a trimmed approval basis on create, update and
|
- 公司当前没有 assets 事前审批制度。Item create/update 继续兼容可空的
|
||||||
submit. Switching an item to a non-assets type clears the stored basis.
|
`assetPriorApprovalBasis`:创建时缺失或空白落 null;更新时 omitted 保留
|
||||||
|
现值,显式 null/空白清空;切换到非 assets 类型仍清空。当前 PC/移动 UI
|
||||||
|
不展示或发送该字段,历史值不得因普通编辑被静默清空。
|
||||||
- `office_supplies` at or above CNY 2,000 per item shows the same non-blocking
|
- `office_supplies` at or above CNY 2,000 per item shows the same non-blocking
|
||||||
office-electronics hint on editable PC/mobile forms and read-only finance
|
office-electronics hint on editable PC/mobile forms and read-only finance
|
||||||
detail. It does not change the type or decide accounting fixed-asset status.
|
detail. It does not change the type or decide accounting fixed-asset status.
|
||||||
@@ -1027,9 +1033,9 @@ await selectApprovalRule(reimbursement.total_amount);
|
|||||||
|
|
||||||
### 4. Validation & Error Matrix
|
### 4. Validation & Error Matrix
|
||||||
|
|
||||||
- Assets item without basis -> `400 VALID_001` with
|
- Assets item without basis -> 允许创建、编辑和提交;字段缺失、数据库现值
|
||||||
`assetPriorApprovalBasis=required_for_assets`; submit returns affected item
|
为 null 或显式清空均不得产生空值解引用或 `500`,其他金额和票据门禁照常
|
||||||
IDs under `asset_prior_approval_basis_required`.
|
执行。
|
||||||
- Guard-on bind without `linkedItemId` -> `400 VALID_001` with
|
- Guard-on bind without `linkedItemId` -> `400 VALID_001` with
|
||||||
`required_when_invoice_guard_enabled`; an item outside the form remains
|
`required_when_invoice_guard_enabled`; an item outside the form remains
|
||||||
`item_not_in_reimbursement` with zero writes.
|
`item_not_in_reimbursement` with zero writes.
|
||||||
@@ -1052,9 +1058,12 @@ await selectApprovalRule(reimbursement.total_amount);
|
|||||||
guard either on or off.
|
guard either on or off.
|
||||||
- Base: guard off preserves an older draft and its auto-created link; later
|
- Base: guard off preserves an older draft and its auto-created link; later
|
||||||
disabling the guard preserves links created during a guarded rollout.
|
disabling the guard preserves links created during a guarded rollout.
|
||||||
|
- Good: assets 编辑请求 omitted 兼容字段时,数据库 null 保持 null、历史非空值
|
||||||
|
原样保留;显式 null/空白才清空。
|
||||||
- Bad: compare every positive item to a zero invoice total, silently create an
|
- Bad: compare every positive item to a zero invoice total, silently create an
|
||||||
item while explicit selection is required, use JavaScript floating-point
|
item while explicit selection is required, use JavaScript floating-point
|
||||||
sums for money, or let repeated init erase finance account mappings.
|
sums for money, let repeated init erase finance account mappings, or 删除前置
|
||||||
|
必填校验后继续对可空值直接调用 `.trim()`。
|
||||||
|
|
||||||
### 6. Tests Required
|
### 6. Tests Required
|
||||||
|
|
||||||
@@ -1064,13 +1073,15 @@ await selectApprovalRule(reimbursement.total_amount);
|
|||||||
- Expense-type route tests assert list mapping, create/update persistence and
|
- Expense-type route tests assert list mapping, create/update persistence and
|
||||||
audit changes for `requiresInvoice`; seed tests assert the two allowances,
|
audit changes for `requiresInvoice`; seed tests assert the two allowances,
|
||||||
disabled legacy types, default-off flag and account-code preservation.
|
disabled legacy types, default-off flag and account-code preservation.
|
||||||
- Item/submit route tests cover assets create/update/submit, invoice-free zero
|
- Item/submit route tests 覆盖 assets 创建 omitted→null、编辑 null+omitted→null、
|
||||||
invoice, missing link, unlinked bound invoice, declared-over-invoice and
|
有值+omitted→保留、显式清空、切换非 assets 清空和无依据提交,并继续覆盖
|
||||||
participant/nonparticipant side effects.
|
invoice-free zero invoice、missing link、unlinked bound invoice、
|
||||||
|
declared-over-invoice 与 participant/nonparticipant side effects。
|
||||||
- Bind tests cover flag on/off, missing/foreign/successful explicit links and
|
- Bind tests cover flag on/off, missing/foreign/successful explicit links and
|
||||||
prove the guard-on path does not create a reimbursement item.
|
prove the guard-on path does not create a reimbursement item.
|
||||||
- PC/mobile Playwright covers descriptions, approval-basis input, the CNY 2,000
|
- PC/mobile Playwright covers asset 登记导向说明、批准依据输入/展示均不存在且
|
||||||
warning, allowance-only submit projection, disabled add action until every
|
保存 payload omission、CNY 2,000 warning、allowance-only submit projection、
|
||||||
|
disabled add action until every
|
||||||
selected invoice has an item, and pending-OCR binding retaining that item.
|
selected invoice has an item, and pending-OCR binding retaining that item.
|
||||||
- Run full backend Vitest with the pinned BIFF8 Python dependencies, Prisma
|
- Run full backend Vitest with the pinned BIFF8 Python dependencies, Prisma
|
||||||
validate/generate check, frontend typecheck/build, focused Playwright,
|
validate/generate check, frontend typecheck/build, focused Playwright,
|
||||||
@@ -1090,6 +1101,11 @@ if (item.amount > linkedInvoices.reduce((sum, invoice) => sum + Number(invoice.t
|
|||||||
update: { account_code_default: null }; // erases finance configuration on init
|
update: { account_code_default: null }; // erases finance configuration on init
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 旧必填校验删除后,omitted/null 会在这里变成 500。
|
||||||
|
asset_prior_approval_basis: targetApprovalBasis.trim();
|
||||||
|
```
|
||||||
|
|
||||||
#### Correct
|
#### Correct
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -1107,6 +1123,10 @@ update: { name, description, requires_invoice }; // preserve account_code_defaul
|
|||||||
create: { name, description, requires_invoice, account_code_default: null };
|
create: { name, description, requires_invoice, account_code_default: null };
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
asset_prior_approval_basis: targetApprovalBasis?.trim() || null;
|
||||||
|
```
|
||||||
|
|
||||||
## Scenario: Payment Batch Receipt Attention Count
|
## Scenario: Payment Batch Receipt Attention Count
|
||||||
|
|
||||||
### 1. Scope / Trigger
|
### 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,58 @@
|
|||||||
|
# 设计:取消报销资产事前批准强制
|
||||||
|
|
||||||
|
## 设计边界
|
||||||
|
|
||||||
|
本轮是对 B1 产品口径的最小纠偏,不新增 schema、feature flag 或审批机制。生产继续运行 `0f9f403`,新候选只改变 assets 明细的保存/提交条件、前端呈现、seed description 与事实源。
|
||||||
|
|
||||||
|
## 服务端兼容合同
|
||||||
|
|
||||||
|
`asset_prior_approval_basis` 保持 nullable,API 的 `assetPriorApprovalBasis?: string | null` 保持兼容:
|
||||||
|
|
||||||
|
| 场景 | 目标行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| 新建 assets,字段缺失/空白 | 保存 null,允许创建 |
|
||||||
|
| 编辑 assets,字段 omitted、现值为 null | 保持 null,允许编辑,不发生 null `.trim()` |
|
||||||
|
| 编辑 assets,字段 omitted、现值有值 | 保留现有值,避免隐藏 UI 后静默清空 |
|
||||||
|
| 编辑 assets,字段显式 null/空白 | 清空为 null |
|
||||||
|
| assets 切换为非 assets | 清空为 null |
|
||||||
|
| 提交 assets 且字段为空 | 不阻断,继续执行金额、票据和 workflow 门禁 |
|
||||||
|
|
||||||
|
创建/编辑仍查询费用类型以验证 enabled/category;category 只用于切换到非 assets 时清理兼容字段,不再决定必填。
|
||||||
|
|
||||||
|
删除创建/编辑必填分支时,原本被校验守卫保护的 `.trim()` 必须同时改为安全归一化:
|
||||||
|
|
||||||
|
```js
|
||||||
|
body.assetPriorApprovalBasis?.trim() || null
|
||||||
|
targetApprovalBasis?.trim() || null
|
||||||
|
```
|
||||||
|
|
||||||
|
不允许用非空断言或默认虚构文本代替 null 兼容。
|
||||||
|
|
||||||
|
## 前端数据流
|
||||||
|
|
||||||
|
- PC `ReimbursementItemEditor` 的表单模型移除 `assetPriorApprovalBasis`,不渲染输入、不参与 disabled、不进入 `onSave`。
|
||||||
|
- PC 详情保存 payload 不再写该字段,编辑初始值也不再从详情 DTO 映射。
|
||||||
|
- 移动端 `ItemFormState`、新增/编辑初始化和 mutation payload 同步移除该字段。
|
||||||
|
- PC 表格与移动卡片移除批准依据展示。API DTO 类型保留,避免对旧服务端/客户端契约做无收益破坏。
|
||||||
|
|
||||||
|
## 主数据说明
|
||||||
|
|
||||||
|
seed 继续通过 upsert 更新启用类型 description,不触碰 `account_code_default`。推荐文案:
|
||||||
|
|
||||||
|
- 设备采购:`通用设备采购;请在明细说明中写明物品名称、实际使用人或责任人、存放位置,便于资产登记。`
|
||||||
|
- 家具采购:`办公家具采购;请在明细说明中写明物品名称、实际使用人或责任人、存放位置,便于资产登记。`
|
||||||
|
- 办公电子设备:`单条明细金额达到 2,000 元的办公电子设备通常选择本类型;请在明细说明中写明物品名称、实际使用人或责任人、存放位置,便于资产登记。该门槛不等同于会计固定资产确认标准。`
|
||||||
|
|
||||||
|
## 发布与回滚
|
||||||
|
|
||||||
|
- 无 migration;init 仍可运行既有 migration status + seed 流程,必须确认 pending=none、seed 退出码 0。
|
||||||
|
- 部署后核对三个 description、B2 仍为 global on、`/ready` 和 bundle marker 为新候选。
|
||||||
|
- 公网用合成 assets 草稿验证 PC/移动保存和提交,不使用 0017 做 smoke。
|
||||||
|
- 异常时回滚应用到本轮发布前候选 `0f9f403`;数据库兼容列和已存在值不需要补偿。回滚后旧候选会恢复强制,因此 0017 在纠偏验收完成前始终不得退回。
|
||||||
|
|
||||||
|
## 风险控制
|
||||||
|
|
||||||
|
- 最大兼容风险是隐藏输入后编辑请求携带 null,误清历史值;通过前端 payload 断言和服务端 omitted-field 测试锁定。
|
||||||
|
- 最大运行时风险是删除必填校验后暴露下游 `.trim()` 的 null/undefined 假设;创建 omitted、编辑 null omitted 和编辑有值 omitted 三条路由测试必须同时覆盖。
|
||||||
|
- 最大文案风险是四处 2,000 元提示只改一半;E2E 同时覆盖 PC/移动编辑态和展示态。
|
||||||
|
- seed 失败会造成代码已取消强制但费用类型仍宣称要批准;部署验收必须同时核应用行为和三条主数据文案。
|
||||||
@@ -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,46 @@
|
|||||||
|
# 实施清单:取消报销资产事前批准强制
|
||||||
|
|
||||||
|
## 1. 规划与候选边界
|
||||||
|
|
||||||
|
- [x] PRD、design、implement 经用户审查并明确批准实施。
|
||||||
|
- [x] 确认当前生产仍运行 `0f9f403`,0017 尚未退回;0016 可由真实财务并行推进,不把真人单据用于本轮 smoke。
|
||||||
|
|
||||||
|
## 2. 服务端纠偏
|
||||||
|
|
||||||
|
- [x] 删除明细创建、编辑和提交三处 assets 批准依据强制错误。
|
||||||
|
- [x] 同步修复被原校验遮蔽的两处空值解引用:create 使用 `body.assetPriorApprovalBasis?.trim() || null`,patch 使用 `targetApprovalBasis?.trim() || null`。
|
||||||
|
- [x] 规范创建/编辑字段语义:create omitted/空白落 null;patch 现值为 null 且 omitted 保持 null、有值且 omitted 保留、显式空清除、切换非 assets 清除。
|
||||||
|
- [x] 保留 Prisma 列、请求 schema、详情响应和 API 兼容字段,不新增 migration。
|
||||||
|
|
||||||
|
## 3. PC 与移动端纠偏
|
||||||
|
|
||||||
|
- [x] PC editor 移除输入、状态、初始值、保存 payload 和 disabled 条件;详情表格移除展示。
|
||||||
|
- [x] 移动端移除 ItemFormState 字段、新建/编辑映射、保存 payload、输入、disabled 条件和卡片展示。
|
||||||
|
- [x] 四处 2,000 元提示统一删除“并填写事前批准依据”,保留原触发函数和提示级语义。
|
||||||
|
|
||||||
|
## 4. 主数据、文档与规约
|
||||||
|
|
||||||
|
- [x] 更新三个启用 assets seed description 为资产登记导向,确认 update/create 分支均落地且不写 account code。
|
||||||
|
- [x] 更新 `docs/产品口径.md` 和 reimbursement spec,删除不存在的强制审批制度与错误矩阵。
|
||||||
|
- [x] 在 reimbursement spec 记录:取消必填校验时必须检查下游依赖的非空/类型/状态假设,并覆盖真实空值路径,不能只删除前置校验。
|
||||||
|
- [x] 不修改既有 migration;migration 单测继续验证 nullable 与无破坏性操作。
|
||||||
|
|
||||||
|
## 5. 测试
|
||||||
|
|
||||||
|
- [x] 后端路由测试覆盖 assets 无依据可提交,以及创建 omitted→null、patch null+omitted→null、patch 有值+omitted→保留、显式清空、切换非 assets 清空;全部不得出现 500,B2 门禁不回退。
|
||||||
|
- [x] seed 测试断言三个 description 不含批准措辞且包含资产登记信息。
|
||||||
|
- [x] r111 E2E 覆盖 PC/移动无输入、无展示、可保存和四处新提示;明确关联与免票合同继续通过。
|
||||||
|
- [x] 运行 `cd server && npm run prisma:validate` 和聚焦 Vitest。
|
||||||
|
- [x] 运行 `cd client && npm run typecheck && npm run build` 及聚焦 Playwright。
|
||||||
|
- [x] 运行 Trellis governance、process contract、docs registry、敏感信息检查和 `git diff --check`。
|
||||||
|
|
||||||
|
## 6. 复核、提交与部署门禁
|
||||||
|
|
||||||
|
- [x] 实施后执行只读专项 review,重点核权限/提交门禁未误删、API 兼容和 seed 文案一致性。
|
||||||
|
- [ ] 用户审查通过后,以中文 commit 引用 task 名,push 与部署分别取得授权。
|
||||||
|
- [ ] 无 migration 部署仍显式记录 pre/post pending=none、init/seed 退出码 0、三条 description、B2 global on、`/ready` 和 bundle marker。
|
||||||
|
- [ ] 公网合成 assets 草稿 PC/移动验证通过并清理后,才由真实财务按已批准理由退回 0017。
|
||||||
|
|
||||||
|
## 7. 回滚
|
||||||
|
|
||||||
|
- [ ] 应用或 seed 验收失败时停止 0017,回滚应用到 `0f9f403`;不回滚数据库列、不清历史值、不改 B2 关联数据。
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 取消报销资产事前批准强制
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
纠正 B1 把不存在的公司制度做成系统强制的问题:公司当前不要求办公家具、通用设备或办公电子设备采购事前审批,报销系统不得要求员工填写“事前批准依据”才能保存或提交。保留已上线的兼容字段与历史数据,不做 migration;把 assets 类型的员工说明改为资产登记导向,要求在明细说明中写清物品、使用人或责任人、存放位置。
|
||||||
|
|
||||||
|
## Background and Confirmed Facts
|
||||||
|
|
||||||
|
- 当前生产候选 `0f9f403` 已上线 B1/B2。B1 在 PC、移动端和服务端把所有 `category=assets` 明细一律要求填写事前批准依据,但公司没有对应的事前审批制度。
|
||||||
|
- 继续保留必填或教员工填写固定话术都会制造虚假合规痕迹;改成大额门槛仍然是在实现一条不存在的规则,因此本轮直接取消强制,不设置金额触发条件。
|
||||||
|
- `ReimbursementItem.asset_prior_approval_basis` 已是可空列;生产现有明细没有已填写值,但仍按兼容原则保留列、API 字段和任何历史值,不删除、不回填、不迁移。
|
||||||
|
- `BX-20260828-0016` 是外部技术服务费用,不含 assets 明细,可在本任务并行规划/实施期间由真实财务作为真人 canary 退回。`BX-20260828-0017` 的实物是办公家具,必须等待本纠偏上线后再退回。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- 报销 assets 明细创建、编辑、提交的批准依据校验。
|
||||||
|
- PC/移动端 assets 明细编辑、保存和展示,以及办公用品 2,000 元选类提示。
|
||||||
|
- 三个启用 assets 费用类型的 seed description。
|
||||||
|
- 产品口径、报销规约、聚焦后端与端到端测试,以及无 migration 的受控部署验收。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### R1 取消所有 assets 批准依据阻断
|
||||||
|
|
||||||
|
- 服务端删除三个强制点:明细创建 `server/src/routes/reimbursements.js:3670`、明细编辑 `:3835`、提交时 `missingAssetApprovalItemIds` 检查 `:4061-4077`。
|
||||||
|
- assets 明细未提供 `assetPriorApprovalBasis` 时允许创建、编辑和提交;票据—明细关联、票面金额、正金额、费用类型启用状态及其他既有门禁保持不变。
|
||||||
|
- 创建 assets 明细时,批准依据缺失或空白保存为 null。编辑时字段未传必须保留历史值,显式传 null/空白才清空;切换为非 assets 类型继续清空该字段。不得因页面隐藏字段而在编辑日期、金额、类型或说明时静默丢失历史值。
|
||||||
|
- 删除必填校验后必须同步消除其下游的非空假设:创建路径 `server/src/routes/reimbursements.js:3686-3687` 使用 `body.assetPriorApprovalBasis?.trim() || null`;编辑路径 `:3854-3856` 使用 `targetApprovalBasis?.trim() || null`。字段 omitted、数据库现值为 null 或显式 null 均不得触发 `.trim()` TypeError/500。
|
||||||
|
|
||||||
|
### R2 移除无用途的前端输入与展示
|
||||||
|
|
||||||
|
- PC `ReimbursementItemEditor.tsx` 和移动端 `MobileReimbursementDetailPage.tsx` 不再渲染“事前批准依据”输入框,也不再因其为空禁用保存按钮。
|
||||||
|
- PC `ReimbursementDetailPage.tsx` 和移动端明细卡不再展示“事前批准依据”行。API 类型与详情响应字段保留以兼容旧客户端和历史数据,但当前 UI 不再发送该字段。
|
||||||
|
- 清理 PC/移动端编辑状态、初始值和保存 payload 中只为该输入框服务的数据流,确保普通编辑不显式发送 null 清空历史值。
|
||||||
|
|
||||||
|
### R3 把资产类型说明改为资产登记导向
|
||||||
|
|
||||||
|
- `assets_equipment`、`assets_furniture`、`assets_office_electronics` 三个启用类型不再出现“事前批准”“批准人”“批准时间”或“批准依据”。
|
||||||
|
- 类型说明要求员工在明细“说明”中写清物品名称、实际使用人或责任人、存放位置,供管理员后续人工建卡和资产登记使用。
|
||||||
|
- `assets_office_electronics` 保留单条明细 `2,000 元`选类提示以及“不等同于会计固定资产确认标准”的声明。
|
||||||
|
|
||||||
|
### R4 统一 2,000 元提示文案
|
||||||
|
|
||||||
|
- 以下四处提示统一为“该金额通常应选择办公电子设备。”,删除“并填写事前批准依据”:PC 编辑态 `ReimbursementItemEditor.tsx:188`、PC 展示态 `ReimbursementDetailPage.tsx:1282`、移动编辑态 `MobileReimbursementDetailPage.tsx:1112`、移动展示态 `MobileReimbursementDetailPage.tsx:1143`。
|
||||||
|
- 提示仍只在 `office_supplies` 且单条明细金额大于等于 `2,000 元`时显示,保持提示级、不阻断、不自动改类。
|
||||||
|
|
||||||
|
### R5 文档、规约与测试同步
|
||||||
|
|
||||||
|
- 更新 `docs/产品口径.md:194`,明确公司当前无 assets 事前审批要求,资产信息通过明细说明支持人工登记。
|
||||||
|
- 更新 `.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md:984/988/1031/1032`,删除强制批准合同和对应错误矩阵,保留 nullable/API 兼容与非 assets 清理语义。
|
||||||
|
- 反转 `server/tests/unit/routes/reimbursements.expense-standards.test.js:337-348` 的断言:缺批准依据的 assets 明细不得再返回 400,并补创建/编辑兼容测试。至少覆盖:创建 assets 且字段 omitted 返回成功并落库 null;编辑数据库现值为 null 的 assets 且字段 omitted 返回成功并保持 null;编辑有历史值的 assets 且字段 omitted 保留原值;显式 null/空白清空;切换非 assets 清空。
|
||||||
|
- `server/tests/unit/scripts/reimbursement-expense-policy-migration.test.js:17` 继续证明列为 nullable 且 migration 无破坏性操作;本轮不得新建或修改生产 migration。
|
||||||
|
- 更新 `client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts:747-804`:PC/移动端不显示批准输入或展示,保存不被阻断,四处 2,000 元提示使用新文案;票据明确关联和免票补助合同继续通过。
|
||||||
|
- 在 reimbursement spec 沉淀通用防回归规则:取消或放宽必填校验时,必须审计后续代码是否依赖该校验提供的非空、类型或状态保证,并为真实空值路径补回归测试;不得只删除前置错误分支。
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] PC 和移动端选择三个 assets 类型时均不出现“事前批准依据”输入框,未填写该字段也能保存明细并提交。
|
||||||
|
- [x] 服务端创建、编辑、提交均不再返回 `required_for_assets` 或 `asset_prior_approval_basis_required`,其他 B2 票据门禁不回退。
|
||||||
|
- [x] 创建 assets 字段 omitted、编辑 assets 现值为 null 且字段 omitted 均成功并保持 null,不出现 TypeError/500;有值 omitted、显式清空和切换非 assets 的兼容语义均有测试锁定。
|
||||||
|
- [x] 当前 UI 编辑 assets 明细不会发送该兼容字段;服务端遗漏字段保留历史值,显式清空有效,切换非 assets 仍清空。
|
||||||
|
- [x] PC/移动编辑态和展示态四处 2,000 元提示一致,仍为提示级且不自动改类型。
|
||||||
|
- [x] 三个启用 assets 类型 description 均转为资产登记导向;办公电子设备仍保留 2,000 元及非会计固定资产门槛声明。
|
||||||
|
- [x] 数据库列、Prisma schema、API 字段及历史数据保留,无 migration、无批量更新、无数据清理。
|
||||||
|
- [x] 聚焦后端测试、前端 typecheck/build、r111 E2E、Trellis/process/docs 治理和 `git diff --check` 全部通过。
|
||||||
|
- [ ] 新候选完成独立只读复核、push、无 migration 部署和公网 PC/移动验证后,才允许真实财务退回 0017。
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- 新增采购申请、审批流、金额批准门槛、数量/单价模型或自动资产建卡。
|
||||||
|
- 改造票据直接重关联、明细删除联动、B3 财务一页式或恢复行内费用标准提示。
|
||||||
|
- 修复 `deploy-existing.sh --no-deps`、PGDATA 容器内检查、admin smoke 治理等运维债。
|
||||||
|
- 清空 `asset_prior_approval_basis` 历史值、删除数据库列或修改既有 migration。
|
||||||
|
- 代替真实财务退回、审批或付款;0016 canary 和 0017 退回继续按生产写入门禁单独执行。
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
- 服务端:Prisma validate;聚焦 reimbursement expense-standards、创建/编辑路由、seed defaults 与既有 migration 测试,确认无批准依据可通过且 B2 金额/关联门禁不回退。
|
||||||
|
- 前端:typecheck、build、r111 聚焦 Playwright;同时覆盖 PC/移动编辑态和展示态的字段消失、保存可用与新提示文案。
|
||||||
|
- 数据与部署:确认无新增 migration、pre/post pending=none、init/seed exit 0、三个 description 正确、B2 仍 global on、合成 assets 草稿通过且 0017 未被当作 smoke。
|
||||||
|
- 治理:task validate、Trellis governance、process contract、docs registry、敏感信息扫描和 `git diff --check`。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "reimbursement-assets-prior-approval-correction",
|
||||||
|
"name": "reimbursement-assets-prior-approval-correction",
|
||||||
|
"title": "取消报销资产事前批准强制",
|
||||||
|
"description": "纠正不存在的 assets 事前审批制度:取消报销明细强制批准依据,隐藏无用途 UI,保留兼容字段与历史数据,并把资产类型说明改为登记导向。",
|
||||||
|
"status": "in_progress",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": "assets reimbursement validation; PC/mobile item editing; expense-type seed descriptions; reimbursement product/spec contracts",
|
||||||
|
"package": null,
|
||||||
|
"priority": "P0",
|
||||||
|
"creator": "shunli",
|
||||||
|
"assignee": "shunli",
|
||||||
|
"createdAt": "2026-08-30",
|
||||||
|
"completedAt": null,
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "main",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": "08-29-reimbursement-expense-policy-invoice-guard",
|
||||||
|
"relatedFiles": [],
|
||||||
|
"notes": "生产 0f9f403 已上线 B1/B2;0016 非 assets 可并行作为真人 canary,0017 为家具采购,必须等待本纠偏上线后再退回。无 migration、无 flag 或生产账号写入。",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
@@ -89,7 +89,6 @@ type ItemFormState = {
|
|||||||
expenseDate: string;
|
expenseDate: string;
|
||||||
amount: string;
|
amount: string;
|
||||||
description: string;
|
description: string;
|
||||||
assetPriorApprovalBasis: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type SelectedInvoiceMap = Record<string, string>;
|
type SelectedInvoiceMap = Record<string, string>;
|
||||||
@@ -331,7 +330,6 @@ export function MobileReimbursementDetailPage() {
|
|||||||
expenseDate: detail?.reimbursementDate ?? todayDate(),
|
expenseDate: detail?.reimbursementDate ?? todayDate(),
|
||||||
amount: "",
|
amount: "",
|
||||||
description: "",
|
description: "",
|
||||||
assetPriorApprovalBasis: "",
|
|
||||||
});
|
});
|
||||||
setItemError("");
|
setItemError("");
|
||||||
};
|
};
|
||||||
@@ -344,7 +342,6 @@ export function MobileReimbursementDetailPage() {
|
|||||||
expenseDate: item.expenseDate ?? detail?.reimbursementDate ?? todayDate(),
|
expenseDate: item.expenseDate ?? detail?.reimbursementDate ?? todayDate(),
|
||||||
amount: item.amount,
|
amount: item.amount,
|
||||||
description: item.description ?? "",
|
description: item.description ?? "",
|
||||||
assetPriorApprovalBasis: item.assetPriorApprovalBasis ?? "",
|
|
||||||
});
|
});
|
||||||
setItemError("");
|
setItemError("");
|
||||||
};
|
};
|
||||||
@@ -360,7 +357,6 @@ export function MobileReimbursementDetailPage() {
|
|||||||
expenseDate: form.expenseDate,
|
expenseDate: form.expenseDate,
|
||||||
amount: form.amount,
|
amount: form.amount,
|
||||||
description: form.description.trim() || undefined,
|
description: form.description.trim() || undefined,
|
||||||
assetPriorApprovalBasis: form.assetPriorApprovalBasis.trim() || null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (form.mode === "edit" && form.itemId) {
|
if (form.mode === "edit" && form.itemId) {
|
||||||
@@ -1093,29 +1089,17 @@ export function MobileReimbursementDetailPage() {
|
|||||||
onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)}
|
onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.category === "assets" ? (
|
|
||||||
<label className="col-span-2 block space-y-1 text-sm text-zinc-700">
|
|
||||||
<span>事前批准依据</span>
|
|
||||||
<Textarea
|
|
||||||
disabled={itemSaveMutation.isPending}
|
|
||||||
placeholder="填写批准人、批准时间和简短依据"
|
|
||||||
rows={2}
|
|
||||||
value={itemForm.assetPriorApprovalBasis}
|
|
||||||
onChange={(event) => setItemForm((current) => current ? { ...current, assetPriorApprovalBasis: event.target.value } : current)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
) : null}
|
|
||||||
{shouldShowOfficeElectronicsHint(
|
{shouldShowOfficeElectronicsHint(
|
||||||
expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.code,
|
expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.code,
|
||||||
itemForm.amount,
|
itemForm.amount,
|
||||||
) ? (
|
) ? (
|
||||||
<p className="col-span-2 text-xs text-amber-600">该金额通常应选择办公电子设备并填写事前批准依据。</p>
|
<p className="col-span-2 text-xs text-amber-600">该金额通常应选择办公电子设备。</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mobile-two-action-grid">
|
<div className="mobile-two-action-grid">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || (expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.category === "assets" && !itemForm.assetPriorApprovalBasis.trim()) || itemSaveMutation.isPending}
|
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending}
|
||||||
onClick={() => itemSaveMutation.mutate(itemForm)}
|
onClick={() => itemSaveMutation.mutate(itemForm)}
|
||||||
>
|
>
|
||||||
{itemSaveMutation.isPending ? "保存中..." : "保存"}
|
{itemSaveMutation.isPending ? "保存中..." : "保存"}
|
||||||
@@ -1140,10 +1124,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
{item.description ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.description}</p> : null}
|
{item.description ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.description}</p> : null}
|
||||||
{shouldShowOfficeElectronicsHint(item.expenseType?.code, item.amount) ? (
|
{shouldShowOfficeElectronicsHint(item.expenseType?.code, item.amount) ? (
|
||||||
<p className="mt-2 text-xs text-amber-600">该金额通常应选择办公电子设备并填写事前批准依据。</p>
|
<p className="mt-2 text-xs text-amber-600">该金额通常应选择办公电子设备。</p>
|
||||||
) : null}
|
|
||||||
{item.assetPriorApprovalBasis ? (
|
|
||||||
<p className="mt-2 text-xs text-zinc-500">事前批准依据:{item.assetPriorApprovalBasis}</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
{canMaintainReimbursement ? (
|
{canMaintainReimbursement ? (
|
||||||
<div className="mobile-two-action-grid mt-3">
|
<div className="mobile-two-action-grid mt-3">
|
||||||
|
|||||||
@@ -136,7 +136,6 @@ export function ReimbursementDetailPage() {
|
|||||||
expenseDate: "",
|
expenseDate: "",
|
||||||
amount: "",
|
amount: "",
|
||||||
description: "",
|
description: "",
|
||||||
assetPriorApprovalBasis: "",
|
|
||||||
});
|
});
|
||||||
const currentDetailSource = buildPcDetailSource(location.pathname, location.search);
|
const currentDetailSource = buildPcDetailSource(location.pathname, location.search);
|
||||||
const navigationState = location.state as PcDetailNavigationState | null;
|
const navigationState = location.state as PcDetailNavigationState | null;
|
||||||
@@ -291,7 +290,6 @@ export function ReimbursementDetailPage() {
|
|||||||
expenseDate: data.expenseDate,
|
expenseDate: data.expenseDate,
|
||||||
amount: data.amount,
|
amount: data.amount,
|
||||||
description: data.description.trim() || undefined,
|
description: data.description.trim() || undefined,
|
||||||
assetPriorApprovalBasis: data.assetPriorApprovalBasis.trim() || null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (itemEditor.mode === "create") {
|
if (itemEditor.mode === "create") {
|
||||||
@@ -814,7 +812,6 @@ export function ReimbursementDetailPage() {
|
|||||||
expenseTypeId: defaultExpenseTypeId,
|
expenseTypeId: defaultExpenseTypeId,
|
||||||
amount: item?.amount ?? "",
|
amount: item?.amount ?? "",
|
||||||
description: item?.description ?? "",
|
description: item?.description ?? "",
|
||||||
assetPriorApprovalBasis: item?.assetPriorApprovalBasis ?? "",
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1279,12 +1276,7 @@ export function ReimbursementDetailPage() {
|
|||||||
<div>{item.description ?? "-"}</div>
|
<div>{item.description ?? "-"}</div>
|
||||||
{shouldShowOfficeElectronicsHint(item.expenseType?.code, item.amount) ? (
|
{shouldShowOfficeElectronicsHint(item.expenseType?.code, item.amount) ? (
|
||||||
<div className="mt-1 text-xs text-amber-600">
|
<div className="mt-1 text-xs text-amber-600">
|
||||||
该金额通常应选择办公电子设备并填写事前批准依据。
|
该金额通常应选择办公电子设备。
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{item.assetPriorApprovalBasis ? (
|
|
||||||
<div className="mt-1 text-xs text-zinc-500">
|
|
||||||
事前批准依据:{item.assetPriorApprovalBasis}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{precheckStandardChecks
|
{precheckStandardChecks
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export type ItemEditorInitialValues = {
|
|||||||
expenseDate: string;
|
expenseDate: string;
|
||||||
amount: string;
|
amount: string;
|
||||||
description: string;
|
description: string;
|
||||||
assetPriorApprovalBasis: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ItemEditorSaveData = {
|
export type ItemEditorSaveData = {
|
||||||
@@ -30,7 +29,6 @@ export type ItemEditorSaveData = {
|
|||||||
expenseDate: string;
|
expenseDate: string;
|
||||||
amount: string;
|
amount: string;
|
||||||
description: string;
|
description: string;
|
||||||
assetPriorApprovalBasis: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ReimbursementItemEditorProps {
|
export interface ReimbursementItemEditorProps {
|
||||||
@@ -60,7 +58,6 @@ export function ReimbursementItemEditor({
|
|||||||
const [expenseDate, setExpenseDate] = useState(initialValues.expenseDate);
|
const [expenseDate, setExpenseDate] = useState(initialValues.expenseDate);
|
||||||
const [amount, setAmount] = useState(initialValues.amount);
|
const [amount, setAmount] = useState(initialValues.amount);
|
||||||
const [description, setDescription] = useState(initialValues.description);
|
const [description, setDescription] = useState(initialValues.description);
|
||||||
const [assetPriorApprovalBasis, setAssetPriorApprovalBasis] = useState(initialValues.assetPriorApprovalBasis);
|
|
||||||
|
|
||||||
// Reset internal state when initialValues change (e.g. switching between create/edit)
|
// Reset internal state when initialValues change (e.g. switching between create/edit)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,11 +65,9 @@ export function ReimbursementItemEditor({
|
|||||||
setExpenseDate(initialValues.expenseDate);
|
setExpenseDate(initialValues.expenseDate);
|
||||||
setAmount(initialValues.amount);
|
setAmount(initialValues.amount);
|
||||||
setDescription(initialValues.description);
|
setDescription(initialValues.description);
|
||||||
setAssetPriorApprovalBasis(initialValues.assetPriorApprovalBasis);
|
}, [initialValues.expenseTypeId, initialValues.expenseDate, initialValues.amount, initialValues.description]);
|
||||||
}, [initialValues.expenseTypeId, initialValues.expenseDate, initialValues.amount, initialValues.description, initialValues.assetPriorApprovalBasis]);
|
|
||||||
|
|
||||||
const selectedExpenseType = expenseTypes.find((item) => item.id === expenseTypeId) ?? null;
|
const selectedExpenseType = expenseTypes.find((item) => item.id === expenseTypeId) ?? null;
|
||||||
const isAssetExpense = selectedExpenseType?.category === "assets";
|
|
||||||
const numericAmount = Number(amount || 0);
|
const numericAmount = Number(amount || 0);
|
||||||
const shouldWarnOfficeEquipment =
|
const shouldWarnOfficeEquipment =
|
||||||
shouldShowOfficeElectronicsHint(selectedExpenseType?.code, numericAmount);
|
shouldShowOfficeElectronicsHint(selectedExpenseType?.code, numericAmount);
|
||||||
@@ -102,7 +97,7 @@ export function ReimbursementItemEditor({
|
|||||||
const hasPositiveAmount = Number.isFinite(numericAmount) && numericAmount > 0;
|
const hasPositiveAmount = Number.isFinite(numericAmount) && numericAmount > 0;
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
onSave({ expenseTypeId, expenseDate, amount, description, assetPriorApprovalBasis });
|
onSave({ expenseTypeId, expenseDate, amount, description });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -185,28 +180,11 @@ export function ReimbursementItemEditor({
|
|||||||
) : null}
|
) : null}
|
||||||
{shouldWarnOfficeEquipment ? (
|
{shouldWarnOfficeEquipment ? (
|
||||||
<p className="text-xs text-amber-600">
|
<p className="text-xs text-amber-600">
|
||||||
该金额通常应选择办公电子设备并填写事前批准依据。
|
该金额通常应选择办公电子设备。
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isAssetExpense ? (
|
|
||||||
<div className="space-y-1 md:col-span-4">
|
|
||||||
<label className="text-xs text-zinc-600" htmlFor="assetPriorApprovalBasis">
|
|
||||||
事前批准依据
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
data-testid="reimbursement-item-asset-approval-basis"
|
|
||||||
disabled={isPending}
|
|
||||||
id="assetPriorApprovalBasis"
|
|
||||||
onChange={(event) => setAssetPriorApprovalBasis(event.target.value)}
|
|
||||||
placeholder="填写批准人、批准时间和简短依据"
|
|
||||||
rows={2}
|
|
||||||
value={assetPriorApprovalBasis}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="space-y-1 md:col-span-4">
|
<div className="space-y-1 md:col-span-4">
|
||||||
<label className="text-xs text-zinc-600" htmlFor="itemDescription">
|
<label className="text-xs text-zinc-600" htmlFor="itemDescription">
|
||||||
说明
|
说明
|
||||||
@@ -230,8 +208,7 @@ export function ReimbursementItemEditor({
|
|||||||
!expenseTypeId ||
|
!expenseTypeId ||
|
||||||
!expenseDate ||
|
!expenseDate ||
|
||||||
!amount.trim() ||
|
!amount.trim() ||
|
||||||
!hasPositiveAmount ||
|
!hasPositiveAmount
|
||||||
(isAssetExpense && !assetPriorApprovalBasis.trim())
|
|
||||||
}
|
}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ function buildExpenseTypes() {
|
|||||||
code: "assets_office_electronics",
|
code: "assets_office_electronics",
|
||||||
name: "办公电子设备",
|
name: "办公电子设备",
|
||||||
category: "assets",
|
category: "assets",
|
||||||
description: "2,000 元及以上通常选本类型,不等同于会计固定资产标准。",
|
description: "单条明细金额达到 2,000 元的办公电子设备通常选择本类型;请在明细说明中写明物品名称、实际使用人或责任人、存放位置,便于资产登记。该门槛不等同于会计固定资产确认标准。",
|
||||||
requiresInvoice: true,
|
requiresInvoice: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
@@ -186,6 +186,7 @@ function buildReimbursementState() {
|
|||||||
failedInvoices: [buildFailedInvoice()],
|
failedInvoices: [buildFailedInvoice()],
|
||||||
statusPollCount: 0,
|
statusPollCount: 0,
|
||||||
bindRequests: [] as Array<{ reimbursementId?: string; linkedItemId?: string }>,
|
bindRequests: [] as Array<{ reimbursementId?: string; linkedItemId?: string }>,
|
||||||
|
itemSaveRequests: [] as Array<Record<string, unknown>>,
|
||||||
submitRequestCount: 0,
|
submitRequestCount: 0,
|
||||||
invoiceGuardEnabled: false,
|
invoiceGuardEnabled: false,
|
||||||
detailItems: null as null | Array<Record<string, unknown>>,
|
detailItems: null as null | Array<Record<string, unknown>>,
|
||||||
@@ -384,6 +385,26 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(pathName === `/api/reimbursements/${reimbursementId}/items` && request.method() === "POST")
|
||||||
|
|| (pathName.startsWith(`/api/reimbursements/${reimbursementId}/items/`) && request.method() === "PATCH")
|
||||||
|
) {
|
||||||
|
const payload = request.postDataJSON() as Record<string, unknown>;
|
||||||
|
state.itemSaveRequests.push(payload);
|
||||||
|
await fulfillJson(route, {
|
||||||
|
item: {
|
||||||
|
id: "asset-item-r111",
|
||||||
|
expenseTypeId: payload.expenseTypeId ?? "expense-type-assets-r111",
|
||||||
|
expenseDate: payload.expenseDate ?? "2026-05-08",
|
||||||
|
amount: payload.amount ?? "2000.00",
|
||||||
|
description: payload.description ?? null,
|
||||||
|
assetPriorApprovalBasis: null,
|
||||||
|
},
|
||||||
|
totalAmount: "2000.00",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (pathName === `/api/reimbursements/${reimbursementId}/precheck`) {
|
if (pathName === `/api/reimbursements/${reimbursementId}/precheck`) {
|
||||||
await fulfillJson(route, {
|
await fulfillJson(route, {
|
||||||
decision: "pass",
|
decision: "pass",
|
||||||
@@ -735,7 +756,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
|||||||
await page.screenshot({ path: screenshotPath("pc-reimbursement-detail.png"), fullPage: true });
|
await page.screenshot({ path: screenshotPath("pc-reimbursement-detail.png"), fullPage: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("B1/B2 在 PC 和移动端显示设备提示并要求明确关联明细", async ({ page }) => {
|
test("B1/B2 在 PC 和移动端取消批准依据并保持设备提示与明确关联", async ({ page }) => {
|
||||||
const state = buildReimbursementState();
|
const state = buildReimbursementState();
|
||||||
state.invoiceGuardEnabled = true;
|
state.invoiceGuardEnabled = true;
|
||||||
state.detailItems = [{
|
state.detailItems = [{
|
||||||
@@ -758,11 +779,18 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
|||||||
await login(page);
|
await login(page);
|
||||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||||
|
|
||||||
await expect(page.getByText("该金额通常应选择办公电子设备并填写事前批准依据。")).toBeVisible();
|
await expect(page.getByText("该金额通常应选择办公电子设备。")).toBeVisible();
|
||||||
await page.getByTestId("reimbursement-item-add").click();
|
await page.getByTestId("reimbursement-item-add").click();
|
||||||
|
await page.locator("#expenseTypeId").selectOption("expense-type-office-supplies-r111");
|
||||||
|
await page.getByTestId("reimbursement-item-amount").fill("2000.00");
|
||||||
|
await expect(page.getByText("该金额通常应选择办公电子设备。")).toHaveCount(2);
|
||||||
await page.locator("#expenseTypeId").selectOption("expense-type-assets-r111");
|
await page.locator("#expenseTypeId").selectOption("expense-type-assets-r111");
|
||||||
await expect(page.getByTestId("reimbursement-item-asset-approval-basis")).toBeVisible();
|
await expect(page.getByTestId("reimbursement-item-asset-approval-basis")).toHaveCount(0);
|
||||||
await expect(page.getByText("2,000 元及以上通常选本类型,不等同于会计固定资产标准。")).toBeVisible();
|
await expect(page.getByText(/请在明细说明中写明物品名称、实际使用人或责任人、存放位置/)).toBeVisible();
|
||||||
|
await page.getByTestId("reimbursement-item-description").fill("合成设备,测试员工使用,测试位置");
|
||||||
|
await page.getByTestId("reimbursement-item-save").click();
|
||||||
|
await expect.poll(() => state.itemSaveRequests.length).toBe(1);
|
||||||
|
expect(state.itemSaveRequests[0]).not.toHaveProperty("assetPriorApprovalBasis");
|
||||||
|
|
||||||
await page.getByTestId("reimbursement-invoice-add").click();
|
await page.getByTestId("reimbursement-invoice-add").click();
|
||||||
const invoiceDialog = page.getByTestId("reimbursement-invoice-dialog");
|
const invoiceDialog = page.getByTestId("reimbursement-invoice-dialog");
|
||||||
@@ -788,10 +816,17 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
|||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
|
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
|
||||||
await expect(page.getByText("该金额通常应选择办公电子设备并填写事前批准依据。")).toBeVisible();
|
await expect(page.getByText("该金额通常应选择办公电子设备。")).toBeVisible();
|
||||||
await page.getByRole("button", { name: "新增", exact: true }).click();
|
await page.getByRole("button", { name: "新增", exact: true }).click();
|
||||||
|
await page.getByLabel("费用类型").selectOption("expense-type-office-supplies-r111");
|
||||||
|
await page.getByRole("textbox", { name: "金额", exact: true }).fill("2000.00");
|
||||||
|
await expect(page.getByText("该金额通常应选择办公电子设备。")).toHaveCount(2);
|
||||||
await page.getByLabel("费用类型").selectOption("expense-type-assets-r111");
|
await page.getByLabel("费用类型").selectOption("expense-type-assets-r111");
|
||||||
await expect(page.getByText("事前批准依据", { exact: true })).toBeVisible();
|
await expect(page.getByText("事前批准依据", { exact: true })).toHaveCount(0);
|
||||||
|
await page.getByRole("textbox", { name: "说明", exact: true }).fill("合成设备,测试员工使用,测试位置");
|
||||||
|
await page.getByRole("button", { name: "保存", exact: true }).click();
|
||||||
|
await expect.poll(() => state.itemSaveRequests.length).toBe(2);
|
||||||
|
expect(state.itemSaveRequests[1]).not.toHaveProperty("assetPriorApprovalBasis");
|
||||||
|
|
||||||
state.invoiceGuardEnabled = false;
|
state.invoiceGuardEnabled = false;
|
||||||
state.invoices = [];
|
state.invoices = [];
|
||||||
|
|||||||
+1
-1
@@ -191,6 +191,6 @@
|
|||||||
### R-7 报销费用类型与票据门禁
|
### R-7 报销费用类型与票据门禁
|
||||||
- “客户招待”统一为“业务招待费”;“培训会议”停止新选并拆为“会议费”“培训费”;“IT 硬件”停止新选,低值配件使用“办公用品”,需登记的选择“办公电子设备”。停用类型保留历史,不迁移旧明细语义。
|
- “客户招待”统一为“业务招待费”;“培训会议”停止新选并拆为“会议费”“培训费”;“IT 硬件”停止新选,低值配件使用“办公用品”,需登记的选择“办公电子设备”。停用类型保留历史,不迁移旧明细语义。
|
||||||
- 办公用品与办公电子设备按单条明细金额 `2,000 元`作选类提示;该金额不等同于会计固定资产确认标准。达到金额仍选择办公用品时只提示员工和财务复核,不自动改类或阻断提交。
|
- 办公用品与办公电子设备按单条明细金额 `2,000 元`作选类提示;该金额不等同于会计固定资产确认标准。达到金额仍选择办公用品时只提示员工和财务复核,不自动改类或阻断提交。
|
||||||
- assets 个人报销属于经事前批准的例外;明细必须填写批准人、批准时间和简短依据。当前不强制关联采购申请,也不自动建资产卡。
|
- 公司当前不要求 assets 类个人报销事前审批;员工应在明细说明中写清物品名称、实际使用人或责任人、存放位置,供管理员后续人工登记。当前不强制关联采购申请,也不自动建资产卡。
|
||||||
- 伙食补助费和市内交通补助费不要求发票;其他首期启用费用类型默认需要有效票据。免票不等于免除补助算式、业务说明、审批或必要附件。
|
- 伙食补助费和市内交通补助费不要求发票;其他首期启用费用类型默认需要有效票据。免票不等于免除补助算式、业务说明、审批或必要附件。
|
||||||
- 票据—明细强关联和票面金额提交门禁使用独立双闸逐步启用。开启时每张有效发票必须明确选择明细,仅对需票明细校验缺票及申报金额高于票面合计;关闭只回退门禁,不删除已建立的关联数据。
|
- 票据—明细强关联和票面金额提交门禁使用独立双闸逐步启用。开启时每张有效发票必须明确选择明细,仅对需票明细校验缺票及申报金额高于票面合计;关闭只回退门禁,不删除已建立的关联数据。
|
||||||
|
|||||||
@@ -3667,15 +3667,6 @@ async function reimbursementRoutes(fastify) {
|
|||||||
details: { expenseTypeId: "invalid_or_disabled" },
|
details: { expenseTypeId: "invalid_or_disabled" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (expenseType.category === "assets" && !body.assetPriorApprovalBasis?.trim()) {
|
|
||||||
throw new AppError({
|
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 400,
|
|
||||||
details: { assetPriorApprovalBasis: "required_for_assets" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = await tx.reimbursementItem.create({
|
const created = await tx.reimbursementItem.create({
|
||||||
data: {
|
data: {
|
||||||
reimbursement_id: reimbursementId,
|
reimbursement_id: reimbursementId,
|
||||||
@@ -3684,7 +3675,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
amount: body.amount,
|
amount: body.amount,
|
||||||
description: body.description,
|
description: body.description,
|
||||||
asset_prior_approval_basis:
|
asset_prior_approval_basis:
|
||||||
expenseType.category === "assets" ? body.assetPriorApprovalBasis.trim() : null,
|
expenseType.category === "assets" ? body.assetPriorApprovalBasis?.trim() || null : null,
|
||||||
created_by: request.employee.id,
|
created_by: request.employee.id,
|
||||||
updated_by: request.employee.id,
|
updated_by: request.employee.id,
|
||||||
},
|
},
|
||||||
@@ -3827,15 +3818,6 @@ async function reimbursementRoutes(fastify) {
|
|||||||
parsedBody.data.assetPriorApprovalBasis === undefined
|
parsedBody.data.assetPriorApprovalBasis === undefined
|
||||||
? item.asset_prior_approval_basis
|
? item.asset_prior_approval_basis
|
||||||
: parsedBody.data.assetPriorApprovalBasis;
|
: parsedBody.data.assetPriorApprovalBasis;
|
||||||
if (expenseType.category === "assets" && !(targetApprovalBasis ?? "").trim()) {
|
|
||||||
throw new AppError({
|
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 400,
|
|
||||||
details: { assetPriorApprovalBasis: "required_for_assets" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await tx.reimbursementItem.update({
|
const updated = await tx.reimbursementItem.update({
|
||||||
where: { id: parsedItemId.data },
|
where: { id: parsedItemId.data },
|
||||||
data: {
|
data: {
|
||||||
@@ -3853,7 +3835,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
: {}),
|
: {}),
|
||||||
asset_prior_approval_basis:
|
asset_prior_approval_basis:
|
||||||
expenseType.category === "assets"
|
expenseType.category === "assets"
|
||||||
? targetApprovalBasis.trim()
|
? targetApprovalBasis?.trim() || null
|
||||||
: null,
|
: null,
|
||||||
updated_by: request.employee.id,
|
updated_by: request.employee.id,
|
||||||
},
|
},
|
||||||
@@ -4029,7 +4011,6 @@ async function reimbursementRoutes(fastify) {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
amount: true,
|
amount: true,
|
||||||
asset_prior_approval_basis: true,
|
|
||||||
expense_type: {
|
expense_type: {
|
||||||
select: { code: true, category: true, requires_invoice: true },
|
select: { code: true, category: true, requires_invoice: true },
|
||||||
},
|
},
|
||||||
@@ -4058,25 +4039,6 @@ async function reimbursementRoutes(fastify) {
|
|||||||
details: { items: "positive_amount_required", itemIds: invalidAmountItems },
|
details: { items: "positive_amount_required", itemIds: invalidAmountItems },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const missingAssetApprovalItemIds = reimbursementItems
|
|
||||||
.filter(
|
|
||||||
(item) =>
|
|
||||||
item.expense_type.category === "assets" &&
|
|
||||||
!item.asset_prior_approval_basis?.trim(),
|
|
||||||
)
|
|
||||||
.map((item) => item.id);
|
|
||||||
if (missingAssetApprovalItemIds.length) {
|
|
||||||
throw new AppError({
|
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 400,
|
|
||||||
details: {
|
|
||||||
items: "asset_prior_approval_basis_required",
|
|
||||||
itemIds: missingAssetApprovalItemIds,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const invoiceGuardGate = await evaluateFeatureFlag({
|
const invoiceGuardGate = await evaluateFeatureFlag({
|
||||||
prisma: tx,
|
prisma: tx,
|
||||||
config: fastify.config,
|
config: fastify.config,
|
||||||
|
|||||||
@@ -47,13 +47,13 @@ const ENABLED_EXPENSE_TYPES = [
|
|||||||
{ code: "business_marketing", name: "营销投放", category: "business", description: "经批准的营销推广或广告投放费用;请上传正式票据并说明渠道和期间。" },
|
{ code: "business_marketing", name: "营销投放", category: "business", description: "经批准的营销推广或广告投放费用;请上传正式票据并说明渠道和期间。" },
|
||||||
{ code: "business_meeting", name: "会议费", category: "business", description: "经批准举办或参加会议发生的费用;请上传正式票据并说明会议事项。" },
|
{ code: "business_meeting", name: "会议费", category: "business", description: "经批准举办或参加会议发生的费用;请上传正式票据并说明会议事项。" },
|
||||||
{ code: "business_training_fee", name: "培训费", category: "business", description: "经批准参加或组织培训发生的费用;请上传正式票据并说明培训事项。" },
|
{ code: "business_training_fee", name: "培训费", category: "business", description: "经批准参加或组织培训发生的费用;请上传正式票据并说明培训事项。" },
|
||||||
{ code: "assets_equipment", name: "设备采购", category: "assets", description: "经事前批准由个人垫付的通用设备采购;请填写批准人、批准时间和可核验依据。" },
|
{ code: "assets_equipment", name: "设备采购", category: "assets", description: "通用设备采购;请在明细说明中写明物品名称、实际使用人或责任人、存放位置,便于资产登记。" },
|
||||||
{ code: "assets_furniture", name: "家具采购", category: "assets", description: "经事前批准由个人垫付的办公家具采购;请填写批准人、批准时间和可核验依据。" },
|
{ code: "assets_furniture", name: "家具采购", category: "assets", description: "办公家具采购;请在明细说明中写明物品名称、实际使用人或责任人、存放位置,便于资产登记。" },
|
||||||
{
|
{
|
||||||
code: "assets_office_electronics",
|
code: "assets_office_electronics",
|
||||||
name: "办公电子设备",
|
name: "办公电子设备",
|
||||||
category: "assets",
|
category: "assets",
|
||||||
description: "单条明细金额达到 2,000 元的办公电子设备通常选择本类型,并填写事前批准依据。该门槛不等同于会计固定资产确认标准。",
|
description: "单条明细金额达到 2,000 元的办公电子设备通常选择本类型;请在明细说明中写明物品名称、实际使用人或责任人、存放位置,便于资产登记。该门槛不等同于会计固定资产确认标准。",
|
||||||
},
|
},
|
||||||
{ code: "utilities", name: "水电费", category: "other", description: "办公或生产经营相关的水电费用;请上传正式票据并说明费用期间。" },
|
{ code: "utilities", name: "水电费", category: "other", description: "办公或生产经营相关的水电费用;请上传正式票据并说明费用期间。" },
|
||||||
{ code: "rent", name: "房租租赁费", category: "other", description: "经批准的场地、设备或其他租赁费用;请上传正式票据并说明租赁事项和期间。" },
|
{ code: "rent", name: "房租租赁费", category: "other", description: "经批准的场地、设备或其他租赁费用;请上传正式票据并说明租赁事项和期间。" },
|
||||||
|
|||||||
@@ -175,20 +175,49 @@ async function buildApp({
|
|||||||
],
|
],
|
||||||
paymentBatchItem = null,
|
paymentBatchItem = null,
|
||||||
paymentActors = [],
|
paymentActors = [],
|
||||||
|
itemRecord = {
|
||||||
|
id: "77777777-7777-4777-8777-777777777777",
|
||||||
|
expense_type_id: "88888888-8888-4888-8888-888888888888",
|
||||||
|
asset_prior_approval_basis: null,
|
||||||
|
},
|
||||||
|
expenseType = {
|
||||||
|
id: "88888888-8888-4888-8888-888888888888",
|
||||||
|
enabled: true,
|
||||||
|
category: "assets",
|
||||||
|
},
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
const tx = {
|
const tx = {
|
||||||
$queryRaw: vi.fn(async () => [reimbursement]),
|
$queryRaw: vi.fn(async () => [reimbursement]),
|
||||||
reimbursementForm: {
|
reimbursementForm: {
|
||||||
update: vi.fn(),
|
update: vi.fn(async () => ({ id: REIMBURSEMENT_ID })),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
},
|
},
|
||||||
|
expenseType: {
|
||||||
|
findUnique: vi.fn(async () => expenseType),
|
||||||
|
},
|
||||||
invoicePool: {
|
invoicePool: {
|
||||||
updateMany: vi.fn(),
|
updateMany: vi.fn(),
|
||||||
},
|
},
|
||||||
reimbursementItem: {
|
reimbursementItem: {
|
||||||
create: vi.fn(),
|
findFirst: vi.fn(async () => itemRecord),
|
||||||
update: vi.fn(),
|
aggregate: vi.fn(async () => ({ _sum: { amount: "10.00" } })),
|
||||||
|
create: vi.fn(async ({ data }) => ({
|
||||||
|
id: itemRecord.id,
|
||||||
|
expense_type_id: data.expense_type_id,
|
||||||
|
expense_date: data.expense_date,
|
||||||
|
amount: data.amount,
|
||||||
|
description: data.description ?? null,
|
||||||
|
asset_prior_approval_basis: data.asset_prior_approval_basis,
|
||||||
|
})),
|
||||||
|
update: vi.fn(async ({ data }) => ({
|
||||||
|
id: itemRecord.id,
|
||||||
|
expense_type_id: data.expense_type_id ?? itemRecord.expense_type_id,
|
||||||
|
expense_date: data.expense_date ?? new Date("2026-04-10T00:00:00.000Z"),
|
||||||
|
amount: data.amount ?? "10.00",
|
||||||
|
description: data.description ?? "合成明细",
|
||||||
|
asset_prior_approval_basis: data.asset_prior_approval_basis,
|
||||||
|
})),
|
||||||
deleteMany: vi.fn(),
|
deleteMany: vi.fn(),
|
||||||
},
|
},
|
||||||
auditLog: {
|
auditLog: {
|
||||||
@@ -1020,3 +1049,139 @@ describe("reimbursement maintenance authorization", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("reimbursement asset item nullable approval-basis compatibility", () => {
|
||||||
|
const itemId = "77777777-7777-4777-8777-777777777777";
|
||||||
|
const assetExpenseTypeId = "88888888-8888-4888-8888-888888888888";
|
||||||
|
const officeExpenseTypeId = "99999999-9999-4999-8999-999999999999";
|
||||||
|
|
||||||
|
it("creates an assets item with an omitted basis and persists null", async () => {
|
||||||
|
const { app, tx } = await buildApp({
|
||||||
|
permissionCodes: ["expense.edit_own"],
|
||||||
|
reimbursement: buildReimbursementRecord({ status: "draft" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/reimbursements/${REIMBURSEMENT_ID}/items`,
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
payload: {
|
||||||
|
expenseTypeId: assetExpenseTypeId,
|
||||||
|
expenseDate: "2026-04-10",
|
||||||
|
amount: "10.00",
|
||||||
|
description: "合成设备",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
|
expect(tx.reimbursementItem.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ asset_prior_approval_basis: null }),
|
||||||
|
}));
|
||||||
|
expect(response.json().data.item.assetPriorApprovalBasis).toBeNull();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edits an assets item whose stored basis is null while omitted and keeps null", async () => {
|
||||||
|
const { app, tx } = await buildApp({
|
||||||
|
permissionCodes: ["expense.edit_own"],
|
||||||
|
reimbursement: buildReimbursementRecord({ status: "draft" }),
|
||||||
|
itemRecord: {
|
||||||
|
id: itemId,
|
||||||
|
expense_type_id: assetExpenseTypeId,
|
||||||
|
asset_prior_approval_basis: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`,
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
payload: { amount: "20.00" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
|
expect(tx.reimbursementItem.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ asset_prior_approval_basis: null }),
|
||||||
|
}));
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves an existing assets basis when the field is omitted", async () => {
|
||||||
|
const { app, tx } = await buildApp({
|
||||||
|
permissionCodes: ["expense.edit_own"],
|
||||||
|
reimbursement: buildReimbursementRecord({ status: "draft" }),
|
||||||
|
itemRecord: {
|
||||||
|
id: itemId,
|
||||||
|
expense_type_id: assetExpenseTypeId,
|
||||||
|
asset_prior_approval_basis: "历史依据",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`,
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
payload: { amount: "20.00" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
|
expect(tx.reimbursementItem.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ asset_prior_approval_basis: "历史依据" }),
|
||||||
|
}));
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([null, " "])("clears an assets basis when explicitly sent as %j", async (assetPriorApprovalBasis) => {
|
||||||
|
const { app, tx } = await buildApp({
|
||||||
|
permissionCodes: ["expense.edit_own"],
|
||||||
|
reimbursement: buildReimbursementRecord({ status: "draft" }),
|
||||||
|
itemRecord: {
|
||||||
|
id: itemId,
|
||||||
|
expense_type_id: assetExpenseTypeId,
|
||||||
|
asset_prior_approval_basis: "历史依据",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`,
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
payload: { assetPriorApprovalBasis },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
|
expect(tx.reimbursementItem.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ asset_prior_approval_basis: null }),
|
||||||
|
}));
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears an existing basis when switching to a non-assets type", async () => {
|
||||||
|
const { app, tx } = await buildApp({
|
||||||
|
permissionCodes: ["expense.edit_own"],
|
||||||
|
reimbursement: buildReimbursementRecord({ status: "draft" }),
|
||||||
|
itemRecord: {
|
||||||
|
id: itemId,
|
||||||
|
expense_type_id: assetExpenseTypeId,
|
||||||
|
asset_prior_approval_basis: "历史依据",
|
||||||
|
},
|
||||||
|
expenseType: { id: officeExpenseTypeId, enabled: true, category: "office" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`,
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
payload: { expenseTypeId: officeExpenseTypeId },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
|
expect(tx.reimbursementItem.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
expense_type_id: officeExpenseTypeId,
|
||||||
|
asset_prior_approval_basis: null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -329,7 +329,8 @@ describe("reimbursements routes expense standard integration", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires prior approval basis for assets items", async () => {
|
it("allows assets items without an approval basis and continues submission", async () => {
|
||||||
|
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
||||||
const { app, tx } = await buildApp({
|
const { app, tx } = await buildApp({
|
||||||
reimbursementItems: [{
|
reimbursementItems: [{
|
||||||
id: "asset-item",
|
id: "asset-item",
|
||||||
@@ -340,12 +341,8 @@ describe("reimbursements routes expense standard integration", () => {
|
|||||||
|
|
||||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||||
|
|
||||||
expect(response.statusCode).toBe(400);
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
expect(response.json().error.details).toEqual({
|
expect(tx.workflowInstance.create).toHaveBeenCalledTimes(1);
|
||||||
items: "asset_prior_approval_basis_required",
|
|
||||||
itemIds: ["asset-item"],
|
|
||||||
});
|
|
||||||
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,23 @@ describe("reimbursement expense policy defaults", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses asset-registration guidance without inventing a prior-approval policy", () => {
|
||||||
|
const assetTypes = ENABLED_EXPENSE_TYPES.filter((item) => item.category === "assets");
|
||||||
|
expect(assetTypes.map((item) => item.code)).toEqual([
|
||||||
|
"assets_equipment",
|
||||||
|
"assets_furniture",
|
||||||
|
"assets_office_electronics",
|
||||||
|
]);
|
||||||
|
for (const expenseType of assetTypes) {
|
||||||
|
expect(expenseType.description).toContain("物品名称");
|
||||||
|
expect(expenseType.description).toContain("实际使用人或责任人");
|
||||||
|
expect(expenseType.description).toContain("存放位置");
|
||||||
|
expect(expenseType.description).not.toMatch(/事前批准|批准人|批准时间|批准依据/);
|
||||||
|
}
|
||||||
|
expect(assetTypes.find((item) => item.code === "assets_office_electronics")?.description)
|
||||||
|
.toContain("不等同于会计固定资产确认标准");
|
||||||
|
});
|
||||||
|
|
||||||
it("seeds the invoice guard disabled by default", () => {
|
it("seeds the invoice guard disabled by default", () => {
|
||||||
expect(DEFAULT_FEATURE_FLAGS).toContainEqual({
|
expect(DEFAULT_FEATURE_FLAGS).toContainEqual({
|
||||||
flag_key: "reimbursement_invoice_guard_v1",
|
flag_key: "reimbursement_invoice_guard_v1",
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户