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,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Injectable,
|
|
||||||
Module,
|
Module,
|
||||||
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { FastifyAdapter } from "@nestjs/platform-fastify";
|
import { FastifyAdapter } from "@nestjs/platform-fastify";
|
||||||
import type { NestFastifyApplication } 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 { WechatAuthService } from "./wechat-auth.js";
|
||||||
|
import { BookingService } from "./booking.service.js";
|
||||||
|
import type { CreateBookingInput } from "./booking.service.js";
|
||||||
import type { FastifyRequest } from "fastify";
|
import type { FastifyRequest } from "fastify";
|
||||||
import { createHmac } from "node:crypto";
|
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")
|
@Controller("health")
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
@Get()
|
@Get()
|
||||||
@@ -114,27 +109,39 @@ export class CustomerSessionController {
|
|||||||
|
|
||||||
@Controller("customer/bookings")
|
@Controller("customer/bookings")
|
||||||
export class CustomerBookingController {
|
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()
|
@Get()
|
||||||
async list(@Req() request: FastifyRequest): Promise<unknown> {
|
async list(@Req() request: FastifyRequest): Promise<unknown> {
|
||||||
const identity = this.identity(request);
|
const identity = this.identity(request);
|
||||||
return this.prisma.booking.findMany({
|
const customer = await this.prisma.customerUser.findUnique({
|
||||||
where: { customerUser: { brandId: identity.brandId, openid: identity.openid } },
|
where: { brandId_openid: identity },
|
||||||
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 } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
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 } {
|
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({
|
@Module({
|
||||||
controllers: [
|
controllers: [
|
||||||
HealthController,
|
HealthController,
|
||||||
PublicController,
|
PublicController,
|
||||||
CustomerSessionController,
|
CustomerSessionController,
|
||||||
CustomerBookingController,
|
CustomerBookingController,
|
||||||
|
AdminBookingController,
|
||||||
],
|
],
|
||||||
providers: [PrismaService, WechatAuthService],
|
providers: [PrismaService, WechatAuthService, BookingService],
|
||||||
exports: [PrismaService, WechatAuthService],
|
exports: [PrismaService, WechatAuthService],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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