feat(api): category-based family grouping, tree family info and custom goods family attribution
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
* 运行:pnpm --filter @inkreach/api backfill:product-families
|
||||
* 幂等性:重复执行时步骤 1 数据不变、步骤 2 候选为空(familyId=null 过滤)。
|
||||
*/
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
|
||||
import { ProductFamiliesService } from '../src/product-families/product-families.service';
|
||||
@@ -51,9 +52,18 @@ async function main() {
|
||||
const result = await families.autoGroup(true);
|
||||
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 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 stale = await prisma.productFamily.count({ where: { stale: true } });
|
||||
console.log(`stats: families=${total}, materialized=${withMatrix}, members=${members}, stale=${stale}`);
|
||||
|
||||
@@ -211,6 +211,31 @@ export class CreateCustomGoodDto extends OmitType(CreateGoodDto, [
|
||||
@IsNumberString()
|
||||
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 })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GoodsController } from './goods.controller';
|
||||
import { GoodsService } from './goods.service';
|
||||
import { SyncModule } from '../sync/sync.module';
|
||||
|
||||
@Module({
|
||||
imports: [SyncModule],
|
||||
import { GoodsService } from './goods.service';
|
||||
import { SyncModule } from '../sync/sync.module';
|
||||
import { ProductFamiliesModule } from '../product-families/product-families.module';
|
||||
|
||||
@Module({
|
||||
imports: [SyncModule, ProductFamiliesModule],
|
||||
controllers: [GoodsController],
|
||||
providers: [GoodsService],
|
||||
exports: [GoodsService],
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { GoodsService } from './goods.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||
|
||||
describe('GoodsService', () => {
|
||||
let service: GoodsService;
|
||||
@@ -30,6 +31,10 @@ describe('GoodsService', () => {
|
||||
provide: SyncService,
|
||||
useValue: { queueProductDetailSync: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: FamilyRecomputeService,
|
||||
useValue: { enqueue: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(GoodsService);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
||||
import { BatchPriorityDto } from './dto/batch-priority.dto';
|
||||
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
CreateCustomGoodDto,
|
||||
@@ -52,6 +53,7 @@ export class GoodsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly syncService: SyncService,
|
||||
private readonly familyRecompute: FamilyRecomputeService,
|
||||
) {}
|
||||
|
||||
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
|
||||
@@ -176,6 +178,14 @@ export class GoodsService {
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
if (dto.positionId !== undefined) await this.ensurePosition(dto.positionId);
|
||||
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 originGood = await tx.originGood.create({
|
||||
@@ -185,6 +195,15 @@ export class GoodsService {
|
||||
goodName: dto.goodName,
|
||||
goodImage: dto.goodImage ?? null,
|
||||
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: {
|
||||
create: this.customDetailData(
|
||||
dto.detail ?? {},
|
||||
@@ -217,6 +236,7 @@ export class GoodsService {
|
||||
}
|
||||
return good.id;
|
||||
});
|
||||
if (family) this.familyRecompute.enqueue(family.id);
|
||||
return this.findOne(goodId);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,10 @@ export interface OriginGoodsTreeNode {
|
||||
variantCount: number;
|
||||
sizeRowCount: 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. */
|
||||
@@ -129,7 +133,11 @@ export class OriginGoodsService {
|
||||
this.prisma.originGood.findMany({
|
||||
where: { delisted: false, source: 'SDS' },
|
||||
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({
|
||||
by: ['originGoodId'],
|
||||
@@ -271,6 +279,10 @@ export class OriginGoodsService {
|
||||
variantCount: og._count.variants,
|
||||
sizeRowCount: this.jsonRows(og.detail?.sizeChart),
|
||||
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);
|
||||
@@ -322,6 +334,10 @@ export class OriginGoodsService {
|
||||
variantCount: og._count.variants,
|
||||
sizeRowCount: this.jsonRows(og.detail?.sizeChart),
|
||||
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 });
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
@@ -68,11 +68,16 @@ describe('SyncService family hooks', () => {
|
||||
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({
|
||||
data: {
|
||||
sdsGoodId: `hook-${stamp}-seed`,
|
||||
goodName: `自动挂${stamp}(包邮)卫衣-ZZA${stamp}-单面印花`,
|
||||
sdsCategoryId: sdsCat,
|
||||
craftLabel: '单面印花',
|
||||
logisticsLabel: '包邮',
|
||||
},
|
||||
@@ -105,11 +110,11 @@ describe('SyncService family hooks', () => {
|
||||
const before = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||
expect((before.priceMatrix as any).rows).toHaveLength(1);
|
||||
|
||||
// 同键新链接(多一个仓库段)→ 自动挂进唯一族
|
||||
// 同分类新链接(不同工艺)→ 自动挂进唯一族
|
||||
const newSdsId = `hook-${stamp}-new`;
|
||||
await (service as any).upsertOriginGood(
|
||||
sdsProduct(newSdsId, `自动挂${stamp}(包邮)卫衣-ZZA${stamp}-单面印花-某仓`),
|
||||
'cat-1',
|
||||
sdsProduct(newSdsId, `自动挂${stamp}(不包邮)卫衣-ZZA${stamp}-双面印花-某仓`),
|
||||
sdsCat,
|
||||
);
|
||||
const newOg = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: newSdsId } });
|
||||
createdOriginGoodIds.push(newOg.id);
|
||||
@@ -117,6 +122,7 @@ describe('SyncService family hooks', () => {
|
||||
|
||||
// 入队的重算已执行(等待异步完成)
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await prisma.category.delete({ where: { id: cat.id } }).catch(() => undefined);
|
||||
});
|
||||
|
||||
it('多族命中时不确定归属 → 不挂载', async () => {
|
||||
|
||||
@@ -673,21 +673,36 @@ export class SyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 新链接自动挂族:3 段分组键恰好命中唯一族才挂载(多族/零族留给管理员裁决)。
|
||||
* 新链接自动挂族:优先按 SDS 分类(=产品模型)匹配已有族的成员;
|
||||
* 无分类时回退名称 3 段键。恰好命中唯一族才挂载(多族/零族留给管理员裁决)。
|
||||
* 锁定族(autoManaged=false)不吸收新成员,只置 stale 提示。
|
||||
*/
|
||||
private async tryAutoAttachToFamily(originGoodId: bigint, goodName: string): Promise<void> {
|
||||
const key = originGroupKey(goodName);
|
||||
if (!key) return;
|
||||
const candidates = await this.prisma.originGood.findMany({
|
||||
where: { familyId: { not: null }, goodName: { startsWith: key } },
|
||||
select: { familyId: true, goodName: true },
|
||||
const self = await this.prisma.originGood.findUnique({
|
||||
where: { id: originGoodId },
|
||||
select: { sdsCategoryId: true },
|
||||
});
|
||||
const familyIds = new Set(
|
||||
candidates
|
||||
.filter((c) => originGroupKey(c.goodName) === key && c.familyId !== null)
|
||||
.map((c) => c.familyId!.toString()),
|
||||
);
|
||||
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);
|
||||
if (!key) return;
|
||||
const candidates = await this.prisma.originGood.findMany({
|
||||
where: { familyId: { not: null }, goodName: { startsWith: key } },
|
||||
select: { familyId: true, goodName: true },
|
||||
});
|
||||
familyIds = new Set(
|
||||
candidates
|
||||
.filter((c) => originGroupKey(c.goodName) === key && c.familyId !== null)
|
||||
.map((c) => c.familyId!.toString()),
|
||||
);
|
||||
}
|
||||
if (familyIds.size !== 1) return;
|
||||
const familyId = BigInt([...familyIds][0]);
|
||||
const family = await this.prisma.productFamily.findUnique({
|
||||
|
||||
Reference in New Issue
Block a user