feat: 泡泡玛特增加年度、半年数据区分
This commit is contained in:
@@ -258,3 +258,389 @@ writeCSV('ppmt_channel_revenue.csv', [
|
||||
], 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 = {}) {
|
||||
return rows
|
||||
.filter((row) => Number(row.period.slice(0, 4)) >= OUTPUT_START_YEAR)
|
||||
.map((row) => headers.map((header) => {
|
||||
if (header === 'period') return row.period;
|
||||
const value = row[header];
|
||||
return Number.isFinite(value) ? value.toFixed(digits[header] ?? 2) : '';
|
||||
}));
|
||||
}
|
||||
|
||||
function writeViewFiles(base, headers, views, digits) {
|
||||
const filenames = {
|
||||
all: `${base}.csv`,
|
||||
annual: `${base}_annual.csv`,
|
||||
halfyear: `${base}_halfyear.csv`,
|
||||
};
|
||||
Object.entries(filenames).forEach(([view, filename]) => {
|
||||
writeCSV(filename, headers, toOutputRows(views[view], headers, digits));
|
||||
});
|
||||
}
|
||||
|
||||
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 readRuntimeRows(filename) {
|
||||
const [header, ...lines] = fs.readFileSync(path.join(OUTPUT_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 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,
|
||||
});
|
||||
|
||||
// 资产负债表为时点指标: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,
|
||||
});
|
||||
|
||||
console.log('已生成第二批连续图表的全部、年度、半年 CSV。');
|
||||
|
||||
Reference in New Issue
Block a user