diff --git a/README.md b/README.md index 9837c6e..846cd01 100644 --- a/README.md +++ b/README.md @@ -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 # 开发 diff --git a/apps/api/package.json b/apps/api/package.json index 221d99c..1931d82 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/prisma/backfill-product-families.ts b/apps/api/prisma/backfill-product-families.ts new file mode 100644 index 0000000..8a06555 --- /dev/null +++ b/apps/api/prisma/backfill-product-families.ts @@ -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); +}); diff --git a/apps/api/prisma/migrations/20260828042358_add_product_families/migration.sql b/apps/api/prisma/migrations/20260828042358_add_product_families/migration.sql new file mode 100644 index 0000000..55b1e9a --- /dev/null +++ b/apps/api/prisma/migrations/20260828042358_add_product_families/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 25b9408..5929a2c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ebfe7b8..7d958bb 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/goods/dto/custom-good.dto.ts b/apps/api/src/goods/dto/custom-good.dto.ts index b3874ca..5fa2976 100644 --- a/apps/api/src/goods/dto/custom-good.dto.ts +++ b/apps/api/src/goods/dto/custom-good.dto.ts @@ -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() diff --git a/apps/api/src/goods/goods.module.ts b/apps/api/src/goods/goods.module.ts index 95907bd..1f74a9e 100644 --- a/apps/api/src/goods/goods.module.ts +++ b/apps/api/src/goods/goods.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { GoodsController } from './goods.controller'; -import { GoodsService } from './goods.service'; -import { SyncModule } from '../sync/sync.module'; - -@Module({ - imports: [SyncModule], +import { GoodsService } from './goods.service'; +import { SyncModule } from '../sync/sync.module'; +import { ProductFamiliesModule } from '../product-families/product-families.module'; + +@Module({ + imports: [SyncModule, ProductFamiliesModule], controllers: [GoodsController], providers: [GoodsService], exports: [GoodsService], diff --git a/apps/api/src/goods/goods.service.spec.ts b/apps/api/src/goods/goods.service.spec.ts index b687494..389f4b9 100644 --- a/apps/api/src/goods/goods.service.spec.ts +++ b/apps/api/src/goods/goods.service.spec.ts @@ -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); diff --git a/apps/api/src/goods/goods.service.ts b/apps/api/src/goods/goods.service.ts index 0d094fa..0c9e850 100644 --- a/apps/api/src/goods/goods.service.ts +++ b/apps/api/src/goods/goods.service.ts @@ -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 { @@ -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); } diff --git a/apps/api/src/origin-goods/origin-goods.service.ts b/apps/api/src/origin-goods/origin-goods.service.ts index 9b8d267..ed0fea0 100644 --- a/apps/api/src/origin-goods/origin-goods.service.ts +++ b/apps/api/src/origin-goods/origin-goods.service.ts @@ -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, })), }); } diff --git a/apps/api/src/product-families/dto/product-family.dto.ts b/apps/api/src/product-families/dto/product-family.dto.ts new file mode 100644 index 0000000..6cd687e --- /dev/null +++ b/apps/api/src/product-families/dto/product-family.dto.ts @@ -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; packageSpecs?: Record }; +} + +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[]; +} diff --git a/apps/api/src/product-families/family-recompute.service.spec.ts b/apps/api/src/product-families/family-recompute.service.spec.ts new file mode 100644 index 0000000..26aa4d5 --- /dev/null +++ b/apps/api/src/product-families/family-recompute.service.spec.ts @@ -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(); + }); +}); diff --git a/apps/api/src/product-families/family-recompute.service.ts b/apps/api/src/product-families/family-recompute.service.ts new file mode 100644 index 0000000..6e334bd --- /dev/null +++ b/apps/api/src/product-families/family-recompute.service.ts @@ -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): ChartSizeRow[] { + const out: ChartSizeRow[] = []; + const seen = new Set(); + 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): 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(); + const colors = new Map(); + const cellMap = new Map(); + + 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>(); + + 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 { + 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 }, + }); + } + } +} diff --git a/apps/api/src/product-families/origin-name.parser.spec.ts b/apps/api/src/product-families/origin-name.parser.spec.ts new file mode 100644 index 0000000..52d016b --- /dev/null +++ b/apps/api/src/product-families/origin-name.parser.spec.ts @@ -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(''); + }); +}); diff --git a/apps/api/src/product-families/origin-name.parser.ts b/apps/api/src/product-families/origin-name.parser.ts new file mode 100644 index 0000000..7527ec2 --- /dev/null +++ b/apps/api/src/product-families/origin-name.parser.ts @@ -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 { + 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('-'); +} diff --git a/apps/api/src/product-families/product-families.controller.ts b/apps/api/src/product-families/product-families.controller.ts new file mode 100644 index 0000000..cb82799 --- /dev/null +++ b/apps/api/src/product-families/product-families.controller.ts @@ -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); + } +} diff --git a/apps/api/src/product-families/product-families.module.ts b/apps/api/src/product-families/product-families.module.ts new file mode 100644 index 0000000..9334c37 --- /dev/null +++ b/apps/api/src/product-families/product-families.module.ts @@ -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 {} diff --git a/apps/api/src/product-families/product-families.service.spec.ts b/apps/api/src/product-families/product-families.service.spec.ts new file mode 100644 index 0000000..9e9db98 --- /dev/null +++ b/apps/api/src/product-families/product-families.service.spec.ts @@ -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 = {}) => { + 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); + }); +}); diff --git a/apps/api/src/product-families/product-families.service.ts b/apps/api/src/product-families/product-families.service.ts new file mode 100644 index 0000000..0beeafa --- /dev/null +++ b/apps/api/src/product-families/product-families.service.ts @@ -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(); + 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 { + 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++}`; + } + } +} diff --git a/apps/api/src/sync/sync-family-hooks.spec.ts b/apps/api/src/sync/sync-family-hooks.spec.ts new file mode 100644 index 0000000..4532536 --- /dev/null +++ b/apps/api/src/sync/sync-family-hooks.spec.ts @@ -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(); + }); +}); diff --git a/apps/api/src/sync/sync.module.ts b/apps/api/src/sync/sync.module.ts index b04ab68..b82a647 100644 --- a/apps/api/src/sync/sync.module.ts +++ b/apps/api/src/sync/sync.module.ts @@ -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], diff --git a/apps/api/src/sync/sync.service.spec.ts b/apps/api/src/sync/sync.service.spec.ts index 0f32ce3..0e7944a 100644 --- a/apps/api/src/sync/sync.service.spec.ts +++ b/apps/api/src/sync/sync.service.spec.ts @@ -7,8 +7,9 @@ import { } from './sync.service'; import { SdsClientService } from './sds-client.service'; import { PrismaService } from '../prisma/prisma.service'; +import { FamilyRecomputeService } from '../product-families/family-recompute.service'; -describe('SyncService', () => { +describe('SyncService', () => { let service: SyncService; let sds: jest.Mocked; let prisma: PrismaService; @@ -17,22 +18,23 @@ describe('SyncService', () => { beforeAll(async () => { const sdsMock: Partial = { - fetchCategoryTree: jest.fn(), - fetchProductsPage: jest.fn(), - fetchProductDetail: jest.fn(async (goodId: string | number) => ({ id: goodId })), + fetchCategoryTree: jest.fn(), + fetchProductsPage: jest.fn(), + fetchProductDetail: jest.fn(async (goodId: string | number) => ({ id: goodId })), }; const moduleRef = await Test.createTestingModule({ imports: [ConfigModule.forRoot({ isGlobal: true })], providers: [ SyncService, { provide: SdsClientService, useValue: sdsMock }, + { provide: FamilyRecomputeService, useValue: { enqueue: jest.fn() } }, PrismaService, ], }).compile(); - service = moduleRef.get(SyncService); - jest - .spyOn(service, 'syncConfiguredProductDetails') - .mockResolvedValue({ synced: 0, failed: 0 }); + service = moduleRef.get(SyncService); + jest + .spyOn(service, 'syncConfiguredProductDetails') + .mockResolvedValue({ synced: 0, failed: 0 }); sds = moduleRef.get(SdsClientService) as jest.Mocked; prisma = moduleRef.get(PrismaService); await prisma.onModuleInit(); @@ -275,68 +277,71 @@ describe('SyncService', () => { expect(logs.length).toBeGreaterThan(0); }); }); -}); - -describe('SyncService product detail scopes', () => { - const originGoods = [ - { id: 1n, sdsGoodId: 'all-1' }, - { id: 2n, sdsGoodId: 'all-2' }, - ]; - - function createService() { - const prisma = { - originGood: { findMany: jest.fn().mockResolvedValue(originGoods) }, - } as unknown as PrismaService; - const sds = { - fetchProductDetail: jest.fn(async (goodId: string) => ({ id: goodId })), - } as unknown as SdsClientService; - const scopedService = new SyncService(prisma, sds); - jest - .spyOn(scopedService as any, 'persistProductDetail') - .mockResolvedValue(undefined); - return { scopedService, prisma, sds }; - } - - it('manual detail sync selects every active origin product', async () => { - const { scopedService, prisma, sds } = createService(); - const result = await scopedService.syncAllProductDetails(); - - expect(prisma.originGood.findMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { delisted: false, source: 'SDS' } }), - ); - expect(sds.fetchProductDetail).toHaveBeenCalledTimes(2); - expect(result).toEqual({ total: 2, synced: 2, failed: 0 }); - }); - - it('hourly detail refresh remains limited to configured products', async () => { - const { scopedService, prisma } = createService(); - await scopedService.syncConfiguredProductDetails(); - - expect(prisma.originGood.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: { delisted: false, source: 'SDS', goods: { some: {} } }, - }), - ); - }); - - it('keeps hourly category/product sync separate from the daily detail sync', async () => { - const { scopedService } = createService(); - const categories = jest.spyOn(scopedService, 'syncCategories').mockResolvedValue({ - inserted: 0, updated: 0, total: 0, deletedStale: 0, - }); - const products = jest.spyOn(scopedService, 'syncProducts').mockResolvedValue({ - inserted: 0, updated: 0, total: 0, leafCategories: 0, delisted: 0, - }); - const details = jest.spyOn(scopedService, 'syncProductDetails').mockResolvedValue({ - total: 0, synced: 0, failed: 0, - }); - - await scopedService.hourlyCron(); - expect(categories).toHaveBeenCalledTimes(1); - expect(products).toHaveBeenCalledTimes(1); - expect(details).not.toHaveBeenCalled(); - - await scopedService.dailyProductDetailCron(); - expect(details).toHaveBeenCalledTimes(1); - }); -}); +}); + +describe('SyncService product detail scopes', () => { + const originGoods = [ + { id: 1n, sdsGoodId: 'all-1' }, + { id: 2n, sdsGoodId: 'all-2' }, + ]; + + function createService() { + const prisma = { + originGood: { findMany: jest.fn().mockResolvedValue(originGoods) }, + } as unknown as PrismaService; + const sds = { + fetchProductDetail: jest.fn(async (goodId: string) => ({ id: goodId })), + } as unknown as SdsClientService; + const familyRecompute = { + enqueue: jest.fn(), + } as unknown as FamilyRecomputeService; + const scopedService = new SyncService(prisma, sds, familyRecompute); + jest + .spyOn(scopedService as any, 'persistProductDetail') + .mockResolvedValue(undefined); + return { scopedService, prisma, sds }; + } + + it('manual detail sync selects every active origin product', async () => { + const { scopedService, prisma, sds } = createService(); + const result = await scopedService.syncAllProductDetails(); + + expect(prisma.originGood.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { delisted: false, source: 'SDS' } }), + ); + expect(sds.fetchProductDetail).toHaveBeenCalledTimes(2); + expect(result).toEqual({ total: 2, synced: 2, failed: 0 }); + }); + + it('hourly detail refresh remains limited to configured products', async () => { + const { scopedService, prisma } = createService(); + await scopedService.syncConfiguredProductDetails(); + + expect(prisma.originGood.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { delisted: false, source: 'SDS', goods: { some: {} } }, + }), + ); + }); + + it('keeps hourly category/product sync separate from the daily detail sync', async () => { + const { scopedService } = createService(); + const categories = jest.spyOn(scopedService, 'syncCategories').mockResolvedValue({ + inserted: 0, updated: 0, total: 0, deletedStale: 0, + }); + const products = jest.spyOn(scopedService, 'syncProducts').mockResolvedValue({ + inserted: 0, updated: 0, total: 0, leafCategories: 0, delisted: 0, + }); + const details = jest.spyOn(scopedService, 'syncProductDetails').mockResolvedValue({ + total: 0, synced: 0, failed: 0, + }); + + await scopedService.hourlyCron(); + expect(categories).toHaveBeenCalledTimes(1); + expect(products).toHaveBeenCalledTimes(1); + expect(details).not.toHaveBeenCalled(); + + await scopedService.dailyProductDetailCron(); + expect(details).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/api/src/sync/sync.service.ts b/apps/api/src/sync/sync.service.ts index be13f69..c923f80 100644 --- a/apps/api/src/sync/sync.service.ts +++ b/apps/api/src/sync/sync.service.ts @@ -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 { + 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 { + const self = await this.prisma.originGood.findUnique({ + where: { id: originGoodId }, + select: { sdsCategoryId: true }, + }); + let familyIds: Set; + 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({ diff --git a/docs/references/product-center.md b/docs/references/product-center.md index 18a2b15..8b4ecf5 100644 --- a/docs/references/product-center.md +++ b/docs/references/product-center.md @@ -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 diff --git a/docs/references/structs.md b/docs/references/structs.md index 506972b..8f83d68 100644 --- a/docs/references/structs.md +++ b/docs/references/structs.md @@ -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 | diff --git a/docs/superpowers/specs/2026-08-28-product-family-merge-design.md b/docs/superpowers/specs/2026-08-28-product-family-merge-design.md new file mode 100644 index 0000000..0d077a3 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-product-family-merge-design.md @@ -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. 覆盖价方向 → 不限制(可高于或低于推导价),仅校验为正数。 diff --git a/plans/feature/product-family-merge-feature.md b/plans/feature/product-family-merge-feature.md new file mode 100644 index 0000000..b3cdb37 --- /dev/null +++ b/plans/feature/product-family-merge-feature.md @@ -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/_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 { + 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 = {}): 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-`); +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 复用。