fujian_water_biz_doc/docs/superpowers/specs/2026-07-15-rev003-charging-p0-remediation-design.md

225 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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. 历史记录继续按旧字段读取;新记录优先使用显式金额拆分字段。
回滚时前端可切回旧页面版本,但后端不恢复通用接口的支付旁路;数据库新增列保留,不执行破坏性回滚。