| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377 |
- 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<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: items.map((item) => this.toCustomerEntity(item as unknown as CustomerRecord)),
- total,
- page,
- pageSize,
- };
- }
- async create(
- stylistId: bigint,
- body: CustomerUpsertDto,
- ): Promise<Record<string, unknown>> {
- 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,
- 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<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,
- 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<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, 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<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,
- 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<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 个月以上';
- }
- }
|