Răsfoiți Sursa

feat: add customer detail and update apis

ChenYu 4 luni în urmă
părinte
comite
d81af46a0d

+ 23 - 0
backend/src/modules/customers/customers.controller.ts

@@ -2,6 +2,8 @@ import {
   Body,
   Controller,
   Get,
+  Param,
+  Patch,
   Post,
   Query,
   Req,
@@ -38,4 +40,25 @@ export class CustomersController {
   create(@Req() request: AuthenticatedRequest, @Body() body: CustomerUpsertDto) {
     return this.customersService.create(BigInt(request.user.stylistId), body);
   }
+
+  @Get(':id')
+  getById(@Req() request: AuthenticatedRequest, @Param('id') id: string) {
+    return this.customersService.getById(
+      BigInt(request.user.stylistId),
+      BigInt(id),
+    );
+  }
+
+  @Patch(':id')
+  update(
+    @Req() request: AuthenticatedRequest,
+    @Param('id') id: string,
+    @Body() body: CustomerUpsertDto,
+  ) {
+    return this.customersService.update(
+      BigInt(request.user.stylistId),
+      BigInt(id),
+      body,
+    );
+  }
 }

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

@@ -8,9 +8,14 @@ describe('CustomersService', () => {
   const prisma = {
     customer: {
       create: jest.fn(),
+      findFirst: jest.fn(),
       findMany: jest.fn(),
+      update: jest.fn(),
       count: jest.fn(),
     },
+    customerTreatmentRecord: {
+      findMany: jest.fn(),
+    },
     aiHairSimulation: {
       findMany: jest.fn(),
     },
@@ -172,4 +177,150 @@ describe('CustomersService', () => {
       }),
     });
   });
+
+  it('returns customer detail with 未记录 visit frequency when fewer than two treatment records exist', async () => {
+    prisma.customer.findFirst.mockResolvedValue({
+      id: BigInt(8),
+      hairstylistId: BigInt(1),
+      name: 'Sato Misaki',
+      namePronunciation: 'さとう みさき',
+      phone: '090-0000-0000',
+      email: 'misaki@example.com',
+      birthday: new Date('1998-04-10T00:00:00.000Z'),
+      avatar: 'https://example.com/avatar.png',
+      expectContact: 'LINE',
+      hairLength: 'MEDIUM',
+      hairTexture: 'NORMAL',
+      notice: '注意保湿',
+      allergy: '无',
+      innerRemark: '常客',
+      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,
+    });
+    prisma.customerTreatmentRecord.findMany.mockResolvedValue([
+      { treatmentTime: new Date('2026-05-01T00:00:00.000Z') },
+    ]);
+
+    const result = await service.getById(BigInt(1), BigInt(8));
+
+    expect(result).toMatchObject({
+      id: '8',
+      name: 'Sato Misaki',
+      visitFrequencyLabel: '未记录',
+      recommendVisitDate: '2026-07-20',
+      nextFollowDate: '2026-07-12',
+    });
+  });
+
+  it('maps treatment intervals to a visit frequency label', async () => {
+    prisma.customer.findFirst.mockResolvedValue({
+      id: BigInt(8),
+      hairstylistId: BigInt(1),
+      name: 'Sato Misaki',
+      namePronunciation: '',
+      phone: '090-0000-0000',
+      email: '',
+      birthday: null,
+      avatar: 'https://example.com/avatar.png',
+      expectContact: 'LINE',
+      hairLength: 'MEDIUM',
+      hairTexture: 'NORMAL',
+      notice: null,
+      allergy: '',
+      innerRemark: null,
+      nextFollowDate: null,
+      recommendVisitDate: null,
+      firstSuggestHair: 'AUTO',
+      planDate: null,
+      isFirstRegister: 1,
+      visitPurpose: null,
+      trouble: null,
+      consultContent: null,
+      expectLook: null,
+      createTime: new Date('2026-06-28T00:00:00.000Z'),
+      updateTime: new Date('2026-06-28T00:00:00.000Z'),
+      isDeleted: 0,
+    });
+    prisma.customerTreatmentRecord.findMany.mockResolvedValue([
+      { treatmentTime: new Date('2026-01-01T00:00:00.000Z') },
+      { treatmentTime: new Date('2026-03-02T00:00:00.000Z') },
+      { treatmentTime: new Date('2026-05-01T00:00:00.000Z') },
+    ]);
+
+    const result = await service.getById(BigInt(1), BigInt(8));
+
+    expect(result.visitFrequencyLabel).toBe('约 2 个月');
+  });
+
+  it('updates only the current stylist customer', async () => {
+    prisma.customer.findFirst.mockResolvedValueOnce({
+      id: BigInt(8),
+      hairstylistId: BigInt(1),
+      isDeleted: 0,
+    });
+    prisma.customer.update.mockResolvedValue({
+      id: BigInt(8),
+      hairstylistId: BigInt(1),
+      name: 'Sato Misaki Updated',
+      namePronunciation: 'さとう みさき',
+      phone: '090-0000-0001',
+      email: '',
+      birthday: null,
+      avatar: 'https://example.com/avatar.png',
+      expectContact: 'PHONE',
+      hairLength: 'LONG',
+      hairTexture: 'CURLY',
+      notice: null,
+      allergy: '',
+      innerRemark: null,
+      nextFollowDate: null,
+      recommendVisitDate: null,
+      firstSuggestHair: 'AUTO',
+      planDate: null,
+      isFirstRegister: 1,
+      visitPurpose: null,
+      trouble: null,
+      consultContent: null,
+      expectLook: null,
+      createTime: new Date('2026-06-28T00:00:00.000Z'),
+      updateTime: new Date('2026-06-28T00:00:00.000Z'),
+      isDeleted: 0,
+    });
+    prisma.customerTreatmentRecord.findMany.mockResolvedValue([]);
+
+    await service.update(
+      BigInt(1),
+      BigInt(8),
+      {
+        name: 'Sato Misaki Updated',
+        phone: '090-0000-0001',
+        expectContact: 'PHONE',
+        hairLength: 'LONG',
+        hairTexture: 'CURLY',
+      } as never,
+    );
+
+    expect(prisma.customer.update).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: { id: BigInt(8) },
+        data: expect.objectContaining({
+          name: 'Sato Misaki Updated',
+          phone: '090-0000-0001',
+          expectContact: 'PHONE',
+          hairLength: 'LONG',
+          hairTexture: 'CURLY',
+        }),
+      }),
+    );
+  });
 });

