chore: 初始化文档站仓库
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
// 生成虚构的支付宝账单样例(用于演示数据与截图,全部为虚假数据)。
|
||||
//
|
||||
// 输出两份文件(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)
|
||||
})
|
||||
Reference in New Issue
Block a user