Compare commits
5 Commits
main
...
44c6437b64
| Author | SHA1 | Date | |
|---|---|---|---|
| 44c6437b64 | |||
| 9605228c3f | |||
| 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 数据列与图表展示(柱/点标签、tooltip)统一保留一位小数(如 `71.1`、`311.4%`),四舍五入;确实需要其他精度时必须在 `footnote()` 或代码注释里说明原因。
|
||||||
|
|
||||||
**CSV 存四位年份**(`2026H1`),简写只在显示层做。核对数据时无歧义。
|
**CSV 存四位年份**(`2026H1`),简写只在显示层做。核对数据时无歧义。
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
period,retail,online,robot_store,wholesale,total_revenue
|
||||||
|
2024H1,57.4,25.9,8.1,8.6,45.59
|
||||||
|
2024H2,49.0,35.2,5.4,10.4,84.78
|
||||||
|
2024FY,51.9,31.9,6.4,9.8,130.37
|
||||||
|
2025H1,49.8,39.6,5.9,4.7,138.75
|
||||||
|
2025H2,44.5,47.0,3.7,4.7,232.45
|
||||||
|
2025FY,46.5,44.3,4.6,4.7,371.20
|
||||||
|
2026H1,53.1,36.2,5.5,5.2,171.74
|
||||||
|
@@ -0,0 +1,3 @@
|
|||||||
|
period,retail,online,robot_store,wholesale,total_revenue
|
||||||
|
2024FY,51.9,31.9,6.4,9.8,130.37
|
||||||
|
2025FY,46.5,44.3,4.6,4.7,371.20
|
||||||
|
@@ -0,0 +1,6 @@
|
|||||||
|
period,retail,online,robot_store,wholesale,total_revenue
|
||||||
|
2024H1,57.4,25.9,8.1,8.6,45.59
|
||||||
|
2024H2,49.0,35.2,5.4,10.4,84.78
|
||||||
|
2025H1,49.8,39.6,5.9,4.7,138.75
|
||||||
|
2025H2,44.5,47.0,3.7,4.7,232.45
|
||||||
|
2026H1,53.1,36.2,5.5,5.2,171.74
|
||||||
|
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` 直接当作下半年单期。金额单位为人民币亿元。
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
region,channel,period,revenue
|
||||||
|
中国,零售店,2024H1,20.04
|
||||||
|
中国,零售店,2024FY,46.01
|
||||||
|
中国,零售店,2025H1,44.06
|
||||||
|
中国,零售店,2025FY,100.75
|
||||||
|
中国,零售店,2026H1,60.90
|
||||||
|
中国,机器人商店,2024H1,3.38
|
||||||
|
中国,机器人商店,2024FY,7.51
|
||||||
|
中国,机器人商店,2025H1,6.78
|
||||||
|
中国,机器人商店,2025FY,13.46
|
||||||
|
中国,机器人商店,2026H1,7.79
|
||||||
|
中国,线上渠道,2024H1,9.41
|
||||||
|
中国,线上渠道,2024FY,27.72
|
||||||
|
中国,线上渠道,2025H1,29.37
|
||||||
|
中国,线上渠道,2025FY,85.22
|
||||||
|
中国,线上渠道,2026H1,47.79
|
||||||
|
中国,批发及其他,2024H1,2.39
|
||||||
|
中国,批发及其他,2024FY,7.63
|
||||||
|
中国,批发及其他,2025H1,2.61
|
||||||
|
中国,批发及其他,2025FY,9.08
|
||||||
|
中国,批发及其他,2026H1,5.53
|
||||||
|
亚太,零售店,2024H1,4.85
|
||||||
|
亚太,零售店,2024FY,16.68
|
||||||
|
亚太,零售店,2025H1,14.93
|
||||||
|
亚太,零售店,2025FY,43.19
|
||||||
|
亚太,零售店,2026H1,17.16
|
||||||
|
亚太,机器人商店,2024H1,0.17
|
||||||
|
亚太,机器人商店,2024FY,0.40
|
||||||
|
亚太,机器人商店,2025H1,0.33
|
||||||
|
亚太,机器人商店,2025FY,0.95
|
||||||
|
亚太,机器人商店,2026H1,0.57
|
||||||
|
亚太,线上渠道,2024H1,1.65
|
||||||
|
亚太,线上渠道,2024FY,9.72
|
||||||
|
亚太,线上渠道,2025H1,10.69
|
||||||
|
亚太,线上渠道,2025FY,30.53
|
||||||
|
亚太,线上渠道,2026H1,6.44
|
||||||
|
亚太,批发及其他,2024H1,1.29
|
||||||
|
亚太,批发及其他,2024FY,4.30
|
||||||
|
亚太,批发及其他,2025H1,2.56
|
||||||
|
亚太,批发及其他,2025FY,5.44
|
||||||
|
亚太,批发及其他,2026H1,1.59
|
||||||
|
美洲,零售店,2024H1,0.87
|
||||||
|
美洲,零售店,2024FY,3.41
|
||||||
|
美洲,零售店,2025H1,7.37
|
||||||
|
美洲,零售店,2025FY,20.04
|
||||||
|
美洲,零售店,2026H1,9.03
|
||||||
|
美洲,机器人商店,2024H1,0.13
|
||||||
|
美洲,机器人商店,2024FY,0.38
|
||||||
|
美洲,机器人商店,2025H1,1.05
|
||||||
|
美洲,机器人商店,2025FY,2.32
|
||||||
|
美洲,机器人商店,2026H1,1.04
|
||||||
|
美洲,线上渠道,2024H1,0.64
|
||||||
|
美洲,线上渠道,2024FY,3.64
|
||||||
|
美洲,线上渠道,2025H1,13.27
|
||||||
|
美洲,线上渠道,2025FY,43.54
|
||||||
|
美洲,线上渠道,2026H1,7.22
|
||||||
|
美洲,批发及其他,2024H1,0.19
|
||||||
|
美洲,批发及其他,2024FY,0.58
|
||||||
|
美洲,批发及其他,2025H1,0.96
|
||||||
|
美洲,批发及其他,2025FY,2.17
|
||||||
|
美洲,批发及其他,2026H1,1.62
|
||||||
|
欧洲及其他,零售店,2024H1,0.41
|
||||||
|
欧洲及其他,零售店,2024FY,1.57
|
||||||
|
欧洲及其他,零售店,2025H1,2.72
|
||||||
|
欧洲及其他,零售店,2025FY,8.56
|
||||||
|
欧洲及其他,零售店,2026H1,4.03
|
||||||
|
欧洲及其他,机器人商店,2024H1,0.01
|
||||||
|
欧洲及其他,机器人商店,2024FY,0.02
|
||||||
|
欧洲及其他,机器人商店,2025H1,0.04
|
||||||
|
欧洲及其他,机器人商店,2025FY,0.17
|
||||||
|
欧洲及其他,机器人商店,2026H1,0.11
|
||||||
|
欧洲及其他,线上渠道,2024H1,0.11
|
||||||
|
欧洲及其他,线上渠道,2024FY,0.55
|
||||||
|
欧洲及其他,线上渠道,2025H1,1.65
|
||||||
|
欧洲及其他,线上渠道,2025FY,5.05
|
||||||
|
欧洲及其他,线上渠道,2026H1,0.68
|
||||||
|
欧洲及其他,批发及其他,2024H1,0.05
|
||||||
|
欧洲及其他,批发及其他,2024FY,0.25
|
||||||
|
欧洲及其他,批发及其他,2025H1,0.36
|
||||||
|
欧洲及其他,批发及其他,2025FY,0.73
|
||||||
|
欧洲及其他,批发及其他,2026H1,0.24
|
||||||
|
Binary file not shown.
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. 当前架构
|
## 2. 当前架构
|
||||||
|
|
||||||
```
|
```
|
||||||
原始财报 / PDF / XLSX(仓库外保存)
|
原始财报 / PDF / XLSX / 截图(证据文件)
|
||||||
│ 人工、AI 或脚本:抽取、归一化、口径确认
|
│ 人工或 AI 提取、复核并记录来源定位
|
||||||
▼
|
▼
|
||||||
data/{company}/*.csv ← 运行时数据
|
data/{company}/rawData/财报分析底稿.xlsx ← 财报分析底稿:唯一可维护的数据层
|
||||||
|
│ 脚本:分类、同比/占比、年度/半年视图、勾稽
|
||||||
|
▼
|
||||||
|
data/{company}/*.csv ← 图表运行时数据,可随时重建,禁止手工编辑
|
||||||
▼
|
▼
|
||||||
manifest.json ← 展示编排与图表配置的唯一事实来源
|
manifest.json ← 展示编排与图表配置的唯一事实来源
|
||||||
├─ chartCatalog ← 每张图只定义一次
|
├─ chartCatalog ← 每张图只定义一次
|
||||||
@@ -69,7 +72,8 @@ js/main.js + js/ui.js ← 解析引用、渲染三栏、导出和
|
|||||||
- `js/charts/index.js`:注册 `key → build 函数`。
|
- `js/charts/index.js`:注册 `key → build 函数`。
|
||||||
- `js/charts/*.js`:只处理该图的业务字段与 ECharts 表达。
|
- `js/charts/*.js`:只处理该图的业务字段与 ECharts 表达。
|
||||||
- `js/theme.js`:字体、配色、图例、坐标轴、脚注和水印等统一视觉规范。
|
- `js/theme.js`:字体、配色、图例、坐标轴、脚注和水印等统一视觉规范。
|
||||||
- `data/{company}/`:图表需要的规范化 CSV。
|
- `data/{company}/rawData/`:财报分析底稿。每家公司以一份可读、可维护的工作簿作为入口;基础科目、期间、单位、来源文件/工作表/定位信息在此维护,分析分类通过独立规则映射,不复制维护分类金额。
|
||||||
|
- `data/{company}/*.csv`:图表需要的规范化 CSV,由底稿生成,不是维护入口。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { cashflowCompositionChart } from './ppmt_cashflow_composition.js';
|
|||||||
import { workingCapitalDaysChart } from './ppmt_working_capital_days.js';
|
import { workingCapitalDaysChart } from './ppmt_working_capital_days.js';
|
||||||
import { expenseStructureChart } from './ppmt_expense_structure.js';
|
import { expenseStructureChart } from './ppmt_expense_structure.js';
|
||||||
import { channelRevenueChart } from './ppmt_channel_revenue.js';
|
import { channelRevenueChart } from './ppmt_channel_revenue.js';
|
||||||
|
import { channelShareChart } from './ppmt_channel_share.js';
|
||||||
import { storesRetailChart } from './ppmt_stores_retail.js';
|
import { storesRetailChart } from './ppmt_stores_retail.js';
|
||||||
import { storesRobotChart } from './ppmt_stores_robot.js';
|
import { storesRobotChart } from './ppmt_stores_robot.js';
|
||||||
import { regionShareChart } from './ppmt_region_share.js';
|
import { regionShareChart } from './ppmt_region_share.js';
|
||||||
@@ -62,6 +63,7 @@ export const chartRegistry = {
|
|||||||
'ppmt-working-capital-days': workingCapitalDaysChart,
|
'ppmt-working-capital-days': workingCapitalDaysChart,
|
||||||
'ppmt-expense-structure': expenseStructureChart,
|
'ppmt-expense-structure': expenseStructureChart,
|
||||||
'ppmt-channel-revenue': channelRevenueChart,
|
'ppmt-channel-revenue': channelRevenueChart,
|
||||||
|
'ppmt-channel-share': channelShareChart,
|
||||||
'ppmt-stores-retail': storesRetailChart,
|
'ppmt-stores-retail': storesRetailChart,
|
||||||
'ppmt-stores-robot': storesRobotChart,
|
'ppmt-stores-robot': storesRobotChart,
|
||||||
'ppmt-region-share': regionShareChart,
|
'ppmt-region-share': regionShareChart,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { col } from '../csv.js';
|
||||||
|
import {
|
||||||
|
CHANNEL_COLORS, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, standardLegend,
|
||||||
|
financialViewLabel, barSeries, footnote, watermarks, LAYOUT,
|
||||||
|
} from '../theme.js';
|
||||||
|
|
||||||
|
const CHANNELS = [
|
||||||
|
{ key: 'retail', name: '零售店', color: CHANNEL_COLORS.retail, labelColor: '#fff' },
|
||||||
|
{ key: 'online', name: '线上渠道', color: CHANNEL_COLORS.online, labelColor: '#fff' },
|
||||||
|
{ key: 'robot_store', name: '机器人商店', color: CHANNEL_COLORS.robotStore, labelColor: '#fff' },
|
||||||
|
{ key: 'wholesale', name: '批发及其他', color: CHANNEL_COLORS.wholesale, labelColor: PALETTE.ink },
|
||||||
|
];
|
||||||
|
|
||||||
|
function sharePoint(value, labelColor) {
|
||||||
|
if (!Number.isFinite(value) || value === 0) return null;
|
||||||
|
// 3.5% 以下的段放不下 11px 段内标签;当前数据最小为 3.7%(2025H2 机器人商店)
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
label: value >= 3.5
|
||||||
|
? { position: 'inside', color: labelColor }
|
||||||
|
: { show: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 泡泡玛特全渠道收入占比:全部地区合并,每根柱按当期总收入归一化为 100%。 */
|
||||||
|
export function channelShareChart(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(CHANNELS.map((item) => item.name)),
|
||||||
|
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.total_revenue.toFixed(2)} 亿元<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: CHANNELS.map((item) => barSeries({
|
||||||
|
name: item.name,
|
||||||
|
data: rows.map((row) => sharePoint(row[item.key], item.labelColor)),
|
||||||
|
color: item.color,
|
||||||
|
barWidth,
|
||||||
|
stack: 'channel-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('数据来源:泡泡玛特分地区渠道收入披露(中国含港澳台);H2为单半年(全年−H1)\n线上渠道含抽盒机、天猫、抖音、自研APP及官网等 | @思考的Joey'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
+2
-1
@@ -27,6 +27,7 @@
|
|||||||
"ppmt-working-capital-days": { "key": "ppmt-working-capital-days", "title": "营运资本效率", "csv": "data/ppmt/ppmt_working_capital_days.csv", "height": 700, "filename": "泡泡玛特营运资本效率" },
|
"ppmt-working-capital-days": { "key": "ppmt-working-capital-days", "title": "营运资本效率", "csv": "data/ppmt/ppmt_working_capital_days.csv", "height": 700, "filename": "泡泡玛特营运资本效率" },
|
||||||
"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-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-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-channel-share": { "key": "ppmt-channel-share", "title": "渠道收入占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_channel_share.csv", "annual": "data/ppmt/ppmt_channel_share_annual.csv", "halfyear": "data/ppmt/ppmt_channel_share_halfyear.csv" }, "height": 680, "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": { "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-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-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": "泡泡玛特各地区零售店单店收入" },
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
{ "id": "expense", "name": "费用率", "chartRefs": ["ppmt-margins", "ppmt-expense-structure"] },
|
{ "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-inventory", "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": "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": "region-channel", "name": "区域渠道", "chartRefs": ["ppmt-region-channel", "ppmt-region-yoy", "ppmt-region-yoy-timeseries", "ppmt-channel-revenue", "ppmt-channel-share", "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": "store-revenue", "name": "单店收入", "chartRefs": ["ppmt-retail-store-revenue", "ppmt-robot-store-revenue"] }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
|
|
||||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const RAW_DIR = path.join(ROOT, 'data/ppmt/raw');
|
const RAW_DIR = path.join(ROOT, 'data/ppmt/raw');
|
||||||
|
const RAWDATA_DIR = path.join(ROOT, 'data/ppmt/rawData');
|
||||||
const OUTPUT_DIR = path.join(ROOT, 'data/ppmt');
|
const OUTPUT_DIR = path.join(ROOT, 'data/ppmt');
|
||||||
|
|
||||||
const FILES = {
|
const FILES = {
|
||||||
@@ -565,8 +566,8 @@ writeViewFiles('ppmt_expense_structure', ['period', 'sales_expense', 'admin_expe
|
|||||||
sales_expense: 2, admin_expense: 2, financial_expense: 2, four_expense_rate: 1,
|
sales_expense: 2, admin_expense: 2, financial_expense: 2, four_expense_rate: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
function readRuntimeRows(filename) {
|
function readCsvRows(dir, filename) {
|
||||||
const [header, ...lines] = fs.readFileSync(path.join(OUTPUT_DIR, filename), 'utf8').trim().split(/\r?\n/);
|
const [header, ...lines] = fs.readFileSync(path.join(dir, filename), 'utf8').trim().split(/\r?\n/);
|
||||||
const headers = header.split(',');
|
const headers = header.split(',');
|
||||||
return lines.map((line) => Object.fromEntries(line.split(',').map((cell, index) => {
|
return lines.map((line) => Object.fromEntries(line.split(',').map((cell, index) => {
|
||||||
const value = Number(cell);
|
const value = Number(cell);
|
||||||
@@ -574,6 +575,10 @@ function readRuntimeRows(filename) {
|
|||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readRuntimeRows(filename) {
|
||||||
|
return readCsvRows(OUTPUT_DIR, filename);
|
||||||
|
}
|
||||||
|
|
||||||
function writeRuntimeViews(base, headers, views, digits = {}) {
|
function writeRuntimeViews(base, headers, views, digits = {}) {
|
||||||
writeViewFiles(base, headers, views, digits);
|
writeViewFiles(base, headers, views, digits);
|
||||||
}
|
}
|
||||||
@@ -698,3 +703,45 @@ writeViewFiles('ppmt_inventory', ['period', 'inventory', 'yoy'], inventoryViews,
|
|||||||
}, 2017);
|
}, 2017);
|
||||||
|
|
||||||
console.log('已生成第二批连续图表的全部、年度、半年 CSV。');
|
console.log('已生成第二批连续图表的全部、年度、半年 CSV。');
|
||||||
|
|
||||||
|
// 渠道收入占比:底表 data/ppmt/rawData/地区渠道收入.csv 整理自
|
||||||
|
// 泡泡玛特-rawdata.xlsx「各地区渠道收入」工作表(中国含港澳台的四地区 × 四渠道明细)。
|
||||||
|
// 全球合计 = 四地区加总;披露口径只有 H1 与全年,H2 单半年 = 全年 − H1;
|
||||||
|
// 占比在金额视图之后计算,按当期四渠道合计归一化,不可用占比直接相减。
|
||||||
|
const CHANNEL_NAME_TO_KEY = {
|
||||||
|
零售店: 'retail',
|
||||||
|
机器人商店: 'robot_store',
|
||||||
|
线上渠道: 'online',
|
||||||
|
批发及其他: 'wholesale',
|
||||||
|
};
|
||||||
|
const channelRevenueByPeriod = new Map();
|
||||||
|
readCsvRows(RAWDATA_DIR, '地区渠道收入.csv').forEach(({ region, channel, period, revenue }) => {
|
||||||
|
const key = CHANNEL_NAME_TO_KEY[channel];
|
||||||
|
if (!key) throw new Error(`地区渠道收入底表存在未知渠道:${channel}`);
|
||||||
|
if (!period || !Number.isFinite(revenue)) throw new Error(`地区渠道收入底表行不完整:${region} ${channel} ${period} ${revenue}`);
|
||||||
|
if (!channelRevenueByPeriod.has(period)) {
|
||||||
|
channelRevenueByPeriod.set(period, { retail: 0, online: 0, robot_store: 0, wholesale: 0 });
|
||||||
|
}
|
||||||
|
channelRevenueByPeriod.get(period)[key] += revenue;
|
||||||
|
});
|
||||||
|
|
||||||
|
const channelAmountViews = buildViews((year, type) => {
|
||||||
|
const bucket = channelRevenueByPeriod.get(type === 'H1' ? `${year}H1` : `${year}FY`);
|
||||||
|
return bucket ?? null;
|
||||||
|
});
|
||||||
|
const channelShareViews = Object.fromEntries(Object.entries(channelAmountViews).map(([view, rows]) => [
|
||||||
|
view,
|
||||||
|
rows.map(({ period, ...amounts }) => {
|
||||||
|
const total = Object.values(amounts).reduce((sum, value) => sum + value, 0);
|
||||||
|
return {
|
||||||
|
period,
|
||||||
|
...Object.fromEntries(Object.entries(amounts).map(([key, value]) => [key, value / total * 100])),
|
||||||
|
total_revenue: total,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
writeRuntimeViews('ppmt_channel_share', ['period', 'retail', 'online', 'robot_store', 'wholesale', 'total_revenue'], channelShareViews, {
|
||||||
|
retail: 1, online: 1, robot_store: 1, wholesale: 1, total_revenue: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('已生成渠道收入占比的全部、年度、半年 CSV。');
|
||||||
|
|||||||
Reference in New Issue
Block a user