# P1-12 散客 memberId 取值实证

> 调研时间:2026-05-04
> 测试库:`test_etl_feiqiu`(TEST_DB_DSN)
> Neo 反馈:"先调研后端 / 数据库:实际可能是 0 或 NULL 或 -1,进行校验"

## 一、测试库实际值分布

### 1.1 dwd 层(原始结算单)

```sql
SELECT COUNT(*) AS total,
       COUNT(*) FILTER (WHERE member_id IS NULL) AS null_cnt,
       COUNT(*) FILTER (WHERE member_id = 0) AS zero_cnt,
       COUNT(*) FILTER (WHERE member_id < 0) AS neg_cnt,
       COUNT(*) FILTER (WHERE member_id > 0) AS pos_cnt
FROM dwd.dwd_settlement_head;
```

| total | null_cnt | zero_cnt | neg_cnt | pos_cnt |
|-------|----------|----------|---------|---------|
| 32789 | 0 | 27742 | 0 | 5047 |

**结论:dwd_settlement_head 中散客全部是 `member_id = 0`,无 NULL,无负数。**

### 1.2 dwd.dim_member(会员维表)

```sql
SELECT member_id FROM dwd.dim_member WHERE member_id IS NULL OR member_id <= 0 LIMIT 10;
-- 结果:0 行
```

`dim_member.member_id` 列约束 `NOT NULL`,且无 0 / 负数行。**dim_member 表中不存在散客占位行。**

### 1.3 dws 层(汇总表)

```sql
-- dws_assistant_customer_stats (散客不入此表)
SELECT COUNT(*), MIN(member_id), MAX(member_id),
       COUNT(*) FILTER (WHERE member_id <= 0) AS scattered
FROM dws.dws_assistant_customer_stats;
```

| total | min | max | scattered |
|-------|-----|-----|-----------|
| 182 | 2799207067109125 | 3180349199961029 | 0 |

证实 DWS 助教-客户表已正确过滤散客。

```sql
-- dws_order_summary 全订单汇总
SELECT COUNT(*) AS total,
       COUNT(*) FILTER (WHERE member_id IS NULL) AS null_cnt,
       COUNT(*) FILTER (WHERE member_id = 0) AS zero_cnt,
       COUNT(*) FILTER (WHERE member_id > 0) AS pos_cnt
FROM dws.dws_order_summary;
```

| total | null_cnt | zero_cnt | pos_cnt |
|-------|----------|----------|---------|
| 32789 | 0 | 27742 | 5047 |

dws_order_summary 与 dwd_settlement_head 一致(NULL=0,zero=27742,pos=5047)。

### 1.4 列约束扫描(dwd + dws)

| schema | table | column | nullable | default |
|--------|-------|--------|----------|---------|
| dwd | dim_member | member_id | NO | (无) |
| dwd | dim_member_ex | member_id | NO | (无) |
| dwd | dwd_recharge_order | member_id | YES | (无) |
| dwd | dwd_refund | member_id | YES | (无) |
| dwd | dwd_settlement_head | member_id | YES | (无) |
| dwd | dwd_table_fee_log | member_id | YES | (无) |
| dws | dws_assistant_customer_stats | member_id | NO | (无) |
| dws | dws_member_assistant_intimacy | member_id | NO | (无) |
| dws | dws_member_assistant_relation_index | member_id | NO | (无) |
| dws | dws_member_consumption_summary | member_id | NO | (无) |
| dws | dws_member_visit_detail | member_id | NO | (无) |
| dws | dws_ml_manual_order_alloc | member_id | NO | **DEFAULT 0** |
| dws | dws_ml_manual_order_source | member_id | NO | **DEFAULT 0** |
| dws | dws_order_summary | member_id | YES | (无) |

**关键观察**:
- DWD 大多数表 `nullable=YES`,但**实际数据全是 0,无 NULL**
- DWS 会员维度表全部 `NOT NULL`(因为散客不入)
- DWS 中 2 张 ML 配单表显式 `DEFAULT 0`,即"散客 = 0"已物化为列默认值
- 没有任何表用 `-1` 或负数表示散客

## 二、各层判散客逻辑现状

