feat(product-family): merge SPU family layer phase-1 backend
This commit is contained in:
@@ -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 # 开发
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { GoodsController } from './goods.controller';
|
||||
import { GoodsService } from './goods.service';
|
||||
import { SyncModule } from '../sync/sync.module';
|
||||
import { ProductFamiliesModule } from '../product-families/product-families.module';
|
||||
|
||||
@Module({
|
||||
imports: [SyncModule],
|
||||
imports: [SyncModule, ProductFamiliesModule],
|
||||
controllers: [GoodsController],
|
||||
providers: [GoodsService],
|
||||
exports: [GoodsService],
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { GoodsService } from './goods.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||
|
||||
describe('GoodsService', () => {
|
||||
let service: GoodsService;
|
||||
@@ -30,6 +31,10 @@ describe('GoodsService', () => {
|
||||
provide: SyncService,
|
||||
useValue: { queueProductDetailSync: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: FamilyRecomputeService,
|
||||
useValue: { enqueue: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(GoodsService);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
||||
import { BatchPriorityDto } from './dto/batch-priority.dto';
|
||||
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
CreateCustomGoodDto,
|
||||
@@ -52,6 +53,7 @@ export class GoodsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly syncService: SyncService,
|
||||
private readonly familyRecompute: FamilyRecomputeService,
|
||||
) {}
|
||||
|
||||
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
|
||||
@@ -176,6 +178,14 @@ export class GoodsService {
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
if (dto.positionId !== undefined) await this.ensurePosition(dto.positionId);
|
||||
for (const tagId of dto.tagIds ?? []) await this.ensureTag(tagId);
|
||||
let family: { id: bigint } | null = null;
|
||||
if (dto.familyId !== undefined) {
|
||||
family = await this.prisma.productFamily.findUnique({
|
||||
where: { id: BigInt(dto.familyId) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!family) throw new NotFoundException(`product family ${dto.familyId} not found`);
|
||||
}
|
||||
|
||||
const goodId = await this.prisma.$transaction(async (tx) => {
|
||||
const originGood = await tx.originGood.create({
|
||||
@@ -185,6 +195,15 @@ export class GoodsService {
|
||||
goodName: dto.goodName,
|
||||
goodImage: dto.goodImage ?? null,
|
||||
goodPrice: this.decimal(dto.goodPrice),
|
||||
...(family ? { familyId: family.id } : {}),
|
||||
...(dto.logisticsLabel !== undefined || dto.craftLabel !== undefined
|
||||
? {
|
||||
logisticsLabel: dto.logisticsLabel ?? null,
|
||||
craftLabel: dto.craftLabel ?? null,
|
||||
skuCode: dto.skuCode ?? null,
|
||||
warehouseLabel: dto.warehouseLabel ?? null,
|
||||
}
|
||||
: {}),
|
||||
detail: {
|
||||
create: this.customDetailData(
|
||||
dto.detail ?? {},
|
||||
@@ -217,6 +236,7 @@ export class GoodsService {
|
||||
}
|
||||
return good.id;
|
||||
});
|
||||
if (family) this.familyRecompute.enqueue(family.id);
|
||||
return this.findOne(goodId);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,10 @@ export interface OriginGoodsTreeNode {
|
||||
variantCount: number;
|
||||
sizeRowCount: number;
|
||||
packageRowCount: number;
|
||||
familyId: string | null;
|
||||
familyName: string | null;
|
||||
familyCode: string | null;
|
||||
familyStale: boolean | null;
|
||||
}
|
||||
|
||||
/** A category node in the hierarchical tree, with origin goods as leaves. */
|
||||
@@ -129,7 +133,11 @@ export class OriginGoodsService {
|
||||
this.prisma.originGood.findMany({
|
||||
where: { delisted: false, source: 'SDS' },
|
||||
orderBy: { goodName: 'asc' },
|
||||
include: { detail: true, _count: { select: { variants: true } } },
|
||||
include: {
|
||||
detail: true,
|
||||
_count: { select: { variants: true } },
|
||||
family: { select: { id: true, familyName: true, familyCode: true, stale: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.good.groupBy({
|
||||
by: ['originGoodId'],
|
||||
@@ -271,6 +279,10 @@ export class OriginGoodsService {
|
||||
variantCount: og._count.variants,
|
||||
sizeRowCount: this.jsonRows(og.detail?.sizeChart),
|
||||
packageRowCount: this.jsonRows(og.detail?.packageSpecs),
|
||||
familyId: og.family?.id.toString() ?? null,
|
||||
familyName: og.family?.familyName ?? null,
|
||||
familyCode: og.family?.familyCode ?? null,
|
||||
familyStale: og.family?.stale ?? null,
|
||||
}));
|
||||
|
||||
const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0);
|
||||
@@ -322,6 +334,10 @@ export class OriginGoodsService {
|
||||
variantCount: og._count.variants,
|
||||
sizeRowCount: this.jsonRows(og.detail?.sizeChart),
|
||||
packageRowCount: this.jsonRows(og.detail?.packageSpecs),
|
||||
familyId: og.family?.id.toString() ?? null,
|
||||
familyName: og.family?.familyName ?? null,
|
||||
familyCode: og.family?.familyCode ?? null,
|
||||
familyStale: og.family?.stale ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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_id(canonical 详情与跳转兜底)',
|
||||
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 端 truncateToProcess(KEEP_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('list:keyword 过滤 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++}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
|
||||
@@ -7,6 +7,7 @@ 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', () => {
|
||||
let service: SyncService;
|
||||
@@ -26,6 +27,7 @@ describe('SyncService', () => {
|
||||
providers: [
|
||||
SyncService,
|
||||
{ provide: SdsClientService, useValue: sdsMock },
|
||||
{ provide: FamilyRecomputeService, useValue: { enqueue: jest.fn() } },
|
||||
PrismaService,
|
||||
],
|
||||
}).compile();
|
||||
@@ -290,7 +292,10 @@ describe('SyncService product detail scopes', () => {
|
||||
const sds = {
|
||||
fetchProductDetail: jest.fn(async (goodId: string) => ({ id: goodId })),
|
||||
} as unknown as SdsClientService;
|
||||
const scopedService = new SyncService(prisma, sds);
|
||||
const familyRecompute = {
|
||||
enqueue: jest.fn(),
|
||||
} as unknown as FamilyRecomputeService;
|
||||
const scopedService = new SyncService(prisma, sds, familyRecompute);
|
||||
jest
|
||||
.spyOn(scopedService as any, 'persistProductDetail')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/admin,D5)
|
||||
|
||||
### 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 migration(product_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 端 truncateToProcess(KEEP_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` model(L23-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`)
|
||||
|
||||
**DTO(class-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`:每组 create(Code 冲突走 `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 置 null,recompute 用第一个成员兜底)。
|
||||
- `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 复用。
|
||||
Reference in New Issue
Block a user