|
|
@@ -0,0 +1,556 @@
|
|
|
+# 顾客模块设计(新建 / 列表 / 统计)
|
|
|
+
|
|
|
+## 1. 背景
|
|
|
+
|
|
|
+当前项目已经具备:
|
|
|
+
|
|
|
+- 发型师登录闭环
|
|
|
+- 最小工作台入口
|
|
|
+- GCS 文件上传能力
|
|
|
+
|
|
|
+顾客模块目前在前端已经存在:
|
|
|
+
|
|
|
+- 新建顾客页面
|
|
|
+- 顾客列表页面
|
|
|
+- 顾客统计卡片
|
|
|
+
|
|
|
+但后端 `customers` 模块仍是占位实现,数据库也缺少真正承载顾客档案主数据的 `customer` 主表。
|
|
|
+
|
|
|
+本次 case 的目标是先完成顾客模块的最小业务闭环:
|
|
|
+
|
|
|
+- 新建顾客
|
|
|
+- 顾客分页列表 + 条件查询
|
|
|
+- 顾客统计接口
|
|
|
+
|
|
|
+顾客详情页明确留到下一个 case。
|
|
|
+
|
|
|
+## 2. 范围
|
|
|
+
|
|
|
+### 本次实现
|
|
|
+
|
|
|
+1. 新建顾客并写入数据库
|
|
|
+2. 头像可选;未上传时使用默认头像 URL
|
|
|
+3. 顾客列表分页查询
|
|
|
+4. 顾客列表条件筛选
|
|
|
+5. 顾客顶部统计接口
|
|
|
+6. 前端顾客新建页与列表页对接真实后端接口
|
|
|
+
|
|
|
+### 本次不实现
|
|
|
+
|
|
|
+1. 顾客详情页
|
|
|
+2. 编辑顾客
|
|
|
+3. 顾客施术记录详情聚合
|
|
|
+4. 顾客提醒发送
|
|
|
+5. 顾客 AI 模拟详情页聚合
|
|
|
+6. 顾客历史记录接口
|
|
|
+
|
|
|
+## 3. 设计原则
|
|
|
+
|
|
|
+### 3.1 数据对齐策略
|
|
|
+
|
|
|
+本次采用:
|
|
|
+
|
|
|
+`以现有数据库设计为准,前端做适配`
|
|
|
+
|
|
|
+解释:
|
|
|
+
|
|
|
+- 数据库结构优先贴近现有 SQL 文档与既有业务表关系
|
|
|
+- 前端当前表单字段不直接决定数据库列名
|
|
|
+- 后端负责做字段名映射与值规范化
|
|
|
+
|
|
|
+### 3.2 头像策略
|
|
|
+
|
|
|
+- 顾客头像不是必填
|
|
|
+- 前端上传头像时,复用已完成的存储能力
|
|
|
+- 上传 `type` 固定使用:`avatar`
|
|
|
+- 若前端未上传头像,则后端在创建顾客时自动写入默认头像 URL:
|
|
|
+
|
|
|
+`https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png`
|
|
|
+
|
|
|
+### 3.3 分页策略
|
|
|
+
|
|
|
+- 顾客列表采用页码分页
|
|
|
+- 查询参数使用:`page` + `pageSize`
|
|
|
+
|
|
|
+### 3.4 查询语义
|
|
|
+
|
|
|
+- 搜索词:模糊查询
|
|
|
+- 到店状态:日期范围查询
|
|
|
+- 跟进:按业务枚举查询
|
|
|
+- 发型:按发长精确匹配
|
|
|
+
|
|
|
+### 3.5 统计语义
|
|
|
+
|
|
|
+- `今天到店`:按计划到店日判断,即 `nextVisit`
|
|
|
+- `需跟进`:按 `followStatus` 判断
|
|
|
+
|
|
|
+## 4. 数据模型设计
|
|
|
+
|
|
|
+### 4.1 新增主表
|
|
|
+
|
|
|
+本次新增 `customer` 主表,作为顾客档案主数据的真实来源。
|
|
|
+
|
|
|
+该表用于承接:
|
|
|
+
|
|
|
+- 顾客基本资料
|
|
|
+- 顾客联系信息
|
|
|
+- 咨询与发质信息
|
|
|
+- 跟进信息
|
|
|
+
|
|
|
+并作为以下表的逻辑上游主表:
|
|
|
+
|
|
|
+- `customer_treatment_record.customer_id`
|
|
|
+- `ai_hair_simulation.customer_id`
|
|
|
+
|
|
|
+本次不强制要求一次性补完数据库外键约束,但 Prisma 与业务语义上必须明确该主从关系。
|
|
|
+
|
|
|
+### 4.2 建议字段
|
|
|
+
|
|
|
+建议字段如下:
|
|
|
+
|
|
|
+- `id`
|
|
|
+- `hairstylist_id`
|
|
|
+- `name`
|
|
|
+- `kana`
|
|
|
+- `phone`
|
|
|
+- `email`
|
|
|
+- `birthday`
|
|
|
+- `avatar_url`
|
|
|
+- `contact_type`
|
|
|
+- `line_status`
|
|
|
+- `last_visit_date`
|
|
|
+- `next_visit_date`
|
|
|
+- `visit_cycle`
|
|
|
+- `hair_length`
|
|
|
+- `hair_type`
|
|
|
+- `hair_condition`
|
|
|
+- `concern`
|
|
|
+- `desired_image`
|
|
|
+- `formula_note`
|
|
|
+- `allergy_note`
|
|
|
+- `private_note`
|
|
|
+- `follow_status`
|
|
|
+- `follow_timing`
|
|
|
+- `follow_message`
|
|
|
+- `create_time`
|
|
|
+- `update_time`
|
|
|
+- `is_deleted`
|
|
|
+
|
|
|
+### 4.3 索引
|
|
|
+
|
|
|
+本次 migration 只新增 `customer` 表及必要索引,不修改既有旧表结构。
|
|
|
+
|
|
|
+建议索引:
|
|
|
+
|
|
|
+- `idx_hairstylist_id`
|
|
|
+- `idx_phone`
|
|
|
+- `idx_next_visit_date`
|
|
|
+- `idx_follow_status`
|
|
|
+- `idx_hair_length`
|
|
|
+- `idx_is_deleted`
|
|
|
+- 组合索引:`(hairstylist_id, is_deleted)`
|
|
|
+
|
|
|
+可选唯一约束:
|
|
|
+
|
|
|
+- `(hairstylist_id, phone, is_deleted)`
|
|
|
+
|
|
|
+说明:
|
|
|
+
|
|
|
+- 若允许同一个发型师名下同手机号只存在一条未删除顾客记录,则增加唯一约束
|
|
|
+- 若当前业务暂不确认,先不加唯一约束,避免阻塞本次实现
|
|
|
+
|
|
|
+本次默认先不强制该唯一约束,避免因历史脏数据或录入场景不明确导致创建失败。
|
|
|
+
|
|
|
+## 5. 值规范化设计
|
|
|
+
|
|
|
+后端负责把前端表单值规范化后再入库。
|
|
|
+
|
|
|
+### 5.1 发长
|
|
|
+
|
|
|
+- `Short` -> `SHORT`
|
|
|
+- `Medium` -> `MEDIUM`
|
|
|
+- `Long` -> `LONG`
|
|
|
+
|
|
|
+### 5.2 联系方式
|
|
|
+
|
|
|
+- `LINE` -> `LINE`
|
|
|
+- `Phone` -> `PHONE`
|
|
|
+- `Email` -> `EMAIL`
|
|
|
+
|
|
|
+### 5.3 LINE 关联状态
|
|
|
+
|
|
|
+- `已关联` / `已連携` -> `LINKED`
|
|
|
+- `未关联` / `未連携` -> `UNLINKED`
|
|
|
+- `邀请已发送` -> `INVITED`
|
|
|
+- `保留` -> `KEEP`
|
|
|
+
|
|
|
+### 5.4 跟进状态
|
|
|
+
|
|
|
+本次采用规范枚举存储:
|
|
|
+
|
|
|
+- `NONE`
|
|
|
+- `NEED_FOLLOW_UP`
|
|
|
+- `SENT`
|
|
|
+
|
|
|
+若前端传入的是展示文案,后端负责映射成上述规范值。
|
|
|
+
|
|
|
+## 6. 前后端字段映射
|
|
|
+
|
|
|
+前端字段与数据库字段映射如下:
|
|
|
+
|
|
|
+- `image` -> `avatar_url`
|
|
|
+- `contact` -> `contact_type`
|
|
|
+- `lineStatus` -> `line_status`
|
|
|
+- `lastVisit` -> `last_visit_date`
|
|
|
+- `nextVisit` -> `next_visit_date`
|
|
|
+- `hairLength` -> `hair_length`
|
|
|
+- `hairType` -> `hair_type`
|
|
|
+- `hairCondition` -> `hair_condition`
|
|
|
+- `desiredImage` -> `desired_image`
|
|
|
+- `formulaNote` -> `formula_note`
|
|
|
+- `allergyNote` -> `allergy_note`
|
|
|
+- `privateNote` -> `private_note`
|
|
|
+- `followStatus` -> `follow_status`
|
|
|
+- `followTiming` -> `follow_timing`
|
|
|
+- `followMessage` -> `follow_message`
|
|
|
+
|
|
|
+说明:
|
|
|
+
|
|
|
+- 前端提交结构尽量保持现状
|
|
|
+- 后端 DTO / mapper 负责做字段映射与值规范化
|
|
|
+- 返回给前端时再映射回前端当前约定字段
|
|
|
+
|
|
|
+## 7. API 设计
|
|
|
+
|
|
|
+### 7.1 新建顾客
|
|
|
+
|
|
|
+`POST /api/v1/customers`
|
|
|
+
|
|
|
+请求体:
|
|
|
+
|
|
|
+```ts
|
|
|
+type CreateCustomerRequest = {
|
|
|
+ name: string
|
|
|
+ phone: string
|
|
|
+ kana?: string
|
|
|
+ email?: string
|
|
|
+ birthday?: string
|
|
|
+ image?: string
|
|
|
+ contact?: string
|
|
|
+ lineStatus?: string
|
|
|
+ lastVisit?: string
|
|
|
+ nextVisit?: string
|
|
|
+ visitCycle?: string
|
|
|
+ hairLength?: string
|
|
|
+ hairType?: string
|
|
|
+ hairCondition?: string
|
|
|
+ concern?: string
|
|
|
+ desiredImage?: string
|
|
|
+ formulaNote?: string
|
|
|
+ allergyNote?: string
|
|
|
+ privateNote?: string
|
|
|
+ followStatus?: string
|
|
|
+ followTiming?: string
|
|
|
+ followMessage?: string
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+规则:
|
|
|
+
|
|
|
+- `name` 必填
|
|
|
+- `phone` 必填
|
|
|
+- `image` 非必填
|
|
|
+- 若 `image` 未传,则后端自动写入默认头像 URL
|
|
|
+- 头像上传由前端先调用存储上传接口完成,再将上传结果 URL 传给顾客接口
|
|
|
+
|
|
|
+返回:
|
|
|
+
|
|
|
+- 返回创建后的顾客对象
|
|
|
+- `id` 对前端按字符串返回
|
|
|
+- 日期字段统一返回 `YYYY-MM-DD`
|
|
|
+
|
|
|
+### 7.2 顾客分页列表
|
|
|
+
|
|
|
+`GET /api/v1/customers`
|
|
|
+
|
|
|
+查询参数:
|
|
|
+
|
|
|
+```ts
|
|
|
+type ListCustomersQuery = {
|
|
|
+ page?: number
|
|
|
+ pageSize?: number
|
|
|
+ q?: string
|
|
|
+ visitStatus?: 'ALL' | 'TODAY' | 'THIS_WEEK' | 'OVER_30_DAYS_NO_VISIT'
|
|
|
+ followStatus?: 'ALL' | 'NEED_FOLLOW_UP' | 'SENT'
|
|
|
+ hairLength?: 'ALL' | 'SHORT' | 'MEDIUM' | 'LONG'
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+返回:
|
|
|
+
|
|
|
+```ts
|
|
|
+type ListCustomersResponse = {
|
|
|
+ items: CustomerListItem[]
|
|
|
+ total: number
|
|
|
+ page: number
|
|
|
+ pageSize: number
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+约束:
|
|
|
+
|
|
|
+- `total` 表示当前筛选条件下的总条数
|
|
|
+- 用于前端展示“搜索结果 X 件”
|
|
|
+- `items` 只返回列表页面当前真正需要的字段
|
|
|
+
|
|
|
+### 7.3 顾客统计
|
|
|
+
|
|
|
+`GET /api/v1/customers/summary`
|
|
|
+
|
|
|
+返回:
|
|
|
+
|
|
|
+```ts
|
|
|
+type CustomerSummaryResponse = {
|
|
|
+ total: number
|
|
|
+ today: number
|
|
|
+ followUp: number
|
|
|
+ simulated: number
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+说明:
|
|
|
+
|
|
|
+- 统计接口归属顾客域内,不继续使用 `/stylist/dashboard/customer-summary`
|
|
|
+- 前端后续改为调用顾客域内接口
|
|
|
+
|
|
|
+## 8. 列表项设计
|
|
|
+
|
|
|
+本次列表项只返回当前前端真正用到的字段,不提前把详情页字段全部塞入列表返回。
|
|
|
+
|
|
|
+建议列表项字段:
|
|
|
+
|
|
|
+- `id`
|
|
|
+- `name`
|
|
|
+- `phone`
|
|
|
+- `image`
|
|
|
+- `lastVisit`
|
|
|
+- `hairLength`
|
|
|
+- `concern`
|
|
|
+- `followStatus`
|
|
|
+
|
|
|
+## 9. 查询规则
|
|
|
+
|
|
|
+### 9.1 搜索词 `q`
|
|
|
+
|
|
|
+模糊查询以下字段:
|
|
|
+
|
|
|
+- `name`
|
|
|
+- `phone`
|
|
|
+- `concern`
|
|
|
+- `private_note`
|
|
|
+
|
|
|
+### 9.2 到店状态 `visitStatus`
|
|
|
+
|
|
|
+#### `ALL`
|
|
|
+
|
|
|
+不加筛选条件
|
|
|
+
|
|
|
+#### `TODAY`
|
|
|
+
|
|
|
+`next_visit_date = 今天`
|
|
|
+
|
|
|
+#### `THIS_WEEK`
|
|
|
+
|
|
|
+`next_visit_date` 落在本周范围内
|
|
|
+
|
|
|
+#### `OVER_30_DAYS_NO_VISIT`
|
|
|
+
|
|
|
+`last_visit_date < 今天 - 30天`
|
|
|
+
|
|
|
+### 9.3 跟进状态 `followStatus`
|
|
|
+
|
|
|
+#### `ALL`
|
|
|
+
|
|
|
+不加筛选条件
|
|
|
+
|
|
|
+#### `NEED_FOLLOW_UP`
|
|
|
+
|
|
|
+`follow_status = NEED_FOLLOW_UP`
|
|
|
+
|
|
|
+#### `SENT`
|
|
|
+
|
|
|
+`follow_status = SENT`
|
|
|
+
|
|
|
+### 9.4 发长 `hairLength`
|
|
|
+
|
|
|
+#### `ALL`
|
|
|
+
|
|
|
+不加筛选条件
|
|
|
+
|
|
|
+#### `SHORT | MEDIUM | LONG`
|
|
|
+
|
|
|
+精确匹配 `hair_length`
|
|
|
+
|
|
|
+## 10. 统计规则
|
|
|
+
|
|
|
+所有统计均只统计:
|
|
|
+
|
|
|
+- 当前登录发型师名下数据
|
|
|
+- `is_deleted = 0`
|
|
|
+
|
|
|
+### 10.1 顾客总数
|
|
|
+
|
|
|
+统计当前发型师名下未删除顾客总数
|
|
|
+
|
|
|
+### 10.2 今天到店
|
|
|
+
|
|
|
+统计:
|
|
|
+
|
|
|
+- `next_visit_date = 今天`
|
|
|
+
|
|
|
+### 10.3 需跟进
|
|
|
+
|
|
|
+统计:
|
|
|
+
|
|
|
+- `follow_status = NEED_FOLLOW_UP`
|
|
|
+
|
|
|
+### 10.4 已模拟
|
|
|
+
|
|
|
+统计规则:
|
|
|
+
|
|
|
+- 在 `ai_hair_simulation` 中存在记录
|
|
|
+- `customer_id` 非空
|
|
|
+- 按 `customer_id` 去重
|
|
|
+- 仅统计当前发型师名下顾客
|
|
|
+
|
|
|
+实现建议:
|
|
|
+
|
|
|
+- 以 `customer` 为主表
|
|
|
+- 关联 `ai_hair_simulation`
|
|
|
+- 对顾客维度去重计数
|
|
|
+
|
|
|
+避免把其他发型师的数据混入当前统计结果。
|
|
|
+
|
|
|
+## 11. 鉴权与数据隔离
|
|
|
+
|
|
|
+本次所有顾客接口都必须按当前登录发型师进行数据隔离。
|
|
|
+
|
|
|
+包括:
|
|
|
+
|
|
|
+- 新建顾客
|
|
|
+- 顾客列表
|
|
|
+- 顾客统计
|
|
|
+
|
|
|
+后端从 JWT 中读取当前用户 `sub`,作为 `hairstylist_id`。
|
|
|
+
|
|
|
+规则:
|
|
|
+
|
|
|
+- 创建时写入 `hairstylist_id`
|
|
|
+- 查询时只能查当前发型师自己的顾客
|
|
|
+- 统计时只能统计当前发型师自己的顾客
|
|
|
+
|
|
|
+## 12. 后端模块边界
|
|
|
+
|
|
|
+本次 `CustomersService` 只实现:
|
|
|
+
|
|
|
+- `create()`
|
|
|
+- `list()`
|
|
|
+- `getSummary()`
|
|
|
+
|
|
|
+本次不实现:
|
|
|
+
|
|
|
+- `getById()`
|
|
|
+- `update()`
|
|
|
+- `records / reminders / detail 聚合`
|
|
|
+
|
|
|
+说明:
|
|
|
+
|
|
|
+- 顾客详情页是下一个 case
|
|
|
+- 本次只覆盖当前前端真正依赖的最小闭环
|
|
|
+
|
|
|
+## 13. 前端改动边界
|
|
|
+
|
|
|
+本次前端只做必要适配:
|
|
|
+
|
|
|
+1. 顾客头像上传时,调用存储上传接口并使用 `type=avatar`
|
|
|
+2. 顾客列表接口补充:
|
|
|
+ - `page`
|
|
|
+ - `pageSize`
|
|
|
+ - `visitStatus`
|
|
|
+ - `followStatus`
|
|
|
+ - `hairLength`
|
|
|
+3. 顶部统计接口改为调用 `/customers/summary`
|
|
|
+4. 分页结构对接真实 `total`
|
|
|
+
|
|
|
+本次不强做复杂分页交互设计,只保留当前页面所需的最小可用能力。
|
|
|
+
|
|
|
+## 14. 测试设计
|
|
|
+
|
|
|
+### 14.1 单元测试
|
|
|
+
|
|
|
+至少覆盖:
|
|
|
+
|
|
|
+1. 新建顾客时未传头像,自动写默认头像 URL
|
|
|
+2. 新建顾客时写入当前 `hairstylist_id`
|
|
|
+3. 列表筛选参数可正确转换为查询条件
|
|
|
+4. 统计逻辑符合定义
|
|
|
+
|
|
|
+### 14.2 e2e 测试
|
|
|
+
|
|
|
+至少覆盖:
|
|
|
+
|
|
|
+1. 未登录访问顾客接口返回 `401`
|
|
|
+2. 新建顾客成功
|
|
|
+3. 顾客列表分页成功
|
|
|
+4. 顾客列表筛选成功
|
|
|
+5. 顾客统计接口成功
|
|
|
+
|
|
|
+### 14.3 联调验收
|
|
|
+
|
|
|
+完成标准:
|
|
|
+
|
|
|
+1. 可从前端“新建顾客”页面真实创建顾客并写入数据库
|
|
|
+2. 未上传头像时,数据库保存默认头像 URL
|
|
|
+3. 顾客列表支持真实分页与筛选
|
|
|
+4. 顶部四个统计值来自真实数据库
|
|
|
+5. “搜索结果 X 件”显示当前筛选后的真实总数
|
|
|
+6. 全链路不使用 mock 成功结果
|
|
|
+
|
|
|
+## 15. 风险与约束
|
|
|
+
|
|
|
+### 15.1 前端现有列表筛选逻辑
|
|
|
+
|
|
|
+当前前端 `CustomerListPage` 里仍有部分本地过滤逻辑。
|
|
|
+
|
|
|
+本次实现时需要收敛为:
|
|
|
+
|
|
|
+- 优先由后端执行筛选
|
|
|
+- 前端不再依赖本地 mock 语义推导过滤结果
|
|
|
+
|
|
|
+### 15.2 枚举展示文案问题
|
|
|
+
|
|
|
+当前前端页面使用的是中文 / 日文展示文案。
|
|
|
+
|
|
|
+本次实现时必须保证:
|
|
|
+
|
|
|
+- 数据库存规范值
|
|
|
+- 前端显示文案通过映射层转换
|
|
|
+
|
|
|
+不能把多语言文案直接写入数据库作为业务值。
|
|
|
+
|
|
|
+### 15.3 详情页延后
|
|
|
+
|
|
|
+本次不得为了“顺手支持详情页”而扩展实现范围。
|
|
|
+
|
|
|
+即使 `getById()` 和 `update()` 当前接口已存在占位,也不在本次 case 中完成。
|
|
|
+
|
|
|
+## 16. 最终结论
|
|
|
+
|
|
|
+本次 case 的完成定义为:
|
|
|
+
|
|
|
+1. 新增 `customer` 主表与 Prisma model
|
|
|
+2. 打通顾客新建
|
|
|
+3. 打通顾客分页列表与筛选
|
|
|
+4. 打通顾客 summary 统计
|
|
|
+5. 前端只做必要适配
|
|
|
+6. 顾客详情页留给下一个 case
|