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