From 9c279ae393587f277774dcdd1cf713272bbd0da7 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Thu, 27 Aug 2026 18:15:56 +0800 Subject: [PATCH] feat(goods): support merged secondary origin goods in create/update/detail --- .../src/goods/dto/batch-create-good.dto.ts | 7 + apps/api/src/goods/dto/create-good.dto.ts | 7 + apps/api/src/goods/dto/good.dto.ts | 226 +++--- apps/api/src/goods/dto/update-good.dto.ts | 7 + apps/api/src/goods/goods.service.spec.ts | 243 +++++-- apps/api/src/goods/goods.service.ts | 657 ++++++++++-------- 6 files changed, 729 insertions(+), 418 deletions(-) diff --git a/apps/api/src/goods/dto/batch-create-good.dto.ts b/apps/api/src/goods/dto/batch-create-good.dto.ts index 709db62..f31e15c 100644 --- a/apps/api/src/goods/dto/batch-create-good.dto.ts +++ b/apps/api/src/goods/dto/batch-create-good.dto.ts @@ -15,6 +15,13 @@ export class BatchCreateItemDto { @Min(1) originGoodId!: number; + @ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' }) + @IsOptional() + @IsArray() + @IsInt({ each: true }) + @Min(1, { each: true }) + mergedOriginGoodIds?: number[]; + @ApiProperty({ required: false }) @IsOptional() @IsInt() diff --git a/apps/api/src/goods/dto/create-good.dto.ts b/apps/api/src/goods/dto/create-good.dto.ts index 87f8ef3..f766141 100644 --- a/apps/api/src/goods/dto/create-good.dto.ts +++ b/apps/api/src/goods/dto/create-good.dto.ts @@ -20,6 +20,13 @@ export class CreateGoodDto { @Min(1) originGoodId!: number; + @ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' }) + @IsOptional() + @IsArray() + @IsInt({ each: true }) + @Min(1, { each: true }) + mergedOriginGoodIds?: number[]; + @ApiProperty() @IsInt() @Min(1) diff --git a/apps/api/src/goods/dto/good.dto.ts b/apps/api/src/goods/dto/good.dto.ts index 0c543a1..0baf2c9 100644 --- a/apps/api/src/goods/dto/good.dto.ts +++ b/apps/api/src/goods/dto/good.dto.ts @@ -6,33 +6,58 @@ export interface GoodRelations { category?: { id: bigint; categoryName: string; categoryIcon: string | null } | null; tag?: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } | null; position?: { id: bigint; indexVal: number } | null; - originGood?: { + originGood?: { id: bigint; - sdsGoodId: string; - source: 'SDS' | 'CUSTOM'; + sdsGoodId: string; + source: 'SDS' | 'CUSTOM'; goodName: string | null; goodImage: string | null; - goodPrice: unknown; - detail?: { - productCode: string | null; - syncedAt: Date; - sizeChart: unknown; - packageSpecs: unknown; - [key: string]: unknown; - } | null; - variants?: Array<{ - sdsVariantId: string; - sku: string; - sizeName: string | null; - colorName: string | null; - colorHex: string | null; - price: unknown; - enabled: boolean; - [key: string]: unknown; - }>; - _count?: { variants: number }; + goodPrice: unknown; + detail?: { + productCode: string | null; + syncedAt: Date; + sizeChart: unknown; + packageSpecs: unknown; + [key: string]: unknown; + } | null; + variants?: Array<{ + sdsVariantId: string; + sku: string; + sizeName: string | null; + colorName: string | null; + colorHex: string | null; + price: unknown; + enabled: boolean; + [key: string]: unknown; + }>; + _count?: { variants: number }; } | null; goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[]; + mergedOriginGoods?: Array<{ + originGood: { + id: bigint; + sdsGoodId: string; + source: 'SDS' | 'CUSTOM'; + goodName: string | null; + goodImage: string | null; + goodPrice: unknown; + detail?: { syncedAt: Date } | Record | null; + variants?: Array<{ [key: string]: unknown }>; + _count?: { variants: number }; + }; + }>; +} + +export interface MergedOriginGoodSummary { + id: string; + sdsGoodId: string; + source: 'SDS' | 'CUSTOM'; + isCustom: boolean; + goodName: string | null; + goodImage: string | null; + goodPrice: string | null; + hasDetail: boolean; + variantCount: number; } export class GoodDto { @@ -81,24 +106,27 @@ export class GoodDto { @ApiProperty({ required: false, type: Array }) tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>; + @ApiProperty({ required: false, type: Array }) + mergedOriginGoods!: MergedOriginGoodSummary[]; + @ApiProperty({ required: false, nullable: true }) position?: { id: string; indexVal: number } | null; @ApiProperty({ required: false, nullable: true }) - originGood?: { + originGood?: { id: string; - sdsGoodId: string; - source: 'SDS' | 'CUSTOM'; - isCustom: boolean; + sdsGoodId: string; + source: 'SDS' | 'CUSTOM'; + isCustom: boolean; goodName: string | null; goodImage: string | null; - goodPrice: string | null; - hasDetail: boolean; - detailSyncedAt: string | null; - variantCount: number; - sizeRowCount: number; - packageRowCount: number; - productCode: string | null; + goodPrice: string | null; + hasDetail: boolean; + detailSyncedAt: string | null; + variantCount: number; + sizeRowCount: number; + packageRowCount: number; + productCode: string | null; } | null; static from( @@ -147,70 +175,108 @@ export class GoodDto { tagFontColor: gt.tag.tagFontColor, })) : [], + mergedOriginGoods: (rel.mergedOriginGoods ?? []).map((m) => ({ + id: m.originGood.id.toString(), + sdsGoodId: m.originGood.sdsGoodId, + source: m.originGood.source, + isCustom: m.originGood.source === 'CUSTOM', + goodName: m.originGood.goodName, + goodImage: m.originGood.goodImage, + goodPrice: + m.originGood.goodPrice === null || m.originGood.goodPrice === undefined + ? null + : (m.originGood.goodPrice as { toString(): string }).toString(), + hasDetail: Boolean(m.originGood.detail), + variantCount: + m.originGood._count?.variants ?? m.originGood.variants?.length ?? 0, + })), position: rel.position ? { id: rel.position.id.toString(), indexVal: rel.position.indexVal, } : null, - originGood: rel.originGood - ? { + originGood: rel.originGood + ? { id: rel.originGood.id.toString(), - sdsGoodId: rel.originGood.sdsGoodId, - source: rel.originGood.source, - isCustom: rel.originGood.source === 'CUSTOM', + sdsGoodId: rel.originGood.sdsGoodId, + source: rel.originGood.source, + isCustom: rel.originGood.source === 'CUSTOM', goodName: rel.originGood.goodName, goodImage: rel.originGood.goodImage, goodPrice: rel.originGood.goodPrice === null || rel.originGood.goodPrice === undefined ? null - : (rel.originGood.goodPrice as { toString(): string }).toString(), - hasDetail: Boolean(rel.originGood.detail), - detailSyncedAt: rel.originGood.detail?.syncedAt.toISOString() ?? null, - variantCount: rel.originGood._count?.variants ?? rel.originGood.variants?.length ?? 0, - sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart), - packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs), - productCode: rel.originGood.detail?.productCode ?? null, - } + : (rel.originGood.goodPrice as { toString(): string }).toString(), + hasDetail: Boolean(rel.originGood.detail), + detailSyncedAt: rel.originGood.detail?.syncedAt.toISOString() ?? null, + variantCount: rel.originGood._count?.variants ?? rel.originGood.variants?.length ?? 0, + sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart), + packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs), + productCode: rel.originGood.detail?.productCode ?? null, + } : null, - }; - } - - private static jsonRows(value: unknown): number { - if (!value || typeof value !== 'object' || !('rows' in value)) return 0; - const rows = (value as { rows?: unknown }).rows; - return Array.isArray(rows) ? rows.length : 0; - } -} - -export class GoodDetailDto extends GoodDto { - @ApiProperty({ nullable: true, type: Object }) - originDetail!: Record | null; - - @ApiProperty({ type: Array }) - variants!: Array>; - - static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto { - const base = GoodDto.from(good, rel); - const detail = rel.originGood?.detail; - return { - ...base, - originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null, - variants: (rel.originGood?.variants ?? []).map((variant) => ({ - ...variant, - price: - variant.price === null || variant.price === undefined - ? null - : (variant.price as { toString(): string }).toString(), - })), - }; - } -} + }; + } + + private static jsonRows(value: unknown): number { + if (!value || typeof value !== 'object' || !('rows' in value)) return 0; + const rows = (value as { rows?: unknown }).rows; + return Array.isArray(rows) ? rows.length : 0; + } +} + +export class GoodDetailDto extends GoodDto { + @ApiProperty({ nullable: true, type: Object }) + originDetail!: Record | null; + + @ApiProperty({ type: Array }) + variants!: Array>; + + static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto { + const base = GoodDto.from(good, rel); + const detail = rel.originGood?.detail; + const toAnnotated = ( + variant: Record, + originGoodId: string, + originGoodName: string | null, + ) => ({ + ...variant, + price: + variant.price === null || variant.price === undefined + ? null + : (variant.price as unknown as { toString(): string }).toString(), + originGoodId, + originGoodName, + }); + const primaryId = good.originGoodId.toString(); + const primaryName = rel.originGood?.goodName ?? null; + const mergedVariants = [ + ...(rel.originGood?.variants ?? []).map((variant) => + toAnnotated(variant as Record, primaryId, primaryName), + ), + ...(rel.mergedOriginGoods ?? []).flatMap((m) => + (m.originGood.variants ?? []).map((variant) => + toAnnotated( + variant, + m.originGood.id.toString(), + m.originGood.goodName, + ), + ), + ), + ]; + return { + ...base, + originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null, + variants: mergedVariants, + }; + } +} export interface PaginatedGoods { items: GoodDto[]; total: number; page: number; pageSize: number; -} +} diff --git a/apps/api/src/goods/dto/update-good.dto.ts b/apps/api/src/goods/dto/update-good.dto.ts index cde1306..54ab7cb 100644 --- a/apps/api/src/goods/dto/update-good.dto.ts +++ b/apps/api/src/goods/dto/update-good.dto.ts @@ -19,6 +19,13 @@ export class UpdateGoodDto { @Min(1) originGoodId?: number; + @ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID 全量覆盖,不含主源' }) + @IsOptional() + @IsArray() + @IsInt({ each: true }) + @Min(1, { each: true }) + mergedOriginGoodIds?: number[]; + @ApiProperty({ required: false }) @IsOptional() @IsInt() diff --git a/apps/api/src/goods/goods.service.spec.ts b/apps/api/src/goods/goods.service.spec.ts index 2779cdf..b687494 100644 --- a/apps/api/src/goods/goods.service.spec.ts +++ b/apps/api/src/goods/goods.service.spec.ts @@ -4,8 +4,8 @@ import { NotFoundException, } from '@nestjs/common'; import { GoodsService } from './goods.service'; -import { PrismaService } from '../prisma/prisma.service'; -import { SyncService } from '../sync/sync.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { SyncService } from '../sync/sync.service'; describe('GoodsService', () => { let service: GoodsService; @@ -23,14 +23,14 @@ describe('GoodsService', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ - providers: [ - GoodsService, - PrismaService, - { - provide: SyncService, - useValue: { queueProductDetailSync: jest.fn() }, - }, - ], + providers: [ + GoodsService, + PrismaService, + { + provide: SyncService, + useValue: { queueProductDetailSync: jest.fn() }, + }, + ], }).compile(); service = moduleRef.get(GoodsService); prisma = moduleRef.get(PrismaService); @@ -101,7 +101,7 @@ describe('GoodsService', () => { expect(service).toBeDefined(); }); - it('creates and reads back a good', async () => { + it('creates and reads back a good', async () => { const created = await service.create({ goodName: `Goods Test ${stamp} basic`, originGoodId: Number(originGoodIds[0]), @@ -117,52 +117,52 @@ describe('GoodsService', () => { const fetched = await service.findOne(BigInt(created.id)); expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`); - }); - - it('creates, edits, and removes a fully editable custom good', async () => { - const created = await service.createCustom({ - goodName: `Goods Test ${stamp} custom`, - goodImage: 'https://example.com/custom.png', - goodPrice: '29.90', - countryId: Number(countryId), - categoryId: Number(categoryId), - tagIds: [Number(tagId)], - detail: { - productCode: `CUSTOM-${stamp}`, - materialDescription: 'Cotton', - sizeChart: { columns: [], rows: [] }, - packageSpecs: { rows: [] }, - }, - variants: [ - { sku: `CUSTOM-SKU-${stamp}`, sizeName: 'S', price: '29.90' }, - ], - }); - - expect(created.originGood?.source).toBe('CUSTOM'); - expect(created.originGood?.isCustom).toBe(true); - expect(created.originGood?.goodPrice).toBe('29.9'); - expect(created.variants).toHaveLength(1); - - const updated = await service.updateCustomContent(BigInt(created.id), { - goodName: `Goods Test ${stamp} custom edited`, - goodPrice: '39.90', - detail: { materialDescription: 'Organic cotton' }, - variants: [ - { sku: `CUSTOM-SKU-${stamp}-M`, sizeName: 'M', price: '39.90' }, - ], - }); - expect(updated.goodName).toContain('custom edited'); - expect(updated.originGood?.goodPrice).toBe('39.9'); - expect(updated.originDetail?.materialDescription).toBe('Organic cotton'); - expect(updated.originDetail?.productCode).toBe(`CUSTOM-${stamp}`); - expect(updated.variants[0]?.sizeName).toBe('M'); - - const customOriginId = BigInt(updated.originGoodId); - await service.remove(BigInt(updated.id)); - await expect( - prisma.originGood.findUnique({ where: { id: customOriginId } }), - ).resolves.toBeNull(); - }); + }); + + it('creates, edits, and removes a fully editable custom good', async () => { + const created = await service.createCustom({ + goodName: `Goods Test ${stamp} custom`, + goodImage: 'https://example.com/custom.png', + goodPrice: '29.90', + countryId: Number(countryId), + categoryId: Number(categoryId), + tagIds: [Number(tagId)], + detail: { + productCode: `CUSTOM-${stamp}`, + materialDescription: 'Cotton', + sizeChart: { columns: [], rows: [] }, + packageSpecs: { rows: [] }, + }, + variants: [ + { sku: `CUSTOM-SKU-${stamp}`, sizeName: 'S', price: '29.90' }, + ], + }); + + expect(created.originGood?.source).toBe('CUSTOM'); + expect(created.originGood?.isCustom).toBe(true); + expect(created.originGood?.goodPrice).toBe('29.9'); + expect(created.variants).toHaveLength(1); + + const updated = await service.updateCustomContent(BigInt(created.id), { + goodName: `Goods Test ${stamp} custom edited`, + goodPrice: '39.90', + detail: { materialDescription: 'Organic cotton' }, + variants: [ + { sku: `CUSTOM-SKU-${stamp}-M`, sizeName: 'M', price: '39.90' }, + ], + }); + expect(updated.goodName).toContain('custom edited'); + expect(updated.originGood?.goodPrice).toBe('39.9'); + expect(updated.originDetail?.materialDescription).toBe('Organic cotton'); + expect(updated.originDetail?.productCode).toBe(`CUSTOM-${stamp}`); + expect(updated.variants[0]?.sizeName).toBe('M'); + + const customOriginId = BigInt(updated.originGoodId); + await service.remove(BigInt(updated.id)); + await expect( + prisma.originGood.findUnique({ where: { id: customOriginId } }), + ).resolves.toBeNull(); + }); it('filters by countryId, tagId, positionId and keyword', async () => { const result = await service.findAll({ @@ -261,6 +261,135 @@ describe('GoodsService', () => { expect(after.total).toBe(before.total); }); + describe('merged origin goods', () => { + it('creates a good with merged origin goods and reads them back', async () => { + const created = await service.create({ + goodName: `Goods Test ${stamp} merged`, + originGoodId: Number(originGoodIds[1]), + mergedOriginGoodIds: [Number(originGoodIds[2]), Number(originGoodIds[3])], + countryId: Number(countryId), + categoryId: Number(categoryId), + }); + expect(created.mergedOriginGoods.map((m) => m.id).sort()).toEqual( + [originGoodIds[2].toString(), originGoodIds[3].toString()].sort(), + ); + const fetched = await service.findOne(BigInt(created.id)); + expect(fetched.mergedOriginGoods.length).toBe(2); + }); + + it('rejects mergedOriginGoodIds containing the primary', async () => { + await expect( + service.create({ + goodName: `Goods Test ${stamp} bad-primary`, + originGoodId: Number(originGoodIds[1]), + mergedOriginGoodIds: [Number(originGoodIds[1])], + countryId: Number(countryId), + categoryId: Number(categoryId), + }), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects mergedOriginGoodIds that do not exist', async () => { + await expect( + service.create({ + goodName: `Goods Test ${stamp} bad-missing`, + originGoodId: Number(originGoodIds[1]), + mergedOriginGoodIds: [999999999], + countryId: Number(countryId), + categoryId: Number(categoryId), + }), + ).rejects.toThrow(BadRequestException); + }); + + it('replaces merged origin goods on update', async () => { + const created = await service.create({ + goodName: `Goods Test ${stamp} replace`, + originGoodId: Number(originGoodIds[1]), + mergedOriginGoodIds: [Number(originGoodIds[2])], + countryId: Number(countryId), + categoryId: Number(categoryId), + }); + const updated = await service.update(BigInt(created.id), { + mergedOriginGoodIds: [Number(originGoodIds[3]), Number(originGoodIds[4])], + }); + expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual( + [originGoodIds[3].toString(), originGoodIds[4].toString()].sort(), + ); + }); + + it('moves old primary into merged list when switching primary', async () => { + const created = await service.create({ + goodName: `Goods Test ${stamp} switch`, + originGoodId: Number(originGoodIds[1]), + mergedOriginGoodIds: [Number(originGoodIds[2])], + countryId: Number(countryId), + categoryId: Number(categoryId), + }); + const updated = await service.update(BigInt(created.id), { + originGoodId: Number(originGoodIds[2]), + mergedOriginGoodIds: [Number(originGoodIds[1]), Number(originGoodIds[3])], + }); + expect(updated.originGoodId).toBe(originGoodIds[2].toString()); + expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual( + [originGoodIds[1].toString(), originGoodIds[3].toString()].sort(), + ); + }); + + it('cascades merged rows on good removal', async () => { + const created = await service.create({ + goodName: `Goods Test ${stamp} cascade`, + originGoodId: Number(originGoodIds[1]), + mergedOriginGoodIds: [Number(originGoodIds[2])], + countryId: Number(countryId), + categoryId: Number(categoryId), + }); + await service.remove(BigInt(created.id)); + const rows = await prisma.goodOriginGood.count({ + where: { goodId: BigInt(created.id) }, + }); + expect(rows).toBe(0); + }); + + it('returns merged variants with source annotation in detail', async () => { + const v1 = await prisma.originGoodVariant.create({ + data: { + originGoodId: originGoodIds[1], + sdsVariantId: `mv-pri-${stamp}`, + sku: `MV-PRI-${stamp}`, + colorName: '黑色', + }, + }); + const v2 = await prisma.originGoodVariant.create({ + data: { + originGoodId: originGoodIds[2], + sdsVariantId: `mv-sec-${stamp}`, + sku: `MV-SEC-${stamp}`, + colorName: '白色', + }, + }); + try { + const created = await service.create({ + goodName: `Goods Test ${stamp} variants`, + originGoodId: Number(originGoodIds[1]), + mergedOriginGoodIds: [Number(originGoodIds[2])], + countryId: Number(countryId), + categoryId: Number(categoryId), + }); + const detail = await service.findOne(BigInt(created.id)); + const sources = new Set( + detail.variants.map((v) => v['originGoodId'] as string), + ); + expect(sources.has(originGoodIds[1].toString())).toBe(true); + expect(sources.has(originGoodIds[2].toString())).toBe(true); + expect(detail.variants).toHaveLength(2); + expect(detail.mergedOriginGoods.find((m) => m.id === originGoodIds[2].toString())?.variantCount).toBe(1); + } finally { + await prisma.originGoodVariant.delete({ where: { id: v1.id } }); + await prisma.originGoodVariant.delete({ where: { id: v2.id } }); + } + }); + }); + it('throws NotFoundException for unknown id', async () => { await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf( NotFoundException, diff --git a/apps/api/src/goods/goods.service.ts b/apps/api/src/goods/goods.service.ts index 3d17234..0d094fa 100644 --- a/apps/api/src/goods/goods.service.ts +++ b/apps/api/src/goods/goods.service.ts @@ -9,38 +9,50 @@ import { CreateGoodDto } from './dto/create-good.dto'; import { UpdateGoodDto } from './dto/update-good.dto'; import { QueryGoodDto } from './dto/query-good.dto'; 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 { randomUUID } from 'crypto'; -import { - CreateCustomGoodDto, - CustomGoodDetailDto, - CustomGoodVariantDto, - UpdateCustomGoodContentDto, -} from './dto/custom-good.dto'; +import { BatchPriorityDto } from './dto/batch-priority.dto'; +import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto'; +import { SyncService } from '../sync/sync.service'; +import { randomUUID } from 'crypto'; +import { + CreateCustomGoodDto, + CustomGoodDetailDto, + CustomGoodVariantDto, + UpdateCustomGoodContentDto, +} from './dto/custom-good.dto'; const GOOD_INCLUDE = { country: true, category: true, tag: true, position: true, - originGood: { - include: { - detail: true, - variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, - _count: { select: { variants: true } }, - }, - }, + originGood: { + include: { + detail: true, + variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, + _count: { select: { variants: true } }, + }, + }, goodTags: { include: { tag: true } }, + mergedOriginGoods: { + orderBy: { createdAt: 'asc' }, + include: { + originGood: { + include: { + detail: true, + variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, + _count: { select: { variants: true } }, + }, + }, + }, + }, } satisfies Prisma.GoodInclude; @Injectable() export class GoodsService { - constructor( - private readonly prisma: PrismaService, - private readonly syncService: SyncService, - ) {} + constructor( + private readonly prisma: PrismaService, + private readonly syncService: SyncService, + ) {} async findAll(query: QueryGoodDto): Promise { const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query; @@ -75,6 +87,7 @@ export class GoodsService { position: g.position, originGood: g.originGood, goodTags: g.goodTags, + mergedOriginGoods: g.mergedOriginGoods, })), total, page, @@ -82,25 +95,31 @@ export class GoodsService { }; } - async findOne(id: bigint): Promise { + async findOne(id: bigint): Promise { const good = await this.prisma.good.findUnique({ where: { id }, include: GOOD_INCLUDE, }); if (!good) throw new NotFoundException(`Good ${id} not found`); - return GoodDetailDto.fromGood(good, { + return GoodDetailDto.fromGood(good, { country: good.country, category: good.category, tag: good.tag, position: good.position, originGood: good.originGood, goodTags: good.goodTags, + mergedOriginGoods: good.mergedOriginGoods, }); } - async create(dto: CreateGoodDto): Promise { - await this.ensureReferences(dto); - const result = await this.prisma.$transaction(async (tx) => { + async create(dto: CreateGoodDto): Promise { + await this.ensureReferences(dto); + const mergedIds = this.dedupeMergedIds( + BigInt(dto.originGoodId), + dto.mergedOriginGoodIds, + ); + await this.ensureMergedOriginGoods(mergedIds); + const result = await this.prisma.$transaction(async (tx) => { const created = await tx.good.create({ data: { goodName: dto.goodName, @@ -120,132 +139,152 @@ export class GoodsService { })), }); } + if (mergedIds.length > 0) { + await tx.goodOriginGood.createMany({ + data: mergedIds.map((originGoodId) => ({ + goodId: created.id, + originGoodId, + })), + }); + } const result = await tx.good.findUniqueOrThrow({ where: { id: created.id }, include: GOOD_INCLUDE, }); - return GoodDto.from(result, { + return GoodDto.from(result, { country: result.country, category: result.category, tag: result.tag, position: result.position, originGood: result.originGood, goodTags: result.goodTags, - }); - }); - if ( - result.originGood?.source === 'SDS' && - result.originGood.sdsGoodId && - !result.originGood.hasDetail - ) { - this.syncService.queueProductDetailSync(result.originGood.sdsGoodId); - } - return result; - } - - async createCustom(dto: CreateCustomGoodDto): Promise { - await this.ensureCountry(dto.countryId); - await this.ensureCategory(dto.categoryId); - if (dto.positionId !== undefined) await this.ensurePosition(dto.positionId); - for (const tagId of dto.tagIds ?? []) await this.ensureTag(tagId); - - const goodId = await this.prisma.$transaction(async (tx) => { - const originGood = await tx.originGood.create({ - data: { - source: 'CUSTOM', - sdsGoodId: `custom-${randomUUID()}`, - goodName: dto.goodName, - goodImage: dto.goodImage ?? null, - goodPrice: this.decimal(dto.goodPrice), - detail: { - create: this.customDetailData( - dto.detail ?? {}, - ) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput, - }, - }, - }); - if (dto.variants?.length) { - await this.replaceCustomVariants(tx, originGood.id, dto.variants); - } - const good = await tx.good.create({ - data: { - originGoodId: originGood.id, - countryId: BigInt(dto.countryId), - categoryId: BigInt(dto.categoryId), - positionId: - dto.positionId === undefined ? null : BigInt(dto.positionId), - goodName: dto.goodName, - goodImage: dto.goodImage ?? null, - goodPriority: dto.goodPriority ?? 0, - }, - }); - if (dto.tagIds?.length) { - await tx.goodTag.createMany({ - data: dto.tagIds.map((tagId) => ({ - goodId: good.id, - tagId: BigInt(tagId), - })), - }); - } - return good.id; - }); - return this.findOne(goodId); - } - - async updateCustomContent( - id: bigint, - dto: UpdateCustomGoodContentDto, - ): Promise { - const existing = await this.prisma.good.findUnique({ - where: { id }, - include: { originGood: true }, - }); - if (!existing) throw new NotFoundException(`Good ${id} not found`); - if (existing.originGood.source !== 'CUSTOM') { - throw new BadRequestException('SDS 映射商品的上游信息不可修改'); - } - - await this.prisma.$transaction(async (tx) => { - await tx.originGood.update({ - where: { id: existing.originGoodId }, - data: { - goodName: dto.goodName, - goodImage: dto.goodImage, - goodPrice: - dto.goodPrice === undefined ? undefined : this.decimal(dto.goodPrice), - }, - }); - if (dto.detail !== undefined) { - await tx.originGoodDetail.upsert({ - where: { originGoodId: existing.originGoodId }, - create: { - originGoodId: existing.originGoodId, - ...(this.customDetailData( - dto.detail, - ) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput), - }, - update: this.customDetailData(dto.detail, true), - }); - } - if (dto.variants !== undefined) { - await this.replaceCustomVariants( - tx, - existing.originGoodId, - dto.variants, - ); - } - const goodData: Prisma.GoodUpdateInput = {}; - if (dto.goodName !== undefined) goodData.goodName = dto.goodName; - if (dto.goodImage !== undefined) goodData.goodImage = dto.goodImage; - if (Object.keys(goodData).length) { - await tx.good.update({ where: { id }, data: goodData }); - } - }); - return this.findOne(id); - } + mergedOriginGoods: result.mergedOriginGoods, + }); + }); + if ( + result.originGood?.source === 'SDS' && + result.originGood.sdsGoodId && + !result.originGood.hasDetail + ) { + this.syncService.queueProductDetailSync(result.originGood.sdsGoodId); + } + return result; + } + + async createCustom(dto: CreateCustomGoodDto): Promise { + await this.ensureCountry(dto.countryId); + await this.ensureCategory(dto.categoryId); + if (dto.positionId !== undefined) await this.ensurePosition(dto.positionId); + for (const tagId of dto.tagIds ?? []) await this.ensureTag(tagId); + + const goodId = await this.prisma.$transaction(async (tx) => { + const originGood = await tx.originGood.create({ + data: { + source: 'CUSTOM', + sdsGoodId: `custom-${randomUUID()}`, + goodName: dto.goodName, + goodImage: dto.goodImage ?? null, + goodPrice: this.decimal(dto.goodPrice), + detail: { + create: this.customDetailData( + dto.detail ?? {}, + ) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput, + }, + }, + }); + if (dto.variants?.length) { + await this.replaceCustomVariants(tx, originGood.id, dto.variants); + } + const good = await tx.good.create({ + data: { + originGoodId: originGood.id, + countryId: BigInt(dto.countryId), + categoryId: BigInt(dto.categoryId), + positionId: + dto.positionId === undefined ? null : BigInt(dto.positionId), + goodName: dto.goodName, + goodImage: dto.goodImage ?? null, + goodPriority: dto.goodPriority ?? 0, + }, + }); + if (dto.tagIds?.length) { + await tx.goodTag.createMany({ + data: dto.tagIds.map((tagId) => ({ + goodId: good.id, + tagId: BigInt(tagId), + })), + }); + } + return good.id; + }); + return this.findOne(goodId); + } + + async updateCustomContent( + id: bigint, + dto: UpdateCustomGoodContentDto, + ): Promise { + const existing = await this.prisma.good.findUnique({ + where: { id }, + include: { originGood: true }, + }); + if (!existing) throw new NotFoundException(`Good ${id} not found`); + if (existing.originGood.source !== 'CUSTOM') { + throw new BadRequestException('SDS 映射商品的上游信息不可修改'); + } + + await this.prisma.$transaction(async (tx) => { + await tx.originGood.update({ + where: { id: existing.originGoodId }, + data: { + goodName: dto.goodName, + goodImage: dto.goodImage, + goodPrice: + dto.goodPrice === undefined ? undefined : this.decimal(dto.goodPrice), + }, + }); + if (dto.detail !== undefined) { + await tx.originGoodDetail.upsert({ + where: { originGoodId: existing.originGoodId }, + create: { + originGoodId: existing.originGoodId, + ...(this.customDetailData( + dto.detail, + ) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput), + }, + update: this.customDetailData(dto.detail, true), + }); + } + if (dto.variants !== undefined) { + await this.replaceCustomVariants( + tx, + existing.originGoodId, + dto.variants, + ); + } + const goodData: Prisma.GoodUpdateInput = {}; + if (dto.goodName !== undefined) goodData.goodName = dto.goodName; + if (dto.goodImage !== undefined) goodData.goodImage = dto.goodImage; + if (Object.keys(goodData).length) { + await tx.good.update({ where: { id }, data: goodData }); + } + }); + return this.findOne(id); + } async update(id: bigint, dto: UpdateGoodDto): Promise { await this.findOne(id); + let mergedIds: bigint[] | undefined; + if (dto.mergedOriginGoodIds !== undefined || dto.originGoodId !== undefined) { + const current = await this.prisma.good.findUniqueOrThrow({ + where: { id }, + select: { originGoodId: true }, + }); + const primaryId = + dto.originGoodId !== undefined ? BigInt(dto.originGoodId) : current.originGoodId; + mergedIds = this.dedupeMergedIds(primaryId, dto.mergedOriginGoodIds); + await this.ensureMergedOriginGoods(mergedIds); + } const data: Prisma.GoodUpdateInput = {}; if (dto.goodName !== undefined) data.goodName = dto.goodName; if (dto.originGoodId !== undefined) { @@ -274,7 +313,7 @@ export class GoodsService { } } - const result = await this.prisma.$transaction(async (tx) => { + const result = await this.prisma.$transaction(async (tx) => { if (dto.tagIds !== undefined) { await tx.goodTag.deleteMany({ where: { goodId: id } }); if (dto.tagIds.length > 0) { @@ -286,47 +325,59 @@ export class GoodsService { }); } } + if (mergedIds !== undefined) { + await tx.goodOriginGood.deleteMany({ where: { goodId: id } }); + if (mergedIds.length > 0) { + await tx.goodOriginGood.createMany({ + data: mergedIds.map((originGoodId) => ({ + goodId: id, + originGoodId, + })), + }); + } + } const updated = await tx.good.update({ where: { id }, data, include: GOOD_INCLUDE, }); - return GoodDto.from(updated, { + return GoodDto.from(updated, { country: updated.country, category: updated.category, tag: updated.tag, position: updated.position, originGood: updated.originGood, goodTags: updated.goodTags, - }); - }); - if ( - result.originGood?.source === 'SDS' && - result.originGood.sdsGoodId && - !result.originGood.hasDetail - ) { - this.syncService.queueProductDetailSync(result.originGood.sdsGoodId); - } - return result; + mergedOriginGoods: updated.mergedOriginGoods, + }); + }); + if ( + result.originGood?.source === 'SDS' && + result.originGood.sdsGoodId && + !result.originGood.hasDetail + ) { + this.syncService.queueProductDetailSync(result.originGood.sdsGoodId); + } + return result; } - async remove(id: bigint): Promise<{ id: string }> { - const good = await this.prisma.good.findUnique({ - where: { id }, - include: { originGood: true }, - }); - if (!good) throw new NotFoundException(`Good ${id} not found`); - await this.prisma.$transaction(async (tx) => { - await tx.good.delete({ where: { id } }); - if (good.originGood.source === 'CUSTOM') { - const remaining = await tx.good.count({ - where: { originGoodId: good.originGoodId }, - }); - if (remaining === 0) { - await tx.originGood.delete({ where: { id: good.originGoodId } }); - } - } - }); + async remove(id: bigint): Promise<{ id: string }> { + const good = await this.prisma.good.findUnique({ + where: { id }, + include: { originGood: true }, + }); + if (!good) throw new NotFoundException(`Good ${id} not found`); + await this.prisma.$transaction(async (tx) => { + await tx.good.delete({ where: { id } }); + if (good.originGood.source === 'CUSTOM') { + const remaining = await tx.good.count({ + where: { originGoodId: good.originGoodId }, + }); + if (remaining === 0) { + await tx.originGood.delete({ where: { id: good.originGoodId } }); + } + } + }); return { id: id.toString() }; } @@ -335,16 +386,16 @@ export class GoodsService { * or none do. */ async batchUpdatePriority(dto: BatchPriorityDto): Promise<{ count: number }> { - const result = await this.prisma.$transaction(async (tx) => { + const result = await this.prisma.$transaction(async (tx) => { for (const item of dto.items) { await tx.good.update({ where: { id: BigInt(item.id) }, data: { goodPriority: item.priority }, }); } - return { count: dto.items.length }; - }); - return result; + return { count: dto.items.length }; + }); + return result; } /** @@ -358,7 +409,7 @@ export class GoodsService { await this.ensureTag(tagId); } } - const result = await this.prisma.$transaction(async (tx) => { + const result = await this.prisma.$transaction(async (tx) => { const created: GoodDto[] = []; for (const item of dto.items) { const og = await tx.originGood.findUnique({ @@ -388,6 +439,24 @@ export class GoodsService { })), }); } + const itemMerged = this.dedupeMergedIds(og.id, item.mergedOriginGoodIds); + if (itemMerged.length > 0) { + const existRows = await tx.originGood.findMany({ + where: { id: { in: itemMerged } }, + select: { id: true }, + }); + if (existRows.length !== itemMerged.length) { + const found = new Set(existRows.map((r) => r.id.toString())); + const missing = itemMerged.find((mid) => !found.has(mid.toString())); + throw new BadRequestException(`Origin good ${missing} not found`); + } + await tx.goodOriginGood.createMany({ + data: itemMerged.map((originGoodId) => ({ + goodId: row.id, + originGoodId, + })), + }); + } const result = await tx.good.findUniqueOrThrow({ where: { id: row.id }, include: GOOD_INCLUDE, @@ -399,23 +468,24 @@ export class GoodsService { position: result.position, originGood: result.originGood, goodTags: result.goodTags, + mergedOriginGoods: result.mergedOriginGoods, })); } - return created; - }); - for (const goodId of new Set( - result - .filter( - (item) => - item.originGood?.source === 'SDS' && - !item.originGood.hasDetail, - ) - .map((item) => item.originGood?.sdsGoodId) - .filter((id): id is string => Boolean(id)), - )) { - this.syncService.queueProductDetailSync(goodId); - } - return result; + return created; + }); + for (const goodId of new Set( + result + .filter( + (item) => + item.originGood?.source === 'SDS' && + !item.originGood.hasDetail, + ) + .map((item) => item.originGood?.sdsGoodId) + .filter((id): id is string => Boolean(id)), + )) { + this.syncService.queueProductDetailSync(goodId); + } + return result; } /** @@ -446,6 +516,31 @@ export class GoodsService { if (!og) throw new BadRequestException(`Origin good ${id} not found`); } + /** Dedupe merged ids and reject any that equals the primary source. */ + private dedupeMergedIds(primaryId: bigint, ids?: number[]): bigint[] { + if (!ids || ids.length === 0) return []; + const unique = [...new Set(ids.map((id) => BigInt(id)))]; + if (unique.includes(primaryId)) { + throw new BadRequestException( + 'mergedOriginGoodIds 不能包含主源 originGoodId', + ); + } + return unique; + } + + private async ensureMergedOriginGoods(ids: bigint[]) { + if (ids.length === 0) return; + const rows = await this.prisma.originGood.findMany({ + where: { id: { in: ids } }, + select: { id: true }, + }); + if (rows.length !== ids.length) { + const found = new Set(rows.map((r) => r.id.toString())); + const missing = ids.find((id) => !found.has(id.toString())); + throw new BadRequestException(`Origin good ${missing} not found`); + } + } + private async ensureCountry(id: number) { const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } }); if (!c) throw new BadRequestException(`Country ${id} not found`); @@ -456,12 +551,12 @@ export class GoodsService { if (!c) throw new BadRequestException(`Category ${id} not found`); } - private async ensureTag(id: number) { + private async ensureTag(id: number) { const t = await this.prisma.tag.findUnique({ where: { id: BigInt(id) } }); if (!t) throw new BadRequestException(`Tag ${id} not found`); } - private async ensureReferences(dto: CreateGoodDto) { + private async ensureReferences(dto: CreateGoodDto) { await this.ensureOriginGood(dto.originGoodId); await this.ensureCountry(dto.countryId); await this.ensureCategory(dto.categoryId); @@ -474,94 +569,94 @@ export class GoodsService { const p = await this.prisma.position.findUnique({ where: { id: BigInt(dto.positionId) } }); if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`); } - } - - private async ensurePosition(id: number) { - const position = await this.prisma.position.findUnique({ - where: { id: BigInt(id) }, - }); - if (!position) throw new BadRequestException(`Position ${id} not found`); - } - - private decimal(value: string | null | undefined): Prisma.Decimal | null { - return value === undefined || value === null || value === '' - ? null - : new Prisma.Decimal(value); - } - - private customDetailData( - detail: CustomGoodDetailDto, - preserveMissing = false, - ): Prisma.OriginGoodDetailUncheckedUpdateInput { - const nullable = (value: T | null | undefined): T | null | undefined => - preserveMissing && value === undefined ? undefined : value ?? null; - const decimal = (value: string | null | undefined) => - preserveMissing && value === undefined ? undefined : this.decimal(value); - const json = ( - value: Record | null | undefined, - ): Prisma.InputJsonValue | Prisma.NullTypes.DbNull | undefined => - preserveMissing && value === undefined - ? undefined - : value === null || value === undefined - ? Prisma.DbNull - : (value as Prisma.InputJsonValue); - return { - productCode: nullable(detail.productCode), - englishName: nullable(detail.englishName), - blankDesignUrl: nullable(detail.blankDesignUrl), - detailsPageVideoUrl: nullable(detail.detailsPageVideoUrl), - textureName: nullable(detail.textureName), - productionCycleHours: nullable(detail.productionCycleHours), - minWeightG: decimal(detail.minWeightG), - reminder: nullable(detail.reminder), - productionProcess: nullable(detail.productionProcess), - materialDescription: nullable(detail.materialDescription), - productPerformance: nullable(detail.productPerformance), - applicableScenarios: nullable(detail.applicableScenarios), - washingInstructions: nullable(detail.washingInstructions), - specialDescription: nullable(detail.specialDescription), - designExplanation: nullable(detail.designExplanation), - designArea: nullable(detail.designArea), - pictureRequest: nullable(detail.pictureRequest), - sizeChart: json(detail.sizeChart), - packageSpecs: json(detail.packageSpecs), - options: json(detail.options), - media: json(detail.media), - }; - } - - private async replaceCustomVariants( - tx: Prisma.TransactionClient, - originGoodId: bigint, - variants: CustomGoodVariantDto[], - ): Promise { - await tx.originGoodVariant.deleteMany({ where: { originGoodId } }); - for (const variant of variants) { - await tx.originGoodVariant.create({ - data: { - originGoodId, - sdsVariantId: `custom-${randomUUID()}`, - sku: variant.sku, - sizeId: variant.sizeId ?? null, - sizeName: variant.sizeName ?? null, - colorId: variant.colorId ?? null, - colorName: variant.colorName ?? null, - colorHex: variant.colorHex ?? null, - imageUrl: variant.imageUrl ?? null, - price: this.decimal(variant.price), - originalPrice: this.decimal(variant.originalPrice), - weightG: this.decimal(variant.weightG), - boxLengthCm: this.decimal(variant.boxLengthCm), - boxWidthCm: this.decimal(variant.boxWidthCm), - boxHeightCm: this.decimal(variant.boxHeightCm), - enabled: variant.enabled ?? true, - sortOrder: variant.sortOrder ?? 0, - designData: - variant.designData === null || variant.designData === undefined - ? Prisma.DbNull - : (variant.designData as Prisma.InputJsonValue), - }, - }); - } - } -} + } + + private async ensurePosition(id: number) { + const position = await this.prisma.position.findUnique({ + where: { id: BigInt(id) }, + }); + if (!position) throw new BadRequestException(`Position ${id} not found`); + } + + private decimal(value: string | null | undefined): Prisma.Decimal | null { + return value === undefined || value === null || value === '' + ? null + : new Prisma.Decimal(value); + } + + private customDetailData( + detail: CustomGoodDetailDto, + preserveMissing = false, + ): Prisma.OriginGoodDetailUncheckedUpdateInput { + const nullable = (value: T | null | undefined): T | null | undefined => + preserveMissing && value === undefined ? undefined : value ?? null; + const decimal = (value: string | null | undefined) => + preserveMissing && value === undefined ? undefined : this.decimal(value); + const json = ( + value: Record | null | undefined, + ): Prisma.InputJsonValue | Prisma.NullTypes.DbNull | undefined => + preserveMissing && value === undefined + ? undefined + : value === null || value === undefined + ? Prisma.DbNull + : (value as Prisma.InputJsonValue); + return { + productCode: nullable(detail.productCode), + englishName: nullable(detail.englishName), + blankDesignUrl: nullable(detail.blankDesignUrl), + detailsPageVideoUrl: nullable(detail.detailsPageVideoUrl), + textureName: nullable(detail.textureName), + productionCycleHours: nullable(detail.productionCycleHours), + minWeightG: decimal(detail.minWeightG), + reminder: nullable(detail.reminder), + productionProcess: nullable(detail.productionProcess), + materialDescription: nullable(detail.materialDescription), + productPerformance: nullable(detail.productPerformance), + applicableScenarios: nullable(detail.applicableScenarios), + washingInstructions: nullable(detail.washingInstructions), + specialDescription: nullable(detail.specialDescription), + designExplanation: nullable(detail.designExplanation), + designArea: nullable(detail.designArea), + pictureRequest: nullable(detail.pictureRequest), + sizeChart: json(detail.sizeChart), + packageSpecs: json(detail.packageSpecs), + options: json(detail.options), + media: json(detail.media), + }; + } + + private async replaceCustomVariants( + tx: Prisma.TransactionClient, + originGoodId: bigint, + variants: CustomGoodVariantDto[], + ): Promise { + await tx.originGoodVariant.deleteMany({ where: { originGoodId } }); + for (const variant of variants) { + await tx.originGoodVariant.create({ + data: { + originGoodId, + sdsVariantId: `custom-${randomUUID()}`, + sku: variant.sku, + sizeId: variant.sizeId ?? null, + sizeName: variant.sizeName ?? null, + colorId: variant.colorId ?? null, + colorName: variant.colorName ?? null, + colorHex: variant.colorHex ?? null, + imageUrl: variant.imageUrl ?? null, + price: this.decimal(variant.price), + originalPrice: this.decimal(variant.originalPrice), + weightG: this.decimal(variant.weightG), + boxLengthCm: this.decimal(variant.boxLengthCm), + boxWidthCm: this.decimal(variant.boxWidthCm), + boxHeightCm: this.decimal(variant.boxHeightCm), + enabled: variant.enabled ?? true, + sortOrder: variant.sortOrder ?? 0, + designData: + variant.designData === null || variant.designData === undefined + ? Prisma.DbNull + : (variant.designData as Prisma.InputJsonValue), + }, + }); + } + } +}