feat(api): category-based family grouping, tree family info and custom goods family attribution

This commit is contained in:
yeuimu
2026-08-28 12:43:58 +08:00
parent d63f1a6f35
commit c8531bfe08
10 changed files with 190 additions and 36 deletions
@@ -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,