feat: implement booking and customer order flow

This commit is contained in:
2026-09-18 16:21:00 +08:00
parent bd45625d36
commit 1e03d055aa
3 changed files with 200 additions and 26 deletions

View File

@@ -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 },
});
}
}

View File

@@ -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<void> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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 {}

View File

@@ -0,0 +1,9 @@
import { Injectable } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaService extends PrismaClient {
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}