fix(product-family): auto-merge SKUs after manual tag edits; fix session cookie path

问题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 夹具改为自包含(不依赖共享库既有数据)。
This commit is contained in:
yeuimu
2026-09-02 18:39:35 +08:00
parent e34398a002
commit f1e81872e7
25 changed files with 848 additions and 78 deletions
@@ -7,6 +7,7 @@ import {
DERIVED_TAG_GROUP_SPECS,
deriveLinkTagNames,
isAutoTagGroupName,
normalizeCraftTag,
} from './auto-tag-rules';
/**
@@ -33,6 +34,7 @@ export class OrganizeService {
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);
@@ -43,12 +45,121 @@ export class OrganizeService {
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 列——存量正确数据不覆盖(同步已不再写入这些列)。
@@ -267,6 +378,36 @@ export class OrganizeService {
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 },