Refactor financial chart implementation
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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 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) => [
|
||||
periodFor(item.date),
|
||||
item.inventory.toFixed(1),
|
||||
item.receivable.toFixed(1),
|
||||
item.payable.toFixed(1),
|
||||
(item.inventory + item.receivable - item.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} 行数据。`);
|
||||
Reference in New Issue
Block a user