Files
accounting/data/import-alipay.js
2026-03-26 01:23:19 +08:00

188 lines
6.5 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);