## Context

### 当前状态

佣金系统存在两套冲突的状态常量定义：

| 常量位置 | 状态 | 值 | 实际含义 |
|---------|------|-----|---------|
| `model/commission.go` | `CommissionStatusReleased` | 1 | 已入账 |
| `model/commission.go` | `CommissionStatusInvalid` | 2 | 已失效 |
| `pkg/constants/iot.go` | `CommissionStatusFrozen` | 1 | 已冻结 |
| `pkg/constants/iot.go` | `CommissionStatusUnfreezing` | 2 | 解冻中 |
| `pkg/constants/iot.go` | `CommissionStatusReleased` | 3 | 已发放 |
| `pkg/constants/iot.go` | `CommissionStatusInvalid` | 4 | 已失效 |

**问题**：
1. 佣金计算时写入 `model.CommissionStatusReleased`（值=1），但 `getCommissionStatusName()` 使用 `pkg/constants/` 的映射，导致 status=1 显示为"已冻结"
2. `commission-records` 接口 OrderNo、ICCID、VirtualNo 等字段硬编码为空，未关联查询
3. 缺少销售来源店铺信息

### 受影响代码位置

| 文件 | 问题 |
|------|------|
| `model/commission.go:40-46` | 定义了与 constants 包冲突的常量 |
| `commission_calculation/service.go:151,216,659` | 使用 model 常量 |
| `shop_commission/service.go:421` | 使用 constants 包做映射（值冲突） |
| `shop_commission/service.go:423-426` | 硬编码空值 |
| `commission_record_store.go:60-110` | 过滤条件未实现 |

## Goals / Non-Goals

**Goals:**
1. 统一佣金状态常量定义，消除二义性
2. 修复 `commission-records` 接口的关联查询
3. 新增销售来源店铺信息

**Non-Goals:**
- 不修改订单佣金状态（`order.commission_status`）的定义
- 不修改钱包冻结/解冻的业务逻辑
- 不修改佣金计算引擎的逻辑

## Decisions

### Decision 1: 统一使用 `pkg/constants/iot.go` 的四态定义

**选择理由**：
- `pkg/constants/iot.go` 已有完整的四态定义（冻结→解冻中→已发放→失效）
- 该常量包被多处使用，包括 `getCommissionStatusName()` 函数

**关于冻结/解冻机制的澄清**：

IoT 卡佣金系统**不实现**冻结/解冻机制（见 `add-one-time-commission/design.md` Non-Goals）。四态常量中 status=1（已冻结）和 status=2（解冻中）是为号卡业务预留的，IoT 卡差价佣金和一次性佣金均直接入账（status=3）。因此本次修复的核心是：消除 `model` 包旧常量（值=1 表示"已入账"）与 `constants` 包新常量（值=1 表示"已冻结"）之间的语义冲突，将所有写入和查询统一到 `constants.CommissionStatusReleased`（值=3）。

**变更内容**：
```go
// 删除 model/commission.go 第 40-46 行
const (
    // CommissionStatusReleased = 1  // 删除
    // CommissionStatusInvalid  = 2  // 删除
)

// 使用 constants 包的定义
Status: constants.CommissionStatusReleased  // 值=3
```

### Decision 2: 数据库状态值迁移

**方案**：通过 SQL 直接迁移历史数据

```sql
-- 迁移脚本
UPDATE tb_commission_record SET status = 3 WHERE status = 1;  -- 已入账（旧值=1）→ 已发放（新值=3）
UPDATE tb_commission_record SET status = 4 WHERE status = 2;  -- 已失效（旧值=2）→ 已失效（新值=4）
```

**注意**：迁移后需要验证数据一致性。

### Decision 3: Store 层实现 JOIN 关联查询

**方案**：修改 `ListByShopID` 方法，添加 LEFT JOIN，并引入专用结果结构体承接扫描结果

```go
// commission_record_store.go

// CommissionRecordWithRelations 包含关联字段的查询结果
type CommissionRecordWithRelations struct {
    model.CommissionRecord
    OrderNo        string     `gorm:"column:order_no"`
    OrderCreatedAt *time.Time `gorm:"column:order_created_at"`
    ICCID          string     `gorm:"column:iccid"`
    VirtualNo      string     `gorm:"column:virtual_no"`
    SellerShopID   *uint      `gorm:"column:seller_shop_id"`
}

func (s *CommissionRecordStore) ListByShopID(...) ([]*CommissionRecordWithRelations, int64, error) {
    query := s.db.WithContext(ctx).Model(&model.CommissionRecord{}).
        Joins("LEFT JOIN tb_order o ON tb_commission_record.order_id = o.id").
        Joins("LEFT JOIN tb_iot_card ic ON tb_commission_record.iot_card_id = ic.id").
        Joins("LEFT JOIN tb_device d ON tb_commission_record.device_id = d.id")
    
    // 投影必要字段
    query = query.Select(`tb_commission_record.*, 
        o.order_no, o.created_at as order_created_at, o.seller_shop_id,
        ic.iccid, d.virtual_no`)
    
    // 过滤条件...
    var records []*CommissionRecordWithRelations
    // ...
    query.Find(&records)
}
```

