v3.5.5版本上传
CI / verify (pull_request) Waiting to run

This commit is contained in:
2026-08-23 12:26:57 +08:00
parent f9161368e1
commit 22cd00da42
18 changed files with 2036 additions and 294 deletions
@@ -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());