Bladeren bron

docs: 添加结算 V2 业务逻辑完整文档

覆盖充值分账、消费结算、跨店分账、月度结算、追溯补建机制及所有边界情况

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 2 dagen geleden
bovenliggende
commit
8711c01fd7
1 gewijzigde bestanden met toevoegingen van 259 en 0 verwijderingen
  1. 259 0
      car-wash-entity/src/main/resources/sql/v2_settlement_doc.md

+ 259 - 0
car-wash-entity/src/main/resources/sql/v2_settlement_doc.md

@@ -0,0 +1,259 @@
+# 结算 V2 业务逻辑文档
+
+> 2026-05-14 上线,2026-06-05 修复幻影站点 000 问题。
+
+## 一、核心概念
+
+### 1.1 用户归属站点
+
+每个洗车用户隶属于一个站点,由 `t_user.station_id` 确定:
+
+| 注册方式 | stationId | 说明 |
+|----------|-----------|------|
+| 扫码设备注册 | 即时确定 | 通过设备 shortId → 站点映射 |
+| 自主注册(微信登录) | null | 首次消费时自动归属到消费站点 |
+
+归属站点在用户首次消费时写入,详见 `WashOrderServiceImpl.createOrder():119-130`。
+
+### 1.2 本店消费 vs 跨店消费
+
+- **本店消费**:用户在自己的归属站点洗车 → `isCross = false`
+- **跨店消费**:用户在其他站点洗车 → `isCross = true`
+
+跨店消费时,归属站需将 70% 的消费金额转给消费站。
+
+### 1.3 分账记录表 (t_split_record)
+
+分账记录是结算的基础数据,记录每一笔资金的来源和去向:
+
+| 类型 | 值 | fromStationId | toStationId | 含义 |
+|------|---|---------------|-------------|------|
+| RECHARGE | 1 | 归属站 | 归属站 | 用户充值,资金流入归属站 |
+| CROSS_EXPEND | 4 | 归属站 | 消费站 | 跨店:归属站支出 |
+| CROSS_INCOME | 6 | 归属站 | 消费站 | 跨店:消费站收入(与 EXPEND 配对) |
+| REFUND | 5 | 归属站 | 归属站 | 退款,资金流出归属站 |
+
+> `tradeNo` 字段:对于 TYPE_RECHARGE,存储微信支付 `transactionId`;对于跨店,存储订单号 `orderId`。
+
+## 二、充值流程
+
+### 2.1 时序
+
+```
+用户              前端              后端                   微信
+ |                |                |                      |
+ |-- 选择金额 ---→|-- wxPay -----→|-- prepay -----------→ |
+ |                |                |  (attach=stationId)   |
+ |                |              ←|-- 支付参数 ------------|
+ |                |                |                      |
+ |-- 调起支付 ----|----|----------|--→ 支付回调 ---------→|
+ |                |    |          |←- notify(stationId) ---|
+ |                |    |          |                        |
+ |                |    |          |  创建 WalletDetail      |
+ |                |    |          |  更新 Account 余额      |
+ |                |    |          |  创建 PayLog            |
+ |                |    |          | if stationId有效       |
+ |                |    |          |   创建 SplitRecord     |
+ |                |    |          |   (RECHARGE)           |
+```
+
+### 2.2 关键逻辑
+
+```java
+// WxPayServiceImpl.java wxNotify() — 充值回调
+// 步骤1: 更新账户余额
+accountService.lambdaUpdate()
+    .setSql("balance = balance + {0}, recharge_balance = recharge_balance + {0}", amount)
+    .eq(Account::getUserId, userId).update();
+
+// 步骤2: 创建分账记录(仅有效站点)
+if (CommUtil.isNotEmptyAndNull(stationId) && !"000".equals(stationId)) {
+    splitRecordService.save(new SplitRecord()
+        .setFromStationId(stationId)
+        .setToStationId(stationId)
+        .setTradeNo(transactionId)
+        .setAmount(amount)
+        .setType(SplitRecord.TYPE_RECHARGE));
+}
+```
+
+### 2.3 无归属用户处理
+
+自主注册用户充值时分两种情况:
+
+- **充值发生在首次消费前**:用户 stationId = null,不创建 SplitRecord。充值金额留在钱包,分账在首次消费时追溯。
+- **充值发生在首次消费后**:用户已有 stationId,正常创建 SplitRecord。
+
+## 三、消费结算流程
+
+### 3.1 订单创建 (createOrder)
+
+```
+用户扫码 → createOrder()
+  ├── 检查是否有未完结订单
+  ├── 检查设备僵死订单
+  ├── 请求阿里云 IoT 创建设备订单
+  ├── 首次消费? → 自动归属站点 + 写入 Redis 缓存
+  ├── 设置 isCross = (消费站 != 归属站)
+  └── 保存 WashOrder
+```
+
+### 3.2 订单结算 (settleOrder)
+
+```
+设备结束洗车 → settleOrder()
+  ├── 幂等检查(已结算则跳过)
+  ├── 扣款:
+  │   ├── recharge_balance 足够 → 全从充值款扣
+  │   └── recharge_balance 不够 → 充值款扣完,差额从赠款扣
+  │
+  ├── 更新订单状态 (已支付、成功)
+  │
+  ├── 追溯补建分账 (backfillUnattributedRecharges)
+  │   查找用户所有已确认的充值 WalletDetail
+  │      → 通过 PayLog 桥接找到对应 transactionId
+  │      → 检查是否已有 SplitRecord(TYPE_RECHARGE)
+  │      → 没有则用当前归属站补建
+  │
+  ├── 分账逻辑:
+  │   ├── amount == 0 → 跳过
+  │   ├── isCross == false → doLocalSplit() —— 空操作
+  │   └── isCross == true  → doCrossSplit() —— 创建 CROSS_EXPEND + CROSS_INCOME
+  │
+  ├── 创建 WalletDetail(消费)
+  ├── 计算折扣
+  └── 发送模板消息(停车券 or 结算通知)
+```
+
+### 3.3 追溯补建详解 (backfillUnattributedRecharges)
+
+这是修复幻影站点 000 的关键逻辑:
+
+```
+输入: userId
+
+1. 查 Redis: userStationId = KymCache.getUserStationId(userId)
+   如果为 null → 跳过(用户仍未归属)
+
+2. 查 WalletDetail: 该用户所有 TYPE_充值 + STATUS_已确认 的记录
+   如果为空 → 跳过
+
+3. 查 PayLog: 通过 WalletDetail.orderNo → PayLog.outTradeNo 桥接
+   提取所有 transactionId (微信支付交易号)
+
+4. 查 SplitRecord: 哪些 transactionId 已有 TYPE_RECHARGE 记录
+   已有 → 跳过(幂等)
+   没有 → 补建 SplitRecord(TYPE_RECHARGE, from=归属站, to=归属站)
+```
+
+关联关系:
+```
+WalletDetail.orderNo  ←→  PayLog.outTradeNo  (商户订单号)
+PayLog.transactionId  ←→  SplitRecord.tradeNo  (微信交易号)
+```
+
+## 四、跨店分账
+
+当用户在非归属站消费时:
+
+```
+归属站 A          消费站 B
+   |                |
+   |  EXPEND -----→ |  (A 支出 70% 消费额)
+   |  ←----- INCOME |  (B 收入 70% 消费额)
+   |                |
+   |  净效果:         |
+   |  A 失去 70%     |
+   |  B 获得 70%     |
+```
+
+```java
+// doCrossSplit() 逻辑
+int crossAmount = totalPayment × 70%;
+
+// 两条记录成对出现
+SplitRecord(TYPE_CROSS_EXPEND, from=归属站A, to=消费站B, amount=crossAmount)
+SplitRecord(TYPE_CROSS_INCOME, from=归属站A, to=消费站B, amount=crossAmount)
+```
+
+**为什么本店消费不需要分账?** 因为充值时已经通过 TYPE_RECHARGE 将全部充值金额记入归属站,用户在本站消费只是钱包扣款,站点收入已经体现在充值分账里了。
+
+## 五、月度结算
+
+### 5.1 触发与幂等
+
+每月由定时任务调用 `SettlementServiceImpl.executeMonthlySettlement()`:
+- 结算周期:上月(`YearMonth.now().minusMonths(1)`)
+- 幂等保护:`uk_station_period` (station_id + settlement_period) 唯一约束
+
+### 5.2 结算公式
+
+```
+每个站点独立计算,以分为单位
+
+1. 汇总当月 SplitRecord:
+   totalRecharge   = SUM(TYPE_RECHARGE   WHERE toStationId   = 本站)
+   totalRefund     = SUM(TYPE_REFUND     WHERE fromStationId = 本站)
+   totalCrossExpend = SUM(TYPE_CROSS_EXPEND WHERE fromStationId = 本站)
+   totalCrossIncome = SUM(TYPE_CROSS_INCOME WHERE toStationId   = 本站)
+
+2. 计算费用基数:
+   feeBase = totalRecharge - totalRefund - totalCrossExpend + totalCrossIncome
+
+3. 平台服务费:
+   platformFee = feeBase × 10%(向下取整)
+
+4. 期初结转:
+   openingBalance = 上期 settlement_record.closing_pending_balance
+
+5. 可结算额:
+   available = openingBalance + feeBase - platformFee
+
+6. 结果:
+   available > 0 → 正常结算:
+     - settlementAmount = available
+     - closingPendingBalance = 0
+     - 加入站点可提现余额 (t_station_account.available_balance)
+     - 平台费入平台账 (t_platform_account)
+   available ≤ 0 → 异常结算:
+     - settlementAmount = 0
+     - closingPendingBalance = available (负数结转下期)
+```
+
+### 5.3 数据流
+
+```
+t_split_record (当月)
+       │
+       ▼
+  executeMonthlySettlement()
+       │
+       ├──→ t_settlement_record (结算单, uk_station_period 幂等)
+       ├──→ t_station_account.available_balance (+ 可结算额)
+       ├──→ t_platform_account (+ 平台费)
+       └──→ t_platform_revenue_record (平台费明细)
+```
+
+## 六、涉及的核心文件
+
+| 文件 | 职责 |
+|------|------|
+| `WxPayServiceImpl.java` | 微信支付充值 + SplitRecord(充值) 创建 |
+| `WashOrderServiceImpl.java` | 订单创建 + 自动归属站点 |
+| `OrderSettlementServiceImpl.java` | 订单结算 + 追溯补建 + 跨店分账 |
+| `SettlementServiceImpl.java` | 月度结算 |
+| `SplitRecord.java` | 分账记录实体 |
+| `PaymentController.java` | 充值接口 |
+| `recharge.vue` / `promotion.vue` | 前端充值页面 |
+
+## 七、边界情况与特殊处理
+
+| 场景 | 处理方式 |
+|------|----------|
+| 自主注册用户充值(无归属站) | 不创建 SplitRecord,首次消费时追溯补建 |
+| 历史残留 stationId="000" 分账 | 月结跳过 "000";SQL 迁移脚本清理历史数据 |
+| 充值后从未消费的用户 | 无 SplitRecord,余额留在钱包,无财务影响 |
+| 消费金额为 0 | settleOrder 提前返回,不执行分账 |
+| 订单重复结算 | settleOrder 幂等检查,已支付订单直接返回 |
+| 充值+赠款混合消费 | 先消耗充值款,不足部分用赠款 |
+| 用户跨多个站点消费 | isCross 每次按 (消费站 != 归属站) 判断 |