feat: implement booking and customer order flow
This commit is contained in:
130
apps/api/src/booking.service.ts
Normal file
130
apps/api/src/booking.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
9
apps/api/src/prisma.service.ts
Normal file
9
apps/api/src/prisma.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user