### 2.1 上游飞球 API 文档(权威源)

`apps/etl/connectors/feiqiu/docs/api-reference/summary/table_fee_transactions.md:92`:

> `member_id` int `0` 会员 ID。`0` = 散客/非会员。非 0 时对应会员档案表的 `id`

**飞球 API 端就规定:散客 = 0**。

### 2.2 ETL DWD 层

`apps/etl/connectors/feiqiu/CLAUDE.md:57`:
> 散客:`member_id ≤ 0`,不计入会员统计(但计入助教业绩)

`apps/etl/connectors/feiqiu/tasks/dws/finance_base_task.py:51`:
```sql
COUNT(CASE WHEN member_id = 0 OR member_id IS NULL THEN 1 END) AS guest_order_count
```

ETL 实测口径:`= 0 OR IS NULL`,采用宽容判断。

`apps/etl/connectors/feiqiu/tasks/dws/base_dws_task.py:1403-1416` 定义 `is_guest()` 工具:
> 散客处理:member_id=0 的客户是散客,不进入客户维度统计

**ETL 主口径:`= 0` 等价于"散客",同时容忍 NULL。**

### 2.3 后端 Python 服务层

`apps/backend/app/services/performance_service.py:88`:
```python
mid_for_name = rec.get("member_id")
is_scattered = not mid_for_name or mid_for_name <= 0
if is_scattered:
    customer_name = "散客待转换会员"
```

`apps/backend/app/services/coach_service.py:442` 与 line 500:
```python
is_scattered = not mid or mid <= 0
```

`apps/backend/app/services/fdw_queries.py:2148`:
> 排除散客(member_id <= 0)

后端统一口径:`not mid or mid <= 0`(等价于 `NULL / 0 / 负数`)。

### 2.4 后端 API 契约

`apps/backend/app/schemas/xcx_coaches.py:76, 92` + `xcx_performance.py:24`:
```python
is_scattered: bool = False  # 散客标识,前端据此置灰名称
```

`apps/backend/docs/API-REFERENCE.md:480, 621-622`:
> `isScattered`:散客标记(`member_id ≤ 0` 时为 `true`),前端据此将客户姓名置灰

**API 已采用"扁平布尔字段"模式,前端不再自己判断 memberId。**

### 2.5 小程序前端

`apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.ts:419,464,478`:
```ts
if (memberId <= 0) {
  wx.showToast({ title: '散客无详情可查看', icon: 'none' })
  return
}
```

`apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.ts:235`:
```ts
wx.showToast({ title: '散客无详情可查看', icon: 'none' })
```

`apps/miniprogram/miniprogram/utils/avatar-color.ts:60-67`:
> 空字符串 / "0" / 负数字符串 → 'default'(灰色,未知客户/散客)

`apps/miniprogram/miniprogram/pages/performance/performance.ts:251`:
> 任务 C1: 散客/未知客户(memberId <= 0)拦截,不跳转

前端两套并存:
1. **新约定**(优先):用后端返回的 `isScattered: bool` 字段
2. **旧兜底**:点击跳转时再 `memberId <= 0` 二次判断(防御性)

`apps/miniprogram/README.md:158-164`:
> 后端判定规则:`member_id <= 0` 时在响应字段上标记 `isScattered = true`...散客条目不提供跳转到 `customer-detail` 的入口。

## 三、推荐统一约定

基于实证 + 现状,**强烈推荐:`member_id = 0` 表示散客**。

理由:
1. **飞球上游 API 就规定 `0` = 散客**,从源头一致
2. **ETL DWD 落库后 `0` 占 84.6%(27742/32789)**,无 NULL,无负数,实证最稳定
3. **DWS 会员维度表全部 `NOT NULL`**,散客本就不入,不存在二义性
4. **DWS 2 张 ML 表已 `DEFAULT 0`**,语义已物化
5. **NULL 语义混淆**:NULL 通常表示"字段不适用 / 未知",散客是明确语义,不应是 NULL
6. **负数(-1)无任何使用证据**,引入会破坏现有 `member_id <= 0` 判断习惯

