# NS4：租户管理后台 — tenant-admin-web

> 优先级：中（可与 NS1/NS2 并行，依赖 P1+P3）
> 预估工作量：大
> 前置条件：P1（数据库基础）、P3（用户认证体系）、admin-web-console 需求 11（租户管理员账号管理）
> 参考基准：`docs/prd/specs/P10-tenant-admin-web.md`

---

## 一、背景与目标

当前系统缺少面向租户管理员的独立管理界面。用户审核、Excel 数据上传、维客线索管理等运营操作无法自助完成，依赖开发人员手动操作数据库。

本 SPEC 目标：构建独立的租户管理 Web 应用，提供：
1. 用户审核与管理（申请审核、身份编辑、店铺归属、助教/员工绑定）
2. Excel 数据上传（4 种模板：财务支出/团购收入/助教奖罚/充值业绩归属）
3. 维客线索管理（查看、修改、删除、隐藏）

### 与现有系统的关系

| 系统 | 用途 | 用户 |
|------|------|------|
| `apps/admin-web/`（系统管理后台） | 平台级管理（Operator 操作） | 系统管理员 |
| `apps/tenant-admin/`（本 SPEC） | 租户级管理 | 租户管理员 |
| `apps/miniprogram/`（小程序） | C 端业务 | 助教/管理者 |

租户管理员账号由系统管理后台（`apps/admin-web/`）的 Operator 创建，租户管理员不可自行注册。

---

## 二、技术架构

### 2.1 前端

- 独立 Web 应用：React + Vite + Ant Design（与 `apps/admin-web/` 同技术栈）
- 部署路径：`apps/tenant-admin/`
- 独立登录入口，与系统管理后台完全隔离

```
apps/tenant-admin/
├── src/
│   ├── pages/
│   │   ├── Login/                  # 登录页
│   │   ├── UserApproval/           # 用户审核
│   │   ├── UserManagement/         # 用户管理
│   │   ├── ExcelUpload/            # Excel 上传（4 种模板）
│   │   └── RetentionClues/         # 维客线索管理
│   ├── components/
│   │   ├── DiffTable/              # 冲突 diff 交互组件
│   │   └── ClueEditor/             # 线索编辑组件
│   ├── services/
│   │   └── api.ts                  # API 调用封装
│   ├── hooks/
│   ├── utils/
│   └── App.tsx
├── package.json
├── vite.config.ts
└── tsconfig.json
```

### 2.2 后端

复用 `apps/backend/` 的 FastAPI，新增租户管理路由模块：

```
apps/backend/app/routers/
├── tenant_auth.py          🆕 租户管理员登录/鉴权
├── tenant_users.py         🆕 用户审核 + 用户管理
├── tenant_excel.py         🆕 Excel 上传/校验/冲突处理
└── tenant_clues.py         🆕 维客线索管理
```

### 2.3 认证体系

- 独立凭据：用户名 + 密码（非微信登录）
- JWT 签发：与小程序 JWT 独立（不同 issuer 或 audience）
- 账号创建：由系统管理后台 Operator 创建，指定用户名、初始密码、所属租户、管辖 site_id 列表
- 权限级别：
  - 租户级管理员：管辖该租户下所有店铺
  - 店铺级管理员：只能管理 Operator 分配的 site_id 列表内的店铺

### 2.4 数据隔离

- 所有查询附加 `site_id IN (管辖列表)` 条件
- FDW 查询需 `SET LOCAL app.current_site_id`（单店铺场景）
- 多店铺场景下，逐 site_id 查询后合并结果

---

## 三、功能详细设计

### 3.1 用户审核页面

#### 页面功能

- 申请列表：展示所有待审核/已审核的用户申请
- 状态筛选：全部 / 待审核(pending) / 已通过(approved) / 已拒绝(rejected)
- 关联建议：根据申请中的球房 ID + 手机号，同时在助教表和员工信息表中匹配
- 审核操作：通过（分配身份+关联助教/员工）/ 拒绝（填写原因）

#### 关联匹配逻辑

