feat(origin-goods): include merged secondary references in tree stats

This commit is contained in:
yeuimu
2026-08-27 18:18:29 +08:00
parent 9c279ae393
commit d9ecd04747
2 changed files with 168 additions and 68 deletions
@@ -77,4 +77,69 @@ describe('OriginGoodsService', () => {
expect(result.total).toBe(0); expect(result.total).toBe(0);
expect(result.items.length).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);
}
});
});
}); });
+103 -68
View File
@@ -12,10 +12,10 @@ export interface PaginatedOriginGoods {
goodPrice: string | null; goodPrice: string | null;
sdsCategoryId: string | null; sdsCategoryId: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
hasDetail: boolean; hasDetail: boolean;
detailSyncedAt: string | null; detailSyncedAt: string | null;
variantCount: number; variantCount: number;
}>; }>;
total: number; total: number;
page: number; page: number;
@@ -35,12 +35,12 @@ export interface OriginGoodsTreeNode {
delisted: boolean; delisted: boolean;
configuredCount: number; configuredCount: number;
configuredCountries: string[]; configuredCountries: string[];
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]; configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[];
hasDetail: boolean; hasDetail: boolean;
detailSyncedAt: string | null; detailSyncedAt: string | null;
variantCount: number; variantCount: number;
sizeRowCount: number; sizeRowCount: number;
packageRowCount: number; packageRowCount: number;
} }
/** A category node in the hierarchical tree, with origin goods as leaves. */ /** A category node in the hierarchical tree, with origin goods as leaves. */
@@ -67,19 +67,19 @@ export class OriginGoodsService {
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> { async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
const { page, pageSize, keyword } = query; const { page, pageSize, keyword } = query;
const where: Prisma.OriginGoodWhereInput = { const where: Prisma.OriginGoodWhereInput = {
source: 'SDS', source: 'SDS',
...(keyword ...(keyword
? { goodName: { contains: keyword, mode: 'insensitive' as const } } ? { goodName: { contains: keyword, mode: 'insensitive' as const } }
: {}), : {}),
}; };
const [total, rows] = await this.prisma.$transaction([ const [total, rows] = await this.prisma.$transaction([
this.prisma.originGood.count({ where }), this.prisma.originGood.count({ where }),
this.prisma.originGood.findMany({ this.prisma.originGood.findMany({
where, where,
orderBy: { id: 'desc' }, orderBy: { id: 'desc' },
include: { detail: true, _count: { select: { variants: true } } }, include: { detail: true, _count: { select: { variants: true } } },
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
}), }),
@@ -94,10 +94,10 @@ export class OriginGoodsService {
goodPrice: r.goodPrice === null || r.goodPrice === undefined ? null : r.goodPrice.toString(), goodPrice: r.goodPrice === null || r.goodPrice === undefined ? null : r.goodPrice.toString(),
sdsCategoryId: r.sdsCategoryId, sdsCategoryId: r.sdsCategoryId,
createdAt: r.createdAt.toISOString(), createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(), updatedAt: r.updatedAt.toISOString(),
hasDetail: Boolean(r.detail), hasDetail: Boolean(r.detail),
detailSyncedAt: r.detail?.syncedAt.toISOString() ?? null, detailSyncedAt: r.detail?.syncedAt.toISOString() ?? null,
variantCount: r._count.variants, variantCount: r._count.variants,
})), })),
total, total,
page, page,
@@ -114,7 +114,7 @@ export class OriginGoodsService {
* under a synthetic "未分类" root node. * under a synthetic "未分类" root node.
*/ */
async getTree(): Promise<OriginGoodsTreeResponse> { async getTree(): Promise<OriginGoodsTreeResponse> {
const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags] = const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags, mergedCounts, mergedWithCountries] =
await Promise.all([ await Promise.all([
this.prisma.category.findMany({ this.prisma.category.findMany({
where: { sdsCategoryId: { not: null } }, where: { sdsCategoryId: { not: null } },
@@ -126,11 +126,11 @@ export class OriginGoodsService {
parentCategoryId: true, parentCategoryId: true,
}, },
}), }),
this.prisma.originGood.findMany({ this.prisma.originGood.findMany({
where: { delisted: false, source: 'SDS' }, where: { delisted: false, source: 'SDS' },
orderBy: { goodName: 'asc' }, orderBy: { goodName: 'asc' },
include: { detail: true, _count: { select: { variants: true } } }, include: { detail: true, _count: { select: { variants: true } } },
}), }),
this.prisma.good.groupBy({ this.prisma.good.groupBy({
by: ['originGoodId'], by: ['originGoodId'],
_count: { _all: true }, _count: { _all: true },
@@ -144,7 +144,12 @@ export class OriginGoodsService {
}), }),
this.prisma.goodTag.findMany({ this.prisma.goodTag.findMany({
select: { select: {
good: { select: { originGoodId: true } }, good: {
select: {
originGoodId: true,
mergedOriginGoods: { select: { originGoodId: true } },
},
},
tag: { tag: {
select: { select: {
tagName: true, 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<string, number>(); const countMap = new Map<string, number>();
configCounts.forEach((c) => configCounts.forEach((c) =>
countMap.set(c.originGoodId.toString(), c._count._all), countMap.set(c.originGoodId.toString(), c._count._all),
); );
// Secondary (merged) references count towards configured status too.
const countryMap = new Map<string, string[]>(); mergedCounts.forEach((c) => {
goodsWithCountries.forEach((g) => { const key = c.originGoodId.toString();
const key = g.originGoodId.toString(); countMap.set(key, (countMap.get(key) ?? 0) + c._count._all);
const name = g.country?.countryName;
if (!name) return;
const arr = countryMap.get(key);
if (arr) arr.push(name);
else countryMap.set(key, [name]);
}); });
const countryMap = new Map<string, string[]>();
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<string, { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]>(); const tagMap = new Map<string, { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]>();
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) => { goodsWithTags.forEach((gt) => {
const key = gt.good.originGoodId.toString();
const tagInfo = { const tagInfo = {
tagName: gt.tag.tagName, tagName: gt.tag.tagName,
tagColor: gt.tag.tagColor, tagColor: gt.tag.tagColor,
@@ -185,11 +221,10 @@ export class OriginGoodsService {
tagGroupName: gt.tag.tagGroup?.groupName ?? null, tagGroupName: gt.tag.tagGroup?.groupName ?? null,
sortOrder: gt.tag.sortOrder, sortOrder: gt.tag.sortOrder,
}; };
const arr = tagMap.get(key); addTag(gt.good.originGoodId.toString(), tagInfo);
if (arr) { // A good's tags also mark its secondary origin goods as configured.
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo); for (const m of gt.good.mergedOriginGoods) {
} else { addTag(m.originGoodId.toString(), tagInfo);
tagMap.set(key, [tagInfo]);
} }
}); });
@@ -230,12 +265,12 @@ export class OriginGoodsService {
delisted: og.delisted, delisted: og.delisted,
configuredCount: countMap.get(og.id.toString()) ?? 0, configuredCount: countMap.get(og.id.toString()) ?? 0,
configuredCountries: countryMap.get(og.id.toString()) ?? [], configuredCountries: countryMap.get(og.id.toString()) ?? [],
configuredTags: tagMap.get(og.id.toString()) ?? [], configuredTags: tagMap.get(og.id.toString()) ?? [],
hasDetail: Boolean(og.detail), hasDetail: Boolean(og.detail),
detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null, detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null,
variantCount: og._count.variants, variantCount: og._count.variants,
sizeRowCount: this.jsonRows(og.detail?.sizeChart), sizeRowCount: this.jsonRows(og.detail?.sizeChart),
packageRowCount: this.jsonRows(og.detail?.packageSpecs), packageRowCount: this.jsonRows(og.detail?.packageSpecs),
})); }));
const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0); const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0);
@@ -281,12 +316,12 @@ export class OriginGoodsService {
delisted: og.delisted, delisted: og.delisted,
configuredCount: countMap.get(og.id.toString()) ?? 0, configuredCount: countMap.get(og.id.toString()) ?? 0,
configuredCountries: countryMap.get(og.id.toString()) ?? [], configuredCountries: countryMap.get(og.id.toString()) ?? [],
configuredTags: tagMap.get(og.id.toString()) ?? [], configuredTags: tagMap.get(og.id.toString()) ?? [],
hasDetail: Boolean(og.detail), hasDetail: Boolean(og.detail),
detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null, detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null,
variantCount: og._count.variants, variantCount: og._count.variants,
sizeRowCount: this.jsonRows(og.detail?.sizeChart), sizeRowCount: this.jsonRows(og.detail?.sizeChart),
packageRowCount: this.jsonRows(og.detail?.packageSpecs), packageRowCount: this.jsonRows(og.detail?.packageSpecs),
})), })),
}); });
} }
@@ -297,16 +332,16 @@ export class OriginGoodsService {
(og) => (countMap.get(og.id.toString()) ?? 0) > 0, (og) => (countMap.get(og.id.toString()) ?? 0) > 0,
).length; ).length;
return { return {
tree, tree,
totalOriginGoods: allOriginGoods.length, totalOriginGoods: allOriginGoods.length,
configuredCount: totalConfigured, configuredCount: totalConfigured,
}; };
} }
private jsonRows(value: unknown): number { private jsonRows(value: unknown): number {
if (!value || typeof value !== 'object' || !('rows' in value)) return 0; if (!value || typeof value !== 'object' || !('rows' in value)) return 0;
const rows = (value as { rows?: unknown }).rows; const rows = (value as { rows?: unknown }).rows;
return Array.isArray(rows) ? rows.length : 0; return Array.isArray(rows) ? rows.length : 0;
} }
} }