customers.service.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. import { Injectable, NotFoundException } from '@nestjs/common';
  2. import { ConfigService } from '@nestjs/config';
  3. import { Prisma } from '@prisma/client';
  4. import { PrismaService } from '../../prisma/prisma.service';
  5. import {
  6. CUSTOMER_DEFAULT_PAGE,
  7. CUSTOMER_DEFAULT_PAGE_SIZE,
  8. CUSTOMER_MAX_PAGE_SIZE,
  9. } from './customers.constants';
  10. import { CustomerUpsertDto } from './dto/customer-upsert.dto';
  11. import { CustomerSummaryDto } from './dto/customer-summary.dto';
  12. import { ListCustomersQueryDto } from './dto/list-customers-query.dto';
  13. import {
  14. formatDateOnly,
  15. parseDateOnly,
  16. toFollowState,
  17. } from './customers.mapper';
  18. type CustomerRecord = {
  19. id: bigint;
  20. name: string;
  21. namePronunciation: string;
  22. phone: string;
  23. email: string;
  24. birthday: Date | null;
  25. avatar: string;
  26. expectContact: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE';
  27. hairLength: 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM';
  28. hairTexture: 'UNCONFIRM' | 'THIN' | 'NORMAL' | 'HARD_CRUDE' | 'CURLY';
  29. notice: string | null;
  30. allergy: string;
  31. innerRemark: string | null;
  32. followContent: string | null;
  33. nextFollowDate: Date | null;
  34. recommendVisitDate: Date | null;
  35. firstSuggestHair: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT';
  36. planDate: Date | null;
  37. isFirstRegister: number;
  38. visitPurpose: 'CUT_DYE' | 'CUT' | 'DYE' | 'CONSULT_SIM' | null;
  39. trouble: string | null;
  40. consultContent: string | null;
  41. expectLook: string | null;
  42. };
  43. @Injectable()
  44. export class CustomersService {
  45. constructor(
  46. private readonly prisma: PrismaService,
  47. private readonly configService: ConfigService,
  48. ) {}
  49. async list(stylistId: bigint, query: ListCustomersQueryDto): Promise<{
  50. items: Array<Record<string, unknown>>;
  51. total: number;
  52. page: number;
  53. pageSize: number;
  54. }> {
  55. const page = query.page ?? CUSTOMER_DEFAULT_PAGE;
  56. const pageSize = Math.min(
  57. query.pageSize ?? CUSTOMER_DEFAULT_PAGE_SIZE,
  58. CUSTOMER_MAX_PAGE_SIZE,
  59. );
  60. const skip = (page - 1) * pageSize;
  61. const where = this.buildListWhere(stylistId, query);
  62. const items = await this.prisma.customer.findMany({
  63. where,
  64. orderBy: { createTime: 'desc' },
  65. skip,
  66. take: pageSize,
  67. });
  68. const total = await this.prisma.customer.count({ where });
  69. return {
  70. items: items.map((item) => this.toCustomerEntity(item as unknown as CustomerRecord)),
  71. total,
  72. page,
  73. pageSize,
  74. };
  75. }
  76. async create(
  77. stylistId: bigint,
  78. body: CustomerUpsertDto,
  79. ): Promise<Record<string, unknown>> {
  80. const created = await this.prisma.customer.create({
  81. data: {
  82. hairstylistId: stylistId,
  83. name: body.name.trim(),
  84. namePronunciation: body.namePronunciation?.trim() ?? '',
  85. phone: body.phone.trim(),
  86. email: body.email?.trim() ?? '',
  87. birthday: parseDateOnly(body.birthday),
  88. avatar:
  89. body.avatar?.trim() ||
  90. this.configService.getOrThrow<string>('customer.defaultAvatarUrl'),
  91. expectContact: body.expectContact ?? 'NONE',
  92. hairLength: body.hairLength ?? 'UNCONFIRM',
  93. hairTexture: body.hairTexture ?? 'UNCONFIRM',
  94. notice: body.notice?.trim() || null,
  95. allergy: body.allergy?.trim() ?? '',
  96. innerRemark: body.innerRemark?.trim() || null,
  97. followContent: body.followContent?.trim() || null,
  98. nextFollowDate: parseDateOnly(body.nextFollowDate),
  99. recommendVisitDate: parseDateOnly(body.recommendVisitDate),
  100. firstSuggestHair: body.firstSuggestHair ?? 'AUTO',
  101. planDate: parseDateOnly(body.planDate),
  102. isFirstRegister: 1,
  103. visitPurpose: body.visitPurpose ?? null,
  104. trouble: body.trouble?.trim() || null,
  105. consultContent: body.consultContent?.trim() || null,
  106. expectLook: body.expectLook?.trim() || null,
  107. },
  108. });
  109. return this.toCustomerEntity(created as unknown as CustomerRecord);
  110. }
  111. async getSummary(stylistId: bigint): Promise<CustomerSummaryDto> {
  112. const today = new Date();
  113. const startOfDay = new Date(
  114. Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()),
  115. );
  116. const endOfDay = new Date(
  117. Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 1),
  118. );
  119. const [total, todayCount, followUp, simulations] = await Promise.all([
  120. this.prisma.customer.count({
  121. where: { hairstylistId: stylistId, isDeleted: 0 },
  122. }),
  123. this.prisma.customer.count({
  124. where: {
  125. hairstylistId: stylistId,
  126. isDeleted: 0,
  127. planDate: { gte: startOfDay, lt: endOfDay },
  128. },
  129. }),
  130. this.prisma.customer.count({
  131. where: {
  132. hairstylistId: stylistId,
  133. isDeleted: 0,
  134. nextFollowDate: { not: null, lte: startOfDay },
  135. },
  136. }),
  137. this.prisma.aiHairSimulation.findMany({
  138. where: {
  139. hairstylistId: stylistId,
  140. isDeleted: 0,
  141. customerId: { not: null },
  142. },
  143. select: { customerId: true },
  144. }),
  145. ]);
  146. return {
  147. total,
  148. today: todayCount,
  149. followUp,
  150. simulated: new Set(
  151. simulations
  152. .map((item) => item.customerId)
  153. .filter((value): value is bigint => value != null),
  154. ).size,
  155. };
  156. }
  157. async getById(
  158. stylistId: bigint,
  159. customerId: bigint,
  160. ): Promise<Record<string, unknown>> {
  161. const customer = await this.prisma.customer.findFirst({
  162. where: {
  163. id: customerId,
  164. hairstylistId: stylistId,
  165. isDeleted: 0,
  166. },
  167. });
  168. if (!customer) {
  169. throw new NotFoundException('Customer not found');
  170. }
  171. const visitFrequencyLabel = await this.getVisitFrequencyLabel(
  172. stylistId,
  173. customerId,
  174. );
  175. return {
  176. ...this.toCustomerEntity(customer as unknown as CustomerRecord),
  177. visitFrequencyLabel,
  178. };
  179. }
  180. async update(
  181. stylistId: bigint,
  182. customerId: bigint,
  183. body: CustomerUpsertDto,
  184. ): Promise<Record<string, unknown>> {
  185. const existing = await this.prisma.customer.findFirst({
  186. where: {
  187. id: customerId,
  188. hairstylistId: stylistId,
  189. isDeleted: 0,
  190. },
  191. select: { id: true, avatar: true },
  192. });
  193. if (!existing) {
  194. throw new NotFoundException('Customer not found');
  195. }
  196. const updated = await this.prisma.customer.update({
  197. where: { id: customerId },
  198. data: {
  199. name: body.name.trim(),
  200. namePronunciation: body.namePronunciation?.trim() ?? '',
  201. phone: body.phone.trim(),
  202. email: body.email?.trim() ?? '',
  203. birthday: parseDateOnly(body.birthday),
  204. avatar:
  205. body.avatar?.trim() ||
  206. existing.avatar ||
  207. this.configService.getOrThrow<string>('customer.defaultAvatarUrl'),
  208. expectContact: body.expectContact ?? 'NONE',
  209. hairLength: body.hairLength ?? 'UNCONFIRM',
  210. hairTexture: body.hairTexture ?? 'UNCONFIRM',
  211. notice: body.notice?.trim() || null,
  212. allergy: body.allergy?.trim() ?? '',
  213. innerRemark: body.innerRemark?.trim() || null,
  214. followContent: body.followContent?.trim() || null,
  215. nextFollowDate: parseDateOnly(body.nextFollowDate),
  216. recommendVisitDate: parseDateOnly(body.recommendVisitDate),
  217. firstSuggestHair: body.firstSuggestHair ?? 'AUTO',
  218. planDate: parseDateOnly(body.planDate),
  219. visitPurpose: body.visitPurpose ?? null,
  220. trouble: body.trouble?.trim() || null,
  221. consultContent: body.consultContent?.trim() || null,
  222. expectLook: body.expectLook?.trim() || null,
  223. },
  224. });
  225. const visitFrequencyLabel = await this.getVisitFrequencyLabel(
  226. stylistId,
  227. customerId,
  228. );
  229. return {
  230. ...this.toCustomerEntity(updated as unknown as CustomerRecord),
  231. visitFrequencyLabel,
  232. };
  233. }
  234. private buildListWhere(
  235. stylistId: bigint,
  236. query: ListCustomersQueryDto,
  237. ): Prisma.CustomerWhereInput {
  238. const where: Prisma.CustomerWhereInput = {
  239. hairstylistId: stylistId,
  240. isDeleted: 0,
  241. };
  242. const searchTerm = query.q?.trim();
  243. if (searchTerm) {
  244. where.OR = [
  245. { name: { contains: searchTerm } },
  246. { phone: { contains: searchTerm } },
  247. { innerRemark: { contains: searchTerm } },
  248. { trouble: { contains: searchTerm } },
  249. { consultContent: { contains: searchTerm } },
  250. ];
  251. }
  252. if (query.hairLength && query.hairLength !== 'ALL') {
  253. where.hairLength = query.hairLength;
  254. }
  255. const now = new Date();
  256. const startOfToday = new Date(
  257. Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
  258. );
  259. const endOfToday = new Date(
  260. Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1),
  261. );
  262. const dayOfWeek = startOfToday.getUTCDay();
  263. const diffToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
  264. const startOfWeek = new Date(startOfToday);
  265. startOfWeek.setUTCDate(startOfWeek.getUTCDate() - diffToMonday);
  266. const endOfWeek = new Date(startOfWeek);
  267. endOfWeek.setUTCDate(endOfWeek.getUTCDate() + 7);
  268. if (query.visitStatus === 'TODAY') {
  269. where.planDate = { gte: startOfToday, lt: endOfToday };
  270. } else if (query.visitStatus === 'THIS_WEEK') {
  271. where.planDate = { gte: startOfWeek, lt: endOfWeek };
  272. }
  273. if (query.followStatus === 'NEED_FOLLOW_UP') {
  274. where.nextFollowDate = { not: null, lte: startOfToday };
  275. } else if (query.followStatus === 'NO_NEED_FOLLOW_UP') {
  276. const noNeedFollowUp: Prisma.CustomerWhereInput = {
  277. OR: [{ nextFollowDate: null }, { nextFollowDate: { gt: startOfToday } }],
  278. };
  279. if (where.OR) {
  280. const searchConditions = where.OR;
  281. delete where.OR;
  282. where.AND = [{ OR: searchConditions }, noNeedFollowUp];
  283. } else {
  284. where.OR = noNeedFollowUp.OR;
  285. }
  286. }
  287. return where;
  288. }
  289. private toCustomerEntity(customer: CustomerRecord) {
  290. return {
  291. id: customer.id.toString(),
  292. name: customer.name,
  293. namePronunciation: customer.namePronunciation || undefined,
  294. phone: customer.phone,
  295. email: customer.email || undefined,
  296. birthday: formatDateOnly(customer.birthday),
  297. avatar: customer.avatar,
  298. expectContact: customer.expectContact,
  299. hairLength: customer.hairLength,
  300. hairTexture: customer.hairTexture,
  301. notice: customer.notice ?? undefined,
  302. allergy: customer.allergy || undefined,
  303. innerRemark: customer.innerRemark ?? undefined,
  304. followContent: customer.followContent ?? undefined,
  305. nextFollowDate: formatDateOnly(customer.nextFollowDate),
  306. recommendVisitDate: formatDateOnly(customer.recommendVisitDate),
  307. firstSuggestHair: customer.firstSuggestHair,
  308. planDate: formatDateOnly(customer.planDate),
  309. isFirstRegister: customer.isFirstRegister === 1,
  310. visitPurpose: customer.visitPurpose ?? undefined,
  311. trouble: customer.trouble ?? undefined,
  312. consultContent: customer.consultContent ?? undefined,
  313. expectLook: customer.expectLook ?? undefined,
  314. followState: toFollowState(customer.nextFollowDate),
  315. };
  316. }
  317. private async getVisitFrequencyLabel(
  318. stylistId: bigint,
  319. customerId: bigint,
  320. ): Promise<string> {
  321. const records = await this.prisma.customerTreatmentRecord.findMany({
  322. where: {
  323. customerId,
  324. hairstylistId: stylistId,
  325. isDeleted: 0,
  326. },
  327. orderBy: { treatmentTime: 'asc' },
  328. select: { treatmentTime: true },
  329. });
  330. if (records.length < 2) {
  331. return '未记录';
  332. }
  333. const gaps = records.slice(1).map((record, index) => {
  334. const previousTime = records[index].treatmentTime.getTime();
  335. const currentTime = record.treatmentTime.getTime();
  336. return Math.round(
  337. (currentTime - previousTime) / (1000 * 60 * 60 * 24),
  338. );
  339. });
  340. const averageGap = gaps.reduce((sum, gap) => sum + gap, 0) / gaps.length;
  341. if (averageGap <= 45) return '约 1.5 个月';
  342. if (averageGap <= 75) return '约 2 个月';
  343. if (averageGap <= 105) return '约 3 个月';
  344. return '约 3 个月以上';
  345. }
  346. }