188 lines
6.5 KiB
JavaScript
188 lines
6.5 KiB
JavaScript
|
|
const fs = require('fs');
|
|||
|
|
|
|||
|
|
const API_BASE = 'http://localhost:3000/api';
|
|||
|
|
const USER = '猪2';
|
|||
|
|
|
|||
|
|
const CAT = {
|
|||
|
|
餐饮: 5, 交通: 6, 购物: 7, 房租: 8, 水电燃气: 9, 话费: 10,
|
|||
|
|
亚马逊物流费: 11, 亚马逊广告费: 12, 亚马逊货款: 13, 其他支出: 14,
|
|||
|
|
工资: 1, 亚马逊转账: 2, 利息: 3, 其他收入: 4,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function parseCSV(text) {
|
|||
|
|
const lines = text.split('\n');
|
|||
|
|
// 找到表头行
|
|||
|
|
let headerIdx = -1;
|
|||
|
|
for (let i = 0; i < lines.length; i++) {
|
|||
|
|
if (lines[i].startsWith('交易时间,')) { headerIdx = i; break; }
|
|||
|
|
}
|
|||
|
|
if (headerIdx === -1) throw new Error('找不到表头');
|
|||
|
|
|
|||
|
|
const records = [];
|
|||
|
|
for (let i = headerIdx + 1; i < lines.length; i++) {
|
|||
|
|
const line = lines[i].trim();
|
|||
|
|
if (!line || line.startsWith('---')) continue;
|
|||
|
|
// 支付宝CSV用逗号分隔,但商品说明中可能没有逗号问题(支付宝导出格式较规范)
|
|||
|
|
const parts = line.split(',');
|
|||
|
|
if (parts.length < 11) continue;
|
|||
|
|
records.push({
|
|||
|
|
time: parts[0].trim(),
|
|||
|
|
txCategory: parts[1].trim(),
|
|||
|
|
counterparty: parts[2].trim(),
|
|||
|
|
account: parts[3].trim(),
|
|||
|
|
product: parts[4].trim(),
|
|||
|
|
direction: parts[5].trim(), // 收/支/不计收支
|
|||
|
|
amount: parseFloat(parts[6].trim()),
|
|||
|
|
payMethod: parts[7].trim(),
|
|||
|
|
status: parts[8].trim(),
|
|||
|
|
orderId: parts[9].trim(),
|
|||
|
|
merchantId: parts[10].trim(),
|
|||
|
|
remark: (parts[11] || '').trim(),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
return records;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function classify(r) {
|
|||
|
|
const { txCategory, counterparty, product, direction, status } = r;
|
|||
|
|
const combined = counterparty + product;
|
|||
|
|
|
|||
|
|
// === 余额宝收益 → 利息(收入) ===
|
|||
|
|
if (product.includes('收益发放') && combined.includes('天弘基金')) {
|
|||
|
|
return { type: 'income', id: CAT.利息, note: product };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// === 淘宝签到提现 / 芭芭农场 → 利息(收入) ===
|
|||
|
|
if (product.includes('淘宝签到提现') || product.includes('芭芭农场')) {
|
|||
|
|
return { type: 'income', id: CAT.利息, note: product };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// === 跳过其他"不计收支"记录 ===
|
|||
|
|
if (direction === '不计收支') {
|
|||
|
|
return null; // 退款、花呗还款、余额宝自动转入等
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// === 收入 ===
|
|||
|
|
if (direction === '收入') {
|
|||
|
|
if (combined.includes('红包') || combined.includes('火把购')) {
|
|||
|
|
return { type: 'income', id: CAT.其他收入, note: `${counterparty}-${product}` };
|
|||
|
|
}
|
|||
|
|
if (combined.includes('收钱码')) {
|
|||
|
|
return { type: 'income', id: CAT.其他收入, note: `收钱码-${counterparty}` };
|
|||
|
|
}
|
|||
|
|
return { type: 'income', id: CAT.其他收入, note: `${counterparty}-${product}` };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// === 支出 ===
|
|||
|
|
// 亚马逊货款
|
|||
|
|
if (product.includes('亚马逊') || (counterparty.includes('郦鸿飞') && product.includes('亚马逊'))) {
|
|||
|
|
return { type: 'expense', id: CAT.亚马逊货款, note: `亚马逊货款-${counterparty}` };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 餐饮
|
|||
|
|
if (txCategory === '餐饮美食') {
|
|||
|
|
return { type: 'expense', id: CAT.餐饮, note: product.length > 30 ? counterparty : product };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 交通
|
|||
|
|
if (txCategory === '交通出行') {
|
|||
|
|
return { type: 'expense', id: CAT.交通, note: `${counterparty}-${product}`.substring(0, 50) };
|
|||
|
|
}
|
|||
|
|
if (product.includes('交通一码通')) {
|
|||
|
|
return { type: 'expense', id: CAT.交通, note: '杭州市民卡交通充值' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 话费
|
|||
|
|
if (txCategory === '充值缴费' && combined.includes('话费')) {
|
|||
|
|
return { type: 'expense', id: CAT.话费, note: product };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 水电燃气
|
|||
|
|
if (txCategory === '充值缴费' && (combined.includes('国网') || combined.includes('电费') || combined.includes('燃气') || combined.includes('水费'))) {
|
|||
|
|
return { type: 'expense', id: CAT.水电燃气, note: product };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 保险
|
|||
|
|
if (txCategory === '保险') {
|
|||
|
|
return { type: 'expense', id: CAT.其他支出, note: `保险-${product}`.substring(0, 50) };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 转账红包(支出)
|
|||
|
|
if (txCategory === '转账红包' && direction === '支出') {
|
|||
|
|
if (combined.includes('亚马逊')) {
|
|||
|
|
return { type: 'expense', id: CAT.亚马逊货款, note: `亚马逊货款-${counterparty}` };
|
|||
|
|
}
|
|||
|
|
return { type: 'expense', id: CAT.其他支出, note: `转账-${counterparty}-${product}`.substring(0, 50) };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 购物类(日用百货/服饰/家居/数码/运动/美容/宠物/母婴/文化休闲/信用借还以外的充值缴费)
|
|||
|
|
const shoppingCategories = ['日用百货', '服饰装扮', '家居家装', '数码电器', '运动户外', '美容美发', '宠物', '母婴亲子', '文化休闲'];
|
|||
|
|
if (shoppingCategories.includes(txCategory)) {
|
|||
|
|
return { type: 'expense', id: CAT.购物, note: product.length > 40 ? counterparty : product.substring(0, 40) };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 兜底
|
|||
|
|
return { type: 'expense', id: CAT.其他支出, note: `${txCategory}-${counterparty}-${product}`.substring(0, 50) };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function main() {
|
|||
|
|
const buf = fs.readFileSync('支付宝交易明细(20260101-20260321).csv');
|
|||
|
|
const text = new TextDecoder('gbk').decode(buf);
|
|||
|
|
const rawRecords = parseCSV(text);
|
|||
|
|
|
|||
|
|
console.log(`共解析 ${rawRecords.length} 条原始记录`);
|
|||
|
|
|
|||
|
|
const toImport = [];
|
|||
|
|
let skipped = 0;
|
|||
|
|
|
|||
|
|
for (const r of rawRecords) {
|
|||
|
|
// 跳过金额为0
|
|||
|
|
if (r.amount === 0 || isNaN(r.amount)) { skipped++; continue; }
|
|||
|
|
// 跳过交易关闭
|
|||
|
|
if (r.status === '交易关闭') { skipped++; continue; }
|
|||
|
|
|
|||
|
|
const result = classify(r);
|
|||
|
|
if (!result) { skipped++; continue; } // 不计收支被跳过
|
|||
|
|
|
|||
|
|
const date = r.time.substring(0, 10); // YYYY-MM-DD
|
|||
|
|
toImport.push({
|
|||
|
|
date,
|
|||
|
|
type: result.type,
|
|||
|
|
category_id: result.id,
|
|||
|
|
amount: r.amount,
|
|||
|
|
note: result.note,
|
|||
|
|
user: USER,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log(`需导入 ${toImport.length} 条,跳过 ${skipped} 条\n`);
|
|||
|
|
|
|||
|
|
let success = 0, fail = 0;
|
|||
|
|
for (let i = 0; i < toImport.length; i++) {
|
|||
|
|
const r = toImport[i];
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`${API_BASE}/records`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify(r),
|
|||
|
|
});
|
|||
|
|
if (res.ok) {
|
|||
|
|
success++;
|
|||
|
|
const label = r.type === 'income' ? '收入' : '支出';
|
|||
|
|
console.log(`[${i + 1}/${toImport.length}] OK ${r.date} ${label} ¥${r.amount} ${r.note}`);
|
|||
|
|
} else {
|
|||
|
|
fail++;
|
|||
|
|
const err = await res.json();
|
|||
|
|
console.log(`[${i + 1}/${toImport.length}] FAIL ${r.date} ¥${r.amount} - ${err.error}`);
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
fail++;
|
|||
|
|
console.log(`[${i + 1}/${toImport.length}] FAIL ${r.date} ¥${r.amount} - ${e.message}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log(`\n导入完成!成功: ${success}, 失败: ${fail}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
main().catch(console.error);
|