|
|
@@ -0,0 +1,250 @@
|
|
|
+import type {
|
|
|
+ ListingDimension,
|
|
|
+ ListingOverviewCategoryFacet,
|
|
|
+ ListingOverviewDimensionStat,
|
|
|
+ ListingOverviewDimensionValue,
|
|
|
+ ListingOverviewQuery,
|
|
|
+ ListingOverviewResponse,
|
|
|
+ ListingOverviewRow,
|
|
|
+ ListingOverviewScoreNature,
|
|
|
+ ListingScoreResult,
|
|
|
+ ListingSourceSnapshot,
|
|
|
+} from '../domain.js';
|
|
|
+import { LISTING_DIMENSION_MAX } from '../scoring/rule-engine.js';
|
|
|
+import { selectCurrentListingScore } from '../scoring/score-status.js';
|
|
|
+import { stableListingHash, stableListingPage, type StableListingSortValue } from './stable-listing-page.js';
|
|
|
+
|
|
|
+export const LISTING_OVERVIEW_DIMENSIONS: readonly ListingDimension[] = ['title', 'selling_points', 'images', 'description', 'specifications'];
|
|
|
+export const LISTING_SIMULATION_MODEL = 'listing-v7-demo-simulation';
|
|
|
+
|
|
|
+const DIMENSION_LABELS: Readonly<Record<ListingDimension, string>> = {
|
|
|
+ title: '商品标题',
|
|
|
+ selling_points: '核心卖点',
|
|
|
+ images: '图片资产',
|
|
|
+ description: '商品详情',
|
|
|
+ specifications: '规格与履约',
|
|
|
+};
|
|
|
+
|
|
|
+const SCORE_NATURE_LABELS: Readonly<Record<ListingOverviewScoreNature, string>> = {
|
|
|
+ simulation: '模拟评分',
|
|
|
+ formal_ai: '正式评分',
|
|
|
+ rule_precheck: '自动检查',
|
|
|
+ unscored: '未评分',
|
|
|
+};
|
|
|
+
|
|
|
+const SCORE_BUCKETS = [
|
|
|
+ { key: 'below_60', label: '60 分以下', minScore: 0, maxScore: 60, maxScoreExclusive: true },
|
|
|
+ { key: '60_69', label: '60–69 分', minScore: 60, maxScore: 70, maxScoreExclusive: true },
|
|
|
+ { key: '70_79', label: '70–79 分', minScore: 70, maxScore: 80, maxScoreExclusive: true },
|
|
|
+ { key: '80_89', label: '80–89 分', minScore: 80, maxScore: 90, maxScoreExclusive: true },
|
|
|
+ { key: '90_100', label: '90–100 分', minScore: 90, maxScore: 100, maxScoreExclusive: false },
|
|
|
+] as const;
|
|
|
+
|
|
|
+function round(value: number, digits = 2): number {
|
|
|
+ const multiplier = 10 ** digits;
|
|
|
+ return Math.round((value + Number.EPSILON) * multiplier) / multiplier;
|
|
|
+}
|
|
|
+
|
|
|
+function median(values: number[]): number | null {
|
|
|
+ if (!values.length) return null;
|
|
|
+ const sorted = [...values].sort((left, right) => left - right);
|
|
|
+ const middle = Math.floor(sorted.length / 2);
|
|
|
+ return sorted.length % 2 ? sorted[middle]! : round((sorted[middle - 1]! + sorted[middle]!) / 2);
|
|
|
+}
|
|
|
+
|
|
|
+function scoreNature(score: ListingScoreResult | null): ListingOverviewScoreNature {
|
|
|
+ if (!score) return 'unscored';
|
|
|
+ if (score.model === LISTING_SIMULATION_MODEL) return 'simulation';
|
|
|
+ return score.scoreKind === 'hybrid_ai' ? 'formal_ai' : 'rule_precheck';
|
|
|
+}
|
|
|
+
|
|
|
+function dimensionValues(score: ListingScoreResult | null): Record<ListingDimension, ListingOverviewDimensionValue> {
|
|
|
+ return Object.fromEntries(LISTING_OVERVIEW_DIMENSIONS.map((key) => {
|
|
|
+ const maximum = LISTING_DIMENSION_MAX[key];
|
|
|
+ const value = score?.dimensions.find((dimension) => dimension.dimension === key)?.score ?? null;
|
|
|
+ return [key, {
|
|
|
+ score: value,
|
|
|
+ maxScore: maximum,
|
|
|
+ rate: value === null ? null : round(value / maximum, 4),
|
|
|
+ gap: value === null ? null : round(maximum - value),
|
|
|
+ }];
|
|
|
+ })) as Record<ListingDimension, ListingOverviewDimensionValue>;
|
|
|
+}
|
|
|
+
|
|
|
+function category(source: ListingSourceSnapshot): { id: string | null; name: string | null; path: string[] } {
|
|
|
+ const path = (source.categoryContext?.pathNames?.length ? source.categoryContext.pathNames : source.categoryContext?.names ?? [])
|
|
|
+ .map((value) => value.trim()).filter(Boolean);
|
|
|
+ const id = source.categoryContext?.categoryId?.trim() || source.categoryIds.at(-1)?.trim() || null;
|
|
|
+ const name = source.categoryContext?.displayName?.trim() || path.at(-1) || source.categoryContext?.names.at(-1)?.trim() || id;
|
|
|
+ return { id, name, path };
|
|
|
+}
|
|
|
+
|
|
|
+function overviewRow(source: ListingSourceSnapshot, scores: ListingScoreResult[]): ListingOverviewRow {
|
|
|
+ const score = selectCurrentListingScore(scores, source.sourceHash).displayScore;
|
|
|
+ const dimensions = dimensionValues(score);
|
|
|
+ const knownDimensions = LISTING_OVERVIEW_DIMENSIONS.filter((key) => dimensions[key].rate !== null);
|
|
|
+ const weakestDimension = knownDimensions.sort((left, right) => dimensions[left].rate! - dimensions[right].rate!)[0] ?? null;
|
|
|
+ const gaps = LISTING_OVERVIEW_DIMENSIONS.map((key) => dimensions[key].gap);
|
|
|
+ const improvementPotential = gaps.every((gap): gap is number => gap !== null) ? round(gaps.reduce((sum, gap) => sum + gap, 0)) : null;
|
|
|
+ const nature = scoreNature(score);
|
|
|
+ const sourceCategory = category(source);
|
|
|
+ return {
|
|
|
+ productId: source.productId,
|
|
|
+ title: source.title,
|
|
|
+ imageUrl: source.images.find((image) => image.isPrimary)?.url ?? source.images[0]?.url ?? null,
|
|
|
+ categoryId: sourceCategory.id,
|
|
|
+ categoryName: sourceCategory.name,
|
|
|
+ categoryPath: sourceCategory.path,
|
|
|
+ categoryIds: [...source.categoryIds],
|
|
|
+ itemStatusLabel: source.itemStatus ? '商品状态已获取' : '商品状态未知',
|
|
|
+ scoreNature: nature,
|
|
|
+ scoreNatureLabel: SCORE_NATURE_LABELS[nature],
|
|
|
+ overallScore: score?.overallScore ?? null,
|
|
|
+ overallRate: score?.overallScore === null || score?.overallScore === undefined ? null : round(score.overallScore / 100, 4),
|
|
|
+ dimensions,
|
|
|
+ weakestDimension,
|
|
|
+ improvementPotential,
|
|
|
+ scoredAt: score?.createdAt ?? null,
|
|
|
+ syncedAt: source.syncedAt,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function matchesRange(value: number | null, minimum: number | undefined, maximum: number | undefined): boolean {
|
|
|
+ if (minimum === undefined && maximum === undefined) return true;
|
|
|
+ if (value === null) return false;
|
|
|
+ return (minimum === undefined || value >= minimum) && (maximum === undefined || value <= maximum);
|
|
|
+}
|
|
|
+
|
|
|
+function matches(row: ListingOverviewRow, query: ListingOverviewQuery, normalizedSearch: string): boolean {
|
|
|
+ if (normalizedSearch && !`${row.productId} ${row.title ?? ''}`.toLocaleLowerCase().includes(normalizedSearch)) return false;
|
|
|
+ if (query.categoryIds?.length && !query.categoryIds.some((categoryId) => row.categoryId === categoryId || row.categoryIds.includes(categoryId))) return false;
|
|
|
+ if (query.scoreNature && row.scoreNature !== query.scoreNature) return false;
|
|
|
+ if (!matchesRange(row.overallScore, query.minScore, query.maxScore)) return false;
|
|
|
+ if (!matchesRange(row.dimensions.title.score, query.titleMin, query.titleMax)) return false;
|
|
|
+ if (!matchesRange(row.dimensions.selling_points.score, query.sellingPointsMin, query.sellingPointsMax)) return false;
|
|
|
+ if (!matchesRange(row.dimensions.images.score, query.imagesMin, query.imagesMax)) return false;
|
|
|
+ if (!matchesRange(row.dimensions.description.score, query.descriptionMin, query.descriptionMax)) return false;
|
|
|
+ if (!matchesRange(row.dimensions.specifications.score, query.specificationsMin, query.specificationsMax)) return false;
|
|
|
+ return !query.weakestDimension || row.weakestDimension === query.weakestDimension;
|
|
|
+}
|
|
|
+
|
|
|
+function categoryFacets(rows: ListingOverviewRow[]): ListingOverviewCategoryFacet[] {
|
|
|
+ const facets = new Map<string, ListingOverviewCategoryFacet>();
|
|
|
+ for (const row of rows) {
|
|
|
+ if (!row.categoryId) continue;
|
|
|
+ const existing = facets.get(row.categoryId);
|
|
|
+ if (existing) existing.count += 1;
|
|
|
+ else facets.set(row.categoryId, {
|
|
|
+ categoryId: row.categoryId,
|
|
|
+ categoryName: row.categoryName ?? row.categoryId,
|
|
|
+ categoryPath: row.categoryPath,
|
|
|
+ count: 1,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ return [...facets.values()].sort((left, right) => right.count - left.count || left.categoryId.localeCompare(right.categoryId));
|
|
|
+}
|
|
|
+
|
|
|
+function dimensionStats(rows: ListingOverviewRow[]): Record<ListingDimension, ListingOverviewDimensionStat> {
|
|
|
+ return Object.fromEntries(LISTING_OVERVIEW_DIMENSIONS.map((key) => {
|
|
|
+ const values = rows.map((row) => row.dimensions[key]).filter((value): value is ListingOverviewDimensionValue & { score: number; rate: number; gap: number } => value.score !== null && value.rate !== null && value.gap !== null);
|
|
|
+ return [key, {
|
|
|
+ key,
|
|
|
+ label: DIMENSION_LABELS[key],
|
|
|
+ maxScore: LISTING_DIMENSION_MAX[key],
|
|
|
+ scoredCount: values.length,
|
|
|
+ averageScore: values.length ? round(values.reduce((sum, value) => sum + value.score, 0) / values.length) : null,
|
|
|
+ averageRate: values.length ? round(values.reduce((sum, value) => sum + value.rate, 0) / values.length, 4) : null,
|
|
|
+ medianScore: median(values.map((value) => value.score)),
|
|
|
+ totalGap: values.length ? round(values.reduce((sum, value) => sum + value.gap, 0)) : null,
|
|
|
+ }];
|
|
|
+ })) as Record<ListingDimension, ListingOverviewDimensionStat>;
|
|
|
+}
|
|
|
+
|
|
|
+function sortValue(row: ListingOverviewRow, sort: ListingOverviewQuery['sort']): StableListingSortValue {
|
|
|
+ if (sort === 'productId') return row.productId;
|
|
|
+ if (sort === 'overallScore') return row.overallScore;
|
|
|
+ if (sort === 'improvementPotential') return row.improvementPotential;
|
|
|
+ if (sort === 'scoredAt') return row.scoredAt;
|
|
|
+ if (sort === 'syncedAt') return row.syncedAt;
|
|
|
+ return row.dimensions[sort].score;
|
|
|
+}
|
|
|
+
|
|
|
+export function queryListingOverview(input: {
|
|
|
+ sources: ListingSourceSnapshot[];
|
|
|
+ scores: ListingScoreResult[];
|
|
|
+ query: ListingOverviewQuery;
|
|
|
+ generatedAt: string;
|
|
|
+}): ListingOverviewResponse {
|
|
|
+ const scoresByProduct = new Map<string, ListingScoreResult[]>();
|
|
|
+ for (const score of input.scores) {
|
|
|
+ if (score.workspaceId !== input.query.workspaceId) continue;
|
|
|
+ const current = scoresByProduct.get(score.productId) ?? [];
|
|
|
+ current.push(score);
|
|
|
+ scoresByProduct.set(score.productId, current);
|
|
|
+ }
|
|
|
+ const sourceRows = input.sources
|
|
|
+ .filter((source) => source.workspaceId === input.query.workspaceId && source.platform === input.query.platform)
|
|
|
+ .map((source) => overviewRow(source, scoresByProduct.get(source.productId) ?? []));
|
|
|
+ const snapshotId = stableListingHash([...sourceRows].sort((left, right) => left.productId.localeCompare(right.productId)));
|
|
|
+ const normalizedSearch = input.query.search?.trim().toLocaleLowerCase() ?? '';
|
|
|
+ const matchedRows = sourceRows.filter((row) => matches(row, input.query, normalizedSearch));
|
|
|
+ const queryHash = stableListingHash({
|
|
|
+ workspaceId: input.query.workspaceId,
|
|
|
+ platform: input.query.platform,
|
|
|
+ search: normalizedSearch,
|
|
|
+ categoryIds: [...new Set(input.query.categoryIds ?? [])].sort(),
|
|
|
+ scoreNature: input.query.scoreNature,
|
|
|
+ minScore: input.query.minScore,
|
|
|
+ maxScore: input.query.maxScore,
|
|
|
+ titleMin: input.query.titleMin,
|
|
|
+ titleMax: input.query.titleMax,
|
|
|
+ sellingPointsMin: input.query.sellingPointsMin,
|
|
|
+ sellingPointsMax: input.query.sellingPointsMax,
|
|
|
+ imagesMin: input.query.imagesMin,
|
|
|
+ imagesMax: input.query.imagesMax,
|
|
|
+ descriptionMin: input.query.descriptionMin,
|
|
|
+ descriptionMax: input.query.descriptionMax,
|
|
|
+ specificationsMin: input.query.specificationsMin,
|
|
|
+ specificationsMax: input.query.specificationsMax,
|
|
|
+ weakestDimension: input.query.weakestDimension,
|
|
|
+ sort: input.query.sort,
|
|
|
+ direction: input.query.direction,
|
|
|
+ });
|
|
|
+ const page = stableListingPage({
|
|
|
+ items: matchedRows,
|
|
|
+ limit: input.query.limit,
|
|
|
+ cursor: input.query.cursor,
|
|
|
+ snapshotId,
|
|
|
+ queryHash,
|
|
|
+ sortKey: input.query.sort,
|
|
|
+ direction: input.query.direction,
|
|
|
+ productId: (row) => row.productId,
|
|
|
+ sortValue: (row) => sortValue(row, input.query.sort),
|
|
|
+ });
|
|
|
+ const numericScores = matchedRows.map((row) => row.overallScore).filter((value): value is number => value !== null);
|
|
|
+ const natureFacets = (Object.keys(SCORE_NATURE_LABELS) as ListingOverviewScoreNature[]).map((value) => ({
|
|
|
+ value,
|
|
|
+ label: SCORE_NATURE_LABELS[value],
|
|
|
+ count: sourceRows.filter((row) => row.scoreNature === value).length,
|
|
|
+ }));
|
|
|
+ return {
|
|
|
+ ...page,
|
|
|
+ summary: {
|
|
|
+ sourceTotal: sourceRows.length,
|
|
|
+ matchedTotal: matchedRows.length,
|
|
|
+ scoredTotal: numericScores.length,
|
|
|
+ simulationTotal: matchedRows.filter((row) => row.scoreNature === 'simulation').length,
|
|
|
+ averageScore: numericScores.length ? round(numericScores.reduce((sum, value) => sum + value, 0) / numericScores.length) : null,
|
|
|
+ medianScore: median(numericScores),
|
|
|
+ scoreDistribution: SCORE_BUCKETS.map((bucket) => ({
|
|
|
+ ...bucket,
|
|
|
+ count: numericScores.filter((score) => score >= bucket.minScore && (bucket.maxScoreExclusive ? score < bucket.maxScore : score <= bucket.maxScore)).length,
|
|
|
+ })),
|
|
|
+ dimensionStats: dimensionStats(matchedRows),
|
|
|
+ categoryFacets: categoryFacets(sourceRows),
|
|
|
+ scoreNatureFacets: natureFacets,
|
|
|
+ snapshotId,
|
|
|
+ generatedAt: input.generatedAt,
|
|
|
+ },
|
|
|
+ };
|
|
|
+}
|