修改了财务数据获取时的一些结构,但还没有完成,今天先做到这儿。

This commit is contained in:
xucheng 2025-11-04 14:03:34 +08:00
parent ff7dc0c95a
commit 3ffb30696b
9 changed files with 589 additions and 355 deletions

View File

@ -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

View File

@ -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_mvpepb
"""
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

View File

@ -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

View File

@ -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

View File

@ -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,
...
}
```

View File

@ -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"

View File

@ -1,3 +1,4 @@
export const runtime = 'nodejs'
import { NextRequest } from 'next/server'
import { prisma } from '../../../lib/prisma'

View File

@ -763,7 +763,7 @@ export default function ReportPage() {
<TableBody>
{(() => {
// 指定显示顺序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 (
<TableRow key={key} className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">
{label || metricDisplayMap[key] || key}
</TableCell>
{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 <TableCell key={y} className="text-right p-2">-</TableCell>;
}
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() {
</TableCell>
);
}
// ROE > 12% 高亮浅绿色背景
if (key === 'roe') {
const highlight = typeof perc === 'number' && perc > 12;
return (
<TableCell key={y} className={`text-right p-2 ${highlight ? 'bg-green-200' : ''}`}>{`${text}%`}</TableCell>
);
}
// ROIC > 12% 高亮浅绿色背景
if (key === 'roic') {
const highlight = typeof perc === 'number' && perc > 12;
return (
<TableCell key={y} className={`text-right p-2 ${highlight ? 'bg-green-200' : ''}`}>{`${text}%`}</TableCell>
);
}
return (
<TableCell key={y} className="text-right p-2">{`${text}%`}</TableCell>
);
} 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() {
</TableRow>
);
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 }) => (
<TableRow key={key} className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
{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 <TableCell key={y} className="text-right p-2">-</TableCell>;
}
const rateText = numberFormatter.format(rate);
const isNegative = rate < 0;
const rateText = numberFormatter.format(v);
const isNegative = v < 0;
return (
<TableCell key={y} className="text-right p-2">
{isNegative ? <span className="text-red-600 bg-red-100">{rateText}%</span> : `${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 }) => (
<TableRow key={key} className={`hover:bg-purple-100 ${key === '__other_assets_ratio' ? 'bg-yellow-50' : ''}`}>
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
{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);
})}
</TableRow>
));
@ -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 }) => (
<TableRow key={key} className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
{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 <TableCell key={y} className="text-right p-2">-</TableCell>;
}
@ -1234,8 +1015,9 @@ export default function ReportPage() {
<TableRow key="__employees_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell>
{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 <TableCell key={y} className="text-right p-2">-</TableCell>;
}
return <TableCell key={y} className="text-right p-2">{integerFormatter.format(Math.round(v))}</TableCell>;
@ -1247,12 +1029,11 @@ export default function ReportPage() {
<TableRow key="__rev_per_emp_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell>
{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 <TableCell key={y} className="text-right p-2">-</TableCell>;
}
const val = (rev / emp) / 10000;
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(val)}</TableCell>;
})}
</TableRow>
@ -1262,12 +1043,11 @@ export default function ReportPage() {
<TableRow key="__profit_per_emp_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell>
{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 <TableCell key={y} className="text-right p-2">-</TableCell>;
}
const val = (prof / emp) / 10000;
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(val)}</TableCell>;
})}
</TableRow>
@ -1277,12 +1057,11 @@ export default function ReportPage() {
<TableRow key="__salary_per_emp_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell>
{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 <TableCell key={y} className="text-right p-2">-</TableCell>;
}
const val = (salaryPaid / emp) / 10000;
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(val)}</TableCell>;
})}
</TableRow>
@ -1303,11 +1082,12 @@ export default function ReportPage() {
<TableRow key="__price_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell>
{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 <TableCell key={y} className="text-right p-2">-</TableCell>;
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(num)}</TableCell>;
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 <TableCell key={y} className="text-right p-2">-</TableCell>;
}
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(v)}</TableCell>;
})}
</TableRow>
),
@ -1316,11 +1096,12 @@ export default function ReportPage() {
<TableRow key="__market_cap_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">亿</TableCell>
{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 <TableCell key={y} className="text-right p-2">-</TableCell>;
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 <TableCell key={y} className="text-right p-2">-</TableCell>;
}
const scaled = v / 10000; // 转为亿元
return <TableCell key={y} className="text-right p-2">{integerFormatter.format(Math.round(scaled))}</TableCell>;
})}
</TableRow>
@ -1330,11 +1111,12 @@ export default function ReportPage() {
<TableRow key="__pe_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">PE</TableCell>
{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 <TableCell key={y} className="text-right p-2">-</TableCell>;
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(num)}</TableCell>;
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 <TableCell key={y} className="text-right p-2">-</TableCell>;
}
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(v)}</TableCell>;
})}
</TableRow>
),
@ -1343,11 +1125,12 @@ export default function ReportPage() {
<TableRow key="__pb_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">PB</TableCell>
{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 <TableCell key={y} className="text-right p-2">-</TableCell>;
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(num)}</TableCell>;
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 <TableCell key={y} className="text-right p-2">-</TableCell>;
}
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(v)}</TableCell>;
})}
</TableRow>
),
@ -1356,11 +1139,12 @@ export default function ReportPage() {
<TableRow key="__holder_num_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell>
{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 <TableCell key={y} className="text-right p-2">-</TableCell>;
return <TableCell key={y} className="text-right p-2">{integerFormatter.format(Math.round(num))}</TableCell>;
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 <TableCell key={y} className="text-right p-2">-</TableCell>;
}
return <TableCell key={y} className="text-right p-2">{integerFormatter.format(Math.round(v))}</TableCell>;
})}
</TableRow>
),

View File

@ -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']
})