feat: 增加不同地区各渠道收入占比图

This commit is contained in:
R524809
2026-09-08 16:53:44 +08:00
parent 44c6437b64
commit bab02e1efd
25 changed files with 888 additions and 29 deletions
+95
View File
@@ -0,0 +1,95 @@
import fs from 'node:fs/promises';
import path from 'node:path';
const sourcePath = path.resolve('data/brk/rawData/brk_holdings_1988_1997_export.csv');
const outputPath = path.resolve('data/brk/brk_holdings.csv');
const COMPANY_NAMES = [
['american express|美国运通', '美国运通(American Express)'],
['coca-cola|可口可乐', '可口可乐(Coca-Cola)'],
['disney|迪士尼', '华特迪士尼(Walt Disney)'],
['freddie mac|房地美|联邦住', '房地美(Freddie Mac)'],
['gillette|吉列', '吉列(Gillette)'],
['travelers|旅行家', '旅行家集团(Travelers Group)'],
['washington post|华盛顿邮报', '华盛顿邮报(Washington Post)'],
['wells fargo|富国银行', '富国银行(Wells Fargo)'],
['capital cities|大都会', '大都会/ABC(Capital Cities/ABC)'],
['geico|盖可', '盖可保险(GEICO)'],
['gannett|甘尼特', '甘尼特(Gannett)'],
['pnc', 'PNC 银行(PNC Bank)'],
['general dynamics|通用动力', '通用动力(General Dynamics)'],
['guinness|健力士', '健力士(Guinness)'],
["mcdonald|麦当劳", '麦当劳(McDonald\'s)'],
].map(([pattern, name]) => ({ pattern: new RegExp(pattern, 'i'), name }));
function parseCSV(text) {
const rows = [];
let row = [];
let value = '';
let quoted = false;
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
if (char === '"') {
if (quoted && text[i + 1] === '"') {
value += '"';
i += 1;
} else {
quoted = !quoted;
}
} else if (char === ',' && !quoted) {
row.push(value.trim());
value = '';
} else if ((char === '\n' || char === '\r') && !quoted) {
if (char === '\r' && text[i + 1] === '\n') i += 1;
row.push(value.trim());
rows.push(row);
row = [];
value = '';
} else {
value += char;
}
}
if (value || row.length) rows.push([...row, value.trim()]);
return rows;
}
function canonicalName(value) {
return COMPANY_NAMES.find(({ pattern }) => pattern.test(value))?.name || value.trim();
}
function parseShares(value) {
const digits = value.replace(/[^\d]/g, '');
return digits ? Number(digits) : NaN;
}
const rows = parseCSV(await fs.readFile(sourcePath, 'utf8'));
const holdings = [];
for (let index = 0; index < rows.length; index += 1) {
const dateMatch = rows[index][0]?.match(/^(\d{4})\/12\/31$/);
if (!dateMatch) continue;
const year = Number(dateMatch[1]);
const header = rows[index + 1] || [];
const sharesFirst = /股(份)?数量|股数|持股数量/.test(header[0] || '');
const companyIndex = sharesFirst ? 1 : 0;
const sharesIndex = sharesFirst ? 0 : 1;
for (let rowIndex = index + 2; rowIndex < rows.length; rowIndex += 1) {
const record = rows[rowIndex];
if (!record.some(Boolean)) break;
if (/^\d{4}\/12\/31$/.test(record[0] || '')) break;
const company = record[companyIndex];
const shares = parseShares(record[sharesIndex] || '');
if (!company || !Number.isFinite(shares)) continue;
holdings.push({ year, company: canonicalName(company), shares });
}
}
holdings.sort((a, b) => a.year - b.year || a.company.localeCompare(b.company, 'zh-CN'));
const lines = ['year,company,shares', ...holdings.map((row) => `${row.year},${row.company},${row.shares}`)];
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, `${lines.join('\n')}\n`, 'utf8');
console.log(`Normalized ${holdings.length} holdings across ${new Set(holdings.map((row) => row.year)).size} years.`);
+78 -1
View File
@@ -714,10 +714,24 @@ const CHANNEL_NAME_TO_KEY = {
线上渠道: '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) throw new Error(`地区渠道收入底表存在未知渠道:${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 });
@@ -745,3 +759,66 @@ writeRuntimeViews('ppmt_channel_share', ['period', 'retail', 'online', 'robot_st
});
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,
});
});
console.log('已生成四地区渠道收入占比(线上细分)的全部、年度、半年 CSV。');