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) => { 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 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, }); // 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。');