左栏商品名显示成光款号(如 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
125 lines
5.4 KiB
TypeScript
125 lines
5.4 KiB
TypeScript
/**
|
||
* 原产品名匹配规则(见 data-cleaning/README.md 命名调查):
|
||
* `国家(物流备注)品名-SKU-工艺位置[-仓库名]`
|
||
* 截断到「工艺位置」= 保留按 `-` 分段的前 3 段,丢弃可选的仓库名段。
|
||
* 国家前缀在第 1 段内,天然参与比较(不同国家不合并)。
|
||
*/
|
||
const KEEP_SEGMENTS = 3;
|
||
|
||
export function truncateToProcess(name: string | null | undefined): string {
|
||
if (!name) return '';
|
||
return name.split('-').slice(0, KEEP_SEGMENTS).join('-');
|
||
}
|
||
|
||
export function sameOriginGroup(
|
||
a: string | null | undefined,
|
||
b: string | null | undefined,
|
||
): boolean {
|
||
const ka = truncateToProcess(a);
|
||
return ka !== '' && ka === truncateToProcess(b);
|
||
}
|
||
|
||
/** 链接名结构化解析结果:品名 + 型号(SKU 代码) */
|
||
export interface ParsedLinkName {
|
||
productName: string;
|
||
skuCode: string | null;
|
||
}
|
||
|
||
/**
|
||
* 解析链接名称:`国家(物流备注)品名-SKU-工艺位置[-·仓库名]` → 品名 + 型号。
|
||
* 与 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 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 };
|
||
}
|
||
|
||
/** 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);
|
||
const base = parsed
|
||
? parsed.skuCode
|
||
? `${parsed.productName} ${parsed.skuCode}`
|
||
: parsed.productName
|
||
: name;
|
||
return stripAsciiParenQualifiers(base);
|
||
}
|
||
|
||
const CRAFT_KEYWORD_MAP: Record<string, string> = {
|
||
直喷: '直喷',
|
||
不打印: '不打印',
|
||
光板: '不打印', // 光板即为不打印
|
||
};
|
||
|
||
/** 链接的定价维度(SKU 表展示用):物流备注 / 工艺 / 印花数量 */
|
||
export interface LinkDims {
|
||
logistics: string | null;
|
||
crafts: string[];
|
||
printCount: string | null;
|
||
}
|
||
|
||
/** 从链接名称解析定价维度;工艺无命中时默认烫画(与派生标签规则一致) */
|
||
export function linkDims(name: string | null | undefined): LinkDims {
|
||
if (!name) return { logistics: null, crafts: [], printCount: null };
|
||
const head = name.split('-')[0] ?? '';
|
||
const openCandidates = [head.indexOf('('), head.indexOf('(')].filter((i) => i >= 0);
|
||
const openIdx = openCandidates.length ? Math.min(...openCandidates) : -1;
|
||
const closeIdx = Math.max(head.lastIndexOf(')'), head.lastIndexOf(')'));
|
||
const logistics =
|
||
openIdx >= 0 && closeIdx > openIdx ? head.slice(openIdx + 1, closeIdx).trim() || null : null;
|
||
const craftHits = [...new Set(Object.entries(CRAFT_KEYWORD_MAP).filter(([k]) => name.includes(k)).map(([, tag]) => tag))];
|
||
// 与 api 端一致:裸「双面/单面」也归入印花数量(如「直喷双面」)
|
||
const printCount = name.includes('双面')
|
||
? '双面印花'
|
||
: name.includes('单面')
|
||
? '单面印花'
|
||
: null;
|
||
return {
|
||
logistics,
|
||
crafts: craftHits.length > 0 ? craftHits : ['烫画'],
|
||
printCount,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 由链接名称派生标签名(与 api 端 auto-tag-rules.ts 规则一致,仅用于成员行只读展示):
|
||
* 印花数量:双面 优先于 单面(含「直喷双面」这类工艺段写法);工艺:直喷/不打印/光板→不打印,皆无则默认烫画;
|
||
* 物流:不包邮 优先于 包邮。
|
||
*/
|
||
export function deriveLinkTagNames(name: string | null | undefined): string[] {
|
||
if (!name) return [];
|
||
const names: string[] = [];
|
||
if (name.includes('双面')) names.push('双面印花');
|
||
else if (name.includes('单面')) names.push('单面印花');
|
||
const craftHits = [...new Set(Object.entries(CRAFT_KEYWORD_MAP).filter(([k]) => name.includes(k)).map(([, tag]) => tag))];
|
||
if (craftHits.length > 0) names.push(...craftHits);
|
||
else names.push('烫画');
|
||
if (name.includes('不包邮')) names.push('不包邮');
|
||
else if (name.includes('包邮')) names.push('包邮');
|
||
return names;
|
||
}
|