@@ -6,6 +6,14 @@ import {
|
||||
normalizeTestPhone,
|
||||
} from '../../common/test-whitelist/test-whitelist.service';
|
||||
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
|
||||
import {
|
||||
flattenSkuOntoProduct,
|
||||
listMinOnSalePrice,
|
||||
mapSkuDto,
|
||||
mapSpecAttrsDto,
|
||||
pickDisplaySku,
|
||||
resolveOrderSku,
|
||||
} from './product-sku.util';
|
||||
|
||||
export type CatalogViewer = {
|
||||
/** C 端用户手机号;无则无法看到白名单商品 */
|
||||
@@ -81,6 +89,8 @@ export class CatalogService {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
coverResource: true,
|
||||
skus: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||
specAttrs: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -106,12 +116,25 @@ export class CatalogService {
|
||||
return serializeBigInt(
|
||||
visible.map((p) => {
|
||||
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
|
||||
const { visibilityWhitelistEnabled: _wl, ...rest } = p;
|
||||
const { visibilityWhitelistEnabled: _wl, skus, specAttrs, ...rest } = p;
|
||||
const display = pickDisplaySku(skus);
|
||||
const flat = flattenSkuOntoProduct(p, display);
|
||||
const minPrice = listMinOnSalePrice(skus);
|
||||
const price = minPrice ?? flat.price;
|
||||
const benefitAmount = flat.benefitAmount;
|
||||
return {
|
||||
...rest,
|
||||
benefitAmount: p.benefitAmount ?? p.price,
|
||||
price: Number(p.price),
|
||||
benefitDisplay: Number(p.benefitAmount ?? p.price),
|
||||
skuCode: flat.skuCode,
|
||||
barcode69: undefined,
|
||||
spec: flat.spec,
|
||||
price,
|
||||
benefitAmount,
|
||||
benefitDisplay: benefitAmount,
|
||||
allowOnSitePickup: flat.allowOnSitePickup,
|
||||
allowOnlinePurchase: flat.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: flat.allowCrossCityDelivery,
|
||||
specEnabled: specAttrs.length > 0 || skus.length > 1,
|
||||
saleUnit: flat.saleUnit,
|
||||
...media,
|
||||
};
|
||||
}),
|
||||
@@ -123,6 +146,14 @@ export class CatalogService {
|
||||
where: { id },
|
||||
include: {
|
||||
coverResource: true,
|
||||
skus: {
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
include: { skuSpecs: { select: { valueId: true } } },
|
||||
},
|
||||
specAttrs: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: { values: { orderBy: { sortOrder: 'asc' } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!product) return null;
|
||||
@@ -144,30 +175,57 @@ export class CatalogService {
|
||||
});
|
||||
|
||||
const media = mapProductMedia(product, resources);
|
||||
const { visibilityWhitelistEnabled: _wl, ...rest } = product;
|
||||
const { visibilityWhitelistEnabled: _wl, skus, specAttrs, ...rest } = product;
|
||||
const display = pickDisplaySku(skus);
|
||||
const flat = flattenSkuOntoProduct(product, display);
|
||||
const defaultSku = skus.find((s) => s.isDefault) ?? display;
|
||||
const cSkus = skus.map((s) => {
|
||||
const dto = mapSkuDto(s);
|
||||
const { skuCode: _c, barcode69: _b, sortOrder: _o, ...publicSku } = dto;
|
||||
return publicSku;
|
||||
});
|
||||
|
||||
return serializeBigInt({
|
||||
...rest,
|
||||
benefitAmount: product.benefitAmount ?? product.price,
|
||||
price: Number(product.price),
|
||||
skuCode: flat.skuCode,
|
||||
spec: flat.spec,
|
||||
price: flat.price,
|
||||
benefitAmount: flat.benefitAmount,
|
||||
benefitDisplay: flat.benefitAmount,
|
||||
allowOnSitePickup: flat.allowOnSitePickup,
|
||||
allowOnlinePurchase: flat.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: flat.allowCrossCityDelivery,
|
||||
saleUnit: flat.saleUnit,
|
||||
specEnabled: specAttrs.length > 0 || skus.length > 1,
|
||||
specAttrs: mapSpecAttrsDto(specAttrs),
|
||||
skus: cSkus,
|
||||
defaultSkuId: defaultSku?.id.toString(),
|
||||
...media,
|
||||
});
|
||||
}
|
||||
|
||||
/** 下单前校验:白名单商品仅全局测试白名单手机号可买 */
|
||||
async assertPurchasable(productId: bigint, viewerPhone?: string | null) {
|
||||
/** 下单前校验:白名单商品仅全局测试白名单手机号可买;返回 SPU + 解析后的 SKU */
|
||||
async assertPurchasable(
|
||||
productId: bigint,
|
||||
viewerPhone?: string | null,
|
||||
skuId?: string | null,
|
||||
options?: { bypassWhitelist?: boolean },
|
||||
) {
|
||||
const product = await this.prisma.commonProductItem.findUnique({
|
||||
where: { id: productId },
|
||||
include: { skus: true },
|
||||
});
|
||||
if (!product || product.status !== 'ON_SALE') {
|
||||
throw new BadRequestException('商品不可购买');
|
||||
}
|
||||
if (product.visibilityWhitelistEnabled) {
|
||||
if (!options?.bypassWhitelist && product.visibilityWhitelistEnabled) {
|
||||
const ok = await this.testWhitelist.isPhoneInWhitelist(viewerPhone);
|
||||
if (!ok) {
|
||||
throw new BadRequestException('该商品暂不对当前账号开放');
|
||||
}
|
||||
}
|
||||
return product;
|
||||
const sku = resolveOrderSku(product.skus, skuId);
|
||||
return { product, sku };
|
||||
}
|
||||
|
||||
async resolveUserPhone(userId: bigint): Promise<string | null> {
|
||||
@@ -177,4 +235,12 @@ export class CatalogService {
|
||||
});
|
||||
return user?.phone ?? null;
|
||||
}
|
||||
|
||||
async listSkusForProduct(productId: bigint) {
|
||||
return this.prisma.commonProductSku.findMany({
|
||||
where: { productId },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
include: { skuSpecs: { select: { valueId: true } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { CommonProductItem, CommonProductSku, ProductSaleUnit } from '@prisma/client';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BOTTLES_PER_BOX } from '@dukang/domain';
|
||||
|
||||
export type SkuWithSpecs = CommonProductSku & {
|
||||
skuSpecs?: Array<{ valueId: bigint; value?: { id: bigint; name: string; attrId: bigint } }>;
|
||||
};
|
||||
|
||||
export type SpecAttrWithValues = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
values: Array<{ id: bigint; name: string; sortOrder: number }>;
|
||||
};
|
||||
|
||||
/** 可售 SKU */
|
||||
export function isSkuOnSale(sku: { status: string }): boolean {
|
||||
return sku.status === 'ON_SALE';
|
||||
}
|
||||
|
||||
export function buildSpecKey(valueIds: bigint[]): string {
|
||||
if (!valueIds.length) return '';
|
||||
return [...valueIds]
|
||||
.map((id) => id.toString())
|
||||
.sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : BigInt(a) > BigInt(b) ? 1 : 0))
|
||||
.join('_');
|
||||
}
|
||||
|
||||
export function buildSpecText(
|
||||
valueIds: bigint[],
|
||||
valueNameById: Map<string, string>,
|
||||
): string {
|
||||
if (!valueIds.length) return '';
|
||||
return valueIds
|
||||
.map((id) => valueNameById.get(id.toString()) ?? '')
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析下单 SKU:显式 skuId,或单可售自动回落;多可售且未传则 400。
|
||||
*/
|
||||
export function resolveOrderSku(
|
||||
skus: CommonProductSku[],
|
||||
skuId?: string | null,
|
||||
): CommonProductSku {
|
||||
const onSale = skus.filter(isSkuOnSale);
|
||||
if (skuId) {
|
||||
const found = skus.find((s) => s.id.toString() === String(skuId));
|
||||
if (!found) throw new BadRequestException('规格不存在');
|
||||
if (!isSkuOnSale(found)) throw new BadRequestException('该规格暂不可购买');
|
||||
return found;
|
||||
}
|
||||
if (onSale.length === 1) return onSale[0];
|
||||
if (onSale.length === 0) throw new BadRequestException('商品暂无可售规格');
|
||||
throw new BadRequestException('请选择规格');
|
||||
}
|
||||
|
||||
/** 列表/拍平:优先默认可售 → 最低价可售 → 默认任意 → 任意 */
|
||||
export function pickDisplaySku(skus: CommonProductSku[]): CommonProductSku | null {
|
||||
if (!skus.length) return null;
|
||||
const onSale = skus.filter(isSkuOnSale);
|
||||
const pool = onSale.length ? onSale : skus;
|
||||
const def = pool.find((s) => s.isDefault);
|
||||
if (def) return def;
|
||||
return [...pool].sort((a, b) => Number(a.price) - Number(b.price) || a.sortOrder - b.sortOrder)[0];
|
||||
}
|
||||
|
||||
export function listMinOnSalePrice(skus: CommonProductSku[]): number | null {
|
||||
const onSale = skus.filter(isSkuOnSale);
|
||||
if (!onSale.length) return null;
|
||||
return Math.min(...onSale.map((s) => Number(s.price)));
|
||||
}
|
||||
|
||||
export function flattenSkuOntoProduct(
|
||||
product: CommonProductItem,
|
||||
sku: CommonProductSku | null,
|
||||
): {
|
||||
skuCode: string;
|
||||
barcode69: string;
|
||||
spec: string;
|
||||
price: number;
|
||||
benefitAmount: number;
|
||||
allowOnSitePickup: boolean;
|
||||
allowOnlinePurchase: boolean;
|
||||
allowCrossCityDelivery: boolean;
|
||||
saleUnit: ProductSaleUnit;
|
||||
bottlesPerUnit: number;
|
||||
} {
|
||||
if (!sku) {
|
||||
return {
|
||||
skuCode: product.skuCode,
|
||||
barcode69: product.barcode69,
|
||||
spec: product.spec,
|
||||
price: Number(product.price),
|
||||
benefitAmount: Number(product.benefitAmount ?? product.price),
|
||||
allowOnSitePickup: product.allowOnSitePickup,
|
||||
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||
saleUnit: 'BOTTLE',
|
||||
bottlesPerUnit: 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
skuCode: sku.skuCode,
|
||||
barcode69: sku.barcode69,
|
||||
spec: sku.specText || product.spec,
|
||||
price: Number(sku.price),
|
||||
benefitAmount: Number(sku.benefitAmount ?? sku.price),
|
||||
allowOnSitePickup: sku.allowOnSitePickup,
|
||||
allowOnlinePurchase: sku.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: sku.allowCrossCityDelivery,
|
||||
saleUnit: sku.saleUnit,
|
||||
bottlesPerUnit: sku.bottlesPerUnit > 0 ? sku.bottlesPerUnit : sku.saleUnit === 'BOX' ? BOTTLES_PER_BOX : 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapSkuDto(sku: SkuWithSpecs) {
|
||||
const specValueIds = (sku.skuSpecs ?? []).map((row) => row.valueId.toString());
|
||||
return {
|
||||
id: sku.id.toString(),
|
||||
specValueIds,
|
||||
specText: sku.specText,
|
||||
price: Number(sku.price),
|
||||
benefitAmount: Number(sku.benefitAmount ?? sku.price),
|
||||
status: sku.status,
|
||||
allowOnlinePurchase: sku.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: sku.allowCrossCityDelivery,
|
||||
allowOnSitePickup: sku.allowOnSitePickup,
|
||||
saleUnit: sku.saleUnit as 'BOTTLE' | 'BOX',
|
||||
bottlesPerUnit: sku.bottlesPerUnit,
|
||||
isDefault: sku.isDefault,
|
||||
skuCode: sku.skuCode,
|
||||
barcode69: sku.barcode69,
|
||||
sortOrder: sku.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapSpecAttrsDto(attrs: SpecAttrWithValues[]) {
|
||||
return attrs
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((attr) => ({
|
||||
id: attr.id.toString(),
|
||||
name: attr.name,
|
||||
values: attr.values
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((v) => ({ id: v.id.toString(), name: v.name })),
|
||||
}));
|
||||
}
|
||||
|
||||
export function syncDefaultSkuFieldsFromProduct(product: {
|
||||
skuCode: string;
|
||||
barcode69: string;
|
||||
spec: string;
|
||||
price: unknown;
|
||||
benefitAmount: unknown;
|
||||
status: string;
|
||||
allowOnSitePickup: boolean;
|
||||
allowOnlinePurchase: boolean;
|
||||
allowCrossCityDelivery: boolean;
|
||||
}) {
|
||||
return {
|
||||
skuCode: product.skuCode,
|
||||
barcode69: product.barcode69,
|
||||
specText: product.spec,
|
||||
price: product.price as never,
|
||||
benefitAmount: product.benefitAmount as never,
|
||||
status: product.status as never,
|
||||
allowOnSitePickup: product.allowOnSitePickup,
|
||||
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||
};
|
||||
}
|
||||
@@ -76,11 +76,12 @@ export class FulfillmentService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送
|
||||
if (shouldHoldAutoCourierDispatch(order.quantity)) {
|
||||
const boxes = calcOrderBoxCount(order.quantity);
|
||||
// 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送(按瓶当量)
|
||||
const bottleQty = order.quantity * (order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1);
|
||||
if (shouldHoldAutoCourierDispatch(bottleQty)) {
|
||||
const boxes = calcOrderBoxCount(bottleQty);
|
||||
this.logger.warn(
|
||||
`大单拦截自动推单:${order.orderNo} quantity=${order.quantity} bottles≈${boxes}箱(阈值 ${XFX_AUTO_DISPATCH_MAX_BOXES}箱/${BOTTLES_PER_BOX}瓶)`,
|
||||
`大单拦截自动推单:${order.orderNo} quantity=${order.quantity}×${order.bottlesPerUnit} bottles≈${boxes}箱(阈值 ${XFX_AUTO_DISPATCH_MAX_BOXES}箱/${BOTTLES_PER_BOX}瓶)`,
|
||||
);
|
||||
await this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
@@ -97,7 +98,7 @@ export class FulfillmentService {
|
||||
refType: 'ORDER',
|
||||
refId: order.id,
|
||||
status: 'PENDING',
|
||||
errorMessage: `大单拦截:${order.quantity}瓶(约${boxes}箱),需总部确认后推小飞侠或自配送`.slice(
|
||||
errorMessage: `大单拦截:${bottleQty}瓶(约${boxes}箱),需总部确认后推小飞侠或自配送`.slice(
|
||||
0,
|
||||
512,
|
||||
),
|
||||
@@ -155,7 +156,7 @@ export class FulfillmentService {
|
||||
addressDetail: order.receiverAddress,
|
||||
},
|
||||
goodsName: order.productName,
|
||||
goodsNum: order.quantity,
|
||||
goodsNum: order.quantity * (order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1),
|
||||
weight: 2,
|
||||
payMode: CourierPayMode.SENDER,
|
||||
remark: `仓配自动发货 ${order.orderNo}`,
|
||||
|
||||
@@ -4,7 +4,12 @@ import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { AdminProductsService } from './admin-products.service';
|
||||
import { AdminProductsQueryDto } from './dto/admin-query.dto';
|
||||
import { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
|
||||
import {
|
||||
CreateProductDto,
|
||||
SaveProductSkusDto,
|
||||
SaveProductSpecsDto,
|
||||
UpdateProductDto,
|
||||
} from './dto/admin-mutate.dto';
|
||||
|
||||
@Controller('admin/products')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@@ -33,6 +38,18 @@ export class AdminProductsController {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Put(':id/specs')
|
||||
@HqOperation({ action: HqOperationAction.PRODUCT_UPDATE, refType: 'PRODUCT', refIdParam: 'id', includeBody: true })
|
||||
saveSpecs(@Param('id') id: string, @Body() dto: SaveProductSpecsDto) {
|
||||
return this.service.saveSpecs(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Put(':id/skus')
|
||||
@HqOperation({ action: HqOperationAction.PRODUCT_UPDATE, refType: 'PRODUCT', refIdParam: 'id', includeBody: true })
|
||||
saveSkus(@Param('id') id: string, @Body() dto: SaveProductSkusDto) {
|
||||
return this.service.saveSkus(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HqOperation({ action: HqOperationAction.PRODUCT_DELETE, refType: 'PRODUCT', refIdParam: 'id' })
|
||||
remove(@Param('id') id: string) {
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { BOTTLES_PER_BOX } from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.mapper';
|
||||
import {
|
||||
buildSpecKey,
|
||||
buildSpecText,
|
||||
mapSkuDto,
|
||||
mapSpecAttrsDto,
|
||||
} from '../catalog/product-sku.util';
|
||||
import type { AdminProductsQueryDto } from './dto/admin-query.dto';
|
||||
import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
|
||||
import type {
|
||||
CreateProductDto,
|
||||
SaveProductSkusDto,
|
||||
SaveProductSpecsDto,
|
||||
UpdateProductDto,
|
||||
} from './dto/admin-mutate.dto';
|
||||
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
|
||||
|
||||
function normalizePhones(phones?: string[]): string[] {
|
||||
@@ -27,6 +39,8 @@ function normalizePhones(phones?: string[]): string[] {
|
||||
|
||||
const SKU_AUTO_PREFIX = 'DK';
|
||||
const SKU_AUTO_PAD = 6;
|
||||
const MAX_SPEC_ATTRS = 3;
|
||||
const MAX_SPEC_VALUES = 10;
|
||||
|
||||
/** 解析履约开关:无线上则强制不可跨城;须至少线上或现场之一 */
|
||||
function resolveFulfillmentFlags(input: {
|
||||
@@ -80,6 +94,8 @@ export class AdminProductsService {
|
||||
include: {
|
||||
coverResource: true,
|
||||
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
|
||||
skus: { select: { id: true }, take: 2 },
|
||||
specAttrs: { select: { id: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.commonProductItem.count({ where }),
|
||||
@@ -100,7 +116,14 @@ export class AdminProductsService {
|
||||
const resourceMap = groupResourcesByProductId(resources);
|
||||
|
||||
return serializeBigInt({
|
||||
items: items.map((p) => this.formatProduct(p, resourceMap.get(p.id.toString()) ?? [])),
|
||||
items: items.map((p) => {
|
||||
const { skus, specAttrs, ...rest } = p;
|
||||
return {
|
||||
...this.formatProduct(rest as never, resourceMap.get(p.id.toString()) ?? []),
|
||||
specEnabled: specAttrs.length > 0 || skus.length > 1,
|
||||
skuCount: skus.length,
|
||||
};
|
||||
}),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -113,6 +136,14 @@ export class AdminProductsService {
|
||||
include: {
|
||||
coverResource: true,
|
||||
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
|
||||
skus: {
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
include: { skuSpecs: { select: { valueId: true } } },
|
||||
},
|
||||
specAttrs: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: { values: { orderBy: { sortOrder: 'asc' } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!product) throw new NotFoundException('商品不存在');
|
||||
@@ -127,11 +158,18 @@ export class AdminProductsService {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
return serializeBigInt(this.formatProduct(product, resources));
|
||||
const { skus, specAttrs, ...rest } = product;
|
||||
return serializeBigInt({
|
||||
...this.formatProduct(rest as never, resources),
|
||||
specEnabled: specAttrs.length > 0 || skus.length > 1,
|
||||
specAttrs: mapSpecAttrsDto(specAttrs),
|
||||
skus: skus.map(mapSkuDto),
|
||||
defaultSkuId: skus.find((s) => s.isDefault)?.id.toString() ?? skus[0]?.id.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: CreateProductDto) {
|
||||
const barcodeExists = await this.prisma.commonProductItem.findFirst({
|
||||
const barcodeExists = await this.prisma.commonProductSku.findFirst({
|
||||
where: { barcode69: dto.barcode69 },
|
||||
});
|
||||
if (barcodeExists) throw new BadRequestException('69 码已存在');
|
||||
@@ -147,7 +185,6 @@ export class AdminProductsService {
|
||||
if (whitelistEnabled) {
|
||||
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
|
||||
}
|
||||
// 手机号统一在「白名单管理」维护;此处忽略分实体 phones(兼容旧客户端传参)
|
||||
void phones;
|
||||
|
||||
const product = await this.createWithGeneratedSku({
|
||||
@@ -169,6 +206,26 @@ export class AdminProductsService {
|
||||
: {}),
|
||||
});
|
||||
|
||||
await this.prisma.commonProductSku.create({
|
||||
data: {
|
||||
productId: product.id,
|
||||
skuCode: product.skuCode,
|
||||
barcode69: product.barcode69,
|
||||
specKey: '',
|
||||
specText: product.spec,
|
||||
price: product.price,
|
||||
benefitAmount: product.benefitAmount,
|
||||
status: product.status,
|
||||
allowOnSitePickup: product.allowOnSitePickup,
|
||||
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||
saleUnit: 'BOTTLE',
|
||||
bottlesPerUnit: 1,
|
||||
isDefault: true,
|
||||
sortOrder: 0,
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.coverUrl) {
|
||||
await this.syncCover(product.id, dto.coverUrl);
|
||||
}
|
||||
@@ -230,7 +287,9 @@ export class AdminProductsService {
|
||||
if (dto.visibilityWhitelistEnabled) {
|
||||
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
|
||||
}
|
||||
// 分实体手机号已废弃;忽略 dto.visibilityPhones
|
||||
|
||||
// 无规格 payload 时:同步默认 SKU(兼容旧 admin 表单)
|
||||
await this.syncDefaultSkuFromProduct(id);
|
||||
|
||||
if (dto.coverUrl) {
|
||||
await this.syncCover(id, dto.coverUrl);
|
||||
@@ -243,6 +302,281 @@ export class AdminProductsService {
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
async saveSpecs(productId: bigint, dto: SaveProductSpecsDto) {
|
||||
const product = await this.prisma.commonProductItem.findUnique({ where: { id: productId } });
|
||||
if (!product) throw new NotFoundException('商品不存在');
|
||||
|
||||
const attrs = dto.attrs ?? [];
|
||||
if (attrs.length > MAX_SPEC_ATTRS) {
|
||||
throw new BadRequestException(`规格轴最多 ${MAX_SPEC_ATTRS} 个`);
|
||||
}
|
||||
for (const attr of attrs) {
|
||||
if ((attr.values?.length ?? 0) > MAX_SPEC_VALUES) {
|
||||
throw new BadRequestException(`每个规格轴最多 ${MAX_SPEC_VALUES} 个值`);
|
||||
}
|
||||
if (!attr.values?.length) {
|
||||
throw new BadRequestException(`规格「${attr.name}」至少需要一个值`);
|
||||
}
|
||||
}
|
||||
|
||||
const existingAttrs = await this.prisma.commonProductSpecAttr.findMany({
|
||||
where: { productId },
|
||||
include: { values: true },
|
||||
});
|
||||
const existingValueIds = existingAttrs.flatMap((a) => a.values.map((v) => v.id));
|
||||
const keepValueIds = new Set(
|
||||
attrs.flatMap((a) => (a.values ?? []).map((v) => v.id).filter(Boolean) as string[]),
|
||||
);
|
||||
|
||||
for (const vid of existingValueIds) {
|
||||
if (keepValueIds.has(vid.toString())) continue;
|
||||
const used = await this.prisma.commonProductSkuSpec.count({ where: { valueId: vid } });
|
||||
if (used > 0) {
|
||||
const orderCount = await this.prisma.order.count({
|
||||
where: { sku: { skuSpecs: { some: { valueId: vid } } } },
|
||||
});
|
||||
if (orderCount > 0) {
|
||||
throw new BadRequestException('有订单关联的规格值不可删除');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
// 删除未保留的轴(级联值);先清 sku_spec 中将被删的 value
|
||||
const keepAttrIds = new Set(attrs.map((a) => a.id).filter(Boolean) as string[]);
|
||||
for (const old of existingAttrs) {
|
||||
if (!keepAttrIds.has(old.id.toString())) {
|
||||
await tx.commonProductSkuSpec.deleteMany({
|
||||
where: { valueId: { in: old.values.map((v) => v.id) } },
|
||||
});
|
||||
await tx.commonProductSpecAttr.delete({ where: { id: old.id } });
|
||||
}
|
||||
}
|
||||
|
||||
for (let ai = 0; ai < attrs.length; ai++) {
|
||||
const attr = attrs[ai];
|
||||
let attrId: bigint;
|
||||
if (attr.id) {
|
||||
attrId = BigInt(attr.id);
|
||||
await tx.commonProductSpecAttr.update({
|
||||
where: { id: attrId },
|
||||
data: { name: attr.name.trim(), sortOrder: attr.sortOrder ?? ai },
|
||||
});
|
||||
} else {
|
||||
const created = await tx.commonProductSpecAttr.create({
|
||||
data: {
|
||||
productId,
|
||||
name: attr.name.trim(),
|
||||
sortOrder: attr.sortOrder ?? ai,
|
||||
},
|
||||
});
|
||||
attrId = created.id;
|
||||
}
|
||||
|
||||
const oldValues = await tx.commonProductSpecValue.findMany({ where: { attrId } });
|
||||
const keepVids = new Set((attr.values ?? []).map((v) => v.id).filter(Boolean) as string[]);
|
||||
for (const ov of oldValues) {
|
||||
if (!keepVids.has(ov.id.toString())) {
|
||||
await tx.commonProductSkuSpec.deleteMany({ where: { valueId: ov.id } });
|
||||
await tx.commonProductSpecValue.delete({ where: { id: ov.id } });
|
||||
}
|
||||
}
|
||||
|
||||
for (let vi = 0; vi < (attr.values ?? []).length; vi++) {
|
||||
const val = attr.values[vi];
|
||||
if (val.id) {
|
||||
await tx.commonProductSpecValue.update({
|
||||
where: { id: BigInt(val.id) },
|
||||
data: { name: val.name.trim(), sortOrder: val.sortOrder ?? vi },
|
||||
});
|
||||
} else {
|
||||
await tx.commonProductSpecValue.create({
|
||||
data: {
|
||||
attrId,
|
||||
name: val.name.trim(),
|
||||
sortOrder: val.sortOrder ?? vi,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.detail(productId);
|
||||
}
|
||||
|
||||
async saveSkus(productId: bigint, dto: SaveProductSkusDto) {
|
||||
const product = await this.prisma.commonProductItem.findUnique({
|
||||
where: { id: productId },
|
||||
include: {
|
||||
specAttrs: { include: { values: true }, orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!product) throw new NotFoundException('商品不存在');
|
||||
|
||||
const rows = dto.skus ?? [];
|
||||
if (!rows.length) throw new BadRequestException('至少保留一个 SKU');
|
||||
|
||||
const valueNameById = new Map<string, string>();
|
||||
const attrValueSets = product.specAttrs.map((a) => {
|
||||
const set = new Set(a.values.map((v) => v.id.toString()));
|
||||
for (const v of a.values) valueNameById.set(v.id.toString(), v.name);
|
||||
return set;
|
||||
});
|
||||
|
||||
let defaultCount = 0;
|
||||
const seenKeys = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const flags = resolveFulfillmentFlags({
|
||||
allowOnlinePurchase: row.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: row.allowCrossCityDelivery,
|
||||
allowOnSitePickup: row.allowOnSitePickup,
|
||||
defaults: {
|
||||
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||
allowOnSitePickup: product.allowOnSitePickup,
|
||||
},
|
||||
});
|
||||
void flags;
|
||||
if (row.isDefault) defaultCount += 1;
|
||||
const valueIds = (row.specValueIds ?? []).map((id) => BigInt(id));
|
||||
if (attrValueSets.length) {
|
||||
const idSet = new Set(valueIds.map((id) => id.toString()));
|
||||
if (idSet.size !== valueIds.length) {
|
||||
throw new BadRequestException('规格值不可重复');
|
||||
}
|
||||
for (const set of attrValueSets) {
|
||||
const hits = [...set].filter((id) => idSet.has(id));
|
||||
if (hits.length !== 1) {
|
||||
throw new BadRequestException('每个 SKU 须选择每个规格轴的一个值');
|
||||
}
|
||||
}
|
||||
if (valueIds.length !== attrValueSets.length) {
|
||||
throw new BadRequestException('每个 SKU 须选择每个规格轴的一个值');
|
||||
}
|
||||
} else if (valueIds.length) {
|
||||
throw new BadRequestException('商品尚未配置规格轴');
|
||||
}
|
||||
const key = buildSpecKey(valueIds);
|
||||
if (seenKeys.has(key)) throw new BadRequestException('存在重复规格组合');
|
||||
seenKeys.add(key);
|
||||
}
|
||||
if (defaultCount !== 1) {
|
||||
throw new BadRequestException('请且仅指定一个默认 SKU');
|
||||
}
|
||||
|
||||
const barcodes = rows.map((r) => r.barcode69.trim());
|
||||
if (new Set(barcodes).size !== barcodes.length) {
|
||||
throw new BadRequestException('69 码不可重复');
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.commonProductSku.findMany({ where: { productId } });
|
||||
const keepIds = new Set(rows.map((r) => r.id).filter(Boolean) as string[]);
|
||||
|
||||
for (const old of existing) {
|
||||
if (keepIds.has(old.id.toString())) continue;
|
||||
const orderCount = await tx.order.count({ where: { skuId: old.id } });
|
||||
if (orderCount > 0) {
|
||||
throw new BadRequestException(`SKU ${old.skuCode} 已有订单,无法删除`);
|
||||
}
|
||||
await tx.commonProductSkuSpec.deleteMany({ where: { skuId: old.id } });
|
||||
await tx.commonProductSku.delete({ where: { id: old.id } });
|
||||
}
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const valueIds = (row.specValueIds ?? []).map((id) => BigInt(id));
|
||||
const specKey = buildSpecKey(valueIds);
|
||||
const specText =
|
||||
buildSpecText(valueIds, valueNameById) || product.spec || row.barcode69;
|
||||
const flags = resolveFulfillmentFlags({
|
||||
allowOnlinePurchase: row.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: row.allowCrossCityDelivery,
|
||||
allowOnSitePickup: row.allowOnSitePickup,
|
||||
defaults: {
|
||||
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||
allowOnSitePickup: product.allowOnSitePickup,
|
||||
},
|
||||
});
|
||||
const saleUnit = row.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE';
|
||||
const bottlesPerUnit =
|
||||
row.bottlesPerUnit && row.bottlesPerUnit > 0
|
||||
? Math.floor(row.bottlesPerUnit)
|
||||
: saleUnit === 'BOX'
|
||||
? BOTTLES_PER_BOX
|
||||
: 1;
|
||||
const status = (row.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE';
|
||||
const skuCode = row.skuCode?.trim() || (await this.nextAutoSkuCodeTx(tx));
|
||||
|
||||
let skuId: bigint;
|
||||
if (row.id) {
|
||||
skuId = BigInt(row.id);
|
||||
await tx.commonProductSku.update({
|
||||
where: { id: skuId },
|
||||
data: {
|
||||
skuCode,
|
||||
barcode69: row.barcode69.trim(),
|
||||
specKey,
|
||||
specText,
|
||||
price: row.price,
|
||||
benefitAmount: row.benefitAmount ?? row.price,
|
||||
status,
|
||||
...flags,
|
||||
saleUnit,
|
||||
bottlesPerUnit,
|
||||
isDefault: !!row.isDefault,
|
||||
sortOrder: row.sortOrder ?? i,
|
||||
},
|
||||
});
|
||||
await tx.commonProductSkuSpec.deleteMany({ where: { skuId } });
|
||||
} else {
|
||||
const created = await tx.commonProductSku.create({
|
||||
data: {
|
||||
productId,
|
||||
skuCode,
|
||||
barcode69: row.barcode69.trim(),
|
||||
specKey,
|
||||
specText,
|
||||
price: row.price,
|
||||
benefitAmount: row.benefitAmount ?? row.price,
|
||||
status,
|
||||
...flags,
|
||||
saleUnit,
|
||||
bottlesPerUnit,
|
||||
isDefault: !!row.isDefault,
|
||||
sortOrder: row.sortOrder ?? i,
|
||||
},
|
||||
});
|
||||
skuId = created.id;
|
||||
}
|
||||
|
||||
if (valueIds.length) {
|
||||
await tx.commonProductSkuSpec.createMany({
|
||||
data: valueIds.map((valueId) => ({ skuId, valueId })),
|
||||
});
|
||||
}
|
||||
|
||||
if (row.isDefault) {
|
||||
await tx.commonProductItem.update({
|
||||
where: { id: productId },
|
||||
data: {
|
||||
skuCode,
|
||||
barcode69: row.barcode69.trim(),
|
||||
spec: specText,
|
||||
price: row.price,
|
||||
benefitAmount: row.benefitAmount ?? row.price,
|
||||
...flags,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.detail(productId);
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
const product = await this.prisma.commonProductItem.findUnique({ where: { id } });
|
||||
if (!product) throw new NotFoundException('商品不存在');
|
||||
@@ -260,15 +594,82 @@ export class AdminProductsService {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async syncDefaultSkuFromProduct(productId: bigint) {
|
||||
const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id: productId } });
|
||||
const defaultSku =
|
||||
(await this.prisma.commonProductSku.findFirst({
|
||||
where: { productId, isDefault: true },
|
||||
})) ??
|
||||
(await this.prisma.commonProductSku.findFirst({
|
||||
where: { productId },
|
||||
orderBy: { id: 'asc' },
|
||||
}));
|
||||
|
||||
if (!defaultSku) {
|
||||
await this.prisma.commonProductSku.create({
|
||||
data: {
|
||||
productId,
|
||||
skuCode: product.skuCode,
|
||||
barcode69: product.barcode69,
|
||||
specKey: '',
|
||||
specText: product.spec,
|
||||
price: product.price,
|
||||
benefitAmount: product.benefitAmount,
|
||||
status: product.status,
|
||||
allowOnSitePickup: product.allowOnSitePickup,
|
||||
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||
saleUnit: 'BOTTLE',
|
||||
bottlesPerUnit: 1,
|
||||
isDefault: true,
|
||||
sortOrder: 0,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 仅当该商品只有 1 个 SKU 时,旧表单字段同步到默认 SKU(避免误改多规格)
|
||||
const skuCount = await this.prisma.commonProductSku.count({ where: { productId } });
|
||||
if (skuCount > 1) return;
|
||||
|
||||
await this.prisma.commonProductSku.update({
|
||||
where: { id: defaultSku.id },
|
||||
data: {
|
||||
skuCode: product.skuCode,
|
||||
barcode69: product.barcode69,
|
||||
specText: product.spec,
|
||||
price: product.price,
|
||||
benefitAmount: product.benefitAmount,
|
||||
status: product.status,
|
||||
allowOnSitePickup: product.allowOnSitePickup,
|
||||
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成 DK + 6 位自增 SKU,冲突重试 */
|
||||
private async nextAutoSkuCode(): Promise<string> {
|
||||
const rows = await this.prisma.commonProductItem.findMany({
|
||||
where: { skuCode: { startsWith: SKU_AUTO_PREFIX } },
|
||||
select: { skuCode: true },
|
||||
});
|
||||
return this.nextAutoSkuCodeTx(this.prisma);
|
||||
}
|
||||
|
||||
private async nextAutoSkuCodeTx(
|
||||
db: Prisma.TransactionClient | PrismaService,
|
||||
): Promise<string> {
|
||||
const [fromItem, fromSku] = await Promise.all([
|
||||
db.commonProductItem.findMany({
|
||||
where: { skuCode: { startsWith: SKU_AUTO_PREFIX } },
|
||||
select: { skuCode: true },
|
||||
}),
|
||||
db.commonProductSku.findMany({
|
||||
where: { skuCode: { startsWith: SKU_AUTO_PREFIX } },
|
||||
select: { skuCode: true },
|
||||
}),
|
||||
]);
|
||||
let maxSeq = 0;
|
||||
const re = new RegExp(`^${SKU_AUTO_PREFIX}(\\d+)$`);
|
||||
for (const row of rows) {
|
||||
for (const row of [...fromItem, ...fromSku]) {
|
||||
const m = re.exec(row.skuCode);
|
||||
if (!m) continue;
|
||||
const n = Number(m[1]);
|
||||
@@ -300,16 +701,6 @@ export class AdminProductsService {
|
||||
throw new BadRequestException('SKU 生成失败,请重试');
|
||||
}
|
||||
|
||||
private async syncVisibilityPhones(productId: bigint, phones: string[]) {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.commonProductVisibilityPhone.deleteMany({ where: { productId } });
|
||||
if (!phones.length) return;
|
||||
await tx.commonProductVisibilityPhone.createMany({
|
||||
data: phones.map((phone) => ({ productId, phone })),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private formatProduct(
|
||||
product: Prisma.CommonProductItemGetPayload<{
|
||||
include: {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Max,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateStoreStatusDto {
|
||||
@@ -1302,6 +1303,111 @@ export class UpdateProductDto {
|
||||
detailContent?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
class AdminSpecValueDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
class AdminSpecAttrDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AdminSpecValueDto)
|
||||
values: AdminSpecValueDto[];
|
||||
}
|
||||
|
||||
export class SaveProductSpecsDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AdminSpecAttrDto)
|
||||
attrs: AdminSpecAttrDto[];
|
||||
}
|
||||
|
||||
class AdminSkuRowDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
specValueIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
skuCode?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
barcode69: string;
|
||||
|
||||
@IsNumber()
|
||||
price: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
benefitAmount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['DRAFT', 'ON_SALE', 'OFF_SALE'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowOnSitePickup?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowOnlinePurchase?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowCrossCityDelivery?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['BOTTLE', 'BOX'])
|
||||
saleUnit?: 'BOTTLE' | 'BOX';
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
bottlesPerUnit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class SaveProductSkusDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AdminSkuRowDto)
|
||||
skus: AdminSkuRowDto[];
|
||||
}
|
||||
|
||||
class ProductDetailFeatureInputDto {
|
||||
@IsString()
|
||||
icon: string;
|
||||
|
||||
@@ -17,6 +17,10 @@ export class HqProxyOrderPreviewDto {
|
||||
@IsNotEmpty()
|
||||
productId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
skuId?: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@@ -76,6 +80,10 @@ export class HqProxyOrderCreateDto {
|
||||
@IsNotEmpty()
|
||||
productId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
skuId?: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
|
||||
@@ -17,6 +17,10 @@ export class PartnerProxyOrderPreviewDto {
|
||||
@IsNotEmpty()
|
||||
productId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
skuId?: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@@ -84,6 +88,10 @@ export class PartnerProxyOrderCreateDto {
|
||||
@IsNotEmpty()
|
||||
productId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
skuId?: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user