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
+18 -64
View File
@@ -1,81 +1,35 @@
/**
* 产品族回填脚本(一次性 / 幂等)
* 1. 全量 OriginGood 回填四个链接名解析列
* 2. auto-group 全量建族并挂成员(每族建立即重算)
* 3. 输出统计与不可解析清单。
* 整理脚本(幂等 / 显式人工触发)——解析去运行时化后的唯一解析入口
* 1. 回填结构化解析列(只补 NULL,不覆盖存量)
* 2. 派生链接标签(未人工接管的 SDS 链接按名称刷新,人工接管不动)+ 商品镜像
* 3. 自动建族(auto-group);
* 4. 全量族重算(并集尺码表/包装 + 五维价格矩阵)。
*
* 运行:pnpm --filter @inkreach/api backfill:product-families
* 幂等性:重复执行时步骤 1 数据不变、步骤 2 候选为空(familyId=null 过滤)。
* 运行:pnpm --filter @inkreach/api organize
* 后台等价入口:POST /product-families/organize(「整理」按钮)。
*/
import { Prisma } from '@prisma/client';
import { PrismaService } from '../src/prisma/prisma.service';
import { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
import { ProductFamiliesService } from '../src/product-families/product-families.service';
import { parseOriginName } from '../src/product-families/origin-name.parser';
import { OrganizeService } from '../src/product-families/organize.service';
async function main() {
const prisma = new PrismaService();
await prisma.onModuleInit();
const recompute = new FamilyRecomputeService(prisma);
const families = new ProductFamiliesService(prisma, recompute);
const organize = new OrganizeService(prisma, recompute, families);
// ---- 1. 解析列回填(分批) ----
const BATCH = 100;
let parsed = 0;
const unparsable: string[] = [];
for (;;) {
const batch = await prisma.originGood.findMany({
orderBy: { id: 'asc' },
take: BATCH,
skip: parsed,
select: { id: true, goodName: true },
});
if (batch.length === 0) break;
for (const og of batch) {
const p = parseOriginName(og.goodName);
if (!p.skuCode && !p.craftLabel) unparsable.push(`#${og.id} ${og.goodName ?? ''}`);
await prisma.originGood.update({
where: { id: og.id },
data: {
skuCode: p.skuCode,
logisticsLabel: p.logisticsLabel,
craftLabel: p.craftLabel,
warehouseLabel: p.warehouseLabel,
},
});
}
parsed += batch.length;
}
console.log(`[1/2] parsed ${parsed} origin goods (${unparsable.length} without sku/craft)`);
// ---- 2. 自动建族(含逐族重算) ----
const result = await families.autoGroup(true);
console.log(`[2/2] created ${result.applied} families`);
// ---- 3. 全量族重算兜底(修复建族早于解析列回填等时序造成的空矩阵) ----
const allFamilies = await prisma.productFamily.findMany({ select: { id: true } });
for (const f of allFamilies) {
await recompute.recomputeFamily(f.id);
}
console.log(`[3/3] recomputed ${allFamilies.length} families`);
// ---- 统计 ----
const total = await prisma.productFamily.count();
const withMatrix = await prisma.productFamily.count({
where: { priceMatrix: { not: Prisma.DbNull } },
});
const members = await prisma.originGood.count({ where: { familyId: { not: null } } });
const stale = await prisma.productFamily.count({ where: { stale: true } });
console.log(`stats: families=${total}, materialized=${withMatrix}, members=${members}, stale=${stale}`);
if (unparsable.length) {
console.log('unparsable names:');
for (const line of unparsable) console.log(` - ${line}`);
}
await prisma.$disconnect();
const result = await organize.organize();
console.log(
`[organize] labels parsed=${result.labelsParsed} (unparsable=${result.labelsUnparsable}) | ` +
`tags links=${result.linksUpdated} goods=${result.goodsUpdated} | ` +
`families created=${result.familiesCreated} recomputed=${result.familiesRecomputed}`,
);
await prisma.onModuleDestroy();
}
main().catch((err) => {
console.error(err);
main().catch((error) => {
console.error(error);
process.exit(1);
});