Compare commits
3 Commits
main
..
d093a322d3
| Author | SHA1 | Date | |
|---|---|---|---|
| d093a322d3 | |||
| 04105ad6eb | |||
| 143cd1fbf8 |
@@ -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`),简写只在显示层做。核对数据时无歧义。
|
||||
|
||||
@@ -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,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,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,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,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,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,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,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,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,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,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,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,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
|
||||
|
||||
|
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
# 泡泡玛特财报分析底稿
|
||||
|
||||
本目录是泡泡玛特的唯一维护数据层。日常只维护 [泡泡玛特_财报分析底稿.xlsx](泡泡玛特_财报分析底稿.xlsx):图表使用的 `data/ppmt/*.csv` 由脚本生成,不直接编辑。
|
||||
|
||||
## 工作簿结构
|
||||
|
||||
- `说明`:维护入口、口径和颜色约定。
|
||||
- `资产负债表`:期末时点的财报明细。
|
||||
- `利润表`:半年累计与全年累计的财报明细。
|
||||
- `现金流量表`:现金流量表披露的原始明细。
|
||||
- `资产分类规则`:把财报明细映射为“类现金、投资资产、有息负债”等分析分类的规则;不维护分类金额。
|
||||
- `检查`:资产负债、现金流量的勾稽差额。出现红色警告时,应回到来源核对,不能为了消除警告而改数。
|
||||
|
||||
## 更新流程
|
||||
|
||||
1. 将新财报 PDF、截图或原始表格保留在 `data/ppmt/raw/`,作为证据文件。
|
||||
2. 由 AI 读取证据后,将直接披露的科目、期间和金额录入本工作簿的蓝色数字区域,并记录来源定位。
|
||||
3. 检查期间、单位和勾稽关系;确认后再生成图表 CSV 与图表。
|
||||
|
||||
只维护基础值:收入、门店数、财报科目等。同比、占比、利润率、H2 单半年和分析分类小计都由后续脚本计算。若财报另有披露占比,可作为核对依据,不应成为第二套手工维护的数据。
|
||||
|
||||
期间约定:`FY` 是全年累计/年末,`H1` 是上半年累计/六月末;不得把来源的 `H2` 直接当作下半年单期。金额单位为人民币亿元。
|
||||
Binary file not shown.
@@ -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
@@ -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,由底稿生成,不是维护入口。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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。');
|
||||
|
||||
Reference in New Issue
Block a user