feat(public): resolve goods by secondary sds id and merge variants

This commit is contained in:
yeuimu
2026-08-27 18:20:25 +08:00
parent d9ecd04747
commit f06dfffbda
2 changed files with 218 additions and 158 deletions
+189 -154
View File
@@ -1,5 +1,5 @@
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { PublicService } from './public.service'; import { PublicService } from './public.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -11,9 +11,9 @@ describe('PublicService', () => {
let categoryId: bigint; let categoryId: bigint;
let childCategoryId: bigint; let childCategoryId: bigint;
let otherCategoryId: bigint; let otherCategoryId: bigint;
let tagId: bigint; let tagId: bigint;
let filterGroupIds: bigint[] = []; let filterGroupIds: bigint[] = [];
let filterTagIds: bigint[] = []; let filterTagIds: bigint[] = [];
let originGoodId: bigint; let originGoodId: bigint;
let goodIds: bigint[] = []; let goodIds: bigint[] = [];
@@ -98,51 +98,51 @@ describe('PublicService', () => {
goodPriority: 1, goodPriority: 1,
}, },
}); });
goodIds = [g1.id, g2.id, g3.id]; goodIds = [g1.id, g2.id, g3.id];
const craftGroup = await prisma.tagGroup.create({ const craftGroup = await prisma.tagGroup.create({
data: { groupName: `Pub Craft ${stamp}`, sortOrder: 100 }, data: { groupName: `Pub Craft ${stamp}`, sortOrder: 100 },
}); });
const materialGroup = await prisma.tagGroup.create({ const materialGroup = await prisma.tagGroup.create({
data: { groupName: `Pub Material ${stamp}`, sortOrder: 101 }, data: { groupName: `Pub Material ${stamp}`, sortOrder: 101 },
}); });
filterGroupIds = [craftGroup.id, materialGroup.id]; filterGroupIds = [craftGroup.id, materialGroup.id];
const craftA = await prisma.tag.create({ const craftA = await prisma.tag.create({
data: { tagName: `Pub Craft A ${stamp}`, tagGroupId: craftGroup.id }, data: { tagName: `Pub Craft A ${stamp}`, tagGroupId: craftGroup.id },
}); });
const craftB = await prisma.tag.create({ const craftB = await prisma.tag.create({
data: { tagName: `Pub Craft B ${stamp}`, tagGroupId: craftGroup.id }, data: { tagName: `Pub Craft B ${stamp}`, tagGroupId: craftGroup.id },
}); });
const cotton = await prisma.tag.create({ const cotton = await prisma.tag.create({
data: { tagName: `Pub Cotton ${stamp}`, tagGroupId: materialGroup.id }, data: { tagName: `Pub Cotton ${stamp}`, tagGroupId: materialGroup.id },
}); });
filterTagIds = [craftA.id, craftB.id, cotton.id]; filterTagIds = [craftA.id, craftB.id, cotton.id];
await prisma.goodTag.createMany({ await prisma.goodTag.createMany({
data: [ data: [
{ goodId: g1.id, tagId: craftA.id }, { goodId: g1.id, tagId: craftA.id },
{ goodId: g1.id, tagId: cotton.id }, { goodId: g1.id, tagId: cotton.id },
{ goodId: g2.id, tagId: craftB.id }, { goodId: g2.id, tagId: craftB.id },
], ],
}); });
await prisma.originGoodDetail.create({ await prisma.originGoodDetail.create({
data: { data: {
originGoodId, originGoodId,
productCode: 'OZ10827003', productCode: 'OZ10827003',
productionProcess: '白墨烫画', productionProcess: '白墨烫画',
sizeChart: { columns: [], rows: [{ sizeId: 'size_0', sizeName: 'S', measurements: [] }] }, sizeChart: { columns: [], rows: [{ sizeId: 'size_0', sizeName: 'S', measurements: [] }] },
packageSpecs: { rows: [{ sizeId: 'size_0', sizeName: 'S' }] }, packageSpecs: { rows: [{ sizeId: 'size_0', sizeName: 'S' }] },
}, },
}); });
await prisma.originGoodVariant.create({ await prisma.originGoodVariant.create({
data: { data: {
originGoodId, originGoodId,
sdsVariantId: `pub-variant-${stamp}`, sdsVariantId: `pub-variant-${stamp}`,
sku: `OZ${stamp}`, sku: `OZ${stamp}`,
sizeName: 'S', sizeName: 'S',
price: 38, price: 38,
}, },
}); });
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter // Seed a good in `otherCategory` so the "onlyHaveGoods" filter
// returns more than one category. // returns more than one category.
@@ -179,9 +179,9 @@ describe('PublicService', () => {
await prisma.position.deleteMany({ await prisma.position.deleteMany({
where: { countryId }, where: { countryId },
}); });
await prisma.tag.delete({ where: { id: tagId } }); await prisma.tag.delete({ where: { id: tagId } });
await prisma.tag.deleteMany({ where: { id: { in: filterTagIds } } }); await prisma.tag.deleteMany({ where: { id: { in: filterTagIds } } });
await prisma.tagGroup.deleteMany({ where: { id: { in: filterGroupIds } } }); await prisma.tagGroup.deleteMany({ where: { id: { in: filterGroupIds } } });
await prisma.originGood.delete({ where: { id: originGoodId } }); await prisma.originGood.delete({ where: { id: originGoodId } });
// Delete children before parent (FK self-relation is RESTRICT). // Delete children before parent (FK self-relation is RESTRICT).
await prisma.category.delete({ where: { id: childCategoryId } }); await prisma.category.delete({ where: { id: childCategoryId } });
@@ -220,139 +220,139 @@ describe('PublicService', () => {
const filtered = await service.getGoods({ const filtered = await service.getGoods({
page: 1, page: 1,
pageSize: 50, pageSize: 50,
countryId: countryId.toString(), countryId: countryId.toString(),
categoryId: categoryId.toString(), // includes child categoryId: categoryId.toString(), // includes child
keyword: `Pub `, keyword: `Pub `,
}); });
expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood
expect(filtered.items.every((g) => g.country.id === countryId.toString())).toBe(true); 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({ const result = await service.getGoods({
page: 1, page: 1,
pageSize: 50, pageSize: 50,
countryId: countryId.toString(), countryId: countryId.toString(),
keyword: `Pub `, keyword: `Pub `,
}); });
const priorities = result.items.map((g) => g.goodPriority); const priorities = result.items.map((g) => g.goodPriority);
// First verify primary descending priority. // First verify primary descending priority.
const sorted = [...priorities].sort((a, b) => b - a); const sorted = [...priorities].sort((a, b) => b - a);
expect(priorities).toEqual(sorted); 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({ const result = await service.getGoods({
page: 1, page: 1,
pageSize: 1, pageSize: 1,
countryId: countryId.toString(), countryId: countryId.toString(),
keyword: `Pub High ${stamp}`, keyword: `Pub High ${stamp}`,
}); });
expect(result.items).toHaveLength(1); expect(result.items).toHaveLength(1);
expect(result.items[0].goodId).toBe(`pub-sds-${stamp}`); expect(result.items[0].goodId).toBe(`pub-sds-${stamp}`);
expect(result.items[0].goodId).not.toBe(goodIds[0].toString()); expect(result.items[0].goodId).not.toBe(goodIds[0].toString());
}); });
it('returns custom goods through the same public product contract', async () => { it('returns custom goods through the same public product contract', async () => {
const customPublicId = `custom-public-${stamp}`; const customPublicId = `custom-public-${stamp}`;
const origin = await prisma.originGood.create({ const origin = await prisma.originGood.create({
data: { data: {
source: 'CUSTOM', source: 'CUSTOM',
sdsGoodId: customPublicId, sdsGoodId: customPublicId,
goodName: `Pub Custom ${stamp}`, goodName: `Pub Custom ${stamp}`,
goodPrice: 42, goodPrice: 42,
detail: { create: { productCode: `CUSTOM-${stamp}` } }, detail: { create: { productCode: `CUSTOM-${stamp}` } },
}, },
}); });
const good = await prisma.good.create({ const good = await prisma.good.create({
data: { data: {
originGoodId: origin.id, originGoodId: origin.id,
countryId, countryId,
categoryId, categoryId,
goodName: `Pub Custom ${stamp}`, goodName: `Pub Custom ${stamp}`,
}, },
}); });
try { try {
const detail = await service.getGood(customPublicId); const detail = await service.getGood(customPublicId);
expect(detail.goodId).toBe(customPublicId); expect(detail.goodId).toBe(customPublicId);
expect(detail.goodName).toBe(`Pub Custom ${stamp}`); expect(detail.goodName).toBe(`Pub Custom ${stamp}`);
expect(detail.productCode).toBe(`CUSTOM-${stamp}`); expect(detail.productCode).toBe(`CUSTOM-${stamp}`);
} finally { } finally {
await prisma.good.delete({ where: { id: good.id } }); await prisma.good.delete({ where: { id: good.id } });
await prisma.originGood.delete({ where: { id: origin.id } }); await prisma.originGood.delete({ where: { id: origin.id } });
} }
}); });
it('getGood returns detail and 404 for unknown id', async () => { it('getGood returns detail and 404 for unknown id', async () => {
const first = await service.getGoods({ const first = await service.getGoods({
page: 1, page: 1,
pageSize: 1, pageSize: 1,
countryId: countryId.toString(), countryId: countryId.toString(),
keyword: `Pub `, keyword: `Pub `,
}); });
expect(first.items.length).toBe(1); expect(first.items.length).toBe(1);
const detail = await service.getGood(`pub-sds-${stamp}`); const detail = await service.getGood(`pub-sds-${stamp}`);
expect(detail.goodId).toBe(first.items[0].goodId); expect(detail.goodId).toBe(first.items[0].goodId);
expect(detail.productCode).toBe('OZ10827003'); expect(detail.productCode).toBe('OZ10827003');
expect(detail.details.productionProcess).toBe('白墨烫画'); expect(detail.details.productionProcess).toBe('白墨烫画');
expect((detail.sizeChart?.rows as unknown[])).toHaveLength(1); expect((detail.sizeChart?.rows as unknown[])).toHaveLength(1);
expect((detail.packageSpecs?.rows as unknown[])).toHaveLength(1); expect((detail.packageSpecs?.rows as unknown[])).toHaveLength(1);
expect(detail.variants).toHaveLength(1); expect(detail.variants).toHaveLength(1);
await expect(service.getGood('99999999')).rejects.toBeInstanceOf( await expect(service.getGood('99999999')).rejects.toBeInstanceOf(
NotFoundException, NotFoundException,
); );
}); });
@@ -379,4 +379,39 @@ describe('PublicService', () => {
const sortOrders = groups.map((g) => g.sortOrder); const sortOrders = groups.map((g) => g.sortOrder);
expect([...sortOrders].sort((a, b) => a - b)).toEqual(sortOrders); 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);
}
});
});
}); });
+29 -4
View File
@@ -34,6 +34,16 @@ const PUBLIC_GOOD_INCLUDE = {
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, 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 } } } }, goodTags: { include: { tag: { include: { tagGroup: true } } } },
} satisfies Prisma.GoodInclude; } satisfies Prisma.GoodInclude;
@@ -198,7 +208,17 @@ export class PublicService {
async getGood(goodId: string): Promise<PublicGoodDetailDto> { async getGood(goodId: string): Promise<PublicGoodDetailDto> {
const good = await this.prisma.good.findFirst({ 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, include: PUBLIC_GOOD_INCLUDE,
orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }], orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }],
}); });
@@ -277,7 +297,7 @@ export class PublicService {
* design-layer素材图 and the product-level blank garment photo are excluded * design-layer素材图 and the product-level blank garment photo are excluded
* because they are not per-color gallery photos. */ * because they are not per-color gallery photos. */
private groupImagesByColor( private groupImagesByColor(
variants: PublicGoodRow['originGood']['variants'], variants: Array<PublicGoodRow['originGood']['variants'][number]>,
): Array<{ colorId: string | null; colorName: string | null; colorHex: string | null; images: string[] }> { ): Array<{ colorId: string | null; colorName: string | null; colorHex: string | null; images: string[] }> {
const groups = new Map<string, { const groups = new Map<string, {
colorId: string | null; colorId: string | null;
@@ -335,6 +355,11 @@ export class PublicService {
private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto { private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto {
const base = this.toPublicGood(good); const base = this.toPublicGood(good);
const detail = good.originGood.detail; const detail = good.originGood.detail;
// Merge primary and secondary origin good variants (dedup identical URLs).
const allVariants = [
...good.originGood.variants,
...good.mergedOriginGoods.flatMap((m) => m.originGood.variants),
];
return { return {
...base, ...base,
productCode: detail?.productCode ?? null, productCode: detail?.productCode ?? null,
@@ -354,11 +379,11 @@ export class PublicService {
pictureRequest: detail?.pictureRequest ?? null, pictureRequest: detail?.pictureRequest ?? null,
}, },
media: (detail?.media as Record<string, unknown> | null) ?? null, media: (detail?.media as Record<string, unknown> | null) ?? null,
mediaByColor: this.groupImagesByColor(good.originGood.variants), mediaByColor: this.groupImagesByColor(allVariants),
options: (detail?.options as Record<string, unknown> | null) ?? null, options: (detail?.options as Record<string, unknown> | null) ?? null,
sizeChart: (detail?.sizeChart as Record<string, unknown> | null) ?? null, sizeChart: (detail?.sizeChart as Record<string, unknown> | null) ?? null,
packageSpecs: (detail?.packageSpecs as Record<string, unknown> | null) ?? null, packageSpecs: (detail?.packageSpecs as Record<string, unknown> | null) ?? null,
variants: good.originGood.variants.map((variant) => ({ variants: allVariants.map((variant) => ({
id: variant.sdsVariantId, id: variant.sdsVariantId,
sku: variant.sku, sku: variant.sku,
sizeId: variant.sizeId, sizeId: variant.sizeId,