Compare commits

..

No commits in common. "dc5e2e934b937f7071f29d4b5bb2017e77c233c3" and "a7ff42959602f251537507551cd3c4150eab437a" have entirely different histories.

12 changed files with 20 additions and 2379 deletions

View File

@ -227,14 +227,17 @@ flowchart TD
```mermaid
flowchart TD
A[查询客户及待缴账单] --> B[选择账单与核销方式]
B --> C[服务端锁定账单与账户]
C --> D[重算本金、违约金、预存抵扣和渠道实收]
D --> E{支付渠道是否已确认}
E -->|现金柜台| F[原子写支付主明细、账户流水和账单投影]
E -->|未接通的非现金渠道| G[阻断直接销账]
F --> H[返回支付批次与金额拆分]
H --> I[按显式支付记录结账]
I --> J[进入红冲、发票和对账流程]
B --> C[选择支付渠道]
C --> D{支付方式}
D -->|柜台现金/POS/扫码| E[现场收费]
D -->|微信/支付宝/聚合支付| F[渠道下单]
D -->|预存款/余额抵扣| G[账户余额核销]
E --> H[更新营业账状态]
F --> I[等待异步回调确认]
G --> H
I --> H
H --> J[生成收费记录与凭证]
J --> K[进入发票/对账流程]
```
### 关键规则
@ -244,17 +247,10 @@ flowchart TD
3. 线上支付必须以回调或查询确认结果为准,不得以发起状态直接记账。
4. 支付能力由 `SYS-009` 提供SYS-002 负责账单核销与业务状态回写。
5. 当前实现侧已确认 `PayCeb` 的欠费查询、缴费处理基础闭环可用,但代理收费对账仍为预留能力;正式文档不得将实时收费对账写成已闭环能力。
6. 柜台收费必须通过 `POST /business/charge/counter-charge/submit` 一次提交全部账单;通用营业账更新不得把未收账单改为已收。
7. 柜台应收固定按“本金 + 违约金”计算;支付事实分别保存渠道实收、预存抵扣和多缴转预存,柜员结账只汇总渠道实收。
8. 客户端只提供业务请求号、账单范围和金额意图;收费员、收费时间和权限范围由服务端登录上下文确定。
9. 当前新链路只允许现金直接完成。POS、扫码和其他非现金方式必须在真实渠道确认接通后再开放不得仅凭前端选择销账。
### 核心数据
- `biz_charge``biz_charge_detail`:待缴与已缴账单主明细。
- `biz_payment_record``biz_payment_record_detail`:支付事实、批次、幂等请求与金额拆分。
- `biz_account``biz_account_log`:预存余额和不可缺失的账户变动流水。
- `biz_settle_record``biz_settle_record_detail`:柜员结账主明细和红冲状态源。
- `biz_collection`:托收/代收主表。
- `biz_withholding`:代扣/托收主表。
- `bk_transaction`:渠道交易流水。
@ -266,13 +262,12 @@ flowchart TD
#### 柜台结账
- 旧系统将“柜台收费”和“柜台结账”拆分为两个菜单,结账阶段包含未结/已结查询、结账红冲、追加抄表和打印动作。
- 当前实现已形成“收费记录 → 显式选择结账 → 已结/未结红冲 → 查询”的主闭环;打印、补打和现金盘点仍按后续工作包补齐
- 当前设计可继续采用统一收费核销模型,但必须补出“收费记录 → 班结结果 → 打印/红冲/查询”的业务闭环,避免柜面日终处理缺口
- 迁移时需保留结账时间、结账人、网点、收费汇总口径和结账后红冲痕迹,保证财务对账与审计连续。
- 当前正式实现中,柜台红冲分为已结账红冲与未结账红冲两条路径:
- 已结账红冲以 `biz_settle_record_detail` 为红冲状态源,要求收费记录已结账且结账明细为正常状态,红冲后同步更新结账明细、支付主单和营业账结清状态。
- 未结账红冲允许 `CHARGE_PAYMENT` 账单收费和 `DEPOSIT_TOPUP` 预存充值两类柜台收款记录。前者恢复营业账和预存抵扣,后者扣减账户余额;两类都写正式反向支付流水,原支付主单标记为 `REVERSED`,并写入未结账红冲投影用于查询。
- 未结账红冲仅允许柜台收费、账单缴费、收款方向、未结账且未绑定结账单的收费记录;执行正式反向支付流水后,将原支付主单标记为 `REVERSED`,将营业账恢复为未收费,并写入未结账红冲投影表用于红冲记录查询。
- 已结账与未结账收费记录不得混批红冲,前端应在未结账列表提供单笔红冲入口,后端仍以支付主单状态进行最终校验。
- `DEPOSIT_TOPUP` 预存记录按已结账/未结账状态进入各自更新路径,扣减账户余额并写 `DEPOSIT_REFUND/OUT` 反向流水;原因必填且重复红冲由唯一约束和状态条件共同阻断。
#### 账单打印服务
@ -294,8 +289,8 @@ flowchart TD
### 落地边界
- **已落地**:营业账主明细、支付主明细、原子多账单柜台收费、幂等预存充值、账户行锁与流水、显式支付记录结账、已结/未结账单与预存红冲、支付汇总和历史查询
- **部分落地**非现金柜台渠道仍需真实渠道确认后开放;打印、补打、柜员现金盘点和更完整的支付域 outbox 状态机尚未纳入本轮 P0
- **已落地**:营业账主明细、交易流水、回调、异常、托收/代扣主对象
- **部分落地**柜台班结、部分收费汇总类对象可能通过业务流程与报表实现,不一定存在独立表;柜台未结账红冲已落地为正式反向支付流水 + 状态回写 + 查询投影
- **文档先行**:实时收费汇总类台账暂不表述为已确认独立实体表。
<a id="mod-rev-004"></a>

View File

@ -430,37 +430,9 @@ retrieval_priority: P0
| 接口编号 | IF-REV-006 |
| 归属模块 | REV-003 |
| 请求方式 | POST |
| 请求路径 | `/business/charge/counter-charge/submit` |
| 请求路径 | `/admin-api/revenue/collection/create` |
| 功能描述 | 处理柜台收费、预存抵扣、渠道收款确认与账单核销 |
| 核心表 | `biz_charge*``biz_payment_record*``biz_account*``biz_settle_record*` |
当前柜台子接口:
| 场景 | 方法与路径 | 请求关键字段 | 服务端约束 |
|------|------------|--------------|------------|
| 多账单柜台收费 | `POST /business/charge/counter-charge/submit` | `requestId``chargeIds``expectedReceivableAmount``actualPayAmount``usePrepay``chargeWay` | 同一事务锁账单和账户、重算金额、写支付批次、余额流水和账单投影;当前只允许现金直接完成 |
| 无欠费预存充值 | `POST /business/charge/counter-topup` | `requestId``custId``amount``chargeWay``remark` | 以请求号幂等;收费员和时间由服务端生成;账户加锁后写余额和流水 |
| 柜员结账 | `POST /business/charge/counter-settle/confirm` | `paymentRecordIds``settleTime``remark` | 只结清显式 ID记录必须属于当前登录收费员且保持未结账金额汇总 `channel_amount`,历史空值回退 `payment_amount` |
| 柜台红冲 | `POST /business/charge/counter-settle/red-flush` | `paymentRecordIds`、必填 `reason` | 按已结/未结和账单收费/预存充值分流,写正式反向支付及账户流水,不允许混批或重复红冲 |
| 收费汇总 | `GET /business/charge/payment-summary` | 收费员/网点与时间范围 | 分别返回渠道实收、预存抵扣、本金、违约金及按收费方式汇总 |
柜台收费请求示例:
```json
{
"requestId": "0f70be44-7182-4f69-a4af-8aa7c43548b1",
"chargeIds": [1001, 1002],
"expectedReceivableAmount": 120.50,
"actualPayAmount": 70.50,
"usePrepay": true,
"chargeWay": 1,
"remark": "柜台收费"
}
```
返回的金额口径固定为:`totalReceivableAmount = channelAmount + prepayAmount - overpayTopupAmount``paymentRecordIds` 为后续结账、红冲和审计的权威范围,`paymentBatchNo` 用于关联一次多账单收费。
通用 `PUT /business/charge/update` 不再接受未收转已收、已收转未收等财务状态迁移;通用账户更新也不得直接改变预存余额。客户端提供的收费员、收费时间或账户余额不作为交易权威数据。
| 核心表 | `biz_collection``biz_charge``bk_transaction` |
### IF-REV-007 账务调整接口
@ -1814,18 +1786,18 @@ sequenceDiagram
| 场景 | 请求约束 | 状态变化 | 查询出口 |
| :--- | :--- | :--- | :--- |
| 已结账红冲 | 所有 `paymentRecordIds` 必须存在正常 `biz_settle_record_detail`,支付主单为 `SETTLED` | 写正式反向支付流水;结账明细标记红冲;原支付主单标记 `REVERSED`;营业账清除结账态 | 红冲记录页从结账明细读取 |
| 未结账红冲 | 所有 `paymentRecordIds` 必须为柜台收款方向、`UNSETTLED`、未绑定 `settle_id`,业务场景为 `CHARGE_PAYMENT``DEPOSIT_TOPUP` | 写正式反向支付流水并将原支付主单标记 `REVERSED`;账单收费恢复营业账和预存抵扣,预存充值扣减账户余额;写入 `biz_counter_unsettled_red_flush_record` | 红冲记录页从未结账红冲投影读取 |
| 未结账红冲 | 所有 `paymentRecordIds` 必须为柜台收费、账单缴费、收款方向、`UNSETTLED`、未绑定 `settle_id` | 写正式反向支付流水;原支付主单标记 `REVERSED`;营业账恢复未收费;写入 `biz_counter_unsettled_red_flush_record` | 红冲记录页从未结账红冲投影读取 |
接口拒绝以下情况:
- 已结账与未结账支付主单混批提交。
- 未结账记录不是允许的柜台账单缴费/预存充值,或缺少对应的可恢复营业账/可扣减账户余额
- 未结账记录不是柜台账单缴费,或缺少可恢复营业账。
- 原支付主单已红冲、已结账状态并发变化,或已存在未结账红冲投影。
- 当前登录收费员尝试红冲其他收费员的收费记录。
前端约束:
- 柜台结账“未结账”列表对`paymentRecordId` 且业务场景为 `CHARGE_PAYMENT``DEPOSIT_TOPUP` 的记录提供红冲入口,最终资格仍由服务端状态校验决定
- 柜台结账“未结账”列表对单笔可红冲收费记录提供红冲按钮,仅对有 `paymentRecordId`、有 `chargeId` 且业务场景为 `CHARGE_PAYMENT` 的行启用
- 柜台结账“已结账”明细继续提供已结账红冲入口。
- 两类入口都必须采集非空红冲原因后再提交。

View File

@ -1,70 +0,0 @@
# 柜台收费缴费后连续预存修复验证
## 根因
单客户账单收费成功后,页面保留收讫展示行,并将其标记为 `displayChargeState: 'settled'`;同时已缴账单仍保留在 `selectedChargeRows` 中。
`noArrearsTopupMode` 要求不存在 `settled` 展示行,因此收费按钮持续禁用,用户无法在缴清账单后立即继续办理预存。保留的选中行还会让上一次账单金额继续参与金额计算。
## 修复
- 单客户普通账单收费成功后,将收讫行标记为只读 `history` 展示。
- 保留 `payState: 1``payStateName: '收讫'``displayChargeType: 'bill'`,确保账单不可重复选择且详情链路不变。
- 收费成功后清空 `selectedChargeRows`,由现有金额同步逻辑清空上一次实收金额。
- 页面无待缴账单且仅存在历史展示行时,自动进入 `noArrearsTopupMode`,后续提交调用预存接口。
- 集收号收费成功后的 `settled` 展示和选中状态保持原样,未改变其批量收费约束。
## TDD 证据
### RED
```bash
node --test tests/operatingCharges/counterChargingPersistentTopup.test.mjs
```
结果:退出码 `1`9 个测试中 7 个通过、2 个失败。失败分别证明单客户收费成功后未切换为历史展示,以及已缴账单未从选中数据中清除。
### GREEN
```bash
node --test tests/operatingCharges/counterChargingPersistentTopup.test.mjs
```
结果:退出码 `0`9/9 通过。
## 回归验证
### 柜台收费既有测试
```bash
pnpm test:counter-charging
```
结果:退出码 `0`4/4 通过。
### 组合测试
```bash
node --test tests/operatingCharges/counterChargingPersistentTopup.test.mjs tests/operatingCharges/counterChargingInventory.test.mjs
```
结果:退出码 `0`13/13 通过。
### 前端构建
```bash
NODE_OPTIONS=--max-old-space-size=8192 pnpm build:dev
```
结果:退出码 `0`,输出 `Build successful. Please see dist directory`。构建过程仅出现项目既有的 SVG symbolId 和 Rollup 注释警告。
## 代码基线
- 前端分支:`fix/counter-topup-detail`
- RED 测试提交:`188ba232`
- 连续预存修复提交:`3c5d6545`
- 验证日期2026-07-14
## 约束
按用户要求,未运行 `vue-tsc``pnpm ts:check` 或任何会调用 `vue-tsc` 的命令。

View File

