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 } },
|
tag: { include: { tagGroup: true } },
|
||||||
position: true,
|
position: true,
|
||||||
originGood: {
|
originGood: {
|
||||||
select: { sdsGoodId: true, goodImage: true, goodPrice: true },
|
select: { sdsGoodId: true, goodImage: true, goodPrice: true, sdsCategoryId: true },
|
||||||
},
|
},
|
||||||
goodTags: { include: { tag: { include: { tagGroup: true } } } },
|
goodTags: { include: { tag: { include: { tagGroup: true } } } },
|
||||||
} satisfies Prisma.GoodInclude;
|
} satisfies Prisma.GoodInclude;
|
||||||
|
|
||||||
type PublicGoodListRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_LIST_INCLUDE }>;
|
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()
|
@Injectable()
|
||||||
export class PublicService {
|
export class PublicService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -220,12 +226,7 @@ export class PublicService {
|
|||||||
? [{ originGood: { goodPrice: 'desc' } }, { id: 'asc' }]
|
? [{ originGood: { goodPrice: 'desc' } }, { id: 'asc' }]
|
||||||
: query.sort === 'NEWEST'
|
: query.sort === 'NEWEST'
|
||||||
? [{ createdAt: 'desc' }, { id: 'asc' }]
|
? [{ createdAt: 'desc' }, { id: 'asc' }]
|
||||||
: [
|
: [{ id: 'asc' }]; // DEFAULT:排序移到内存做(树序,见下)
|
||||||
{ goodPriority: 'desc' },
|
|
||||||
{ position: { indexVal: 'asc' } },
|
|
||||||
{ createdAt: 'desc' },
|
|
||||||
{ id: 'asc' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 契约族化:一族对外只暴露一条(代表行=排序第一条,goodId=族ID);
|
// 契约族化:一族对外只暴露一条(代表行=排序第一条,goodId=族ID);
|
||||||
// 无族 Good(自定义商品)各自成一条。商品量级为百级,先取全量匹配
|
// 无族 Good(自定义商品)各自成一条。商品量级为百级,先取全量匹配
|
||||||
@@ -235,6 +236,15 @@ export class PublicService {
|
|||||||
include: PUBLIC_GOOD_LIST_INCLUDE,
|
include: PUBLIC_GOOD_LIST_INCLUDE,
|
||||||
orderBy,
|
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 familyMinPrices = await this.loadFamilyMinPrices();
|
||||||
const grouped = new Map<string, PublicGoodListRow[]>();
|
const grouped = new Map<string, PublicGoodListRow[]>();
|
||||||
for (const good of rows) {
|
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
|
* 族最低价一次 SQL 聚合:price_matrix 是每族 ~11KB 的 JSONB,按行 include
|
||||||
* 会让每个商品都携带整份矩阵(实测全量 ~330ms);PG 端展开聚合只回传
|
* 会让每个商品都携带整份矩阵(实测全量 ~330ms);PG 端展开聚合只回传
|
||||||
|
|||||||
Reference in New Issue
Block a user