v3.5.4版本提交
CI / verify (pull_request) Waiting to run

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
@@ -0,0 +1,46 @@
/**
* v3.5.4:为尚无 SKU 的商品回填默认 SKU(与 migrate-product-sku-v354.sql 一致)
* 用法:cd server/dukang-api && npx ts-node prisma/backfill-product-skus.ts
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const products = await prisma.commonProductItem.findMany({
include: { skus: { select: { id: true }, take: 1 } },
});
let created = 0;
for (const p of products) {
if (p.skus.length > 0) continue;
await prisma.commonProductSku.create({
data: {
productId: p.id,
skuCode: p.skuCode,
barcode69: p.barcode69,
specKey: '',
specText: p.spec,
price: p.price,
benefitAmount: p.benefitAmount,
status: p.status,
allowOnSitePickup: p.allowOnSitePickup,
allowOnlinePurchase: p.allowOnlinePurchase,
allowCrossCityDelivery: p.allowCrossCityDelivery,
saleUnit: 'BOTTLE',
bottlesPerUnit: 1,
isDefault: true,
sortOrder: 0,
},
});
created += 1;
console.log(`created default sku for product ${p.id} ${p.skuCode}`);
}
console.log(`done, created=${created}, scanned=${products.length}`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -0,0 +1,97 @@
-- v3.5.4 商品规格:SPU + SKU
-- 1) 新建规格/SKU 表 2) 订单快照列 3) SPU 去掉 sku/barcode 唯一 4) 存量默认 SKU 回填
-- 销售单位枚举(Prisma 用字符串枚举映射;MySQL 用 VARCHAR,此处仅建表)
CREATE TABLE IF NOT EXISTS common_product_spec_attr (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
product_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(32) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_spec_attr_product (product_id, sort_order),
CONSTRAINT fk_spec_attr_product FOREIGN KEY (product_id) REFERENCES common_product_item (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS common_product_spec_value (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
attr_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(64) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_spec_value_attr (attr_id, sort_order),
CONSTRAINT fk_spec_value_attr FOREIGN KEY (attr_id) REFERENCES common_product_spec_attr (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS common_product_sku (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
product_id BIGINT UNSIGNED NOT NULL,
sku_code VARCHAR(32) NOT NULL,
barcode_69 VARCHAR(32) NOT NULL,
spec_key VARCHAR(128) NOT NULL DEFAULT '',
spec_text VARCHAR(128) NOT NULL DEFAULT '',
price DECIMAL(10, 2) NOT NULL,
benefit_amount DECIMAL(10, 2) NULL,
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
allow_on_site_pickup TINYINT(1) NOT NULL DEFAULT 0,
allow_online_purchase TINYINT(1) NOT NULL DEFAULT 1,
allow_cross_city_delivery TINYINT(1) NOT NULL DEFAULT 1,
sale_unit VARCHAR(16) NOT NULL DEFAULT 'BOTTLE',
bottles_per_unit INT NOT NULL DEFAULT 1,
is_default TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_product_sku_code (sku_code),
UNIQUE KEY uk_product_sku_barcode (barcode_69),
UNIQUE KEY uk_product_sku_spec_key (product_id, spec_key),
KEY idx_product_sku_status (product_id, status),
KEY idx_product_sku_default (product_id, is_default),
CONSTRAINT fk_product_sku_product FOREIGN KEY (product_id) REFERENCES common_product_item (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS common_product_sku_spec (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
sku_id BIGINT UNSIGNED NOT NULL,
value_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_sku_spec (sku_id, value_id),
KEY idx_sku_spec_value (value_id),
CONSTRAINT fk_sku_spec_sku FOREIGN KEY (sku_id) REFERENCES common_product_sku (id) ON DELETE CASCADE,
CONSTRAINT fk_sku_spec_value FOREIGN KEY (value_id) REFERENCES common_product_spec_value (id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 订单快照列
ALTER TABLE user_order
ADD COLUMN sku_id BIGINT UNSIGNED NULL AFTER product_id,
ADD COLUMN sale_unit VARCHAR(16) NOT NULL DEFAULT 'BOTTLE' AFTER quantity,
ADD COLUMN bottles_per_unit INT NOT NULL DEFAULT 1 AFTER sale_unit;
ALTER TABLE user_order
ADD KEY idx_order_sku_id (sku_id);
-- SPU:去掉 sku_code / barcode_69 唯一(保留普通索引);若索引名不同可手工调整
ALTER TABLE common_product_item DROP INDEX sku_code;
ALTER TABLE common_product_item DROP INDEX barcode_69;
ALTER TABLE common_product_item ADD INDEX idx_product_item_sku_code (sku_code);
ALTER TABLE common_product_item ADD INDEX idx_product_item_barcode_69 (barcode_69);
-- 存量商品回填默认 SKU(无规格,瓶装)
INSERT INTO common_product_sku (
product_id, sku_code, barcode_69, spec_key, spec_text, price, benefit_amount, status,
allow_on_site_pickup, allow_online_purchase, allow_cross_city_delivery,
sale_unit, bottles_per_unit, is_default, sort_order, created_at, updated_at
)
SELECT
p.id, p.sku_code, p.barcode_69, '', COALESCE(p.spec, ''), p.price, p.benefit_amount, p.status,
p.allow_on_site_pickup, p.allow_online_purchase, p.allow_cross_city_delivery,
'BOTTLE', 1, 1, 0, NOW(3), NOW(3)
FROM common_product_item p
WHERE NOT EXISTS (
SELECT 1 FROM common_product_sku s WHERE s.product_id = p.id
);
+101 -2
View File
@@ -164,6 +164,12 @@ enum ProductStatus {
OFF_SALE
}
/// SKU 销售单位:瓶 / 箱(起购与物流按瓶当量)
enum ProductSaleUnit {
BOTTLE
BOX
}
enum DetailTemplateStatus {
ACTIVE
DISABLED
@@ -791,10 +797,13 @@ model DevPlanTaskDispatch {
@@map("dev_plan_task_dispatch")
}
/// 商品 SPU(详情页实体);价格/码/履约等列为默认 SKU 冗余,供旧客户端拍平读取
model CommonProductItem {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
skuCode String @unique @map("sku_code") @db.VarChar(32)
barcode69 String @unique @map("barcode_69") @db.VarChar(32)
/// 默认 SKU 冗余;唯一约束已下放到 common_product_sku
skuCode String @map("sku_code") @db.VarChar(32)
/// 默认 SKU 冗余;唯一约束已下放到 common_product_sku
barcode69 String @map("barcode_69") @db.VarChar(32)
name String @db.VarChar(128)
subtitle String? @db.VarChar(256)
aromaType AromaType @map("aroma_type")
@@ -818,11 +827,94 @@ model CommonProductItem {
coverResource CommonResource? @relation("ProductCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
orders Order[]
visibilityPhones CommonProductVisibilityPhone[]
specAttrs CommonProductSpecAttr[]
skus CommonProductSku[]
@@index([skuCode])
@@index([barcode69])
@@index([status, aromaType])
@@map("common_product_item")
}
/// 销售规格轴(如「包装」)
model CommonProductSpecAttr {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
productId BigInt @map("product_id") @db.UnsignedBigInt
name String @db.VarChar(32)
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Cascade)
values CommonProductSpecValue[]
@@index([productId, sortOrder])
@@map("common_product_spec_attr")
}
/// 规格值(如「单瓶」「整箱6瓶」)
model CommonProductSpecValue {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
attrId BigInt @map("attr_id") @db.UnsignedBigInt
name String @db.VarChar(64)
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
attr CommonProductSpecAttr @relation(fields: [attrId], references: [id], onDelete: Cascade)
skuSpecs CommonProductSkuSpec[]
@@index([attrId, sortOrder])
@@map("common_product_spec_value")
}
/// 可售 SKU(规格组合)
model CommonProductSku {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
productId BigInt @map("product_id") @db.UnsignedBigInt
skuCode String @unique @map("sku_code") @db.VarChar(32)
barcode69 String @unique @map("barcode_69") @db.VarChar(32)
/// 规格值 id 按 attr.sortOrder 拼接,无规格为 ""
specKey String @default("") @map("spec_key") @db.VarChar(128)
specText String @default("") @map("spec_text") @db.VarChar(128)
price Decimal @db.Decimal(10, 2)
benefitAmount Decimal? @map("benefit_amount") @db.Decimal(10, 2)
status ProductStatus @default(DRAFT)
allowOnSitePickup Boolean @default(false) @map("allow_on_site_pickup")
allowOnlinePurchase Boolean @default(true) @map("allow_online_purchase")
allowCrossCityDelivery Boolean @default(true) @map("allow_cross_city_delivery")
saleUnit ProductSaleUnit @default(BOTTLE) @map("sale_unit")
/// 每销售单位对应瓶数(瓶=1,箱默认=6)
bottlesPerUnit Int @default(1) @map("bottles_per_unit")
isDefault Boolean @default(false) @map("is_default")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Cascade)
skuSpecs CommonProductSkuSpec[]
orders Order[]
@@unique([productId, specKey])
@@index([productId, status])
@@index([productId, isDefault])
@@map("common_product_sku")
}
/// SKU ↔ 规格值
model CommonProductSkuSpec {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
skuId BigInt @map("sku_id") @db.UnsignedBigInt
valueId BigInt @map("value_id") @db.UnsignedBigInt
sku CommonProductSku @relation(fields: [skuId], references: [id], onDelete: Cascade)
value CommonProductSpecValue @relation(fields: [valueId], references: [id], onDelete: Restrict)
@@unique([skuId, valueId])
@@index([valueId])
@@map("common_product_sku_spec")
}
/// Product visibility whitelist phones (match by bound phone)
model CommonProductVisibilityPhone {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
@@ -1495,11 +1587,16 @@ model Order {
promoCodeId BigInt? @map("promo_code_id") @db.UnsignedBigInt
channelSource String? @map("channel_source") @db.VarChar(128)
productId BigInt @map("product_id") @db.UnsignedBigInt
/// v3.5.4 起可空;历史单无 sku 快照时仍用 product 冗余字段展示
skuId BigInt? @map("sku_id") @db.UnsignedBigInt
barcode69 String @map("barcode_69") @db.VarChar(32)
productName String @map("product_name") @db.VarChar(128)
productSpec String @map("product_spec") @db.VarChar(128)
imageResourceId BigInt? @map("image_resource_id") @db.UnsignedBigInt
/// 下单数量(销售单位:瓶或箱)
quantity Int
saleUnit ProductSaleUnit @default(BOTTLE) @map("sale_unit")
bottlesPerUnit Int @default(1) @map("bottles_per_unit")
listUnitPrice Decimal @map("list_unit_price") @db.Decimal(10, 2)
listAmount Decimal @map("list_amount") @db.Decimal(10, 2)
discountAmount Decimal @default(0) @map("discount_amount") @db.Decimal(10, 2)
@@ -1553,6 +1650,7 @@ model Order {
reshipments Order[] @relation("OrderReshipment")
promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull)
product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Restrict)
sku CommonProductSku? @relation(fields: [skuId], references: [id], onDelete: Restrict)
imageResource CommonResource? @relation("OrderProductImage", fields: [imageResourceId], references: [id], onDelete: SetNull)
delivery OrderDelivery?
benefitCoupon BenefitCoupon?
@@ -1561,6 +1659,7 @@ model Order {
@@index([userId, status])
@@index([cityId, createdAt])
@@index([productId])
@@index([skuId])
@@index([barcode69])
@@index([payExternalNo])
@@index([ipCity])
+20
View File
@@ -412,6 +412,26 @@ async function main() {
});
await prisma.commonProductSku.create({
data: {
productId: product.id,
skuCode: def.skuCode,
barcode69: `69000000000${i + 1}`,
specKey: '',
specText: '500ml | 53度',
price: def.price,
benefitAmount: def.price,
status: 'ON_SALE',
allowOnSitePickup: false,
allowOnlinePurchase: true,
allowCrossCityDelivery: true,
saleUnit: 'BOTTLE',
bottlesPerUnit: 1,
isDefault: true,
sortOrder: 0,
},
});
const cover = await createMockResource(ResourceOwnerType.PRODUCT, product.id, ResourceBizType.COVER, def.img);
await prisma.commonProductItem.update({
@@ -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,