@ -1,74 +0,0 @@
# 柜台收费预存款详情修复验证
## 根因
柜台收费页面的预存款展示行使用支付记录 ID但“详情”按钮统一调用账单详情接口 `/business/charge/get`。支付记录 ID 被误作账单 ID 后,接口无法返回对应预存业务数据,账单详情弹窗中的字段全部显示为 `--`
## 修复
- 按 `displayChargeType` 分流普通账单与预存款详情。
- 预存款不再调用账单详情接口,改用专用弹窗展示客户、支付和余额快照。
- 历史预存记录保留 `lastDeposit``deposit`、预存金额、收费时间和收费方式。
- 刚办理成功的预存优先使用重新查询到的持久化记录;查询未恢复记录时使用成功响应降级展示。
- 普通账单继续使用原 `/business/charge/get` 详情链路。
- 金额或余额为 `0` 时显示 `0.00`,仅空值显示 `--`
## TDD 证据
### RED
命令:
```bash
node --test tests/operatingCharges/counterChargingPersistentTopup.test.mjs
```
结果:退出码 `1`,共 7 个测试,原有 4 个通过,新增 3 个失败。失败分别证明缺少预存款详情分流、专用字段与余额映射、零值格式化。
### GREEN
命令:
```bash
node --test tests/operatingCharges/counterChargingPersistentTopup.test.mjs
```
结果:退出码 `0`7/7 通过。
## 回归验证
### 柜台收费既有测试
```bash
pnpm test:counter-charging
```
结果:退出码 `0`4/4 通过。
### 组合测试
```bash
node --test tests/operatingCharges/counterChargingPersistentTopup.test.mjs tests/operatingCharges/counterChargingInventory.test.mjs
```
结果:退出码 `0`11/11 通过。
### 前端构建
```bash
NODE_OPTIONS=--max-old-space-size=8192 pnpm build:dev
```
结果:退出码 `0`,输出 `Build successful. Please see dist directory`。构建过程中存在项目既有的 SVG symbolId 和 Rollup 注释警告,但未导致构建失败。
## 代码基线
- 前端分支:`fix/counter-topup-detail`
- RED 测试提交:`2aeecaa8`
- 余额快照提交:`83142c72`
- 专用详情实现提交:`c6941001`
- 验证日期2026-07-14
## 约束
按用户要求,未运行 `vue-tsc``pnpm ts:check` 或任何会调用 `vue-tsc` 的命令。

View File

@ -1,113 +0,0 @@
# REV-003 营业收费 P0 审计记录
## 1. 记录信息
- 审计日期2026-07-14 至 2026-07-15
- 后端基线:`5fa11d5392f759be8560b5c696ace2bc814b2faf`
- 前端基线:`bd515cc3ade9a95a5ccb884f87d5625ebe3131b9`
- 文档基线:`8617af66fd45e6889bc02bebb4ba5fa95fc93cde`
- 审计范围:柜台收费、预存充值与抵扣、支付主明细、柜员结账、红冲、银行冲正、预存审批回调、前端收费和结账页面
- 执行约束:未运行 `vue-tsc`
## 2. 总体结论
基线审计时,营业收费虽已有客户查询、欠费查询、柜台收费、预存充值、支付记录、柜员结账和红冲骨架,但不能按财务级闭环验收通过。问题集中在金额口径、跨账单事务、结账范围、渠道确认、余额并发、幂等和反向流水。
截至 2026-07-15本记录列出的 9 项 P0 已完成代码整改并通过定向测试、后端编译和前端 Vite 构建。新的柜台收费主链路达到“服务端原子提交、请求幂等、账户与账单加锁、金额拆分可对账、显式记录结账、账单与预存可红冲”的首批上线条件;非现金渠道、打印/补打和柜员现金盘点仍不在本轮 P0 范围内。
## 3. P0 缺陷清单
### P0-01 应收金额口径断裂
`biz_charge.extended_amount` 的实体定义是不含违约金的本金金额,`late_fee` 独立保存;现有柜台支付却以 `extended_amount` 作为支付总额,再在支付明细中执行 `extended_amount - late_fee`。真实开账后产生违约金时,存在漏收违约金并错误压缩本金的风险。
统一口径:
```text
应收总额 = 本金 + 违约金
渠道实收 + 预存抵扣 = 应收总额 + 多缴转预存
```
### P0-02 多账单收费与多缴转预存不原子
前端逐账单调用 `PUT /business/charge/update`,全部成功后才单独调用预存充值。任一中间请求失败会形成部分账单已缴、部分账单未缴,或者账单已销但多缴未进入预存的状态。
### P0-03 通用营业账更新可作为支付入口
通用 `ChargeSaveReqVO` 暴露支付状态、收费员、收费时间、收费途径和金额字段。后端对零付、负数、少付和绕过前端的直接请求缺少完整金额覆盖校验,且客户端可指定收费员和收费时间。
### P0-04 柜员结账确认范围与实际范围不一致
前端确认框按当前分页和当前筛选行计算金额,请求只传 `cashierId`;后端收到后结清该收费员全部未结支付记录。页面确认金额不能约束真实落账范围。
### P0-05 预存抵扣无法与柜员实交资金对账
支付主单的 `payment_amount` 记录整张账单金额,柜员结账直接汇总该字段,会把预存抵扣也计入柜员应交资金。支付事实未分别保存渠道实收、预存抵扣和多缴转预存。
### P0-06 余额和支付记录缺少并发、幂等保护
账户余额采用查询后计算再全行更新,没有行锁、版本号或条件原子更新。柜台充值没有业务请求号;同一请求重试可能重复充值。同一账单并发收费也缺少数据库唯一约束。
### P0-07 已结账预存红冲无法成功
已结账预存红冲调用只匹配 `CHARGE_PAYMENT` 的 Mapper 方法;`DEPOSIT_TOPUP` 记录更新数必为零。现有单元测试通过 mock 返回成功,未覆盖真实 SQL 条件。
### P0-08 银行冲正和业务支付状态可能分裂
银行冲正以非空营业账对象作为成功条件。营业账不是可冲状态时可能没有发生业务反向处理,但银行交易仍被标记为已冲正。银行交易状态和业务支付入账也没有可重放的统一状态机。
### P0-09 预存审批回调可重放
审批成功回调缺少完成状态门禁,余额修改发生在支付退款去重之前,并存在绕过账户流水服务直接写余额的路径。重复回调可能重复扣款或重复转账。
## 4. 当前相对正确的链路
- 单线程柜台预存正常路径处于同一事务,并写支付记录、余额和账户流水。
- 单线程单账单收费可以写支付主明细并投影营业账状态。
- 柜台账单收费红冲已有反向支付、预存恢复和账单状态恢复结构。
- 柜员结账已有结账主明细、条件更新和重复结账防护基础。
- 缴费历史主要从 `PaymentRecord` / `PaymentRecordDetail` 读取,而不是完全依赖营业账结果字段。
这些正确性只在合法金额、单线程、合法入口和数据库约束完整部署的条件下成立。
## 5. 基线验证
前端在隔离工作树执行以下相关源码契约测试:
```bash
node --test \
src/views/operatingCharges/counterCharging/counterTopup.contract.test.mjs \
tests/revenue-bugs/counterChargeAndCheckoutDisplay.contract.test.mjs \
tests/rev006/counterCheckoutOldPageInventory.test.mjs
```
结果30 项22 通过、8 失败。失败属于整改前基线,其中既有旧测试与当前代码漂移,也有结账聚合和预存展示契约未对齐。
后端聚合定向测试首次执行超过等待窗口后被中止,未形成通过结论;实施阶段必须按单测试类执行并记录明确退出码。
## 6. 数据排查建议
若当前环境已经存在真实收费数据,实施上线前至少核对:
1. 已收账单的 `extended_amount + late_fee` 是否等于支付分配金额。
2. 支付主单、支付明细与账单投影是否一一对应。
3. 柜员结账金额是否剔除了预存抵扣并包含真实渠道实收。
4. 同客户、同金额、相近时间的预存充值是否存在重复请求。
5. 账户当前余额是否等于期初余额加全部有效账户流水净额。
6. 已冲正银行交易是否都有对应业务反向支付及账单状态恢复。
## 7. P0 整改结果
| 缺陷 | 整改结果 |
|------|----------|
| P0-01 金额口径 | 服务端和前端统一按本金 + 违约金计算;支付主单显式保存渠道实收、预存抵扣和多缴转预存 |
| P0-02 非原子收费 | 新增 `POST /business/charge/counter-charge/submit`,多账单、预存抵扣和多缴转预存在单事务内完成 |
| P0-03 通用支付旁路 | 通用营业账更新禁止财务状态迁移,通用账户更新禁止直接改余额 |
| P0-04 结账范围漂移 | 前端真实维护选择状态,后端只结清请求中的 `paymentRecordIds` |
| P0-05 柜员实交失真 | 结账与汇总优先使用 `channel_amount`,预存抵扣单独展示 |
| P0-06 并发与幂等 | 支付记录增加请求号/批次/唯一约束;账单与账户按序加锁并条件更新 |
| P0-07 预存红冲 | 已结和未结 `DEPOSIT_TOPUP` 分别进入正确状态路径并写反向账户流水 |
| P0-08 银行冲正分裂 | 业务反向未成功时银行交易不再标记已冲正 |
| P0-09 回调重放 | 预存审批完成回调增加状态门禁、正式记录锁和账户服务幂等变更 |
详细命令、退出结果、提交清单和剩余风险见 `docs/evidence/rev003-charging/2026-07-15-p0-verification.md`

View File

@ -1,136 +0,0 @@
# REV-003 营业收费 P0 验证证据
## 1. 验证结论
2026-07-15 完成营业收费 P0 代码整改、审查后加固和定向验收。后端营业模块 130 项测试、银行模块 7 项测试、29 个 Maven reactor 模块编译、前端 44 项 Node 契约测试及 Vite 全量构建均通过。
验证期间未运行 `vue-tsc`
## 2. 实施提交
### 后端 `fix/rev003-charging-p0`
| 提交 | 内容 |
|------|------|
| `098749d58` | 增加柜台支付幂等和金额拆分字段 |
| `04a5247a2` | 统一本金与违约金口径 |
| `d72a360f2` | 账户加锁并去重柜台预存充值 |
| `276d90df7` | 新增原子多账单柜台收费命令 |
| `25c385c27` | 封堵通用财务状态和余额修改旁路 |
| `f0b906048` | 按显式支付记录执行柜员结账 |
| `6c2e4850c` | 修复已结/未结预存红冲状态路径 |
| `a4d3cc0cc` | 防止银行假冲正和预存回调重放 |
| `f7f32ff6e` | 审查后加固:请求级 advisory lock、通用写路径行锁、银行精确交易冲正、结算分账和审批终态保护 |
### 前端 `fix/rev003-charging-p0`
| 提交 | 内容 |
|------|------|
| `fe21be95` | 柜台收费切换为单次原子批量提交 |
| `06a5d70a` | 柜台结账只提交显式勾选的支付记录 |
| `0553ff1a` | 审查后加固:成功与刷新结果分离、集收刷新、金额展示契约、当前收银员范围和输入校验 |
## 3. 后端验证
### 营业模块定向测试
```bash
mvn -pl sw-business/sw-business-server -am \
-Dtest=ChargeControllerTest,ChargeServiceCounterPaymentTest,AccountServiceImplConcurrencyTest,CounterChargeApplicationServiceImplTest,PaymentRecordServiceImplTest,PaymentCommandApplicationServiceTest,CounterSettleApplicationServiceImplTest,PrestorageBpmCallbackServiceTest,PaymentQueryServiceTest \
-Dsurefire.failIfNoSpecifiedTests=false test
```
结果:退出码 09 个测试类合计 `Tests run: 130, Failures: 0, Errors: 0, Skipped: 0`
### 银行冲正测试
```bash
mvn -pl sw-business-bank/sw-business-bank-server -am \
-Dtest=PayInvalidServiceImplTest,PayCebServiceImplTest \
-Dsurefire.failIfNoSpecifiedTests=false test
```
结果:退出码 0`Tests run: 7, Failures: 0, Errors: 0, Skipped: 0`。覆盖本金加违约金金额口径、空违约金兼容、失败交易原请求金额持久化及重放,以及银行原交易冲正失败时不得错误标记成功。测试日志中的异常栈为用例主动模拟业务红冲失败。
### 后端编译
```bash
mvn -pl sw-business/sw-business-server,sw-business-bank/sw-business-bank-server \
-am -DskipTests compile
```
结果:退出码 029 个 reactor 模块全部 `SUCCESS`,最终 `BUILD SUCCESS`
## 4. 前端验证
### P0 契约测试
```bash
node --test \
src/views/operatingCharges/counterCharging/counterTopup.contract.test.mjs \
src/views/operatingCharges/counterCheckout/checkout-top-summary.contract.test.mjs \
tests/operatingCharges/counterChargingP0Flow.test.mjs \
tests/operatingCharges/counterChargingPersistentTopup.test.mjs \
tests/operatingCharges/counterChargingHubMode.test.mjs \
tests/operatingCharges/counterChargingHubActualAmount.test.mjs \
tests/operatingCharges/counterChargingZeroAmount.contract.test.mjs \
tests/operatingCharges/counterCheckoutCashierOptions.contract.test.mjs \
tests/operatingCharges/counterCheckoutExplicitSelection.test.mjs
```
结果:退出码 0`tests 44``pass 44``fail 0`
覆盖点包括:
- 本金与违约金按分计算,拒绝非法和负数金额。
- 同一批次只调用一次原子收费接口,重试期间复用请求号。
- 无欠费预存可连续操作,零金额账单不会误进入预存模式。
- 当前仅现金渠道可直接提交,配置异常时仍回退现金安全项。
- 结账只提交明确勾选且去重后的支付记录 ID切换支付方式或刷新数据时清除选择。
- 结账金额和展示优先使用渠道实收,预存抵扣独立表达。
- 已结、未结红冲原因均为必填。
- 命令成功后立即清除重试请求号并展示成功结果;客户或汇总刷新失败只提示刷新警告,不得误报交易失败。
- 集收成功后重新查询各客户余额和未缴账单,同时保留本次收讫快照。
- 账单主表和详情统一显示本金加违约金;顶部实收优先使用 `channelAmount`,预存抵扣独立展示。
- 结账查询和提交主体固定为当前登录收银员,不再把其他用户、银行或收费方式混入收费员筛选。
- `useMessage.prompt` 透传 Element Plus 校验选项,金额换算拒绝 `NaN/Infinity`
### Vite 全量构建
`build:dev` 脚本已确认仅执行 `vite build --mode dev`,不包含 `vue-tsc`
第一次按 Node 默认堆上限执行时,在 Rollup 大体量打包阶段触发约 4GB 堆上限,报 `JavaScript heap out of memory`。随后不修改源码和构建配置,仅提高本次 Node 堆上限复验:
```bash
NODE_OPTIONS=--max-old-space-size=8192 \
node ./node_modules/vite/bin/vite.js build --mode dev
```
结果:退出码 0输出 `Build successful. Please see dist directory`
构建仍会输出项目既有的中文 SVG `symbolId` 命名警告和 Rollup `PURE` 注释警告,本轮未新增相关资源,也不影响构建成功。
## 5. 数据库集成门禁
当前环境未设置 `REV004_IT_DB_URL`,因此未执行依赖真实数据库的可选集成测试。上线前仍需在目标 PostgreSQL 环境验证:
1. 新增列、条件唯一索引和历史空值兼容 DDL 已完整部署。
2. PostgreSQL 事务级 advisory lock 可按“租户 + requestId”串行化同一柜台命令同请求号不相交账单也不得双成功。
3. 同一账单并发收费只有一个请求成功,失败批次不留下部分支付或余额变化。
4. 柜员结账金额等于所选记录的渠道实收之和。
5. 已结/未结预存红冲正确扣减余额并生成唯一反向流水。
6. 创建反向支付唯一索引前DDL 的重复关系预检没有发现历史脏数据;曾以零默认值落库的未知历史拆分字段已恢复为 NULL。
7. 银行缴费按“本金 + 违约金”校验并锁定账单;冲正必须命中原 `bankTransactionId`,重试旧冲正不得影响后续新缴费。
## 6. 已知非本轮问题
实施期间单独运行完整 `ChargeServiceAccountingAdjustTest` 时,既有用例 `testAdjustAccounting_usageGarbageFeeUsesCustomerBasicNumberOnly` 出现期望 `12.34`、实际 `1221.66` 的失败。该用例属于用量垃圾费账务调整,不在 REV-003 P0 范围;本轮涉及的银行冲正方法、通用财务旁路和上述 137 项后端测试均已通过。该问题应作为 REV-004 独立缺陷继续处理。
前端扩大到全部 `tests/operatingCharges/*.test.mjs` 的历史库存扫描时,仍有若干与当前实现范围不一致或依赖固定相邻仓库路径的旧正则契约失败;本轮以列出的 44 项 P0 定向契约及 Vite 全量构建作为发布门禁,旧库存迁移另行治理。
## 7. 发布约束
1. 先部署数据库 DDL再部署后端最后部署前端。
2. 不回退通用营业账/账户更新的财务旁路封堵。
3. 首批只开放现金柜台收费;非现金渠道接入真实确认前保持禁用。
4. 上线后按 `payment_batch_no``request_id`、账户流水和结账差异持续观察。

