@@ -38,7 +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';
|
||||
import type { ProductSaleUnit } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class TradeService {
|
||||
@@ -64,23 +64,34 @@ export class TradeService {
|
||||
|
||||
private readonly logger = new Logger(TradeService.name);
|
||||
|
||||
private overlayProductWithSku(
|
||||
private overlayProductWithSale(
|
||||
productDto: Record<string, unknown>,
|
||||
sku: CommonProductSku,
|
||||
sale: {
|
||||
skuId: bigint | null;
|
||||
skuCode: string;
|
||||
specText: string;
|
||||
price: unknown;
|
||||
benefitAmount: unknown;
|
||||
allowOnSitePickup: boolean;
|
||||
allowOnlinePurchase: boolean;
|
||||
allowCrossCityDelivery: boolean;
|
||||
saleUnit: ProductSaleUnit;
|
||||
bottlesPerUnit: number;
|
||||
},
|
||||
): 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(),
|
||||
skuCode: sale.skuCode,
|
||||
spec: sale.specText || productDto.spec,
|
||||
price: Number(sale.price),
|
||||
benefitAmount: Number(sale.benefitAmount ?? sale.price),
|
||||
benefitDisplay: Number(sale.benefitAmount ?? sale.price),
|
||||
allowOnSitePickup: sale.allowOnSitePickup,
|
||||
allowOnlinePurchase: sale.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: sale.allowCrossCityDelivery,
|
||||
saleUnit: sale.saleUnit,
|
||||
bottlesPerUnit: sale.bottlesPerUnit,
|
||||
...(sale.skuId ? { selectedSkuId: sale.skuId.toString() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,7 +106,7 @@ export class TradeService {
|
||||
},
|
||||
) {
|
||||
const viewerPhone = await this.catalogService.resolveUserPhone(userId);
|
||||
const { product: spu, sku } = await this.catalogService.assertPurchasable(
|
||||
const { product: spu, sale } = await this.catalogService.assertPurchasable(
|
||||
BigInt(body.productId),
|
||||
viewerPhone,
|
||||
body.skuId,
|
||||
@@ -104,13 +115,13 @@ export class TradeService {
|
||||
if (!productDto || productDto.status !== 'ON_SALE') {
|
||||
throw new BadRequestException('商品不可购买');
|
||||
}
|
||||
const product = this.overlayProductWithSku(productDto as Record<string, unknown>, sku);
|
||||
const product = this.overlayProductWithSale(productDto as Record<string, unknown>, sale);
|
||||
|
||||
const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } });
|
||||
if (!city) throw new BadRequestException('暂无开城城市');
|
||||
|
||||
const onSitePickup = !!body.onSitePickup;
|
||||
if (onSitePickup && !sku.allowOnSitePickup) {
|
||||
if (onSitePickup && !sale.allowOnSitePickup) {
|
||||
throw new BadRequestException('该规格不支持现场取货');
|
||||
}
|
||||
|
||||
@@ -129,8 +140,8 @@ export class TradeService {
|
||||
let addressOk = true;
|
||||
let addressMessage: string | null = null;
|
||||
if (!onSitePickup) {
|
||||
const allowOnline = sku.allowOnlinePurchase !== false;
|
||||
const allowCross = sku.allowCrossCityDelivery !== false;
|
||||
const allowOnline = sale.allowOnlinePurchase !== false;
|
||||
const allowCross = sale.allowCrossCityDelivery !== false;
|
||||
if (deliveryType === 'LOCAL' && !allowOnline) {
|
||||
addressOk = false;
|
||||
addressMessage = '该规格不支持线上购买';
|
||||
@@ -145,20 +156,20 @@ export class TradeService {
|
||||
}
|
||||
}
|
||||
|
||||
const bottlesPerUnit = sku.bottlesPerUnit > 0 ? sku.bottlesPerUnit : 1;
|
||||
const bottlesPerUnit = sale.bottlesPerUnit > 0 ? sale.bottlesPerUnit : 1;
|
||||
const check = validateMinPurchase(
|
||||
deliveryType,
|
||||
body.quantity,
|
||||
city.localMinQty,
|
||||
city.crossMinQty,
|
||||
{ bottlesPerUnit, saleUnit: sku.saleUnit },
|
||||
{ bottlesPerUnit, saleUnit: sale.saleUnit },
|
||||
);
|
||||
|
||||
const unitPrice = Number(sku.price);
|
||||
const unitPrice = Number(sale.price);
|
||||
const productAmount = unitPrice * body.quantity;
|
||||
const benefitPerUnit = calcBenefitAmount({
|
||||
price: unitPrice,
|
||||
benefitAmount: sku.benefitAmount != null ? Number(sku.benefitAmount) : null,
|
||||
benefitAmount: sale.benefitAmount != null ? Number(sale.benefitAmount) : null,
|
||||
});
|
||||
|
||||
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
|
||||
@@ -186,10 +197,13 @@ export class TradeService {
|
||||
addressMessage,
|
||||
minQty,
|
||||
onSitePickup,
|
||||
allowCrossCityDelivery: sku.allowCrossCityDelivery !== false,
|
||||
allowOnlinePurchase: sku.allowOnlinePurchase !== false,
|
||||
skuId: sku.id.toString(),
|
||||
saleUnit: sku.saleUnit,
|
||||
allowCrossCityDelivery: sale.allowCrossCityDelivery !== false,
|
||||
allowOnlinePurchase: sale.allowOnlinePurchase !== false,
|
||||
skuId: sale.skuId?.toString(),
|
||||
barcode69: sale.barcode69,
|
||||
productSpec: sale.specText,
|
||||
unitPrice,
|
||||
saleUnit: sale.saleUnit,
|
||||
bottlesPerUnit,
|
||||
bottleQuantity: body.quantity * bottlesPerUnit,
|
||||
};
|
||||
@@ -247,9 +261,6 @@ 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);
|
||||
@@ -282,15 +293,15 @@ export class TradeService {
|
||||
payStatus: 'UNPAID',
|
||||
deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
|
||||
productId: product.id,
|
||||
skuId: sku.id,
|
||||
barcode69: sku.barcode69,
|
||||
skuId: preview.skuId ? BigInt(preview.skuId) : null,
|
||||
barcode69: preview.barcode69 || product.barcode69,
|
||||
productName: product.name,
|
||||
productSpec: sku.specText || product.spec,
|
||||
productSpec: preview.productSpec || product.spec,
|
||||
imageResourceId: product.coverResourceId,
|
||||
quantity: body.quantity,
|
||||
saleUnit: sku.saleUnit,
|
||||
saleUnit: preview.saleUnit,
|
||||
bottlesPerUnit: preview.bottlesPerUnit,
|
||||
listUnitPrice: sku.price,
|
||||
listUnitPrice: preview.unitPrice,
|
||||
listAmount: preview.productAmount,
|
||||
productAmount: preview.productAmount,
|
||||
receiverName,
|
||||
@@ -346,7 +357,7 @@ export class TradeService {
|
||||
extraJson: {
|
||||
orderId: order.id.toString(),
|
||||
productId: body.productId,
|
||||
skuId: sku.id.toString(),
|
||||
skuId: preview.skuId,
|
||||
quantity: body.quantity,
|
||||
onSitePickup,
|
||||
},
|
||||
@@ -1080,6 +1091,7 @@ export class TradeService {
|
||||
titleId?: string;
|
||||
titleType?: string;
|
||||
invoiceKind?: string;
|
||||
invoiceCategory?: string;
|
||||
titleName?: string;
|
||||
taxNo?: string | null;
|
||||
addressPhone?: string | null;
|
||||
@@ -1088,6 +1100,7 @@ export class TradeService {
|
||||
phone?: string;
|
||||
remark?: string;
|
||||
},
|
||||
opts?: { allowSpecialKind?: boolean },
|
||||
) {
|
||||
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
@@ -1137,7 +1150,8 @@ export class TradeService {
|
||||
if (resolved.titleType === 'ENTERPRISE' && !resolved.taxNo?.trim()) {
|
||||
throw new BadRequestException('企业抬头须填写税号');
|
||||
}
|
||||
const invoiceKind = body.invoiceKind || 'NORMAL';
|
||||
const invoiceKind =
|
||||
opts?.allowSpecialKind && body.invoiceKind === 'SPECIAL' ? 'SPECIAL' : 'NORMAL';
|
||||
if (invoiceKind === 'SPECIAL') {
|
||||
if (resolved.titleType !== 'ENTERPRISE') {
|
||||
throw new BadRequestException('专用发票仅支持企业抬头');
|
||||
@@ -1146,6 +1160,7 @@ export class TradeService {
|
||||
throw new BadRequestException('专用发票须填写税号、地址电话与开户行账号');
|
||||
}
|
||||
}
|
||||
const invoiceCategory = body.invoiceCategory === 'CATERING' ? 'CATERING' : 'LIQUOR';
|
||||
|
||||
const invoice = await this.prisma.userInvoice.create({
|
||||
data: {
|
||||
@@ -1154,6 +1169,7 @@ export class TradeService {
|
||||
userId,
|
||||
titleType: resolved.titleType as never,
|
||||
invoiceKind: invoiceKind as never,
|
||||
invoiceCategory: invoiceCategory as never,
|
||||
titleName: resolved.titleName.trim(),
|
||||
taxNo: resolved.taxNo?.trim() || null,
|
||||
addressPhone: resolved.addressPhone?.trim() || null,
|
||||
@@ -1161,7 +1177,7 @@ export class TradeService {
|
||||
email: resolved.email.trim(),
|
||||
phone: resolved.phone.trim(),
|
||||
remark: body.remark?.trim() || null,
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
if (!order.isTest) {
|
||||
@@ -1254,6 +1270,7 @@ export class TradeService {
|
||||
titleId?: string;
|
||||
titleType?: string;
|
||||
invoiceKind?: string;
|
||||
invoiceCategory?: string;
|
||||
titleName?: string;
|
||||
taxNo?: string;
|
||||
addressPhone?: string;
|
||||
@@ -1267,7 +1284,7 @@ export class TradeService {
|
||||
if (!orderNo) throw new BadRequestException('请填写订单号');
|
||||
const order = await this.prisma.order.findFirst({ where: { orderNo } });
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
return this.createInvoice(order.userId, order.id, body);
|
||||
return this.createInvoice(order.userId, order.id, body, { allowSpecialKind: true });
|
||||
}
|
||||
|
||||
async adminListInvoices(query: {
|
||||
@@ -1598,7 +1615,7 @@ export class TradeService {
|
||||
},
|
||||
viewer?: { phone?: string | null; bypassWhitelist?: boolean },
|
||||
) {
|
||||
const { product: spu, sku } = await this.catalogService.assertPurchasable(
|
||||
const { product: spu, sale } = await this.catalogService.assertPurchasable(
|
||||
BigInt(body.productId),
|
||||
viewer?.phone,
|
||||
body.skuId,
|
||||
@@ -1620,7 +1637,7 @@ export class TradeService {
|
||||
let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = 'LOCAL';
|
||||
|
||||
if (deliveryMode === 'ON_SITE_PICKUP') {
|
||||
if (!sku.allowOnSitePickup) {
|
||||
if (!sale.allowOnSitePickup) {
|
||||
throw new BadRequestException('该规格不支持现场提货');
|
||||
}
|
||||
deliveryType = 'ON_SITE_PICKUP';
|
||||
@@ -1629,8 +1646,8 @@ export class TradeService {
|
||||
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
|
||||
deliveryType = 'CROSS_CITY';
|
||||
}
|
||||
const allowOnline = sku.allowOnlinePurchase !== false;
|
||||
const allowCross = sku.allowCrossCityDelivery !== false;
|
||||
const allowOnline = sale.allowOnlinePurchase !== false;
|
||||
const allowCross = sale.allowCrossCityDelivery !== false;
|
||||
if (deliveryType === 'LOCAL' && !allowOnline) {
|
||||
throw new BadRequestException('该规格不支持线上购买');
|
||||
}
|
||||
@@ -1644,21 +1661,21 @@ export class TradeService {
|
||||
}
|
||||
}
|
||||
|
||||
const bottlesPerUnit = sku.bottlesPerUnit > 0 ? sku.bottlesPerUnit : 1;
|
||||
const bottlesPerUnit = sale.bottlesPerUnit > 0 ? sale.bottlesPerUnit : 1;
|
||||
const check = validateMinPurchase(
|
||||
deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : deliveryType === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'LOCAL',
|
||||
body.quantity,
|
||||
city.localMinQty,
|
||||
city.crossMinQty,
|
||||
{ bottlesPerUnit, saleUnit: sku.saleUnit },
|
||||
{ bottlesPerUnit, saleUnit: sale.saleUnit },
|
||||
);
|
||||
if (!check.ok) throw new BadRequestException(check.message);
|
||||
|
||||
const unitPrice = Number(sku.price);
|
||||
const unitPrice = Number(sale.price);
|
||||
const productAmount = unitPrice * body.quantity;
|
||||
const benefitPerUnit = calcBenefitAmount({
|
||||
price: unitPrice,
|
||||
benefitAmount: sku.benefitAmount != null ? Number(sku.benefitAmount) : null,
|
||||
benefitAmount: sale.benefitAmount != null ? Number(sale.benefitAmount) : null,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -1667,8 +1684,10 @@ export class TradeService {
|
||||
benefitAmount: benefitPerUnit * body.quantity,
|
||||
deliveryType,
|
||||
unitPrice,
|
||||
skuId: sku.id.toString(),
|
||||
saleUnit: sku.saleUnit,
|
||||
skuId: sale.skuId?.toString(),
|
||||
barcode69: sale.barcode69,
|
||||
productSpec: sale.specText,
|
||||
saleUnit: sale.saleUnit,
|
||||
bottlesPerUnit,
|
||||
bottleQuantity: body.quantity * bottlesPerUnit,
|
||||
minQuantity: toMinSaleQuantity(
|
||||
@@ -1755,9 +1774,6 @@ 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)}`;
|
||||
@@ -1815,15 +1831,15 @@ export class TradeService {
|
||||
channelSource: 'PROXY_ONLINE',
|
||||
promoCodeId,
|
||||
productId: product.id,
|
||||
skuId: sku.id,
|
||||
barcode69: sku.barcode69,
|
||||
skuId: preview.skuId ? BigInt(preview.skuId) : null,
|
||||
barcode69: preview.barcode69 || product.barcode69,
|
||||
productName: product.name,
|
||||
productSpec: sku.specText || product.spec,
|
||||
productSpec: preview.productSpec || product.spec,
|
||||
imageResourceId: product.coverResourceId,
|
||||
quantity: body.quantity,
|
||||
saleUnit: sku.saleUnit,
|
||||
saleUnit: preview.saleUnit,
|
||||
bottlesPerUnit: preview.bottlesPerUnit,
|
||||
listUnitPrice: sku.price,
|
||||
listUnitPrice: preview.unitPrice,
|
||||
listAmount: preview.productAmount,
|
||||
productAmount: preview.productAmount,
|
||||
payAmount: preview.payAmount,
|
||||
@@ -2171,9 +2187,6 @@ 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)}`;
|
||||
@@ -2231,15 +2244,15 @@ export class TradeService {
|
||||
channelSource: 'PROXY_ONLINE',
|
||||
promoCodeId,
|
||||
productId: product.id,
|
||||
skuId: sku.id,
|
||||
barcode69: sku.barcode69,
|
||||
skuId: preview.skuId ? BigInt(preview.skuId) : null,
|
||||
barcode69: preview.barcode69 || product.barcode69,
|
||||
productName: product.name,
|
||||
productSpec: sku.specText || product.spec,
|
||||
productSpec: preview.productSpec || product.spec,
|
||||
imageResourceId: product.coverResourceId,
|
||||
quantity: body.quantity,
|
||||
saleUnit: sku.saleUnit,
|
||||
saleUnit: preview.saleUnit,
|
||||
bottlesPerUnit: preview.bottlesPerUnit,
|
||||
listUnitPrice: sku.price,
|
||||
listUnitPrice: preview.unitPrice,
|
||||
listAmount: preview.productAmount,
|
||||
productAmount: preview.productAmount,
|
||||
payAmount: preview.payAmount,
|
||||
|
||||
Reference in New Issue
Block a user