From 1e03d055aadc36f5bbddb8d897e7010e322178b3 Mon Sep 17 00:00:00 2001 From: que01 Date: Fri, 18 Sep 2026 16:21:00 +0800 Subject: [PATCH] feat: implement booking and customer order flow --- apps/api/src/booking.service.ts | 130 ++++++++++++++++++++++++++++++++ apps/api/src/main.ts | 87 ++++++++++++++------- apps/api/src/prisma.service.ts | 9 +++ 3 files changed, 200 insertions(+), 26 deletions(-) create mode 100644 apps/api/src/booking.service.ts create mode 100644 apps/api/src/prisma.service.ts diff --git a/apps/api/src/booking.service.ts b/apps/api/src/booking.service.ts new file mode 100644 index 0000000..8bb6506 --- /dev/null +++ b/apps/api/src/booking.service.ts @@ -0,0 +1,130 @@ +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { BookingRequestType, Prisma } from "@prisma/client"; +import { PrismaService } from "./prisma.service.js"; + +export interface CreateBookingInput { + requestType: BookingRequestType; + customerName: string; + phone?: string; + wechatId?: string; + contactPreference: "wechat" | "phone" | "none"; + fulfillmentType?: "pickup" | "delivery"; + serviceId?: string; + requestedDate?: string; + requestedTime?: string; + deliveryAddress?: string; + note?: string; + extraData?: Prisma.InputJsonObject; + items?: Array<{ serviceId: string; quantity: number }>; +} + +@Injectable() +export class BookingService { + constructor(private readonly prisma: PrismaService) {} + + async create(brandId: string, customerUserId: string, input: CreateBookingInput) { + if (!input.customerName.trim() || (!input.phone?.trim() && !input.wechatId?.trim())) { + throw new BadRequestException("称呼和至少一种联系方式为必填项"); + } + if ( + input.requestType === "package" && + (!input.items?.length || input.items.some((item) => item.quantity < 1)) + ) { + throw new BadRequestException("普通套餐至少选择一份有效套餐"); + } + const extraData = input.extraData as { people?: unknown; location?: unknown } | undefined; + if ( + input.requestType === "private_chef" && + (!input.requestedDate || !extraData?.people || !extraData.location) + ) { + throw new BadRequestException("私厨需求必须填写日期、人数和地点"); + } + const services = input.items?.length + ? await this.prisma.service.findMany({ + where: { + id: { in: input.items.map((item) => item.serviceId) }, + brandId, + isEnabled: true, + }, + }) + : []; + const serviceMap = new Map(services.map((service) => [service.id, service])); + const items = (input.items ?? []).map((item) => { + const service = serviceMap.get(item.serviceId); + if (!service || !service.bookingEnabled || service.priceAmount === null) + throw new BadRequestException("套餐不存在或暂不可预订"); + return { + serviceId: service.id, + serviceNameSnapshot: service.name, + unitPriceSnapshot: service.priceAmount, + quantity: item.quantity, + subtotalSnapshot: service.priceAmount * item.quantity, + }; + }); + const estimatedAmount = items.reduce((sum, item) => sum + item.subtotalSnapshot, 0) || null; + const booking = await this.prisma.booking.create({ + data: { + bookingNo: `AC${Date.now()}${Math.floor(Math.random() * 1000) + .toString() + .padStart(3, "0")}`, + brandId, + customerUserId, + requestType: input.requestType, + customerName: input.customerName.trim(), + phone: input.phone?.trim() ?? null, + wechatId: input.wechatId?.trim() ?? null, + contactPreference: input.contactPreference, + fulfillmentType: input.fulfillmentType ?? null, + serviceId: items[0]?.serviceId ?? input.serviceId ?? null, + requestedDate: input.requestedDate + ? new Date(`${input.requestedDate}T00:00:00.000Z`) + : null, + requestedTime: input.requestedTime ?? null, + deliveryAddress: input.deliveryAddress ?? null, + estimatedAmount, + ...(input.extraData ? { extraData: input.extraData } : {}), + note: input.note ?? null, + items: { create: items }, + }, + select: { bookingNo: true, status: true, estimatedAmount: true, createdAt: true }, + }); + return booking; + } + + async listForCustomer(brandId: string, customerUserId: string) { + return this.prisma.booking.findMany({ + where: { brandId, customerUserId }, + orderBy: { createdAt: "desc" }, + include: { items: true, service: { select: { name: true } } }, + }); + } + + async cancelForCustomer(brandId: string, customerUserId: string, id: string) { + const booking = await this.prisma.booking.findFirst({ where: { id, brandId, customerUserId } }); + if (!booking) throw new NotFoundException("订单不存在"); + if (booking.status !== "received") throw new BadRequestException("当前状态不可取消"); + return this.prisma.booking.update({ where: { id }, data: { status: "cancelled" } }); + } + + async listForAdmin(brandId: string) { + return this.prisma.booking.findMany({ + where: { brandId }, + orderBy: { createdAt: "desc" }, + include: { items: true, tags: { include: { tag: true } } }, + }); + } + + async updateStatus( + brandId: string, + id: string, + status: "received" | "contacting" | "confirmed" | "completed" | "cancelled", + adminNote?: string, + ) { + const booking = await this.prisma.booking.findFirst({ where: { id, brandId } }); + if (!booking) throw new NotFoundException("订单不存在"); + return this.prisma.booking.update({ + where: { id }, + data: { status, ...(adminNote !== undefined ? { adminNote } : {}), isRead: true }, + }); + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 1935a5a..a00a3f4 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -4,16 +4,18 @@ import { Body, Controller, Get, - Injectable, Module, + Param, Post, Req, } from "@nestjs/common"; import { NestFactory } from "@nestjs/core"; import { FastifyAdapter } from "@nestjs/platform-fastify"; import type { NestFastifyApplication } from "@nestjs/platform-fastify"; -import { PrismaClient } from "@prisma/client"; +import { PrismaService } from "./prisma.service.js"; import { WechatAuthService } from "./wechat-auth.js"; +import { BookingService } from "./booking.service.js"; +import type { CreateBookingInput } from "./booking.service.js"; import type { FastifyRequest } from "fastify"; import { createHmac } from "node:crypto"; @@ -37,13 +39,6 @@ function readIdentity(token: string): { brandId: string; openid: string } { }; } -@Injectable() -export class PrismaService extends PrismaClient { - async onModuleDestroy(): Promise { - await this.$disconnect(); - } -} - @Controller("health") export class HealthController { @Get() @@ -114,27 +109,39 @@ export class CustomerSessionController { @Controller("customer/bookings") export class CustomerBookingController { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly bookings: BookingService, + ) {} + + @Post() + async create(@Req() request: FastifyRequest, @Body() body: CreateBookingInput): Promise { + const identity = this.identity(request); + const customer = await this.prisma.customerUser.findUnique({ + where: { brandId_openid: identity }, + }); + if (!customer) throw new BadRequestException("请先完成微信登录"); + return this.bookings.create(identity.brandId, customer.id, body); + } @Get() async list(@Req() request: FastifyRequest): Promise { const identity = this.identity(request); - return this.prisma.booking.findMany({ - where: { customerUser: { brandId: identity.brandId, openid: identity.openid } }, - orderBy: { createdAt: "desc" }, - select: { - bookingNo: true, - requestType: true, - customerName: true, - status: true, - estimatedAmount: true, - requestedDate: true, - requestedTime: true, - fulfillmentType: true, - createdAt: true, - items: { select: { serviceNameSnapshot: true, quantity: true, subtotalSnapshot: true } }, - }, + const customer = await this.prisma.customerUser.findUnique({ + where: { brandId_openid: identity }, }); + if (!customer) throw new BadRequestException("请先完成微信登录"); + return this.bookings.listForCustomer(identity.brandId, customer.id); + } + + @Post(":id/cancel") + async cancel(@Req() request: FastifyRequest, @Param("id") id: string): Promise { + const identity = this.identity(request); + const customer = await this.prisma.customerUser.findUnique({ + where: { brandId_openid: identity }, + }); + if (!customer) throw new BadRequestException("请先完成微信登录"); + return this.bookings.cancelForCustomer(identity.brandId, customer.id, id); } private identity(request: FastifyRequest): { brandId: string; openid: string } { @@ -149,14 +156,42 @@ export class CustomerBookingController { } } +@Controller("admin/bookings") +export class AdminBookingController { + constructor(private readonly bookings: BookingService) {} + @Get() + list(): Promise { + return this.bookings.listForAdmin( + process.env.DEFAULT_BRAND_ID ?? "00000000-0000-0000-0000-000000000001", + ); + } + @Post(":id/status") + update( + @Param("id") id: string, + @Body() + body: { + status: "received" | "contacting" | "confirmed" | "completed" | "cancelled"; + adminNote?: string; + }, + ): Promise { + return this.bookings.updateStatus( + process.env.DEFAULT_BRAND_ID ?? "00000000-0000-0000-0000-000000000001", + id, + body.status, + body.adminNote, + ); + } +} + @Module({ controllers: [ HealthController, PublicController, CustomerSessionController, CustomerBookingController, + AdminBookingController, ], - providers: [PrismaService, WechatAuthService], + providers: [PrismaService, WechatAuthService, BookingService], exports: [PrismaService, WechatAuthService], }) export class AppModule {} diff --git a/apps/api/src/prisma.service.ts b/apps/api/src/prisma.service.ts new file mode 100644 index 0000000..9185c47 --- /dev/null +++ b/apps/api/src/prisma.service.ts @@ -0,0 +1,9 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaClient } from "@prisma/client"; + +@Injectable() +export class PrismaService extends PrismaClient { + async onModuleDestroy(): Promise { + await this.$disconnect(); + } +}