View File

@ -1,197 +0,0 @@
# Counter Charge Then Topup Continuation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让单客户普通账单缴费成功后保留收讫记录,同时立即进入可继续预存的状态。
**Architecture:** 将单客户刚缴纳的账单保存为只读 `history` 展示行,并清空 `selectedChargeRows`。现有 `noArrearsTopupMode` 会忽略历史展示行并自动切换到预存提交路径;集收号路径不调整。
**Tech Stack:** Vue 3、TypeScript、Node.js `node:test`、现有柜台收费状态模型
---
## File Structure
- Modify: `water-frontend/tests/operatingCharges/counterChargingPersistentTopup.test.mjs`
- 增加缴费后连续预存的状态契约测试。
- Modify: `water-frontend/src/views/operatingCharges/counterCharging/index.vue`
- 允许收讫账单使用历史展示状态,并在单客户缴费成功后清空选中行。
- Create: `water-docs/docs/evidence/bugfix/2026-07-14-counter-charge-then-topup-continuation.md`
- 记录根因、TDD 和回归验证结果。
### Task 1: Reproduce the blocked continuation state
**Files:**
- Modify: `water-frontend/tests/operatingCharges/counterChargingPersistentTopup.test.mjs`
- [ ] **Step 1: Add a failing test for single-customer post-payment state**
```js
test('single-customer payment keeps paid bills as history and clears the payable selection', () => {
const submitStart = pageSource.indexOf('const confirmCharge = async')
const submitEnd = pageSource.indexOf('const editCustomer =')
const submitSection = pageSource.slice(submitStart, submitEnd)
assert.match(
submitSection,
/setSettledChargeDisplayRows\(settledChargeRowsSnapshot,\s*'history'\)[\s\S]*selectedChargeRows\.value\s*=\s*\[\]/
)
})
```
- [ ] **Step 2: Add a failing test for history-row semantics and unchanged hub behavior**
```js
test('paid bill history remains read-only while hub charging keeps its existing settled flow', () => {
assert.match(
pageSource,
/const setSettledChargeDisplayRows = \([\s\S]*displayChargeState:\s*ChargeDisplayRowVO\['displayChargeState'\] = 'settled'[\s\S]*displayChargeState,[\s\S]*displayChargeType:\s*displayType/
)
assert.match(pageSource, /payState:\s*1,[\s\S]*payStateName:\s*'收讫'/)
const submitStart = pageSource.indexOf('const confirmCharge = async')
const submitEnd = pageSource.indexOf('const editCustomer =')
const submitSection = pageSource.slice(submitStart, submitEnd)
assert.match(
submitSection,
/if \(isHubChargeMode\.value\) \{[\s\S]*setSettledHubChargeDisplayRows\(settledChargeRowsSnapshot\)[\s\S]*selectedChargeRows\.value = \[\.\.\.settledChargeRowsSnapshot\]/
)
})
```
- [ ] **Step 3: Run the focused test and verify RED**
Run:
```bash
node --test tests/operatingCharges/counterChargingPersistentTopup.test.mjs
```
Expected: 9 个测试中原有 7 个通过,新增 2 个因缺少历史状态参数和选中行清理而失败。
- [ ] **Step 4: Commit the RED test**
```bash
git add tests/operatingCharges/counterChargingPersistentTopup.test.mjs
git commit -m "test: reproduce blocked charge then topup flow"
```
### Task 2: Convert paid bills to non-blocking history rows
**Files:**
- Modify: `water-frontend/src/views/operatingCharges/counterCharging/index.vue`
- [ ] **Step 1: Parameterize the paid-bill display state**
`setSettledChargeDisplayRows` 改为:
```ts
const setSettledChargeDisplayRows = (
rows: CounterChargingChargeVO[],
displayChargeState: ChargeDisplayRowVO['displayChargeState'] = 'settled',
displayType: ChargeDisplayRowVO['displayChargeType'] = 'bill'
) => {
settledChargeDisplayRows.value = rows.map((row) => ({
...row,
payState: 1,
payStateName: '收讫',
displayChargeState,
displayChargeType: displayType
}))
}
```
- [ ] **Step 2: Change only the single-customer successful payment branch**
将普通账单收费成功分支改为:
```ts
} else {
setSettledChargeDisplayRows(settledChargeRowsSnapshot, 'history')
selectedChargeRows.value = []
}
syncActualAmountFromSelection()
```
集收号分支继续保留:
```ts
setSettledHubChargeDisplayRows(settledChargeRowsSnapshot)
selectedChargeRows.value = [...settledChargeRowsSnapshot]
```
- [ ] **Step 3: Run the focused test and verify GREEN**
Run:
```bash
node --test tests/operatingCharges/counterChargingPersistentTopup.test.mjs
```
Expected: 9/9 通过。
- [ ] **Step 4: Run the existing counter-charging regression test**
Run:
```bash
pnpm test:counter-charging
```
Expected: 4/4 通过。
- [ ] **Step 5: Commit the implementation**
```bash
git add src/views/operatingCharges/counterCharging/index.vue
git commit -m "fix: allow topup after counter payment"
```
### Task 3: Verify and record evidence
**Files:**
- Create: `water-docs/docs/evidence/bugfix/2026-07-14-counter-charge-then-topup-continuation.md`
- [ ] **Step 1: Run the combined focused tests**
Run:
```bash
node --test tests/operatingCharges/counterChargingPersistentTopup.test.mjs tests/operatingCharges/counterChargingInventory.test.mjs
```
Expected: 13/13 通过。
- [ ] **Step 2: Run the frontend build**
Run:
```bash
NODE_OPTIONS=--max-old-space-size=8192 pnpm build:dev
```
Expected: 退出码 `0`,输出 `Build successful. Please see dist directory`
不得运行 `vue-tsc``pnpm ts:check` 或任何会调用 `vue-tsc` 的命令。
- [ ] **Step 3: Check the final diff**
Run:
```bash
git diff --check develop...HEAD
git status --short
```
Expected: 无空白错误,前端 worktree 干净。
- [ ] **Step 4: Record exact evidence**
证据文档记录阻塞根因、RED 结果 7 通过/2 失败、GREEN 结果 9/9、组合测试 13/13、构建成功、集收号路径未调整以及未运行 `vue-tsc`
- [ ] **Step 5: Commit evidence**
```bash
git add docs/evidence/bugfix/2026-07-14-counter-charge-then-topup-continuation.md
git commit -m "docs: record charge then topup verification"
```

View File

