Переглянути джерело

feat: implement customer create list summary backend

ChenYu 4 місяців тому
батько
коміт
c40c752ba1

+ 6 - 1
backend/src/config/env.schema.ts

@@ -14,7 +14,12 @@ export const envSchema = z.object({
   GCS_PROJECT_ID: z.string().min(1),
   GCS_CLIENT_EMAIL: z.string().email(),
   GCS_PRIVATE_KEY: z.string().min(1),
-  CUSTOMER_DEFAULT_AVATAR_URL: z.string().url(),
+  CUSTOMER_DEFAULT_AVATAR_URL: z
+    .string()
+    .url()
+    .default(
+      'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
+    ),
 });
 
 export function validateEnv(config: Record<string, unknown>) {

+ 3 - 0
backend/src/modules/customers/customers.constants.ts

@@ -0,0 +1,3 @@
+export const CUSTOMER_DEFAULT_PAGE = 1;
+export const CUSTOMER_DEFAULT_PAGE_SIZE = 20;
+export const CUSTOMER_MAX_PAGE_SIZE = 100;

+ 17 - 4
backend/src/modules/customers/customers.controller.ts

@@ -6,21 +6,34 @@ import {
   Patch,
   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: number;
+  };
+};
+
 @UseGuards(JwtAuthGuard)
 @Controller('customers')
 export class CustomersController {
   constructor(private readonly customersService: CustomersService) {}
 
   @Get()
-  list(@Query() query: ListCustomersQueryDto) {
-    return this.customersService.list(query);
+  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));
   }
 
   @Get(':id')
@@ -29,8 +42,8 @@ export class CustomersController {
   }
 
   @Post()
