Author SHA1 Message Date
yeuimu 37353470e7 fix(categories): 分类名去括号——同步入库整段删除括号段
左侧分类树/官网分类名的括号段(型号限定词(JSA002)与内容性括号
(黑+卡其)/(短裤 & 长裤))整段删除。分类名每小时被 SDS 同步覆盖,
清洗落在 syncCategories 入库路径(cleanCategoryDisplayName,款号 token
保留,剥空守卫),另附幂等存量脚本 fix:category-names。

已执行:V1/V2 各 33 条分类名去括号,复跑幂等 0。注意 api 容器重建
部署前旧镜像的下一轮同步会把名称写回原文,部署后自动恢复干净。

Tests: api 23 suites/206 passed(含新增 category-name.spec 7 例),tsc clean
2026-09-03 01:27:38 +08:00
yeuimu 9eafac7204 fix(goods): 商品展示名统一「品名 SKU」——剥 ASCII 型号限定词、纯款号补描述
左栏商品名显示成光款号(如 PLTK016)的根因是双层解析不一致:api 写路径按
首括号对解析产出「童装…(DTG180) PLTK016」,admin 展示解析按末括号取品名
误显示成 PLTK016。

- admin parseLinkName 对齐 api 首括号对解析 + 无 SKU 段中文品名守卫,
  cleanLinkName 剥离 ASCII 型号限定词(中文括号内容保留)
- api 新增 cleanGoodDisplayName/describePureSkuGoodName,接入 create/update/
  batchCreate(batchCreate 补上缺失的 normalize);纯款号按 SDS 分类名补描述
- 新增幂等存量修复脚本 fix:good-names(默认 dry-run,--apply 写库)
- 已执行:V2 栈 34 条限定词名、V1 栈 213 条原始链接名规范化,复跑幂等 0

