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>(); 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>(); const catOwners = new Map(); 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(); 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(); 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 }> { 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, ): 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> { const map = new Map(); 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; } }