diff --git a/backend/app/data_providers/base.py b/backend/app/data_providers/base.py index 97336d5..aa4b019 100644 --- a/backend/app/data_providers/base.py +++ b/backend/app/data_providers/base.py @@ -45,16 +45,20 @@ class BaseDataProvider(ABC): pass @abstractmethod - async def get_financial_statements(self, stock_code: str, report_dates: List[str]) -> List[Dict[str, Any]]: + async def get_financial_statements(self, stock_code: str, report_dates: List[str]) -> Dict[str, List[Dict[str, Any]]]: """ - Fetches financial statements for a list of report dates. + Fetches financial statements for a list of report dates and returns them + in a series format. - This method should aim to fetch data for all requested dates in a single call if possible - and then combine them into a unified format. + The series format is a dictionary where keys are metric names (e.g., 'revenue') + and values are a list of data points over time. + e.g., {"revenue": [{"year": "2023", "value": 1000}, ...]} + + Providers should also calculate derived metrics if they are not directly available. :param stock_code: The stock identifier. :param report_dates: A list of report dates to fetch data for (e.g., ['20231231', '20221231']). - :return: A list of dictionaries, each containing financial statement data for a specific period. + :return: A dictionary in series format. """ pass @@ -63,9 +67,22 @@ class BaseDataProvider(ABC): Fetches a single financial statement for a specific report date. This is a convenience method that can be implemented by calling get_financial_statements. + Note: The return value of this function is a single report (dictionary), + not a series object. This is for compatibility with parts of the code + that need a single flat report. + :param stock_code: The stock identifier. :param report_date: The report date for the statement (e.g., '20231231'). :return: A dictionary with financial statement data, or None if not found. """ - results = await self.get_financial_statements(stock_code, [report_date]) - return results[0] if results else None + series_data = await self.get_financial_statements(stock_code, [report_date]) + if not series_data: + return None + + report: Dict[str, Any] = {"ts_code": stock_code, "end_date": report_date} + for metric, points in series_data.items(): + for point in points: + if point.get("year") == report_date[:4]: + report[metric] = point.get("value") + break + return report diff --git a/backend/app/data_providers/tushare.py b/backend/app/data_providers/tushare.py index 79111c0..49644f7 100644 --- a/backend/app/data_providers/tushare.py +++ b/backend/app/data_providers/tushare.py @@ -87,12 +87,225 @@ class TushareProvider(BaseDataProvider): logger.error(f"Tushare get_daily_price failed for {stock_code}: {e}") return [] - async def get_financial_statements(self, stock_code: str, report_dates: List[str]) -> List[Dict[str, Any]]: + async def get_daily_basic_points(self, stock_code: str, trade_dates: List[str]) -> List[Dict[str, Any]]: + """ + 获取指定交易日列表的 daily_basic 数据(例如 total_mv、pe、pb)。 + """ + try: + tasks = [ + self._query( + api_name="daily_basic", + params={"ts_code": stock_code, "trade_date": d}, + ) for d in trade_dates + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + rows: List[Dict[str, Any]] = [] + for res in results: + if isinstance(res, list) and res: + rows.extend(res) + logger.info(f"Tushare daily_basic returned {len(rows)} rows for {stock_code} on {len(trade_dates)} dates") + return rows + except Exception as e: + logger.error(f"Tushare get_daily_basic_points failed for {stock_code}: {e}") + return [] + + async def get_daily_points(self, stock_code: str, trade_dates: List[str]) -> List[Dict[str, Any]]: + """ + 获取指定交易日列表的日行情(例如 close)。 + """ + try: + tasks = [ + self._query( + api_name="daily", + params={"ts_code": stock_code, "trade_date": d}, + ) for d in trade_dates + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + rows: List[Dict[str, Any]] = [] + for res in results: + if isinstance(res, list) and res: + rows.extend(res) + logger.info(f"Tushare daily returned {len(rows)} rows for {stock_code} on {len(trade_dates)} dates") + return rows + except Exception as e: + logger.error(f"Tushare get_daily_points failed for {stock_code}: {e}") + return [] + + def _calculate_derived_metrics(self, series: Dict[str, List[Dict]], years: List[str]) -> Dict[str, List[Dict]]: + """ + 在 Tushare provider 内部计算派生指标。 + """ + # --- Helper Functions --- + def _get_value(key: str, year: str) -> Optional[float]: + if key not in series: + return None + point = next((p for p in series[key] if p.get("year") == year), None) + if point is None or point.get("value") is None: + return None + try: + return float(point["value"]) + except (ValueError, TypeError): + return None + + def _get_avg_value(key: str, year: str) -> Optional[float]: + current_val = _get_value(key, year) + try: + prev_year = str(int(year) - 1) + prev_val = _get_value(key, prev_year) + except (ValueError, TypeError): + prev_val = None + if current_val is None: return None + if prev_val is None: return current_val + return (current_val + prev_val) / 2 + + def _get_cogs(year: str) -> Optional[float]: + revenue = _get_value('revenue', year) + gp_margin_raw = _get_value('grossprofit_margin', year) + if revenue is None or gp_margin_raw is None: return None + gp_margin = gp_margin_raw / 100.0 if abs(gp_margin_raw) > 1 else gp_margin_raw + return revenue * (1 - gp_margin) + + def add_series(key: str, data: List[Dict]): + if data: + series[key] = data + + # --- Calculations --- + fcf_data = [] + for year in years: + op_cashflow = _get_value('n_cashflow_act', year) + capex = _get_value('c_pay_acq_const_fiolta', year) + if op_cashflow is not None and capex is not None: + fcf_data.append({"year": year, "value": op_cashflow - capex}) + add_series('__free_cash_flow', fcf_data) + + fee_calcs = [ + ('__sell_rate', 'sell_exp', 'revenue'), + ('__admin_rate', 'admin_exp', 'revenue'), + ('__rd_rate', 'rd_exp', 'revenue'), + ('__depr_ratio', 'depr_fa_coga_dpba', 'revenue'), + ] + for key, num_key, den_key in fee_calcs: + data = [] + for year in years: + numerator = _get_value(num_key, year) + denominator = _get_value(den_key, year) + if numerator is not None and denominator is not None and denominator != 0: + data.append({"year": year, "value": (numerator / denominator) * 100}) + add_series(key, data) + + tax_rate_data = [] + for year in years: + tax_to_ebt = _get_value('tax_to_ebt', year) + if tax_to_ebt is not None: + rate = tax_to_ebt * 100 if abs(tax_to_ebt) <= 1 else tax_to_ebt + tax_rate_data.append({"year": year, "value": rate}) + add_series('__tax_rate', tax_rate_data) + + other_fee_data = [] + for year in years: + gp_raw = _get_value('grossprofit_margin', year) + np_raw = _get_value('netprofit_margin', year) + rev = _get_value('revenue', year) + sell_exp = _get_value('sell_exp', year) + admin_exp = _get_value('admin_exp', year) + rd_exp = _get_value('rd_exp', year) + if all(v is not None for v in [gp_raw, np_raw, rev, sell_exp, admin_exp, rd_exp]) and rev != 0: + gp = gp_raw / 100 if abs(gp_raw) > 1 else gp_raw + np = np_raw / 100 if abs(np_raw) > 1 else np_raw + sell_rate = sell_exp / rev + admin_rate = admin_exp / rev + rd_rate = rd_exp / rev + other_rate = (gp - np - sell_rate - admin_rate - rd_rate) * 100 + other_fee_data.append({"year": year, "value": other_rate}) + add_series('__other_fee_rate', other_fee_data) + + asset_ratio_keys = [ + ('__money_cap_ratio', 'money_cap'), ('__inventories_ratio', 'inventories'), + ('__ar_ratio', 'accounts_receiv_bill'), ('__prepay_ratio', 'prepayment'), + ('__fix_assets_ratio', 'fix_assets'), ('__lt_invest_ratio', 'lt_eqt_invest'), + ('__goodwill_ratio', 'goodwill'), ('__ap_ratio', 'accounts_pay'), + ('__st_borr_ratio', 'st_borr'), ('__lt_borr_ratio', 'lt_borr'), + ] + for key, num_key in asset_ratio_keys: + data = [] + for year in years: + numerator = _get_value(num_key, year) + denominator = _get_value('total_assets', year) + if numerator is not None and denominator is not None and denominator != 0: + data.append({"year": year, "value": (numerator / denominator) * 100}) + add_series(key, data) + + adv_data = [] + for year in years: + adv = _get_value('adv_receipts', year) or 0 + contract = _get_value('contract_liab', year) or 0 + total_assets = _get_value('total_assets', year) + if total_assets is not None and total_assets != 0: + adv_data.append({"year": year, "value": ((adv + contract) / total_assets) * 100}) + add_series('__adv_ratio', adv_data) + + other_assets_data = [] + known_assets_keys = ['money_cap', 'inventories', 'accounts_receiv_bill', 'prepayment', 'fix_assets', 'lt_eqt_invest', 'goodwill'] + for year in years: + total_assets = _get_value('total_assets', year) + if total_assets is not None and total_assets != 0: + sum_known = sum(_get_value(k, year) or 0 for k in known_assets_keys) + other_assets_data.append({"year": year, "value": ((total_assets - sum_known) / total_assets) * 100}) + add_series('__other_assets_ratio', other_assets_data) + + op_assets_data = [] + for year in years: + total_assets = _get_value('total_assets', year) + if total_assets is not None and total_assets != 0: + inv = _get_value('inventories', year) or 0 + ar = _get_value('accounts_receiv_bill', year) or 0 + pre = _get_value('prepayment', year) or 0 + ap = _get_value('accounts_pay', year) or 0 + adv = _get_value('adv_receipts', year) or 0 + contract_liab = _get_value('contract_liab', year) or 0 + operating_assets = inv + ar + pre - ap - adv - contract_liab + op_assets_data.append({"year": year, "value": (operating_assets / total_assets) * 100}) + add_series('__operating_assets_ratio', op_assets_data) + + debt_ratio_data = [] + for year in years: + total_assets = _get_value('total_assets', year) + if total_assets is not None and total_assets != 0: + st_borr = _get_value('st_borr', year) or 0 + lt_borr = _get_value('lt_borr', year) or 0 + debt_ratio_data.append({"year": year, "value": ((st_borr + lt_borr) / total_assets) * 100}) + add_series('__interest_bearing_debt_ratio', debt_ratio_data) + + payturn_data = [] + for year in years: + avg_ap = _get_avg_value('accounts_pay', year) + cogs = _get_cogs(year) + if avg_ap is not None and cogs is not None and cogs != 0: + payturn_data.append({"year": year, "value": (365 * avg_ap) / cogs}) + add_series('payturn_days', payturn_data) + + per_capita_calcs = [ + ('__rev_per_emp', 'revenue', 10000), + ('__profit_per_emp', 'n_income', 10000), + ('__salary_per_emp', 'c_paid_to_for_empl', 10000), + ] + for key, num_key, divisor in per_capita_calcs: + data = [] + for year in years: + numerator = _get_value(num_key, year) + employees = _get_value('employees', year) + if numerator is not None and employees is not None and employees != 0: + data.append({"year": year, "value": (numerator / employees) / divisor}) + add_series(key, data) + + return series + + async def get_financial_statements(self, stock_code: str, report_dates: List[str]) -> Dict[str, List[Dict[str, Any]]]: all_statements: List[Dict[str, Any]] = [] for date in report_dates: logger.info(f"Fetching financial statements for {stock_code}, report date: {date}") try: - bs_rows, ic_rows, cf_rows = await asyncio.gather( + bs_rows, ic_rows, cf_rows, fi_rows = await asyncio.gather( self._query( api_name="balancesheet", params={"ts_code": stock_code, "period": date, "report_type": 1}, @@ -104,10 +317,15 @@ class TushareProvider(BaseDataProvider): self._query( api_name="cashflow", params={"ts_code": stock_code, "period": date, "report_type": 1}, - ) + ), + # 补充关键财务比率(ROE/ROA/毛利率等) + self._query( + api_name="fina_indicator", + params={"ts_code": stock_code, "period": date}, + ), ) - if not bs_rows and not ic_rows and not cf_rows: + if not bs_rows and not ic_rows and not cf_rows and not fi_rows: logger.warning(f"No financial statements components found from Tushare for {stock_code} on {date}") continue @@ -115,10 +333,12 @@ class TushareProvider(BaseDataProvider): bs_data = bs_rows[0] if bs_rows else {} ic_data = ic_rows[0] if ic_rows else {} cf_data = cf_rows[0] if cf_rows else {} + fi_data = fi_rows[0] if fi_rows else {} merged.update(bs_data) merged.update(ic_data) merged.update(cf_data) + merged.update(fi_data) merged["end_date"] = merged.get("end_date") or merged.get("period") or date logger.debug(f"Merged statement for {date} has keys: {list(merged.keys())}") @@ -129,4 +349,24 @@ class TushareProvider(BaseDataProvider): continue logger.info(f"Successfully fetched {len(all_statements)} statement(s) for {stock_code}.") - return all_statements + + # Transform to series format + series: Dict[str, List[Dict]] = {} + if all_statements: + for report in all_statements: + year = report.get("end_date", "")[:4] + if not year: continue + for key, value in report.items(): + if key in ['ts_code', 'end_date', 'ann_date', 'f_ann_date', 'report_type', 'comp_type', 'end_type', 'update_flag', 'period']: + continue + if isinstance(value, (int, float)) and value is not None: + if key not in series: + series[key] = [] + if not any(d['year'] == year for d in series[key]): + series[key].append({"year": year, "value": value}) + + # Calculate derived metrics + years = sorted(list(set(d['year'] for s in series.values() for d in s))) + series = self._calculate_derived_metrics(series, years) + + return series diff --git a/backend/app/routers/financial.py b/backend/app/routers/financial.py index 440dfa9..1582493 100644 --- a/backend/app/routers/financial.py +++ b/backend/app/routers/financial.py @@ -22,6 +22,7 @@ from app.schemas.financial import ( ) from app.services.company_profile_client import CompanyProfileClient from app.services.analysis_client import AnalysisClient, load_analysis_config, get_analysis_config +from app.services.financial_calculator import calculate_derived_metrics # Lazy DataManager loader to avoid import-time failures when optional providers/config are missing _dm = None @@ -293,36 +294,67 @@ async def get_china_financials( step_financials = StepRecord(name="拉取财务报表", start_ts=started_real.isoformat(), status="running") steps.append(step_financials) - all_financial_data = await get_dm().get_financial_statements(stock_code=ts_code, report_dates=report_dates) + # Fetch all financial statements at once (already in series format from provider) + series = await get_dm().get_financial_statements(stock_code=ts_code, report_dates=report_dates) - if all_financial_data: - # Process financial data into the 'series' format - for report in all_financial_data: - year = report.get("end_date", "")[:4] - for key, value in report.items(): - # Skip non-numeric fields like ts_code, end_date, ann_date, etc. - if key in ['ts_code', 'end_date', 'ann_date', 'f_ann_date', 'report_type', 'comp_type', 'end_type', 'update_flag']: - continue - - # Only include numeric values - if isinstance(value, (int, float)) and value is not None: - if key not in series: - series[key] = [] - - # Avoid duplicates for the same year - if not any(d['year'] == year for d in series[key]): - series[key].append({"year": year, "value": value}) - else: + if not series: errors["financial_statements"] = "Failed to fetch from all providers." step_financials.status = "done" step_financials.end_ts = datetime.now(timezone.utc).isoformat() step_financials.duration_ms = int((time.perf_counter_ns() - started) / 1_000_000) - # --- Potentially fetch other data types like daily prices if needed by config --- - # This part is simplified. The original code had complex logic for different api_groups. - # We will assume for now that the main data comes from financial_statements. - # The logic can be extended here to call other data_manager methods based on `fin_cfg`. + # --- 拉取市值/估值(daily_basic)与股价(daily)按年度末日期 --- + try: + # 仅当配置包含相应分组时再尝试拉取 + has_daily_basic = bool(api_groups.get("daily_basic")) + has_daily = bool(api_groups.get("daily")) + + if has_daily_basic or has_daily: + step_market = StepRecord(name="拉取市值与股价", start_ts=datetime.now(timezone.utc).isoformat(), status="running") + steps.append(step_market) + + try: + if has_daily_basic: + db_rows = await get_dm().get_data('get_daily_basic_points', stock_code=ts_code, trade_dates=report_dates) + if isinstance(db_rows, list): + for row in db_rows: + trade_date = row.get('trade_date') or row.get('trade_dt') or row.get('date') + if not trade_date: + continue + year = str(trade_date)[:4] + for key, value in row.items(): + if key in ['ts_code', 'trade_date', 'trade_dt', 'date']: + continue + if isinstance(value, (int, float)) and value is not None: + if key not in series: + series[key] = [] + if not any(d['year'] == year for d in series[key]): + series[key].append({"year": year, "value": value}) + if has_daily: + d_rows = await get_dm().get_data('get_daily_points', stock_code=ts_code, trade_dates=report_dates) + if isinstance(d_rows, list): + for row in d_rows: + trade_date = row.get('trade_date') or row.get('trade_dt') or row.get('date') + if not trade_date: + continue + year = str(trade_date)[:4] + for key, value in row.items(): + if key in ['ts_code', 'trade_date', 'trade_dt', 'date']: + continue + if isinstance(value, (int, float)) and value is not None: + if key not in series: + series[key] = [] + if not any(d['year'] == year for d in series[key]): + series[key].append({"year": year, "value": value}) + except Exception as e: + errors["market_data"] = f"Failed to fetch market data: {e}" + finally: + step_market.status = "done" + step_market.end_ts = datetime.now(timezone.utc).isoformat() + step_market.duration_ms = int((time.perf_counter_ns() - started) / 1_000_000) + except Exception as e: + errors["market_data_init"] = f"Market data init failed: {e}" finished_real = datetime.now(timezone.utc) elapsed_ms = int((time.perf_counter_ns() - started) / 1_000_000) @@ -339,6 +371,9 @@ async def get_china_financials( arr_sorted = sorted(arr_limited, key=lambda x: x["year"]) series[key] = arr_sorted + # Calculate derived financial metrics + series = calculate_derived_metrics(series, years_list) + meta = FinancialMeta( started_at=started_real.isoformat(), finished_at=finished_real.isoformat(), @@ -523,15 +558,16 @@ async def generate_analysis( # Try to get financial data for context try: - # A simplified approach to get the latest year's financial data + # A simplified approach to get a single financial report current_year = datetime.now().year report_dates = [f"{current_year-1}1231"] # Get last year's report - latest_financials = await get_dm().get_financial_statements( + # Use get_financial_statement which is designed to return a single flat report + latest_financials_report = await get_dm().get_financial_statement( stock_code=ts_code, - report_dates=report_dates + report_dates=report_dates[0] ) - if latest_financials: - financial_data = {"series": latest_financials[0]} + if latest_financials_report: + financial_data = {"series": latest_financials_report} except Exception as e: logger.warning(f"[API] Failed to get financial data: {e}") financial_data = None diff --git a/backend/requirements.txt b/backend/requirements.txt index 483cd49..4e4e265 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,7 +7,7 @@ aiosqlite==0.20.0 alembic==1.13.3 openai==1.37.0 asyncpg -greenlet==3.0.3 +greenlet>=3.1.0 # Data Providers tushare==1.4.1 diff --git a/docs/data_provider_interface.md b/docs/data_provider_interface.md new file mode 100644 index 0000000..e087db7 --- /dev/null +++ b/docs/data_provider_interface.md @@ -0,0 +1,116 @@ +# DataProvider 接口规范 + +本文档定义了 `BaseDataProvider` 抽象基类的接口规范。所有用于获取金融数据的数据提供商都必须继承此类并实现其定义的所有抽象方法。 + +## 设计哲学 + +`BaseDataProvider` 的设计旨在创建一个统一、标准化的接口,用于从各种不同的外部数据源(如 Tushare, iFind, yfinance, Finnhub 等)获取金融数据。通过这种方式,上层服务(如 `DataManager`)可以以一种与具体数据源无关的方式来请求数据,从而实现了系统核心逻辑与数据源的解耦。 + +这种设计带来了以下好处: +- **可扩展性**: 添加新的数据源变得简单,只需创建一个新的类继承 `BaseDataProvider` 并实现其接口即可,无需改动现有核心逻辑。 +- **健壮性**: `DataManager` 可以根据配置实现数据源的优先级和故障转移(Fallback),当一个数据源不可用时,可以无缝切换到备用数据源。 +- **一致性**: 所有数据提供商返回的数据格式都是标准化的,简化了上层服务的数据处理逻辑。 + +## 接口定义 (`BaseDataProvider`) + +### 1. `get_stock_basic` + +- **目的**: 获取单只股票的基本信息。 +- **方法签名**: `async def get_stock_basic(self, stock_code: str) -> Optional[Dict[str, Any]]` +- **参数**: + - `stock_code` (str): 股票的唯一代码。代码应尽量使用数据源通用的格式(例如 A 股的 `000001.SZ`)。 +- **返回值**: + - 一个包含股票基本信息的字典 (`Dict`),例如公司名称、上市日期、行业等。 + - 如果未找到该股票,则返回 `None`。 +- **示例**: + ```json + { + "ts_code": "000001.SZ", + "name": "平安银行", + "area": "深圳", + "industry": "银行", + "list_date": "19910403" + } + ``` + +### 2. `get_daily_price` + +- **目的**: 获取指定时间范围内的每日股价行情数据。 +- **方法签名**: `async def get_daily_price(self, stock_code: str, start_date: str, end_date: str) -> List[Dict[str, Any]]` +- **参数**: + - `stock_code` (str): 股票代码。 + - `start_date` (str): 开始日期,格式为 'YYYYMMDD'。 + - `end_date` (str): 结束日期,格式为 'YYYYMMDD'。 +- **返回值**: + - 一个列表 (`List`),其中每个元素是一个字典,代表一天的行情数据。 + - 如果没有数据,则返回一个空列表 `[]`。 +- **示例**: + ```json + [ + { + "trade_date": "20231229", + "open": 10.5, + "high": 10.6, + "low": 10.4, + "close": 10.55, + "vol": 1234567.0 + }, + ... + ] + ``` + +### 3. `get_financial_statements` + +- **目的**: 获取多年的财务报表数据,并将其处理成标准化的 **时间序列 (Series)** 格式。这是最核心也是最复杂的方法。 +- **方法签名**: `async def get_financial_statements(self, stock_code: str, report_dates: List[str]) -> Dict[str, List[Dict[str, Any]]]` +- **参数**: + - `stock_code` (str): 股票代码。 + - `report_dates` (List[str]): 财报报告期列表,格式为 `['YYYYMMDD', ...]`。通常使用年末的日期,如 `['20221231', '20211231']`。 +- **返回值**: + - 一个时间序列格式的字典。该字典的键 (key) 是财务指标的名称(如 `revenue`, `n_income`),值 (value) 是一个列表,列表中的每个元素代表该指标在一个年份的数据点。 + - 如果无法获取任何数据,应返回一个空字典 `{}`。 +- **关键要求**: + 1. **数据合并**: 数据提供商内部需要调用多个API(如利润表、资产负债表、现金流量表、财务指标等)来获取所有需要的原始指标,并将它们合并。 + 2. **格式转换**: 必须将合并后的年度报表数据转换为标准的时间序列格式。 + 3. **衍生指标计算**: **数据提供商必须负责计算所有派生的财务指标**。如果某些指标(如自由现金流、各种费用率、资产占比等)无法从API直接获取,提供商需要在内部完成计算,并将计算结果一同放入返回的时间序列对象中。这确保了无论数据源如何,返回给上层服务的数据都是完整且可以直接使用的。 +- **示例**: + ```json + { + "revenue": [ + { "year": "2021", "value": 100000000 }, + { "year": "2022", "value": 120000000 } + ], + "n_income": [ + { "year": "2021", "value": 10000000 }, + { "year": "2022", "value": 12000000 } + ], + "__free_cash_flow": [ + { "year": "2021", "value": 8000000 }, + { "year": "2022", "value": 9500000 } + ], + "__sell_rate": [ + { "year": "2021", "value": 15.5 }, + { "year": "2222", "value": 16.2 } + ] + } + ``` + +### 4. `get_financial_statement` (辅助方法) + +- **目的**: 这是一个便利的辅助方法,用于获取单份、扁平化的财务报告。它主要用于需要单点数据的场景,以保持向后兼容性。 +- **方法签名**: `async def get_financial_statement(self, stock_code: str, report_date: str) -> Optional[Dict[str, Any]]` +- **实现**: 此方法通常通过调用 `get_financial_statements` 并从返回的时间序列数据中重构出单份报告来实现。基类中已提供了默认实现,通常无需重写。 +- **返回值**: + - 一个扁平化的字典,包含了指定报告期的所有财务指标。 + - 如果没有数据,则返回 `None`。 +- **示例**: + ```json + { + "ts_code": "000001.SZ", + "end_date": "20221231", + "revenue": 120000000, + "n_income": 12000000, + "__free_cash_flow": 9500000, + ... + } + ``` diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b45a1ae..7218f0f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2239,6 +2239,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2249,6 +2250,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2311,6 +2313,7 @@ "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", @@ -2834,6 +2837,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4093,6 +4097,7 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4267,6 +4272,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7071,6 +7077,7 @@ "resolved": "https://registry.npmjs.org/next/-/next-15.5.5.tgz", "integrity": "sha512-OQVdBPtpBfq7HxFN0kOVb7rXXOSIkt5lTzDJDGRBcOyVvNRIWFauMqi1gIHd1pszq1542vMOGY0HP4CaiALfkA==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "15.5.5", "@swc/helpers": "0.5.15", @@ -7537,6 +7544,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "6.18.0", "@prisma/engines": "6.18.0" @@ -7642,6 +7650,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7651,6 +7660,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -7662,7 +7672,8 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-markdown": { "version": "10.1.0", @@ -7696,6 +7707,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -7828,7 +7840,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -8668,6 +8681,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8847,6 +8861,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index 55c0d22..237066f 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -1,3 +1,4 @@ +export const runtime = 'nodejs' import { NextRequest } from 'next/server' import { prisma } from '../../../lib/prisma' diff --git a/frontend/src/app/report/[symbol]/page.tsx b/frontend/src/app/report/[symbol]/page.tsx index 20ab870..8051d65 100644 --- a/frontend/src/app/report/[symbol]/page.tsx +++ b/frontend/src/app/report/[symbol]/page.tsx @@ -763,7 +763,7 @@ export default function ReportPage() { {(() => { // 指定显示顺序(tushareParam) - const ORDER: Array<{ key: string; label?: string; kind?: 'computed' }> = [ + const ORDER: Array<{ key: string; label?: string }> = [ { key: 'roe' }, { key: 'roa' }, { key: 'roic' }, @@ -775,7 +775,7 @@ export default function ReportPage() { { key: 'dt_netprofit_yoy' }, { key: 'n_cashflow_act' }, { key: 'c_pay_acq_const_fiolta' }, - { key: '__free_cash_flow', label: '自由现金流', kind: 'computed' }, + { key: '__free_cash_flow', label: '自由现金流' }, { key: 'cash_div_tax', label: '分红' }, { key: 'buyback', label: '回购' }, { key: 'total_assets' }, @@ -794,41 +794,32 @@ export default function ReportPage() { ); const PERCENT_KEYS = new Set([ - 'roe','roa','roic','grossprofit_margin','netprofit_margin','tr_yoy','dt_netprofit_yoy' + 'roe','roa','roic','grossprofit_margin','netprofit_margin','tr_yoy','dt_netprofit_yoy', + // Add all calculated percentage rows + '__sell_rate', '__admin_rate', '__rd_rate', '__other_fee_rate', '__tax_rate', '__depr_ratio', + '__money_cap_ratio', '__inventories_ratio', '__ar_ratio', '__prepay_ratio', + '__fix_assets_ratio', '__lt_invest_ratio', '__goodwill_ratio', '__other_assets_ratio', + '__ap_ratio', '__adv_ratio', '__st_borr_ratio', '__lt_borr_ratio', + '__operating_assets_ratio', '__interest_bearing_debt_ratio' ]); - const rows = ORDER.map(({ key, label, kind }) => { - // 自由现金流为计算项:经营现金流 - 资本开支 - const isComputed = kind === 'computed' && key === '__free_cash_flow'; + const rows = ORDER.map(({ key, label }) => { const points = series[key] as Array<{ year?: string; value?: number | null }> | undefined; - const operating = series['n_cashflow_act'] as Array<{ year?: string; value?: number | null }> | undefined; - const capex = series['c_pay_acq_const_fiolta'] as Array<{ year?: string; value?: number | null }> | undefined; - + return ( {label || metricDisplayMap[key] || key} {years.map((y) => { - let v: number | null | undefined = undefined; - if (isComputed) { - const op = operating?.find(p => p?.year === y)?.value ?? null; - const cp = capex?.find(p => p?.year === y)?.value ?? null; - if (op == null || cp == null) { - v = null; - } else { - v = (typeof op === 'number' ? op : Number(op)) - (typeof cp === 'number' ? cp : Number(cp)); - } - } else { - v = points?.find(p => p?.year === y)?.value ?? null; - } - + const v = points?.find(p => p?.year === y)?.value ?? null; + const groupName = metricGroupMap[key]; const rawNum = typeof v === 'number' ? v : (v == null ? null : Number(v)); if (rawNum == null || Number.isNaN(rawNum)) { return -; } if (PERCENT_KEYS.has(key)) { - const perc = Math.abs(rawNum) <= 1 ? rawNum * 100 : rawNum; + const perc = Math.abs(rawNum) <= 1 && key !== 'tax_to_ebt' && key !== '__tax_rate' ? rawNum * 100 : rawNum; const text = Number.isFinite(perc) ? numberFormatter.format(perc) : '-'; const isGrowthRow = key === 'tr_yoy' || key === 'dt_netprofit_yoy'; if (isGrowthRow) { @@ -839,28 +830,14 @@ export default function ReportPage() { ); } - // ROE > 12% 高亮浅绿色背景 - if (key === 'roe') { - const highlight = typeof perc === 'number' && perc > 12; - return ( - {`${text}%`} - ); - } - // ROIC > 12% 高亮浅绿色背景 - if (key === 'roic') { - const highlight = typeof perc === 'number' && perc > 12; - return ( - {`${text}%`} - ); - } return ( {`${text}%`} ); } else { const isFinGroup = groupName === 'income' || groupName === 'balancesheet' || groupName === 'cashflow'; - const scaled = key === 'total_mv' - ? rawNum / 10000 - : (isFinGroup || isComputed ? rawNum / 1e8 : rawNum); + const scaled = key === 'total_mv' + ? rawNum / 10000 + : (isFinGroup || key === '__free_cash_flow' ? rawNum / 1e8 : rawNum); const formatter = key === 'total_mv' ? integerFormatter : numberFormatter; const text = Number.isFinite(scaled) ? formatter.format(scaled) : '-'; if (key === '__free_cash_flow') { @@ -892,73 +869,25 @@ export default function ReportPage() { ); - const getVal = (arr: Array<{ year?: string; value?: number | null }> | undefined, y: string) => { - const v = arr?.find(p => p?.year === y)?.value; - return typeof v === 'number' ? v : (v == null ? null : Number(v)); - }; - - // 销售费用率 = sell_exp / revenue const feeRows = [ - { key: '__sell_rate', label: '销售费用率', num: series['sell_exp'] as any, den: series['revenue'] as any }, - { key: '__admin_rate', label: '管理费用率', num: series['admin_exp'] as any, den: series['revenue'] as any }, - { key: '__rd_rate', label: '研发费用率', num: series['rd_exp'] as any, den: series['revenue'] as any }, - { key: '__other_fee_rate', label: '其他费用率', num: undefined, den: series['revenue'] as any }, - // 所得税率:若有 tax_to_ebt,用该指标;否则按 income_tax / 税前利润 计算(暂无字段,留空) - { key: '__tax_rate', label: '所得税率', num: series['tax_to_ebt'] as any, den: undefined }, - // 折旧费用占比 = 折旧费用 / 收入 - { key: '__depr_ratio', label: '折旧费用占比', num: series['depr_fa_coga_dpba'] as any, den: series['revenue'] as any }, - ].map(({ key, label, num, den }) => ( + { key: '__sell_rate', label: '销售费用率' }, + { key: '__admin_rate', label: '管理费用率' }, + { key: '__rd_rate', label: '研发费用率' }, + { key: '__other_fee_rate', label: '其他费用率' }, + { key: '__tax_rate', label: '所得税率' }, + { key: '__depr_ratio', label: '折旧费用占比' }, + ].map(({ key, label }) => ( {label} - {years.map((y) => { - const numerator = getVal(num, y); - const denominator = getVal(den, y); + {years.map((y) => { + const points = series[key] as Array<{ year?: string; value?: number | null }> | undefined; + const v = points?.find(p => p?.year === y)?.value ?? null; - let rate: number | null = null; - if (key === '__tax_rate') { - // tax_to_ebt 有些接口为比例(0-1),有些可能已是百分数 - if (numerator == null || Number.isNaN(numerator)) { - rate = null; - } else if (Math.abs(numerator) <= 1) { - rate = numerator * 100; - } else { - rate = numerator; // 认为已是百分比 - } - } else if (key === '__other_fee_rate') { - // 其他费用率 = 毛利率 - 净利润率 - 销售费用率 - 管理费用率 - 研发费用率 - const gpRaw = getVal(series['grossprofit_margin'] as any, y); - const npRaw = getVal(series['netprofit_margin'] as any, y); - const rev = getVal(series['revenue'] as any, y); - const sell = getVal(series['sell_exp'] as any, y); - const admin = getVal(series['admin_exp'] as any, y); - const rd = getVal(series['rd_exp'] as any, y); - if ( - gpRaw == null || npRaw == null || rev == null || rev === 0 || - sell == null || admin == null || rd == null - ) { - rate = null; - } else { - // 将毛利率、净利润率标准化为百分比 - const gp = Math.abs(gpRaw) <= 1 ? gpRaw * 100 : gpRaw; - const np = Math.abs(npRaw) <= 1 ? npRaw * 100 : npRaw; - const sellRate = (sell / rev) * 100; - const adminRate = (admin / rev) * 100; - const rdRate = (rd / rev) * 100; - rate = gp - np - sellRate - adminRate - rdRate; - } - } else { - if (numerator == null || denominator == null || denominator === 0) { - rate = null; - } else { - rate = (numerator / denominator) * 100; - } - } - - if (rate == null || !Number.isFinite(rate)) { + if (v == null || !Number.isFinite(v)) { return -; } - const rateText = numberFormatter.format(rate); - const isNegative = rate < 0; + const rateText = numberFormatter.format(v); + const isNegative = v < 0; return ( {isNegative ? {rateText}% : `${rateText}%`} @@ -994,129 +923,28 @@ export default function ReportPage() { }; const assetRows = [ - { - key: '__money_cap_ratio', label: '现金占比', calc: (y: string) => { - const num = getVal(series['money_cap'] as any, y); - const den = getVal(series['total_assets'] as any, y); - return num == null || den == null || den === 0 ? null : (num / den) * 100; - } - }, - { - key: '__inventories_ratio', label: '库存占比', calc: (y: string) => { - const num = getVal(series['inventories'] as any, y); - const den = getVal(series['total_assets'] as any, y); - return num == null || den == null || den === 0 ? null : (num / den) * 100; - } - }, - { - key: '__ar_ratio', label: '应收款占比', calc: (y: string) => { - const num = getVal(series['accounts_receiv_bill'] as any, y); - const den = getVal(series['total_assets'] as any, y); - return num == null || den == null || den === 0 ? null : (num / den) * 100; - } - }, - { - key: '__prepay_ratio', label: '预付款占比', calc: (y: string) => { - const num = getVal(series['prepayment'] as any, y); - const den = getVal(series['total_assets'] as any, y); - return num == null || den == null || den === 0 ? null : (num / den) * 100; - } - }, - { - key: '__fix_assets_ratio', label: '固定资产占比', calc: (y: string) => { - const num = getVal(series['fix_assets'] as any, y); - const den = getVal(series['total_assets'] as any, y); - return num == null || den == null || den === 0 ? null : (num / den) * 100; - } - }, - { - key: '__lt_invest_ratio', label: '长期投资占比', calc: (y: string) => { - const num = getVal(series['lt_eqt_invest'] as any, y); - const den = getVal(series['total_assets'] as any, y); - return num == null || den == null || den === 0 ? null : (num / den) * 100; - } - }, - { - key: '__goodwill_ratio', label: '商誉占比', calc: (y: string) => { - const num = getVal(series['goodwill'] as any, y); - const den = getVal(series['total_assets'] as any, y); - return num == null || den == null || den === 0 ? null : (num / den) * 100; - } - }, - { - key: '__other_assets_ratio', label: '其他资产占比', calc: (y: string) => { - const total = getVal(series['total_assets'] as any, y); - if (total == null || total === 0) return null; - const parts = [ - getVal(series['money_cap'] as any, y), - getVal(series['inventories'] as any, y), - getVal(series['accounts_receiv_bill'] as any, y), - getVal(series['prepayment'] as any, y), - getVal(series['fix_assets'] as any, y), - getVal(series['lt_eqt_invest'] as any, y), - getVal(series['goodwill'] as any, y), - ].map(v => (typeof v === 'number' && Number.isFinite(v) ? v : 0)); - const sumKnown = parts.reduce((acc: number, v: number) => acc + v, 0); - return ((total - sumKnown) / total) * 100; - } - }, - { - key: '__ap_ratio', label: '应付款占比', calc: (y: string) => { - const num = getVal(series['accounts_pay'] as any, y); - const den = getVal(series['total_assets'] as any, y); - return num == null || den == null || den === 0 ? null : (num / den) * 100; - } - }, - { - key: '__adv_ratio', label: '预收款占比', calc: (y: string) => { - const adv = getVal(series['adv_receipts'] as any, y) || 0; - const contractLiab = getVal(series['contract_liab'] as any, y) || 0; - const num = adv + contractLiab; - const den = getVal(series['total_assets'] as any, y); - return den == null || den === 0 ? null : (num / den) * 100; - } - }, - { - key: '__st_borr_ratio', label: '短期借款占比', calc: (y: string) => { - const num = getVal(series['st_borr'] as any, y); - const den = getVal(series['total_assets'] as any, y); - return num == null || den == null || den === 0 ? null : (num / den) * 100; - } - }, - { - key: '__lt_borr_ratio', label: '长期借款占比', calc: (y: string) => { - const num = getVal(series['lt_borr'] as any, y); - const den = getVal(series['total_assets'] as any, y); - return num == null || den == null || den === 0 ? null : (num / den) * 100; - } - }, - { - key: '__operating_assets_ratio', label: '运营资产占比', calc: (y: string) => { - const total = getVal(series['total_assets'] as any, y); - if (total == null || total === 0) return null; - const inv = getVal(series['inventories'] as any, y) || 0; - const ar = getVal(series['accounts_receiv_bill'] as any, y) || 0; - const pre = getVal(series['prepayment'] as any, y) || 0; - const ap = getVal(series['accounts_pay'] as any, y) || 0; - const adv = getVal(series['adv_receipts'] as any, y) || 0; - const contractLiab = getVal(series['contract_liab'] as any, y) || 0; - const operating = inv + ar + pre - ap - adv - contractLiab; - return (operating / total) * 100; - } - }, - { - key: '__interest_bearing_debt_ratio', label: '有息负债率', calc: (y: string) => { - const total = getVal(series['total_assets'] as any, y); - if (total == null || total === 0) return null; - const st = getVal(series['st_borr'] as any, y) || 0; - const lt = getVal(series['lt_borr'] as any, y) || 0; - return ((st + lt) / total) * 100; - } - }, - ].map(({ key, label, calc }) => ( + { key: '__money_cap_ratio', label: '现金占比' }, + { key: '__inventories_ratio', label: '库存占比' }, + { key: '__ar_ratio', label: '应收款占比' }, + { key: '__prepay_ratio', label: '预付款占比' }, + { key: '__fix_assets_ratio', label: '固定资产占比' }, + { key: '__lt_invest_ratio', label: '长期投资占比' }, + { key: '__goodwill_ratio', label: '商誉占比' }, + { key: '__other_assets_ratio', label: '其他资产占比' }, + { key: '__ap_ratio', label: '应付款占比' }, + { key: '__adv_ratio', label: '预收款占比' }, + { key: '__st_borr_ratio', label: '短期借款占比' }, + { key: '__lt_borr_ratio', label: '长期借款占比' }, + { key: '__operating_assets_ratio', label: '运营资产占比' }, + { key: '__interest_bearing_debt_ratio', label: '有息负债率' }, + ].map(({ key, label }) => ( {label} - {years.map((y) => ratioCell(calc(y), y))} + {years.map((y) => { + const points = series[key] as Array<{ year?: string; value?: number | null }> | undefined; + const v = points?.find(p => p?.year === y)?.value ?? null; + return ratioCell(v, y); + })} )); @@ -1140,61 +968,14 @@ export default function ReportPage() { { key: 'assets_turn', label: '总资产周转率' }, ]; - const getYearNumber = (ys: string) => { - const n = Number(ys); - return Number.isFinite(n) ? n : null; - }; - const getPoint = (arr: Array<{ year?: string; value?: number | null }> | undefined, year: string) => { - return arr?.find(p => p?.year === year)?.value ?? null; - }; - const getAvg = (arr: Array<{ year?: string; value?: number | null }> | undefined, year: string) => { - const curr = getPoint(arr, year); - const yNum = getYearNumber(year); - const prevYear = yNum != null ? String(yNum - 1) : null; - const prev = prevYear ? getPoint(arr, prevYear) : null; - const c = typeof curr === 'number' ? curr : (curr == null ? null : Number(curr)); - const p = typeof prev === 'number' ? prev : (prev == null ? null : Number(prev)); - if (c == null) return null; - if (p == null) return c; - return (c + p) / 2; - }; - const getMarginRatio = (year: string) => { - const gmRaw = getPoint(series['grossprofit_margin'] as any, year); - if (gmRaw == null) return null; - const gmNum = typeof gmRaw === 'number' ? gmRaw : Number(gmRaw); - if (!Number.isFinite(gmNum)) return null; - // 支持 0-1 或 百分数 - return Math.abs(gmNum) <= 1 ? gmNum : gmNum / 100; - }; - const getRevenue = (year: string) => { - const rev = getPoint(series['revenue'] as any, year); - const r = typeof rev === 'number' ? rev : (rev == null ? null : Number(rev)); - return r; - }; - const getCOGS = (year: string) => { - const rev = getRevenue(year); - const gm = getMarginRatio(year); - if (rev == null || gm == null) return null; - const cogs = rev * (1 - gm); - return Number.isFinite(cogs) ? cogs : null; - }; - const turnoverRows = turnoverItems.map(({ key, label }) => ( {label} {years.map((y) => { - let value: number | null = null; - if (key === 'payturn_days') { - const avgAP = getAvg(series['accounts_pay'] as any, y); - const cogs = getCOGS(y); - value = avgAP == null || cogs == null || cogs === 0 ? null : (365 * avgAP) / cogs; - } else { - // 直接显示原值(从API读取:invturn_days、arturn_days、fa_turn、assets_turn) - const arr = series[key] as Array<{ year?: string; value?: number | null }> | undefined; - const v = arr?.find(p => p?.year === y)?.value ?? null; - const num = typeof v === 'number' ? v : (v == null ? null : Number(v)); - value = num == null || Number.isNaN(num) ? null : num; - } + const points = series[key] as Array<{ year?: string; value?: number | null }> | undefined; + const v = points?.find(p => p?.year === y)?.value ?? null; + const value = typeof v === 'number' ? v : (v == null ? null : Number(v)); + if (value == null || !Number.isFinite(value)) { return -; } @@ -1234,8 +1015,9 @@ export default function ReportPage() { 员工人数 {years.map((y) => { - const v = getVal(series['employees'] as any, y); - if (v == null || !Number.isFinite(v)) { + const points = series['employees'] as Array<{ year?: string; value?: number | null }> | undefined; + const v = points?.find(p => p?.year === y)?.value ?? null; + if (v == null) { return -; } return {integerFormatter.format(Math.round(v))}; @@ -1247,12 +1029,11 @@ export default function ReportPage() { 人均创收(万元) {years.map((y) => { - const rev = getVal(series['revenue'] as any, y); - const emp = getVal(series['employees'] as any, y); - if (rev == null || emp == null || emp === 0) { + const points = series['__rev_per_emp'] as Array<{ year?: string; value?: number | null }> | undefined; + const val = points?.find(p => p?.year === y)?.value ?? null; + if (val == null) { return -; } - const val = (rev / emp) / 10000; return {numberFormatter.format(val)}; })} @@ -1262,12 +1043,11 @@ export default function ReportPage() { 人均创利(万元) {years.map((y) => { - const prof = getVal(series['n_income'] as any, y); - const emp = getVal(series['employees'] as any, y); - if (prof == null || emp == null || emp === 0) { + const points = series['__profit_per_emp'] as Array<{ year?: string; value?: number | null }> | undefined; + const val = points?.find(p => p?.year === y)?.value ?? null; + if (val == null) { return -; } - const val = (prof / emp) / 10000; return {numberFormatter.format(val)}; })} @@ -1277,12 +1057,11 @@ export default function ReportPage() { 人均工资(万元) {years.map((y) => { - const salaryPaid = getVal(series['c_paid_to_for_empl'] as any, y); - const emp = getVal(series['employees'] as any, y); - if (salaryPaid == null || emp == null || emp === 0) { + const points = series['__salary_per_emp'] as Array<{ year?: string; value?: number | null }> | undefined; + const val = points?.find(p => p?.year === y)?.value ?? null; + if (val == null) { return -; } - const val = (salaryPaid / emp) / 10000; return {numberFormatter.format(val)}; })} @@ -1303,11 +1082,12 @@ export default function ReportPage() { 股价 {years.map((y) => { - const arr = series['close'] as Array<{ year?: string; value?: number | null }> | undefined; - const v = arr?.find(p => p?.year === y)?.value ?? null; - const num = typeof v === 'number' ? v : (v == null ? null : Number(v)); - if (num == null || !Number.isFinite(num)) return -; - return {numberFormatter.format(num)}; + const points = series['close'] as Array<{ year?: string; value?: number | null }> | undefined; + const v = points?.find(p => p?.year === y)?.value ?? null; + if (v == null) { + return -; + } + return {numberFormatter.format(v)}; })} ), @@ -1316,11 +1096,12 @@ export default function ReportPage() { 市值(亿元) {years.map((y) => { - const arr = series['total_mv'] as Array<{ year?: string; value?: number | null }> | undefined; - const v = arr?.find(p => p?.year === y)?.value ?? null; - const num = typeof v === 'number' ? v : (v == null ? null : Number(v)); - if (num == null || !Number.isFinite(num)) return -; - const scaled = num / 10000; // 转为亿元 + const points = series['total_mv'] as Array<{ year?: string; value?: number | null }> | undefined; + const v = points?.find(p => p?.year === y)?.value ?? null; + if (v == null) { + return -; + } + const scaled = v / 10000; // 转为亿元 return {integerFormatter.format(Math.round(scaled))}; })} @@ -1330,11 +1111,12 @@ export default function ReportPage() { PE {years.map((y) => { - const arr = series['pe'] as Array<{ year?: string; value?: number | null }> | undefined; - const v = arr?.find(p => p?.year === y)?.value ?? null; - const num = typeof v === 'number' ? v : (v == null ? null : Number(v)); - if (num == null || !Number.isFinite(num)) return -; - return {numberFormatter.format(num)}; + const points = series['pe'] as Array<{ year?: string; value?: number | null }> | undefined; + const v = points?.find(p => p?.year === y)?.value ?? null; + if (v == null) { + return -; + } + return {numberFormatter.format(v)}; })} ), @@ -1343,11 +1125,12 @@ export default function ReportPage() { PB {years.map((y) => { - const arr = series['pb'] as Array<{ year?: string; value?: number | null }> | undefined; - const v = arr?.find(p => p?.year === y)?.value ?? null; - const num = typeof v === 'number' ? v : (v == null ? null : Number(v)); - if (num == null || !Number.isFinite(num)) return -; - return {numberFormatter.format(num)}; + const points = series['pb'] as Array<{ year?: string; value?: number | null }> | undefined; + const v = points?.find(p => p?.year === y)?.value ?? null; + if (v == null) { + return -; + } + return {numberFormatter.format(v)}; })} ), @@ -1356,11 +1139,12 @@ export default function ReportPage() { 股东户数 {years.map((y) => { - const arr = series['holder_num'] as Array<{ year?: string; value?: number | null }> | undefined; - const v = arr?.find(p => p?.year === y)?.value ?? null; - const num = typeof v === 'number' ? v : (v == null ? null : Number(v)); - if (num == null || !Number.isFinite(num)) return -; - return {integerFormatter.format(Math.round(num))}; + const points = series['holder_num'] as Array<{ year?: string; value?: number | null }> | undefined; + const v = points?.find(p => p?.year === y)?.value ?? null; + if (v == null) { + return -; + } + return {integerFormatter.format(Math.round(v))}; })} ), diff --git a/frontend/src/lib/prisma.ts b/frontend/src/lib/prisma.ts index 290ba6d..43e6e14 100644 --- a/frontend/src/lib/prisma.ts +++ b/frontend/src/lib/prisma.ts @@ -1,10 +1,35 @@ import { PrismaClient } from '@prisma/client' +import fs from 'node:fs' +import path from 'node:path' const globalForPrisma = global as unknown as { prisma?: PrismaClient } +function loadDatabaseUrlFromConfig(): string | undefined { + try { + const configPath = path.resolve(process.cwd(), '..', 'config', 'config.json') + const raw = fs.readFileSync(configPath, 'utf-8') + const json = JSON.parse(raw) + const dbUrl: unknown = json?.database?.url + if (typeof dbUrl !== 'string' || !dbUrl) return undefined + + // 将后端风格的 "postgresql+asyncpg://" 转换为 Prisma 需要的 "postgresql://" + let url = dbUrl.replace(/^postgresql\+[^:]+:\/\//, 'postgresql://') + // 若未指定 schema,默认 public + if (!/[?&]schema=/.test(url)) { + url += (url.includes('?') ? '&' : '?') + 'schema=public' + } + return url + } catch { + return undefined + } +} + +const databaseUrl = loadDatabaseUrlFromConfig() || process.env.DATABASE_URL + export const prisma = globalForPrisma.prisma || new PrismaClient({ + datasources: databaseUrl ? { db: { url: databaseUrl } } : undefined, log: ['error', 'warn'] })