merge: refactor/v2 完全并入 develop(v2 为准,v2 血统 20+ 提交收敛为主干)
This commit is contained in:
@@ -24,9 +24,11 @@
|
||||
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts",
|
||||
"import:product-detail": "ts-node prisma/import-product-detail.ts",
|
||||
"backfill:product-families": "ts-node prisma/backfill-product-families.ts",
|
||||
"backfill:product-families": "ts-node prisma/backfill-product-families.ts",
|
||||
"fix:good-names": "ts-node prisma/fix-pure-sku-good-names.ts",
|
||||
"fix:category-names": "ts-node prisma/fix-category-parens.ts",
|
||||
"organize": "ts-node prisma/backfill-product-families.ts"
|
||||
"organize": "ts-node prisma/backfill-product-families.ts",
|
||||
"recompute:families": "ts-node prisma/recompute-all-families.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^3.0.1",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 一次性回填:解析 排序表.md → categories.sort_order(二级/款)+ countries.sort_order
|
||||
* + 沙特国家行 + 中东根更名
|
||||
* 幂等:可重复执行;SDS 同步若覆盖根名称,重跑本脚本即可恢复
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 排序表国家名 → 新树根分类名(与库内 category_name 精确对应)
|
||||
const ROOT_MAP: Record<string, string> = {
|
||||
'美国': '美国工厂直发',
|
||||
'英国': '英国本地直发',
|
||||
'日本': '日本本地工厂直发',
|
||||
'墨西哥': '墨西哥工厂本地直发',
|
||||
'巴西': '巴西本地工厂直发',
|
||||
'中东': '中东本地工厂直发', // 同时更名沙特
|
||||
'波兰': '欧洲波兰工厂直发',
|
||||
'西班牙': '欧洲西班牙工厂直发',
|
||||
'德国': '欧洲德国工厂本地直发',
|
||||
'意大利': '欧洲意大利工厂直发',
|
||||
'加拿大': '加拿大本地工厂直发',
|
||||
'澳大利亚': '澳大利亚本地工厂直发',
|
||||
'韩国': '韩国本地直发',
|
||||
'中国(国内工厂)': '国内工厂',
|
||||
};
|
||||
|
||||
const norm = (s: string) => s.trim().replace(/\s+/g, '');
|
||||
const codeOf = (s: string) => (s.trim().match(/^[A-Za-z0-9]+/) ?? [''])[0];
|
||||
|
||||
async function main() {
|
||||
const raw = readFileSync(process.env.SORT_TABLE_PATH ?? '/repo/排序表.md', 'utf8');
|
||||
let country: string | null = null;
|
||||
let l2: string | null = null;
|
||||
const tree: Array<{ country: string; l2: string; l3: string }> = [];
|
||||
const countryOrder: string[] = [];
|
||||
for (const line of raw.split('\n')) {
|
||||
const t = line.trim();
|
||||
if (t.startsWith('# ')) {
|
||||
const name = t.slice(2).trim();
|
||||
if (name === '全部' || name.includes('工厂直发国家')) continue;
|
||||
country = name;
|
||||
if (!countryOrder.includes(name)) countryOrder.push(name);
|
||||
} else if (t.startsWith('## ') && country) {
|
||||
l2 = t.slice(3).trim();
|
||||
} else if (t.startsWith('### ') && country && l2) {
|
||||
tree.push({ country, l2, l3: t.slice(4).trim() });
|
||||
}
|
||||
}
|
||||
console.log(`parsed: ${countryOrder.length} countries, ${tree.length} leaves`);
|
||||
|
||||
const roots = await prisma.category.findMany({
|
||||
where: { parentCategoryId: null, sdsCategoryId: { not: null } },
|
||||
include: { children: { include: { children: true } } },
|
||||
});
|
||||
const rootByName = new Map(roots.map((r) => [r.categoryName, r]));
|
||||
const unmatched: string[] = [];
|
||||
|
||||
// 1) countries:沙特 upsert + 顺序重写(中国无国家行,跳过)
|
||||
for (let i = 0; i < countryOrder.length; i++) {
|
||||
const name = countryOrder[i];
|
||||
const dbCountry = name === '中东' ? '沙特' : name;
|
||||
if (dbCountry === '中国(国内工厂)') continue;
|
||||
const sortOrder = i + 1;
|
||||
const existing = await prisma.country.findUnique({ where: { countryName: dbCountry } });
|
||||
if (existing) {
|
||||
await prisma.country.update({ where: { id: existing.id }, data: { sortOrder } });
|
||||
} else if (dbCountry === '沙特') {
|
||||
await prisma.country.create({ data: { countryName: '沙特', sortOrder } });
|
||||
console.log('created country: 沙特');
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 中东根 → 沙特
|
||||
const meRoot = rootByName.get('中东本地工厂直发');
|
||||
if (meRoot) {
|
||||
await prisma.category.update({ where: { id: meRoot.id }, data: { categoryName: '沙特本地工厂直发' } });
|
||||
rootByName.set('沙特本地工厂直发', meRoot);
|
||||
console.log('renamed root: 中东本地工厂直发 -> 沙特本地工厂直发');
|
||||
}
|
||||
|
||||
// 3) 二级/款 sort_order
|
||||
for (const countryName of countryOrder) {
|
||||
const rootName = countryName === '中东' ? '沙特本地工厂直发' : ROOT_MAP[countryName];
|
||||
const root = rootByName.get(rootName);
|
||||
if (!root) {
|
||||
unmatched.push(`ROOT MISS: ${countryName} (expect root "${rootName}")`);
|
||||
continue;
|
||||
}
|
||||
const l2s = root.children;
|
||||
const l2NamesInOrder: string[] = [];
|
||||
for (const row of tree) {
|
||||
if (row.country === countryName && !l2NamesInOrder.includes(row.l2)) l2NamesInOrder.push(row.l2);
|
||||
}
|
||||
for (let i = 0; i < l2NamesInOrder.length; i++) {
|
||||
const target = l2NamesInOrder[i];
|
||||
const mid = l2s.find((m) => norm(m.categoryName) === norm(target));
|
||||
if (!mid) {
|
||||
unmatched.push(`L2 MISS: ${countryName} / ${target}`);
|
||||
continue;
|
||||
}
|
||||
await prisma.category.update({ where: { id: mid.id }, data: { sortOrder: i + 1 } });
|
||||
const leaves = mid.children;
|
||||
const l3Names = tree.filter((r) => r.country === countryName && r.l2 === target).map((r) => r.l3);
|
||||
for (let j = 0; j < l3Names.length; j++) {
|
||||
const want = norm(l3Names[j]);
|
||||
const code = norm(codeOf(l3Names[j]));
|
||||
const leaf =
|
||||
leaves.find((l) => norm(l.categoryName) === want) ??
|
||||
(code ? leaves.find((l) => norm(l.categoryName).startsWith(code)) : undefined);
|
||||
if (!leaf) {
|
||||
unmatched.push(`L3 MISS: ${countryName} / ${target} / ${l3Names[j]}`);
|
||||
continue;
|
||||
}
|
||||
await prisma.category.update({ where: { id: leaf.id }, data: { sortOrder: j + 1 } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unmatched.length) {
|
||||
console.error(`UNMATCHED (${unmatched.length}):\n` + unmatched.join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('backfill done');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "categories" ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 全量族重算(一次性维护脚本):
|
||||
* 逐族调用 FamilyRecomputeService.recomputeFamily,刷新物化字段
|
||||
* (并集尺码表/包装规则 + 五维价格矩阵,含选项组词表序排序)。
|
||||
* autoManaged=false 的族内部只置 stale,不会覆盖人工物化字段。
|
||||
*
|
||||
* 运行:pnpm --filter @inkreach/api recompute:families
|
||||
*/
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaService();
|
||||
await prisma.onModuleInit();
|
||||
const recompute = new FamilyRecomputeService(prisma);
|
||||
|
||||
const families = await prisma.productFamily.findMany({
|
||||
select: { id: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
for (const { id } of families) {
|
||||
try {
|
||||
await recompute.recomputeFamily(id);
|
||||
ok++;
|
||||
} catch (error) {
|
||||
failed++;
|
||||
console.error(`family ${id} recompute failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
console.log(`[recompute:families] total=${families.length} ok=${ok} failed=${failed}`);
|
||||
await prisma.onModuleDestroy();
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -161,6 +161,7 @@ model Category {
|
||||
categoryName String @map("category_name")
|
||||
categoryIcon String? @map("category_icon")
|
||||
sdsCategoryId String? @unique @map("sds_category_id")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
@@ -1,6 +1,10 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { FamilyRecomputeService } from './family-recompute.service';
|
||||
import {
|
||||
FamilyRecomputeService,
|
||||
derivePriceMatrix,
|
||||
memberMatrixCombos,
|
||||
} from './family-recompute.service';
|
||||
import { ProductFamiliesService } from './product-families.service';
|
||||
import { OrganizeService } from './organize.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -400,3 +404,130 @@ describe('FamilyRecomputeService', () => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* derivePriceMatrix 纯函数单测:选项组排序(词表序 + 未知值沉底)。
|
||||
* 业务确认的展示顺序:printCount 单面→双面;craft 烫画→直喷→不打印;logistics 不包邮→包邮。
|
||||
* 词表外自由文本(CUSTOM craftLabel/logisticsLabel,如"海运")沉底,相互间保持首次遇到序。
|
||||
*/
|
||||
describe('derivePriceMatrix 选项组排序', () => {
|
||||
const mkMember = (
|
||||
id: number,
|
||||
over: { tags?: string[]; source?: 'SDS' | 'CUSTOM'; craftLabel?: string | null; logisticsLabel?: string | null },
|
||||
) =>
|
||||
({
|
||||
id: BigInt(id),
|
||||
source: over.source ?? 'SDS',
|
||||
originGoodTags: (over.tags ?? []).map((t) => ({ tag: { tagName: t } })),
|
||||
variants: [],
|
||||
craftLabel: over.craftLabel ?? null,
|
||||
logisticsLabel: over.logisticsLabel ?? null,
|
||||
}) as any;
|
||||
|
||||
it('成员顺序无关:三个维度恒为词表序', () => {
|
||||
// 故意让"遇到序"与目标词表序全部相反(双面在前/直喷在前/不包邮在前)
|
||||
const members = [
|
||||
mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] }),
|
||||
mkMember(2, { tags: ['烫画', '单面印花', '包邮'] }),
|
||||
];
|
||||
const m = derivePriceMatrix(members, []);
|
||||
expect(m.printCounts).toEqual(['单面印花', '双面印花']);
|
||||
expect(m.crafts).toEqual(['烫画', '直喷']);
|
||||
expect(m.logistics).toEqual(['不包邮', '包邮']);
|
||||
});
|
||||
|
||||
it('子集保序:只出现部分取值时相对顺序不变', () => {
|
||||
const m = derivePriceMatrix([mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] })], []);
|
||||
expect(m.printCounts).toEqual(['双面印花']);
|
||||
expect(m.crafts).toEqual(['直喷']);
|
||||
expect(m.logistics).toEqual(['不包邮']);
|
||||
});
|
||||
|
||||
it('未知自由文本沉底且相互保持首次遇到序', () => {
|
||||
const members = [
|
||||
// CUSTOM:自由文本标签 + tags 提供印花数量(无印花维度不进矩阵的既有语义)
|
||||
mkMember(1, { source: 'CUSTOM', tags: ['双面印花'], craftLabel: '丝印', logisticsLabel: '海运' }),
|
||||
mkMember(2, { source: 'CUSTOM', tags: ['双面印花'], craftLabel: '水洗', logisticsLabel: '空运' }),
|
||||
mkMember(3, { tags: ['烫画', '单面印花', '包邮'] }),
|
||||
];
|
||||
const m = derivePriceMatrix(members, []);
|
||||
expect(m.printCounts).toEqual(['单面印花', '双面印花']);
|
||||
expect(m.crafts).toEqual(['烫画', '丝印', '水洗']);
|
||||
expect(m.logistics).toEqual(['包邮', '海运', '空运']);
|
||||
});
|
||||
|
||||
it('人工覆盖新增取值参与同一排序', () => {
|
||||
const members = [mkMember(1, { tags: ['烫画', '单面印花', '包邮'] })];
|
||||
const overrides = [
|
||||
{
|
||||
sizeId: 'size_M',
|
||||
colorId: 'color_red',
|
||||
printCount: '四面印花',
|
||||
craft: '丝印',
|
||||
logistics: '海运',
|
||||
price: '40',
|
||||
},
|
||||
] as any;
|
||||
const m = derivePriceMatrix(members, overrides);
|
||||
expect(m.printCounts).toEqual(['单面印花', '四面印花']);
|
||||
expect(m.crafts).toEqual(['烫画', '丝印']);
|
||||
expect(m.logistics).toEqual(['包邮', '海运']);
|
||||
});
|
||||
|
||||
it('幂等:同输入多跑两遍输出全等(含顺序),与成员遍历顺序无关', () => {
|
||||
const members = [
|
||||
mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] }),
|
||||
mkMember(2, { source: 'CUSTOM', tags: ['双面印花'], craftLabel: '丝印', logisticsLabel: '海运' }),
|
||||
mkMember(3, { tags: ['烫画', '单面印花', '包邮'] }),
|
||||
];
|
||||
const first = derivePriceMatrix(members, []);
|
||||
const second = derivePriceMatrix(members, []);
|
||||
const reversed = derivePriceMatrix([...members].reverse(), []);
|
||||
expect(JSON.stringify(second)).toBe(JSON.stringify(first));
|
||||
expect(JSON.stringify(reversed)).toBe(JSON.stringify(first));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 印刷工艺新增"热转印":矩阵维度词表放开准入 + 展示序追加末尾。
|
||||
* 自动派生规则不改 —— SDS 链接只能人工打标;不打标的链接/族零影响。
|
||||
*/
|
||||
describe('热转印工艺', () => {
|
||||
const mkMember = (
|
||||
id: number,
|
||||
over: { tags?: string[]; source?: 'SDS' | 'CUSTOM'; craftLabel?: string | null; logisticsLabel?: string | null },
|
||||
) =>
|
||||
({
|
||||
id: BigInt(id),
|
||||
source: over.source ?? 'SDS',
|
||||
originGoodTags: (over.tags ?? []).map((t) => ({ tag: { tagName: t } })),
|
||||
variants: [],
|
||||
craftLabel: over.craftLabel ?? null,
|
||||
logisticsLabel: over.logisticsLabel ?? null,
|
||||
}) as any;
|
||||
|
||||
it('memberMatrixCombos:打标热转印的链接产出矩阵组合(此前被词表过滤为空)', () => {
|
||||
const combos = memberMatrixCombos({ tagNames: ['单面印花', '热转印', '包邮'] });
|
||||
expect(combos).toEqual([{ printCount: '单面印花', craft: '热转印', logistics: '包邮' }]);
|
||||
});
|
||||
|
||||
it('derivePriceMatrix:crafts 展示序为 烫画→直喷→不打印→热转印(词表内,不再沉底)', () => {
|
||||
const members = [
|
||||
mkMember(1, { tags: ['热转印', '单面印花', '包邮'] }),
|
||||
mkMember(2, { tags: ['烫画', '单面印花', '包邮'] }),
|
||||
mkMember(3, { tags: ['直喷', '单面印花', '不包邮'] }),
|
||||
mkMember(4, { tags: ['不打印', '单面印花', '包邮'] }),
|
||||
];
|
||||
const m = derivePriceMatrix(members, []);
|
||||
expect(m.crafts).toEqual(['烫画', '直喷', '不打印', '热转印']);
|
||||
});
|
||||
|
||||
it('词表外自由文本(丝印/水洗)仍然沉底,与热转印区分', () => {
|
||||
const members = [
|
||||
mkMember(1, { tags: ['热转印', '单面印花', '包邮'] }),
|
||||
mkMember(2, { source: 'CUSTOM', tags: ['单面印花'], craftLabel: '丝印', logisticsLabel: '包邮' }),
|
||||
];
|
||||
const m = derivePriceMatrix(members, []);
|
||||
expect(m.crafts).toEqual(['热转印', '丝印']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,11 +66,31 @@ type OverrideRow = Prisma.FamilyPriceOverrideGetPayload<{}>;
|
||||
/** 矩阵三个归因维度的合法取值(封闭集合,与派生标签组一致) */
|
||||
const DIM_VALUES = {
|
||||
printCount: ['单面印花', '双面印花'],
|
||||
craft: ['烫画', '直喷', '不打印'],
|
||||
craft: ['烫画', '直喷', '不打印', '热转印'],
|
||||
logistics: ['包邮', '不包邮'],
|
||||
} as const satisfies Record<string, readonly string[]>;
|
||||
|
||||
type DimKey = keyof typeof DIM_VALUES;
|
||||
|
||||
/**
|
||||
* 选项组展示顺序(业务确认):printCount 单面→双面;craft 烫画→直喷→不打印→热转印;logistics 不包邮→包邮。
|
||||
* 与 DIM_VALUES 的"组合生成序"是两回事,物化前对聚合数组按此稳定排序;
|
||||
* 词表外自由文本(CUSTOM 标签,如"海运")沉底,相互间保持首次遇到序。
|
||||
*/
|
||||
const OPTION_DISPLAY_ORDER = {
|
||||
printCount: ['单面印花', '双面印花'],
|
||||
craft: ['烫画', '直喷', '不打印', '热转印'],
|
||||
logistics: ['不包邮', '包邮'],
|
||||
} as const;
|
||||
|
||||
function optionRank(value: string, canon: readonly string[]): number {
|
||||
const i = canon.indexOf(value);
|
||||
return i === -1 ? canon.length : i;
|
||||
}
|
||||
|
||||
function sortOptions(values: string[], canon: readonly string[]): string[] {
|
||||
return [...values].sort((a, b) => optionRank(a, canon) - optionRank(b, canon));
|
||||
}
|
||||
export interface MatrixCombo {
|
||||
printCount: string;
|
||||
craft: string;
|
||||
@@ -270,9 +290,9 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]):
|
||||
return {
|
||||
sizes: [...sizes.entries()].map(([key, name]) => ({ key, name })),
|
||||
colors: [...colors.entries()].map(([key, v]) => ({ key, ...v })),
|
||||
printCounts,
|
||||
crafts,
|
||||
logistics: logisticsOptions,
|
||||
printCounts: sortOptions(printCounts, OPTION_DISPLAY_ORDER.printCount),
|
||||
crafts: sortOptions(crafts, OPTION_DISPLAY_ORDER.craft),
|
||||
logistics: sortOptions(logisticsOptions, OPTION_DISPLAY_ORDER.logistics),
|
||||
rows,
|
||||
};
|
||||
}
|
||||
@@ -330,6 +350,8 @@ export class FamilyRecomputeService {
|
||||
include: {
|
||||
originGoods: {
|
||||
where: { delisted: false },
|
||||
// 固定遍历序:保证"首次遇到序"(未知自由文本取值的相对顺序)确定性,重算幂等
|
||||
orderBy: { id: 'asc' },
|
||||
include: {
|
||||
detail: true,
|
||||
variants: { where: { enabled: true }, orderBy: { sortOrder: 'asc' } },
|
||||
|
||||
@@ -650,4 +650,225 @@ 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]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('family representative row consistency (列表/首页代表行对齐详情)', () => {
|
||||
// 同族两条 Good:同 priority=10,Low 的 id 更小/createdAt 更早/价格更低/位置更好,
|
||||
// High 的 createdAt 更新。详情代表行规则 = priority desc → createdAt desc → id asc
|
||||
// → 详情永远取 High;列表/首页必须与详情一致,而不是随排序参数漂移到 Low。
|
||||
const stamp3 = `${stamp}-rep`;
|
||||
let repFamilyId: bigint;
|
||||
let trash = {
|
||||
goodIds: [] as bigint[],
|
||||
positionIds: [] as bigint[],
|
||||
originGoodIds: [] as bigint[],
|
||||
};
|
||||
const repLowName = `Rep Low ${stamp3}`;
|
||||
const repHighName = `Rep High ${stamp3}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const posLow = await prisma.position.create({
|
||||
data: { indexVal: 1, countryId, categoryId },
|
||||
});
|
||||
const posHigh = await prisma.position.create({
|
||||
data: { indexVal: 5, countryId, categoryId },
|
||||
});
|
||||
trash.positionIds = [posLow.id, posHigh.id];
|
||||
|
||||
const ogLow = await prisma.originGood.create({
|
||||
data: { sdsGoodId: `rep-low-${stamp3}`, goodName: repLowName, goodPrice: 10 },
|
||||
});
|
||||
const ogHigh = await prisma.originGood.create({
|
||||
data: { sdsGoodId: `rep-high-${stamp3}`, goodName: repHighName, goodPrice: 20 },
|
||||
});
|
||||
trash.originGoodIds = [ogLow.id, ogHigh.id];
|
||||
|
||||
const family = await prisma.productFamily.create({
|
||||
data: { familyName: `rep-fam-${stamp3}`, primaryOriginGoodId: ogLow.id },
|
||||
});
|
||||
repFamilyId = family.id;
|
||||
await prisma.originGood.updateMany({
|
||||
where: { id: { in: [ogLow.id, ogHigh.id] } },
|
||||
data: { familyId: family.id },
|
||||
});
|
||||
|
||||
const gLow = await prisma.good.create({
|
||||
data: {
|
||||
goodName: repLowName,
|
||||
originGoodId: ogLow.id,
|
||||
familyId: family.id,
|
||||
countryId,
|
||||
categoryId,
|
||||
goodPriority: 10,
|
||||
positionId: posLow.id,
|
||||
createdAt: new Date(stamp),
|
||||
},
|
||||
});
|
||||
const gHigh = await prisma.good.create({
|
||||
data: {
|
||||
goodName: repHighName,
|
||||
originGoodId: ogHigh.id,
|
||||
familyId: family.id,
|
||||
countryId,
|
||||
categoryId,
|
||||
goodPriority: 10,
|
||||
positionId: posHigh.id,
|
||||
createdAt: new Date(stamp + 60_000),
|
||||
},
|
||||
});
|
||||
trash.goodIds = [gLow.id, gHigh.id];
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.good.deleteMany({ where: { id: { in: trash.goodIds } } }).catch(() => undefined);
|
||||
await prisma.position
|
||||
.deleteMany({ where: { id: { in: trash.positionIds } } })
|
||||
.catch(() => undefined);
|
||||
await prisma.productFamily.delete({ where: { id: repFamilyId } }).catch(() => undefined);
|
||||
await prisma.originGood
|
||||
.deleteMany({ where: { id: { in: trash.originGoodIds } } })
|
||||
.catch(() => undefined);
|
||||
});
|
||||
|
||||
it('DEFAULT 列表代表行与详情一致(priority 并列时取 createdAt 最新,而非 id 最小)', async () => {
|
||||
const detail = await service.getGood(repFamilyId.toString());
|
||||
expect(detail.goodName).toBe(repHighName);
|
||||
|
||||
const list = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
keyword: 'Rep ', // 本文件夹具唯一前缀,圈定本族(goodName: Rep Low/High)
|
||||
});
|
||||
const ours = list.items.filter((i) => i.goodId === repFamilyId.toString());
|
||||
expect(ours).toHaveLength(1);
|
||||
expect(ours[0].goodName).toBe(detail.goodName);
|
||||
});
|
||||
|
||||
it('PRICE_ASC 列表代表行不漂移到价格更低的成员', async () => {
|
||||
const detail = await service.getGood(repFamilyId.toString());
|
||||
const list = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
keyword: 'Rep ',
|
||||
sort: 'PRICE_ASC',
|
||||
});
|
||||
const ours = list.items.filter((i) => i.goodId === repFamilyId.toString());
|
||||
expect(ours).toHaveLength(1);
|
||||
expect(ours[0].goodName).toBe(detail.goodName);
|
||||
});
|
||||
|
||||
it('home-goods 代表行与详情一致(不取位置更好的成员)', async () => {
|
||||
const detail = await service.getGood(repFamilyId.toString());
|
||||
const home = await service.getHomeGoods({ limit: 50, countryId: countryId.toString() });
|
||||
const ours = home.filter((h) => h.goodId === repFamilyId.toString());
|
||||
expect(ours).toHaveLength(1);
|
||||
expect(ours[0].goodName).toBe(detail.goodName);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
@@ -244,7 +254,7 @@ export class PublicService {
|
||||
else grouped.set(key, [good]);
|
||||
}
|
||||
let items = [...grouped.values()].map((goods) => {
|
||||
const rep = goods[0];
|
||||
const rep = this.pickFamilyRepresentative(goods);
|
||||
const dto = this.toPublicGood(rep);
|
||||
// 列表价 = 族矩阵最低价("这个款之下有哪些价格"的起价);无矩阵回退链接价
|
||||
const familyMin = rep.familyId
|
||||
@@ -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 端展开聚合只回传
|
||||
@@ -365,16 +421,20 @@ export class PublicService {
|
||||
take: query.limit,
|
||||
});
|
||||
const familyMinPrices = await this.loadFamilyMinPrices();
|
||||
// 首页同样按族去重(同族多条位置配置只保留排序最前一条),再截取 limit
|
||||
const seen = new Set<string>();
|
||||
const items: PublicGoodDto[] = [];
|
||||
// 首页同样按族去重(一族只出一条),代表行选取与详情/列表一致
|
||||
const grouped = new Map<string, PublicGoodListRow[]>();
|
||||
for (const good of rows) {
|
||||
const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const dto = this.toPublicGood(good);
|
||||
const familyMin = good.familyId
|
||||
? familyMinPrices.get(good.familyId.toString())
|
||||
const bucket = grouped.get(key);
|
||||
if (bucket) bucket.push(good);
|
||||
else grouped.set(key, [good]);
|
||||
}
|
||||
const items: PublicGoodDto[] = [];
|
||||
for (const goods of grouped.values()) {
|
||||
const rep = this.pickFamilyRepresentative(goods);
|
||||
const dto = this.toPublicGood(rep);
|
||||
const familyMin = rep.familyId
|
||||
? familyMinPrices.get(rep.familyId.toString())
|
||||
: undefined;
|
||||
if (familyMin !== undefined) dto.price = familyMin;
|
||||
items.push(dto);
|
||||
@@ -382,6 +442,22 @@ export class PublicService {
|
||||
return items.slice(0, query.limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 族代表行选取:与详情 getGoodByFamilyId 的 orderBy 保持一致
|
||||
* (goodPriority desc → createdAt desc → id asc),
|
||||
* 保证列表/首页与详情返回的 goodName、主图、分类等代表行字段一致。
|
||||
*/
|
||||
private pickFamilyRepresentative<T extends { goodPriority: number | null; createdAt: Date; id: bigint }>(
|
||||
goods: T[],
|
||||
): T {
|
||||
return [...goods].sort(
|
||||
(a, b) =>
|
||||
(b.goodPriority ?? 0) - (a.goodPriority ?? 0) ||
|
||||
b.createdAt.getTime() - a.createdAt.getTime() ||
|
||||
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
||||
)[0];
|
||||
}
|
||||
|
||||
/** 入参用精简行类型:列表(PublicGoodListRow)与详情(PublicGoodRow,字段超集)都能传 */
|
||||
private toPublicGood(good: PublicGoodListRow): PublicGoodDto {
|
||||
const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) =>
|
||||
|
||||
Reference in New Issue
Block a user