v3.5.4版本提交
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-08-21 15:48:15 +08:00
parent 26334ed072
commit a01217539c
32 changed files with 2278 additions and 147 deletions
@@ -11,6 +11,7 @@ import {
calcBenefitAmount,
generateOrderNo,
orderTabToStatuses,
toMinSaleQuantity,
validateMinPurchase,
} from '@dukang/domain';
import { loadAppConfig, ClientApp, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
@@ -37,6 +38,7 @@ import { AlertService } from '../../common/alert/alert.service';
import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
import type { Request } from 'express';
import type { CommonProductSku } from '@prisma/client';
@Injectable()
export class TradeService {
@@ -62,22 +64,54 @@ export class TradeService {
private readonly logger = new Logger(TradeService.name);
private overlayProductWithSku(
productDto: Record<string, unknown>,
sku: CommonProductSku,
): Record<string, unknown> {
return {
...productDto,
skuCode: sku.skuCode,
spec: sku.specText || productDto.spec,
price: Number(sku.price),
benefitAmount: Number(sku.benefitAmount ?? sku.price),
benefitDisplay: Number(sku.benefitAmount ?? sku.price),
allowOnSitePickup: sku.allowOnSitePickup,
allowOnlinePurchase: sku.allowOnlinePurchase,
allowCrossCityDelivery: sku.allowCrossCityDelivery,
saleUnit: sku.saleUnit,
bottlesPerUnit: sku.bottlesPerUnit,
selectedSkuId: sku.id.toString(),
};
}
async preview(
userId: bigint,
body: { productId: string; quantity: number; addressId?: string; onSitePickup?: boolean },
body: {
productId: string;
quantity: number;
addressId?: string;
onSitePickup?: boolean;
skuId?: string;
},
) {
const viewerPhone = await this.catalogService.resolveUserPhone(userId);
const product = await this.catalogService.getProduct(BigInt(body.productId), { phone: viewerPhone });
if (!product || product.status !== 'ON_SALE') {
const { product: spu, sku } = await this.catalogService.assertPurchasable(
BigInt(body.productId),
viewerPhone,
body.skuId,
);
const productDto = await this.catalogService.getProduct(spu.id, { phone: viewerPhone });
if (!productDto || productDto.status !== 'ON_SALE') {
throw new BadRequestException('商品不可购买');
}
const product = this.overlayProductWithSku(productDto as Record<string, unknown>, sku);
const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } });
if (!city) throw new BadRequestException('暂无开城城市');
const onSitePickup = !!body.onSitePickup;
if (onSitePickup && !product.allowOnSitePickup) {
throw new BadRequestException('该商品不支持现场取货');
if (onSitePickup && !sku.allowOnSitePickup) {
throw new BadRequestException('该规格不支持现场取货');
}
let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = onSitePickup
@@ -95,43 +129,46 @@ export class TradeService {
let addressOk = true;
let addressMessage: string | null = null;
if (!onSitePickup) {
const allowOnline = product.allowOnlinePurchase !== false;
const allowCross = product.allowCrossCityDelivery !== false;
const allowOnline = sku.allowOnlinePurchase !== false;
const allowCross = sku.allowCrossCityDelivery !== false;
if (deliveryType === 'LOCAL' && !allowOnline) {
addressOk = false;
addressMessage = '该商品不支持线上购买';
addressMessage = '该规格不支持线上购买';
} else if (deliveryType === 'CROSS_CITY') {
if (!allowOnline) {
addressOk = false;
addressMessage = '该商品不支持线上购买';
addressMessage = '该规格不支持线上购买';
} else if (!allowCross) {
addressOk = false;
addressMessage = '该商品不支持跨城配送,请更换为开城城市内的收货地址';
addressMessage = '该规格不支持跨城配送,请更换为开城城市内的收货地址';
}
}
}
const bottlesPerUnit = sku.bottlesPerUnit > 0 ? sku.bottlesPerUnit : 1;
const check = validateMinPurchase(
deliveryType,
body.quantity,
city.localMinQty,
city.crossMinQty,
{ bottlesPerUnit, saleUnit: sku.saleUnit },
);
const unitPrice = Number(product.price);
const unitPrice = Number(sku.price);
const productAmount = unitPrice * body.quantity;
const benefitPerUnit = calcBenefitAmount({
price: unitPrice,
benefitAmount: product.benefitAmount ? Number(product.benefitAmount) : null,
benefitAmount: sku.benefitAmount != null ? Number(sku.benefitAmount) : null,
});
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
const minQty =
const minBottleQty =
deliveryType === 'ON_SITE_PICKUP'
? city.localMinQty
: deliveryType === 'LOCAL'
? city.localMinQty
: city.crossMinQty;
const minQty = toMinSaleQuantity(minBottleQty, bottlesPerUnit);
return {
product,
@@ -143,16 +180,18 @@ export class TradeService {
payAmount: productAmount,
benefitAmount: benefitPerUnit * body.quantity,
city: serializeBigInt(city),
/** 起购未满足时仍返回预览,供确认页改数量;下单接口仍会硬校验 */
quantityOk: check.ok,
quantityMessage: check.ok ? null : (check.message ?? null),
/** 地址/履约未满足时仍返回预览,供确认页提示换地址;下单接口仍会硬校验 */
addressOk,
addressMessage,
minQty,
onSitePickup,
allowCrossCityDelivery: product.allowCrossCityDelivery !== false,
allowOnlinePurchase: product.allowOnlinePurchase !== false,
allowCrossCityDelivery: sku.allowCrossCityDelivery !== false,
allowOnlinePurchase: sku.allowOnlinePurchase !== false,
skuId: sku.id.toString(),
saleUnit: sku.saleUnit,
bottlesPerUnit,
bottleQuantity: body.quantity * bottlesPerUnit,
};
}
@@ -164,6 +203,7 @@ export class TradeService {
addressId?: string;
onSitePickup?: boolean;
clientLocation?: unknown;
skuId?: string;
},
req: Request,
) {
@@ -207,6 +247,9 @@ export class TradeService {
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
where: { id: BigInt(body.productId) },
});
const sku = await this.prisma.commonProductSku.findUniqueOrThrow({
where: { id: BigInt(preview.skuId) },
});
const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
const orderNo = generateOrderNo();
const payExpireAt = new Date(Date.now() + 30 * 60 * 1000);
@@ -239,12 +282,15 @@ export class TradeService {
payStatus: 'UNPAID',
deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
productId: product.id,
barcode69: product.barcode69,
skuId: sku.id,
barcode69: sku.barcode69,
productName: product.name,
productSpec: product.spec,
productSpec: sku.specText || product.spec,
imageResourceId: product.coverResourceId,
quantity: body.quantity,
listUnitPrice: product.price,
saleUnit: sku.saleUnit,
bottlesPerUnit: preview.bottlesPerUnit,
listUnitPrice: sku.price,
listAmount: preview.productAmount,
productAmount: preview.productAmount,
receiverName,
@@ -300,6 +346,7 @@ export class TradeService {
extraJson: {
orderId: order.id.toString(),
productId: body.productId,
skuId: sku.id.toString(),
quantity: body.quantity,
onSitePickup,
},
@@ -1503,18 +1550,28 @@ export class TradeService {
},
}),
]);
const details = await Promise.all(
products.map((p) =>
this.catalogService.getProduct(BigInt(String(p.id)), { phone: primary.phone }),
),
);
return serializeBigInt({
products: products.map((p) => ({
id: p.id,
name: p.name,
spec: p.spec,
price: Number(p.price),
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean }).allowOnlinePurchase !== false,
products: details.filter(Boolean).map((p) => ({
id: p!.id,
name: p!.name,
spec: p!.spec,
price: Number(p!.price),
benefitAmount: p!.benefitAmount != null ? Number(p!.benefitAmount) : null,
coverUrl: (p as { mainImageUrl?: string | null } | null)?.mainImageUrl ?? null,
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean } | null)?.allowOnSitePickup,
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean } | null)?.allowOnlinePurchase !== false,
allowCrossCityDelivery:
(p as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery !== false,
(p as { allowCrossCityDelivery?: boolean } | null)?.allowCrossCityDelivery !== false,
specEnabled: !!(p as { specEnabled?: boolean } | null)?.specEnabled,
saleUnit: (p as { saleUnit?: string } | null)?.saleUnit,
skus: (p as { skus?: unknown[] } | null)?.skus ?? [],
defaultSkuId: (p as { defaultSkuId?: string } | null)?.defaultSkuId,
specAttrs: (p as { specAttrs?: unknown[] } | null)?.specAttrs ?? [],
})),
promoCodes,
stores: stores.map((s) => ({
@@ -1537,13 +1594,25 @@ export class TradeService {
storeId?: string;
receiverCity?: string;
receiverDistrict?: string;
skuId?: string;
},
viewer?: { phone?: string | null; bypassWhitelist?: boolean },
) {
const product = await this.catalogService.getProduct(BigInt(body.productId), viewer ?? {});
if (!product || product.status !== 'ON_SALE') {
const { product: spu, sku } = await this.catalogService.assertPurchasable(
BigInt(body.productId),
viewer?.phone,
body.skuId,
{ bypassWhitelist: !!viewer?.bypassWhitelist },
);
if (spu.status !== 'ON_SALE') {
throw new BadRequestException('商品不可购买');
}
// 非 bypass 时再走可见性(与 getProduct 一致)
if (!viewer?.bypassWhitelist) {
const dto = await this.catalogService.getProduct(spu.id, viewer ?? {});
if (!dto) throw new BadRequestException('商品不可购买');
}
const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } });
if (!city) throw new BadRequestException('暂无开城城市');
@@ -1551,8 +1620,8 @@ export class TradeService {
let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = 'LOCAL';
if (deliveryMode === 'ON_SITE_PICKUP') {
if (!product.allowOnSitePickup) {
throw new BadRequestException('该商品不支持现场提货');
if (!sku.allowOnSitePickup) {
throw new BadRequestException('该规格不支持现场提货');
}
deliveryType = 'ON_SITE_PICKUP';
} else {
@@ -1560,34 +1629,36 @@ export class TradeService {
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
deliveryType = 'CROSS_CITY';
}
const allowOnline = product.allowOnlinePurchase !== false;
const allowCross = product.allowCrossCityDelivery !== false;
const allowOnline = sku.allowOnlinePurchase !== false;
const allowCross = sku.allowCrossCityDelivery !== false;
if (deliveryType === 'LOCAL' && !allowOnline) {
throw new BadRequestException('该商品不支持线上购买');
throw new BadRequestException('该规格不支持线上购买');
}
if (deliveryType === 'CROSS_CITY') {
if (!allowOnline) {
throw new BadRequestException('该商品不支持线上购买');
throw new BadRequestException('该规格不支持线上购买');
}
if (!allowCross) {
throw new BadRequestException('该商品不支持跨城配送');
throw new BadRequestException('该规格不支持跨城配送');
}
}
}
const bottlesPerUnit = sku.bottlesPerUnit > 0 ? sku.bottlesPerUnit : 1;
const check = validateMinPurchase(
deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : 'LOCAL',
deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : deliveryType === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'LOCAL',
body.quantity,
city.localMinQty,
city.crossMinQty,
{ bottlesPerUnit, saleUnit: sku.saleUnit },
);
if (!check.ok) throw new BadRequestException(check.message);
const unitPrice = Number(product.price);
const unitPrice = Number(sku.price);
const productAmount = unitPrice * body.quantity;
const benefitPerUnit = calcBenefitAmount({
price: unitPrice,
benefitAmount: product.benefitAmount ? Number(product.benefitAmount) : null,
benefitAmount: sku.benefitAmount != null ? Number(sku.benefitAmount) : null,
});
return {
@@ -1596,6 +1667,14 @@ export class TradeService {
benefitAmount: benefitPerUnit * body.quantity,
deliveryType,
unitPrice,
skuId: sku.id.toString(),
saleUnit: sku.saleUnit,
bottlesPerUnit,
bottleQuantity: body.quantity * bottlesPerUnit,
minQuantity: toMinSaleQuantity(
deliveryType === 'CROSS_CITY' ? city.crossMinQty : city.localMinQty,
bottlesPerUnit,
),
};
}
@@ -1609,6 +1688,7 @@ export class TradeService {
storeId?: string;
receiverCity?: string;
receiverDistrict?: string;
skuId?: string;
},
) {
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
@@ -1630,6 +1710,7 @@ export class TradeService {
productId: string;
quantity: number;
promoCodeId?: string;
skuId?: string;
},
req: Request,
) {
@@ -1666,6 +1747,7 @@ export class TradeService {
storeId: body.storeId,
receiverCity: body.city,
receiverDistrict: body.district,
skuId: body.skuId,
},
{ phone: primary.phone },
);
@@ -1673,6 +1755,9 @@ export class TradeService {
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
where: { id: BigInt(body.productId) },
});
const sku = await this.prisma.commonProductSku.findUniqueOrThrow({
where: { id: BigInt(preview.skuId) },
});
const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
let receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
@@ -1730,12 +1815,15 @@ export class TradeService {
channelSource: 'PROXY_ONLINE',
promoCodeId,
productId: product.id,
barcode69: product.barcode69,
skuId: sku.id,
barcode69: sku.barcode69,
productName: product.name,
productSpec: product.spec,
productSpec: sku.specText || product.spec,
imageResourceId: product.coverResourceId,
quantity: body.quantity,
listUnitPrice: product.price,
saleUnit: sku.saleUnit,
bottlesPerUnit: preview.bottlesPerUnit,
listUnitPrice: sku.price,
listAmount: preview.productAmount,
productAmount: preview.productAmount,
payAmount: preview.payAmount,
@@ -1993,18 +2081,28 @@ export class TradeService {
this.catalogService.listProducts(undefined, undefined, { bypassWhitelist: true }),
this.promoCodeService.listActiveOptions(),
]);
const details = await Promise.all(
products.map((p) =>
this.catalogService.getProduct(BigInt(String(p.id)), { bypassWhitelist: true }),
),
);
return serializeBigInt({
products: products.map((p) => ({
id: p.id,
name: p.name,
spec: p.spec,
price: Number(p.price),
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean }).allowOnlinePurchase !== false,
products: details.filter(Boolean).map((p) => ({
id: p!.id,
name: p!.name,
spec: p!.spec,
price: Number(p!.price),
benefitAmount: p!.benefitAmount != null ? Number(p!.benefitAmount) : null,
coverUrl: (p as { mainImageUrl?: string | null } | null)?.mainImageUrl ?? null,
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean } | null)?.allowOnSitePickup,
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean } | null)?.allowOnlinePurchase !== false,
allowCrossCityDelivery:
(p as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery !== false,
(p as { allowCrossCityDelivery?: boolean } | null)?.allowCrossCityDelivery !== false,
specEnabled: !!(p as { specEnabled?: boolean } | null)?.specEnabled,
saleUnit: (p as { saleUnit?: string } | null)?.saleUnit,
skus: (p as { skus?: unknown[] } | null)?.skus ?? [],
defaultSkuId: (p as { defaultSkuId?: string } | null)?.defaultSkuId,
specAttrs: (p as { specAttrs?: unknown[] } | null)?.specAttrs ?? [],
})),
promoCodes,
stores: [],
@@ -2025,6 +2123,7 @@ export class TradeService {
productId: string;
quantity: number;
promoCodeId?: string;
skuId?: string;
},
req: Request,
) {
@@ -2064,6 +2163,7 @@ export class TradeService {
deliveryMode: body.deliveryMode,
receiverCity: body.city,
receiverDistrict: body.district,
skuId: body.skuId,
},
{ bypassWhitelist: true },
);
@@ -2071,6 +2171,9 @@ export class TradeService {
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
where: { id: BigInt(body.productId) },
});
const sku = await this.prisma.commonProductSku.findUniqueOrThrow({
where: { id: BigInt(preview.skuId) },
});
const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
let receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
@@ -2128,12 +2231,15 @@ export class TradeService {
channelSource: 'PROXY_ONLINE',
promoCodeId,
productId: product.id,
barcode69: product.barcode69,
skuId: sku.id,
barcode69: sku.barcode69,
productName: product.name,
productSpec: product.spec,
productSpec: sku.specText || product.spec,
imageResourceId: product.coverResourceId,
quantity: body.quantity,
listUnitPrice: product.price,
saleUnit: sku.saleUnit,
bottlesPerUnit: preview.bottlesPerUnit,
listUnitPrice: sku.price,
listAmount: preview.productAmount,
productAmount: preview.productAmount,
payAmount: preview.payAmount,