**说明**：
- JOIN 条件必须使用实际表名 `tb_commission_record`，GORM 不自动生成别名
- 使用嵌入 `model.CommissionRecord` 的专用结构体，避免污染原始模型
- `SellerShopID` 直接从订单表 JOIN 取得，`SellerShopName` 在 Service 层批量查询（见 Decision 5）

**替代方案考虑**：
- 方案 A（当前选择）：Store 层 JOIN - 减少数据库往返次数
- 方案 B：Service 层批量查询 - 更灵活但 N+1 查询

### Decision 4: DTO 新增销售来源字段

```go
// shop_commission_dto.go
type ShopCommissionRecordItem struct {
    // ... 现有字段
    SellerShopID   uint   `json:"seller_shop_id"`   // 新增
    SellerShopName string `json:"seller_shop_name"` // 新增
}
```

**查询逻辑**：
- `SellerShopID`：通过 `o.seller_shop_id` 从订单 JOIN 直接获取（已在 Decision 3 的 SELECT 中包含）
- `SellerShopName`：Store 层不再多加一次 JOIN（避免进一步增加 JOIN 复杂度），由 Service 层收集所有 `SellerShopID` 后批量查询 `tb_shop`，填充到 DTO

```go
// service 层伪代码
sellerShopIDs := collectUniqueSellerShopIDs(records)
shops, _ := s.shopStore.GetByIDs(ctx, sellerShopIDs)
shopNameMap := buildShopNameMap(shops)
for _, item := range items {
    item.SellerShopName = shopNameMap[item.SellerShopID]
}
```

### Decision 5: 佣金统计查询的状态过滤语义

**问题**：`GetStats` 和 `GetDailyStats` 目前用 `status = model.CommissionStatusReleased`（值=1）过滤，语义是"只统计已发放的佣金"。但总佣金应包含冻结中的佣金（status=1,2,3 均为有效佣金，仅 status=4 失效、status=99 待人工处理应排除）。

**决策**：将两处过滤条件从"精确匹配已发放"改为"排除无效和待审"：

```go
// 修改前
Where("status = ?", model.CommissionStatusReleased)  // 值=1（旧语义：已入账）

// 修改后
Where("status NOT IN (?)", []int{constants.CommissionStatusInvalid, constants.CommissionStatusPendingReview})
// 即 status NOT IN (4, 99)，包含已冻结(1)、解冻中(2)、已发放(3)
```

**影响文件**：`internal/store/postgres/commission_record_store.go` 第 123 行（`GetStats`）和第 169 行（`GetDailyStats`）。

注：IoT 卡当前实现中不存在 status=1/2 的记录，此改动为面向未来的正确语义，不影响现有数据结果。

## Risks / Trade-offs

**[风险] 数据库迁移可能影响历史数据**

→ **缓解措施**：
1. 迁移前先备份数据
2. 在测试环境验证迁移脚本
3. 迁移后对比记录数确认

**[风险] JOIN 查询可能影响性能**

→ **缓解措施**：
1. 确保 `order_id`、`iot_card_id`、`device_id` 有索引
2. 添加 LIMIT 和分页
3. 监控查询性能（P95 < 200ms）

**[风险] 常量变更可能影响其他模块**

→ **缓解措施**：
1. 全局搜索 `model.CommissionStatus` 确保无遗漏
2. 编写单元测试验证状态值

## Open Questions

1. ~~**一次性佣金是否需要冻结逻辑？**~~ **已确认**：IoT 卡差价佣金和一次性佣金均直接入账，不实现冻结/解冻。冻结机制为号卡业务预留，IoT 卡侧本期 Non-Goal（见 `add-one-time-commission/design.md`）。

2. **是否需要回滚旧数据的 status 值？** 还是直接迁移？→ 直接迁移（1→3, 2→4）。

3. **销售店铺名称是否需要缓存？** 避免每次查询都 JOIN shop 表。
