Files
invest-mind-store/packages/design-document/我编写的文档/财务报表模块-产品与数据库设计.md
R524809 1f7fded275 tq[MESSAGE]:
[TYPE]:
[JIRA]:
2026-02-11 16:05:45 +08:00

215 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 财务报表模块 - 产品与数据库设计
## 一、需求摘要
- **用途**:个人研究用,上传并落库公司历次财务报表(资产负债表、利润表、现金流量表),支持按年/按季;后续做绘制、分类、对比查看。
- **数据来源**:页面上传 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。 |
按上述设计,你可以在不开放的前提下,先实现「上传 → 解析 → 落库 → 预计算」闭环,再在现有库上扩展列表/筛选/分类展示与绘图。