14 KiB
14 KiB
财务报表模块 - 产品与数据库设计
一、需求摘要
- 用途:个人研究用,上传并落库公司历次财务报表(资产负债表、利润表、现金流量表),支持按年/按季;后续做绘制、分类、对比查看。
- 数据来源:页面上传 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」,但查询和索引会稍复杂;键值表更清晰、易查、易扩展。
- 新增表 financial_report_stats:
这样:常用统计只算一次、落库,后续绘图、筛选、对比都直接读预计算结果。
3. Node + NestJS 能否准确处理 Excel 并落库?
可以。用成熟库读 Excel,在服务层做校验与映射,再落库。
-
选型:
- xlsx (SheetJS):轻量、使用广,适合「读入整表 → 转 JSON/二维数组」再自己解析。
- exceljs:API 更细(按单元格、按行),适合格式复杂或需要严格按行列解析的场景。
建议:若 Excel 格式相对固定(例如首行/首列是时间或科目名,其余为数值),用 xlsx 即可;若有多表、多标题行、合并单元格,可用 exceljs 按位置解析。
-
流程建议:
- 上传接口:接收 Excel 文件 + 元数据(公司、市场、表型、报告期类型 年/季)。
- 解析服务:
- 用 xlsx/exceljs 读取 sheet,得到二维数组或按行列访问。
- 根据「市场 + 表型」选择或配置解析规则(例如:第几行是期数/时间,第几列是科目名,哪些列对应要落库的期数)。
- 过滤:只保留你需要的报告期列(丢弃不需要的时间段)。
- 校验:
- 必填项:公司、市场、表型、至少一期数据。
- 数值列尽量做「可解析为数字」的校验;异常行可记录日志或返回错误行号。
- 落库:
- 事务内:插入/更新报表主表 → 插入/更新行项目表(按行写入 item_name + value)。
- 若同一公司、同一报告期、同一表型已存在,则采用「覆盖」或「跳过」策略(建议明确一种,并在接口文档说明)。
- 预计算:在报表落库后调用统计计算逻辑,写入 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 实现要点)
-
上传接口
- 使用现有或新建
StorageController的 multipart 接口,接受file+ body(stock_code, market, statement_type, period_type, report_dates_to_import[] 等)。 - 文件类型校验:
.xlsx/.xls(若需要),大小限制(如 10MB)。
- 使用现有或新建
-
解析服务(新模块,如 FinancialReportParseService)
- 使用 xlsx 或 exceljs 读 buffer。
- 按 market + statement_type 选择解析规则(可从配置或策略 map 读取):
- 表头行、科目列、数据列对应关系;
- 哪些列对应「要落库的报告期」(对应 report_date)。
- 输出:
{ reportDate, lines: [ { itemName, value, rowOrder } ] }[],只包含需要落库的期数。
-
报表服务(FinancialReportService)
- 对每个 reportDate:
- 若存在则删除旧 line、旧 stats;
- 插入 financial_report(或更新 updated_at);
- 批量插入 financial_report_line;
- 调用 StatsService 计算并写入 financial_report_stats。
- 事务包裹,保证要么全部成功要么全部回滚。
- 对每个 reportDate:
-
统计计算服务(FinancialReportStatsService)
- 输入:report_id,以及该 report 的 lines(或从 DB 再查一次)。
- 根据 statement_type 计算:
- 资产负债表:总资产、总负债、净资产、流动比率、速动比率等;
- 利润表:营收、净利润、毛利率等;
- 现金流量表:经营/投资/筹资现金流等。
- 写入 financial_report_stats(upsert by report_id + stat_key + dimension)。
-
校验与错误处理
- 解析阶段:无法识别表头、缺少必填列时返回 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。 |
按上述设计,你可以在不开放的前提下,先实现「上传 → 解析 → 落库 → 预计算」闭环,再在现有库上扩展列表/筛选/分类展示与绘图。