```
用户申请（球房ID + 手机号）
  → site_code_mapping 查 site_id
  → 并行匹配：
     ├── fdw_etl.v_dim_assistant（phone 匹配，scd2_is_current=1）
     └── fdw_etl.v_dim_staff + v_dim_staff_ex（phone 匹配）
  → 返回匹配建议列表（可能多条）
  → 管理员选择关联目标
```

#### 审核通过后操作

1. 更新 `auth.users.status = 'approved'`
2. 分配角色（助教/管理者）→ 写入 `auth.user_roles`
3. 关联助教 → 写入 `auth.user_assistant_binding`（含 staff_id）
4. 分配 site_id → 更新 `auth.users.site_id`

#### 接口设计

| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 申请列表 | GET | `/api/tenant/applications` | 支持 status 筛选、分页 |
| 关联建议 | GET | `/api/tenant/applications/{id}/match-suggestions` | 返回助教+员工匹配结果 |
| 审核通过 | POST | `/api/tenant/applications/{id}/approve` | body: role, assistant_id, staff_id |
| 审核拒绝 | POST | `/api/tenant/applications/{id}/reject` | body: reason |

### 3.2 用户管理页面

#### 页面功能

- 用户列表：展示已通过审核的用户（姓名、角色、关联助教、店铺、状态）
- 身份编辑：修改角色（助教↔管理者）
- 店铺归属：修改用户的 site_id
- 关联助教/员工：修改绑定关系
- 禁用/启用：冻结用户账号

#### 接口设计

| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 用户列表 | GET | `/api/tenant/users` | 支持角色筛选、搜索、分页 |
| 编辑用户 | PATCH | `/api/tenant/users/{id}` | body: role, site_id, status |
| 修改绑定 | PUT | `/api/tenant/users/{id}/binding` | body: assistant_id, staff_id |

### 3.3 Excel 上传

#### 4 种模板

##### 模板 1：财务支出（按月）

| 列名 | 类型 | 必填 | 校验规则 |
|------|------|------|---------|
| 月份 | YYYY-MM | 是 | 格式校验，不超过当前月 |
| 支出类别 | 文本 | 是 | 枚举：房租/水电/物业/食品饮料进货/耗材/报销/固定人员工资/其他费用 |
| 金额 | 数值(2) | 是 | > 0，精度 2 位小数 |
| 备注 | 文本 | 否 | 最长 500 字符 |

主键：月份 + 支出类别
写入目标：`dws.dws_finance_expense_summary`（通过后端 API 写入 ETL 库，或写入业务库 staging 表后由 ETL 同步）

##### 模板 2：团购平台收入（按月）

| 列名 | 类型 | 必填 | 校验规则 |
|------|------|------|---------|
| 月份 | YYYY-MM | 是 | 格式校验 |
| 平台名称 | 文本 | 是 | 非空 |
| 收入金额 | 数值(2) | 是 | > 0 |
| 备注 | 文本 | 否 | 最长 500 字符 |

主键：月份 + 平台名称
写入目标：`dws.dws_platform_settlement`（或业务库 staging 表）

##### 模板 3：助教奖罚（按月）

| 列名 | 类型 | 必填 | 校验规则 |
|------|------|------|---------|
| 月份 | YYYY-MM | 是 | 格式校验 |
| 助教姓名 | 文本 | 是 | 非空 |
| 助教编号 | 文本 | 是 | 非空 |
| 类型 | 文本 | 是 | 枚举：扣款/奖金 |
| 金额 | 数值(2) | 是 | > 0 |
| 原因 | 文本 | 是 | 非空，最长 200 字符 |

主键：月份 + 助教姓名 + 助教编号 + 类型 + 原因（同一助教同月可多笔）
写入目标：`biz.salary_adjustments`

##### 模板 4：充值业绩归属（按月）

| 列名 | 类型 | 必填 | 校验规则 |
|------|------|------|---------|
| 充值日期 | YYYY-MM-DD | 是 | 格式校验 |
| 会员名称 | 文本 | 是 | 非空 |
| 充值金额 | 数值(2) | 是 | > 0 |
| 归属助教 | 文本 | 是 | 非空 |
| 奖励金额 | 数值(2) | 是 | ≥ 0 |

