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
-1
@@ -10,4 +10,4 @@ deploy/data-dump.json
|
|||||||
uploads/
|
uploads/
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
data-cleaning/
|
data-cleaning/
|
||||||
backups/
|
backups/.zcode/
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ export const productFamiliesApi = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 整理原产品库(显式人工动作):解析列回填 → 派生标签 → 自动建族 → 全量重算 */
|
||||||
|
organize: () => {
|
||||||
|
return request.post<any, {
|
||||||
|
labelsParsed: number
|
||||||
|
labelsUnparsable: number
|
||||||
|
linksUpdated: number
|
||||||
|
goodsUpdated: number
|
||||||
|
familiesCreated: number
|
||||||
|
familiesRecomputed: number
|
||||||
|
}>('/product-families/organize')
|
||||||
|
},
|
||||||
|
|
||||||
updateMembers: (id: string, data: { addOriginGoodIds?: string[]; removeOriginGoodIds?: string[] }) => {
|
updateMembers: (id: string, data: { addOriginGoodIds?: string[]; removeOriginGoodIds?: string[] }) => {
|
||||||
return request.post<any, ProductFamily>(`/product-families/${id}/members`, data)
|
return request.post<any, ProductFamily>(`/product-families/${id}/members`, data)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { categoriesApi } from '@/api/categories'
|
|||||||
import { tagsApi } from '@/api/tags'
|
import { tagsApi } from '@/api/tags'
|
||||||
import { tagGroupsApi } from '@/api/tag-groups'
|
import { tagGroupsApi } from '@/api/tag-groups'
|
||||||
import { originGoodsApi } from '@/api/origin-goods'
|
import { originGoodsApi } from '@/api/origin-goods'
|
||||||
|
import { productFamiliesApi } from '@/api/product-families'
|
||||||
import { syncApi } from '@/api/sync'
|
import { syncApi } from '@/api/sync'
|
||||||
import { sameFamily } from '@/utils/family-match'
|
import { sameFamily } from '@/utils/family-match'
|
||||||
import { sameOriginGroup } from '@/utils/origin-name'
|
import { sameOriginGroup } from '@/utils/origin-name'
|
||||||
@@ -999,6 +1000,32 @@ function onSearch() {
|
|||||||
rightTreeRef.value?.filter?.('')
|
rightTreeRef.value?.filter?.('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 整理原产品库:解析列回填 → 派生标签 → 自动建族 → 全量重算(显式人工动作) */
|
||||||
|
const organizing = ref(false)
|
||||||
|
async function onOrganize() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'将执行:回填解析列 → 派生标签(人工接管不动)→ 自动建族 → 全量重算矩阵。可能耗时较长,继续?',
|
||||||
|
'整理原产品库',
|
||||||
|
{ confirmButtonText: '整理', cancelButtonText: '取消' },
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
organizing.value = true
|
||||||
|
try {
|
||||||
|
const r = await productFamiliesApi.organize()
|
||||||
|
ElMessage.success(
|
||||||
|
`整理完成:解析 ${r.labelsParsed} 条 / 标签更新 ${r.linksUpdated} 链接 / 新建族 ${r.familiesCreated} / 重算 ${r.familiesRecomputed} 族`,
|
||||||
|
)
|
||||||
|
await loadAll()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || '整理失败')
|
||||||
|
} finally {
|
||||||
|
organizing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => loadAll())
|
onMounted(() => loadAll())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1036,6 +1063,7 @@ onMounted(() => loadAll())
|
|||||||
</el-input>
|
</el-input>
|
||||||
<el-button size="small" type="primary" :icon="Search" @click="onSearch">搜索</el-button>
|
<el-button size="small" type="primary" :icon="Search" @click="onSearch">搜索</el-button>
|
||||||
<div class="gv-filter-spacer" />
|
<div class="gv-filter-spacer" />
|
||||||
|
<el-button size="small" :loading="organizing" @click="onOrganize">整理</el-button>
|
||||||
<el-button size="small" type="success" :icon="Plus" @click="openCustomCreate">新增自定义商品</el-button>
|
<el-button size="small" type="success" :icon="Plus" @click="openCustomCreate">新增自定义商品</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
"prisma:studio": "prisma studio",
|
"prisma:studio": "prisma studio",
|
||||||
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts",
|
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts",
|
||||||
"import:product-detail": "ts-node prisma/import-product-detail.ts",
|
"import:product-detail": "ts-node prisma/import-product-detail.ts",
|
||||||
"backfill:product-families": "ts-node prisma/backfill-product-families.ts"
|
"backfill:product-families": "ts-node prisma/backfill-product-families.ts",
|
||||||
|
"organize": "ts-node prisma/backfill-product-families.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^3.0.1",
|
"@nestjs/axios": "^3.0.1",
|
||||||
|
|||||||
@@ -1,81 +1,35 @@
|
|||||||
/**
|
/**
|
||||||
* 产品族回填脚本(一次性 / 幂等):
|
* 整理脚本(幂等 / 显式人工触发)——解析去运行时化后的唯一解析入口:
|
||||||
* 1. 全量 OriginGood 回填四个链接名解析列;
|
* 1. 回填结构化解析列(只补 NULL,不覆盖存量);
|
||||||
* 2. auto-group 全量建族并挂成员(每族建立即重算);
|
* 2. 派生链接标签(未人工接管的 SDS 链接按名称刷新,人工接管不动)+ 商品镜像;
|
||||||
* 3. 输出统计与不可解析清单。
|
* 3. 自动建族(auto-group);
|
||||||
|
* 4. 全量族重算(并集尺码表/包装 + 五维价格矩阵)。
|
||||||
*
|
*
|
||||||
* 运行:pnpm --filter @inkreach/api backfill:product-families
|
* 运行:pnpm --filter @inkreach/api organize
|
||||||
* 幂等性:重复执行时步骤 1 数据不变、步骤 2 候选为空(familyId=null 过滤)。
|
* 后台等价入口:POST /product-families/organize(「整理」按钮)。
|
||||||
*/
|
*/
|
||||||
import { Prisma } from '@prisma/client';
|
|
||||||
import { PrismaService } from '../src/prisma/prisma.service';
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
import { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
|
import { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
|
||||||
import { ProductFamiliesService } from '../src/product-families/product-families.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() {
|
async function main() {
|
||||||
const prisma = new PrismaService();
|
const prisma = new PrismaService();
|
||||||
await prisma.onModuleInit();
|
await prisma.onModuleInit();
|
||||||
const recompute = new FamilyRecomputeService(prisma);
|
const recompute = new FamilyRecomputeService(prisma);
|
||||||
const families = new ProductFamiliesService(prisma, recompute);
|
const families = new ProductFamiliesService(prisma, recompute);
|
||||||
|
const organize = new OrganizeService(prisma, recompute, families);
|
||||||
|
|
||||||
// ---- 1. 解析列回填(分批) ----
|
const result = await organize.organize();
|
||||||
const BATCH = 100;
|
console.log(
|
||||||
let parsed = 0;
|
`[organize] labels parsed=${result.labelsParsed} (unparsable=${result.labelsUnparsable}) | ` +
|
||||||
const unparsable: string[] = [];
|
`tags links=${result.linksUpdated} goods=${result.goodsUpdated} | ` +
|
||||||
for (;;) {
|
`families created=${result.familiesCreated} recomputed=${result.familiesRecomputed}`,
|
||||||
const batch = await prisma.originGood.findMany({
|
);
|
||||||
orderBy: { id: 'asc' },
|
await prisma.onModuleDestroy();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((error) => {
|
||||||
console.error(err);
|
console.error(error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -274,6 +274,44 @@ describe('GoodsService', () => {
|
|||||||
expect(after.total).toBe(before.total);
|
expect(after.total).toBe(before.total);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('good name normalization', () => {
|
||||||
|
it('create/update 时把结构完整的原始链接名规范化为 品名+型号', async () => {
|
||||||
|
const created = await service.create({
|
||||||
|
goodName: '德国(不包邮)230g水洗T恤-DETM002-双面印花',
|
||||||
|
originGoodId: Number(originGoodIds[0]),
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(created.goodName).toBe('230g水洗T恤 DETM002');
|
||||||
|
const updated = await service.update(BigInt(created.id), {
|
||||||
|
goodName: '加拿大(不包邮)180g纯棉T恤-CATM001-单面印花',
|
||||||
|
});
|
||||||
|
expect(updated.goodName).toBe('180g纯棉T恤 CATM001');
|
||||||
|
} finally {
|
||||||
|
await prisma.good.delete({ where: { id: BigInt(created.id) } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非链接结构的名称原样保留(已解析名/自定义名)', async () => {
|
||||||
|
const created = await service.create({
|
||||||
|
goodName: '230g水洗T恤 DETM002',
|
||||||
|
originGoodId: Number(originGoodIds[0]),
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(created.goodName).toBe('230g水洗T恤 DETM002');
|
||||||
|
const updated = await service.update(BigInt(created.id), {
|
||||||
|
goodName: '自定义商品 ABC',
|
||||||
|
});
|
||||||
|
expect(updated.goodName).toBe('自定义商品 ABC');
|
||||||
|
} finally {
|
||||||
|
await prisma.good.delete({ where: { id: BigInt(created.id) } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('merged origin goods', () => {
|
describe('merged origin goods', () => {
|
||||||
it('creates a good with merged origin goods and reads them back', async () => {
|
it('creates a good with merged origin goods and reads them back', async () => {
|
||||||
const created = await service.create({
|
const created = await service.create({
|
||||||
|
|||||||
+774
-754
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { OriginGoodsService } from './origin-goods.service';
|
import { OriginGoodsService } from './origin-goods.service';
|
||||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||||
|
import { ProductFamiliesService } from '../product-families/product-families.service';
|
||||||
|
import { OrganizeService } from '../product-families/organize.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
describe('OriginGoodsService', () => {
|
describe('OriginGoodsService', () => {
|
||||||
@@ -11,7 +13,7 @@ describe('OriginGoodsService', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [OriginGoodsService, FamilyRecomputeService, PrismaService],
|
providers: [OriginGoodsService, FamilyRecomputeService, ProductFamiliesService, OrganizeService, PrismaService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(OriginGoodsService);
|
service = moduleRef.get(OriginGoodsService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||||
|
import { OrganizeService } from '../product-families/organize.service';
|
||||||
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
||||||
|
|
||||||
/** 链接标签(origin_good_tags 行,含人工/派生标记) */
|
/** 链接标签(origin_good_tags 行,含人工/派生标记) */
|
||||||
@@ -89,6 +90,7 @@ export class OriginGoodsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly familyRecompute: FamilyRecomputeService,
|
private readonly familyRecompute: FamilyRecomputeService,
|
||||||
|
private readonly organize: OrganizeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** 链接当前标签(含 manual 标记) */
|
/** 链接当前标签(含 manual 标记) */
|
||||||
@@ -152,10 +154,16 @@ export class OriginGoodsService {
|
|||||||
this.prisma.originGood.update({ where: { id }, data: { tagsManual: true } }),
|
this.prisma.originGood.update({ where: { id }, data: { tagsManual: true } }),
|
||||||
]);
|
]);
|
||||||
await this.familyRecompute.mirrorLinkTagsToGoods(id);
|
await this.familyRecompute.mirrorLinkTagsToGoods(id);
|
||||||
|
// 人工改标签 → 归因维度可能变化,自动重算族矩阵(有族才重算)
|
||||||
|
const ogFull = await this.prisma.originGood.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { familyId: true },
|
||||||
|
});
|
||||||
|
if (ogFull?.familyId) await this.familyRecompute.recomputeFamily(ogFull.familyId);
|
||||||
return this.getTags(id);
|
return this.getTags(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 恢复自动:清掉全部标签行(含人工行),回到按链接名称派生 */
|
/** 恢复自动:清掉全部标签行(含人工行),按链接名称重新派生(显式人工动作) */
|
||||||
async resetTags(id: bigint): Promise<OriginGoodTagsResult> {
|
async resetTags(id: bigint): Promise<OriginGoodTagsResult> {
|
||||||
const og = await this.prisma.originGood.findUnique({
|
const og = await this.prisma.originGood.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -166,12 +174,9 @@ export class OriginGoodsService {
|
|||||||
this.prisma.originGoodTag.deleteMany({ where: { originGoodId: id } }),
|
this.prisma.originGoodTag.deleteMany({ where: { originGoodId: id } }),
|
||||||
this.prisma.originGood.update({ where: { id }, data: { tagsManual: false } }),
|
this.prisma.originGood.update({ where: { id }, data: { tagsManual: false } }),
|
||||||
]);
|
]);
|
||||||
if (og.familyId) {
|
// 派生集中在整理服务(解析去运行时化);「恢复自动」本身是显式人工动作
|
||||||
// 族内链接:整族重派生 + 商品镜像
|
await this.organize.deriveTagsForOg(id);
|
||||||
await this.familyRecompute.syncFamilyTags(og.familyId);
|
if (og.familyId) await this.familyRecompute.recomputeFamily(og.familyId);
|
||||||
} else {
|
|
||||||
await this.familyRecompute.refreshLinkTags(id);
|
|
||||||
}
|
|
||||||
return this.getTags(id);
|
return this.getTags(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,12 @@ describe('auto-tag-rules / deriveLinkTagNames', () => {
|
|||||||
expect(deriveLinkTagNames('美国(包邮光板)T恤-DG001')).toEqual(['不打印', '包邮']);
|
expect(deriveLinkTagNames('美国(包邮光板)T恤-DG001')).toEqual(['不打印', '包邮']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('直喷命中时不给默认烫画', () => {
|
it('直喷命中时不给默认烫画;名称未写单/双面时印花数量默认单面印花', () => {
|
||||||
expect(deriveLinkTagNames('美国(包邮)卫衣-DG002-直喷')).toEqual(['直喷', '包邮']);
|
expect(deriveLinkTagNames('美国(包邮)卫衣-DG002-直喷')).toEqual(['单面印花', '直喷', '包邮']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不打印/光板(无印花面)不补默认印花数量', () => {
|
||||||
|
expect(deriveLinkTagNames('美国(不包邮光板)T恤-DG001-不打印')).toEqual(['不打印', '不包邮']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('物流备注既无包邮也无不包邮时不下发物流标签', () => {
|
it('物流备注既无包邮也无不包邮时不下发物流标签', () => {
|
||||||
|
|||||||
@@ -48,14 +48,16 @@ export function deriveLinkTagNames(name: string | null | undefined): string[] {
|
|||||||
const names: string[] = [];
|
const names: string[] = [];
|
||||||
|
|
||||||
// 「双面印花/单面印花」显式命中,或工艺段写作「直喷双面/直喷单面」的裸「双面/单面」;
|
// 「双面印花/单面印花」显式命中,或工艺段写作「直喷双面/直喷单面」的裸「双面/单面」;
|
||||||
// 双面优先判断,避免「单面」误吞
|
// 双面优先判断,避免「单面」误吞。名称完全没写且工艺非「不打印」时默认单面印花
|
||||||
if (name.includes('双面')) names.push('双面印花');
|
// (解析假设集中在整理脚本层,可审查可重跑;不打印/光板 无印花面,不补)
|
||||||
else if (name.includes('单面')) names.push('单面印花');
|
|
||||||
|
|
||||||
const craftTags = new Set<string>();
|
const craftTags = new Set<string>();
|
||||||
for (const [keyword, tagName] of Object.entries(CRAFT_KEYWORD_MAP)) {
|
for (const [keyword, tagName] of Object.entries(CRAFT_KEYWORD_MAP)) {
|
||||||
if (name.includes(keyword)) craftTags.add(tagName);
|
if (name.includes(keyword)) craftTags.add(tagName);
|
||||||
}
|
}
|
||||||
|
const noPrint = craftTags.has('不打印');
|
||||||
|
if (name.includes('双面')) names.push('双面印花');
|
||||||
|
else if (name.includes('单面')) names.push('单面印花');
|
||||||
|
else if (!noPrint) names.push('单面印花');
|
||||||
if (craftTags.size > 0) names.push(...craftTags);
|
if (craftTags.size > 0) names.push(...craftTags);
|
||||||
else names.push(CRAFT_DEFAULT);
|
else names.push(CRAFT_DEFAULT);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { FamilyRecomputeService } from './family-recompute.service';
|
import { FamilyRecomputeService } from './family-recompute.service';
|
||||||
|
import { ProductFamiliesService } from './product-families.service';
|
||||||
|
import { OrganizeService } from './organize.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,6 +12,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
*/
|
*/
|
||||||
describe('FamilyRecomputeService', () => {
|
describe('FamilyRecomputeService', () => {
|
||||||
let service: FamilyRecomputeService;
|
let service: FamilyRecomputeService;
|
||||||
|
let organize!: OrganizeService;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
const createdOriginGoodIds: bigint[] = [];
|
const createdOriginGoodIds: bigint[] = [];
|
||||||
@@ -19,6 +22,7 @@ describe('FamilyRecomputeService', () => {
|
|||||||
|
|
||||||
const mkOriginGood = async (over: {
|
const mkOriginGood = async (over: {
|
||||||
goodName?: string;
|
goodName?: string;
|
||||||
|
source?: 'SDS' | 'CUSTOM';
|
||||||
craftLabel?: string | null;
|
craftLabel?: string | null;
|
||||||
logisticsLabel?: string | null;
|
logisticsLabel?: string | null;
|
||||||
variants?: Array<{
|
variants?: Array<{
|
||||||
@@ -38,7 +42,7 @@ describe('FamilyRecomputeService', () => {
|
|||||||
data: {
|
data: {
|
||||||
sdsGoodId: `recompute-${stamp}-${createdOriginGoodIds.length}-${Math.random().toString(36).slice(2, 7)}`,
|
sdsGoodId: `recompute-${stamp}-${createdOriginGoodIds.length}-${Math.random().toString(36).slice(2, 7)}`,
|
||||||
goodName: over.goodName ?? `测试链接-${stamp}`,
|
goodName: over.goodName ?? `测试链接-${stamp}`,
|
||||||
source: 'CUSTOM',
|
source: over.source ?? 'CUSTOM',
|
||||||
craftLabel: over.craftLabel ?? null,
|
craftLabel: over.craftLabel ?? null,
|
||||||
logisticsLabel: over.logisticsLabel ?? null,
|
logisticsLabel: over.logisticsLabel ?? null,
|
||||||
},
|
},
|
||||||
@@ -98,6 +102,11 @@ describe('FamilyRecomputeService', () => {
|
|||||||
providers: [FamilyRecomputeService, PrismaService],
|
providers: [FamilyRecomputeService, PrismaService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(FamilyRecomputeService);
|
service = moduleRef.get(FamilyRecomputeService);
|
||||||
|
organize = new OrganizeService(
|
||||||
|
moduleRef.get(PrismaService),
|
||||||
|
service,
|
||||||
|
new ProductFamiliesService(moduleRef.get(PrismaService), service),
|
||||||
|
);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
await prisma.onModuleInit();
|
await prisma.onModuleInit();
|
||||||
});
|
});
|
||||||
@@ -141,8 +150,9 @@ describe('FamilyRecomputeService', () => {
|
|||||||
expect(after.stale).toBe(false);
|
expect(after.stale).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('价格矩阵:五维格子取最低价并累积来源;维度取链接标签、名称回退;停用变体不参与', async () => {
|
it('价格矩阵:五维格子取最低价并累积来源;维度取整理派生的链接标签;停用变体不参与', async () => {
|
||||||
const a = await mkOriginGood({
|
const a = await mkOriginGood({
|
||||||
|
source: 'SDS',
|
||||||
goodName: '美国(包邮)测试A-PA1-单面印花',
|
goodName: '美国(包邮)测试A-PA1-单面印花',
|
||||||
variants: [
|
variants: [
|
||||||
{ sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
|
{ sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
|
||||||
@@ -151,6 +161,7 @@ describe('FamilyRecomputeService', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const b = await mkOriginGood({
|
const b = await mkOriginGood({
|
||||||
|
source: 'SDS',
|
||||||
goodName: '美国(包邮)测试B-PB1-单面印花', // 同格子(不同仓库)
|
goodName: '美国(包邮)测试B-PB1-单面印花', // 同格子(不同仓库)
|
||||||
variants: [
|
variants: [
|
||||||
{ sdsVariantId: 'v4', sku: 'B-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 24.5 },
|
{ sdsVariantId: 'v4', sku: 'B-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 24.5 },
|
||||||
@@ -158,19 +169,23 @@ describe('FamilyRecomputeService', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const d = await mkOriginGood({
|
const d = await mkOriginGood({
|
||||||
|
source: 'SDS',
|
||||||
goodName: '美国(包邮)测试D-PD1-直喷双面',
|
goodName: '美国(包邮)测试D-PD1-直喷双面',
|
||||||
variants: [
|
variants: [
|
||||||
{ sdsVariantId: 'v6', sku: 'D-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 30 },
|
{ sdsVariantId: 'v6', sku: 'D-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 30 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const e = await mkOriginGood({
|
const e = await mkOriginGood({
|
||||||
goodName: '美国(不包邮)测试E-PE1-光板', // 无标签行 → 名称回退:不打印 + 单面印花默认
|
source: 'SDS',
|
||||||
|
goodName: '美国(不包邮)测试E-PE1-光板', // 整理派生:不打印 + 不包邮(无印花数量标签 → 结构化占位单面)
|
||||||
variants: [
|
variants: [
|
||||||
{ sdsVariantId: 'v7', sku: 'E-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 22 },
|
{ sdsVariantId: 'v7', sku: 'E-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 22 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, b.id, d.id, e.id] });
|
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, b.id, d.id, e.id] });
|
||||||
|
|
||||||
|
// 整理(显式派生标签)→ 重算(纯聚合)。运行时不解析名称,未派生的成员不进矩阵
|
||||||
|
await organize.deriveFamilyTags(family.id);
|
||||||
await service.recomputeFamily(family.id);
|
await service.recomputeFamily(family.id);
|
||||||
|
|
||||||
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||||
@@ -185,7 +200,7 @@ describe('FamilyRecomputeService', () => {
|
|||||||
// 直喷双面 → 印花数量/工艺两维正确拆分
|
// 直喷双面 → 印花数量/工艺两维正确拆分
|
||||||
const dg = matrix.rows.find((r: any) => r.printCount === '双面印花' && r.craft === '直喷' && r.price === '30');
|
const dg = matrix.rows.find((r: any) => r.printCount === '双面印花' && r.craft === '直喷' && r.price === '30');
|
||||||
expect(dg).toBeTruthy();
|
expect(dg).toBeTruthy();
|
||||||
// 光板(无标签)→ 工艺=不打印、印花数量回退单面、物流=不包邮
|
// 光板 → 工艺=不打印、印花数量占位单面、物流=不包邮
|
||||||
const blank = matrix.rows.find((r: any) => r.craft === '不打印' && r.logistics === '不包邮');
|
const blank = matrix.rows.find((r: any) => r.craft === '不打印' && r.logistics === '不包邮');
|
||||||
expect(blank).toBeTruthy();
|
expect(blank).toBeTruthy();
|
||||||
expect(blank.printCount).toBe('单面印花');
|
expect(blank.printCount).toBe('单面印花');
|
||||||
@@ -195,7 +210,27 @@ describe('FamilyRecomputeService', () => {
|
|||||||
expect(matrix.sizes.map((s: any) => s.name).sort()).toEqual(['S', 'XL', 'XXXL']);
|
expect(matrix.sizes.map((s: any) => s.name).sort()).toEqual(['S', 'XL', 'XXXL']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('价格矩阵:人工接管标签优先于名称派生', async () => {
|
it('价格矩阵:未整理(无标签)的 SDS 成员不进矩阵', async () => {
|
||||||
|
const a = await mkOriginGood({
|
||||||
|
source: 'SDS',
|
||||||
|
goodName: '美国(包邮)测试U-PU1-单面印花',
|
||||||
|
variants: [
|
||||||
|
{ sdsVariantId: 'v1', sku: 'U-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] });
|
||||||
|
// 只重算、不整理 → 无标签 → 空矩阵
|
||||||
|
await service.recomputeFamily(family.id);
|
||||||
|
let after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||||
|
expect((after.priceMatrix as any).rows).toHaveLength(0);
|
||||||
|
// 整理后进入矩阵
|
||||||
|
await organize.deriveFamilyTags(family.id);
|
||||||
|
await service.recomputeFamily(family.id);
|
||||||
|
after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||||
|
expect((after.priceMatrix as any).rows).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('价格矩阵:人工接管标签即权威维度(人工标签 > 一切)', async () => {
|
||||||
const a = await mkOriginGood({
|
const a = await mkOriginGood({
|
||||||
goodName: '美国(包邮)测试M-PM1-单面印花', // 名称派生是 单面/烫画/包邮
|
goodName: '美国(包邮)测试M-PM1-单面印花', // 名称派生是 单面/烫画/包邮
|
||||||
variants: [
|
variants: [
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import {
|
import { isAutoTagGroupName } from './auto-tag-rules';
|
||||||
DERIVED_TAG_GROUP_SPECS,
|
|
||||||
deriveLinkTagNames,
|
|
||||||
isAutoTagGroupName,
|
|
||||||
} from './auto-tag-rules';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 产品族重算:并集尺码表/包装规则 + 五维价格矩阵物化。
|
* 产品族重算:并集尺码表/包装规则 + 五维价格矩阵物化。
|
||||||
@@ -82,38 +78,38 @@ export interface MatrixCombo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 成员在矩阵中的归因维度组合(纯函数),取值优先级:
|
* 成员在矩阵中的归因维度组合(纯函数),取值只有两个来源——
|
||||||
* 1. 链接有效标签(人工接管后仍准确,仅认封闭词表内的标签);
|
* 1. 链接有效标签(人工接管后仍准确,仅认封闭词表内的标签);
|
||||||
* 2. CUSTOM 成员的管理员显式标签(craftLabel/logisticsLabel,自由文本);
|
* 2. CUSTOM 成员的管理员显式标签(craftLabel/logisticsLabel,自由文本)。
|
||||||
* 3. 链接名称派生(deriveLinkTagNames);
|
* 【不解析名称】:SDS 成员无标签 → 返回空(不进矩阵),由整理(OrganizeService)显式补标签。
|
||||||
* 4. 组内默认值(单面印花 / 烫画 / 包邮)。
|
|
||||||
* 多值时取笛卡尔积 —— 一个链接理论上只属一个组合,此处只是容错。
|
* 多值时取笛卡尔积 —— 一个链接理论上只属一个组合,此处只是容错。
|
||||||
*/
|
*/
|
||||||
export function memberMatrixCombos(input: {
|
export function memberMatrixCombos(input: {
|
||||||
goodName: string | null;
|
|
||||||
tagNames: string[];
|
tagNames: string[];
|
||||||
customLabels?: { craft?: string | null; logistics?: string | null };
|
customLabels?: { craft?: string | null; logistics?: string | null };
|
||||||
}): MatrixCombo[] {
|
}): MatrixCombo[] {
|
||||||
const derived = deriveLinkTagNames(input.goodName);
|
const values = (dim: DimKey): string[] =>
|
||||||
const values = (dim: DimKey): string[] => {
|
DIM_VALUES[dim].filter((v) => input.tagNames.includes(v));
|
||||||
const fromTags = DIM_VALUES[dim].filter((v) => input.tagNames.includes(v));
|
|
||||||
if (fromTags.length) return [...fromTags];
|
let printCounts = values('printCount');
|
||||||
const label =
|
let crafts = values('craft');
|
||||||
dim === 'craft' ? input.customLabels?.craft : input.customLabels?.logistics;
|
let logistics = values('logistics');
|
||||||
if (dim !== 'printCount' && label) return [label];
|
// CUSTOM 成员:管理员显式标签是唯一来源(craftLabel/logisticsLabel,自由文本)
|
||||||
const fromName = derived.filter((n) =>
|
const craftLabel = input.customLabels?.craft ?? '';
|
||||||
(DIM_VALUES[dim] as readonly string[]).includes(n),
|
const logisticsLabel = input.customLabels?.logistics ?? '';
|
||||||
);
|
if (!crafts.length && craftLabel) crafts = [craftLabel];
|
||||||
if (fromName.length) return fromName;
|
if (!logistics.length && logisticsLabel) logistics = [logisticsLabel];
|
||||||
if (dim === 'printCount') {
|
// 结构化占位规则(非名称解析):工艺=不打印 时印花面不存在,印花数量固定单面占位,
|
||||||
const craftLabel = input.customLabels?.craft ?? '';
|
// 保证光板链接的价格进矩阵;工艺=烫画/直喷 而缺印花数量标签 → 缺维度不进矩阵(等整理补标签)
|
||||||
return [craftLabel.includes('双面') ? '双面印花' : DIM_VALUES[dim][0]];
|
const noPrintCraft =
|
||||||
}
|
crafts.includes('不打印') || craftLabel.includes('不打印') || craftLabel.includes('光板');
|
||||||
return [DIM_VALUES[dim][0]];
|
if (!printCounts.length && noPrintCraft) {
|
||||||
};
|
printCounts = [DIM_VALUES.printCount[0]];
|
||||||
const printCounts = values('printCount');
|
} else if (!printCounts.length && craftLabel.includes('双面')) {
|
||||||
const crafts = values('craft');
|
printCounts = ['双面印花'];
|
||||||
const logistics = values('logistics');
|
}
|
||||||
|
if (!printCounts.length || !crafts.length || !logistics.length) return [];
|
||||||
|
|
||||||
const combos: MatrixCombo[] = [];
|
const combos: MatrixCombo[] = [];
|
||||||
for (const printCount of printCounts) {
|
for (const printCount of printCounts) {
|
||||||
for (const craft of crafts) {
|
for (const craft of crafts) {
|
||||||
@@ -168,7 +164,6 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]):
|
|||||||
const memberCombos = members.map((member) => ({
|
const memberCombos = members.map((member) => ({
|
||||||
member,
|
member,
|
||||||
combos: memberMatrixCombos({
|
combos: memberMatrixCombos({
|
||||||
goodName: member.goodName,
|
|
||||||
tagNames: member.originGoodTags.map((r) => r.tag.tagName),
|
tagNames: member.originGoodTags.map((r) => r.tag.tagName),
|
||||||
// CUSTOM 成员无同步标签,管理员显式填写的标签字段是其唯一归因来源
|
// CUSTOM 成员无同步标签,管理员显式填写的标签字段是其唯一归因来源
|
||||||
customLabels:
|
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 标签 = 链接标签 ∪ 非自动组既有标签) */
|
/** 把链接的有效标签镜像到其名下商品(good 标签 = 链接标签 ∪ 非自动组既有标签) */
|
||||||
async mirrorLinkTagsToGoods(ogId: bigint): Promise<void> {
|
async mirrorLinkTagsToGoods(ogId: bigint): Promise<void> {
|
||||||
const rows = await this.prisma.originGoodTag.findMany({
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-7
@@ -1,16 +1,19 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { FamilyRecomputeService } from './family-recompute.service';
|
import { FamilyRecomputeService } from './family-recompute.service';
|
||||||
|
import { ProductFamiliesService } from './product-families.service';
|
||||||
|
import { OrganizeService } from './organize.service';
|
||||||
import { OriginGoodsService } from '../origin-goods/origin-goods.service';
|
import { OriginGoodsService } from '../origin-goods/origin-goods.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标签与「产品链接」一一对应:
|
* 整理服务(解析去运行时化后的唯一派生入口):
|
||||||
* 1) 链接级:未人工接管的 SDS 链接按名称派生 origin_good_tags(manual=false);
|
* 1) 链接级:未人工接管的 SDS 链接按名称派生 origin_good_tags(manual=false);
|
||||||
* 2) 商品级:good 标签镜像其链接的有效标签(保留非自动组人工标签);
|
* 2) 商品级:good 标签镜像其链接的有效标签(保留非自动组人工标签);
|
||||||
* 3) 人工接管(updateTags)后自动同步不再覆盖,恢复自动(resetTags)回到派生。
|
* 3) 人工接管(updateTags)后自动同步不再覆盖,恢复自动(resetTags)回到派生。
|
||||||
*/
|
*/
|
||||||
describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
|
describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
|
||||||
let recompute: FamilyRecomputeService;
|
let recompute: FamilyRecomputeService;
|
||||||
|
let organize: OrganizeService;
|
||||||
let originGoods: OriginGoodsService;
|
let originGoods: OriginGoodsService;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
@@ -26,9 +29,10 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [FamilyRecomputeService, OriginGoodsService, PrismaService],
|
providers: [FamilyRecomputeService, ProductFamiliesService, OrganizeService, OriginGoodsService, PrismaService],
|
||||||
}).compile();
|
}).compile();
|
||||||
recompute = moduleRef.get(FamilyRecomputeService);
|
recompute = moduleRef.get(FamilyRecomputeService);
|
||||||
|
organize = moduleRef.get(OrganizeService);
|
||||||
originGoods = moduleRef.get(OriginGoodsService);
|
originGoods = moduleRef.get(OriginGoodsService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
await prisma.onModuleInit();
|
await prisma.onModuleInit();
|
||||||
@@ -119,7 +123,7 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
|
|||||||
await prisma.goodTag.create({ data: { goodId: good1.id, tagId: legacyPositionTag.id } });
|
await prisma.goodTag.create({ data: { goodId: good1.id, tagId: legacyPositionTag.id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const r1 = await recompute.syncFamilyTags(family.id);
|
const r1 = await organize.deriveFamilyTags(family.id);
|
||||||
expect(r1.linksUpdated).toBe(2);
|
expect(r1.linksUpdated).toBe(2);
|
||||||
expect(r1.goodsUpdated).toBe(2);
|
expect(r1.goodsUpdated).toBe(2);
|
||||||
|
|
||||||
@@ -137,7 +141,7 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
|
|||||||
expect(names2).not.toContain('烫画');
|
expect(names2).not.toContain('烫画');
|
||||||
|
|
||||||
// 幂等
|
// 幂等
|
||||||
const r2 = await recompute.syncFamilyTags(family.id);
|
const r2 = await organize.deriveFamilyTags(family.id);
|
||||||
expect(r2.linksUpdated).toBe(0);
|
expect(r2.linksUpdated).toBe(0);
|
||||||
expect(r2.goodsUpdated).toBe(0);
|
expect(r2.goodsUpdated).toBe(0);
|
||||||
});
|
});
|
||||||
@@ -168,7 +172,7 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
|
|||||||
});
|
});
|
||||||
ids.good.push(good.id);
|
ids.good.push(good.id);
|
||||||
|
|
||||||
await recompute.syncFamilyTags(family.id);
|
await organize.deriveFamilyTags(family.id);
|
||||||
expect(await linkTagNames(og.id)).toEqual(['包邮', '烫画', '单面印花']);
|
expect(await linkTagNames(og.id)).toEqual(['包邮', '烫画', '单面印花']);
|
||||||
|
|
||||||
// 人工接管:解析错了(实际是直喷)→ 改成 直喷
|
// 人工接管:解析错了(实际是直喷)→ 改成 直喷
|
||||||
@@ -182,7 +186,7 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
|
|||||||
expect(await goodTagNames(good.id)).toEqual(['包邮', '直喷']);
|
expect(await goodTagNames(good.id)).toEqual(['包邮', '直喷']);
|
||||||
|
|
||||||
// 再次族同步:人工行不被覆盖
|
// 再次族同步:人工行不被覆盖
|
||||||
await recompute.syncFamilyTags(family.id);
|
await organize.deriveFamilyTags(family.id);
|
||||||
expect(await linkTagNames(og.id)).toEqual(['包邮', '直喷']);
|
expect(await linkTagNames(og.id)).toEqual(['包邮', '直喷']);
|
||||||
expect(await goodTagNames(good.id)).toEqual(['包邮', '直喷']);
|
expect(await goodTagNames(good.id)).toEqual(['包邮', '直喷']);
|
||||||
|
|
||||||
@@ -218,7 +222,7 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
|
|||||||
});
|
});
|
||||||
ids.good.push(good.id);
|
ids.good.push(good.id);
|
||||||
|
|
||||||
const r = await recompute.syncFamilyTags(family.id);
|
const r = await organize.deriveFamilyTags(family.id);
|
||||||
expect(r.linksUpdated).toBe(0);
|
expect(r.linksUpdated).toBe(0);
|
||||||
expect(await goodTagNames(good.id)).toEqual([]);
|
expect(await goodTagNames(good.id)).toEqual([]);
|
||||||
|
|
||||||
@@ -227,4 +231,56 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
|
|||||||
await originGoods.updateTags(ogCustom.id, [Number(baoyou!.id)]);
|
await originGoods.updateTags(ogCustom.id, [Number(baoyou!.id)]);
|
||||||
expect(await goodTagNames(good.id)).toEqual(['包邮']);
|
expect(await goodTagNames(good.id)).toEqual(['包邮']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('族重算不解析不触碰标签:上游改名后重算,标签/矩阵维度保持不变(防倒灌)', async () => {
|
||||||
|
const og = await prisma.originGood.create({
|
||||||
|
data: {
|
||||||
|
sdsGoodId: `tagsync-nodrift-${stamp}`,
|
||||||
|
goodName: `美国(包邮)防倒灌T恤-ND${stamp}-单面印花`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ids.originGood.push(og.id);
|
||||||
|
await prisma.originGoodVariant.create({
|
||||||
|
data: {
|
||||||
|
originGoodId: og.id,
|
||||||
|
sdsVariantId: `nd-v-${stamp}`,
|
||||||
|
sku: `ND${stamp}`,
|
||||||
|
sizeName: 'S',
|
||||||
|
price: 21,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const family = await prisma.productFamily.create({
|
||||||
|
data: { familyName: `防倒灌族-${stamp}`, primaryOriginGoodId: og.id },
|
||||||
|
});
|
||||||
|
ids.family.push(family.id);
|
||||||
|
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } });
|
||||||
|
|
||||||
|
// 整理派生一次 → 标签就位,矩阵有格子
|
||||||
|
await organize.deriveFamilyTags(family.id);
|
||||||
|
await recompute.recomputeFamily(family.id);
|
||||||
|
const tagsBefore = await linkTagNames(og.id);
|
||||||
|
expect(tagsBefore).toEqual(['包邮', '烫画', '单面印花']);
|
||||||
|
let fam = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||||
|
expect((fam.priceMatrix as any).rows).toHaveLength(1);
|
||||||
|
|
||||||
|
// 模拟上游改名(同步镜像只更新 goodName)→ 仅重算、不整理
|
||||||
|
await prisma.originGood.update({
|
||||||
|
where: { id: og.id },
|
||||||
|
data: { goodName: '美国(包邮)防倒灌T恤-ND0000-直喷双面' },
|
||||||
|
});
|
||||||
|
await recompute.recomputeFamily(family.id);
|
||||||
|
|
||||||
|
// 标签与矩阵维度保持派生时的快照,不随名称漂移
|
||||||
|
expect(await linkTagNames(og.id)).toEqual(tagsBefore);
|
||||||
|
fam = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||||
|
const row = (fam.priceMatrix as any).rows[0];
|
||||||
|
expect(row.printCount).toBe('单面印花');
|
||||||
|
expect(row.craft).toBe('烫画');
|
||||||
|
|
||||||
|
// 显式整理后才按新名称刷新
|
||||||
|
await organize.deriveFamilyTags(family.id);
|
||||||
|
const refreshed = await linkTagNames(og.id);
|
||||||
|
expect(refreshed).toHaveLength(3);
|
||||||
|
expect(refreshed).toEqual(expect.arrayContaining(['包邮', '双面印花', '直喷']));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整理服务(解析去运行时化的唯一解析入口):
|
||||||
|
* 所有"按上游链接名解析/派生"的逻辑集中于此,由显式人工动作触发——
|
||||||
|
* 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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Put, Q
|
|||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { ProductFamiliesService } from './product-families.service';
|
import { ProductFamiliesService } from './product-families.service';
|
||||||
|
import { OrganizeService } from './organize.service';
|
||||||
import {
|
import {
|
||||||
AutoGroupDto,
|
AutoGroupDto,
|
||||||
CreateCustomMemberDto,
|
CreateCustomMemberDto,
|
||||||
@@ -18,7 +19,10 @@ import {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('product-families')
|
@Controller('product-families')
|
||||||
export class ProductFamiliesController {
|
export class ProductFamiliesController {
|
||||||
constructor(private readonly service: ProductFamiliesService) {}
|
constructor(
|
||||||
|
private readonly service: ProductFamiliesService,
|
||||||
|
private readonly organize: OrganizeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List product families with keyword & pagination' })
|
@ApiOperation({ summary: 'List product families with keyword & pagination' })
|
||||||
@@ -26,6 +30,15 @@ export class ProductFamiliesController {
|
|||||||
return this.service.list(query);
|
return this.service.list(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('organize')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'整理原产品库(显式人工动作):回填解析列 → 派生标签(人工接管不动)→ 自动建族 → 全量重算矩阵',
|
||||||
|
})
|
||||||
|
organizeAll() {
|
||||||
|
return this.organize.organize();
|
||||||
|
}
|
||||||
|
|
||||||
@Post('auto-group')
|
@Post('auto-group')
|
||||||
@ApiOperation({ summary: 'Auto-group unassigned origin goods by 3-segment name key' })
|
@ApiOperation({ summary: 'Auto-group unassigned origin goods by 3-segment name key' })
|
||||||
autoGroup(@Body() dto: AutoGroupDto) {
|
autoGroup(@Body() dto: AutoGroupDto) {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
import { FamilyRecomputeService } from './family-recompute.service';
|
import { FamilyRecomputeService } from './family-recompute.service';
|
||||||
|
import { OrganizeService } from './organize.service';
|
||||||
import { ProductFamiliesController } from './product-families.controller';
|
import { ProductFamiliesController } from './product-families.controller';
|
||||||
import { ProductFamiliesService } from './product-families.service';
|
import { ProductFamiliesService } from './product-families.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
controllers: [ProductFamiliesController],
|
controllers: [ProductFamiliesController],
|
||||||
providers: [ProductFamiliesService, FamilyRecomputeService],
|
providers: [ProductFamiliesService, FamilyRecomputeService, OrganizeService],
|
||||||
exports: [ProductFamiliesService, FamilyRecomputeService],
|
exports: [ProductFamiliesService, FamilyRecomputeService, OrganizeService],
|
||||||
})
|
})
|
||||||
export class ProductFamiliesModule {}
|
export class ProductFamiliesModule {}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { ProductFamiliesService } from './product-families.service';
|
import { ProductFamiliesService } from './product-families.service';
|
||||||
import { FamilyRecomputeService } from './family-recompute.service';
|
import { FamilyRecomputeService } from './family-recompute.service';
|
||||||
|
import { OrganizeService } from './organize.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
describe('ProductFamiliesService', () => {
|
describe('ProductFamiliesService', () => {
|
||||||
let service: ProductFamiliesService;
|
let service: ProductFamiliesService;
|
||||||
|
let organize!: OrganizeService;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
const createdOriginGoodIds: bigint[] = [];
|
const createdOriginGoodIds: bigint[] = [];
|
||||||
@@ -29,6 +31,11 @@ describe('ProductFamiliesService', () => {
|
|||||||
providers: [ProductFamiliesService, FamilyRecomputeService, PrismaService],
|
providers: [ProductFamiliesService, FamilyRecomputeService, PrismaService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(ProductFamiliesService);
|
service = moduleRef.get(ProductFamiliesService);
|
||||||
|
organize = new OrganizeService(
|
||||||
|
moduleRef.get(PrismaService),
|
||||||
|
moduleRef.get(FamilyRecomputeService),
|
||||||
|
service,
|
||||||
|
);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
await prisma.onModuleInit();
|
await prisma.onModuleInit();
|
||||||
});
|
});
|
||||||
@@ -57,6 +64,7 @@ describe('ProductFamiliesService', () => {
|
|||||||
price: new Prisma.Decimal(25),
|
price: new Prisma.Decimal(25),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await organize.deriveTagsForOg(a.id);
|
||||||
const f1 = (await service.create({
|
const f1 = (await service.create({
|
||||||
familyName: '测试T恤',
|
familyName: '测试T恤',
|
||||||
familyCode: code,
|
familyCode: code,
|
||||||
@@ -216,7 +224,7 @@ describe('ProductFamiliesService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('price overrides:非法维度 400;合法覆盖生效;删除恢复推导价', async () => {
|
it('price overrides:非法维度 400;合法覆盖生效;删除恢复推导价', async () => {
|
||||||
const a = await mkOriginGood(`覆盖${stamp}`, { craftLabel: '单面印花', logisticsLabel: '包邮' });
|
const a = await mkOriginGood(`美国(包邮)覆盖测试-OV${stamp}-单面印花`);
|
||||||
await prisma.originGoodVariant.create({
|
await prisma.originGoodVariant.create({
|
||||||
data: {
|
data: {
|
||||||
originGoodId: a.id,
|
originGoodId: a.id,
|
||||||
@@ -229,6 +237,7 @@ describe('ProductFamiliesService', () => {
|
|||||||
price: new Prisma.Decimal(25),
|
price: new Prisma.Decimal(25),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await organize.deriveTagsForOg(a.id);
|
||||||
const f = (await service.create({
|
const f = (await service.create({
|
||||||
familyName: `覆盖族-${stamp}`,
|
familyName: `覆盖族-${stamp}`,
|
||||||
originGoodIds: [a.id.toString()],
|
originGoodIds: [a.id.toString()],
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Prisma } from '@prisma/client';
|
|||||||
import { PublicService } from './public.service';
|
import { PublicService } from './public.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||||
|
import { ProductFamiliesService } from '../product-families/product-families.service';
|
||||||
|
import { OrganizeService } from '../product-families/organize.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 三期灰度族块集成测试:PUBLIC_DETAIL_FROM_FAMILY 开关两态行为、
|
* 三期灰度族块集成测试:PUBLIC_DETAIL_FROM_FAMILY 开关两态行为、
|
||||||
@@ -68,7 +70,15 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => {
|
|||||||
familyId = family.id;
|
familyId = family.id;
|
||||||
createdFamilyIds.push(family.id);
|
createdFamilyIds.push(family.id);
|
||||||
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } });
|
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } });
|
||||||
await new FamilyRecomputeService(prisma).recomputeFamily(family.id);
|
// 解析去运行时化:先整理派生标签,再重算(重算只聚合不解析)
|
||||||
|
const recomputeSvc = new FamilyRecomputeService(prisma);
|
||||||
|
const organizeSvc = new OrganizeService(
|
||||||
|
prisma,
|
||||||
|
recomputeSvc,
|
||||||
|
new ProductFamiliesService(prisma, recomputeSvc),
|
||||||
|
);
|
||||||
|
await organizeSvc.deriveFamilyTags(family.id);
|
||||||
|
await recomputeSvc.recomputeFamily(family.id);
|
||||||
|
|
||||||
const good = await prisma.good.create({
|
const good = await prisma.good.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -3,16 +3,20 @@ import { Prisma } from '@prisma/client';
|
|||||||
import { SyncService } from './sync.service';
|
import { SyncService } from './sync.service';
|
||||||
import { SdsClientService } from './sds-client.service';
|
import { SdsClientService } from './sds-client.service';
|
||||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||||
|
import { ProductFamiliesService } from '../product-families/product-families.service';
|
||||||
|
import { OrganizeService } from '../product-families/organize.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步钩子集成测试:upsertOriginGood 的解析列写入、新链接自动挂族、
|
* 同步钩子集成测试(解析去运行时化后):
|
||||||
|
* upsertOriginGood 纯镜像(不写解析列)、新链接不自动挂族(归族走整理)、
|
||||||
* persistProductDetail 后的族重算入队。
|
* persistProductDetail 后的族重算入队。
|
||||||
*/
|
*/
|
||||||
describe('SyncService family hooks', () => {
|
describe('SyncService family hooks', () => {
|
||||||
let service: SyncService;
|
let service: SyncService;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
let recompute: FamilyRecomputeService;
|
let recompute: FamilyRecomputeService;
|
||||||
|
let organize: OrganizeService;
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
const createdOriginGoodIds: bigint[] = [];
|
const createdOriginGoodIds: bigint[] = [];
|
||||||
const createdFamilyIds: bigint[] = [];
|
const createdFamilyIds: bigint[] = [];
|
||||||
@@ -29,12 +33,15 @@ describe('SyncService family hooks', () => {
|
|||||||
useValue: {},
|
useValue: {},
|
||||||
},
|
},
|
||||||
FamilyRecomputeService,
|
FamilyRecomputeService,
|
||||||
|
ProductFamiliesService,
|
||||||
|
OrganizeService,
|
||||||
PrismaService,
|
PrismaService,
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(SyncService);
|
service = moduleRef.get(SyncService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
recompute = moduleRef.get(FamilyRecomputeService);
|
recompute = moduleRef.get(FamilyRecomputeService);
|
||||||
|
organize = moduleRef.get(OrganizeService);
|
||||||
await prisma.onModuleInit();
|
await prisma.onModuleInit();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,7 +51,7 @@ describe('SyncService family hooks', () => {
|
|||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('upsertOriginGood:写入四个解析列(create 与 update 全量覆盖)', async () => {
|
it('upsertOriginGood:纯镜像——只存原文,不写解析列', async () => {
|
||||||
const sdsId = `hook-${stamp}-parse`;
|
const sdsId = `hook-${stamp}-parse`;
|
||||||
const result1 = await (service as any).upsertOriginGood(
|
const result1 = await (service as any).upsertOriginGood(
|
||||||
sdsProduct(sdsId, '美国(包邮)240g涤纶休闲短裤-DG206-单面印花-美西洛杉矶一仓'),
|
sdsProduct(sdsId, '美国(包邮)240g涤纶休闲短裤-DG206-单面印花-美西洛杉矶一仓'),
|
||||||
@@ -53,22 +60,24 @@ describe('SyncService family hooks', () => {
|
|||||||
expect(result1).toBe('inserted');
|
expect(result1).toBe('inserted');
|
||||||
const og = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } });
|
const og = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } });
|
||||||
createdOriginGoodIds.push(og.id);
|
createdOriginGoodIds.push(og.id);
|
||||||
expect(og.skuCode).toBe('DG206');
|
expect(og.goodName).toBe('美国(包邮)240g涤纶休闲短裤-DG206-单面印花-美西洛杉矶一仓');
|
||||||
expect(og.logisticsLabel).toBe('包邮');
|
expect(og.skuCode).toBeNull();
|
||||||
expect(og.craftLabel).toBe('单面印花');
|
expect(og.logisticsLabel).toBeNull();
|
||||||
expect(og.warehouseLabel).toBe('美西洛杉矶一仓');
|
expect(og.craftLabel).toBeNull();
|
||||||
|
expect(og.warehouseLabel).toBeNull();
|
||||||
|
|
||||||
// 更新为不带仓库的名称 → 解析列全量覆盖(warehouseLabel 置空)
|
// 更新名称 → 镜像原文变化;解析列保持为空(由整理回填)
|
||||||
await (service as any).upsertOriginGood(
|
await (service as any).upsertOriginGood(
|
||||||
sdsProduct(sdsId, '美国(不包邮)240g涤纶休闲短裤-DG206-单面印花'),
|
sdsProduct(sdsId, '美国(不包邮)240g涤纶休闲短裤-DG206-单面印花'),
|
||||||
`cat-${stamp}-parse`,
|
`cat-${stamp}-parse`,
|
||||||
);
|
);
|
||||||
const og2 = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } });
|
const og2 = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } });
|
||||||
expect(og2.logisticsLabel).toBe('不包邮');
|
expect(og2.goodName).toBe('美国(不包邮)240g涤纶休闲短裤-DG206-单面印花');
|
||||||
expect(og2.warehouseLabel).toBeNull();
|
expect(og2.skuCode).toBeNull();
|
||||||
|
expect(og2.logisticsLabel).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('新链接自动挂族:按 SDS 分类唯一命中族则挂载并触发重算', async () => {
|
it('新链接不自动挂族(归族由整理显式完成);族矩阵保持不变', async () => {
|
||||||
const sdsCat = `cat-hook-${stamp}`;
|
const sdsCat = `cat-hook-${stamp}`;
|
||||||
const cat = await prisma.category.create({
|
const cat = await prisma.category.create({
|
||||||
data: { categoryName: `挂族测试分类-${stamp}`, sdsCategoryId: sdsCat },
|
data: { categoryName: `挂族测试分类-${stamp}`, sdsCategoryId: sdsCat },
|
||||||
@@ -78,8 +87,6 @@ describe('SyncService family hooks', () => {
|
|||||||
sdsGoodId: `hook-${stamp}-seed`,
|
sdsGoodId: `hook-${stamp}-seed`,
|
||||||
goodName: `自动挂${stamp}(包邮)卫衣-ZZA${stamp}-单面印花`,
|
goodName: `自动挂${stamp}(包邮)卫衣-ZZA${stamp}-单面印花`,
|
||||||
sdsCategoryId: sdsCat,
|
sdsCategoryId: sdsCat,
|
||||||
craftLabel: '单面印花',
|
|
||||||
logisticsLabel: '包邮',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
createdOriginGoodIds.push(seed.id);
|
createdOriginGoodIds.push(seed.id);
|
||||||
@@ -106,11 +113,12 @@ describe('SyncService family hooks', () => {
|
|||||||
where: { id: seed.id },
|
where: { id: seed.id },
|
||||||
data: { familyId: family.id },
|
data: { familyId: family.id },
|
||||||
});
|
});
|
||||||
|
await organize.deriveTagsForOg(seed.id);
|
||||||
await recompute.recomputeFamily(family.id);
|
await recompute.recomputeFamily(family.id);
|
||||||
const before = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
const before = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||||
expect((before.priceMatrix as any).rows).toHaveLength(1);
|
expect((before.priceMatrix as any).rows).toHaveLength(1);
|
||||||
|
|
||||||
// 同分类新链接(不同工艺)→ 自动挂进唯一族
|
// 同分类新链接(不同工艺)→ 同步后保持无族,等整理/管理员归族
|
||||||
const newSdsId = `hook-${stamp}-new`;
|
const newSdsId = `hook-${stamp}-new`;
|
||||||
await (service as any).upsertOriginGood(
|
await (service as any).upsertOriginGood(
|
||||||
sdsProduct(newSdsId, `自动挂${stamp}(不包邮)卫衣-ZZA${stamp}-双面印花-某仓`),
|
sdsProduct(newSdsId, `自动挂${stamp}(不包邮)卫衣-ZZA${stamp}-双面印花-某仓`),
|
||||||
@@ -118,10 +126,12 @@ describe('SyncService family hooks', () => {
|
|||||||
);
|
);
|
||||||
const newOg = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: newSdsId } });
|
const newOg = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: newSdsId } });
|
||||||
createdOriginGoodIds.push(newOg.id);
|
createdOriginGoodIds.push(newOg.id);
|
||||||
expect(newOg.familyId).toBe(family.id);
|
expect(newOg.familyId).toBeNull();
|
||||||
|
|
||||||
// 入队的重算已执行(等待异步完成)
|
// 族矩阵不因新链接同步而变化
|
||||||
await new Promise((r) => setTimeout(r, 200));
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||||
|
expect((after.priceMatrix as any).rows).toHaveLength(1);
|
||||||
await prisma.category.delete({ where: { id: cat.id } }).catch(() => undefined);
|
await prisma.category.delete({ where: { id: cat.id } }).catch(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+766
-826
File diff suppressed because it is too large
Load Diff
@@ -91,15 +91,25 @@ curl -X POST /product-families/12/members/custom -H "Authorization: Bearer $T" \
|
|||||||
"variants":[{"sku":"C-M","sizeId":"size_M","sizeName":"M","colorId":"color_red","colorName":"红色","price": 33}]}'
|
"variants":[{"sku":"C-M","sizeId":"size_M","sizeName":"M","colorId":"color_red","colorName":"红色","price": 33}]}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**同步联动**:商品同步落库时刷新解析列;新链接与已有族**同分类唯一命中**时自动挂族
|
**三层架构(解析去运行时化)**:
|
||||||
(多族/零族留给管理员;锁定族只置 `stale`);详情同步提交后异步重算受影响族
|
|
||||||
(进程内去重、幂等)。回填/修复脚本:
|
1. **同步 = 纯镜像**:SDS 给什么存什么(名称原文/图/价/分类 ID),不解析、不归族、不写解析列;
|
||||||
|
2. **整理 = 显式人工动作**(所有解析/派生集中于此,可审查可重跑):回填解析列 → 派生链接标签
|
||||||
|
(未人工接管的 SDS 链接按名称刷新;人工接管永不覆盖)→ 自动建族 → 全量重算。入口二选一:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm --filter @inkreach/api backfill:product-families
|
pnpm --filter @inkreach/api organize # CLI
|
||||||
# → [1/2] 解析全部链接名 [2/2] 自动建族 [3/3] 全量族重算(幂等,可随时重跑)
|
curl -X POST /product-families/organize -H "$AUTH" # 后台「整理」按钮(同一逻辑)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
3. **重算 = 自动的结构化聚合**:归族/成员变化/详情同步/标签调整自动触发;输入只有
|
||||||
|
成员变体 + 链接标签 + 覆盖价,**不解析名称**——上游改名永远不会倒灌已派生的标签与矩阵;
|
||||||
|
未整理(无标签)的 SDS 成员不进矩阵,整理后齐全。
|
||||||
|
|
||||||
|
派生默认(脚本层假设,显式可审查):工艺无关键字 → 烫画;印花数量无单/双面且工艺非不打印 →
|
||||||
|
单面印花;不打印/光板 无印花面不补(矩阵中印花数量以单面占位,纯结构化规则)。
|
||||||
|
「恢复自动」(链接标签重置)本身是显式人工动作,同样走整理服务的单链接派生。
|
||||||
|
|
||||||
**公开读路径(四期族化契约,默认开启)**:`PUBLIC_DETAIL_FROM_FAMILY=false` 可应急回退旧行为。
|
**公开读路径(四期族化契约,默认开启)**:`PUBLIC_DETAIL_FROM_FAMILY=false` 可应急回退旧行为。
|
||||||
公开端点**以款(族)为一等公民**:
|
公开端点**以款(族)为一等公民**:
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ apps/api/
|
|||||||
| `/origin-goods/tree` `GET` | 配置状态树(叶子含 `familyId/familyName/familyCode/familyStale`) | JWT |
|
| `/origin-goods/tree` `GET` | 配置状态树(叶子含 `familyId/familyName/familyCode/familyStale`) | JWT |
|
||||||
| `/origin-goods/:id/tags` `GET/PUT/DELETE` | 链接级标签:查(含 manual 标记)/ 人工接管全量替换 / 恢复按名称自动派生;写入后镜像到名下商品 | JWT |
|
| `/origin-goods/:id/tags` `GET/PUT/DELETE` | 链接级标签:查(含 manual 标记)/ 人工接管全量替换 / 恢复按名称自动派生;写入后镜像到名下商品 | JWT |
|
||||||
| `/product-families` `GET/POST` | 产品族分页列表(`keyword` 匹配名称/编码)/ 建族(可直挂成员) | JWT |
|
| `/product-families` `GET/POST` | 产品族分页列表(`keyword` 匹配名称/编码)/ 建族(可直挂成员) | JWT |
|
||||||
|
| `/product-families/organize` `POST` | 整理原产品库(显式人工动作):回填解析列 → 派生标签(人工接管不动)→ 自动建族 → 全量重算;CLI 等价 `pnpm --filter @inkreach/api organize` | JWT |
|
||||||
| `/product-families/auto-group` `POST` | 自动成族:按 SDS 分类(产品模型)聚合无族链接;`{apply:false}` 仅预览,`{apply:true}` 落库并逐族重算(幂等) | JWT |
|
| `/product-families/auto-group` `POST` | 自动成族:按 SDS 分类(产品模型)聚合无族链接;`{apply:false}` 仅预览,`{apply:true}` 落库并逐族重算(幂等) | JWT |
|
||||||
| `/product-families/:id` `GET/PATCH` | 族详情(成员+覆盖)/ 编辑 canonical 字段、`autoManaged`、主链接 | JWT |
|
| `/product-families/:id` `GET/PATCH` | 族详情(成员+覆盖)/ 编辑 canonical 字段、`autoManaged`、主链接 | JWT |
|
||||||
| `/product-families/:id/recompute` `POST` | 手动重算并集与价格矩阵 | JWT |
|
| `/product-families/:id/recompute` `POST` | 手动重算并集与价格矩阵 | JWT |
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 解析去运行时化:同步纯镜像 + 整理脚本化 + 矩阵自动重算
|
||||||
|
|
||||||
|
## 背景 / 决策(用户拍板,2026-08-30)
|
||||||
|
|
||||||
|
现行"同步即解析"模式系统性依赖上游链接命名(8 处解析点),上游改名会倒灌污染
|
||||||
|
已正确的标签/矩阵(族重算按当前名称重派生标签),缺关键字时静默默认兜底。
|
||||||
|
|
||||||
|
最终架构:
|
||||||
|
|
||||||
|
1. **同步 = 纯镜像**:SDS 给什么存什么,零解析、零归族
|
||||||
|
2. **解析/派生/归族 = 显式脚本(OrganizeService)**:跑才生效,可重跑可审查;
|
||||||
|
触发方式 = CLI + 后台"整理"按钮(都是人工显式动作)
|
||||||
|
3. **价格矩阵 = 归族后自动计算**:成员变化/详情同步/标签调整自动触发重算;
|
||||||
|
输入只有结构化数据(成员变体 + 标签 + 覆盖价),**不碰名称**;人工可改价(覆盖表保留)
|
||||||
|
|
||||||
|
## 变更清单
|
||||||
|
|
||||||
|
### A. sync.service 纯净化
|
||||||
|
- 删 `parseOriginName` 解析列写入(skuCode/logisticsLabel/craftLabel/warehouseLabel
|
||||||
|
不再随同步写入;存量数据保留,缺列由整理脚本回填)
|
||||||
|
- 删同步时自动归族(同分类/同名已配置族自动挂接的整段逻辑)
|
||||||
|
- 保留:详情同步后 enqueue 族重算(重算纯净化后是无解析的聚合)
|
||||||
|
|
||||||
|
### B. family-recompute.service 纯净化
|
||||||
|
- `recomputeFamily` 尾部不再调用 `syncFamilyTags`(标签永不随重算变)
|
||||||
|
- `memberMatrixCombos` 删名称兜底:SDS 成员维度只认标签,无标签不进矩阵;
|
||||||
|
CUSTOM 成员继续用显式 labels(管理员填写,非上游)
|
||||||
|
- 标签派生逻辑(deriveLinkTagNames 应用 + ensureDerivedTagMap + goods 镜像中的
|
||||||
|
派生部分)迁出到 OrganizeService;`mirrorLinkTagsToGoods`(纯镜像聚合)保留
|
||||||
|
|
||||||
|
### C. 新增 OrganizeService(product-families/organize.service.ts)
|
||||||
|
`organize(): { labelsParsed, tagsDerived, familiesCreated, familiesRecomputed, unparsable }`
|
||||||
|
1. 回填缺失的四个解析列(幂等,只补 null)
|
||||||
|
2. 派生标签:非人工接管(tagsManual=false)SDS 链接按名称刷新自动标签,
|
||||||
|
人工接管的不动;随后镜像到名下商品
|
||||||
|
3. `autoGroup` 建族(复用 ProductFamiliesService.autoGroup)
|
||||||
|
4. 全量族重算
|
||||||
|
`deriveTagsForOg(ogId)` 供 resetTags(恢复自动,显式人工动作)复用
|
||||||
|
|
||||||
|
### D. 入口
|
||||||
|
- CLI:`prisma/backfill-product-families.ts` 改薄壳调 OrganizeService;
|
||||||
|
package.json 加 `organize` 命令(与 backfill 等价)
|
||||||
|
- 端点:`POST /product-families/organize`(JWT)
|
||||||
|
- admin:商品页工具栏「整理」按钮 → 调端点 → toast 结果
|
||||||
|
|
||||||
|
### E. 测试(TDD)
|
||||||
|
- organize.service.spec:派生/幂等/人工接管保护/镜像
|
||||||
|
- family-recompute.spec:无标签成员不进矩阵;重算不改标签
|
||||||
|
- sync.spec:同步不写解析列、不归族
|
||||||
|
- public / product-families / family-block spec:fixture 补标签(原靠重算时名称兜底)
|
||||||
|
- origin-goods resetTags:走 organize 派生
|
||||||
|
|
||||||
|
### F. 文档
|
||||||
|
product-center.md 架构段落重写(同步/整理/重算三层)、structs.md 端点、README 命令
|
||||||
|
|
||||||
|
## 明确不做
|
||||||
|
- 不删解析器/派生规则本身(脚本要用,规则不变)
|
||||||
|
- 不动人工接管/手动并族/覆盖价机制
|
||||||
|
- normalizeGoodName(管理员输入边界质检)保留,随本分支一并提交
|
||||||
|
|
||||||
|
## 风险
|
||||||
|
- 存量已正确标签不再被重派生覆盖(这是目标行为);但同步改名后名称与标签可能
|
||||||
|
不同步——由整理脚本显式刷新(人工决定何时跑)
|
||||||
|
- 新同步链接在整理前无标签无族(不进矩阵不进公开)——整理后齐全
|
||||||
Reference in New Issue
Block a user