|
@@ -1,16 +1,58 @@
|
|
|
import { Injectable, NotImplementedException } from '@nestjs/common';
|
|
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 { CustomerUpsertDto } from './dto/customer-upsert.dto';
|
|
|
|
|
+import { CustomerSummaryDto } from './dto/customer-summary.dto';
|
|
|
import { ListCustomersQueryDto } from './dto/list-customers-query.dto';
|
|
import { ListCustomersQueryDto } from './dto/list-customers-query.dto';
|
|
|
|
|
+import {
|
|
|
|
|
+ formatDateOnly,
|
|
|
|
|
+ normalizeContactType,
|
|
|
|
|
+ normalizeFollowStatus,
|
|
|
|
|
+ normalizeHairLength,
|
|
|
|
|
+ normalizeLineStatus,
|
|
|
|
|
+ parseDateOnly,
|
|
|
|
|
+ toFrontendHairLength,
|
|
|
|
|
+} from './customers.mapper';
|
|
|
|
|
|
|
|
@Injectable()
|
|
@Injectable()
|
|
|
export class CustomersService {
|
|
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;
|
|
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 {
|
|
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');
|
|
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> {
|
|
async update(_id: string, _body: CustomerUpsertDto): Promise<never> {
|
|
|
throw new NotImplementedException('Customer update is not implemented yet');
|
|
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,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|