问题2(SKU 无法自动合并,GBIU017 案例)四处叠加根因修复: - 人工标签旧词「热转印」被封闭词表静默踢出合并矩阵 → CRAFT_TAG_ALIASES 别名归一为「烫画」(矩阵归因与 CUSTOM 标签同路径生效) - updateTags 缺任一定价维度组即整链掉出矩阵且人工接管后永不恢复 → 缺啥补啥(按链接名派生补齐,人工勾选值不动,响应带 filledDimensionTags) - autoGroup 只建新族从不并入已有族(产生 USIU005-2 类碎片)→ 并入已有 autoManaged 同款族优先,无匹配才新建;同款仅人工锁定族则跳过并报告 - 名称回退分组键含物流备注,包邮/不包邮永不同组 → 新增族语义键 familyNameKey(国家+品名+SKU),api/admin 两侧同构,合并默认勾选随之修复 附加: - updateTags 后未归族链接自动并入匹配族(attachToMatchingFamily,响应带 attachedFamilyId) - 整理新增碎片族合并 consolidateFragments:纯碎片族并入带商品族并删除 (保公开 goodId=族ID 稳定),带商品/覆盖价/人工锁定进人工复审报告 - admin:保存标签提示补齐明细,整理完成消息含并入/碎片合并/待人工数 问题1(后台频繁 Unauthorized 掉线): - refresh cookie path '/auth' 与代理前缀(/api、/v2-api)不匹配导致浏览器 永远带不上 refresh cookie → 两 cookie path 统一为 '/' - v2 构建基址带尾斜杠 + 手工拼接产生 /v2-api//auth/refresh 双斜杠 404 → request.ts 规范拼接 测试:api jest 187/187、admin vitest 22/22、双侧 tsc 0 错误; public.service 夹具改为自包含(不依赖共享库既有数据)。
483 lines
18 KiB
TypeScript
483 lines
18 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
||
import { PrismaService } from '../prisma/prisma.service';
|
||
import { FamilyRecomputeService } from './family-recompute.service';
|
||
import { ProductFamiliesService } from './product-families.service';
|
||
import { parseOriginName } from './origin-name.parser';
|
||
import {
|
||
DERIVED_TAG_GROUP_SPECS,
|
||
deriveLinkTagNames,
|
||
isAutoTagGroupName,
|
||
normalizeCraftTag,
|
||
} from './auto-tag-rules';
|
||
|
||
/**
|
||
* 整理服务(解析去运行时化的唯一解析入口):
|
||
* 所有"按上游链接名解析/派生"的逻辑集中于此,由显式人工动作触发——
|
||
* CLI(pnpm --filter @inkreach/api organize)或后台「整理」按钮(POST /product-families/organize)。
|
||
*
|
||
* 运行时(同步/重算/公开读)永不解析名称:
|
||
* - 同步 = 纯镜像;族重算 = 结构化聚合(成员变体 + 链接标签 + 覆盖价)。
|
||
*
|
||
* 人工接管(tagsManual=true)的链接派生永不触碰;幂等可重跑。
|
||
*/
|
||
@Injectable()
|
||
export class OrganizeService {
|
||
private readonly logger = new Logger(OrganizeService.name);
|
||
|
||
constructor(
|
||
private readonly prisma: PrismaService,
|
||
private readonly recompute: FamilyRecomputeService,
|
||
private readonly families: ProductFamiliesService,
|
||
) {}
|
||
|
||
async organize() {
|
||
const labels = await this.backfillLabels();
|
||
const tags = await this.deriveAllTags();
|
||
const grouped = await this.families.autoGroup(true);
|
||
const fragments = await this.consolidateFragments();
|
||
const families = await this.prisma.productFamily.findMany({ select: { id: true } });
|
||
for (const f of families) {
|
||
await this.recompute.recomputeFamily(f.id);
|
||
}
|
||
const result = {
|
||
labelsParsed: labels.parsed,
|
||
labelsUnparsable: labels.unparsable,
|
||
linksUpdated: tags.linksUpdated,
|
||
goodsUpdated: tags.goodsUpdated,
|
||
familiesCreated: grouped.applied,
|
||
familiesMerged: grouped.merged,
|
||
familiesSkipped: grouped.skipped?.length ?? 0,
|
||
fragmentsConsolidated: fragments.consolidated,
|
||
fragmentsManualReview: fragments.manualReview.length,
|
||
familiesRecomputed: families.length,
|
||
};
|
||
this.logger.log(`organize done: ${JSON.stringify(result)}`);
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 碎片族合并:同一 SDS 分类出现多个族时(历史 autoGroup 只建不并所致),
|
||
* 把「纯碎片族」(autoManaged、无商品、无覆盖价)的成员并入最老族并删除空族;
|
||
* 带商品/覆盖价或人工锁定的碎片族保留,写进人工复审报告(由后台手动移动成员)。
|
||
* keeper 选择:同簇中最早带商品的族,否则最老的 autoManaged 族。
|
||
*/
|
||
async consolidateFragments(): Promise<{
|
||
consolidated: number;
|
||
manualReview: Array<{ keeperFamilyId: string; fragmentFamilyId: string; reason: string }>;
|
||
}> {
|
||
const families = await this.prisma.productFamily.findMany({
|
||
select: { id: true, autoManaged: true },
|
||
orderBy: { id: 'asc' },
|
||
});
|
||
const members = await this.prisma.originGood.findMany({
|
||
where: { familyId: { not: null }, delisted: false },
|
||
select: { familyId: true, sdsCategoryId: true },
|
||
});
|
||
const goodCounts = await this.prisma.good.groupBy({
|
||
by: ['familyId'],
|
||
_count: { _all: true },
|
||
where: { familyId: { not: null } },
|
||
});
|
||
const goodsByFamily = new Map(goodCounts.map((g) => [g.familyId!.toString(), g._count._all]));
|
||
const overrideCounts = await this.prisma.familyPriceOverride.groupBy({
|
||
by: ['familyId'],
|
||
_count: { _all: true },
|
||
});
|
||
const overridesByFamily = new Map(
|
||
overrideCounts.map((o) => [o.familyId.toString(), o._count._all]),
|
||
);
|
||
const autoManagedById = new Map(families.map((f) => [f.id.toString(), f.autoManaged]));
|
||
const familyCatIds = new Map<string, Set<string>>();
|
||
for (const m of members) {
|
||
if (!m.sdsCategoryId) continue;
|
||
const key = m.familyId!.toString();
|
||
familyCatIds.set(key, (familyCatIds.get(key) ?? new Set()).add(m.sdsCategoryId));
|
||
}
|
||
|
||
// 按 SDS 分类聚簇:同分类的多个族互为碎片
|
||
const clusterByFamily = new Map<string, Set<string>>();
|
||
const catOwners = new Map<string, string[]>();
|
||
for (const f of families) {
|
||
const cats = familyCatIds.get(f.id.toString()) ?? new Set();
|
||
for (const cat of cats) {
|
||
const owners = catOwners.get(cat) ?? [];
|
||
owners.push(f.id.toString());
|
||
catOwners.set(cat, owners);
|
||
}
|
||
}
|
||
for (const owners of catOwners.values()) {
|
||
if (owners.length < 2) continue;
|
||
for (const id of owners) {
|
||
const cluster = clusterByFamily.get(id) ?? new Set();
|
||
owners.forEach((o) => cluster.add(o));
|
||
clusterByFamily.set(id, cluster);
|
||
}
|
||
}
|
||
|
||
let consolidated = 0;
|
||
const manualReview: Array<{ keeperFamilyId: string; fragmentFamilyId: string; reason: string }> = [];
|
||
const processedClusters = new Set<string>();
|
||
for (const [familyIdStr, cluster] of clusterByFamily) {
|
||
const sorted = [...cluster].sort((a, b) => Number(BigInt(a) - BigInt(b)));
|
||
const clusterKey = sorted.join(',');
|
||
if (processedClusters.has(clusterKey)) continue;
|
||
processedClusters.add(clusterKey);
|
||
// keeper:最早带商品的族,否则最老的 autoManaged 族
|
||
const withGoods = sorted.find((id) => (goodsByFamily.get(id) ?? 0) > 0);
|
||
const keeper =
|
||
withGoods ??
|
||
sorted.find((id) => autoManagedById.get(id) === true) ??
|
||
sorted[0];
|
||
for (const id of sorted) {
|
||
if (id === keeper) continue;
|
||
const reasons: string[] = [];
|
||
if ((goodsByFamily.get(id) ?? 0) > 0) reasons.push('has-goods');
|
||
if ((overridesByFamily.get(id) ?? 0) > 0) reasons.push('has-overrides');
|
||
if (autoManagedById.get(id) !== true) reasons.push('manual-locked');
|
||
if (reasons.length) {
|
||
manualReview.push({
|
||
keeperFamilyId: keeper,
|
||
fragmentFamilyId: id,
|
||
reason: reasons.join(','),
|
||
});
|
||
continue;
|
||
}
|
||
// 纯碎片:成员并入 keeper,删除空族
|
||
const keeperId = BigInt(keeper);
|
||
await this.prisma.originGood.updateMany({
|
||
where: { familyId: BigInt(id) },
|
||
data: { familyId: keeperId },
|
||
});
|
||
await this.prisma.good.updateMany({
|
||
where: { familyId: BigInt(id) },
|
||
data: { familyId: keeperId },
|
||
});
|
||
await this.prisma.productFamily.delete({ where: { id: BigInt(id) } });
|
||
consolidated += 1;
|
||
}
|
||
await this.recompute.recomputeFamily(BigInt(keeper));
|
||
}
|
||
return { consolidated, manualReview };
|
||
}
|
||
|
||
/**
|
||
* 回填结构化解析列(skuCode/logisticsLabel/craftLabel/warehouseLabel)。
|
||
* 只补 NULL 列——存量正确数据不覆盖(同步已不再写入这些列)。
|
||
*/
|
||
async backfillLabels(): Promise<{ parsed: number; unparsable: number }> {
|
||
const ogs = await this.prisma.originGood.findMany({
|
||
where: {
|
||
OR: [
|
||
{ skuCode: null },
|
||
{ logisticsLabel: null },
|
||
{ craftLabel: null },
|
||
{ warehouseLabel: null },
|
||
],
|
||
},
|
||
select: { id: true, goodName: true, skuCode: true, logisticsLabel: true, craftLabel: true, warehouseLabel: true },
|
||
});
|
||
let parsed = 0;
|
||
let unparsable = 0;
|
||
for (const og of ogs) {
|
||
const p = parseOriginName(og.goodName);
|
||
const next = {
|
||
skuCode: og.skuCode ?? p.skuCode,
|
||
logisticsLabel: og.logisticsLabel ?? p.logisticsLabel,
|
||
craftLabel: og.craftLabel ?? p.craftLabel,
|
||
warehouseLabel: og.warehouseLabel ?? p.warehouseLabel,
|
||
};
|
||
if (!p.skuCode && !p.craftLabel) unparsable += 1;
|
||
await this.prisma.originGood.update({ where: { id: og.id }, data: next });
|
||
parsed += 1;
|
||
}
|
||
return { parsed, unparsable };
|
||
}
|
||
|
||
/** 全量派生:未人工接管的 SDS 链接按名称刷新自动标签,人工接管不动;随后镜像商品并重算受影响族 */
|
||
async deriveAllTags(): Promise<{ linksUpdated: number; goodsUpdated: number; familiesRecomputed: number }> {
|
||
const families = await this.prisma.productFamily.findMany({
|
||
select: { id: true },
|
||
orderBy: { id: 'asc' },
|
||
});
|
||
let linksUpdated = 0;
|
||
let goodsUpdated = 0;
|
||
const touchedFamilies: bigint[] = [];
|
||
for (const f of families) {
|
||
const r = await this.deriveFamilyTags(f.id);
|
||
linksUpdated += r.linksUpdated;
|
||
goodsUpdated += r.goodsUpdated;
|
||
if (r.linksUpdated > 0 || r.goodsUpdated > 0) touchedFamilies.push(f.id);
|
||
}
|
||
// 散链接(无族)也派生并镜像,保证族化之前标签已就绪
|
||
const loose = await this.prisma.originGood.findMany({
|
||
where: { familyId: null, delisted: false, source: 'SDS', tagsManual: false },
|
||
select: { id: true },
|
||
});
|
||
for (const og of loose) {
|
||
const r = await this.deriveTagsForOg(og.id);
|
||
linksUpdated += r.linksUpdated;
|
||
}
|
||
for (const fid of touchedFamilies) {
|
||
await this.recompute.recomputeFamily(fid);
|
||
}
|
||
return { linksUpdated, goodsUpdated, familiesRecomputed: touchedFamilies.length };
|
||
}
|
||
|
||
/**
|
||
* 族 → 标签派生(标签与「产品链接」一一对应):
|
||
* 1) 链接级:未人工接管(tagsManual=false)的 SDS 链接,按链接名称刷新
|
||
* origin_good_tags 的派生行(manual=false);人工行(manual=true)永远保留;
|
||
* 2) 商品级镜像:good 标签 = 自身链接的有效标签 ∪ 非自动组的既有标签。
|
||
* 仅更新发生变化的行,幂等。
|
||
*/
|
||
async deriveFamilyTags(
|
||
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 };
|
||
|
||
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;
|
||
}
|
||
|
||
// 商品级镜像:派生写入后重新读取链接标签(上面的 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: { id: true } },
|
||
},
|
||
});
|
||
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 { autoGroupIds } = await this.autoGroupIds();
|
||
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 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 })),
|
||
}),
|
||
]),
|
||
]);
|
||
goodsUpdated += 1;
|
||
}
|
||
return { goodsUpdated, linksUpdated };
|
||
}
|
||
|
||
/**
|
||
* 单链接派生(供「恢复自动」等显式人工动作复用):
|
||
* 未人工接管时按名称刷新派生标签,并把有效标签镜像到其名下商品。
|
||
*/
|
||
async deriveTagsForOg(ogId: bigint): Promise<{ linksUpdated: number }> {
|
||
const og = await this.prisma.originGood.findUnique({
|
||
where: { id: ogId },
|
||
include: { originGoodTags: true },
|
||
});
|
||
if (!og) return { linksUpdated: 0 };
|
||
let linksUpdated = 0;
|
||
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,
|
||
})),
|
||
}),
|
||
]),
|
||
]);
|
||
linksUpdated = 1;
|
||
}
|
||
await this.recompute.mirrorLinkTagsToGoods(og.id);
|
||
return { linksUpdated };
|
||
}
|
||
|
||
/**
|
||
* 人工标签缺维补齐:三个定价维度组(印花数量/工艺/物流)任一组在当前标签里
|
||
* 没有封闭词表取值时,按链接名称派生补上;工艺维度经别名归一后判断(热转印≈烫画)。
|
||
* 人工已勾的维度永不覆盖;名称派生不出该维度时不补(由调用方提示)。
|
||
*/
|
||
async fillMissingDimTags(
|
||
goodName: string | null,
|
||
currentTagIds: bigint[],
|
||
): Promise<{ tagIds: bigint[]; names: string[] }> {
|
||
const tagMap = await this.ensureDerivedTagMap();
|
||
const current = currentTagIds.length
|
||
? await this.prisma.tag.findMany({
|
||
where: { id: { in: currentTagIds } },
|
||
select: { tagName: true },
|
||
})
|
||
: [];
|
||
const currentNames = new Set(current.map((t) => normalizeCraftTag(t.tagName)));
|
||
const derivedNames = deriveLinkTagNames(goodName);
|
||
const missingNames: string[] = [];
|
||
for (const spec of DERIVED_TAG_GROUP_SPECS) {
|
||
if (spec.tags.some((name) => currentNames.has(name))) continue;
|
||
const fromName = spec.tags.find((name) => derivedNames.includes(name));
|
||
if (fromName) missingNames.push(fromName);
|
||
}
|
||
const tagIds = missingNames
|
||
.map((name) => tagMap.get(name))
|
||
.filter((id): id is bigint => id !== undefined);
|
||
return { tagIds, names: missingNames };
|
||
}
|
||
|
||
private async autoGroupIds(): Promise<{ autoGroupIds: Set<string> }> {
|
||
const groups = await this.prisma.tagGroup.findMany({
|
||
select: { id: true, groupName: true },
|
||
});
|
||
return {
|
||
autoGroupIds: new Set(
|
||
groups
|
||
.filter((g) => isAutoTagGroupName(g.groupName))
|
||
.map((g) => g.id.toString()),
|
||
),
|
||
};
|
||
}
|
||
|
||
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,天然去重) */
|
||
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;
|
||
}
|
||
}
|