feat(api): category-based family grouping, tree family info and custom goods family attribution
This commit is contained in:
@@ -98,7 +98,7 @@ describe('ProductFamiliesService', () => {
|
||||
await service.patch(BigInt(f.id), { autoManaged: true });
|
||||
});
|
||||
|
||||
it('auto-group:预览不写库;apply 建族挂成员且幂等', async () => {
|
||||
it('auto-group:无分类时按名称键分组;预览不写库;apply 幂等', async () => {
|
||||
const g1a = await mkOriginGood(`自动组${stamp}(包邮)卫衣-ZZ${stamp}-单面印花`);
|
||||
const g1b = await mkOriginGood(`自动组${stamp}(包邮)卫衣-ZZ${stamp}-单面印花-某仓`);
|
||||
const g2 = await mkOriginGood(`自动组${stamp}(不包邮)卫衣-ZZ${stamp}-单面印花`);
|
||||
@@ -129,6 +129,40 @@ describe('ProductFamiliesService', () => {
|
||||
expect(hitAgain).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('auto-group:同 SDS 分类(产品模型)跨工艺/编码归一族,族名取分类名', async () => {
|
||||
const cat = await prisma.category.create({
|
||||
data: { categoryName: `ZZF${stamp} 180G纯棉T恤(ZZA${stamp})`, sdsCategoryId: `cat-hook-${stamp}` },
|
||||
});
|
||||
try {
|
||||
const a = await mkOriginGood(`美国(包邮)180g纯棉T恤-DGZ${stamp}-单面印花`, {
|
||||
sdsCategoryId: `cat-hook-${stamp}`,
|
||||
craftLabel: '单面印花',
|
||||
logisticsLabel: '包邮',
|
||||
});
|
||||
const b = await mkOriginGood(`美国(不包邮)180GT恤-ZZA${stamp}-双面印花-某仓`, {
|
||||
sdsCategoryId: `cat-hook-${stamp}`,
|
||||
craftLabel: '双面印花',
|
||||
logisticsLabel: '不包邮',
|
||||
});
|
||||
const result = (await service.autoGroup(true)) as any;
|
||||
const grouped = await prisma.originGood.findMany({
|
||||
where: { id: { in: [a.id, b.id] } },
|
||||
select: { familyId: true },
|
||||
});
|
||||
expect(grouped[0].familyId).not.toBeNull();
|
||||
expect(grouped[0].familyId).toBe(grouped[1].familyId); // 跨工艺/编码/物流同族
|
||||
const family = await prisma.productFamily.findUniqueOrThrow({
|
||||
where: { id: grouped[0].familyId! },
|
||||
});
|
||||
createdFamilyIds.push(family.id);
|
||||
expect(family.familyName).toBe(`ZZF${stamp} 180G纯棉T恤(ZZA${stamp})`);
|
||||
expect(family.familyCode).toBe(`ZZF${stamp}`);
|
||||
expect(result.applied).toBeGreaterThanOrEqual(1);
|
||||
} finally {
|
||||
await prisma.category.delete({ where: { id: cat.id } }).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it('members:增删成员、移除主链接后 primary 落到剩余成员', async () => {
|
||||
const a = await mkOriginGood(`成员${stamp}A`, { craftLabel: '单面印花', logisticsLabel: '包邮' });
|
||||
const b = await mkOriginGood(`成员${stamp}B`, { craftLabel: '单面印花', logisticsLabel: '包邮' });
|
||||
|
||||
@@ -13,8 +13,14 @@ import {
|
||||
UpdateFamilyMembersDto,
|
||||
} from './dto/product-family.dto';
|
||||
|
||||
const FAMILY_INCLUDE = {
|
||||
originGoods: {
|
||||
/** 从分类名提取模型编码:`DG001 180G纯棉T恤(JSA002)` → `DG001` */
|
||||
function codeFromCategoryName(categoryName: string | null | undefined): string | null {
|
||||
if (!categoryName) return null;
|
||||
const token = categoryName.trim().split(/\s+/)[0] ?? '';
|
||||
return /^[A-Za-z0-9]+$/.test(token) ? token : null;
|
||||
}
|
||||
|
||||
const FAMILY_INCLUDE = { originGoods: {
|
||||
select: {
|
||||
id: true,
|
||||
sdsGoodId: true,
|
||||
@@ -128,16 +134,24 @@ export class ProductFamiliesService {
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
/** 自动建族:按 3 段分组键聚合无族链接;apply=false 仅预览 */
|
||||
/** 自动成族:SDS 叶子分类即产品模型(如 "DG001 180G纯棉T恤(JSA002)"),
|
||||
* 同分类链接归一族(跨工艺/物流/仓库/编码);无分类回退名称 3 段键;apply=false 仅预览 */
|
||||
async autoGroup(apply: boolean) {
|
||||
const candidates = await this.prisma.originGood.findMany({
|
||||
where: { familyId: null, delisted: false },
|
||||
select: { id: true, goodName: true, goodImage: true, source: true },
|
||||
select: { id: true, goodName: true, goodImage: true, source: true, sdsCategoryId: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const categories = await this.prisma.category.findMany({
|
||||
where: { sdsCategoryId: { not: null } },
|
||||
select: { sdsCategoryId: true, categoryName: true },
|
||||
});
|
||||
const catName = new Map(categories.map((c) => [c.sdsCategoryId as string, c.categoryName]));
|
||||
|
||||
const groups = new Map<string, typeof candidates>();
|
||||
for (const og of candidates) {
|
||||
const key = originGroupKey(og.goodName);
|
||||
const nameKey = originGroupKey(og.goodName);
|
||||
const key = og.sdsCategoryId ? `cat:${og.sdsCategoryId}` : nameKey ? `name:${nameKey}` : '';
|
||||
if (!key) continue;
|
||||
const arr = groups.get(key);
|
||||
if (arr) arr.push(og);
|
||||
@@ -146,10 +160,14 @@ export class ProductFamiliesService {
|
||||
|
||||
const preview = [...groups.values()].map((members) => {
|
||||
const parsed = parseOriginName(members[0].goodName);
|
||||
const categoryName = members[0].sdsCategoryId
|
||||
? (catName.get(members[0].sdsCategoryId) ?? null)
|
||||
: null;
|
||||
return {
|
||||
groupKey: originGroupKey(members[0].goodName),
|
||||
familyName: parsed.productName ?? parsed.country ?? originGroupKey(members[0].goodName),
|
||||
familyCode: parsed.skuCode ?? null,
|
||||
groupKey: members[0].sdsCategoryId ? `cat:${members[0].sdsCategoryId}` : `name:${originGroupKey(members[0].goodName)}`,
|
||||
familyName:
|
||||
categoryName ?? parsed.productName ?? originGroupKey(members[0].goodName),
|
||||
familyCode: codeFromCategoryName(categoryName) ?? parsed.skuCode ?? null,
|
||||
memberCount: members.length,
|
||||
sampleNames: members.slice(0, 3).map((m) => m.goodName ?? ''),
|
||||
};
|
||||
@@ -160,15 +178,19 @@ export class ProductFamiliesService {
|
||||
let applied = 0;
|
||||
for (const members of groups.values()) {
|
||||
const parsed = parseOriginName(members[0].goodName);
|
||||
const categoryName = members[0].sdsCategoryId
|
||||
? (catName.get(members[0].sdsCategoryId) ?? null)
|
||||
: null;
|
||||
const fallbackCode =
|
||||
members[0].source === 'CUSTOM' ? `CUSTOM-${members[0].id}` : null;
|
||||
const family = await this.prisma.productFamily.create({
|
||||
data: {
|
||||
familyName: parsed.productName ?? parsed.country ?? originGroupKey(members[0].goodName),
|
||||
familyCode: parsed.skuCode
|
||||
? await this.ensureUniqueCode(parsed.skuCode)
|
||||
: fallbackCode
|
||||
? await this.ensureUniqueCode(fallbackCode)
|
||||
familyName: categoryName ?? parsed.productName ?? originGroupKey(members[0].goodName),
|
||||
familyCode:
|
||||
codeFromCategoryName(categoryName) ?? parsed.skuCode ?? fallbackCode
|
||||
? await this.ensureUniqueCode(
|
||||
(codeFromCategoryName(categoryName) ?? parsed.skuCode ?? fallbackCode)!,
|
||||
)
|
||||
: null,
|
||||
familyImage: members[0].goodImage ?? null,
|
||||
primaryOriginGoodId: members[0].id,
|
||||
|
||||
Reference in New Issue
Block a user