diff --git a/AGENTS.md b/AGENTS.md index 9dc5293..b9ae7e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,3 +98,6 @@ Prisma Postgres 最佳实践: prisma-postgres 技能 - **中文断言勿手写字面量排序**:JS `Array.sort()` 对中文按 UTF-16 码位排(如 烫 U+70EB < 直 U+76F4),手写期望序列容易按拼音/习惯顺序写反;比较选项集合时用 `expect.arrayContaining` + 长度,或对两侧统一 `.sort()` 后再比较。 - **测试夹具必须自包含**:断言库内"存在某类数据"的用例(如标签组过滤)在全新/一次性数据库上必挂;夹具自己种下断言所依赖的数据,不依赖共享库的既有状态。 - **严禁直接热修运行容器内的代码**:本次线上 v2 镜像内被塞过未提交的词表改动(热转印),仓库重建镜像即复发且难排查;所有修复必须落在仓库并重建部署。排查"线上行为与代码不符"时先 `md5sum` 比对容器内源文件与仓库。 +- **两端"同构"解析器必须连测试用例也同构**:admin 与 api 各有一份链接名解析器,本次展示 bug(`品名(DTG180) SKU` 显示成光款号)正是两端取括号策略不一致(api 首括号对 vs admin 末括号)所致。改任何一端解析行为时,另一端同样输入的用例必须同步补上(origin-name.spec ↔ origin-name.parser.spec/goods.service.spec)。 +- **共享开发库不可达时用一次性 docker postgres 跑集成测试**:`docker run -d --name inkreach-test-pg -e POSTGRES_USER=test -e POSTGRES_PASSWORD=test -e POSTGRES_DB=inkreach_test -p 127.0.0.1:54329:5432 postgres:16-alpine` → `DATABASE_URL=… npx prisma migrate deploy` → jest/vitest 指向该库;用完 `docker rm -f`。夹具自包含的套件在新库上直接绿。 +- **宿主机即部署机时,改生产数据前先 `docker inspect` 拿容器真实注入的环境变量**:deploy/.env 里的密码可能与运行容器不一致(v2 栈独立 env);对两个栈的库做数据修复时,dry-run 清单必须逐栈分别核对后再 apply。 diff --git a/apps/admin/src/utils/origin-name.spec.ts b/apps/admin/src/utils/origin-name.spec.ts index 6ae3737..ec589d4 100644 --- a/apps/admin/src/utils/origin-name.spec.ts +++ b/apps/admin/src/utils/origin-name.spec.ts @@ -80,6 +80,38 @@ describe('parseLinkName / cleanLinkName', () => { }); }); +describe('已规范化的「品名(型号限定词) SKU」防误解析 + 限定词剥离', () => { + it('品名内含(限定词)的已规范化名不再解析(否则展示会变成光款号)', () => { + expect(parseLinkName('童装纯色插肩短袖T恤(DTG180) PLTK016')).toBeNull(); + expect(parseLinkName('牛奶丝T恤(女款) DG601')).toBeNull(); + expect(parseLinkName('180G纯棉T恤 (JSA002) DG001')).toBeNull(); + }); + + it('cleanLinkName 对已规范化名剥离 ASCII 型号限定词', () => { + expect(cleanLinkName('童装纯色插肩短袖T恤(DTG180) PLTK016')).toBe( + '童装纯色插肩短袖T恤 PLTK016', + ); + expect(cleanLinkName('180G纯棉T恤 (JSA002) DG001')).toBe('180G纯棉T恤 DG001'); + }); + + it('原始链接名品名内含限定词时,解析后一并剥离', () => { + expect( + cleanLinkName('波兰(包邮)童装纯色插肩短袖T恤(DTG180)-PLTK016-双面印花'), + ).toBe('童装纯色插肩短袖T恤 PLTK016'); + }); + + it('中文括号内容(如(女款))不剥离', () => { + expect(cleanLinkName('牛奶丝T恤(女款) DG601')).toBe('牛奶丝T恤(女款) DG601'); + }); + + it('无 SKU 段的单段链接名仍按首括号对解析(与 api 端一致)', () => { + expect(parseLinkName('波兰(包邮)童装T恤')).toEqual({ + productName: '童装T恤', + skuCode: null, + }); + }); +}); + describe('deriveLinkTagNames', () => { it('单面印花 + 包邮 + 无工艺关键字 → 单面印花/烫画/包邮', () => { expect(deriveLinkTagNames('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toEqual([ diff --git a/apps/admin/src/utils/origin-name.ts b/apps/admin/src/utils/origin-name.ts index a099039..03e7138 100644 --- a/apps/admin/src/utils/origin-name.ts +++ b/apps/admin/src/utils/origin-name.ts @@ -51,27 +51,46 @@ export interface ParsedLinkName { /** * 解析链接名称:`国家(物流备注)品名-SKU-工艺位置[-·仓库名]` → 品名 + 型号。 - * 与 api 端 origin-name.parser.ts 同构;解析失败(无「国家(备注)」前缀结构)返回 null, - * 调用方应回退显示原始名称 —— 手动维护的品名不走该解析。 + * 与 api 端 origin-name.parser.ts 同构(首括号对解析,半角/全角混用先归一); + * 解析失败(无「国家(备注)」前缀结构)返回 null,调用方应回退显示原始名称 + * —— 手动维护的品名不走该解析。 */ export function parseLinkName(name: string | null | undefined): ParsedLinkName | null { if (!name) return null; const segs = name.split('-').map((s) => s.trim()); const head = segs[0] ?? ''; - const closeIdx = Math.max(head.lastIndexOf(')'), head.lastIndexOf(')')); - if (closeIdx <= 0 || closeIdx === head.length - 1) return null; - const productName = head.slice(closeIdx + 1).trim(); + const normalized = head.replace(/\(/g, '(').replace(/\)/g, ')'); + const openAt = normalized.indexOf('('); + const closeAt = openAt >= 0 ? normalized.indexOf(')', openAt) : -1; + if (closeAt <= openAt || closeAt === normalized.length - 1) return null; + const productName = normalized.slice(closeAt + 1).trim(); if (!productName) return null; + // 无 SKU 段时仅当括号后的品名含中文才视为链接名 —— 防把已规范化的 + // 「品名(型号限定词) SKU」(如「童装…(DTG180) PLTK016」)的尾段款号误当品名 + if (segs.length < 2 && !/[\u4e00-\u9fff]/.test(productName)) return null; const skuCode = segs[1] || null; return { productName, skuCode }; } -/** 展示名:`品名 型号`(如「180g纯棉T恤成人款 DG001」);解析失败回退原名 */ +/** ASCII 型号限定词括号组(SDS 品名内的供应商/工艺型号,如(DTG180)(JSA002)) */ +const ASCII_PAREN_GROUP = /(?:(|\()[A-Za-z0-9]+(?:)|\))/g; + +/** 展示名剥离 ASCII 型号限定词;剥空(名称仅剩限定词)时原样返回 */ +export function stripAsciiParenQualifiers(name: string): string { + const stripped = name.replace(ASCII_PAREN_GROUP, ' ').replace(/\s+/g, ' ').trim(); + return stripped || name; +} + +/** 展示名:`品名 型号`(如「180g纯棉T恤成人款 DG001」);解析失败回退原名。品名内的 ASCII 型号限定词一并剥离 */ export function cleanLinkName(name: string | null | undefined): string { if (!name) return ''; const parsed = parseLinkName(name); - if (!parsed) return name; - return parsed.skuCode ? `${parsed.productName} ${parsed.skuCode}` : parsed.productName; + const base = parsed + ? parsed.skuCode + ? `${parsed.productName} ${parsed.skuCode}` + : parsed.productName + : name; + return stripAsciiParenQualifiers(base); } const CRAFT_KEYWORD_MAP: Record = { diff --git a/apps/api/package.json b/apps/api/package.json index 4c8c08d..5683a40 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,6 +24,7 @@ "configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts", "import:product-detail": "ts-node prisma/import-product-detail.ts", "backfill:product-families": "ts-node prisma/backfill-product-families.ts", + "fix:good-names": "ts-node prisma/fix-pure-sku-good-names.ts", "organize": "ts-node prisma/backfill-product-families.ts" }, "dependencies": { diff --git a/apps/api/prisma/fix-pure-sku-good-names.ts b/apps/api/prisma/fix-pure-sku-good-names.ts new file mode 100644 index 0000000..8e9a06a --- /dev/null +++ b/apps/api/prisma/fix-pure-sku-good-names.ts @@ -0,0 +1,42 @@ +/** + * 存量修复脚本(幂等):把纯款号商品名(如 `PLTK016`)按主链接 SDS 分类名 + * 补成「描述名 款号」(如 `童装纯色插肩短袖T恤 PLTK016`)。 + * + * 运行: + * pnpm --filter @inkreach/api fix:good-names # dry-run,只打印清单 + * pnpm --filter @inkreach/api fix:good-names -- --apply # 实际写库 + * origin_goods.good_name 为 SDS 纯镜像(每小时同步覆盖),本脚本只改 goods.good_name。 + */ +import { PrismaService } from '../src/prisma/prisma.service'; +import { SyncService } from '../src/sync/sync.service'; +import { FamilyRecomputeService } from '../src/product-families/family-recompute.service'; +import { GoodsService } from '../src/goods/goods.service'; + +async function main() { + const dryRun = !process.argv.includes('--apply'); + const prisma = new PrismaService(); + await prisma.onModuleInit(); + const recompute = new FamilyRecomputeService(prisma); + // 回填路径不触发详情同步,SyncService 仅作占位依赖 + const goods = new GoodsService( + prisma, + { queueProductDetailSync: async () => undefined } as unknown as SyncService, + recompute, + ); + + const result = await goods.backfillPureSkuGoodNames({ dryRun }); + const mode = dryRun ? 'DRY-RUN(未写库,加 --apply 执行)' : 'APPLIED'; + console.log(`[fix:good-names] ${mode} renamed=${result.renamed} unmapped=${result.unmapped.length}`); + for (const r of result.renames) { + console.log(` #${r.id} ${r.from} -> ${r.to}`); + } + for (const u of result.unmapped) { + console.log(` #${u.id} ${u.from} -> (无分类映射,保留原名)`); + } + await prisma.onModuleDestroy(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/apps/api/src/goods/goods.service.spec.ts b/apps/api/src/goods/goods.service.spec.ts index 839c587..c2119af 100644 --- a/apps/api/src/goods/goods.service.spec.ts +++ b/apps/api/src/goods/goods.service.spec.ts @@ -3,7 +3,12 @@ import { BadRequestException, NotFoundException, } from '@nestjs/common'; -import { GoodsService } from './goods.service'; +import { + cleanGoodDisplayName, + describePureSkuGoodName, + GoodsService, + isPureSkuGoodName, +} from './goods.service'; import { PrismaService } from '../prisma/prisma.service'; import { SyncService } from '../sync/sync.service'; import { FamilyRecomputeService } from '../product-families/family-recompute.service'; @@ -312,6 +317,223 @@ describe('GoodsService', () => { }); }); + describe('pure sku good name(纯款号补描述)', () => { + const pskuCode = `PSKU${stamp}`; + const pskuDesc = `童装纯色测试插肩T恤${stamp}`; + let pskuOriginGoodId: bigint; + let bareOriginGoodId: bigint; + + beforeAll(async () => { + await prisma.category.create({ + data: { + categoryName: `${pskuCode} ${pskuDesc}`, + sdsCategoryId: `psku-cat-${stamp}`, + }, + }); + const pskuOg = await prisma.originGood.create({ + data: { + sdsGoodId: `sds-psku-${stamp}`, + goodName: pskuCode, + sdsCategoryId: `psku-cat-${stamp}`, + }, + }); + pskuOriginGoodId = pskuOg.id; + const bareOg = await prisma.originGood.create({ + data: { + sdsGoodId: `sds-psku-bare-${stamp}`, + goodName: `PSKB${stamp}`, + }, + }); + bareOriginGoodId = bareOg.id; + }); + + afterAll(async () => { + await prisma.good.deleteMany({ + where: { OR: [{ goodName: { contains: pskuCode } }, { goodName: { contains: `PSKB${stamp}` } }] }, + }); + await prisma.originGood.deleteMany({ + where: { id: { in: [pskuOriginGoodId, bareOriginGoodId] } }, + }); + await prisma.category.deleteMany({ + where: { sdsCategoryId: `psku-cat-${stamp}` }, + }); + }); + + it('isPureSkuGoodName:仅纯字母数字为真', () => { + expect(isPureSkuGoodName('PLTK016')).toBe(true); + expect(isPureSkuGoodName(' pltk016 ')).toBe(true); + expect(isPureSkuGoodName('PSKU1')).toBe(true); + expect(isPureSkuGoodName('童装纯色插肩短袖T恤 PLTK016')).toBe(false); + expect(isPureSkuGoodName('德国(不包邮)T恤-DG001-烫画')).toBe(false); + expect(isPureSkuGoodName('230g水洗T恤 DETM002')).toBe(false); + expect(isPureSkuGoodName('P')).toBe(false); + expect(isPureSkuGoodName('')).toBe(false); + }); + + it('cleanGoodDisplayName:品名内的 ASCII 型号限定词剥离,中文括号内容保留', () => { + expect( + cleanGoodDisplayName('波兰(包邮)童装纯色插肩短袖T恤(DTG180)-PLTK016-双面印花'), + ).toBe('童装纯色插肩短袖T恤 PLTK016'); + expect(cleanGoodDisplayName('童装纯色插肩短袖T恤(DTG180) PLTK016')).toBe( + '童装纯色插肩短袖T恤 PLTK016', + ); + expect(cleanGoodDisplayName('180G纯棉T恤 (JSA002) DG001')).toBe('180G纯棉T恤 DG001'); + expect(cleanGoodDisplayName('牛奶丝T恤(女款) DG601')).toBe('牛奶丝T恤(女款) DG601'); + expect(cleanGoodDisplayName('230g水洗T恤 DETM002')).toBe('230g水洗T恤 DETM002'); + }); + + it('describePureSkuGoodName:标准分类名剥款号补描述', () => { + expect( + describePureSkuGoodName('PLTK016', 'PLTK016 童装纯色插肩短袖T恤'), + ).toBe('童装纯色插肩短袖T恤 PLTK016'); + }); + + it('describePureSkuGoodName:分类名无款号前缀时用全名', () => { + expect( + describePureSkuGoodName('PLTK016', '童装纯色插肩短袖T恤'), + ).toBe('童装纯色插肩短袖T恤 PLTK016'); + }); + + it('describePureSkuGoodName:分类名即款号/空分类名/非纯款号名原样返回', () => { + expect(describePureSkuGoodName('PLTK016', 'PLTK016')).toBe('PLTK016'); + expect(describePureSkuGoodName('PLTK016', null)).toBe('PLTK016'); + expect(describePureSkuGoodName('PLTK016', undefined)).toBe('PLTK016'); + expect( + describePureSkuGoodName('童装T恤 PLTK016', 'PLTK016 童装T恤'), + ).toBe('童装T恤 PLTK016'); + expect(describePureSkuGoodName('230g水洗T恤 DETM002', 'PLTK016 童装T恤')).toBe( + '230g水洗T恤 DETM002', + ); + }); + + it('describePureSkuGoodName:分类名中的 ASCII 型号限定词一并剥离', () => { + expect( + describePureSkuGoodName('PLTK016', 'PLTK016 童装纯色插肩短袖T恤(DTG180)'), + ).toBe('童装纯色插肩短袖T恤 PLTK016'); + }); + + it('create:纯款号名按主链接 SDS 分类名补描述', async () => { + const created = await service.create({ + goodName: pskuCode, + originGoodId: Number(pskuOriginGoodId), + countryId: Number(countryId), + categoryId: Number(categoryId), + }); + try { + expect(created.goodName).toBe(`${pskuDesc} ${pskuCode}`); + } finally { + await prisma.good.delete({ where: { id: BigInt(created.id) } }); + } + }); + + it('create:主链接无 sdsCategoryId 映射时纯款号原样保留', async () => { + const created = await service.create({ + goodName: `PSKB${stamp}`, + originGoodId: Number(bareOriginGoodId), + countryId: Number(countryId), + categoryId: Number(categoryId), + }); + try { + expect(created.goodName).toBe(`PSKB${stamp}`); + } finally { + await prisma.good.delete({ where: { id: BigInt(created.id) } }); + } + }); + + it('update:改成纯款号名按商品主链接分类名补描述', async () => { + const created = await service.create({ + goodName: `Goods Test ${stamp} psku-rename`, + originGoodId: Number(pskuOriginGoodId), + countryId: Number(countryId), + categoryId: Number(categoryId), + }); + try { + const updated = await service.update(BigInt(created.id), { + goodName: pskuCode, + }); + expect(updated.goodName).toBe(`${pskuDesc} ${pskuCode}`); + } finally { + await prisma.good.delete({ where: { id: BigInt(created.id) } }); + } + }); + + it('batchCreate:纯款号链接名补描述、完整链接名规范化', async () => { + const created = await service.batchCreate({ + countryId: Number(countryId), + categoryId: Number(categoryId), + items: [{ originGoodId: Number(pskuOriginGoodId) }], + }); + try { + expect(created[0]?.goodName).toBe(`${pskuDesc} ${pskuCode}`); + } finally { + await prisma.good.deleteMany({ + where: { id: { in: created.map((g) => BigInt(g.id)) } }, + }); + } + }); + + it('backfillPureSkuGoodNames:dry-run 只列不改,apply 改名且幂等', async () => { + // 绕过写路径直写库,模拟存量纯款号名 + const row = await prisma.good.create({ + data: { + goodName: pskuCode, + originGoodId: pskuOriginGoodId, + countryId, + categoryId, + }, + }); + try { + // dry-run / apply 均用 goodId 限定在本夹具,避免测试副作用波及共享库真实数据 + const dry = await service.backfillPureSkuGoodNames({ dryRun: true, goodId: row.id }); + expect(dry.renames).toEqual([ + { id: row.id.toString(), from: pskuCode, to: `${pskuDesc} ${pskuCode}` }, + ]); + expect( + (await prisma.good.findUniqueOrThrow({ where: { id: row.id } })).goodName, + ).toBe(pskuCode); + + const applied = await service.backfillPureSkuGoodNames({ dryRun: false, goodId: row.id }); + expect(applied.renamed).toBe(1); + expect( + (await prisma.good.findUniqueOrThrow({ where: { id: row.id } })).goodName, + ).toBe(`${pskuDesc} ${pskuCode}`); + + // 幂等:已改名的行不再出现在后续清单 + const again = await service.backfillPureSkuGoodNames({ dryRun: true, goodId: row.id }); + expect(again.renames).toEqual([]); + } finally { + await prisma.good.delete({ where: { id: row.id } }); + } + }); + + it('backfillPureSkuGoodNames:剥离「品名(型号限定词) SKU」存量名中的限定词', async () => { + const row = await prisma.good.create({ + data: { + goodName: `${pskuDesc}(DTG180) ${pskuCode}`, + originGoodId: pskuOriginGoodId, + countryId, + categoryId, + }, + }); + try { + const dry = await service.backfillPureSkuGoodNames({ dryRun: true, goodId: row.id }); + expect(dry.renames).toEqual([ + { + id: row.id.toString(), + from: `${pskuDesc}(DTG180) ${pskuCode}`, + to: `${pskuDesc} ${pskuCode}`, + }, + ]); + await service.backfillPureSkuGoodNames({ dryRun: false, goodId: row.id }); + expect( + (await prisma.good.findUniqueOrThrow({ where: { id: row.id } })).goodName, + ).toBe(`${pskuDesc} ${pskuCode}`); + } finally { + await prisma.good.delete({ where: { id: row.id } }); + } + }); + }); + describe('merged origin goods', () => { it('creates a good with merged origin goods and reads them back', async () => { const created = await service.create({ diff --git a/apps/api/src/goods/goods.service.ts b/apps/api/src/goods/goods.service.ts index d7a99f7..b5ea7ce 100644 --- a/apps/api/src/goods/goods.service.ts +++ b/apps/api/src/goods/goods.service.ts @@ -72,6 +72,48 @@ export function normalizeGoodName(name: string): string { return name; } +/** ASCII 型号限定词括号组(SDS 品名内的供应商/工艺型号,如(DTG180)(JSA002)) */ +const ASCII_PAREN_GROUP = /(?:(|\()[A-Za-z0-9]+(?:)|\))/g; + +/** 展示名剥离 ASCII 型号限定词;剥空(名称仅剩限定词)时原样返回 */ +export function stripAsciiParenQualifiers(name: string): string { + const stripped = name.replace(ASCII_PAREN_GROUP, ' ').replace(/\s+/g, ' ').trim(); + return stripped || name; +} + +/** + * 商品展示名清洗:规范化 + 剥离 ASCII 型号限定词。 + * SDS 品名常带型号限定词(如 `童装…(DTG180)`),展示名统一剥成「品名 SKU」。 + */ +export function cleanGoodDisplayName(name: string): string { + return stripAsciiParenQualifiers(normalizeGoodName(name)); +} + +/** 纯款号名:仅字母/数字(如 `PLTK016`),链接名解析不出品名即此类 */ +export function isPureSkuGoodName(name: string): boolean { + return /^[A-Za-z0-9]{2,}$/.test(name.trim()); +} + +/** + * 纯款号展示名兜底:用 SDS 分类名补描述。 + * `PLTK016` + 分类名 `PLTK016 童装纯色插肩短袖T恤` → `童装纯色插肩短袖T恤 PLTK016`。 + * 分类名首 token 仅当为纯字母数字时视为款号剥离(与 auto-group 的 + * codeFromCategoryName 语义一致),描述中的 ASCII 型号限定词一并剥离; + * 非纯款号名、无分类名或剥后为空时原样返回。 + */ +export function describePureSkuGoodName( + name: string, + categoryName: string | null | undefined, +): string { + if (!isPureSkuGoodName(name) || !categoryName?.trim()) return name; + const sku = name.trim(); + const tokens = categoryName.trim().split(/\s+/); + const desc = stripAsciiParenQualifiers( + (/^[A-Za-z0-9]+$/.test(tokens[0] ?? '') ? tokens.slice(1) : tokens).join(' '), + ); + return desc ? `${desc} ${sku}` : name; +} + @Injectable() export class GoodsService { constructor( @@ -149,12 +191,12 @@ export class GoodsService { // Good 的族是派生数据:主链接所属族 const primary = await tx.originGood.findUnique({ where: { id: BigInt(dto.originGoodId) }, - select: { familyId: true }, + select: { familyId: true, sdsCategoryId: true }, }); const tagIds = await this.stripAutoGroupTags(dto.tagIds ?? [], primary?.familyId ?? null); const created = await tx.good.create({ data: { - goodName: normalizeGoodName(dto.goodName), + goodName: await this.resolveGoodName(tx, dto.goodName, primary?.sdsCategoryId), goodImage: dto.goodImage, originGoodId: BigInt(dto.originGoodId), familyId: primary?.familyId ?? null, @@ -344,7 +386,13 @@ export class GoodsService { await this.ensureMergedOriginGoods(mergedIds); } const data: Prisma.GoodUpdateInput = {}; - if (dto.goodName !== undefined) data.goodName = normalizeGoodName(dto.goodName); + if (dto.goodName !== undefined) { + data.goodName = await this.resolveGoodName( + this.prisma, + dto.goodName, + await this.sdsCategoryIdForGood(id, dto.originGoodId), + ); + } if (dto.originGoodId !== undefined) { await this.ensureOriginGood(dto.originGoodId); data.originGood = { connect: { id: BigInt(dto.originGoodId) } }; @@ -518,7 +566,11 @@ export class GoodsService { } const row = await tx.good.create({ data: { - goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`, + goodName: await this.resolveGoodName( + tx, + og.goodName ?? `Origin Good ${og.sdsGoodId}`, + og.sdsCategoryId, + ), goodImage: og.goodImage, originGoodId: og.id, familyId: og.familyId, @@ -585,6 +637,81 @@ export class GoodsService { return result; } + /** + * 存量修复(幂等):商品展示名统一为「品名 SKU」—— + * 1. 剥离 ASCII 型号限定词(如 `童装…(DTG180) PLTK016` → `童装… PLTK016`), + * 顺带把结构完整的原始链接名规范化; + * 2. 清洗后仍为纯款号(如 `PLTK016`)的,按主链接 SDS 分类名补描述。 + * dryRun 只返回清单不写库;goodId 可将范围限定到单个商品(测试用)。 + * 纯款号但无分类映射(补不出描述名)的行保持原样并计入 unmapped。 + */ + async backfillPureSkuGoodNames(opts: { + dryRun: boolean; + goodId?: bigint; + }): Promise<{ + applied: boolean; + renamed: number; + renames: { id: string; from: string; to: string }[]; + unmapped: { id: string; from: string }[]; + }> { + const rows = await this.prisma.good.findMany({ + where: opts.goodId !== undefined ? { id: opts.goodId } : {}, + select: { + id: true, + goodName: true, + originGood: { select: { sdsCategoryId: true } }, + }, + }); + const cleaned = rows.map((row) => { + const name = cleanGoodDisplayName(row.goodName); + return { row, name, pure: isPureSkuGoodName(name) }; + }); + const sdsCategoryIds = [ + ...new Set( + cleaned + .filter((c) => c.pure) + .map((c) => c.row.originGood?.sdsCategoryId) + .filter((v): v is string => Boolean(v)), + ), + ]; + const categories = sdsCategoryIds.length + ? await this.prisma.category.findMany({ + where: { sdsCategoryId: { in: sdsCategoryIds } }, + select: { sdsCategoryId: true, categoryName: true }, + }) + : []; + const categoryNameBySdsId = new Map( + categories.map((c) => [c.sdsCategoryId, c.categoryName]), + ); + + const renames: { id: string; from: string; to: string }[] = []; + const unmapped: { id: string; from: string }[] = []; + for (const { row, name, pure } of cleaned) { + const categoryName = + pure && row.originGood?.sdsCategoryId + ? categoryNameBySdsId.get(row.originGood.sdsCategoryId) + : undefined; + const to = describePureSkuGoodName(name, categoryName); + if (to === row.goodName) { + if (pure) unmapped.push({ id: row.id.toString(), from: row.goodName }); + } else { + renames.push({ id: row.id.toString(), from: row.goodName, to }); + } + } + + if (!opts.dryRun && renames.length > 0) { + await this.prisma.$transaction( + renames.map((r) => + this.prisma.good.update({ + where: { id: BigInt(r.id) }, + data: { goodName: r.to }, + }), + ), + ); + } + return { applied: !opts.dryRun, renamed: renames.length, renames, unmapped }; + } + /** * Walk the category tree and return the requested id + all of its * descendants. We use a level-by-level BFS to keep the queries small @@ -606,6 +733,48 @@ export class GoodsService { return ids; } + /** + * 写路径名称兜底:规范化后仍为纯款号(如 `PLTK016`,SDS 部分链接名只有款号) + * 时,用主链接 SDS 分类名(形如 `PLTK016 童装纯色插肩短袖T恤`)补成「描述名 款号」。 + * 非纯款号名不做任何额外查询。 + */ + private async resolveGoodName( + db: Pick, + rawName: string, + sdsCategoryId: string | null | undefined, + ): Promise { + const normalized = cleanGoodDisplayName(rawName); + if (!isPureSkuGoodName(normalized) || !sdsCategoryId) return normalized; + const category = await db.category.findUnique({ + where: { sdsCategoryId }, + select: { categoryName: true }, + }); + return describePureSkuGoodName(normalized, category?.categoryName); + } + + /** 商品主链接(可为本次切换后的)的 sdsCategoryId;无链接或未分类返回 null */ + private async sdsCategoryIdForGood( + goodId: bigint, + overrideOriginGoodId?: number, + ): Promise { + let originGoodId: bigint | null = null; + if (overrideOriginGoodId !== undefined) { + originGoodId = BigInt(overrideOriginGoodId); + } else { + const good = await this.prisma.good.findUnique({ + where: { id: goodId }, + select: { originGoodId: true }, + }); + originGoodId = good?.originGoodId ?? null; + } + if (originGoodId === null) return null; + const og = await this.prisma.originGood.findUnique({ + where: { id: originGoodId }, + select: { sdsCategoryId: true }, + }); + return og?.sdsCategoryId ?? null; + } + private async ensureOriginGood(id: number) { const og = await this.prisma.originGood.findUnique({ where: { id: BigInt(id) }, diff --git a/docs/references/product-center.md b/docs/references/product-center.md index 78746e6..bda9946 100644 --- a/docs/references/product-center.md +++ b/docs/references/product-center.md @@ -223,7 +223,23 @@ curl -X POST /product-families/organize -H "$AUTH" # 后台「整理 **链接名称展示解析**:后台所有链接名展示位(左右树、族成员列表、配置/编辑弹窗、搜索候选) 统一解析为「品名 型号」(如 `美国(包邮)180g纯棉T恤成人款-DG001-单面印花` → `180g纯棉T恤成人款 DG001`),悬停 tooltip 保留原始全名;搜索同时匹配原始名与解析名 -(`utils/origin-name.ts#cleanLinkName`)。 +(`utils/origin-name.ts#cleanLinkName`)。解析按**首括号对**取品名(与 api 端 +`origin-name.parser.ts#splitSegment1` 同构);无 SKU 段时仅当括号后品名含中文才视为 +链接名——防止已规范化的「品名(型号限定词) SKU」(如 `童装…(DTG180) PLTK016`) +被误解析成光款号。 + +**商品展示名规范化与存量修复(2026-09)**:`goods.good_name` 统一存「品名 SKU」—— + +- 写路径(`goods.service.ts` create/update/batchCreate 经 `resolveGoodName`): + 结构完整的原始链接名规范化为「品名 SKU」,品名内的 **ASCII 型号限定词** + (如 `(DTG180)`/`(JSA002)`,供应商或工艺型号)一律剥离(中文括号内容如 + `(女款)`保留);清洗后仍为**纯款号**(如 `PLTK016`,SDS 部分链接名只有款号) + 时按主链接 SDS 分类名补描述(`PLTK016 童装纯色插肩短袖T恤` → + `童装纯色插肩短袖T恤 PLTK016`);batchCreate 同样走规范化;自定义商品 + (createCustom/updateCustomContent)的人工命名不受影响; +- 存量修复脚本:`pnpm --filter @inkreach/api fix:good-names`(默认 dry-run 打印 + 旧名→新名清单,`-- --apply` 写库,幂等可重跑;`origin_goods.good_name` 为 SDS + 纯镜像不动,只改 `goods.good_name`)。 diff --git a/docs/references/structs.md b/docs/references/structs.md index 25c36a1..9ea86e5 100644 --- a/docs/references/structs.md +++ b/docs/references/structs.md @@ -47,7 +47,8 @@ apps/api/ ├── prisma/ │ ├── schema.prisma # 数据模型(OriginGood/ProductFamily/FamilyPriceOverride/Country/Category/Tag/Position/Good/User/SyncLog) │ ├── migrations/ # Prisma migrate 历史 -│ └── backfill-product-families.ts # 产品族回填脚本(解析列→自动建族→全量重算,幂等) +│ ├── backfill-product-families.ts # 产品族回填脚本(解析列→自动建族→全量重算,幂等) +│ └── fix-pure-sku-good-names.ts # 商品名存量修复脚本(剥型号限定词/纯款号补描述,幂等,dry-run 默认) ├── src/ │ ├── main.ts # 入口:CORS、ValidationPipe、Swagger、BigInt JSON 序列化 │ ├── app.module.ts # 根模块,聚合所有业务模块 @@ -60,7 +61,7 @@ apps/api/ │ ├── positions/ # 坑位 CRUD(受 JWT 保护) │ ├── origin-goods/ # SDS 原始商品快照(只读分页 + 配置状态树 + 链接级标签人工接管) │ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group(并入已有族优先,familyNameKey 族语义键)/ attachToMatchingFamily(单链接自动归族)/ consolidateFragments(碎片族合并)/ 成员管理 / 自定义成员 / 价格覆盖 / 重算 / 按链接名称派生标签(auto-tag-rules,含 热转印→烫画 别名) -│ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建 +│ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建 + 展示名规范化(品名 SKU,剥 ASCII 型号限定词,纯款号按 SDS 分类名补描述) │ ├── sync/ # SDS 同步:分类 / 商品 / 同步日志 │ ├── public/ # 公开 API:分类树 / 国家 / 商品分页 / 商品详情 │ └── common/ # 全局装饰器 / 过滤器 / 拦截器 diff --git a/plans/fix/pure-sku-good-name-fix.md b/plans/fix/pure-sku-good-name-fix.md new file mode 100644 index 0000000..de88590 --- /dev/null +++ b/plans/fix/pure-sku-good-name-fix.md @@ -0,0 +1,77 @@ +# 修复左栏商品名显示成光款号(PLTK016 → 童装纯色插肩短袖T恤 PLTK016) + +- 分支:`bugfix/pure-sku-good-name-yeuimu`(自 `develop`) +- 日期:2026-09-02 +- 类型:Bug 修复 / 数据修复 + +## 问题 + +后台商品配置页(GoodsView)左栏部分商品只显示纯款号(如 `PLTK016`),期望显示 +「描述名 款号」(如 `童装纯色插肩短袖T恤 PLTK016`)。 + +## 根因(两层) + +1. **展示解析误判(主因,V2 栈 34 条)**:部分 SDS 品名自带 ASCII 型号限定词, + 写路径 `normalizeGoodName`(api `splitSegment1` 首括号对解析)产出的展示名为 + `童装纯色插肩短袖T恤(DTG180) PLTK016`;而 admin 展示解析器 + `parseLinkName` 用**最后一个** `)` 之后的文本当品名 → 显示成光秃秃 `PLTK016`。 +2. **纯款号名(防御路径,当期两库 0 条)**:SDS 部分链接名只有款号,解析不出 + 品名时 `normalizeGoodName` 原样入库,左栏显示裸款号;且 `batchCreate` 完全 + 未过 normalize。 + +描述名权威来源:SDS 分类名 `categories.category_name`(`PLTK016 童装纯色插肩短袖T恤`, +`auto-group` 族名同源),去首 token 款号即得。 + +## 边界与约束 + +- `origin_goods.good_name` 是 SDS 纯镜像、每小时被同步覆盖 —— **不动镜像**, + 只修 `goods.good_name`(官网展示名,前台官网直接透传,同步受益)。 +- 中文括号内容(如 `(女款)`)不是型号限定词,保留。 +- CUSTOM 商品(createCustom/updateCustomContent)人工命名不受影响。 + +## 实现 + +### admin(apps/admin/src/utils/origin-name.ts) + +- `parseLinkName` 对齐 api 端:首括号对解析 + 半/全角归一;无 SKU 段时仅当 + 括号后品名含中文才视为链接名(防「品名(限定词) SKU」误解析)。 +- 新增 `stripAsciiParenQualifiers`;`cleanLinkName` 展示统一剥离 ASCII 型号限定词。 + +### api(apps/api/src/goods/goods.service.ts) + +- 导出 `stripAsciiParenQualifiers` / `cleanGoodDisplayName`(规范化 + 剥限定词)/ + `isPureSkuGoodName` / `describePureSkuGoodName`(纯款号按 SDS 分类名补描述, + 分类名中的限定词一并剥离)。 +- `resolveGoodName` 私有助手接入 `create` / `update` / `batchCreate`(batchCreate + 顺带补上缺失的 normalize);`sdsCategoryIdForGood` 取(可切换后的)主链接分类。 +- `backfillPureSkuGoodNames({ dryRun, goodId? })`:存量修复,幂等,支持 goodId + 限定(测试隔离用)。 + +### 脚本 + +- `apps/api/prisma/fix-pure-sku-good-names.ts`(薄壳,手动构造依赖), + npm script `fix:good-names`;默认 dry-run,`-- --apply` 写库。 + +## 测试(TDD) + +- admin `origin-name.spec.ts`:+5(误解析防护、限定词剥离、中文括号保留、 + 首括号单段解析),共 23 绿。 +- api `goods.service.spec.ts`:+9(纯函数边界、create/update/batchCreate 补描述、 + 无映射保留、backfill dry-run/apply/幂等/限定词剥离),共 30 绿。 +- 全量:api 22 套件 199 测试绿(临时 docker postgres + `prisma migrate deploy`, + 共享开发库 192.168.124.92 当期不可达);admin vitest 2 文件 27 测试绿; + 两端 `tsc --noEmit` 零错误。 + +## 落库结果(2026-09-02 执行) + +- V2 栈(deploy-v2-postgres-1):34 条 `品名(限定词) SKU` → `品名 SKU` + (含 #263 `童装纯色插肩短袖T恤 PLTK016`)。 +- V1 栈(deploy-postgres-1):213 条原始链接名 → 规范化「品名 SKU」(后台展示 + 等价,官网卡片由原始链接名变为干净品名)。 +- 两库复跑 dry-run `renamed=0 unmapped=0`,幂等验证通过;数据已干净,线上旧 + 前端镜像无需等重部署即正确显示;代码修复防新增。 + +## 权限评估 + +无新增端点/操作,全部复用既有已控权限路径(PATCH /goods/:id 等);CLI 脚本为 +运维通道 —— 无需新增权限项。