-  create(@Body() body: CustomerUpsertDto) {
-    return this.customersService.create(body);
+  create(@Req() request: AuthenticatedRequest, @Body() body: CustomerUpsertDto) {
+    return this.customersService.create(BigInt(request.user.stylistId), body);
   }
 
   @Patch(':id')

+ 62 - 0
backend/src/modules/customers/customers.mapper.ts

@@ -0,0 +1,62 @@
+const HAIR_LENGTH_MAP: Record<string, 'SHORT' | 'MEDIUM' | 'LONG'> = {
+  Short: 'SHORT',
+  Medium: 'MEDIUM',
+  Long: 'LONG',
+};
+
+const CONTACT_TYPE_MAP: Record<string, 'LINE' | 'PHONE' | 'EMAIL'> = {
+  LINE: 'LINE',
+  Phone: 'PHONE',
+  Email: 'EMAIL',
+};
+
+const LINE_STATUS_MAP: Record<string, 'LINKED' | 'UNLINKED' | 'INVITED' | 'KEEP'> = {
+  已关联: 'LINKED',
+  已連携: 'LINKED',
+  未关联: 'UNLINKED',
+  未連携: 'UNLINKED',
+  邀请已发送: 'INVITED',
+  保留: 'KEEP',
+};
+
+const FOLLOW_STATUS_MAP: Record<string, 'NONE' | 'NEED_FOLLOW_UP' | 'SENT'> = {
+  '': 'NONE',
+  NONE: 'NONE',
+  NEED_FOLLOW_UP: 'NEED_FOLLOW_UP',
+  SENT: 'SENT',
+  需跟进: 'NEED_FOLLOW_UP',
+  已发送: 'SENT',
+  发送计划: 'NEED_FOLLOW_UP',
+};
+
+export function normalizeHairLength(value?: string) {
+  return value ? HAIR_LENGTH_MAP[value] ?? null : null;
+}
+
+export function normalizeContactType(value?: string) {
+  return value ? CONTACT_TYPE_MAP[value] ?? 'LINE' : 'LINE';
+}
+
+export function normalizeLineStatus(value?: string) {
+  return value ? LINE_STATUS_MAP[value] ?? null : null;
+}
+
+export function normalizeFollowStatus(value?: string) {
+  return value == null ? 'NONE' : FOLLOW_STATUS_MAP[value] ?? 'NONE';
+}
+
+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 toFrontendHairLength(value: string | null) {
+  if (!value) {
+    return undefined;
+  }
+
+  return value[0] + value.slice(1).toLowerCase();
+}

+ 3 - 1
backend/src/modules/customers/customers.module.ts

@@ -1,10 +1,12 @@
 import { Module } from '@nestjs/common';
+import { ConfigModule } from '@nestjs/config';
+import { PrismaModule } from '../../prisma/prisma.module';
 import { AuthModule } from '../auth/auth.module';
 import { CustomersController } from './customers.controller';
 import { CustomersService } from './customers.service';
 
 @Module({
-  imports: [AuthModule],
+  imports: [AuthModule, PrismaModule, ConfigModule],
   controllers: [CustomersController],
   providers: [CustomersService],
 })

+ 141 - 0
backend/src/modules/customers/customers.service.spec.ts

@@ -0,0 +1,141 @@
+import { ConfigService } from '@nestjs/config';
+import { Test } from '@nestjs/testing';
+import { PrismaService } from '../../prisma/prisma.service';
+import { CustomersService } from './customers.service';
+
+describe('CustomersService', () => {
+  let service: CustomersService;
+  const prisma = {
+    customer: {
+      create: jest.fn(),
+      findMany: jest.fn(),
+      count: jest.fn(),
+    },
+    aiHairSimulation: {
+      findMany: jest.fn(),
+    },
+  };
+
+  beforeEach(async () => {
+    jest.resetAllMocks();
+
+    const moduleRef = await Test.createTestingModule({
+      providers: [
+        CustomersService,
+        { provide: PrismaService, useValue: prisma },
+        {
+          provide: ConfigService,
+          useValue: {
+            getOrThrow: jest.fn((key: string) => {
+              if (key === 'customer.defaultAvatarUrl') {
+                return 'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png';
+              }
+
+              throw new Error(`Unexpected config key: ${key}`);
+            }),
+          },
+        },
+      ],
+    }).compile();
+
+    service = moduleRef.get(CustomersService);
+  });
+
+  it('uses the default avatar url when image is omitted', async () => {
+    prisma.customer.create.mockResolvedValue({
+      id: BigInt(11),
+      hairstylistId: BigInt(1),
+      name: 'Sato Misaki',
+      kana: '',
+      phone: '090-0000-0000',
+      email: '',
+      birthday: null,
+      avatarUrl:
+        'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
+      contactType: 'LINE',
+      lineStatus: null,
+      lastVisitDate: null,
+      nextVisitDate: null,
+      visitCycle: '',
+      hairLength: 'MEDIUM',
+      hairType: '',
+      hairCondition: '',
+      concern: null,
+      desiredImage: null,
+      formulaNote: null,
+      allergyNote: null,
+      privateNote: null,
+      followStatus: 'NONE',
+      followTiming: '',
+      followMessage: null,
+      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',
+      phone: '090-0000-0000',
+      hairLength: 'Medium',
+      contact: 'LINE',
+    });
+
+    expect(prisma.customer.create).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({
+          hairstylistId: BigInt(1),
+          avatarUrl:
+            'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
+        }),
+      }),
+    );
+  });
+
+  it('maps list filters into prisma query conditions', async () => {
+    prisma.customer.findMany.mockResolvedValue([]);
+    prisma.customer.count.mockResolvedValue(0);
+
+    await service.list(BigInt(1), {
+      page: 2,
+      pageSize: 20,
+      q: '090',
+      visitStatus: 'TODAY',
+      followStatus: 'NEED_FOLLOW_UP',
+      hairLength: 'LONG',
+    });
+
+    expect(prisma.customer.findMany).toHaveBeenCalledWith(
+      expect.objectContaining({
+        skip: 20,
+        take: 20,
+        where: expect.objectContaining({
+          hairstylistId: BigInt(1),
+          isDeleted: 0,
+          followStatus: 'NEED_FOLLOW_UP',
+          hairLength: 'LONG',
+        }),
+      }),
+    );
+  });
+
+  it('counts summary values from customer and ai simulation data', 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,
+    });
+  });
+});

+ 245 - 6
backend/src/modules/customers/customers.service.ts

@@ -1,16 +1,58 @@
 import { Injectable, NotImplementedException } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { Prisma } from '@prisma/client';
+import { PrismaService } from '../../prisma/prisma.service';
+import {
+  CUSTOMER_DEFAULT_PAGE,
+  CUSTOMER_DEFAULT_PAGE_SIZE,
+  CUSTOMER_MAX_PAGE_SIZE,
+} from './customers.constants';
 import { CustomerUpsertDto } from './dto/customer-upsert.dto';
