# 需求15/16/18/19/20/21 技术方案

---

## 需求15：套餐下架后允许续费

### 业务规则

- 下架套餐（`shelf_status=2`）不可被**新购**
- 下架套餐**可以续费**（已在使用该套餐的客户）
- 续费仅支持**客户自己购买**（不允许代理代购下架套餐给新客户）

### 后端

**当前逻辑**：下架套餐在购买时被拦截。

修改：在订单创建校验中，区分"新购"和"续费"场景：

```go
// internal/service/order/service.go 或 client_order/service.go
func (s *Service) validatePackageAvailability(
    ctx context.Context,
    pkg *model.Package,
    assetID uint,
    isRenewal bool,
) error {
    if pkg.Status == constants.StatusDisabled { // 0=禁用，定义在 pkg/constants/constants.go
        return errors.New(errors.CodeForbidden, "套餐已禁用")
    }
    if pkg.ShelfStatus == constants.ShelfStatusOff && !isRenewal { // 2=下架
        return errors.New(errors.CodeForbidden, "套餐已下架，不可新购")
    }
    return nil
}
```

**isRenewal 判断**：查当前资产是否有该套餐的历史生效记录：

```go
func (s *Service) isRenewal(ctx context.Context, assetType string, assetID uint, packageID uint) bool {
    count, _ := s.packageUsageStore.CountByAssetAndPackage(ctx, assetType, assetID, packageID)
    return count > 0
}
```

### 前端

C端续费页面：下架套餐不在"新购"列表中展示，但在"续费"入口中仍可展示。

后台代购时：下架套餐的"代购"按钮禁用，tooltip 提示"套餐已下架，不可代购"。

---

## 需求16：代理分销码与佣金提现

### 业务规则

**DST-001**：新建代理时自动建立分销归属关系（指定发展人）  
**DST-002**：员工可作为代理发展人进行标识  
**二维码**：代理或员工生成推广二维码 → 扫码进入H5填写信息 → 提交成为代理申请 → 平台审批

### 数据库变更

```sql
-- 1. Shop 表新增发展人字段
ALTER TABLE tb_shop
    ADD COLUMN referrer_type VARCHAR(20) DEFAULT NULL
        COMMENT '发展人类型 shop=代理介绍 admin=员工介绍',
    ADD COLUMN referrer_id BIGINT DEFAULT NULL
        COMMENT '发展人ID（shop_id 或 tb_account.id）',
    ADD COLUMN referrer_name VARCHAR(50) DEFAULT NULL
        COMMENT '发展人姓名快照';

-- 2. 分销码表
CREATE TABLE tb_distribution_code (
    id          BIGSERIAL PRIMARY KEY,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at  TIMESTAMPTZ,
    creator     BIGINT NOT NULL DEFAULT 0,
    updater     BIGINT NOT NULL DEFAULT 0,
    code        VARCHAR(32) NOT NULL,
    owner_type  VARCHAR(20) NOT NULL,  -- shop=代理 | admin=员工
    owner_id    BIGINT NOT NULL,
    owner_name  VARCHAR(50) NOT NULL DEFAULT '',
    qr_code_url TEXT,
    use_count   INT NOT NULL DEFAULT 0,
    status      INT NOT NULL DEFAULT 1, -- 1=有效 0=禁用
    expires_at  TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_distribution_code ON tb_distribution_code(code) WHERE deleted_at IS NULL;

-- 3. 代理申请表（H5扫码提交）
CREATE TABLE tb_agent_application (
    id                BIGSERIAL PRIMARY KEY,
    created_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at        TIMESTAMPTZ,
    creator           BIGINT NOT NULL DEFAULT 0,
    updater           BIGINT NOT NULL DEFAULT 0,
    apply_no          VARCHAR(30) NOT NULL,
    distribution_code VARCHAR(32) NOT NULL,
    referrer_type     VARCHAR(20) NOT NULL,
    referrer_id       BIGINT NOT NULL,
    applicant_name    VARCHAR(50) NOT NULL,
    applicant_phone   VARCHAR(20) NOT NULL,
    shop_name         VARCHAR(100) NOT NULL,
    province          VARCHAR(50),
    city              VARCHAR(50),
    district          VARCHAR(50),
    address           VARCHAR(255),
    status            INT NOT NULL DEFAULT 1, -- 1=待审批 2=已通过 3=已拒绝
    reject_reason     TEXT,
    reviewed_by       BIGINT,
    reviewed_at       TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_agent_application_no ON tb_agent_application(apply_no) WHERE deleted_at IS NULL;
```

