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 { 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)), total, page, pageSize, }; } async getById(_id: string): Promise { throw new NotImplementedException('Customer detail is not implemented yet'); } async create( stylistId: bigint, body: CustomerUpsertDto, ): Promise> { const avatarUrl = body.image?.trim() || this.configService.getOrThrow('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 { throw new NotImplementedException('Customer update is not implemented yet'); } 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, 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, }; } }