feat(api): product families backfill script

This commit is contained in:
yeuimu
2026-08-28 12:36:39 +08:00
parent d20a933e34
commit d63f1a6f35
4 changed files with 159 additions and 76 deletions
+2 -1
View File
@@ -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,
}, },
+78 -73
View File
@@ -7,8 +7,9 @@ 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;
let sds: jest.Mocked<SdsClientService>; let sds: jest.Mocked<SdsClientService>;
let prisma: PrismaService; let prisma: PrismaService;
@@ -17,22 +18,23 @@ describe('SyncService', () => {
beforeAll(async () => { beforeAll(async () => {
const sdsMock: Partial<SdsClientService> = { const sdsMock: Partial<SdsClientService> = {
fetchCategoryTree: jest.fn(), fetchCategoryTree: jest.fn(),
fetchProductsPage: jest.fn(), fetchProductsPage: jest.fn(),
fetchProductDetail: jest.fn(async (goodId: string | number) => ({ id: goodId })), fetchProductDetail: jest.fn(async (goodId: string | number) => ({ id: goodId })),
}; };
const moduleRef = await Test.createTestingModule({ const moduleRef = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ isGlobal: true })], imports: [ConfigModule.forRoot({ isGlobal: true })],
providers: [ providers: [
SyncService, SyncService,
{ provide: SdsClientService, useValue: sdsMock }, { provide: SdsClientService, useValue: sdsMock },
{ provide: FamilyRecomputeService, useValue: { enqueue: jest.fn() } },
PrismaService, PrismaService,
], ],
}).compile(); }).compile();
service = moduleRef.get(SyncService); service = moduleRef.get(SyncService);
jest jest
.spyOn(service, 'syncConfiguredProductDetails') .spyOn(service, 'syncConfiguredProductDetails')
.mockResolvedValue({ synced: 0, failed: 0 }); .mockResolvedValue({ synced: 0, failed: 0 });
sds = moduleRef.get(SdsClientService) as jest.Mocked<SdsClientService>; sds = moduleRef.get(SdsClientService) as jest.Mocked<SdsClientService>;
prisma = moduleRef.get(PrismaService); prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit(); await prisma.onModuleInit();
@@ -275,68 +277,71 @@ describe('SyncService', () => {
expect(logs.length).toBeGreaterThan(0); expect(logs.length).toBeGreaterThan(0);
}); });
}); });
}); });
describe('SyncService product detail scopes', () => { describe('SyncService product detail scopes', () => {
const originGoods = [ const originGoods = [
{ id: 1n, sdsGoodId: 'all-1' }, { id: 1n, sdsGoodId: 'all-1' },
{ id: 2n, sdsGoodId: 'all-2' }, { id: 2n, sdsGoodId: 'all-2' },
]; ];
function createService() { function createService() {
const prisma = { const prisma = {
originGood: { findMany: jest.fn().mockResolvedValue(originGoods) }, originGood: { findMany: jest.fn().mockResolvedValue(originGoods) },
} as unknown as PrismaService; } as unknown as PrismaService;
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 = {
jest enqueue: jest.fn(),
.spyOn(scopedService as any, 'persistProductDetail') } as unknown as FamilyRecomputeService;
.mockResolvedValue(undefined); const scopedService = new SyncService(prisma, sds, familyRecompute);
return { scopedService, prisma, sds }; jest
} .spyOn(scopedService as any, 'persistProductDetail')
.mockResolvedValue(undefined);
it('manual detail sync selects every active origin product', async () => { return { scopedService, prisma, sds };
const { scopedService, prisma, sds } = createService(); }
const result = await scopedService.syncAllProductDetails();
it('manual detail sync selects every active origin product', async () => {
expect(prisma.originGood.findMany).toHaveBeenCalledWith( const { scopedService, prisma, sds } = createService();
expect.objectContaining({ where: { delisted: false, source: 'SDS' } }), const result = await scopedService.syncAllProductDetails();
);
expect(sds.fetchProductDetail).toHaveBeenCalledTimes(2); expect(prisma.originGood.findMany).toHaveBeenCalledWith(
expect(result).toEqual({ total: 2, synced: 2, failed: 0 }); expect.objectContaining({ where: { delisted: false, source: 'SDS' } }),
}); );
expect(sds.fetchProductDetail).toHaveBeenCalledTimes(2);
it('hourly detail refresh remains limited to configured products', async () => { expect(result).toEqual({ total: 2, synced: 2, failed: 0 });
const { scopedService, prisma } = createService(); });
await scopedService.syncConfiguredProductDetails();
it('hourly detail refresh remains limited to configured products', async () => {
expect(prisma.originGood.findMany).toHaveBeenCalledWith( const { scopedService, prisma } = createService();
expect.objectContaining({ await scopedService.syncConfiguredProductDetails();
where: { delisted: false, source: 'SDS', goods: { some: {} } },
}), expect(prisma.originGood.findMany).toHaveBeenCalledWith(
); expect.objectContaining({
}); where: { delisted: false, source: 'SDS', goods: { some: {} } },
}),
it('keeps hourly category/product sync separate from the daily detail sync', async () => { );
const { scopedService } = createService(); });
const categories = jest.spyOn(scopedService, 'syncCategories').mockResolvedValue({
inserted: 0, updated: 0, total: 0, deletedStale: 0, it('keeps hourly category/product sync separate from the daily detail sync', async () => {
}); const { scopedService } = createService();
const products = jest.spyOn(scopedService, 'syncProducts').mockResolvedValue({ const categories = jest.spyOn(scopedService, 'syncCategories').mockResolvedValue({
inserted: 0, updated: 0, total: 0, leafCategories: 0, delisted: 0, inserted: 0, updated: 0, total: 0, deletedStale: 0,
}); });
const details = jest.spyOn(scopedService, 'syncProductDetails').mockResolvedValue({ const products = jest.spyOn(scopedService, 'syncProducts').mockResolvedValue({
total: 0, synced: 0, failed: 0, inserted: 0, updated: 0, total: 0, leafCategories: 0, delisted: 0,
}); });
const details = jest.spyOn(scopedService, 'syncProductDetails').mockResolvedValue({
await scopedService.hourlyCron(); total: 0, synced: 0, failed: 0,
expect(categories).toHaveBeenCalledTimes(1); });
expect(products).toHaveBeenCalledTimes(1);
expect(details).not.toHaveBeenCalled(); await scopedService.hourlyCron();
expect(categories).toHaveBeenCalledTimes(1);
await scopedService.dailyProductDetailCron(); expect(products).toHaveBeenCalledTimes(1);
expect(details).toHaveBeenCalledTimes(1); expect(details).not.toHaveBeenCalled();
});
}); await scopedService.dailyProductDetailCron();
expect(details).toHaveBeenCalledTimes(1);
});
});