feat(api): order public goods by country > mid-category > style > priority
This commit is contained in:
@@ -621,4 +621,111 @@ describe('PublicService', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGoods tree-order sorting (国家→二级→款→priority)', () => {
|
||||
// 结构: 国家A(sort=1)>MidA>LeafA1(sort=1, 2条goods)、LeafA2(sort=2);国家B(sort=2)>MidB>LeafB1
|
||||
// 期望默认顺序: A款1(priority desc) -> A款2 -> B款1;B 的 priority=99 也不能越级
|
||||
const stamp2 = `${stamp}-treeorder`;
|
||||
const sdsA1 = `la1-${stamp2}`;
|
||||
const sdsA2 = `la2-${stamp2}`;
|
||||
const sdsB1 = `lb1-${stamp2}`;
|
||||
const trash = {
|
||||
goodIds: [] as bigint[],
|
||||
familyIds: [] as bigint[],
|
||||
originGoodIds: [] as bigint[],
|
||||
categoryIds: [] as bigint[],
|
||||
countryIds: [] as bigint[],
|
||||
};
|
||||
let orderedFamilyIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const cA = await prisma.country.create({
|
||||
data: { countryName: `TreeOrder A ${stamp2}`, sortOrder: 1 },
|
||||
});
|
||||
const cB = await prisma.country.create({
|
||||
data: { countryName: `TreeOrder B ${stamp2}`, sortOrder: 2 },
|
||||
});
|
||||
trash.countryIds = [cA.id, cB.id];
|
||||
const midA = await prisma.category.create({
|
||||
data: { categoryName: `TreeOrder MidA ${stamp2}`, sdsCategoryId: `ma-${stamp2}`, sortOrder: 1 },
|
||||
});
|
||||
const leafA1 = await prisma.category.create({
|
||||
data: { categoryName: `TreeOrder LeafA1 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: sdsA1, sortOrder: 1 },
|
||||
});
|
||||
const leafA2 = await prisma.category.create({
|
||||
data: { categoryName: `TreeOrder LeafA2 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: sdsA2, sortOrder: 2 },
|
||||
});
|
||||
const midB = await prisma.category.create({
|
||||
data: { categoryName: `TreeOrder MidB ${stamp2}`, sdsCategoryId: `mb-${stamp2}`, sortOrder: 2 },
|
||||
});
|
||||
const leafB1 = await prisma.category.create({
|
||||
data: { categoryName: `TreeOrder LeafB1 ${stamp2}`, parentCategoryId: midB.id, sdsCategoryId: sdsB1, sortOrder: 1 },
|
||||
});
|
||||
trash.categoryIds = [leafA1.id, leafA2.id, leafB1.id, midA.id, midB.id];
|
||||
|
||||
const mk = async (
|
||||
countryId: bigint,
|
||||
sdsCategoryId: string,
|
||||
name: string,
|
||||
priority: number,
|
||||
) => {
|
||||
const og = await prisma.originGood.create({
|
||||
data: { sdsGoodId: `to-${name}-${stamp2}`, goodName: name, sdsCategoryId },
|
||||
});
|
||||
trash.originGoodIds.push(og.id);
|
||||
const fam = await prisma.productFamily.create({
|
||||
data: { familyName: `to-fam-${name}-${stamp2}`, primaryOriginGoodId: og.id },
|
||||
});
|
||||
trash.familyIds.push(fam.id);
|
||||
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: fam.id } });
|
||||
const good = await prisma.good.create({
|
||||
data: {
|
||||
goodName: `TO${stamp2}-${name}`,
|
||||
originGoodId: og.id,
|
||||
familyId: fam.id,
|
||||
countryId,
|
||||
categoryId: sdsCategoryId === sdsA1 ? leafA1.id : sdsCategoryId === sdsA2 ? leafA2.id : leafB1.id,
|
||||
goodPriority: priority,
|
||||
},
|
||||
});
|
||||
trash.goodIds.push(good.id);
|
||||
return fam.id.toString();
|
||||
};
|
||||
|
||||
const a1Low = await mk(cA.id, sdsA1, 'A1Low', 1);
|
||||
const a1High = await mk(cA.id, sdsA1, 'A1High', 9);
|
||||
const a2 = await mk(cA.id, sdsA2, 'A2', 0);
|
||||
const b1 = await mk(cB.id, sdsB1, 'B1', 99);
|
||||
orderedFamilyIds = [a1High, a1Low, a2, b1];
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.good.deleteMany({ where: { id: { in: trash.goodIds } } }).catch(() => undefined);
|
||||
await prisma.productFamily.deleteMany({ where: { id: { in: trash.familyIds } } }).catch(() => undefined);
|
||||
await prisma.originGood.deleteMany({ where: { id: { in: trash.originGoodIds } } }).catch(() => undefined);
|
||||
for (const id of trash.categoryIds) {
|
||||
await prisma.category.delete({ where: { id } }).catch(() => undefined);
|
||||
}
|
||||
await prisma.country.deleteMany({ where: { id: { in: trash.countryIds } } }).catch(() => undefined);
|
||||
});
|
||||
|
||||
it('DEFAULT: country > mid > leaf > priority (cross-country priority cannot jump the queue)', async () => {
|
||||
const res = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: `TO${stamp2}`, // 唯一前缀圈定本夹具 4 条,避免全库分页截断
|
||||
});
|
||||
expect(res.total).toBe(4);
|
||||
const idx = res.items.map((i) => i.goodId);
|
||||
const pos = orderedFamilyIds.map((id) => idx.indexOf(id));
|
||||
expect(pos.every((p) => p >= 0)).toBe(true); // 全部命中
|
||||
expect(pos).toEqual([...pos].sort((a, b) => a - b)); // 相对有序
|
||||
// 同款内 priority desc
|
||||
expect(idx.indexOf(orderedFamilyIds[0])).toBeLessThan(idx.indexOf(orderedFamilyIds[1]));
|
||||
// 款顺序:LeafA1 -> LeafA2
|
||||
expect(idx.indexOf(orderedFamilyIds[1])).toBeLessThan(idx.indexOf(orderedFamilyIds[2]));
|
||||
// 国家/款顺序优先于 priority:B1(99) 不能排到 A2(0) 前面
|
||||
expect(idx.indexOf(orderedFamilyIds[2])).toBeLessThan(idx.indexOf(orderedFamilyIds[3]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,13 +71,19 @@ const PUBLIC_GOOD_LIST_INCLUDE = {
|
||||
tag: { include: { tagGroup: true } },
|
||||
position: true,
|
||||
originGood: {
|
||||
select: { sdsGoodId: true, goodImage: true, goodPrice: true },
|
||||
select: { sdsGoodId: true, goodImage: true, goodPrice: true, sdsCategoryId: true },
|
||||
},
|
||||
goodTags: { include: { tag: { include: { tagGroup: true } } } },
|
||||
} satisfies Prisma.GoodInclude;
|
||||
|
||||
type PublicGoodListRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_LIST_INCLUDE }>;
|
||||
|
||||
interface TreeOrderMeta {
|
||||
countryOrder: Map<string, number>;
|
||||
/** key: origin_goods.sds_category_id → 款所属二级(c2)/款(c3) 的顺序值 */
|
||||
leafOrder: Map<string, { c2: number; c3: number }>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PublicService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -220,12 +226,7 @@ export class PublicService {
|
||||
? [{ originGood: { goodPrice: 'desc' } }, { id: 'asc' }]
|
||||
: query.sort === 'NEWEST'
|
||||
? [{ createdAt: 'desc' }, { id: 'asc' }]
|
||||
: [
|
||||
{ goodPriority: 'desc' },
|
||||
{ position: { indexVal: 'asc' } },
|
||||
{ createdAt: 'desc' },
|
||||
{ id: 'asc' },
|
||||
];
|
||||
: [{ id: 'asc' }]; // DEFAULT:排序移到内存做(树序,见下)
|
||||
|
||||
// 契约族化:一族对外只暴露一条(代表行=排序第一条,goodId=族ID);
|
||||
// 无族 Good(自定义商品)各自成一条。商品量级为百级,先取全量匹配
|
||||
@@ -235,6 +236,15 @@ export class PublicService {
|
||||
include: PUBLIC_GOOD_LIST_INCLUDE,
|
||||
orderBy,
|
||||
});
|
||||
if (!query.sort || query.sort === 'DEFAULT') {
|
||||
// 默认排序 = 款序树:国家 → 款所属二级 → 款 → 优先级。
|
||||
// 款顺序存于新树 categories.sort_order(回填自排序表),商品经
|
||||
// origin_goods.sds_category_id 定位到款;同一款下多条 Good 共享
|
||||
// 前三层键,仅按 goodPriority 分先后。缺键(如款不在树中)沉到
|
||||
// 所属国家分组末尾。
|
||||
const meta = await this.loadTreeOrderMeta();
|
||||
rows.sort((a, b) => this.compareByTreeOrder(meta, a, b));
|
||||
}
|
||||
const familyMinPrices = await this.loadFamilyMinPrices();
|
||||
const grouped = new Map<string, PublicGoodListRow[]>();
|
||||
for (const good of rows) {
|
||||
@@ -270,6 +280,52 @@ export class PublicService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 款序元数据:countries.sort_order(一级)+ 新树二/三级 categories.sort_order
|
||||
* (款顺序,回填自排序表)。key 用 origin_goods.sds_category_id 关联商品→款。
|
||||
*/
|
||||
private async loadTreeOrderMeta(): Promise<TreeOrderMeta> {
|
||||
const [countries, leaves] = await Promise.all([
|
||||
this.prisma.country.findMany({ select: { id: true, sortOrder: true } }),
|
||||
this.prisma.$queryRaw<
|
||||
Array<{ sds_category_id: string; c2: number; c3: number }>
|
||||
>`
|
||||
SELECT leaf.sds_category_id,
|
||||
COALESCE(mid.sort_order, 2147483647) AS c2,
|
||||
COALESCE(leaf.sort_order, 2147483647) AS c3
|
||||
FROM categories leaf
|
||||
JOIN categories mid ON mid.category_id = leaf.parent_category_id
|
||||
WHERE leaf.sds_category_id IS NOT NULL AND leaf.sds_category_id <> ''
|
||||
`,
|
||||
]);
|
||||
return {
|
||||
countryOrder: new Map(countries.map((c) => [c.id.toString(), c.sortOrder])),
|
||||
leafOrder: new Map(
|
||||
leaves.map((l) => [l.sds_category_id, { c2: Number(l.c2), c3: Number(l.c3) }]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private compareByTreeOrder(meta: TreeOrderMeta, a: PublicGoodListRow, b: PublicGoodListRow): number {
|
||||
const MAX = Number.MAX_SAFE_INTEGER;
|
||||
const key = (g: PublicGoodListRow): [number, number, number, number, number] => {
|
||||
const leaf = meta.leafOrder.get(g.originGood.sdsCategoryId ?? '');
|
||||
return [
|
||||
meta.countryOrder.get(g.countryId.toString()) ?? MAX,
|
||||
leaf?.c2 ?? MAX,
|
||||
leaf?.c3 ?? MAX,
|
||||
-(g.goodPriority ?? 0),
|
||||
Number(g.id),
|
||||
];
|
||||
};
|
||||
const ka = key(a);
|
||||
const kb = key(b);
|
||||
for (let i = 0; i < ka.length; i++) {
|
||||
if (ka[i] !== kb[i]) return ka[i] - kb[i];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 族最低价一次 SQL 聚合:price_matrix 是每族 ~11KB 的 JSONB,按行 include
|
||||
* 会让每个商品都携带整份矩阵(实测全量 ~330ms);PG 端展开聚合只回传
|
||||
|
||||
Reference in New Issue
Block a user