**建议口径**:
- **ETL 写入**:固定写 `0`,不允许写 NULL(可加 NOT NULL 约束 + DEFAULT 0,但需评估上游影响)
- **业务判断**:`is_scattered = (member_id IS NULL OR member_id <= 0)`(防御性容忍 NULL,但实测无 NULL)
- **API 返回**:用 `isScattered: bool` 扁平字段,前端不再判断

## 四、各层调整建议

### 4.1 ETL DWD-DOC 写入约定

文件:`apps/etl/connectors/feiqiu/CLAUDE.md` + `docs/etl_tasks/dws_tasks.md`

**新增条款**(强制):
> **散客 member_id 约定**:
> - 飞球 API 端 `member_id = 0` 表示散客
> - DWD 写入时直接保留 `0`,不转换为 NULL,不映射为 -1
> - 判断逻辑统一用 `member_id IS NULL OR member_id <= 0`(容忍上游异常)
> - DWD-DOC 12 条强制规则中明确:`member_id` 列保留原始 `0`,不允许任何替换

是否需要 schema 变更:
- 选项 A(零工作):保持现状,nullable=YES,但事实上无 NULL,文档规范化即可
- 选项 B(可选加强):DWD 表 `member_id` 改为 NOT NULL DEFAULT 0,需要 schema 变更 + 兼容性测试

**推荐选项 A**(零工作 + 文档规范化),因为现状无问题,不需要破坏性变更。

### 4.2 后端 API 层约定

**已经做对了**(无需改动):
- 所有面向小程序的接口都返回 `isScattered: bool` 扁平字段
- 后端统一用 `not mid or mid <= 0` 判断
- API-REFERENCE.md 已明文规定

**建议补充**:
- 在 `apps/backend/CLAUDE.md` 或 `apps/backend/docs/CONVENTIONS.md` 新增"散客契约"段:
  > 后端 API 一律返回 `isScattered: bool`,前端不应再自行判断 `memberId <= 0`,旧的 `memberId <= 0` 判断逻辑应逐步替换为读 `isScattered`。

### 4.3 小程序前端约定

**问题**:前端目前**两套机制并存**:
- 部分组件读 `isScattered`(WXML)
- 部分页面 ts 仍用 `memberId <= 0`(coach-detail / coach-service-records / performance)

**建议**:
- 新代码**禁止**写 `memberId <= 0`,统一用 `record.isScattered`
- 旧代码逐步替换(非紧急,功能等价)
- 在 `apps/miniprogram/README.md` 散客章节加一句:**判断口径以 `isScattered` 为准,不要从 `memberId` 反推**

## 五、给 Neo 的决策清单

| 决策点 | 选项 | 推荐 | 理由 |
|-------|------|------|------|
| 散客 ID 真值 | NULL / 0 / -1 / `<=0` | **`0`** | 飞球上游约定 + 实测 27742 行无 NULL/负数 |
| ETL DWD schema 变更 | 不变 / NOT NULL DEFAULT 0 | **不变** | 现状已稳定,改 schema 影响面大 |
| 后端 API 字段 | `isScattered: bool`(已实施) | **保持** | 前端不再自行判断 |
| 前端旧代码 | 立即重构 / 渐进替换 | **渐进替换** | 功能等价,无紧急 bug |
| 文档规范化 | 必须 | **必须** | DWD-DOC 12 条 + 后端 CONVENTIONS + miniprogram README 各加一段 |

**Neo 只需拍板一句:"散客统一记 `0`,前端读 `isScattered`,不变 schema,文档跟上"** — 三层约定就闭环。

## 六、附:风险点与边界

1. **飞球 API 偶发 NULL**:虽然测试库无 NULL,但生产环境若飞球某次返 `null`,DWD 落库会保留 NULL,业务判断仍要兼容(`OR IS NULL`)
2. **ML 配单表 DEFAULT 0**:若误把"未配单"和"散客配单"都记为 0,会出现语义混淆。需在 ML 任务中明确 `member_id = 0` 仅指散客,不能用作"配单失败占位"
3. **dim_member.member_id NOT NULL**:维表无散客占位行,后端 JOIN 时若用 INNER JOIN 会丢散客行,必须用 LEFT JOIN(已检查 fdw_queries.py 多处使用 LEFT JOIN)
