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:
yeuimu
2026-09-03 01:27:26 +08:00
parent b81d944c38
commit 9eafac7204
10 changed files with 605 additions and 15 deletions
+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) },