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} 行数据。`);