From 3960fb5c5ec3e73f02e78a68f1ce3ada61d29c24 Mon Sep 17 00:00:00 2001 From: tangweijie <877588133@qq.com> Date: Wed, 15 Jul 2026 09:42:37 +0800 Subject: [PATCH] docs: record REV-003 charging P0 remediation design --- .../rev003-charging/2026-07-15-p0-audit.md | 96 ++++++++ ...5-rev003-charging-p0-remediation-design.md | 217 ++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 docs/evidence/rev003-charging/2026-07-15-p0-audit.md create mode 100644 docs/superpowers/specs/2026-07-15-rev003-charging-p0-remediation-design.md diff --git a/docs/evidence/rev003-charging/2026-07-15-p0-audit.md b/docs/evidence/rev003-charging/2026-07-15-p0-audit.md new file mode 100644 index 0000000..cb30fda --- /dev/null +++ b/docs/evidence/rev003-charging/2026-07-15-p0-audit.md @@ -0,0 +1,96 @@ +# REV-003 营业收费 P0 审计记录 + +## 1. 记录信息 + +- 审计日期:2026-07-14 至 2026-07-15 +- 后端基线:`5fa11d5392f759be8560b5c696ace2bc814b2faf` +- 前端基线:`bd515cc3ade9a95a5ccb884f87d5625ebe3131b9` +- 文档基线:`8617af66fd45e6889bc02bebb4ba5fa95fc93cde` +- 审计范围:柜台收费、预存充值与抵扣、支付主明细、柜员结账、红冲、银行冲正、预存审批回调、前端收费和结账页面 +- 执行约束:未运行 `vue-tsc` + +## 2. 总体结论 + +营业收费已有客户查询、欠费查询、柜台收费、预存充值、支付记录、柜员结账和红冲骨架,但当前不能按财务级闭环验收通过。问题集中在金额口径、跨账单事务、结账范围、渠道确认、余额并发、幂等和反向流水,必须先完成 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. 已冲正银行交易是否都有对应业务反向支付及账单状态恢复。 + diff --git a/docs/superpowers/specs/2026-07-15-rev003-charging-p0-remediation-design.md b/docs/superpowers/specs/2026-07-15-rev003-charging-p0-remediation-design.md new file mode 100644 index 0000000..b9e1546 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-rev003-charging-p0-remediation-design.md @@ -0,0 +1,217 @@ +# 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)` 条件唯一索引。 +- 反向支付增加 `(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. 历史记录继续按旧字段读取;新记录优先使用显式金额拆分字段。 + +回滚时前端可切回旧页面版本,但后端不恢复通用接口的支付旁路;数据库新增列保留,不执行破坏性回滚。 +