主键：充值日期 + 会员名称 + 归属助教
写入目标：`dws.dws_assistant_recharge_commission`（或业务库 staging 表）

#### 人员匹配校验（模板 3/4）

上传助教奖罚和充值业绩归属时，需校验助教姓名+编号是否存在：

```
助教姓名 + 助教编号
  → fdw_etl.v_dim_assistant（nickname + assistant_number 匹配，scd2_is_current=1）
  → 如不匹配，尝试 fdw_etl.v_dim_staff + v_dim_staff_ex（name + staff_number 匹配）
  → 匹配失败：标记为校验警告（不阻断上传，但提示管理员确认）
```

#### 冲突处理流程

```
上传 Excel
  → 后端解析 + 格式校验
  → 返回校验结果：
     ├── 格式错误行 → 前端标红，要求修正后重新上传
     ├── 无冲突行 → 标记为"待写入"
     └── 冲突行（主键已存在）→ 返回 diff 数据（旧值 vs 新值）
  → 前端展示 diff 交互表格：
     - 每行显示：字段名 | 旧值 | 新值 | 操作（替换/保留）
     - 支持"全部替换"/"全部保留"快捷操作
  → 用户确认后提交
  → 后端按选择写入
```

#### 接口设计

| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 上传解析 | POST | `/api/tenant/excel/upload` | multipart/form-data，返回校验结果+冲突列表 |
| 确认写入 | POST | `/api/tenant/excel/confirm` | body: upload_id, resolutions[] |
| 上传记录 | GET | `/api/tenant/excel/logs` | 历史上传记录列表 |
| 模板下载 | GET | `/api/tenant/excel/template/{type}` | 下载空白模板 |

### 3.4 维客线索管理

#### 页面功能

- 客户搜索：按客户姓名/手机号搜索（姓名从 dim_member.nickname，手机从 dim_member.mobile）
- 门店筛选：按管辖 site_id 筛选
- 线索列表：展示该客户的全部维客线索
  - 标签（大类枚举：客户基础/消费习惯/玩法偏好/促销偏好/社交关系/重要反馈）
  - 摘要（含 Emoji 前缀）
  - 详情
  - 提供人（recorded_by_name）
  - 来源（manual / ai_consumption / ai_note）
  - 记录时间
  - 隐藏状态
- 操作：
  - 修改：编辑标签、摘要、详情
  - 删除：二次确认后物理删除
  - 隐藏/显示：切换 `is_hidden` 状态

#### 数据源

- `zqyy_app.public.member_retention_clue`（线索数据）
- `fdw_etl.v_dim_member`（客户信息：nickname、mobile，通过 member_id 关联）

> ⚠️ 会员字段断档（DQ-6）：客户姓名/手机必须从 dim_member 获取，不可使用结算单上的冗余字段

#### 接口设计

| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 客户搜索 | GET | `/api/tenant/customers/search` | query: keyword, site_id |
| 线索列表 | GET | `/api/tenant/customers/{member_id}/clues` | 该客户全部线索 |
| 修改线索 | PATCH | `/api/tenant/clues/{id}` | body: category, summary, detail |
| 删除线索 | DELETE | `/api/tenant/clues/{id}` | 二次确认后物理删除 |
| 隐藏/显示 | PATCH | `/api/tenant/clues/{id}/visibility` | body: is_hidden |

---

## 四、数据库审查与新增表

### 4.1 现有表满足度

| 功能 | 现有表 | 是否满足 | 缺口 |
|------|--------|---------|------|
| 用户审核 | auth.users, auth.user_applications | ✅ 满足 | 无 |
| 用户管理 | auth.users, auth.user_roles, auth.user_assistant_binding | ✅ 满足 | 无 |
| 维客线索 | public.member_retention_clue | ⚠️ 部分 | 缺 is_hidden 字段 |
| 助教奖罚 | — | ❌ 不满足 | 需新建 biz.salary_adjustments |
| 上传记录 | — | ❌ 不满足 | 需新建 biz.excel_upload_log |
| 财务支出/团购收入/充值归属 | DWS 表 | ⚠️ 待定 | 可能需要 staging 表 |

