diff --git a/data/ppmt/ppmt_channel_share.csv b/data/ppmt/ppmt_channel_share.csv new file mode 100644 index 0000000..2b29bed --- /dev/null +++ b/data/ppmt/ppmt_channel_share.csv @@ -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 diff --git a/data/ppmt/ppmt_channel_share_annual.csv b/data/ppmt/ppmt_channel_share_annual.csv new file mode 100644 index 0000000..ce460d6 --- /dev/null +++ b/data/ppmt/ppmt_channel_share_annual.csv @@ -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 diff --git a/data/ppmt/ppmt_channel_share_halfyear.csv b/data/ppmt/ppmt_channel_share_halfyear.csv new file mode 100644 index 0000000..153d0b2 --- /dev/null +++ b/data/ppmt/ppmt_channel_share_halfyear.csv @@ -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 diff --git a/data/ppmt/rawData/地区渠道收入.csv b/data/ppmt/rawData/地区渠道收入.csv new file mode 100644 index 0000000..7118114 --- /dev/null +++ b/data/ppmt/rawData/地区渠道收入.csv @@ -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 diff --git a/data/ppmt/rawData/泡泡玛特-rawdata.xlsx b/data/ppmt/rawData/泡泡玛特-rawdata.xlsx new file mode 100644 index 0000000..ee2c6e9 Binary files /dev/null and b/data/ppmt/rawData/泡泡玛特-rawdata.xlsx differ diff --git a/js/charts/index.js b/js/charts/index.js index a19f66a..c7f7134 100644 --- a/js/charts/index.js +++ b/js/charts/index.js @@ -25,6 +25,7 @@ import { cashflowCompositionChart } from './ppmt_cashflow_composition.js'; import { workingCapitalDaysChart } from './ppmt_working_capital_days.js'; import { expenseStructureChart } from './ppmt_expense_structure.js'; import { channelRevenueChart } from './ppmt_channel_revenue.js'; +import { channelShareChart } from './ppmt_channel_share.js'; import { storesRetailChart } from './ppmt_stores_retail.js'; import { storesRobotChart } from './ppmt_stores_robot.js'; import { regionShareChart } from './ppmt_region_share.js'; @@ -62,6 +63,7 @@ export const chartRegistry = { 'ppmt-working-capital-days': workingCapitalDaysChart, 'ppmt-expense-structure': expenseStructureChart, 'ppmt-channel-revenue': channelRevenueChart, + 'ppmt-channel-share': channelShareChart, 'ppmt-stores-retail': storesRetailChart, 'ppmt-stores-robot': storesRobotChart, 'ppmt-region-share': regionShareChart, diff --git a/js/charts/ppmt_channel_share.js b/js/charts/ppmt_channel_share.js new file mode 100644 index 0000000..cea360f --- /dev/null +++ b/js/charts/ppmt_channel_share.js @@ -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 `${params[0].axisValue} 总收入 ${row.total_revenue.toFixed(2)} 亿元
${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: 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'), + ], + }; +} diff --git a/manifest.json b/manifest.json index ffc2f9b..0b10ebd 100644 --- a/manifest.json +++ b/manifest.json @@ -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-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-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-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": "泡泡玛特各地区零售店单店收入" }, @@ -40,7 +41,7 @@ { "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": "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"] } ] }, diff --git a/scripts/normalize_ppmt_financials.mjs b/scripts/normalize_ppmt_financials.mjs index 174507a..2cbf922 100644 --- a/scripts/normalize_ppmt_financials.mjs +++ b/scripts/normalize_ppmt_financials.mjs @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); 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 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, }); -function readRuntimeRows(filename) { - const [header, ...lines] = fs.readFileSync(path.join(OUTPUT_DIR, filename), 'utf8').trim().split(/\r?\n/); +function readCsvRows(dir, filename) { + const [header, ...lines] = fs.readFileSync(path.join(dir, filename), 'utf8').trim().split(/\r?\n/); const headers = header.split(','); return lines.map((line) => Object.fromEntries(line.split(',').map((cell, index) => { 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 = {}) { writeViewFiles(base, headers, views, digits); } @@ -698,3 +703,45 @@ writeViewFiles('ppmt_inventory', ['period', 'inventory', 'yoy'], inventoryViews, }, 2017); 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。');