@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* 方案 A:把 DK000007 / DK000008 合并为同一 SPU,保留原 SKU id 与 DK 码,订单改绑到对应 SKU。
|
||||
*
|
||||
* 用法:cd server/dukang-api && npx ts-node --transpile-only prisma/merge-spu-dk000007-008.ts
|
||||
* 预览:npx ts-node --transpile-only prisma/merge-spu-dk000007-008.ts --dry-run
|
||||
*
|
||||
* 幸存 SPU = DK000007 所在商品;默认规格 = 国标特级20。
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const CODES = ['DK000007', 'DK000008'] as const;
|
||||
const SURVIVOR_CODE = 'DK000007';
|
||||
const SPEC_ATTR_NAME = '规格';
|
||||
const SPU_NAME = '酒祖杜康(国标特级)';
|
||||
const SPEC_LABEL: Record<(typeof CODES)[number], string> = {
|
||||
DK000007: '国标特级20',
|
||||
DK000008: '国标特级30',
|
||||
};
|
||||
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
|
||||
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('_');
|
||||
}
|
||||
|
||||
async function ensureSkuImageColumn() {
|
||||
const rows = await prisma.$queryRaw<Array<{ cnt: bigint | number }>>`
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'common_product_sku'
|
||||
AND COLUMN_NAME = 'image_url'
|
||||
`;
|
||||
const cnt = Number(rows[0]?.cnt ?? 0);
|
||||
if (cnt > 0) return;
|
||||
await prisma.$executeRawUnsafe(
|
||||
'ALTER TABLE `common_product_sku` ADD COLUMN `image_url` VARCHAR(512) NULL AFTER `sort_order`',
|
||||
);
|
||||
console.log('added column common_product_sku.image_url');
|
||||
}
|
||||
|
||||
async function resolveProductByCode(skuCode: string) {
|
||||
const sku = await prisma.commonProductSku.findUnique({
|
||||
where: { skuCode },
|
||||
include: { product: true },
|
||||
});
|
||||
if (sku) return { product: sku.product, sku };
|
||||
const product = await prisma.commonProductItem.findFirst({
|
||||
where: { skuCode },
|
||||
});
|
||||
if (!product) {
|
||||
throw new Error(`找不到商品/SKU:${skuCode}`);
|
||||
}
|
||||
return { product, sku: null as null };
|
||||
}
|
||||
|
||||
async function coverUrlOf(productId: bigint, coverResourceId: bigint | null) {
|
||||
if (coverResourceId) {
|
||||
const res = await prisma.commonResource.findUnique({
|
||||
where: { id: coverResourceId },
|
||||
select: { url: true },
|
||||
});
|
||||
if (res?.url) return res.url;
|
||||
}
|
||||
const fallback = await prisma.commonResource.findFirst({
|
||||
where: { ownerType: 'PRODUCT', ownerId: productId, bizType: 'COVER', status: 'ACTIVE' },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { url: true },
|
||||
});
|
||||
return fallback?.url ?? null;
|
||||
}
|
||||
|
||||
async function ensureSkuFromProduct(
|
||||
tx: PrismaClient,
|
||||
product: {
|
||||
id: bigint;
|
||||
skuCode: string;
|
||||
barcode69: string;
|
||||
spec: string;
|
||||
price: unknown;
|
||||
benefitAmount: unknown;
|
||||
status: 'DRAFT' | 'ON_SALE' | 'OFF_SALE';
|
||||
allowOnSitePickup: boolean;
|
||||
allowOnlinePurchase: boolean;
|
||||
allowCrossCityDelivery: boolean;
|
||||
},
|
||||
) {
|
||||
const existing =
|
||||
(await tx.commonProductSku.findUnique({ where: { skuCode: product.skuCode } })) ??
|
||||
(await tx.commonProductSku.findFirst({
|
||||
where: { productId: product.id, isDefault: true },
|
||||
}));
|
||||
if (existing) return existing;
|
||||
return tx.commonProductSku.create({
|
||||
data: {
|
||||
productId: product.id,
|
||||
skuCode: product.skuCode,
|
||||
barcode69: product.barcode69,
|
||||
specKey: '',
|
||||
specText: product.spec,
|
||||
price: product.price as never,
|
||||
benefitAmount: product.benefitAmount as never,
|
||||
status: product.status,
|
||||
allowOnSitePickup: product.allowOnSitePickup,
|
||||
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||
saleUnit: 'BOTTLE',
|
||||
bottlesPerUnit: 1,
|
||||
isDefault: true,
|
||||
sortOrder: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureSkuImageColumn();
|
||||
|
||||
const rows = [];
|
||||
for (const code of CODES) {
|
||||
const resolved = await resolveProductByCode(code);
|
||||
rows.push({ code, ...resolved });
|
||||
}
|
||||
|
||||
const survivorRow = rows.find((r) => r.code === SURVIVOR_CODE);
|
||||
if (!survivorRow) throw new Error(`缺少幸存码 ${SURVIVOR_CODE}`);
|
||||
const discardRows = rows.filter((r) => r.product.id !== survivorRow.product.id);
|
||||
const survivorId = survivorRow.product.id;
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
dryRun,
|
||||
survivor: {
|
||||
productId: survivorId.toString(),
|
||||
name: survivorRow.product.name,
|
||||
skuCode: survivorRow.product.skuCode,
|
||||
},
|
||||
items: await Promise.all(
|
||||
rows.map(async (r) => {
|
||||
const orderCount = await prisma.order.count({ where: { productId: r.product.id } });
|
||||
const nullSku = await prisma.order.count({
|
||||
where: { productId: r.product.id, skuId: null },
|
||||
});
|
||||
return {
|
||||
code: r.code,
|
||||
productId: r.product.id.toString(),
|
||||
name: r.product.name,
|
||||
skuId: r.sku?.id.toString() ?? null,
|
||||
orders: orderCount,
|
||||
nullSkuOrders: nullSku,
|
||||
};
|
||||
}),
|
||||
),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
if (discardRows.length === 0 && survivorRow.sku) {
|
||||
const sibling = await prisma.commonProductSku.findMany({
|
||||
where: { productId: survivorId, skuCode: { in: [...CODES] } },
|
||||
});
|
||||
if (sibling.length === CODES.length) {
|
||||
console.log('SKU 已在同一 SPU 上,继续核对订单 product_id / sku_id');
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log('[dry-run] skip writes');
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const db = tx as unknown as PrismaClient;
|
||||
const skuByCode = new Map<string, { id: bigint }>();
|
||||
|
||||
for (const row of rows) {
|
||||
const sku = await ensureSkuFromProduct(db, row.product);
|
||||
skuByCode.set(row.code, sku);
|
||||
console.log(`ensured sku ${row.code} id=${sku.id} fromProduct=${row.product.id}`);
|
||||
}
|
||||
|
||||
let attr = await db.commonProductSpecAttr.findFirst({
|
||||
where: { productId: survivorId, name: SPEC_ATTR_NAME },
|
||||
});
|
||||
if (!attr) {
|
||||
attr = await db.commonProductSpecAttr.create({
|
||||
data: { productId: survivorId, name: SPEC_ATTR_NAME, sortOrder: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
const valueIdByCode = new Map<string, bigint>();
|
||||
for (let i = 0; i < CODES.length; i++) {
|
||||
const code = CODES[i];
|
||||
const label = SPEC_LABEL[code];
|
||||
let value = await db.commonProductSpecValue.findFirst({
|
||||
where: { attrId: attr.id, name: label },
|
||||
});
|
||||
if (!value) {
|
||||
value = await db.commonProductSpecValue.create({
|
||||
data: { attrId: attr.id, name: label, sortOrder: i },
|
||||
});
|
||||
}
|
||||
valueIdByCode.set(code, value.id);
|
||||
}
|
||||
|
||||
for (let i = 0; i < CODES.length; i++) {
|
||||
const code = CODES[i];
|
||||
const sku = skuByCode.get(code)!;
|
||||
const valueId = valueIdByCode.get(code)!;
|
||||
const specKey = buildSpecKey([valueId]);
|
||||
const specText = SPEC_LABEL[code];
|
||||
const sourceProduct = rows.find((r) => r.code === code)!.product;
|
||||
const imageUrl = await coverUrlOf(sourceProduct.id, sourceProduct.coverResourceId);
|
||||
const isDefault = code === SURVIVOR_CODE;
|
||||
|
||||
await db.commonProductSkuSpec.deleteMany({ where: { skuId: sku.id } });
|
||||
await db.commonProductSku.update({
|
||||
where: { id: sku.id },
|
||||
data: {
|
||||
product: { connect: { id: survivorId } },
|
||||
specKey,
|
||||
specText,
|
||||
isDefault,
|
||||
sortOrder: i,
|
||||
imageUrl,
|
||||
status: sourceProduct.status,
|
||||
},
|
||||
});
|
||||
await db.commonProductSkuSpec.create({
|
||||
data: { skuId: sku.id, valueId },
|
||||
});
|
||||
|
||||
const orderRes = await db.order.updateMany({
|
||||
where: {
|
||||
OR: [{ skuId: sku.id }, { productId: sourceProduct.id, skuId: null }],
|
||||
},
|
||||
data: { productId: survivorId, skuId: sku.id },
|
||||
});
|
||||
console.log(
|
||||
`bound ${code} → product ${survivorId} sku ${sku.id} spec=${specText} orders=${orderRes.count}`,
|
||||
);
|
||||
}
|
||||
|
||||
const leftover = await db.order.findMany({
|
||||
where: { productId: { in: discardRows.map((r) => r.product.id) } },
|
||||
select: { id: true, orderNo: true, productId: true, skuId: true },
|
||||
});
|
||||
if (leftover.length) {
|
||||
throw new Error(
|
||||
`仍有订单绑在被合并 SPU 上:${leftover.map((o) => o.orderNo).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const discard of discardRows) {
|
||||
const phones = await db.commonProductVisibilityPhone.findMany({
|
||||
where: { productId: discard.product.id },
|
||||
});
|
||||
for (const p of phones) {
|
||||
await db.commonProductVisibilityPhone.upsert({
|
||||
where: { productId_phone: { productId: survivorId, phone: p.phone } },
|
||||
create: { productId: survivorId, phone: p.phone },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
await db.commonProductVisibilityPhone.deleteMany({
|
||||
where: { productId: discard.product.id },
|
||||
});
|
||||
await db.commonProductSkuSpec.deleteMany({
|
||||
where: { sku: { productId: discard.product.id } },
|
||||
});
|
||||
await db.commonProductSku.deleteMany({ where: { productId: discard.product.id } });
|
||||
await db.commonProductSpecAttr.deleteMany({ where: { productId: discard.product.id } });
|
||||
await db.commonResource.deleteMany({
|
||||
where: { ownerType: 'PRODUCT', ownerId: discard.product.id },
|
||||
});
|
||||
await db.commonProductItem.update({
|
||||
where: { id: discard.product.id },
|
||||
data: { status: 'OFF_SALE', coverResourceId: null },
|
||||
});
|
||||
await db.commonProductItem.delete({ where: { id: discard.product.id } });
|
||||
console.log(`deleted discard SPU ${discard.product.id} ${discard.code}`);
|
||||
}
|
||||
|
||||
const defaultSku = await db.commonProductSku.findUniqueOrThrow({
|
||||
where: { skuCode: SURVIVOR_CODE },
|
||||
});
|
||||
await db.commonProductItem.update({
|
||||
where: { id: survivorId },
|
||||
data: {
|
||||
name: SPU_NAME,
|
||||
skuCode: defaultSku.skuCode,
|
||||
barcode69: defaultSku.barcode69,
|
||||
spec: defaultSku.specText,
|
||||
price: defaultSku.price,
|
||||
benefitAmount: defaultSku.benefitAmount,
|
||||
allowOnSitePickup: defaultSku.allowOnSitePickup,
|
||||
allowOnlinePurchase: defaultSku.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: defaultSku.allowCrossCityDelivery,
|
||||
status: 'ON_SALE',
|
||||
},
|
||||
});
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
const skus = await prisma.commonProductSku.findMany({
|
||||
where: { skuCode: { in: [...CODES] } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
const orders = await prisma.order.groupBy({
|
||||
by: ['productId', 'skuId'],
|
||||
where: { sku: { skuCode: { in: [...CODES] } } },
|
||||
_count: true,
|
||||
});
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
done: true,
|
||||
skus: skus.map((s) => ({
|
||||
id: s.id.toString(),
|
||||
productId: s.productId.toString(),
|
||||
skuCode: s.skuCode,
|
||||
specText: s.specText,
|
||||
isDefault: s.isDefault,
|
||||
})),
|
||||
orders: orders.map((o) => ({
|
||||
productId: o.productId.toString(),
|
||||
skuId: o.skuId?.toString() ?? null,
|
||||
count: o._count,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
Reference in New Issue
Block a user