From b3c7c30a1fb8e45ad203ab1f678cbc70b1392e68 Mon Sep 17 00:00:00 2001 From: joey-home Date: Sun, 6 Sep 2026 22:31:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E6=B3=A1=E6=B3=A1?= =?UTF-8?q?=E7=8E=9B=E7=89=B9=E5=9B=BE=E8=A1=A8=EF=BC=8C=E5=8A=A0=E5=85=A5?= =?UTF-8?q?=E5=8D=95=E5=BA=97=E6=94=B6=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/ppmt/ppmt_inventory.csv | 17 +++ data/ppmt/ppmt_inventory_annual.csv | 10 ++ data/ppmt/ppmt_inventory_halfyear.csv | 8 ++ data/ppmt/ppmt_retail_store_revenue.csv | 8 ++ .../ppmt/ppmt_retail_store_revenue_annual.csv | 3 + .../ppmt_retail_store_revenue_halfyear.csv | 6 + data/ppmt/ppmt_robot_store_revenue.csv | 8 ++ data/ppmt/ppmt_robot_store_revenue_annual.csv | 3 + .../ppmt_robot_store_revenue_halfyear.csv | 6 + data/ppmt/ppmt_stores_retail_share.csv | 6 + data/ppmt/ppmt_stores_retail_share_annual.csv | 3 + .../ppmt_stores_retail_share_halfyear.csv | 4 + data/ppmt/ppmt_working_capital_days.csv | 26 ++--- js/charts/index.js | 8 ++ js/charts/ppmt_channel_revenue.js | 8 +- js/charts/ppmt_inventory.js | 82 +++++++++++++ js/charts/ppmt_retail_store_revenue.js | 110 ++++++++++++++++++ js/charts/ppmt_robot_store_revenue.js | 108 +++++++++++++++++ js/charts/ppmt_stores_retail_share.js | 83 +++++++++++++ js/charts/ppmt_working_capital_days.js | 4 +- manifest.json | 9 +- scripts/normalize_ppmt_financials.mjs | 76 ++++++++++-- 22 files changed, 566 insertions(+), 30 deletions(-) create mode 100644 data/ppmt/ppmt_inventory.csv create mode 100644 data/ppmt/ppmt_inventory_annual.csv create mode 100644 data/ppmt/ppmt_inventory_halfyear.csv create mode 100644 data/ppmt/ppmt_retail_store_revenue.csv create mode 100644 data/ppmt/ppmt_retail_store_revenue_annual.csv create mode 100644 data/ppmt/ppmt_retail_store_revenue_halfyear.csv create mode 100644 data/ppmt/ppmt_robot_store_revenue.csv create mode 100644 data/ppmt/ppmt_robot_store_revenue_annual.csv create mode 100644 data/ppmt/ppmt_robot_store_revenue_halfyear.csv create mode 100644 data/ppmt/ppmt_stores_retail_share.csv create mode 100644 data/ppmt/ppmt_stores_retail_share_annual.csv create mode 100644 data/ppmt/ppmt_stores_retail_share_halfyear.csv create mode 100644 js/charts/ppmt_inventory.js create mode 100644 js/charts/ppmt_retail_store_revenue.js create mode 100644 js/charts/ppmt_robot_store_revenue.js create mode 100644 js/charts/ppmt_stores_retail_share.js diff --git a/data/ppmt/ppmt_inventory.csv b/data/ppmt/ppmt_inventory.csv new file mode 100644 index 0000000..aadda13 --- /dev/null +++ b/data/ppmt/ppmt_inventory.csv @@ -0,0 +1,17 @@ +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 diff --git a/data/ppmt/ppmt_inventory_annual.csv b/data/ppmt/ppmt_inventory_annual.csv new file mode 100644 index 0000000..89a7f46 --- /dev/null +++ b/data/ppmt/ppmt_inventory_annual.csv @@ -0,0 +1,10 @@ +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 diff --git a/data/ppmt/ppmt_inventory_halfyear.csv b/data/ppmt/ppmt_inventory_halfyear.csv new file mode 100644 index 0000000..92d7508 --- /dev/null +++ b/data/ppmt/ppmt_inventory_halfyear.csv @@ -0,0 +1,8 @@ +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 diff --git a/data/ppmt/ppmt_retail_store_revenue.csv b/data/ppmt/ppmt_retail_store_revenue.csv new file mode 100644 index 0000000..007f6fb --- /dev/null +++ b/data/ppmt/ppmt_retail_store_revenue.csv @@ -0,0 +1,8 @@ +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 diff --git a/data/ppmt/ppmt_retail_store_revenue_annual.csv b/data/ppmt/ppmt_retail_store_revenue_annual.csv new file mode 100644 index 0000000..4effe5c --- /dev/null +++ b/data/ppmt/ppmt_retail_store_revenue_annual.csv @@ -0,0 +1,3 @@ +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 diff --git a/data/ppmt/ppmt_retail_store_revenue_halfyear.csv b/data/ppmt/ppmt_retail_store_revenue_halfyear.csv new file mode 100644 index 0000000..bba7d33 --- /dev/null +++ b/data/ppmt/ppmt_retail_store_revenue_halfyear.csv @@ -0,0 +1,6 @@ +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 diff --git a/data/ppmt/ppmt_robot_store_revenue.csv b/data/ppmt/ppmt_robot_store_revenue.csv new file mode 100644 index 0000000..97b9e96 --- /dev/null +++ b/data/ppmt/ppmt_robot_store_revenue.csv @@ -0,0 +1,8 @@ +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 diff --git a/data/ppmt/ppmt_robot_store_revenue_annual.csv b/data/ppmt/ppmt_robot_store_revenue_annual.csv new file mode 100644 index 0000000..7fb9f12 --- /dev/null +++ b/data/ppmt/ppmt_robot_store_revenue_annual.csv @@ -0,0 +1,3 @@ +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 diff --git a/data/ppmt/ppmt_robot_store_revenue_halfyear.csv b/data/ppmt/ppmt_robot_store_revenue_halfyear.csv new file mode 100644 index 0000000..022c101 --- /dev/null +++ b/data/ppmt/ppmt_robot_store_revenue_halfyear.csv @@ -0,0 +1,6 @@ +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 diff --git a/data/ppmt/ppmt_stores_retail_share.csv b/data/ppmt/ppmt_stores_retail_share.csv new file mode 100644 index 0000000..0f43733 --- /dev/null +++ b/data/ppmt/ppmt_stores_retail_share.csv @@ -0,0 +1,6 @@ +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 diff --git a/data/ppmt/ppmt_stores_retail_share_annual.csv b/data/ppmt/ppmt_stores_retail_share_annual.csv new file mode 100644 index 0000000..72f857d --- /dev/null +++ b/data/ppmt/ppmt_stores_retail_share_annual.csv @@ -0,0 +1,3 @@ +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 diff --git a/data/ppmt/ppmt_stores_retail_share_halfyear.csv b/data/ppmt/ppmt_stores_retail_share_halfyear.csv new file mode 100644 index 0000000..b020722 --- /dev/null +++ b/data/ppmt/ppmt_stores_retail_share_halfyear.csv @@ -0,0 +1,4 @@ +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 diff --git a/data/ppmt/ppmt_working_capital_days.csv b/data/ppmt/ppmt_working_capital_days.csv index f79bc4b..07a4363 100644 --- a/data/ppmt/ppmt_working_capital_days.csv +++ b/data/ppmt/ppmt_working_capital_days.csv @@ -1,14 +1,14 @@ period,inventory_days,receivable_days,payable_days,cash_conversion_cycle -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 +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 diff --git a/js/charts/index.js b/js/charts/index.js index a57a2f6..a19f66a 100644 --- a/js/charts/index.js +++ b/js/charts/index.js @@ -28,6 +28,10 @@ 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 = { @@ -61,6 +65,10 @@ 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) { diff --git a/js/charts/ppmt_channel_revenue.js b/js/charts/ppmt_channel_revenue.js index 531228b..9d3f8fe 100644 --- a/js/charts/ppmt_channel_revenue.js +++ b/js/charts/ppmt_channel_revenue.js @@ -31,6 +31,10 @@ 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; @@ -111,8 +115,8 @@ export function channelRevenueChart(rows, { width, height, view = 'all' }) { nameGap: 60, nameRotate: -90, nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, - min: 0, - max: 250, + min: yoyMin, + max: yoyMax, interval: 50, axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' }, splitLine: { show: false }, diff --git a/js/charts/ppmt_inventory.js b/js/charts/ppmt_inventory.js new file mode 100644 index 0000000..a28589d --- /dev/null +++ b/js/charts/ppmt_inventory.js @@ -0,0 +1,82 @@ +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'), + ], + }; +} diff --git a/js/charts/ppmt_retail_store_revenue.js b/js/charts/ppmt_retail_store_revenue.js new file mode 100644 index 0000000..11e481c --- /dev/null +++ b/js/charts/ppmt_retail_store_revenue.js @@ -0,0 +1,110 @@ +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) => `${params[0].axisValue}
${params + .filter((item) => Number.isFinite(item.value)) + .map((item) => `${item.marker}${item.seriesName}: ${Number(item.value).toFixed(1)}${item.seriesName.endsWith('同比') ? '%' : ' 万元/店'}`) + .join('
')}`, + }, + 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'), + ], + }; +} diff --git a/js/charts/ppmt_robot_store_revenue.js b/js/charts/ppmt_robot_store_revenue.js new file mode 100644 index 0000000..b5ec3df --- /dev/null +++ b/js/charts/ppmt_robot_store_revenue.js @@ -0,0 +1,108 @@ +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) => `${params[0].axisValue}
${params + .filter((item) => Number.isFinite(item.value)) + .map((item) => `${item.marker}${item.seriesName}: ${Number(item.value).toFixed(1)}${item.seriesName.endsWith('同比') ? '%' : ' 万元/店'}`) + .join('
')}`, + }, + 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'), + ], + }; +} diff --git a/js/charts/ppmt_stores_retail_share.js b/js/charts/ppmt_stores_retail_share.js new file mode 100644 index 0000000..381964b --- /dev/null +++ b/js/charts/ppmt_stores_retail_share.js @@ -0,0 +1,83 @@ +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 `${params[0].axisValue} 全球合计 ${row.global} 家
${params + .filter((item) => item.value !== null) + .slice() + .reverse() + .map((item) => `${item.marker}${item.seriesName}: ${item.value.toFixed(1)}%`) + .join('
')}`; + }, + }, + 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'), + ], + }; +} diff --git a/js/charts/ppmt_working_capital_days.js b/js/charts/ppmt_working_capital_days.js index 25271bf..5c47184 100644 --- a/js/charts/ppmt_working_capital_days.js +++ b/js/charts/ppmt_working_capital_days.js @@ -36,7 +36,7 @@ export function workingCapitalDaysChart(rows, { width, height }) { title: { ...titleStyle, text: '泡泡玛特营运资本效率', - subtext: '现金转换周期 = 存货周转天数 + 应收周转天数 − 应付周转天数', + subtext: '现金转换周期 = 存货周转天数 + 应收周转天数 − 应付周转天数(中报按181天、年报按365天)', }, 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('数据来源:泡泡玛特财务指标(合并报表)| @思考的Joey'), + footnote('数据来源:泡泡玛特财务指标(合并报表);原始180/360天口径已换算为181/365天 | @思考的Joey'), ], }; } diff --git a/manifest.json b/manifest.json index c8ce31c..ffc2f9b 100644 --- a/manifest.json +++ b/manifest.json @@ -12,6 +12,7 @@ "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半年收入占比" }, @@ -27,16 +28,20 @@ "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-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": "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"] } + { "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"] } ] }, { diff --git a/scripts/normalize_ppmt_financials.mjs b/scripts/normalize_ppmt_financials.mjs index 2c05e84..174507a 100644 --- a/scripts/normalize_ppmt_financials.mjs +++ b/scripts/normalize_ppmt_financials.mjs @@ -184,13 +184,21 @@ 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) => [ - periodFor(item.date), - item.inventory.toFixed(1), - item.receivable.toFixed(1), - item.payable.toFixed(1), - (item.inventory + item.receivable - item.payable).toFixed(1), -]).reverse(); +)).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(); writeCSV('ppmt_working_capital_days.csv', [ 'period', 'inventory_days', 'receivable_days', 'payable_days', 'cash_conversion_cycle', @@ -341,9 +349,9 @@ function yoyFor(rows, key, outputKey = key) { }); } -function toOutputRows(rows, headers, digits = {}) { +function toOutputRows(rows, headers, digits = {}, startYear = OUTPUT_START_YEAR) { return rows - .filter((row) => Number(row.period.slice(0, 4)) >= OUTPUT_START_YEAR) + .filter((row) => Number(row.period.slice(0, 4)) >= startYear) .map((row) => headers.map((header) => { if (header === 'period') return row.period; const value = row[header]; @@ -351,14 +359,14 @@ function toOutputRows(rows, headers, digits = {}) { })); } -function writeViewFiles(base, headers, views, digits) { +function writeViewFiles(base, headers, views, digits, startYear = OUTPUT_START_YEAR) { 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)); + writeCSV(filename, headers, toOutputRows(views[view], headers, digits, startYear)); }); } @@ -614,6 +622,31 @@ 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`); @@ -643,4 +676,25 @@ 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。');