feat(product-family): auto-derive logistics/craft tags from family members
This commit is contained in:
@@ -289,5 +289,94 @@ export class FamilyRecomputeService {
|
||||
data: { stale: true },
|
||||
});
|
||||
}
|
||||
|
||||
// 派生标签同步(无论是否锁定:标签是派生数据而非人工策展)
|
||||
await this.syncFamilyTags(family.id, members);
|
||||
}
|
||||
|
||||
/**
|
||||
* 族 → 商品标签同步:物流/工艺/印刷位置类标签组(组名含「物流」「工艺」或「位置」,
|
||||
* 与官网筛选维度一致)下的标签由族成员的 logisticsLabel/craftLabel 派生;
|
||||
* 标签名与链接标签存在词尾差异(单面印花 ↔ 单面印),采用前缀匹配并取最长命中。
|
||||
* 其他分组保持人工管理。仅更新发生变化的商品,幂等。
|
||||
*/
|
||||
async syncFamilyTags(
|
||||
familyId: bigint,
|
||||
preloadedMembers?: Member[],
|
||||
): Promise<{ goodsUpdated: number }> {
|
||||
const members =
|
||||
preloadedMembers ??
|
||||
(await this.prisma.originGood.findMany({
|
||||
where: { familyId, delisted: false },
|
||||
select: { logisticsLabel: true, craftLabel: true },
|
||||
}));
|
||||
const labels = members
|
||||
.flatMap((m) => [m.logisticsLabel, m.craftLabel])
|
||||
.filter((l): l is string => !!l);
|
||||
|
||||
const autoGroups = await this.prisma.tagGroup.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ groupName: { contains: '物流' } },
|
||||
{ groupName: { contains: '工艺' } },
|
||||
{ groupName: { contains: '位置' } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const autoGroupIds = new Set(autoGroups.map((g) => g.id.toString()));
|
||||
if (!autoGroupIds.size || !labels.length) {
|
||||
return { goodsUpdated: 0 };
|
||||
}
|
||||
|
||||
const candidates = await this.prisma.tag.findMany({
|
||||
where: { tagGroupId: { in: autoGroups.map((g) => g.id) } },
|
||||
select: { id: true, tagName: true },
|
||||
});
|
||||
// 每个链接标签取「最长前缀命中」的标签(避免短名误吃长名场景)
|
||||
const derivedIds = new Set<bigint>();
|
||||
for (const label of new Set(labels)) {
|
||||
let best: { id: bigint; name: string } | null = null;
|
||||
for (const tag of candidates) {
|
||||
if (label === tag.tagName || label.startsWith(tag.tagName)) {
|
||||
if (!best || tag.tagName.length > best.name.length) {
|
||||
best = { id: tag.id, name: tag.tagName };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best) derivedIds.add(best.id);
|
||||
}
|
||||
|
||||
const goods = await this.prisma.good.findMany({
|
||||
where: { familyId },
|
||||
include: { goodTags: { include: { tag: { select: { id: true, tagGroupId: true } } } } },
|
||||
});
|
||||
|
||||
let goodsUpdated = 0;
|
||||
for (const good of goods) {
|
||||
// 保留:非自动分组的既有标签(人工管理)∪ 派生标签
|
||||
const keep = good.goodTags
|
||||
.filter((gt) => !autoGroupIds.has(gt.tag.tagGroupId?.toString() ?? ''))
|
||||
.map((gt) => gt.tagId);
|
||||
const target = [...new Set([...keep, ...derivedIds])].sort((a, b) =>
|
||||
Number(a - b),
|
||||
);
|
||||
const current = good.goodTags.map((gt) => gt.tagId).sort((a, b) => Number(a - b));
|
||||
const same =
|
||||
target.length === current.length && target.every((id, i) => id === current[i]);
|
||||
if (same) continue;
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.goodTag.deleteMany({ where: { goodId: good.id } }),
|
||||
...(!target.length
|
||||
? []
|
||||
: [
|
||||
this.prisma.goodTag.createMany({
|
||||
data: target.map((tagId) => ({ goodId: good.id, tagId })),
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
goodsUpdated += 1;
|
||||
}
|
||||
return { goodsUpdated };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { FamilyRecomputeService } from './family-recompute.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/** 族 → 商品标签自动同步(物流/工艺组由成员标签派生,其他分组人工保留) */
|
||||
describe('FamilyRecomputeService.syncFamilyTags', () => {
|
||||
let service: FamilyRecomputeService;
|
||||
let prisma: PrismaService;
|
||||
const stamp = Date.now();
|
||||
const ids = {
|
||||
originGood: [] as bigint[],
|
||||
family: [] as bigint[],
|
||||
good: [] as bigint[],
|
||||
country: [] as bigint[],
|
||||
category: [] as bigint[],
|
||||
group: [] as bigint[],
|
||||
otherGroup: [] as bigint[],
|
||||
tag: [] as bigint[],
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [FamilyRecomputeService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(FamilyRecomputeService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
ids.country.push((await prisma.country.create({ data: { countryName: `标签同步国家-${stamp}` } })).id);
|
||||
ids.category.push((await prisma.category.create({ data: { categoryName: `标签同步分类-${stamp}` } })).id);
|
||||
// 物流组(自动)+ 其他组(人工)
|
||||
ids.group.push((await prisma.tagGroup.create({ data: { groupName: `物流渠道${stamp}`, sortOrder: 1 } })).id);
|
||||
ids.otherGroup.push((await prisma.tagGroup.create({ data: { groupName: `风格${stamp}`, sortOrder: 2 } })).id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.goodTag.deleteMany({ where: { tagId: { in: ids.tag } } });
|
||||
await prisma.good.deleteMany({ where: { id: { in: ids.good } } });
|
||||
await prisma.originGood.deleteMany({ where: { id: { in: ids.originGood } } });
|
||||
await prisma.productFamily.deleteMany({ where: { id: { in: ids.family } } });
|
||||
await prisma.tag.deleteMany({ where: { id: { in: ids.tag } } });
|
||||
await prisma.tagGroup.deleteMany({ where: { id: { in: [...ids.group, ...ids.otherGroup] } } });
|
||||
await prisma.country.deleteMany({ where: { id: { in: ids.country } } });
|
||||
await prisma.category.deleteMany({ where: { id: { in: ids.category } } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
function mkTag(name: string, groupId: bigint) {
|
||||
return prisma.tag.create({ data: { tagName: name, tagGroupId: groupId } });
|
||||
}
|
||||
|
||||
it('族成员标签 → 自动同步到族下商品;人工分组标签保留;成员变化后增量更新', async () => {
|
||||
const tagBaoyou = await mkTag(`包邮${stamp}`, ids.group[0]);
|
||||
const tagDanyin = await mkTag(`单面印花${stamp}`, ids.group[0]);
|
||||
const tagStyle = await mkTag(`潮流${stamp}`, ids.otherGroup[0]);
|
||||
ids.tag.push(tagBaoyou.id, tagDanyin.id, tagStyle.id);
|
||||
|
||||
const og1 = await prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId: `tagsync-${stamp}-1`,
|
||||
goodName: `美国(包邮${stamp})T恤-TS${stamp}-单面印花${stamp}`,
|
||||
logisticsLabel: `包邮${stamp}`,
|
||||
craftLabel: `单面印花${stamp}`,
|
||||
},
|
||||
});
|
||||
const og2 = await prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId: `tagsync-${stamp}-2`,
|
||||
goodName: `美国(不包邮)T恤-TS${stamp}-双面印花`,
|
||||
logisticsLabel: `不包邮`,
|
||||
craftLabel: `双面印花`,
|
||||
},
|
||||
});
|
||||
ids.originGood.push(og1.id, og2.id);
|
||||
const family = await prisma.productFamily.create({
|
||||
data: { familyName: `标签同步族-${stamp}`, primaryOriginGoodId: og1.id },
|
||||
});
|
||||
ids.family.push(family.id);
|
||||
await prisma.originGood.updateMany({
|
||||
where: { id: { in: [og1.id, og2.id] } },
|
||||
data: { familyId: family.id },
|
||||
});
|
||||
const good = await prisma.good.create({
|
||||
data: {
|
||||
originGoodId: og1.id,
|
||||
familyId: family.id,
|
||||
countryId: ids.country[0],
|
||||
categoryId: ids.category[0],
|
||||
goodName: `标签同步商品-${stamp}`,
|
||||
},
|
||||
});
|
||||
ids.good.push(good.id);
|
||||
// 预置一个人工分组标签
|
||||
await prisma.goodTag.create({ data: { goodId: good.id, tagId: tagStyle.id } });
|
||||
|
||||
// 初次同步:物流/工艺组标签由成员标签派生(含真实组的前缀命中),人工"潮流"保留
|
||||
const r1 = await service.syncFamilyTags(family.id);
|
||||
expect(r1.goodsUpdated).toBe(1);
|
||||
const names1 = (await prisma.goodTag.findMany({
|
||||
where: { goodId: good.id },
|
||||
include: { tag: true },
|
||||
})).map((t) => t.tag.tagName);
|
||||
expect(names1).toContain(`包邮${stamp}`); // og1 物流(最长前缀命中戳记标签)
|
||||
expect(names1).toContain(`单面印花${stamp}`); // og1 工艺
|
||||
expect(names1).toContain('不包邮'); // og2 物流(真实组命中)
|
||||
expect(names1).toContain('双面印'); // og2 工艺 双面印花 → 前缀命中真实标签
|
||||
expect(names1).toContain(`潮流${stamp}`); // 人工分组标签保留
|
||||
|
||||
// 幂等:无变化不写
|
||||
const r2 = await service.syncFamilyTags(family.id);
|
||||
expect(r2.goodsUpdated).toBe(0);
|
||||
|
||||
// og1 工艺改掉 → 其派生的 单面印花stamped 消失(og2 工艺是 双面印花,不命中它),人工标签保留
|
||||
await prisma.originGood.update({
|
||||
where: { id: og1.id },
|
||||
data: { craftLabel: `双面印花` },
|
||||
});
|
||||
await service.syncFamilyTags(family.id);
|
||||
const names3 = (await prisma.goodTag.findMany({
|
||||
where: { goodId: good.id },
|
||||
include: { tag: true },
|
||||
})).map((t) => t.tag.tagName);
|
||||
expect(names3).not.toContain(`单面印花${stamp}`);
|
||||
expect(names3).toContain(`包邮${stamp}`);
|
||||
expect(names3).toContain(`潮流${stamp}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user