# Customer Module Redesign Around Database Schema Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Rebuild the customer create/list/summary slice so backend schema, APIs, frontend forms/lists, and docs all align with `backend/docs/数据库设计.md#L16-L54`, with `hairstylist_id` added as the only structural supplement required for JWT isolation. **Architecture:** Replace the current incorrect customer model instead of extending it. Backend persists and queries the real `customer` table fields from the approved database design, frontend adapts its current page structure to the new field semantics, and docs are replaced so the old incorrect model does not remain as reference material. **Tech Stack:** NestJS 11, Prisma, MySQL, Jest, Supertest, React 19, React Router 7, TypeScript, JWT auth, existing `/api/v1` API structure, existing storage upload endpoint. ## Global Constraints - API routing must continue to use the `/api/v1` prefix. - The `customer` implementation must use `backend/docs/数据库设计.md#L16-L54` as the source-of-truth field model. - The only allowed structural supplement to that customer table design is `hairstylist_id` for JWT-based data isolation. - All customer data must be isolated by the current JWT hairstylist identity. - Customer avatar upload must reuse the existing storage upload capability and use `type=avatar`. - Customer avatar is optional; when omitted, the backend must persist the exact default URL `https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png`. - Customer list pagination must use `page` + `pageSize`. - Customer summary metrics must be `total`, `today`, `followUp`, `simulated`. - `today` must be based on `plan_date`. - `followUp` must be derived from `next_follow_date <= today`. - The legacy incorrect customer model (`kana`, `avatar_url`, `contact_type`, `line_status`, `last_visit_date`, `next_visit_date`, `visit_cycle`, `hair_type`, `hair_condition`, `concern`, `desired_image`, `formula_note`, `private_note`, `follow_status`, `follow_timing`, `follow_message`) must not remain as the main customer model. - The unsupported legacy filter meaning `30日以上未到店` must be removed because the source-of-truth SQL has no `last_visit_date`. - Customer detail, update, reminders, and history remain out of scope. - Keep a zero-mock business path: real backend behavior, real DB writes, real frontend API integration. - After implementation, replace outdated customer-related docs so old incorrect field descriptions do not remain and mislead future developers. --- ### Task 1: Rebuild Persistence And Config Around The Approved Customer Table **Files:** - Modify: `backend/prisma/schema.prisma` - Modify: `backend/prisma/migrations/20260628120000_add_customer_table/migration.sql` - Modify: `backend/src/config/env.schema.ts` - Modify: `backend/src/config/app.config.ts` - Modify: `backend/src/config/app.config.spec.ts` - Modify: `backend/.env.example` **Interfaces:** - Consumes: `backend/docs/数据库设计.md#L16-L54` - Produces: Prisma `Customer` model with the approved field names plus `hairstylistId` - Produces: config value `customer.defaultAvatarUrl` - Produces: SQL migration comments and indexes aligned with the redesigned model - [ ] **Step 1: Write the failing config test for the customer default avatar URL** Update `backend/src/config/app.config.spec.ts` with: ```ts it('exposes the customer default avatar url', () => { process.env.CUSTOMER_DEFAULT_AVATAR_URL = 'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png'; const config = appConfig(); expect(config.customer.defaultAvatarUrl).toBe( 'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png', ); }); ``` - [ ] **Step 2: Run the config test to verify it fails** Run: `npm test -- app.config.spec.ts --runInBand` Expected: FAIL because `customer.defaultAvatarUrl` does not exist yet. - [ ] **Step 3: Add the customer avatar default to env schema and app config** Update `backend/src/config/env.schema.ts` with: ```ts CUSTOMER_DEFAULT_AVATAR_URL: z.string().url(), ``` Update `backend/src/config/app.config.ts` with: ```ts customer: { defaultAvatarUrl: process.env.CUSTOMER_DEFAULT_AVATAR_URL ?? 'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png', }, ``` Update `backend/.env.example` with: ```env CUSTOMER_DEFAULT_AVATAR_URL=https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png ``` - [ ] **Step 4: Rewrite the Prisma enums and `Customer` model to match the approved database design** Replace the customer-related enums and model in `backend/prisma/schema.prisma` with: ```prisma enum CustomerExpectContact { LINE PHONE EMAIL NONE } enum CustomerHairLength { SHORT MEDIUM LONG UNCONFIRM } enum CustomerHairTexture { UNCONFIRM THIN NORMAL HARD_CRUDE CURLY } enum CustomerFirstSuggestHair { AUTO LONG MEDIUM SHORT } enum CustomerVisitPurpose { CUT_DYE CUT DYE CONSULT_SIM } model Customer { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt hairstylistId BigInt @map("hairstylist_id") @db.UnsignedBigInt name String @db.VarChar(30) namePronunciation String @default("") @map("name_pronunciation") @db.VarChar(50) phone String @db.VarChar(20) email String @default("") @db.VarChar(100) birthday DateTime? @db.Date avatar String @default("") @db.VarChar(500) expectContact CustomerExpectContact @default(NONE) @map("expect_contact") hairLength CustomerHairLength @default(UNCONFIRM) @map("hair_length") hairTexture CustomerHairTexture @default(UNCONFIRM) @map("hair_texture") notice String? @db.Text allergy String @default("") @db.VarChar(500) innerRemark String? @map("inner_remark") @db.Text nextFollowDate DateTime? @map("next_follow_date") @db.Date recommendVisitDate DateTime? @map("recommend_visit_date") @db.Date firstSuggestHair CustomerFirstSuggestHair @default(AUTO) @map("first_suggest_hair") planDate DateTime? @map("plan_date") @db.Date isFirstRegister Int @default(0) @map("is_first_register") @db.UnsignedTinyInt visitPurpose CustomerVisitPurpose? @map("visit_purpose") trouble String? @db.Text consultContent String? @map("consult_content") @db.Text expectLook String? @map("expect_look") @db.Text createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") isDeleted Int @default(0) @map("is_deleted") @db.UnsignedTinyInt @@unique([phone, isDeleted], map: "uk_phone_del") @@index([hairstylistId, isDeleted], map: "idx_hairstylist_is_deleted") @@index([isDeleted], map: "idx_is_deleted") @@index([nextFollowDate, isDeleted], map: "idx_next_follow") @@index([recommendVisitDate, isDeleted], map: "idx_recommend_visit") @@index([planDate, isDeleted], map: "idx_plan_date") @@map("customer") } ``` - [ ] **Step 5: Rewrite the customer migration SQL with comments matching the approved model** Update `backend/prisma/migrations/20260628120000_add_customer_table/migration.sql` with: ```sql CREATE TABLE `customer` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '顾客主键ID', `hairstylist_id` BIGINT UNSIGNED NOT NULL COMMENT '所属发型师ID,JWT 隔离必需字段', `name` VARCHAR(30) NOT NULL COMMENT '顾客姓名(必填)', `name_pronunciation` VARCHAR(50) DEFAULT '' COMMENT '姓名读音', `phone` VARCHAR(20) NOT NULL COMMENT '联系电话(必填)', `email` VARCHAR(100) DEFAULT '' COMMENT '邮箱', `birthday` DATE NULL COMMENT '生日', `avatar` VARCHAR(500) DEFAULT '' COMMENT '顾客头像图片URL地址', `expect_contact` ENUM('LINE','PHONE','EMAIL','NONE') NOT NULL DEFAULT 'NONE' COMMENT '期望联系方式:LINE/电话/邮箱/未设置', `hair_length` ENUM('SHORT','MEDIUM','LONG','UNCONFIRM') NOT NULL DEFAULT 'UNCONFIRM' COMMENT '当前头发长度:短发/中长发/长发/未确认', `hair_texture` ENUM('UNCONFIRM','THIN','NORMAL','HARD_CRUDE','CURLY') NOT NULL DEFAULT 'UNCONFIRM' COMMENT '发质:未确认/细/普通/粗硬/有自然卷', `notice` TEXT NULL COMMENT '发型护理注意事项', `allergy` VARCHAR(500) DEFAULT '' COMMENT '过敏史/过敏产品', `inner_remark` TEXT NULL COMMENT '门店内部备注(仅内部查看)', `next_follow_date` DATE NULL COMMENT '下次跟进日期', `recommend_visit_date` DATE NULL COMMENT '推荐到店日', `first_suggest_hair` ENUM('AUTO','LONG','MEDIUM','SHORT') NOT NULL DEFAULT 'AUTO' COMMENT '首次建议发型模板:自动推荐/长发/中长发/短发', `plan_date` DATE NULL COMMENT '到店计划日', `is_first_register` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否首次登记:0否,1是', `visit_purpose` ENUM('CUT_DYE','CUT','DYE','CONSULT_SIM') NULL COMMENT '到店目的:剪发+染发/剪发/染发/模拟咨询', `trouble` TEXT NULL COMMENT '发质/发型烦恼', `consult_content` TEXT NULL COMMENT '详细咨询内容', `expect_look` TEXT NULL COMMENT '期望发型形象描述', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除 0正常 1删除', PRIMARY KEY (`id`), UNIQUE KEY `uk_phone_del` (`phone`,`is_deleted`) COMMENT '手机号唯一,删除后可重录', KEY `idx_hairstylist_is_deleted` (`hairstylist_id`,`is_deleted`) COMMENT '按发型师隔离顾客数据', KEY `idx_is_deleted` (`is_deleted`) COMMENT '逻辑删除筛选', KEY `idx_next_follow` (`next_follow_date`,`is_deleted`) COMMENT '跟进日期筛选索引', KEY `idx_recommend_visit` (`recommend_visit_date`,`is_deleted`) COMMENT '推荐到店日期索引', KEY `idx_plan_date` (`plan_date`,`is_deleted`) COMMENT '到店计划日筛选索引' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='顾客信息+咨询登记表(合并单表)'; ``` - [ ] **Step 6: Run the config test and Prisma validation to verify it passes** Run: `npm test -- app.config.spec.ts --runInBand` Expected: PASS. Run: `npx prisma validate` Expected: `The schema at prisma/schema.prisma is valid`. - [ ] **Step 7: Commit the persistence redesign** ```bash git add backend/prisma/schema.prisma backend/prisma/migrations/20260628120000_add_customer_table/migration.sql backend/src/config/env.schema.ts backend/src/config/app.config.ts backend/src/config/app.config.spec.ts backend/.env.example git commit -m "feat: realign customer persistence with db schema" ``` ### Task 2: Rebuild Backend DTOs, Mapping, Create/List/Summary Logic, And Tests **Files:** - Modify: `backend/src/modules/customers/dto/customer-upsert.dto.ts` - Modify: `backend/src/modules/customers/dto/list-customers-query.dto.ts` - Modify: `backend/src/modules/customers/dto/customer-summary.dto.ts` - Modify: `backend/src/modules/customers/customers.mapper.ts` - Modify: `backend/src/modules/customers/customers.service.ts` - Modify: `backend/src/modules/customers/customers.controller.ts` - Modify: `backend/src/modules/customers/customers.service.spec.ts` - Modify: `backend/test/app.e2e-spec.ts` **Interfaces:** - Consumes: Prisma `Customer` model from Task 1, config `customer.defaultAvatarUrl`, JWT `stylistId` - Produces: `CustomersService.create(stylistId: bigint, body: CustomerUpsertDto)` - Produces: `CustomersService.list(stylistId: bigint, query: ListCustomersQueryDto)` - Produces: `CustomersService.getSummary(stylistId: bigint): Promise` - Produces: API routes `POST /api/v1/customers`, `GET /api/v1/customers`, `GET /api/v1/customers/summary` - [ ] **Step 1: Write failing service tests for the redesigned fields and summary semantics** Update `backend/src/modules/customers/customers.service.spec.ts` with: ```ts it('creates a customer using the source-of-truth fields', async () => { prisma.customer.create.mockResolvedValue({ id: BigInt(11), hairstylistId: BigInt(1), name: 'Sato Misaki', namePronunciation: 'さとう みさき', phone: '090-0000-0000', email: '', birthday: null, avatar: 'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png', expectContact: 'LINE', hairLength: 'MEDIUM', hairTexture: 'NORMAL', notice: null, allergy: '', innerRemark: null, nextFollowDate: new Date('2026-07-12T00:00:00.000Z'), recommendVisitDate: new Date('2026-07-20T00:00:00.000Z'), firstSuggestHair: 'AUTO', planDate: new Date('2026-06-28T00:00:00.000Z'), isFirstRegister: 1, visitPurpose: 'CONSULT_SIM', trouble: '发量少', consultContent: '希望有轻盈感', expectLook: '自然层次感', createTime: new Date('2026-06-28T00:00:00.000Z'), updateTime: new Date('2026-06-28T00:00:00.000Z'), isDeleted: 0, }); await service.create(BigInt(1), { name: 'Sato Misaki', namePronunciation: 'さとう みさき', phone: '090-0000-0000', expectContact: 'LINE', hairLength: 'MEDIUM', hairTexture: 'NORMAL', nextFollowDate: '2026-07-12', recommendVisitDate: '2026-07-20', planDate: '2026-06-28', isFirstRegister: true, visitPurpose: 'CONSULT_SIM', trouble: '发量少', consultContent: '希望有轻盈感', expectLook: '自然层次感', }); expect(prisma.customer.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ hairstylistId: BigInt(1), namePronunciation: 'さとう みさき', expectContact: 'LINE', hairTexture: 'NORMAL', planDate: new Date('2026-06-28T00:00:00.000Z'), }), }), ); }); it('maps visit filters to plan_date and follow filters to next_follow_date', async () => { prisma.customer.findMany.mockResolvedValue([]); prisma.customer.count.mockResolvedValue(0); await service.list(BigInt(1), { page: 1, pageSize: 20, q: 'Misaki', visitStatus: 'TODAY', followStatus: 'NEED_FOLLOW_UP', hairLength: 'MEDIUM', }); expect(prisma.customer.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ hairstylistId: BigInt(1), isDeleted: 0, hairLength: 'MEDIUM', planDate: expect.any(Object), nextFollowDate: expect.any(Object), }), }), ); }); it('counts summary values using plan_date and next_follow_date', async () => { prisma.customer.count .mockResolvedValueOnce(12) .mockResolvedValueOnce(3) .mockResolvedValueOnce(4); prisma.aiHairSimulation.findMany.mockResolvedValue([ { customerId: BigInt(10) }, { customerId: BigInt(10) }, { customerId: BigInt(11) }, ]); const result = await service.getSummary(BigInt(1)); expect(result).toEqual({ total: 12, today: 3, followUp: 4, simulated: 2, }); }); ``` - [ ] **Step 2: Run the service test to verify it fails** Run: `npm test -- customers.service.spec.ts --runInBand` Expected: FAIL because service logic still reflects the old incorrect model. - [ ] **Step 3: Replace the DTOs with the approved request and query shapes** Update `backend/src/modules/customers/dto/customer-upsert.dto.ts` with: ```ts import { Transform } from 'class-transformer'; import { IsBoolean, IsEmail, IsIn, IsOptional, IsString } from 'class-validator'; export class CustomerUpsertDto { @IsString() name!: string; @IsOptional() @IsString() namePronunciation?: string; @IsString() phone!: string; @IsOptional() @IsEmail() email?: string; @IsOptional() @IsString() birthday?: string; @IsOptional() @IsString() avatar?: string; @IsOptional() @IsIn(['LINE', 'PHONE', 'EMAIL', 'NONE']) expectContact?: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE'; @IsOptional() @IsIn(['SHORT', 'MEDIUM', 'LONG', 'UNCONFIRM']) hairLength?: 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM'; @IsOptional() @IsIn(['UNCONFIRM', 'THIN', 'NORMAL', 'HARD_CRUDE', 'CURLY']) hairTexture?: 'UNCONFIRM' | 'THIN' | 'NORMAL' | 'HARD_CRUDE' | 'CURLY'; @IsOptional() @IsString() notice?: string; @IsOptional() @IsString() allergy?: string; @IsOptional() @IsString() innerRemark?: string; @IsOptional() @IsString() nextFollowDate?: string; @IsOptional() @IsString() recommendVisitDate?: string; @IsOptional() @IsIn(['AUTO', 'LONG', 'MEDIUM', 'SHORT']) firstSuggestHair?: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT'; @IsOptional() @IsString() planDate?: string; @IsOptional() @Transform(({ value }) => value === true || value === 'true' || value === 1 || value === '1') @IsBoolean() isFirstRegister?: boolean; @IsOptional() @IsIn(['CUT_DYE', 'CUT', 'DYE', 'CONSULT_SIM']) visitPurpose?: 'CUT_DYE' | 'CUT' | 'DYE' | 'CONSULT_SIM'; @IsOptional() @IsString() trouble?: string; @IsOptional() @IsString() consultContent?: string; @IsOptional() @IsString() expectLook?: string; } ``` Update `backend/src/modules/customers/dto/list-customers-query.dto.ts` with: ```ts import { Transform } from 'class-transformer'; import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; export class ListCustomersQueryDto { @IsOptional() @Transform(({ value }) => Number(value)) @IsInt() @Min(1) page?: number; @IsOptional() @Transform(({ value }) => Number(value)) @IsInt() @Min(1) @Max(100) pageSize?: number; @IsOptional() @IsString() q?: string; @IsOptional() @IsIn(['ALL', 'TODAY', 'THIS_WEEK']) visitStatus?: 'ALL' | 'TODAY' | 'THIS_WEEK'; @IsOptional() @IsIn(['ALL', 'NEED_FOLLOW_UP', 'NO_NEED_FOLLOW_UP']) followStatus?: 'ALL' | 'NEED_FOLLOW_UP' | 'NO_NEED_FOLLOW_UP'; @IsOptional() @IsIn(['ALL', 'SHORT', 'MEDIUM', 'LONG', 'UNCONFIRM']) hairLength?: 'ALL' | 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM'; } ``` - [ ] **Step 4: Rewrite the mapper helpers around the new field semantics** Update `backend/src/modules/customers/customers.mapper.ts` with: ```ts export function parseDateOnly(value?: string) { return value ? new Date(`${value}T00:00:00.000Z`) : null; } export function formatDateOnly(value: Date | null) { return value ? value.toISOString().slice(0, 10) : undefined; } export function toBooleanFlag(value?: boolean) { return value ? 1 : 0; } export function toFollowState(nextFollowDate: Date | null, now = new Date()) { if (!nextFollowDate) return 'NO_NEED_FOLLOW_UP'; const startOfToday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); return nextFollowDate <= startOfToday ? 'NEED_FOLLOW_UP' : 'NO_NEED_FOLLOW_UP'; } ``` - [ ] **Step 5: Rewrite `CustomersService` to persist and query the real customer fields** Update the `create()` data block in `backend/src/modules/customers/customers.service.ts` to: ```ts const created = await this.prisma.customer.create({ data: { hairstylistId: stylistId, name: body.name.trim(), namePronunciation: body.namePronunciation?.trim() ?? '', phone: body.phone.trim(), email: body.email?.trim() ?? '', birthday: parseDateOnly(body.birthday), avatar: body.avatar?.trim() || this.configService.getOrThrow('customer.defaultAvatarUrl'), expectContact: body.expectContact ?? 'NONE', hairLength: body.hairLength ?? 'UNCONFIRM', hairTexture: body.hairTexture ?? 'UNCONFIRM', notice: body.notice?.trim() || null, allergy: body.allergy?.trim() ?? '', innerRemark: body.innerRemark?.trim() || null, nextFollowDate: parseDateOnly(body.nextFollowDate), recommendVisitDate: parseDateOnly(body.recommendVisitDate), firstSuggestHair: body.firstSuggestHair ?? 'AUTO', planDate: parseDateOnly(body.planDate), isFirstRegister: toBooleanFlag(body.isFirstRegister), visitPurpose: body.visitPurpose ?? null, trouble: body.trouble?.trim() || null, consultContent: body.consultContent?.trim() || null, expectLook: body.expectLook?.trim() || null, }, }); ``` Update the list where builder to: ```ts if (query.q?.trim()) { where.OR = [ { name: { contains: query.q.trim() } }, { phone: { contains: query.q.trim() } }, { innerRemark: { contains: query.q.trim() } }, { trouble: { contains: query.q.trim() } }, { consultContent: { contains: query.q.trim() } }, ]; } if (query.hairLength && query.hairLength !== 'ALL') { where.hairLength = query.hairLength; } const now = new Date(); const startOfToday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); const endOfToday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1)); const dayOfWeek = startOfToday.getUTCDay(); const diffToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1; const startOfWeek = new Date(startOfToday); startOfWeek.setUTCDate(startOfWeek.getUTCDate() - diffToMonday); const endOfWeek = new Date(startOfWeek); endOfWeek.setUTCDate(endOfWeek.getUTCDate() + 7); if (query.visitStatus === 'TODAY') { where.planDate = { gte: startOfToday, lt: endOfToday }; } else if (query.visitStatus === 'THIS_WEEK') { where.planDate = { gte: startOfWeek, lt: endOfWeek }; } if (query.followStatus === 'NEED_FOLLOW_UP') { where.nextFollowDate = { not: null, lte: startOfToday }; } else if (query.followStatus === 'NO_NEED_FOLLOW_UP') { where.OR = [ ...(where.OR ?? []), { nextFollowDate: null }, { nextFollowDate: { gt: startOfToday } }, ]; } ``` Return customer entities as: ```ts return { id: customer.id.toString(), name: customer.name, namePronunciation: customer.namePronunciation || undefined, phone: customer.phone, email: customer.email || undefined, birthday: formatDateOnly(customer.birthday), avatar: customer.avatar, expectContact: customer.expectContact, hairLength: customer.hairLength, hairTexture: customer.hairTexture, notice: customer.notice ?? undefined, allergy: customer.allergy || undefined, innerRemark: customer.innerRemark ?? undefined, nextFollowDate: formatDateOnly(customer.nextFollowDate), recommendVisitDate: formatDateOnly(customer.recommendVisitDate), firstSuggestHair: customer.firstSuggestHair, planDate: formatDateOnly(customer.planDate), isFirstRegister: customer.isFirstRegister === 1, visitPurpose: customer.visitPurpose ?? undefined, trouble: customer.trouble ?? undefined, consultContent: customer.consultContent ?? undefined, expectLook: customer.expectLook ?? undefined, followState: toFollowState(customer.nextFollowDate), }; ``` Count summary values as: ```ts const [total, todayCount, followUp, simulations] = await Promise.all([ this.prisma.customer.count({ where: { hairstylistId: stylistId, isDeleted: 0 } }), this.prisma.customer.count({ where: { hairstylistId: stylistId, isDeleted: 0, planDate: { gte: startOfDay, lt: endOfDay }, }, }), this.prisma.customer.count({ where: { hairstylistId: stylistId, isDeleted: 0, nextFollowDate: { not: null, lte: startOfDay }, }, }), this.prisma.aiHairSimulation.findMany({ where: { hairstylistId: stylistId, isDeleted: 0, customerId: { not: null }, }, select: { customerId: true }, }), ]); ``` - [ ] **Step 6: Update the controller to use the JWT stylist id consistently** Update `backend/src/modules/customers/customers.controller.ts` with: ```ts import { Body, Controller, Get, Post, Query, Req, UseGuards } from '@nestjs/common'; import type { Request } from 'express'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CustomerUpsertDto } from './dto/customer-upsert.dto'; import { ListCustomersQueryDto } from './dto/list-customers-query.dto'; import { CustomersService } from './customers.service'; type AuthenticatedRequest = Request & { user: { stylistId: string; }; }; @UseGuards(JwtAuthGuard) @Controller('customers') export class CustomersController { constructor(private readonly customersService: CustomersService) {} @Get() list(@Req() request: AuthenticatedRequest, @Query() query: ListCustomersQueryDto) { return this.customersService.list(BigInt(request.user.stylistId), query); } @Get('summary') summary(@Req() request: AuthenticatedRequest) { return this.customersService.getSummary(BigInt(request.user.stylistId)); } @Post() create(@Req() request: AuthenticatedRequest, @Body() body: CustomerUpsertDto) { return this.customersService.create(BigInt(request.user.stylistId), body); } } ``` - [ ] **Step 7: Extend E2E tests to cover the redesigned payload and list semantics** Add to `backend/test/app.e2e-spec.ts`: ```ts it('/api/v1/customers (POST) creates a customer using the redesigned fields', async () => { const token = await jwtService.signAsync({ sub: 1, stylistId: '1', email: 'stylist@example.com', username: 'Ogawa', }); const response = await request(app.getHttpServer()) .post('/api/v1/customers') .set('Authorization', `Bearer ${token}`) .send({ name: 'Sato Misaki', namePronunciation: 'さとう みさき', phone: '090-0000-0000', expectContact: 'LINE', hairLength: 'MEDIUM', hairTexture: 'NORMAL', planDate: '2026-06-28', nextFollowDate: '2026-07-12', visitPurpose: 'CONSULT_SIM', trouble: '发量少', consultContent: '希望有轻盈感', expectLook: '自然层次感', }) .expect(201); expect(response.body.ok).toBe(true); expect(response.body.data.namePronunciation).toBe('さとう みさき'); expect(response.body.data.avatar).toBe( 'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png', ); }); it('/api/v1/customers (GET) returns paginated customers using plan_date semantics', async () => { const token = await jwtService.signAsync({ sub: 1, stylistId: '1', email: 'stylist@example.com', username: 'Ogawa', }); const response = await request(app.getHttpServer()) .get('/api/v1/customers?page=1&pageSize=20&q=Sato&visitStatus=TODAY&hairLength=MEDIUM') .set('Authorization', `Bearer ${token}`) .expect(200); expect(response.body.ok).toBe(true); expect(response.body.data.page).toBe(1); expect(response.body.data.pageSize).toBe(20); expect(Array.isArray(response.body.data.items)).toBe(true); }); ``` - [ ] **Step 8: Run backend tests to verify they pass** Run: `npm test -- customers.service.spec.ts app.config.spec.ts --runInBand` Expected: PASS. Run: `npm run test:e2e -- app.e2e-spec.ts` Expected: PASS. - [ ] **Step 9: Commit the backend redesign** ```bash git add backend/src/modules/customers backend/test/app.e2e-spec.ts git commit -m "feat: rebuild customer backend around db schema" ``` ### Task 3: Rebuild Frontend Contracts, Form Mapping, List Semantics, And Tests **Files:** - Modify: `frontend/src/services/api/contracts.ts` - Modify: `frontend/src/services/api/customers.ts` - Modify: `frontend/src/shared/customerForm.ts` - Modify: `frontend/src/pages/dashboard/customers/CustomerFormPage.tsx` - Modify: `frontend/src/pages/dashboard/customers/CustomerListPage.tsx` - Modify: `frontend/src/pages/dashboard/customers/__tests__/CustomerFormPage.test.tsx` - Modify: `frontend/src/pages/dashboard/customers/__tests__/CustomerListPage.test.tsx` - Modify: `frontend/src/i18n/locales/zh/translation.json` - Modify: `frontend/src/i18n/locales/ja/translation.json` - Modify: `frontend/public/css/aitori.css` **Interfaces:** - Consumes: redesigned backend routes and response shape from Task 2 - Produces: frontend `CustomerEntity` aligned with `avatar`, `namePronunciation`, `expectContact`, `hairTexture`, `nextFollowDate`, `recommendVisitDate`, `planDate`, `followState` - Produces: form payload aligned with `CustomerUpsertDto` - Produces: list UI and filters aligned with `plan_date`, derived follow state, and always-visible pagination - [ ] **Step 1: Write failing frontend tests for the redesigned form payload and list semantics** Update `frontend/src/pages/dashboard/customers/__tests__/CustomerFormPage.test.tsx` with: ```ts it('submits the redesigned customer payload', async () => { createCustomer.mockResolvedValue({ id: '1' }); render( , ); fireEvent.change(screen.getByLabelText(/姓名/i), { target: { value: 'Sato Misaki' } }); fireEvent.change(screen.getByLabelText(/电话号码/i), { target: { value: '090-0000-0000' } }); fireEvent.click(screen.getAllByRole('button', { name: /登记顾客/i })[0]); await waitFor(() => { expect(createCustomer).toHaveBeenCalledWith( expect.objectContaining({ name: 'Sato Misaki', phone: '090-0000-0000', }), ); }); }); ``` Update `frontend/src/pages/dashboard/customers/__tests__/CustomerListPage.test.tsx` to assert: ```ts expect(screen.getByText('待设置')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '上一页' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '下一页' })).toBeInTheDocument(); ``` - [ ] **Step 2: Run the frontend customer tests to verify they fail** Run: `npm test -- CustomerFormPage.test.tsx CustomerListPage.test.tsx` Expected: FAIL because frontend contracts and page logic still depend on the old customer model. - [ ] **Step 3: Rewrite frontend customer contracts and API helpers around the new model** Update `frontend/src/services/api/contracts.ts` with: ```ts export type CustomerEntity = { id: string name: string namePronunciation?: string phone: string email?: string birthday?: string avatar?: string expectContact?: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE' hairLength?: 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM' hairTexture?: 'UNCONFIRM' | 'THIN' | 'NORMAL' | 'HARD_CRUDE' | 'CURLY' notice?: string allergy?: string innerRemark?: string nextFollowDate?: string recommendVisitDate?: string firstSuggestHair?: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT' planDate?: string isFirstRegister?: boolean visitPurpose?: 'CUT_DYE' | 'CUT' | 'DYE' | 'CONSULT_SIM' trouble?: string consultContent?: string expectLook?: string followState?: 'NEED_FOLLOW_UP' | 'NO_NEED_FOLLOW_UP' } export type PaginatedCustomerList = { items: CustomerEntity[] total: number page: number pageSize: number } ``` Update `frontend/src/services/api/customers.ts` with: ```ts export type CustomerUpsertInput = { name: string namePronunciation?: string phone: string email?: string birthday?: string avatar?: string expectContact?: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE' hairLength?: 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM' hairTexture?: 'UNCONFIRM' | 'THIN' | 'NORMAL' | 'HARD_CRUDE' | 'CURLY' notice?: string allergy?: string innerRemark?: string nextFollowDate?: string recommendVisitDate?: string firstSuggestHair?: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT' planDate?: string isFirstRegister?: boolean visitPurpose?: 'CUT_DYE' | 'CUT' | 'DYE' | 'CONSULT_SIM' trouble?: string consultContent?: string expectLook?: string } export async function listCustomers( params: { page?: number pageSize?: number q?: string visitStatus?: string followStatus?: string hairLength?: string } = {}, ): Promise { const query = new URLSearchParams() if (params.page) query.set('page', String(params.page)) if (params.pageSize) query.set('pageSize', String(params.pageSize)) if (params.q) query.set('q', params.q) if (params.visitStatus) query.set('visitStatus', params.visitStatus) if (params.followStatus) query.set('followStatus', params.followStatus) if (params.hairLength) query.set('hairLength', params.hairLength) const qs = query.toString() return http.get(`/customers${qs ? `?${qs}` : ''}`) } ``` - [ ] **Step 4: Rewrite the shared customer form mapping around the new payload** Update `frontend/src/shared/customerForm.ts` with: ```ts export type CustomerFormValues = { name: string namePronunciation: string phone: string email: string birthday: string expectContact: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE' hairLength: 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM' hairTexture: 'UNCONFIRM' | 'THIN' | 'NORMAL' | 'HARD_CRUDE' | 'CURLY' notice: string allergy: string innerRemark: string nextFollowDate: string recommendVisitDate: string firstSuggestHair: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT' planDate: string isFirstRegister: boolean visitPurpose: 'CUT_DYE' | 'CUT' | 'DYE' | 'CONSULT_SIM' | '' trouble: string consultContent: string expectLook: string } export function emptyCustomerForm(): CustomerFormValues { return { name: '', namePronunciation: '', phone: '', email: '', birthday: '', expectContact: 'NONE', hairLength: 'UNCONFIRM', hairTexture: 'UNCONFIRM', notice: '', allergy: '', innerRemark: '', nextFollowDate: '', recommendVisitDate: '', firstSuggestHair: 'AUTO', planDate: '', isFirstRegister: false, visitPurpose: '', trouble: '', consultContent: '', expectLook: '', } } export function formValuesToPayload(values: CustomerFormValues, avatar?: string): CustomerUpsertInput { const pick = (value: string) => (value.trim() ? value.trim() : undefined) return { name: values.name.trim(), namePronunciation: pick(values.namePronunciation), phone: values.phone.trim(), email: pick(values.email), birthday: pick(values.birthday), avatar, expectContact: values.expectContact, hairLength: values.hairLength, hairTexture: values.hairTexture, notice: pick(values.notice), allergy: pick(values.allergy), innerRemark: pick(values.innerRemark), nextFollowDate: pick(values.nextFollowDate), recommendVisitDate: pick(values.recommendVisitDate), firstSuggestHair: values.firstSuggestHair, planDate: pick(values.planDate), isFirstRegister: values.isFirstRegister, visitPurpose: values.visitPurpose || undefined, trouble: pick(values.trouble), consultContent: pick(values.consultContent), expectLook: pick(values.expectLook), } } ``` - [ ] **Step 5: Rewrite the customer form page to use the new field semantics and avatar upload** Update the submit block in `frontend/src/pages/dashboard/customers/CustomerFormPage.tsx` to: ```ts const handleSubmit: FormEventHandler = async (event) => { event.preventDefault() if (!form.name || !form.phone || submitting) return setError('') setSubmitting(true) try { let avatar: string | undefined if (photoFile) avatar = (await uploadMedia(photoFile, 'avatar')).url const payload = formValuesToPayload(form, avatar) await createCustomer(payload) navigate(path('/customers')) } catch (err) { setError(getSubmitErrorMessage(err, locale)) } finally { setSubmitting(false) } } ``` Bind visible fields to `namePronunciation`, `expectContact`, `hairTexture`, `nextFollowDate`, `recommendVisitDate`, `planDate`, `visitPurpose`, `trouble`, `consultContent`, and `expectLook`, and remove legacy submit payload usage of `lineStatus`, `lastVisit`, `nextVisit`, `visitCycle`, `hairType`, `hairCondition`, `concern`, `desiredImage`, `formulaNote`, `privateNote`, `followStatus`, `followTiming`, and `followMessage`. - [ ] **Step 6: Rewrite the customer list page filters and row rendering around plan date and derived follow state** Update `frontend/src/pages/dashboard/customers/CustomerListPage.tsx` with: ```ts const VISIT_STATUS_API_MAP: Record = { 全部: undefined, 今天到店: 'TODAY', 本周到店: 'THIS_WEEK', 全て: undefined, 本日来店: 'TODAY', 今週来店: 'THIS_WEEK', } const FOLLOW_STATUS_API_MAP: Record = { 全部: undefined, 需跟进: 'NEED_FOLLOW_UP', 无需跟进: 'NO_NEED_FOLLOW_UP', 全て: undefined, 要フォロー: 'NEED_FOLLOW_UP', フォロー不要: 'NO_NEED_FOLLOW_UP', } const HAIR_LENGTH_API_MAP: Record = { 全部: undefined, 短发: 'SHORT', 中长发: 'MEDIUM', 长发: 'LONG', 未确认: 'UNCONFIRM', 全て: undefined, ショート: 'SHORT', ミディアム: 'MEDIUM', ロング: 'LONG', 未設定: 'UNCONFIRM', } ``` Render list rows with: ```tsx {customer.planDate || '—'} {customer.trouble || '—'} {customer.followState === 'NEED_FOLLOW_UP' ? pageCopy.statusMap.needFollowUp : pageCopy.statusMap.noNeedFollowUp} ``` Keep the pager block and always render it using `Math.max(1, Math.ceil(total / pageSize))`. - [ ] **Step 7: Update locale copy and CSS for the redesigned list semantics** Update `frontend/src/i18n/locales/zh/translation.json` with: ```json "followOptions": ["全部", "需跟进", "无需跟进"], "styleOptions": ["全部", "短发", "中长发", "长发", "未确认"], "statusMap": { "needFollowUp": "需跟进", "noNeedFollowUp": "待设置" }, "pagination": { "prev": "上一页", "next": "下一页" } ``` Update `frontend/src/i18n/locales/ja/translation.json` with: ```json "followOptions": ["すべて", "要フォロー", "フォロー不要"], "styleOptions": ["すべて", "ショート", "ミディアム", "ロング", "未設定"], "statusMap": { "needFollowUp": "要フォロー", "noNeedFollowUp": "未設定" }, "pagination": { "prev": "前へ", "next": "次へ" } ``` Keep `frontend/public/css/aitori.css` row layout as: ```css .customer-row { min-width: 760px; display: grid; grid-template-columns: 260px 140px minmax(180px, 1fr) 120px; gap: 16px; align-items: center; padding: 16px 0; } ``` - [ ] **Step 8: Run frontend tests to verify they pass** Run: `npm test -- CustomerFormPage.test.tsx CustomerListPage.test.tsx customers.test.ts App.test.tsx` Expected: PASS. - [ ] **Step 9: Commit the frontend redesign** ```bash git add frontend/src/services/api/contracts.ts frontend/src/services/api/customers.ts frontend/src/shared/customerForm.ts frontend/src/pages/dashboard/customers frontend/src/i18n/locales/zh/translation.json frontend/src/i18n/locales/ja/translation.json frontend/public/css/aitori.css git commit -m "feat: rebuild customer frontend around db schema" ``` ### Task 4: Replace Outdated Docs And Perform Final Verification **Files:** - Modify: `backend/docs/数据库设计.md` - Modify: `docs/superpowers/specs/2026-06-28-customer-module-create-list-summary-design.md` only if implementation reveals a spec correction is needed - Modify: `docs/superpowers/plans/2026-06-28-customer-module-create-list-summary.md` only if implementation-driven corrections are needed - Check: `backend/src/modules/customers/**` - Check: `frontend/src/pages/dashboard/customers/**` **Interfaces:** - Consumes: completed backend/frontend implementation from Tasks 1-3 - Produces: final verified branch state with docs that no longer describe the incorrect customer model - [ ] **Step 1: Rewrite `backend/docs/数据库设计.md` so the customer section matches the real implementation** Replace the customer table block so it matches the final implemented structure, including `hairstylist_id`, and remove any wording that suggests the old incorrect model is still relevant. Use this SQL block as the baseline: ```sql CREATE TABLE `customer` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '顾客主键ID', `hairstylist_id` BIGINT UNSIGNED NOT NULL COMMENT '所属发型师ID,JWT 隔离必需字段', `name` VARCHAR(30) NOT NULL COMMENT '顾客姓名(必填)', `name_pronunciation` VARCHAR(50) DEFAULT '' COMMENT '姓名读音', `phone` VARCHAR(20) NOT NULL COMMENT '联系电话(必填)', `email` VARCHAR(100) DEFAULT '' COMMENT '邮箱', `birthday` DATE NULL COMMENT '生日', `avatar` VARCHAR(500) DEFAULT '' COMMENT '顾客头像图片URL地址', `expect_contact` ENUM('LINE','PHONE','EMAIL','NONE') NOT NULL DEFAULT 'NONE' COMMENT '期望联系方式:LINE/电话/邮箱/未设置', `hair_length` ENUM('SHORT','MEDIUM','LONG','UNCONFIRM') NOT NULL DEFAULT 'UNCONFIRM' COMMENT '当前头发长度:短发/中长发/长发/未确认', `hair_texture` ENUM('UNCONFIRM','THIN','NORMAL','HARD_CRUDE','CURLY') NOT NULL DEFAULT 'UNCONFIRM' COMMENT '发质:未确认/细/普通/粗硬/有自然卷', `notice` TEXT NULL COMMENT '发型护理注意事项', `allergy` VARCHAR(500) DEFAULT '' COMMENT '过敏史/过敏产品', `inner_remark` TEXT NULL COMMENT '门店内部备注(仅内部查看)', `next_follow_date` DATE NULL COMMENT '下次跟进日期', `recommend_visit_date` DATE NULL COMMENT '推荐到店日', `first_suggest_hair` ENUM('AUTO','LONG','MEDIUM','SHORT') NOT NULL DEFAULT 'AUTO' COMMENT '首次建议发型模板:自动推荐/长发/中长发/短发', `plan_date` DATE NULL COMMENT '到店计划日', `is_first_register` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否首次登记:0否,1是', `visit_purpose` ENUM('CUT_DYE','CUT','DYE','CONSULT_SIM') NULL COMMENT '到店目的:剪发+染发/剪发/染发/模拟咨询', `trouble` TEXT NULL COMMENT '发质/发型烦恼', `consult_content` TEXT NULL COMMENT '详细咨询内容', `expect_look` TEXT NULL COMMENT '期望发型形象描述', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除 0正常 1删除', PRIMARY KEY (`id`), UNIQUE KEY `uk_phone_del` (`phone`,`is_deleted`) COMMENT '手机号唯一,删除后可重录', KEY `idx_hairstylist_is_deleted` (`hairstylist_id`,`is_deleted`) COMMENT '按发型师隔离顾客数据', KEY `idx_is_deleted` (`is_deleted`) COMMENT '逻辑删除筛选', KEY `idx_next_follow` (`next_follow_date`,`is_deleted`) COMMENT '跟进日期筛选索引', KEY `idx_recommend_visit` (`recommend_visit_date`,`is_deleted`) COMMENT '推荐到店日期索引', KEY `idx_plan_date` (`plan_date`,`is_deleted`) COMMENT '到店计划日筛选索引' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='顾客信息+咨询登记表(合并单表)'; ``` - [ ] **Step 2: Run targeted backend and frontend verification** Run from `backend`: ```bash npm run lint npm test -- app.config.spec.ts customers.service.spec.ts --runInBand npm run test:e2e -- app.e2e-spec.ts ``` Expected: PASS. Run from `frontend`: ```bash npm test -- customers.test.ts CustomerListPage.test.tsx CustomerFormPage.test.tsx App.test.tsx ``` Expected: PASS. - [ ] **Step 3: Run diagnostics on all customer files touched in this redesign** Check diagnostics for: ```text backend/src/modules/customers/customers.service.ts backend/src/modules/customers/customers.controller.ts backend/src/modules/customers/dto/customer-upsert.dto.ts backend/src/modules/customers/dto/list-customers-query.dto.ts frontend/src/shared/customerForm.ts frontend/src/pages/dashboard/customers/CustomerFormPage.tsx frontend/src/pages/dashboard/customers/CustomerListPage.tsx frontend/src/services/api/customers.ts backend/docs/数据库设计.md ``` Expected: no newly introduced blocking diagnostics. - [ ] **Step 4: Manually verify spec coverage before the final commit** Confirm these outcomes: ```text - Customer schema matches 数据库设计 customer block plus hairstylist_id - Customer create uses avatar fallback and JWT hairstylist isolation - Customer list uses plan_date for visit filters - Customer follow state is derived from next_follow_date, not stored as a legacy enum - Customer list no longer offers unsupported 30-day-no-visit semantics - Customer pager always renders - Outdated customer docs are replaced, not preserved alongside the new model ``` Expected: all items confirmed. - [ ] **Step 5: Commit the verified redesign and doc replacement** ```bash git add backend/docs/数据库设计.md docs/superpowers/specs/2026-06-28-customer-module-create-list-summary-design.md docs/superpowers/plans/2026-06-28-customer-module-create-list-summary.md backend/src/modules/customers frontend/src/pages/dashboard/customers frontend/src/services/api frontend/src/shared/customerForm.ts frontend/src/i18n/locales/zh/translation.json frontend/src/i18n/locales/ja/translation.json frontend/public/css/aitori.css git commit -m "feat: redesign customer module around database schema" ```