|
|
@@ -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 每次按 (消费站 != 归属站) 判断 |
|