tq[MESSAGE]:

[TYPE]:
[JIRA]:
This commit is contained in:
R524809
2026-02-11 16:05:45 +08:00
parent 571465cfbb
commit 1f7fded275
2 changed files with 372 additions and 1 deletions

View File

@@ -8,10 +8,16 @@
├── user (用户表) ├── user (用户表)
基础数据相关 基础数据相关
├── brokers (券商表) ├── broker (券商表)
├── stock_info (股票基本信息表) ├── stock_info (股票基本信息表)
└── stock_daily_price (股票每日收盘价表) └── stock_daily_price (股票每日收盘价表)
财务报表相关(见 财务报表模块-产品与数据库设计.md
├── financial_report (报表主表)
├── financial_report_line (行项目表)
├── financial_report_stats (预计算统计表)
└── financial_item_category (自定义分类表,可选)
账户相关 账户相关
├── positions (持仓表) ├── positions (持仓表)
├── position_changes (持仓变更记录表) ├── position_changes (持仓变更记录表)
@@ -513,6 +519,157 @@ WHERE p.auto_price_update = true
--- ---
## 财务报表相关表设计
> 详细产品与设计说明见:[财务报表模块-产品与数据库设计.md](./财务报表模块-产品与数据库设计.md)
### financial_report 报表主表
| 字段名 | 数据类型 | 约束 | 说明 |
|--------|---------|------|------|
| id | BIGSERIAL | PRIMARY KEY | 主键 |
| stock_code | VARCHAR(20) | NOT NULL | 股票代码,关联 stock_info |
| market | VARCHAR(20) | NOT NULL | 市场,关联 stock_info |
| report_date | DATE | NOT NULL | 报告期截止日 |
| period_type | VARCHAR(20) | NOT NULL | annual / quarterly |
| statement_type | VARCHAR(30) | NOT NULL | balance_sheet / income_statement / cash_flow |
| source_file_name | VARCHAR(255) | | 来源文件名 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 更新时间 |
```sql
CREATE TABLE financial_report (
id BIGSERIAL PRIMARY KEY,
stock_code VARCHAR(20) NOT NULL,
market VARCHAR(20) NOT NULL,
report_date DATE NOT NULL,
period_type VARCHAR(20) NOT NULL,
statement_type VARCHAR(30) NOT NULL,
source_file_name VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(stock_code, market, report_date, statement_type),
CONSTRAINT fk_financial_report_stock FOREIGN KEY (stock_code, market)
REFERENCES stock_info(stock_code, market) ON DELETE CASCADE,
CONSTRAINT check_period_type CHECK (period_type IN ('annual', 'quarterly')),
CONSTRAINT check_statement_type CHECK (statement_type IN ('balance_sheet', 'income_statement', 'cash_flow'))
);
CREATE INDEX idx_financial_report_stock_market ON financial_report(stock_code, market);
CREATE INDEX idx_financial_report_report_date ON financial_report(report_date);
CREATE INDEX idx_financial_report_statement_type ON financial_report(statement_type);
CREATE TRIGGER update_financial_report_updated_at
BEFORE UPDATE ON financial_report
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
COMMENT ON TABLE financial_report IS '财务报表主表:公司+报告期+表型';
COMMENT ON COLUMN financial_report.statement_type IS 'balance_sheet(资产负债表)/income_statement(利润表)/cash_flow(现金流量表)';
```
### financial_report_line 行项目表
| 字段名 | 数据类型 | 约束 | 说明 |
|--------|---------|------|------|
| id | BIGSERIAL | PRIMARY KEY | 主键 |
| report_id | BIGINT | NOT NULL, FK | 所属报表 |
| item_name | VARCHAR(200) | NOT NULL | 科目名称(原文) |
| item_code | VARCHAR(50) | | 科目编码(可选) |
| value | DECIMAL(24, 6) | | 数值 |
| unit | VARCHAR(20) | | 单位 |
| row_order | INT | DEFAULT 0 | 行顺序 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 |
```sql
CREATE TABLE financial_report_line (
id BIGSERIAL PRIMARY KEY,
report_id BIGINT NOT NULL REFERENCES financial_report(id) ON DELETE CASCADE,
item_name VARCHAR(200) NOT NULL,
item_code VARCHAR(50),
value DECIMAL(24, 6),
unit VARCHAR(20),
row_order INT DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_financial_report_line_report_id ON financial_report_line(report_id);
CREATE INDEX idx_financial_report_line_item_name ON financial_report_line(report_id, item_name);
COMMENT ON TABLE financial_report_line IS '财务报表行项目,存原始科目名与数值,支持多市场科目差异';
```
### financial_report_stats 预计算统计表
| 字段名 | 数据类型 | 约束 | 说明 |
|--------|---------|------|------|
| id | BIGSERIAL | PRIMARY KEY | 主键 |
| report_id | BIGINT | NOT NULL, FK | 所属报表 |
| stat_key | VARCHAR(80) | NOT NULL | 如 total_assets, net_profit, roe |
| stat_value | DECIMAL(24, 6) | | 统计值 |
| dimension | VARCHAR(50) | | 可选维度 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 更新时间 |
```sql
CREATE TABLE financial_report_stats (
id BIGSERIAL PRIMARY KEY,
report_id BIGINT NOT NULL REFERENCES financial_report(id) ON DELETE CASCADE,
stat_key VARCHAR(80) NOT NULL,
stat_value DECIMAL(24, 6),
dimension VARCHAR(50),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(report_id, stat_key, dimension)
);
CREATE INDEX idx_financial_report_stats_report_id ON financial_report_stats(report_id);
CREATE INDEX idx_financial_report_stats_stat_key ON financial_report_stats(stat_key);
CREATE TRIGGER update_financial_report_stats_updated_at
BEFORE UPDATE ON financial_report_stats
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
COMMENT ON TABLE financial_report_stats IS '报表预计算统计项,上传时计算并写入';
```
### financial_item_category 自定义分类表(可选)
| 字段名 | 数据类型 | 约束 | 说明 |
|--------|---------|------|------|
| id | BIGSERIAL | PRIMARY KEY | 主键 |
| user_id | BIGINT | FK user, 可选 | 多用户时按用户隔离 |
| market | VARCHAR(20) | NOT NULL | 市场 |
| statement_type | VARCHAR(30) | NOT NULL | 表型 |
| item_name_pattern | VARCHAR(200) | NOT NULL | 科目名或匹配模式 |
| category_slug | VARCHAR(60) | NOT NULL | 分类标签,如 cash_like, operating |
| sort_order | INT | DEFAULT 0 | 展示顺序 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 更新时间 |
```sql
CREATE TABLE financial_item_category (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES user(user_id) ON DELETE CASCADE,
market VARCHAR(20) NOT NULL,
statement_type VARCHAR(30) NOT NULL,
item_name_pattern VARCHAR(200) NOT NULL,
category_slug VARCHAR(60) NOT NULL,
sort_order INT DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_financial_item_category_user_market ON financial_item_category(user_id, market, statement_type);
CREATE TRIGGER update_financial_item_category_updated_at
BEFORE UPDATE ON financial_item_category
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
COMMENT ON TABLE financial_item_category IS '科目名到自定义分类的映射,用于按类现金/投资资产等聚合展示';
```
---
## 账户相关表设计 ## 账户相关表设计
### positions 持仓表 ### positions 持仓表

View File

@@ -0,0 +1,214 @@
# 财务报表模块 - 产品与数据库设计
## 一、需求摘要
- **用途**:个人研究用,上传并落库公司历次财务报表(资产负债表、利润表、现金流量表),支持按年/按季;后续做绘制、分类、对比查看。
- **数据来源**:页面上传 Excel每次一家公司、一种表型、多期数据表中部分时间/列需要丢弃,部分需要落库。
- **扩展需求**:不同市场(如 A 股 / 港股)科目名不同;需支持自定义分类(如类现金资产、投资资产、应收类、经营类等);常用统计希望预存避免每次计算。
---
## 二、三个核心问题的建议
### 1. 数据库如何支持不同市场、不同公司的财务数据(科目不一致)?
**思路:用「报表 + 行项目」的通用结构,行项目以「原始科目名 + 数值」存储,不强行统一科目编码。**
- **不采用**:为每个市场建一套固定列(如 A 股一张表、港股一张表)。维护成本高,且新增市场或科目时要改表结构。
- **推荐**
- **报表主表**:一条记录 = 某公司、某报告期、某表型(资产负债表/利润表/现金流量表)、某市场。
- **行项目表**:每条记录 = 某报表下的一个科目一行,存「科目名称(原文)」「数值」「单位」「顺序」等。
- A 股、港股、美股的科目名可以完全不同,都落在同一张行项目表里,用 `report_id` + `item_name` 区分;前端或分析时再通过「分类配置」把不同市场的不同科目名映射到你自己的分类(类现金、投资资产等)。
这样:
- 同一套表支持所有市场。
- 新市场只需新数据,不必改表结构。
- 科目差异体现在「行项目」的 `item_name` 上,分类通过单独的分类配置表完成(见下)。
---
### 2. 常用统计数据如何存储(具体存哪些可后续再定)?
**思路:单独建「报表统计值」表,按统计项键值存储;具体要存哪些可以逐步加。**
- **不推荐**:每次从行项目实时聚合(如 SUM、比率在报表很多时会有性能和复杂度问题。
- **推荐**
- 新增表 **financial_report_stats**
- 关联到某一份报表report_id
- 存「统计项 key」`total_assets``net_profit``roe``operating_cash_flow``current_ratio` 等)。
- 存「统计值」(数值型);必要时可加「维度」字段(如按子类型、按口径)。
- **何时写入**:在上传/覆盖该报表时,由后端解析并计算这些统计项,写入或更新本表。
- **具体要存哪些**可以在代码里先实现一批常用项总资产、净资产、净利润、营收、经营现金流、ROE、资产负债率、流动比率等后续要加新指标时只需加新的 `stat_key` 和计算逻辑,无需改表结构。若希望更灵活,可以用一条 JSONB 列存「多个 stat_key → value」但查询和索引会稍复杂键值表更清晰、易查、易扩展。
这样:常用统计只算一次、落库,后续绘图、筛选、对比都直接读预计算结果。
---
### 3. Node + NestJS 能否准确处理 Excel 并落库?
**可以。用成熟库读 Excel在服务层做校验与映射再落库。**
- **选型**
- **xlsx (SheetJS)**:轻量、使用广,适合「读入整表 → 转 JSON/二维数组」再自己解析。
- **exceljs**API 更细(按单元格、按行),适合格式复杂或需要严格按行列解析的场景。
建议:若 Excel 格式相对固定(例如首行/首列是时间或科目名,其余为数值),用 **xlsx** 即可;若有多表、多标题行、合并单元格,可用 **exceljs** 按位置解析。
- **流程建议**
1. 上传接口:接收 Excel 文件 + 元数据(公司、市场、表型、报告期类型 年/季)。
2. 解析服务:
- 用 xlsx/exceljs 读取 sheet得到二维数组或按行列访问。
- 根据「市场 + 表型」选择或配置解析规则(例如:第几行是期数/时间,第几列是科目名,哪些列对应要落库的期数)。
3. 过滤:只保留你需要的报告期列(丢弃不需要的时间段)。
4. 校验:
- 必填项:公司、市场、表型、至少一期数据。
- 数值列尽量做「可解析为数字」的校验;异常行可记录日志或返回错误行号。
5. 落库:
- 事务内:插入/更新报表主表 → 插入/更新行项目表(按行写入 item_name + value
- 若同一公司、同一报告期、同一表型已存在,则采用「覆盖」或「跳过」策略(建议明确一种,并在接口文档说明)。
6. 预计算:在报表落库后调用统计计算逻辑,写入 **financial_report_stats**
- **准确性**
- 依赖 Excel 格式相对稳定。建议:为每种数据源(如 Wind 导出的 A 股、港交所格式等)定一个「解析模板」或配置(行列位置、科目名到内部 item_name 的映射),在代码里分支或配置化;这样同一来源的 Excel 能稳定、准确解析。
- 可在解析后做简单一致性检查(如资产负债表左右是否平衡、利润表/现金流量表关键合计是否与行项目一致),不一致时打日志或返回警告,避免静默错误。
---
## 三、数据库设计(与现有库衔接)
### 3.1 与现有表的关系
- **公司维度**:复用现有 **stock_info**stock_code + market 唯一),不再新建「公司表」。一份报表归属到某公司 = 关联到 (stock_code, market)。
- **用户**:若后续要做「我的自定义分类」按用户隔离,可关联 **user**;若仅自用单用户,可先不关联,分类配置全局即可。
### 3.2 表结构概览
```
财务报表相关
├── financial_report (报表主表:公司+报告期+表型+市场)
├── financial_report_line (行项目:科目名+数值,支持多市场科目差异)
├── financial_report_stats 预计算统计项report_id + stat_key + value
└── financial_item_category (可选,自定义分类:科目名/模式 → 你的分类标签)
```
### 3.3 表定义
#### financial_report 报表主表
| 字段名 | 数据类型 | 约束 | 说明 |
|--------|----------|------|------|
| id | BIGSERIAL | PRIMARY KEY | 主键 |
| stock_code | VARCHAR(20) | NOT NULL | 股票代码,关联 stock_info |
| market | VARCHAR(20) | NOT NULL | 市场,关联 stock_info |
| report_date | DATE | NOT NULL | 报告期截止日(如年报 2023-12-31一季报 2023-03-31 |
| period_type | VARCHAR(20) | NOT NULL | 报告期类型annual / quarterly |
| statement_type | VARCHAR(30) | NOT NULL | 表型balance_sheet / income_statement / cash_flow |
| source_file_name | VARCHAR(255) | | 来源文件名(便于追溯) |
| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 更新时间 |
| UNIQUE(stock_code, market, report_date, statement_type) | | | 同一公司同一报告期同一表型唯一 |
- 同一公司、同一报告期、同一表型只保留一份;上传时若已存在可覆盖(更新 updated_at 并删旧行项目、再插新行项目 + 重算 stats
#### financial_report_line 行项目表
| 字段名 | 数据类型 | 约束 | 说明 |
|--------|----------|------|------|
| id | BIGSERIAL | PRIMARY KEY | 主键 |
| report_id | BIGINT | NOT NULL, FK → financial_report.id | 所属报表 |
| item_name | VARCHAR(200) | NOT NULL | 科目名称原文如「货币资金」「Cash and bank balances」 |
| item_code | VARCHAR(50) | | 可选,科目编码(若 Excel 有) |
| value | DECIMAL(24, 6) | | 数值 |
| unit | VARCHAR(20) | | 单位(元、千元、万元等,可选) |
| row_order | INT | DEFAULT 0 | 行顺序(便于还原表序) |
| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 |
- 索引report_id(report_id, item_name) 若需防重复可加 UNIQUE取决于你是否允许同名多行多数情况下一表内科目名不重复
#### financial_report_stats 预计算统计表
| 字段名 | 数据类型 | 约束 | 说明 |
|--------|----------|------|------|
| id | BIGSERIAL | PRIMARY KEY | 主键 |
| report_id | BIGINT | NOT NULL, FK → financial_report.id | 所属报表 |
| stat_key | VARCHAR(80) | NOT NULL | 统计项键,如 total_assets, net_profit, roe, current_ratio |
| stat_value | DECIMAL(24, 6) | | 统计值 |
| dimension | VARCHAR(50) | | 可选维度(如口径、子类型) |
| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 更新时间 |
| UNIQUE(report_id, stat_key, dimension) | | | 同一报表同一 key及维度唯一便于 upsert |
- 具体 stat_key 可在代码中枚举或配置,逐步增加(如 total_assets, total_liabilities, net_assets, revenue, net_profit, operating_cash_flow, roe, roa, current_ratio, quick_ratio, asset_liability_ratio 等)。
#### financial_item_category 自定义分类(可选)
| 字段名 | 数据类型 | 约束 | 说明 |
|--------|----------|------|------|
| id | BIGSERIAL | PRIMARY KEY | 主键 |
| user_id | BIGINT | FK → user.user_id, 可选 | 若多用户则按用户隔离;单用户可 NULL 表示全局 |
| market | VARCHAR(20) | NOT NULL | 市场 |
| statement_type | VARCHAR(30) | NOT NULL | 表型 |
| item_name_pattern | VARCHAR(200) | NOT NULL | 科目名或匹配模式(如精确「货币资金」或简单通配) |
| category_slug | VARCHAR(60) | NOT NULL | 你的分类标签,如 cash_like, investment, receivable, operating |
| sort_order | INT | DEFAULT 0 | 展示顺序 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 更新时间 |
- 查询时:根据报表的 market + statement_type用 line 的 item_name 去匹配 item_name_pattern得到 category_slug再在前端或 API 里按 category 聚合/展示。后续若要支持「多对一」(多个科目同一分类),可多条记录相同 category_slug、不同 item_name_pattern。
---
## 四、Excel 解析与落库流程NestJS 实现要点)
1. **上传接口**
- 使用现有或新建 `StorageController` 的 multipart 接口,接受 `file` + bodystock_code, market, statement_type, period_type, report_dates_to_import[] 等)。
- 文件类型校验:`.xlsx` / `.xls`(若需要),大小限制(如 10MB
2. **解析服务(新模块,如 FinancialReportParseService**
- 使用 **xlsx****exceljs** 读 buffer。
- 按 market + statement_type 选择解析规则(可从配置或策略 map 读取):
- 表头行、科目列、数据列对应关系;
- 哪些列对应「要落库的报告期」(对应 report_date
- 输出:`{ reportDate, lines: [ { itemName, value, rowOrder } ] }[]`,只包含需要落库的期数。
3. **报表服务FinancialReportService**
- 对每个 reportDate
- 若存在则删除旧 line、旧 stats
- 插入 financial_report或更新 updated_at
- 批量插入 financial_report_line
- 调用 StatsService 计算并写入 financial_report_stats。
- 事务包裹,保证要么全部成功要么全部回滚。
4. **统计计算服务FinancialReportStatsService**
- 输入report_id以及该 report 的 lines或从 DB 再查一次)。
- 根据 statement_type 计算:
- 资产负债表:总资产、总负债、净资产、流动比率、速动比率等;
- 利润表:营收、净利润、毛利率等;
- 现金流量表:经营/投资/筹资现金流等。
- 写入 financial_report_statsupsert by report_id + stat_key + dimension
5. **校验与错误处理**
- 解析阶段:无法识别表头、缺少必填列时返回 400 + 明确错误信息。
- 数值解析失败:可记录行号并跳过该行或整表拒绝,建议至少日志 + 返回「第 N 行解析失败」。
- 若启用资产负债表平衡校验:资产合计 ≈ 负债+权益合计,不一致时记录 warning 或返回提示,不阻塞落库(因有时四舍五入或口径差异)。
---
## 五、后续可扩展点
- **多 sheet**:一个 Excel 内多 sheet 对应多表型或多公司时,可在解析时循环 sheet每个 sheet 对应一个 (company, statement_type) 的导入。
- **科目标准化**:若希望跨市场对比同一「逻辑科目」,可在 line 表增加 optional 的 `standard_item_code`,由解析时或后台任务根据 item_name + market 映射到统一编码。
- **版本与审计**:若需保留历史版本,可在 financial_report 加 version 或不改主表、仅保留「每次上传生成新 report_id 并软删旧版」的策略。
- **前端**:上传页选择公司(从 stock_info 查)、市场、表型、文件;解析结果可先预览「将落库的期数 + 行数」,确认后再写入;列表页按公司/时间/表型筛选,详情页按自定义分类展示行项目与预计算统计、并做简单图表。
---
## 六、总结
| 问题 | 建议 |
|------|------|
| 不同市场、不同公司科目不一致 | 用「报表主表 + 行项目表」存原始科目名与数值;分类用单独配置表做「科目名 → 自定义分类」映射。 |
| 常用统计不想每次算 | 用 financial_report_stats 表存 (report_id, stat_key, stat_value),在上传/更新报表时计算并写入;具体指标可后续逐步加。 |
| Node + NestJS 能否准确读 Excel 并落库 | 可以;用 xlsx 或 exceljs 解析,固定或配置化每种数据源格式,在服务层做校验与过滤后事务落库,并在落库后写 stats。 |
按上述设计,你可以在不开放的前提下,先实现「上传 → 解析 → 落库 → 预计算」闭环,再在现有库上扩展列表/筛选/分类展示与绘图。