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({