### 4.2 需新建的表

#### 表 1：`biz.salary_adjustments`（助教奖罚明细）

```sql
CREATE TABLE biz.salary_adjustments (
    id BIGSERIAL PRIMARY KEY,
    site_id BIGINT NOT NULL,
    assistant_id BIGINT,              -- 匹配到的助教 ID（可空，匹配失败时为 NULL）
    assistant_name VARCHAR(100) NOT NULL,
    assistant_number VARCHAR(50) NOT NULL,
    salary_month VARCHAR(7) NOT NULL,  -- YYYY-MM
    adjustment_type VARCHAR(20) NOT NULL CHECK (adjustment_type IN ('deduction', 'bonus')),
    amount NUMERIC(12,2) NOT NULL CHECK (amount > 0),
    reason VARCHAR(200) NOT NULL,
    upload_batch_id BIGINT REFERENCES biz.excel_upload_log(id),
    created_at TIMESTAMPTZ DEFAULT NOW(),
    created_by BIGINT                  -- 上传人（租户管理员 ID）
);

CREATE INDEX idx_salary_adj_site_month ON biz.salary_adjustments(site_id, salary_month);
CREATE INDEX idx_salary_adj_assistant ON biz.salary_adjustments(assistant_id, salary_month);
```

#### 表 2：`biz.excel_upload_log`（Excel 上传记录）

```sql
CREATE TABLE biz.excel_upload_log (
    id BIGSERIAL PRIMARY KEY,
    site_id BIGINT NOT NULL,
    upload_type VARCHAR(30) NOT NULL CHECK (upload_type IN (
        'expense', 'platform_income', 'salary_adj', 'recharge_commission'
    )),
    file_name VARCHAR(255) NOT NULL,
    uploaded_by BIGINT NOT NULL,       -- 租户管理员 ID
    row_count INTEGER NOT NULL DEFAULT 0,
    conflict_count INTEGER NOT NULL DEFAULT 0,
    resolved_count INTEGER NOT NULL DEFAULT 0,
    status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN (
        'pending', 'confirmed', 'failed'
    )),
    error_detail JSONB,                -- 校验错误详情
    created_at TIMESTAMPTZ DEFAULT NOW(),
    confirmed_at TIMESTAMPTZ
);

CREATE INDEX idx_excel_log_site ON biz.excel_upload_log(site_id, created_at DESC);
```

### 4.3 需变更的表

#### `public.member_retention_clue` — 新增字段

```sql
ALTER TABLE public.member_retention_clue
    ADD COLUMN is_hidden BOOLEAN NOT NULL DEFAULT false;

COMMENT ON COLUMN public.member_retention_clue.is_hidden
    IS '是否隐藏（true=管理后台保留但小程序不展示）';
```

> 小程序端查询线索时需增加 `WHERE is_hidden = false` 条件

#### `public.member_retention_clue` — 确认 source 字段

P10 spec 中提到需新增 `source` 字段，需确认该字段是否已在 P4/P5 阶段建立。如未建立：

```sql
ALTER TABLE public.member_retention_clue
    ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'manual'
    CHECK (source IN ('manual', 'ai_consumption', 'ai_note'));

COMMENT ON COLUMN public.member_retention_clue.source
    IS '线索来源：manual=人工录入, ai_consumption=应用3消费分析, ai_note=应用6备注分析';
```

### 4.4 Excel 写入目标表的 staging 策略

财务支出、团购收入、充值业绩归属三种模板的数据最终需要进入 DWS 层。有两种策略：

**方案 A：直接写入 DWS 表**（通过后端 API 直连 ETL 库写入）
- 优点：数据即时可用
- 缺点：绕过 ETL 流程，数据一致性风险

**方案 B：写入业务库 staging 表，ETL 定时同步**
- 优点：数据经过 ETL 标准流程，一致性有保障
- 缺点：数据有延迟（取决于 ETL 调度频率）

