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

10 KiB
Raw Blame History

REV-003 营业收费 P0 整改设计

1. 目标

在不破坏历史数据查询的前提下,将柜台收费从“逐账单通用更新”迁移为服务端专用、可幂等、可并发保护、可准确结账和可完整红冲的交易链路。

2. 范围

本设计包含五个柜台主链路工作包和一个外部资金工作包:

  1. 统一本金、违约金、渠道实收、预存抵扣和多缴转预存口径。
  2. 新增专用批量柜台收费接口,在一个事务中处理多个账单。
  3. 柜员结账改为按明确的支付记录 ID 集合确认。
  4. 修复已结账和未结账预存红冲。
  5. 增加账户行锁、请求幂等键和关键唯一约束,封堵通用支付旁路。
  6. 修复银行冲正错误成功和预存审批回调重放;银行全量 outbox 状态机作为该工作包的后续增强,不阻塞柜台主链路首批上线。

本轮不扩展新的支付渠道适配器、发票平台、打印平台和柜员现金盘点页面。非现金渠道在没有真实渠道确认信息时不得通过新接口直接销账。

3. 方案比较

方案 A继续修补现有逐账单 /charge/update

优点是改动小;缺点是无法提供跨账单事务、请求幂等和清晰的支付边界,通用 CRUD 仍可绕过财务校验。拒绝采用。

方案 B新增专用柜台收费接口保留旧接口作为非支付兼容入口

新接口负责金额校验、账户锁定、支付事实和账单投影;前端一次提交全部账单。旧接口禁止产生新的柜台支付状态迁移。该方案兼容历史查询和已有数据模型,风险可控。采用此方案。

方案 C立即重建统一支付平台和单一支付主单模型

长期最完整,但会同时影响银行、微信、支付宝、代扣、发票和对账,超出单个 P0 修复窗口。作为后续支付域重构方向,不在本轮直接实施。

4. 目标架构

前端选择账单
  -> POST /business/charge/counter-charge/submit
  -> 服务端按 requestId 查询幂等结果
  -> 锁定账单和主付款账户
  -> 重新计算本金、违约金、预存抵扣和渠道应收
  -> 校验页面期望金额与服务端金额
  -> 写同一 paymentBatchNo 下的支付记录和明细
  -> 扣减预存、处理多缴转预存并写账户流水
  -> 条件更新账单为已收
  -> 返回批次号、支付记录、金额拆分和余额结果

现阶段继续保留“一张账单一条 PaymentRecord”通过 payment_batch_norequest_id 形成一次业务收费批次。这样避免一次性重写现有查询、红冲和发票关联逻辑,同时满足跨账单事务和批次凭证要求。

5. 金额模型

每张账单:

principalAmount = max(extendedAmount, 0)
lateFeeAmount = max(lateFee, 0)
receivableAmount = principalAmount + lateFeeAmount
channelAmount + prepayAmount = receivableAmount

整个收费批次:

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

请求:

{
  "requestId": "uuid-or-terminal-request-no",
  "chargeIds": [1001, 1002],
  "expectedReceivableAmount": 120.50,
  "actualPayAmount": 70.50,
  "usePrepay": true,
  "chargeWay": 1,
  "remark": "柜台收费"
}

规则:

  • requestId 必填,同租户内相同请求重复提交返回首次成功结果。
  • chargeIds 去重后不能为空,所有账单必须处于未收状态。
  • 服务端重新计算应收;与 expectedReceivableAmount 不一致时拒绝并要求刷新。
  • actualPayAmount 允许为零,但只允许出现在全额预存抵扣场景。
  • 少付拒绝;多缴仅允许账单归属同一个主付款账户,多缴部分在同一事务生成独立预存记录。
  • 首批新接口只允许现金渠道直接完成;其他渠道必须有真实渠道确认能力后再加入允许列表。
  • 收费员、收费时间和网点由登录上下文生成,不接受客户端覆盖。

响应:

{
  "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_amountrequest_idpayment_batch_no

6.3 柜员结账

POST /business/charge/counter-settle/confirm 增加必填 paymentRecordIds

{
  "paymentRecordIds": [5001, 5002],
  "settleTime": "2026-07-15T18:00:00",
  "remark": "当班结账"
}

服务端只结清这些记录,并校验记录均属于当前登录收费员、均为未结账、均未绑定其他结账单。结账金额汇总 channel_amount,同时在明细中保留账单金额和预存抵扣金额。

7. 幂等与并发

  • biz_payment_record 增加 request_idpayment_batch_nochannel_amountprepay_amountoverpay_amount
  • 柜台账单支付增加 (tenant_id, request_id, biz_scene, source_ref_id) 条件唯一索引。
  • 柜台充值增加 (tenant_id, request_id, biz_scene) 条件唯一索引。
  • 反向支付增加 (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
  • 红冲原因前后端统一必填。
  • 数据库唯一索引阻止同一原支付产生两张相同业务场景的反向单。

9. 高风险旁路

  • 通用 updateCharge 不再允许从未收到已收的柜台支付迁移,调用方必须使用新接口。
  • 已收或已结账营业账禁止通过通用删除接口删除。
  • 账户通用更新不得直接改变 deposit;账户删除至少要求余额为零且不存在有效流水。
  • invalidCharge 在未实际执行反向处理时必须抛出明确错误,银行侧不得以非空对象判断冲正成功。
  • 预存审批成功回调只允许从一个明确待执行状态进入完成状态;完成状态重放直接返回,不再修改余额。

10. 前端设计

  • 实收金额改为受控金额输入,提交前校验有限数、非负和两位小数。
  • 应收金额使用 本金 + 违约金,不再只取 extendedAmount
  • 前端不再计算逐账单预存分配,只提交 usePrepay 和批次金额,由服务端返回最终拆分。
  • 收费确认框展示应收、本金、违约金、预存抵扣、渠道实收和多缴转预存。
  • 一次调用批量收费接口;任何失败均不在前端制造部分成功状态。
  • 柜员结账表格真实维护选择状态,确认请求提交选中的 paymentRecordIds 和对应金额。
  • 配置加载失败时只保留现金安全默认项;没有真实渠道确认能力的方式不可提交。
  • “删除账单”改名为“本次不收”。

11. 错误处理

  • 金额变化:返回“账单金额已变化,请刷新后重新收费”。
  • 幂等重复:返回首次成功批次,不重复扣款或充值。
  • 并发收费:条件更新失败后整批回滚并返回“账单已被其他操作处理”。
  • 余额不足:整批回滚,返回最新可用余额。
  • 结账范围失效:任何选中记录已结账或不属于当前收费员时,整批拒绝。
  • 红冲余额不足:不修改原支付和结账状态,返回“红冲预存款余额不足”。

12. 测试设计

必须按 TDD 完成:

  1. 本金 100、违约金 10收费总额必须为 110支付明细本金 100、违约金 10。
  2. actualPayAmount 为负数、少付、非数字映射结果时拒绝。
  3. 两张账单第二张更新失败,第一张支付、预存和账单状态全部回滚。
  4. 同一 requestId 重复收费或充值,只产生一组支付事实。
  5. 同一账单并发收费,只有一个请求成功。
  6. 同一账户并发扣款或充值,余额和账户流水不丢失。
  7. 当前页选择两条结账时,后端只结这两条。
  8. 已结账和未结账预存分别可以红冲;重复红冲只产生一张反向单。
  9. 银行冲正未实际反向时返回失败。
  10. 预存审批完成回调重放不重复修改余额。

前端验证使用 node:test、现有页面 smoke 或 Playwright不运行 vue-tsc。后端按相关测试类单独执行,数据库集成测试继续由 REV004_IT_DB_URL 门禁控制。

13. 发布与兼容

  1. 先部署数据库新增列和唯一索引,新增列允许为空,历史记录不强制回填。
  2. 部署后端新接口及旧接口支付迁移封堵。
  3. 部署前端切换到新接口。
  4. 观察支付批次、账户流水和结账差异后,再将非现金渠道逐一接入真实确认流程。
  5. 历史记录继续按旧字段读取;新记录优先使用显式金额拆分字段。

回滚时前端可切回旧页面版本,但后端不恢复通用接口的支付旁路;数据库新增列保留,不执行破坏性回滚。