feat(product-family): derive tags per link name (印花数量/工艺/物流 rules)

This commit is contained in:
yeuimu
2026-08-28 15:33:11 +08:00
parent f5a3b8c840
commit ee336968a0
5 changed files with 314 additions and 112 deletions
@@ -1,6 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
DERIVED_TAG_GROUP_SPECS,
deriveLinkTagNames,
isAutoTagGroupName,
} from './auto-tag-rules';
/**
* 产品族重算:并集尺码表/包装规则 + 五维价格矩阵物化。
@@ -291,73 +296,45 @@ export class FamilyRecomputeService {
}
// 派生标签同步(无论是否锁定:标签是派生数据而非人工策展)
await this.syncFamilyTags(family.id, members);
await this.syncFamilyTags(family.id);
}
/**
* 族 → 商品标签同步:物流/工艺/印刷位置类标签组(组名含「物流」「工艺」或「位置」,
* 与官网筛选维度一致)下的标签由族成员的 logisticsLabel/craftLabel 派生;
* 标签名与链接标签存在词尾差异(单面印花 ↔ 单面印),采用前缀匹配并取最长命中
* 其他分组保持人工管理。仅更新发生变化的商品,幂等。
* 族 → 商品标签同步:标签与「产品链接」一一对应,按每条链接自身的名称解析
* (印花数量 / 工艺 / 物流,规则见 auto-tag-rules.ts)。派生组缺失的组/标签
* 自动补建;仅更新发生变化的商品,幂等
*/
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);
}
async syncFamilyTags(familyId: bigint): Promise<{ goodsUpdated: number }> {
const tagMap = await this.ensureDerivedTagMap();
const goods = await this.prisma.good.findMany({
where: { familyId },
include: { goodTags: { include: { tag: { select: { id: true, tagGroupId: true } } } } },
include: {
goodTags: { include: { tag: { select: { id: true, tagGroupId: true } } } },
originGood: { select: { goodName: true, source: true } },
},
});
if (!goods.length) return { goodsUpdated: 0 };
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()),
);
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 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) =>
Number(a - b),
);
@@ -379,4 +356,50 @@ export class FamilyRecomputeService {
}
return { goodsUpdated };
}
/** 确保派生标签组与标签存在,返回「标签名 → 标签 id」映射(并发下取最小 id,天然去重) */
private async ensureDerivedTagMap(): Promise<Map<string, bigint>> {
const map = new Map<string, bigint>();
for (const spec of DERIVED_TAG_GROUP_SPECS) {
const findGroups = () =>
this.prisma.tagGroup.findMany({
where: { groupName: { contains: spec.group } },
orderBy: { id: 'asc' },
});
let group = (await findGroups())[0] ?? null;
if (!group) {
try {
group = await this.prisma.tagGroup.create({ data: { groupName: spec.group } });
} catch {
group = (await findGroups())[0] ?? null;
}
}
if (!group) continue;
const tags = await this.prisma.tag.findMany({
where: { tagGroupId: group.id, tagName: { in: spec.tags } },
orderBy: { id: 'asc' },
});
for (const tagName of spec.tags) {
const existing = tags.find((t) => t.tagName === tagName);
if (existing) {
map.set(tagName, existing.id);
continue;
}
try {
const created = await this.prisma.tag.create({
data: { tagGroupId: group.id, tagName },
});
map.set(tagName, created.id);
} catch {
const fallback = await this.prisma.tag.findFirst({
where: { tagGroupId: group.id, tagName },
orderBy: { id: 'asc' },
});
if (fallback) map.set(tagName, fallback.id);
}
}
}
return map;
}
}