2026-06-28-customer-module-create-list-summary.md 45 KB

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:

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:

  CUSTOMER_DEFAULT_AVATAR_URL: z.string().url(),

Update backend/src/config/app.config.ts with:

    customer: {
      defaultAvatarUrl:
        process.env.CUSTOMER_DEFAULT_AVATAR_URL ??
        'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
    },

Update backend/.env.example with:

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:

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:

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

    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<CustomerSummaryDto>
  • 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:

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:

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:

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:

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:

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<string>('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:

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:

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:

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:

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:

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

    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:

it('submits the redesigned customer payload', async () => {
  createCustomer.mockResolvedValue({ id: '1' });

  render(
    <HelmetProvider>
      <MemoryRouter>
        <CustomerFormPage />
      </MemoryRouter>
    </HelmetProvider>,
  );

  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:

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:

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:

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<PaginatedCustomerList> {
  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<PaginatedCustomerList>(`/customers${qs ? `?${qs}` : ''}`)
}
  • Step 4: Rewrite the shared customer form mapping around the new payload

Update frontend/src/shared/customerForm.ts with:

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:

const handleSubmit: FormEventHandler<HTMLFormElement> = 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:

const VISIT_STATUS_API_MAP: Record<string, string | undefined> = {
  全部: undefined,
  今天到店: 'TODAY',
  本周到店: 'THIS_WEEK',
  全て: undefined,
  本日来店: 'TODAY',
  今週来店: 'THIS_WEEK',
}

const FOLLOW_STATUS_API_MAP: Record<string, string | undefined> = {
  全部: undefined,
  需跟进: 'NEED_FOLLOW_UP',
  无需跟进: 'NO_NEED_FOLLOW_UP',
  全て: undefined,
  要フォロー: 'NEED_FOLLOW_UP',
  フォロー不要: 'NO_NEED_FOLLOW_UP',
}

const HAIR_LENGTH_API_MAP: Record<string, string | undefined> = {
  全部: undefined,
  短发: 'SHORT',
  中长发: 'MEDIUM',
  长发: 'LONG',
  未确认: 'UNCONFIRM',
  全て: undefined,
  ショート: 'SHORT',
  ミディアム: 'MEDIUM',
  ロング: 'LONG',
  未設定: 'UNCONFIRM',
}

Render list rows with:

<span>{customer.planDate || '—'}</span>
<span>{customer.trouble || '—'}</span>
<span>
  <span className="workbench-badge">
    {customer.followState === 'NEED_FOLLOW_UP'
      ? pageCopy.statusMap.needFollowUp
      : pageCopy.statusMap.noNeedFollowUp}
  </span>
</span>

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:

"followOptions": ["全部", "需跟进", "无需跟进"],
"styleOptions": ["全部", "短发", "中长发", "长发", "未确认"],
"statusMap": { "needFollowUp": "需跟进", "noNeedFollowUp": "待设置" },
"pagination": { "prev": "上一页", "next": "下一页" }

Update frontend/src/i18n/locales/ja/translation.json with:

"followOptions": ["すべて", "要フォロー", "フォロー不要"],
"styleOptions": ["すべて", "ショート", "ミディアム", "ロング", "未設定"],
"statusMap": { "needFollowUp": "要フォロー", "noNeedFollowUp": "未設定" },
"pagination": { "prev": "前へ", "next": "次へ" }

Keep frontend/public/css/aitori.css row layout as:

.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

    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:

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:

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:

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:

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:

- 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

    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"