|
|
|
@@ -1,12 +1,11 @@
|
|
|
|
|
import { ConflictException, ForbiddenException, Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
|
|
|
|
import { OrderStatus, Prisma } from '@prisma/client';
|
|
|
|
|
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
|
|
|
|
import { DesignListStatus, OrderStatus, Prisma } from '@prisma/client';
|
|
|
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
|
|
|
import { CreateOrderDto } from './dto/create-order.dto';
|
|
|
|
|
import { OrderQueryDto } from './dto/order-query.dto';
|
|
|
|
|
|
|
|
|
|
const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
|
|
|
|
|
|
|
|
// 订单号生成:日期 + 随机后缀(无 Math.random 依赖要求仅限 workflow 脚本,普通运行时可用)
|
|
|
|
|
function generateOrderNo(): string {
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const ymd =
|
|
|
|
@@ -40,7 +39,13 @@ export class OrdersService {
|
|
|
|
|
await this.expirePendingOrders({ userId });
|
|
|
|
|
const where = { userId, ...(query.status ? { status: query.status } : {}) };
|
|
|
|
|
const [rows, total] = await this.prisma.$transaction([
|
|
|
|
|
this.prisma.order.findMany({ where, include: { items: true, payment: true }, orderBy: { createdAt: 'desc' }, skip: (query.page - 1) * query.pageSize, take: query.pageSize }),
|
|
|
|
|
this.prisma.order.findMany({
|
|
|
|
|
where,
|
|
|
|
|
include: { items: true, payment: true },
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
skip: (query.page - 1) * query.pageSize,
|
|
|
|
|
take: query.pageSize,
|
|
|
|
|
}),
|
|
|
|
|
this.prisma.order.count({ where }),
|
|
|
|
|
]);
|
|
|
|
|
return { list: rows.map((row) => this.serialize(row)), total, page: query.page, pageSize: query.pageSize };
|
|
|
|
@@ -56,21 +61,46 @@ export class OrdersService {
|
|
|
|
|
|
|
|
|
|
async create(userId: string, dto: CreateOrderDto) {
|
|
|
|
|
if (dto.requestId) {
|
|
|
|
|
const existing = await this.prisma.order.findFirst({ where: { userId, requestId: dto.requestId }, include: { items: true, payment: true } });
|
|
|
|
|
const existing = await this.prisma.order.findFirst({
|
|
|
|
|
where: { userId, requestId: dto.requestId },
|
|
|
|
|
include: { items: true, payment: true },
|
|
|
|
|
});
|
|
|
|
|
if (existing) return this.serialize(existing);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const address = await this.prisma.address.findUnique({ where: { id: dto.addressId } });
|
|
|
|
|
if (!address) throw new NotFoundException('地址不存在');
|
|
|
|
|
if (address.userId !== userId) throw new ForbiddenException('无权使用该地址');
|
|
|
|
|
const design = dto.designListId ? await this.prisma.designList.findUnique({ where: { id: dto.designListId } }) : null;
|
|
|
|
|
|
|
|
|
|
const design = dto.designListId
|
|
|
|
|
? await this.prisma.designList.findUnique({ where: { id: dto.designListId } })
|
|
|
|
|
: null;
|
|
|
|
|
if (dto.designListId && !design) throw new NotFoundException('设计清单不存在');
|
|
|
|
|
if (design && design.userId !== userId) throw new ForbiddenException('无权使用该设计清单');
|
|
|
|
|
const products = await this.prisma.product.findMany({ where: { id: { in: dto.items.map((item) => item.productId) }, status: 'ON_SALE' } });
|
|
|
|
|
if (products.length !== new Set(dto.items.map((item) => item.productId)).size) throw new BadRequestException('存在无效或已下架商品');
|
|
|
|
|
|
|
|
|
|
const products = await this.prisma.product.findMany({
|
|
|
|
|
where: { id: { in: dto.items.map((item) => item.productId) }, status: 'ON_SALE' },
|
|
|
|
|
});
|
|
|
|
|
if (products.length !== new Set(dto.items.map((item) => item.productId)).size) {
|
|
|
|
|
throw new BadRequestException('存在无效或已下架商品');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const byId = new Map(products.map((product) => [product.id, product]));
|
|
|
|
|
const total = dto.items.reduce((sum, item) => sum.plus(new Prisma.Decimal(byId.get(item.productId)!.price).mul(item.quantity)), new Prisma.Decimal(0));
|
|
|
|
|
const addressSnapshot = { id: address.id, name: address.name, phone: address.phone, province: address.province, city: address.city, district: address.district, detail: address.detail };
|
|
|
|
|
const total = dto.items.reduce(
|
|
|
|
|
(sum, item) => sum.plus(new Prisma.Decimal(byId.get(item.productId)!.price).mul(item.quantity)),
|
|
|
|
|
new Prisma.Decimal(0),
|
|
|
|
|
);
|
|
|
|
|
const addressSnapshot = {
|
|
|
|
|
id: address.id,
|
|
|
|
|
name: address.name,
|
|
|
|
|
phone: address.phone,
|
|
|
|
|
province: address.province,
|
|
|
|
|
city: address.city,
|
|
|
|
|
district: address.district,
|
|
|
|
|
detail: address.detail,
|
|
|
|
|
};
|
|
|
|
|
const paymentExpiresAt = new Date(Date.now() + PAYMENT_TIMEOUT_MS);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const created = await this.prisma.$transaction(async (tx) => {
|
|
|
|
|
let orderNo = generateOrderNo();
|
|
|
|
@@ -79,7 +109,8 @@ export class OrdersService {
|
|
|
|
|
if (!collision) break;
|
|
|
|
|
orderNo = generateOrderNo();
|
|
|
|
|
}
|
|
|
|
|
return tx.order.create({
|
|
|
|
|
|
|
|
|
|
const order = await tx.order.create({
|
|
|
|
|
data: {
|
|
|
|
|
orderNo,
|
|
|
|
|
user: { connect: { id: userId } },
|
|
|
|
@@ -88,16 +119,39 @@ export class OrdersService {
|
|
|
|
|
paymentExpiresAt,
|
|
|
|
|
addressSnapshot: addressSnapshot as Prisma.InputJsonValue,
|
|
|
|
|
designList: dto.designListId ? { connect: { id: dto.designListId } } : undefined,
|
|
|
|
|
items: { create: dto.items.map((item) => ({ productId: item.productId, name: byId.get(item.productId)!.name, price: byId.get(item.productId)!.price, quantity: item.quantity })) },
|
|
|
|
|
items: {
|
|
|
|
|
create: dto.items.map((item) => ({
|
|
|
|
|
productId: item.productId,
|
|
|
|
|
name: byId.get(item.productId)!.name,
|
|
|
|
|
price: byId.get(item.productId)!.price,
|
|
|
|
|
quantity: item.quantity,
|
|
|
|
|
})),
|
|
|
|
|
},
|
|
|
|
|
payment: { create: { status: 'PENDING' } },
|
|
|
|
|
},
|
|
|
|
|
include: { items: true, payment: true },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (dto.designListId) {
|
|
|
|
|
await tx.designList.updateMany({
|
|
|
|
|
where: {
|
|
|
|
|
id: dto.designListId,
|
|
|
|
|
status: { in: [DesignListStatus.DRAFT, DesignListStatus.SUBMITTED] },
|
|
|
|
|
},
|
|
|
|
|
data: { status: DesignListStatus.PROCESSING },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return order;
|
|
|
|
|
});
|
|
|
|
|
return this.serialize(created);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
|
|
|
|
const existing = dto.requestId ? await this.prisma.order.findFirst({ where: { userId, requestId: dto.requestId }, include: { items: true, payment: true } }) : null;
|
|
|
|
|
const existing = dto.requestId
|
|
|
|
|
? await this.prisma.order.findFirst({
|
|
|
|
|
where: { userId, requestId: dto.requestId },
|
|
|
|
|
include: { items: true, payment: true },
|
|
|
|
|
})
|
|
|
|
|
: null;
|
|
|
|
|
if (existing) return this.serialize(existing);
|
|
|
|
|
throw new ConflictException('订单号冲突,请重试');
|
|
|
|
|
}
|
|
|
|
@@ -108,13 +162,32 @@ export class OrdersService {
|
|
|
|
|
async cancel(userId: string, id: string) {
|
|
|
|
|
const order = await this.getOwned(userId, id);
|
|
|
|
|
if (order.status !== OrderStatus.PENDING) throw new ConflictException('仅待付款订单可取消');
|
|
|
|
|
return this.serialize(await this.prisma.order.update({ where: { id }, data: { status: OrderStatus.CANCELLED }, include: { items: true, payment: true } }));
|
|
|
|
|
const updated = await this.prisma.order.update({
|
|
|
|
|
where: { id },
|
|
|
|
|
data: { status: OrderStatus.CANCELLED },
|
|
|
|
|
include: { items: true, payment: true },
|
|
|
|
|
});
|
|
|
|
|
return this.serialize(updated);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async confirm(userId: string, id: string) {
|
|
|
|
|
const order = await this.getOwned(userId, id);
|
|
|
|
|
if (order.status !== OrderStatus.SHIPPED) throw new ConflictException('仅待收货订单可确认收货');
|
|
|
|
|
return this.serialize(await this.prisma.order.update({ where: { id }, data: { status: OrderStatus.COMPLETED }, include: { items: true, payment: true } }));
|
|
|
|
|
const updated = await this.prisma.$transaction(async (tx) => {
|
|
|
|
|
const completed = await tx.order.update({
|
|
|
|
|
where: { id },
|
|
|
|
|
data: { status: OrderStatus.COMPLETED },
|
|
|
|
|
include: { items: true, payment: true },
|
|
|
|
|
});
|
|
|
|
|
if (order.designListId) {
|
|
|
|
|
await tx.designList.updateMany({
|
|
|
|
|
where: { id: order.designListId, status: DesignListStatus.PROCESSING },
|
|
|
|
|
data: { status: DesignListStatus.DONE },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return completed;
|
|
|
|
|
});
|
|
|
|
|
return this.serialize(updated);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async getOwned(userId: string, id: string) {
|
|
|
|
@@ -125,10 +198,6 @@ export class OrdersService {
|
|
|
|
|
return order;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 过期状态由服务端收口,而不是只依赖小程序倒计时。这样用户切后台、换设备或
|
|
|
|
|
* 直接调用支付接口时,订单仍无法绕过 30 分钟限制。
|
|
|
|
|
*/
|
|
|
|
|
private async expirePendingOrders(where: Prisma.OrderWhereInput) {
|
|
|
|
|
await this.prisma.order.updateMany({
|
|
|
|
|
where: {
|
|
|
|
|