# PRD：UR#96 店铺业务员归属、继承与筛选

Status: 代码完成、验证延期

---

## Problem Statement

店铺目前只有上下级代理关系，没有明确的平台业务员归属。运营无法按负责员工筛选店铺，套餐临期和钱包低余额等通知也无法稳定找到对应平台员工。

代理账号可以发展下级代理，因此业务员归属不能只在平台创建店铺时人工填写。下级代理应默认继承直属上级店铺当时的业务员，但代理不得自行指定或更换平台员工；同时，平台后续调整上级店铺业务员不能自动覆盖已有下级店铺，否则会破坏平台对单个下级店铺的独立调整。

## Solution

在 `tb_shop` 增加可空 `business_owner_account_id`，表示该店铺当前负责业务员。代理创建下级店铺时由服务端复制所选直属上级店铺当时的业务员 ID，代理请求不得提供该字段；复制完成后父子店铺各自保存，后续不做级联同步。

只有超级管理员和平台账号可以显式设置、清空或更换业务员，且只能选择当前启用、未删除的普通平台账号。店铺列表、详情和筛选 Query 批量投影业务员账号名及手机号摘要；该字段只用于平台内部业务归属和通知接收，不进入代理层级、数据权限、佣金或分销计算。

## User Stories

1. 作为平台运营人员，我希望创建或编辑店铺时可以选择一个启用的平台业务员，也可以清空归属。
2. 作为平台运营人员，我希望按业务员筛选我权限范围内的店铺，并在列表和详情看到业务员摘要。
3. 作为代理账号，我希望发展下级代理时，新店铺自动沿用我的店铺当前业务员，无需也不能自行选择。
4. 作为平台运营人员，我希望修改上级店铺业务员后，已有下级店铺不被级联覆盖，便于逐店铺独立管理。
5. 作为代理账号，我希望能查看有权限店铺的业务员归属，但不能通过构造请求篡改它。
6. 作为通知系统，我希望按店铺稳定找到当前业务员；账号停用或删除时保留归属历史，但不向不可用账号发送通知。
7. 作为审计人员，我希望区分平台人工设置、代理建店继承、清空和更换，并能看到变更前后账号。

## Implementation Decisions

### 领域含义与数据模型

- `tb_shop` 新增 `business_owner_account_id BIGINT NULL`，不建立数据库外键或 GORM 关联标签；Model 只保存 ID，关联账号由 Query 显式批量加载。
- `business_owner_account_id` 表示平台内部当前业务负责人和通知接收关系，不是“发展人”层级事实。它不参与 `parent_id` 店铺层级、数据权限、授权范围、代理分销、佣金、提现或客户归属计算。
- 可被人工选择的业务员必须满足 `tb_account.user_type=2`、`status=1`、`deleted_at IS NULL`。超级管理员可以管理该字段，但 `user_type=1` 的超级管理员账号本身不是业务员候选。
- 一个店铺本期最多一个业务员，可为空；一个平台业务员可以负责多个店铺。
- 业务员账号禁用或软删除后不清空店铺字段，不级联更新店铺；列表/详情仍尽可能按原 ID 展示历史账号摘要并标记当前不可用，通知接收人解析时跳过。账号重新启用后，该未变更关联重新成为可用通知关系。
- 为 `business_owner_account_id` 建普通索引以支持筛选，不建立唯一约束。

### 创建时继承

- 创建店铺继续使用 `POST /api/admin/shops`，新增存在性感知的可空字段 `business_owner_account_id`。
- 代理账号创建其现有权限允许的新下级店铺时，业务员只能由服务端从最终校验通过的 `parent_id` 店铺复制：
  - 上级有业务员 ID：原值复制到新店铺，包括上级账号此刻已停用/软删除但关联仍保留的情况。
  - 上级业务员为空：新店铺也为空。
  - 请求 JSON 只要主动出现 `business_owner_account_id`，无论值为原值、其他 ID 或 `null`，都返回禁止操作，不静默忽略。
- 代理建店必须先复用现有店铺层级与管理权限校验，确保 `parent_id` 确实处于调用者允许发展的范围；不能通过选择无权上级间接复制归属或创建店铺。本需求不扩大代理原有发展层级权限。
- 超级管理员或平台账号创建店铺时：
  - 显式传正整数 ID：校验为当前可用平台业务员后设置。
  - 显式传 `null`：创建为空业务员。
  - 字段缺失且存在上级店铺：默认复制上级店铺当前业务员。
  - 字段缺失且为顶级店铺：默认为空。
