feat(product-family): derive tags per link name (印花数量/工艺/物流 rules)
This commit is contained in:
@@ -13,6 +13,7 @@ import { BatchPriorityDto } from './dto/batch-priority.dto';
|
|||||||
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
|
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
|
||||||
import { SyncService } from '../sync/sync.service';
|
import { SyncService } from '../sync/sync.service';
|
||||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||||
|
import { isAutoTagGroupName } from '../product-families/auto-tag-rules';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import {
|
import {
|
||||||
CreateCustomGoodDto,
|
CreateCustomGoodDto,
|
||||||
@@ -619,24 +620,18 @@ export class GoodsService {
|
|||||||
if (!c) throw new BadRequestException(`Category ${id} not found`);
|
if (!c) throw new BadRequestException(`Category ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 有族商品:剔除自动组(物流/工艺/位置)标签——它们由族同步管理 */
|
/** 有族商品:剔除自动组(物流/工艺/印花数量等)标签——它们由族按链接名称派生 */
|
||||||
private async stripAutoGroupTags(
|
private async stripAutoGroupTags(
|
||||||
tagIds: number[],
|
tagIds: number[],
|
||||||
familyId: bigint | null,
|
familyId: bigint | null,
|
||||||
): Promise<number[]> {
|
): Promise<number[]> {
|
||||||
if (!familyId || !tagIds.length) return tagIds;
|
if (!familyId || !tagIds.length) return tagIds;
|
||||||
const autoGroups = await this.prisma.tagGroup.findMany({
|
const groups = await this.prisma.tagGroup.findMany({
|
||||||
where: {
|
select: { id: true, groupName: true },
|
||||||
OR: [
|
|
||||||
{ groupName: { contains: '物流' } },
|
|
||||||
{ groupName: { contains: '工艺' } },
|
|
||||||
{ groupName: { contains: '位置' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
});
|
||||||
if (!autoGroups.length) return tagIds;
|
const autoIds = new Set(
|
||||||
const autoIds = new Set(autoGroups.map((g) => g.id.toString()));
|
groups.filter((g) => isAutoTagGroupName(g.groupName)).map((g) => g.id.toString()),
|
||||||
|
);
|
||||||
const tags = await this.prisma.tag.findMany({
|
const tags = await this.prisma.tag.findMany({
|
||||||
where: { id: { in: tagIds.map((id) => BigInt(id)) } },
|
where: { id: { in: tagIds.map((id) => BigInt(id)) } },
|
||||||
select: { id: true, tagGroupId: true },
|
select: { id: true, tagGroupId: true },
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { deriveLinkTagNames, isAutoTagGroupName } from './auto-tag-rules';
|
||||||
|
|
||||||
|
describe('auto-tag-rules / deriveLinkTagNames', () => {
|
||||||
|
it('单面印花 + 包邮 + 无工艺关键字 → 单面印花/烫画/包邮', () => {
|
||||||
|
expect(
|
||||||
|
deriveLinkTagNames('美国(包邮)180g纯棉T恤成人款-DG001-单面印花'),
|
||||||
|
).toEqual(['单面印花', '烫画', '包邮']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('双面印花 + 不包邮 → 双面印花/烫画/不包邮', () => {
|
||||||
|
expect(
|
||||||
|
deriveLinkTagNames('美国(不包邮)180g纯棉T恤成人款-DG001-双面印花·美东新泽西仓'),
|
||||||
|
).toEqual(['双面印花', '烫画', '不包邮']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不打印 + 光板(物流备注)+ 不包邮 → 两个工艺标签 + 不包邮,无印花数量标签', () => {
|
||||||
|
expect(
|
||||||
|
deriveLinkTagNames('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'),
|
||||||
|
).toEqual(['不打印', '光板', '不包邮']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('直喷命中时不给默认烫画', () => {
|
||||||
|
expect(deriveLinkTagNames('美国(包邮)卫衣-DG002-直喷')).toEqual(['直喷', '包邮']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('物流备注既无包邮也无不包邮时不下发物流标签', () => {
|
||||||
|
const names = deriveLinkTagNames('美国(快递)卫衣-DG002-单面印花');
|
||||||
|
expect(names).toContain('单面印花');
|
||||||
|
expect(names).toContain('烫画');
|
||||||
|
expect(names).not.toContain('包邮');
|
||||||
|
expect(names).not.toContain('不包邮');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空名称返回空数组', () => {
|
||||||
|
expect(deriveLinkTagNames(null)).toEqual([]);
|
||||||
|
expect(deriveLinkTagNames('')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('先判「不包邮」再判「包邮」,避免子串误命中', () => {
|
||||||
|
const names = deriveLinkTagNames('美国(不包邮)T恤-DG001-单面印花');
|
||||||
|
expect(names).toContain('不包邮');
|
||||||
|
expect(names).not.toContain('包邮');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('auto-tag-rules / isAutoTagGroupName', () => {
|
||||||
|
it('命中真实派生组:物流渠道 / 印刷工艺 / 印花数量 / 印刷位置', () => {
|
||||||
|
expect(isAutoTagGroupName('物流渠道')).toBe(true);
|
||||||
|
expect(isAutoTagGroupName('印刷工艺')).toBe(true);
|
||||||
|
expect(isAutoTagGroupName('印花数量')).toBe(true);
|
||||||
|
expect(isAutoTagGroupName('印刷位置')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('其他分组不视为自动组', () => {
|
||||||
|
expect(isAutoTagGroupName('潮流')).toBe(false);
|
||||||
|
expect(isAutoTagGroupName('场景')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* 链接标签自动派生规则(产品确认版,2026-08-28):
|
||||||
|
*
|
||||||
|
* 标签与「产品链接」一一对应 —— 每条链接因 印花数量/工艺/物流 不同而价格不同
|
||||||
|
* (价格矩阵维度 = 印花数量·工艺 × 物流 × 尺码 × 颜色),因此标签按链接名称解析:
|
||||||
|
*
|
||||||
|
* - 印花数量:名称含「双面印花」→ 双面印花;否则含「单面印花」→ 单面印花;
|
||||||
|
* - 工艺:名称含「直喷」「不打印」「光板」→ 对应标签(可多个);都不含 → 默认「烫画」;
|
||||||
|
* - 物流:名称含「不包邮」→ 不包邮;否则含「包邮」→ 包邮(先判「不包邮」,避免子串误命中)。
|
||||||
|
*
|
||||||
|
* 这些分组(印花数量 / 印刷工艺 / 物流渠道)下的标签为派生数据,不接受手动写入;
|
||||||
|
* 其他分组保持人工管理。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 自动(派生)标签组名匹配:命中即视为族托管,手输的这类标签会被剔除 */
|
||||||
|
export function isAutoTagGroupName(name: string): boolean {
|
||||||
|
return /物流|工艺|位置|印花数量/.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DerivedTagGroupSpec {
|
||||||
|
/** 标签组名(查找用 includes 匹配,缺失时自动建组) */
|
||||||
|
group: string;
|
||||||
|
/** 组内规则标签(缺失时自动建标) */
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 派生标签所属组及其全部合法取值 */
|
||||||
|
export const DERIVED_TAG_GROUP_SPECS: DerivedTagGroupSpec[] = [
|
||||||
|
{ group: '物流渠道', tags: ['包邮', '不包邮'] },
|
||||||
|
{ group: '印花数量', tags: ['单面印花', '双面印花'] },
|
||||||
|
{ group: '印刷工艺', tags: ['烫画', '直喷', '不打印', '光板'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CRAFT_KEYWORDS = ['直喷', '不打印', '光板'] as const;
|
||||||
|
const CRAFT_DEFAULT = '烫画';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由链接名称解析标签名集合(不含组信息)。
|
||||||
|
* 确定性、纯函数 —— 同名链接在任何环境派生结果一致。
|
||||||
|
*/
|
||||||
|
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 = CRAFT_KEYWORDS.filter((keyword) => name.includes(keyword));
|
||||||
|
if (craftHits.length > 0) names.push(...craftHits);
|
||||||
|
else names.push(CRAFT_DEFAULT);
|
||||||
|
|
||||||
|
if (name.includes('不包邮')) names.push('不包邮');
|
||||||
|
else if (name.includes('包邮')) names.push('包邮');
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
DERIVED_TAG_GROUP_SPECS,
|
||||||
|
deriveLinkTagNames,
|
||||||
|
isAutoTagGroupName,
|
||||||
|
} from './auto-tag-rules';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 产品族重算:并集尺码表/包装规则 + 五维价格矩阵物化。
|
* 产品族重算:并集尺码表/包装规则 + 五维价格矩阵物化。
|
||||||
@@ -291,73 +296,45 @@ export class FamilyRecomputeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 派生标签同步(无论是否锁定:标签是派生数据而非人工策展)
|
// 派生标签同步(无论是否锁定:标签是派生数据而非人工策展)
|
||||||
await this.syncFamilyTags(family.id, members);
|
await this.syncFamilyTags(family.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 族 → 商品标签同步:物流/工艺/印刷位置类标签组(组名含「物流」「工艺」或「位置」,
|
* 族 → 商品标签同步:标签与「产品链接」一一对应,按每条链接自身的名称解析
|
||||||
* 与官网筛选维度一致)下的标签由族成员的 logisticsLabel/craftLabel 派生;
|
* (印花数量 / 工艺 / 物流,规则见 auto-tag-rules.ts)。派生组缺失的组/标签
|
||||||
* 标签名与链接标签存在词尾差异(单面印花 ↔ 单面印),采用前缀匹配并取最长命中。
|
* 自动补建;仅更新发生变化的商品,幂等。
|
||||||
* 其他分组保持人工管理。仅更新发生变化的商品,幂等。
|
|
||||||
*/
|
*/
|
||||||
async syncFamilyTags(
|
async syncFamilyTags(familyId: bigint): Promise<{ goodsUpdated: number }> {
|
||||||
familyId: bigint,
|
const tagMap = await this.ensureDerivedTagMap();
|
||||||
preloadedMembers?: Member[],
|
|
||||||
): Promise<{ goodsUpdated: number }> {
|
|
||||||
const members =
|
|
||||||
preloadedMembers ??
|
|
||||||
(await this.prisma.originGood.findMany({
|
|
||||||
where: { familyId, delisted: false },
|
|
||||||
select: { logisticsLabel: true, craftLabel: true },
|
|
||||||
}));
|
|
||||||
const labels = members
|
|
||||||
.flatMap((m) => [m.logisticsLabel, m.craftLabel])
|
|
||||||
.filter((l): l is string => !!l);
|
|
||||||
|
|
||||||
const autoGroups = await this.prisma.tagGroup.findMany({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ groupName: { contains: '物流' } },
|
|
||||||
{ groupName: { contains: '工艺' } },
|
|
||||||
{ groupName: { contains: '位置' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
const autoGroupIds = new Set(autoGroups.map((g) => g.id.toString()));
|
|
||||||
if (!autoGroupIds.size || !labels.length) {
|
|
||||||
return { goodsUpdated: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidates = await this.prisma.tag.findMany({
|
|
||||||
where: { tagGroupId: { in: autoGroups.map((g) => g.id) } },
|
|
||||||
select: { id: true, tagName: true },
|
|
||||||
});
|
|
||||||
// 每个链接标签取「最长前缀命中」的标签(避免短名误吃长名场景)
|
|
||||||
const derivedIds = new Set<bigint>();
|
|
||||||
for (const label of new Set(labels)) {
|
|
||||||
let best: { id: bigint; name: string } | null = null;
|
|
||||||
for (const tag of candidates) {
|
|
||||||
if (label === tag.tagName || label.startsWith(tag.tagName)) {
|
|
||||||
if (!best || tag.tagName.length > best.name.length) {
|
|
||||||
best = { id: tag.id, name: tag.tagName };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (best) derivedIds.add(best.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const goods = await this.prisma.good.findMany({
|
const goods = await this.prisma.good.findMany({
|
||||||
where: { familyId },
|
where: { familyId },
|
||||||
include: { goodTags: { include: { tag: { select: { id: true, tagGroupId: true } } } } },
|
include: {
|
||||||
|
goodTags: { include: { tag: { select: { id: true, tagGroupId: true } } } },
|
||||||
|
originGood: { select: { goodName: true, source: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
if (!goods.length) return { goodsUpdated: 0 };
|
||||||
|
|
||||||
|
const groups = await this.prisma.tagGroup.findMany({
|
||||||
|
select: { id: true, groupName: true },
|
||||||
|
});
|
||||||
|
const autoGroupIds = new Set(
|
||||||
|
groups.filter((g) => isAutoTagGroupName(g.groupName)).map((g) => g.id.toString()),
|
||||||
|
);
|
||||||
|
|
||||||
let goodsUpdated = 0;
|
let goodsUpdated = 0;
|
||||||
for (const good of goods) {
|
for (const good of goods) {
|
||||||
// 保留:非自动分组的既有标签(人工管理)∪ 派生标签
|
// 保留:非自动分组的既有标签(人工管理)∪ 本链接名称的派生标签
|
||||||
const keep = good.goodTags
|
const keep = good.goodTags
|
||||||
.filter((gt) => !autoGroupIds.has(gt.tag.tagGroupId?.toString() ?? ''))
|
.filter((gt) => !autoGroupIds.has(gt.tag.tagGroupId?.toString() ?? ''))
|
||||||
.map((gt) => gt.tagId);
|
.map((gt) => gt.tagId);
|
||||||
|
const derivedNames =
|
||||||
|
good.originGood && good.originGood.source === 'SDS'
|
||||||
|
? deriveLinkTagNames(good.originGood.goodName)
|
||||||
|
: [];
|
||||||
|
const derivedIds = derivedNames
|
||||||
|
.map((name) => tagMap.get(name))
|
||||||
|
.filter((id): id is bigint => id !== undefined);
|
||||||
const target = [...new Set([...keep, ...derivedIds])].sort((a, b) =>
|
const target = [...new Set([...keep, ...derivedIds])].sort((a, b) =>
|
||||||
Number(a - b),
|
Number(a - b),
|
||||||
);
|
);
|
||||||
@@ -379,4 +356,50 @@ export class FamilyRecomputeService {
|
|||||||
}
|
}
|
||||||
return { goodsUpdated };
|
return { goodsUpdated };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 确保派生标签组与标签存在,返回「标签名 → 标签 id」映射(并发下取最小 id,天然去重) */
|
||||||
|
private async ensureDerivedTagMap(): Promise<Map<string, bigint>> {
|
||||||
|
const map = new Map<string, bigint>();
|
||||||
|
for (const spec of DERIVED_TAG_GROUP_SPECS) {
|
||||||
|
const findGroups = () =>
|
||||||
|
this.prisma.tagGroup.findMany({
|
||||||
|
where: { groupName: { contains: spec.group } },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
let group = (await findGroups())[0] ?? null;
|
||||||
|
if (!group) {
|
||||||
|
try {
|
||||||
|
group = await this.prisma.tagGroup.create({ data: { groupName: spec.group } });
|
||||||
|
} catch {
|
||||||
|
group = (await findGroups())[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!group) continue;
|
||||||
|
|
||||||
|
const tags = await this.prisma.tag.findMany({
|
||||||
|
where: { tagGroupId: group.id, tagName: { in: spec.tags } },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
for (const tagName of spec.tags) {
|
||||||
|
const existing = tags.find((t) => t.tagName === tagName);
|
||||||
|
if (existing) {
|
||||||
|
map.set(tagName, existing.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.tag.create({
|
||||||
|
data: { tagGroupId: group.id, tagName },
|
||||||
|
});
|
||||||
|
map.set(tagName, created.id);
|
||||||
|
} catch {
|
||||||
|
const fallback = await this.prisma.tag.findFirst({
|
||||||
|
where: { tagGroupId: group.id, tagName },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
if (fallback) map.set(tagName, fallback.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { Prisma } from '@prisma/client';
|
|
||||||
import { FamilyRecomputeService } from './family-recompute.service';
|
import { FamilyRecomputeService } from './family-recompute.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
/** 族 → 商品标签自动同步(物流/工艺组由成员标签派生,其他分组人工保留) */
|
/** 族 → 商品标签自动同步:标签按每条链接自身名称派生(印花数量/工艺/物流) */
|
||||||
describe('FamilyRecomputeService.syncFamilyTags', () => {
|
describe('FamilyRecomputeService.syncFamilyTags(按链接派生)', () => {
|
||||||
let service: FamilyRecomputeService;
|
let service: FamilyRecomputeService;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
@@ -15,7 +14,6 @@ describe('FamilyRecomputeService.syncFamilyTags', () => {
|
|||||||
country: [] as bigint[],
|
country: [] as bigint[],
|
||||||
category: [] as bigint[],
|
category: [] as bigint[],
|
||||||
group: [] as bigint[],
|
group: [] as bigint[],
|
||||||
otherGroup: [] as bigint[],
|
|
||||||
tag: [] as bigint[],
|
tag: [] as bigint[],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,47 +27,54 @@ describe('FamilyRecomputeService.syncFamilyTags', () => {
|
|||||||
|
|
||||||
ids.country.push((await prisma.country.create({ data: { countryName: `标签同步国家-${stamp}` } })).id);
|
ids.country.push((await prisma.country.create({ data: { countryName: `标签同步国家-${stamp}` } })).id);
|
||||||
ids.category.push((await prisma.category.create({ data: { categoryName: `标签同步分类-${stamp}` } })).id);
|
ids.category.push((await prisma.category.create({ data: { categoryName: `标签同步分类-${stamp}` } })).id);
|
||||||
// 物流组(自动)+ 其他组(人工)
|
// 人工分组(派生规则之外,标签应保留)
|
||||||
ids.group.push((await prisma.tagGroup.create({ data: { groupName: `物流渠道${stamp}`, sortOrder: 1 } })).id);
|
ids.group.push((await prisma.tagGroup.create({ data: { groupName: `风格${stamp}`, sortOrder: 9 } })).id);
|
||||||
ids.otherGroup.push((await prisma.tagGroup.create({ data: { groupName: `风格${stamp}`, sortOrder: 2 } })).id);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await prisma.goodTag.deleteMany({ where: { tagId: { in: ids.tag } } });
|
await prisma.goodTag.deleteMany({ where: { goodId: { in: ids.good } } });
|
||||||
await prisma.good.deleteMany({ where: { id: { in: ids.good } } });
|
await prisma.good.deleteMany({ where: { id: { in: ids.good } } });
|
||||||
await prisma.originGood.deleteMany({ where: { id: { in: ids.originGood } } });
|
await prisma.originGood.deleteMany({ where: { id: { in: ids.originGood } } });
|
||||||
await prisma.productFamily.deleteMany({ where: { id: { in: ids.family } } });
|
await prisma.productFamily.deleteMany({ where: { id: { in: ids.family } } });
|
||||||
await prisma.tag.deleteMany({ where: { id: { in: ids.tag } } });
|
await prisma.tag.deleteMany({ where: { id: { in: ids.tag } } });
|
||||||
await prisma.tagGroup.deleteMany({ where: { id: { in: [...ids.group, ...ids.otherGroup] } } });
|
await prisma.tagGroup.deleteMany({ where: { id: { in: ids.group } } });
|
||||||
await prisma.country.deleteMany({ where: { id: { in: ids.country } } });
|
await prisma.country.deleteMany({ where: { id: { in: ids.country } } });
|
||||||
await prisma.category.deleteMany({ where: { id: { in: ids.category } } });
|
await prisma.category.deleteMany({ where: { id: { in: ids.category } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
function mkTag(name: string, groupId: bigint) {
|
async function tagNames(goodId: bigint): Promise<string[]> {
|
||||||
return prisma.tag.create({ data: { tagName: name, tagGroupId: groupId } });
|
const rows = await prisma.goodTag.findMany({
|
||||||
|
where: { goodId },
|
||||||
|
include: { tag: true },
|
||||||
|
});
|
||||||
|
return rows.map((r) => r.tag.tagName);
|
||||||
}
|
}
|
||||||
|
|
||||||
it('族成员标签 → 自动同步到族下商品;人工分组标签保留;成员变化后增量更新', async () => {
|
it('每个商品的标签只来自自己的链接名称;旧自动组标签被剔除;人工分组保留', async () => {
|
||||||
const tagBaoyou = await mkTag(`包邮${stamp}`, ids.group[0]);
|
const styleTag = await prisma.tag.create({ data: { tagName: `潮流${stamp}`, tagGroupId: ids.group[0] } });
|
||||||
const tagDanyin = await mkTag(`单面印花${stamp}`, ids.group[0]);
|
ids.tag.push(styleTag.id);
|
||||||
const tagStyle = await mkTag(`潮流${stamp}`, ids.otherGroup[0]);
|
const legacyPositionTag = await prisma.tag.findFirst({
|
||||||
ids.tag.push(tagBaoyou.id, tagDanyin.id, tagStyle.id);
|
where: { tagName: '单面印', tagGroup: { groupName: { contains: '印刷位置' } } },
|
||||||
|
});
|
||||||
|
const legacyBaoyou = await prisma.tag.findFirst({
|
||||||
|
where: { tagName: '包邮', tagGroup: { groupName: { contains: '物流渠道' } } },
|
||||||
|
});
|
||||||
|
|
||||||
const og1 = await prisma.originGood.create({
|
const og1 = await prisma.originGood.create({
|
||||||
data: {
|
data: {
|
||||||
sdsGoodId: `tagsync-${stamp}-1`,
|
sdsGoodId: `tagsync-${stamp}-1`,
|
||||||
goodName: `美国(包邮${stamp})T恤-TS${stamp}-单面印花${stamp}`,
|
goodName: `美国(包邮)180g纯棉T恤成人款-DG${stamp}-单面印花`,
|
||||||
logisticsLabel: `包邮${stamp}`,
|
logisticsLabel: '包邮',
|
||||||
craftLabel: `单面印花${stamp}`,
|
craftLabel: '单面印花',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const og2 = await prisma.originGood.create({
|
const og2 = await prisma.originGood.create({
|
||||||
data: {
|
data: {
|
||||||
sdsGoodId: `tagsync-${stamp}-2`,
|
sdsGoodId: `tagsync-${stamp}-2`,
|
||||||
goodName: `美国(不包邮)T恤-TS${stamp}-双面印花`,
|
goodName: `美国(不包邮光板)180GT恤成人款-JS${stamp}-不打印·美西洛杉矶二仓`,
|
||||||
logisticsLabel: `不包邮`,
|
logisticsLabel: '不包邮光板',
|
||||||
craftLabel: `双面印花`,
|
craftLabel: '不打印',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ids.originGood.push(og1.id, og2.id);
|
ids.originGood.push(og1.id, og2.id);
|
||||||
@@ -81,48 +86,113 @@ describe('FamilyRecomputeService.syncFamilyTags', () => {
|
|||||||
where: { id: { in: [og1.id, og2.id] } },
|
where: { id: { in: [og1.id, og2.id] } },
|
||||||
data: { familyId: family.id },
|
data: { familyId: family.id },
|
||||||
});
|
});
|
||||||
const good = await prisma.good.create({
|
|
||||||
|
const good1 = await prisma.good.create({
|
||||||
data: {
|
data: {
|
||||||
originGoodId: og1.id,
|
originGoodId: og1.id,
|
||||||
familyId: family.id,
|
familyId: family.id,
|
||||||
countryId: ids.country[0],
|
countryId: ids.country[0],
|
||||||
categoryId: ids.category[0],
|
categoryId: ids.category[0],
|
||||||
goodName: `标签同步商品-${stamp}`,
|
goodName: `标签同步商品1-${stamp}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ids.good.push(good.id);
|
const good2 = await prisma.good.create({
|
||||||
// 预置一个人工分组标签
|
data: {
|
||||||
await prisma.goodTag.create({ data: { goodId: good.id, tagId: tagStyle.id } });
|
originGoodId: og2.id,
|
||||||
|
familyId: family.id,
|
||||||
|
countryId: ids.country[0],
|
||||||
|
categoryId: ids.category[0],
|
||||||
|
goodName: `标签同步商品2-${stamp}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ids.good.push(good1.id, good2.id);
|
||||||
|
// 预置:人工分组标签(保留)+ 旧自动组标签(应被剔除)
|
||||||
|
await prisma.goodTag.create({ data: { goodId: good1.id, tagId: styleTag.id } });
|
||||||
|
if (legacyPositionTag) {
|
||||||
|
await prisma.goodTag.create({ data: { goodId: good1.id, tagId: legacyPositionTag.id } });
|
||||||
|
}
|
||||||
|
if (legacyBaoyou) {
|
||||||
|
await prisma.goodTag.create({ data: { goodId: good2.id, tagId: legacyBaoyou.id } });
|
||||||
|
}
|
||||||
|
|
||||||
// 初次同步:物流/工艺组标签由成员标签派生(含真实组的前缀命中),人工"潮流"保留
|
|
||||||
const r1 = await service.syncFamilyTags(family.id);
|
const r1 = await service.syncFamilyTags(family.id);
|
||||||
expect(r1.goodsUpdated).toBe(1);
|
expect(r1.goodsUpdated).toBe(2);
|
||||||
const names1 = (await prisma.goodTag.findMany({
|
|
||||||
where: { goodId: good.id },
|
const names1 = await tagNames(good1.id);
|
||||||
include: { tag: true },
|
expect(names1).toContain('单面印花');
|
||||||
})).map((t) => t.tag.tagName);
|
expect(names1).toContain('烫画');
|
||||||
expect(names1).toContain(`包邮${stamp}`); // og1 物流(最长前缀命中戳记标签)
|
expect(names1).toContain('包邮');
|
||||||
expect(names1).toContain(`单面印花${stamp}`); // og1 工艺
|
expect(names1).toContain(`潮流${stamp}`);
|
||||||
expect(names1).toContain('不包邮'); // og2 物流(真实组命中)
|
expect(names1).not.toContain('单面印');
|
||||||
expect(names1).toContain('双面印'); // og2 工艺 双面印花 → 前缀命中真实标签
|
expect(names1).not.toContain('双面印花');
|
||||||
expect(names1).toContain(`潮流${stamp}`); // 人工分组标签保留
|
expect(names1).not.toContain('不包邮');
|
||||||
|
|
||||||
|
// og2 名称含 不打印 + 光板(物流备注)→ 两个工艺标签,无默认烫画、无印花数量标签
|
||||||
|
const names2 = await tagNames(good2.id);
|
||||||
|
expect(names2).toContain('不打印');
|
||||||
|
expect(names2).toContain('光板');
|
||||||
|
expect(names2).toContain('不包邮');
|
||||||
|
expect(names2).not.toContain('烫画');
|
||||||
|
expect(names2).not.toContain('包邮');
|
||||||
|
expect(names2).not.toContain('单面印花');
|
||||||
|
|
||||||
// 幂等:无变化不写
|
// 幂等:无变化不写
|
||||||
const r2 = await service.syncFamilyTags(family.id);
|
const r2 = await service.syncFamilyTags(family.id);
|
||||||
expect(r2.goodsUpdated).toBe(0);
|
expect(r2.goodsUpdated).toBe(0);
|
||||||
|
|
||||||
// og1 工艺改掉 → 其派生的 单面印花stamped 消失(og2 工艺是 双面印花,不命中它),人工标签保留
|
// 链接名称变化 → good1 标签跟随新名称
|
||||||
await prisma.originGood.update({
|
await prisma.originGood.update({
|
||||||
where: { id: og1.id },
|
where: { id: og1.id },
|
||||||
data: { craftLabel: `双面印花` },
|
data: { goodName: `美国(不包邮)180g纯棉T恤成人款-DG${stamp}-双面印花` },
|
||||||
});
|
});
|
||||||
await service.syncFamilyTags(family.id);
|
await service.syncFamilyTags(family.id);
|
||||||
const names3 = (await prisma.goodTag.findMany({
|
const names3 = await tagNames(good1.id);
|
||||||
where: { goodId: good.id },
|
expect(names3).toContain('双面印花');
|
||||||
include: { tag: true },
|
expect(names3).toContain('烫画');
|
||||||
})).map((t) => t.tag.tagName);
|
expect(names3).toContain('不包邮');
|
||||||
expect(names3).not.toContain(`单面印花${stamp}`);
|
|
||||||
expect(names3).toContain(`包邮${stamp}`);
|
|
||||||
expect(names3).toContain(`潮流${stamp}`);
|
expect(names3).toContain(`潮流${stamp}`);
|
||||||
|
expect(names3).not.toContain('单面印花');
|
||||||
|
expect(names3).not.toContain('包邮');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('自定义来源的成员不派生标签,仅剔除自动组标签', async () => {
|
||||||
|
const realBaoyou = await prisma.tag.findFirst({
|
||||||
|
where: { tagName: '包邮', tagGroup: { groupName: { contains: '物流渠道' } } },
|
||||||
|
});
|
||||||
|
const ogCustom = await prisma.originGood.create({
|
||||||
|
data: {
|
||||||
|
source: 'CUSTOM',
|
||||||
|
sdsGoodId: `tagsync-custom-${stamp}`,
|
||||||
|
goodName: `自定义包邮T恤${stamp}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ids.originGood.push(ogCustom.id);
|
||||||
|
const family = await prisma.productFamily.create({
|
||||||
|
data: { familyName: `自定义标签族-${stamp}`, primaryOriginGoodId: ogCustom.id },
|
||||||
|
});
|
||||||
|
ids.family.push(family.id);
|
||||||
|
await prisma.originGood.update({
|
||||||
|
where: { id: ogCustom.id },
|
||||||
|
data: { familyId: family.id },
|
||||||
|
});
|
||||||
|
const good = await prisma.good.create({
|
||||||
|
data: {
|
||||||
|
originGoodId: ogCustom.id,
|
||||||
|
familyId: family.id,
|
||||||
|
countryId: ids.country[0],
|
||||||
|
categoryId: ids.category[0],
|
||||||
|
goodName: `自定义标签商品-${stamp}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ids.good.push(good.id);
|
||||||
|
if (realBaoyou) {
|
||||||
|
await prisma.goodTag.create({ data: { goodId: good.id, tagId: realBaoyou.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = await service.syncFamilyTags(family.id);
|
||||||
|
expect(r.goodsUpdated).toBe(realBaoyou ? 1 : 0);
|
||||||
|
const names = await tagNames(good.id);
|
||||||
|
expect(names).not.toContain('包邮');
|
||||||
|
expect(names).not.toContain('烫画');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user