+ 118 - 1
backend/src/modules/customers/customers.service.ts

@@ -1,4 +1,4 @@
-import { Injectable } from '@nestjs/common';
+import { Injectable, NotFoundException } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import { Prisma } from '@prisma/client';
 import { PrismaService } from '../../prisma/prisma.service';
@@ -162,6 +162,90 @@ export class CustomersService {
     };
   }
 
+  async getById(
+    stylistId: bigint,
+    customerId: bigint,
+  ): Promise<Record<string, unknown>> {
+    const customer = await this.prisma.customer.findFirst({
+      where: {
+        id: customerId,
+        hairstylistId: stylistId,
+        isDeleted: 0,
+      },
+    });
+
+    if (!customer) {
+      throw new NotFoundException('Customer not found');
+    }
+
+    const visitFrequencyLabel = await this.getVisitFrequencyLabel(
+      stylistId,
+      customerId,
+    );
+
+    return {
+      ...this.toCustomerEntity(customer as unknown as CustomerRecord),
+      visitFrequencyLabel,
+    };
+  }
+
+  async update(
+    stylistId: bigint,
+    customerId: bigint,
+    body: CustomerUpsertDto,
+  ): Promise<Record<string, unknown>> {
+    const existing = await this.prisma.customer.findFirst({
+      where: {
+        id: customerId,
+        hairstylistId: stylistId,
+        isDeleted: 0,
+      },
+      select: { id: true },
+    });
+
+    if (!existing) {
+      throw new NotFoundException('Customer not found');
+    }
+
+    const updated = await this.prisma.customer.update({
+      where: { id: customerId },
+      data: {
+        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),
+        visitPurpose: body.visitPurpose ?? null,
+        trouble: body.trouble?.trim() || null,
+        consultContent: body.consultContent?.trim() || null,
+        expectLook: body.expectLook?.trim() || null,
+      },
+    });
+
+    const visitFrequencyLabel = await this.getVisitFrequencyLabel(
+      stylistId,
+      customerId,
+    );
+
+    return {
+      ...this.toCustomerEntity(updated as unknown as CustomerRecord),
+      visitFrequencyLabel,
+    };
+  }
+
   private buildListWhere(
     stylistId: bigint,
     query: ListCustomersQueryDto,
@@ -252,4 +336,37 @@ export class CustomersService {
       followState: toFollowState(customer.nextFollowDate),
     };
   }
