"use client" import { useEffect, useState } from "react" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Loader2, DollarSign, RefreshCw, ChevronRight, ChevronDown } from "lucide-react" import { Button } from "@/components/ui/button" import { getFinancialData } from "@/lib/api" import { formatNumber, formatLargeNumber, formatDate } from "@/lib/formatters" import type { FinancialDataResponse } from "@/lib/types" interface BloombergViewProps { companyId: number onBack?: () => void selectedCurrency?: string userMarket?: string lastUpdate?: string } export function BloombergView({ companyId, onBack, selectedCurrency = "Auto", userMarket, lastUpdate }: BloombergViewProps) { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState("") const loadData = async () => { if (!companyId) return setLoading(true) setError("") try { console.log("Fetching Bloomberg data for company:", companyId) const result = await getFinancialData(companyId, "Bloomberg") console.log("Bloomberg data result:", result) setData(result) } catch (err: any) { console.error("Bloomberg fetch error:", err) setError(err.message || "加载 Bloomberg 数据失败") } finally { setLoading(false) } } useEffect(() => { loadData() }, [companyId, lastUpdate]) if (loading) { return (

正在加载 Bloomberg 原始数据...

) } if (error) { return (

{error}

) } if (!data) return null // 如果后端提供了统一数据字段,直接使用 const mergedData = data.unified_data || [] return (

财务概览

{/* Insert Inline Header Info here */}
) } // --------------------------------------------------------------------------- // Basic Info Header Component // --------------------------------------------------------------------------- function BasicInfoHeader({ data, selectedCurrency = "Auto", userMarket }: { data: any[], selectedCurrency?: string, userMarket?: string }) { if (!data || data.length === 0) return null // 1. Determine Target Currency (Logic shared with Table) let targetCurrency = selectedCurrency if (targetCurrency === "Auto" && userMarket) { const market = userMarket.toUpperCase() if (market.includes("JP")) targetCurrency = "JPY" else if (market.includes("VN")) targetCurrency = "VND" else if (market.includes("CH")) targetCurrency = "CNY" else if (market.includes("HK")) targetCurrency = "HKD" else targetCurrency = "USD" } // 2. Find Latest Valid Data for each field // We sort data by date descending to get latest first. // Note: 'end_date' is the reporting period, 'update_date' is when it was fetched. // Usually PE/PB/MarketCap are "current" values, so they might be associated with the latest reporting period or a special "current" row. // Based on bloomberg logic, they are saved as time series. We take the latest available. // Sort rows by date descending const sortedRows = [...data].sort((a, b) => new Date(b.end_date).getTime() - new Date(a.end_date).getTime()) // Helper to find first non-null value const findValue = (keys: string[]) => { for (const row of sortedRows) { // Check currency match if required (PE/PB/MarketCap are currency dependent usually, or at least MarketCap is) // However, PE/PB are ratios. // Strict currency check for everything to be safe? if (targetCurrency !== 'Auto') { if (row.currency && row.currency !== targetCurrency) continue } else { // If auto, try to match first row's currency if established, or just take any? // Let's stick to the targetCurrency logic used in Table which resolved 'Auto' to a specific one if possible. } for (const key of keys) { if (row[key] !== null && row[key] !== undefined && row[key] !== '') { return { value: row[key], row } } } } return null } // Extract Fields const companyName = findValue(['company_name'])?.value || '-' // Find latest update_date from ALL rows matching currency let maxUpdateDate = '' for (const row of data) { if (targetCurrency !== 'Auto' && row.currency && row.currency !== targetCurrency) continue if (row.update_date && row.update_date > maxUpdateDate) { maxUpdateDate = row.update_date } } const updateDate = maxUpdateDate // update_date is a timestamp string "2023-10-xx 10:00:00". User wants "Day only". const displayDate = updateDate ? formatDate(updateDate) : '-' const pe = findValue(['pe', 'pe_ratio'])?.value const pb = findValue(['pb', 'pb_ratio'])?.value const marketCapTuple = findValue(['market_cap']) const marketCap = marketCapTuple?.value const marketCapCurrency = marketCapTuple?.row?.currency || targetCurrency const abroadRev = findValue(['rev_abroad', 'pct_revenue_from_foreign_sources'])?.value const divYield = findValue(['dividend_yield', 'dividend_12_month_yield'])?.value const ipoDateRaw = findValue(['ipo_date', 'IPO_date', 'eqy_init_po_dt'])?.value const ipoDate = ipoDateRaw ? formatDate(ipoDateRaw) : '-' // Formatters const formatRatio = (val: any) => { if (val === undefined || val === null) return '-' const num = parseFloat(val) if (isNaN(num)) return '-' return num.toFixed(2) } const formatPercent = (val: any) => { if (val === undefined || val === null) return '-' const num = parseFloat(val) if (isNaN(num)) return '-' return num.toFixed(2) + '%' } const formatMoney = (val: any, currency: string) => { if (val === undefined || val === null) return '-' let num = parseFloat(val) if (isNaN(num)) return '-' // Backend now returns Market Cap in Millions for ALL currencies (via BDH). // To convert Millions to "Yi" (100 Million), we divide by 100. // This applies uniformly to JPY, USD, CNY, etc. num = num / 100 return num.toLocaleString('en-US', { maximumFractionDigits: 2 }) + ' 亿' } return (
{/* Group 1: Identity */}
{companyName}
IPO: {ipoDate} 更新: {displayDate}
{/* Group 2: Key Ratios */}
PE {formatRatio(pe)}
PB {formatRatio(pb)}
股息率 {formatPercent(divYield)}
{/* Group 3: Market Size & Biz */}
市值 ({marketCapCurrency}) {formatMoney(marketCap, marketCapCurrency)}
海外收入 {formatPercent(abroadRev)}
) } function RawDataTable({ data, title, selectedCurrency = "Auto", userMarket }: { data: any[], title: string, selectedCurrency?: string, userMarket?: string }) { if (!data || data.length === 0) { return ( {title}
暂无数据
) } // --------------------------------------------------------------------------- // 1. Filter Data Rows (Handle Multi-Currency) - DO THIS FIRST! // --------------------------------------------------------------------------- // Determine Target Currency Strategy let targetCurrency = selectedCurrency if (targetCurrency === "Auto" && userMarket) { const market = userMarket.toUpperCase() if (market.includes("JP")) targetCurrency = "JPY" else if (market.includes("VN")) targetCurrency = "VND" else if (market.includes("CH")) targetCurrency = "CNY" else if (market.includes("HK")) targetCurrency = "HKD" else targetCurrency = "USD" } const filteredRows = data.filter(row => { // Basic Validity Check if (!row || row.revenue === null || row.revenue === undefined) return false // Currency Filtering // Case 1: Specific Currency Selected (or resolved from Auto) if (targetCurrency !== "Auto") { if (row.currency && row.currency !== targetCurrency) { return false } } // Case 2: Pure Auto (Fallback if no userMarket) else if (selectedCurrency === "Auto") { if (data.length > 0 && data[0].currency && row.currency && row.currency !== data[0].currency) { return false } } return true }) // --------------------------------------------------------------------------- // 1.5 Enrich Data with Ratios (Expense / Revenue) // --------------------------------------------------------------------------- filteredRows.forEach(row => { const revenue = typeof row.revenue === 'string' ? parseFloat(row.revenue) : row.revenue if (revenue && revenue !== 0) { // Helper to calculate and assign if value exists const calcRatio = (key: string, targetKey: string) => { const val = typeof row[key] === 'string' ? parseFloat(row[key]) : row[key] if (val !== null && val !== undefined && !isNaN(val)) { row[targetKey] = (val / revenue) * 100 } } calcRatio('selling&marketing', 'selling&marketing_to_revenue') calcRatio('general&admin', 'general&admin_to_revenue') calcRatio('sg&a', 'sg&a_to_revenue') calcRatio('r&d', 'r&d_to_revenue') calcRatio('depreciation', 'depreciation_to_revenue') // Asset Ratios (Denominator: Total Assets) const calcAssetRatio = (key: string, targetKey: string) => { const totalAssets = typeof row['total_assets'] === 'string' ? parseFloat(row['total_assets']) : row['total_assets'] const val = typeof row[key] === 'string' ? parseFloat(row[key]) : row[key] if (totalAssets && totalAssets !== 0 && val !== null && val !== undefined && !isNaN(val)) { row[targetKey] = (val / totalAssets) * 100 } } calcAssetRatio('cash', 'cash_to_assets') calcAssetRatio('inventory', 'inventory_to_assets') calcAssetRatio('accounts¬es_receivable', 'receivables_to_assets') calcAssetRatio('prepaid', 'prepaid_to_assets') calcAssetRatio('property_plant&equipment', 'ppe_to_assets') calcAssetRatio('lt_investment', 'lt_investment_to_assets') calcAssetRatio('goodwill', 'goodwill_to_assets') calcAssetRatio('accounts_payable', 'payables_to_assets') calcAssetRatio('st_defer_rev', 'deferred_revenue_to_assets') calcAssetRatio('st_debt', 'st_debt_to_assets') calcAssetRatio('lt_debt', 'lt_debt_to_assets') // Calculate Other Assets Ratio // Formula: 100 - Cash% - Inventory% - Receivables% - Prepaid% - PP&E% - LT Investment% if (row['total_assets']) { const getR = (k: string) => (typeof row[k] === 'number' ? row[k] : 0) const sumKnown = getR('cash_to_assets') + getR('inventory_to_assets') + getR('receivables_to_assets') + getR('prepaid_to_assets') + getR('ppe_to_assets') + getR('lt_investment_to_assets') + getR('goodwill_to_assets') row['other_assets_ratio'] = 100 - sumKnown } // Calculate Other Expense Ratio // Formula: Gross Margin - Net Margin - SG&A% - R&D% // Note: All values are expected to be in Percentage (0-100) scale const getVal = (k: string) => { const v = row[k] if (v === null || v === undefined) return 0 const num = typeof v === 'string' ? parseFloat(v) : v return (typeof num === 'number' && !isNaN(num)) ? num : 0 } // Check if we have the necessary components to make a meaningful calculation // We need at least Gross Margin and Net Profit Margin to be present // Note: We check against null/undefined, but getVal handles parsing so we can be more lenient in check or rely on getVal result (though 0 might be valid) // It's safer to try calculating if keys exist if (row['gross_margin'] !== undefined && row['net_profit_margin'] !== undefined) { const gross = getVal('gross_margin') const net = getVal('net_profit_margin') const sga = getVal('sg&a_to_revenue') const rd = getVal('r&d_to_revenue') row['other_expense_ratio'] = gross - net - sga - rd // Calculate Breakdown Rows row['other_breakdown_gross'] = gross row['other_breakdown_net'] = -1 * net row['other_breakdown_sga'] = -1 * sga row['other_breakdown_rd'] = -1 * rd } // Calculate Per Employee Metrics (Unit: Wan) // Revenue input is in Millions (based on formatMoney logic / 100 -> Yi) // Metric = (Val * 1,000,000) / Employee / 10,000 // Metric = (Val * 100) / Employee const emp = getVal('num_of_employees') || getVal('employee') // handle both keys if present if (emp && emp > 0) { const rev = typeof row['revenue'] === 'string' ? parseFloat(row['revenue']) : row['revenue'] const net = typeof row['net_income'] === 'string' ? parseFloat(row['net_income']) : row['net_income'] if (rev !== undefined && !isNaN(rev)) { row['revenue_per_employee'] = (rev * 100) / emp } if (net !== undefined && !isNaN(net)) { row['profit_per_employee'] = (net * 100) / emp } } } }) // --------------------------------------------------------------------------- // 2. Extract Indicators (From Filtered Data ONLY) // --------------------------------------------------------------------------- // 提取所有指标键值 (排除元数据) const excludeKeys = ['id', 'company_code', 'code', 'symbol', 'market', 'update_date', 'create_time', 'end_date', 'ts_code'] // 从合并后的数据中收集所有可能的指标 const allIndicators = new Set() filteredRows.forEach(row => { Object.keys(row).forEach(k => { if (!excludeKeys.includes(k)) { allIndicators.add(k) } }) }) // 对指标进行逻辑排序/分组 const priorityOrder = [ 'currency', '__SECTION_MAIN__', 'roe', 'roce', 'roa', 'gross_margin', 'net_profit_margin', 'ebitda_margin', 'revenue', 'revenue_growth', 'net_income', 'netincome_growth', 'cash_from_operating', 'capital_expenditure', 'free_cash_flow', 'dividends_paid', 'dividend_payout_ratio', 'repurchase', 'total_assets', 'equity', '__SECTION_EXPENSE__', 'selling&marketing_to_revenue', 'selling&marketing', 'general&admin_to_revenue', 'general&admin', 'sg&a_to_revenue', 'sg&a', 'r&d_to_revenue', 'r&d', 'other_expense_ratio', 'other_breakdown_gross', 'other_breakdown_net', 'other_breakdown_sga', 'other_breakdown_rd', 'depreciation_to_revenue', 'depreciation', 'tax_rate', '__SECTION_ASSET__', 'cash_to_assets', 'cash', 'inventory_to_assets', 'inventory', 'receivables_to_assets', 'accounts¬es_receivable', 'prepaid_to_assets', 'prepaid', 'ppe_to_assets', 'property_plant&equipment', 'lt_investment_to_assets', 'lt_investment', 'goodwill_to_assets', 'goodwill', 'other_assets_ratio', 'payables_to_assets', 'accounts_payable', 'deferred_revenue_to_assets', 'st_defer_rev', 'st_debt_to_assets', 'st_debt', 'lt_debt_to_assets', 'lt_debt', 'total_debt_ratio', '__SECTION_TURNOVER__', 'inventory_days', 'days_sales_outstanding', 'payables_days', 'net_fixed_asset_turnover', 'asset_turnover', '__SECTION_EFFICIENCY__', 'employee', 'num_of_employees', 'revenue_per_employee', 'profit_per_employee', '__SECTION_MARKET__', 'last_price', 'market_cap', 'pe', 'pb', 'dividend_yield', 'shareholders' ] // Ensure section header is in the list to be sorted const sectionKeys = [ '__SECTION_MAIN__', '__SECTION_EXPENSE__', '__SECTION_ASSET__', '__SECTION_TURNOVER__', '__SECTION_EFFICIENCY__', '__SECTION_MARKET__', 'other_breakdown_gross', 'other_breakdown_net', 'other_breakdown_sga', 'other_breakdown_rd', ] sectionKeys.forEach(key => { if (!allIndicators.has(key)) { allIndicators.add(key) } }) const sortedIndicators = Array.from(allIndicators).sort((a, b) => { const idxA = priorityOrder.indexOf(a) const idxB = priorityOrder.indexOf(b) // Items in priority list come first, sorted by their order in the list if (idxA !== -1 && idxB !== -1) return idxA - idxB // Items in priority list come before items not in list if (idxA !== -1) return -1 if (idxB !== -1) return 1 // Remaining items sorted alphabetically return a.localeCompare(b) }) // --------------------------------------------------------------------------- // 1. Filter Data Rows (Handle Multi-Currency) // --------------------------------------------------------------------------- // We must filter BEFORE creating the Map, because multiple rows might exist // for the same date (e.g. 2023 JPY and 2023 USD). // The Map key is 'end_date', so it can only hold one currency's data per date. // --------------------------------------------------------------------------- // 2. Build Data Structures from Filtered Rows // --------------------------------------------------------------------------- // 构建查找表: Map const dataMap = new Map() filteredRows.forEach(row => { dataMap.set(row.end_date, row) }) // 提取日期列表 const dates = Array.from(new Set(filteredRows.map(row => row.end_date))) .sort((a, b) => new Date(b).getTime() - new Date(a).getTime()) // Tooltip State const [tooltip, setTooltip] = useState<{ show: boolean, x: number, y: number, text: string }>({ show: false, x: 0, y: 0, text: '' }) // Interactive Rows State const [expandedMetrics, setExpandedMetrics] = useState>(new Set()) const ratioToAbsMap: Record = { 'selling&marketing_to_revenue': 'selling&marketing', 'general&admin_to_revenue': 'general&admin', 'sg&a_to_revenue': 'sg&a', 'r&d_to_revenue': 'r&d', 'depreciation_to_revenue': 'depreciation', 'other_expense_ratio': ['other_breakdown_gross', 'other_breakdown_net', 'other_breakdown_sga', 'other_breakdown_rd'], // Asset Ratios 'cash_to_assets': 'cash', 'inventory_to_assets': 'inventory', 'receivables_to_assets': 'accounts¬es_receivable', 'prepaid_to_assets': 'prepaid', 'ppe_to_assets': 'property_plant&equipment', 'lt_investment_to_assets': 'lt_investment', 'goodwill_to_assets': 'goodwill', 'payables_to_assets': 'accounts_payable', 'deferred_revenue_to_assets': 'st_defer_rev', 'st_debt_to_assets': 'st_debt', 'lt_debt_to_assets': 'lt_debt', } const hiddenByDefault = new Set() Object.values(ratioToAbsMap).forEach(val => { if (Array.isArray(val)) { val.forEach(v => hiddenByDefault.add(v)) } else { hiddenByDefault.add(val) } }) // Ensure virtual keys are included in sortedIndicators // (Handled via sectionKeys logic above) const handleRowClick = (indicator: string) => { const target = ratioToAbsMap[indicator] if (!target) return setExpandedMetrics(prev => { const next = new Set(prev) const targets = Array.isArray(target) ? target : [target] // Toggle logic: If first target is present, remove all. Else add all. // This ensures consistent state even if desynced. const isExpanded = next.has(targets[0]) targets.forEach(t => { if (isExpanded) { next.delete(t) } else { next.add(t) } }) return next }) } const handleMouseMove = (e: React.MouseEvent, text: string) => { setTooltip({ show: true, x: e.clientX, y: e.clientY, text }) } const handleMouseLeave = () => { setTooltip(prev => ({ ...prev, show: false })) } // 获取第一行数据的货币单位 (假设一致) const currency = data[0]?.currency || 'CNY' return (
{tooltip.show && (
{tooltip.text}
)}
指标 / 报告期 (单位: 亿)
{dates.map(date => ( {formatDate(date)} ))}
{sortedIndicators.map(indicator => { if (indicator === '__SECTION_MAIN__') { return ( 主要指标 ) } if (indicator === '__SECTION_EXPENSE__') { return ( 费用指标 ) } if (indicator === '__SECTION_ASSET__') { return ( 资产结构 ) } if (indicator === '__SECTION_TURNOVER__') { return ( 周转能力 ) } if (indicator === '__SECTION_EFFICIENCY__') { return ( 人均效率 ) } if (indicator === '__SECTION_MARKET__') { return ( 市场表现 ) } // Check visibility const isHidden = hiddenByDefault.has(indicator) && !expandedMetrics.has(indicator) if (isHidden) return null const isTrigger = indicator in ratioToAbsMap // Check expansion state for array targets let isExpanded = false if (isTrigger) { const target = ratioToAbsMap[indicator] const firstTarget = Array.isArray(target) ? target[0] : target isExpanded = expandedMetrics.has(firstTarget) } const isSubRow = hiddenByDefault.has(indicator) const subRowClass = isSubRow ? "text-xs text-muted-foreground" : "" return ( !isTrigger && handleMouseMove(e, indicator)} onMouseLeave={handleMouseLeave} onClick={() => isTrigger && handleRowClick(indicator)} >
{isTrigger && ( isExpanded ? : )} {formatColumnName(indicator)}
{dates.map(date => { const row = dataMap.get(date) const value = row ? row[indicator] : null let highlightClass = '' let textStyle = '' if (value !== null && value !== undefined) { const numVal = typeof value === 'string' ? parseFloat(value) : value if (typeof numVal === 'number' && !isNaN(numVal)) { const assetRatios = [ 'cash_to_assets', 'inventory_to_assets', 'receivables_to_assets', 'prepaid_to_assets', 'ppe_to_assets', 'lt_investment_to_assets', 'payables_to_assets', 'deferred_revenue_to_assets', 'st_debt_to_assets', 'lt_debt_to_assets', 'other_assets_ratio', 'goodwill_to_assets' ] if (assetRatios.includes(indicator) && numVal > 30) { highlightClass = 'bg-red-100 dark:bg-red-900/40 font-bold' } else if (indicator === 'other_assets_ratio') highlightClass = 'bg-yellow-100 dark:bg-yellow-900/40' else if (indicator === 'roe' && numVal > 15) highlightClass = 'bg-green-100 dark:bg-green-900/40' else if (indicator === 'gross_margin' && numVal > 40) highlightClass = 'bg-green-100 dark:bg-green-900/40' else if (indicator === 'net_profit_margin' && numVal > 15) highlightClass = 'bg-green-100 dark:bg-green-900/40' else if (indicator === 'roce' && numVal > 15) highlightClass = 'bg-green-100 dark:bg-green-900/40' else if (indicator === 'roa' && numVal > 10) highlightClass = 'bg-green-100 dark:bg-green-900/40' else if (indicator === 'revenue_growth') { if (numVal > 15) highlightClass = 'bg-green-100 dark:bg-green-900/40' else if (numVal < 0) highlightClass = 'bg-red-100 dark:bg-red-900/40' } else if (indicator === 'netincome_growth') { if (numVal > 20) highlightClass = 'bg-green-100 dark:bg-green-900/40' else if (numVal < 0) highlightClass = 'bg-red-100 dark:bg-red-900/40' } // Dynamic text style based on value for growth rates if (['revenue_growth', 'netincome_growth'].includes(indicator)) { if (numVal < 0) { textStyle = 'text-red-600 italic font-bold dark:text-red-400' } else { textStyle = 'text-blue-600 italic dark:text-blue-400' } } } } // Fallback for text style if value is missing/invalid but indicator is growth rate if (textStyle === '' && ['revenue_growth', 'netincome_growth'].includes(indicator)) { textStyle = 'text-blue-600 italic dark:text-blue-400' } return ( {formatCellValue(indicator, value)} ) })}
) })}
) } // 复用之前的 formatting 逻辑 function formatColumnName(column: string): string { const nameMap: Record = { // --- 核心利润表 --- 'revenue': '营业收入', 'net_income': '净利润', 'operating_profit': '营业利润', 'gross_margin': '毛利率', 'net_profit_margin': '净利率', 'ebitda_margin': 'EBITDA利润率', 'sg&a': '销售管理费用(SG&A)', 'sg&a_to_revenue': '销售管理费用率', 'selling&marketing': '销售费用', 'selling&marketing_to_revenue': '销售费用率', 'general&admin': '管理费用', 'general&admin_to_revenue': '管理费用率', 'ga_exp': '管理费用', // Alias 'rd_exp': '研发费用', 'r&d': '研发费用', 'r&d_to_revenue': '研发费用率', 'other_expense_ratio': '其他费用率', 'other_breakdown_gross': '毛利率', 'other_breakdown_net': '(-) 净利率', 'other_breakdown_sga': '(-) 销售管理费用率(SG&A)', 'other_breakdown_rd': '(-) 研发费用率', 'depreciation': '折旧', 'depreciation_to_revenue': '折旧率', 'tax_rate': '有效税率', 'dividends': '分红', 'dividend_payout_ratio': '分红支付率', 'netincome_growth': '净利润增长率', 'revenue_growth': '营收增长率', // --- 资产负债表 --- 'total_assets': '总资产', 'total_liabilities': '总负债', 'shareholders_equity': '净资产', // Alias 'total_equity': '净资产', // Alias 'equity': '净资产', // Real Bloomberg Key 'cash': '现金', 'inventory': '存货', 'accounts¬es_receivable': '应收账款及票据', 'receivables': '应收账款', 'accounts_payable': '应付账款', 'prepayment': '预付款项', 'prepaid': '预付款项', 'fixed_assets': '固定资产', 'property_plant&equipment': '固定资产(PP&E)', 'long_term_investments': '长期投资', 'lt_investment': '长期投资', 'short_term_debt': '短期借款', 'st_debt': '短期借款', 'st_debt_to_assets': '短期借款占比', 'long_term_debt': '长期借款', 'lt_debt': '长期借款', 'lt_debt_to_assets': '长期借款占比', 'deferred_revenue': '递延收入', 'st_defer_rev': '短期递延收入', 'deferred_revenue_to_assets': '预收占比', 'goodwill': '商誉', 'goodwill_to_assets': '商誉占比', 'total_debt_ratio': '总债务比率', 'cash_to_assets': '现金占比', 'inventory_to_assets': '存货占比', 'receivables_to_assets': '应收占比', 'prepaid_to_assets': '预付占比', 'ppe_to_assets': '固定资产占比', 'lt_investment_to_assets': '长期投资占比', 'payables_to_assets': '应付占比', 'other_assets_ratio': '其他资产占比', // --- 现金流量表 --- 'cash_from_operating': '经营现金流', 'ocf': '经营现金流', // Alias 'capital_expenditure': '资本支出', 'capex': '资本支出', // Alias 'free_cash_flow': '自由现金流', 'fcf': '自由现金流', // Alias 'dividends_paid': '支付股息', 'repurchase': '股份回购', // --- 运营效率/周转 --- 'asset_turnover': '总资产周转率', 'net_fixed_asset_turnover': '固定资产周转率', // Correct Bloomberg Key 'net_fixed_asset_turn': '固定资产周转率', // Alias just in case 'inventory_days': '存货周转天数', 'invent_days': '存货周转天数', 'days_sales_outstanding': '应收账款周转天数', 'payables_days': '应付账款周转天数', 'employee': '员工人数', 'num_of_employees': '员工人数', 'revenue_per_employee': '人均创收(万)', 'profit_per_employee': '人均创利(万)', // --- 估值与市场 --- 'pe': '市盈率(PE)', 'pe_ratio': '市盈率(PE)', 'pb': '市净率(PB)', 'pb_ratio': '市净率(PB)', 'market_cap': '市值', 'price': '最新股价', 'last_price': '最新股价', 'roe': 'ROE', 'roa': 'ROA', 'roce': 'ROCE', 'dividend_yield': '股息率', 'shareholders': '股东人数', 'company_name': '公司名称', 'ipo_date': '上市日期', 'rev_abroad': '海外收入占比', 'currency': '货币', } return nameMap[column.toLowerCase()] || column } function formatCellValue(column: string, value: any): string { if (value === null || value === undefined) return '-' // 尝试解析文本数字 let numVal = value if (typeof value === 'string') { const parsed = parseFloat(value) if (!isNaN(parsed)) { numVal = parsed } } if (typeof numVal === 'number') { const lowerCol = column.toLowerCase() // Special scaling for tax_rate if (lowerCol === 'tax_rate') { numVal = numVal * 100 } const isRatio = ['roe', 'roce', 'roa', 'gross_margin', 'net_profit_margin', 'ebitda_margin', 'dividend_yield', 'rev_abroad', 'tax_rate', 'revenue_growth', 'netincome_growth', 'selling&marketing_to_revenue', 'general&admin_to_revenue', 'sg&a_to_revenue', 'r&d_to_revenue', 'depreciation_to_revenue', 'other_expense_ratio', 'other_breakdown_gross', 'other_breakdown_net', 'other_breakdown_sga', 'other_breakdown_rd', 'cash_to_assets', 'inventory_to_assets', 'receivables_to_assets', 'prepaid_to_assets', 'ppe_to_assets', 'lt_investment_to_assets', 'payables_to_assets', 'deferred_revenue_to_assets', 'st_debt_to_assets', 'lt_debt_to_assets', 'other_assets_ratio', 'goodwill_to_assets' ].includes(lowerCol) // Special handling for Per Employee (Just number formatting, 2 decimals) if (['revenue_per_employee', 'profit_per_employee'].includes(lowerCol)) { if (numVal === undefined || numVal === null || isNaN(numVal)) return '-' return numVal.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } // Scale huge monetary values to "Yi" (100 Million) // Bloomberg raw data is in Millions. // Millions -> Yi (100 Millions) = divide by 100. const isMoney = [ 'revenue', 'net_income', 'operating_profit', 'total_assets', 'total_liabilities', 'equity', 'shareholders_equity', 'total_equity', 'cash', 'inventory', 'accounts¬es_receivable', 'receivables', 'accounts_payable', 'prepaid', 'prepayment', 'property_plant&equipment', 'fixed_assets', 'lt_investment', 'long_term_investments', 'st_debt', 'short_term_debt', 'lt_debt', 'long_term_debt', 'st_defer_rev', 'deferred_revenue', 'goodwill', 'cash_from_operating', 'capital_expenditure', 'capex', 'free_cash_flow', 'fcf', 'dividends_paid', 'repurchase', 'sg&a', 'selling&marketing', 'general&admin', 'r&d', 'depreciation', 'market_cap' ].includes(lowerCol) if (isMoney) { numVal = numVal / 100 } if (isRatio) { return numVal.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + '%' } // 如果大于 1000,不保留小数;否则保留两位小数 const isLarge = Math.abs(numVal) > 1000 return numVal.toLocaleString('en-US', { minimumFractionDigits: isLarge ? 0 : 2, maximumFractionDigits: isLarge ? 0 : 2 }) } return String(value) }