feat: 增加不同地区各渠道收入占比图
This commit is contained in:
@@ -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.`);
|
||||
@@ -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。');
|
||||
|
||||
Reference in New Issue
Block a user