3 Commits

Author SHA1 Message Date
R524809 d093a322d3 docs: 更新方案文档 2026-09-04 17:33:06 +08:00
R524809 04105ad6eb feat:更新rawData 2026-09-04 16:47:14 +08:00
R524809 143cd1fbf8 docs: add maintainable PPMT financial workbook 2026-08-31 17:49:00 +08:00
29 changed files with 236 additions and 570 deletions
+4
View File
@@ -45,6 +45,10 @@ pnpm test # 全量,只在改了 t
## 数据口径
**底稿与图表数据分层。** `data/{company}/rawData/` 是唯一可维护的数据层。当前泡泡玛特的维护入口是其中的 `泡泡玛特_财报分析底稿.xlsx`:人工或 AI 从 PDF、截图、XLSX 提取的基础值先写入这份底稿,并保留来源文件、工作表和定位信息。`data/{company}/*.csv` 是图表运行时投影,只能由脚本生成,禁止手工编辑。
**维护明细,不维护可推导结果。** 收入、门店数、财报科目等基础值应保存;占比、同比、利润率、H2 单半年和分析分类小计应由脚本生成。若财报披露了占比,可作为核对值保留,但不是图表的唯一输入。资产负债表的“类现金/经营类资产/有息负债”等分类以独立规则映射明细科目,不复制成第二套人工数字。
**数值默认保留一位小数。** CSV 数据列与图表展示(柱/点标签、tooltip)统一保留一位小数(如 `71.1`、`311.4%`),四舍五入;确实需要其他精度时必须在 `footnote()` 或代码注释里说明原因。
**CSV 存四位年份**(`2026H1`),简写只在显示层做。核对数据时无歧义。
-17
View File
@@ -1,17 +0,0 @@
period,inventory,yoy
2017FY,0.16,
2018FY,0.29,87.0
2019FY,0.96,231.4
2020H1,2.24,
2020FY,2.25,134.0
2021H1,3.16,41.0
2021FY,7.89,250.0
2022H1,9.57,203.0
2022FY,8.67,9.9
2023H1,7.59,-20.8
2023FY,9.05,4.4
2024H1,9.17,20.8
2024FY,15.25,68.5
2025H1,22.74,148.0
2025FY,54.73,259.0
2026H1,61.02,168.4
1 period inventory yoy
2 2017FY 0.16
3 2018FY 0.29 87.0
4 2019FY 0.96 231.4
5 2020H1 2.24
6 2020FY 2.25 134.0
7 2021H1 3.16 41.0
8 2021FY 7.89 250.0
9 2022H1 9.57 203.0
10 2022FY 8.67 9.9
11 2023H1 7.59 -20.8
12 2023FY 9.05 4.4
13 2024H1 9.17 20.8
14 2024FY 15.25 68.5
15 2025H1 22.74 148.0
16 2025FY 54.73 259.0
17 2026H1 61.02 168.4
-10
View File
@@ -1,10 +0,0 @@
period,inventory,yoy
2017FY,0.16,
2018FY,0.29,87.0
2019FY,0.96,231.4
2020FY,2.25,134.0
2021FY,7.89,250.0
2022FY,8.67,9.9
2023FY,9.05,4.4
2024FY,15.25,68.5
2025FY,54.73,259.0
1 period inventory yoy
2 2017FY 0.16
3 2018FY 0.29 87.0
4 2019FY 0.96 231.4
5 2020FY 2.25 134.0
6 2021FY 7.89 250.0
7 2022FY 8.67 9.9
8 2023FY 9.05 4.4
9 2024FY 15.25 68.5
10 2025FY 54.73 259.0
-8
View File
@@ -1,8 +0,0 @@
period,inventory,yoy
2020H1,2.24,
2021H1,3.16,41.0
2022H1,9.57,203.0
2023H1,7.59,-20.8
2024H1,9.17,20.8
2025H1,22.74,148.0
2026H1,61.02,168.4
1 period inventory yoy
2 2020H1 2.24
3 2021H1 3.16 41.0
4 2022H1 9.57 203.0
5 2023H1 7.59 -20.8
6 2024H1 9.17 20.8
7 2025H1 22.74 148.0
8 2026H1 61.02 168.4
-8
View File
@@ -1,8 +0,0 @@
period,china_retail_revenue,china_average_stores,china_per_store,apac_retail_revenue,apac_average_stores,apac_per_store,americas_retail_revenue,americas_average_stores,americas_per_store,europe_other_retail_revenue,europe_other_average_stores,europe_other_per_store
2024H1,20.04,381.0,526.0,,,,,,,,,
2024H2,25.97,415.0,625.8,11.83,46.5,2544.1,2.54,16.0,1587.5,1.16,11.5,1008.7
2024FY,46.01,397.0,1158.9,,,,,,,,,
2025H1,44.06,437.0,1008.2,14.93,61.5,2427.6,7.37,31.5,2339.7,2.72,16.0,1700.0
2025H2,56.69,444.0,1276.8,28.26,77.0,3670.1,12.67,52.5,2413.3,5.84,27.0,2163.0
2025FY,100.75,438.0,2300.2,43.19,69.5,6214.4,20.04,43.0,4660.5,8.56,25.0,3424.0
2026H1,60.90,450.0,1353.3,17.16,87.5,1961.1,9.03,75.0,1204.0,4.03,40.5,995.1
1 period china_retail_revenue china_average_stores china_per_store apac_retail_revenue apac_average_stores apac_per_store americas_retail_revenue americas_average_stores americas_per_store europe_other_retail_revenue europe_other_average_stores europe_other_per_store
2 2024H1 20.04 381.0 526.0
3 2024H2 25.97 415.0 625.8 11.83 46.5 2544.1 2.54 16.0 1587.5 1.16 11.5 1008.7
4 2024FY 46.01 397.0 1158.9
5 2025H1 44.06 437.0 1008.2 14.93 61.5 2427.6 7.37 31.5 2339.7 2.72 16.0 1700.0
6 2025H2 56.69 444.0 1276.8 28.26 77.0 3670.1 12.67 52.5 2413.3 5.84 27.0 2163.0
7 2025FY 100.75 438.0 2300.2 43.19 69.5 6214.4 20.04 43.0 4660.5 8.56 25.0 3424.0
8 2026H1 60.90 450.0 1353.3 17.16 87.5 1961.1 9.03 75.0 1204.0 4.03 40.5 995.1
@@ -1,3 +0,0 @@
period,china_retail_revenue,china_average_stores,china_per_store,apac_retail_revenue,apac_average_stores,apac_per_store,americas_retail_revenue,americas_average_stores,americas_per_store,europe_other_retail_revenue,europe_other_average_stores,europe_other_per_store
2024FY,46.01,397.0,1158.9,,,,,,,,,
2025FY,100.75,438.0,2300.2,43.19,69.5,6214.4,20.04,43.0,4660.5,8.56,25.0,3424.0
1 period china_retail_revenue china_average_stores china_per_store apac_retail_revenue apac_average_stores apac_per_store americas_retail_revenue americas_average_stores americas_per_store europe_other_retail_revenue europe_other_average_stores europe_other_per_store
2 2024FY 46.01 397.0 1158.9
3 2025FY 100.75 438.0 2300.2 43.19 69.5 6214.4 20.04 43.0 4660.5 8.56 25.0 3424.0
@@ -1,6 +0,0 @@
period,china_retail_revenue,china_average_stores,china_per_store,apac_retail_revenue,apac_average_stores,apac_per_store,americas_retail_revenue,americas_average_stores,americas_per_store,europe_other_retail_revenue,europe_other_average_stores,europe_other_per_store
2024H1,20.04,381.0,526.0,,,,,,,,,
2024H2,25.97,415.0,625.8,11.83,46.5,2544.1,2.54,16.0,1587.5,1.16,11.5,1008.7
2025H1,44.06,437.0,1008.2,14.93,61.5,2427.6,7.37,31.5,2339.7,2.72,16.0,1700.0
2025H2,56.69,444.0,1276.8,28.26,77.0,3670.1,12.67,52.5,2413.3,5.84,27.0,2163.0
2026H1,60.90,450.0,1353.3,17.16,87.5,1961.1,9.03,75.0,1204.0,4.03,40.5,995.1
1 period china_retail_revenue china_average_stores china_per_store apac_retail_revenue apac_average_stores apac_per_store americas_retail_revenue americas_average_stores americas_per_store europe_other_retail_revenue europe_other_average_stores europe_other_per_store
2 2024H1 20.04 381.0 526.0
3 2024H2 25.97 415.0 625.8 11.83 46.5 2544.1 2.54 16.0 1587.5 1.16 11.5 1008.7
4 2025H1 44.06 437.0 1008.2 14.93 61.5 2427.6 7.37 31.5 2339.7 2.72 16.0 1700.0
5 2025H2 56.69 444.0 1276.8 28.26 77.0 3670.1 12.67 52.5 2413.3 5.84 27.0 2163.0
6 2026H1 60.90 450.0 1353.3 17.16 87.5 1961.1 9.03 75.0 1204.0 4.03 40.5 995.1
-8
View File
@@ -1,8 +0,0 @@
period,china_robot_revenue,china_average_stores,china_per_store,overseas_robot_revenue,overseas_average_stores,overseas_per_store
2024H1,3.38,2207.0,15.3,,,,
2024H2,4.13,2281.5,18.1,0.49,,
2024FY,7.51,2264.5,33.2,0.80,116.0,69.0
2025H1,6.78,2388.0,28.4,1.42,146.5,96.9
2025H2,6.68,2416.5,27.6,2.02,200.5,100.7
2025FY,13.46,2367.5,56.9,3.44,187.0,184.0
2026H1,7.79,2439.0,31.9,1.72,293.0,58.7
1 period,china_robot_revenue,china_average_stores,china_per_store,overseas_robot_revenue,overseas_average_stores,overseas_per_store
2 2024H1,3.38,2207.0,15.3,,,,
3 2024H2,4.13,2281.5,18.1,0.49,,
4 2024FY,7.51,2264.5,33.2,0.80,116.0,69.0
5 2025H1,6.78,2388.0,28.4,1.42,146.5,96.9
6 2025H2,6.68,2416.5,27.6,2.02,200.5,100.7
7 2025FY,13.46,2367.5,56.9,3.44,187.0,184.0
8 2026H1,7.79,2439.0,31.9,1.72,293.0,58.7
@@ -1,3 +0,0 @@
period,china_robot_revenue,china_average_stores,china_per_store,overseas_robot_revenue,overseas_average_stores,overseas_per_store
2024FY,7.51,2264.5,33.2,0.80,116.0,69.0
2025FY,13.46,2367.5,56.9,3.44,187.0,184.0
1 period china_robot_revenue china_average_stores china_per_store overseas_robot_revenue overseas_average_stores overseas_per_store
2 2024FY 7.51 2264.5 33.2 0.80 116.0 69.0
3 2025FY 13.46 2367.5 56.9 3.44 187.0 184.0
@@ -1,6 +0,0 @@
period,china_robot_revenue,china_average_stores,china_per_store,overseas_robot_revenue,overseas_average_stores,overseas_per_store
2024H1,3.38,2207.0,15.3,,,,
2024H2,4.13,2281.5,18.1,0.49,,
2025H1,6.78,2388.0,28.4,1.42,146.5,96.9
2025H2,6.68,2416.5,27.6,2.02,200.5,100.7
2026H1,7.79,2439.0,31.9,1.72,293.0,58.7
1 period,china_robot_revenue,china_average_stores,china_per_store,overseas_robot_revenue,overseas_average_stores,overseas_per_store
2 2024H1,3.38,2207.0,15.3,,,,
3 2024H2,4.13,2281.5,18.1,0.49,,
4 2025H1,6.78,2388.0,28.4,1.42,146.5,96.9
5 2025H2,6.68,2416.5,27.6,2.02,200.5,100.7
6 2026H1,7.79,2439.0,31.9,1.72,293.0,58.7
-6
View File
@@ -1,6 +0,0 @@
period,global,china,apac,americas,europe,other
2024H1,457,87.3,8.5,2.2,2.0,0.0
2024FY,521,82.7,10.4,4.2,2.7,0.0
2025H1,571,77.6,12.1,7.2,3.2,0.0
2025FY,630,70.6,13.5,10.2,5.7,0.0
2026H1,676,67.3,13.3,12.7,6.7,0.0
1 period global china apac americas europe other
2 2024H1 457 87.3 8.5 2.2 2.0 0.0
3 2024FY 521 82.7 10.4 4.2 2.7 0.0
4 2025H1 571 77.6 12.1 7.2 3.2 0.0
5 2025FY 630 70.6 13.5 10.2 5.7 0.0
6 2026H1 676 67.3 13.3 12.7 6.7 0.0
@@ -1,3 +0,0 @@
period,global,china,apac,americas,europe,other
2024FY,521,82.7,10.4,4.2,2.7,0.0
2025FY,630,70.6,13.5,10.2,5.7,0.0
1 period global china apac americas europe other
2 2024FY 521 82.7 10.4 4.2 2.7 0.0
3 2025FY 630 70.6 13.5 10.2 5.7 0.0
@@ -1,4 +0,0 @@
period,global,china,apac,americas,europe,other
2024H1,457,87.3,8.5,2.2,2.0,0.0
2025H1,571,77.6,12.1,7.2,3.2,0.0
2026H1,676,67.3,13.3,12.7,6.7,0.0
1 period global china apac americas europe other
2 2024H1 457 87.3 8.5 2.2 2.0 0.0
3 2025H1 571 77.6 12.1 7.2 3.2 0.0
4 2026H1 676 67.3 13.3 12.7 6.7 0.0
+13 -13
View File
@@ -1,14 +1,14 @@
period,inventory_days,receivable_days,payable_days,cash_conversion_cycle
2020H1,102.0,9.6,27.8,83.8
2020FY,63.9,9.0,28.8,44.1
2021H1,74.7,10.9,41.1,44.6
2021FY,106.9,10.1,30.4,86.6
2022H1,159.9,11.7,44.9,126.6
2022FY,154.0,14.5,47.0,121.5
2023H1,131.9,13.0,54.7,90.2
2023FY,132.7,14.9,51.9,95.7
2024H1,100.6,11.6,54.8,57.4
2024FY,102.4,11.2,53.6,59.9
2025H1,83.5,9.5,49.1,43.9
2025FY,123.3,6.9,36.6,93.6
2026H1,201.2,8.2,42.9,166.5
2020H1,101.4,9.6,27.6,83.3
2020FY,63.0,8.9,28.4,43.5
2021H1,74.3,10.9,40.9,44.3
2021FY,105.4,10.0,29.9,85.5
2022H1,159.0,11.6,44.7,125.9
2022FY,151.8,14.3,46.3,119.8
2023H1,131.2,12.9,54.4,89.7
2023FY,130.9,14.7,51.2,94.4
2024H1,100.0,11.6,54.5,57.1
2024FY,101.0,11.0,52.9,59.1
2025H1,83.1,9.4,48.8,43.7
2025FY,121.6,6.8,36.1,92.3
2026H1,200.0,8.1,42.6,165.5
1 period inventory_days receivable_days payable_days cash_conversion_cycle
2 2020H1 102.0 101.4 9.6 27.8 27.6 83.8 83.3
3 2020FY 63.9 63.0 9.0 8.9 28.8 28.4 44.1 43.5
4 2021H1 74.7 74.3 10.9 41.1 40.9 44.6 44.3
5 2021FY 106.9 105.4 10.1 10.0 30.4 29.9 86.6 85.5
6 2022H1 159.9 159.0 11.7 11.6 44.9 44.7 126.6 125.9
7 2022FY 154.0 151.8 14.5 14.3 47.0 46.3 121.5 119.8
8 2023H1 131.9 131.2 13.0 12.9 54.7 54.4 90.2 89.7
9 2023FY 132.7 130.9 14.9 14.7 51.9 51.2 95.7 94.4
10 2024H1 100.6 100.0 11.6 54.8 54.5 57.4 57.1
11 2024FY 102.4 101.0 11.2 11.0 53.6 52.9 59.9 59.1
12 2025H1 83.5 83.1 9.5 9.4 49.1 48.8 43.9 43.7
13 2025FY 123.3 121.6 6.9 6.8 36.6 36.1 93.6 92.3
14 2026H1 201.2 200.0 8.2 8.1 42.9 42.6 166.5 165.5
Binary file not shown.
Binary file not shown.
+22
View File
@@ -0,0 +1,22 @@
# 泡泡玛特财报分析底稿
本目录是泡泡玛特的唯一维护数据层。日常只维护 [泡泡玛特_财报分析底稿.xlsx](泡泡玛特_财报分析底稿.xlsx):图表使用的 `data/ppmt/*.csv` 由脚本生成,不直接编辑。
## 工作簿结构
- `说明`:维护入口、口径和颜色约定。
- `资产负债表`:期末时点的财报明细。
- `利润表`:半年累计与全年累计的财报明细。
- `现金流量表`:现金流量表披露的原始明细。
- `资产分类规则`:把财报明细映射为“类现金、投资资产、有息负债”等分析分类的规则;不维护分类金额。
- `检查`:资产负债、现金流量的勾稽差额。出现红色警告时,应回到来源核对,不能为了消除警告而改数。
## 更新流程
1. 将新财报 PDF、截图或原始表格保留在 `data/ppmt/raw/`,作为证据文件。
2. 由 AI 读取证据后,将直接披露的科目、期间和金额录入本工作簿的蓝色数字区域,并记录来源定位。
3. 检查期间、单位和勾稽关系;确认后再生成图表 CSV 与图表。
只维护基础值:收入、门店数、财报科目等。同比、占比、利润率、H2 单半年和分析分类小计都由后续脚本计算。若财报另有披露占比,可作为核对依据,不应成为第二套手工维护的数据。
期间约定:`FY` 是全年累计/年末,`H1` 是上半年累计/六月末;不得把来源的 `H2` 直接当作下半年单期。金额单位为人民币亿元。
+172
View File
@@ -0,0 +1,172 @@
# 财报图表项目数据架构改造方案
> 状态:建议实施
>
> 目标:保持静态图表站点的低复杂度,同时建立可追溯、可重复生成的数据链路。
## 1. 决策
当前不建设在线数据库和后端 API。
本项目目前是静态数据展示工具:浏览器按需读取 CSV,数据量很小,没有多人并发写入、权限控制、运行时查询或服务端计算的需求。此时引入 Postgres、API、鉴权和迁移机制会增加运维成本,但不能解决当前的数据正确性与可维护性问题。
CSV 保留为图表的运行时交付格式;需要改造的是 CSV 之前的数据生产链路。
## 2. 当前问题
### 2.1 权威数据源不一致
设计文档和 `data/ppmt/rawData/README.md` 将 `财报分析底稿.xlsx` 定义为唯一维护入口,但 `scripts/normalize_ppmt_financials.mjs` 实际读取的是 `data/ppmt/raw/*.csv`。底稿更新不会稳定地传递到图表。
### 2.2 派生产物被当作输入
归一化脚本的一部分通过 `readRuntimeRows()` 读取 `data/ppmt/*.csv`,再生成年度、半年等视图。IP、品类、资产等数据不能完全从上游重新构建;运行时 CSV 被删除、手改或过期时,会影响后续结果。
### 2.3 数据更新不完整
已出现底稿含有新期间、但相应图表 CSV 尚未更新的情况。问题不在某个数值,而在于链路没有可重复的全量构建保证。
### 2.4 发布边界不清晰
Vite 目前递归复制 `data/` 下的全部 CSV,因此原始披露 CSV 也会进入 `dist/`。原始证据、维护输入和公开运行时数据应有物理边界。
### 2.5 现有测试只验证展示
`pnpm test` 已能验证 manifest、CSV 可读性、图表渲染和部分视觉规范;还没有验证期间、单位、勾稽、同比口径、来源定位或派生逻辑。
## 3. 目标架构
```text
原始披露文件(PDF / XLSX / 原始导出 CSV,只读证据)
│
▼
人工录入与复核的规范化基础事实层(唯一权威)
│ 数据构建:映射、同比、期间拆分、勾稽、图表数据整形
▼
图表运行时 CSV(完全可重建,不允许手工修改)
│
▼
静态站点:manifest + Vanilla JS + ECharts
```
### 3.1 各层职责
| 层 | 内容 | 是否人工编辑 | 发布到网站 |
| --- | --- | --- | --- |
| 原始证据 | 财报 PDF、公司 XLSX、原始导出 CSV、截图 | 否 | 否 |
| 基础事实层 | 直接披露的数值、期间、单位、来源定位、科目映射 | 是,受控编辑 | 否 |
| 派生层 | H2、同比、利润率、分类汇总、年度/半年视图 | 否,脚本生成 | 否 |
| 图表 CSV | 仅包含图表需要的字段和布局 | 否,脚本生成 | 是 |
## 4. 基础事实层如何维护
基础事实层需要人工维护,但人工只编辑直接披露且可追溯的事实。
允许人工编辑的内容:
- 原始财报直接披露的金额、门店数等基础数值;
- 期间、币种、单位、合并口径;
- 来源文件、页码、表格、行号或单元格定位;
- 科目映射与分类规则,例如“类现金”“投资资产”“经营资产”。
禁止人工编辑的内容:
- 同比、环比、占比、利润率;
- `H2 = FY - H1` 等期间拆分;
- 分析分类小计;
- 任何图表运行时 CSV。
现有 `财报分析底稿.xlsx` 可以继续作为人工录入界面。改造后的要求是:构建脚本必须从这份工作簿或其一次性导出的规范化输入文件读取,不再并行从其他位置读取同一事实。工作簿是编辑器,不是与其他 CSV 并列的第二权威来源。
基础事实建议采用“长表”结构,便于复用、校验和未来接入多家公司:
| company_id | metric_id | period_end | period_type | value | unit | source_file | source_locator | revision |
| --- | --- | --- | --- | ---: | --- | --- | --- | --- |
| ppmt | revenue | 2026-06-30 | H1_YTD | 171.73 | CNY_100M | 2026H1.pdf | p.12 / 利润表 / 营业收入 | 1 |
运营类事实可使用相同结构,或按需要增加 `dimension_type`、`dimension_value`,例如地区、渠道、IP 和品类。
## 5. 目录与构建边界
建议将数据目录拆分,避免将证据文件误发布:
```text
data/
ppmt/
evidence/ # 原始披露文件,只读,不发布
input/ # 基础事实与映射规则,受控维护,不发布
generated/ # 构建中间产物,可删除并重建,不发布
public/
data/
ppmt/ # 仅图表运行时 CSV,发布
scripts/
import_ppmt_facts.* # 从 XLSX 或受控输入导入基础事实
build_ppmt_datasets.* # 从基础事实完整生成图表 CSV
check_ppmt_data.* # 质量与勾稽校验
```
Vite 仅复制 `public/`;不再以“复制整个 `data/` 再过滤扩展名”的方式发布文件。
## 6. 数据构建与校验
推荐提供三个明确命令:
```bash
pnpm data:import # 可选:从人工维护的 XLSX 导出/读取基础事实
pnpm data:build # 从唯一权威源全量生成 generated 与 public/data
pnpm data:check # 校验输入、派生结果与勾稽关系
pnpm test # 图表渲染和展示回归
pnpm build # 静态站点构建
```
`data:build` 必须满足幂等性:在清空 `generated/` 和 `public/data/` 后,仍能只凭 `input/` 与 `evidence/` 完整生成相同的图表数据。构建脚本不得读取自身输出目录作为输入。
`data:check` 至少覆盖:
- 必填字段、数值类型、单位和币种;
- 期间格式、排序、重复与缺失;
- 流量类 `H2 = FY - H1`,时点类 H2/年末口径;
- 同比仅比较同口径上一年期间;
- 资产 = 负债 + 权益、现金流勾稽等适用检查;
- 图表字段 schema、空值约束和合理数值范围;
- 每条人工事实都有来源定位。
## 7. 分阶段实施
### P0:先恢复单一真相源
1. 决定以现有底稿 XLSX 作为人工维护入口。
2. 为底稿建立稳定导入器,输出规范化基础事实;或将其导出为版本化输入 CSV。
3. 将现有脚本中读取运行时 CSV 的逻辑改为读取基础事实。
4. 将图表 CSV 移到仅发布目录,原始证据移出发布路径。
5. 增加 `data:check` 的最小校验与全量重建测试。
完成标准:删除全部图表 CSV 后,仍能从唯一输入源重建所有图表;底稿新增期间后,数据检查能明确指出哪些图表应更新。
### P1:第二家公司接入后抽象共性
1. 固化跨公司通用的基础事实 schema、期间模型、来源定位格式和校验规则。
2. 仅在两个公司确实复用同一字段映射与口径时,抽取通用图表或数据模板。
3. 为 manifest 增加可选的数据契约版本,避免图表代码默默依赖 CSV 列名。
### P2:需要分析型查询时引入本地数据库
当需要跨公司指标筛选、较多事实数据查询、复杂聚合或批量分析时,引入 DuckDB + Parquet 作为本地构建和分析层。它可以替代或补强 `generated/`,但前端仍然发布静态 CSV 或预聚合 JSON。
### P3:满足以下条件才建设在线数据库
仅在出现多人在线录入、权限分级、审计版本、自动数据抓取、实时更新或面向用户的任意组合查询时,再建设 Postgres + 后端 API。
在线数据库只替换“基础事实层的存储与协作方式”;图表运行时仍应优先使用缓存后的静态数据集或受版本控制的 API 响应,不应让每张图直接查询底层事实表。
## 8. 数据库选择原则
“使用数据库”不是替代数据治理的方案。无论存储介质是 XLSX、CSV、DuckDB 还是 Postgres,都必须先满足:
1. 只有一个权威输入源;
2. 派生产物可从该输入源完整重建;
3. 每个数值可回溯到披露来源;
4. 口径、单位和期间语义经过自动校验;
5. 前端只消费稳定的数据契约。
在当前规模下,最优路线是“XLSX 受控人工录入 + 规范化事实 + 自动生成 CSV + 静态发布”,而不是立即迁移在线数据库。
+8 -4
View File
@@ -23,10 +23,13 @@
## 2. 当前架构
```
原始财报 / PDF / XLSX(仓库外保存)
│ 人工、AI 或脚本:抽取、归一化、口径确认
原始财报 / PDF / XLSX / 截图(证据文件)
│ 人工或 AI 提取、复核并记录来源定位
▼
data/{company}/*.csv ← 运行时数据
data/{company}/rawData/财报分析底稿.xlsx ← 财报分析底稿:唯一可维护的数据层
│ 脚本:分类、同比/占比、年度/半年视图、勾稽
▼
data/{company}/*.csv ← 图表运行时数据,可随时重建,禁止手工编辑
▼
manifest.json ← 展示编排与图表配置的唯一事实来源
├─ chartCatalog ← 每张图只定义一次
@@ -69,7 +72,8 @@ js/main.js + js/ui.js ← 解析引用、渲染三栏、导出和
- `js/charts/index.js`:注册 `key → build 函数`。
- `js/charts/*.js`:只处理该图的业务字段与 ECharts 表达。
- `js/theme.js`:字体、配色、图例、坐标轴、脚注和水印等统一视觉规范。
- `data/{company}/`:图表需要的规范化 CSV。
- `data/{company}/rawData/`:财报分析底稿。每家公司以一份可读、可维护的工作簿作为入口;基础科目、期间、单位、来源文件/工作表/定位信息在此维护,分析分类通过独立规则映射,不复制维护分类金额。
- `data/{company}/*.csv`:图表需要的规范化 CSV,由底稿生成,不是维护入口。
---
-8
View File
@@ -28,10 +28,6 @@ import { channelRevenueChart } from './ppmt_channel_revenue.js';
import { storesRetailChart } from './ppmt_stores_retail.js';
import { storesRobotChart } from './ppmt_stores_robot.js';
import { regionShareChart } from './ppmt_region_share.js';
import { inventoryBalanceChart } from './ppmt_inventory.js';
import { storesRetailShareChart } from './ppmt_stores_retail_share.js';
import { retailStoreRevenueChart } from './ppmt_retail_store_revenue.js';
import { robotStoreRevenueChart } from './ppmt_robot_store_revenue.js';
/** 图表构建函数注册表。manifest 只引用这里的 key,不直接引用 JS 函数。 */
export const chartRegistry = {
@@ -65,10 +61,6 @@ export const chartRegistry = {
'ppmt-stores-retail': storesRetailChart,
'ppmt-stores-robot': storesRobotChart,
'ppmt-region-share': regionShareChart,
'ppmt-inventory': inventoryBalanceChart,
'ppmt-stores-retail-share': storesRetailShareChart,
'ppmt-retail-store-revenue': retailStoreRevenueChart,
'ppmt-robot-store-revenue': robotStoreRevenueChart,
};
export function getChartBuilder(key) {
+2 -6
View File
@@ -31,10 +31,6 @@ export function channelRevenueChart(rows, { width, height, view = 'all' }) {
const totalMax = Math.max(...rows.map((row) => num(row.total_revenue) || 0));
const axisMax = NICE_MAX.find((max) => max >= totalMax * 1.02)
?? Math.ceil((totalMax * 1.2) / 100) * 100;
const yoyValues = col(rows, 'revenue_yoy').filter(Number.isFinite);
// 全部/半年视图存在 2022H2 的负增长,右轴需包含负区间,避免折线被 0 轴裁切。
const yoyMin = Math.min(0, Math.floor(Math.min(...yoyValues) / 50) * 50);
const yoyMax = Math.max(50, Math.ceil(Math.max(...yoyValues) / 50) * 50);
// 绘图区像素高:画布高 − grid top 145 − grid bottom 90
const pxPerUnit = (height - LAYOUT.gridTop - 90) / axisMax;
@@ -115,8 +111,8 @@ export function channelRevenueChart(rows, { width, height, view = 'all' }) {
nameGap: 60,
nameRotate: -90,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: yoyMin,
max: yoyMax,
min: 0,
max: 250,
interval: 50,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
splitLine: { show: false },
-82
View File
@@ -1,82 +0,0 @@
import { col } from '../csv.js';
import {
FINANCIAL_COLORS, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, standardLegend,
financialViewLabel, barSeries, lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
/** 泡泡玛特存货余额与同报告期同比:存货为期末时点数。 */
export function inventoryBalanceChart(rows, { width, height, view = 'all' }) {
const yoy = col(rows, 'yoy').filter(Number.isFinite);
const yoyMin = Math.min(0, Math.floor(Math.min(...yoy) / 20) * 20);
const yoyMax = Math.max(50, Math.ceil(Math.max(...yoy) / 50) * 50);
const barWidth = view === 'all' ? 28.8 : 60.8;
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: `泡泡玛特存货余额及同比(${financialViewLabel(view)})`,
subtext: '柱状为报告期末存货余额;折线为同报告期同比(H1 对 H1、全年对全年)',
},
legend: standardLegend(['存货余额', '存货同比']),
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
valueFormatter: (value) => `${value.toFixed(1)}`,
},
xAxis: { ...standalonePeriodAxis, data: col(rows, 'period') },
yAxis: [
{
...axisCommon,
type: 'value',
name: '存货余额(亿元)',
nameLocation: 'middle',
nameGap: 52,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
},
{
...axisCommon,
type: 'value',
name: '同比(%)',
nameLocation: 'middle',
nameGap: 58,
nameRotate: -90,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: yoyMin,
max: yoyMax,
interval: (yoyMax - yoyMin) / 5,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
splitLine: { show: false },
},
],
series: [
{
...barSeries({
name: '存货余额',
data: col(rows, 'inventory'),
color: FINANCIAL_COLORS.investingCashFlow,
barWidth,
formatter: (point) => point.value.toFixed(1),
}),
emphasis: { focus: 'series' },
},
{
...lineSeries({
name: '存货同比',
data: col(rows, 'yoy'),
color: FINANCIAL_COLORS.cashConversion,
formatter: (point) => `${point.value.toFixed(1)}%`,
}),
yAxisIndex: 1,
z: 10,
},
],
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特历年合并资产负债表;存货为报告期末时点数 | @思考的Joey'),
],
};
}
-110
View File
@@ -1,110 +0,0 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, standardLegend,
financialViewLabel, barSeries, lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
const REGIONS = [
{ key: 'china', name: '中国(含港澳台)', color: '#3F7FBE' },
{ key: 'apac', name: '亚太', color: '#36A28F' },
{ key: 'americas', name: '美洲', color: '#E4A349' },
{ key: 'europe_other', name: '欧洲及其他', color: PALETTE.brown },
];
function yoyByPeriod(rows, key) {
const values = new Map(rows.map((row) => [row.period, row[key]]));
return rows.map((row) => {
const year = Number(row.period.slice(0, 4));
const prior = values.get(`${year - 1}${row.period.slice(4)}`);
return Number.isFinite(row[key]) && Number.isFinite(prior) && prior !== 0
? (row[key] / prior - 1) * 100
: null;
});
}
/** 各地区零售店单店收入:收入 ÷ 期初、期末平均零售店数。 */
export function retailStoreRevenueChart(rows, { width, height, view = 'all' }) {
const values = REGIONS.flatMap((region) => col(rows, `${region.key}_per_store`)).filter(Number.isFinite);
const yMax = Math.ceil(Math.max(...values) / 1000) * 1000;
const yoySeries = REGIONS.map((region) => ({ ...region, data: yoyByPeriod(rows, `${region.key}_per_store`) }));
const yoyValues = yoySeries.flatMap((region) => region.data).filter(Number.isFinite);
const yoyMin = Math.min(0, Math.floor(Math.min(...yoyValues) / 50) * 50);
const yoyMax = Math.max(50, Math.ceil(Math.max(...yoyValues) / 50) * 50);
const legendItems = REGIONS.flatMap((region) => [region.name, `${region.name}同比`]);
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: `泡泡玛特各地区零售店单店收入(${financialViewLabel(view)})`,
subtext: '柱状为单店收入;同色虚线为单店收入同比;H2为下半年单期',
},
legend: standardLegend(legendItems, { itemGap: 18 }),
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
formatter: (params) => `<b>${params[0].axisValue}</b><br/>${params
.filter((item) => Number.isFinite(item.value))
.map((item) => `${item.marker}${item.seriesName}: ${Number(item.value).toFixed(1)}${item.seriesName.endsWith('同比') ? '%' : ' 万元/店'}`)
.join('<br/>')}`,
},
xAxis: { ...standalonePeriodAxis, data: col(rows, 'period') },
yAxis: [
{
...axisCommon,
type: 'value',
name: '单店收入(万元/店)',
nameLocation: 'middle',
nameGap: 58,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
max: yMax,
interval: yMax / 5,
},
{
...axisCommon,
type: 'value',
name: '单店收入同比(%)',
nameLocation: 'middle',
nameGap: 60,
nameRotate: -90,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: yoyMin,
max: yoyMax,
interval: 50,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
splitLine: { show: false },
},
],
series: [
...REGIONS.map((region) => ({
...barSeries({
name: region.name,
data: col(rows, `${region.key}_per_store`),
color: region.color,
barWidth: view === 'all' ? 17 : 34,
formatter: (point) => point.value.toFixed(0),
}),
emphasis: { focus: 'series' },
})),
...yoySeries.map((region) => ({
...lineSeries({
name: `${region.name}同比`,
data: region.data,
color: region.color,
width: 2,
formatter: (point) => `${point.value.toFixed(0)}%`,
}),
yAxisIndex: 1,
lineStyle: { color: region.color, width: 2, type: 'dashed' },
z: 10,
})),
],
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特历年公告;收入仅含“零售店”渠道;H2收入=全年−H1\n2024年海外各区域缺少期初店数,相关单店收入留空 | @思考的Joey'),
],
};
}
-108
View File
@@ -1,108 +0,0 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, standardLegend,
financialViewLabel, barSeries, lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
const REGIONS = [
{ key: 'china', name: '中国(含港澳台)', color: '#71AD81' },
{ key: 'overseas', name: '海外', color: PALETTE.brown },
];
function yoyByPeriod(rows, key) {
const values = new Map(rows.map((row) => [row.period, row[key]]));
return rows.map((row) => {
const year = Number(row.period.slice(0, 4));
const prior = values.get(`${year - 1}${row.period.slice(4)}`);
return Number.isFinite(row[key]) && Number.isFinite(prior) && prior !== 0
? (row[key] / prior - 1) * 100
: null;
});
}
/** 中国与海外机器人商店单店收入:收入 ÷ 期初、期末平均机器人商店数。 */
export function robotStoreRevenueChart(rows, { width, height, view = 'all' }) {
const values = REGIONS.flatMap((region) => col(rows, `${region.key}_per_store`)).filter(Number.isFinite);
const yMax = Math.ceil(Math.max(...values) / 50) * 50;
const yoySeries = REGIONS.map((region) => ({ ...region, data: yoyByPeriod(rows, `${region.key}_per_store`) }));
const yoyValues = yoySeries.flatMap((region) => region.data).filter(Number.isFinite);
const yoyMin = Math.min(0, Math.floor(Math.min(...yoyValues) / 50) * 50);
const yoyMax = Math.max(50, Math.ceil(Math.max(...yoyValues) / 50) * 50);
const legendItems = REGIONS.flatMap((region) => [region.name, `${region.name}同比`]);
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: `泡泡玛特机器人商店单店收入(${financialViewLabel(view)})`,
subtext: '柱状为单店收入;同色虚线为单店收入同比;H2为下半年单期',
},
legend: standardLegend(legendItems, { itemGap: 18 }),
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
formatter: (params) => `<b>${params[0].axisValue}</b><br/>${params
.filter((item) => Number.isFinite(item.value))
.map((item) => `${item.marker}${item.seriesName}: ${Number(item.value).toFixed(1)}${item.seriesName.endsWith('同比') ? '%' : ' 万元/店'}`)
.join('<br/>')}`,
},
xAxis: { ...standalonePeriodAxis, data: col(rows, 'period') },
yAxis: [
{
...axisCommon,
type: 'value',
name: '单店收入(万元/店)',
nameLocation: 'middle',
nameGap: 58,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
max: yMax,
interval: yMax / 5,
},
{
...axisCommon,
type: 'value',
name: '单店收入同比(%)',
nameLocation: 'middle',
nameGap: 60,
nameRotate: -90,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: yoyMin,
max: yoyMax,
interval: 50,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
splitLine: { show: false },
},
],
series: [
...REGIONS.map((region) => ({
...barSeries({
name: region.name,
data: col(rows, `${region.key}_per_store`),
color: region.color,
barWidth: view === 'all' ? 28 : 52,
formatter: (point) => point.value.toFixed(0),
}),
emphasis: { focus: 'series' },
})),
...yoySeries.map((region) => ({
...lineSeries({
name: `${region.name}同比`,
data: region.data,
color: region.color,
width: 2,
formatter: (point) => `${point.value.toFixed(0)}%`,
}),
yAxisIndex: 1,
lineStyle: { color: region.color, width: 2, type: 'dashed' },
z: 10,
})),
],
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特历年公告;海外收入=亚太+美洲+欧洲及其他的机器人商店收入;H2收入=全年−H1\n2024H1海外期末机器人商店数未披露,相关单店收入留空 | @思考的Joey'),
],
};
}
-83
View File
@@ -1,83 +0,0 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, standardLegend,
financialViewLabel, barSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
/** 泡泡玛特分地区零售门店占比:每根柱标准化为各期末全球门店数的 100%。 */
const REGIONS = [
{ key: 'china', name: '中国(含港澳台)', color: '#3F7FBE', labelColor: '#fff' },
{ key: 'apac', name: '亚太', color: '#36A28F', labelColor: '#fff' },
{ key: 'americas', name: '美洲', color: '#E4A349', labelColor: PALETTE.ink },
{ key: 'europe', name: '欧洲', color: PALETTE.brown, labelColor: '#fff' },
{ key: 'other', name: '其他', color: PALETTE.sand, labelColor: PALETTE.ink },
];
function sharePoint(value, labelColor) {
if (!Number.isFinite(value) || value === 0) return null;
return {
value,
label: value >= 5
? { position: 'inside', color: labelColor }
: { show: false },
};
}
export function storesRetailShareChart(rows, { width, height, view = 'all' }) {
const periods = col(rows, 'period');
const barWidth = view === 'all' ? 48 : 80;
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: `泡泡玛特分地区零售门店数量占比(${financialViewLabel(view)})`,
subtext: '归一化堆叠:每根柱为当期末全球零售门店数的 100%',
},
legend: standardLegend(REGIONS.map((item) => item.name), { itemGap: 20 }),
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
formatter: (params) => {
const row = rows[periods.indexOf(params[0].axisValue)];
return `<b>${params[0].axisValue}</b> 全球合计 ${row.global} 家<br/>${params
.filter((item) => item.value !== null)
.slice()
.reverse()
.map((item) => `${item.marker}${item.seriesName}: ${item.value.toFixed(1)}%`)
.join('<br/>')}`;
},
},
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 100 },
xAxis: { ...standalonePeriodAxis, data: periods },
yAxis: {
...axisCommon,
type: 'value',
min: 0,
max: 100,
interval: 20,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
},
series: REGIONS.map((item) => barSeries({
name: item.name,
data: rows.map((row) => sharePoint(row[item.key], item.labelColor)),
color: item.color,
barWidth,
stack: 'stores-retail-share',
label: {
show: true,
position: 'inside',
fontSize: 11,
fontFamily: FONT,
fontWeight: 500,
formatter: (point) => `${point.value.toFixed(1)}%`,
},
emphasis: { focus: 'series' },
})),
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特历年公告;门店数为各报告期末时点数\n中国=中国内地+港澳台;其他=全球合计−中国−亚太−美洲−欧洲 | @思考的Joey'),
],
};
}
+2 -2
View File
@@ -36,7 +36,7 @@ export function workingCapitalDaysChart(rows, { width, height }) {
title: {
...titleStyle,
text: '泡泡玛特营运资本效率',
subtext: '现金转换周期 = 存货周转天数 + 应收周转天数 − 应付周转天数(中报按181天、年报按365天)',
subtext: '现金转换周期 = 存货周转天数 + 应收周转天数 − 应付周转天数',
},
legend: { ...legendStyle, top: LAYOUT.legendTop, data: SERIES.map((item) => item.name) },
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
@@ -78,7 +78,7 @@ export function workingCapitalDaysChart(rows, { width, height }) {
})),
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特财务指标(合并报表);原始180/360天口径已换算为181/365天 | @思考的Joey'),
footnote('数据来源:泡泡玛特财务指标(合并报表)| @思考的Joey'),
],
};
}
+2 -7
View File
@@ -12,7 +12,6 @@
"ppmt-bridge": { "key": "ppmt-bridge", "title": "净利率同比拆解", "csv": "data/ppmt/ppmt_margin_bridge.csv", "height": 700, "filename": "泡泡玛特净利率同比拆解" },
"ppmt-margins": { "key": "ppmt-margins", "title": "利润率与费用率拆解", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_margins.csv", "annual": "data/ppmt/ppmt_margins_annual.csv", "halfyear": "data/ppmt/ppmt_margins_halfyear.csv" }, "height": 680, "filename": "泡泡玛特利润率与费用率拆解" },
"ppmt-assets": { "key": "ppmt-assets", "title": "资产结构演变", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_assets.csv", "annual": "data/ppmt/ppmt_assets_annual.csv", "halfyear": "data/ppmt/ppmt_assets_halfyear.csv" }, "height": 680, "filename": "泡泡玛特资产结构" },
"ppmt-inventory": { "key": "ppmt-inventory", "title": "存货余额及同比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_inventory.csv", "annual": "data/ppmt/ppmt_inventory_annual.csv", "halfyear": "data/ppmt/ppmt_inventory_halfyear.csv" }, "height": 680, "filename": "泡泡玛特存货余额及同比" },
"ppmt-ip": { "key": "ppmt-ip", "title": "主要 IP 收入趋势", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_ip.csv", "annual": "data/ppmt/ppmt_ip_annual.csv", "halfyear": "data/ppmt/ppmt_ip_halfyear.csv" }, "height": 720, "filename": "泡泡玛特IP收入趋势" },
"ppmt-ip-share": { "key": "ppmt-ip-share", "title": "主要 IP 收入占比", "csv": "data/ppmt/ppmt_ip_share_2026h1.csv", "height": 720, "filename": "泡泡玛特IP收入占比2026H1" },
"ppmt-ip-share-history": { "key": "ppmt-ip-share-history", "title": "各 IP 半年收入占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_ip_share_history.csv", "annual": "data/ppmt/ppmt_ip_share_history_annual.csv", "halfyear": "data/ppmt/ppmt_ip_share_history_halfyear.csv" }, "height": 700, "filename": "泡泡玛特各IP半年收入占比" },
@@ -28,20 +27,16 @@
"ppmt-expense-structure": { "key": "ppmt-expense-structure", "title": "费用结构与费用率", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_expense_structure.csv", "annual": "data/ppmt/ppmt_expense_structure_annual.csv", "halfyear": "data/ppmt/ppmt_expense_structure_halfyear.csv" }, "height": 700, "filename": "泡泡玛特费用结构与费用率" },
"ppmt-channel-revenue": { "key": "ppmt-channel-revenue", "title": "渠道收入结构与增速", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_channel_revenue.csv", "annual": "data/ppmt/ppmt_channel_revenue_annual.csv", "halfyear": "data/ppmt/ppmt_channel_revenue_halfyear.csv" }, "height": 720, "filename": "泡泡玛特渠道收入结构与增速" },
"ppmt-stores-retail": { "key": "ppmt-stores-retail", "title": "分地区零售门店数量与同比增速", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_stores_retail.csv", "annual": "data/ppmt/ppmt_stores_retail_annual.csv", "halfyear": "data/ppmt/ppmt_stores_retail_halfyear.csv" }, "height": 680, "filename": "泡泡玛特分地区零售门店数量与同比增速" },
"ppmt-stores-retail-share": { "key": "ppmt-stores-retail-share", "title": "分地区零售门店数量占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_stores_retail_share.csv", "annual": "data/ppmt/ppmt_stores_retail_share_annual.csv", "halfyear": "data/ppmt/ppmt_stores_retail_share_halfyear.csv" }, "height": 680, "filename": "泡泡玛特分地区零售门店数量占比" },
"ppmt-retail-store-revenue": { "key": "ppmt-retail-store-revenue", "title": "各地区零售店单店收入", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_retail_store_revenue.csv", "annual": "data/ppmt/ppmt_retail_store_revenue_annual.csv", "halfyear": "data/ppmt/ppmt_retail_store_revenue_halfyear.csv" }, "height": 680, "filename": "泡泡玛特各地区零售店单店收入" },
"ppmt-stores-robot": { "key": "ppmt-stores-robot", "title": "分地区机器人商店数量与同比增速", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_stores_robot.csv", "annual": "data/ppmt/ppmt_stores_robot_annual.csv", "halfyear": "data/ppmt/ppmt_stores_robot_halfyear.csv" }, "height": 680, "filename": "泡泡玛特分地区机器人商店数量与同比增速" },
"ppmt-robot-store-revenue": { "key": "ppmt-robot-store-revenue", "title": "机器人商店单店收入", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_robot_store_revenue.csv", "annual": "data/ppmt/ppmt_robot_store_revenue_annual.csv", "halfyear": "data/ppmt/ppmt_robot_store_revenue_halfyear.csv" }, "height": 680, "filename": "泡泡玛特机器人商店单店收入" },
"ppmt-region-share": { "key": "ppmt-region-share", "title": "分地区营收占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_region_share.csv", "annual": "data/ppmt/ppmt_region_share_annual.csv", "halfyear": "data/ppmt/ppmt_region_share_halfyear.csv" }, "height": 680, "filename": "泡泡玛特分地区营收占比" }
},
"tabs": [
{ "id": "overview", "name": "概览", "chartRefs": ["ppmt-growth", "ppmt-profit", "ppmt-ip-share", "ppmt-region-channel"] },
{ "id": "profitability", "name": "盈利能力", "chartRefs": ["ppmt-growth", "ppmt-core-profit", "ppmt-core-margin", "ppmt-bridge", "ppmt-sankey", "ppmt-cash-conversion", "ppmt-cashflow-composition"] },
{ "id": "expense", "name": "费用率", "chartRefs": ["ppmt-margins", "ppmt-expense-structure"] },
{ "id": "balance-sheet", "name": "资产负债", "chartRefs": ["ppmt-assets", "ppmt-inventory", "ppmt-liquidity-working-capital", "ppmt-working-capital-days"] },
{ "id": "balance-sheet", "name": "资产负债", "chartRefs": ["ppmt-assets", "ppmt-liquidity-working-capital", "ppmt-working-capital-days"] },
{ "id": "ip-category", "name": "IP 与品类", "chartRefs": ["ppmt-ip", "ppmt-ip-share", "ppmt-ip-share-history", "ppmt-ip-ex-monsters", "ppmt-category"] },
{ "id": "region-channel", "name": "区域渠道", "chartRefs": ["ppmt-region-channel", "ppmt-region-yoy", "ppmt-region-yoy-timeseries", "ppmt-channel-revenue", "ppmt-stores-retail", "ppmt-stores-retail-share", "ppmt-stores-robot", "ppmt-region-share"] },
{ "id": "store-revenue", "name": "单店收入", "chartRefs": ["ppmt-retail-store-revenue", "ppmt-robot-store-revenue"] }
{ "id": "region-channel", "name": "区域渠道", "chartRefs": ["ppmt-region-channel", "ppmt-region-yoy", "ppmt-region-yoy-timeseries", "ppmt-channel-revenue", "ppmt-stores-retail", "ppmt-stores-robot", "ppmt-region-share"] }
]
},
{
+11 -65
View File
@@ -184,21 +184,13 @@ const workingCapitalDaysRows = indicators.dates.map((date, index) => ({
})).filter((item) => (
item.date >= '2020-01-01'
&& item.inventory !== null && item.receivable !== null && item.payable !== null
)).map((item) => {
const period = periodFor(item.date);
// 原始指标表按半年 180 天、全年 360 天计算;此处统一换算为自然日 181/365 天。
const factor = period.endsWith('H1') ? 181 / 180 : 365 / 360;
const inventory = item.inventory * factor;
const receivable = item.receivable * factor;
const payable = item.payable * factor;
return [
period,
inventory.toFixed(1),
receivable.toFixed(1),
payable.toFixed(1),
(inventory + receivable - payable).toFixed(1),
];
}).reverse();
)).map((item) => [
periodFor(item.date),
item.inventory.toFixed(1),
item.receivable.toFixed(1),
item.payable.toFixed(1),
(item.inventory + item.receivable - item.payable).toFixed(1),
]).reverse();
writeCSV('ppmt_working_capital_days.csv', [
'period', 'inventory_days', 'receivable_days', 'payable_days', 'cash_conversion_cycle',
@@ -349,9 +341,9 @@ function yoyFor(rows, key, outputKey = key) {
});
}
function toOutputRows(rows, headers, digits = {}, startYear = OUTPUT_START_YEAR) {
function toOutputRows(rows, headers, digits = {}) {
return rows
.filter((row) => Number(row.period.slice(0, 4)) >= startYear)
.filter((row) => Number(row.period.slice(0, 4)) >= OUTPUT_START_YEAR)
.map((row) => headers.map((header) => {
if (header === 'period') return row.period;
const value = row[header];
@@ -359,14 +351,14 @@ function toOutputRows(rows, headers, digits = {}, startYear = OUTPUT_START_YEAR)
}));
}
function writeViewFiles(base, headers, views, digits, startYear = OUTPUT_START_YEAR) {
function writeViewFiles(base, headers, views, digits) {
const filenames = {
all: `${base}.csv`,
annual: `${base}_annual.csv`,
halfyear: `${base}_halfyear.csv`,
};
Object.entries(filenames).forEach(([view, filename]) => {
writeCSV(filename, headers, toOutputRows(views[view], headers, digits, startYear));
writeCSV(filename, headers, toOutputRows(views[view], headers, digits));
});
}
@@ -622,31 +614,6 @@ writeRuntimeViews('ppmt_category', ['period', 'plush', 'figure', 'mega', 'other'
plush: 2, figure: 2, mega: 2, other: 2,
});
// 2024H1 起门店披露可将中国内地与港澳台合并,并按全球总数归一化为地区占比。
const retailStoreRows = readRuntimeRows('ppmt_stores_retail.csv');
const retailStoreShareViews = { all: [], annual: [], halfyear: [] };
retailStoreRows.filter((row) => (
Number(row.period.slice(0, 4)) >= 2024
&& ['mainland', 'hkmtw', 'apac', 'americas', 'europe', 'global'].every((key) => Number.isFinite(row[key]))
)).forEach((row) => {
const china = row.mainland + row.hkmtw;
const other = row.global - china - row.apac - row.americas - row.europe;
const shareRow = {
period: row.period,
global: row.global,
china: china / row.global * 100,
apac: row.apac / row.global * 100,
americas: row.americas / row.global * 100,
europe: row.europe / row.global * 100,
other: other / row.global * 100,
};
retailStoreShareViews.all.push(shareRow);
retailStoreShareViews[row.period.endsWith('FY') ? 'annual' : 'halfyear'].push(shareRow);
});
writeRuntimeViews('ppmt_stores_retail_share', ['period', 'global', 'china', 'apac', 'americas', 'europe', 'other'], retailStoreShareViews, {
global: 0, china: 1, apac: 1, americas: 1, europe: 1, other: 1,
});
// 资产负债表为时点指标:H2 是年末时点,而非“全年累计”。
function buildSnapshotViews(base, headers, digits) {
const source = readRuntimeRows(`${base}.csv`);
@@ -676,25 +643,4 @@ buildSnapshotViews('ppmt_liquidity_working_capital', ['period', 'cash', 'total_l
cash: 2, total_liabilities: 2, net_working_capital: 2,
});
// 存货为资产负债表期末时点数:半年仅取中报期末,年度仅取年报期末。
// 同比严格按相同报告期(H1 对 H1、FY 对 FY)计算,不把中报与年报交叉比较。
const inventoryBalances = row(balance, '存货');
const inventoryViews = { all: [], annual: [], halfyear: [] };
balance.dates.map((date, index) => ({
date,
inventory: inventoryBalances[index],
})).filter(({ date, inventory }) => (
date >= '2017-01-01' && inventory > 0 && (date.endsWith('-06-30') || date.endsWith('-12-31'))
)).sort((a, b) => a.date.localeCompare(b.date)).forEach(({ date, inventory }) => {
const row = { period: periodFor(date), inventory: inventory / HUNDRED_MILLION };
inventoryViews.all.push(row);
inventoryViews[row.period.endsWith('FY') ? 'annual' : 'halfyear'].push(row);
});
Object.keys(inventoryViews).forEach((view) => {
inventoryViews[view] = yoyFor(inventoryViews[view], 'inventory', 'yoy');
});
writeViewFiles('ppmt_inventory', ['period', 'inventory', 'yoy'], inventoryViews, {
inventory: 2, yoy: 1,
}, 2017);
console.log('已生成第二批连续图表的全部、年度、半年 CSV。');