- 创建时复制是一次性快照关系。上级店铺以后被设置、清空或更换业务员，既有下级、孙级店铺均不自动更新；只有之后新创建的直属下级读取上级当时的当前值。
- 店铺、初始主账号、角色、主/分佣钱包、业务员归属和创建审计属于同一个完整创建用例，必须在同一数据库事务内成功或失败，避免当前多表创建留下半成品。

### 编辑权限和三态字段

- `PUT /api/admin/shops/{id}` 增加存在性感知的可空 `business_owner_account_id`，语义为：字段缺失保持不变，显式 `null` 清空，正整数校验后替换。
- DTO/解析必须真实区分“字段缺失”和“显式 null”，不能用普通 `*uint` 把二者都解释成 nil；可使用项目内明确的 Optional/Nullable 类型，但不得把这个差异留给 Handler 猜测。
- 超级管理员和平台账号可以设置、清空、更换；代理账号不得修改。代理更新其他店铺资料且字段缺失时保留原业务员，字段一旦出现则返回统一禁止访问错误。
- 企业账号不具备业务员候选或写权限。所有写操作除账号类型检查外，仍要执行目标店铺的既有资源权限校验；无权或不存在统一返回“无权限操作该资源或资源不存在”。
- 设置 ID 时在事务内重新校验业务员仍为启用平台账号，不能仅相信前端候选列表。禁用、删除、代理或企业账号 ID 均返回参数/业务错误且不更新店铺。
- 平台手工修改一个店铺只影响该店铺，不向上、向下或同级传播，也不修改历史通知和审计快照。

### Query 与 API

- `GET /api/admin/shops` 新增可选精确筛选 `business_owner_account_id`，与店铺名、编号、联系电话、层级、状态等现有条件按 AND 组合；分页、排序和调用者数据范围保持原契约。
- 店铺响应增加：
  - `business_owner_account_id:uint|null`
  - `business_owner_username:string`
  - `business_owner_phone_summary:string`
  - `business_owner_available:bool`
- 手机号摘要固定保留前三位和后四位，例如 `138****8000`；空值返回空字符串。普通店铺 Query 不返回业务员完整手机号。
- 列表按当前页业务员 ID 一次批量加载，包括必要的软删除只读投影，禁止每行查询账号。筛选按店铺保存的 ID 执行，因此账号停用/删除后仍可用该 ID 查到历史负责店铺。
- 若前端现有店铺详情没有独立接口，应补齐 `GET /api/admin/shops/{id}` 并返回同一 `ShopResponse`；动态路由必须排在 `/cascade`、`/fund-summary`、`/business-owner-candidates` 等静态路由之后，避免吞掉现有路径。
- 新增最小候选 Query：`GET /api/admin/shops/business-owner-candidates?keyword=&page=&page_size=`，仅超级管理员和平台账号可调用。固定筛选当前启用、未删除的 `user_type=2` 账号，`keyword` 对用户名或手机号做受控查询，返回 `id/username/phone_summary`，默认 20、最大 100。
- 代理端不需要候选接口；其创建/编辑表单只展示继承或现有业务员摘要，不显示可搜索选择框。
- 新字段向后兼容：旧平台客户端不传字段时，创建子店铺按继承规则、编辑保持不变；旧代理客户端不传字段时正常继承/保留。

### Application、通知与审计

- 本需求使用 Application 事务脚本，不创建空洞 Shop 聚合。Application 负责操作者类型、资源权限、上级解析、继承/三态命令、候选账号校验、事务保存和 Audit Event；Query 负责列表、详情、候选及 DTO 投影。
- 店铺创建事件记录 `assignment_source=inherited/explicit/empty`、上级店铺 ID 和最终业务员 ID。编辑事件只有字段实际变化时记录业务员变更，前后数据均使用 ID、账号名摘要和可用状态。
- 业务员归属变更属于平台内部敏感业务操作，接入统一 Audit Writer；关键成功审计与店铺事务同事务，失败/拒绝按公共审计规则记录。
- 公共通知接收人解析按店铺当前 `business_owner_account_id` 查账号并再次校验可用性；不得因为父店铺后来变更业务员而动态向上追溯，也不得沿代理层级向所有上级业务员发送。
- 账号禁用/删除不触发批量清空或改派。运营通过店铺列表按该业务员筛选后逐个或后续独立批量能力处理，本需求不建设自动转派。
- 新增/修改 DTO、Model、Handler 和迁移时遵循相应项目专项规范；新增 Handler 后同步两个 OpenAPI 文档生成器。

