From d9ecd047476b33f1436824054f305f0d68d326ed Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Thu, 27 Aug 2026 18:18:29 +0800 Subject: [PATCH] feat(origin-goods): include merged secondary references in tree stats --- .../origin-goods/origin-goods.service.spec.ts | 65 +++++++ .../src/origin-goods/origin-goods.service.ts | 171 +++++++++++------- 2 files changed, 168 insertions(+), 68 deletions(-) diff --git a/apps/api/src/origin-goods/origin-goods.service.spec.ts b/apps/api/src/origin-goods/origin-goods.service.spec.ts index 9b19a64..1adf7a4 100644 --- a/apps/api/src/origin-goods/origin-goods.service.spec.ts +++ b/apps/api/src/origin-goods/origin-goods.service.spec.ts @@ -77,4 +77,69 @@ describe('OriginGoodsService', () => { expect(result.total).toBe(0); expect(result.items.length).toBe(0); }); + + describe('getTree merged references', () => { + /* eslint-disable @typescript-eslint/no-explicit-any */ + function findOgNode( + treeResponse: { tree: any[] }, + ogId: string, + ): { configuredCount: number; configuredCountries: string[] } { + let found: { configuredCount: number; configuredCountries: string[] } | null = null; + const walk = (nodes: any[]) => { + for (const n of nodes) { + const hit = (n.originGoods ?? []).find((o: any) => o.id === ogId); + if (hit) { + found = hit; + return; + } + if (n.children?.length) walk(n.children); + } + }; + walk(treeResponse.tree); + if (!found) throw new Error(`og node ${ogId} not found in tree`); + return found; + } + + it('counts secondary references as configured', async () => { + const sdsCat = `tree-cat-${stamp}`; + await prisma.originGood.createMany({ + data: [ + { sdsGoodId: `tree-a-${stamp}`, goodName: `Tree A ${stamp}`, sdsCategoryId: sdsCat }, + { sdsGoodId: `tree-b-${stamp}`, goodName: `Tree B ${stamp}`, sdsCategoryId: sdsCat }, + ], + }); + createdSds.push(`tree-a-${stamp}`, `tree-b-${stamp}`); + const originA = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: `tree-a-${stamp}` } }); + const originB = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: `tree-b-${stamp}` } }); + + const cat = await prisma.category.create({ + data: { categoryName: `Tree Cat ${stamp}`, sdsCategoryId: sdsCat }, + }); + const country = await prisma.country.create({ + data: { countryName: `Tree Country ${stamp}` }, + }); + const good = await prisma.good.create({ + data: { + goodName: `Tree Good ${stamp}`, + originGoodId: originA.id, + countryId: country.id, + categoryId: cat.id, + }, + }); + await prisma.goodOriginGood.create({ + data: { goodId: good.id, originGoodId: originB.id }, + }); + + try { + const tree = await service.getTree(); + const nodeB = findOgNode(tree, originB.id.toString()); + expect(nodeB.configuredCount).toBeGreaterThanOrEqual(1); + expect(nodeB.configuredCountries).toContain(`Tree Country ${stamp}`); + } finally { + await prisma.good.delete({ where: { id: good.id } }).catch(() => undefined); + await prisma.country.delete({ where: { id: country.id } }).catch(() => undefined); + await prisma.category.delete({ where: { id: cat.id } }).catch(() => undefined); + } + }); + }); }); diff --git a/apps/api/src/origin-goods/origin-goods.service.ts b/apps/api/src/origin-goods/origin-goods.service.ts index 82db742..9b8d267 100644 --- a/apps/api/src/origin-goods/origin-goods.service.ts +++ b/apps/api/src/origin-goods/origin-goods.service.ts @@ -12,10 +12,10 @@ export interface PaginatedOriginGoods { goodPrice: string | null; sdsCategoryId: string | null; createdAt: string; - updatedAt: string; - hasDetail: boolean; - detailSyncedAt: string | null; - variantCount: number; + updatedAt: string; + hasDetail: boolean; + detailSyncedAt: string | null; + variantCount: number; }>; total: number; page: number; @@ -35,12 +35,12 @@ export interface OriginGoodsTreeNode { delisted: boolean; configuredCount: number; configuredCountries: string[]; - configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]; - hasDetail: boolean; - detailSyncedAt: string | null; - variantCount: number; - sizeRowCount: number; - packageRowCount: number; + configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]; + hasDetail: boolean; + detailSyncedAt: string | null; + variantCount: number; + sizeRowCount: number; + packageRowCount: number; } /** A category node in the hierarchical tree, with origin goods as leaves. */ @@ -67,19 +67,19 @@ export class OriginGoodsService { async findAll(query: QueryOriginGoodDto): Promise { const { page, pageSize, keyword } = query; - const where: Prisma.OriginGoodWhereInput = { - source: 'SDS', - ...(keyword - ? { goodName: { contains: keyword, mode: 'insensitive' as const } } - : {}), - }; + const where: Prisma.OriginGoodWhereInput = { + source: 'SDS', + ...(keyword + ? { goodName: { contains: keyword, mode: 'insensitive' as const } } + : {}), + }; const [total, rows] = await this.prisma.$transaction([ this.prisma.originGood.count({ where }), - this.prisma.originGood.findMany({ - where, - orderBy: { id: 'desc' }, - include: { detail: true, _count: { select: { variants: true } } }, + this.prisma.originGood.findMany({ + where, + orderBy: { id: 'desc' }, + include: { detail: true, _count: { select: { variants: true } } }, skip: (page - 1) * pageSize, take: pageSize, }), @@ -94,10 +94,10 @@ export class OriginGoodsService { goodPrice: r.goodPrice === null || r.goodPrice === undefined ? null : r.goodPrice.toString(), sdsCategoryId: r.sdsCategoryId, createdAt: r.createdAt.toISOString(), - updatedAt: r.updatedAt.toISOString(), - hasDetail: Boolean(r.detail), - detailSyncedAt: r.detail?.syncedAt.toISOString() ?? null, - variantCount: r._count.variants, + updatedAt: r.updatedAt.toISOString(), + hasDetail: Boolean(r.detail), + detailSyncedAt: r.detail?.syncedAt.toISOString() ?? null, + variantCount: r._count.variants, })), total, page, @@ -114,7 +114,7 @@ export class OriginGoodsService { * under a synthetic "未分类" root node. */ async getTree(): Promise { - const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags] = + const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags, mergedCounts, mergedWithCountries] = await Promise.all([ this.prisma.category.findMany({ where: { sdsCategoryId: { not: null } }, @@ -126,11 +126,11 @@ export class OriginGoodsService { parentCategoryId: true, }, }), - this.prisma.originGood.findMany({ - where: { delisted: false, source: 'SDS' }, - orderBy: { goodName: 'asc' }, - include: { detail: true, _count: { select: { variants: true } } }, - }), + this.prisma.originGood.findMany({ + where: { delisted: false, source: 'SDS' }, + orderBy: { goodName: 'asc' }, + include: { detail: true, _count: { select: { variants: true } } }, + }), this.prisma.good.groupBy({ by: ['originGoodId'], _count: { _all: true }, @@ -144,7 +144,12 @@ export class OriginGoodsService { }), this.prisma.goodTag.findMany({ select: { - good: { select: { originGoodId: true } }, + good: { + select: { + originGoodId: true, + mergedOriginGoods: { select: { originGoodId: true } }, + }, + }, tag: { select: { tagName: true, @@ -157,26 +162,57 @@ export class OriginGoodsService { }, }, }), + // Secondary-source references (good_origin_goods) + this.prisma.goodOriginGood.groupBy({ + by: ['originGoodId'], + _count: { _all: true }, + }), + this.prisma.goodOriginGood.findMany({ + select: { + originGoodId: true, + good: { select: { country: { select: { countryName: true } } } }, + }, + }), ]); const countMap = new Map(); configCounts.forEach((c) => countMap.set(c.originGoodId.toString(), c._count._all), ); - - const countryMap = new Map(); - goodsWithCountries.forEach((g) => { - const key = g.originGoodId.toString(); - const name = g.country?.countryName; - if (!name) return; - const arr = countryMap.get(key); - if (arr) arr.push(name); - else countryMap.set(key, [name]); + // Secondary (merged) references count towards configured status too. + mergedCounts.forEach((c) => { + const key = c.originGoodId.toString(); + countMap.set(key, (countMap.get(key) ?? 0) + c._count._all); }); + const countryMap = new Map(); + const addCountry = (key: string, name?: string | null) => { + if (!name) return; + const arr = countryMap.get(key); + if (!arr?.includes(name)) { + countryMap.set(key, [...(arr ?? []), name]); + } + }; + goodsWithCountries.forEach((g) => + addCountry(g.originGoodId.toString(), g.country?.countryName), + ); + mergedWithCountries.forEach((m) => + addCountry(m.originGoodId.toString(), m.good.country?.countryName), + ); + const tagMap = new Map(); + const addTag = ( + key: string, + tagInfo: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }, + ) => { + const arr = tagMap.get(key); + if (arr) { + if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo); + } else { + tagMap.set(key, [tagInfo]); + } + }; goodsWithTags.forEach((gt) => { - const key = gt.good.originGoodId.toString(); const tagInfo = { tagName: gt.tag.tagName, tagColor: gt.tag.tagColor, @@ -185,11 +221,10 @@ export class OriginGoodsService { tagGroupName: gt.tag.tagGroup?.groupName ?? null, sortOrder: gt.tag.sortOrder, }; - const arr = tagMap.get(key); - if (arr) { - if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo); - } else { - tagMap.set(key, [tagInfo]); + addTag(gt.good.originGoodId.toString(), tagInfo); + // A good's tags also mark its secondary origin goods as configured. + for (const m of gt.good.mergedOriginGoods) { + addTag(m.originGoodId.toString(), tagInfo); } }); @@ -230,12 +265,12 @@ export class OriginGoodsService { delisted: og.delisted, configuredCount: countMap.get(og.id.toString()) ?? 0, configuredCountries: countryMap.get(og.id.toString()) ?? [], - configuredTags: tagMap.get(og.id.toString()) ?? [], - hasDetail: Boolean(og.detail), - detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null, - variantCount: og._count.variants, - sizeRowCount: this.jsonRows(og.detail?.sizeChart), - packageRowCount: this.jsonRows(og.detail?.packageSpecs), + configuredTags: tagMap.get(og.id.toString()) ?? [], + hasDetail: Boolean(og.detail), + detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null, + variantCount: og._count.variants, + sizeRowCount: this.jsonRows(og.detail?.sizeChart), + packageRowCount: this.jsonRows(og.detail?.packageSpecs), })); const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0); @@ -281,12 +316,12 @@ export class OriginGoodsService { delisted: og.delisted, configuredCount: countMap.get(og.id.toString()) ?? 0, configuredCountries: countryMap.get(og.id.toString()) ?? [], - configuredTags: tagMap.get(og.id.toString()) ?? [], - hasDetail: Boolean(og.detail), - detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null, - variantCount: og._count.variants, - sizeRowCount: this.jsonRows(og.detail?.sizeChart), - packageRowCount: this.jsonRows(og.detail?.packageSpecs), + configuredTags: tagMap.get(og.id.toString()) ?? [], + hasDetail: Boolean(og.detail), + detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null, + variantCount: og._count.variants, + sizeRowCount: this.jsonRows(og.detail?.sizeChart), + packageRowCount: this.jsonRows(og.detail?.packageSpecs), })), }); } @@ -297,16 +332,16 @@ export class OriginGoodsService { (og) => (countMap.get(og.id.toString()) ?? 0) > 0, ).length; - return { + return { tree, totalOriginGoods: allOriginGoods.length, configuredCount: totalConfigured, - }; - } - - private 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; - } -} + }; + } + + private 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; + } +}