@@ -18,19 +18,120 @@ import {
1818export { fieldToLabel , fmtNum , fmtQtyWithUnit , fmtFieldValue , fmtNumRaw }
1919
2020const CHART_REQUEST_RE = / 图 表 | 可 视 化 | 柱 状 图 | 条 形 图 | 折 线 图 | 饼 图 | 曲 线 图 | 趋 势 图 | 对 比 图 /
21-
22- function pickPreferredNumericField ( cols : string [ ] ) : string | undefined {
23- const preferred = [
21+ const COMPARISON_REQUEST_RE = / 对 比 | 比 较 | 比 一 比 | 和 .+ 比 | 相 比 | 相 较 | 排 名 | 排 行 | 名 次 | 领 先 | 落 后 | 高 低 | 头 部 | 梯 队 | 其 它 茶 | 其 他 茶 /
22+ const QUANTITY_REQUEST_RE = / 销 量 | 卖 了 多 少 | 卖 出 | 总 量 | 数 量 | 多 少 斤 | 多 少 两 | 多 少 饼 | 多 少 盒 | 多 少 提 | 多 少 件 | 动 销 | 出 货 量 /
23+ const ORDER_COUNT_REQUEST_RE = / 订 单 数 | 单 量 | 多 少 单 | 几 单 /
24+ const AMOUNT_REQUEST_RE = / 销 售 额 | 销 售 金 额 | 营 收 | 营 业 额 | 收 入 | 成 交 额 | 金 额 | g m v / i
25+ const DERIVED_CATEGORY_FIELD = '__viz_category__'
26+
27+ function pickPreferredNumericField ( cols : string [ ] , question = '' ) : string | undefined {
28+ const amountPreferred = [
29+ 'net_sales' , 'netSales' , 'sales' , 'revenue' , 'amount' , 'total_amount' , 'totalAmount' ,
30+ 'net_profit' , 'netProfit' , 'gross_profit' , 'grossProfit' , 'profit_rate' , 'profitRate' ,
31+ 'total_qty' , 'totalQty' , 'quantity' , 'available_qty' , 'availableQty' , 'stock_qty' , 'stockQty' ,
32+ 'order_count' , 'orderCount' , 'orders' , 'count' , 'cnt' ,
33+ ]
34+ const quantityPreferred = [
35+ 'total_qty' , 'totalQty' , 'quantity' , 'available_qty' , 'availableQty' , 'stock_qty' , 'stockQty' ,
36+ 'order_count' , 'orderCount' , 'orders' , 'count' , 'cnt' ,
37+ 'net_sales' , 'netSales' , 'sales' , 'revenue' , 'amount' , 'total_amount' , 'totalAmount' ,
38+ 'net_profit' , 'netProfit' , 'gross_profit' , 'grossProfit' , 'profit_rate' , 'profitRate' ,
39+ ]
40+ const orderPreferred = [
41+ 'order_count' , 'orderCount' , 'orders' , 'count' , 'cnt' ,
42+ 'total_qty' , 'totalQty' , 'quantity' ,
43+ 'net_sales' , 'netSales' , 'sales' , 'revenue' , 'amount' , 'total_amount' , 'totalAmount' ,
44+ 'net_profit' , 'netProfit' , 'gross_profit' , 'grossProfit' , 'profit_rate' , 'profitRate' ,
45+ ]
46+ const fallbackPreferred = [
2447 'net_sales' , 'netSales' , 'sales' , 'revenue' , 'amount' , 'total_amount' , 'totalAmount' ,
2548 'net_profit' , 'netProfit' , 'gross_profit' , 'grossProfit' , 'profit_rate' , 'profitRate' ,
2649 'exchange_amount' , 'exchangeAmount' , 'return_amount' , 'returnAmount' , 'refund_amount' , 'refundAmount' ,
2750 'quantity' , 'total_qty' , 'totalQty' , 'available_qty' , 'availableQty' , 'stock_qty' , 'stockQty' ,
2851 'order_count' , 'orderCount' , 'orders' , 'count' , 'cnt' ,
2952 ]
3053 const filtered = cols . filter ( ( field ) => ! NON_METRIC_NUMERIC_FIELDS . has ( field ) )
54+ if ( filtered . length === 0 ) return undefined
55+
56+ const normalizedQuestion = question . trim ( )
57+ const preferred = ORDER_COUNT_REQUEST_RE . test ( normalizedQuestion )
58+ ? orderPreferred
59+ : QUANTITY_REQUEST_RE . test ( normalizedQuestion ) && ! AMOUNT_REQUEST_RE . test ( normalizedQuestion )
60+ ? quantityPreferred
61+ : AMOUNT_REQUEST_RE . test ( normalizedQuestion )
62+ ? amountPreferred
63+ : fallbackPreferred
64+
3165 return preferred . find ( ( field ) => filtered . includes ( field ) ) ?? filtered [ 0 ]
3266}
3367
68+ function getTextValue ( row : Record < string , unknown > , fields : string [ ] ) {
69+ for ( const field of fields ) {
70+ const value = row [ field ]
71+ if ( typeof value === 'string' && value . trim ( ) ) return value . trim ( )
72+ }
73+
74+ return ''
75+ }
76+
77+ function getDuplicateProductNames ( rows : Record < string , unknown > [ ] ) {
78+ const countMap = new Map < string , number > ( )
79+
80+ rows . forEach ( ( row ) => {
81+ const productName = getTextValue ( row , [ 'product_name' , 'productName' , 'name' ] )
82+ if ( ! productName ) return
83+ countMap . set ( productName , ( countMap . get ( productName ) ?? 0 ) + 1 )
84+ } )
85+
86+ return new Set (
87+ [ ...countMap . entries ( ) ]
88+ . filter ( ( [ , count ] ) => count > 1 )
89+ . map ( ( [ name ] ) => name ) ,
90+ )
91+ }
92+
93+ function resolveCategoryField ( rows : Record < string , unknown > [ ] , stringCols : string [ ] ) {
94+ const preferredField = stringCols [ 0 ]
95+ if ( ! preferredField ) return undefined
96+
97+ if ( [ 'product_name' , 'productName' , 'name' ] . includes ( preferredField ) && getDuplicateProductNames ( rows ) . size > 0 ) {
98+ return DERIVED_CATEGORY_FIELD
99+ }
100+
101+ return preferredField
102+ }
103+
104+ function buildDerivedCategoryLabel ( row : Record < string , unknown > , duplicateProductNames : Set < string > ) {
105+ const productName = getTextValue ( row , [ 'product_name' , 'productName' , 'name' ] )
106+ if ( ! productName ) return '-'
107+ if ( ! duplicateProductNames . has ( productName ) ) return productName
108+
109+ const spec = getTextValue ( row , [ 'spec' ] )
110+ const year = row . year
111+ const yearText = typeof year === 'number' && Number . isFinite ( year )
112+ ? `${ year } 年`
113+ : ( typeof year === 'string' && year . trim ( ) ? `${ year . trim ( ) } 年` : '' )
114+ const extraParts = [ spec , yearText ] . filter ( Boolean )
115+
116+ return extraParts . length > 0 ? `${ productName } (${ extraParts . join ( '·' ) } )` : productName
117+ }
118+
119+ export function prepareVisualizationRows (
120+ rows : Record < string , unknown > [ ] ,
121+ xField : string ,
122+ yField ?: string ,
123+ ) {
124+ const duplicateProductNames = xField === DERIVED_CATEGORY_FIELD ? getDuplicateProductNames ( rows ) : new Set < string > ( )
125+
126+ return rows . map ( ( row ) => ( {
127+ ...row ,
128+ [ xField ] : xField === DERIVED_CATEGORY_FIELD
129+ ? buildDerivedCategoryLabel ( row , duplicateProductNames )
130+ : String ( row [ xField ] ?? '' ) ,
131+ ...( yField ? { [ yField ] : Number ( row [ yField ] ) || 0 } : { } ) ,
132+ } ) )
133+ }
134+
34135export function detectVisualization (
35136 rows : Record < string , unknown > [ ] ,
36137 question : string ,
@@ -60,47 +161,56 @@ export function detectVisualization(
60161
61162 const q = question
62163 const isChartRequest = CHART_REQUEST_RE . test ( q )
164+ const isComparison = COMPARISON_REQUEST_RE . test ( q ) && rows . length > 1
63165 const isProportion = / 占 比 | 百 分 | 比 例 | 构 成 | 分 布 / . test ( q ) && rows . length <= 10
64166 const isTrend = / 趋 势 | 走 势 | 变 化 | 按 月 | 每 月 | 每 周 | 按 日 | 每 天 | 月 份 / . test ( q ) &&
65167 ( dateCols . length > 0 || stringCols . length > 0 )
66168 const hasDetailIdentityField = DETAIL_RECORD_FIELDS . some ( ( field ) => cols . includes ( field ) )
67- const hasPreferredMetric = Boolean ( pickPreferredNumericField ( numericCols ) )
169+ const preferredMetric = pickPreferredNumericField ( numericCols , q )
170+ const hasPreferredMetric = Boolean ( preferredMetric )
171+
172+ if ( isComparison && preferredMetric ) {
173+ const xField = resolveCategoryField ( rows , stringCols )
174+ if ( xField ) {
175+ return { type : 'bar' , xField, yField : preferredMetric }
176+ }
177+ }
68178
69179 if ( hasDetailIdentityField && ! isChartRequest ) {
70180 return { type : 'table' }
71181 }
72182
73183 if ( isChartRequest && hasPreferredMetric ) {
74- const preferredYField = pickPreferredNumericField ( numericCols )
184+ const preferredYField = preferredMetric
75185
76186 if ( isProportion && stringCols . length >= 1 && preferredYField ) {
77- return { type : 'pie' , nameField : stringCols [ 0 ] , valueField : preferredYField }
187+ return { type : 'pie' , nameField : resolveCategoryField ( rows , stringCols ) ?? stringCols [ 0 ] , valueField : preferredYField }
78188 }
79189
80- const xField = dateCols [ 0 ] || stringCols [ 0 ]
190+ const xField = dateCols [ 0 ] || resolveCategoryField ( rows , stringCols )
81191 if ( xField && preferredYField ) {
82192 return { type : dateCols . length > 0 ? 'line' : 'bar' , xField, yField : preferredYField }
83193 }
84194 }
85195
86196 if ( isProportion && hasPreferredMetric && stringCols . length >= 1 ) {
87- const valueField = pickPreferredNumericField ( numericCols )
197+ const valueField = preferredMetric
88198 if ( valueField ) return { type : 'pie' , nameField : stringCols [ 0 ] , valueField }
89199 }
90200
91201 if ( isTrend && hasPreferredMetric ) {
92- const xField = dateCols [ 0 ] || stringCols [ 0 ]
93- const yField = pickPreferredNumericField ( numericCols )
202+ const xField = dateCols [ 0 ] || resolveCategoryField ( rows , stringCols )
203+ const yField = preferredMetric
94204 if ( xField && yField ) return { type : 'line' , xField, yField }
95205 }
96206
97207 if ( stringCols . length >= 1 && hasPreferredMetric && rows . length <= 20 ) {
98- const yField = pickPreferredNumericField ( numericCols )
99- if ( yField ) return { type : 'bar' , xField : stringCols [ 0 ] , yField }
208+ const yField = preferredMetric
209+ if ( yField ) return { type : 'bar' , xField : resolveCategoryField ( rows , stringCols ) ?? stringCols [ 0 ] , yField }
100210 }
101211
102212 if ( dateCols . length >= 1 && hasPreferredMetric ) {
103- const yField = pickPreferredNumericField ( numericCols )
213+ const yField = preferredMetric
104214 if ( yField ) return { type : 'line' , xField : dateCols [ 0 ] , yField }
105215 }
106216
0 commit comments