feat(trade): add on-site pickup order flow
CI / verify (pull_request) Has been cancelled

Product flag, ON_SITE_PICKUP delivery, mini confirm/receive pages; pay skips warehouse auto-advance.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-23 01:17:59 +08:00
parent e93e4a7721
commit cebeefdb22
19 changed files with 580 additions and 146 deletions
@@ -89,6 +89,7 @@ export class AdminProductsService {
benefitAmount: dto.benefitAmount ?? dto.price,
status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE',
sortOrder: dto.sortOrder ?? 0,
allowOnSitePickup: dto.allowOnSitePickup ?? false,
...(dto.detailContent !== undefined
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
: {}),
@@ -118,6 +119,7 @@ export class AdminProductsService {
...(dto.benefitAmount !== undefined ? { benefitAmount: dto.benefitAmount } : {}),
...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
...(dto.allowOnSitePickup !== undefined ? { allowOnSitePickup: dto.allowOnSitePickup } : {}),
...(dto.detailContent !== undefined
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
: {}),
@@ -947,6 +947,10 @@ export class CreateProductDto {
@IsNumber()
sortOrder?: number;
@IsOptional()
@IsBoolean()
allowOnSitePickup?: boolean;
@IsOptional()
@IsString()
coverUrl?: string;
@@ -995,6 +999,10 @@ export class UpdateProductDto {
@IsNumber()
sortOrder?: number;
@IsOptional()
@IsBoolean()
allowOnSitePickup?: boolean;
@IsOptional()
@IsString()
coverUrl?: string;
@@ -51,7 +51,10 @@ export class TradeService {
private readonly fulfillmentService: FulfillmentService,
) {}
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
async preview(
userId: bigint,
body: { productId: string; quantity: number; addressId?: string; onSitePickup?: boolean },
) {
const product = await this.catalogService.getProduct(BigInt(body.productId));
if (!product || product.status !== 'ON_SALE') {
throw new BadRequestException('商品不可购买');
@@ -59,8 +62,15 @@ export class TradeService {
const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } });
if (!city) throw new BadRequestException('暂无开城城市');
let deliveryType: 'LOCAL' | 'CROSS_CITY' = 'LOCAL';
if (body.addressId) {
const onSitePickup = !!body.onSitePickup;
if (onSitePickup && !product.allowOnSitePickup) {
throw new BadRequestException('该商品不支持现场取货');
}
let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = onSitePickup
? 'ON_SITE_PICKUP'
: 'LOCAL';
if (!onSitePickup && body.addressId) {
const address = await this.prisma.userAddress.findFirst({
where: { id: BigInt(body.addressId), userId },
});
@@ -84,13 +94,19 @@ export class TradeService {
});
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
const minQty =
deliveryType === 'ON_SITE_PICKUP'
? 1
: deliveryType === 'LOCAL'
? city.localMinQty
: city.crossMinQty;
return {
product,
quantity: body.quantity,
deliveryType,
productAmount,
freightAmount: deliveryType === 'CROSS_CITY' ? 0 : 0,
freightAmount: 0,
freightPayType,
payAmount: productAmount,
benefitAmount: benefitPerUnit * body.quantity,
@@ -98,7 +114,8 @@ export class TradeService {
/** 起购未满足时仍返回预览,供确认页改数量;下单接口仍会硬校验 */
quantityOk: check.ok,
quantityMessage: check.ok ? null : (check.message ?? null),
minQty: deliveryType === 'LOCAL' ? city.localMinQty : city.crossMinQty,
minQty,
onSitePickup,
};
}
@@ -107,7 +124,8 @@ export class TradeService {
body: {
productId: string;
quantity: number;
addressId: string;
addressId?: string;
onSitePickup?: boolean;
clientLocation?: unknown;
},
req: Request,
@@ -116,10 +134,35 @@ export class TradeService {
if (preview.quantityOk === false) {
throw new BadRequestException(preview.quantityMessage || '购买数量不满足起购要求');
}
const address = await this.prisma.userAddress.findFirst({
where: { id: BigInt(body.addressId), userId },
});
if (!address) throw new BadRequestException('请选择收货地址');
const onSitePickup = !!body.onSitePickup || preview.deliveryType === 'ON_SITE_PICKUP';
let receiverName = '现场取货';
let receiverPhone = '00000000000';
let receiverAddress = '现场取货';
let receiverProvince = '';
let receiverCity = '';
let receiverDistrict = '';
if (onSitePickup) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
receiverPhone = user?.phone || '00000000000';
receiverName = (user?.nickname?.trim() || '现场取货').slice(0, 32);
receiverProvince = '现场';
receiverCity = '现场';
receiverDistrict = '取货';
} else {
if (!body.addressId) throw new BadRequestException('请选择收货地址');
const address = await this.prisma.userAddress.findFirst({
where: { id: BigInt(body.addressId), userId },
});
if (!address) throw new BadRequestException('请选择收货地址');
receiverName = address.receiverName;
receiverPhone = address.phone;
receiverAddress = `${address.province}${address.city}${address.district}${address.detail}`;
receiverProvince = address.province;
receiverCity = address.city;
receiverDistrict = address.district;
}
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
where: { id: BigInt(body.productId) },
@@ -149,7 +192,7 @@ export class TradeService {
cityId: city.id,
status: 'PENDING_PAY',
payStatus: 'UNPAID',
deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY',
deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
productId: product.id,
barcode69: product.barcode69,
productName: product.name,
@@ -159,12 +202,12 @@ export class TradeService {
listUnitPrice: product.price,
listAmount: preview.productAmount,
productAmount: preview.productAmount,
receiverName: address.receiverName,
receiverPhone: address.phone,
receiverAddress: `${address.province}${address.city}${address.district}${address.detail}`,
receiverProvince: address.province,
receiverCity: address.city,
receiverDistrict: address.district,
receiverName,
receiverPhone,
receiverAddress,
receiverProvince,
receiverCity,
receiverDistrict,
clientIp: location.clientIp,
ipProvince: location.ipProvince,
ipCity: location.ipCity,
@@ -203,6 +246,7 @@ export class TradeService {
orderId: order.id.toString(),
productId: body.productId,
quantity: body.quantity,
onSitePickup,
},
});
@@ -241,12 +285,14 @@ export class TradeService {
order.cityId,
order.receiverDistrict,
);
const toStatus =
order.deliveryType === 'ON_SITE_PICKUP' ? 'PENDING_RECEIVE' : 'PENDING_SHIP';
await this.prisma.$transaction(async (tx) => {
await tx.order.update({
where: { id: order.id },
data: {
status: 'PENDING_SHIP',
status: toStatus,
payStatus: 'PAID',
paidAt: now,
payExternalNo: externalNo,
@@ -269,7 +315,7 @@ export class TradeService {
data: buildOrderStatusEvent({
orderId: order.id,
fromStatus: 'PENDING_PAY',
toStatus: 'PENDING_SHIP',
toStatus,
operator: 'MOCK_PAY',
}),
});
@@ -292,6 +338,20 @@ export class TradeService {
private async afterOrderPaid(orderId: bigint) {
await this.benefitService.grantOnOrderPaid(orderId);
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
if (!order) return;
const delivery = await this.prisma.orderDelivery.findUnique({ where: { orderId } });
if (!delivery) {
await this.prisma.orderDelivery.create({
data: { orderId, provider: 'MANUAL' },
});
}
if (order.deliveryType === 'ON_SITE_PICKUP') {
return;
}
await this.fulfillmentService.dispatchAfterPay(orderId);
const refreshed = await this.prisma.order.findUnique({ where: { id: orderId } });
if (refreshed?.status === 'PENDING_SHIP') {
@@ -335,6 +395,8 @@ export class TradeService {
order.cityId,
order.receiverDistrict,
);
const toStatus =
order.deliveryType === 'ON_SITE_PICKUP' ? 'PENDING_RECEIVE' : 'PENDING_SHIP';
await this.prisma.$transaction(async (tx) => {
const current = await tx.order.findUnique({ where: { id: order.id } });
if (!current || current.payStatus === 'PAID') return;
@@ -342,7 +404,7 @@ export class TradeService {
await tx.order.update({
where: { id: order.id },
data: {
status: 'PENDING_SHIP',
status: toStatus,
payStatus: 'PAID',
paidAt: now,
payExternalNo: params.transactionId,
@@ -365,7 +427,7 @@ export class TradeService {
data: buildOrderStatusEvent({
orderId: order.id,
fromStatus: 'PENDING_PAY',
toStatus: 'PENDING_SHIP',
toStatus,
operator: 'WECHAT_PAY',
}),
});
@@ -470,25 +532,12 @@ export class TradeService {
async confirmReceive(
userId: bigint,
orderId: bigint,
opts?: { onSitePickup?: boolean },
_opts?: { onSitePickup?: boolean },
) {
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
if (!order) throw new NotFoundException('订单不存在');
const onSitePickup = !!opts?.onSitePickup;
const onSiteEligible = [
'PENDING_SHIP',
'OUT_WAREHOUSE',
'SHIPPING',
'SHIPPED',
'PENDING_RECEIVE',
'DELIVERED',
];
if (onSitePickup) {
if (!onSiteEligible.includes(order.status)) {
throw new BadRequestException('当前状态不可现场取货');
}
} else if (!['PENDING_RECEIVE', 'DELIVERED'].includes(order.status)) {
if (!['PENDING_RECEIVE', 'DELIVERED'].includes(order.status)) {
throw new BadRequestException('当前状态不可确认收货');
}
@@ -496,8 +545,8 @@ export class TradeService {
order.id,
order.status,
'COMPLETED',
onSitePickup ? 'USER_ON_SITE' : 'USER',
onSitePickup ? '用户现场取货确认收货' : undefined,
order.deliveryType === 'ON_SITE_PICKUP' ? 'USER_ON_SITE' : 'USER',
order.deliveryType === 'ON_SITE_PICKUP' ? '用户现场取货确认收货' : undefined,
);
return this.getOrder(userId, orderId);
}