### 前端交互

- 超级管理员和平台账号的店铺创建/编辑表单增加可搜索业务员下拉：支持不选择、清空、加载、无候选和失败重试；候选显示账号名与手机号摘要。
- 创建下级店铺时字段初始显示继承到的上级业务员；平台可覆盖或清空。前端是否展示默认值不作为业务规则，最终继承由后端保证。
- 代理账号只读展示“业务员”，不显示选择或清空控件；创建下级时提示“默认继承上级店铺业务员”。
- 店铺列表增加业务员列和筛选。停用/删除账号显示原摘要及“已停用/不可用”，不误显示为空；清空归属显示“-”。
- 编辑提交期间禁用重复提交；权限拒绝、候选失效和并发变更原地展示后端中文错误，成功后重新请求店铺详情。

### 发布与历史数据

- 迁移新增可空字段和普通索引，不回填存量店铺，不根据祖先或创建人猜测历史业务员。所有存量店铺初始为空，由平台后续维护。
- 部署后新建店铺立即按新规则写入；不运行父子全量级联脚本。回滚应用时新增字段可以保留，已产生的业务员关联和审计不得清空。
- 发布前只读核验 `tb_shop` 层级异常和平台账号状态；发布后抽查平台显式设置、代理继承、父级修改不级联及通知接收人解析。

## Testing Decisions

- Application 测试覆盖超级管理员、平台、代理、企业四类操作者的创建和编辑矩阵；验证只有前两类可显式设置/清空/更换。
- 创建测试覆盖：代理上级有/无业务员、上级业务员可用/停用/软删除、代理主动传相同 ID/其他 ID/null、平台显式 ID/null/字段缺失和顶级店铺。
- 继承回归验证父店铺后续设置、清空和更换均不改变既有子孙店铺；新建直属下级只复制创建时父店铺当前值。
- 候选与写入测试验证只有 `user_type=2 + status=1 + 未删除` 可选择；超级管理员、代理、企业、停用和软删除账号均不能被人工新绑定。
- 权限测试验证代理不能利用无权 `parent_id` 创建店铺，不能通过字段缺失/null 混淆清空归属，企业和越权平台请求使用统一安全错误语义。
- PostgreSQL 集成测试验证创建多表事务原子性、业务员索引筛选、AND 条件、分页总数、软删除账号历史投影和批量查询无 N+1。
- HTTP 集成测试穿过真实 Fiber 认证、Handler、Application/Query、GORM 和统一响应，特别验证 JSON 字段缺失、null、0、正 ID 四种解析语义。
- API 测试覆盖店铺列表、详情和候选；验证手机号固定脱敏，代理不能调用候选，静态候选/资金概况/级联路由不被详情动态路由吞掉。
- Audit 测试验证继承、显式设置、清空、更换和拒绝结果，事务失败不留下店铺/账号/钱包半成品或成功审计。
- 前端验收覆盖平台选择/清空、代理只读、停用业务员展示、业务员筛选、空态、失败态和父级修改不级联。

## Out of Scope

- 不恢复需求 16 的分销码、发展关系、佣金、提现或 H5 代理申请；“业务员”不等于分销发展人。
- 不因业务员归属扩大账号的数据权限或店铺管理范围。
- 不自动级联上级业务员的后续变更，不批量回填存量子店铺。
- 不允许代理账号自行设置、清空或更换业务员。
- 不建设多业务员、团队、部门、区域或自动轮转分配。
- 不在业务员账号停用/删除时自动改派，也不发送外部渠道通知。

## Further Notes

- 当前代码的 `Shop`、创建/更新 DTO 和列表 Query 均没有业务员字段；店铺创建还连续创建店铺、主账号、角色和两个钱包但没有显式完整事务，接入继承时必须把该完整创建用例收口。
- 当前已有平台账号列表能力，但返回字段和权限范围大于下拉框所需；专用候选 Query 用于最小披露和明确写权限。
- 用户确认的核心口径是“创建时继承、之后独立、不自动级联”；实现不得把查询时动态向父级取值当作继承。