@ -1,382 +0,0 @@
# Counter Topup Detail Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 修复柜台收费预存款记录点击详情后全部显示 `--` 的问题,为预存款提供独立且语义正确的详情展示。
**Architecture:** 前端根据展示行的 `displayChargeType` 分流详情链路。普通账单继续查询 `/business/charge/get`;预存款直接使用支付记录快照与当前客户资料打开专用详情弹窗,不把支付记录 ID 当作账单 ID。
**Tech Stack:** Vue 3、TypeScript、Element Plus、现有 Axios API 封装、Node.js `node:test`
---
## File Structure
- Modify: `water-frontend/src/views/operatingCharges/counterCharging/index.vue`
- 扩充预存展示行字段、映射支付余额快照、详情类型分流、增加预存款专用弹窗。
- Modify: `water-frontend/tests/operatingCharges/counterChargingPersistentTopup.test.mjs`
- 增加预存款详情分流、字段展示、余额映射和普通账单链路不回归的契约测试。
- Create: `water-docs/docs/evidence/bugfix/2026-07-14-counter-topup-detail.md`
- 记录根因、RED/GREEN 结果、构建结果与未运行 `vue-tsc` 的约束。
### Task 1: Add failing topup-detail contract tests
**Files:**
- Modify: `water-frontend/tests/operatingCharges/counterChargingPersistentTopup.test.mjs`
- [ ] **Step 1: Add a failing test for detail routing**
在现有测试文件中增加源码契约断言,要求 `openChargeDetail``displayChargeType === 'topup'` 分流,并且仅普通账单调用 `getChargeById`
```js
test('counter charging routes topup rows to a dedicated payment detail', () => {
assert.match(
source,
/if \(row\.displayChargeType === 'topup'\) \{[\s\S]*openTopupDetail\(row\)[\s\S]*return[\s\S]*getChargeById\(row\.id\)/
)
})
```
- [ ] **Step 2: Add a failing test for dedicated fields and balance snapshots**
```js
test('topup detail shows payment semantics and keeps balance snapshots', () => {
for (const label of ['预存款详情', '业务类型', '支付记录ID', '预存金额', '期初余额', '期末余额', '收费时间', '收费方式', '收费状态']) {
assert.match(source, new RegExp(label))
}
assert.match(source, /lastDeposit:\s*record\.lastDeposit/)
assert.match(source, /deposit:\s*record\.deposit/)
assert.match(source, /displayTopupMoney\(topupDetailData\?\.lastDeposit\)/)
assert.match(source, /displayTopupMoney\(topupDetailData\?\.deposit\)/)
})
```
- [ ] **Step 3: Add a failing test for preserving normal bill details and zero values**
```js
test('normal bills keep their original detail request and zero topup values remain visible', () => {
assert.match(source, /chargeDetailData\.value = await getChargeById\(row\.id\)/)
assert.match(source, /value === null \|\| value === undefined \|\| value === ''/)
assert.match(source, /Number\(value\)\.toFixed\(2\)/)
})
```
- [ ] **Step 4: Run the focused test and verify RED**
Run:
```bash
node --test tests/operatingCharges/counterChargingPersistentTopup.test.mjs
```
Expected: FAIL on the new detail-routing/detail-fields assertions because no topup-specific detail path exists yet.
- [ ] **Step 5: Commit the RED test**
```bash
git add tests/operatingCharges/counterChargingPersistentTopup.test.mjs
git commit -m "test: reproduce empty counter topup detail"
```
### Task 2: Preserve complete topup display data
**Files:**
- Modify: `water-frontend/src/views/operatingCharges/counterCharging/index.vue`
- [ ] **Step 1: Extend the display-row type with payment snapshots**
```ts
type ChargeDisplayRowVO = CounterChargingChargeVO & {
displayChargeState?: 'settled' | 'history'
displayChargeType?: 'bill' | 'topup'
payTime?: string
chargeWay?: number
lastDeposit?: number
deposit?: number
}
```
- [ ] **Step 2: Extend the topup-row builder input and output**
```ts
const setSettledTopupDisplayRow = (record: {
id: number
amount: number
payTime: string
chargeWay: number
lastDeposit?: number
deposit?: number
}, displayChargeState: 'settled' | 'history' = 'settled') => {
const current = currentCustomer.value
settledChargeDisplayRows.value = [
{
id: record.id,
meterId: 0,
recordId: 0,
billMonth: '预存款',
custId: current?.id || 0,
custCode: current?.custCode || current?.code,
custName: current?.custName || current?.name,
custAddress: current?.custAddress || current?.address,
billAmount: record.amount,
lateFee: 0,
extendedAmount: record.amount,
payState: 1,
payStateName: '收讫',
meterCode: '预存',
recordNo: '预存',
payTime: record.payTime,
chargeWay: record.chargeWay,
lastDeposit: record.lastDeposit,
deposit: record.deposit,
displayChargeState,
displayChargeType: 'topup'
} as ChargeDisplayRowVO
]
}
```
客户地址使用 `current?.custAddress || current?.address`;金额继续放在 `billAmount``extendedAmount`,余额快照分别放入 `lastDeposit``deposit`
- [ ] **Step 3: Map persisted payment snapshots**
`loadLatestCounterTopupDisplay` 中传入:
```ts
setSettledTopupDisplayRow({
id: record.id,
amount: Number(record.actualMoney ?? 0),
payTime: record.payDate || record.creationTime || '',
chargeWay: Number(record.chargeWay ?? 1),
lastDeposit: record.lastDeposit,
deposit: record.deposit
}, 'history')
```
- [ ] **Step 4: Avoid overwriting the persisted row after an immediate topup**
收费成功刷新后,只有在 `loadCustomerChargeData` 未恢复任何预存行时才用成功响应建立降级行:
```ts
if (!settledChargeDisplayRows.value.length && counterTopupResult?.paymentRecordId) {
setSettledTopupDisplayRow({
id: counterTopupResult.paymentRecordId,
amount: Number(counterTopupResult.amount ?? enteredAmount.value),
payTime: counterTopupResult.payTime || payDate,
chargeWay: chargeWayKeyToValueMap[payType.value as keyof typeof chargeWayKeyToValueMap] || 1,
deposit: counterTopupResult.balanceAfter
})
}
```
- [ ] **Step 5: Run the focused test**
Run:
```bash
node --test tests/operatingCharges/counterChargingPersistentTopup.test.mjs
```
Expected: detail-routing test remains FAIL; balance-mapping assertions PASS.
- [ ] **Step 6: Commit the data mapping**
```bash
git add src/views/operatingCharges/counterCharging/index.vue tests/operatingCharges/counterChargingPersistentTopup.test.mjs
git commit -m "fix: preserve counter topup detail snapshots"
```
### Task 3: Add the dedicated topup-detail dialog and routing
**Files:**
- Modify: `water-frontend/src/views/operatingCharges/counterCharging/index.vue`
- [ ] **Step 1: Add topup-detail state and zero-safe formatting**
```ts
const showTopupDetailDialog = ref(false)
const topupDetailData = ref<ChargeDisplayRowVO | null>(null)
const displayTopupMoney = (value: unknown) => {
if (value === null || value === undefined || value === '') return '--'
const amount = Number(value)
return Number.isFinite(amount) ? amount.toFixed(2) : '--'
}
const getChargeWayLabel = (value: unknown) => {
const chargeWay = Number(value)
return chargeWayLabelMap.value[chargeWay] || '--'
}
```
- [ ] **Step 2: Add the dedicated dialog**
在账单详情弹窗后增加以下完整弹窗:
```vue
<el-dialog v-model="showTopupDetailDialog" title="预存款详情" width="760px">
<el-descriptions :column="3" border>
<el-descriptions-item label="客户编号">{{ topupDetailData?.custCode || '--' }}</el-descriptions-item>
<el-descriptions-item label="客户名称">{{ topupDetailData?.custName || '--' }}</el-descriptions-item>
<el-descriptions-item label="客户地址">{{ topupDetailData?.custAddress || '--' }}</el-descriptions-item>
<el-descriptions-item label="业务类型">预存款</el-descriptions-item>
<el-descriptions-item label="支付记录ID">{{ topupDetailData?.id || '--' }}</el-descriptions-item>
<el-descriptions-item label="预存金额">{{ displayTopupMoney(topupDetailData?.billAmount) }}</el-descriptions-item>
<el-descriptions-item label="期初余额">{{ displayTopupMoney(topupDetailData?.lastDeposit) }}</el-descriptions-item>
<el-descriptions-item label="期末余额">{{ displayTopupMoney(topupDetailData?.deposit) }}</el-descriptions-item>
<el-descriptions-item label="收费时间">{{ formatChargeDateTime(topupDetailData?.payTime) }}</el-descriptions-item>
<el-descriptions-item label="收费方式">{{ getChargeWayLabel(topupDetailData?.chargeWay) }}</el-descriptions-item>
<el-descriptions-item label="收费状态">收讫</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="showTopupDetailDialog = false">关闭</el-button>
</template>
</el-dialog>
```
- [ ] **Step 3: Route topup rows before the bill request**
```ts
const openTopupDetail = (row: ChargeDisplayRowVO) => {
if (!row.id) {
ElMessage.warning('预存记录信息不完整')
return
}
topupDetailData.value = row
showTopupDetailDialog.value = true
}
const openChargeDetail = async (row: ChargeDisplayRowVO) => {
if (row.displayChargeType === 'topup') {
openTopupDetail(row)
return
}
if (!row?.id) return
chargeDetailLoading.value = true
showChargeDetailDialog.value = true
try {
chargeDetailData.value = await getChargeById(row.id)
} catch (error: any) {
ElMessage.error(error?.message || '查看账单详情失败')
showChargeDetailDialog.value = false
} finally {
chargeDetailLoading.value = false
}
}
```
- [ ] **Step 4: Clear topup-detail state with the page state**
`resetCustomerChargeState``onPageActivated` 的无恢复参数分支中加入:
```ts
showTopupDetailDialog.value = false
topupDetailData.value = null
```
- [ ] **Step 5: Run the focused test and verify GREEN**
Run:
```bash
node --test tests/operatingCharges/counterChargingPersistentTopup.test.mjs
```
Expected: PASS, including all pre-existing topup persistence tests and new detail tests.
- [ ] **Step 6: Run the existing counter-charging regression test**
Run:
```bash
pnpm test:counter-charging
```
Expected: PASS.
- [ ] **Step 7: Commit the UI fix**
```bash
git add src/views/operatingCharges/counterCharging/index.vue tests/operatingCharges/counterChargingPersistentTopup.test.mjs
git commit -m "fix: show counter topup payment details"
```
### Task 4: Verify and record evidence
**Files:**
- Create: `water-docs/docs/evidence/bugfix/2026-07-14-counter-topup-detail.md`
- [ ] **Step 1: Run focused tests together**
Run:
```bash
node --test tests/operatingCharges/counterChargingPersistentTopup.test.mjs tests/operatingCharges/counterChargingInventory.test.mjs
```
Expected: all focused tests PASS.
- [ ] **Step 2: Run the frontend build with sufficient heap**
Run:
```bash
NODE_OPTIONS=--max-old-space-size=8192 pnpm build:dev
```
Expected: `Build successful. Please see dist directory`.
Do not run `vue-tsc`, `pnpm ts:check`, or any command that invokes `vue-tsc`.
- [ ] **Step 3: Check the diff and worktree**
Run:
```bash
git diff --check
git status --short
```
Expected: no whitespace errors; only intentional files are present before the final commit.
- [ ] **Step 4: Write verification evidence**
全部验证达到本计划预期后,创建以下证据;若实际结果不同,停止提交证据并先处理差异:
```markdown
# 柜台收费预存款详情修复验证
## 根因
预存款展示行使用支付记录 ID但详情按钮统一调用账单详情接口 `/business/charge/get`,导致支付记录 ID 被误作账单 ID详情字段全部显示为 `--`
## 修复
- 按 `displayChargeType` 分流普通账单与预存款详情。
- 预存款使用专用弹窗展示支付及余额快照。
- 普通账单继续使用原账单详情接口。
## TDD 证据
- RED`node --test tests/operatingCharges/counterChargingPersistentTopup.test.mjs`
- 结果7 个测试中原有 4 个通过,新增 3 个按预期失败,失败原因分别为缺少详情分流、专用字段和零值格式化。
- GREEN`node --test tests/operatingCharges/counterChargingPersistentTopup.test.mjs`
- 结果7/7 通过。
## 回归验证
- `pnpm test:counter-charging`:退出码 04/4 通过。
- `node --test tests/operatingCharges/counterChargingPersistentTopup.test.mjs tests/operatingCharges/counterChargingInventory.test.mjs`:退出码 011/11 通过。
- `NODE_OPTIONS=--max-old-space-size=8192 pnpm build:dev`:退出码 0输出 `Build successful. Please see dist directory`
## 约束
按用户要求,未运行 `vue-tsc``pnpm ts:check` 或任何会调用 `vue-tsc` 的命令。
```
- [ ] **Step 5: Commit evidence**
```bash
git add docs/evidence/bugfix/2026-07-14-counter-topup-detail.md
git commit -m "docs: record counter topup detail verification"
```

View File

