customers.service.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import { Injectable, NotImplementedException } 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. normalizeContactType,
  16. normalizeFollowStatus,
  17. normalizeHairLength,
  18. normalizeLineStatus,
  19. parseDateOnly,
  20. toFrontendHairLength,
  21. } from './customers.mapper';
  22. @Injectable()
  23. export class CustomersService {
  24. constructor(
  25. private readonly prisma: PrismaService,
  26. private readonly configService: ConfigService,
  27. ) {}
  28. async list(stylistId: bigint, query: ListCustomersQueryDto): Promise<{
  29. items: Array<Record<string, unknown>>;
  30. total: number;
  31. page: number;
  32. pageSize: number;
  33. }> {
  34. const page = query.page ?? CUSTOMER_DEFAULT_PAGE;
  35. const pageSize = Math.min(
  36. query.pageSize ?? CUSTOMER_DEFAULT_PAGE_SIZE,
  37. CUSTOMER_MAX_PAGE_SIZE,
  38. );
  39. const skip = (page - 1) * pageSize;
  40. const where = this.buildListWhere(stylistId, query);
  41. const items = await this.prisma.customer.findMany({
  42. where,
  43. orderBy: { createTime: 'desc' },
  44. skip,
  45. take: pageSize,
  46. });
  47. const total = await this.prisma.customer.count({ where });
  48. return {
  49. items: items.map((item) => this.toCustomerEntity(item)),
  50. total,
  51. page,
  52. pageSize,
  53. };
  54. }
  55. async getById(_id: string): Promise<never> {
  56. throw new NotImplementedException('Customer detail is not implemented yet');
  57. }
  58. async create(
  59. stylistId: bigint,
  60. body: CustomerUpsertDto,
  61. ): Promise<Record<string, unknown>> {
  62. const avatarUrl =
  63. body.image?.trim() ||
  64. this.configService.getOrThrow<string>('customer.defaultAvatarUrl');
  65. const created = await this.prisma.customer.create({
  66. data: {
  67. hairstylistId: stylistId,
  68. name: body.name.trim(),
  69. kana: body.kana?.trim() ?? '',
  70. phone: body.phone.trim(),
  71. email: body.email?.trim() ?? '',
  72. birthday: parseDateOnly(body.birthday),
  73. avatarUrl,
  74. contactType: normalizeContactType(body.contact),
  75. lineStatus: normalizeLineStatus(body.lineStatus),
  76. lastVisitDate: parseDateOnly(body.lastVisit),
  77. nextVisitDate: parseDateOnly(body.nextVisit),
  78. visitCycle: body.visitCycle?.trim() ?? '',
  79. hairLength: normalizeHairLength(body.hairLength),
  80. hairType: body.hairType?.trim() ?? '',
  81. hairCondition: body.hairCondition?.trim() ?? '',
  82. concern: body.concern?.trim() || null,
  83. desiredImage: body.desiredImage?.trim() || null,
  84. formulaNote: body.formulaNote?.trim() || null,
  85. allergyNote: body.allergyNote?.trim() || null,
  86. privateNote: body.privateNote?.trim() || null,
  87. followStatus: normalizeFollowStatus(body.followStatus),
  88. followTiming: body.followTiming?.trim() ?? '',
  89. followMessage: body.followMessage?.trim() || null,
  90. },
  91. });
  92. return this.toCustomerEntity(created);
  93. }
  94. async update(_id: string, _body: CustomerUpsertDto): Promise<never> {
  95. throw new NotImplementedException('Customer update is not implemented yet');
  96. }
  97. async getSummary(stylistId: bigint): Promise<CustomerSummaryDto> {
  98. const today = new Date();
  99. const startOfDay = new Date(
  100. Date.UTC(
  101. today.getUTCFullYear(),
  102. today.getUTCMonth(),
  103. today.getUTCDate(),
  104. ),
  105. );
  106. const endOfDay = new Date(
  107. Date.UTC(
  108. today.getUTCFullYear(),
  109. today.getUTCMonth(),
  110. today.getUTCDate() + 1,
  111. ),
  112. );
  113. const [total, todayCount, followUp, simulations] = await Promise.all([
  114. this.prisma.customer.count({
  115. where: { hairstylistId: stylistId, isDeleted: 0 },
  116. }),
  117. this.prisma.customer.count({
  118. where: {
  119. hairstylistId: stylistId,
  120. isDeleted: 0,
  121. nextVisitDate: { gte: startOfDay, lt: endOfDay },
  122. },
  123. }),
  124. this.prisma.customer.count({
  125. where: {
  126. hairstylistId: stylistId,
  127. isDeleted: 0,
  128. followStatus: 'NEED_FOLLOW_UP',
  129. },
  130. }),
  131. this.prisma.aiHairSimulation.findMany({
  132. where: {
  133. hairstylistId: stylistId,
  134. isDeleted: 0,
  135. customerId: { not: null },
  136. },
  137. select: { customerId: true },
  138. }),
  139. ]);
  140. return {
  141. total,
  142. today: todayCount,
  143. followUp,
  144. simulated: new Set(
  145. simulations
  146. .map((item) => item.customerId)
  147. .filter((value): value is bigint => value != null),
  148. ).size,
  149. };
  150. }
  151. private buildListWhere(
  152. stylistId: bigint,
  153. query: ListCustomersQueryDto,
  154. ): Prisma.CustomerWhereInput {
  155. const where: Prisma.CustomerWhereInput = {
  156. hairstylistId: stylistId,
  157. isDeleted: 0,
  158. };
  159. if (query.q?.trim()) {
  160. where.OR = [
  161. { name: { contains: query.q.trim() } },
  162. { phone: { contains: query.q.trim() } },
  163. { concern: { contains: query.q.trim() } },
  164. { privateNote: { contains: query.q.trim() } },
  165. ];
  166. }
  167. if (query.followStatus && query.followStatus !== 'ALL') {
  168. where.followStatus = query.followStatus;
  169. }
  170. if (query.hairLength && query.hairLength !== 'ALL') {
  171. where.hairLength = query.hairLength;
  172. }
  173. const now = new Date();
  174. const startOfToday = new Date(
  175. Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
  176. );
  177. const endOfToday = new Date(
  178. Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1),
  179. );
  180. const dayOfWeek = startOfToday.getUTCDay();
  181. const diffToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
  182. const startOfWeek = new Date(startOfToday);
  183. startOfWeek.setUTCDate(startOfWeek.getUTCDate() - diffToMonday);
  184. const endOfWeek = new Date(startOfWeek);
  185. endOfWeek.setUTCDate(endOfWeek.getUTCDate() + 7);
  186. const thirtyDaysAgo = new Date(startOfToday);
  187. thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30);
  188. if (query.visitStatus === 'TODAY') {
  189. where.nextVisitDate = { gte: startOfToday, lt: endOfToday };
  190. } else if (query.visitStatus === 'THIS_WEEK') {
  191. where.nextVisitDate = { gte: startOfWeek, lt: endOfWeek };
  192. } else if (query.visitStatus === 'OVER_30_DAYS_NO_VISIT') {
  193. where.lastVisitDate = { lt: thirtyDaysAgo };
  194. }
  195. return where;
  196. }
  197. private toCustomerEntity(customer: {
  198. id: bigint;
  199. name: string;
  200. kana: string;
  201. phone: string;
  202. email: string;
  203. birthday: Date | null;
  204. avatarUrl: string;
  205. contactType: string;
  206. lineStatus: string | null;
  207. lastVisitDate: Date | null;
  208. nextVisitDate: Date | null;
  209. visitCycle: string;
  210. hairLength: string | null;
  211. hairType: string;
  212. hairCondition: string;
  213. concern: string | null;
  214. desiredImage: string | null;
  215. formulaNote: string | null;
  216. allergyNote: string | null;
  217. privateNote: string | null;
  218. followStatus: string;
  219. followTiming: string;
  220. followMessage: string | null;
  221. }) {
  222. return {
  223. id: customer.id.toString(),
  224. name: customer.name,
  225. kana: customer.kana || undefined,
  226. phone: customer.phone,
  227. email: customer.email || undefined,
  228. birthday: formatDateOnly(customer.birthday),
  229. image: customer.avatarUrl,
  230. contact: customer.contactType,
  231. lineStatus: customer.lineStatus ?? undefined,
  232. lastVisit: formatDateOnly(customer.lastVisitDate),
  233. nextVisit: formatDateOnly(customer.nextVisitDate),
  234. visitCycle: customer.visitCycle || undefined,
  235. hairLength: toFrontendHairLength(customer.hairLength),
  236. hairType: customer.hairType || undefined,
  237. hairCondition: customer.hairCondition || undefined,
  238. concern: customer.concern ?? undefined,
  239. desiredImage: customer.desiredImage ?? undefined,
  240. formulaNote: customer.formulaNote ?? undefined,
  241. allergyNote: customer.allergyNote ?? undefined,
  242. privateNote: customer.privateNote ?? undefined,
  243. followStatus: customer.followStatus,
  244. followTiming: customer.followTiming || undefined,
  245. followMessage: customer.followMessage ?? undefined,
  246. };
  247. }
  248. }