### API 设计

**生成分销码**：
```
POST /admin/distribution-codes
body: { owner_type: "admin"|"shop", owner_id: 123 }
```

**H5 扫码获取分销码信息**：
```
GET /app/distribution-codes/{code}
```

**H5 提交代理申请**：
```
POST /app/agent-applications
body: { distribution_code, applicant_name, applicant_phone, shop_name, ... }
```

**后台代理申请列表/审批**：
```
GET  /admin/agent-applications?status=1
POST /admin/agent-applications/{id}/approve
POST /admin/agent-applications/{id}/reject
body: { reject_reason: "..." }
```

审批通过时：自动创建 `tb_shop` + `tb_account`，设置 `referrer_type` 和 `referrer_id`。

**佣金提现（DST-003~007）**：

现有 commission 框架基础上新增材料上传字段，提现申请时一次性上传所有材料（对象存储 Key 列表）。

---

## 需求18：多人审批（APR-001~009）

### 依赖

基于 [审批流基础设施](./基础设施/审批流.md) 和 [站内消息](./基础设施/站内消息.md)。

APR-009（企微审批对接）= Phase 2。

### 实现要点

| 编号 | 需求 | 实现 |
|------|------|------|
| APR-001~003 | 充值/退款多级审核 | 见需求20/21 |
| APR-004 | 审核环节：部门领导→财务 | 审批流固定两步：step=1 部门领导，step=2 财务 |
| APR-005 | 待审核有消息提示 | 站内消息 `NotifyTypeApprovalPending` |
| APR-006 | 上一级完成后才提示下一级 | `ApprovalFlowAggregate.Approve()` 触发 `ApprovalStepAdvancedEvent` |
| APR-007 | 通过后通知申请人 | `ApprovalCompletedEvent` handler |
| APR-008 | 驳回/退回后通知申请人含原因 | `ApprovalRejectedEvent` / `ApprovalReturnedEvent` handler |
| APR-009 | 对接企微 | **Phase 2** |

### 业务表与审批流的关联

充值单、退款单接入审批流，各自新增 `approval_flow_id` 字段：

```sql
ALTER TABLE tb_refund_request ADD COLUMN approval_flow_id BIGINT DEFAULT NULL
    COMMENT '当前审批流ID（已退回后重新提交会更新为新流程ID）';

ALTER TABLE tb_agent_recharge_record ADD COLUMN approval_flow_id BIGINT DEFAULT NULL
    COMMENT '当前审批流ID（已退回后重新提交会更新为新流程ID）';
```