建议采用方案 B，需新建 3 张 staging 表：

```sql
-- biz.stg_finance_expense（财务支出 staging）
CREATE TABLE biz.stg_finance_expense (
    id BIGSERIAL PRIMARY KEY,
    site_id BIGINT NOT NULL,
    expense_month VARCHAR(7) NOT NULL,
    category VARCHAR(50) NOT NULL,
    amount NUMERIC(12,2) NOT NULL,
    remark TEXT,
    upload_batch_id BIGINT REFERENCES biz.excel_upload_log(id),
    synced_at TIMESTAMPTZ,             -- ETL 同步时间（NULL=未同步）
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- biz.stg_platform_income（团购收入 staging）
CREATE TABLE biz.stg_platform_income (
    id BIGSERIAL PRIMARY KEY,
    site_id BIGINT NOT NULL,
    income_month VARCHAR(7) NOT NULL,
    platform_name VARCHAR(100) NOT NULL,
    amount NUMERIC(12,2) NOT NULL,
    remark TEXT,
    upload_batch_id BIGINT REFERENCES biz.excel_upload_log(id),
    synced_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- biz.stg_recharge_commission（充值业绩归属 staging）
CREATE TABLE biz.stg_recharge_commission (
    id BIGSERIAL PRIMARY KEY,
    site_id BIGINT NOT NULL,
    recharge_date DATE NOT NULL,
    member_name VARCHAR(100) NOT NULL,
    recharge_amount NUMERIC(12,2) NOT NULL,
    assigned_assistant VARCHAR(100) NOT NULL,
    reward_amount NUMERIC(12,2) NOT NULL,
    upload_batch_id BIGINT REFERENCES biz.excel_upload_log(id),
    synced_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT NOW()
);
```

---

## 五、租户管理员账号体系

### 5.1 账号存储

租户管理员账号存储在 `auth.tenant_admins` 表（需新建）：

```sql
CREATE TABLE auth.tenant_admins (
    id BIGSERIAL PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    display_name VARCHAR(100),
    tenant_id BIGINT NOT NULL,         -- 所属租户
    managed_site_ids BIGINT[] NOT NULL, -- 管辖的 site_id 列表
    is_active BOOLEAN NOT NULL DEFAULT true,
    created_by BIGINT,                 -- 创建该账号的 Operator ID
    created_at TIMESTAMPTZ DEFAULT NOW(),
    last_login_at TIMESTAMPTZ
);

CREATE INDEX idx_tenant_admin_tenant ON auth.tenant_admins(tenant_id);
```

### 5.2 与小程序用户体系的隔离

- 租户管理员使用 `auth.tenant_admins` 表，小程序用户使用 `auth.users` 表
- JWT 签发时使用不同的 `aud`（audience）字段区分
- 后端路由通过不同的认证依赖注入区分（`require_tenant_admin()` vs `require_approved()`）

---

## 六、参考文档

| 文档 | 路径 | 用途 |
|------|------|------|
| P10 原始 spec | `docs/prd/specs/P10-tenant-admin-web.md` | 需求定义基准 |
| admin-web 现有代码 | `apps/admin-web/` | 技术栈参考（React + Vite + Ant Design） |
| BD 手册-认证表 | `docs/database/BD_Manual_auth_tables.md` | auth schema 表结构 |
| BD 手册-业务表 | `docs/database/BD_Manual_biz_tables.md` | biz schema 表结构 |
| 权限矩阵 | `docs/permission_matrix/` | 角色-权限映射参考 |
| DWD-DOC 标杆 | `docs/reports/DWD-DOC/` | 金额口径权威参考 |
| 数据依赖矩阵 | `docs/prd/specs/00-数据依赖矩阵.md` | 租户管理后台数据源映射 |
| member_retention_clue DDL | `db/zqyy_app/` | 维客线索表结构 |

---

## 七、预审查清单（SPEC 启动前确认）

### 7.1 账号与认证

