// 生成虚构的支付宝账单样例(用于演示数据与截图,全部为虚假数据)。 // // 输出两份文件(UTF-8),格式与支付宝「交易记录明细查询」一致,可被 AlipayBillParser 解析: // - alipay_fake.csv:导入测试库,作为演示交易数据; // - alipay_fake_preview.csv:交易号不同、未导入,用于截取「账单解析预览」(显示为可导入)。 import { writeFile } from 'node:fs/promises' import { resolve } from 'node:path' import ExcelJS from 'exceljs' import { ROOT } from './config.js' const OUT = resolve(ROOT, 'scripts', 'samples', 'alipay_fake.csv') const OUT_PREVIEW = resolve(ROOT, 'scripts', 'samples', 'alipay_fake_preview.csv') // 虚构淘宝订单样例(xlsx):导入用与预览用各一份,全部为虚假数据。 const TAOBAO_OUT = resolve(ROOT, 'scripts', 'samples', 'taobao_fake.xlsx') const TAOBAO_OUT_PREVIEW = resolve(ROOT, 'scripts', 'samples', 'taobao_fake_preview.xlsx') // 虚构京东交易流水样例(CSV):导入用与预览用各一份,全部为虚假数据。 const JD_OUT = resolve(ROOT, 'scripts', 'samples', 'jd_fake.csv') const JD_OUT_PREVIEW = resolve(ROOT, 'scripts', 'samples', 'jd_fake_preview.csv') /** 虚构支出商户:交易对方、商品名称、金额区间 [最小, 最大] */ const EXPENSE_MERCHANTS: Array<[string, string, number, number]> = [ ['星巴克', '拿铁咖啡', 28, 45], ['瑞幸咖啡', '生椰拿铁', 15, 25], ['滴滴出行', '快车行程', 12, 68], ['麦当劳', '午餐套餐', 22, 55], ['肯德基', '全家桶', 35, 99], ['美团外卖', '外卖订单', 18, 68], ['京东商城', '日用品', 39, 299], ['淘宝网', '服饰鞋包', 59, 499], ['永辉超市', '生鲜食品', 35, 220], ['屈臣氏', '洗护用品', 25, 160], ['万达影城', '电影票', 35, 80], ['中国移动', '话费充值', 50, 100], ['国家电网', '电费缴纳', 60, 200], ['中石化加油站', '加油', 200, 400], ['一兆韦德健身房', '健身月卡', 199, 399], ['当当网', '图书', 30, 120], ['网易云音乐', '会员订阅', 15, 25], ['顺丰速运', '快递费', 12, 30], ['海底捞', '聚餐', 120, 360], ['全家便利店', '零食饮料', 8, 45], ] /** 虚构收入来源:交易对方、商品名称、金额区间 */ const INCOME_SOURCES: Array<[string, string, number, number]> = [ ['某某科技有限公司', '工资', 12000, 18000], ['某某科技有限公司', '季度奖金', 3000, 8000], ['李娜', '转账', 100, 800], ['王芳', '还款', 50, 500], ] /** 虚构人名(用于转账类支出) */ const PEOPLE = ['张伟', '李娜', '王芳', '刘洋', '陈静', '赵磊', '孙悦', '周涛'] /** 补零 */ function pad(n: number): string { return n < 10 ? `0${n}` : `${n}` } /** 格式化时间为 yyyy-MM-dd HH:mm:ss */ function fmt(d: Date): string { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad( d.getMinutes(), )}:${pad(d.getSeconds())}` } /** 确定性伪随机(保证多次生成结果一致) */ function makeRandom(seed: number): () => number { let state = seed return () => { state = (state * 1103515245 + 12345) & 0x7fffffff return state / 0x7fffffff } } /** 生成一条账单数据行 */ function buildRow( seq: number, time: Date, counterparty: string, product: string, amount: number, direction: '支出' | '收入' | '不计收支', fundStatus: string, prefix: string, ): string { const tradeNo = `${prefix}${time.getFullYear()}${pad(time.getMonth() + 1)}${pad(time.getDate())}${String( 100000 + seq, ).slice(-6)}` const t = fmt(time) const status = direction === '不计收支' ? '退款成功' : '交易成功' const cols = [ tradeNo, `M${tradeNo}`, t, t, t, '', '即时到账', counterparty, product, amount.toFixed(2), direction, status, '0.00', '0.00', '', fundStatus, ] return cols.join(',') } /** * 生成一份虚构账单文本。 * * @param seed 伪随机种子 * @param prefix 交易号前缀(区分不同文件) * @param startMonth 起始月份(1-12) * @param months 覆盖月数 * @returns 账单文本 */ function buildCsv(seed: number, prefix: string, startMonth: number, months: number): string { const rand = makeRandom(seed) const lines: string[] = [ '支付宝交易记录明细查询', '账号:[demo@example.com]', '起始日期:[2026-01-01 00:00:00] 终止日期:[2026-12-31 00:00:00]', '---------------------------------交易记录明细列表------------------------------------', '交易号,商家订单号,交易创建时间,付款时间,最近修改时间,交易来源地,类型,交易对方,商品名称,金额(元),收/支,交易状态,服务费(元),成功退款(元),备注,资金状态', ] let seq = 0 for (let m = 0; m < months; m++) { const month = startMonth + m const count = 28 + Math.floor(rand() * 7) for (let i = 0; i < count; i++) { seq++ const day = 1 + Math.floor(rand() * 27) const hour = 7 + Math.floor(rand() * 15) const minute = Math.floor(rand() * 60) const time = new Date(2026, month - 1, day, hour, minute, Math.floor(rand() * 60)) const roll = rand() if (roll < 0.06) { const [cp, prod, lo, hi] = INCOME_SOURCES[Math.floor(rand() * INCOME_SOURCES.length)] const amount = Math.round((lo + rand() * (hi - lo)) * 100) / 100 lines.push(buildRow(seq, time, cp, prod, amount, '收入', '已收入', prefix)) } else if (roll < 0.09) { const [cp, prod, lo, hi] = EXPENSE_MERCHANTS[Math.floor(rand() * EXPENSE_MERCHANTS.length)] const amount = Math.round((lo + rand() * (hi - lo)) * 100) / 100 lines.push(buildRow(seq, time, cp, prod, amount, '不计收支', '已支出', prefix)) } else { const [cp, prod, lo, hi] = EXPENSE_MERCHANTS[Math.floor(rand() * EXPENSE_MERCHANTS.length)] const amount = Math.round((lo + rand() * (hi - lo)) * 100) / 100 lines.push(buildRow(seq, time, cp, prod, amount, '支出', '已支出', prefix)) } } } // 若干转账类支出(虚构人名) for (let i = 0; i < 6; i++) { seq++ const day = 3 + i * 25 const time = new Date(2026, startMonth + 1, day > 28 ? 28 : day, 20, 30, 0) const person = PEOPLE[i % PEOPLE.length] lines.push(buildRow(seq, time, person, '转账', 200 + i * 50, '支出', '已支出', prefix)) } lines.push('------------------------------------------------------------------------------------') lines.push(`共${seq}笔记录`) return lines.join('\n') } /** 淘宝订单数据表头(与淘宝导出列一致) */ const TAOBAO_HEADER = [ '订单号', '订单提交时间', '订单状态', '店铺名称', '商品名称', '商品链接', '型号款式', '商品数量', '商品金额', '实付金额', '运费', '物流公司(当前仅支持未完结订单)', '物流单号(当前仅支持未完结订单)', ] /** 虚构淘宝店铺与商品:[店铺名, 商品名, 型号, 单价下限, 单价上限] */ const TAOBAO_SHOPS: Array<[string, string, string, number, number]> = [ ['优选数码专营店', '蓝牙接收发射器 二合一', '蓝牙5.3适配器', 15, 60], ['居家良品旗舰店', '加厚收纳箱 特大号', '150# 奶油白', 40, 130], ['潮流服饰专营店', '夏季冰丝家居服套装', '雾霾蓝 XL', 45, 99], ['乐活食品旗舰店', '每日坚果混合装', '30 包整箱', 30, 120], ['清饮茶业旗舰店', '冷萃乌龙茶包', '混合口味 20 袋', 20, 80], ['简造数码企业店', '手机壳 磨砂防摔', '透明款', 12, 35], ['趣玩玩具旗舰店', '益智积木拼装', '1000 颗粒', 59, 199], ['洁家日用旗舰店', '一次性丁腈手套', 'M 码 100 只', 15, 45], ] /** 淘宝订单状态分布:交易成功居多,少量交易关闭与卖家已发货 */ function pickTaobaoStatus(rand: () => number): string { const roll = rand() if (roll < 0.1) return '交易关闭' if (roll < 0.14) return '卖家已发货' return '交易成功' } /** * 生成一份虚构的淘宝订单 xlsx。 * * @param path 输出文件路径 * @param seed 伪随机种子 * @param prefix 订单号前缀(区分不同文件) * @param startMonth 起始月份(1-12) * @param months 覆盖月数 */ async function buildTaobaoFile( path: string, seed: number, prefix: string, startMonth: number, months: number, ): Promise { const rand = makeRandom(seed) const workbook = new ExcelJS.Workbook() const sheet = workbook.addWorksheet('订单数据') sheet.addRow(TAOBAO_HEADER) let seq = 0 for (let m = 0; m < months; m++) { const month = startMonth + m const count = 10 + Math.floor(rand() * 6) for (let i = 0; i < count; i++) { seq++ const day = 1 + Math.floor(rand() * 27) const hour = 8 + Math.floor(rand() * 14) const time = new Date(2026, month - 1, day, hour, Math.floor(rand() * 60), Math.floor(rand() * 60)) const [shop, product, model, lo, hi] = TAOBAO_SHOPS[Math.floor(rand() * TAOBAO_SHOPS.length)] const status = pickTaobaoStatus(rand) // 每个订单包含 1-3 个商品,多商品订单使用合并单元格(仅首行有订单级字段) const itemCount = 1 + Math.floor(rand() * 3) const items: Array<[string, string, number, number]> = [] let itemsTotal = 0 for (let k = 0; k < itemCount; k++) { const quantity = 1 + Math.floor(rand() * 2) const unitPrice = Math.round((lo + rand() * (hi - lo)) * 100) / 100 const name = k === 0 ? product : `${product} 配件${k}` items.push([name, model, quantity, unitPrice]) itemsTotal += quantity * unitPrice } const shipping = rand() < 0.3 ? 2.5 : 0 // 卖家已发货视为未完成,实付金额为 0 const paid = status === '卖家已发货' ? 0 : Math.round((itemsTotal + shipping) * 100) / 100 const orderNo = `${prefix}${time.getFullYear()}${pad(time.getMonth() + 1)}${pad(time.getDate())}${String( 100000 + seq, ).slice(-6)}` const submitTime = fmt(time) const firstRow = sheet.rowCount + 1 items.forEach(([name, spec, quantity, unitPrice], idx) => { sheet.addRow([ idx === 0 ? orderNo : null, idx === 0 ? submitTime : null, idx === 0 ? status : null, idx === 0 ? shop : null, name, `https://item.taobao.com/item.htm?id=${1000000000000 + seq * 10 + idx}`, spec, quantity, unitPrice, idx === 0 ? paid : null, idx === 0 ? shipping : null, idx === 0 ? '圆通速递' : null, idx === 0 ? `YT${orderNo.slice(-12)}` : null, ]) }) // 多商品订单合并订单级列(订单号、提交时间、状态、店铺、实付金额、运费) if (itemCount > 1) { const lastRow = sheet.rowCount for (const col of [1, 2, 3, 4, 10, 11]) { sheet.mergeCells(firstRow, col, lastRow, col) } } } } await workbook.xlsx.writeFile(path) } /** 虚构京东消费商户:[商户名称, 交易说明, 交易分类, 金额下限, 金额上限] */ const JD_MERCHANTS: Array<[string, string, string, number, number]> = [ ['京东平台商户', '卫加猫咪零食猫条整箱混合口味', '其他网购', 20, 60], ['京东平台商户', 'tritan塑料水杯大容量便携运动水壶', '日用百货', 20, 60], ['京东超市', '抽纸整箱家用实惠装', '食品饮料', 30, 90], ['京东自营', '蓝牙耳机 无线降噪', '数码家电', 80, 400], ['京东平台商户', '洗衣液 大瓶装补充装', '日用百货', 25, 80], ['京东自营', '坚果零食大礼包', '食品饮料', 40, 130], ] /** 京东流水表头 */ const JD_HEADER = '交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类,交易订单号,商家订单号,备注' /** * 生成一份虚构的京东交易流水 CSV。 * * @param seed 伪随机种子 * @param prefix 交易订单号前缀(区分不同文件) * @param startMonth 起始月份(1-12) * @param months 覆盖月数 * @returns 账单文本(含 BOM) */ function buildJdCsv(seed: number, prefix: string, startMonth: number, months: number): string { const rand = makeRandom(seed) const dataLines: string[] = [] let seq = 0 const counts = { income: 0, expense: 0, neutral: 0 } const sums = { income: 0, expense: 0, neutral: 0 } const nextOrderNo = (time: Date): string => `${prefix}${time.getFullYear()}${pad(time.getMonth() + 1)}${pad(time.getDate())}${String(100000 + seq).slice(-6)}` const push = (time: Date, merchant: string, desc: string, amount: number, pay: string, status: string, direction: string, category: string, remark: string) => { const orderNo = nextOrderNo(time) dataLines.push( [fmt(time), merchant, desc, amount.toFixed(2), pay, status, direction, category, orderNo, `112${orderNo}`, remark].join(','), ) if (direction === '收入') { counts.income++ sums.income += amount } else if (direction === '不计收支') { counts.neutral++ sums.neutral += amount } else { counts.expense++ sums.expense += amount } } for (let m = 0; m < months; m++) { const month = startMonth + m const count = 6 + Math.floor(rand() * 5) for (let i = 0; i < count; i++) { seq++ const day = 1 + Math.floor(rand() * 27) const hour = 8 + Math.floor(rand() * 14) const time = new Date(2026, month - 1, day, hour, Math.floor(rand() * 60), Math.floor(rand() * 60)) const [merchant, desc, category, lo, hi] = JD_MERCHANTS[Math.floor(rand() * JD_MERCHANTS.length)] const amount = Math.round((lo + rand() * (hi - lo)) * 100) / 100 const pay = rand() < 0.6 ? '京东白条' : rand() < 0.5 ? '京东小金库' : '招商银行储蓄卡(3702)' push(time, merchant, desc, amount, pay, '交易成功', '支出', category, '计入当月账单,还款日次月28日') } // 每月一笔白条还款与一笔小金库转入(不计收支) seq++ const repayTime = new Date(2026, month - 1, 25, 19, 5, 37) const repayAmount = Math.round((500 + rand() * 800) * 100) / 100 push(repayTime, '京东白条', '白条主动还款', repayAmount, '京东小金库', '还款成功', '不计收支', '白条', '还款至当月账单') seq++ const transferTime = new Date(2026, month - 1, 25, 19, 5, 36) push(transferTime, '京东小金库', '京东小金库-转入', repayAmount, '招商银行储蓄卡(3702)', '交易成功', '不计收支', '小金库', ' ') } // 一笔虚构退款(收入) seq++ push(new Date(2026, startMonth, 12, 10, 30, 0), '京东平台商户', '蓝牙耳机 退款', 129.0, '京东白条', '退款成功', '收入', '数码家电', '全额退款') const total = counts.income + counts.expense + counts.neutral const header = [ '\uFEFF导出信息:', '京东账号名:demo_jd_user', '申请时间:2026-09-21 12:28:54', `日期区间:2026-${pad(startMonth)}-01 至 2026-${pad(startMonth + months)}-21`, '导出交易类型:全部', '导出交易场景:全部', `共:${total}笔记录`, `收入:${counts.income}笔,${sums.income.toFixed(2)}元`, `支出:${counts.expense}笔,${sums.expense.toFixed(2)}元`, `不计收支:${counts.neutral}笔,${sums.neutral.toFixed(2)}元`, '', '特别提示', '1.本明细为每笔订单支付的明细,不包括已删除的记录;', '2.个人资金互转、全额退款等记为【不计收支】类;', '3.本明细仅供个人对账使用。', '', JD_HEADER, ] return [...header, ...dataLines].join('\n') } async function main(): Promise { // 导入用:2026-03 ~ 2026-09 const imported = buildCsv(20260920, '1', 3, 7) await writeFile(OUT, imported, 'utf8') // 预览用:2026-01 ~ 2026-03,交易号不同(未导入) const preview = buildCsv(20260101, '9', 1, 3) await writeFile(OUT_PREVIEW, preview, 'utf8') // 淘宝订单(xlsx):导入用与预览用各一份 await buildTaobaoFile(TAOBAO_OUT, 20260921, '1', 3, 7) await buildTaobaoFile(TAOBAO_OUT_PREVIEW, 20260101, '9', 1, 3) // 京东交易流水(CSV):导入用与预览用各一份(订单号前缀与淘宝样例区分,避免演示数据去重冲突) await writeFile(JD_OUT, buildJdCsv(20260921, '2', 3, 7), 'utf8') await writeFile(JD_OUT_PREVIEW, buildJdCsv(20260101, '8', 1, 3), 'utf8') console.log( `已生成虚构账单:\n - ${OUT}\n - ${OUT_PREVIEW}\n - ${TAOBAO_OUT}\n - ${TAOBAO_OUT_PREVIEW}\n - ${JD_OUT}\n - ${JD_OUT_PREVIEW}`, ) } main().catch((err) => { console.error('FAIL', err) process.exit(1) })