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:
@@ -1,11 +1,7 @@
|
||||
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';
|
||||
import { isAutoTagGroupName } from './auto-tag-rules';
|
||||
|
||||
/**
|
||||
* 产品族重算:并集尺码表/包装规则 + 五维价格矩阵物化。
|
||||
@@ -82,38 +78,38 @@ export interface MatrixCombo {
|
||||
}
|
||||
|
||||
/**
|
||||
* 成员在矩阵中的归因维度组合(纯函数),取值优先级:
|
||||
* 成员在矩阵中的归因维度组合(纯函数),取值只有两个来源——
|
||||
* 1. 链接有效标签(人工接管后仍准确,仅认封闭词表内的标签);
|
||||
* 2. CUSTOM 成员的管理员显式标签(craftLabel/logisticsLabel,自由文本);
|
||||
* 3. 链接名称派生(deriveLinkTagNames);
|
||||
* 4. 组内默认值(单面印花 / 烫画 / 包邮)。
|
||||
* 2. CUSTOM 成员的管理员显式标签(craftLabel/logisticsLabel,自由文本)。
|
||||
* 【不解析名称】:SDS 成员无标签 → 返回空(不进矩阵),由整理(OrganizeService)显式补标签。
|
||||
* 多值时取笛卡尔积 —— 一个链接理论上只属一个组合,此处只是容错。
|
||||
*/
|
||||
export function memberMatrixCombos(input: {
|
||||
goodName: string | null;
|
||||
tagNames: string[];
|
||||
customLabels?: { craft?: string | null; logistics?: string | null };
|
||||
}): MatrixCombo[] {
|
||||
const derived = deriveLinkTagNames(input.goodName);
|
||||
const values = (dim: DimKey): string[] => {
|
||||
const fromTags = DIM_VALUES[dim].filter((v) => input.tagNames.includes(v));
|
||||
if (fromTags.length) return [...fromTags];
|
||||
const label =
|
||||
dim === 'craft' ? input.customLabels?.craft : input.customLabels?.logistics;
|
||||
if (dim !== 'printCount' && label) return [label];
|
||||
const fromName = derived.filter((n) =>
|
||||
(DIM_VALUES[dim] as readonly string[]).includes(n),
|
||||
);
|
||||
if (fromName.length) return fromName;
|
||||
if (dim === 'printCount') {
|
||||
const craftLabel = input.customLabels?.craft ?? '';
|
||||
return [craftLabel.includes('双面') ? '双面印花' : DIM_VALUES[dim][0]];
|
||||
}
|
||||
return [DIM_VALUES[dim][0]];
|
||||
};
|
||||
const printCounts = values('printCount');
|
||||
const crafts = values('craft');
|
||||
const logistics = values('logistics');
|
||||
const values = (dim: DimKey): string[] =>
|
||||
DIM_VALUES[dim].filter((v) => input.tagNames.includes(v));
|
||||
|
||||
let printCounts = values('printCount');
|
||||
let crafts = values('craft');
|
||||
let logistics = values('logistics');
|
||||
// CUSTOM 成员:管理员显式标签是唯一来源(craftLabel/logisticsLabel,自由文本)
|
||||
const craftLabel = input.customLabels?.craft ?? '';
|
||||
const logisticsLabel = input.customLabels?.logistics ?? '';
|
||||
if (!crafts.length && craftLabel) crafts = [craftLabel];
|
||||
if (!logistics.length && logisticsLabel) logistics = [logisticsLabel];
|
||||
// 结构化占位规则(非名称解析):工艺=不打印 时印花面不存在,印花数量固定单面占位,
|
||||
// 保证光板链接的价格进矩阵;工艺=烫画/直喷 而缺印花数量标签 → 缺维度不进矩阵(等整理补标签)
|
||||
const noPrintCraft =
|
||||
crafts.includes('不打印') || craftLabel.includes('不打印') || craftLabel.includes('光板');
|
||||
if (!printCounts.length && noPrintCraft) {
|
||||
printCounts = [DIM_VALUES.printCount[0]];
|
||||
} else if (!printCounts.length && craftLabel.includes('双面')) {
|
||||
printCounts = ['双面印花'];
|
||||
}
|
||||
if (!printCounts.length || !crafts.length || !logistics.length) return [];
|
||||
|
||||
const combos: MatrixCombo[] = [];
|
||||
for (const printCount of printCounts) {
|
||||
for (const craft of crafts) {
|
||||
@@ -168,7 +164,6 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]):
|
||||
const memberCombos = members.map((member) => ({
|
||||
member,
|
||||
combos: memberMatrixCombos({
|
||||
goodName: member.goodName,
|
||||
tagNames: member.originGoodTags.map((r) => r.tag.tagName),
|
||||
// CUSTOM 成员无同步标签,管理员显式填写的标签字段是其唯一归因来源
|
||||
customLabels:
|
||||
@@ -389,167 +384,7 @@ export class FamilyRecomputeService {
|
||||
});
|
||||
}
|
||||
|
||||
// 派生标签同步(无论是否锁定:标签是派生数据而非人工策展)
|
||||
await this.syncFamilyTags(family.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 族 → 标签同步(标签与「产品链接」一一对应):
|
||||
* 1) 链接级:未人工接管(tagsManual=false)的 SDS 链接,按链接名称刷新
|
||||
* origin_good_tags 的派生行(manual=false);人工行(manual=true)永远保留;
|
||||
* 2) 商品级镜像:good 标签 = 自身链接的有效标签 ∪ 非自动组的既有标签。
|
||||
* 仅更新发生变化的行,幂等。
|
||||
*/
|
||||
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: { 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 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 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 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({
|
||||
@@ -594,62 +429,5 @@ export class FamilyRecomputeService {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user