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
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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<string, string> = {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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) },
|
||||
|
||||
Reference in New Issue
Block a user