refactor(api): parsing out of runtime — pure-mirror sync, explicit organize, auto aggregate recompute

解析去运行时化(三层架构,plans/refactor/organize-script-refactor.md):
- 同步 = 纯镜像:upsertOriginGood 不再写解析列、不再自动挂族;详情同步仍触发重算
- 整理 = 显式人工动作(OrganizeService):解析列回填 → 派生标签(人工接管永不
  覆盖)→ auto-group 建族 → 全量重算;入口 CLI(pnpm --filter @inkreach/api
  organize)+ POST /product-families/organize + 后台「整理」按钮
- 重算 = 纯结构化聚合:不再按名称重派生标签(防上游改名倒灌,回归测试覆盖);
  矩阵维度只认标签/CUSTOM 显式标签,未整理成员不进矩阵;工艺=不打印时
  印花数量以单面占位(纯结构化规则);「恢复自动」走整理的单链接派生
- 派生默认补齐(脚本层假设):名称无单/双面且工艺非不打印 → 印花数量单面印花
- goods 服务建品/更新后仅镜像标签+重算(不派生);含商品名入库规范化
  (normalizeGoodName,管理员输入边界质检)
- organize.service.spec 由 tag-sync spec 迁移 + 防倒灌回归;sync/recompute/
  public/families spec 全部适配;API 173/173,admin typecheck+22/22
This commit is contained in:
yeuimu
2026-08-30 01:27:01 +08:00
parent 976f308e3c
commit 93ac525c55
24 changed files with 2280 additions and 1946 deletions
@@ -0,0 +1,341 @@
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,
} from './auto-tag-rules';
/**
* 整理服务(解析去运行时化的唯一解析入口):
* 所有"按上游链接名解析/派生"的逻辑集中于此,由显式人工动作触发——
* CLIpnpm --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 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,
familiesRecomputed: families.length,
};
this.logger.log(`organize done: ${JSON.stringify(result)}`);
return result;
}
/**
* 回填结构化解析列(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 };
}
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;
}
}