Files
ledgerly-docs/scripts/ts/make-fake-bills.ts
T

413 lines
17 KiB
TypeScript
Raw 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.
// 生成虚构的支付宝账单样例(用于演示数据与截图,全部为虚假数据)。
//
// 输出两份文件(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<void> {
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<void> {
// 导入用: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)
})