+import { CustomerSummaryDto } from './dto/customer-summary.dto';
 import { ListCustomersQueryDto } from './dto/list-customers-query.dto';
+import {
+  formatDateOnly,
+  normalizeContactType,
+  normalizeFollowStatus,
+  normalizeHairLength,
+  normalizeLineStatus,
+  parseDateOnly,
+  toFrontendHairLength,
+} from './customers.mapper';
 
 @Injectable()
 export class CustomersService {
-  async list(_query: ListCustomersQueryDto): Promise<{
-    items: never[];
+  constructor(
+    private readonly prisma: PrismaService,
+    private readonly configService: ConfigService,
+  ) {}
+
+  async list(stylistId: bigint, query: ListCustomersQueryDto): Promise<{
+    items: Array<Record<string, unknown>>;
     total: number;
+    page: number;
+    pageSize: number;
   }> {
+    const page = query.page ?? CUSTOMER_DEFAULT_PAGE;
+    const pageSize = Math.min(
+      query.pageSize ?? CUSTOMER_DEFAULT_PAGE_SIZE,
+      CUSTOMER_MAX_PAGE_SIZE,
+    );
+    const skip = (page - 1) * pageSize;
+    const where = this.buildListWhere(stylistId, query);
+    const items = await this.prisma.customer.findMany({
+      where,
+      orderBy: { createTime: 'desc' },
+      skip,
+      take: pageSize,
+    });
+    const total = await this.prisma.customer.count({ where });
+
     return {
-      items: [],
-      total: 0,
+      items: items.map((item) => this.toCustomerEntity(item)),
+      total,
+      page,
+      pageSize,
     };
   }
 
@@ -18,11 +60,208 @@ export class CustomersService {
     throw new NotImplementedException('Customer detail is not implemented yet');
   }
 
-  async create(_body: CustomerUpsertDto): Promise<never> {
-    throw new NotImplementedException('Customer creation is not implemented yet');
+  async create(
+    stylistId: bigint,
+    body: CustomerUpsertDto,
+  ): Promise<Record<string, unknown>> {
+    const avatarUrl =
+      body.image?.trim() ||
+      this.configService.getOrThrow<string>('customer.defaultAvatarUrl');
+
+    const created = await this.prisma.customer.create({
+      data: {
+        hairstylistId: stylistId,
+        name: body.name.trim(),
+        kana: body.kana?.trim() ?? '',
+        phone: body.phone.trim(),
+        email: body.email?.trim() ?? '',
+        birthday: parseDateOnly(body.birthday),
+        avatarUrl,
+        contactType: normalizeContactType(body.contact),
+        lineStatus: normalizeLineStatus(body.lineStatus),
+        lastVisitDate: parseDateOnly(body.lastVisit),
+        nextVisitDate: parseDateOnly(body.nextVisit),
+        visitCycle: body.visitCycle?.trim() ?? '',
+        hairLength: normalizeHairLength(body.hairLength),
+        hairType: body.hairType?.trim() ?? '',
+        hairCondition: body.hairCondition?.trim() ?? '',
+        concern: body.concern?.trim() || null,
+        desiredImage: body.desiredImage?.trim() || null,
+        formulaNote: body.formulaNote?.trim() || null,
+        allergyNote: body.allergyNote?.trim() || null,
+        privateNote: body.privateNote?.trim() || null,
+        followStatus: normalizeFollowStatus(body.followStatus),
+        followTiming: body.followTiming?.trim() ?? '',
+        followMessage: body.followMessage?.trim() || null,
+      },
+    });
+
+    return this.toCustomerEntity(created);
   }
 
   async update(_id: string, _body: CustomerUpsertDto): Promise<never> {
     throw new NotImplementedException('Customer update is not implemented yet');
   }
+
+  async getSummary(stylistId: bigint): Promise<CustomerSummaryDto> {
+    const today = new Date();
+    const startOfDay = new Date(
+      Date.UTC(
+        today.getUTCFullYear(),
+        today.getUTCMonth(),
+        today.getUTCDate(),
+      ),
+    );
+    const endOfDay = new Date(
+      Date.UTC(
+        today.getUTCFullYear(),
+        today.getUTCMonth(),
+        today.getUTCDate() + 1,
+      ),
+    );
+
+    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,
+          nextVisitDate: { gte: startOfDay, lt: endOfDay },
+        },
+      }),
+      this.prisma.customer.count({
+        where: {
+          hairstylistId: stylistId,
+          isDeleted: 0,
+          followStatus: 'NEED_FOLLOW_UP',
+        },
+      }),
+      this.prisma.aiHairSimulation.findMany({
+        where: {
+          hairstylistId: stylistId,
+          isDeleted: 0,
+          customerId: { not: null },
+        },
+        select: { customerId: true },
+      }),
+    ]);
+
+    return {
+      total,
+      today: todayCount,
+      followUp,
+      simulated: new Set(
+        simulations
+          .map((item) => item.customerId)
+          .filter((value): value is bigint => value != null),
+      ).size,
+    };
+  }
+
+  private buildListWhere(
+    stylistId: bigint,
+    query: ListCustomersQueryDto,
+  ): Prisma.CustomerWhereInput {
+    const where: Prisma.CustomerWhereInput = {
+      hairstylistId: stylistId,
+      isDeleted: 0,
+    };
+
+    if (query.q?.trim()) {
+      where.OR = [
+        { name: { contains: query.q.trim() } },
+        { phone: { contains: query.q.trim() } },
+        { concern: { contains: query.q.trim() } },
+        { privateNote: { contains: query.q.trim() } },
+      ];
+    }
+
+    if (query.followStatus && query.followStatus !== 'ALL') {
+      where.followStatus = query.followStatus;
+    }
+
+    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);
+    const thirtyDaysAgo = new Date(startOfToday);
+    thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30);
+
+    if (query.visitStatus === 'TODAY') {
+      where.nextVisitDate = { gte: startOfToday, lt: endOfToday };
+    } else if (query.visitStatus === 'THIS_WEEK') {
+      where.nextVisitDate = { gte: startOfWeek, lt: endOfWeek };
+    } else if (query.visitStatus === 'OVER_30_DAYS_NO_VISIT') {
+      where.lastVisitDate = { lt: thirtyDaysAgo };
+    }
+
+    return where;
+  }
+
+  private toCustomerEntity(customer: {
+    id: bigint;
+    name: string;
+    kana: string;
+    phone: string;
+    email: string;
+    birthday: Date | null;
+    avatarUrl: string;
+    contactType: string;
+    lineStatus: string | null;
+    lastVisitDate: Date | null;
+    nextVisitDate: Date | null;
+    visitCycle: string;
+    hairLength: string | null;
+    hairType: string;
+    hairCondition: string;
+    concern: string | null;
+    desiredImage: string | null;
+    formulaNote: string | null;
+    allergyNote: string | null;
+    privateNote: string | null;
+    followStatus: string;
+    followTiming: string;
+    followMessage: string | null;
+  }) {
+    return {
+      id: customer.id.toString(),
+      name: customer.name,
+      kana: customer.kana || undefined,
+      phone: customer.phone,
+      email: customer.email || undefined,
+      birthday: formatDateOnly(customer.birthday),
+      image: customer.avatarUrl,
+      contact: customer.contactType,
+      lineStatus: customer.lineStatus ?? undefined,
+      lastVisit: formatDateOnly(customer.lastVisitDate),
+      nextVisit: formatDateOnly(customer.nextVisitDate),
+      visitCycle: customer.visitCycle || undefined,
+      hairLength: toFrontendHairLength(customer.hairLength),
+      hairType: customer.hairType || undefined,
+      hairCondition: customer.hairCondition || undefined,
+      concern: customer.concern ?? undefined,
+      desiredImage: customer.desiredImage ?? undefined,
+      formulaNote: customer.formulaNote ?? undefined,
+      allergyNote: customer.allergyNote ?? undefined,
+      privateNote: customer.privateNote ?? undefined,
+      followStatus: customer.followStatus,
+      followTiming: customer.followTiming || undefined,
+      followMessage: customer.followMessage ?? undefined,
+    };
+  }
 }

