feat(product-family): link-level tags (origin_good_tags) with manual override + member detail enrichment

This commit is contained in:
yeuimu
2026-08-28 16:00:46 +08:00
parent 37b52c5ebb
commit 08201f18a4
11 changed files with 477 additions and 87 deletions
@@ -300,20 +300,93 @@ export class FamilyRecomputeService {
}
/**
* 族 → 商品标签同步标签与「产品链接」一一对应,按每条链接自身的名称解析
* (印花数量 / 工艺 / 物流,规则见 auto-tag-rules.ts)。派生组缺失的组/标签
* 自动补建;仅更新发生变化的商品,幂等。
* 族 → 标签同步标签与「产品链接」一一对应):
* 1) 链接级:未人工接管(tagsManual=false)的 SDS 链接,按链接名称刷新
* origin_good_tags 的派生行(manual=false);人工行(manual=true)永远保留;
* 2) 商品级镜像:good 标签 = 自身链接的有效标签 ∪ 非自动组的既有标签。
* 仅更新发生变化的行,幂等。
*/
async syncFamilyTags(familyId: bigint): Promise<{ goodsUpdated: number }> {
async syncFamilyTags(
familyId: bigint,
): Promise<{ goodsUpdated: number; linksUpdated: number }> {
const tagMap = await this.ensureDerivedTagMap();
const family = await this.prisma.productFamily.findUnique({
where: { id: familyId },
include: {
originGoods: {
where: { delisted: false },
include: { originGoodTags: true },
},
},
});
if (!family) return { goodsUpdated: 0, linksUpdated: 0 };
// 1) 链接级派生
let linksUpdated = 0;
for (const og of family.originGoods) {
if (og.tagsManual || og.source !== 'SDS') continue;
const derivedIds = this.resolveDerivedIds(og.goodName, tagMap);
const autoRows = og.originGoodTags.filter((r) => !r.manual);
const currentIds = autoRows.map((r) => r.tagId).sort((a, b) => Number(a - b));
const same =
derivedIds.length === currentIds.length &&
derivedIds.every((id, i) => id === currentIds[i]);
if (same) continue;
await this.prisma.$transaction([
this.prisma.originGoodTag.deleteMany({
where: { originGoodId: og.id, manual: false },
}),
...(!derivedIds.length
? []
: [
this.prisma.originGoodTag.createMany({
data: derivedIds.map((tagId) => ({
originGoodId: og.id,
tagId,
manual: false,
})),
}),
]),
]);
linksUpdated += 1;
}
// 2) 商品级镜像:派生写入后重新读取链接标签(上面的 include 是派生前快照)
const familyOgIds = family.originGoods.map((o) => o.id);
const freshRows = familyOgIds.length
? await this.prisma.originGoodTag.findMany({
where: { originGoodId: { in: familyOgIds } },
})
: [];
const tagsByOg = new Map<string, bigint[]>();
for (const r of freshRows) {
const key = r.originGoodId.toString();
tagsByOg.set(key, [...(tagsByOg.get(key) ?? []), r.tagId]);
}
const goods = await this.prisma.good.findMany({
where: { familyId },
include: {
goodTags: { include: { tag: { select: { id: true, tagGroupId: true } } } },
originGood: { select: { goodName: true, source: true } },
originGood: { select: { id: true } },
},
});
if (!goods.length) return { goodsUpdated: 0 };
const strayOgIds = [
...new Set(
goods
.map((g) => g.originGood?.id.toString())
.filter((id): id is string => !!id && !tagsByOg.has(id)),
),
];
if (strayOgIds.length) {
const rows = await this.prisma.originGoodTag.findMany({
where: { originGoodId: { in: strayOgIds.map((v) => BigInt(v)) } },
});
for (const r of rows) {
const key = r.originGoodId.toString();
tagsByOg.set(key, [...(tagsByOg.get(key) ?? []), r.tagId]);
}
}
const groups = await this.prisma.tagGroup.findMany({
select: { id: true, groupName: true },
@@ -324,18 +397,14 @@ export class FamilyRecomputeService {
let goodsUpdated = 0;
for (const good of goods) {
// 保留:非自动分组的既有标签(人工管理)∪ 本链接名称的派生标签
const linkTagIds = good.originGood
? (tagsByOg.get(good.originGood.id.toString()) ?? [])
: [];
// 保留:非自动分组的既有标签(人工管理)∪ 链接有效标签
const keep = good.goodTags
.filter((gt) => !autoGroupIds.has(gt.tag.tagGroupId?.toString() ?? ''))
.map((gt) => gt.tagId);
const derivedNames =
good.originGood && good.originGood.source === 'SDS'
? deriveLinkTagNames(good.originGood.goodName)
: [];
const derivedIds = derivedNames
.map((name) => tagMap.get(name))
.filter((id): id is bigint => id !== undefined);
const target = [...new Set([...keep, ...derivedIds])].sort((a, b) =>
const target = [...new Set([...keep, ...linkTagIds])].sort((a, b) =>
Number(a - b),
);
const current = good.goodTags.map((gt) => gt.tagId).sort((a, b) => Number(a - b));
@@ -354,7 +423,94 @@ export class FamilyRecomputeService {
]);
goodsUpdated += 1;
}
return { goodsUpdated };
return { goodsUpdated, linksUpdated };
}
/** 单链接:未人工接管时按名称刷新派生标签,并把有效标签镜像到其名下商品 */
async refreshLinkTags(ogId: bigint): Promise<void> {
const og = await this.prisma.originGood.findUnique({
where: { id: ogId },
include: { originGoodTags: true },
});
if (!og) return;
if (!og.tagsManual && og.source === 'SDS') {
const tagMap = await this.ensureDerivedTagMap();
const derivedIds = this.resolveDerivedIds(og.goodName, tagMap);
await this.prisma.$transaction([
this.prisma.originGoodTag.deleteMany({
where: { originGoodId: og.id, manual: false },
}),
...(!derivedIds.length
? []
: [
this.prisma.originGoodTag.createMany({
data: derivedIds.map((tagId) => ({
originGoodId: og.id,
tagId,
manual: false,
})),
}),
]),
]);
}
await this.mirrorLinkTagsToGoods(og.id);
}
/** 把链接的有效标签镜像到其名下商品(good 标签 = 链接标签 ∪ 非自动组既有标签) */
async mirrorLinkTagsToGoods(ogId: bigint): Promise<void> {
const rows = await this.prisma.originGoodTag.findMany({
where: { originGoodId: ogId },
select: { tagId: true },
});
const linkTagIds = rows.map((r) => r.tagId);
const goods = await this.prisma.good.findMany({
where: { originGoodId: ogId },
include: {
goodTags: { include: { tag: { select: { id: true, tagGroupId: true } } } },
},
});
if (!goods.length) return;
const groups = await this.prisma.tagGroup.findMany({
select: { id: true, groupName: true },
});
const autoGroupIds = new Set(
groups.filter((g) => isAutoTagGroupName(g.groupName)).map((g) => g.id.toString()),
);
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, ...linkTagIds])].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 })),
}),
]),
]);
}
}
private resolveDerivedIds(
name: string | null | undefined,
tagMap: Map<string, bigint>,
): bigint[] {
return [
...new Set(
deriveLinkTagNames(name)
.map((n) => tagMap.get(n))
.filter((id): id is bigint => id !== undefined),
),
].sort((a, b) => Number(a - b));
}
/** 确保派生标签组与标签存在,返回「标签名 → 标签 id」映射(并发下取最小 id,天然去重) */