@ -1,993 +0,0 @@
# REV-003 Charging P0 Remediation Implementation Plan
## Execution status (2026-07-15)
- Backend Tasks 1-8 are implemented and committed; 106 focused business tests, 1 bank-reversal test, and the 29-module compile passed.
- Frontend Tasks 9-10 are implemented and committed; 38 Node contract tests and the full Vite build passed.
- Task 11 formal design, audit update, and verification evidence are complete. `vue-tsc` was not run.
- `REV004_IT_DB_URL` is unset in this environment, so real-database integration remains a release gate rather than a local completion result.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the unsafe per-bill generic update flow with an atomic, idempotent counter-charge command and make amounts, settlement, top-up reversal, account concurrency, bank reversal, and callback replay financially consistent.
**Architecture:** Keep the existing one-`PaymentRecord`-per-charge compatibility model, but group all records from one counter operation with `requestId` and `paymentBatchNo`. A new `CounterChargeApplicationService` owns amount calculation, row locking, account mutations, payment capture, and charge projection inside one transaction. Settlement accepts explicit payment record IDs, and all balance/reversal paths use locked account rows plus append-only payment/account logs.
**Tech Stack:** Java 17, Spring Boot, MyBatis-Plus, PostgreSQL 16, JUnit 5, Mockito, Vue 3, TypeScript, Element Plus, Node.js `node:test`.
---
## File map
### Backend files to create
- `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countercharge/CounterChargeAmountCalculator.java` — pure amount allocation rules.
- `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countercharge/CounterChargeApplicationService.java` — command boundary.
- `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countercharge/CounterChargeApplicationServiceImpl.java` — transaction orchestration.
- `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/vo/CounterChargeSubmitReqVO.java` — dedicated command request.
- `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/vo/CounterChargeSubmitRespVO.java` — payment batch result.
- `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countercharge/CounterChargeAmountCalculatorTest.java`.
- `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countercharge/CounterChargeApplicationServiceImplTest.java`.
- `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/account/AccountServiceImplConcurrencyTest.java`.
- `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord/payment-record-p0-schema.contract.test.mjs`.
- `sql/rev003/REV003_counter_charge_p0_ddl.sql` — additive nullable columns and unique indexes.
### Backend files to modify
- `PaymentRecordDO.java`, `PaymentRecordMapper.java`, `PaymentRecordService.java`, `PaymentRecordServiceImpl.java`.
- `AccountMapper.java`, `AccountServiceImpl.java`.
- `ChargeMapper.java`, `ChargeController.java`, `ChargeServiceImpl.java`, `ChargeServiceCounterPaymentTest.java`.
- `CounterTopupReqVO.java`, `CounterTopupRespVO.java`.
- `CounterSettleConfirmReqVO.java`, `CounterSettleApplicationServiceImpl.java`, `CounterSettleApplicationServiceImplTest.java`.
- `CounterUnsettledPageRespVO.java`, `CounterSettleDetailRespVO.java`, `PaymentQueryServiceImpl.java`, `PaymentQueryServiceTest.java`.
- `PrestorageBpmCallbackService.java`, `PrestorageFormalizationService.java`, `PrestorageBpmCallbackServiceTest.java`.
- `sw-business-bank/.../PayInvalidServiceImpl.java` plus a new focused test class.
### Frontend files to create
- `src/views/operatingCharges/counterCharging/counterChargeMath.mjs` — finite-number and receivable helpers used by the page and directly tested by Node.
- `types/counter-charge-math.d.ts` — TypeScript declarations for the `.mjs` helper.
- `tests/operatingCharges/counterChargingP0Flow.test.mjs` — API and page wiring contract.
- `tests/operatingCharges/counterCheckoutExplicitSelection.test.mjs` — exact-settlement contract.
### Frontend files to modify
- `src/api/operatingCharges/counterCharging/index.ts`.
- `src/views/operatingCharges/counterCharging/index.vue`.
- `src/api/business/charge/counterSettle.ts`.
- `src/views/operatingCharges/counterCheckout/components/CounterUnsettledPanel.vue`.
- `src/views/operatingCharges/counterCheckout/components/CounterSettleConfirmDialog.vue`.
- `src/views/operatingCharges/counterCheckout/components/CounterSettledDetailDialog.vue`.
### Documentation files to update
- `docs/evidence/rev003-charging/2026-07-15-p0-audit.md`.
- `docs/evidence/rev003-charging/2026-07-15-p0-verification.md`.
- `docs/design/02_Detailed_Design/12_REV_Detailed.md`.
- `docs/design/03_Technical_Design/03_Interface_Design.md`.
---
### Task 1: Add explicit payment amount and idempotency fields
**Files:**
- Create: `water-backend/sql/rev003/REV003_counter_charge_p0_ddl.sql`
- Create: `water-backend/sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord/payment-record-p0-schema.contract.test.mjs`
- Modify: `water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/dataobject/paymentrecord/PaymentRecordDO.java`
- Modify: `water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/paymentrecord/PaymentRecordMapper.java`
- [ ] **Step 1: Write the failing schema contract test**
```javascript
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
const paymentRecord = readFileSync(
'sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/dataobject/paymentrecord/PaymentRecordDO.java',
'utf8'
)
const ddl = readFileSync('sql/rev003/REV003_counter_charge_p0_ddl.sql', 'utf8')
test('payment record exposes P0 amount split and idempotency fields', () => {
for (const field of ['requestId', 'paymentBatchNo', 'channelAmount', 'prepayAmount', 'overpayAmount']) {
assert.match(paymentRecord, new RegExp(`private .* ${field};`))
}
})
test('P0 DDL adds additive fields and unique idempotency indexes', () => {
assert.match(ddl, /ADD COLUMN IF NOT EXISTS request_id/)
assert.match(ddl, /ADD COLUMN IF NOT EXISTS channel_amount/)
assert.match(ddl, /uk_biz_payment_record_counter_request/)
assert.match(ddl, /uk_biz_payment_record_reverse_relation/)
})
```
- [ ] **Step 2: Run the contract and verify RED**
Run:
```bash
node --test sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord/payment-record-p0-schema.contract.test.mjs
```
Expected: FAIL because the DDL file and fields do not exist.
- [ ] **Step 3: Add the DO fields**
```java
private String requestId;
private String paymentBatchNo;
private BigDecimal channelAmount;
private BigDecimal prepayAmount;
private BigDecimal overpayAmount;
```
- [ ] **Step 4: Add additive PostgreSQL DDL**
```sql
ALTER TABLE biz_payment_record ADD COLUMN IF NOT EXISTS request_id VARCHAR(64);
ALTER TABLE biz_payment_record ADD COLUMN IF NOT EXISTS payment_batch_no VARCHAR(64);
ALTER TABLE biz_payment_record ADD COLUMN IF NOT EXISTS channel_amount NUMERIC(18, 2) NOT NULL DEFAULT 0;
ALTER TABLE biz_payment_record ADD COLUMN IF NOT EXISTS prepay_amount NUMERIC(18, 2) NOT NULL DEFAULT 0;
ALTER TABLE biz_payment_record ADD COLUMN IF NOT EXISTS overpay_amount NUMERIC(18, 2) NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_biz_payment_record_batch
ON biz_payment_record (tenant_id, payment_batch_no, deleted);
CREATE UNIQUE INDEX IF NOT EXISTS uk_biz_payment_record_counter_request
ON biz_payment_record (tenant_id, request_id, biz_scene, source_ref_id)
WHERE deleted = 0 AND request_id IS NOT NULL AND biz_scene = 'CHARGE_PAYMENT';
CREATE UNIQUE INDEX IF NOT EXISTS uk_biz_payment_record_topup_request
ON biz_payment_record (tenant_id, request_id, biz_scene)
WHERE deleted = 0 AND request_id IS NOT NULL AND biz_scene = 'DEPOSIT_TOPUP';
CREATE UNIQUE INDEX IF NOT EXISTS uk_biz_payment_record_reverse_relation
ON biz_payment_record (tenant_id, related_payment_record_id, biz_scene)
WHERE deleted = 0 AND related_payment_record_id IS NOT NULL;
```
- [ ] **Step 5: Add mapper queries used by idempotent commands**
```java
default List<PaymentRecordDO> selectByRequestId(String requestId, String bizScene) {
if (requestId == null || requestId.isBlank()) {
return List.of();
}
return selectList(new LambdaQueryWrapperX<PaymentRecordDO>()
.eq(PaymentRecordDO::getRequestId, requestId)
.eqIfPresent(PaymentRecordDO::getBizScene, bizScene)
.orderByAsc(PaymentRecordDO::getId));
}
default List<PaymentRecordDO> selectCounterUnsettledRecordsByIds(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return List.of();
}
return selectList(new LambdaQueryWrapperX<PaymentRecordDO>()
.in(PaymentRecordDO::getId, ids)
.eq(PaymentRecordDO::getSourceType, PaymentSourceTypeEnum.COUNTER_CHARGE.getValue())
.in(PaymentRecordDO::getBizScene, List.of("CHARGE_PAYMENT", "DEPOSIT_TOPUP"))
.eq(PaymentRecordDO::getPayInOut, "IN")
.eq(PaymentRecordDO::getSettleStatus, PaymentSettleStatusEnum.UNSETTLED.getCode())
.isNull(PaymentRecordDO::getSettleId)
.orderByAsc(PaymentRecordDO::getId));
}
```
- [ ] **Step 6: Verify GREEN**
Run the Step 2 command. Expected: 2 tests PASS.
- [ ] **Step 7: Commit**
```bash
git add sql/rev003 sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/dataobject/paymentrecord sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/paymentrecord sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord/payment-record-p0-schema.contract.test.mjs
git commit -m "feat: add counter payment idempotency fields"
```
### Task 2: Correct principal and late-fee semantics
**Files:**
- Modify: `water-backend/sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord/PaymentRecordServiceImplTest.java`
- Modify: `water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentrecord/PaymentRecordServiceImpl.java`
- Modify: `water-backend/sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceCounterPaymentTest.java`
- Modify: `water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java`
- [ ] **Step 1: Add a failing payment allocation test**
```java
@Test
void captureCounterPayment_shouldKeepPrincipalAndAddLateFeeToReceivable() {
ChargeDO charge = buildCharge(1001L, 9001L, new BigDecimal("100.00"), new BigDecimal("10.00"));
PaymentRecordDO result = paymentRecordService.captureChargePaymentFromCounter(
charge, LocalDateTime.of(2026, 7, 15, 10, 0), 1, 1, "1001", BigDecimal.ZERO);
ArgumentCaptor<PaymentRecordDO> recordCaptor = ArgumentCaptor.forClass(PaymentRecordDO.class);
verify(paymentRecordMapper).insert(recordCaptor.capture());
assertEquals(new BigDecimal("110.00"), recordCaptor.getValue().getPaymentAmount());
assertEquals(new BigDecimal("100.00"), recordCaptor.getValue().getBillAmount());
assertEquals(new BigDecimal("10.00"), recordCaptor.getValue().getLateFeeAmount());
ArgumentCaptor<PaymentRecordDetailDO> detailCaptor = ArgumentCaptor.forClass(PaymentRecordDetailDO.class);
verify(paymentRecordDetailMapper, times(2)).insert(detailCaptor.capture());
assertEquals(new BigDecimal("100.00"), detailCaptor.getAllValues().get(0).getPrincipalAmount());
assertEquals(new BigDecimal("10.00"), detailCaptor.getAllValues().get(1).getLateFeeAmount());
}
```
- [ ] **Step 2: Run the test and verify RED**
```bash
mvn -pl sw-business/sw-business-server -Dtest=PaymentRecordServiceImplTest#captureCounterPayment_shouldKeepPrincipalAndAddLateFeeToReceivable -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: FAIL because payment amount is `100.00` and principal is `90.00`.
- [ ] **Step 3: Implement canonical helpers**
```java
private BigDecimal principalAmount(ChargeDO charge) {
return nonNegative(charge == null ? null : charge.getExtendedAmount());
}
private BigDecimal lateFeeAmount(ChargeDO charge) {
return nonNegative(charge == null ? null : charge.getLateFee());
}
private BigDecimal receivableAmount(ChargeDO charge) {
return principalAmount(charge).add(lateFeeAmount(charge));
}
private BigDecimal nonNegative(BigDecimal amount) {
return amount == null || amount.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : amount;
}
```
Use `receivableAmount(charge)` for `paymentAmount` and `allocatedAmount`; use `principalAmount(charge)` for `billAmount` and the principal detail. Never subtract late fee from `extendedAmount`.
- [ ] **Step 4: Add a failing counter-preview test**
For an overdue charge with principal `8.00`, late fee `3.00`, and a current principal `12.00`, assert:
```java
assertEquals(new BigDecimal("8.00"), result.getOverduePrincipal());
assertEquals(new BigDecimal("3.00"), result.getOverdueLateFee());
assertEquals(new BigDecimal("23.00"), result.getTotalReceivable());
assertEquals(new BigDecimal("13.00"), result.getRemainingPayable());
```
- [ ] **Step 5: Run the preview test and verify RED**
```bash
mvn -pl sw-business/sw-business-server -Dtest=ChargeServiceCounterPaymentTest#getCounterPreview_shouldApplyPrepaidInFixedPriorityOrder -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: FAIL because overdue principal is currently calculated as `extendedAmount - lateFee`.
- [ ] **Step 6: Correct preview calculation**
Use `extendedAmount` as principal and add `lateFee` to the total. Preserve priority: overdue late fee, overdue principal, current principal.
- [ ] **Step 7: Run both focused test classes**
```bash
mvn -pl sw-business/sw-business-server -Dtest=PaymentRecordServiceImplTest,ChargeServiceCounterPaymentTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentrecord/PaymentRecordServiceImpl.java sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord/PaymentRecordServiceImplTest.java sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceCounterPaymentTest.java
git commit -m "fix: align counter charge principal and late fee amounts"
```
### Task 3: Lock accounts and make top-up idempotent
**Files:**
- Create: `water-backend/sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/account/AccountServiceImplConcurrencyTest.java`
- Modify: `AccountMapper.java`, `AccountServiceImpl.java`
- Modify: `CounterTopupReqVO.java`, `ChargeServiceCounterPaymentTest.java`, `ChargeServiceImpl.java`
- Modify: `PaymentRecordService.java`, `PaymentRecordServiceImpl.java`, `PaymentCommandApplicationService.java`, `PaymentCommandApplicationServiceImpl.java`
- [ ] **Step 1: Write failing account-lock tests**
```java
@Test
void increaseDeposit_shouldReadAccountForUpdateBeforeWriting() {
AccountDO account = AccountDO.builder().id(1L).custId(66L).deposit(new BigDecimal("5.00")).build();
when(accountMapper.selectByCustIdForUpdate(66L)).thenReturn(account);
AccountDO result = service.increaseDeposit(66L, new BigDecimal("2.00"), logContext());
assertEquals(new BigDecimal("7.00"), result.getDeposit());
verify(accountMapper).selectByCustIdForUpdate(66L);
verify(accountMapper).updateById(account);
}
@Test
void decreaseDeposit_shouldRejectInsufficientLockedBalanceWithoutUpdate() {
AccountDO account = AccountDO.builder().id(1L).custId(66L).deposit(new BigDecimal("5.00")).build();
when(accountMapper.selectByCustIdForUpdate(66L)).thenReturn(account);
assertThrows(ServiceException.class,
() -> service.decreaseDeposit(66L, new BigDecimal("6.00"), logContext()));
verify(accountMapper, never()).updateById(any());
}
```
- [ ] **Step 2: Verify RED**
Run the new test class. Expected: compilation failure because `selectByCustIdForUpdate` does not exist.
- [ ] **Step 3: Add the locking mapper method**
```java
default AccountDO selectByCustIdForUpdate(Long custId) {
if (custId == null) {
return null;
}
return selectOne(new LambdaQueryWrapperX<AccountDO>()
.eq(AccountDO::getCustId, custId)
.last("FOR UPDATE"));
}
```
Change all deposit increase/decrease implementations to use this method.
- [ ] **Step 4: Add a failing top-up replay test**
```java
@Test
void counterTopup_shouldReturnExistingPaymentForRepeatedRequestId() {
CounterTopupReqVO req = buildTopup("REQ-1");
PaymentRecordDO existing = PaymentRecordDO.builder()
.id(99L).paymentNo("TOP-99").requestId("REQ-1")
.paymentAmount(new BigDecimal("50.00")).build();
when(paymentRecordService.getByRequestId("REQ-1", "DEPOSIT_TOPUP")).thenReturn(List.of(existing));
when(accountService.getAccountByCustId(66L)).thenReturn(AccountDO.builder().deposit(new BigDecimal("80.00")).build());
CounterTopupRespVO result = chargeService.counterTopup(req);
assertEquals(99L, result.getPaymentRecordId());
verify(accountService, never()).increaseDeposit(anyLong(), any(), any());
}
```
- [ ] **Step 5: Add request ID and server-owned actor/time**
`CounterTopupReqVO`:
```java
@NotBlank(message = "请求号不能为空")
private String requestId;
```
Keep legacy `cashierId` and `payTime` nullable for deserialization compatibility, but resolve production values as:
```java
String cashierId = Optional.ofNullable(SecurityFrameworkUtils.getLoginUserId())
.map(String::valueOf)
.orElse(reqVO.getCashierId());
LocalDateTime payTime = LocalDateTime.now();
```
Extend payment capture with `requestId`, `paymentBatchNo`, `channelAmount`, and initialize `prepayAmount/overpayAmount` to zero. Before mutating balance, return an existing completed payment for the same request.
- [ ] **Step 6: Run focused tests**
```bash
mvn -pl sw-business/sw-business-server -Dtest=AccountServiceImplConcurrencyTest,ChargeServiceCounterPaymentTest,PaymentRecordServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/account sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/account sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/vo/CounterTopupReqVO.java sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentrecord sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentapp sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/account sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceCounterPaymentTest.java
git commit -m "fix: lock deposit updates and deduplicate counter topups"
```
### Task 4: Implement the atomic multi-bill counter-charge command
**Files:**
- Create the counter-charge service, calculator, DTOs, and tests listed in the file map.
- Modify: `ChargeController.java`, `ChargeMapper.java`, payment service/application signatures.
- [ ] **Step 1: Write calculator tests first**
```java
@Test
void calculate_shouldAddLateFeeAndAllocatePrepayOldestFirst() {
List<BillInput> bills = List.of(
new BillInput(1L, 66L, 202605, bd("100.00"), bd("10.00")),
new BillInput(2L, 66L, 202606, bd("50.00"), bd("0.00")));
BatchAmounts result = calculator.calculate(bills, Map.of(66L, bd("120.00")), true, bd("40.00"));
assertEquals(bd("160.00"), result.totalReceivable());
assertEquals(bd("120.00"), result.totalPrepay());
assertEquals(bd("40.00"), result.channelAmount());
assertEquals(bd("0.00"), result.overpay());
assertEquals(bd("110.00"), result.items().get(0).prepayAmount());
assertEquals(bd("10.00"), result.items().get(1).prepayAmount());
}
@Test
void calculate_shouldRejectShortPayment() {
assertThrows(ServiceException.class, () -> calculator.calculate(
List.of(new BillInput(1L, 66L, 202605, bd("100.00"), bd("10.00"))),
Map.of(), false, bd("109.99")));
}
@Test
void calculate_shouldRejectOverpayAcrossMultipleMainAccounts() {
assertThrows(ServiceException.class, () -> calculator.calculate(
List.of(
new BillInput(1L, 66L, 202605, bd("10.00"), bd("0.00")),
new BillInput(2L, 77L, 202605, bd("10.00"), bd("0.00"))),
Map.of(), false, bd("21.00")));
}
```
- [ ] **Step 2: Verify calculator RED**
Run `CounterChargeAmountCalculatorTest`. Expected: compilation failure because the calculator does not exist.
- [ ] **Step 3: Implement the pure calculator**
Create records `BillInput`, `ItemAmounts`, and `BatchAmounts`. Sort by bill month then charge ID. Clamp only null values to zero; reject negative principal, late fee, channel amount, or balance. Allocate prepay per main account and enforce the batch equation exactly at scale 2.
- [ ] **Step 4: Write application-service tests**
Cover:
```java
@Test
void submit_shouldCaptureTwoBillsAndProjectBothInsideOneBatch() { /* assert same requestId/batchNo */ }
@Test
void submit_shouldReturnExistingBatchWithoutMutatingForReplay() { /* existing request records */ }
@Test
void submit_shouldRollbackContractWhenOneChargeIsNoLongerUnpaid() { /* conditional update count */ }
@Test
void submit_shouldCreateTopupInSameTransactionForSingleAccountOverpay() { /* overpay record + account log */ }
@Test
void submit_shouldRejectExpectedAmountMismatch() { /* stale page amount */ }
```
- [ ] **Step 5: Define the request and response DTOs**
```java
@Data
public class CounterChargeSubmitReqVO {
@NotBlank private String requestId;
@NotEmpty private List<@NotNull Long> chargeIds;
@NotNull @DecimalMin("0.01") private BigDecimal expectedReceivableAmount;
@NotNull @DecimalMin("0.00") private BigDecimal actualPayAmount;
@NotNull private Boolean usePrepay;
@NotNull private Integer chargeWay;
private String remark;
}
```
```java
@Data @Builder
public class CounterChargeSubmitRespVO {
private String requestId;
private String paymentBatchNo;
private List<Long> paymentRecordIds;
private BigDecimal totalReceivableAmount;
private BigDecimal channelAmount;
private BigDecimal prepayAmount;
private BigDecimal overpayTopupAmount;
private BigDecimal balanceAfter;
}
```
- [ ] **Step 6: Implement locking and conditional projection**
`ChargeMapper` must provide:
```java
default List<ChargeDO> selectByIdsForUpdate(List<Long> ids) {
return selectList(new LambdaQueryWrapperX<ChargeDO>()
.in(ChargeDO::getId, ids)
.orderByAsc(ChargeDO::getId)
.last("FOR UPDATE"));
}
default int markCounterPaid(Long id, LocalDateTime payTime, Integer chargeMethod,
Integer chargeWay, String cashierId) {
return update(null, new LambdaUpdateWrapper<ChargeDO>()
.set(ChargeDO::getPayState, PayStateEnum.PAID.getValue())
.set(ChargeDO::getPayDate, payTime)
.set(ChargeDO::getChargeMethod, chargeMethod)
.set(ChargeDO::getChargeWay, chargeWay)
.set(ChargeDO::getCashierId, cashierId)
.eq(ChargeDO::getId, id)
.eq(ChargeDO::getPayState, PayStateEnum.UNPAID.getValue()));
}
```
- [ ] **Step 7: Implement the transaction service**
The `@Transactional` method must:
1. Normalize and validate `requestId` and IDs.
2. Return an existing batch when request ID and charge set match; reject reuse with different IDs.
3. Allow only `chargeWay=1` until a confirmed channel adapter exists.
4. Lock charges and main accounts in ascending ID order.
5. Calculate amounts and compare server total with expected total.
6. Create per-charge PaymentRecords with shared batch/request IDs and explicit channel/prepay values.
7. Write prepay deductions using the payment record ID in `AccountLogContext`.
8. Conditionally mark every charge paid; any update count other than one throws and rolls back.
9. Create overpay top-up and balance increase in the same transaction.
10. Return the batch response.
- [ ] **Step 8: Add the controller endpoint**
```java
@PostMapping("/counter-charge/submit")
@PreAuthorize("@ss.hasPermission('business:charge:update')")
public CommonResult<CounterChargeSubmitRespVO> submitCounterCharge(
@Valid @RequestBody CounterChargeSubmitReqVO reqVO) {
return success(counterChargeApplicationService.submit(reqVO));
}
```
- [ ] **Step 9: Run focused tests**
```bash
mvn -pl sw-business/sw-business-server -Dtest=CounterChargeAmountCalculatorTest,CounterChargeApplicationServiceImplTest,ChargeControllerTest,PaymentRecordServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: PASS.
- [ ] **Step 10: Commit**
```bash
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countercharge sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/charge sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentrecord sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentapp sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countercharge sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/controller/admin/charge
git commit -m "feat: add atomic multi-bill counter charge command"
```
### Task 5: Close generic charge and account financial bypasses
**Files:**
- Modify: `ChargeServiceCounterPaymentTest.java`, `ChargeServiceImpl.java`.
- Modify: `AccountServiceImplConcurrencyTest.java`, `AccountServiceImpl.java`.
- [ ] **Step 1: Replace old generic-payment tests with rejection tests**
```java
@Test
void updateCharge_shouldRejectCounterPaymentTransition() {
ChargeDO before = buildCharge(1001L, PayStateEnum.UNPAID.getValue());
when(chargeMapper.selectById(1001L)).thenReturn(before);
ChargeSaveReqVO req = paidCounterUpdate(1001L);
ServiceException error = assertThrows(ServiceException.class, () -> chargeService.updateCharge(req));
assertEquals("柜台收费请使用专用收费接口", error.getMessage());
verify(chargeMapper, never()).updateById(any());
}
@Test
void deleteCharge_shouldRejectPaidOrSettledBill() { /* payState != UNPAID */ }
```
- [ ] **Step 2: Verify RED**
Run `ChargeServiceCounterPaymentTest`. Expected: old path still succeeds.
- [ ] **Step 3: Implement guards before generic updates/deletes**
Reject any `UNPAID -> PAID/SETTLED` transition in `updateCharge`; reject delete when `payState != UNPAID` or an active PaymentRecord exists.
- [ ] **Step 4: Guard generic account balance changes**
Add tests that `updateAccount` rejects a request whose deposit differs from the persisted account and `deleteAccount` rejects non-zero balances. Preserve non-financial account metadata updates.
- [ ] **Step 5: Run focused tests and commit**
```bash
mvn -pl sw-business/sw-business-server -Dtest=ChargeServiceCounterPaymentTest,AccountServiceImplConcurrencyTest -Dsurefire.failIfNoSpecifiedTests=false test
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/account/AccountServiceImpl.java sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceCounterPaymentTest.java sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/account/AccountServiceImplConcurrencyTest.java
git commit -m "fix: block generic financial state mutations"
```
### Task 6: Settle an explicit payment-record set and use channel amounts
**Files:**
- Modify settlement VO/service/tests, PaymentRecord service/mapper, query VO/service/tests.
- [ ] **Step 1: Write failing exact-scope tests**
```java
@Test
void confirm_shouldSettleOnlyRequestedPaymentRecords() {
CounterSettleConfirmReqVO req = new CounterSettleConfirmReqVO();
req.setPaymentRecordIds(List.of(101L, 102L));
req.setSettleTime(SETTLE_TIME);
when(paymentRecordService.getCounterUnsettledRecordsByIds(List.of(101L, 102L)))
.thenReturn(List.of(record(101L, "1001", "20.00", "5.00"),
record(102L, "1001", "16.00", "0.00")));
CounterSettleRespVO result = service.confirm(req);
verify(paymentRecordService).markSettled(List.of(101L, 102L), result.getSettleId(), SETTLE_TIME);
assertEquals(new BigDecimal("36.00"), result.getTotalAmount());
}
@Test
void confirm_shouldRejectRequestedRecordOwnedByAnotherCashier() { /* login 1001, row 2002 */ }
@Test
void confirm_shouldRejectWhenAnyRequestedIdIsMissingOrAlreadySettled() { /* size mismatch */ }
```
- [ ] **Step 2: Verify RED**
Run `CounterSettleApplicationServiceImplTest`. Expected: compile failure because request IDs and service method do not exist.
- [ ] **Step 3: Change the request contract**
```java
@NotEmpty(message = "请选择待结账记录")
private List<@NotNull Long> paymentRecordIds;
// cashierId remains read-compatible but is ignored when an authenticated user exists
private String cashierId;
```
- [ ] **Step 4: Implement exact-scope settlement**
- Normalize/deduplicate request IDs.
- Load eligible rows only by IDs.
- Require loaded count to equal requested count.
- Resolve cashier from authenticated user and require every row to match.
- Calculate `settleAmount` from `channelAmount`, falling back to legacy `paymentAmount` only when the new field is null.
- Mark exactly those records settled and exactly their charge IDs projected as settled.
- [ ] **Step 5: Expose amount split in query responses**
Add `channelAmount` and `prepayAmount` to unsettled/detail response VOs. Update payment summary to aggregate channel money separately from prepay.
- [ ] **Step 6: Run focused tests and commit**
```bash
mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest,PaymentQueryServiceTest,PaymentRecordServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/vo sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countersettle sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentrecord sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentquery sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentquery
git commit -m "fix: settle explicit counter payment records"
```
### Task 7: Repair top-up reversal and enforce reverse uniqueness
**Files:**
- Modify: `CounterSettleApplicationServiceImplTest.java`, `CounterSettleApplicationServiceImpl.java`, `PaymentRecordMapper.java`, `PaymentRecordServiceImplTest.java`, `PaymentRecordServiceImpl.java`.
- [ ] **Step 1: Correct the mock expectation first**
Change the settled top-up test to expect:
```java
when(paymentRecordMapper.markCounterSettleTopupReversed(501L, 701L)).thenReturn(1);
verify(paymentRecordMapper).markCounterSettleTopupReversed(501L, 701L);
verify(paymentRecordMapper, never()).markCounterSettleReversed(anyLong(), anyLong());
```
Add an unsettled top-up test expecting `markCounterUnsettledTopupReversed`.
- [ ] **Step 2: Verify RED**
Run the two top-up red-flush tests. Expected: FAIL because production calls the charge-payment mapper method or rejects the unsettled top-up.
- [ ] **Step 3: Route each state to the correct mapper method**
Use `markCounterSettleTopupReversed` for settled top-ups and `markCounterUnsettledTopupReversed` for unsettled top-ups. Do not require a settle detail for an unsettled top-up.
- [ ] **Step 4: Handle concurrent duplicate reverse insertion**
Keep the existing read-before-insert behavior for friendly replay, rely on the new unique index for races, and translate duplicate-key exceptions by reloading the existing reverse record.
- [ ] **Step 5: Run tests and commit**
```bash
mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest,PaymentRecordServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countersettle sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentrecord sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/paymentrecord sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord
git commit -m "fix: reverse counter topups through correct state path"
```
### Task 8: Harden bank invalidation and prestorage callback replay
**Files:**
- Create: `water-backend/sw-business-bank/sw-business-bank-server/src/test/java/cn/com/emsoft/sw/bankbusiness/service/payinvalid/PayInvalidServiceImplTest.java`
- Modify: `PayInvalidServiceImpl.java`, `ChargeServiceImpl.java`.
- Modify: `PrestorageBpmCallbackServiceTest.java`, `PrestorageBpmCallbackService.java`, `PrestorageFormalizationService.java` and its mapper.
- [ ] **Step 1: Add a failing invalid-charge state test**
```java
@Test
void invalidCharge_shouldRejectWhenBusinessReverseDidNotRun() {
ChargeDO charge = charge(1L, PayStateEnum.SETTLED.getValue());
when(chargeMapper.selectById(1L)).thenReturn(charge);
ServiceException error = assertThrows(ServiceException.class, () -> service.invalidCharge(1L));
assertEquals("当前账单状态不允许银行冲正", error.getMessage());
verify(paymentCommandApplicationService, never()).reverseChargePayment(any());
}
```
- [ ] **Step 2: Implement explicit invalidation result**
`ChargeServiceImpl.invalidCharge` returns only after `reverseChargePayment` succeeds. Null charge returns null; every non-`PAID` state throws. `PayInvalidServiceImpl` treats any exception or null as failure and must not mark the original bank transaction reversed.
- [ ] **Step 3: Add failing callback replay tests**
```java
@Test
void handleApproved_shouldReturnWithoutMutationWhenAlreadyCompleted() {
PrestorageAdjustDO main = adjustment("COMPLETED");
when(prestorageFormalizationService.getViewForUpdate("REV004-PRF-1"))
.thenReturn(view(main));
service.handleApproved("REV004-PRF-1", "PROC-1");
verifyNoInteractions(accountService, paymentCommandApplicationService);
}
```
Add a transfer test that verifies both accounts are locked in ascending account ID order and changed through `AccountService` with AccountLog contexts.
- [ ] **Step 4: Implement locked callback execution**
- Add `getViewForUpdate(adjustmentNo)` using a mapper `FOR UPDATE` query.
- Return immediately when `businessStatus=COMPLETED`.
- Only `APPROVED_PENDING_EXECUTION`, or the initial approved callback before binding, may execute.
- Replace direct `AccountMapper.updateById` balance writes with locked `AccountService.decreaseDeposit/increaseDeposit` calls carrying adjustment source information.
- [ ] **Step 5: Run focused tests and commit**
```bash
mvn -pl sw-business/sw-business-server -Dtest=PrestorageBpmCallbackServiceTest -Dsurefire.failIfNoSpecifiedTests=false test
mvn -pl sw-business-bank/sw-business-bank-server -Dtest=PayInvalidServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/accountingadjust/prestorage sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/accountingadjust/prestorage sw-business-bank/sw-business-bank-server/src/main/java/cn/com/emsoft/sw/bankbusiness/service/payinvalid sw-business-bank/sw-business-bank-server/src/test/java/cn/com/emsoft/sw/bankbusiness/service/payinvalid sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java
git commit -m "fix: prevent false bank reversal and callback replay"
```
### Task 9: Switch counter charging UI to the atomic command
**Files:**
- Create: `counterChargeMath.mjs`, declaration file, `counterChargingP0Flow.test.mjs`.
- Modify: counter-charging API and page.
- [ ] **Step 1: Write failing real math-helper tests**
```javascript
import test from 'node:test'
import assert from 'node:assert/strict'
import { billReceivable, parseMoney } from '../../src/views/operatingCharges/counterCharging/counterChargeMath.mjs'
test('bill receivable adds principal and late fee', () => {
assert.equal(billReceivable({ extendedAmount: 100, lateFee: 10 }), 110)
})
test('parseMoney rejects non finite values', () => {
assert.equal(parseMoney('abc'), null)
assert.equal(parseMoney('Infinity'), null)
assert.equal(parseMoney('10.20'), 10.2)
})
```
- [ ] **Step 2: Verify RED**
Run `node --test tests/operatingCharges/counterChargingP0Flow.test.mjs`. Expected: module-not-found.
- [ ] **Step 3: Implement the helper**
```javascript
export const toCents = (value) => Math.round(Number(value || 0) * 100)
export const fromCents = (value) => Number((value / 100).toFixed(2))
export const parseMoney = (value) => {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= 0 ? fromCents(toCents(parsed)) : null
}
export const billReceivable = (bill) => fromCents(
toCents(bill?.extendedAmount ?? bill?.billAmount ?? 0) + toCents(bill?.lateFee ?? 0)
)
```
- [ ] **Step 4: Add the atomic API contract**
```ts
export interface CounterChargeSubmitPayload {
requestId: string
chargeIds: number[]
expectedReceivableAmount: number
actualPayAmount: number
usePrepay: boolean
chargeWay: number
remark?: string
}
export const submitCounterCharge = (data: CounterChargeSubmitPayload) =>
request.post<CounterChargeSubmitRespVO>({
url: '/business/charge/counter-charge/submit',
data
})
```
Update top-up payload to require `requestId`; stop sending client-owned cashier/time for the new path.
- [ ] **Step 5: Replace page-side allocation and sequential submission**
- Calculate display receivable with `billReceivable`.
- Validate `parseMoney(actualAmount)` before opening and before confirming.
- Keep a `pendingRequestId` across retry; clear it only when selection/amount changes or the request succeeds.
- Call `submitCounterCharge` exactly once with all selected IDs.
- Remove the `for ... submitCashCharge` loop and separate overpay request.
- Update success rendering from the batch response and then refresh customer, bills, balance, and payment summary.
- Only cash remains enabled for direct submission; configuration failure also falls back to cash only.
- Rename “删除” to “本次不收”.
- [ ] **Step 6: Expand confirmation details**
Display principal, late fee, total receivable, estimated prepay, channel actual, and overpay. Mark the prepay split as “以服务端提交结果为准”.
- [ ] **Step 7: Run focused frontend tests**
```bash
node --test tests/operatingCharges/counterChargingP0Flow.test.mjs src/views/operatingCharges/counterCharging/counterTopup.contract.test.mjs tests/operatingCharges/counterChargingZeroAmount.contract.test.mjs tests/revenue-bugs/counterChargeAndCheckoutDisplay.contract.test.mjs
```
Expected: new tests PASS; update obsolete regex assertions in existing files to the new accepted behavior, without weakening amount or atomicity assertions.
- [ ] **Step 8: Commit**
```bash
git add src/api/operatingCharges/counterCharging src/views/operatingCharges/counterCharging types/counter-charge-math.d.ts tests/operatingCharges/counterChargingP0Flow.test.mjs tests/operatingCharges/counterChargingZeroAmount.contract.test.mjs tests/revenue-bugs/counterChargeAndCheckoutDisplay.contract.test.mjs
git commit -m "fix: submit counter charges as one atomic batch"
```
### Task 10: Make counter settlement selection real
**Files:**
- Create: `tests/operatingCharges/counterCheckoutExplicitSelection.test.mjs`.
- Modify: counter-settle API and three counter-checkout components.
- [ ] **Step 1: Write the failing page contract**
```javascript
test('counter checkout submits selected payment record ids', () => {
assert.match(panel, /@selection-change="\(rows.*handleGroupSelection/)
assert.match(panel, /selectedPaymentRecordIds/)
assert.match(dialog, /paymentRecordIds: summary\.value\.paymentRecordIds/)
assert.match(api, /paymentRecordIds: number\[\]/)
})
```
- [ ] **Step 2: Verify RED**
Run the new test. Expected: FAIL because selection is currently decorative and the request only contains cashier ID.
- [ ] **Step 3: Implement group selection state**
Maintain `Map<string, CounterUnsettledRowVO[]>`, flatten unique selected rows, and derive amount/count from those rows only. The settle button is disabled until at least one eligible row is selected. Clear selections after data reload or successful settlement.
- [ ] **Step 4: Submit exact IDs**
Change `CounterSettleConfirmReqVO` to:
```ts
export interface CounterSettleConfirmReqVO {
paymentRecordIds: number[]
settleTime: string
remark?: string
}
```
Pass IDs through the dialog. Do not send `cashierId` as authority.
- [ ] **Step 5: Make reversal reason required**
```ts
export interface CounterRedFlushReqVO {
paymentRecordIds: number[]
reason: string
}
```
Use an input validator that rejects blank reasons in both unsettled and settled dialogs.
- [ ] **Step 6: Run focused tests and commit**
```bash
node --test tests/operatingCharges/counterCheckoutExplicitSelection.test.mjs tests/rev006/counterCheckoutOldPageInventory.test.mjs tests/revenue-bugs/counterChargeAndCheckoutDisplay.contract.test.mjs src/views/operatingCharges/counterCheckout/redFlushReason.contract.test.mjs
git add src/api/business/charge/counterSettle.ts src/views/operatingCharges/counterCheckout tests/operatingCharges/counterCheckoutExplicitSelection.test.mjs tests/rev006/counterCheckoutOldPageInventory.test.mjs tests/revenue-bugs/counterChargeAndCheckoutDisplay.contract.test.mjs
git commit -m "fix: settle only explicitly selected payment records"
```
### Task 11: Verification, formal documentation, and evidence
**Files:**
- Create: `water-docs/docs/evidence/rev003-charging/2026-07-15-p0-verification.md`.
- Modify formal detailed/interface design and the audit record.
- [ ] **Step 1: Run backend unit tests by focused class**
```bash
mvn -pl sw-business/sw-business-server -Dtest=CounterChargeAmountCalculatorTest,CounterChargeApplicationServiceImplTest,PaymentRecordServiceImplTest,ChargeServiceCounterPaymentTest,AccountServiceImplConcurrencyTest,CounterSettleApplicationServiceImplTest,PaymentQueryServiceTest,PrestorageBpmCallbackServiceTest -Dsurefire.failIfNoSpecifiedTests=false test
mvn -pl sw-business-bank/sw-business-bank-server -Dtest=PayInvalidServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test
```
- [ ] **Step 2: Run backend compile**
```bash
mvn -pl sw-business/sw-business-server,sw-business-bank/sw-business-bank-server -am -DskipTests compile
```
- [ ] **Step 3: Run frontend deterministic tests**
```bash
node --test \
tests/operatingCharges/counterChargingP0Flow.test.mjs \
tests/operatingCharges/counterCheckoutExplicitSelection.test.mjs \
src/views/operatingCharges/counterCharging/counterTopup.contract.test.mjs \
src/views/operatingCharges/counterCheckout/redFlushReason.contract.test.mjs \
tests/operatingCharges/counterChargingZeroAmount.contract.test.mjs \
tests/revenue-bugs/counterChargeAndCheckoutDisplay.contract.test.mjs \
tests/rev006/counterCheckoutOldPageInventory.test.mjs
```
- [ ] **Step 4: Run frontend build without `vue-tsc`**
```bash
pnpm build:dev
```
Do not run `pnpm ts:check` or any direct `vue-tsc` command.
- [ ] **Step 5: Run database integration tests when configured**
```bash
if [ -n "$REV004_IT_DB_URL" ]; then
mvn -pl sw-business/sw-business-server \
-Dtest=CounterChargeFullChainIntegrationTest,CounterSettleIntegrationTest \
-Dsurefire.failIfNoSpecifiedTests=false test
else
echo "BLOCKED: REV004_IT_DB_URL is not configured"
fi
```
- [ ] **Step 6: Update formal design**
Document the actual `/business/charge/counter-charge/submit` contract, request idempotency, explicit settlement IDs, amount formulas, channel-confirmation boundary, and additive database fields. Mark the old generic update payment path as compatibility-only and prohibited for new counter payments.
- [ ] **Step 7: Record verification evidence**
The evidence file must contain exact commands, exit codes, test counts, build result, integration-test `PASS/FAIL/BLOCKED`, backend/frontend commit SHAs, remaining risks, and an explicit statement that `vue-tsc` was not run.
- [ ] **Step 8: Commit documentation**
```bash
git add docs/evidence/rev003-charging docs/design/02_Detailed_Design/12_REV_Detailed.md docs/design/03_Technical_Design/03_Interface_Design.md docs/superpowers/plans/2026-07-15-rev003-charging-p0-remediation.md
git commit -m "docs: record REV-003 charging P0 implementation evidence"
```
## Plan self-review
- Spec coverage: all P0 findings in the approved design map to Tasks 110; bank outbox is explicitly excluded from the first delivery but false-success handling is included.
- Placeholder scan: no placeholder markers or unspecified test steps remain.
- Type consistency: request fields use `requestId`, `chargeIds`, `expectedReceivableAmount`, `actualPayAmount`, `usePrepay`, `chargeWay`; settlement consistently uses `paymentRecordIds`.
- Safety: all production changes have a failing test first; all financial mutations are transactional and use locked rows or conditional updates.
- User constraint: no plan step invokes `vue-tsc`.

View File

@ -1,60 +0,0 @@
# 柜台收费缴费后连续预存设计
## 背景与问题
单客户柜台收费完成后,页面会保留刚缴纳的账单作为“收讫”展示行。当前实现同时将这些账单标记为 `displayChargeState: 'settled'` 并继续保留在 `selectedChargeRows` 中。
无欠费预存模式要求页面不存在 `settled` 展示行,因此缴费完成后收费按钮被禁用,用户无法在同一客户、同一页面中继续办理预存。
## 目标
- 单客户普通账单缴费成功后,可以立即继续办理预存。
- 已缴账单继续显示“收讫”,用于核对和查看账单详情。
- 已缴账单不得再次进入收费提交数据。
- 缴费完成后清空上一次实收金额,避免误将账单金额作为预存金额再次提交。
- 集收号收费逻辑保持不变。
## 方案
### 已缴账单展示状态
单客户普通账单收费成功后:
1. 保留账单展示行;
2. `payState``payStateName` 继续表示“收讫”;
3. 将 `displayChargeState` 设为 `history`,表示该行仅供回看,不再阻塞后续操作;
4. `displayChargeType` 保持 `bill`,详情继续使用普通账单详情链路。
### 选中状态与金额
- 收费成功后将 `selectedChargeRows` 清空。
- 调用现有金额同步逻辑后,`actualAmount` 清空。
- 已缴展示行因 `payState === 1` 继续保持不可勾选,不能再次加入收费数据。
### 预存模式
后台刷新后不存在待缴账单,且历史展示行不计入 `hasSettledChargeDisplay`,因此 `noArrearsTopupMode` 自动成立。收费按钮重新可用,用户输入新的实收金额后,提交路径调用预存接口而不是账单收费接口。
### 集收号边界
集收号收费完成后的展示和按钮限制不调整。集收号模式仍不支持缴费后直接多缴或预存,避免改变现有批量收费约束。
## 异常与安全约束
- 如果收费成功后的账单刷新失败,沿用现有异常提示,不把失败状态误切换成预存模式。
- 历史收讫行必须不可选择、不可删除,但可以查看详情和打印。
- 连续预存提交时,支付载荷中不得包含刚缴纳账单的 ID。
- 页面切换客户或关闭客户时,继续沿用现有状态清理逻辑。
## 测试与验收
新增前端 `node:test` 契约测试,先验证失败再实施:
1. 单客户普通账单收费成功后,收讫展示行使用 `history` 状态;
2. 收费成功后 `selectedChargeRows` 被清空;
3. 历史收讫行仍保持 `payState: 1``payStateName: '收讫'``displayChargeType: 'bill'`
4. 历史收讫行不阻塞 `noArrearsTopupMode`
5. 连续操作时进入 `submitCounterTopup` 路径,不重复调用 `submitCashCharge`
6. 集收号收费成功后的现有处理保持不变。
验证运行相关 `node:test`、柜台收费现有测试和前端构建;按用户要求不运行 `vue-tsc`

View File

@ -1,77 +0,0 @@
# 柜台收费预存款详情展示修复设计
## 背景与问题
柜台收费在客户无欠费时支持办理预存。预存成功后,页面会把支付记录作为一条只读的“预存款”记录展示。
当前预存款行的 `id` 是支付记录 ID但点击“详情”时统一调用 `/business/charge/get`,该接口要求账单 ID。由于对象类型和 ID 语义不一致,账单详情弹窗无法取得数据,所有字段显示为 `--`
## 目标
- 预存款记录点击“详情”时展示符合预存业务语义的信息。
- 预存款详情不得调用账单详情接口。
- 普通账单详情链路保持不变。
- 历史恢复的最新预存记录与刚办理成功的预存记录使用同一套详情展示。
## 非目标
- 不新增后端支付记录详情接口。
- 不改造普通账单详情接口。
- 不扩展为完整的历史预存记录列表。
- 不调整预存、收费或余额入账逻辑。
## 方案
### 详情分流
点击详情时根据行的 `displayChargeType` 判断记录类型:
- `topup`:打开预存款专用详情,不调用 `/business/charge/get`
- `bill` 或未标记:继续使用现有账单详情链路。
### 预存详情数据
预存款显示行补充并保留以下支付快照字段:
- 支付记录 ID
- 预存金额
- 收费时间
- 收费方式
- 期初余额
- 期末余额
- 收费状态
客户编号、客户名称、客户地址优先取当前客户信息;支付字段取预存记录行。历史恢复时使用 `/business/charge/payment-record/page-new` 已返回的 `lastDeposit``deposit``actualMoney``chargeWay` 和支付时间。
刚办理成功的预存记录在刷新最新预存记录后展示,以确保详情字段与持久化支付记录一致;若刷新失败,则保留成功响应和当前客户信息作为降级展示,不影响预存成功结果。
### 页面展示
预存款专用详情使用独立弹窗,展示:
1. 客户编号、客户名称、客户地址;
2. 业务类型(固定为“预存款”)、支付记录 ID
3. 预存金额、期初余额、期末余额;
4. 收费时间、收费方式、收费状态(“收讫”)。
不展示账务年月、抄码、水量、账单金额、违约金、开票状态等账单专属字段。
## 异常处理
- 预存行缺少支付记录 ID 时,不发起账单详情请求,提示“预存记录信息不完整”。
- 金额或余额为 `0` 时必须显示 `0.00`,不得因假值判断显示 `--`
- 期初或期末余额确实缺失时显示 `--`
- 普通账单详情请求失败时沿用现有错误提示和关闭弹窗行为。
## 测试与验收
新增前端 `node:test` 契约测试,先验证失败再实施修复,覆盖:
1. 预存款详情按 `displayChargeType === 'topup'` 分流;
2. 预存款详情不调用 `getChargeById`
3. 专用弹窗包含客户、支付、余额和状态字段;
4. 历史预存记录映射 `lastDeposit``deposit`
5. 普通账单仍调用原账单详情接口;
6. `0` 金额与余额正确显示。
验证仅运行相关 `node:test`、柜台收费现有测试和前端构建;按用户要求不运行 `vue-tsc`

View File

@ -1,224 +0,0 @@
# REV-003 营业收费 P0 整改设计
## 1. 目标
在不破坏历史数据查询的前提下,将柜台收费从“逐账单通用更新”迁移为服务端专用、可幂等、可并发保护、可准确结账和可完整红冲的交易链路。
## 2. 范围
本设计包含五个柜台主链路工作包和一个外部资金工作包:
1. 统一本金、违约金、渠道实收、预存抵扣和多缴转预存口径。
2. 新增专用批量柜台收费接口,在一个事务中处理多个账单。
3. 柜员结账改为按明确的支付记录 ID 集合确认。
4. 修复已结账和未结账预存红冲。
5. 增加账户行锁、请求幂等键和关键唯一约束,封堵通用支付旁路。
6. 修复银行冲正错误成功和预存审批回调重放;银行全量 outbox 状态机作为该工作包的后续增强,不阻塞柜台主链路首批上线。
本轮不扩展新的支付渠道适配器、发票平台、打印平台和柜员现金盘点页面。非现金渠道在没有真实渠道确认信息时不得通过新接口直接销账。
## 3. 方案比较
### 方案 A继续修补现有逐账单 `/charge/update`
优点是改动小;缺点是无法提供跨账单事务、请求幂等和清晰的支付边界,通用 CRUD 仍可绕过财务校验。拒绝采用。
### 方案 B新增专用柜台收费接口保留旧接口作为非支付兼容入口
新接口负责金额校验、账户锁定、支付事实和账单投影;前端一次提交全部账单。旧接口禁止产生新的柜台支付状态迁移。该方案兼容历史查询和已有数据模型,风险可控。采用此方案。
### 方案 C立即重建统一支付平台和单一支付主单模型
长期最完整,但会同时影响银行、微信、支付宝、代扣、发票和对账,超出单个 P0 修复窗口。作为后续支付域重构方向,不在本轮直接实施。
## 4. 目标架构
```text
前端选择账单
-> POST /business/charge/counter-charge/submit
-> 服务端按 requestId 查询幂等结果
-> 锁定账单和主付款账户
-> 重新计算本金、违约金、预存抵扣和渠道应收
-> 校验页面期望金额与服务端金额
-> 写同一 paymentBatchNo 下的支付记录和明细
-> 扣减预存、处理多缴转预存并写账户流水
-> 条件更新账单为已收
-> 返回批次号、支付记录、金额拆分和余额结果
```
现阶段继续保留“一张账单一条 PaymentRecord”通过 `payment_batch_no``request_id` 形成一次业务收费批次。这样避免一次性重写现有查询、红冲和发票关联逻辑,同时满足跨账单事务和批次凭证要求。
## 5. 金额模型
每张账单:
```text
principalAmount = max(extendedAmount, 0)
lateFeeAmount = max(lateFee, 0)
receivableAmount = principalAmount + lateFeeAmount
channelAmount + prepayAmount = receivableAmount
```
整个收费批次:
```text
totalReceivable = sum(receivableAmount)
actualChannelAmount + totalPrepayAmount
= totalReceivable + overpayTopupAmount
```
PaymentRecord 字段口径:
- `payment_amount`:该账单实际核销总额,等于本金加违约金。
- `bill_amount`:本金。
- `late_fee_amount`:违约金。
- `channel_amount`:外部渠道实收。
- `prepay_amount`:预存抵扣。
- `overpay_amount`:仅独立多缴转预存记录使用;账单支付记录固定为零。
- `allocated_amount`:实际核销金额,账单支付时等于 `payment_amount`
柜员结账只汇总 `channel_amount`,预存抵扣单独展示,不计入柜员应交渠道资金。
## 6. 接口设计
### 6.1 批量柜台收费
`POST /admin-api/business/charge/counter-charge/submit`
请求:
```json
{
"requestId": "uuid-or-terminal-request-no",
"chargeIds": [1001, 1002],
"expectedReceivableAmount": 120.50,
"actualPayAmount": 70.50,
"usePrepay": true,
"chargeWay": 1,
"remark": "柜台收费"
}
```
规则:
- `requestId` 必填,同租户内相同请求重复提交返回首次成功结果。
- `chargeIds` 去重后不能为空,所有账单必须处于未收状态。
- 服务端重新计算应收;与 `expectedReceivableAmount` 不一致时拒绝并要求刷新。
- `actualPayAmount` 允许为零,但只允许出现在全额预存抵扣场景。
- 少付拒绝;多缴仅允许账单归属同一个主付款账户,多缴部分在同一事务生成独立预存记录。
- 首批新接口只允许现金渠道直接完成;其他渠道必须有真实渠道确认能力后再加入允许列表。
- 收费员、收费时间和网点由登录上下文生成,不接受客户端覆盖。
响应:
```json
{
"paymentBatchNo": "CP202607150001",
"paymentRecordIds": [5001, 5002],
"totalReceivableAmount": 120.50,
"channelAmount": 70.50,
"prepayAmount": 50.00,
"overpayTopupAmount": 0.00,
"balanceAfter": 0.00
}
```
### 6.2 柜台预存
保留 `POST /business/charge/counter-topup` 路径,增加必填 `requestId`,收费员和时间改为服务端生成。充值支付记录写入 `channel_amount``request_id``payment_batch_no`
### 6.3 柜员结账
`POST /business/charge/counter-settle/confirm` 增加必填 `paymentRecordIds`
```json
{
"paymentRecordIds": [5001, 5002],
"settleTime": "2026-07-15T18:00:00",
"remark": "当班结账"
}
```
服务端只结清这些记录,并校验记录均属于当前登录收费员、均为未结账、均未绑定其他结账单。结账金额汇总 `channel_amount`,同时在明细中保留账单金额和预存抵扣金额。
## 7. 幂等与并发
- `biz_payment_record` 增加 `request_id``payment_batch_no``channel_amount``prepay_amount``overpay_amount`
- 柜台账单支付增加 `(tenant_id, request_id, biz_scene, source_ref_id)` 条件唯一索引。
- 柜台充值增加 `(tenant_id, request_id, biz_scene)` 条件唯一索引。
- 同租户、同 `requestId` 在查询支付记录前获取 PostgreSQL 事务级 advisory lock避免两组不相交账单并发复用同一请求号时绕过逐账单唯一索引。
- 反向支付增加 `(tenant_id, related_payment_record_id, biz_scene)` 条件唯一索引。
- 收费事务对所选营业账按 ID 排序并加行锁,对主付款账户按客户 ID 排序并加行锁,避免死锁。
- 账户增减统一从 `SELECT ... FOR UPDATE` 获取余额,再写余额和 AccountLog。
- 账单投影使用 `WHERE pay_state = UNPAID` 的条件更新,更新数不等于预期时整批回滚。
## 8. 红冲设计
- 未结账账单支付:原支付改为 `REVERSED`,追加 `CHARGE_REVERSE/OUT`,恢复预存抵扣并将账单恢复未收。
- 已结账账单支付:先反转结账明细,再执行同样的支付反向和账单恢复。
- 未结账预存:调用 `markCounterUnsettledTopupReversed`,扣减账户余额,追加 `DEPOSIT_REFUND/OUT`
- 已结账预存:调用 `markCounterSettleTopupReversed`,反转结账明细,扣减账户余额,追加 `DEPOSIT_REFUND/OUT`
- 红冲原因前后端统一必填。
- 数据库唯一索引阻止同一原支付产生两张相同业务场景的反向单。
- 银行冲正必须同时传入账单 ID 和原 `bankTransactionId`,锁定精确银行支付主单;旧交易的幂等重试只返回既有反向单,不得重置后续新缴费的账单投影。
## 9. 高风险旁路
- 通用 `updateCharge` 不再允许从未收到已收的柜台支付迁移,调用方必须使用新接口。
- 已收或已结账营业账禁止通过通用删除接口删除。
- 账户通用更新不得直接改变 `deposit`;账户删除至少要求余额为零且不存在有效流水。
- `invalidCharge` 在未实际执行反向处理时必须抛出明确错误,银行侧不得以非空对象判断冲正成功。
- 银行缴费按 `extendedAmount + lateFee` 计算应收分值,业务层对账单加行锁并校验银行交易流水金额后才生成支付事实。
- 预存审批成功回调只允许从一个明确待执行状态进入完成状态;完成状态重放直接返回,不再修改余额。
## 10. 前端设计
- 实收金额改为受控金额输入,提交前校验有限数、非负和两位小数。
- 应收金额使用 `本金 + 违约金`,不再只取 `extendedAmount`
- 前端不再计算逐账单预存分配,只提交 `usePrepay` 和批次金额,由服务端返回最终拆分。
- 收费确认框展示应收、本金、违约金、预存抵扣、渠道实收和多缴转预存。
- 一次调用批量收费接口;任何失败均不在前端制造部分成功状态。
- 柜员结账表格真实维护选择状态,确认请求提交选中的 `paymentRecordIds` 和对应金额。
- 配置加载失败时只保留现金安全默认项;没有真实渠道确认能力的方式不可提交。
- “删除账单”改名为“本次不收”。
- 收费/预存命令成功后立即清除当前请求号并展示成功结果;后续客户、余额、账单或汇总刷新失败只提示刷新警告,不能回落为“收费失败/预存失败”。
- 集收成功后逐户刷新客户余额和未缴账单,同时保留本次收讫快照;柜员结账范围固定为当前登录收费员。
## 11. 错误处理
- 金额变化:返回“账单金额已变化,请刷新后重新收费”。
- 幂等重复:返回首次成功批次,不重复扣款或充值。
- 并发收费:条件更新失败后整批回滚并返回“账单已被其他操作处理”。
- 余额不足:整批回滚,返回最新可用余额。
- 结账范围失效:任何选中记录已结账或不属于当前收费员时,整批拒绝。
- 红冲余额不足:不修改原支付和结账状态,返回“红冲预存款余额不足”。
## 12. 测试设计
必须按 TDD 完成:
1. 本金 100、违约金 10收费总额必须为 110支付明细本金 100、违约金 10。
2. `actualPayAmount` 为负数、少付、非数字映射结果时拒绝。
3. 两张账单第二张更新失败,第一张支付、预存和账单状态全部回滚。
4. 同一 `requestId` 重复收费或充值,只产生一组支付事实。
5. 同一账单并发收费,只有一个请求成功。
6. 同一账户并发扣款或充值,余额和账户流水不丢失。
7. 当前页选择两条结账时,后端只结这两条。
8. 已结账和未结账预存分别可以红冲;重复红冲只产生一张反向单。
9. 银行冲正未实际反向时返回失败。
10. 预存审批完成回调重放不重复修改余额。
11. 同一 `requestId`、两组不相交账单并发提交时只能形成一个业务批次。
12. 银行支付 A 冲正后由 B 再次缴费A 的冲正重试不得冲掉 B。
13. 收费命令成功但页面刷新失败时,只显示刷新警告且不生成新的重试请求号。
前端验证使用 `node:test`、现有页面 smoke 或 Playwright不运行 `vue-tsc`。后端按相关测试类单独执行,数据库集成测试继续由 `REV004_IT_DB_URL` 门禁控制。
## 13. 发布与兼容
1. 先部署数据库新增列和唯一索引,新增列允许为空,历史记录不强制回填。
2. 部署后端新接口及旧接口支付迁移封堵。
3. 部署前端切换到新接口。
4. 观察支付批次、账户流水和结账差异后,再将非现金渠道逐一接入真实确认流程。
5. 历史记录继续按旧字段读取;新记录优先使用显式金额拆分字段。
回滚时前端可切回旧页面版本,但后端不恢复通用接口的支付旁路;数据库新增列保留,不执行破坏性回滚。