+ 6 - 0
backend/src/modules/customers/dto/customer-summary.dto.ts

@@ -0,0 +1,6 @@
+export class CustomerSummaryDto {
+  total!: number;
+  today!: number;
+  followUp!: number;
+  simulated!: number;
+}

+ 68 - 4
backend/src/modules/customers/dto/customer-upsert.dto.ts

@@ -1,4 +1,4 @@
-import { IsArray, IsEmail, IsOptional, IsString } from 'class-validator';
+import { IsEmail, IsOptional, IsString } from 'class-validator';
 
 export class CustomerUpsertDto {
   @IsString()
@@ -17,9 +17,73 @@ export class CustomerUpsertDto {
 
   @IsOptional()
   @IsString()
-  summary?: string;
+  birthday?: string;
 
   @IsOptional()
-  @IsArray()
-  tags?: string[];
+  @IsString()
+  image?: string;
+
+  @IsOptional()
+  @IsString()
+  contact?: string;
+
+  @IsOptional()
+  @IsString()
+  lineStatus?: string;
+
+  @IsOptional()
+  @IsString()
+  lastVisit?: string;
+
+  @IsOptional()
+  @IsString()
+  nextVisit?: string;
+
+  @IsOptional()
+  @IsString()
+  visitCycle?: string;
+
+  @IsOptional()
+  @IsString()
+  hairLength?: string;
+
+  @IsOptional()
+  @IsString()
+  hairType?: string;
+
+  @IsOptional()
+  @IsString()
+  hairCondition?: string;
+
+  @IsOptional()
+  @IsString()
+  concern?: string;
+
+  @IsOptional()
+  @IsString()
+  desiredImage?: string;
+
+  @IsOptional()
+  @IsString()
+  formulaNote?: string;
+
+  @IsOptional()
+  @IsString()
+  allergyNote?: string;
+
+  @IsOptional()
+  @IsString()
+  privateNote?: string;
+
+  @IsOptional()
+  @IsString()
+  followStatus?: string;
+
+  @IsOptional()
+  @IsString()
+  followTiming?: string;
+
+  @IsOptional()
+  @IsString()
+  followMessage?: string;
 }

+ 25 - 3
backend/src/modules/customers/dto/list-customers-query.dto.ts

@@ -1,11 +1,33 @@
-import { IsOptional, IsString } from 'class-validator';
+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()
-  @IsString()
-  style?: string;
+  @IsIn(['ALL', 'TODAY', 'THIS_WEEK', 'OVER_30_DAYS_NO_VISIT'])
+  visitStatus?: 'ALL' | 'TODAY' | 'THIS_WEEK' | 'OVER_30_DAYS_NO_VISIT';
+
+  @IsOptional()
+  @IsIn(['ALL', 'NEED_FOLLOW_UP', 'SENT'])
+  followStatus?: 'ALL' | 'NEED_FOLLOW_UP' | 'SENT';
+
+  @IsOptional()
+  @IsIn(['ALL', 'SHORT', 'MEDIUM', 'LONG'])
+  hairLength?: 'ALL' | 'SHORT' | 'MEDIUM' | 'LONG';
 }