1. **租户管理员账号模型**：是否需要独立的 `auth.tenant_admins` 表？还是复用 `auth.users` 表增加 `user_type` 字段区分？
2. **密码策略**：初始密码是否需要强制修改？密码复杂度要求？是否需要密码过期机制？
3. **多租户隔离**：一个管理员是否可以管辖多个租户？还是严格一对一？
4. **会话管理**：JWT 过期时间？是否需要 refresh token？是否支持多设备同时登录？

### 7.2 用户审核

5. **关联匹配优先级**：助教表和员工信息表同时匹配到时，优先展示哪个？是否需要合并展示？
6. **审核拒绝后**：用户是否可以重新申请？重新申请是新建记录还是更新原记录？
7. **批量审核**：是否需要支持批量通过/拒绝？
8. **审核通知**：审核结果是否需要通知用户（小程序消息/微信模板消息）？

### 7.3 Excel 上传

9. **写入策略**：财务支出/团购收入/充值归属是直接写入 DWS 表（方案 A）还是写入 staging 表由 ETL 同步（方案 B）？
10. **文件大小限制**：单次上传的 Excel 文件大小上限？行数上限？
11. **历史数据**：是否允许上传历史月份的数据？是否有时间范围限制？
12. **模板版本**：Excel 模板是否需要版本管理？表头变更时如何兼容旧模板？
13. **助教匹配失败处理**：模板 3/4 中助教姓名+编号匹配失败时，是阻断上传还是允许上传但标记警告？

### 7.4 维客线索

14. **隐藏 vs 删除**：隐藏的线索是否可以恢复显示？删除是否需要软删除（保留记录但标记删除）？
15. **AI 线索保护**：AI 生成的线索（source=ai_consumption/ai_note）是否允许管理员修改/删除？修改后 source 是否变更为 manual？
16. **线索审计**：线索的修改/删除操作是否需要记录操作日志（谁在什么时间做了什么操作）？
17. **批量操作**：是否需要支持批量隐藏/删除线索？

### 7.5 部署与运维

18. **部署方式**：租户管理后台是独立部署还是与系统管理后台共享服务器？域名/路径如何规划？
19. **前端构建**：是否与 admin-web 共享 pnpm workspace？还是完全独立的 package.json？
20. **监控**：是否需要独立的访问日志和错误监控？

---

## 八、任务清单（草案，SPEC 细化后调整）

### Batch A：基础设施
- [ ] T1：创建 `apps/tenant-admin/` 项目骨架（React + Vite + Ant Design）
- [ ] T2：创建 `auth.tenant_admins` 表 + DDL 迁移脚本
- [ ] T3：实现租户管理员登录 API（`tenant_auth.py`：登录/JWT 签发/鉴权中间件）
- [ ] T4：创建 `biz.salary_adjustments` + `biz.excel_upload_log` 表 + DDL 迁移脚本

### Batch B：用户审核与管理
- [ ] T5：实现用户审核后端 API（申请列表/关联建议/审核通过/审核拒绝）
- [ ] T6：实现用户审核前端页面（申请列表 + 状态筛选 + 关联建议展示 + 审核操作）
- [ ] T7：实现用户管理后端 API（用户列表/编辑/绑定修改）
- [ ] T8：实现用户管理前端页面（用户列表 + 身份编辑 + 店铺归属）

### Batch C：Excel 上传
- [ ] T9：实现 Excel 解析+校验后端（4 种模板的格式校验 + 人员匹配校验）
- [ ] T10：实现冲突检测后端（主键匹配 + diff 数据生成）
- [ ] T11：实现 Excel 上传前端（模板下载 + 上传 + 校验结果展示 + diff 交互 + 确认）
- [ ] T12：创建 staging 表（如采用方案 B）+ 写入逻辑

### Batch D：维客线索管理
- [ ] T13：`member_retention_clue` 新增 `is_hidden` 字段 + DDL 迁移
- [ ] T14：实现维客线索后端 API（客户搜索/线索列表/修改/删除/隐藏）
- [ ] T15：实现维客线索前端页面（客户搜索 + 线索列表 + 编辑/删除/隐藏操作）
- [ ] T16：小程序端线索查询增加 `WHERE is_hidden = false` 条件
