From f1e81872e78cafa0593e3e55885f28891989af8d Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 18:38:57 +0800 Subject: [PATCH] fix(product-family): auto-merge SKUs after manual tag edits; fix session cookie path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题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 夹具改为自包含(不依赖共享库既有数据)。 --- AGENTS.md | 4 +- apps/admin/src/api/product-families.ts | 4 + apps/admin/src/api/request.ts | 5 +- apps/admin/src/types/index.ts | 4 + apps/admin/src/utils/family-match.spec.ts | 10 +- apps/admin/src/utils/family-match.ts | 8 +- apps/admin/src/utils/origin-name.ts | 24 +++ apps/admin/src/views/goods/GoodsView.vue | 2 +- .../goods/components/GoodsEditDialog.vue | 6 +- apps/api/src/auth/auth.controller.ts | 11 +- .../origin-goods/origin-goods.service.spec.ts | 96 ++++++++++ .../src/origin-goods/origin-goods.service.ts | 41 +++- .../src/product-families/auto-tag-rules.ts | 12 ++ .../family-recompute.service.spec.ts | 32 ++++ .../family-recompute.service.ts | 8 +- .../product-families/organize.service.spec.ts | 92 +++++++-- .../src/product-families/organize.service.ts | 141 ++++++++++++++ .../origin-name.parser.spec.ts | 40 +++- .../product-families/origin-name.parser.ts | 26 +++ .../product-families.service.spec.ts | 65 ++++++- .../product-families.service.ts | 178 +++++++++++++++--- apps/api/src/public/public.service.spec.ts | 29 +++ docs/references/product-center.md | 24 ++- docs/references/structs.md | 6 +- plans/fix/family-auto-merge-fix.md | 58 ++++++ 25 files changed, 848 insertions(+), 78 deletions(-) create mode 100644 plans/fix/family-auto-merge-fix.md diff --git a/AGENTS.md b/AGENTS.md index 86cbe70..9dc5293 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,4 +95,6 @@ Prisma Postgres 最佳实践: prisma-postgres 技能 - **数据库漂移处理**:连共享开发库时先跑 `prisma migrate status`;若报"列已存在"类错误,说明有人用 `db push` 带外改过库,用 `prisma migrate resolve --applied ` 把已存在的迁移标记为已应用,再 `migrate deploy` 应用真正缺的部分。切勿盲目 reset 共享库。 - **连真实库的集成测试隔离**:jest 并行套件共用一个数据库时,(a) 夹具的天然键(sdsCategoryId、名称等)必须带运行时间戳唯一化,禁止跨运行共享字面量;(b) 全量型操作(如 auto-group 扫全库)会顺带扫到其他并行套件的夹具,其写入路径必须对"成员中途消失"宽容(跳过而非抛错),否则会随机挂测试。 - **跑全量 jest 前先停 dev server**:`nest start --watch` 等常驻进程与测试共用数据库时,其重编译窗口/后台钩子会与测试写入竞争,造成"单跑绿、全量偶发红"的假阳性;验证基线前先停掉所有 watch 进程再跑。 -- **中文断言勿手写字面量排序**:JS `Array.sort()` 对中文按 UTF-16 码位排(如 烫 U+70EB < 直 U+76F4),手写期望序列容易按拼音/习惯顺序写反;比较选项集合时用 `expect.arrayContaining` + 长度,或从同一排序函数生成期望。 +- **中文断言勿手写字面量排序**:JS `Array.sort()` 对中文按 UTF-16 码位排(如 烫 U+70EB < 直 U+76F4),手写期望序列容易按拼音/习惯顺序写反;比较选项集合时用 `expect.arrayContaining` + 长度,或对两侧统一 `.sort()` 后再比较。 +- **测试夹具必须自包含**:断言库内"存在某类数据"的用例(如标签组过滤)在全新/一次性数据库上必挂;夹具自己种下断言所依赖的数据,不依赖共享库的既有状态。 +- **严禁直接热修运行容器内的代码**:本次线上 v2 镜像内被塞过未提交的词表改动(热转印),仓库重建镜像即复发且难排查;所有修复必须落在仓库并重建部署。排查"线上行为与代码不符"时先 `md5sum` 比对容器内源文件与仓库。 diff --git a/apps/admin/src/api/product-families.ts b/apps/admin/src/api/product-families.ts index 911723f..cdcbe62 100644 --- a/apps/admin/src/api/product-families.ts +++ b/apps/admin/src/api/product-families.ts @@ -44,6 +44,10 @@ export const productFamiliesApi = { linksUpdated: number goodsUpdated: number familiesCreated: number + familiesMerged: number + familiesSkipped: number + fragmentsConsolidated: number + fragmentsManualReview: number familiesRecomputed: number }>('/product-families/organize') }, diff --git a/apps/admin/src/api/request.ts b/apps/admin/src/api/request.ts index 42bd95a..c8e2780 100644 --- a/apps/admin/src/api/request.ts +++ b/apps/admin/src/api/request.ts @@ -36,8 +36,11 @@ request.interceptors.response.use( ) { config._retried = true try { + // 规范拼接:VITE_API_BASE 可能带尾斜杠(如 /v2-api/),直接相加会产生 + // /v2-api//auth/refresh 双斜杠导致 404 + const base = (import.meta.env.VITE_API_BASE || '/api').replace(/\/+$/, '') await axios.post( - `${import.meta.env.VITE_API_BASE || '/api'}/auth/refresh`, + `${base}/auth/refresh`, {}, { withCredentials: true }, ) diff --git a/apps/admin/src/types/index.ts b/apps/admin/src/types/index.ts index 073bb0f..d8b889e 100644 --- a/apps/admin/src/types/index.ts +++ b/apps/admin/src/types/index.ts @@ -414,6 +414,10 @@ export interface OriginGoodTagInfo { export interface OriginGoodTagsResult { tagsManual: boolean tags: OriginGoodTagInfo[] + /** 人工勾选缺定价维度时按链接名自动补齐的标签名(缺啥补啥) */ + filledDimensionTags?: string[] + /** 保存时链接无族且自动并入成功的目标族 id */ + attachedFamilyId?: string } export interface ProductFamilyMember { diff --git a/apps/admin/src/utils/family-match.spec.ts b/apps/admin/src/utils/family-match.spec.ts index ebe07da..84e758a 100644 --- a/apps/admin/src/utils/family-match.spec.ts +++ b/apps/admin/src/utils/family-match.spec.ts @@ -10,11 +10,17 @@ describe('sameFamily', () => { ).toBe(true) }) - it('任一方 familyId 为空 → 回退名称 3 段规则', () => { + it('任一方 familyId 为空 → 回退族语义键(国家+品名+SKU,物流/工艺不分族)', () => { const a = node(null, '美国(包邮)T恤-DG001-单面印花') expect(sameFamily(a, node(null, '美国(包邮)T恤-DG001-单面印花-某仓'))).toBe(true) expect(sameFamily(a, node('12', '美国(包邮)T恤-DG001-单面印花'))).toBe(true) - expect(sameFamily(a, node(null, '美国(不包邮)T恤-DG001-单面印花'))).toBe(false) + // 物流/工艺是矩阵维度而非分族维度:包邮 vs 不包邮、单面 vs 双面 同款同族 + expect(sameFamily(a, node(null, '美国(不包邮)T恤-DG001-单面印花'))).toBe(true) + expect(sameFamily(a, node(null, '美国(不包邮)T恤-DG001-双面印花-美西一仓'))).toBe(true) + // 不同款号不同族 + expect(sameFamily(a, node(null, '美国(包邮)T恤-DG002-单面印花'))).toBe(false) + // 不同国家不同族 + expect(sameFamily(a, node(null, '英国(包邮)T恤-DG001-单面印花'))).toBe(false) }) it('familyId 不同 → false(即使名称同段)', () => { diff --git a/apps/admin/src/utils/family-match.ts b/apps/admin/src/utils/family-match.ts index 2459d34..023ba0c 100644 --- a/apps/admin/src/utils/family-match.ts +++ b/apps/admin/src/utils/family-match.ts @@ -1,14 +1,14 @@ -import { sameOriginGroup } from './origin-name' +import { familyNameKey } from './origin-name' /** * 两个原产品节点是否属于同一产品族: - * 优先用服务端返回的 familyId 精确匹配;无族归属时回退名称 3 段规则 - * (老数据/未回填场景兜底)。 + * 优先用服务端返回的 familyId 精确匹配;无族归属时回退族语义键 + * (国家+品名+SKU —— 物流/工艺是矩阵维度不分族;老数据/未回填场景兜底)。 */ export function sameFamily( a: { familyId?: string | null; goodName?: string | null }, b: { familyId?: string | null; goodName?: string | null }, ): boolean { if (a.familyId && b.familyId) return a.familyId === b.familyId - return sameOriginGroup(a.goodName, b.goodName) + return familyNameKey(a.goodName) !== '' && familyNameKey(a.goodName) === familyNameKey(b.goodName) } diff --git a/apps/admin/src/utils/origin-name.ts b/apps/admin/src/utils/origin-name.ts index 935c7b1..a099039 100644 --- a/apps/admin/src/utils/origin-name.ts +++ b/apps/admin/src/utils/origin-name.ts @@ -19,6 +19,30 @@ export function sameOriginGroup( return ka !== '' && ka === truncateToProcess(b); } +/** + * 族语义分组键(与 api 端 familyNameKey 同构):第 1 段剥离(物流备注)括号, + * 保留 国家+品名+SKU,丢工艺/仓库段 —— 物流/工艺是价格矩阵维度而非分族维度。 + */ +export function familyNameKey(name: string | null | undefined): string { + if (!name) return ''; + const segs = name.split('-').map((s) => s.trim()); + const head = segs[0] ?? ''; + if (!head) return ''; + const normalized = head.replace(/\(/g, '(').replace(/\)/g, ')'); + const openAt = normalized.indexOf('('); + let stripped = normalized; + if (openAt >= 0) { + const closeAt = normalized.indexOf(')', openAt); + if (closeAt > openAt) { + stripped = normalized.slice(0, openAt) + normalized.slice(closeAt + 1); + } + } + stripped = stripped.trim(); + const sku = segs[1] ?? ''; + if (!stripped) return sku || ''; + return sku ? `${stripped}-${sku}` : stripped; +} + /** 链接名结构化解析结果:品名 + 型号(SKU 代码) */ export interface ParsedLinkName { productName: string; diff --git a/apps/admin/src/views/goods/GoodsView.vue b/apps/admin/src/views/goods/GoodsView.vue index e1fab31..59c7203 100644 --- a/apps/admin/src/views/goods/GoodsView.vue +++ b/apps/admin/src/views/goods/GoodsView.vue @@ -1024,7 +1024,7 @@ async function onOrganize() { try { const r = await productFamiliesApi.organize() ElMessage.success( - `整理完成:解析 ${r.labelsParsed} 条 / 标签更新 ${r.linksUpdated} 链接 / 新建族 ${r.familiesCreated} / 重算 ${r.familiesRecomputed} 族`, + `整理完成:解析 ${r.labelsParsed} 条 / 标签更新 ${r.linksUpdated} 链接 / 新建族 ${r.familiesCreated} / 并入 ${r.familiesMerged} 链接 / 碎片合并 ${r.fragmentsConsolidated ?? 0} 族${r.fragmentsManualReview ? `(待人工合并 ${r.fragmentsManualReview} 族)` : ''} / 重算 ${r.familiesRecomputed} 族`, ) await loadAll() } catch (e: any) { diff --git a/apps/admin/src/views/goods/components/GoodsEditDialog.vue b/apps/admin/src/views/goods/components/GoodsEditDialog.vue index cfcc97c..4636232 100644 --- a/apps/admin/src/views/goods/components/GoodsEditDialog.vue +++ b/apps/admin/src/views/goods/components/GoodsEditDialog.vue @@ -9,6 +9,7 @@ import { productFamiliesApi } from '@/api/product-families' import { countriesApi } from '@/api/countries' import { syncApi } from '@/api/sync' import { cleanLinkName, deriveLinkTagNames, linkDims } from '@/utils/origin-name' +import type { OriginGoodTagsResult } from '@/types' import { findCategoryPath, buildCascader } from '@/utils/category-tree' const props = defineProps<{ @@ -364,8 +365,9 @@ async function loadEditFamily() { async function saveMemberTags(row: { id: string }) { memberTagSaving.value = row.id try { - await originGoodsApi.updateTags(row.id, memberTagEdits.value[row.id] ?? []) - ElMessage.success('标签已保存') + const res = await originGoodsApi.updateTags(row.id, memberTagEdits.value[row.id] ?? []) + const filled = (res as OriginGoodTagsResult | undefined)?.filledDimensionTags ?? [] + ElMessage.success(filled.length ? `标签已保存(缺维度已自动补齐:${filled.join('、')})` : '标签已保存') await loadEditFamily() emit('changed') } catch (e: any) { diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 7357245..7f777e3 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -64,8 +64,9 @@ export class AuthController { httpOnly: true, sameSite: 'lax', secure: isProd, - // Only ever sent to /auth/refresh and /auth/logout - path: '/auth', + // 浏览器实际请求路径带代理前缀(/api/auth/*、/v2-api/auth/*),cookie path + // 必须用 '/' 才能命中;否则 refresh cookie 永远带不上 → 访问令牌一过期就掉线 + path: '/', }); // The refresh token deliberately stays HttpOnly-only. return { @@ -86,7 +87,7 @@ export class AuthController { ): Promise { const token = req.cookies?.[REFRESH_TOKEN_COOKIE]; if (!token) { - res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' }); + res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/' }); throw new UnauthorizedException('Missing refresh token'); } const result = await this.authService.refresh(token); @@ -100,7 +101,7 @@ export class AuthController { httpOnly: true, sameSite: 'lax', secure: isProd, - path: '/auth', + path: '/', }); return { accessToken: result.accessToken, @@ -126,7 +127,7 @@ export class AuthController { ): Promise<{ success: true }> { await this.authService.logout(req.user.id); res.clearCookie(ACCESS_TOKEN_COOKIE, { path: '/' }); - res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' }); + res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/' }); return { success: true }; } } diff --git a/apps/api/src/origin-goods/origin-goods.service.spec.ts b/apps/api/src/origin-goods/origin-goods.service.spec.ts index 717e72c..c417ccc 100644 --- a/apps/api/src/origin-goods/origin-goods.service.spec.ts +++ b/apps/api/src/origin-goods/origin-goods.service.spec.ts @@ -7,6 +7,8 @@ import { PrismaService } from '../prisma/prisma.service'; describe('OriginGoodsService', () => { let service: OriginGoodsService; + let organize: OrganizeService; + let families: ProductFamiliesService; let prisma: PrismaService; const stamp = Date.now(); const createdSds: string[] = []; @@ -16,6 +18,8 @@ describe('OriginGoodsService', () => { providers: [OriginGoodsService, FamilyRecomputeService, ProductFamiliesService, OrganizeService, PrismaService], }).compile(); service = moduleRef.get(OriginGoodsService); + organize = moduleRef.get(OrganizeService); + families = moduleRef.get(ProductFamiliesService); prisma = moduleRef.get(PrismaService); await prisma.onModuleInit(); @@ -161,4 +165,96 @@ describe('OriginGoodsService', () => { } }); }); + + describe('updateTags:缺啥补啥 + 自动归族', () => { + const tagIdByNames = async (names: string[]) => { + const rows = await prisma.tag.findMany({ where: { tagName: { in: names } } }); + return rows.map((r) => r.id); + }; + const tagNamesOf = async (ogId: bigint) => { + const rows = await prisma.originGoodTag.findMany({ + where: { originGoodId: ogId }, + include: { tag: true }, + }); + return rows.map((r) => r.tag.tagName).sort(); + }; + let ogFill: any; + + beforeAll(async () => { + ogFill = await prisma.originGood.create({ + data: { sdsGoodId: `ogt-fill-${stamp}`, goodName: '美国(包邮)测试K-OG1-单面印花' }, + }); + createdSds.push(ogFill.sdsGoodId); + // 派生一次,确保三个维度组的标签字典存在 + await organize.deriveTagsForOg(ogFill.id); + }); + + it('缺维度自动补齐:只勾「不包邮」→ 补 单面印花+烫画,全部人工行', async () => { + const [nfs] = await tagIdByNames(['不包邮']); + const res = await service.updateTags(ogFill.id, [Number(nfs)]); + expect(res.tagsManual).toBe(true); + expect(res.tags.map((t: any) => t.tagName).sort()).toEqual( + ['不包邮', '单面印花', '烫画'].sort(), + ); + expect((res as any).filledDimensionTags?.sort()).toEqual(['单面印花', '烫画'].sort()); + const rows = await prisma.originGoodTag.findMany({ where: { originGoodId: ogFill.id } }); + expect(rows.every((r) => r.manual)).toBe(true); + }); + + it('人工勾齐不补;旧词「热转印」经别名视为工艺维度已齐', async () => { + const full = await tagIdByNames(['包邮', '单面印花', '烫画']); + const res1 = await service.updateTags(ogFill.id, full.map(Number)); + expect((res1 as any).filledDimensionTags ?? []).toEqual([]); + // 热转印(旧词)满足工艺维度,不再补 烫画 + const alias = await tagIdByNames(['包邮', '单面印花', '热转印']); + // 旧词可能不存在于字典:ensureDerivedTagMap 只建封闭词;此处兜底创建 + if (alias.length === 3) { + const res2 = await service.updateTags(ogFill.id, alias.map(Number)); + expect((res2 as any).filledDimensionTags ?? []).toEqual([]); + } else { + const g = await prisma.tagGroup.upsert({ + where: { groupName: '印刷工艺' }, + create: { groupName: '印刷工艺' }, + update: {}, + }); + const t = await prisma.tag.upsert({ + where: { tagName: '热转印' }, + create: { tagName: '热转印', tagGroupId: g.id }, + update: {}, + }); + const [baoyou, danmian] = await tagIdByNames(['包邮', '单面印花']); + const res2 = await service.updateTags(ogFill.id, [Number(baoyou), Number(danmian), Number(t.id)]); + expect((res2 as any).filledDimensionTags ?? []).toEqual([]); + } + expect(await tagNamesOf(ogFill.id)).toEqual(['包邮', '单面印花', '热转印'].sort()); + }); + + it('无族链接保存标签后自动并入匹配族(同分类优先),响应带 attachedFamilyId', async () => { + const cat = `cat-ogt-${stamp}`; + const a = await prisma.originGood.create({ + data: { sdsGoodId: `ogt-fam-${stamp}-a`, goodName: '美国(包邮)测试K-OG2-双面印花', sdsCategoryId: cat }, + }); + const b = await prisma.originGood.create({ + data: { sdsGoodId: `ogt-fam-${stamp}-b`, goodName: '美国(不包邮)测试K-OG2-双面印花', sdsCategoryId: cat }, + }); + createdSds.push(a.sdsGoodId, b.sdsGoodId); + const family = await families.create({ + familyName: `测试K族${stamp}`, + originGoodIds: [a.id.toString()], + primaryOriginGoodId: a.id.toString(), + }); + try { + await organize.deriveTagsForOg(b.id); + const tagIds = (await prisma.originGoodTag.findMany({ where: { originGoodId: b.id } })).map((r) => Number(r.tagId)); + const res = await service.updateTags(b.id, tagIds); + expect((res as any).attachedFamilyId).toBe(String(family.id)); + const after = await prisma.originGood.findUniqueOrThrow({ where: { id: b.id } }); + expect(after.familyId).toBe(BigInt(family.id)); + } finally { + await prisma.originGoodTag.deleteMany({ where: { originGoodId: { in: [a.id, b.id] } } }).catch(() => undefined); + await prisma.originGood.deleteMany({ where: { id: { in: [a.id, b.id] } } }).catch(() => undefined); + await prisma.productFamily.deleteMany({ where: { id: BigInt(family.id) } }).catch(() => undefined); + } + }); + }); }); diff --git a/apps/api/src/origin-goods/origin-goods.service.ts b/apps/api/src/origin-goods/origin-goods.service.ts index f4ad489..f35a95b 100644 --- a/apps/api/src/origin-goods/origin-goods.service.ts +++ b/apps/api/src/origin-goods/origin-goods.service.ts @@ -7,6 +7,7 @@ import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { FamilyRecomputeService } from '../product-families/family-recompute.service'; import { OrganizeService } from '../product-families/organize.service'; +import { ProductFamiliesService } from '../product-families/product-families.service'; import { QueryOriginGoodDto } from './dto/query-origin-good.dto'; /** 链接标签(origin_good_tags 行,含人工/派生标记) */ @@ -21,6 +22,10 @@ export interface OriginGoodTagItem { export interface OriginGoodTagsResult { tagsManual: boolean; tags: OriginGoodTagItem[]; + /** 人工勾选缺定价维度时按链接名自动补齐的标签名(缺啥补啥) */ + filledDimensionTags?: string[]; + /** 保存时链接无族且自动并入成功的目标族 id */ + attachedFamilyId?: string; } export interface PaginatedOriginGoods { @@ -91,6 +96,7 @@ export class OriginGoodsService { private readonly prisma: PrismaService, private readonly familyRecompute: FamilyRecomputeService, private readonly organize: OrganizeService, + private readonly families: ProductFamiliesService, ) {} /** 链接当前标签(含 manual 标记) */ @@ -120,16 +126,17 @@ export class OriginGoodsService { } /** - * 人工接管链接标签:全量替换为 manual 行(自动同步永不覆盖), - * 并把有效标签镜像到该链接名下的商品。 + * 人工接管链接标签:人工勾选全量保存为 manual 行(自动同步永不覆盖); + * 三个定价维度组(印花数量/工艺/物流)缺哪组按链接名自动补哪组(缺啥补啥), + * 保证链接 SKU 不掉出族价格矩阵;未归族链接随后自动并入匹配族。 */ async updateTags(id: bigint, tagIds: number[]): Promise { const og = await this.prisma.originGood.findUnique({ where: { id }, - select: { id: true }, + select: { id: true, goodName: true, source: true }, }); if (!og) throw new NotFoundException(`Origin good ${id} not found`); - const uniqueIds = [...new Set(tagIds.map((v) => BigInt(v)))].sort((a, b) => + let uniqueIds = [...new Set(tagIds.map((v) => BigInt(v)))].sort((a, b) => Number(a - b), ); if (uniqueIds.length) { @@ -138,6 +145,16 @@ export class OriginGoodsService { throw new BadRequestException('存在无效标签'); } } + // CUSTOM 成员的归因走管理员显式 craftLabel/logisticsLabel,名称不解析、不补齐 + const fill = + og.source === 'SDS' + ? await this.organize.fillMissingDimTags(og.goodName, uniqueIds) + : { tagIds: [] as bigint[], names: [] as string[] }; + if (fill.tagIds.length) { + uniqueIds = [...new Set([...uniqueIds, ...fill.tagIds])].sort((a, b) => + Number(a - b), + ); + } await this.prisma.$transaction([ this.prisma.originGoodTag.deleteMany({ where: { originGoodId: id } }), ...(!uniqueIds.length @@ -159,8 +176,20 @@ export class OriginGoodsService { where: { id }, select: { familyId: true }, }); - if (ogFull?.familyId) await this.familyRecompute.recomputeFamily(ogFull.familyId); - return this.getTags(id); + let attachedFamilyId: string | undefined; + if (ogFull?.familyId) { + await this.familyRecompute.recomputeFamily(ogFull.familyId); + } else { + // 无族链接:自动并入匹配的同款族(同 SDS 分类优先,回退族语义名称键) + const fid = await this.families.attachToMatchingFamily(id); + if (fid) attachedFamilyId = fid.toString(); + } + const base = await this.getTags(id); + return { + ...base, + filledDimensionTags: fill.names, + ...(attachedFamilyId ? { attachedFamilyId } : {}), + }; } /** 恢复自动:清掉全部标签行(含人工行),按链接名称重新派生(显式人工动作) */ diff --git a/apps/api/src/product-families/auto-tag-rules.ts b/apps/api/src/product-families/auto-tag-rules.ts index f03160f..3d182fb 100644 --- a/apps/api/src/product-families/auto-tag-rules.ts +++ b/apps/api/src/product-families/auto-tag-rules.ts @@ -17,6 +17,18 @@ export function isAutoTagGroupName(name: string): boolean { return /物流|工艺|位置|印花数量/.test(name); } +/** + * 工艺旧词 → 封闭词表别名:人工标签历史上建过「热转印」(= 烫画,热转印工艺), + * 归一后才能进价格矩阵 —— 别名只影响矩阵归因,不改标签字典本身。 + */ +export const CRAFT_TAG_ALIASES: Record = { + 热转印: '烫画', +}; + +export function normalizeCraftTag(name: string): string { + return CRAFT_TAG_ALIASES[name] ?? name; +} + export interface DerivedTagGroupSpec { /** 标签组名(查找用 includes 匹配,缺失时自动建组) */ group: string; diff --git a/apps/api/src/product-families/family-recompute.service.spec.ts b/apps/api/src/product-families/family-recompute.service.spec.ts index 9c80615..6c96778 100644 --- a/apps/api/src/product-families/family-recompute.service.spec.ts +++ b/apps/api/src/product-families/family-recompute.service.spec.ts @@ -265,6 +265,38 @@ describe('FamilyRecomputeService', () => { expect(matrix.rows[0].logistics).toBe('不包邮'); }); + it('价格矩阵:人工标签旧词「热转印」归一为「烫画」进矩阵(不再被封闭词表踢出)', async () => { + const a = await mkOriginGood({ + goodName: '英国(不包邮)测试毯-PM2-单面印花', + variants: [ + { sdsVariantId: 'v1', sku: 'PM2-S', sizeId: 'size_S', sizeName: 'S', colorId: 'color_w', colorName: '白色', price: 30 }, + ], + }); + // 人工勾了旧词表里的 热转印(历史上人工建的标签),其余维度齐全 + const tagIds: bigint[] = []; + for (const [groupName, tagName] of [['印花数量', '单面印花'], ['印刷工艺', '热转印'], ['物流渠道', '不包邮']] as const) { + const group = await prisma.tagGroup.findFirst({ where: { groupName } }); + const g = group ?? (await prisma.tagGroup.create({ data: { groupName } })); + if (!group) createdTagGroupIds.push(g.id); + const existing = await prisma.tag.findFirst({ where: { tagName } }); + const t = existing ?? (await prisma.tag.create({ data: { tagName, tagGroupId: g.id } })); + if (!existing) createdTagIds.push(t.id); + tagIds.push(t.id); + } + await prisma.originGoodTag.createMany({ + data: tagIds.map((tagId) => ({ originGoodId: a.id, tagId, manual: true })), + }); + const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] }); + + await service.recomputeFamily(family.id); + + const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); + const matrix = after.priceMatrix as any; + expect(matrix.rows).toHaveLength(1); + expect(matrix.rows[0].craft).toBe('烫画'); // 归一后的封闭词表值 + expect(matrix.crafts).toEqual(['烫画']); + }); + it('覆盖:命中改价 manual=true,未命中新增行并扩充选项', async () => { const a = await mkOriginGood({ goodName: '美国(包邮)测试O-PO1-单面印花', diff --git a/apps/api/src/product-families/family-recompute.service.ts b/apps/api/src/product-families/family-recompute.service.ts index f7ff66c..471ccc9 100644 --- a/apps/api/src/product-families/family-recompute.service.ts +++ b/apps/api/src/product-families/family-recompute.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; -import { isAutoTagGroupName } from './auto-tag-rules'; +import { isAutoTagGroupName, normalizeCraftTag } from './auto-tag-rules'; /** * 产品族重算:并集尺码表/包装规则 + 五维价格矩阵物化。 @@ -88,14 +88,16 @@ export function memberMatrixCombos(input: { tagNames: string[]; customLabels?: { craft?: string | null; logistics?: string | null }; }): MatrixCombo[] { + // 旧词别名归一(热转印→烫画):只影响矩阵归因,封闭词表本身不变 + const tagNames = input.tagNames.map(normalizeCraftTag); const values = (dim: DimKey): string[] => - DIM_VALUES[dim].filter((v) => input.tagNames.includes(v)); + DIM_VALUES[dim].filter((v) => tagNames.includes(v)); let printCounts = values('printCount'); let crafts = values('craft'); let logistics = values('logistics'); // CUSTOM 成员:管理员显式标签是唯一来源(craftLabel/logisticsLabel,自由文本) - const craftLabel = input.customLabels?.craft ?? ''; + const craftLabel = normalizeCraftTag(input.customLabels?.craft ?? ''); const logisticsLabel = input.customLabels?.logistics ?? ''; if (!crafts.length && craftLabel) crafts = [craftLabel]; if (!logistics.length && logisticsLabel) logistics = [logisticsLabel]; diff --git a/apps/api/src/product-families/organize.service.spec.ts b/apps/api/src/product-families/organize.service.spec.ts index e85fca1..7440ef7 100644 --- a/apps/api/src/product-families/organize.service.spec.ts +++ b/apps/api/src/product-families/organize.service.spec.ts @@ -58,12 +58,12 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => { async function goodTagNames(goodId: bigint): Promise { const rows = await prisma.goodTag.findMany({ where: { goodId }, include: { tag: true } }); - return rows.map((r) => r.tag.tagName); + return rows.map((r) => r.tag.tagName).sort(); } async function linkTagNames(ogId: bigint): Promise { const rows = await prisma.originGoodTag.findMany({ where: { originGoodId: ogId }, include: { tag: true } }); - return rows.map((r) => r.tag.tagName); + return rows.map((r) => r.tag.tagName).sort(); } it('链接派生标签落在链接上,商品镜像链接标签;旧自动组标签剔除、人工分组保留', async () => { @@ -128,7 +128,7 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => { expect(r1.goodsUpdated).toBe(2); // 链接级:og1 → 单面印花/烫画/包邮;og2 → 不打印(光板归并为不打印)/不包邮 - expect(await linkTagNames(og1.id)).toEqual(['包邮', '烫画', '单面印花']); + expect(await linkTagNames(og1.id)).toEqual(['包邮', '单面印花', '烫画']); expect(await linkTagNames(og2.id)).toEqual(['不包邮', '不打印']); // 商品级:镜像各自链接(+人工分组保留,旧自动组剔除) @@ -173,28 +173,30 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => { ids.good.push(good.id); await organize.deriveFamilyTags(family.id); - expect(await linkTagNames(og.id)).toEqual(['包邮', '烫画', '单面印花']); + expect(await linkTagNames(og.id)).toEqual(['包邮', '单面印花', '烫画']); // 人工接管:解析错了(实际是直喷)→ 改成 直喷 const zhpena = await prisma.tag.findFirst({ where: { tagName: '直喷' } }); const baoyou = await prisma.tag.findFirst({ where: { tagName: '包邮' } }); const result = await originGoods.updateTags(og.id, [Number(zhpena!.id), Number(baoyou!.id)]); expect(result.tagsManual).toBe(true); - expect(result.tags.map((t) => t.tagName)).toEqual(['包邮', '直喷']); + // 人工勾了 直喷/包邮,缺印花数量维度 → 按名称自动补「单面印花」(缺啥补啥) + expect(result.tags.map((t) => t.tagName).sort()).toEqual(['包邮', '单面印花', '直喷']); + expect(result.filledDimensionTags).toEqual(['单面印花']); expect(result.tags.every((t) => t.manual)).toBe(true); - // 商品镜像跟随人工修正 - expect(await goodTagNames(good.id)).toEqual(['包邮', '直喷']); + // 商品镜像跟随人工修正(含补齐维度) + expect(await goodTagNames(good.id)).toEqual(['包邮', '单面印花', '直喷']); - // 再次族同步:人工行不被覆盖 + // 再次族同步:人工行(含补齐行)不被覆盖 await organize.deriveFamilyTags(family.id); - expect(await linkTagNames(og.id)).toEqual(['包邮', '直喷']); - expect(await goodTagNames(good.id)).toEqual(['包邮', '直喷']); + expect(await linkTagNames(og.id)).toEqual(['包邮', '单面印花', '直喷']); + expect(await goodTagNames(good.id)).toEqual(['包邮', '单面印花', '直喷']); // 恢复自动 → 回到名称派生结果 const reset = await originGoods.resetTags(og.id); expect(reset.tagsManual).toBe(false); - expect(reset.tags.map((t) => t.tagName)).toEqual(['包邮', '烫画', '单面印花']); - expect(await goodTagNames(good.id)).toEqual(['包邮', '烫画', '单面印花']); + expect(reset.tags.map((t) => t.tagName)).toEqual(['包邮', '单面印花', '烫画']); + expect(await goodTagNames(good.id)).toEqual(['包邮', '单面印花', '烫画']); }); it('自定义来源链接不派生标签,仅镜像人工配置', async () => { @@ -259,7 +261,7 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => { await organize.deriveFamilyTags(family.id); await recompute.recomputeFamily(family.id); const tagsBefore = await linkTagNames(og.id); - expect(tagsBefore).toEqual(['包邮', '烫画', '单面印花']); + expect(tagsBefore).toEqual(['包邮', '单面印花', '烫画']); let fam = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); expect((fam.priceMatrix as any).rows).toHaveLength(1); @@ -283,4 +285,68 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => { expect(refreshed).toHaveLength(3); expect(refreshed).toEqual(expect.arrayContaining(['包邮', '双面印花', '直喷'])); }); + + it('整理碎片合并:纯碎片族并入带商品族(保公开 goodId 稳定)并删除;多族带商品进人工复审', async () => { + const cat = `cat-frag-${stamp}`; + const mkFrag = async (suffix: string) => { + const og = await prisma.originGood.create({ + data: { sdsGoodId: `frag-${stamp}-${suffix}`, goodName: `美国(包邮)碎片测试-FG1-单面印花-${suffix}`, sdsCategoryId: cat }, + }); + ids.originGood.push(og.id); + const f = await prisma.productFamily.create({ + data: { familyName: `碎片族${stamp}-${suffix}`, primaryOriginGoodId: og.id }, + }); + ids.family.push(f.id); + await prisma.originGood.update({ where: { id: og.id }, data: { familyId: f.id } }); + return { og, f }; + }; + const loose = await mkFrag('a'); // 纯碎片(无商品/无覆盖价) + const curated = await mkFrag('b'); // 带商品 → keeper(公开 goodId=族ID) + const good = await prisma.good.create({ + data: { + goodName: `碎片商品${stamp}`, + originGoodId: curated.og.id, + familyId: curated.f.id, + countryId: ids.country[0], + categoryId: ids.category[0], + }, + }); + ids.good.push(good.id); + // 另一个也带商品的族 → 不自动动,进人工复审 + const curated2 = await mkFrag('c'); + const good2 = await prisma.good.create({ + data: { + goodName: `碎片商品2${stamp}`, + originGoodId: curated2.og.id, + familyId: curated2.f.id, + countryId: ids.country[0], + categoryId: ids.category[0], + }, + }); + ids.good.push(good2.id); + + try { + const result = await organize.consolidateFragments(); + // 纯碎片族成员并入带商品族,碎片族被删除 + const moved = await prisma.originGood.findUniqueOrThrow({ where: { id: loose.og.id } }); + expect(moved.familyId).toBe(curated.f.id); + const gone = await prisma.productFamily.findUnique({ where: { id: loose.f.id } }); + expect(gone).toBeNull(); + // 带商品的族保留;两个带商品族互为碎片 → 较新的进人工复审 + const kept = await prisma.productFamily.findUniqueOrThrow({ where: { id: curated.f.id } }); + expect(kept).toBeTruthy(); + const review = (result.manualReview ?? []).find((r: any) => r.fragmentFamilyId === String(curated2.f.id)); + expect(review).toBeTruthy(); + expect(review?.keeperFamilyId).toBe(String(curated.f.id)); + expect(review?.reason).toContain('has-goods'); + } finally { + await prisma.goodTag.deleteMany({ where: { goodId: { in: ids.good } } }).catch(() => undefined); + await prisma.good.deleteMany({ where: { id: { in: ids.good } } }).catch(() => undefined); + ids.good = []; + await prisma.productFamily.deleteMany({ where: { id: { in: ids.family } } }).catch(() => undefined); + ids.family = []; + await prisma.originGood.deleteMany({ where: { id: { in: ids.originGood } } }).catch(() => undefined); + ids.originGood = []; + } + }); }); diff --git a/apps/api/src/product-families/organize.service.ts b/apps/api/src/product-families/organize.service.ts index 7fb1b68..bbe897a 100644 --- a/apps/api/src/product-families/organize.service.ts +++ b/apps/api/src/product-families/organize.service.ts @@ -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>(); + 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>(); + const catOwners = new Map(); + 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(); + 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 }> { const groups = await this.prisma.tagGroup.findMany({ select: { id: true, groupName: true }, diff --git a/apps/api/src/product-families/origin-name.parser.spec.ts b/apps/api/src/product-families/origin-name.parser.spec.ts index 7e8cc54..36195e3 100644 --- a/apps/api/src/product-families/origin-name.parser.spec.ts +++ b/apps/api/src/product-families/origin-name.parser.spec.ts @@ -1,4 +1,4 @@ -import { parseOriginName, originGroupKey } from './origin-name.parser'; +import { parseOriginName, originGroupKey, familyNameKey } from './origin-name.parser'; describe('parseOriginName', () => { it('解析完整四段名(含仓库)', () => { @@ -102,3 +102,41 @@ describe('originGroupKey', () => { expect(originGroupKey('')).toBe(''); }); }); + +describe('familyNameKey(族语义分组键)', () => { + it('物流差异同键:包邮/不包邮归一族', () => { + expect(familyNameKey('英国(不包邮)鼠标垫-GBIU017-单面印花')).toBe( + familyNameKey('英国(包邮*运费订单结算时支付)鼠标垫-GBIU017-单面印花'), + ); + expect(familyNameKey('英国(不包邮)鼠标垫-GBIU017-单面印花')).toBe('英国鼠标垫-GBIU017'); + }); + + it('工艺/仓库段不参与:跨工艺归一族', () => { + expect(familyNameKey('美国(包邮)T恤-DG001-单面印花-美西一仓')).toBe( + familyNameKey('美国(不包邮)T恤-DG001-双面印花'), + ); + }); + + it('不同 SKU / 不同国家不同键', () => { + expect(familyNameKey('美国(包邮)T恤-DG001-单面印花')).not.toBe( + familyNameKey('美国(包邮)T恤-DG002-单面印花'), + ); + expect(familyNameKey('美国(包邮)T恤-DG001-单面印花')).not.toBe( + familyNameKey('英国(包邮)T恤-DG001-单面印花'), + ); + }); + + it('半角/全角括号混用与无括号都能出键', () => { + expect(familyNameKey('美国(不包邮)T恤-DG001-单面印花')).toBe('美国T恤-DG001'); + expect(familyNameKey('美国T恤-DG001-单面印花')).toBe('美国T恤-DG001'); + }); + + it('无 SKU 段时仅用品名段', () => { + expect(familyNameKey('美国(包邮)T恤')).toBe('美国T恤'); + }); + + it('空名返回空串', () => { + expect(familyNameKey(null)).toBe(''); + expect(familyNameKey('')).toBe(''); + }); +}); diff --git a/apps/api/src/product-families/origin-name.parser.ts b/apps/api/src/product-families/origin-name.parser.ts index 135789d..4daf725 100644 --- a/apps/api/src/product-families/origin-name.parser.ts +++ b/apps/api/src/product-families/origin-name.parser.ts @@ -63,3 +63,29 @@ export function originGroupKey(name: string | null | undefined): string { if (!name) return ''; return name.split('-').slice(0, 3).join('-'); } + +/** + * 族语义分组键(自动建族/归族的回退键):第 1 段剥离(物流备注)括号内容, + * 保留 国家+品名,拼接 SKU 段,丢弃工艺/仓库段 —— 物流、工艺、印花是价格矩阵 + * 维度而非分族维度,同款链接(同国家+品名+SKU)必须归一族。 + */ +export function familyNameKey(name: string | null | undefined): string { + if (!name) return ''; + const segs = name.split('-').map((s) => s.trim()); + const head = segs[0] ?? ''; + if (!head) return ''; + // 半角/全角括号混用先归一(与 splitSegment1 一致) + const normalized = head.replace(/\(/g, '(').replace(/\)/g, ')'); + const openAt = normalized.indexOf('('); + let stripped = normalized; + if (openAt >= 0) { + const closeAt = normalized.indexOf(')', openAt); + if (closeAt > openAt) { + stripped = normalized.slice(0, openAt) + normalized.slice(closeAt + 1); + } + } + stripped = stripped.trim(); + const sku = segs[1] ?? ''; + if (!stripped) return sku || ''; + return sku ? `${stripped}-${sku}` : stripped; +} diff --git a/apps/api/src/product-families/product-families.service.spec.ts b/apps/api/src/product-families/product-families.service.spec.ts index 5698bb4..c926eaa 100644 --- a/apps/api/src/product-families/product-families.service.spec.ts +++ b/apps/api/src/product-families/product-families.service.spec.ts @@ -108,19 +108,20 @@ describe('ProductFamiliesService', () => { await service.patch(BigInt(f.id), { autoManaged: true }); }); - it('auto-group:无分类时按名称键分组;预览不写库;apply 幂等', async () => { + it('auto-group:无分类时按族语义键分组(物流/工艺不分族);预览不写库;apply 幂等', async () => { const g1a = await mkOriginGood(`自动组${stamp}(包邮)卫衣-ZZ${stamp}-单面印花`); const g1b = await mkOriginGood(`自动组${stamp}(包邮)卫衣-ZZ${stamp}-单面印花-某仓`); const g2 = await mkOriginGood(`自动组${stamp}(不包邮)卫衣-ZZ${stamp}-单面印花`); const preview = (await service.autoGroup(false)) as any; expect(preview.applied).toBe(0); - const hit = preview.groups.find((g: any) => g.groupKey.includes(`ZZ${stamp}`) && g.memberCount === 2); + const hit = preview.groups.find((g: any) => g.groupKey.includes(`ZZ${stamp}`) && g.memberCount === 3); expect(hit).toBeTruthy(); expect(hit.familyName).toContain('卫衣'); + expect(hit.action).toBe('create'); const applied = (await service.autoGroup(true)) as any; - expect(applied.applied).toBeGreaterThanOrEqual(2); // 包邮组 + 不包邮组(物流不同不同组) + expect(applied.applied).toBeGreaterThanOrEqual(1); // 同款三链接一族(物流是矩阵维度) const families = await prisma.productFamily.findMany({ where: { familyName: { contains: `自动组${stamp}` } }, }); @@ -130,8 +131,9 @@ describe('ProductFamiliesService', () => { select: { familyId: true }, }); expect(grouped.every((g) => g.familyId !== null)).toBe(true); - expect(grouped[0].familyId).toBe(grouped[1].familyId); // 同组同族 - expect(grouped[2].familyId).not.toBe(grouped[0].familyId); // 物流不同不同族 + // 物流/工艺差异不拆族:三条链接同一族 + expect(grouped[0].familyId).toBe(grouped[1].familyId); + expect(grouped[2].familyId).toBe(grouped[0].familyId); // 幂等:候选已清空 const again = (await service.autoGroup(true)) as any; @@ -139,6 +141,59 @@ describe('ProductFamiliesService', () => { expect(hitAgain).toHaveLength(0); }); + it('auto-group:已有同款族时并入而非新建(不再产生 -2 碎片族)', async () => { + const cat = `cat-merge-${stamp}`; + const a = await mkOriginGood(`美国(包邮)测试M-MG1-单面印花`, { sdsCategoryId: cat }); + const b = await mkOriginGood(`美国(不包邮)测试M-MG1-双面印花`, { sdsCategoryId: cat }); + // 先给 a 建族(模拟早先整理只见到 a) + const family = await service.create({ + familyName: `测试M族${stamp}`, + originGoodIds: [a.id.toString()], + primaryOriginGoodId: a.id.toString(), + }); + createdFamilyIds.push(BigInt(family.id)); + try { + const result = (await service.autoGroup(true)) as any; + const after = await prisma.originGood.findUniqueOrThrow({ where: { id: b.id } }); + expect(after.familyId).toBe(BigInt(family.id)); // b 并入已有族 + expect(result.merged).toBeGreaterThanOrEqual(1); + // 该分类没有产生第二个族 + const dup = await prisma.productFamily.findMany({ + where: { familyName: { contains: `测试M族${stamp}` } }, + }); + expect(dup).toHaveLength(1); + } finally { + await prisma.originGoodTag.deleteMany({ where: { originGoodId: { in: [a.id, b.id] } } }).catch(() => undefined); + await prisma.originGood.deleteMany({ where: { id: { in: [a.id, b.id] } } }).catch(() => undefined); + await prisma.productFamily.deleteMany({ where: { id: BigInt(family.id) } }).catch(() => undefined); + } + }); + + it('auto-group:同款已有族但为人工锁定(autoManaged=false)时不并入也不新建', async () => { + const cat = `cat-manual-${stamp}`; + const a = await mkOriginGood(`美国(包邮)测试M-MG2-单面印花`, { sdsCategoryId: cat }); + const b = await mkOriginGood(`美国(不包邮)测试M-MG2-单面印花`, { sdsCategoryId: cat }); + const family = await service.create({ + familyName: `测试M锁定族${stamp}`, + originGoodIds: [a.id.toString()], + primaryOriginGoodId: a.id.toString(), + }); + createdFamilyIds.push(BigInt(family.id)); + try { + await prisma.productFamily.update({ where: { id: BigInt(family.id) }, data: { autoManaged: false } }); + const result = (await service.autoGroup(true)) as any; + const after = await prisma.originGood.findUniqueOrThrow({ where: { id: b.id } }); + expect(after.familyId).toBeNull(); // 不并入人工族 + const sameCat = await prisma.productFamily.findMany({ where: { familyName: { contains: `测试M锁定族${stamp}` } } }); + expect(sameCat).toHaveLength(1); // 也不新建平行族 + expect((result.skipped ?? []).length).toBeGreaterThanOrEqual(0); // 报告字段存在 + } finally { + await prisma.originGoodTag.deleteMany({ where: { originGoodId: { in: [a.id, b.id] } } }).catch(() => undefined); + await prisma.originGood.deleteMany({ where: { id: { in: [a.id, b.id] } } }).catch(() => undefined); + await prisma.productFamily.deleteMany({ where: { id: BigInt(family.id) } }).catch(() => undefined); + } + }); + it('auto-group:同 SDS 分类(产品模型)跨工艺/编码归一族,族名取分类名', async () => { const cat = await prisma.category.create({ data: { categoryName: `ZZF${stamp} 180G纯棉T恤(ZZA${stamp})`, sdsCategoryId: `cat-hook-${stamp}` }, diff --git a/apps/api/src/product-families/product-families.service.ts b/apps/api/src/product-families/product-families.service.ts index 8708eba..e824e32 100644 --- a/apps/api/src/product-families/product-families.service.ts +++ b/apps/api/src/product-families/product-families.service.ts @@ -2,7 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { FamilyRecomputeService, PriceMatrix } from './family-recompute.service'; -import { originGroupKey, parseOriginName } from './origin-name.parser'; +import { originGroupKey, parseOriginName, familyNameKey } from './origin-name.parser'; import { CreateCustomMemberDto, CreateProductFamilyDto, @@ -147,7 +147,11 @@ export class ProductFamiliesService { } /** 自动成族:SDS 叶子分类即产品模型(如 "DG001 180G纯棉T恤(JSA002)"), - * 同分类链接归一族(跨工艺/物流/仓库/编码);无分类回退名称 3 段键;apply=false 仅预览 */ + * 同分类链接归一族(跨工艺/物流/仓库/编码);无分类回退族语义名称键 + * (国家+品名+SKU,物流/工艺是矩阵维度不分族)。 + * 已有同款 autoManaged 族时并入(不再新建 -2 碎片族);同款仅有 + * 人工锁定族(autoManaged=false)时跳过该组,不并入也不新建。 + * apply=false 仅预览(action 标注 create/merge/skip)。 */ async autoGroup(apply: boolean) { const candidates = await this.prisma.originGood.findMany({ where: { familyId: null, delisted: false }, @@ -160,64 +164,184 @@ export class ProductFamiliesService { }); const catName = new Map(categories.map((c) => [c.sdsCategoryId as string, c.categoryName])); + // 已有族的成员指纹:分类/名称键 → 候选族 id(用于"并入而非新建") + const familyMembers = await this.prisma.originGood.findMany({ + where: { familyId: { not: null }, delisted: false }, + select: { familyId: true, sdsCategoryId: true, goodName: true }, + }); + const catFamilies = new Map(); + const nameFamilies = new Map(); + const push = (map: Map, key: string, id: bigint) => { + map.set(key, [...(map.get(key) ?? []), id]); + }; + for (const m of familyMembers) { + if (m.sdsCategoryId) push(catFamilies, m.sdsCategoryId, m.familyId!); + const nk = familyNameKey(m.goodName); + if (nk) push(nameFamilies, nk, m.familyId!); + } + const autoFamilyIds = new Set( + ( + await this.prisma.productFamily.findMany({ + where: { autoManaged: true }, + select: { id: true }, + }) + ).map((f) => f.id), + ); + /** 匹配可并入的已有族:分类优先,回退名称键;仅 autoManaged,多候选取最老 */ + const matchAutoFamily = (cat: string | null, nameKey: string): bigint | null => { + const pool = [ + ...new Set([...(cat ? (catFamilies.get(cat) ?? []) : []), ...(nameKey ? (nameFamilies.get(nameKey) ?? []) : [])]), + ] + .filter((id) => autoFamilyIds.has(id)) + .sort((a, b) => Number(a - b)); + return pool[0] ?? null; + }; + /** 该组是否已有同款族(含人工锁定族,用于 skip 判断) */ + const hasAnyFamily = (cat: string | null, nameKey: string): boolean => + Boolean(cat && catFamilies.has(cat)) || Boolean(nameKey && nameFamilies.has(nameKey)); + const groups = new Map(); + const groupKeys = new Map(); for (const og of candidates) { - const nameKey = originGroupKey(og.goodName); + const nameKey = familyNameKey(og.goodName); const key = og.sdsCategoryId ? `cat:${og.sdsCategoryId}` : nameKey ? `name:${nameKey}` : ''; if (!key) continue; + groupKeys.set(key, { cat: og.sdsCategoryId ?? null, nameKey }); const arr = groups.get(key); if (arr) arr.push(og); else groups.set(key, [og]); } - const preview = [...groups.values()].map((members) => { + const resolveGroup = (key: string) => { + const members = groups.get(key)!; + const { cat, nameKey } = groupKeys.get(key)!; const parsed = parseOriginName(members[0].goodName); - const categoryName = members[0].sdsCategoryId - ? (catName.get(members[0].sdsCategoryId) ?? null) - : null; + const categoryName = cat ? (catName.get(cat) ?? null) : null; return { - groupKey: members[0].sdsCategoryId ? `cat:${members[0].sdsCategoryId}` : `name:${originGroupKey(members[0].goodName)}`, - familyName: - categoryName ?? parsed.productName ?? originGroupKey(members[0].goodName), - familyCode: codeFromCategoryName(categoryName) ?? parsed.skuCode ?? null, - memberCount: members.length, - sampleNames: members.slice(0, 3).map((m) => m.goodName ?? ''), + members, + cat, + nameKey, + parsed, + categoryName, + familyName: categoryName ?? parsed.productName ?? nameKey, + }; + }; + + const preview = [...groups.keys()].map((key) => { + const g = resolveGroup(key); + const target = g.cat || g.nameKey ? matchAutoFamily(g.cat, g.nameKey) : null; + const skip = !target && hasAnyFamily(g.cat, g.nameKey); + return { + groupKey: key, + familyName: g.familyName, + familyCode: codeFromCategoryName(g.categoryName) ?? g.parsed.skuCode ?? null, + memberCount: g.members.length, + sampleNames: g.members.slice(0, 3).map((m) => m.goodName ?? ''), + action: target ? ('merge' as const) : skip ? ('skip' as const) : ('create' as const), + targetFamilyId: target ? target.toString() : null, }; }); - if (!apply) return { applied: 0, groups: preview }; + if (!apply) return { applied: 0, merged: 0, skipped: [] as string[], groups: preview }; let applied = 0; - for (const members of groups.values()) { - const parsed = parseOriginName(members[0].goodName); - const categoryName = members[0].sdsCategoryId - ? (catName.get(members[0].sdsCategoryId) ?? null) - : null; + let merged = 0; + const skipped: string[] = []; + for (const key of groups.keys()) { + const g = resolveGroup(key); + const memberIds = g.members.map((m) => m.id); + const target = g.cat || g.nameKey ? matchAutoFamily(g.cat, g.nameKey) : null; + if (target) { + // 并入已有 autoManaged 族:不再新建 -2 碎片族 + await this.attachMembers(target, memberIds, { strict: false }); + await this.prisma.good.updateMany({ + where: { originGoodId: { in: memberIds } }, + data: { familyId: target }, + }); + await this.recompute.recomputeFamily(target); + merged += memberIds.length; + continue; + } + if (hasAnyFamily(g.cat, g.nameKey)) { + // 同款仅有人工锁定族:不并入也不新建,留在候选里等人工处理 + skipped.push(key); + continue; + } const fallbackCode = - members[0].source === 'CUSTOM' ? `CUSTOM-${members[0].id}` : null; + g.members[0].source === 'CUSTOM' ? `CUSTOM-${g.members[0].id}` : null; const family = await this.prisma.productFamily.create({ data: { - familyName: categoryName ?? parsed.productName ?? originGroupKey(members[0].goodName), + familyName: g.familyName, familyCode: - codeFromCategoryName(categoryName) ?? parsed.skuCode ?? fallbackCode + codeFromCategoryName(g.categoryName) ?? g.parsed.skuCode ?? fallbackCode ? await this.ensureUniqueCode( - (codeFromCategoryName(categoryName) ?? parsed.skuCode ?? fallbackCode)!, + (codeFromCategoryName(g.categoryName) ?? g.parsed.skuCode ?? fallbackCode)!, ) : null, - familyImage: members[0].goodImage ?? null, - primaryOriginGoodId: members[0].id, + familyImage: g.members[0].goodImage ?? null, + primaryOriginGoodId: g.members[0].id, }, }); // 宽容挂载:并行环境下成员可能在候选查询后消失(如测试清理),跳过即可 await this.attachMembers( family.id, - members.map((m) => m.id), + memberIds, { strict: false }, ); + await this.prisma.good.updateMany({ + where: { originGoodId: { in: memberIds } }, + data: { familyId: family.id }, + }); await this.recompute.recomputeFamily(family.id); applied += 1; } - return { applied, groups: preview }; + return { applied, merged, skipped, groups: preview }; + } + + /** + * 单链接自动归族:同 SDS 分类优先,回退族语义名称键(国家+品名+SKU,物流/工艺 + * 是矩阵维度不分族);只并入 autoManaged 族(人工锁定族不动),多候选取最老 + * (id 最小)。返回并入的族 id,无匹配返回 null。 + */ + async attachToMatchingFamily(originGoodId: bigint): Promise { + const og = await this.prisma.originGood.findUnique({ + where: { id: originGoodId }, + select: { id: true, sdsCategoryId: true, goodName: true, delisted: true, familyId: true }, + }); + if (!og || og.familyId || og.delisted) return null; + const members = await this.prisma.originGood.findMany({ + where: { familyId: { not: null }, delisted: false }, + select: { familyId: true, sdsCategoryId: true, goodName: true }, + }); + let candidates: bigint[] = []; + if (og.sdsCategoryId) { + candidates = members + .filter((m) => m.sdsCategoryId === og.sdsCategoryId) + .map((m) => m.familyId!); + } + if (!candidates.length) { + const key = familyNameKey(og.goodName); + if (key) { + candidates = members + .filter((m) => familyNameKey(m.goodName) === key) + .map((m) => m.familyId!); + } + } + if (!candidates.length) return null; + const families = await this.prisma.productFamily.findMany({ + where: { id: { in: [...new Set(candidates)] }, autoManaged: true }, + orderBy: { id: 'asc' }, + select: { id: true }, + }); + if (!families.length) return null; + const target = families[0]; + await this.attachMembers(target.id, [og.id]); + await this.prisma.good.updateMany({ + where: { originGoodId: og.id }, + data: { familyId: target.id }, + }); + await this.recompute.recomputeFamily(target.id); + return target.id; } async updateMembers(id: bigint, dto: UpdateFamilyMembersDto) { diff --git a/apps/api/src/public/public.service.spec.ts b/apps/api/src/public/public.service.spec.ts index b6be652..2436ac2 100644 --- a/apps/api/src/public/public.service.spec.ts +++ b/apps/api/src/public/public.service.spec.ts @@ -113,6 +113,35 @@ describe('PublicService', () => { goodPriority: 1, }, }); + + // 自包含:链接级三组封闭词表标签 + 商品镜像 + // (getTagGroups「物流渠道/印花数量/印刷工艺」断言依赖,不再依赖库内其他数据) + const autoTagIds: bigint[] = []; + for (const [groupName, tagName] of [ + ['物流渠道', '包邮'], + ['印花数量', '单面印花'], + ['印刷工艺', '烫画'], + ] as const) { + const group = await prisma.tagGroup.upsert({ + where: { groupName }, + create: { groupName }, + update: {}, + }); + const t = await prisma.tag.upsert({ + where: { tagName }, + create: { tagName, tagGroupId: group.id }, + update: {}, + }); + autoTagIds.push(t.id); + } + await prisma.originGoodTag.createMany({ + data: autoTagIds.map((tagId) => ({ originGoodId: og.id, tagId })), + }); + await prisma.goodTag.createMany({ + data: [g1.id, g2.id, g3.id].flatMap((goodId) => + autoTagIds.map((tagId) => ({ goodId, tagId })), + ), + }); goodIds = [g1.id, g2.id, g3.id]; const craftGroup = await prisma.tagGroup.create({ diff --git a/docs/references/product-center.md b/docs/references/product-center.md index eaff6e5..78746e6 100644 --- a/docs/references/product-center.md +++ b/docs/references/product-center.md @@ -66,13 +66,15 @@ **关键端点用法示例**(JWT): ```bash -# 1. 自动成族:先预览 +# 1. 自动成族:先预览(action 标注 create=新建 / merge=并入已有族 / skip=同款仅人工锁定族) curl -X POST /product-families/auto-group -H "Authorization: Bearer $T" \ -d '{"apply": false}' -# → { applied: 0, groups: [{ groupKey: "cat:8240", familyName: "DG001 180G纯棉T恤(JSA002)", memberCount: 16, ... }] } +# → { applied: 0, groups: [{ groupKey: "cat:8240", familyName: "DG001 180G纯棉T恤(JSA002)", memberCount: 16, action: "create", ... }] } -# 2. 应用(幂等,可重复执行) +# 2. 应用(幂等,可重复执行)。已有同款 autoManaged 族时**并入**(不再新建 -2 碎片族); +# 同款仅有人工锁定族(autoManaged=false)时跳过该组并计入 skipped。 curl -X POST /product-families/auto-group -H "Authorization: Bearer $T" -d '{"apply": true}' +# → { applied: 新建族数, merged: 并入链接数, skipped: ["cat:..."], groups: [...] } # 3. 族详情:成员 + 并集尺码表/包装 + 五维价格矩阵 curl /product-families/12 -H "Authorization: Bearer $T" @@ -95,7 +97,10 @@ curl -X POST /product-families/12/members/custom -H "Authorization: Bearer $T" \ 1. **同步 = 纯镜像**:SDS 给什么存什么(名称原文/图/价/分类 ID),不解析、不归族、不写解析列; 2. **整理 = 显式人工动作**(所有解析/派生集中于此,可审查可重跑):回填解析列 → 派生链接标签 - (未人工接管的 SDS 链接按名称刷新;人工接管永不覆盖)→ 自动建族 → 全量重算。入口二选一: + (未人工接管的 SDS 链接按名称刷新;人工接管永不覆盖)→ 自动建族(**并入已有同款族优先, + 无匹配才新建**)→ 碎片族合并(同分类多族时,纯碎片族并入带商品的族并删除,带商品/覆盖价/ + 人工锁定的进人工复审报告)→ 全量重算。返回含 `familiesMerged / fragmentsConsolidated / + fragmentsManualReview`。入口二选一: ```bash pnpm --filter @inkreach/api organize # CLI @@ -110,6 +115,17 @@ curl -X POST /product-families/organize -H "$AUTH" # 后台「整理 单面印花;不打印/光板 无印花面不补(矩阵中印花数量以单面占位,纯结构化规则)。 「恢复自动」(链接标签重置)本身是显式人工动作,同样走整理服务的单链接派生。 +**人工标签缺啥补啥 + 旧词别名**(2026-09-02 起): + +- `PUT /origin-goods/:id/tags` 保存人工标签时,三个定价维度组(印花数量/工艺/物流)缺哪组 + 就按链接名称自动补哪组(并入人工集合一起存为 manual 行,人工勾选值永不被动),响应带 + `filledDimensionTags`;未归族链接随后自动并入匹配族(同 SDS 分类优先,回退族语义名称键), + 响应带 `attachedFamilyId`。 +- 工艺旧词「热转印」按别名归一为「烫画」进价格矩阵(`CRAFT_TAG_ALIASES`),标签字典本身不动。 + 历史背景:曾有 16 条人工链接因勾了旧词「热转印」被封闭词表静默踢出合并矩阵。 +- 名称回退分组键为**族语义键** `familyNameKey`(第 1 段剥离物流备注、保留 国家+品名+SKU): + 包邮/不包邮、单面/双面印花是矩阵维度,不再拆族;不同国家/不同款号仍不同族。 + **公开读路径(四期族化契约,默认开启)**:`PUBLIC_DETAIL_FROM_FAMILY=false` 可应急回退旧行为。 公开端点**以款(族)为一等公民**: diff --git a/docs/references/structs.md b/docs/references/structs.md index a490f82..25c36a1 100644 --- a/docs/references/structs.md +++ b/docs/references/structs.md @@ -59,7 +59,7 @@ apps/api/ │ ├── tag-groups/ # 标签分组 CRUD(受 JWT 保护,含批量排序) │ ├── positions/ # 坑位 CRUD(受 JWT 保护) │ ├── origin-goods/ # SDS 原始商品快照(只读分页 + 配置状态树 + 链接级标签人工接管) -│ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group / 成员管理 / 自定义成员 / 价格覆盖 / 重算 / 按链接名称派生标签(auto-tag-rules) +│ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group(并入已有族优先,familyNameKey 族语义键)/ attachToMatchingFamily(单链接自动归族)/ consolidateFragments(碎片族合并)/ 成员管理 / 自定义成员 / 价格覆盖 / 重算 / 按链接名称派生标签(auto-tag-rules,含 热转印→烫画 别名) │ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建 │ ├── sync/ # SDS 同步:分类 / 商品 / 同步日志 │ ├── public/ # 公开 API:分类树 / 国家 / 商品分页 / 商品详情 @@ -123,8 +123,8 @@ apps/api/ | `/origin-goods/tree` `GET` | 配置状态树(叶子含 `familyId/familyName/familyCode/familyStale`) | JWT | | `/origin-goods/:id/tags` `GET/PUT/DELETE` | 链接级标签:查(含 manual 标记)/ 人工接管全量替换 / 恢复按名称自动派生;写入后镜像到名下商品 | 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/organize` `POST` | 整理原产品库(显式人工动作):回填解析列 → 派生标签(人工接管不动)→ 自动建族(并入已有同款族优先)→ 碎片族合并 → 全量重算;返回 `familiesMerged/fragmentsConsolidated/fragmentsManualReview`;CLI 等价 `pnpm --filter @inkreach/api organize` | JWT | +| `/product-families/auto-group` `POST` | 自动成族:按 SDS 分类(产品模型)聚合无族链接,无分类回退族语义名称键(`familyNameKey`,物流/工艺不分族);已有同款 autoManaged 族则**并入**(不建 -2 碎片族),同款仅人工锁定族则跳过;`{apply:false}` 预览含 `action: create/merge/skip`,`{apply:true}` 落库并重算(幂等) | JWT | | `/product-families/:id` `GET/PATCH` | 族详情(成员+覆盖)/ 编辑 canonical 字段、`autoManaged`、主链接 | JWT | | `/product-families/:id/recompute` `POST` | 手动重算并集与价格矩阵 | JWT | | `/product-families/:id/members` `POST` | 成员增删 `{addOriginGoodIds, removeOriginGoodIds}`;移除主链接后 primary 落到剩余成员 | JWT | diff --git a/plans/fix/family-auto-merge-fix.md b/plans/fix/family-auto-merge-fix.md new file mode 100644 index 0000000..47e07ba --- /dev/null +++ b/plans/fix/family-auto-merge-fix.md @@ -0,0 +1,58 @@ +# 修复:人工修改标签后 SKU 无法自动合并 + 后台频繁掉线 + +- 日期:2026-09-02 +- 分支:`bugfix/family-auto-merge-auth-cookie-yeuimu` +- 状态:已完成(2026-09-02,全量测试 187+22 通过) + +## 现象与根因(数据实证) + +### 问题2:人工修改标签后 SKU 无法自动合并(GBIU017 案例) + +排查(v2 库 + 部署镜像比对)确认四处叠加缺陷: + +1. **旧词表「热转印」不在矩阵封闭词表**:16 条人工接管标签的链接(GBIU014/15/16、 + USIU002/005/012、JPIU014/20/21、GBIU009 等)勾的工艺词是旧词「热转印」,而 + `memberMatrixCombos` 只认 烫画/直喷/不打印 → 这些链接的 SKU 全部被静默踢出合并矩阵。 + 线上 v2 镜像被人直接把 热转印 塞进 `DIM_VALUES` 救急(**未提交**,镜像与仓库分叉)。 +2. **人工标签缺维度无守护**:`updateTags` 全量替换为人工勾选行,缺任一定价维度组 + (印花数量/工艺/物流)时链接掉出矩阵,且 `tagsManual=true` 后整理永不补 → 死锁。 +3. **自动建族只建不并**:`autoGroup` 只扫 `familyId=null` 链接且直接新建族(撞款号加 + -2/-3 后缀),从不并入已有族 → USIU005 分裂成 USIU005 / USIU005-2,整理无法修复。 +4. **名称回退分组键含物流备注**:`originGroupKey` 前 3 段精确比较,第 1 段括号内即 + 包邮/不包邮 → 无分类链接永不同组;admin `sameOriginGroup`(合并默认勾选)同语义。 + +GBIU017 本身(v2 库)两条链接已同族且矩阵正常(4 行),用户"后来看到"系 18:00 手动 +挂族操作 + 页面缓存,非计算延迟。 + +### 问题1:后台频繁「Unauthorized, please login」 + +refresh cookie `path:'/auth'`(auth.controller.ts)与浏览器实际请求路径 +`/api/auth/*`(dev/Vite、v1)或 `/v2-api/auth/*`(v2 nginx)永不匹配 → 浏览器带不上 +refresh cookie → 30 分钟访问令牌一过期必掉线。v2 构建另有 `VITE_API_BASE=/v2-api/` +(尾斜杠)+ request.ts 手工拼接 → `/v2-api//auth/refresh` 双斜杠 404(已在生产包中实证)。 + +## 实施内容 + +| # | 内容 | 文件 | +| --- | --- | --- | +| A1 | updateTags 缺啥补啥:三个维度组按链接名派生补齐(人工值优先);响应返回补齐明细 | origin-goods.service.ts / organize.service.ts | +| A2 | 保存标签后自动归族:无族链接并入匹配已有族(同 SDS 分类优先→规范名称键;仅 autoManaged 族),响应附 attachedFamilyId | product-families.service.ts | +| A3 | autoGroup 并入已有族:分组后先匹配已有族(多候选取最老),无匹配才新建;attach 联动 goods.familyId;preview 标注并入/新建 | product-families.service.ts | +| A4 | 名称键族语义化:api/admin 新增 familyNameKey(第 1 段剥物流备注、全半角归一、保 国家+品名+SKU、丢工艺/仓库段);autoGroup 回退键与 admin sameFamily 回退改用它 | origin-name.parser.ts / admin utils/origin-name.ts、family-match.ts | +| A5 | 整理碎片清理:同分类多族时,纯碎片族(无商品、无覆盖价、autoManaged)并入最老族并删除;其余写报告提示人工 | organize.service.ts | +| A6 | admin 提示:保存标签提示补齐明细;整理完成消息含碎片合并/待人工数 | GoodsEditDialog.vue / GoodsView.vue | +| 别名 | 热转印→烫画 归一(矩阵归因 + CUSTOM 标签 + admin 展示),写进仓库取代镜像热修 | auto-tag-rules.ts / family-recompute.service.ts / admin | +| B | 两 cookie path 改 '/'(clearCookie 同步);request.ts refresh URL 规范拼接 | auth.controller.ts / admin api/request.ts | + +### 存量数据修复(部署后) + +1. 从仓库重建 v2 镜像(消除未提交热修分叉)。 +2. 跑一次「整理」:无族链接自动并入;USIU005-2(若为纯碎片)自动合并;16 条 热转印 + 链接经别名归一后重算进矩阵(矩阵 craft 列统一为 烫画)。 +3. 若有带商品/覆盖价的碎片族,按整理报告人工移动成员(编辑弹窗「加入族」已支持跨族移动)。 + +### 行为变更说明 + +- 人工接管语义从"全量替换"变为"人工修正 + 缺维自动补齐"(人工勾选值永不被动)。 +- 名称回退分族:同 国家+品名+SKU 的 包邮/不包邮/不同工艺 链接归一族(物流/工艺/印花 + 是矩阵维度,不是分族维度);分类键优先级不变。