From f06dfffbdaa60d99341dfe3c48ede0cd23d492f3 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Thu, 27 Aug 2026 18:20:25 +0800 Subject: [PATCH] feat(public): resolve goods by secondary sds id and merge variants --- apps/api/src/public/public.service.spec.ts | 343 ++++++++++++--------- apps/api/src/public/public.service.ts | 33 +- 2 files changed, 218 insertions(+), 158 deletions(-) diff --git a/apps/api/src/public/public.service.spec.ts b/apps/api/src/public/public.service.spec.ts index 7a81ff1..f9b0caf 100644 --- a/apps/api/src/public/public.service.spec.ts +++ b/apps/api/src/public/public.service.spec.ts @@ -1,5 +1,5 @@ import { Test } from '@nestjs/testing'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { PublicService } from './public.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -11,9 +11,9 @@ describe('PublicService', () => { let categoryId: bigint; let childCategoryId: bigint; let otherCategoryId: bigint; - let tagId: bigint; - let filterGroupIds: bigint[] = []; - let filterTagIds: bigint[] = []; + let tagId: bigint; + let filterGroupIds: bigint[] = []; + let filterTagIds: bigint[] = []; let originGoodId: bigint; let goodIds: bigint[] = []; @@ -98,51 +98,51 @@ describe('PublicService', () => { goodPriority: 1, }, }); - goodIds = [g1.id, g2.id, g3.id]; - - const craftGroup = await prisma.tagGroup.create({ - data: { groupName: `Pub Craft ${stamp}`, sortOrder: 100 }, - }); - const materialGroup = await prisma.tagGroup.create({ - data: { groupName: `Pub Material ${stamp}`, sortOrder: 101 }, - }); - filterGroupIds = [craftGroup.id, materialGroup.id]; - const craftA = await prisma.tag.create({ - data: { tagName: `Pub Craft A ${stamp}`, tagGroupId: craftGroup.id }, - }); - const craftB = await prisma.tag.create({ - data: { tagName: `Pub Craft B ${stamp}`, tagGroupId: craftGroup.id }, - }); - const cotton = await prisma.tag.create({ - data: { tagName: `Pub Cotton ${stamp}`, tagGroupId: materialGroup.id }, - }); - filterTagIds = [craftA.id, craftB.id, cotton.id]; - await prisma.goodTag.createMany({ - data: [ - { goodId: g1.id, tagId: craftA.id }, - { goodId: g1.id, tagId: cotton.id }, - { goodId: g2.id, tagId: craftB.id }, - ], - }); - - await prisma.originGoodDetail.create({ - data: { - originGoodId, - productCode: 'OZ10827003', - productionProcess: '白墨烫画', - sizeChart: { columns: [], rows: [{ sizeId: 'size_0', sizeName: 'S', measurements: [] }] }, - packageSpecs: { rows: [{ sizeId: 'size_0', sizeName: 'S' }] }, - }, - }); - await prisma.originGoodVariant.create({ - data: { - originGoodId, - sdsVariantId: `pub-variant-${stamp}`, - sku: `OZ${stamp}`, - sizeName: 'S', - price: 38, - }, - }); + goodIds = [g1.id, g2.id, g3.id]; + + const craftGroup = await prisma.tagGroup.create({ + data: { groupName: `Pub Craft ${stamp}`, sortOrder: 100 }, + }); + const materialGroup = await prisma.tagGroup.create({ + data: { groupName: `Pub Material ${stamp}`, sortOrder: 101 }, + }); + filterGroupIds = [craftGroup.id, materialGroup.id]; + const craftA = await prisma.tag.create({ + data: { tagName: `Pub Craft A ${stamp}`, tagGroupId: craftGroup.id }, + }); + const craftB = await prisma.tag.create({ + data: { tagName: `Pub Craft B ${stamp}`, tagGroupId: craftGroup.id }, + }); + const cotton = await prisma.tag.create({ + data: { tagName: `Pub Cotton ${stamp}`, tagGroupId: materialGroup.id }, + }); + filterTagIds = [craftA.id, craftB.id, cotton.id]; + await prisma.goodTag.createMany({ + data: [ + { goodId: g1.id, tagId: craftA.id }, + { goodId: g1.id, tagId: cotton.id }, + { goodId: g2.id, tagId: craftB.id }, + ], + }); + + await prisma.originGoodDetail.create({ + data: { + originGoodId, + productCode: 'OZ10827003', + productionProcess: '白墨烫画', + sizeChart: { columns: [], rows: [{ sizeId: 'size_0', sizeName: 'S', measurements: [] }] }, + packageSpecs: { rows: [{ sizeId: 'size_0', sizeName: 'S' }] }, + }, + }); + await prisma.originGoodVariant.create({ + data: { + originGoodId, + sdsVariantId: `pub-variant-${stamp}`, + sku: `OZ${stamp}`, + sizeName: 'S', + price: 38, + }, + }); // Seed a good in `otherCategory` so the "onlyHaveGoods" filter // returns more than one category. @@ -179,9 +179,9 @@ describe('PublicService', () => { await prisma.position.deleteMany({ where: { countryId }, }); - await prisma.tag.delete({ where: { id: tagId } }); - await prisma.tag.deleteMany({ where: { id: { in: filterTagIds } } }); - await prisma.tagGroup.deleteMany({ where: { id: { in: filterGroupIds } } }); + await prisma.tag.delete({ where: { id: tagId } }); + await prisma.tag.deleteMany({ where: { id: { in: filterTagIds } } }); + await prisma.tagGroup.deleteMany({ where: { id: { in: filterGroupIds } } }); await prisma.originGood.delete({ where: { id: originGoodId } }); // Delete children before parent (FK self-relation is RESTRICT). await prisma.category.delete({ where: { id: childCategoryId } }); @@ -220,139 +220,139 @@ describe('PublicService', () => { const filtered = await service.getGoods({ page: 1, pageSize: 50, - countryId: countryId.toString(), - categoryId: categoryId.toString(), // includes child + countryId: countryId.toString(), + categoryId: categoryId.toString(), // includes child keyword: `Pub `, }); expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood expect(filtered.items.every((g) => g.country.id === countryId.toString())).toBe(true); }); - it('sorts by priority DESC, position.indexVal ASC, createdAt DESC', async () => { + it('sorts by priority DESC, position.indexVal ASC, createdAt DESC', async () => { const result = await service.getGoods({ page: 1, pageSize: 50, - countryId: countryId.toString(), + countryId: countryId.toString(), keyword: `Pub `, }); const priorities = result.items.map((g) => g.goodPriority); // First verify primary descending priority. const sorted = [...priorities].sort((a, b) => b - a); expect(priorities).toEqual(sorted); - }); - - it('uses OR within one tag group and AND across tag groups', async () => { - const sameGroup = await service.getGoods({ - page: 1, - pageSize: 50, - countryId: countryId.toString(), - keyword: `Pub `, - tags: [ - { - tagGroupId: filterGroupIds[0].toString(), - tagIds: filterTagIds.slice(0, 2).map(String), - }, - ], - }); - expect(sameGroup.items.map((item) => item.goodName)).toEqual( - expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]), - ); - - const acrossGroups = await service.getGoods({ - page: 1, - pageSize: 50, - countryId: countryId.toString(), - keyword: `Pub `, - tags: [ - { - tagGroupId: filterGroupIds[0].toString(), - tagIds: filterTagIds.slice(0, 2).map(String), - }, - { - tagGroupId: filterGroupIds[1].toString(), - tagIds: [filterTagIds[2].toString()], - }, - ], - }); - expect(acrossGroups.items.map((item) => item.goodName)).toContain(`Pub High ${stamp}`); - expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`); - }); - - it('rejects a tag paired with the wrong tag group', async () => { - await expect( - service.getGoods({ - page: 1, - pageSize: 20, - tags: [ - { - tagGroupId: filterGroupIds[1].toString(), - tagIds: [filterTagIds[0].toString()], - }, - ], - }), - ).rejects.toBeInstanceOf(BadRequestException); - }); + }); - it('returns the SDS product id as the public product id', async () => { + it('uses OR within one tag group and AND across tag groups', async () => { + const sameGroup = await service.getGoods({ + page: 1, + pageSize: 50, + countryId: countryId.toString(), + keyword: `Pub `, + tags: [ + { + tagGroupId: filterGroupIds[0].toString(), + tagIds: filterTagIds.slice(0, 2).map(String), + }, + ], + }); + expect(sameGroup.items.map((item) => item.goodName)).toEqual( + expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]), + ); + + const acrossGroups = await service.getGoods({ + page: 1, + pageSize: 50, + countryId: countryId.toString(), + keyword: `Pub `, + tags: [ + { + tagGroupId: filterGroupIds[0].toString(), + tagIds: filterTagIds.slice(0, 2).map(String), + }, + { + tagGroupId: filterGroupIds[1].toString(), + tagIds: [filterTagIds[2].toString()], + }, + ], + }); + expect(acrossGroups.items.map((item) => item.goodName)).toContain(`Pub High ${stamp}`); + expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`); + }); + + it('rejects a tag paired with the wrong tag group', async () => { + await expect( + service.getGoods({ + page: 1, + pageSize: 20, + tags: [ + { + tagGroupId: filterGroupIds[1].toString(), + tagIds: [filterTagIds[0].toString()], + }, + ], + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('returns the SDS product id as the public product id', async () => { const result = await service.getGoods({ page: 1, pageSize: 1, - countryId: countryId.toString(), + countryId: countryId.toString(), keyword: `Pub High ${stamp}`, }); expect(result.items).toHaveLength(1); - expect(result.items[0].goodId).toBe(`pub-sds-${stamp}`); - expect(result.items[0].goodId).not.toBe(goodIds[0].toString()); - }); - - it('returns custom goods through the same public product contract', async () => { - const customPublicId = `custom-public-${stamp}`; - const origin = await prisma.originGood.create({ - data: { - source: 'CUSTOM', - sdsGoodId: customPublicId, - goodName: `Pub Custom ${stamp}`, - goodPrice: 42, - detail: { create: { productCode: `CUSTOM-${stamp}` } }, - }, - }); - const good = await prisma.good.create({ - data: { - originGoodId: origin.id, - countryId, - categoryId, - goodName: `Pub Custom ${stamp}`, - }, - }); - try { - const detail = await service.getGood(customPublicId); - expect(detail.goodId).toBe(customPublicId); - expect(detail.goodName).toBe(`Pub Custom ${stamp}`); - expect(detail.productCode).toBe(`CUSTOM-${stamp}`); - } finally { - await prisma.good.delete({ where: { id: good.id } }); - await prisma.originGood.delete({ where: { id: origin.id } }); - } - }); + expect(result.items[0].goodId).toBe(`pub-sds-${stamp}`); + expect(result.items[0].goodId).not.toBe(goodIds[0].toString()); + }); + + it('returns custom goods through the same public product contract', async () => { + const customPublicId = `custom-public-${stamp}`; + const origin = await prisma.originGood.create({ + data: { + source: 'CUSTOM', + sdsGoodId: customPublicId, + goodName: `Pub Custom ${stamp}`, + goodPrice: 42, + detail: { create: { productCode: `CUSTOM-${stamp}` } }, + }, + }); + const good = await prisma.good.create({ + data: { + originGoodId: origin.id, + countryId, + categoryId, + goodName: `Pub Custom ${stamp}`, + }, + }); + try { + const detail = await service.getGood(customPublicId); + expect(detail.goodId).toBe(customPublicId); + expect(detail.goodName).toBe(`Pub Custom ${stamp}`); + expect(detail.productCode).toBe(`CUSTOM-${stamp}`); + } finally { + await prisma.good.delete({ where: { id: good.id } }); + await prisma.originGood.delete({ where: { id: origin.id } }); + } + }); it('getGood returns detail and 404 for unknown id', async () => { const first = await service.getGoods({ page: 1, pageSize: 1, - countryId: countryId.toString(), + countryId: countryId.toString(), keyword: `Pub `, }); expect(first.items.length).toBe(1); - const detail = await service.getGood(`pub-sds-${stamp}`); - expect(detail.goodId).toBe(first.items[0].goodId); - expect(detail.productCode).toBe('OZ10827003'); - expect(detail.details.productionProcess).toBe('白墨烫画'); - expect((detail.sizeChart?.rows as unknown[])).toHaveLength(1); - expect((detail.packageSpecs?.rows as unknown[])).toHaveLength(1); - expect(detail.variants).toHaveLength(1); - - await expect(service.getGood('99999999')).rejects.toBeInstanceOf( + const detail = await service.getGood(`pub-sds-${stamp}`); + expect(detail.goodId).toBe(first.items[0].goodId); + expect(detail.productCode).toBe('OZ10827003'); + expect(detail.details.productionProcess).toBe('白墨烫画'); + expect((detail.sizeChart?.rows as unknown[])).toHaveLength(1); + expect((detail.packageSpecs?.rows as unknown[])).toHaveLength(1); + expect(detail.variants).toHaveLength(1); + + await expect(service.getGood('99999999')).rejects.toBeInstanceOf( NotFoundException, ); }); @@ -379,4 +379,39 @@ describe('PublicService', () => { const sortOrders = groups.map((g) => g.sortOrder); expect([...sortOrders].sort((a, b) => a - b)).toEqual(sortOrders); }); + + describe('merged secondary origin goods', () => { + it('resolves a good by secondary sdsGoodId with merged variants', async () => { + const secondary = await prisma.originGood.create({ + data: { sdsGoodId: `pub-secondary-${stamp}`, goodName: `Pub Secondary ${stamp}` }, + }); + const secVariant = await prisma.originGoodVariant.create({ + data: { + originGoodId: secondary.id, + sdsVariantId: `pub-var-sec-${stamp}`, + sku: `PUB-SEC-${stamp}`, + colorId: 'black', + colorName: '黑色', + imageUrl: 'http://img/black-sec', + }, + }); + // Attach as secondary source of the highest-priority fixture good. + await prisma.goodOriginGood.create({ + data: { goodId: goodIds[0], originGoodId: secondary.id }, + }); + + try { + const detail = await service.getGood(`pub-secondary-${stamp}`); + expect(detail.goodId).toBe(`pub-sds-${stamp}`); // 对外 goodId 仍是主源 + expect(detail.variants.length).toBeGreaterThanOrEqual(2); + const black = detail.mediaByColor.find((g) => g.colorName === '黑色'); + expect(black).toBeTruthy(); + expect(black!.images).toContain('http://img/black-sec'); + } finally { + await prisma.goodOriginGood.deleteMany({ where: { originGoodId: secondary.id } }); + await prisma.originGoodVariant.delete({ where: { id: secVariant.id } }).catch(() => undefined); + await prisma.originGood.delete({ where: { id: secondary.id } }).catch(() => undefined); + } + }); + }); }); diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index 45f11e0..37e74ba 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -34,6 +34,16 @@ const PUBLIC_GOOD_INCLUDE = { variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, }, }, + mergedOriginGoods: { + orderBy: { createdAt: 'asc' as const }, + include: { + originGood: { + include: { + variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, + }, + }, + }, + }, goodTags: { include: { tag: { include: { tagGroup: true } } } }, } satisfies Prisma.GoodInclude; @@ -198,7 +208,17 @@ export class PublicService { async getGood(goodId: string): Promise { const good = await this.prisma.good.findFirst({ - where: { originGood: { sdsGoodId: goodId, delisted: false } }, + where: { + OR: [ + { originGood: { sdsGoodId: goodId, delisted: false } }, + // Merged secondary sources also resolve to the same good. + { + mergedOriginGoods: { + some: { originGood: { sdsGoodId: goodId, delisted: false } }, + }, + }, + ], + }, include: PUBLIC_GOOD_INCLUDE, orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }], }); @@ -277,7 +297,7 @@ export class PublicService { * design-layer素材图 and the product-level blank garment photo are excluded * because they are not per-color gallery photos. */ private groupImagesByColor( - variants: PublicGoodRow['originGood']['variants'], + variants: Array, ): Array<{ colorId: string | null; colorName: string | null; colorHex: string | null; images: string[] }> { const groups = new Map m.originGood.variants), + ]; return { ...base, productCode: detail?.productCode ?? null, @@ -354,11 +379,11 @@ export class PublicService { pictureRequest: detail?.pictureRequest ?? null, }, media: (detail?.media as Record | null) ?? null, - mediaByColor: this.groupImagesByColor(good.originGood.variants), + mediaByColor: this.groupImagesByColor(allVariants), options: (detail?.options as Record | null) ?? null, sizeChart: (detail?.sizeChart as Record | null) ?? null, packageSpecs: (detail?.packageSpecs as Record | null) ?? null, - variants: good.originGood.variants.map((variant) => ({ + variants: allVariants.map((variant) => ({ id: variant.sdsVariantId, sku: variant.sku, sizeId: variant.sizeId,