/** * 方案 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>` 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(); 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(); 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());