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 { Module } from '@nestjs/common';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
import { ProductFamiliesModule } from '../product-families/product-families.module';
|
||||||
import { SyncController } from './sync.controller';
|
import { SyncController } from './sync.controller';
|
||||||
import { SyncService } from './sync.service';
|
import { SyncService } from './sync.service';
|
||||||
import { SdsClientService } from './sds-client.service';
|
import { SdsClientService } from './sds-client.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ScheduleModule.forRoot(), HttpModule],
|
imports: [ScheduleModule.forRoot(), HttpModule, ProductFamiliesModule],
|
||||||
controllers: [SyncController],
|
controllers: [SyncController],
|
||||||
providers: [SyncService, SdsClientService],
|
providers: [SyncService, SdsClientService],
|
||||||
exports: [SyncService, SdsClientService],
|
exports: [SyncService, SdsClientService],
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
SdsProductDetail,
|
SdsProductDetail,
|
||||||
} from './sds-client.service';
|
} from './sds-client.service';
|
||||||
import { normalizeProductDetail } from './sds-product-detail.mapper';
|
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 {
|
export interface CategorySyncResult {
|
||||||
inserted: number;
|
inserted: number;
|
||||||
@@ -75,6 +77,7 @@ export class SyncService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly sds: SdsClientService,
|
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);
|
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 = {
|
const data: Prisma.OriginGoodUncheckedUpdateInput = {
|
||||||
sdsCategoryId,
|
sdsCategoryId,
|
||||||
goodName,
|
goodName,
|
||||||
goodImage,
|
goodImage,
|
||||||
goodPrice,
|
goodPrice,
|
||||||
|
...parsedData,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
await this.prisma.originGood.create({
|
const created = await this.prisma.originGood.create({
|
||||||
data: {
|
data: {
|
||||||
sdsGoodId,
|
sdsGoodId,
|
||||||
sdsCategoryId,
|
sdsCategoryId,
|
||||||
goodName,
|
goodName,
|
||||||
goodImage,
|
goodImage,
|
||||||
goodPrice,
|
goodPrice,
|
||||||
|
...parsedData,
|
||||||
},
|
},
|
||||||
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
await this.tryAutoAttachToFamily(created.id, goodName);
|
||||||
return 'inserted';
|
return 'inserted';
|
||||||
}
|
}
|
||||||
await this.prisma.originGood.update({
|
await this.prisma.originGood.update({
|
||||||
|
|||||||
Reference in New Issue
Block a user