接入协议详见 [审批流文档 - 接入协议](./基础设施/审批流.md#六接入协议业务层如何接入审批流)。

---

## 需求19：批量订购套餐（BPO-001~008）

### 业务流程

1. 员工进入批量订购页面
2. 选择代理 + 上传 Excel（字段：资产类型/资产标识/套餐编码/套餐名称/支付方式）
3. 系统解析并校验每一行
4. 支付方式：`offline=线下支付（默认成功）` / `agent_wallet=代理钱包支付`
5. 代理钱包支付时从代理余额扣款（含信用额度）
6. 线下支付时标记为已支付（不走钱包）
7. 校验失败的行展示在页面，带失败原因
8. **线下支付需上传整批次的支付凭证**

### 数据库变更

```sql
CREATE TABLE tb_bulk_purchase_task (
    id             BIGSERIAL PRIMARY KEY,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at     TIMESTAMPTZ,
    creator        BIGINT NOT NULL DEFAULT 0,
    updater        BIGINT NOT NULL DEFAULT 0,
    task_no        VARCHAR(30) NOT NULL,
    shop_id        BIGINT NOT NULL,
    operator_id    BIGINT NOT NULL,
    payment_method VARCHAR(20) NOT NULL,  -- offline | agent_wallet
    voucher_keys   JSONB,                 -- 线下支付凭证列表
    total_count    INT NOT NULL DEFAULT 0,
    success_count  INT NOT NULL DEFAULT 0,
    fail_count     INT NOT NULL DEFAULT 0,
    status         INT NOT NULL DEFAULT 1, -- 1=处理中 2=已完成
    failed_items   JSONB                   -- 失败明细
);
CREATE UNIQUE INDEX idx_bulk_purchase_task_no ON tb_bulk_purchase_task(task_no) WHERE deleted_at IS NULL;
```

> 失败明细存 JSONB（`failed_items`），与现有 DeviceImportTask 模式一致，无需单独明细表。

### API 设计

**下载 Excel 模板**：
```
GET /admin/bulk-purchases/template
```

**上传并提交**：
```
POST /admin/bulk-purchases
Content-Type: multipart/form-data

字段：
  shop_id: 123
  payment_method: offline | agent_wallet
  voucher_keys: ["key1","key2"]   (offline 时必填)
  file: <Excel文件>
```

**查询任务状态**：
```
GET /admin/bulk-purchases/{task_id}
```

---

## 需求20：退款审批

### 依赖

基于 [审批流基础设施](./基础设施/审批流.md)。

### 退款单现有状态（不变）

```
1=待审批  2=已通过  3=已拒绝  4=已退回
```

退款单本身的状态含义不变，审批进度由 `tb_approval_flow` 管理，两者通过 `approval_flow_id` 关联。

### 流程

```
提交退款申请（POST /admin/refund-requests）
  → 创建 RefundRequest（status=1 待审批）
  → 调 SubmitApprovalUseCase 创建 ApprovalFlow（biz_type=refund）
  → 把 approval_flow_id 写入 RefundRequest

审批人操作（POST /admin/approvals/{flow_id}/approve|reject|return）
  → ApprovalCompletedEvent → RefundRequest.status=2（已通过）→ 触发实际退款
  → ApprovalRejectedEvent  → RefundRequest.status=3（已拒绝）
  → ApprovalReturnedEvent  → RefundRequest.status=4（已退回）→ 通知提交人可修改
```

### 退回后重新提交

```
PUT /admin/refund-requests/{id}       仅 status=4 时可编辑退款单内容
POST /admin/refund-requests/{id}/resubmit
  → 校验 status=4
  → 新建 ApprovalFlow
  → 更新 approval_flow_id，status 回到 1（待审批）
```

---

## 需求21：充值审核流程

### 充值单现有状态

```
tb_agent_recharge_record：1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款
```

员工线下充值走审批流，需在现有状态基础上**新增"已退回"状态**：

```sql
-- status 说明更新（不改原有值语义，追加新状态）
-- 6=已退回（审批人退回给提交人修改）
```

充值业务的状态语义：
- `1=待支付`：创建未支付（线下充值等待审批时也停在这里，由 approval_flow_id 判断是否在审批中）
- `2=已支付`：支付成功（线下充值审批通过后跳到已完成，不经过此状态）
- `3=已完成`：充值到账
- `4=已关闭`：取消/超时
- `5=已退款`：退款
- `6=已退回`：审批人退回给提交人修改

### 流程

**代理自行充值（不走审批）**：

```
代理提交充值申请 → 系统生成收款码 → 代理扫码支付 → 回调自动充值到钱包
```

**员工线下代充值（走审批）**：

```
POST /admin/agent-recharge-records（payment_method=offline）
  → 创建 AgentRechargeRecord（status=1）
  → 调 SubmitApprovalUseCase 创建 ApprovalFlow（biz_type=recharge）
  → 把 approval_flow_id 写入记录

审批通过 → AgentRechargeRecord.status=3（已完成）→ 实际充值到钱包
审批驳回 → AgentRechargeRecord.status=4（已关闭）
审批退回 → AgentRechargeRecord.status=6（已退回）→ 通知提交人可修改
```

### 退回后重新提交

```
PUT /admin/agent-recharge-records/{id}    仅 status=6 时可编辑
POST /admin/agent-recharge-records/{id}/resubmit
  → 校验 status=6
  → 新建 ApprovalFlow
  → 更新 approval_flow_id，status 回到 1（待支付/待审批）
```
