feat(api): sync hooks for parsing, auto-attach and family recompute
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { SyncService } from './sync.service';
|
||||
import { SdsClientService } from './sds-client.service';
|
||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* 同步钩子集成测试:upsertOriginGood 的解析列写入、新链接自动挂族、
|
||||
* persistProductDetail 后的族重算入队。
|
||||
*/
|
||||
describe('SyncService family hooks', () => {
|
||||
let service: SyncService;
|
||||
let prisma: PrismaService;
|
||||
let recompute: FamilyRecomputeService;
|
||||
const stamp = Date.now();
|
||||
const createdOriginGoodIds: bigint[] = [];
|
||||
const createdFamilyIds: bigint[] = [];
|
||||
|
||||
const sdsProduct = (id: string, name: string) =>
|
||||
({ id, name, pic: 'https://example.com/pic.jpg' }) as any;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
SyncService,
|
||||
{
|
||||
provide: SdsClientService,
|
||||
useValue: {},
|
||||
},
|
||||
FamilyRecomputeService,
|
||||
PrismaService,
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(SyncService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
recompute = moduleRef.get(FamilyRecomputeService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.originGood.deleteMany({ where: { id: { in: createdOriginGoodIds } } });
|
||||
await prisma.productFamily.deleteMany({ where: { id: { in: createdFamilyIds } } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('upsertOriginGood:写入四个解析列(create 与 update 全量覆盖)', async () => {
|
||||
const sdsId = `hook-${stamp}-parse`;
|
||||
const result1 = await (service as any).upsertOriginGood(
|
||||
sdsProduct(sdsId, '美国(包邮)240g涤纶休闲短裤-DG206-单面印花-美西洛杉矶一仓'),
|
||||
'cat-1',
|
||||
);
|
||||
expect(result1).toBe('inserted');
|
||||
const og = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } });
|
||||
createdOriginGoodIds.push(og.id);
|
||||
expect(og.skuCode).toBe('DG206');
|
||||
expect(og.logisticsLabel).toBe('包邮');
|
||||
expect(og.craftLabel).toBe('单面印花');
|
||||
expect(og.warehouseLabel).toBe('美西洛杉矶一仓');
|
||||
|
||||
// 更新为不带仓库的名称 → 解析列全量覆盖(warehouseLabel 置空)
|
||||
await (service as any).upsertOriginGood(
|
||||
sdsProduct(sdsId, '美国(不包邮)240g涤纶休闲短裤-DG206-单面印花'),
|
||||
'cat-1',
|
||||
);
|
||||
const og2 = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } });
|
||||
expect(og2.logisticsLabel).toBe('不包邮');
|
||||
expect(og2.warehouseLabel).toBeNull();
|
||||
});
|
||||
|
||||
it('新链接自动挂族:唯一命中族则挂载并触发重算', async () => {
|
||||
const seed = await prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId: `hook-${stamp}-seed`,
|
||||
goodName: `自动挂${stamp}(包邮)卫衣-ZZA${stamp}-单面印花`,
|
||||
craftLabel: '单面印花',
|
||||
logisticsLabel: '包邮',
|
||||
},
|
||||
});
|
||||
createdOriginGoodIds.push(seed.id);
|
||||
await prisma.originGoodVariant.create({
|
||||
data: {
|
||||
originGoodId: seed.id,
|
||||
sdsVariantId: 'seed-v1',
|
||||
sku: 'SEED-S',
|
||||
sizeId: 'size_S',
|
||||
sizeName: 'S',
|
||||
colorId: 'color_blk',
|
||||
colorName: '黑色',
|
||||
price: new Prisma.Decimal(25),
|
||||
},
|
||||
});
|
||||
const family = await prisma.productFamily.create({
|
||||
data: {
|
||||
familyName: `自动挂族-${stamp}`,
|
||||
primaryOriginGoodId: seed.id,
|
||||
},
|
||||
});
|
||||
createdFamilyIds.push(family.id);
|
||||
await prisma.originGood.update({
|
||||
where: { id: seed.id },
|
||||
data: { familyId: family.id },
|
||||
});
|
||||
await recompute.recomputeFamily(family.id);
|
||||
const before = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||
expect((before.priceMatrix as any).rows).toHaveLength(1);
|
||||
|
||||
// 同键新链接(多一个仓库段)→ 自动挂进唯一族
|
||||
const newSdsId = `hook-${stamp}-new`;
|
||||
await (service as any).upsertOriginGood(
|
||||
sdsProduct(newSdsId, `自动挂${stamp}(包邮)卫衣-ZZA${stamp}-单面印花-某仓`),
|
||||
'cat-1',
|
||||
);
|
||||
const newOg = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: newSdsId } });
|
||||
createdOriginGoodIds.push(newOg.id);
|
||||
expect(newOg.familyId).toBe(family.id);
|
||||
|
||||
// 入队的重算已执行(等待异步完成)
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
});
|
||||
|
||||
it('多族命中时不确定归属 → 不挂载', async () => {
|
||||
const key = `自动挂${stamp}B`;
|
||||
const mk = async (suffix: string) => {
|
||||
const og = await prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId: `hook-${stamp}-multi-${suffix}`,
|
||||
goodName: `${key}(包邮)卫衣-ZZB${stamp}-单面印花`,
|
||||
craftLabel: '单面印花',
|
||||
logisticsLabel: '包邮',
|
||||
},
|
||||
});
|
||||
createdOriginGoodIds.push(og.id);
|
||||
const family = await prisma.productFamily.create({
|
||||
data: { familyName: `多族${suffix}-${stamp}`, primaryOriginGoodId: og.id },
|
||||
});
|
||||
createdFamilyIds.push(family.id);
|
||||
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } });
|
||||
return og;
|
||||
};
|
||||
await mk('x');
|
||||
await mk('y');
|
||||
|
||||
const sdsId = `hook-${stamp}-multi-new`;
|
||||
await (service as any).upsertOriginGood(
|
||||
sdsProduct(sdsId, `${key}(包邮)卫衣-ZZB${stamp}-单面印花-新仓`),
|
||||
'cat-1',
|
||||
);
|
||||
const og = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } });
|
||||
createdOriginGoodIds.push(og.id);
|
||||
expect(og.familyId).toBeNull(); // 两个候选族 → 留给管理员
|
||||
});
|
||||
|
||||
it('maybeEnqueueFamilyRecompute:有族入队、无族跳过', async () => {
|
||||
const enqueueSpy = jest.spyOn(recompute, 'enqueue').mockImplementation(() => undefined);
|
||||
|
||||
const og = await prisma.originGood.create({
|
||||
data: { sdsGoodId: `hook-${stamp}-noattach`, goodName: `不成组名称-${stamp}` },
|
||||
});
|
||||
createdOriginGoodIds.push(og.id);
|
||||
await (service as any).maybeEnqueueFamilyRecompute(og.id);
|
||||
expect(enqueueSpy).not.toHaveBeenCalled();
|
||||
|
||||
const family = await prisma.productFamily.create({
|
||||
data: { familyName: `入队族-${stamp}` },
|
||||
});
|
||||
createdFamilyIds.push(family.id);
|
||||
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } });
|
||||
await (service as any).maybeEnqueueFamilyRecompute(og.id);
|
||||
expect(enqueueSpy).toHaveBeenCalledWith(family.id);
|
||||
|
||||
enqueueSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ProductFamiliesModule } from '../product-families/product-families.module';
|
||||
import { SyncController } from './sync.controller';
|
||||
import { SyncService } from './sync.service';
|
||||
import { SdsClientService } from './sds-client.service';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot(), HttpModule],
|
||||
imports: [ScheduleModule.forRoot(), HttpModule, ProductFamiliesModule],
|
||||
controllers: [SyncController],
|
||||
providers: [SyncService, SdsClientService],
|
||||
exports: [SyncService, SdsClientService],
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
SdsProductDetail,
|
||||
} from './sds-client.service';
|
||||
import { normalizeProductDetail } from './sds-product-detail.mapper';
|
||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||
import { originGroupKey, parseOriginName } from '../product-families/origin-name.parser';
|
||||
|
||||
export interface CategorySyncResult {
|
||||
inserted: number;
|
||||
@@ -75,6 +77,7 @@ export class SyncService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sds: SdsClientService,
|
||||
private readonly familyRecompute: FamilyRecomputeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -656,6 +659,54 @@ export class SyncService {
|
||||
},
|
||||
});
|
||||
});
|
||||
// 族成员的详情/变体变化 → 异步重算该族(进程内去重)
|
||||
await this.maybeEnqueueFamilyRecompute(originGoodId);
|
||||
}
|
||||
|
||||
/** 详情同步后的族重算钩子:链接有族归属才入队 */
|
||||
private async maybeEnqueueFamilyRecompute(originGoodId: bigint): Promise<void> {
|
||||
const og = await this.prisma.originGood.findUnique({
|
||||
where: { id: originGoodId },
|
||||
select: { familyId: true },
|
||||
});
|
||||
if (og?.familyId) this.familyRecompute.enqueue(og.familyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新链接自动挂族:3 段分组键恰好命中唯一族才挂载(多族/零族留给管理员裁决)。
|
||||
* 锁定族(autoManaged=false)不吸收新成员,只置 stale 提示。
|
||||
*/
|
||||
private async tryAutoAttachToFamily(originGoodId: bigint, goodName: string): Promise<void> {
|
||||
const key = originGroupKey(goodName);
|
||||
if (!key) return;
|
||||
const candidates = await this.prisma.originGood.findMany({
|
||||
where: { familyId: { not: null }, goodName: { startsWith: key } },
|
||||
select: { familyId: true, goodName: true },
|
||||
});
|
||||
const familyIds = new Set(
|
||||
candidates
|
||||
.filter((c) => originGroupKey(c.goodName) === key && c.familyId !== null)
|
||||
.map((c) => c.familyId!.toString()),
|
||||
);
|
||||
if (familyIds.size !== 1) return;
|
||||
const familyId = BigInt([...familyIds][0]);
|
||||
const family = await this.prisma.productFamily.findUnique({
|
||||
where: { id: familyId },
|
||||
select: { autoManaged: true },
|
||||
});
|
||||
if (!family) return;
|
||||
if (family.autoManaged) {
|
||||
await this.prisma.originGood.update({
|
||||
where: { id: originGoodId },
|
||||
data: { familyId },
|
||||
});
|
||||
this.familyRecompute.enqueue(familyId);
|
||||
} else {
|
||||
await this.prisma.productFamily.update({
|
||||
where: { id: familyId },
|
||||
data: { stale: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -715,23 +766,35 @@ export class SyncService {
|
||||
goodPrice = new Prisma.Decimal(n);
|
||||
}
|
||||
}
|
||||
// 链接名结构化解析列(镜像纯度:全量覆盖,含空值)
|
||||
const parsed = parseOriginName(goodName);
|
||||
const parsedData = {
|
||||
skuCode: parsed.skuCode,
|
||||
logisticsLabel: parsed.logisticsLabel,
|
||||
craftLabel: parsed.craftLabel,
|
||||
warehouseLabel: parsed.warehouseLabel,
|
||||
};
|
||||
const data: Prisma.OriginGoodUncheckedUpdateInput = {
|
||||
sdsCategoryId,
|
||||
goodName,
|
||||
goodImage,
|
||||
goodPrice,
|
||||
...parsedData,
|
||||
};
|
||||
|
||||
if (!existing) {
|
||||
await this.prisma.originGood.create({
|
||||
const created = await this.prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId,
|
||||
sdsCategoryId,
|
||||
goodName,
|
||||
goodImage,
|
||||
goodPrice,
|
||||
...parsedData,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
await this.tryAutoAttachToFamily(created.id, goodName);
|
||||
return 'inserted';
|
||||
}
|
||||
await this.prisma.originGood.update({
|
||||
|
||||
Reference in New Issue
Block a user