837 lines
34 KiB
JavaScript
837 lines
34 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
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 = {
|
|
cashflow: '泡泡玛特_现金流量表_合并报表_20260828_134646.csv',
|
|
income: '泡泡玛特_利润表_合并报表_20260828_134510.csv',
|
|
balance: '泡泡玛特_资产负债表_合并报表_20260828_134503.csv',
|
|
indicators: '泡泡玛特_财务指标_合并报表_20260828_134712.csv',
|
|
revenueComposition: '泡泡玛特_营收构成_20260828_134749.csv',
|
|
};
|
|
|
|
function clean(cell = '') {
|
|
return cell.trim().replace(/^\uFEFF/, '').replace(/^="(.*)"$/, '$1');
|
|
}
|
|
|
|
function toNumber(cell) {
|
|
const value = clean(cell).replace(/^=/, '');
|
|
if (!value) return null;
|
|
if (value.endsWith('%')) {
|
|
const percentage = Number(value.slice(0, -1));
|
|
return Number.isFinite(percentage) ? percentage / 100 : null;
|
|
}
|
|
const number = Number(value);
|
|
return Number.isFinite(number) ? number : null;
|
|
}
|
|
|
|
function readStatement(filename) {
|
|
const lines = fs.readFileSync(path.join(RAW_DIR, filename), 'utf8')
|
|
.trim()
|
|
.split(/\r?\n/)
|
|
.map((line) => line.split(','));
|
|
const dates = lines[1].slice(3).map(clean);
|
|
const values = new Map(lines.slice(2).map((cells) => [clean(cells[0]), cells.slice(3)]));
|
|
return { dates, values };
|
|
}
|
|
|
|
function row(statement, name) {
|
|
const values = statement.values.get(name);
|
|
if (!values) throw new Error(`原始报表缺少行:${name}`);
|
|
return values.map(toNumber);
|
|
}
|
|
|
|
function periodFor(date) {
|
|
const [year, month] = date.split('-');
|
|
if (month === '06') return `${year}H1`;
|
|
if (month === '12') return `${year}FY`;
|
|
throw new Error(`不支持的报表日期:${date}`);
|
|
}
|
|
|
|
function periodForRevenue(date) {
|
|
const [year, quarter] = date.split('-Q');
|
|
if (quarter === '2') return `${year}H1`;
|
|
if (quarter === '4') return `${year}FY`;
|
|
throw new Error(`不支持的营收构成期间:${date}`);
|
|
}
|
|
|
|
function readRevenueComposition(filename) {
|
|
const lines = fs.readFileSync(path.join(RAW_DIR, filename), 'utf8')
|
|
.trim()
|
|
.split(/\r?\n/)
|
|
.map((line) => line.split(','));
|
|
const dates = lines[0].slice(2).map(clean);
|
|
const channels = new Map();
|
|
let section = '';
|
|
let isTotal = false;
|
|
|
|
lines.slice(1).forEach((cells) => {
|
|
const first = clean(cells[0]);
|
|
const second = clean(cells[1]);
|
|
if (first.startsWith('按服務類型分-')) {
|
|
section = first.replace('按服務類型分-', '');
|
|
isTotal = false;
|
|
return;
|
|
}
|
|
if (first === '合計') {
|
|
section = '合計';
|
|
isTotal = true;
|
|
return;
|
|
}
|
|
if (second !== '收入' && second !== '收入同比') return;
|
|
const values = cells.slice(2).map(toNumber);
|
|
if (isTotal) channels.set(second === '收入' ? 'totalRevenue' : 'revenueYoy', values);
|
|
else if (section) channels.set(`${section}:${second}`, values);
|
|
});
|
|
return { dates, channels };
|
|
}
|
|
|
|
function writeCSV(filename, headers, rows) {
|
|
const text = [headers.join(','), ...rows.map((values) => values.join(','))].join('\n').concat('\n');
|
|
fs.writeFileSync(path.join(OUTPUT_DIR, filename), text);
|
|
}
|
|
|
|
const cashflow = readStatement(FILES.cashflow);
|
|
const income = readStatement(FILES.income);
|
|
const balance = readStatement(FILES.balance);
|
|
const indicators = readStatement(FILES.indicators);
|
|
const revenueComposition = readRevenueComposition(FILES.revenueComposition);
|
|
|
|
const operatingCashFlow = row(cashflow, '经营活动产生的现金流量净额');
|
|
const netProfit = row(income, '三、净利润');
|
|
|
|
const cashConversionRows = cashflow.dates.map((date, index) => ({
|
|
date,
|
|
operatingCashFlow: operatingCashFlow[index],
|
|
netProfit: netProfit[index],
|
|
})).filter(({ date, operatingCashFlow: cfo, netProfit: profit }) => (
|
|
date >= '2020-01-01' && date.endsWith('-12-31') && cfo !== null && profit !== null
|
|
)).map(({ date, operatingCashFlow: cfo, netProfit: profit }) => [
|
|
periodFor(date),
|
|
(cfo / 100000000).toFixed(2),
|
|
(profit / 100000000).toFixed(2),
|
|
(cfo / profit * 100).toFixed(1),
|
|
]).reverse();
|
|
|
|
writeCSV('ppmt_cash_conversion.csv', [
|
|
'period', 'operating_cash_flow', 'net_profit', 'cash_conversion',
|
|
], cashConversionRows);
|
|
|
|
const cash = row(balance, '货币资金');
|
|
const liabilities = row(balance, '二、负债合计');
|
|
const currentAssets = row(balance, '流动资产合计');
|
|
const currentLiabilities = row(balance, '流动负债合计');
|
|
|
|
const liquidityRows = balance.dates.map((date, index) => ({
|
|
date,
|
|
cash: cash[index],
|
|
liabilities: liabilities[index],
|
|
currentAssets: currentAssets[index],
|
|
currentLiabilities: currentLiabilities[index],
|
|
})).filter((item) => (
|
|
item.date >= '2020-01-01'
|
|
&& item.cash !== null
|
|
&& item.liabilities !== null
|
|
&& item.currentAssets !== null
|
|
&& item.currentLiabilities !== null
|
|
)).map((item) => [
|
|
periodFor(item.date),
|
|
(item.cash / 100000000).toFixed(2),
|
|
(item.liabilities / 100000000).toFixed(2),
|
|
((item.currentAssets - item.currentLiabilities) / 100000000).toFixed(2),
|
|
]).reverse();
|
|
|
|
writeCSV('ppmt_liquidity_working_capital.csv', [
|
|
'period', 'cash', 'total_liabilities', 'net_working_capital',
|
|
], liquidityRows);
|
|
|
|
const investingCashFlow = row(cashflow, '投资活动产生的现金流量净额');
|
|
const financingCashFlow = row(cashflow, '筹资活动产生的现金流量净额');
|
|
const netCashIncrease = row(cashflow, '现金及现金等价物的净增加额');
|
|
const cashflowCompositionRows = cashflow.dates.map((date, index) => ({
|
|
date,
|
|
operating: operatingCashFlow[index],
|
|
investing: investingCashFlow[index],
|
|
financing: financingCashFlow[index],
|
|
netIncrease: netCashIncrease[index],
|
|
})).filter((item) => (
|
|
item.date >= '2020-01-01' && item.date.endsWith('-12-31')
|
|
&& Object.values(item).every((value) => value !== null)
|
|
)).map((item) => [
|
|
periodFor(item.date),
|
|
(item.operating / 100000000).toFixed(2),
|
|
(item.investing / 100000000).toFixed(2),
|
|
(item.financing / 100000000).toFixed(2),
|
|
(item.netIncrease / 100000000).toFixed(2),
|
|
]).reverse();
|
|
|
|
writeCSV('ppmt_cashflow_composition.csv', [
|
|
'period', 'operating_cash_flow', 'investing_cash_flow', 'financing_cash_flow', 'net_cash_increase',
|
|
], cashflowCompositionRows);
|
|
|
|
const inventoryDays = row(indicators, '存货周转天数');
|
|
const receivableDays = row(indicators, '应收票据和应收账款周转天数');
|
|
const payableDays = row(indicators, '应付票据和应付账款周转天数');
|
|
const workingCapitalDaysRows = indicators.dates.map((date, index) => ({
|
|
date,
|
|
inventory: inventoryDays[index],
|
|
receivable: receivableDays[index],
|
|
payable: payableDays[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();
|
|
|
|
writeCSV('ppmt_working_capital_days.csv', [
|
|
'period', 'inventory_days', 'receivable_days', 'payable_days', 'cash_conversion_cycle',
|
|
], workingCapitalDaysRows);
|
|
|
|
const revenue = row(income, '一、营业总收入');
|
|
const salesExpense = row(income, '减:销售费用');
|
|
const adminExpense = row(income, '管理费用');
|
|
const financialExpense = row(income, '财务费用');
|
|
const fourExpenseRate = row(income, '四项费用率');
|
|
const expenseRows = income.dates.map((date, index) => ({
|
|
date,
|
|
revenue: revenue[index],
|
|
sales: salesExpense[index],
|
|
admin: adminExpense[index],
|
|
financial: financialExpense[index],
|
|
rate: fourExpenseRate[index],
|
|
})).filter((item) => (
|
|
item.date >= '2020-01-01' && item.date.endsWith('-12-31')
|
|
&& Object.values(item).every((value) => value !== null)
|
|
)).map((item) => [
|
|
periodFor(item.date),
|
|
(item.sales / 100000000).toFixed(2),
|
|
(item.admin / 100000000).toFixed(2),
|
|
(item.financial / 100000000).toFixed(2),
|
|
(item.rate * 100).toFixed(1),
|
|
]).reverse();
|
|
|
|
writeCSV('ppmt_expense_structure.csv', [
|
|
'period', 'sales_expense', 'admin_expense', 'financial_expense', 'four_expense_rate',
|
|
], expenseRows);
|
|
|
|
const channelKeys = [
|
|
['零售店銷售收益', 'retail'],
|
|
['線上銷售收益', 'online'],
|
|
['機器人商店銷售收益', 'robot_store'],
|
|
['批發收益', 'wholesale'],
|
|
];
|
|
const channelRows = revenueComposition.dates.map((date, index) => {
|
|
const values = Object.fromEntries(channelKeys.map(([name, key]) => [
|
|
key,
|
|
revenueComposition.channels.get(`${name}:收入`)?.[index] ?? null,
|
|
]));
|
|
return {
|
|
date,
|
|
...values,
|
|
totalRevenue: revenueComposition.channels.get('totalRevenue')?.[index] ?? null,
|
|
revenueYoy: revenueComposition.channels.get('revenueYoy')?.[index] ?? null,
|
|
};
|
|
}).filter((item) => (
|
|
item.date >= '2020-Q2'
|
|
&& Object.values(item).every((value) => value !== null)
|
|
)).map((item) => [
|
|
periodForRevenue(item.date),
|
|
(item.retail / 100000000).toFixed(2),
|
|
(item.online / 100000000).toFixed(2),
|
|
(item.robot_store / 100000000).toFixed(2),
|
|
(item.wholesale / 100000000).toFixed(2),
|
|
(item.totalRevenue / 100000000).toFixed(2),
|
|
(item.revenueYoy * 100).toFixed(1),
|
|
]).reverse();
|
|
|
|
writeCSV('ppmt_channel_revenue.csv', [
|
|
'period', 'retail', 'online', 'robot_store', 'wholesale', 'total_revenue', 'revenue_yoy',
|
|
], channelRows);
|
|
|
|
console.log(`已生成现金转换 ${cashConversionRows.length}、流动性 ${liquidityRows.length}、现金流 ${cashflowCompositionRows.length}、营运资本 ${workingCapitalDaysRows.length}、费用 ${expenseRows.length}、渠道 ${channelRows.length} 行数据。`);
|
|
|
|
// 多维度图表:FY 为全年,H1/H2 为独立半年。所有拆分和同比均在数据层完成。
|
|
const VIEW_YEARS = Array.from({ length: 8 }, (_, index) => 2019 + index);
|
|
const OUTPUT_START_YEAR = 2020;
|
|
const HUNDRED_MILLION = 100000000;
|
|
|
|
function isComplete(values) {
|
|
return values && Object.values(values).every((value) => Number.isFinite(value));
|
|
}
|
|
|
|
function subtractPeriod(fullYear, firstHalf) {
|
|
if (!isComplete(fullYear) || !isComplete(firstHalf)) return null;
|
|
return Object.fromEntries(Object.keys(fullYear).map((key) => [key, fullYear[key] - firstHalf[key]]));
|
|
}
|
|
|
|
function buildViews(readPeriod) {
|
|
const views = { all: [], annual: [], halfyear: [] };
|
|
VIEW_YEARS.forEach((year) => {
|
|
const h1 = readPeriod(year, 'H1');
|
|
const fy = readPeriod(year, 'FY');
|
|
const h2 = subtractPeriod(fy, h1);
|
|
if (h1) {
|
|
const row = { period: `${year}H1`, ...h1 };
|
|
views.halfyear.push(row);
|
|
views.all.push(row);
|
|
}
|
|
if (h2) {
|
|
const row = { period: `${year}H2`, ...h2 };
|
|
views.halfyear.push(row);
|
|
views.all.push(row);
|
|
}
|
|
if (fy) {
|
|
const row = { period: `${year}FY`, ...fy };
|
|
views.annual.push(row);
|
|
views.all.push(row);
|
|
}
|
|
});
|
|
return views;
|
|
}
|
|
|
|
function addPeriods(firstHalf, secondHalf) {
|
|
if (!isComplete(firstHalf) || !isComplete(secondHalf)) return null;
|
|
return Object.fromEntries(Object.keys(firstHalf).map((key) => [key, firstHalf[key] + secondHalf[key]]));
|
|
}
|
|
|
|
// IP、品类等运营数据的 H2 已是单独半年,年度值应由 H1 + H2 汇总。
|
|
function buildViewsFromHalfYears(readPeriod) {
|
|
const views = { all: [], annual: [], halfyear: [] };
|
|
VIEW_YEARS.forEach((year) => {
|
|
const h1 = readPeriod(year, 'H1');
|
|
const h2 = readPeriod(year, 'H2');
|
|
const fy = addPeriods(h1, h2);
|
|
if (h1) {
|
|
const row = { period: `${year}H1`, ...h1 };
|
|
views.halfyear.push(row);
|
|
views.all.push(row);
|
|
}
|
|
if (h2) {
|
|
const row = { period: `${year}H2`, ...h2 };
|
|
views.halfyear.push(row);
|
|
views.all.push(row);
|
|
}
|
|
if (fy) {
|
|
const row = { period: `${year}FY`, ...fy };
|
|
views.annual.push(row);
|
|
views.all.push(row);
|
|
}
|
|
});
|
|
return views;
|
|
}
|
|
|
|
function yoyFor(rows, key, outputKey = key) {
|
|
const byPeriod = new Map(rows.map((row) => [row.period, row]));
|
|
return rows.map((row) => {
|
|
const year = Number(row.period.slice(0, 4));
|
|
const prior = byPeriod.get(`${year - 1}${row.period.slice(4)}`);
|
|
return {
|
|
...row,
|
|
[outputKey]: prior && prior[key] !== 0 ? (row[key] / prior[key] - 1) * 100 : null,
|
|
};
|
|
});
|
|
}
|
|
|
|
function toOutputRows(rows, headers, digits = {}, startYear = OUTPUT_START_YEAR) {
|
|
return rows
|
|
.filter((row) => Number(row.period.slice(0, 4)) >= startYear)
|
|
.map((row) => headers.map((header) => {
|
|
if (header === 'period') return row.period;
|
|
const value = row[header];
|
|
return Number.isFinite(value) ? value.toFixed(digits[header] ?? 2) : '';
|
|
}));
|
|
}
|
|
|
|
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, startYear));
|
|
});
|
|
}
|
|
|
|
const incomeDateIndex = new Map(income.dates.map((date, index) => [date, index]));
|
|
const incomeColumns = {
|
|
revenue: row(income, '一、营业总收入'),
|
|
cost: row(income, '营业成本'),
|
|
sales_expense: row(income, '减:销售费用'),
|
|
admin_expense: row(income, '管理费用'),
|
|
net_profit: row(income, '三、净利润'),
|
|
non_gaap_net_profit: row(income, '非国际公认会计准则净利润'),
|
|
};
|
|
|
|
function incomePeriod(year, type) {
|
|
const date = `${year}-${type === 'H1' ? '06-30' : '12-31'}`;
|
|
const index = incomeDateIndex.get(date);
|
|
if (index === undefined) return null;
|
|
const result = Object.fromEntries(Object.entries(incomeColumns).map(([key, values]) => [key, values[index]]));
|
|
// 2020 年非 GAAP 净利未披露,沿用现有图表的归母净利替代口径。
|
|
result.non_gaap_net_profit ??= result.net_profit;
|
|
return isComplete(result) ? result : null;
|
|
}
|
|
|
|
const cashflowDateIndex = new Map(cashflow.dates.map((date, index) => [date, index]));
|
|
const cashflowColumns = {
|
|
operating_cash_flow: row(cashflow, '经营活动产生的现金流量净额'),
|
|
investing_cash_flow: row(cashflow, '投资活动产生的现金流量净额'),
|
|
financing_cash_flow: row(cashflow, '筹资活动产生的现金流量净额'),
|
|
net_cash_increase: row(cashflow, '现金及现金等价物的净增加额'),
|
|
};
|
|
|
|
function cashflowPeriod(year, type) {
|
|
const date = `${year}-${type === 'H1' ? '06-30' : '12-31'}`;
|
|
const index = cashflowDateIndex.get(date);
|
|
if (index === undefined) return null;
|
|
const result = Object.fromEntries(Object.entries(cashflowColumns).map(([key, values]) => [key, values[index]]));
|
|
return isComplete(result) ? result : null;
|
|
}
|
|
|
|
function inBillion(values) {
|
|
return Object.fromEntries(Object.entries(values).map(([key, value]) => [key, value / HUNDRED_MILLION]));
|
|
}
|
|
|
|
const growthViews = buildViews((year, type) => {
|
|
const values = incomePeriod(year, type);
|
|
return values && {
|
|
non_gaap_net_profit: values.non_gaap_net_profit,
|
|
gross_profit: values.revenue - values.cost,
|
|
revenue: values.revenue,
|
|
cost: values.cost,
|
|
};
|
|
});
|
|
Object.keys(growthViews).forEach((view) => {
|
|
['non_gaap_net_profit', 'gross_profit', 'revenue', 'cost'].forEach((key) => {
|
|
growthViews[view] = yoyFor(growthViews[view], key);
|
|
});
|
|
});
|
|
writeViewFiles('ppmt_growth', ['period', 'non_gaap_net_profit', 'gross_profit', 'revenue', 'cost'], growthViews, {
|
|
non_gaap_net_profit: 1, gross_profit: 1, revenue: 1, cost: 1,
|
|
});
|
|
|
|
const coreProfitViews = buildViews((year, type) => {
|
|
const values = incomePeriod(year, type);
|
|
return values && { core_profit: (values.revenue - values.cost - values.sales_expense - values.admin_expense) / HUNDRED_MILLION };
|
|
});
|
|
Object.keys(coreProfitViews).forEach((view) => {
|
|
coreProfitViews[view] = yoyFor(coreProfitViews[view], 'core_profit', 'yoy');
|
|
});
|
|
writeViewFiles('ppmt_core_profit', ['period', 'core_profit', 'yoy'], coreProfitViews, { core_profit: 2, yoy: 1 });
|
|
|
|
const marginAmountViews = buildViews((year, type) => {
|
|
const values = incomePeriod(year, type);
|
|
return values && {
|
|
revenue: values.revenue,
|
|
gross_profit: values.revenue - values.cost,
|
|
sales_expense: values.sales_expense,
|
|
admin_expense: values.admin_expense,
|
|
non_gaap_net_profit: values.non_gaap_net_profit,
|
|
};
|
|
});
|
|
const marginsViews = Object.fromEntries(Object.entries(marginAmountViews).map(([view, rows]) => [
|
|
view,
|
|
rows.map((values) => {
|
|
if (values.revenue === 0) return values;
|
|
return {
|
|
period: values.period,
|
|
gross_margin: values.gross_profit / values.revenue * 100,
|
|
sales_expense_rate: values.sales_expense / values.revenue * 100,
|
|
admin_expense_rate: values.admin_expense / values.revenue * 100,
|
|
non_gaap_net_margin: values.non_gaap_net_profit / values.revenue * 100,
|
|
};
|
|
}),
|
|
]));
|
|
writeViewFiles('ppmt_margins', ['period', 'gross_margin', 'sales_expense_rate', 'admin_expense_rate', 'non_gaap_net_margin'], marginsViews, {
|
|
gross_margin: 2, sales_expense_rate: 2, admin_expense_rate: 2, non_gaap_net_margin: 2,
|
|
});
|
|
|
|
const cashConversionViews = buildViews((year, type) => {
|
|
const flow = cashflowPeriod(year, type);
|
|
const incomeValues = incomePeriod(year, type);
|
|
if (!flow || !incomeValues) return null;
|
|
return inBillion({
|
|
operating_cash_flow: flow.operating_cash_flow,
|
|
net_profit: incomeValues.net_profit,
|
|
});
|
|
});
|
|
Object.values(cashConversionViews).forEach((rows) => rows.forEach((row) => {
|
|
row.cash_conversion = row.net_profit === 0 ? null : row.operating_cash_flow / row.net_profit * 100;
|
|
}));
|
|
writeViewFiles('ppmt_cash_conversion', ['period', 'operating_cash_flow', 'net_profit', 'cash_conversion'], cashConversionViews, {
|
|
operating_cash_flow: 2, net_profit: 2, cash_conversion: 1,
|
|
});
|
|
|
|
const cashflowCompositionViews = buildViews((year, type) => {
|
|
const values = cashflowPeriod(year, type);
|
|
return values && inBillion(values);
|
|
});
|
|
writeViewFiles('ppmt_cashflow_composition', ['period', 'operating_cash_flow', 'investing_cash_flow', 'financing_cash_flow', 'net_cash_increase'], cashflowCompositionViews, {
|
|
operating_cash_flow: 2, investing_cash_flow: 2, financing_cash_flow: 2, net_cash_increase: 2,
|
|
});
|
|
|
|
const channelKeysForViews = [
|
|
['零售店銷售收益', 'retail'],
|
|
['線上銷售收益', 'online'],
|
|
['機器人商店銷售收益', 'robot_store'],
|
|
['批發收益', 'wholesale'],
|
|
];
|
|
const revenueDateIndex = new Map(revenueComposition.dates.map((date, index) => [date, index]));
|
|
function channelPeriod(year, type) {
|
|
const index = revenueDateIndex.get(`${year}-Q${type === 'H1' ? 2 : 4}`);
|
|
if (index === undefined) return null;
|
|
const values = Object.fromEntries(channelKeysForViews.map(([name, key]) => [
|
|
key, revenueComposition.channels.get(`${name}:收入`)?.[index] ?? null,
|
|
]));
|
|
const total = revenueComposition.channels.get('totalRevenue')?.[index] ?? null;
|
|
return isComplete({ ...values, total }) ? inBillion({ ...values, total_revenue: total }) : null;
|
|
}
|
|
|
|
const channelRevenueViews = buildViews(channelPeriod);
|
|
Object.keys(channelRevenueViews).forEach((view) => {
|
|
channelRevenueViews[view] = yoyFor(channelRevenueViews[view], 'total_revenue', 'revenue_yoy');
|
|
});
|
|
writeViewFiles('ppmt_channel_revenue', ['period', 'retail', 'online', 'robot_store', 'wholesale', 'total_revenue', 'revenue_yoy'], channelRevenueViews, {
|
|
retail: 2, online: 2, robot_store: 2, wholesale: 2, total_revenue: 2, revenue_yoy: 1,
|
|
});
|
|
|
|
console.log('已生成首批六图的全部、年度、半年 CSV。');
|
|
|
|
const profitViews = buildViews((year, type) => {
|
|
const values = incomePeriod(year, type);
|
|
return values && inBillion({
|
|
revenue: values.revenue,
|
|
gross_profit: values.revenue - values.cost,
|
|
net_profit: values.net_profit,
|
|
});
|
|
});
|
|
Object.values(profitViews).forEach((rows) => rows.forEach((item) => {
|
|
item.gross_margin = item.revenue === 0 ? null : item.gross_profit / item.revenue * 100;
|
|
}));
|
|
writeViewFiles('ppmt_profit', ['period', 'revenue', 'gross_profit', 'gross_margin', 'net_profit'], profitViews, {
|
|
revenue: 2, gross_profit: 2, gross_margin: 2, net_profit: 2,
|
|
});
|
|
|
|
const coreMarginViews = buildViews((year, type) => {
|
|
const values = incomePeriod(year, type);
|
|
return values && {
|
|
revenue: values.revenue,
|
|
core_profit: values.revenue - values.cost - values.sales_expense - values.admin_expense,
|
|
};
|
|
});
|
|
Object.values(coreMarginViews).forEach((rows) => rows.forEach((item) => {
|
|
item.core_margin = item.revenue === 0 ? null : item.core_profit / item.revenue * 100;
|
|
}));
|
|
writeViewFiles('ppmt_core_margin', ['period', 'core_margin'], coreMarginViews, { core_margin: 1 });
|
|
|
|
const expenseAmountViews = buildViews((year, type) => {
|
|
const values = incomePeriod(year, type);
|
|
if (!values) return null;
|
|
const financialExpense = row(income, '财务费用')[incomeDateIndex.get(`${year}-${type === 'H1' ? '06-30' : '12-31'}`)];
|
|
return {
|
|
revenue: values.revenue,
|
|
sales_expense: values.sales_expense,
|
|
admin_expense: values.admin_expense,
|
|
financial_expense: financialExpense,
|
|
};
|
|
});
|
|
const expenseViews = Object.fromEntries(Object.entries(expenseAmountViews).map(([view, rows]) => [
|
|
view,
|
|
rows.map((item) => ({
|
|
...inBillion(item),
|
|
period: item.period,
|
|
four_expense_rate: (item.sales_expense + item.admin_expense + item.financial_expense) / item.revenue * 100,
|
|
})),
|
|
]));
|
|
writeViewFiles('ppmt_expense_structure', ['period', 'sales_expense', 'admin_expense', 'financial_expense', 'four_expense_rate'], expenseViews, {
|
|
sales_expense: 2, admin_expense: 2, financial_expense: 2, four_expense_rate: 1,
|
|
});
|
|
|
|
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);
|
|
return [headers[index], cell !== '' && Number.isFinite(value) ? value : cell];
|
|
})));
|
|
}
|
|
|
|
function readRuntimeRows(filename) {
|
|
return readCsvRows(OUTPUT_DIR, filename);
|
|
}
|
|
|
|
function writeRuntimeViews(base, headers, views, digits = {}) {
|
|
writeViewFiles(base, headers, views, digits);
|
|
}
|
|
|
|
function buildWideIpViews(filename) {
|
|
const rows = readRuntimeRows(filename);
|
|
const id = Object.keys(rows[0])[0];
|
|
const sourcePeriods = Object.keys(rows[0]).slice(1).filter((period) => /H[12]$/.test(period));
|
|
const years = [...new Set(sourcePeriods.map((period) => period.slice(0, 4)))];
|
|
const halfyear = sourcePeriods;
|
|
const annual = years.filter((year) => sourcePeriods.includes(`${year}H1`) && sourcePeriods.includes(`${year}H2`)).map((year) => `${year}FY`);
|
|
const all = years.flatMap((year) => [
|
|
...(sourcePeriods.includes(`${year}H1`) ? [`${year}H1`] : []),
|
|
...(sourcePeriods.includes(`${year}H2`) ? [`${year}H2`, `${year}FY`] : []),
|
|
]);
|
|
const valuesFor = (periods) => rows.map((row) => [row[id], ...periods.map((period) => {
|
|
if (!period.endsWith('FY')) return row[period];
|
|
return row[`${period.slice(0, 4)}H1`] + row[`${period.slice(0, 4)}H2`];
|
|
})]);
|
|
writeCSV(filename, [id, ...all], valuesFor(all));
|
|
writeCSV(filename.replace('.csv', '_annual.csv'), [id, ...annual], valuesFor(annual));
|
|
writeCSV(filename.replace('.csv', '_halfyear.csv'), [id, ...halfyear], valuesFor(halfyear));
|
|
}
|
|
|
|
buildWideIpViews('ppmt_ip.csv');
|
|
buildWideIpViews('ppmt_ip_share_history.csv');
|
|
|
|
const ipExMonstersRows = readRuntimeRows('ppmt_ip_ex_monsters.csv');
|
|
function ipExMonstersPeriod(year, type) {
|
|
const row = ipExMonstersRows.find((item) => item.period === `${year}${type}`);
|
|
return row ? { revenue: row.revenue } : null;
|
|
}
|
|
const ipExMonstersViews = buildViewsFromHalfYears(ipExMonstersPeriod);
|
|
Object.keys(ipExMonstersViews).forEach((view) => {
|
|
ipExMonstersViews[view] = yoyFor(ipExMonstersViews[view], 'revenue', 'yoy');
|
|
});
|
|
writeRuntimeViews('ppmt_ip_ex_monsters', ['period', 'revenue', 'yoy'], ipExMonstersViews, { revenue: 2, yoy: 1 });
|
|
|
|
const categoryRows = readRuntimeRows('ppmt_category.csv').filter((item) => item.period !== '品类');
|
|
function categoryPeriod(year, type) {
|
|
const row = categoryRows.find((item) => item.period === `${year}${type}`);
|
|
return row && { plush: row.plush, figure: row.figure, mega: row.mega, other: row.other };
|
|
}
|
|
const categoryViews = buildViewsFromHalfYears(categoryPeriod);
|
|
writeRuntimeViews('ppmt_category', ['period', 'plush', 'figure', 'mega', 'other'], categoryViews, {
|
|
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`);
|
|
const views = { all: [], annual: [], halfyear: [] };
|
|
VIEW_YEARS.forEach((year) => {
|
|
const h1 = source.find((row) => row.period === `${year}H1`);
|
|
const yearEnd = source.find((row) => row.period === `${year}H2` || row.period === `${year}FY`);
|
|
if (h1) {
|
|
const row = { ...h1, period: `${year}H1` };
|
|
views.all.push(row);
|
|
views.halfyear.push(row);
|
|
}
|
|
if (yearEnd) {
|
|
const halfyearRow = { ...yearEnd, period: `${year}H2` };
|
|
views.all.push(halfyearRow);
|
|
views.halfyear.push(halfyearRow);
|
|
views.annual.push({ ...yearEnd, period: `${year}FY` });
|
|
}
|
|
});
|
|
writeRuntimeViews(base, headers, views, digits);
|
|
}
|
|
|
|
buildSnapshotViews('ppmt_assets', ['period', 'total_assets', 'cash_like', 'receivables', 'investment', 'operating_assets'], {
|
|
total_assets: 2, cash_like: 2, receivables: 2, investment: 2, operating_assets: 2,
|
|
});
|
|
buildSnapshotViews('ppmt_liquidity_working_capital', ['period', 'cash', 'total_liabilities', 'net_working_capital'], {
|
|
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。');
|
|
|
|
// 渠道收入占比:底表 data/ppmt/rawData/地区渠道收入.csv 整理自
|
|
// 泡泡玛特-rawdata.xlsx「各地区渠道收入」工作表(中国含港澳台的四地区 × 四渠道明细)。
|
|
// 全球合计 = 四地区加总;披露口径只有 H1 与全年,H2 单半年 = 全年 − H1;
|
|
// 占比在金额视图之后计算,按当期四渠道合计归一化,不可用占比直接相减。
|
|
const CHANNEL_NAME_TO_KEY = {
|
|
零售店: 'retail',
|
|
机器人商店: 'robot_store',
|
|
线上渠道: 'online',
|
|
批发及其他: 'wholesale',
|
|
};
|
|
const ONLINE_SUB_NAME_TO_KEY = {
|
|
泡泡玛特抽盒机: 'roboshop',
|
|
天猫旗舰店: 'tmall',
|
|
抖音平台: 'douyin',
|
|
自研APP及官方网站: 'own_app',
|
|
Shopee: 'shopee',
|
|
TikTok: 'tiktok',
|
|
亚马逊: 'amazon',
|
|
其他线上渠道: 'online_other',
|
|
};
|
|
const channelRevenueByPeriod = new Map();
|
|
readCsvRows(RAWDATA_DIR, '地区渠道收入.csv').forEach(({ region, channel, period, revenue }) => {
|
|
const key = CHANNEL_NAME_TO_KEY[channel];
|
|
if (!key) {
|
|
// 线上细分行只进分地区图;未知渠道仍视为底表错误。
|
|
if (!ONLINE_SUB_NAME_TO_KEY[channel]) throw new Error(`地区渠道收入底表存在未知渠道:${channel}`);
|
|
return;
|
|
}
|
|
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。');
|
|
|
|
// 分地区渠道收入占比:线上渠道按披露细分拆开,各地区细分不同。
|
|
// 底表中「线上渠道」合计行仅作核对值;细分空缺期已按 0 录入并核对
|
|
// (美洲区 2026H1 起 TikTok 未再产生收入、亚马逊 2026 年起开售,与线上合计勾稽一致)。
|
|
const REGION_KEY_BY_NAME = { 中国: 'china', 亚太: 'apac', 美洲: 'americas', 欧洲及其他: 'europe' };
|
|
const REGION_SERIES = {
|
|
china: ['retail', 'robot_store', 'roboshop', 'tmall', 'douyin', 'online_other', 'wholesale'],
|
|
apac: ['retail', 'robot_store', 'own_app', 'shopee', 'online_other', 'wholesale'],
|
|
americas: ['retail', 'robot_store', 'own_app', 'tiktok', 'amazon', 'online_other', 'wholesale'],
|
|
europe: ['retail', 'robot_store', 'own_app', 'online_other', 'wholesale'],
|
|
};
|
|
const TOP_AND_SUB_NAME_TO_KEY = { ...CHANNEL_NAME_TO_KEY, ...ONLINE_SUB_NAME_TO_KEY };
|
|
const regionAmounts = new Map();
|
|
const regionOnlineDisclosed = new Map();
|
|
readCsvRows(RAWDATA_DIR, '地区渠道收入.csv').forEach(({ region, channel, period, revenue }) => {
|
|
const regionKey = REGION_KEY_BY_NAME[region];
|
|
if (!regionKey) throw new Error(`地区渠道收入底表存在未知地区:${region}`);
|
|
if (channel === '线上渠道') {
|
|
regionOnlineDisclosed.set(`${regionKey}:${period}`, revenue);
|
|
return;
|
|
}
|
|
const key = TOP_AND_SUB_NAME_TO_KEY[channel];
|
|
if (!key) throw new Error(`地区渠道收入底表存在未知渠道:${channel}`);
|
|
if (!period || !Number.isFinite(revenue)) throw new Error(`地区渠道收入底表行不完整:${region} ${channel} ${period} ${revenue}`);
|
|
const bucketKey = `${regionKey}:${period}`;
|
|
if (!regionAmounts.has(bucketKey)) regionAmounts.set(bucketKey, {});
|
|
const bucket = regionAmounts.get(bucketKey);
|
|
bucket[key] = (bucket[key] ?? 0) + revenue;
|
|
});
|
|
regionOnlineDisclosed.forEach((disclosed, bucketKey) => {
|
|
const bucket = regionAmounts.get(bucketKey);
|
|
if (!bucket) throw new Error(`地区渠道收入底表缺少线上合计对应的期间:${bucketKey}`);
|
|
const subSum = Object.values(ONLINE_SUB_NAME_TO_KEY).reduce((sum, key) => sum + (bucket[key] ?? 0), 0);
|
|
if (Math.abs(subSum - disclosed) > 0.05) {
|
|
throw new Error(`${bucketKey} 线上细分合计 ${subSum.toFixed(2)} ≠ 披露线上渠道 ${disclosed.toFixed(2)}`);
|
|
}
|
|
});
|
|
|
|
Object.entries(REGION_SERIES).forEach(([regionKey, seriesKeys]) => {
|
|
const amountViews = buildViews((year, type) => {
|
|
const bucket = regionAmounts.get(`${regionKey}:${type === 'H1' ? `${year}H1` : `${year}FY`}`);
|
|
if (!bucket) return null;
|
|
const values = Object.fromEntries(seriesKeys.map((key) => [key, bucket[key] ?? 0]));
|
|
return isComplete(values) ? values : null;
|
|
});
|
|
const shareViews = Object.fromEntries(Object.entries(amountViews).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_${regionKey}`, ['period', ...seriesKeys, 'total_revenue'], shareViews, {
|
|
...Object.fromEntries(seriesKeys.map((key) => [key, 1])),
|
|
total_revenue: 2,
|
|
});
|
|
// 金额视图与占比视图同源(披露金额,非占比×总收入还原),供分地区渠道金额堆叠柱图使用。
|
|
const amountViewsWithTotal = Object.fromEntries(Object.entries(amountViews).map(([view, rows]) => [
|
|
view,
|
|
rows.map((row) => ({
|
|
...row,
|
|
total_revenue: seriesKeys.reduce((sum, key) => sum + row[key], 0),
|
|
})),
|
|
]));
|
|
writeRuntimeViews(`ppmt_channel_amount_${regionKey}`, ['period', ...seriesKeys, 'total_revenue'], amountViewsWithTotal, {
|
|
...Object.fromEntries(seriesKeys.map((key) => [key, 1])),
|
|
total_revenue: 2,
|
|
});
|
|
});
|
|
|
|
console.log('已生成四地区渠道收入占比(线上细分)的全部、年度、半年 CSV。');
|