Tests: api 22 suites/199 passed, admin 27 passed, tsc clean
2026-09-03 01:27:26 +08:00
yeuimu b81d944c38 docs(agent): add dimension-value onboarding lesson 2026-09-02 16:37:56 +08:00
yeuimu f09a629311 merge: add heat-transfer craft to matrix vocabularies 2026-09-02 16:34:27 +08:00
yeuimu 8903afdc82 feat(product-families): add heat-transfer (热转印) craft to matrix vocabularies
Append 热转印 to DIM_VALUES.craft so links manually tagged with the new
tag enter the price matrix (previously the closed vocabulary filtered it
out and the link's prices would silently disappear), and to
OPTION_DISPLAY_ORDER.craft so the button group renders it after 不打印.
No derivation rules changed: existing links are unaffected until an
operator explicitly tags them.
2026-09-02 16:33:41 +08:00
yeuimu fb85a56444 docs(plan): add heat-transfer craft tag feature plan 2026-09-02 16:31:26 +08:00
yeuimu 5f81b18388 docs(agent): add materialized-array order determinism lesson 2026-09-02 16:08:35 +08:00
yeuimu f828cc0bd9 merge: fix price-matrix option sort (canonical display order) 2026-09-02 16:05:05 +08:00
17 changed files with 899 additions and 22 deletions
+8
View File
@@ -103,3 +103,11 @@ Prisma Postgres 最佳实践: prisma-postgres 技能
- **跨套件分页断言要圈定夹具**:真实库上测"列表排序"时全库数据可能远超 pageSize,夹具根本进不了第一页;给夹具商品名加唯一前缀 + `keyword` 过滤圈定,断言既稳定又能看到完整顺序。
- **"绝对排序键 + 子集过滤"模式**:需要"任意筛选组合下顺序都正确"时,给每条数据算好一组绝对排序键(如 国家→二级→款→priority,缺失沉底),筛选只做子集过滤不做特殊排序分支——比每个筛选组合写一套 orderBy 逻辑可靠得多。
- **"代表行选取收敛为单一方法"**:多端点共享同一实体视图(如族化商品对外的代表行)时,代表行选取规则必须收敛为一个私有方法供所有端点复用,禁止各端点各自 `goods[0]`——否则排序参数不同会导致 goodName/主图等字段在列表与详情间漂移。
- **"物化数组的顺序必须有确定性来源"**:把聚合结果物化进 JSONB(如 price_matrix 的 printCounts/crafts/logistics)时,"Set 去重保首现序"不是顺序保证——源查询没有 orderBy 时 Postgres 不承诺返回序,同一数据两次重算顺序可能漂移。必须:① 聚合后按业务词表序稳定排序(词表外自由文本沉底);② 源查询显式 `orderBy` 固定遍历序。测试用"打乱输入顺序断言输出全等"覆盖顺序确定性。
- **"新增维度取值 = 标签行 + 两处词表常量,缺一不可"**:工艺/物流/印花数量这类封闭维度新增可选值时,只往 `tags` 表插行不够——矩阵维度词表 `DIM_VALUES` 和展示序 `OPTION_DISPLAY_ORDER` 是硬编码常量,必须同步追加,否则打了新标签的链接会被词表过滤导致价格格子静默消失(比报错更危险)。同时确认自动派生规则(auto-tag-rules / admin origin-name)是否要认识新值;存量数据零影响的前提是"只放开准入,不主动打标"。
- **测试夹具必须自包含**:断言库内"存在某类数据"的用例(如标签组过滤)在全新/一次性数据库上必挂;夹具自己种下断言所依赖的数据,不依赖共享库的既有状态。
- **严禁直接热修运行容器内的代码**:本次线上 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。
- **改"镜像字段"的数据前先查覆盖路径**:分类名每小时被 `syncCategories` 用 SDS 原名无条件覆盖——直接改库/界面改名都会被下一轮同步冲回。此类字段的清洗必须写进同步入库路径(如 `cleanCategoryDisplayName`),存量靠回填脚本;数据先改而镜像未重部署的窗口期内会被冲回一次,重部署后自动恢复。
+32
View File
@@ -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([
+27 -8
View File
@@ -27,27 +27,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<string, string> = {
+4
View File
@@ -24,6 +24,10 @@
"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",
"backfill:product-families": "ts-node prisma/backfill-product-families.ts",
"fix:good-names": "ts-node prisma/fix-pure-sku-good-names.ts",
"fix:good-names": "ts-node prisma/fix-pure-sku-good-names.ts",
"fix:category-names": "ts-node prisma/fix-category-parens.ts",
"organize": "ts-node prisma/backfill-product-families.ts",
"recompute:families": "ts-node prisma/recompute-all-families.ts"
},
+51
View File
@@ -0,0 +1,51 @@
/**
* 存量修复脚本(幂等):分类名去括号 —— 删除所有括号段(型号限定词/内容性括号,
* 整段删除)。分类名每小时被 SDS 同步覆盖,清洗已落在 syncCategories 入库路径;
* 本脚本用于部署后立即生效(不等下一轮同步)或修人工建的历史分类。
*
* 运行:
* pnpm --filter @inkreach/api fix:category-names # dry-run,只打印清单
* pnpm --filter @inkreach/api fix:category-names -- --apply # 实际写库
*/
import { PrismaService } from '../src/prisma/prisma.service';
import { cleanCategoryDisplayName } from '../src/sync/sync.service';
async function main() {
const apply = process.argv.includes('--apply');
const prisma = new PrismaService();
await prisma.onModuleInit();
const rows = await prisma.category.findMany({
select: { id: true, categoryName: true },
orderBy: { id: 'asc' },
});
const renames = rows
.map((row) => ({
id: row.id,
from: row.categoryName,
to: cleanCategoryDisplayName(row.categoryName) ?? row.categoryName,
}))
.filter((r) => r.from !== r.to);
if (apply && renames.length > 0) {
await prisma.$transaction(
renames.map((r) =>
prisma.category.update({
where: { id: r.id },
data: { categoryName: r.to },
}),
),
);
}
const mode = apply ? 'APPLIED' : 'DRY-RUN(未写库,加 --apply 执行)';
console.log(`[fix:category-names] ${mode} renamed=${renames.length}`);
for (const r of renames) {
console.log(` #${r.id} ${r.from} -> ${r.to}`);
}
await prisma.onModuleDestroy();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -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);
});
+223 -1
View File
@@ -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('backfillPureSkuGoodNamesdry-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({
+173 -4
View File
@@ -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<PrismaService, 'category'>,
rawName: string,
sdsCategoryId: string | null | undefined,
): Promise<string> {
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<string | null> {
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) },
@@ -1,6 +1,10 @@
import { Test } from '@nestjs/testing';
import { Prisma } from '@prisma/client';
import { FamilyRecomputeService, derivePriceMatrix } from './family-recompute.service';
import {
FamilyRecomputeService,
derivePriceMatrix,
memberMatrixCombos,
} from './family-recompute.service';
import { ProductFamiliesService } from './product-families.service';
import { OrganizeService } from './organize.service';
import { PrismaService } from '../prisma/prisma.service';
@@ -441,7 +445,7 @@ describe('derivePriceMatrix 选项组排序', () => {
it('幂等:同输入多跑两遍输出全等(含顺序),与成员遍历顺序无关', () => {
const members = [
mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] }),
mkMember(2, { source: 'CUSTOM', craftLabel: '丝印', logisticsLabel: '海运' }),
mkMember(2, { source: 'CUSTOM', tags: ['双面印花'], craftLabel: '丝印', logisticsLabel: '海运' }),
mkMember(3, { tags: ['烫画', '单面印花', '包邮'] }),
];
const first = derivePriceMatrix(members, []);
@@ -451,3 +455,47 @@ describe('derivePriceMatrix 选项组排序', () => {
expect(JSON.stringify(reversed)).toBe(JSON.stringify(first));
});
});
/**
* 印刷工艺新增"热转印":矩阵维度词表放开准入 + 展示序追加末尾。
* 自动派生规则不改 —— SDS 链接只能人工打标;不打标的链接/族零影响。
*/
describe('热转印工艺', () => {
const mkMember = (
id: number,
over: { tags?: string[]; source?: 'SDS' | 'CUSTOM'; craftLabel?: string | null; logisticsLabel?: string | null },
) =>
({
id: BigInt(id),
source: over.source ?? 'SDS',
originGoodTags: (over.tags ?? []).map((t) => ({ tag: { tagName: t } })),
variants: [],
craftLabel: over.craftLabel ?? null,
logisticsLabel: over.logisticsLabel ?? null,
}) as any;
it('memberMatrixCombos:打标热转印的链接产出矩阵组合(此前被词表过滤为空)', () => {
const combos = memberMatrixCombos({ tagNames: ['单面印花', '热转印', '包邮'] });
expect(combos).toEqual([{ printCount: '单面印花', craft: '热转印', logistics: '包邮' }]);
});
it('derivePriceMatrixcrafts 展示序为 烫画→直喷→不打印→热转印(词表内,不再沉底)', () => {
const members = [
mkMember(1, { tags: ['热转印', '单面印花', '包邮'] }),
mkMember(2, { tags: ['烫画', '单面印花', '包邮'] }),
mkMember(3, { tags: ['直喷', '单面印花', '不包邮'] }),
mkMember(4, { tags: ['不打印', '单面印花', '包邮'] }),
];
const m = derivePriceMatrix(members, []);
expect(m.crafts).toEqual(['烫画', '直喷', '不打印', '热转印']);
});
it('词表外自由文本(丝印/水洗)仍然沉底,与热转印区分', () => {
const members = [
mkMember(1, { tags: ['热转印', '单面印花', '包邮'] }),
mkMember(2, { source: 'CUSTOM', tags: ['单面印花'], craftLabel: '丝印', logisticsLabel: '包邮' }),
];
const m = derivePriceMatrix(members, []);
expect(m.crafts).toEqual(['热转印', '丝印']);
});
});
@@ -66,20 +66,20 @@ type OverrideRow = Prisma.FamilyPriceOverrideGetPayload<{}>;
/** 矩阵三个归因维度的合法取值(封闭集合,与派生标签组一致) */
const DIM_VALUES = {
printCount: ['单面印花', '双面印花'],
craft: ['烫画', '直喷', '不打印'],
craft: ['烫画', '直喷', '不打印', '热转印'],
logistics: ['包邮', '不包邮'],
} as const satisfies Record<string, readonly string[]>;
type DimKey = keyof typeof DIM_VALUES;
/**
* 选项组展示顺序(业务确认):printCount 单面→双面;craft 烫画→直喷→不打印;logistics 不包邮→包邮。
* 选项组展示顺序(业务确认):printCount 单面→双面;craft 烫画→直喷→不打印→热转印logistics 不包邮→包邮。
* 与 DIM_VALUES 的"组合生成序"是两回事,物化前对聚合数组按此稳定排序;
* 词表外自由文本(CUSTOM 标签,如"海运")沉底,相互间保持首次遇到序。
*/
const OPTION_DISPLAY_ORDER = {
printCount: ['单面印花', '双面印花'],
craft: ['烫画', '直喷', '不打印'],
craft: ['烫画', '直喷', '不打印', '热转印'],
logistics: ['不包邮', '包邮'],
} as const;
+46
View File
@@ -0,0 +1,46 @@
import { cleanCategoryDisplayName } from './sync.service';
describe('cleanCategoryDisplayName(分类名去括号,同步入库清洗)', () => {
it('删除型号限定词括号段(含名称后带空格的形态)', () => {
expect(cleanCategoryDisplayName('DG001 180G纯棉T恤 JSA002')).toBe(
'DG001 180G纯棉T恤',
);
expect(cleanCategoryDisplayName('DG004 230G水洗T恤(JSA003')).toBe(
'DG004 230G水洗T恤',
);
});
it('删除内容性括号段(整段删除)', () => {
expect(cleanCategoryDisplayName('YSM02 全棉斜纹拼色帽子(黑+卡其)')).toBe(
'YSM02 全棉斜纹拼色帽子',
);
expect(cleanCategoryDisplayName('下装(短裤 & 长裤)')).toBe('下装');
});
it('多个括号段一并删除并折叠空白', () => {
expect(cleanCategoryDisplayName('T恤(短袖)(男款)成人')).toBe('T恤 成人');
});
it('半角括号同样处理', () => {
expect(cleanCategoryDisplayName('DG001 180G纯棉T恤 (JSA002)')).toBe(
'DG001 180G纯棉T恤',
);
});
it('无括号名称原样返回', () => {
expect(cleanCategoryDisplayName('PLTK016 童装纯色插肩短袖T恤')).toBe(
'PLTK016 童装纯色插肩短袖T恤',
);
});
it('剥空守卫:名称仅剩括号段时原样返回', () => {
expect(cleanCategoryDisplayName('JSA002')).toBe('JSA002');
expect(cleanCategoryDisplayName('(短裤 & 长裤)')).toBe('(短裤 & 长裤)');
});
it('空/null 输入容错', () => {
expect(cleanCategoryDisplayName('')).toBe('');
expect(cleanCategoryDisplayName(null)).toBe(null);
expect(cleanCategoryDisplayName(undefined)).toBe(undefined);
});
});
+20 -2
View File
@@ -24,6 +24,24 @@ export interface CategorySyncResult {
deletedStale: number;
}
/** 全角/半角括号段(非嵌套),分类名清洗用 */
const PAREN_GROUP = /[^()()]*|\([^()()]*\)/g;
/**
* 分类展示名清洗:删除所有括号段(型号限定词如 `(JSA002)`、内容性括号如
* `(黑+卡其)`/`(短裤 & 长裤)`,整段删除),折叠多余空白。剥空(名称仅剩
* 括号段)时原样返回。分类名每小时被 SDS 同步覆盖,清洗必须在此入库路径生效
* 才能持久;款号 token(如 `DG001`)不受影响。
*/
export function cleanCategoryDisplayName(name: string | null | undefined): string | null | undefined {
if (!name) return name;
let cleaned = name.replace(PAREN_GROUP, ' ');
// 嵌套/残缺括号剥除后可能留下空括号对
cleaned = cleaned.replace(/\s*|\(\s*\)/g, ' ');
cleaned = cleaned.replace(/\s+/g, ' ').trim();
return cleaned || name;
}
export interface ProductSyncResult {
inserted: number;
updated: number;
@@ -185,7 +203,7 @@ export class SyncService {
await tx.category.create({
data: {
sdsCategoryId: node.sdsId,
categoryName: node.name,
categoryName: cleanCategoryDisplayName(node.name) ?? node.name,
categoryIcon: node.icon ?? null,
},
});
@@ -194,7 +212,7 @@ export class SyncService {
await tx.category.update({
where: { id: existing.id },
data: {
categoryName: node.name,
categoryName: cleanCategoryDisplayName(node.name) ?? node.name,
categoryIcon: node.icon ?? null,
},
});
+26 -1
View File
@@ -207,7 +207,32 @@ 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`)。
**分类名去括号(2026-09**`categories.category_name` 不再保留任何括号段——
型号限定词(`DG001 180G纯棉T恤(JSA002`)与内容性括号(`下装(短裤 & 长裤)`
**整段删除**`YSM02 全棉斜纹拼色帽子(黑+卡其)``YSM02 全棉斜纹拼色帽子`)。
分类名每小时被 SDS 同步覆盖,清洗在 `syncCategories` 入库路径生效
`sync.service.ts#cleanCategoryDisplayName`,款号 token 如 `DG001` 保留);存量
修复脚本 `pnpm --filter @inkreach/api fix:category-names`dry-run 默认,
`-- --apply` 写库,幂等,覆盖人工建的历史分类)。auto-group 族名、纯款号补描述
describePureSkuGoodName)均以分类名为源,自动随之变干净。
+8 -1
View File
@@ -47,7 +47,9 @@ 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 默认)
│ └── fix-category-parens.ts # 分类名去括号存量修复脚本(整段删除括号段,幂等,dry-run 默认)
├── src/
│ ├── main.ts # 入口:CORS、ValidationPipe、Swagger、BigInt JSON 序列化
│ ├── app.module.ts # 根模块,聚合所有业务模块
@@ -59,8 +61,13 @@ apps/api/
│ ├── tag-groups/ # 标签分组 CRUD(受 JWT 保护,含批量排序)
│ ├── positions/ # 坑位 CRUD(受 JWT 保护)
│ ├── origin-goods/ # SDS 原始商品快照(只读分页 + 配置状态树 + 链接级标签人工接管)
<<<<<<< HEAD
│ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group / 成员管理 / 自定义成员 / 价格覆盖 / 重算 / 按链接名称派生标签(auto-tag-rules
│ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建
=======
│ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group(并入已有族优先,familyNameKey 族语义键)/ attachToMatchingFamily(单链接自动归族)/ consolidateFragments(碎片族合并)/ 成员管理 / 自定义成员 / 价格覆盖 / 重算 / 按链接名称派生标签(auto-tag-rules,含 热转印→烫画 别名)
│ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建 + 展示名规范化(品名 SKU,剥 ASCII 型号限定词,纯款号按 SDS 分类名补描述)
>>>>>>> c3be8ab (fix(goods): 商品展示名统一「品名 SKU」——剥 ASCII 型号限定词、纯款号补描述)
│ ├── sync/ # SDS 同步:分类 / 商品 / 同步日志
│ ├── public/ # 公开 API:分类树 / 国家 / 商品分页 / 商品详情
│ └── common/ # 全局装饰器 / 过滤器 / 拦截器
@@ -0,0 +1,49 @@
# Feature:印刷工艺新增"热转印"选项
日期:2026-09-02
类型:新功能(feature
影响面:`apps/api/src/product-families/family-recompute.service.ts`(两处词表常量)+ 测试 + `tags` 表新增一行
## 背景
业务需要新增印刷工艺"热转印"。标签由 `tags` 表驱动(`GET /tags` 全量返回,管理端打标用),但价格矩阵的维度词表在后端硬编码,需同步放开准入。
## 现状(2026-09-02 分析确认)
- `tags` 表:印刷工艺组(tag_group_id=3)现有 烫画(1)/直喷(2)/不打印(3),有 `POST /tags` 接口(JwtAuthGuard,重名 409
- 矩阵维度词表 [DIM_VALUES.craft](file:///opt/inkreach-v2/apps/api/src/product-families/family-recompute.service.ts) = `['烫画','直喷','不打印']` 硬编码,`memberMatrixCombos` 按它过滤链接标签——词表外的标签会导致链接不进矩阵
- 展示序词表 `OPTION_DISPLAY_ORDER.craft` 同样硬编码,词表外沉底
- 名称自动派生规则(`auto-tag-rules.ts` / admin `origin-name.ts`)**本次不改**:SDS 链接不会自动派生热转印,只能人工打标
## 方案
1. `DIM_VALUES.craft` 追加 `'热转印'`(末尾)——矩阵认可该工艺标签,SDS/CUSTOM 链接人工打标后均可进矩阵
2. `OPTION_DISPLAY_ORDER.craft` 追加 `'热转印'`(末尾)——按钮组显示在 不打印 之后(业务确认排序表先不改,放最后)
3. `POST /tags { tagName: '热转印', tagGroupId: '3', sortOrder: 4 }` 创建标签行(管理端标签栏立即可见/可选)
4. **不给任何存量链接打标**——纯增量能力,存量族矩阵零变化
## 明确不做
- 不改自动派生规则(名称含"热转印"仍派生默认烫画,如需后续再议)
- 不给存量链接打标、不重算存量族
- 不动 自建商品 craftLabel 维度填错的历史数据
## 测试计划(TDD
测试文件:`family-recompute.service.spec.ts`memberMatrixCombos / derivePriceMatrix 纯函数)
1. `memberMatrixCombos`:链接标签 [单面印花, 热转印, 包邮] → 产出 `{ printCount:'单面印花', craft:'热转印', logistics:'包邮' }`(修复前被词表过滤 → combos 为空,RED
2. `derivePriceMatrix`crafts 输出 `烫画→直喷→不打印→热转印`(热转印作为词表内值参与排序,不再沉底)
3. 既有"未知自由文本沉底"用例(丝印/水洗)仍应沉底——热转印是词表内,丝印/水洗仍是词表外
4. 全量 jest + tsc 通过
## 线上验证
1. `GET /tags` 返回 热转印(tagGroupId=3, sortOrder=4
2. 抽查存量族 priceMatrix.crafts 与改动前一致(无新增值)
3. 无链接打标 → 公开详情 crafts 不含 热转印
## 回滚
- 代码:revert 两行常量(未打标状态零影响)
- 标签:无引用可直接删(`origin_good_tags` 无该行)
+60
View File
@@ -0,0 +1,60 @@
# 分类名去括号(DG001 180G纯棉T恤(JSA002 → DG001 180G纯棉T恤)
- 分支:`bugfix/category-name-parens-yeuimu`(自 `develop`
- 日期:2026-09-03
- 类型:Bug 修复 / 数据修复
- 前置:`pure-sku-good-name-fix.md`(商品名已统一「品名 SKU」,goods 表已无括号)
## 问题
后台左侧分类树(admin 左栏 + 官网分类树/商品卡共用)部分分类名带括号,两栈各
33 条,分两类:
- 型号限定词:`DG001 180G纯棉T恤 JSA002`
- 内容性括号:`YSM02 全棉斜纹拼色帽子(黑+卡其)``下装(短裤 & 长裤)`
用户要求:括号段**整段删除**。
## 关键约束
`categories.category_name` 是每小时 cron`syncCategories`)从 SDS 分类树**强制覆盖**
的镜像(update 分支无条件写 `node.name`)——只改库会被下一轮同步冲回。清洗必须
落在**同步入库路径**才能持久;镜像其余字段(icon/树结构)行为不变。
## 实现
### 1. 同步入库清洗(apps/api/src/sync/sync.service.ts
- [x] 导出纯函数 `cleanCategoryDisplayName(name)`:删除所有 `(…)`/`(…)`
括号段(含半角),折叠多余空白;剥空(名称仅剩括号段)时原样返回。
- [x] `syncCategories` upsert 的 create/update 分支均写清洗后的名称
(SDS 原文不再落库;款号 token `DG001` 保留,codeFromCategoryName 不受影响)。
### 2. 存量回填脚本
- [x] `apps/api/prisma/fix-category-parens.ts` + npm script `fix:category-names`
(默认 dry-run 打印 旧名 → 新名,`-- --apply` 写库,幂等;覆盖 SDS 与人工
建的分类)。
### 3. 测试(TDD
- [x] `src/sync/category-name.spec.ts`:型号限定词/内容性括号/多括号段/半角/
剥空守卫/无括号原样/空白折叠。
- [x] 全量 api 测试零回归(一次性 docker postgres)。
## 落库(2026-09-03 执行)
- [x] V1/V2 各 33 条分类名去括号,复跑 dry-run 0 条。
- 注意:**api 容器重建部署前**,旧镜像的每小时同步会把名称写回带括号原文;
部署带清洗逻辑的新镜像后,下一轮同步自动恢复干净(无需再跑脚本)。
## 已知影响
- `下装(短裤 & 长裤)` 两行同名分类清洗后均为 `下装`(清洗前已是同文重复,
树中原即并列两行,视觉不变差)。
- `configure:product-center-icons` 脚本按中文名幂等更新图标:其词表若含带括号
名称将匹配不到已清洗行,需要时更新词表(本次核查无带括号配置项受影响)。
## 权限评估
无新增端点/操作;CLI 脚本为运维通道 —— 无需新增权限项。
+77
View File
@@ -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)人工命名不受影响。
## 实现
### adminapps/admin/src/utils/origin-name.ts
- `parseLinkName` 对齐 api 端:首括号对解析 + 半/全角归一;无 SKU 段时仅当
括号后品名含中文才视为链接名(防「品名(限定词) SKU」误解析)。
- 新增 `stripAsciiParenQualifiers``cleanLinkName` 展示统一剥离 ASCII 型号限定词。
### apiapps/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 脚本为
运维通道 —— 无需新增权限项。