+
+  private async getVisitFrequencyLabel(
+    stylistId: bigint,
+    customerId: bigint,
+  ): Promise<string> {
+    const records = await this.prisma.customerTreatmentRecord.findMany({
+      where: {
+        customerId,
+        hairstylistId: stylistId,
+        isDeleted: 0,
+      },
+      orderBy: { treatmentTime: 'asc' },
+      select: { treatmentTime: true },
+    });
+
+    if (records.length < 2) {
+      return '未记录';
+    }
+
+    const gaps = records.slice(1).map((record, index) => {
+      const previousTime = records[index].treatmentTime.getTime();
+      const currentTime = record.treatmentTime.getTime();
+      return Math.round(
+        (currentTime - previousTime) / (1000 * 60 * 60 * 24),
+      );
+    });
+    const averageGap = gaps.reduce((sum, gap) => sum + gap, 0) / gaps.length;
+
+    if (averageGap <= 45) return '约 1.5 个月';
+    if (averageGap <= 75) return '约 2 个月';
+    if (averageGap <= 105) return '约 3 个月';
+    return '约 3 个月以上';
+  }
 }

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

@@ -244,6 +244,63 @@ describe('App (e2e)', () => {
     });
   });
 
+  it('/api/v1/customers/:id (GET) returns customer detail', async () => {
+    const token = await signToken();
+    const uniqueSuffix = Date.now();
+    const customerName = `e2e-d-${uniqueSuffix}`;
+
+    const created = await request(app.getHttpServer())
+      .post('/api/v1/customers')
+      .set('Authorization', `Bearer ${token}`)
+      .send({
+        name: customerName,
+        phone: `091-${String(uniqueSuffix).slice(-4)}-3333`,
+        recommendVisitDate: '2026-07-22',
+        nextFollowDate: '2026-07-15',
+      })
+      .expect(201);
+
+    const detail = await request(app.getHttpServer())
+      .get(`/api/v1/customers/${created.body.data.id}`)
+      .set('Authorization', `Bearer ${token}`)
+      .expect(200);
+
+    expect(detail.body.ok).toBe(true);
+    expect(detail.body.data.name).toBe(customerName);
+    expect(detail.body.data.visitFrequencyLabel).toBe('未记录');
+  });
+
+  it('/api/v1/customers/:id (PATCH) updates customer detail', async () => {
+    const token = await signToken();
+    const uniqueSuffix = Date.now();
+    const customerName = `e2e-u-${uniqueSuffix}`;
+
+    const created = await request(app.getHttpServer())
+      .post('/api/v1/customers')
+      .set('Authorization', `Bearer ${token}`)
+      .send({
+        name: customerName,
+        phone: `092-${String(uniqueSuffix).slice(-4)}-4444`,
+      })
+      .expect(201);
+
+    const updated = await request(app.getHttpServer())
+      .patch(`/api/v1/customers/${created.body.data.id}`)
+      .set('Authorization', `Bearer ${token}`)
+      .send({
+        name: `${customerName}-updated`,
+        phone: `093-${String(uniqueSuffix).slice(-4)}-5555`,
+        hairLength: 'LONG',
+        hairTexture: 'CURLY',
+      })
+      .expect(200);
+
+    expect(updated.body.ok).toBe(true);
+    expect(updated.body.data.name).toBe(`${customerName}-updated`);
+    expect(updated.body.data.phone).toBe(`093-${String(uniqueSuffix).slice(-4)}-5555`);
+    expect(updated.body.data.hairLength).toBe('LONG');
+  });
+
   it('/api/v1/templates (GET) returns an empty list envelope', async () => {
     const response = await request(app.getHttpServer())
       .get('/api/v1/templates')