+ 125 - 0
backend/test/app.e2e-spec.ts

@@ -1,15 +1,27 @@
 import { INestApplication } from '@nestjs/common';
+import { JwtService } from '@nestjs/jwt';
 import { Test } from '@nestjs/testing';
 import * as request from 'supertest';
 import { AppModule } from '../src/app.module';
 import { configureApp } from '../src/app.setup';
+import { PrismaService } from '../src/prisma/prisma.service';
 import { GcsStorageDriver } from '../src/modules/storage/gcs-storage.driver';
 import { STORAGE_DRIVER } from '../src/modules/storage/storage.constants';
 
 describe('App (e2e)', () => {
   let app: INestApplication;
+  let jwtService: JwtService;
+  let prisma: PrismaService;
   const upload = jest.fn();
 
+  async function signToken() {
+    return jwtService.signAsync({
+      sub: 1,
+      email: 'stylist@example.com',
+      username: 'Ogawa',
+    });
+  }
+
   beforeAll(async () => {
     upload.mockReset();
     upload.mockResolvedValue(undefined);
@@ -26,9 +38,25 @@ describe('App (e2e)', () => {
     app = moduleRef.createNestApplication();
     configureApp(app);
     await app.init();
+    jwtService = app.get(JwtService);
+    prisma = app.get(PrismaService);
   });
 
   afterAll(async () => {
+    await prisma.aiHairSimulation.deleteMany({
+      where: {
+        simulateRemark: {
+          startsWith: 'app-e2e-customer-summary-',
+        },
+      },
+    });
+    await prisma.customer.deleteMany({
+      where: {
+        name: {
+          startsWith: 'app-e2e-customer-',
+        },
+      },
+    });
     await app.close();
   });
 
@@ -104,6 +132,103 @@ describe('App (e2e)', () => {
     });
   });
 
+  it('/api/v1/customers (POST) creates a customer with default avatar', async () => {
+    const token = await signToken();
+    const uniqueSuffix = Date.now();
+
+    const response = await request(app.getHttpServer())
+      .post('/api/v1/customers')
+      .set('Authorization', token)
+      .send({
+        name: `app-e2e-customer-${uniqueSuffix}`,
+        phone: `090-${String(uniqueSuffix).slice(-4)}-0000`,
+        hairLength: 'Medium',
+        followStatus: 'NEED_FOLLOW_UP',
+        nextVisit: '2026-06-28',
+      })
+      .expect(201);
+
+    expect(response.body.ok).toBe(true);
+    expect(response.body.data.name).toBe(`app-e2e-customer-${uniqueSuffix}`);
+    expect(response.body.data.image).toBe(
+      'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
+    );
+  });
+
+  it('/api/v1/customers (GET) returns paginated customers', async () => {
+    const token = await signToken();
+    const uniqueSuffix = `${Date.now()}-list`;
+
+    await prisma.customer.create({
+      data: {
+        hairstylistId: BigInt(1),
+        name: `app-e2e-customer-${uniqueSuffix}`,
+        kana: '',
+        phone: `090-${String(Date.now()).slice(-4)}-1111`,
+        email: '',
+        avatarUrl:
+          'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
+        contactType: 'LINE',
+        hairLength: 'MEDIUM',
+        followStatus: 'NEED_FOLLOW_UP',
+      },
+    });
+
+    const response = await request(app.getHttpServer())
+      .get('/api/v1/customers?page=1&pageSize=20&q=app-e2e-customer-&hairLength=MEDIUM&followStatus=NEED_FOLLOW_UP')
+      .set('Authorization', token)
+      .expect(200);
+
+    expect(response.body.ok).toBe(true);
+    expect(response.body.data).toEqual({
+      items: expect.any(Array),
+      total: expect.any(Number),
+      page: 1,
+      pageSize: 20,
+    });
+  });
+
+  it('/api/v1/customers/summary (GET) returns customer summary', async () => {
+    const token = await signToken();
+    const uniqueSuffix = `${Date.now()}-summary`;
+    const customer = await prisma.customer.create({
+      data: {
+        hairstylistId: BigInt(1),
+        name: `app-e2e-customer-${uniqueSuffix}`,
+        kana: '',
+        phone: `090-${String(Date.now()).slice(-4)}-2222`,
+        email: '',
+        avatarUrl:
+          'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
+        contactType: 'LINE',
+        nextVisitDate: new Date(),
+        followStatus: 'NEED_FOLLOW_UP',
+      },
+    });
+
+    await prisma.aiHairSimulation.create({
+      data: {
+        customerId: customer.id,
+        hairstylistId: BigInt(1),
+        originImage: customer.avatarUrl,
+        simulateRemark: `app-e2e-customer-summary-${uniqueSuffix}`,
+      },
+    });
+
+    const response = await request(app.getHttpServer())
+      .get('/api/v1/customers/summary')
+      .set('Authorization', token)
+      .expect(200);
+
+    expect(response.body.ok).toBe(true);
+    expect(response.body.data).toEqual({
+      total: expect.any(Number),
+      today: expect.any(Number),
+      followUp: expect.any(Number),
+      simulated: expect.any(Number),
+    });
+  });
+
   it('/api/v1/templates (GET) returns an empty list envelope', async () => {
     const response = await request(app.getHttpServer())
       .get('/api/v1/templates')

+ 2 - 0
backend/test/auth.e2e-spec.ts

@@ -77,6 +77,8 @@ describe('Auth (e2e)', () => {
       data: {
         items: [],
         total: 0,
+        page: 1,
+        pageSize: 20,
       },
       error: null,
     });