feat(api): product families backfill script
This commit is contained in:
@@ -22,7 +22,8 @@
|
|||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:studio": "prisma studio",
|
"prisma:studio": "prisma studio",
|
||||||
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts",
|
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts",
|
||||||
"import:product-detail": "ts-node prisma/import-product-detail.ts"
|
"import:product-detail": "ts-node prisma/import-product-detail.ts",
|
||||||
|
"backfill:product-families": "ts-node prisma/backfill-product-families.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^3.0.1",
|
"@nestjs/axios": "^3.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* 产品族回填脚本(一次性 / 幂等):
|
||||||
|
* 1. 全量 OriginGood 回填四个链接名解析列;
|
||||||
|
* 2. auto-group 全量建族并挂成员(每族建立即重算);
|
||||||
|
* 3. 输出统计与不可解析清单。
|
||||||
|
*
|
||||||
|
* 运行:pnpm --filter @inkreach/api backfill:product-families
|
||||||
|
* 幂等性:重复执行时步骤 1 数据不变、步骤 2 候选为空(familyId=null 过滤)。
|
||||||
|
*/
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
|
||||||
|
import { ProductFamiliesService } from '../src/product-families/product-families.service';
|
||||||
|
import { parseOriginName } from '../src/product-families/origin-name.parser';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const prisma = new PrismaService();
|
||||||
|
await prisma.onModuleInit();
|
||||||
|
const recompute = new FamilyRecomputeService(prisma);
|
||||||
|
const families = new ProductFamiliesService(prisma, recompute);
|
||||||
|
|
||||||
|
// ---- 1. 解析列回填(分批) ----
|
||||||
|
const BATCH = 100;
|
||||||
|
let parsed = 0;
|
||||||
|
const unparsable: string[] = [];
|
||||||
|
for (;;) {
|
||||||
|
const batch = await prisma.originGood.findMany({
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
take: BATCH,
|
||||||
|
skip: parsed,
|
||||||
|
select: { id: true, goodName: true },
|
||||||
|
});
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
for (const og of batch) {
|
||||||
|
const p = parseOriginName(og.goodName);
|
||||||
|
if (!p.skuCode && !p.craftLabel) unparsable.push(`#${og.id} ${og.goodName ?? ''}`);
|
||||||
|
await prisma.originGood.update({
|
||||||
|
where: { id: og.id },
|
||||||
|
data: {
|
||||||
|
skuCode: p.skuCode,
|
||||||
|
logisticsLabel: p.logisticsLabel,
|
||||||
|
craftLabel: p.craftLabel,
|
||||||
|
warehouseLabel: p.warehouseLabel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
parsed += batch.length;
|
||||||
|
}
|
||||||
|
console.log(`[1/2] parsed ${parsed} origin goods (${unparsable.length} without sku/craft)`);
|
||||||
|
|
||||||
|
// ---- 2. 自动建族(含逐族重算) ----
|
||||||
|
const result = await families.autoGroup(true);
|
||||||
|
console.log(`[2/2] created ${result.applied} families`);
|
||||||
|
|
||||||
|
// ---- 统计 ----
|
||||||
|
const total = await prisma.productFamily.count();
|
||||||
|
const withMatrix = await prisma.productFamily.count({ where: { priceMatrix: { not: null } } });
|
||||||
|
const members = await prisma.originGood.count({ where: { familyId: { not: null } } });
|
||||||
|
const stale = await prisma.productFamily.count({ where: { stale: true } });
|
||||||
|
console.log(`stats: families=${total}, materialized=${withMatrix}, members=${members}, stale=${stale}`);
|
||||||
|
if (unparsable.length) {
|
||||||
|
console.log('unparsable names:');
|
||||||
|
for (const line of unparsable) console.log(` - ${line}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -132,7 +132,7 @@ export class ProductFamiliesService {
|
|||||||
async autoGroup(apply: boolean) {
|
async autoGroup(apply: boolean) {
|
||||||
const candidates = await this.prisma.originGood.findMany({
|
const candidates = await this.prisma.originGood.findMany({
|
||||||
where: { familyId: null, delisted: false },
|
where: { familyId: null, delisted: false },
|
||||||
select: { id: true, goodName: true, goodImage: true },
|
select: { id: true, goodName: true, goodImage: true, source: true },
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
});
|
});
|
||||||
const groups = new Map<string, typeof candidates>();
|
const groups = new Map<string, typeof candidates>();
|
||||||
@@ -160,10 +160,16 @@ export class ProductFamiliesService {
|
|||||||
let applied = 0;
|
let applied = 0;
|
||||||
for (const members of groups.values()) {
|
for (const members of groups.values()) {
|
||||||
const parsed = parseOriginName(members[0].goodName);
|
const parsed = parseOriginName(members[0].goodName);
|
||||||
|
const fallbackCode =
|
||||||
|
members[0].source === 'CUSTOM' ? `CUSTOM-${members[0].id}` : null;
|
||||||
const family = await this.prisma.productFamily.create({
|
const family = await this.prisma.productFamily.create({
|
||||||
data: {
|
data: {
|
||||||
familyName: parsed.productName ?? parsed.country ?? originGroupKey(members[0].goodName),
|
familyName: parsed.productName ?? parsed.country ?? originGroupKey(members[0].goodName),
|
||||||
familyCode: parsed.skuCode ? await this.ensureUniqueCode(parsed.skuCode) : null,
|
familyCode: parsed.skuCode
|
||||||
|
? await this.ensureUniqueCode(parsed.skuCode)
|
||||||
|
: fallbackCode
|
||||||
|
? await this.ensureUniqueCode(fallbackCode)
|
||||||
|
: null,
|
||||||
familyImage: members[0].goodImage ?? null,
|
familyImage: members[0].goodImage ?? null,
|
||||||
primaryOriginGoodId: members[0].id,
|
primaryOriginGoodId: members[0].id,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from './sync.service';
|
} from './sync.service';
|
||||||
import { SdsClientService } from './sds-client.service';
|
import { SdsClientService } from './sds-client.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||||
|
|
||||||
describe('SyncService', () => {
|
describe('SyncService', () => {
|
||||||
let service: SyncService;
|
let service: SyncService;
|
||||||
@@ -26,6 +27,7 @@ describe('SyncService', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
SyncService,
|
SyncService,
|
||||||
{ provide: SdsClientService, useValue: sdsMock },
|
{ provide: SdsClientService, useValue: sdsMock },
|
||||||
|
{ provide: FamilyRecomputeService, useValue: { enqueue: jest.fn() } },
|
||||||
PrismaService,
|
PrismaService,
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
@@ -290,7 +292,10 @@ describe('SyncService product detail scopes', () => {
|
|||||||
const sds = {
|
const sds = {
|
||||||
fetchProductDetail: jest.fn(async (goodId: string) => ({ id: goodId })),
|
fetchProductDetail: jest.fn(async (goodId: string) => ({ id: goodId })),
|
||||||
} as unknown as SdsClientService;
|
} as unknown as SdsClientService;
|
||||||
const scopedService = new SyncService(prisma, sds);
|
const familyRecompute = {
|
||||||
|
enqueue: jest.fn(),
|
||||||
|
} as unknown as FamilyRecomputeService;
|
||||||
|
const scopedService = new SyncService(prisma, sds, familyRecompute);
|
||||||
jest
|
jest
|
||||||
.spyOn(scopedService as any, 'persistProductDetail')
|
.spyOn(scopedService as any, 'persistProductDetail')
|
||||||
.mockResolvedValue(undefined);
|
.mockResolvedValue(undefined);
|
||||||
|
|||||||
Reference in New Issue
Block a user