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
+11 -1
View File
@@ -7,6 +7,7 @@
* 运行:pnpm --filter @inkreach/api backfill:product-families * 运行:pnpm --filter @inkreach/api backfill:product-families
* 幂等性:重复执行时步骤 1 数据不变、步骤 2 候选为空(familyId=null 过滤)。 * 幂等性:重复执行时步骤 1 数据不变、步骤 2 候选为空(familyId=null 过滤)。
*/ */
import { Prisma } from '@prisma/client';
import { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
import { FamilyRecomputeService } from '../src/product-families/family-recompute.service'; import { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
import { ProductFamiliesService } from '../src/product-families/product-families.service'; import { ProductFamiliesService } from '../src/product-families/product-families.service';
@@ -51,9 +52,18 @@ async function main() {
const result = await families.autoGroup(true); const result = await families.autoGroup(true);
console.log(`[2/2] created ${result.applied} families`); console.log(`[2/2] created ${result.applied} families`);
// ---- 3. 全量族重算兜底(修复建族早于解析列回填等时序造成的空矩阵) ----
const allFamilies = await prisma.productFamily.findMany({ select: { id: true } });
for (const f of allFamilies) {
await recompute.recomputeFamily(f.id);
}
console.log(`[3/3] recomputed ${allFamilies.length} families`);
// ---- 统计 ---- // ---- 统计 ----
const total = await prisma.productFamily.count(); const total = await prisma.productFamily.count();
const withMatrix = await prisma.productFamily.count({ where: { priceMatrix: { not: null } } }); const withMatrix = await prisma.productFamily.count({
where: { priceMatrix: { not: Prisma.DbNull } },
});
const members = await prisma.originGood.count({ where: { familyId: { not: null } } }); const members = await prisma.originGood.count({ where: { familyId: { not: null } } });
const stale = await prisma.productFamily.count({ where: { stale: true } }); const stale = await prisma.productFamily.count({ where: { stale: true } });
console.log(`stats: families=${total}, materialized=${withMatrix}, members=${members}, stale=${stale}`); console.log(`stats: families=${total}, materialized=${withMatrix}, members=${members}, stale=${stale}`);
+25
View File
@@ -211,6 +211,31 @@ export class CreateCustomGoodDto extends OmitType(CreateGoodDto, [
@IsNumberString() @IsNumberString()
goodPrice?: string | null; goodPrice?: string | null;
@ApiProperty({ required: false, description: '物流归因(入族后参与价格矩阵)', example: '包邮' })
@IsOptional()
@IsString()
logisticsLabel?: string;
@ApiProperty({ required: false, description: '工艺/印花数量归因', example: '双面印花' })
@IsOptional()
@IsString()
craftLabel?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
skuCode?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
warehouseLabel?: string;
@ApiProperty({ required: false, description: '挂入的产品族 id(缺省为独立商品)', example: '12' })
@IsOptional()
@IsNumberString()
familyId?: string;
@ApiProperty({ required: false, type: CustomGoodDetailDto }) @ApiProperty({ required: false, type: CustomGoodDetailDto })
@IsOptional() @IsOptional()
@ValidateNested() @ValidateNested()
+2 -1
View File
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { GoodsController } from './goods.controller'; import { GoodsController } from './goods.controller';
import { GoodsService } from './goods.service'; import { GoodsService } from './goods.service';
import { SyncModule } from '../sync/sync.module'; import { SyncModule } from '../sync/sync.module';
import { ProductFamiliesModule } from '../product-families/product-families.module';
@Module({ @Module({
imports: [SyncModule], imports: [SyncModule, ProductFamiliesModule],
controllers: [GoodsController], controllers: [GoodsController],
providers: [GoodsService], providers: [GoodsService],
exports: [GoodsService], exports: [GoodsService],
+5
View File
@@ -6,6 +6,7 @@ import {
import { GoodsService } from './goods.service'; import { GoodsService } from './goods.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SyncService } from '../sync/sync.service'; import { SyncService } from '../sync/sync.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
describe('GoodsService', () => { describe('GoodsService', () => {
let service: GoodsService; let service: GoodsService;
@@ -30,6 +31,10 @@ describe('GoodsService', () => {
provide: SyncService, provide: SyncService,
useValue: { queueProductDetailSync: jest.fn() }, useValue: { queueProductDetailSync: jest.fn() },
}, },
{
provide: FamilyRecomputeService,
useValue: { enqueue: jest.fn() },
},
], ],
}).compile(); }).compile();
service = moduleRef.get(GoodsService); service = moduleRef.get(GoodsService);
+20
View File
@@ -12,6 +12,7 @@ import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
import { BatchPriorityDto } from './dto/batch-priority.dto'; 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 { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { import {
CreateCustomGoodDto, CreateCustomGoodDto,
@@ -52,6 +53,7 @@ export class GoodsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly syncService: SyncService, private readonly syncService: SyncService,
private readonly familyRecompute: FamilyRecomputeService,
) {} ) {}
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> { async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
@@ -176,6 +178,14 @@ export class GoodsService {
await this.ensureCategory(dto.categoryId); await this.ensureCategory(dto.categoryId);
if (dto.positionId !== undefined) await this.ensurePosition(dto.positionId); if (dto.positionId !== undefined) await this.ensurePosition(dto.positionId);
for (const tagId of dto.tagIds ?? []) await this.ensureTag(tagId); for (const tagId of dto.tagIds ?? []) await this.ensureTag(tagId);
let family: { id: bigint } | null = null;
if (dto.familyId !== undefined) {
family = await this.prisma.productFamily.findUnique({
where: { id: BigInt(dto.familyId) },
select: { id: true },
});
if (!family) throw new NotFoundException(`product family ${dto.familyId} not found`);
}
const goodId = await this.prisma.$transaction(async (tx) => { const goodId = await this.prisma.$transaction(async (tx) => {
const originGood = await tx.originGood.create({ const originGood = await tx.originGood.create({
@@ -185,6 +195,15 @@ export class GoodsService {
goodName: dto.goodName, goodName: dto.goodName,
goodImage: dto.goodImage ?? null, goodImage: dto.goodImage ?? null,
goodPrice: this.decimal(dto.goodPrice), goodPrice: this.decimal(dto.goodPrice),
...(family ? { familyId: family.id } : {}),
...(dto.logisticsLabel !== undefined || dto.craftLabel !== undefined
? {
logisticsLabel: dto.logisticsLabel ?? null,
craftLabel: dto.craftLabel ?? null,
skuCode: dto.skuCode ?? null,
warehouseLabel: dto.warehouseLabel ?? null,
}
: {}),
detail: { detail: {
create: this.customDetailData( create: this.customDetailData(
dto.detail ?? {}, dto.detail ?? {},
@@ -217,6 +236,7 @@ export class GoodsService {
} }
return good.id; return good.id;
}); });
if (family) this.familyRecompute.enqueue(family.id);
return this.findOne(goodId); return this.findOne(goodId);
} }
@@ -41,6 +41,10 @@ export interface OriginGoodsTreeNode {
variantCount: number; variantCount: number;
sizeRowCount: number; sizeRowCount: number;
packageRowCount: number; packageRowCount: number;
familyId: string | null;
familyName: string | null;
familyCode: string | null;
familyStale: boolean | null;
} }
/** A category node in the hierarchical tree, with origin goods as leaves. */ /** A category node in the hierarchical tree, with origin goods as leaves. */
@@ -129,7 +133,11 @@ export class OriginGoodsService {
this.prisma.originGood.findMany({ this.prisma.originGood.findMany({
where: { delisted: false, source: 'SDS' }, where: { delisted: false, source: 'SDS' },
orderBy: { goodName: 'asc' }, orderBy: { goodName: 'asc' },
include: { detail: true, _count: { select: { variants: true } } }, include: {
detail: true,
_count: { select: { variants: true } },
family: { select: { id: true, familyName: true, familyCode: true, stale: true } },
},
}), }),
this.prisma.good.groupBy({ this.prisma.good.groupBy({
by: ['originGoodId'], by: ['originGoodId'],
@@ -271,6 +279,10 @@ export class OriginGoodsService {
variantCount: og._count.variants, variantCount: og._count.variants,
sizeRowCount: this.jsonRows(og.detail?.sizeChart), sizeRowCount: this.jsonRows(og.detail?.sizeChart),
packageRowCount: this.jsonRows(og.detail?.packageSpecs), packageRowCount: this.jsonRows(og.detail?.packageSpecs),
familyId: og.family?.id.toString() ?? null,
familyName: og.family?.familyName ?? null,
familyCode: og.family?.familyCode ?? null,
familyStale: og.family?.stale ?? null,
})); }));
const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0); const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0);
@@ -322,6 +334,10 @@ export class OriginGoodsService {
variantCount: og._count.variants, variantCount: og._count.variants,
sizeRowCount: this.jsonRows(og.detail?.sizeChart), sizeRowCount: this.jsonRows(og.detail?.sizeChart),
packageRowCount: this.jsonRows(og.detail?.packageSpecs), packageRowCount: this.jsonRows(og.detail?.packageSpecs),
familyId: og.family?.id.toString() ?? null,
familyName: og.family?.familyName ?? null,
familyCode: og.family?.familyCode ?? null,
familyStale: og.family?.stale ?? null,
})), })),
}); });
} }
@@ -98,7 +98,7 @@ describe('ProductFamiliesService', () => {
await service.patch(BigInt(f.id), { autoManaged: true }); 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 g1a = await mkOriginGood(`自动组${stamp}(包邮)卫衣-ZZ${stamp}-单面印花`);
const g1b = await mkOriginGood(`自动组${stamp}(包邮)卫衣-ZZ${stamp}-单面印花-某仓`); const g1b = await mkOriginGood(`自动组${stamp}(包邮)卫衣-ZZ${stamp}-单面印花-某仓`);
const g2 = await mkOriginGood(`自动组${stamp}(不包邮)卫衣-ZZ${stamp}-单面印花`); const g2 = await mkOriginGood(`自动组${stamp}(不包邮)卫衣-ZZ${stamp}-单面印花`);
@@ -129,6 +129,40 @@ describe('ProductFamiliesService', () => {
expect(hitAgain).toHaveLength(0); 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 () => { it('members:增删成员、移除主链接后 primary 落到剩余成员', async () => {
const a = await mkOriginGood(`成员${stamp}A`, { craftLabel: '单面印花', logisticsLabel: '包邮' }); const a = await mkOriginGood(`成员${stamp}A`, { craftLabel: '单面印花', logisticsLabel: '包邮' });
const b = await mkOriginGood(`成员${stamp}B`, { craftLabel: '单面印花', logisticsLabel: '包邮' }); const b = await mkOriginGood(`成员${stamp}B`, { craftLabel: '单面印花', logisticsLabel: '包邮' });
@@ -13,8 +13,14 @@ import {
UpdateFamilyMembersDto, UpdateFamilyMembersDto,
} from './dto/product-family.dto'; } from './dto/product-family.dto';
const FAMILY_INCLUDE = { /** 从分类名提取模型编码:`DG001 180G纯棉T恤(JSA002` → `DG001` */
originGoods: { 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: { select: {
id: true, id: true,
sdsGoodId: true, sdsGoodId: true,
@@ -128,16 +134,24 @@ export class ProductFamiliesService {
return this.detail(id); return this.detail(id);
} }
/** 自动族:按 3 段分组键聚合无族链接;apply=false 仅预览 */ /** 自动族:SDS 叶子分类即产品模型(如 "DG001 180G纯棉T恤(JSA002"),
* 同分类链接归一族(跨工艺/物流/仓库/编码);无分类回退名称 3 段键;apply=false 仅预览 */
async autoGroup(apply: boolean) { async autoGroup(apply: boolean) {
const candidates = await this.prisma.originGood.findMany({ const candidates = await this.prisma.originGood.findMany({
where: { familyId: null, delisted: false }, 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' }, 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>(); const groups = new Map<string, typeof candidates>();
for (const og of 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; if (!key) continue;
const arr = groups.get(key); const arr = groups.get(key);
if (arr) arr.push(og); if (arr) arr.push(og);
@@ -146,10 +160,14 @@ export class ProductFamiliesService {
const preview = [...groups.values()].map((members) => { const preview = [...groups.values()].map((members) => {
const parsed = parseOriginName(members[0].goodName); const parsed = parseOriginName(members[0].goodName);
const categoryName = members[0].sdsCategoryId
? (catName.get(members[0].sdsCategoryId) ?? null)
: null;
return { return {
groupKey: originGroupKey(members[0].goodName), groupKey: members[0].sdsCategoryId ? `cat:${members[0].sdsCategoryId}` : `name:${originGroupKey(members[0].goodName)}`,
familyName: parsed.productName ?? parsed.country ?? originGroupKey(members[0].goodName), familyName:
familyCode: parsed.skuCode ?? null, categoryName ?? parsed.productName ?? originGroupKey(members[0].goodName),
familyCode: codeFromCategoryName(categoryName) ?? parsed.skuCode ?? null,
memberCount: members.length, memberCount: members.length,
sampleNames: members.slice(0, 3).map((m) => m.goodName ?? ''), sampleNames: members.slice(0, 3).map((m) => m.goodName ?? ''),
}; };
@@ -160,15 +178,19 @@ export class ProductFamiliesService {
let applied = 0; let applied = 0;
for (const members of groups.values()) { for (const members of groups.values()) {
const parsed = parseOriginName(members[0].goodName); const parsed = parseOriginName(members[0].goodName);
const categoryName = members[0].sdsCategoryId
? (catName.get(members[0].sdsCategoryId) ?? null)
: null;
const fallbackCode = const fallbackCode =
members[0].source === 'CUSTOM' ? `CUSTOM-${members[0].id}` : null; members[0].source === 'CUSTOM' ? `CUSTOM-${members[0].id}` : null;
const family = await this.prisma.productFamily.create({ const family = await this.prisma.productFamily.create({
data: { data: {
familyName: parsed.productName ?? parsed.country ?? originGroupKey(members[0].goodName), familyName: categoryName ?? parsed.productName ?? originGroupKey(members[0].goodName),
familyCode: parsed.skuCode familyCode:
? await this.ensureUniqueCode(parsed.skuCode) codeFromCategoryName(categoryName) ?? parsed.skuCode ?? fallbackCode
: fallbackCode ? await this.ensureUniqueCode(
? await this.ensureUniqueCode(fallbackCode) (codeFromCategoryName(categoryName) ?? parsed.skuCode ?? fallbackCode)!,
)
: null, : null,
familyImage: members[0].goodImage ?? null, familyImage: members[0].goodImage ?? null,
primaryOriginGoodId: members[0].id, primaryOriginGoodId: members[0].id,
+10 -4
View File
@@ -68,11 +68,16 @@ describe('SyncService family hooks', () => {
expect(og2.warehouseLabel).toBeNull(); expect(og2.warehouseLabel).toBeNull();
}); });
it('新链接自动挂族:唯一命中族则挂载并触发重算', async () => { it('新链接自动挂族:按 SDS 分类唯一命中族则挂载并触发重算', async () => {
const sdsCat = `cat-hook-${stamp}`;
const cat = await prisma.category.create({
data: { categoryName: `挂族测试分类-${stamp}`, sdsCategoryId: sdsCat },
});
const seed = await prisma.originGood.create({ const seed = await prisma.originGood.create({
data: { data: {
sdsGoodId: `hook-${stamp}-seed`, sdsGoodId: `hook-${stamp}-seed`,
goodName: `自动挂${stamp}(包邮)卫衣-ZZA${stamp}-单面印花`, goodName: `自动挂${stamp}(包邮)卫衣-ZZA${stamp}-单面印花`,
sdsCategoryId: sdsCat,
craftLabel: '单面印花', craftLabel: '单面印花',
logisticsLabel: '包邮', logisticsLabel: '包邮',
}, },
@@ -105,11 +110,11 @@ describe('SyncService family hooks', () => {
const before = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); const before = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
expect((before.priceMatrix as any).rows).toHaveLength(1); expect((before.priceMatrix as any).rows).toHaveLength(1);
// 同新链接(多一个仓库段)→ 自动挂进唯一族 // 同分类新链接(不同工艺)→ 自动挂进唯一族
const newSdsId = `hook-${stamp}-new`; const newSdsId = `hook-${stamp}-new`;
await (service as any).upsertOriginGood( await (service as any).upsertOriginGood(
sdsProduct(newSdsId, `自动挂${stamp}(包邮)卫衣-ZZA${stamp}-面印花-某仓`), sdsProduct(newSdsId, `自动挂${stamp}包邮)卫衣-ZZA${stamp}-面印花-某仓`),
'cat-1', sdsCat,
); );
const newOg = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: newSdsId } }); const newOg = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: newSdsId } });
createdOriginGoodIds.push(newOg.id); createdOriginGoodIds.push(newOg.id);
@@ -117,6 +122,7 @@ describe('SyncService family hooks', () => {
// 入队的重算已执行(等待异步完成) // 入队的重算已执行(等待异步完成)
await new Promise((r) => setTimeout(r, 200)); await new Promise((r) => setTimeout(r, 200));
await prisma.category.delete({ where: { id: cat.id } }).catch(() => undefined);
}); });
it('多族命中时不确定归属 → 不挂载', async () => { it('多族命中时不确定归属 → 不挂载', async () => {
+17 -2
View File
@@ -673,21 +673,36 @@ export class SyncService {
} }
/** /**
* 新链接自动挂族:3 段分组键恰好命中唯一族才挂载(多族/零族留给管理员裁决)。 * 新链接自动挂族:优先按 SDS 分类(=产品模型)匹配已有族的成员;
* 无分类时回退名称 3 段键。恰好命中唯一族才挂载(多族/零族留给管理员裁决)。
* 锁定族(autoManaged=false)不吸收新成员,只置 stale 提示。 * 锁定族(autoManaged=false)不吸收新成员,只置 stale 提示。
*/ */
private async tryAutoAttachToFamily(originGoodId: bigint, goodName: string): Promise<void> { private async tryAutoAttachToFamily(originGoodId: bigint, goodName: string): Promise<void> {
const self = await this.prisma.originGood.findUnique({
where: { id: originGoodId },
select: { sdsCategoryId: true },
});
let familyIds: Set<string>;
if (self?.sdsCategoryId) {
const siblings = await this.prisma.originGood.findMany({
where: { sdsCategoryId: self.sdsCategoryId, familyId: { not: null } },
select: { familyId: true },
distinct: ['familyId'],
});
familyIds = new Set(siblings.map((s) => s.familyId!.toString()));
} else {
const key = originGroupKey(goodName); const key = originGroupKey(goodName);
if (!key) return; if (!key) return;
const candidates = await this.prisma.originGood.findMany({ const candidates = await this.prisma.originGood.findMany({
where: { familyId: { not: null }, goodName: { startsWith: key } }, where: { familyId: { not: null }, goodName: { startsWith: key } },
select: { familyId: true, goodName: true }, select: { familyId: true, goodName: true },
}); });
const familyIds = new Set( familyIds = new Set(
candidates candidates
.filter((c) => originGroupKey(c.goodName) === key && c.familyId !== null) .filter((c) => originGroupKey(c.goodName) === key && c.familyId !== null)
.map((c) => c.familyId!.toString()), .map((c) => c.familyId!.toString()),
); );
}
if (familyIds.size !== 1) return; if (familyIds.size !== 1) return;
const familyId = BigInt([...familyIds][0]); const familyId = BigInt([...familyIds][0]);
const family = await this.prisma.productFamily.findUnique({ const family = await this.prisma.productFamily.findUnique({