353 lines
11 KiB
TypeScript
353 lines
11 KiB
TypeScript
/**
|
||
* 方案 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());
|