import { Injectable, NotFoundException } 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, parseDateOnly, toFollowState, } from './customers.mapper'; type CustomerRecord = { id: bigint; name: string; namePronunciation: string; phone: string; email: string; birthday: Date | null; avatar: string; expectContact: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE'; hairLength: 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM'; hairTexture: 'UNCONFIRM' | 'THIN' | 'NORMAL' | 'HARD_CRUDE' | 'CURLY'; notice: string | null; allergy: string; innerRemark: string | null; followContent: string | null; nextFollowDate: Date | null; recommendVisitDate: Date | null; firstSuggestHair: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT'; planDate: Date | null; isFirstRegister: number; visitPurpose: 'CUT_DYE' | 'CUT' | 'DYE' | 'CONSULT_SIM' | null; trouble: string | null; consultContent: string | null; expectLook: string | null; }; @Injectable() export class CustomersService { constructor( private readonly prisma: PrismaService, private readonly configService: ConfigService, ) {} async list(stylistId: bigint, query: ListCustomersQueryDto): Promise<{ items: Array>; 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: items.map((item) => this.toCustomerEntity(item as unknown as CustomerRecord)), total, page, pageSize, }; } async create( stylistId: bigint, body: CustomerUpsertDto, ): Promise> { 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, followContent: body.followContent?.trim() || null, nextFollowDate: parseDateOnly(body.nextFollowDate), recommendVisitDate: parseDateOnly(body.recommendVisitDate), firstSuggestHair: body.firstSuggestHair ?? 'AUTO', planDate: parseDateOnly(body.planDate), isFirstRegister: 1, visitPurpose: body.visitPurpose ?? null, trouble: body.trouble?.trim() || null, consultContent: body.consultContent?.trim() || null, expectLook: body.expectLook?.trim() || null, }, }); return this.toCustomerEntity(created as unknown as CustomerRecord); } async getSummary(stylistId: bigint): Promise { 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, 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 }, }), ]); return { total, today: todayCount, followUp, simulated: new Set( simulations .map((item) => item.customerId) .filter((value): value is bigint => value != null), ).size, }; } async getById( stylistId: bigint, customerId: bigint, ): Promise> { 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> { const existing = await this.prisma.customer.findFirst({ where: { id: customerId, hairstylistId: stylistId, isDeleted: 0, }, select: { id: true, avatar: 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() || existing.avatar || 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, followContent: body.followContent?.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, ): Prisma.CustomerWhereInput { const where: Prisma.CustomerWhereInput = { hairstylistId: stylistId, isDeleted: 0, }; const searchTerm = query.q?.trim(); if (searchTerm) { where.OR = [ { name: { contains: searchTerm } }, { phone: { contains: searchTerm } }, { innerRemark: { contains: searchTerm } }, { trouble: { contains: searchTerm } }, { consultContent: { contains: searchTerm } }, ]; } 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') { const noNeedFollowUp: Prisma.CustomerWhereInput = { OR: [{ nextFollowDate: null }, { nextFollowDate: { gt: startOfToday } }], }; if (where.OR) { const searchConditions = where.OR; delete where.OR; where.AND = [{ OR: searchConditions }, noNeedFollowUp]; } else { where.OR = noNeedFollowUp.OR; } } return where; } private toCustomerEntity(customer: CustomerRecord) { 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, followContent: customer.followContent ?? 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), }; } private async getVisitFrequencyLabel( stylistId: bigint, customerId: bigint, ): Promise { 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 个月以上'; } }