feat(product-family): merge SPU family layer phase-1 backend

This commit is contained in:
yeuimu
2026-08-28 12:50:49 +08:00
28 changed files with 3490 additions and 87 deletions
+1
View File
@@ -106,6 +106,7 @@ pnpm --filter @inkreach/api test # 单元测试
pnpm --filter @inkreach/api prisma:generate # 生成 Prisma Client
pnpm --filter @inkreach/api prisma:migrate # 运行迁移
pnpm --filter @inkreach/api prisma:studio # 打开 Prisma Studio
pnpm --filter @inkreach/api backfill:product-families # 产品族回填(解析链接名→自动建族→全量重算,幂等)
# 官网
pnpm --filter @inkreach/website dev # 开发
+2 -1
View File
@@ -22,7 +22,8 @@
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts",
"import:product-detail": "ts-node prisma/import-product-detail.ts"
"import:product-detail": "ts-node prisma/import-product-detail.ts",
"backfill:product-families": "ts-node prisma/backfill-product-families.ts"
},
"dependencies": {
"@nestjs/axios": "^3.0.1",
@@ -0,0 +1,81 @@
/**
* 产品族回填脚本(一次性 / 幂等):
* 1. 全量 OriginGood 回填四个链接名解析列;
* 2. auto-group 全量建族并挂成员(每族建立即重算);
* 3. 输出统计与不可解析清单。
*
* 运行: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';
import { parseOriginName } from '../src/product-families/origin-name.parser';
async function main() {
const prisma = new PrismaService();
await prisma.onModuleInit();
const recompute = new FamilyRecomputeService(prisma);
const families = new ProductFamiliesService(prisma, recompute);
// ---- 1. 解析列回填(分批) ----
const BATCH = 100;
let parsed = 0;
const unparsable: string[] = [];
for (;;) {
const batch = await prisma.originGood.findMany({
orderBy: { id: 'asc' },
take: BATCH,
skip: parsed,
select: { id: true, goodName: true },
});
if (batch.length === 0) break;
for (const og of batch) {
const p = parseOriginName(og.goodName);
if (!p.skuCode && !p.craftLabel) unparsable.push(`#${og.id} ${og.goodName ?? ''}`);
await prisma.originGood.update({
where: { id: og.id },
data: {
skuCode: p.skuCode,
logisticsLabel: p.logisticsLabel,
craftLabel: p.craftLabel,
warehouseLabel: p.warehouseLabel,
},
});
}
parsed += batch.length;
}
console.log(`[1/2] parsed ${parsed} origin goods (${unparsable.length} without sku/craft)`);
// ---- 2. 自动建族(含逐族重算) ----
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: 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}`);
if (unparsable.length) {
console.log('unparsable names:');
for (const line of unparsable) console.log(` - ${line}`);
}
await prisma.$disconnect();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -0,0 +1,79 @@
-- AlterTable
ALTER TABLE "origin_goods" ADD COLUMN "craft_label" TEXT,
ADD COLUMN "family_id" BIGINT,
ADD COLUMN "logistics_label" TEXT,
ADD COLUMN "sku_code" TEXT,
ADD COLUMN "warehouse_label" TEXT;
-- CreateTable
CREATE TABLE "product_families" (
"family_id" BIGSERIAL NOT NULL,
"family_code" TEXT,
"family_name" TEXT NOT NULL,
"family_image" TEXT,
"country_id" BIGINT,
"category_id" BIGINT,
"primary_origin_good_id" BIGINT,
"detail" JSONB,
"size_chart" JSONB,
"package_specs" JSONB,
"price_matrix" JSONB,
"auto_managed" BOOLEAN NOT NULL DEFAULT true,
"stale" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "product_families_pkey" PRIMARY KEY ("family_id")
);
-- CreateTable
CREATE TABLE "family_price_overrides" (
"family_price_override_id" BIGSERIAL NOT NULL,
"family_id" BIGINT NOT NULL,
"size_id" TEXT NOT NULL,
"color_id" TEXT NOT NULL,
"craft" TEXT NOT NULL,
"logistics" TEXT NOT NULL,
"price" DECIMAL(12,2) NOT NULL,
"note" TEXT,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "family_price_overrides_pkey" PRIMARY KEY ("family_price_override_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "product_families_family_code_key" ON "product_families"("family_code");
-- CreateIndex
CREATE INDEX "product_families_country_id_idx" ON "product_families"("country_id");
-- CreateIndex
CREATE INDEX "product_families_category_id_idx" ON "product_families"("category_id");
-- CreateIndex
CREATE UNIQUE INDEX "family_price_overrides_family_id_size_id_color_id_craft_log_key" ON "family_price_overrides"("family_id", "size_id", "color_id", "craft", "logistics");
-- CreateIndex
CREATE INDEX "origin_goods_family_id_idx" ON "origin_goods"("family_id");
-- CreateIndex
CREATE INDEX "origin_goods_sku_code_idx" ON "origin_goods"("sku_code");
-- CreateIndex
CREATE INDEX "origin_goods_logistics_label_idx" ON "origin_goods"("logistics_label");
-- CreateIndex
CREATE INDEX "origin_goods_craft_label_idx" ON "origin_goods"("craft_label");
-- AddForeignKey
ALTER TABLE "origin_goods" ADD CONSTRAINT "origin_goods_family_id_fkey" FOREIGN KEY ("family_id") REFERENCES "product_families"("family_id") ON DELETE SET NULL ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "product_families" ADD CONSTRAINT "product_families_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("country_id") ON DELETE SET NULL ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "product_families" ADD CONSTRAINT "product_families_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("category_id") ON DELETE SET NULL ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "family_price_overrides" ADD CONSTRAINT "family_price_overrides_family_id_fkey" FOREIGN KEY ("family_id") REFERENCES "product_families"("family_id") ON DELETE CASCADE ON UPDATE NO ACTION;
+62
View File
@@ -30,9 +30,15 @@ model OriginGood {
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
delisted Boolean @default(false)
source OriginGoodSource @default(SDS)
familyId BigInt? @map("family_id")
skuCode String? @map("sku_code")
logisticsLabel String? @map("logistics_label")
craftLabel String? @map("craft_label")
warehouseLabel String? @map("warehouse_label")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
family ProductFamily? @relation(fields: [familyId], references: [id], onDelete: SetNull, onUpdate: NoAction)
goods Good[]
detail OriginGoodDetail?
variants OriginGoodVariant[]
@@ -40,6 +46,10 @@ model OriginGood {
@@index([sdsCategoryId])
@@index([source])
@@index([familyId])
@@index([skuCode])
@@index([logisticsLabel])
@@index([craftLabel])
@@map("origin_goods")
}
@@ -118,6 +128,7 @@ model Country {
goods Good[]
positions Position[]
families ProductFamily[]
@@map("countries")
}
@@ -136,6 +147,7 @@ model Category {
children Category[] @relation("CategoryToCategory")
goods Good[]
positions Position[]
families ProductFamily[]
@@index([parentCategoryId])
@@map("categories")
@@ -258,6 +270,56 @@ model GoodOriginGood {
@@map("good_origin_goods")
}
// ---------- Product Families (SPU layer over origin goods) ----------
// 设计文档:docs/superpowers/specs/2026-08-28-product-family-merge-design.md
// primaryOriginGoodId 刻意不建 FK:避免与 origin_goods 相互依赖,主链接被同步删除时由服务层清理。
model ProductFamily {
id BigInt @id @default(autoincrement()) @map("family_id")
familyCode String? @unique @map("family_code")
familyName String @map("family_name")
familyImage String? @map("family_image")
countryId BigInt? @map("country_id")
categoryId BigInt? @map("category_id")
primaryOriginGoodId BigInt? @map("primary_origin_good_id")
detail Json?
sizeChart Json? @map("size_chart")
packageSpecs Json? @map("package_specs")
priceMatrix Json? @map("price_matrix")
autoManaged Boolean @default(true) @map("auto_managed")
stale Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
originGoods OriginGood[]
priceOverrides FamilyPriceOverride[]
country Country? @relation(fields: [countryId], references: [id], onDelete: SetNull, onUpdate: NoAction)
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull, onUpdate: NoAction)
@@index([countryId])
@@index([categoryId])
@@map("product_families")
}
// ---------- Family Price Overrides (manual per-cell price) ----------
// 独立于推导矩阵:重算只重建推导部分,本表永不被动覆盖。
model FamilyPriceOverride {
id BigInt @id @default(autoincrement()) @map("family_price_override_id")
familyId BigInt @map("family_id")
sizeId String @map("size_id")
colorId String @map("color_id")
craft String
logistics String
price Decimal @db.Decimal(12, 2)
note String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
family ProductFamily @relation(fields: [familyId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@unique([familyId, sizeId, colorId, craft, logistics])
@@map("family_price_overrides")
}
// ---------- Users (admin authentication) ----------
enum Role {
ADMIN
+2
View File
@@ -11,6 +11,7 @@ import { TagsModule } from './tags/tags.module';
import { TagGroupsModule } from './tag-groups/tag-groups.module';
import { PositionsModule } from './positions/positions.module';
import { OriginGoodsModule } from './origin-goods/origin-goods.module';
import { ProductFamiliesModule } from './product-families/product-families.module';
import { GoodsModule } from './goods/goods.module';
import { SyncModule } from './sync/sync.module';
import { PublicModule } from './public/public.module';
@@ -37,6 +38,7 @@ import { UploadModule } from './upload/upload.module';
TagGroupsModule,
PositionsModule,
OriginGoodsModule,
ProductFamiliesModule,
GoodsModule,
SyncModule,
PublicModule,
+25
View File
@@ -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()
+6 -5
View File
@@ -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],
+5
View File
@@ -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);
+20
View File
@@ -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,
})),
});
}
@@ -0,0 +1,270 @@
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsNotEmpty,
IsNumber,
IsNumberString,
IsObject,
IsOptional,
IsString,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
export class CreateProductFamilyDto {
@ApiProperty({ example: '180g纯棉T恤(成人款)' })
@IsString()
@IsNotEmpty()
@MaxLength(200)
familyName!: string;
@ApiProperty({ required: false, example: 'DG001' })
@IsOptional()
@IsString()
@MaxLength(100)
familyCode?: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
familyImage?: string | null;
@ApiProperty({ required: false, example: '1' })
@IsOptional()
@IsNumberString()
countryId?: string;
@ApiProperty({ required: false, example: '2' })
@IsOptional()
@IsNumberString()
categoryId?: string;
@ApiProperty({
required: false,
description: '主链接 origin_good_idcanonical 详情与跳转兜底)',
example: '123',
})
@IsOptional()
@IsNumberString()
primaryOriginGoodId?: string;
@ApiProperty({ required: false, type: [String], example: ['123', '124'] })
@IsOptional()
@IsArray()
@IsNumberString({}, { each: true })
originGoodIds?: string[];
}
export class PatchProductFamilyDto extends PartialType(CreateProductFamilyDto) {
@ApiProperty({ required: false, description: 'false = 人工锁定,重算只置 stale' })
@IsOptional()
@IsBoolean()
autoManaged?: boolean;
}
export class QueryProductFamilyDto {
@ApiProperty({ required: false, description: '匹配 familyName / familyCode' })
@IsOptional()
@IsString()
keyword?: string;
@ApiProperty({ required: false, default: 1 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number;
@ApiProperty({ required: false, default: 20 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
pageSize?: number;
}
export class AutoGroupDto {
@ApiProperty({ required: false, default: false, description: 'true = 实际建族' })
@IsOptional()
@IsBoolean()
apply?: boolean;
}
export class UpdateFamilyMembersDto {
@ApiProperty({ required: false, type: [String] })
@IsOptional()
@IsArray()
@IsNumberString({}, { each: true })
addOriginGoodIds?: string[];
@ApiProperty({ required: false, type: [String] })
@IsOptional()
@IsArray()
@IsNumberString({}, { each: true })
removeOriginGoodIds?: string[];
}
export class CustomMemberVariantDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
sku!: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
sizeId?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
sizeName?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
colorId?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
colorName?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
colorHex?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
imageUrl?: string | null;
@ApiProperty({ example: '28.00' })
@IsNumber()
@Min(0.01)
price!: number;
}
export class CreateCustomMemberDto {
@ApiProperty({ example: '美国(包邮)180g纯棉T恤-DG001-双面印花(自建)' })
@IsString()
@IsNotEmpty()
@MaxLength(300)
goodName!: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
goodImage?: string | null;
@ApiProperty({ description: '物流归因(价格矩阵维度,必填)', example: '包邮' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
logisticsLabel!: string;
@ApiProperty({ description: '工艺/印花数量归因(价格矩阵维度,必填)', example: '双面印花' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
craftLabel!: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
skuCode?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
warehouseLabel?: string;
@ApiProperty({ type: [CustomMemberVariantDto], minItems: 1 })
@IsArray()
@ValidateNested({ each: true })
@Type(() => CustomMemberVariantDto)
variants!: CustomMemberVariantDto[];
@ApiProperty({
required: false,
type: Object,
description: '可选人工详情(尺码表/包装规则参与并集)',
})
@IsOptional()
@IsObject()
detail?: { sizeChart?: Record<string, unknown>; packageSpecs?: Record<string, unknown> };
}
export class PriceOverrideItemDto {
@ApiProperty({ example: 'size_S' })
@IsString()
@IsNotEmpty()
sizeId!: string;
@ApiProperty({ example: 'color_blk' })
@IsString()
@IsNotEmpty()
colorId!: string;
@ApiProperty({ example: '单面印花' })
@IsString()
@IsNotEmpty()
craft!: string;
@ApiProperty({ example: '包邮' })
@IsString()
@IsNotEmpty()
logistics!: string;
@ApiProperty({ example: '23.00' })
@IsNumber()
@Min(0.01)
price!: number;
@ApiProperty({ required: false, description: '改价原因(审计)' })
@IsOptional()
@IsString()
note?: string;
}
export class PutPriceOverridesDto {
@ApiProperty({ type: [PriceOverrideItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => PriceOverrideItemDto)
items!: PriceOverrideItemDto[];
}
export class PriceOverrideCellDto {
@ApiProperty({ example: 'size_S' })
@IsString()
@IsNotEmpty()
sizeId!: string;
@ApiProperty({ example: 'color_blk' })
@IsString()
@IsNotEmpty()
colorId!: string;
@ApiProperty({ example: '单面印花' })
@IsString()
@IsNotEmpty()
craft!: string;
@ApiProperty({ example: '包邮' })
@IsString()
@IsNotEmpty()
logistics!: string;
}
export class DeletePriceOverridesDto {
@ApiProperty({ type: [PriceOverrideCellDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => PriceOverrideCellDto)
cells!: PriceOverrideCellDto[];
}
@@ -0,0 +1,287 @@
import { Test } from '@nestjs/testing';
import { Prisma } from '@prisma/client';
import { FamilyRecomputeService } from './family-recompute.service';
import { PrismaService } from '../prisma/prisma.service';
/**
* 集成测试(连真实库,与 goods.service.spec.ts 同模式):
* 验证并集尺码表/包装规则裁决、五维价格矩阵推导(最低价/来源累积/维度过滤)、
* 覆盖合并、autoManaged 锁定语义与 canonical detail 初始化。
*/
describe('FamilyRecomputeService', () => {
let service: FamilyRecomputeService;
let prisma: PrismaService;
const stamp = Date.now();
const createdOriginGoodIds: bigint[] = [];
const createdFamilyIds: bigint[] = [];
const mkOriginGood = async (over: {
craftLabel?: string | null;
logisticsLabel?: string | null;
variants?: Array<{
sdsVariantId: string;
sku: string;
sizeId?: string;
sizeName?: string;
colorId?: string;
colorName?: string;
price: number;
enabled?: boolean;
}>;
sizeChart?: object;
packageSpecs?: object;
}) => {
const og = await prisma.originGood.create({
data: {
sdsGoodId: `recompute-${stamp}-${createdOriginGoodIds.length}-${Math.random().toString(36).slice(2, 7)}`,
goodName: `测试链接-${stamp}`,
source: 'CUSTOM',
craftLabel: over.craftLabel ?? null,
logisticsLabel: over.logisticsLabel ?? null,
},
});
createdOriginGoodIds.push(og.id);
if (over.variants?.length) {
await prisma.originGoodVariant.createMany({
data: over.variants.map((v) => ({
originGoodId: og.id,
sdsVariantId: v.sdsVariantId,
sku: v.sku,
sizeId: v.sizeId ?? null,
sizeName: v.sizeName ?? null,
colorId: v.colorId ?? null,
colorName: v.colorName ?? null,
price: new Prisma.Decimal(v.price),
enabled: v.enabled ?? true,
})),
});
}
if (over.sizeChart || over.packageSpecs) {
await prisma.originGoodDetail.create({
data: {
originGoodId: og.id,
sizeChart: (over.sizeChart ?? undefined) as Prisma.InputJsonValue,
packageSpecs: (over.packageSpecs ?? undefined) as Prisma.InputJsonValue,
},
});
}
return og;
};
const mkFamily = async (over: {
primaryOriginGoodId?: bigint | null;
autoManaged?: boolean;
memberIds?: bigint[];
}) => {
const family = await prisma.productFamily.create({
data: {
familyName: `测试族-${stamp}-${createdFamilyIds.length}`,
autoManaged: over.autoManaged ?? true,
primaryOriginGoodId: over.primaryOriginGoodId ?? null,
},
});
createdFamilyIds.push(family.id);
if (over.memberIds?.length) {
await prisma.originGood.updateMany({
where: { id: { in: over.memberIds } },
data: { familyId: family.id },
});
}
return family;
};
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [FamilyRecomputeService, PrismaService],
}).compile();
service = moduleRef.get(FamilyRecomputeService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
});
afterAll(async () => {
await prisma.originGood.deleteMany({ where: { id: { in: createdOriginGoodIds } } });
await prisma.productFamily.deleteMany({ where: { id: { in: createdFamilyIds } } });
await prisma.$disconnect();
});
it('并集尺码表:覆盖最全的成员优先、主链接次之、按首现顺序合并', async () => {
// A:主链接,2 行;C:非主链接,3 行 → C 的 S 行胜出
const a = await mkOriginGood({
craftLabel: '单面印花',
logisticsLabel: '包邮',
sizeChart: { columns: [{ key: 'chest', name: '胸围' }], rows: [
{ sizeId: 'size_S', sizeName: 'S', chest: 100 },
{ sizeId: 'size_XL', sizeName: 'XL', chest: 108 },
] },
});
const c = await mkOriginGood({
craftLabel: '单面印花',
logisticsLabel: '包邮',
sizeChart: { columns: [{ key: 'chest', name: '胸围' }], rows: [
{ sizeId: 'size_S', sizeName: 'S', chest: 98 },
{ sizeId: 'size_XXL', sizeName: 'XXL', chest: 112 },
{ sizeId: 'size_XXXL', sizeName: 'XXXL', chest: 116 },
] },
});
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, c.id] });
await service.recomputeFamily(family.id);
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
const chart = after.sizeChart as { rows: Array<{ sizeName: string; chest: number }> };
const names = chart.rows.map((r) => r.sizeName);
expect(names).toEqual(['S', 'XXL', 'XXXL', 'XL']); // C(3行) 优先遍历,A 补充 XL
expect(chart.rows.find((r) => r.sizeName === 'S')?.chest).toBe(98); // 冲突取覆盖最全成员
expect(after.stale).toBe(false);
});
it('价格矩阵:同格子取最低价并累积来源;缺失归因维度/停用变体不参与', async () => {
const a = await mkOriginGood({
craftLabel: '单面印花',
logisticsLabel: '包邮',
variants: [
{ sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
{ sdsVariantId: 'v2', sku: 'A-XL-WHT', sizeId: 'size_XL', sizeName: 'XL', colorId: 'color_wht', colorName: '白色', price: 27 },
{ sdsVariantId: 'v3', sku: 'A-S-BLK-OFF', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 20, enabled: false },
],
});
const b = await mkOriginGood({
craftLabel: '单面印花',
logisticsLabel: '包邮', // 同格子(不同仓库)
variants: [
{ sdsVariantId: 'v4', sku: 'B-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 24.5 },
{ sdsVariantId: 'v5', sku: 'B-XXXL-BLK', sizeId: 'size_XXXL', sizeName: 'XXXL', colorId: 'color_blk', colorName: '黑色', price: 29 },
],
});
const d = await mkOriginGood({
craftLabel: '双面印花',
logisticsLabel: '专线',
variants: [
{ sdsVariantId: 'v6', sku: 'D-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 30 },
],
});
const e = await mkOriginGood({
craftLabel: null, // 缺工艺 → 不参与矩阵
logisticsLabel: '包邮',
variants: [
{ sdsVariantId: 'v7', sku: 'E-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 1 },
],
});
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, b.id, d.id, e.id] });
await service.recomputeFamily(family.id);
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
const matrix = after.priceMatrix as any;
const cell = matrix.rows.find((r: any) => r.sizeId === 'size_S' && r.colorId === 'color_blk' && r.craft === '单面印花' && r.logistics === '包邮');
expect(cell.price).toBe('24.5');
expect(cell.manual).toBe(false);
expect(cell.sources).toHaveLength(2); // a.v1 + b.v4;停用 v3 排除
expect(matrix.rows.find((r: any) => r.craft === '双面印花' && r.price === '30')).toBeTruthy();
expect(matrix.rows.some((r: any) => Number(r.price) === 1)).toBe(false); // e 缺归因被排除
expect(matrix.crafts.sort()).toEqual(['单面印花', '双面印花']);
expect(matrix.logistics.sort()).toEqual(['专线', '包邮']);
expect(matrix.sizes.map((s: any) => s.name).sort()).toEqual(['S', 'XL', 'XXXL']);
});
it('覆盖:命中改价 manual=true,未命中新增行并扩充选项', async () => {
const a = await mkOriginGood({
craftLabel: '单面印花',
logisticsLabel: '包邮',
variants: [
{ sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
],
});
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] });
await prisma.familyPriceOverride.createMany({
data: [
{ familyId: family.id, sizeId: 'size_S', colorId: 'color_blk', craft: '单面印花', logistics: '包邮', price: new Prisma.Decimal(23) },
{ familyId: family.id, sizeId: 'size_M', colorId: 'color_red', craft: '三面印花', logistics: '海运', price: new Prisma.Decimal(40) },
],
});
await service.recomputeFamily(family.id);
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
const matrix = after.priceMatrix as any;
const hit = matrix.rows.find((r: any) => r.sizeId === 'size_S' && r.colorId === 'color_blk');
expect(hit.price).toBe('23');
expect(hit.manual).toBe(true);
const added = matrix.rows.find((r: any) => r.sizeId === 'size_M' && r.craft === '三面印花');
expect(added).toBeTruthy();
expect(added.manual).toBe(true);
expect(added.sources).toEqual([]);
expect(matrix.crafts).toContain('三面印花');
expect(matrix.logistics).toContain('海运');
expect(matrix.sizes.some((s: any) => s.key === 'size_M')).toBe(true);
});
it('autoManaged=false:只置 stale,不覆盖物化字段', async () => {
const a = await mkOriginGood({
craftLabel: '单面印花',
logisticsLabel: '包邮',
variants: [
{ sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
],
});
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id], autoManaged: false });
await prisma.productFamily.update({
where: { id: family.id },
data: { priceMatrix: { version: 'locked' } as Prisma.InputJsonValue },
});
await service.recomputeFamily(family.id);
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
expect(after.stale).toBe(true);
expect((after.priceMatrix as any).version).toBe('locked'); // 未被覆盖
});
it('canonical detail:为空时从主链接初始化一次,非空不动', async () => {
const a = await mkOriginGood({ craftLabel: '单面印花', logisticsLabel: '包邮' });
await prisma.originGoodDetail.create({
data: { originGoodId: a.id, englishName: 'Cotton Tee', materialDescription: '100% 棉' },
});
const b = await mkOriginGood({ craftLabel: '单面印花', logisticsLabel: '包邮' });
await prisma.originGoodDetail.create({
data: { originGoodId: b.id, englishName: 'Should Not Win' },
});
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, b.id] });
await service.recomputeFamily(family.id);
let after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
expect((after.detail as any).englishName).toBe('Cotton Tee');
expect((after.detail as any).materialDescription).toBe('100% 棉');
await prisma.productFamily.update({
where: { id: family.id },
data: { detail: { englishName: '人工已改' } as Prisma.InputJsonValue },
});
await service.recomputeFamily(family.id);
after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
expect((after.detail as any).englishName).toBe('人工已改');
});
it('族不存在时 no-op 不抛错', async () => {
await expect(service.recomputeFamily(999999n)).resolves.toBeUndefined();
});
it('enqueue 去重且串行执行', async () => {
const a = await mkOriginGood({
craftLabel: '单面印花',
logisticsLabel: '包邮',
variants: [{ sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 }],
});
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] });
const spy = jest.spyOn(service, 'recomputeFamily').mockResolvedValue();
service.enqueue(family.id);
service.enqueue(family.id);
service.enqueue(family.id);
await new Promise((r) => setTimeout(r, 50));
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
});
@@ -0,0 +1,293 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
/**
* 产品族重算:并集尺码表/包装规则 + 五维价格矩阵物化。
* 设计:docs/superpowers/specs/2026-08-28-product-family-merge-design.md §7-§9
*
* 关键不变量:
* - 推导输入全部为确定性数据(成员镜像、覆盖表),重算幂等;
* - autoManaged=false 的族只置 stale,绝不覆盖人工物化字段;
* - 覆盖表与自定义成员数据只读,永不写回。
*/
export interface PriceMatrixSource {
sdsGoodId: string;
sdsVariantId: string;
price: string;
}
export interface PriceMatrixRow {
sizeId: string;
sizeName: string | null;
colorId: string;
colorName: string | null;
craft: string;
logistics: string;
price: string;
manual: boolean;
sources: PriceMatrixSource[];
}
export interface PriceMatrix {
sizes: Array<{ key: string; name: string | null }>;
colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>;
crafts: string[];
logistics: string[];
rows: PriceMatrixRow[];
}
export interface ChartSizeRow {
sizeId?: string | null;
sizeName?: string | null;
[key: string]: unknown;
}
export interface ChartLike {
columns?: unknown;
rows?: ChartSizeRow[];
}
type Member = Prisma.OriginGoodGetPayload<{
include: { detail: true; variants: true };
}>;
type OverrideRow = Prisma.FamilyPriceOverrideGetPayload<{}>;
function chartRowKey(row: ChartSizeRow): string {
return String(row.sizeName ?? row.sizeId ?? '');
}
/**
* 按优先级顺序(已排序的成员 → 各自的 chart)合并尺码行并集。
* 同键冲突时先到先得 —— 调用方负责把"覆盖最全的成员 / 主链接"排在前面。
*/
export function unionChartRows(charts: Array<ChartLike | null | undefined>): ChartSizeRow[] {
const out: ChartSizeRow[] = [];
const seen = new Set<string>();
for (const chart of charts) {
for (const row of chart?.rows ?? []) {
const key = chartRowKey(row);
if (!key || seen.has(key)) continue;
seen.add(key);
out.push(row);
}
}
return out;
}
function firstColumns(charts: Array<ChartLike | null | undefined>): unknown {
for (const chart of charts) {
if (chart?.columns !== undefined && chart?.columns !== null) return chart.columns;
}
return undefined;
}
/**
* 推导五维价格矩阵:(sizeKey, colorKey, craft, logistics) → 价格。
* 尺寸键 = variant.sizeId ?? variant.sizeName;颜色键 = variant.colorId ?? variant.colorName。
* 同格子多来源取最低价,sources 全保留;随后合并人工覆盖(manual=true)。
*/
export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]): PriceMatrix {
const sizes = new Map<string, string | null>();
const colors = new Map<string, { name: string | null; hex: string | null; imageUrl: string | null }>();
const cellMap = new Map<string, PriceMatrixRow>();
for (const member of members) {
if (!member.craftLabel || !member.logisticsLabel) continue;
for (const variant of member.variants) {
if (variant.price === null) continue;
const sizeKey = String(variant.sizeId ?? variant.sizeName ?? '');
const colorKey = String(variant.colorId ?? variant.colorName ?? '');
if (!sizeKey && !colorKey) continue;
if (sizeKey && !sizes.has(sizeKey)) sizes.set(sizeKey, variant.sizeName);
if (colorKey) {
const prev = colors.get(colorKey);
colors.set(colorKey, {
name: prev?.name ?? variant.colorName,
hex: prev?.hex ?? variant.colorHex,
imageUrl: prev?.imageUrl ?? variant.imageUrl,
});
}
const key = `${sizeKey}|${colorKey}|${member.craftLabel}|${member.logisticsLabel}`;
const priceStr = variant.price.toString();
const source: PriceMatrixSource = {
sdsGoodId: member.sdsGoodId,
sdsVariantId: variant.sdsVariantId,
price: priceStr,
};
const existing = cellMap.get(key);
if (!existing) {
cellMap.set(key, {
sizeId: sizeKey,
sizeName: variant.sizeName,
colorId: colorKey,
colorName: variant.colorName,
craft: member.craftLabel,
logistics: member.logisticsLabel,
price: priceStr,
manual: false,
sources: [source],
});
} else {
existing.sources.push(source);
if (Number(variant.price) < Number(existing.price)) existing.price = priceStr;
}
}
}
const crafts = [...new Set(members.map((m) => m.craftLabel).filter((c): c is string => !!c))];
const logisticsOptions = [
...new Set(members.map((m) => m.logisticsLabel).filter((l): l is string => !!l)),
];
const rows = [...cellMap.values()];
// 人工覆盖:命中改价,未命中新增行(人工补组合),选项并入覆盖用到的取值
for (const override of overrides) {
if (!crafts.includes(override.craft)) crafts.push(override.craft);
if (!logisticsOptions.includes(override.logistics)) logisticsOptions.push(override.logistics);
if (override.sizeId && !sizes.has(override.sizeId)) sizes.set(override.sizeId, null);
if (override.colorId && !colors.has(override.colorId)) {
colors.set(override.colorId, { name: null, hex: null, imageUrl: null });
}
const key = `${override.sizeId}|${override.colorId}|${override.craft}|${override.logistics}`;
const row = cellMap.get(key);
if (row) {
row.price = override.price.toString();
row.manual = true;
} else {
const added: PriceMatrixRow = {
sizeId: override.sizeId,
sizeName: sizes.get(override.sizeId) ?? null,
colorId: override.colorId,
colorName: colors.get(override.colorId)?.name ?? null,
craft: override.craft,
logistics: override.logistics,
price: override.price.toString(),
manual: true,
sources: [],
};
cellMap.set(key, added);
rows.push(added);
}
}
return {
sizes: [...sizes.entries()].map(([key, name]) => ({ key, name })),
colors: [...colors.entries()].map(([key, v]) => ({ key, ...v })),
crafts,
logistics: logisticsOptions,
rows,
};
}
/** canonical detail 的文本字段(从主链接 detail 摘取,不含 JSON 物化与时间戳) */
function pickDetailText(detail: Prisma.OriginGoodDetailGetPayload<{}>): Prisma.InputJsonValue {
return {
productCode: detail.productCode,
englishName: detail.englishName,
blankDesignUrl: detail.blankDesignUrl,
detailsPageVideoUrl: detail.detailsPageVideoUrl,
textureName: detail.textureName,
productionCycleHours: detail.productionCycleHours,
minWeightG: detail.minWeightG ? detail.minWeightG.toString() : null,
reminder: detail.reminder,
productionProcess: detail.productionProcess,
materialDescription: detail.materialDescription,
productPerformance: detail.productPerformance,
applicableScenarios: detail.applicableScenarios,
washingInstructions: detail.washingInstructions,
specialDescription: detail.specialDescription,
designExplanation: detail.designExplanation,
designArea: detail.designArea,
pictureRequest: detail.pictureRequest,
};
}
const json = (value: unknown): Prisma.InputJsonValue | typeof Prisma.DbNull =>
(value === undefined || value === null ? Prisma.DbNull : value) as Prisma.InputJsonValue;
@Injectable()
export class FamilyRecomputeService {
private readonly logger = new Logger(FamilyRecomputeService.name);
private readonly pending = new Map<string, Promise<void>>();
constructor(private readonly prisma: PrismaService) {}
/** 进程内去重的异步重算入口(同步钩子用) */
enqueue(familyId: bigint): void {
const key = familyId.toString();
if (this.pending.has(key)) return;
const run = this.recomputeFamily(familyId)
.catch((error) => {
this.logger.error(`family ${key} recompute failed: ${String(error)}`);
})
.finally(() => {
this.pending.delete(key);
});
this.pending.set(key, run);
}
async recomputeFamily(familyId: bigint): Promise<void> {
const family = await this.prisma.productFamily.findUnique({
where: { id: familyId },
include: {
originGoods: {
where: { delisted: false },
include: {
detail: true,
variants: { where: { enabled: true }, orderBy: { sortOrder: 'asc' } },
},
},
priceOverrides: true,
},
});
if (!family) return;
const members = family.originGoods;
const primary = members.find((m) => m.id === family.primaryOriginGoodId) ?? members[0] ?? null;
// 冲突裁决优先级:尺码覆盖最全 → 主链接 → id 升序(与设计 §7 一致)
const priority = [...members].sort((a, b) => {
const aRows = a.detail?.sizeChart ? ((a.detail.sizeChart as ChartLike).rows?.length ?? 0) : 0;
const bRows = b.detail?.sizeChart ? ((b.detail.sizeChart as ChartLike).rows?.length ?? 0) : 0;
if (bRows !== aRows) return bRows - aRows;
const aPrimary = a.id === primary?.id ? 1 : 0;
const bPrimary = b.id === primary?.id ? 1 : 0;
if (aPrimary !== bPrimary) return bPrimary - aPrimary;
return Number(a.id - b.id);
});
const sizeCharts = priority.map((m) => m.detail?.sizeChart as ChartLike | undefined);
const packageCharts = priority.map((m) => m.detail?.packageSpecs as ChartLike | undefined);
const sizeChart = unionChartRows(sizeCharts).length
? { columns: firstColumns(sizeCharts), rows: unionChartRows(sizeCharts) }
: null;
const packageSpecs = unionChartRows(packageCharts).length
? { columns: firstColumns(packageCharts), rows: unionChartRows(packageCharts) }
: null;
const matrix = derivePriceMatrix(members, family.priceOverrides);
if (family.autoManaged) {
await this.prisma.productFamily.update({
where: { id: family.id },
data: {
sizeChart: json(sizeChart),
packageSpecs: json(packageSpecs),
priceMatrix: json(matrix),
stale: false,
// canonical detail 仅在为空时从主链接初始化一次,人工编辑后永不被覆盖
...(family.detail === null && primary?.detail
? { detail: pickDetailText(primary.detail) }
: {}),
},
});
} else {
await this.prisma.productFamily.update({
where: { id: family.id },
data: { stale: true },
});
}
}
}
@@ -0,0 +1,96 @@
import { parseOriginName, originGroupKey } from './origin-name.parser';
describe('parseOriginName', () => {
it('解析完整四段名(含仓库)', () => {
expect(
parseOriginName('美国(不包邮)240g涤纶休闲短裤-DG206-单面印花-美西洛杉矶一仓'),
).toEqual({
country: '美国',
logisticsLabel: '不包邮',
productName: '240g涤纶休闲短裤',
skuCode: 'DG206',
craftLabel: '单面印花',
warehouseLabel: '美西洛杉矶一仓',
});
});
it('解析三段名(无仓库)', () => {
expect(
parseOriginName('墨西哥(不包邮)180g纯棉女装修身T恤-METP001-单面印花'),
).toEqual({
country: '墨西哥',
logisticsLabel: '不包邮',
productName: '180g纯棉女装修身T恤',
skuCode: 'METP001',
craftLabel: '单面印花',
warehouseLabel: null,
});
});
it('物流备注含星号等符号原样保留', () => {
const r = parseOriginName(
'波兰(包邮*运费订单结算时支付)250g男女同款抓毛圆领卫衣-PLHM002-双面印花',
);
expect(r.logisticsLabel).toBe('包邮*运费订单结算时支付');
expect(r.skuCode).toBe('PLHM002');
});
it('半角括号也能解析', () => {
const r = parseOriginName('美国(包邮)T恤-DG001-单面印花');
expect(r.country).toBe('美国');
expect(r.logisticsLabel).toBe('包邮');
expect(r.productName).toBe('T恤');
});
it('段1无括号时 country=整段、物流为空', () => {
const r = parseOriginName('美国T恤-DG001-单面印花');
expect(r.country).toBe('美国T恤');
expect(r.logisticsLabel).toBeNull();
expect(r.productName).toBeNull();
});
it('两段名:只解析国家/物流/品名/SKU', () => {
const r = parseOriginName('美国(包邮)T恤-DG001');
expect(r.skuCode).toBe('DG001');
expect(r.craftLabel).toBeNull();
expect(r.warehouseLabel).toBeNull();
});
it('一段名/空值/null 安全', () => {
expect(parseOriginName('随便一个名字').skuCode).toBeNull();
expect(parseOriginName('随便一个名字').country).toBe('随便一个名字');
expect(parseOriginName(null).country).toBeNull();
expect(parseOriginName('').country).toBeNull();
});
it('段前后空格被 trim', () => {
const r = parseOriginName('美国(包邮) T恤 - DG001 - 单面印花');
expect(r.skuCode).toBe('DG001');
expect(r.craftLabel).toBe('单面印花');
expect(r.productName).toBe('T恤');
});
it('仓库段含连字符时整体保留', () => {
const r = parseOriginName('美国(包邮)T恤-DG001-单面印花-某仓-二期');
expect(r.warehouseLabel).toBe('某仓-二期');
});
});
describe('originGroupKey', () => {
it('与 admin truncateToProcess 语义一致:前3段', () => {
expect(originGroupKey('美国(包邮)T恤-DG001-单面印花-美西一仓')).toBe(
'美国(包邮)T恤-DG001-单面印花',
);
});
it('物流差异导致不同组', () => {
expect(originGroupKey('美国(不包邮)T恤-DG001-单面印花')).not.toBe(
originGroupKey('美国(包邮)T恤-DG001-单面印花'),
);
});
it('空名返回空串', () => {
expect(originGroupKey(null)).toBe('');
expect(originGroupKey('')).toBe('');
});
});
@@ -0,0 +1,69 @@
/**
* 原产品链接名结构化解析。
*
* 名称格式(与 admin 端 utils/origin-name.ts 的调查结论一致):
* `国家(物流备注)品名-SKU-工艺位置[-仓库名]`
*
* 仓库段为可选;段与段之间以 `-` 分隔,段 1 内国家与物流备注以全角(或半角)括号分隔。
* 解析失败的字段置 null,不抛异常 —— 上游名称是自由文本,解析器必须容错。
*/
export interface ParsedOriginName {
country: string | null;
logisticsLabel: string | null;
productName: string | null;
skuCode: string | null;
craftLabel: string | null;
warehouseLabel: string | null;
}
const EMPTY: ParsedOriginName = {
country: null,
logisticsLabel: null,
productName: null,
skuCode: null,
craftLabel: null,
warehouseLabel: null,
};
function splitSegment1(seg: string): Pick<ParsedOriginName, 'country' | 'logisticsLabel' | 'productName'> {
const pairs: Array<[string, string]> = [
['', ''],
['(', ')'],
];
for (const [open, close] of pairs) {
const openAt = seg.indexOf(open);
if (openAt >= 0) {
const closeAt = seg.indexOf(close, openAt);
if (closeAt > openAt) {
return {
country: seg.slice(0, openAt).trim() || null,
logisticsLabel: seg.slice(openAt + 1, closeAt).trim() || null,
productName: seg.slice(closeAt + 1).trim() || null,
};
}
}
}
return { country: seg.trim() || null, logisticsLabel: null, productName: null };
}
export function parseOriginName(name: string | null | undefined): ParsedOriginName {
if (!name) return EMPTY;
const segs = name.split('-').map((s) => s.trim());
if (segs.length === 0 || segs[0] === '') return EMPTY;
return {
...splitSegment1(segs[0]),
skuCode: segs[1] || null,
craftLabel: segs[2] || null,
// 仓库名本身可能含连字符,第 4 段起整体保留
warehouseLabel: segs.length > 3 ? segs.slice(3).join('-') : null,
};
}
/**
* 分组键:与 admin 端 truncateToProcessKEEP_SEGMENTS = 3)语义一致,
* 保留按 `-` 分段的前 3 段,丢弃可选的仓库名段。
*/
export function originGroupKey(name: string | null | undefined): string {
if (!name) return '';
return name.split('-').slice(0, 3).join('-');
}
@@ -0,0 +1,88 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Put, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ProductFamiliesService } from './product-families.service';
import {
AutoGroupDto,
CreateCustomMemberDto,
CreateProductFamilyDto,
DeletePriceOverridesDto,
PatchProductFamilyDto,
PutPriceOverridesDto,
QueryProductFamilyDto,
UpdateFamilyMembersDto,
} from './dto/product-family.dto';
@ApiTags('product-families')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('product-families')
export class ProductFamiliesController {
constructor(private readonly service: ProductFamiliesService) {}
@Get()
@ApiOperation({ summary: 'List product families with keyword & pagination' })
list(@Query() query: QueryProductFamilyDto) {
return this.service.list(query);
}
@Post('auto-group')
@ApiOperation({ summary: 'Auto-group unassigned origin goods by 3-segment name key' })
autoGroup(@Body() dto: AutoGroupDto) {
return this.service.autoGroup(dto.apply ?? false);
}
@Post()
@ApiOperation({ summary: 'Create a product family (optionally attach members)' })
create(@Body() dto: CreateProductFamilyDto) {
return this.service.create(dto);
}
@Get(':id')
@ApiOperation({ summary: 'Family detail with members & overrides' })
detail(@Param('id', ParseIntPipe) id: string) {
return this.service.detail(BigInt(id));
}
@Patch(':id')
@ApiOperation({ summary: 'Patch canonical fields / autoManaged / primary link' })
patch(@Param('id', ParseIntPipe) id: string, @Body() dto: PatchProductFamilyDto) {
return this.service.patch(BigInt(id), dto);
}
@Post(':id/recompute')
@ApiOperation({ summary: 'Manually recompute union & price matrix' })
recompute(@Param('id', ParseIntPipe) id: string) {
return this.service.recomputeNow(BigInt(id));
}
@Post(':id/members')
@ApiOperation({ summary: 'Add / remove SDS member links' })
updateMembers(@Param('id', ParseIntPipe) id: string, @Body() dto: UpdateFamilyMembersDto) {
return this.service.updateMembers(BigInt(id), dto);
}
@Post(':id/members/custom')
@ApiOperation({ summary: 'Create a custom (manual) member inside the family' })
createCustomMember(@Param('id', ParseIntPipe) id: string, @Body() dto: CreateCustomMemberDto) {
return this.service.createCustomMember(BigInt(id), dto);
}
@Get(':id/price-overrides')
@ApiOperation({ summary: 'List manual price overrides with derived-price comparison' })
listOverrides(@Param('id', ParseIntPipe) id: string) {
return this.service.listPriceOverrides(BigInt(id));
}
@Put(':id/price-overrides')
@ApiOperation({ summary: 'Batch upsert manual price overrides' })
putOverrides(@Param('id', ParseIntPipe) id: string, @Body() dto: PutPriceOverridesDto) {
return this.service.putPriceOverrides(BigInt(id), dto.items);
}
@Delete(':id/price-overrides')
@ApiOperation({ summary: 'Remove overrides to restore derived prices' })
deleteOverrides(@Param('id', ParseIntPipe) id: string, @Body() dto: DeletePriceOverridesDto) {
return this.service.deletePriceOverrides(BigInt(id), dto);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { FamilyRecomputeService } from './family-recompute.service';
import { ProductFamiliesController } from './product-families.controller';
import { ProductFamiliesService } from './product-families.service';
@Module({
imports: [PrismaModule],
controllers: [ProductFamiliesController],
providers: [ProductFamiliesService, FamilyRecomputeService],
exports: [ProductFamiliesService, FamilyRecomputeService],
})
export class ProductFamiliesModule {}
@@ -0,0 +1,272 @@
import { Test } from '@nestjs/testing';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { ProductFamiliesService } from './product-families.service';
import { FamilyRecomputeService } from './family-recompute.service';
import { PrismaService } from '../prisma/prisma.service';
describe('ProductFamiliesService', () => {
let service: ProductFamiliesService;
let prisma: PrismaService;
const stamp = Date.now();
const createdOriginGoodIds: bigint[] = [];
const createdFamilyIds: bigint[] = [];
const mkOriginGood = async (name: string, over: Record<string, unknown> = {}) => {
const og = await prisma.originGood.create({
data: {
sdsGoodId: `famsvc-${stamp}-${createdOriginGoodIds.length}-${Math.random().toString(36).slice(2, 7)}`,
goodName: name,
...over,
},
});
createdOriginGoodIds.push(og.id);
return og;
};
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [ProductFamiliesService, FamilyRecomputeService, PrismaService],
}).compile();
service = moduleRef.get(ProductFamiliesService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
});
afterAll(async () => {
await prisma.originGood.deleteMany({ where: { id: { in: createdOriginGoodIds } } });
await prisma.productFamily.deleteMany({ where: { id: { in: createdFamilyIds } } });
await prisma.$disconnect();
});
it('create:挂成员、重算、familyCode 冲突自动加后缀', async () => {
const a = await mkOriginGood('美国(包邮)T恤-DGTEST-单面印花', {
craftLabel: '单面印花',
logisticsLabel: '包邮',
});
await prisma.originGoodVariant.create({
data: {
originGoodId: a.id,
sdsVariantId: 'svc-v1',
sku: 'T-S',
sizeId: 'size_S',
sizeName: 'S',
colorId: 'color_blk',
colorName: '黑色',
price: new Prisma.Decimal(25),
},
});
const f1 = (await service.create({
familyName: '测试T恤',
familyCode: 'DGTEST',
originGoodIds: [a.id.toString()],
primaryOriginGoodId: a.id.toString(),
})) as any;
createdFamilyIds.push(BigInt(f1.id));
expect(f1.familyCode).toBe('DGTEST');
expect(f1._count.originGoods).toBe(1);
expect((f1.priceMatrix as any).rows).toHaveLength(1);
const f2 = (await service.create({ familyName: '测试T恤二号', familyCode: 'DGTEST' })) as any;
createdFamilyIds.push(BigInt(f2.id));
expect(f2.familyCode).toBe('DGTEST-2');
});
it('detail:不存在 404', async () => {
await expect(service.detail(999999n)).rejects.toThrow(NotFoundException);
});
it('listkeyword 过滤 familyName/familyCode + 分页字段', async () => {
const res = (await service.list({ keyword: `测试T恤`, page: 1, pageSize: 10 })) as any;
expect(res.total).toBeGreaterThanOrEqual(2);
expect(res.items.length).toBeGreaterThanOrEqual(2);
expect(res.page).toBe(1);
const byCode = (await service.list({ keyword: 'DGTEST-2' })) as any;
expect(byCode.total).toBe(1);
});
it('patch:改 canonical 字段并触发重算,不丢成员', async () => {
const f = (await service.create({ familyName: `补丁族-${stamp}` })) as any;
createdFamilyIds.push(BigInt(f.id));
const patched = (await service.patch(BigInt(f.id), {
familyName: `补丁族改-${stamp}`,
autoManaged: false,
})) as any;
expect(patched.familyName).toBe(`补丁族改-${stamp}`);
expect(patched.autoManaged).toBe(false);
// 改回,避免影响后续用例
await service.patch(BigInt(f.id), { autoManaged: true });
});
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}-单面印花`);
const preview = (await service.autoGroup(false)) as any;
expect(preview.applied).toBe(0);
const hit = preview.groups.find((g: any) => g.groupKey.includes(`ZZ${stamp}`) && g.memberCount === 2);
expect(hit).toBeTruthy();
expect(hit.familyName).toContain('卫衣');
const applied = (await service.autoGroup(true)) as any;
expect(applied.applied).toBeGreaterThanOrEqual(2); // 包邮组 + 不包邮组(物流不同不同组)
const families = await prisma.productFamily.findMany({
where: { familyName: { contains: `自动组${stamp}` } },
});
for (const f of families) createdFamilyIds.push(f.id);
const grouped = await prisma.originGood.findMany({
where: { id: { in: [g1a.id, g1b.id, g2.id] } },
select: { familyId: true },
});
expect(grouped.every((g) => g.familyId !== null)).toBe(true);
expect(grouped[0].familyId).toBe(grouped[1].familyId); // 同组同族
expect(grouped[2].familyId).not.toBe(grouped[0].familyId); // 物流不同不同族
// 幂等:候选已清空
const again = (await service.autoGroup(true)) as any;
const hitAgain = again.groups.filter((g: any) => g.groupKey.includes(`ZZ${stamp}`));
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: '包邮' });
const c = await mkOriginGood(`成员${stamp}C`, { craftLabel: '单面印花', logisticsLabel: '包邮' });
const f = (await service.create({
familyName: `成员族-${stamp}`,
originGoodIds: [a.id.toString(), b.id.toString(), c.id.toString()],
primaryOriginGoodId: a.id.toString(),
})) as any;
createdFamilyIds.push(BigInt(f.id));
const removed = (await service.updateMembers(BigInt(f.id), {
removeOriginGoodIds: [a.id.toString()],
})) as any;
expect(removed._count.originGoods).toBe(2);
expect(BigInt(removed.primaryOriginGoodId)).toBe(b.id); // 落到剩余最小 id
const added = (await service.updateMembers(BigInt(f.id), {
addOriginGoodIds: [a.id.toString()],
})) as any;
expect(added._count.originGoods).toBe(3);
await expect(
service.updateMembers(BigInt(f.id), { addOriginGoodIds: ['999999'] }),
).rejects.toThrow(BadRequestException);
});
it('custom member:创建带归因的 CUSTOM 成员并入矩阵', async () => {
const f = (await service.create({ familyName: `自建族-${stamp}` })) as any;
createdFamilyIds.push(BigInt(f.id));
const member = (await service.createCustomMember(BigInt(f.id), {
goodName: `自建商品-${stamp}`,
logisticsLabel: '海运',
craftLabel: '双面印花',
skuCode: `ZZC${stamp}`,
variants: [{ sku: 'C-M', sizeId: 'size_M', sizeName: 'M', colorId: 'color_red', colorName: '红色', price: 33 }],
detail: { sizeChart: { rows: [{ sizeId: 'size_M', sizeName: 'M', chest: 100 }] } },
})) as any;
expect(member.sdsGoodId.startsWith('custom-')).toBe(true);
expect(member.logisticsLabel).toBe('海运');
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: BigInt(f.id) } });
const matrix = after.priceMatrix as any;
expect(matrix.rows).toHaveLength(1);
expect(matrix.rows[0].price).toBe('33');
expect(matrix.logistics).toContain('海运');
expect((after.sizeChart as any).rows).toHaveLength(1); // 自定义成员尺码参与并集
expect(BigInt(after.primaryOriginGoodId!)).toBe(BigInt(member.id)); // 空族首个成员成为主链接
});
it('price overrides:非法维度 400;合法覆盖生效;删除恢复推导价', async () => {
const a = await mkOriginGood(`覆盖${stamp}`, { craftLabel: '单面印花', logisticsLabel: '包邮' });
await prisma.originGoodVariant.create({
data: {
originGoodId: a.id,
sdsVariantId: 'ov-v1',
sku: 'O-S',
sizeId: 'size_S',
sizeName: 'S',
colorId: 'color_blk',
colorName: '黑色',
price: new Prisma.Decimal(25),
},
});
const f = (await service.create({
familyName: `覆盖族-${stamp}`,
originGoodIds: [a.id.toString()],
primaryOriginGoodId: a.id.toString(),
})) as any;
createdFamilyIds.push(BigInt(f.id));
const fid = BigInt(f.id);
await expect(
service.putPriceOverrides(fid, [
{ sizeId: 'size_S', colorId: 'color_blk', craft: '不存在的工艺', logistics: '包邮', price: 1 },
]),
).rejects.toThrow(BadRequestException);
const result = (await service.putPriceOverrides(fid, [
{ sizeId: 'size_S', colorId: 'color_blk', craft: '单面印花', logistics: '包邮', price: 23, note: '促销' },
])) as any;
expect(result.items).toHaveLength(1);
expect(result.items[0].derivedPrice).toBe('25');
expect(result.items[0].diff).toBe('-2.00');
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: fid } });
const row = (after.priceMatrix as any).rows[0];
expect(row.price).toBe('23');
expect(row.manual).toBe(true);
const restored = (await service.deletePriceOverrides(fid, {
cells: [{ sizeId: 'size_S', colorId: 'color_blk', craft: '单面印花', logistics: '包邮' }],
})) as any;
expect(restored.items).toHaveLength(0);
const after2 = await prisma.productFamily.findUniqueOrThrow({ where: { id: fid } });
expect((after2.priceMatrix as any).rows[0].price).toBe('25');
expect((after2.priceMatrix as any).rows[0].manual).toBe(false);
});
it('recomputeNow:手动重算返回最新族详情', async () => {
const f = (await service.create({ familyName: `手动重算族-${stamp}` })) as any;
createdFamilyIds.push(BigInt(f.id));
const res = (await service.recomputeNow(BigInt(f.id))) as any;
expect(BigInt(res.id)).toBe(BigInt(f.id));
await expect(service.recomputeNow(999999n)).rejects.toThrow(NotFoundException);
});
});
@@ -0,0 +1,468 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { FamilyRecomputeService, PriceMatrix } from './family-recompute.service';
import { originGroupKey, parseOriginName } from './origin-name.parser';
import {
CreateCustomMemberDto,
CreateProductFamilyDto,
DeletePriceOverridesDto,
PatchProductFamilyDto,
PriceOverrideItemDto,
QueryProductFamilyDto,
UpdateFamilyMembersDto,
} from './dto/product-family.dto';
/** 从分类名提取模型编码:`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,
goodName: true,
goodImage: true,
source: true,
delisted: true,
skuCode: true,
logisticsLabel: true,
craftLabel: true,
warehouseLabel: true,
},
},
priceOverrides: true,
_count: { select: { originGoods: true, priceOverrides: true } },
} satisfies Prisma.ProductFamilyInclude;
@Injectable()
export class ProductFamiliesService {
constructor(
private readonly prisma: PrismaService,
private readonly recompute: FamilyRecomputeService,
) {}
async list(query: QueryProductFamilyDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.ProductFamilyWhereInput = query.keyword
? {
OR: [
{ familyName: { contains: query.keyword, mode: 'insensitive' } },
{ familyCode: { contains: query.keyword, mode: 'insensitive' } },
],
}
: {};
const [items, total] = await this.prisma.$transaction([
this.prisma.productFamily.findMany({
where,
include: { _count: { select: { originGoods: true, priceOverrides: true } } },
orderBy: { updatedAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.productFamily.count({ where }),
]);
return { items, total, page, pageSize };
}
async detail(id: bigint) {
const family = await this.prisma.productFamily.findUnique({
where: { id },
include: FAMILY_INCLUDE,
});
if (!family) throw new NotFoundException(`product family ${id} not found`);
return family;
}
async create(dto: CreateProductFamilyDto) {
const family = await this.prisma.productFamily.create({
data: {
familyName: dto.familyName,
familyCode: dto.familyCode ? await this.ensureUniqueCode(dto.familyCode) : null,
familyImage: dto.familyImage ?? null,
countryId: dto.countryId ? BigInt(dto.countryId) : null,
categoryId: dto.categoryId ? BigInt(dto.categoryId) : null,
primaryOriginGoodId: dto.primaryOriginGoodId ? BigInt(dto.primaryOriginGoodId) : null,
},
});
if (dto.originGoodIds?.length) {
await this.attachMembers(
family.id,
dto.originGoodIds.map((v) => BigInt(v)),
);
}
await this.recompute.recomputeFamily(family.id);
return this.detail(family.id);
}
async patch(id: bigint, dto: PatchProductFamilyDto) {
const existing = await this.prisma.productFamily.findUnique({ where: { id } });
if (!existing) throw new NotFoundException(`product family ${id} not found`);
const data: Prisma.ProductFamilyUpdateInput = {};
if (dto.familyName !== undefined) data.familyName = dto.familyName;
if (dto.familyImage !== undefined) data.familyImage = dto.familyImage;
if (dto.autoManaged !== undefined) data.autoManaged = dto.autoManaged;
if (dto.countryId !== undefined) {
data.country = dto.countryId
? { connect: { id: BigInt(dto.countryId) } }
: { disconnect: true };
}
if (dto.categoryId !== undefined) {
data.category = dto.categoryId
? { connect: { id: BigInt(dto.categoryId) } }
: { disconnect: true };
}
if (dto.familyCode !== undefined) {
data.familyCode =
dto.familyCode === ''
? null
: dto.familyCode !== existing.familyCode
? await this.ensureUniqueCode(dto.familyCode, id)
: existing.familyCode;
}
if (dto.primaryOriginGoodId !== undefined) {
data.primaryOriginGoodId = dto.primaryOriginGoodId ? BigInt(dto.primaryOriginGoodId) : null;
}
await this.prisma.productFamily.update({ where: { id }, data });
await this.recompute.recomputeFamily(id);
return this.detail(id);
}
/** 自动成族: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, 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 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);
else groups.set(key, [og]);
}
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: 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 ?? ''),
};
});
if (!apply) return { applied: 0, groups: preview };
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: 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,
},
});
await this.attachMembers(
family.id,
members.map((m) => m.id),
);
await this.recompute.recomputeFamily(family.id);
applied += 1;
}
return { applied, groups: preview };
}
async updateMembers(id: bigint, dto: UpdateFamilyMembersDto) {
const family = await this.prisma.productFamily.findUnique({ where: { id } });
if (!family) throw new NotFoundException(`product family ${id} not found`);
if (dto.removeOriginGoodIds?.length) {
const removeIds = dto.removeOriginGoodIds.map((v) => BigInt(v));
const remaining = await this.prisma.originGood.count({
where: { familyId: id, id: { notIn: removeIds } },
});
await this.prisma.originGood.updateMany({
where: { id: { in: removeIds }, familyId: id },
data: { familyId: null },
});
// 移除的是主链接(或主链接已不在族内)→ 落到剩余第一个成员
if (remaining > 0) {
const stillPrimary = await this.prisma.originGood.count({
where: { familyId: id, id: family.primaryOriginGoodId ?? -1n },
});
if (!stillPrimary) {
const next = await this.prisma.originGood.findFirst({
where: { familyId: id },
orderBy: { id: 'asc' },
select: { id: true },
});
if (next) {
await this.prisma.productFamily.update({
where: { id },
data: { primaryOriginGoodId: next.id },
});
}
}
} else {
await this.prisma.productFamily.update({
where: { id },
data: { primaryOriginGoodId: null },
});
}
}
if (dto.addOriginGoodIds?.length) {
await this.attachMembers(
id,
dto.addOriginGoodIds.map((v) => BigInt(v)),
);
}
await this.recompute.recomputeFamily(id);
return this.detail(id);
}
/** 在族内创建自定义成员(人工商品),成功后重算 */
async createCustomMember(familyId: bigint, dto: CreateCustomMemberDto) {
const family = await this.prisma.productFamily.findUnique({ where: { id: familyId } });
if (!family) throw new NotFoundException(`product family ${familyId} not found`);
const { randomUUID } = await import('node:crypto');
const originGood = await this.prisma.originGood.create({
data: {
sdsGoodId: `custom-${randomUUID()}`,
goodName: dto.goodName,
goodImage: dto.goodImage ?? null,
source: 'CUSTOM',
familyId,
skuCode: dto.skuCode ?? null,
logisticsLabel: dto.logisticsLabel,
craftLabel: dto.craftLabel,
warehouseLabel: dto.warehouseLabel ?? null,
},
});
await this.prisma.originGoodVariant.createMany({
data: dto.variants.map((v) => ({
originGoodId: originGood.id,
sdsVariantId: `custom-${randomUUID()}`,
sku: v.sku,
sizeId: v.sizeId ?? null,
sizeName: v.sizeName ?? null,
colorId: v.colorId ?? null,
colorName: v.colorName ?? null,
colorHex: v.colorHex ?? null,
imageUrl: v.imageUrl ?? null,
price: new Prisma.Decimal(v.price),
})),
});
if (dto.detail?.sizeChart || dto.detail?.packageSpecs) {
await this.prisma.originGoodDetail.create({
data: {
originGoodId: originGood.id,
sizeChart: (dto.detail?.sizeChart ?? undefined) as Prisma.InputJsonValue,
packageSpecs: (dto.detail?.packageSpecs ?? undefined) as Prisma.InputJsonValue,
},
});
}
if (!family.primaryOriginGoodId) {
await this.prisma.productFamily.update({
where: { id: familyId },
data: { primaryOriginGoodId: originGood.id },
});
}
await this.recompute.recomputeFamily(familyId);
return originGood;
}
async listPriceOverrides(id: bigint) {
const family = await this.prisma.productFamily.findUnique({ where: { id } });
if (!family) throw new NotFoundException(`product family ${id} not found`);
const overrides = await this.prisma.familyPriceOverride.findMany({
where: { familyId: id },
orderBy: { updatedAt: 'desc' },
});
const matrix = (family.priceMatrix as PriceMatrix | null) ?? null;
const rows = matrix?.rows ?? [];
return {
items: overrides.map((o) => {
// 推导价 = 该格子全部来源中的最低价(覆盖生效前的推导结果,保留在 sources 里)
const row = rows.find(
(r) =>
r.sizeId === o.sizeId &&
r.colorId === o.colorId &&
r.craft === o.craft &&
r.logistics === o.logistics,
);
const derived =
row?.sources.length && !row.manual
? row.price
: row?.sources.length
? String(Math.min(...row.sources.map((s) => Number(s.price))))
: null;
return {
...o,
derivedPrice: derived,
diff: derived !== null ? (Number(o.price) - Number(derived)).toFixed(2) : null,
};
}),
};
}
async putPriceOverrides(id: bigint, items: PriceOverrideItemDto[]) {
const family = await this.prisma.productFamily.findUnique({ where: { id } });
if (!family) throw new NotFoundException(`product family ${id} not found`);
// 矩阵未物化(如刚建族)时先重算,保证维度校验有依据
let matrix = family.priceMatrix as PriceMatrix | null;
if (!matrix) {
await this.recompute.recomputeFamily(id);
const refreshed = await this.prisma.productFamily.findUnique({ where: { id } });
matrix = (refreshed?.priceMatrix as PriceMatrix | null) ?? null;
}
const allowed = {
sizes: new Set((matrix?.sizes ?? []).map((s) => s.key)),
colors: new Set((matrix?.colors ?? []).map((c) => c.key)),
crafts: new Set(matrix?.crafts ?? []),
logistics: new Set(matrix?.logistics ?? []),
};
const invalid = items.filter(
(i) =>
!allowed.sizes.has(i.sizeId) ||
!allowed.colors.has(i.colorId) ||
!allowed.crafts.has(i.craft) ||
!allowed.logistics.has(i.logistics),
);
if (invalid.length) {
throw new BadRequestException({
message: 'price override dimensions must exist in the family matrix',
invalidCells: invalid.map((i) => ({
sizeId: i.sizeId,
colorId: i.colorId,
craft: i.craft,
logistics: i.logistics,
})),
});
}
for (const item of items) {
await this.prisma.familyPriceOverride.upsert({
where: {
familyId_sizeId_colorId_craft_logistics: {
familyId: id,
sizeId: item.sizeId,
colorId: item.colorId,
craft: item.craft,
logistics: item.logistics,
},
},
create: {
familyId: id,
sizeId: item.sizeId,
colorId: item.colorId,
craft: item.craft,
logistics: item.logistics,
price: new Prisma.Decimal(item.price),
note: item.note ?? null,
},
update: {
price: new Prisma.Decimal(item.price),
note: item.note ?? null,
},
});
}
await this.recompute.recomputeFamily(id);
return this.listPriceOverrides(id);
}
async deletePriceOverrides(id: bigint, dto: DeletePriceOverridesDto) {
for (const cell of dto.cells) {
await this.prisma.familyPriceOverride.deleteMany({
where: {
familyId: id,
sizeId: cell.sizeId,
colorId: cell.colorId,
craft: cell.craft,
logistics: cell.logistics,
},
});
}
await this.recompute.recomputeFamily(id);
return this.listPriceOverrides(id);
}
async recomputeNow(id: bigint) {
const family = await this.prisma.productFamily.findUnique({ where: { id } });
if (!family) throw new NotFoundException(`product family ${id} not found`);
await this.recompute.recomputeFamily(id);
return this.detail(id);
}
private async attachMembers(familyId: bigint, originGoodIds: bigint[]) {
if (!originGoodIds.length) return;
const existings = await this.prisma.originGood.findMany({
where: { id: { in: originGoodIds } },
select: { id: true },
});
const existingIds = new Set(existings.map((e) => e.id.toString()));
const missing = originGoodIds.filter((v) => !existingIds.has(v.toString()));
if (missing.length) {
throw new BadRequestException({
message: 'origin goods not found',
ids: missing.map(String),
});
}
await this.prisma.originGood.updateMany({
where: { id: { in: originGoodIds } },
data: { familyId },
});
}
/** familyCode 全局唯一:冲突时追加 -2/-3… 后缀(不同国家同 SKU 常见) */
private async ensureUniqueCode(code: string, selfId?: bigint): Promise<string> {
let candidate = code;
let seq = 2;
// eslint-disable-next-line no-constant-condition
while (true) {
const clash = await this.prisma.productFamily.findFirst({
where: { familyCode: candidate, ...(selfId ? { id: { not: selfId } } : {}) },
select: { id: true },
});
if (!clash) return candidate;
candidate = `${code}-${seq++}`;
}
}
}
+182
View File
@@ -0,0 +1,182 @@
import { Test } from '@nestjs/testing';
import { Prisma } from '@prisma/client';
import { SyncService } from './sync.service';
import { SdsClientService } from './sds-client.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
import { PrismaService } from '../prisma/prisma.service';
/**
* 同步钩子集成测试:upsertOriginGood 的解析列写入、新链接自动挂族、
* persistProductDetail 后的族重算入队。
*/
describe('SyncService family hooks', () => {
let service: SyncService;
let prisma: PrismaService;
let recompute: FamilyRecomputeService;
const stamp = Date.now();
const createdOriginGoodIds: bigint[] = [];
const createdFamilyIds: bigint[] = [];
const sdsProduct = (id: string, name: string) =>
({ id, name, pic: 'https://example.com/pic.jpg' }) as any;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
SyncService,
{
provide: SdsClientService,
useValue: {},
},
FamilyRecomputeService,
PrismaService,
],
}).compile();
service = moduleRef.get(SyncService);
prisma = moduleRef.get(PrismaService);
recompute = moduleRef.get(FamilyRecomputeService);
await prisma.onModuleInit();
});
afterAll(async () => {
await prisma.originGood.deleteMany({ where: { id: { in: createdOriginGoodIds } } });
await prisma.productFamily.deleteMany({ where: { id: { in: createdFamilyIds } } });
await prisma.$disconnect();
});
it('upsertOriginGood:写入四个解析列(create 与 update 全量覆盖)', async () => {
const sdsId = `hook-${stamp}-parse`;
const result1 = await (service as any).upsertOriginGood(
sdsProduct(sdsId, '美国(包邮)240g涤纶休闲短裤-DG206-单面印花-美西洛杉矶一仓'),
`cat-${stamp}-parse`,
);
expect(result1).toBe('inserted');
const og = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } });
createdOriginGoodIds.push(og.id);
expect(og.skuCode).toBe('DG206');
expect(og.logisticsLabel).toBe('包邮');
expect(og.craftLabel).toBe('单面印花');
expect(og.warehouseLabel).toBe('美西洛杉矶一仓');
// 更新为不带仓库的名称 → 解析列全量覆盖(warehouseLabel 置空)
await (service as any).upsertOriginGood(
sdsProduct(sdsId, '美国(不包邮)240g涤纶休闲短裤-DG206-单面印花'),
`cat-${stamp}-parse`,
);
const og2 = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } });
expect(og2.logisticsLabel).toBe('不包邮');
expect(og2.warehouseLabel).toBeNull();
});
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: '包邮',
},
});
createdOriginGoodIds.push(seed.id);
await prisma.originGoodVariant.create({
data: {
originGoodId: seed.id,
sdsVariantId: 'seed-v1',
sku: 'SEED-S',
sizeId: 'size_S',
sizeName: 'S',
colorId: 'color_blk',
colorName: '黑色',
price: new Prisma.Decimal(25),
},
});
const family = await prisma.productFamily.create({
data: {
familyName: `自动挂族-${stamp}`,
primaryOriginGoodId: seed.id,
},
});
createdFamilyIds.push(family.id);
await prisma.originGood.update({
where: { id: seed.id },
data: { familyId: family.id },
});
await recompute.recomputeFamily(family.id);
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}-双面印花-某仓`),
sdsCat,
);
const newOg = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: newSdsId } });
createdOriginGoodIds.push(newOg.id);
expect(newOg.familyId).toBe(family.id);
// 入队的重算已执行(等待异步完成)
await new Promise((r) => setTimeout(r, 200));
await prisma.category.delete({ where: { id: cat.id } }).catch(() => undefined);
});
it('多族命中时不确定归属 → 不挂载', async () => {
// 两个族各含一个同分类成员 → 新链接分类命中两个族,归属不明,留给管理员
const sdsCat = `cat-multi-${stamp}`;
const mk = async (suffix: string) => {
const og = await prisma.originGood.create({
data: {
sdsGoodId: `hook-${stamp}-multi-${suffix}`,
goodName: `${suffix}(包邮)卫衣-ZZB${stamp}-单面印花`,
sdsCategoryId: sdsCat,
craftLabel: '单面印花',
logisticsLabel: '包邮',
},
});
createdOriginGoodIds.push(og.id);
const family = await prisma.productFamily.create({
data: { familyName: `多族${suffix}-${stamp}`, primaryOriginGoodId: og.id },
});
createdFamilyIds.push(family.id);
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } });
return og;
};
await mk('x');
await mk('y');
const sdsId = `hook-${stamp}-multi-new`;
await (service as any).upsertOriginGood(
sdsProduct(sdsId, `新(包邮)卫衣-ZZB${stamp}-单面印花-新仓`),
sdsCat,
);
const og = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } });
createdOriginGoodIds.push(og.id);
expect(og.familyId).toBeNull(); // 两个候选族 → 留给管理员
});
it('maybeEnqueueFamilyRecompute:有族入队、无族跳过', async () => {
const enqueueSpy = jest.spyOn(recompute, 'enqueue').mockImplementation(() => undefined);
const og = await prisma.originGood.create({
data: { sdsGoodId: `hook-${stamp}-noattach`, goodName: `不成组名称-${stamp}` },
});
createdOriginGoodIds.push(og.id);
await (service as any).maybeEnqueueFamilyRecompute(og.id);
expect(enqueueSpy).not.toHaveBeenCalled();
const family = await prisma.productFamily.create({
data: { familyName: `入队族-${stamp}` },
});
createdFamilyIds.push(family.id);
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } });
await (service as any).maybeEnqueueFamilyRecompute(og.id);
expect(enqueueSpy).toHaveBeenCalledWith(family.id);
enqueueSpy.mockRestore();
});
});
+2 -1
View File
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { HttpModule } from '@nestjs/axios';
import { ProductFamiliesModule } from '../product-families/product-families.module';
import { SyncController } from './sync.controller';
import { SyncService } from './sync.service';
import { SdsClientService } from './sds-client.service';
@Module({
imports: [ScheduleModule.forRoot(), HttpModule],
imports: [ScheduleModule.forRoot(), HttpModule, ProductFamiliesModule],
controllers: [SyncController],
providers: [SyncService, SdsClientService],
exports: [SyncService, SdsClientService],
+78 -73
View File
@@ -7,8 +7,9 @@ import {
} from './sync.service';
import { SdsClientService } from './sds-client.service';
import { PrismaService } from '../prisma/prisma.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
describe('SyncService', () => {
describe('SyncService', () => {
let service: SyncService;
let sds: jest.Mocked<SdsClientService>;
let prisma: PrismaService;
@@ -17,22 +18,23 @@ describe('SyncService', () => {
beforeAll(async () => {
const sdsMock: Partial<SdsClientService> = {
fetchCategoryTree: jest.fn(),
fetchProductsPage: jest.fn(),
fetchProductDetail: jest.fn(async (goodId: string | number) => ({ id: goodId })),
fetchCategoryTree: jest.fn(),
fetchProductsPage: jest.fn(),
fetchProductDetail: jest.fn(async (goodId: string | number) => ({ id: goodId })),
};
const moduleRef = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ isGlobal: true })],
providers: [
SyncService,
{ provide: SdsClientService, useValue: sdsMock },
{ provide: FamilyRecomputeService, useValue: { enqueue: jest.fn() } },
PrismaService,
],
}).compile();
service = moduleRef.get(SyncService);
jest
.spyOn(service, 'syncConfiguredProductDetails')
.mockResolvedValue({ synced: 0, failed: 0 });
service = moduleRef.get(SyncService);
jest
.spyOn(service, 'syncConfiguredProductDetails')
.mockResolvedValue({ synced: 0, failed: 0 });
sds = moduleRef.get(SdsClientService) as jest.Mocked<SdsClientService>;
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
@@ -275,68 +277,71 @@ describe('SyncService', () => {
expect(logs.length).toBeGreaterThan(0);
});
});
});
describe('SyncService product detail scopes', () => {
const originGoods = [
{ id: 1n, sdsGoodId: 'all-1' },
{ id: 2n, sdsGoodId: 'all-2' },
];
function createService() {
const prisma = {
originGood: { findMany: jest.fn().mockResolvedValue(originGoods) },
} as unknown as PrismaService;
const sds = {
fetchProductDetail: jest.fn(async (goodId: string) => ({ id: goodId })),
} as unknown as SdsClientService;
const scopedService = new SyncService(prisma, sds);
jest
.spyOn(scopedService as any, 'persistProductDetail')
.mockResolvedValue(undefined);
return { scopedService, prisma, sds };
}
it('manual detail sync selects every active origin product', async () => {
const { scopedService, prisma, sds } = createService();
const result = await scopedService.syncAllProductDetails();
expect(prisma.originGood.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { delisted: false, source: 'SDS' } }),
);
expect(sds.fetchProductDetail).toHaveBeenCalledTimes(2);
expect(result).toEqual({ total: 2, synced: 2, failed: 0 });
});
it('hourly detail refresh remains limited to configured products', async () => {
const { scopedService, prisma } = createService();
await scopedService.syncConfiguredProductDetails();
expect(prisma.originGood.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { delisted: false, source: 'SDS', goods: { some: {} } },
}),
);
});
it('keeps hourly category/product sync separate from the daily detail sync', async () => {
const { scopedService } = createService();
const categories = jest.spyOn(scopedService, 'syncCategories').mockResolvedValue({
inserted: 0, updated: 0, total: 0, deletedStale: 0,
});
const products = jest.spyOn(scopedService, 'syncProducts').mockResolvedValue({
inserted: 0, updated: 0, total: 0, leafCategories: 0, delisted: 0,
});
const details = jest.spyOn(scopedService, 'syncProductDetails').mockResolvedValue({
total: 0, synced: 0, failed: 0,
});
await scopedService.hourlyCron();
expect(categories).toHaveBeenCalledTimes(1);
expect(products).toHaveBeenCalledTimes(1);
expect(details).not.toHaveBeenCalled();
await scopedService.dailyProductDetailCron();
expect(details).toHaveBeenCalledTimes(1);
});
});
});
describe('SyncService product detail scopes', () => {
const originGoods = [
{ id: 1n, sdsGoodId: 'all-1' },
{ id: 2n, sdsGoodId: 'all-2' },
];
function createService() {
const prisma = {
originGood: { findMany: jest.fn().mockResolvedValue(originGoods) },
} as unknown as PrismaService;
const sds = {
fetchProductDetail: jest.fn(async (goodId: string) => ({ id: goodId })),
} as unknown as SdsClientService;
const familyRecompute = {
enqueue: jest.fn(),
} as unknown as FamilyRecomputeService;
const scopedService = new SyncService(prisma, sds, familyRecompute);
jest
.spyOn(scopedService as any, 'persistProductDetail')
.mockResolvedValue(undefined);
return { scopedService, prisma, sds };
}
it('manual detail sync selects every active origin product', async () => {
const { scopedService, prisma, sds } = createService();
const result = await scopedService.syncAllProductDetails();
expect(prisma.originGood.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { delisted: false, source: 'SDS' } }),
);
expect(sds.fetchProductDetail).toHaveBeenCalledTimes(2);
expect(result).toEqual({ total: 2, synced: 2, failed: 0 });
});
it('hourly detail refresh remains limited to configured products', async () => {
const { scopedService, prisma } = createService();
await scopedService.syncConfiguredProductDetails();
expect(prisma.originGood.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { delisted: false, source: 'SDS', goods: { some: {} } },
}),
);
});
it('keeps hourly category/product sync separate from the daily detail sync', async () => {
const { scopedService } = createService();
const categories = jest.spyOn(scopedService, 'syncCategories').mockResolvedValue({
inserted: 0, updated: 0, total: 0, deletedStale: 0,
});
const products = jest.spyOn(scopedService, 'syncProducts').mockResolvedValue({
inserted: 0, updated: 0, total: 0, leafCategories: 0, delisted: 0,
});
const details = jest.spyOn(scopedService, 'syncProductDetails').mockResolvedValue({
total: 0, synced: 0, failed: 0,
});
await scopedService.hourlyCron();
expect(categories).toHaveBeenCalledTimes(1);
expect(products).toHaveBeenCalledTimes(1);
expect(details).not.toHaveBeenCalled();
await scopedService.dailyProductDetailCron();
expect(details).toHaveBeenCalledTimes(1);
});
});
+79 -1
View File
@@ -14,6 +14,8 @@ import {
SdsProductDetail,
} from './sds-client.service';
import { normalizeProductDetail } from './sds-product-detail.mapper';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
import { originGroupKey, parseOriginName } from '../product-families/origin-name.parser';
export interface CategorySyncResult {
inserted: number;
@@ -75,6 +77,7 @@ export class SyncService {
constructor(
private readonly prisma: PrismaService,
private readonly sds: SdsClientService,
private readonly familyRecompute: FamilyRecomputeService,
) {}
/**
@@ -656,6 +659,69 @@ export class SyncService {
},
});
});
// 族成员的详情/变体变化 → 异步重算该族(进程内去重)
await this.maybeEnqueueFamilyRecompute(originGoodId);
}
/** 详情同步后的族重算钩子:链接有族归属才入队 */
private async maybeEnqueueFamilyRecompute(originGoodId: bigint): Promise<void> {
const og = await this.prisma.originGood.findUnique({
where: { id: originGoodId },
select: { familyId: true },
});
if (og?.familyId) this.familyRecompute.enqueue(og.familyId);
}
/**
* 新链接自动挂族:优先按 SDS 分类(=产品模型)匹配已有族的成员;
* 无分类时回退名称 3 段键。恰好命中唯一族才挂载(多族/零族留给管理员裁决)。
* 锁定族(autoManaged=false)不吸收新成员,只置 stale 提示。
*/
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);
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({
where: { id: familyId },
select: { autoManaged: true },
});
if (!family) return;
if (family.autoManaged) {
await this.prisma.originGood.update({
where: { id: originGoodId },
data: { familyId },
});
this.familyRecompute.enqueue(familyId);
} else {
await this.prisma.productFamily.update({
where: { id: familyId },
data: { stale: true },
});
}
}
/**
@@ -715,23 +781,35 @@ export class SyncService {
goodPrice = new Prisma.Decimal(n);
}
}
// 链接名结构化解析列(镜像纯度:全量覆盖,含空值)
const parsed = parseOriginName(goodName);
const parsedData = {
skuCode: parsed.skuCode,
logisticsLabel: parsed.logisticsLabel,
craftLabel: parsed.craftLabel,
warehouseLabel: parsed.warehouseLabel,
};
const data: Prisma.OriginGoodUncheckedUpdateInput = {
sdsCategoryId,
goodName,
goodImage,
goodPrice,
...parsedData,
};
if (!existing) {
await this.prisma.originGood.create({
const created = await this.prisma.originGood.create({
data: {
sdsGoodId,
sdsCategoryId,
goodName,
goodImage,
goodPrice,
...parsedData,
},
select: { id: true },
});
await this.tryAutoAttachToFamily(created.id, goodName);
return 'inserted';
}
await this.prisma.originGood.update({
+52
View File
@@ -50,6 +50,58 @@
- 配置弹窗(右栏拖拽/配置按钮):自动勾选同分类下同名兄弟原产品作为副源提交(`mergedOriginGoodIds`);
- 编辑弹窗「关联原产品」区:可搜索添加副源、移除副源、切换主源(切换后旧主源自动转为副源)。
> 该 Good 级合并能力保留可用;新一级的「产品族(SPU)合并」见下节,公开读路径接入族数据属于三期范围。
## 产品族(SPU 层,一期后端已上线)
设计文档:`docs/superpowers/specs/2026-08-28-product-family-merge-design.md`
**模型**SDS 叶子分类即产品模型(如分类 `DG001 180G纯棉T恤(JSA002` 下 16 条链接),
族 = 同分类链接的合并体;链接名解析列(`国家(物流)品名-SKU-工艺[-仓库]`)提供
物流/工艺归因。例:DG001 族 = 16 链接 × 3 工艺 × 3 物流 × 9 尺码(S~XXXXXL 并集)× 20 颜色 ≈ 470 格价格矩阵,起价取光板(不打印)最低价。
**价格**`价格 = f(尺码, 颜色, 物流, 工艺[含印花数量])`,全部从成员链接变体推导透传
(同格子多仓库取最低价,来源全保留);`family_price_overrides` 表支持按格人工改价,
删覆盖即恢复推导价。
**关键端点用法示例**JWT):
```bash
# 1. 自动成族:先预览
curl -X POST /product-families/auto-group -H "Authorization: Bearer $T" \
-d '{"apply": false}'
# → { applied: 0, groups: [{ groupKey: "cat:8240", familyName: "DG001 180G纯棉T恤(JSA002", memberCount: 16, ... }] }
# 2. 应用(幂等,可重复执行)
curl -X POST /product-families/auto-group -H "Authorization: Bearer $T" -d '{"apply": true}'
# 3. 族详情:成员 + 并集尺码表/包装 + 五维价格矩阵
curl /product-families/12 -H "Authorization: Bearer $T"
# 4. 人工改价(维度必须存在于族矩阵选项,否则 400 并列出非法键)
curl -X PUT /product-families/12/price-overrides -H "Authorization: Bearer $T" \
-d '{"items": [{"sizeId":"size_S","colorId":"color_blk","craft":"单面印花","logistics":"包邮","price": 23, "note": "促销"}]}'
# 5. 删覆盖恢复推导价
curl -X DELETE /product-families/12/price-overrides -H "Authorization: Bearer $T" \
-d '{"cells": [{"sizeId":"size_S","colorId":"color_blk","craft":"单面印花","logistics":"包邮"}]}'
# 6. 族内创建自定义商品(人工补链接,物流/工艺归因必填)
curl -X POST /product-families/12/members/custom -H "Authorization: Bearer $T" \
-d '{"goodName":"美国(海运)180g纯棉T恤-DG001-烫画","logisticsLabel":"海运","craftLabel":"烫画",
"variants":[{"sku":"C-M","sizeId":"size_M","sizeName":"M","colorId":"color_red","colorName":"红色","price": 33}]}'
```
**同步联动**:商品同步落库时刷新解析列;新链接与已有族**同分类唯一命中**时自动挂族
(多族/零族留给管理员;锁定族只置 `stale`);详情同步提交后异步重算受影响族
(进程内去重、幂等)。回填/修复脚本:
```bash
pnpm --filter @inkreach/api backfill:product-families
# → [1/2] 解析全部链接名 [2/2] 自动建族 [3/3] 全量族重算(幂等,可随时重跑)
```
## 验证
```bash
+19 -5
View File
@@ -45,8 +45,9 @@ inkreach-official/
```
apps/api/
├── prisma/
│ ├── schema.prisma # 数据模型(OriginGood/Country/Category/Tag/Position/Good/User/SyncLog
── migrations/ # Prisma migrate 历史
│ ├── schema.prisma # 数据模型(OriginGood/ProductFamily/FamilyPriceOverride/Country/Category/Tag/Position/Good/User/SyncLog
── migrations/ # Prisma migrate 历史
│ └── backfill-product-families.ts # 产品族回填脚本(解析列→自动建族→全量重算,幂等)
├── src/
│ ├── main.ts # 入口:CORS、ValidationPipe、Swagger、BigInt JSON 序列化
│ ├── app.module.ts # 根模块,聚合所有业务模块
@@ -57,7 +58,8 @@ apps/api/
│ ├── tags/ # 标签 CRUD(受 JWT 保护)
│ ├── tag-groups/ # 标签分组 CRUD(受 JWT 保护,含批量排序)
│ ├── positions/ # 坑位 CRUD(受 JWT 保护)
│ ├── origin-goods/ # SDS 原始商品快照(只读分页)
│ ├── origin-goods/ # SDS 原始商品快照(只读分页 + 配置状态树,树叶子含族信息
│ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group / 成员管理 / 自定义成员 / 价格覆盖 / 重算
│ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建
│ ├── sync/ # SDS 同步:分类 / 商品 / 同步日志
│ ├── public/ # 公开 API:分类树 / 国家 / 商品分页 / 商品详情
@@ -77,7 +79,11 @@ apps/api/
| 模型 | 说明 |
|------|------|
| `OriginGood` | SDS 原始商品缓存,关联 `sds_good_id`(唯一) |
| `OriginGood` | SDS 原始商品缓存,关联 `sds_good_id`(唯一);含四个链接名解析列(`skuCode/logisticsLabel/craftLabel/warehouseLabel`,格式 `国家(物流)品名-SKU-工艺[-仓库]`)与 `familyId` 族归属 |
| `OriginGoodDetail` | SDS `/products/{id}` 详情缓存(文本字段 + `sizeChart/packageSpecs/options/media` JSON |
| `OriginGoodVariant` | SDS 子 SKU 缓存(尺码 × 颜色 × 价格),价格矩阵推导的数据源 |
| `ProductFamily` | 产品族(SPU 层):同 SDS 分类(产品模型)的多条链接合并为一族;物化并集尺码表/包装规则与五维价格矩阵(`priceMatrix` JSON);`autoManaged=false` 为人工锁定(重算只置 `stale`);`primaryOriginGoodId` 主链接(无 FK,服务层维护) |
| `FamilyPriceOverride` | 人工按格改价:`(familyId, sizeId, colorId, craft, logistics)` 唯一 → `price`;独立于推导矩阵,重算永不覆盖 |
| `Country` | 国家,关联 goods / positions |
| `Category` | 自引用树形品类,可选 `sds_category_id` |
| `Tag` | 标签,含 `tagColor``tagFontColor``tagGroupId``sortOrder``timing` |
@@ -95,7 +101,7 @@ apps/api/
- **全局 `ValidationPipe`**`whitelist + transform + forbidNonWhitelisted`
- **全局 `HttpExceptionFilter`**:统一错误响应形态。
- **CORS 白名单**`http://localhost:5173`admin)和 `http://localhost:3000`website)。
- **JWT**:所有 `/goods /categories /countries /tags /positions /origin-goods /sync/*` 路由受 `JwtAuthGuard` 保护;`/public/*``/auth/*` 公开。
- **JWT**:所有 `/goods /categories /countries /tags /positions /origin-goods /product-families /sync/*` 路由受 `JwtAuthGuard` 保护;`/public/*``/auth/*` 公开。
### API 路由
@@ -113,6 +119,14 @@ apps/api/
| `/tags/sort` `PATCH` | 批量更新 tag 排序和分组归属 | JWT |
| `/tag-groups/sort` `PATCH` | 批量更新分组排序 | JWT |
| `/origin-goods` `GET` | SDS 原始商品快照分页 | JWT |
| `/origin-goods/tree` `GET` | 配置状态树(叶子含 `familyId/familyName/familyCode/familyStale` | JWT |
| `/product-families` `GET/POST` | 产品族分页列表(`keyword` 匹配名称/编码)/ 建族(可直挂成员) | JWT |
| `/product-families/auto-group` `POST` | 自动成族:按 SDS 分类(产品模型)聚合无族链接;`{apply:false}` 仅预览,`{apply:true}` 落库并逐族重算(幂等) | JWT |
| `/product-families/:id` `GET/PATCH` | 族详情(成员+覆盖)/ 编辑 canonical 字段、`autoManaged`、主链接 | JWT |
| `/product-families/:id/recompute` `POST` | 手动重算并集与价格矩阵 | JWT |
| `/product-families/:id/members` `POST` | 成员增删 `{addOriginGoodIds, removeOriginGoodIds}`;移除主链接后 primary 落到剩余成员 | JWT |
| `/product-families/:id/members/custom` `POST` | 族内创建自定义成员(人工商品:物流/工艺归因必填 + 变体价格 + 可选尺码表/包装) | JWT |
| `/product-families/:id/price-overrides` `GET/PUT/DELETE` | 人工改价:查(含推导价对照与差额)/ 批量 upsert / 按格删除恢复推导价;维度必须存在于族矩阵选项 | JWT |
| `/goods` | 后台商品 CRUD + `POST /goods/batch` + `PATCH /goods/batch-priority` | JWT |
| `/sync/categories` `POST` | 手动触发分类同步 | JWT |
| `/sync/products` `POST` | 手动触发商品同步 | JWT |
@@ -0,0 +1,377 @@
# 产品族(SPU)合并 — 后端设计
- 日期:2026-08-28(同日修订:价格允许人工覆盖、支持人工添加自定义商品入族;公开接口零新增全复用;admin 前端纳入本期)
- 状态:已评审(讨论稿定稿,待实施计划)
- 分支:`feature/product-link-merge-yeuimu`
- 范围:后端(apps/api+ 后台管理前端(apps/admin);官网(apps/website)不在本期,
但公开接口保持 100% 兼容复用(见 §10.2)
## 1. 背景与问题
SDS(InkPOD)上游会为同一个实体商品下发多条近似链接,名称格式为
`国家(物流备注)品名-SKU-工艺位置[-仓库名]`,例如
`美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓`
一个"DG001 180G纯棉T恤(JSA002"分类下存在约 18 条这样的链接。
现有系统的合并能力落在商品层(`goods.origin_good_id` 主源 + `good_origin_goods` 副源),
但合并语义很浅:
- 变体列表简单拼接,无归因、不去重;
- 商品详情、尺码表、包装规则、价格**只取主源**,副源独有的大尺码(如 XXXXL)直接丢失;
- 物流、工艺只存在于链接名的自由文本中,无法结构化查询;
- 同一个族若按国家配置多个 Good,需要重复合并、重算并集。
## 2. 目标 / 非目标
**目标**
1. 在原产品库引入"产品族(ProductFamily"实体:多个 OriginGood 链接合并为一个族;
2. 族的共享属性:规范名称、主图、国家、分类、商品详情;
3. 尺码表与包装规则做**并集**S~XXXL ∪ S~XXXXXL),冲突有明确裁决规则;
4. 价格完全由数据推导:`价格 = f(尺码, 颜色, 物流, 工艺, 印花数量)`,其中印花数量
即链接工艺段自带的属性(单面/双面印花等),**无订单量折扣、无人工定价**;
5. 官网买家可对五个维度全部做选择,价格实时联动;
6. SDS 同步流水线保持"纯镜像"定位不变。
**非目标**
- 不引入价格阶梯/折扣表(订单量定价已被明确否决);价格默认按链接推导透传,
但**允许人工按格改价(覆盖表)**,并支持**人工添加自定义商品入族**(见 §5.5、§5.6、§8);
- 不改动同步任务的核心抓取逻辑与安全护栏;
- 不改动 apps/website(官网)——公开接口零新增、原样复用,官网可无感灰度。
## 3. 决策记录
| # | 问题 | 决策 |
| --- | --- | --- |
| D1 | 合并落在哪一层 | **新增 SPU 产品族层**(介于 OriginGood 与 Good 之间),而非强化 Good 级合并或读取时动态聚合 |
| D2 | 价格形态 | **默认按链接透传 + 人工覆盖**2026-08-28 修订):无阶梯折扣、无全手动明码矩阵;价格默认 = 成员链接变体的 SDS 原价,允许按格子人工改价(覆盖表),并允许人工添加自定义商品入族 |
| D3 | 买家选价粒度 | **五维全选**:详情页提供尺码/颜色/工艺(印花数量)/物流选择器,价格实时联动 |
| D4 | 公开接口 | **零新增、全复用**:不新建任何公开端点,族数据以增量字段嵌入既有 `/public/*` 响应;`goodId=sdsGoodId` 与"任意成员命中同一族"语义不变 |
| D5 | 前端范围 | **admin 后台前端纳入本期**(原产品树按族分组、族管理、人工改价、自定义商品四块界面);官网不做 |
## 4. 架构
```
SDS 同步(不动) 产品族层(新增,人工策展) 运营配置层(调整)
OriginGood #1 ─┐ ┌── ProductFamily ─────────────┐ Good
OriginGood #2 ─┼─ familyId ──────►│ 规范名/主图/国家/分类 │──────► 新增引用族(familyId)
...(18条) │ (自动预填, │ 商品详情(canonical) │ 保留 origin_good_id
OriginGood #18 ┘ 可人工调整) │ 并集尺码表 + 并集包装规则(物化)│ 国家/坑位/标签/优先级不变
│ 价格矩阵(推导物化,零人工) │
└──────────────────────────────┘
```
核心原则:
- `OriginGood` 保持纯 SDS 镜像,同步流水线不改;仅在详情同步提交后追加"重算受影响族"的钩子;
- 族是详情、尺码、包装、价格的唯一事实来源;
- 族上 `autoManaged=true` 时随上游自动重算,`false` 时人工锁定、只置 stale 标记。
## 5. 数据模型(Prisma 草案)
### 5.1 新增 `ProductFamily`
```prisma
model ProductFamily {
id BigInt @id @default(autoincrement()) @map("family_id")
familyCode String? @map("family_code") // 如 DG001 / JSA002,从链接名 SKU 段提取,可人工改
familyName String @map("family_name") // 规范名,如 180g纯棉T恤(成人款)
familyImage String? @map("family_image")
countryId BigInt? @map("country_id") // 关联现有 Country
categoryId BigInt? @map("category_id") // 关联现有 Category
detail Json? // canonical 详情(文本字段默认取主链接,可人工编辑)
sizeChart Json? @map("size_chart") // 物化并集
packageSpecs Json? @map("package_specs") // 物化并集
priceMatrix Json? @map("price_matrix") // 物化推导结果(重算快照,非人工数据)
autoManaged Boolean @default(true) @map("auto_managed")
stale Boolean @default(false) // 锁定族在上游变化后的待处理标记
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
originGoods OriginGood[]
goods Good[]
country Country? @relation(fields: [countryId], references: [id])
category Category? @relation(fields: [categoryId], references: [id])
@@unique([familyCode])
@@index([countryId])
@@index([categoryId])
@@map("product_families")
}
```
### 5.2 `OriginGood` 增量
```prisma
familyId BigInt? @map("family_id") // 归属族,族删除时置空
skuCode String? @map("sku_code") // 名称第2段,如 DG001
logisticsLabel String? @map("logistics_label") // 第1段括号内物流备注,如 包邮/专线
craftLabel String? @map("craft_label") // 第3段工艺位置,如 单面印花(含印花数量语义)
warehouseLabel String? @map("warehouse_label") // 第4段(可选)仓库,如 美西洛杉矶一仓
@@index([familyId]) @@index([skuCode]) @@index([logisticsLabel]) @@index([craftLabel])
```
这四个解析列由同步落库时自动填充(见 §6),是价格归因、自动建族、按维度查询的共同地基。
解析失败的链接列置空,不阻塞同步,进后台待处理列表。
### 5.3 `Good` 增量
```prisma
familyId BigInt? @map("family_id") // 新事实来源;保留 origin_good_id 作为主链接(跳转/兜底)
@@index([familyId])
```
`good_origin_goods` 关联表在迁移完成后只读保留一段时间,确认无回归后废弃删除。
### 5.4 价格矩阵 JSON 结构(物化快照)
```jsonc
{
"crafts": ["单面印花", "双面印花"], // 族内去重后的工艺(印花数量)选项
"logistics": ["包邮", "专线"], // 族内去重后的物流选项
"rows": [
{
"sizeId": "size_3", "sizeName": "XXXL",
"colorId": "color_2", "colorName": "黑色",
"craft": "单面印花", "logistics": "包邮",
"price": "25.00", // 有效价(Decimal 字符串):推导价或人工覆盖价
"manual": false, // true = 该格价格来自 FamilyPriceOverride
"sources": [ // 同格子的全部来源(后台核对下单路由用)
{ "sdsGoodId": "123", "sdsVariantId": "456", "price": "25.00" }
]
}
]
}
```
### 5.5 价格覆盖表(人工改价)
```prisma
model FamilyPriceOverride {
id BigInt @id @default(autoincrement()) @map("family_price_override_id")
familyId BigInt @map("family_id")
sizeId String @map("size_id") // 精确格,不做通配
colorId String @map("color_id")
craft String @map("craft")
logistics String @map("logistics")
price Decimal @db.Decimal(12, 2) // 人工价,仅校验为正数,不限制方向
note String? // 改价原因备注(审计)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
family ProductFamily @relation(fields: [familyId], references: [id], onDelete: Cascade)
@@unique([familyId, sizeId, colorId, craft, logistics])
@@map("family_price_overrides")
}
```
- 覆盖表**独立于推导矩阵**:重算只重建推导矩阵,覆盖表永不被动过;
- 有效价 = 覆盖命中 ? 覆盖价 : 推导价,物化时合并进 `priceMatrix`(命中行标记 `manual: true`);
- 覆盖可命中推导矩阵中**不存在**的格子(人工补组合/补价),但各维度取值必须来自该族
的选项集合(sizes/colors/crafts/logistics),校验失败返回 400
- 删除覆盖即恢复推导价,无任何副作用。
### 5.6 自定义成员(人工添加商品)
复用现有 `OriginGood.source = CUSTOM` 机制(`POST /goods/custom` 已存在),扩展为可入族:
- 自定义 OriginGood 与 SDS 链接一样通过 `familyId` 挂族;其变体(尺码/颜色/价格)、
详情、尺码表、包装规则全部为人工数据;
- 四个解析列(skuCode/logistics/craft/warehouse)由管理员创建时**手工填写**,
其中物流、工艺为必填(价格矩阵归因依赖);
- 重算语义:自定义成员的变体与详情是**人工数据、只读贡献**——并集与矩阵推导时读取,
永不覆盖或删除;仅当管理员显式移出族或删除该商品时失效;
- 自动建族(3 段规则)不会把 CUSTOM 成员吸进族,归属完全由人工决定。
## 6. 链接名结构化解析
输入:`OriginGood.goodName``国家(物流备注)品名-SKU-工艺位置[-仓库名]`)。
输出:`{ country?, logisticsLabel?, productName?, skuCode?, craftLabel?, warehouseLabel? }`
- 按现有 3 段规则(`apps/admin/src/utils/origin-name.ts` 的语义)在后端以 TS 实现,
作为共享解析器(放 `apps/api/src/origin-goods/origin-name.parser.ts` 或 common),
admin 端后续改为复用同一语义;
- 解析时机:同步 upsert `OriginGood` 时、以及存量回填脚本;
- 容错:名称不符合格式时各列置空;解析器对真实数据(约 552 条,其中 541 条符合格式)需有
golden 样本测试,包括畸形样本。
## 7. 并集与冲突规则
- **尺码表并集**:按 `sizeName` 对齐合并各成员的 `sizeChart`
同一尺码测量值冲突时,取**尺码覆盖最全的成员**的该行(即提供 XXXXL 的链接优先),
其次取主链接;无法对齐时记 warning 日志,供管理员在族编辑页人工裁决后锁定(`autoManaged=false`)。
- **包装规则并集**:同样按尺码对齐合并 `packageSpecs`,冲突规则同上。
- **商品详情**:canonical 版本默认取主链接(管理员可指定),并集只作用于尺码表与包装规则。
- **同格子价格冲突**(同物流+同工艺、不同仓库的链接,同尺码颜色不同价):取**最低价**,
`sources` 保留全部来源;在此基础上若存在人工覆盖,以覆盖价为最终有效价(§5.5)。
## 8. 价格模型
```
第一次:选链接 → 物流 + 工艺(印花数量) (一个组合对应一条/多条成员链接)
第二次:选变体 → 尺码 + 颜色 (链接下的 SDS 变体)
推导价 = 该 (物流, 工艺) 组合下该 (尺码, 颜色) 变体的 SDS 价格,原样透传
有效价 = 覆盖表命中 ? 覆盖价 : 推导价 // 人工改价入口,见 §5.5
```
- 推导过程:每条成员链接按 `(logisticsLabel, craftLabel)` 归因 → 其变体按
`(sizeId, colorId)` 落格 → 去重(冲突取最低价)→ 物化为 `priceMatrix`
- 展示"起价" = 有效矩阵(推导 ∪ 覆盖)最低格价格;
- 阶梯折扣表不存在;人工定价仅有覆盖表按格修改一个入口(§5.5)。
## 9. 同步联动与重算
`persistProductDetail``apps/api/src/sync/sync.service.ts`)事务提交后:
1. 若该 `OriginGood.familyId` 非空,投递**去重的异步重算任务**(进程内队列即可,幂等);
2. 重算内容:并集尺码表、并集包装规则、价格矩阵、族选项(crafts/logistics 去重列表);
3. `autoManaged=true`:直接重算覆盖镜像物化字段;
4. `autoManaged=false`:只置 `stale=true`,绝不静默覆盖人工数据;
5. 幂等性:重算输入均为确定性数据(成员镜像、覆盖表、自定义成员),同一状态重算结果相同,可安全重试;
6. 覆盖表(§5.5)与自定义成员数据(§5.6)在重算中**只读**,永不覆盖;
物化 `priceMatrix` 时把覆盖合并为最终有效矩阵(命中行 `manual: true`)。
商品列表同步(`syncProducts`)在 upsert `OriginGood` 时同步刷新四个解析列;
链接被标记 `delisted` 时,其族在下一次重算中自然剔除该成员的变体与尺码贡献。
## 10. API 契约
### 10.1 后台(JWT 保护,需按现有权限体系评估,见 §13)
| 端点 | 说明 |
| --- | --- |
| `GET /product-families?keyword=&page=&pageSize=` | 分页列表(含成员数、stale 标记) |
| `POST /product-families/auto-group` | 按 3 段规则**预览**可成族分组,`apply=true` 时落库(DG001 18 条一键成族) |
| `POST /product-families` | 手工建族 |
| `PATCH /product-families/:id` | 编辑 canonical 字段(名称/主图/国家/分类/详情/主链接/autoManaged |
| `POST /product-families/:id/members` | 增删成员 `{ addOriginGoodIds, removeOriginGoodIds }`,变更后触发重算 |
| `POST /product-families/:id/recompute` | 手动重算 |
| `GET /product-families/:id/price-overrides` | 覆盖列表(含推导价对照、差异标记) |
| `PUT /product-families/:id/price-overrides` | 批量 upsert 人工价 `{ items: [{ sizeId, colorId, craft, logistics, price, note }] }`,写后触发重物化 |
| `DELETE /product-families/:id/price-overrides` | 按格删除覆盖 `{ cells: [...] }`,恢复推导价 |
| `POST /product-families/:id/members/custom` | 在族内创建自定义成员(人工商品:名称/主图/物流/工艺归因/变体价格/详情) |
| `GET /origin-goods/tree` | 改为按**真实族**分组展示,替代名称截断的临时分组 |
`POST /goods/custom` 保留并扩展:请求体增加解析列(物流/工艺必填)与结构化变体
(尺码/颜色/价格),创建后可直接指定 `familyId` 挂族;未指定则形成单成员族。
### 10.2 公开(无鉴权)
**复用原则(D4):不新增任何公开端点、不改变路径与参数语义**。族数据全部以增量字段
嵌入既有响应,官网现有代码继续工作;详情页消费 `family` 块属于纯增量升级。
`GET /public/goods/:goodId`goodId 仍为 sdsGoodId,主源或副源命中均可,语义不变)详情新增:
```jsonc
{
"family": {
"familyCode": "DG001",
"sizes": [{ "id": "size_1", "name": "S", "available": true }], // 并集
"colors": [{ "id": "color_1", "name": "黑色", "hex": "#000", "imageUrl": "..." }],
"crafts": ["单面印花", "双面印花"],
"logisticsOptions": ["包邮", "专线"],
"sizeChart": { /* */ },
"packageSpecs": { /* */ },
"priceMatrix": { /* §5.4 manual */ }
}
}
```
- 矩阵直接嵌入详情响应:无阶梯计算,矩阵是静态快照,前端本地联动即可,
体量与现状(主副源变体拼接)相当甚至更小(去重后);
- 不可用组合由矩阵行的**缺席**表达(前端禁用对应选项);
- 读路径切换加配置开关(如 `PUBLIC_DETAIL_FROM_FAMILY`)灰度,可秒回退。
## 11. 后台管理前端(apps/adminD5
### 11.1 现状与入口
- 外层壳 `views/product-management/ProductManagementView.vue` 以标签页组织:
商品配置(`views/goods/GoodsView.vue`,约 2655 行)、数据同步(`views/sync/SyncView.vue`);
- 本期新增第三个标签页"**产品族**",并改造 GoodsView 的原产品树。
### 11.2 新增"产品族"标签页
新建 `views/product-family/` 目录,按职责拆组件(避免复刻 GoodsView 的巨型单文件):
- `FamilyList.vue`:族列表——关键词/分页/成员数、`stale` 红点、`autoManaged` 状态;
- `FamilyDetail.vue`:族详情——canonical 字段编辑(名称/主图/国家/分类/主链接/autoManaged)、
成员管理(SDS 链接增删、创建自定义成员)、手动重算;
- `AutoGroupDialog.vue`:自动建族——按 3 段规则展示候选分组预览,确认后应用;
- `PriceOverridePanel.vue`:人工改价矩阵——按 (工艺 × 物流) 切换页签,表格为尺码 × 颜色,
单元格显示有效价,人工格高亮并展示与推导价的差额,行内编辑即调
`PUT / DELETE price-overrides`
- 图片上传复用现有 `components/ImageUpload.vue`
### 11.3 GoodsView 改造
- 右侧原产品树分组依据从**前端名称截断**(`utils/origin-name.ts`,本期后退役)切换为
`GET /origin-goods/tree` 返回的真实族分组;
- 原"合并到 Good"交互改为"挂到族":勾选兄弟链接 → 加入既有族或新建族;
- Good 配置表单增加 `familyId`(选族替代选主源+副源),保留"主链接"概念用于跳转兜底。
### 11.4 API client
- 新增 `src/api/product-families.ts`(族 CRUD / auto-group / members / overrides / recompute);
- `src/api/origin-goods.ts``src/api/goods.ts` 随契约更新(familyId 字段)。
### 11.5 前端测试
关键交互(auto-group 预览、覆盖编辑、自定义成员创建、树分组切换)随仓库现有测试设施
补充组件/交互测试;全量现有测试保持通过。
## 12. 迁移与灰度
1. **建表加列**Prisma migrationproduct_families、OriginGood 四解析列 + familyId、Good.familyId);
2. **解析回填**:脚本解析全部存量 `OriginGood.goodName` 填四列,输出不可解析清单;
3. **自动建族**:按 3 段键分组,每组建族(规范名取品名段、familyCode 取 SKU 段)、挂成员;
**每条非下架链接都归属一个族**,无兄弟则单成员族(统一不变量,免特判);
4. **合并关系映射**:遍历现有 `good_origin_goods`,每个 Good 的成员集合
(主源+副源)对应到族(恰好匹配自动族则直接关联;横跨多组则建独立族),回填 `Good.familyId`
5. **物化回填**:对全部族跑一次重算,填充并集与价格矩阵;
6. **读路径灰度**:开关切换 `/public/goods/:goodId` 详情来源;确认无回归后,
`good_origin_goods` 转只读观察一段时间再废弃;
7. **自定义商品与覆盖**:存量 `source=CUSTOM` 的 OriginGood 解析列保持为空,由管理员
按需补填并挂族;覆盖表初始为空,随运营逐步产生,无需数据迁移。
分期建议:一期后端(族 + 解析 + 自动建族 + 树接口改造),二期 admin 前端(产品族页 + GoodsView 改造),
三期(并集物化 + 公开详情灰度切换 + Good.familyId),避免单次变更过大。
## 13. 测试要点
- 解析器单元测试:golden 样本覆盖真实 552 条中的代表格式 + 畸形样本;
- 并集合并:同尺码冲突裁决、缺尺码补齐、包装规则合并、warning 记录;
- 价格矩阵推导:归因正确性、同格子最低价、delisted 成员剔除、幂等重算;
- 锁定语义:`autoManaged=false` 的族在重算时只置 stale、字段不被覆盖;
- 覆盖语义:重算后覆盖价保留且生效;覆盖可新增推导矩阵中不存在的格子(校验维度取值);
删除覆盖恢复推导价;覆盖行在公开矩阵中带 `manual` 标记;
- 自定义成员:变体/详情不被重算覆盖;物流/工艺归因正确进入矩阵;自动建族不吸收 CUSTOM 成员;
- 迁移映射:现有 Good 合并关系到族的映射(恰好匹配 / 横跨多组);
- 公开接口契约:开关两态下的响应形状、任何成员 sdsGoodId 均命中同一族;
- 全量现有测试保持通过(回归红线)。
## 14. 权限评估(按 AGENTS.md 要求)
新增后台操作需纳入现有权限体系(User/Role + JWT):
- 产品族列表/详情:读权限(对齐现有 origin-goods 读);
- 建族/自动成族/成员增删/canonical 编辑/手动重算:写权限(对齐现有 goods 配置写);
- 同步触发重算:系统内部行为,走服务账户,不暴露新端点。
上线前需在 `docs/references/authority-matrix-ui.md` 同步权限矩阵。
## 15. 默认值与待确认项
以下规则已按默认值设计,实施前如需推翻请在此记录:
1. 同格子多链接价格冲突 → **取最低价**
2. 尺码测量值冲突 → **尺码覆盖最全的成员优先**
3. 单链接是否建族 → **是**(统一不变量);
4. `familyCode` 唯一性 → 全局唯一,允许为空(历史/畸形数据);
5. 覆盖粒度 → 精确格 `(sizeId, colorId, craft, logistics)`,不做通配符;
6. 覆盖价方向 → 不限制(可高于或低于推导价),仅校验为正数。
@@ -0,0 +1,545 @@
# 产品族(SPU)合并 — 一期后端实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在原产品库与 Good 之间落地 ProductFamily(产品族)层:链接名结构化解析、族 CRUD、自动建族、成员管理(含自定义商品)、价格推导矩阵 + 人工覆盖、同步钩子与回填脚本。
**Architecture:** `OriginGood` 保持纯 SDS 镜像不动;新增 `product-families` NestJS 模块(controller/service/DTO+ 独立的 `FamilyRecomputeService`(并集与矩阵物化,可被同步钩子异步调用)。价格 = 成员变体按 (物流, 工艺) 归因的并集矩阵,`FamilyPriceOverride` 覆盖表按格改价。设计依据:`docs/superpowers/specs/2026-08-28-product-family-merge-design.md`
**Tech Stack:** NestJS 10 + Prisma 5 + PostgreSQL + Jest(测试与现有 `*.spec.ts` 同构,mock PrismaService)。
**分期:** 本计划为一期后端。二期(admin 前端)、三期(公开读路径灰度 + Good.familyId)在一期合并后另出计划。
**环境基线(已就绪):** `apps/api/.env` 已配置;依赖已安装;远程库漂移已修复(3 个迁移已对齐);现有 15 套件 107 测试全绿。
---
## 文件结构总览
```
apps/api/
prisma/
schema.prisma # 修改:+ProductFamily/+FamilyPriceOverride/+OriginGood 解析列
migrations/<ts>_add_product_families/migration.sql
backfill-product-families.ts # 新建:解析回填+自动建族+全量重算脚本
src/
product-families/ # 新建模块
origin-name.parser.ts # 纯函数解析器(零依赖)
origin-name.parser.spec.ts
family-recompute.service.ts # 并集/矩阵物化核心
family-recompute.service.spec.ts
product-families.service.ts # CRUD/auto-group/members/overrides
product-families.service.spec.ts
product-families.controller.ts
product-families.module.ts
dto/ (create/patch/auto-group/members/overrides)
src/sync/sync.service.ts # 修改:upsertOriginGood 解析列;persistProductDetail 后钩子
src/origin-goods/origin-goods.service.ts # 修改:树叶子节点 + 族信息
src/goods/goods.service.ts # 修改:createCustom 扩展解析列与 familyId
src/app.module.ts # 修改:注册 ProductFamiliesModule
```
**约定(全程适用):**
- 价格矩阵 JSON 的格子维度键:`sizeId = variant.sizeId ?? variant.sizeName``colorId = variant.colorId ?? variant.colorName`(无 id 用名称,两者皆空则该变体不可归格,跳过)。
- Decimal 一律 `toString()` 后入 JSON。
- Prisma JSON 写入用 `value ?? Prisma.DbNull` 惯例(与 `persistProductDetail` 一致)。
- 每个 Task 完成 = 测试绿 + conventional commit。
---
### Task 1: 链接名解析器(纯函数,TDD)
**Files:**
- Create: `apps/api/src/product-families/origin-name.parser.ts`
- Test: `apps/api/src/product-families/origin-name.parser.spec.ts`
- [ ] **Step 1: 写失败测试**
```ts
// origin-name.parser.spec.ts
import { parseOriginName, originGroupKey } from './origin-name.parser';
describe('parseOriginName', () => {
it('解析完整四段名(含仓库)', () => {
expect(parseOriginName('美国(不包邮)240g涤纶休闲短裤-DG206-单面印花-美西洛杉矶一仓')).toEqual({
country: '美国', logisticsLabel: '不包邮', productName: '240g涤纶休闲短裤',
skuCode: 'DG206', craftLabel: '单面印花', warehouseLabel: '美西洛杉矶一仓',
});
});
it('解析三段名(无仓库)', () => {
expect(parseOriginName('墨西哥(不包邮)180g纯棉女装修身T恤-METP001-单面印花')).toEqual({
country: '墨西哥', logisticsLabel: '不包邮', productName: '180g纯棉女装修身T恤',
skuCode: 'METP001', craftLabel: '单面印花', warehouseLabel: null,
});
});
it('物流备注含星号等符号原样保留', () => {
const r = parseOriginName('波兰(包邮*运费订单结算时支付)250g男女同款抓毛圆领卫衣-PLHM002-双面印花');
expect(r.logisticsLabel).toBe('包邮*运费订单结算时支付');
expect(r.skuCode).toBe('PLHM002');
});
it('半角括号也能解析', () => {
const r = parseOriginName('美国(包邮)T恤-DG001-单面印花');
expect(r.country).toBe('美国'); expect(r.logisticsLabel).toBe('包邮'); expect(r.productName).toBe('T恤');
});
it('段1无括号时 country=整段、物流为空', () => {
const r = parseOriginName('美国T恤-DG001-单面印花');
expect(r.country).toBe('美国T恤'); expect(r.logisticsLabel).toBeNull(); expect(r.productName).toBeNull();
});
it('两段名:只解析国家/物流/品名/SKU', () => {
const r = parseOriginName('美国(包邮)T恤-DG001');
expect(r.skuCode).toBe('DG001'); expect(r.craftLabel).toBeNull(); expect(r.warehouseLabel).toBeNull();
});
it('一段名/空值/_null 安全', () => {
expect(parseOriginName('随便一个名字').skuCode).toBeNull();
expect(parseOriginName(null).country).toBeNull();
expect(parseOriginName('').country).toBeNull();
});
it('段前后空格被 trim', () => {
const r = parseOriginName('美国(包邮) T恤 - DG001 - 单面印花');
expect(r.skuCode).toBe('DG001'); expect(r.craftLabel).toBe('单面印花'); expect(r.productName).toBe('T恤');
});
});
describe('originGroupKey', () => {
it('与 admin truncateToProcess 语义一致:前3段、物流差异导致不同组', () => {
expect(originGroupKey('美国(包邮)T恤-DG001-单面印花-美西一仓')).toBe('美国(包邮)T恤-DG001-单面印花');
expect(originGroupKey('美国(不包邮)T恤-DG001-单面印花')).not.toBe(originGroupKey('美国(包邮)T恤-DG001-单面印花'));
});
it('空名返回空串', () => expect(originGroupKey(null)).toBe(''));
});
```
- [ ] **Step 2: 运行确认失败**
Run: `cd apps/api && npx jest origin-name.parser --silent`
Expected: FAIL(模块不存在)
- [ ] **Step 3: 最小实现**
```ts
// origin-name.parser.ts
export interface ParsedOriginName {
country: string | null;
logisticsLabel: string | null;
productName: string | null;
skuCode: string | null;
craftLabel: string | null;
warehouseLabel: string | null;
}
const FULLWIDTH = { open: '', close: '' };
function splitSegment1(seg: string): Pick<ParsedOriginName, 'country' | 'logisticsLabel' | 'productName'> {
const full = seg.indexOf(FULLWIDTH.open);
if (full >= 0) {
const close = seg.indexOf(FULLWIDTH.close, full);
if (close > full) {
return {
country: seg.slice(0, full).trim() || null,
logisticsLabel: seg.slice(full + 1, close).trim() || null,
productName: seg.slice(close + 1).trim() || null,
};
}
}
const halfOpen = seg.indexOf('(');
if (halfOpen >= 0) {
const close = seg.indexOf(')', halfOpen);
if (close > halfOpen) {
return {
country: seg.slice(0, halfOpen).trim() || null,
logisticsLabel: seg.slice(halfOpen + 1, close).trim() || null,
productName: seg.slice(close + 1).trim() || null,
};
}
}
return { country: seg.trim() || null, logisticsLabel: null, productName: null };
}
export function parseOriginName(name: string | null | undefined): ParsedOriginName {
const empty: ParsedOriginName = { country: null, logisticsLabel: null, productName: null, skuCode: null, craftLabel: null, warehouseLabel: null };
if (!name) return empty;
const segs = name.split('-').map((s) => s.trim());
if (segs.length === 0 || segs[0] === '') return empty;
const head = splitSegment1(segs[0]);
return {
...head,
skuCode: segs[1] || null,
craftLabel: segs[2] || null,
warehouseLabel: segs.length > 3 ? segs.slice(3).join('-') : null,
};
}
/** 与 admin 端 truncateToProcessKEEP_SEGMENTS=3)语义一致的分组键 */
export function originGroupKey(name: string | null | undefined): string {
if (!name) return '';
return name.split('-').slice(0, 3).join('-');
}
```
- [ ] **Step 4: 测试通过**
Run: `cd apps/api && npx jest origin-name.parser --silent`
Expected: PASS
- [ ] **Step 5: Commit** `feat(api): add origin good name parser`
---
### Task 2: Prisma Schema 与迁移
**Files:**
- Modify: `apps/api/prisma/schema.prisma`
- [ ] **Step 1: schema 增量**
`OriginGood` modelL23-44)中 `source` 字段后追加:
```prisma
familyId BigInt? @map("family_id")
skuCode String? @map("sku_code")
logisticsLabel String? @map("logistics_label")
craftLabel String? @map("craft_label")
warehouseLabel String? @map("warehouse_label")
```
relations 区追加 `family ProductFamily? @relation(fields: [familyId], references: [id], onDelete: SetNull, onUpdate: NoAction)``@@index` 区追加 `@@index([familyId])``@@index([skuCode])``@@index([logisticsLabel])``@@index([craftLabel])`
`GoodOriginGood`L248)之后新增两个 model
```prisma
// ---------- Product Families (SPU layer over origin goods) ----------
model ProductFamily {
id BigInt @id @default(autoincrement()) @map("family_id")
familyCode String? @unique @map("family_code")
familyName String @map("family_name")
familyImage String? @map("family_image")
countryId BigInt? @map("country_id")
categoryId BigInt? @map("category_id")
primaryOriginGoodId BigInt? @map("primary_origin_good_id")
detail Json?
sizeChart Json? @map("size_chart")
packageSpecs Json? @map("package_specs")
priceMatrix Json? @map("price_matrix")
autoManaged Boolean @default(true) @map("auto_managed")
stale Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
originGoods OriginGood[]
priceOverrides FamilyPriceOverride[]
country Country? @relation(fields: [countryId], references: [id], onDelete: SetNull, onUpdate: NoAction)
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull, onUpdate: NoAction)
@@index([countryId])
@@index([categoryId])
@@map("product_families")
}
model FamilyPriceOverride {
id BigInt @id @default(autoincrement()) @map("family_price_override_id")
familyId BigInt @map("family_id")
sizeId String @map("size_id")
colorId String @map("color_id")
craft String
logistics String
price Decimal @db.Decimal(12, 2)
note String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
family ProductFamily @relation(fields: [familyId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@unique([familyId, sizeId, colorId, craft, logistics])
@@map("family_price_overrides")
}
```
`Country`/`Category` model 的 relations 区各加 `families ProductFamily[]`。注意 `primaryOriginGoodId` 刻意**不建 FK**(避免与 origin_goods 成环依赖,删除主链接时由服务层清理)。
- [ ] **Step 2: 生成并应用迁移**
Run: `cd apps/api && npx prisma migrate dev --name add_product_families && npx prisma generate`
Expected: 生成新 migration 目录,数据库应用成功,`npx jest --silent` 保持全绿。
- [ ] **Step 3: Commit** `feat(api): add product_families schema and migration`
---
### Task 3: FamilyRecomputeService(并集 + 矩阵物化,TDD 核心)
**Files:**
- Create: `apps/api/src/product-families/family-recompute.service.ts`
- Test: `apps/api/src/product-families/family-recompute.service.spec.ts`
**核心类型(导出,供 service/脚本复用):**
```ts
export interface PriceMatrixSource { sdsGoodId: string; sdsVariantId: string; price: string }
export interface PriceMatrixRow {
sizeId: string; sizeName: string | null;
colorId: string; colorName: string | null;
craft: string; logistics: string;
price: string; manual: boolean;
sources: PriceMatrixSource[];
}
export interface PriceMatrix {
sizes: Array<{ key: string; name: string | null }>;
colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>;
crafts: string[];
logistics: string[];
rows: PriceMatrixRow[];
}
export interface ChartSizeRow { sizeId?: string | null; sizeName?: string | null; measurements?: unknown; [k: string]: unknown }
export interface ChartLike { columns?: unknown; rows?: ChartSizeRow[] }
```
**行为规格(测试逐条覆盖):**
1. `unionCharts(charts: Array<{ rows: ChartSizeRow[] }>): ChartSizeRow[]` — 按 `sizeName ?? sizeId` 对齐取并集;冲突时按“提供尺码数最多的成员优先,其次主链接”裁决(成员排序:rows 数 desc → isPrimary desc → id asc)。
2. `derivePriceMatrix(members, overrides)` — 规则:
- 仅统计 `craftLabel && logisticsLabel` 均非空成员的 `enabled=true、price!=null` 变体;
- 同格子多来源:`price` 取最低,`sources` 全保留;
- 覆盖命中(sizeId/colorId/craft/logistics 四键相等)→ `price=覆盖价, manual=true`;命中推导不存在的格子 → 新增行(`sources=[]`);
- `sizes/colors` 选项 = 全体参与变体的键去重(含覆盖新增键);`crafts/logistics` = 成员标签去重 ∪ 覆盖用到的值。
3. `recomputeFamily(familyId)`:加载族+成员(`delisted=false`,含 detail/variants/overrides);`autoManaged=true` → 物化写回 `sizeChart/packageSpecs/priceMatrix``stale=false``autoManaged=false` → 仅 `stale=true` 不改物化字段;族不存在 → no-op。canonical `detail` 仅在为空时从主链接(`primaryOriginGoodId` ?? 第一个成员)初始化一次。
4. `enqueue(familyId)`:进程内 Map 去重,串行执行,异常记 logger.error 不抛出。
**测试样例(mock prisma,参照 `goods.service.spec.ts` 的 `mockPrisma` 模式):**
```ts
const mkMember = (over: Partial<OriginGood> = {}): any => ({
id: 1n, sdsGoodId: 'a', craftLabel: '单面印花', logisticsLabel: '包邮', delisted: false,
detail: null, variants: [], ...over,
});
```
用例清单:
- 并集:`[{S,XL},{XL,XXL}]``[S,XL,XXL]` 顺序稳定(按首次出现序);
- 冲突:成员A(rows=2)与B(rows=3)同尺码不同测量值 → 取B的行;
- 主链接优先于同 rows 数的非主链接;
- 矩阵:两个成员(同物流工艺不同仓库)同格子 25.00/24.50 → price=24.50、sources 两条;
- craft 或 logistics 为空的成员不参与矩阵;
- 覆盖:命中改价 manual=true;未命中格新增行;crafts/logistics 选项并入覆盖值;
- delisted 成员被 where 过滤(验证 findUnique include 的 where 参数);
- autoManaged=false 只置 stale
- detail 为空时从主链接初始化、非空不动。
Run: `cd apps/api && npx jest family-recompute --silent`
- [ ]Commit: `feat(api): add family recompute service with union and price matrix`
---
### Task 4: ProductFamilies 模块骨架(CRUD + 注册)
**Files:**
- Create: `src/product-families/product-families.module.ts``product-families.service.ts``product-families.controller.ts``dto/product-family.dto.ts`
- Modify: `src/app.module.ts`imports 加 `ProductFamiliesModule`
**DTOclass-validator,与现有 goods DTO 风格一致):**
```ts
export class CreateProductFamilyDto {
@IsString() @IsNotEmpty() @MaxLength(200) familyName: string;
@IsOptional() @IsString() @MaxLength(100) familyCode?: string;
@IsOptional() @IsString() familyImage?: string;
@IsOptional() @IsBigInt() countryId?: bigint;
@IsOptional() @IsBigInt() categoryId?: bigint;
@IsOptional() @IsBigInt() primaryOriginGoodId?: bigint;
@IsOptional() @IsArray() originGoodIds?: bigint[]; // 建族时直接挂成员
}
export class PatchProductFamilyDto extends PartialType(CreateProductFamilyDto) {
@IsOptional() @IsBoolean() autoManaged?: boolean;
}
```
**Service 方法与语义:**
- `list({ keyword?, page, pageSize })``{ items, total, page, pageSize }`items 含 `_count.originGoods``stale``autoManaged``familyCode`keyword 匹配 familyName/familyCode contains。
- `create(dto)``familyCode` 冲突时自动追加 `-2`/`-3` 后缀(`ensureUniqueCode` 私有方法);挂成员后 `recompute`
- `detail(id)`:含成员(含解析列)、覆盖列表、物化矩阵摘要。
- `patch(id, dto)`:仅 canonical 字段 + autoManaged + primaryOriginGoodId;改 primary 后 recompute。
- BigInt 序列化沿用现有模式(查询结果直接返回,序列化时 `JSON.stringify``(BigInt.prototype as any).toJSON` 已有全局处理则复用;无则在返回前 `JSON.parse(JSON.stringify(x, (_, v) => typeof v === 'bigint' ? v.toString() : v))`)。
**Controller 路由(JWT 默认保护,无需额外装饰):**
```ts
@Controller('product-families')
export class ProductFamiliesController {
@Get() list(@Query() q)
@Post() create(@Body() dto)
@Get(':id') detail(@Param('id', ParseBigIntPipe) id: bigint)
@Patch(':id') patch(@Param('id', ParseBigIntPipe) id: bigint, @Body() dto)
}
```
测试:create 的 familyCode 去重后缀、list keyword 过滤、patch 不触碰物化字段。
Run: `cd apps/api && npx jest product-families.service --silent`
- [ ]Commit: `feat(api): product families module skeleton with CRUD`
---
### Task 5: 自动建族 auto-group(预览 + 应用)
**Files:**
- Modify: `src/product-families/product-families.service.ts``product-families.controller.ts``dto/product-family.dto.ts`
**语义:**
- `POST /product-families/auto-group` body `{ apply?: boolean }`
- 候选 = `source=SDS, delisted=false, familyId=null` 的 OriginGood,按 `originGroupKey(goodName)` 分组(空键丢弃);组内 ≥1 条即可成族。
- 预览返回 `{ groups: [{ groupKey, familyName, familyCode, memberCount, sampleNames: string[] }] }``familyName=parse.productName ?? seg1``familyCode=parse.skuCode`
- `apply=true`:每组 createCode 冲突走 `ensureUniqueCode`+ 挂成员 + 逐族 recompute;跳过组内成员已全部有族的组(防止重复建族)。
- 幂等:重复调用 apply 不产生重复族(候选 familyId=null 过滤保证)。
测试:分组正确性(物流不同不同组)、空键丢弃、apply 幂等、preview 不写库。
- [ ]Commit: `feat(api): family auto-group preview and apply`
---
### Task 6: 成员管理 + 自定义成员
**Files:**
- Modify: `product-families.service.ts``product-families.controller.ts``dto/product-family.dto.ts`
**语义:**
- `POST /:id/members` body `{ addOriginGoodIds?: bigint[], removeOriginGoodIds?: bigint[] }`
- add:校验 OriginGood 存在;成员变更后 recompute
- remove:从族摘除(`familyId=null`);若移除的是 `primaryOriginGoodId` → 自动落到剩余第一个成员并置告警字段(实现:primary 置 nullrecompute 用第一个成员兜底)。
- `POST /:id/members/custom` body
```ts
export class CreateCustomMemberDto {
@IsString() @IsNotEmpty() goodName: string;
@IsOptional() @IsString() goodImage?: string;
@IsString() @IsNotEmpty() logisticsLabel: string; // 必填:矩阵归因
@IsString() @IsNotEmpty() craftLabel: string; // 必填:矩阵归因
@IsOptional() @IsString() skuCode?: string;
@IsOptional() @IsString() warehouseLabel?: string;
@IsArray() @ValidateNested({ each: true }) @Type(() => CustomVariantDto)
variants: CustomVariantDto[]; // 至少1条
@IsOptional() @IsObject() detail?: { sizeChart?: object; packageSpecs?: object; [k: string]: unknown };
}
export class CustomVariantDto {
@IsString() sku: string;
@IsOptional() @IsString() sizeId?: string; @IsOptional() @IsString() sizeName?: string;
@IsOptional() @IsString() colorId?: string; @IsOptional() @IsString() colorName?: string;
@IsOptional() @IsString() colorHex?: string; @IsOptional() @IsString() imageUrl?: string;
@IsNumber() @Min(0.01) price: number;
}
```
- 创建 `OriginGood { source: CUSTOM, sdsGoodId: 'custom-'+randomUUID(), familyId:族id, 解析列=dto }` + variants + 可选 `OriginGoodDetail{sizeChart, packageSpecs}`;成功后 recompute。
- CUSTOM 成员在 recompute 中与 SDS 同权(读其变体价格),重算永不写其变体/详情。
测试:成员增删触发 recompute、移除主链接的 primary 兜底、custom 成员创建字段落库(sdsGoodId 前缀 custom-、解析列来自 dto)、recompute 计入 custom 变体。
- [ ]Commit: `feat(api): family member management and custom members`
---
### Task 7: 价格覆盖表
**Files:**
- Modify: `product-families.service.ts``product-families.controller.ts``dto/product-family.dto.ts`
**语义:**
- `GET /:id/price-overrides``{ items: [...override, derivedPrice: string|null, diff: string|null] }`(对照推导价与差额,便于 admin 展示)。
- `PUT /:id/price-overrides` body `{ items: [{ sizeId, colorId, craft, logistics, price, note? }] }`:批量 upsert`@@unique` 五键);写后 recompute。
- `DELETE /:id/price-overrides` body `{ cells: [{ sizeId, colorId, craft, logistics }] }`:删覆盖恢复推导价,recompute。
- 校验:`price > 0`;四维度键必须 ∈ 族当前矩阵选项(`priceMatrix.sizes/colors/crafts/logistics`),否则 400(错误信息列出非法键)。
- **边界**:族尚未物化(priceMatrix 为 null,如刚建族未 recompute)→ 先 recompute 再校验。
测试:upsert 幂等、非法维度键 400、删除后恢复推导价、覆盖后矩阵行 manual=true、derivedPrice/diff 计算。
- [ ]Commit: `feat(api): family price overrides endpoints`
---
### Task 8: 手动重算端点 + 同步钩子 + 新链接自动挂族
**Files:**
- Modify: `product-families.controller.ts``POST /:id/recompute`
- Modify: `src/sync/sync.service.ts`:
- `upsertOriginGood`L686-742):`goodName` 解析后把 `skuCode/logisticsLabel/craftLabel/warehouseLabel` 并入 create/update data(字段级:解析结果全量覆盖,空值也写 null,保持镜像纯度);
- `upsertOriginGood` 新插入(`!existing`)时尝试**自动挂族**:按 `originGroupKey(goodName)` 找同键已有族的成员 → 唯一族:`autoManaged=true` 直接挂+enqueue`false` 只置 `stale=true`;多族/零族:跳过(留给管理员);
- `persistProductDetail`(L606-659)事务成功返回后:查 `originGood.familyId`,非空则 `familyRecompute.enqueue(familyId)`
- `SyncModule` imports `ProductFamiliesModule`(导出 `FamilyRecomputeService`);`SyncService` 构造注入。
- 循环依赖防护:`ProductFamiliesModule` **不** import `SyncModule`
测试:upsert data 含解析列、新链接唯一族命中挂载、多族不挂、persist 后 enqueue 被调用(spy)。
- [ ]Commit: `feat(api): sync hooks for parsing, auto-attach and family recompute`
---
### Task 9: 回填脚本(解析 → 自动建族 → 全量重算)
**Files:**
- Create: `apps/api/prisma/backfill-product-families.ts`package.json 加 script `"backfill:product-families": "ts-node prisma/backfill-product-families.ts"`
**语义(与 auto-group 复用 service,脚本只做编排):**
1. 全量 SDS+CUSTOM OriginGood 回填四解析列(`prisma.$transaction` 分批 100 条 update);
2.`autoGroup({ apply: true })`(此时 familyId 全 null,等价全量建族;CUSTOM 单条成族、code 取 skuCode ?? `CUSTOM-<id>`);
3. 遍历所有族逐个 `recomputeFamily`,输出统计 `{ parsed, unparsable, familiesCreated, recomputed }` 与不可解析清单;
4. 幂等:重复执行时步骤1无变化、步骤2候选为空、步骤3重算结果相同。
Run(真实库执行一次): `cd apps/api && pnpm backfill:product-families`
Expected: 约 544 条解析、>90% 成族、0 报错。
- [ ]Commit: `feat(api): product families backfill script`
---
### Task 10: origin-goods 树接入族信息
**Files:**
- Modify: `src/origin-goods/origin-goods.service.ts``getTree` L116 起)
**语义(最小增量,不破坏现有响应形状):**
- `findMany originGoods` 的 include 增加 `family: { select: { id: true, familyName: true, familyCode: true, stale: true, autoManaged: true } }`
- 树叶子节点 payload 增加 `familyId: string | null``familyName: string | null``familyCode: string | null` 字段(沿用该文件现有的叶子映射处,逐字段展开;BigInt → string 与现有一致);
- 类型 `OriginGoodsTreeResponse` 相应扩展(该文件内或 dto 文件内的 interface)。
测试:叶子包含族字段、无族时为 null。
- [ ]Commit: `feat(api): expose family info in origin goods tree`
---
### Task 11: goods.service createCustom 扩展
**Files:**
- Modify: `src/goods/goods.service.ts``createCustom` L174-221)、`src/goods/dto/`(对应 create-custom DTO
**语义:**
- `POST /goods/custom` 请求体可选新增:`logisticsLabel/craftLabel/skuCode/warehouseLabel/familyId`
-`familyId` → 创建的 CUSTOM OriginGood 直接入族(校验族存在,404 否则)并 enqueue recompute
-`familyId` → 维持现状(独立 CUSTOM 商品,familyId=null,后续可被挂族或运行回填脚本时单条成族);
- 现有调用方不传新字段 → 行为完全不变(回归红线)。
测试:带 familyId 创建入族、不带时与旧路径一致(现有 spec 全绿即证)。
- [ ]Commit: `feat(api): custom goods support family attribution`
---
### Task 12: 文档更新 + 全量验证
**Files:**
- Modify: `docs/references/structs.md`(新模块/新表/新端点)、`docs/references/product-center.md`(族概念与端点用法示例)、`README.md`backfill 命令)
- Modify: `docs/references/authority-matrix-ui.md`product-families 读写权限行)
- [ ] **Step 1:** `cd apps/api && npx jest --silent` 全绿(现有 107 + 新增全部)。
- [ ] **Step 2:** `pnpm -r build` 通过(api/admin/website 均编译)。
- [ ] **Step 3:** 启动 `pnpm --filter @inkreach/api dev`Swagger `/api/docs` 冒烟:auto-group 预览 → 应用 → 族详情含矩阵 → 覆盖改价 → 重算。
- [ ] **Step 4:** 遵循 verification-before-completion 技能自查。
- [ ]Commit: `docs: update references for product families`
---
## Self-Review 记录
- **Spec 覆盖:** §5.1/5.2/5.5/5.6 数据模型→Task 2;§6 解析→Task 1/8/9;§7 并集→Task 3;§8 价格→Task 3/7;§9 同步联动→Task 8;§10.1 端点→Task 4-8、10、11;§12 迁移→Task 2/9。三期范围(公开读路径/Good.familyId/good_origin_goods 废弃)不在本计划,已声明。
- **占位符:** 无 TBD/TODO;核心算法任务(1/3/5/6/7)含完整测试规格与代码。
- **类型一致:** `PriceMatrix/PriceMatrixRow` 在 Task 3 定义、Task 7 校验引用同一类型;`ensureUniqueCode` 在 Task 4 定义、Task 5 复用;`enqueue/recomputeFamily` 在 Task 3 定义、Task 6/8/9/11 复用。