Initial commit: Receipt Studio desktop application

This commit is contained in:
fuenmao
2026-10-19 11:09:09 +08:00
commit 2caac3c31f
77 changed files with 9846 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
import {it,expect} from 'vitest'
import {ConfirmationService} from '../src/main/confirmations'
import type {ConfirmationRequest} from '../src/shared/types'
const options={title:'删除记录?',description:'删除本批次',variant:'danger' as const,confirmLabel:'删除记录'}
it('操作等待匹配确认,其他请求的回复不能批准当前操作',async()=>{
let request:ConfirmationRequest|undefined
const service=new ConfirmationService(value=>{request=value})
const answer=service.ask(options)
expect(service.hasPending).toBe(true);expect(request).toMatchObject(options)
expect(()=>service.answer('wrong-request',true)).toThrow('已失效')
expect(service.hasPending).toBe(true)
service.answer(request!.id,true)
expect(await answer).toBe(true);expect(service.hasPending).toBe(false)
expect(()=>service.answer(request!.id,true)).toThrow('已失效')
})
it('取消与界面失联均不批准操作',async()=>{
let request:ConfirmationRequest|undefined
const service=new ConfirmationService(value=>{request=value})
const cancelled=service.ask(options);service.answer(request!.id,false);expect(await cancelled).toBe(false)
const interrupted=service.ask(options);service.cancel();expect(await interrupted).toBe(false)
service.cancel();expect(service.hasPending).toBe(false)
})
it('重复请求不替换等待中的确认窗口',async()=>{
let request:ConfirmationRequest|undefined
const service=new ConfirmationService(value=>{request=value})
const first=service.ask(options),id=request!.id
await expect(service.ask({...options,title:'另一个请求'})).rejects.toThrow('当前确认')
expect(request!.id).toBe(id)
service.answer(id,true);expect(await first).toBe(true)
})
it('发送失败后可重新请求确认',async()=>{
let failed=true,request:ConfirmationRequest|undefined
const service=new ConfirmationService(value=>{if(failed)throw Error('window unavailable');request=value})
await expect(service.ask(options)).rejects.toThrow('window unavailable');expect(service.hasPending).toBe(false)
failed=false
const answer=service.ask(options);service.answer(request!.id,false);expect(await answer).toBe(false)
})
+29
View File
@@ -0,0 +1,29 @@
import { describe,it,expect,beforeEach,afterEach } from 'vitest'
import { mkdtemp,rm,writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { parseMoney,chineseMoney,moneyCells,MAX_CENTS } from '../src/core/money'
import { parseReceiptDate } from '../src/core/date'
import { buildDocument,TemplateRegistry } from '../src/core/templates'
import { defaultSettings } from '../src/shared/settings'
import { Ledger } from '../src/main/ledger'
import { XlsxReader,createWorkbookTemplate } from '../src/main/workbooks'
import ExcelJS from 'exceljs'
describe('金额与日期',()=>{
it('以整数分保存,金额格不截断',()=>{expect(parseMoney('3,500.01')).toBe(350001);expect(parseMoney('0.01')).toBe(1);expect(parseMoney('9999999.99')).toBe(MAX_CENTS);expect(moneyCells(MAX_CENTS).join('')).toBe('999999999')})
it.each(['-1','0','1.001','1e3','12,34','10000000','abc'])('拒绝无效金额 %s',v=>{expect(()=>parseMoney(v)).toThrow()})
it.each([[350000,'叁仟伍佰元整'],[100101,'壹仟零壹元零壹分'],[100000001,'壹佰万元零壹分'],[1010000,'壹万零壹佰元整'],[1234,'壹拾贰元叁角肆分']])('正确转换金额大写', (n,text)=>expect(chineseMoney(n as number)).toBe(text))
it('验证真实日期与 Excel 的 1900/1904 日期制',()=>{expect(parseReceiptDate('2024-2-29')).toBe('2024-02-29');expect(()=>parseReceiptDate('2026-2-29')).toThrow();expect(parseReceiptDate(61)).toBe('1900-03-01');expect(parseReceiptDate(0,true)).toBe('1904-01-01');expect(()=>parseReceiptDate(60)).toThrow()})
})
let dir:string
beforeEach(async()=>{dir=await mkdtemp(path.join(tmpdir(),'receipt-studio-test-'))})
afterEach(async()=>{await rm(dir,{recursive:true,force:true})})
const input={date:'2026-10-16',customer:'测试客户',item:'服务费',amountCents:350000,cashier:'',reviewer:''}
describe('持久编号',()=>{
it('预览不推进,保留批次快照并在重启后继续编号',async()=>{const store=new Ledger(path.join(dir,'data.sqlite'));await store.open();expect(store.nextNumber('SJ')).toBe('SJ00000001');expect(store.nextNumber('SJ')).toBe('SJ00000001');const settings={...defaultSettings(),defaultCashier:'张三'};const batch=await store.reserve({name:'a.xlsx',hash:'hash'},[input,input],settings,null);expect(batch.receipts.map(r=>r.number)).toEqual(['SJ00000001','SJ00000002']);expect(batch.receipts[0].cashier).toBe('张三');const again=new Ledger(path.join(dir,'data.sqlite'));await again.open();expect(again.nextNumber('SJ')).toBe('SJ00000003');expect(again.get(batch.id).receipts).toEqual(batch.receipts)})
it('数据损坏时停止分配编号,保留原文件',async()=>{const file=path.join(dir,'bad.sqlite');await writeFile(file,'broken');await expect(new Ledger(file).open()).rejects.toThrow('停止分配编号')})
})
describe('模板和工作簿',()=>{
it('模板转义用户文字,奇数张 A4 保留空白半页',()=>{const registry=new TemplateRegistry(),r={...input,number:'SJ00000001',customer:'<script>alert(1)</script>'};const html=buildDocument(registry.get('classic-v1'),[r,r,r],{settings:defaultSettings(),seal:null,preview:false},'print',2,true);expect(html).not.toContain('<script>');expect(html).toContain('&lt;script&gt;');expect(html.match(/<section class="page">/g)).toHaveLength(2);expect(html.match(/class="slot"/g)).toHaveLength(4);expect(html).not.toContain('预览样张')})
it('导入标准模板并定位错误行,支持千分位与重复行提醒',async()=>{const file=path.join(dir,'test.xlsx');await createWorkbookTemplate(file);const book=new ExcelJS.Workbook();await book.xlsx.readFile(file);book.worksheets[0].addRow(['2026-10-16','学校','年级','客户','项目','12.345']);book.worksheets[0].addRow(['2026-10-16','学校','年级','客户','项目','1,234.50']);book.worksheets[0].addRow(['2026-10-16','学校','年级','客户','项目','1,234.50']);await book.xlsx.writeFile(file);const draft=await new XlsxReader().read(file);expect(draft.rows).toHaveLength(4);expect(draft.rows[1].excelRow).toBe(3);expect(draft.rows[1].errors[0]).toContain('两位小数');expect(draft.rows[2].receipt.amountCents).toBe(123450);expect(draft.rows[3].warnings).toHaveLength(1)})
})
+34
View File
@@ -0,0 +1,34 @@
import {expect,it} from 'vitest'
import path from 'node:path'
import {groupReceipts} from '../src/main/export-layout'
import type {Receipt} from '../src/shared/types'
function receipt(school:string|undefined,grade='高一',item='服务费'):Receipt {
return {number:'SJ00000001',date:'2026-10-19',school,grade,customer:'学生',item,amountCents:100,cashier:'',reviewer:''}
}
it('旧记录没有学校年级时保留原客户名称,并落入未分类目录',()=>{
const original={...receipt(undefined),grade:undefined,customer:'以前的客户名称'}
expect(groupReceipts([original])[0].directory).toBe(path.join('未分类学校','未分类年级','服务费'))
expect(original.customer).toBe('以前的客户名称')
})
it('Windows 非法字符、保留名、路径跳转及超长名称生成安全且不混组的目录',()=>{
const schools=['学校/A','学校?A','学校_A','CON','..','ABC','abc','甲'.repeat(80)+'一','甲'.repeat(80)+'二']
const rows=schools.map((school,i)=>({...receipt(school,'../高一','资料:费'),number:String(i)}))
const groups=groupReceipts(rows),root=path.resolve('export-root')
expect(groups).toHaveLength(rows.length)
expect(new Set(groups.map(g=>g.directory.toLowerCase())).size).toBe(rows.length)
for(const group of groups){
expect(path.resolve(root,group.directory).startsWith(root+path.sep)).toBe(true)
const parts=group.directory.split(path.sep)
expect(parts).toHaveLength(3)
for(const part of parts){
expect(part).not.toMatch(/[<>:"/\\|?*\u0000-\u001f]/)
expect(part).not.toMatch(/[. ]$/)
expect(part).not.toMatch(/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i)
expect(part.length).toBeLessThanOrEqual(80)
}
}
expect(groupReceipts(rows).map(g=>g.directory)).toEqual(groups.map(g=>g.directory))
})
+106
View File
@@ -0,0 +1,106 @@
import {it,expect} from 'vitest'
import {mkdtemp,rm,readdir,readFile,access} from 'node:fs/promises'
import path from 'node:path'
import {tmpdir} from 'node:os'
import {PDFDocument} from 'pdf-lib'
import {Ledger} from '../src/main/ledger'
import {ExportCoordinator} from '../src/main/exporter'
import {defaultSettings} from '../src/shared/settings'
import type {PdfEngine} from '../src/main/pdf-engine'
import type {ExportProgress} from '../src/shared/types'
it('大批量按块排版,奇数张只保留一个空白半页',async()=>{
const dir=await mkdtemp(path.join(tmpdir(),'receipt-export-'))
try{
const ledger=new Ledger(path.join(dir,'db.sqlite'));await ledger.open()
const settings={...defaultSettings(dir),fileNamePattern:'number' as const}
const inputs=Array.from({length:81},()=>({date:'2026-10-16',customer:'客户',item:'项目',amountCents:100,cashier:'',reviewer:''}))
const batch=await ledger.reserve({name:'test',hash:'h'},inputs,settings,null)
const sizes:number[]=[]
const engine:PdfEngine={async pdf(rows,_s,_seal,_layout,perPage){sizes.push(rows.length);const p=await PDFDocument.create();for(let i=0;i<Math.ceil(rows.length/perPage);i++)p.addPage([595.28,841.89]);return Buffer.from(await p.save())},async png(){return Buffer.alloc(0)},close(){}}
let latest:ExportProgress|undefined
const exporter=new ExportCoordinator(engine,ledger,p=>{latest=p})
await exporter.run(batch,{pdf:false,png:false,print:true,receiptsPerPage:2,cutLine:true},dir)
expect(sizes).toEqual([40,40,1]);expect(latest!.status).toBe('completed')
const pdf=await PDFDocument.load(await readFile(path.join(batch.outputDirectory,'未分类学校','未分类年级','项目','A4打印版_每页2张.pdf')))
expect(pdf.getPageCount()).toBe(41);expect(ledger.nextNumber('SJ')).toBe('SJ00000082')
}finally{await rm(dir,{recursive:true,force:true})}
})
it('取消保留编号,重新导出始终建立独立批次目录,兼容旧的关闭设置',async()=>{
const dir=await mkdtemp(path.join(tmpdir(),'receipt-cancel-'))
try{
const ledger=new Ledger(path.join(dir,'db.sqlite'));await ledger.open()
const settings={...defaultSettings(dir),createBatchFolder:false,fileNamePattern:'number' as const}
const inputs=Array.from({length:3},()=>({date:'2026-10-16',customer:'客户',item:'项目',amountCents:100,cashier:'',reviewer:''}))
const batch=await ledger.reserve({name:'test',hash:'h'},inputs,settings,null)
const engine:PdfEngine={async pdf(){const p=await PDFDocument.create();p.addPage();return Buffer.from(await p.save())},async png(){return Buffer.alloc(0)},close(){}}
const exporter=new ExportCoordinator(engine,ledger,p=>{if(p.completed===1&&!p.finished)exporter.cancel()})
const options={pdf:true,png:false,print:false,receiptsPerPage:2 as const,cutLine:true}
await exporter.run(batch,options,dir);expect(ledger.get(batch.id).status).toBe('cancelled')
const firstDirectory=batch.outputDirectory
expect(path.dirname(firstDirectory)).toBe(dir)
expect(ledger.nextNumber('SJ')).toBe('SJ00000004')
const retry=new ExportCoordinator(engine,ledger,()=>{});await retry.run(ledger.get(batch.id),options,dir)
const retryDirectory=ledger.get(batch.id).outputDirectory
expect(path.dirname(retryDirectory)).toBe(dir);expect(retryDirectory).not.toBe(firstDirectory)
expect(await readdir(path.join(firstDirectory,'未分类学校','未分类年级','项目'))).toContain('SJ00000001.pdf')
expect(await readdir(path.join(retryDirectory,'未分类学校','未分类年级','项目'))).toContain('SJ00000001.pdf')
expect(ledger.get(batch.id).status).toBe('completed');expect(ledger.nextNumber('SJ')).toBe('SJ00000004')
}finally{await rm(dir,{recursive:true,force:true})}
})
it('PDF、图片、打印版按学校、年级、项目分层,非连续行在组内按原顺序排版',async()=>{
const dir=await mkdtemp(path.join(tmpdir(),'receipt-groups-'))
try{
const ledger=new Ledger(path.join(dir,'db.sqlite'));await ledger.open()
const settings={...defaultSettings(dir),fileNamePattern:'number' as const}
const categories=[['甲学校','高一','服务费'],['乙学校','高一','服务费'],['甲学校','高一','服务费'],['甲学校','高二','服务费'],['甲学校','高一','资料费'],['甲学校','高一','服务费']]
const inputs=categories.map(([school,grade,item],i)=>({date:'2026-10-19',school,grade,studentName:'学生'+i,customer:school+' '+grade+' 学生'+i,item,amountCents:100,cashier:'',reviewer:''}))
const batch=await ledger.reserve({name:'分组.xlsx',hash:'groups'},inputs,settings,null)
const printed:string[][]=[]
const engine:PdfEngine={async pdf(rows,_settings,_seal,layout,perPage){
if(layout==='print')printed.push(rows.map(r=>r.number))
const pdf=await PDFDocument.create();for(let i=0;i<Math.ceil(rows.length/perPage);i++)pdf.addPage()
return Buffer.from(await pdf.save())
},async png(){return Buffer.from('test image')},close(){}}
let latest:ExportProgress|undefined
await new ExportCoordinator(engine,ledger,p=>{latest=p}).run(batch,{pdf:true,png:true,print:true,receiptsPerPage:2,cutLine:true},dir)
expect(path.dirname(batch.outputDirectory)).toBe(dir)
expect(path.basename(batch.outputDirectory)).toMatch(/^\d{8}_\d{6}_/)
for(const [i,receipt] of batch.receipts.entries()){
const folder=path.join(batch.outputDirectory,...categories[i])
await access(path.join(folder,receipt.number+'.pdf'));await access(path.join(folder,receipt.number+'.png'))
}
expect(printed).toEqual([['SJ00000001','SJ00000003','SJ00000006'],['SJ00000002'],['SJ00000004'],['SJ00000005']])
for(const [school,grade,item] of categories){
const file=path.join(batch.outputDirectory,school,grade,item,'A4打印版_每页2张.pdf')
const pdf=await PDFDocument.load(await readFile(file))
expect(pdf.getPageCount()).toBe(school==='甲学校'&&grade==='高一'&&item==='服务费'?2:1)
}
const manifest=JSON.parse(await readFile(path.join(batch.outputDirectory,'生成清单.json'),'utf8'))
expect(manifest.printFiles).toHaveLength(4)
expect(manifest.receipts[0].directory).toBe('甲学校/高一/服务费')
expect(manifest.receipts[0].files).toEqual(['甲学校/高一/服务费/SJ00000001.pdf','甲学校/高一/服务费/SJ00000001.png'])
for(const file of [...manifest.printFiles,...manifest.receipts.flatMap((r:{files:string[]})=>r.files)])await access(path.join(batch.outputDirectory,file))
expect(latest).toMatchObject({status:'completed',completed:10,total:10})
expect(ledger.nextNumber('SJ')).toBe('SJ00000007')
}finally{await rm(dir,{recursive:true,force:true})}
})
it('一个打印分组失败后继续生成其他分组,记录部分完成和准确的成功失败张数',async()=>{
const dir=await mkdtemp(path.join(tmpdir(),'receipt-group-failure-'))
try{
const ledger=new Ledger(path.join(dir,'db.sqlite'));await ledger.open()
const inputs=['甲学校','甲学校','乙学校'].map(school=>({date:'2026-10-19',school,grade:'高一',customer:school+' 学生',item:'服务费',amountCents:100,cashier:'',reviewer:''}))
const batch=await ledger.reserve({name:'分组.xlsx',hash:'failure'},inputs,defaultSettings(dir),null)
const engine:PdfEngine={async pdf(rows){
if(rows[0].school==='甲学校')throw Error('排版失败')
const pdf=await PDFDocument.create();pdf.addPage();return Buffer.from(await pdf.save())
},async png(){return Buffer.alloc(0)},close(){}}
let latest:ExportProgress|undefined
await new ExportCoordinator(engine,ledger,p=>{latest=p}).run(batch,{pdf:false,png:false,print:true,receiptsPerPage:2,cutLine:true},dir)
expect(ledger.get(batch.id)).toMatchObject({status:'partial',successCount:1,failureCount:2})
await access(path.join(batch.outputDirectory,'乙学校','高一','服务费','A4打印版_每页2张.pdf'))
expect(latest).toMatchObject({completed:5,total:5})
expect(latest!.errors[0]).toContain('甲学校 / 高一 / 服务费')
}finally{await rm(dir,{recursive:true,force:true})}
})
+64
View File
@@ -0,0 +1,64 @@
import {it,expect} from 'vitest'
import {mkdtemp,rm} from 'node:fs/promises'
import {tmpdir} from 'node:os'
import path from 'node:path'
import {Ledger} from '../src/main/ledger'
import {defaultSettings} from '../src/shared/settings'
function input(school:string|undefined,grade:string|undefined,studentName:string,item:string){return {date:'2026-10-19',school,grade,studentName,customer:[school,grade,studentName].filter(Boolean).join(' '),item,amountCents:100,cashier:'',reviewer:''}}
it('按同一张收据组合筛选,支持姓名和中间编号,并返回可直接预览的匹配信息',async()=>{
const dir=await mkdtemp(path.join(tmpdir(),'receipt-search-'))
try{
const ledger=new Ledger(path.join(dir,'db.sqlite'));await ledger.open()
const batch=await ledger.reserve({name:'收据模板.xlsx',hash:'a'},[input('甲学校','高一','张三','服务费'),input('乙学校','高二','李四','资料费'),input('甲学校','高一','王五','服务费')],defaultSettings(dir),null)
expect(ledger.summaries({school:'甲学校',grade:'高二',item:'资料费'})).toEqual([])
expect(ledger.summaries({search:'李四',school:'甲学校'})).toEqual([])
expect(ledger.summaries({search:' sj00000002 ',school:'乙学校',grade:'高二',item:'资料费'})[0]).toMatchObject({id:batch.id,count:3,match:{count:1,number:'SJ00000002',customer:'乙学校 高二 李四'}})
expect(ledger.summaries({search:'王五'})[0].match?.number).toBe('SJ00000003')
expect(ledger.summaries({school:'甲学校'})[0].match?.count).toBe(2)
expect(ledger.summaries({search:'收据模板.xlsx'})).toHaveLength(1)
expect(ledger.summaries()[0].groups).toEqual([{school:'甲学校',grade:'高一',item:'服务费',count:2,totalCents:200},{school:'乙学校',grade:'高二',item:'资料费',count:1,totalCents:100}])
expect(ledger.nextNumber('SJ')).toBe('SJ00000004')
}finally{await rm(dir,{recursive:true,force:true})}
})
it('查找和下拉选项覆盖超过最近 200 批的旧记录,旧数据缺失学校年级也能查找',async()=>{
const dir=await mkdtemp(path.join(tmpdir(),'receipt-old-search-'))
try{
const ledger=new Ledger(path.join(dir,'db.sqlite'));await ledger.open()
const oldest=await ledger.reserve({name:'旧记录.xlsx',hash:'old'},[input(undefined,undefined,'旧姓名','旧项目')],defaultSettings(dir),null)
oldest.outputDirectory=path.join(dir,'old-output');oldest.status='completed';await ledger.update(oldest)
for(let i=0;i<200;i++)await ledger.reserve({name:'新记录.xlsx',hash:String(i)},[input('新学校','高一','新姓名','新项目')],defaultSettings(dir),null)
expect(ledger.summaries()).toHaveLength(200)
expect(ledger.summaries().some(batch=>batch.id===oldest.id)).toBe(false)
expect(ledger.batchPage({page:41})).toMatchObject({total:201,page:41,pageSize:5,items:[{id:oldest.id}]})
expect(ledger.summaries({search:'旧姓名',school:'未分类学校',grade:'未分类年级',item:'旧项目'})[0].id).toBe(oldest.id)
expect(ledger.isOutputDirectory(oldest.outputDirectory)).toBe(true)
expect(ledger.isOutputDirectory(path.join(dir,'other'))).toBe(false)
expect(ledger.historyFilters()).toMatchObject({schools:expect.arrayContaining(['新学校','未分类学校']),grades:expect.arrayContaining(['高一','未分类年级']),items:expect.arrayContaining(['旧项目','新项目'])})
expect(ledger.nextNumber('SJ')).toBe('SJ00000202')
}finally{await rm(dir,{recursive:true,force:true})}
},20000)
it('默认每页五条,分页互不重复,按筛选结果计数,并在删除末页记录后回到有效页',async()=>{
const dir=await mkdtemp(path.join(tmpdir(),'receipt-pages-'))
try{
const ledger=new Ledger(path.join(dir,'db.sqlite'));await ledger.open()
const batches=[]
for(let i=1;i<=12;i++){
const batch=await ledger.reserve({name:'批次'+i,hash:String(i)},[input(i%2?'甲学校':'乙学校','高一','学生'+i,'服务费')],defaultSettings(dir),null)
batch.status='completed';await ledger.update(batch);batches.push(batch)
}
const first=ledger.batchPage(),second=ledger.batchPage({page:2}),last=ledger.batchPage({page:3})
expect(first).toMatchObject({total:12,page:1,pageSize:5})
expect(first.items.map(batch=>batch.sourceName)).toEqual(['批次12','批次11','批次10','批次9','批次8'])
expect(second.items).toHaveLength(5);expect(last.items).toHaveLength(2)
expect(new Set([...first.items,...second.items,...last.items].map(batch=>batch.id)).size).toBe(12)
expect(ledger.batchPage({page:2,pageSize:10})).toMatchObject({total:12,page:2,pageSize:10,items:expect.arrayContaining([{...last.items[0]},{...last.items[1]}])})
expect(ledger.batchPage({pageSize:50}).items).toHaveLength(12)
expect(ledger.batchPage({school:'甲学校',page:2})).toMatchObject({total:6,page:2,items:[{id:batches[0].id}]})
expect(ledger.batchPage({school:'甲学校',page:999})).toMatchObject({total:6,page:2,items:[{id:batches[0].id}]})
expect(ledger.batchPage({search:'不存在',page:999})).toEqual({items:[],total:0,page:1,pageSize:5})
await ledger.deleteBatch(batches[0].id);await ledger.deleteBatch(batches[1].id)
expect(ledger.batchPage({page:3})).toMatchObject({total:10,page:2,items:expect.any(Array)})
expect(ledger.batchPage({page:3}).items).toHaveLength(5)
expect(ledger.nextNumber('SJ')).toBe('SJ00000013')
}finally{await rm(dir,{recursive:true,force:true})}
})
+40
View File
@@ -0,0 +1,40 @@
import {it,expect,beforeEach,afterEach} from 'vitest'
import {mkdtemp,rm,writeFile,readFile} from 'node:fs/promises'
import {tmpdir} from 'node:os'
import path from 'node:path'
import {Ledger} from '../src/main/ledger'
import {defaultSettings} from '../src/shared/settings'
import type {Batch} from '../src/shared/types'
let dir:string
const input={date:'2026-10-16',customer:'测试学校 高2028届 张三',item:'服务费',amountCents:350000,cashier:'',reviewer:''}
beforeEach(async()=>{dir=await mkdtemp(path.join(tmpdir(),'receipt-delete-'))})
afterEach(async()=>{await rm(dir,{recursive:true,force:true})})
async function open(name='db.sqlite'){const store=new Ledger(path.join(dir,name));await store.open();return store}
async function complete(store:Ledger,batch:Batch){batch.status='completed';batch.outputDirectory=dir;await store.update(batch)}
it('删除批次持久生效,导出文件和已分配流水保留,重新导入不引用已删除记录',async()=>{
const store=await open(),settings=defaultSettings(dir)
const first=await store.reserve({name:'first.xlsx',hash:'same'},[input],settings,null);await complete(store,first)
const second=await store.reserve({name:'second.xlsx',hash:'same'},[input,input],settings,null);await complete(store,second)
const file=path.join(dir,'already-exported.pdf');await writeFile(file,'existing file')
await store.deleteBatch(second.id)
expect(store.summaries().map(b=>b.id)).toEqual([first.id])
expect(()=>store.get(second.id)).toThrow('不存在')
expect(store.previous('same')).toBe(first.id)
expect(store.nextNumber('SJ')).toBe('SJ00000004')
const restarted=await open()
expect(restarted.summaries()).toHaveLength(1);expect(restarted.nextNumber('SJ')).toBe('SJ00000004')
await restarted.deleteBatch(first.id)
expect(restarted.previous('same')).toBeUndefined();expect(restarted.summaries()).toHaveLength(0)
const next=await restarted.reserve({name:'new.xlsx',hash:'new'},[input],settings,null)
expect(next.receipts[0].number).toBe('SJ00000004')
expect(await readFile(file,'utf8')).toBe('existing file')
})
it('正在生成和已分配编号的批次不能删除,失败后仍保留完整记录',async()=>{
const store=await open(),batch=await store.reserve({name:'test',hash:'h'},[input],defaultSettings(dir),null)
await expect(store.deleteBatch(batch.id)).rejects.toThrow('生成中的批次不能删除')
batch.status='running';await store.update(batch)
await expect(store.deleteBatch(batch.id)).rejects.toThrow('生成中的批次不能删除')
await expect(store.deleteBatch('missing')).rejects.toThrow('不存在')
expect(store.get(batch.id).status).toBe('running')
expect(store.nextNumber('SJ')).toBe('SJ00000002')
})
+11
View File
@@ -0,0 +1,11 @@
import {it,expect} from 'vitest'
import {withPngDensity} from '../src/main/png-density'
// A small valid PNG; injecting resolution must not change its image payload.
const source=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jvF8AAAAASUVORK5CYII=','base64')
it('PNG 带有实际 300 DPI 分辨率且可重复更新',()=>{
const png=withPngDensity(source,300),index=png.indexOf('pHYs')
expect(png.readUInt32BE(index+4)).toBe(11811);expect(png.readUInt32BE(index+8)).toBe(11811)
const second=withPngDensity(png,150)
expect(second.toString('latin1').split('pHYs')).toHaveLength(2)
expect(second.readUInt32BE(second.indexOf('pHYs')+4)).toBe(5906)
})
+24
View File
@@ -0,0 +1,24 @@
import {it,expect} from 'vitest'
import {PrintDocumentStore} from '../src/main/print-documents'
import {conciseError} from '../src/shared/errors'
it('large print documents use short isolated local addresses and can be released',()=>{
const store=new PrintDocumentStore(),html='<html>'+ 'x'.repeat(3*1024*1024)+'</html>'
const first=store.publish(html),second=store.publish('<html>second</html>')
expect(first.url.length).toBeLessThan(100)
expect(first.url).not.toBe(second.url)
expect(store.read(first.url)).toBe(html)
expect(store.read(first.url+'?extra')).toBeUndefined()
first.dispose();first.dispose()
expect(store.read(first.url)).toBeUndefined()
expect(store.read(second.url)).toBe('<html>second</html>')
second.dispose();expect(store.read(second.url)).toBeUndefined()
})
it('loading errors retain the error code without exposing embedded document content',()=>{
const message=conciseError(new Error("ERR_INVALID_URL (-300) loading 'data:text/html;charset=utf-8,"+'sensitive'.repeat(100000)+"'"))
expect(message).toContain('ERR_INVALID_URL (-300)')
expect(message).not.toContain('sensitive')
expect(message.length).toBeLessThan(100)
expect(conciseError('x'.repeat(1000)).length).toBe(400)
expect(conciseError('文件夹无法访问')).toBe('文件夹无法访问')
})
+26
View File
@@ -0,0 +1,26 @@
import {it,expect} from 'vitest'
import {importGroupTree,printPreviewRows,summarizeReceiptGroups} from '../src/shared/receipt-groups'
import type {ImportRow} from '../src/shared/types'
function row(index:number,school='甲学校',errors:string[]=[]):ImportRow{
return {index,excelRow:index+2,errors,warnings:[],receipt:{date:'2026-10-19',school,grade:'高一',studentName:'学生'+index,customer:school+' 高一 学生'+index,item:'服务费',amountCents:(index+1)*100,cashier:'',reviewer:''}}
}
it('非连续行合并为同组,分组金额与勾选同步,错误行始终不计入可生成数据',()=>{
const rows=[row(0),row(1,'乙学校'),row(2),row(3,'甲学校',['日期无效'])],before=JSON.stringify(rows)
const [schoolA,schoolB]=importGroupTree(rows,new Set([2,3]))
expect(schoolA).toMatchObject({label:'甲学校',indices:[0,2],selectedCount:1,totalCents:400,selectedCents:300,invalidCount:1})
expect(schoolA.children[0].children[0]).toMatchObject({label:'服务费',indices:[0,2],selectedCount:1,invalidCount:1})
expect(schoolB).toMatchObject({label:'乙学校',indices:[1],selectedCount:0,totalCents:200,selectedCents:0})
expect(JSON.stringify(rows)).toBe(before)
expect(summarizeReceiptGroups(rows.filter(row=>!row.errors.length).map(row=>row.receipt))).toEqual([
{school:'甲学校',grade:'高一',item:'服务费',count:2,totalCents:400},
{school:'乙学校',grade:'高一',item:'服务费',count:1,totalCents:200}
])
})
it('A4 预览只显示所选同组收据,定位到所在页,最后一页不会混入其他学校',()=>{
const rows=[row(0),row(1,'乙学校'),row(2),row(3,'甲学校',['金额无效']),row(4)]
expect(printPreviewRows(rows,new Set([0,1,2,3,4]),2,2).map(row=>row.index)).toEqual([0,2])
expect(printPreviewRows(rows,new Set([0,1,2,4]),4,2).map(row=>row.index)).toEqual([4])
expect(printPreviewRows(rows,new Set([1,2,4]),2,2).map(row=>row.index)).toEqual([2,4])
expect(printPreviewRows(rows,new Set([1,2,4]),4,1).map(row=>row.index)).toEqual([4])
expect(printPreviewRows(rows,new Set(),0,2)).toEqual([])
})
+22
View File
@@ -0,0 +1,22 @@
import {it,expect} from 'vitest'
import {mkdtemp,readFile,writeFile,rm} from 'node:fs/promises'
import {tmpdir} from 'node:os'
import path from 'node:path'
import {SettingsStore} from '../src/main/settings-store'
import {defaultSettings} from '../src/shared/settings'
it('升级旧配置启用固定导出行为,保留其他偏好,并在下次保存后持久化',async()=>{
const dir=await mkdtemp(path.join(tmpdir(),'receipt-settings-')),file=path.join(dir,'settings.json')
try{
const old:Partial<ReturnType<typeof defaultSettings>>={...defaultSettings(dir),createBatchFolder:false,openAfterExport:false,numberPrefix:'OLD',defaultCashier:'原出纳',fileNamePattern:'customer_number'}
delete old.historyPageSize
await writeFile(file,JSON.stringify(old))
const store=new SettingsStore(file,dir),loaded=await store.load()
expect(loaded).toMatchObject({createBatchFolder:true,openAfterExport:true,numberPrefix:'OLD',defaultCashier:'原出纳',fileNamePattern:'customer_number',outputDirectory:dir,historyPageSize:5})
// Loading does not overwrite the user's existing file; the next explicit save persists the policy.
expect(JSON.parse(await readFile(file,'utf8'))).toEqual(old)
const saved=await store.save({...loaded,createBatchFolder:false,openAfterExport:false,defaultReviewer:'复核人',historyPageSize:20})
expect(saved).toMatchObject({createBatchFolder:true,openAfterExport:true,defaultReviewer:'复核人',numberPrefix:'OLD',historyPageSize:20})
expect(await new SettingsStore(file,dir).load()).toEqual(saved)
}finally{await rm(dir,{recursive:true,force:true})}
})
+50
View File
@@ -0,0 +1,50 @@
import {it,expect,beforeEach,afterEach} from 'vitest'
import ExcelJS from 'exceljs'
import {mkdtemp,rm} from 'node:fs/promises'
import {tmpdir} from 'node:os'
import path from 'node:path'
import {XlsxReader,createWorkbookTemplate} from '../src/main/workbooks'
import {Ledger} from '../src/main/ledger'
import {defaultSettings} from '../src/shared/settings'
let dir:string
const headers=['收款日期','学校','年级','姓名','收费项目','金额','出纳','复核']
beforeEach(async()=>{dir=await mkdtemp(path.join(tmpdir(),'receipt-fields-'))})
afterEach(async()=>{await rm(dir,{recursive:true,force:true})})
async function workbook(row:unknown[],columns=headers){
const file=path.join(dir,'data.xlsx'),book=new ExcelJS.Workbook(),sheet=book.addWorksheet('收据数据')
sheet.addRow(columns);sheet.addRow(row);await book.xlsx.writeFile(file);return file
}
it('下载模板包含学校、年级、姓名,金额列和筛选范围随之调整',async()=>{
const file=path.join(dir,'template.xlsx');await createWorkbookTemplate(file)
const book=new ExcelJS.Workbook();await book.xlsx.readFile(file);const sheet=book.worksheets[0]
expect(sheet.getRow(1).values).toEqual([undefined,...headers])
expect(sheet.getColumn(6).numFmt).toBe('0.00');expect(sheet.autoFilter).toBe('A1:H1')
const draft=await new XlsxReader().read(file)
expect(draft.rows[0].errors).toEqual([])
expect(draft.rows[0].receipt.customer).toBe('某某学校 初二年级 张三')
})
it('三列去除首尾空白后,用单个空格拼接,并保留原字段用于扩展',async()=>{
const file=await workbook(['2026-10-16',' 巴南中学 ',' 高2028届 ',' 张三 ','服务费',3500])
const draft=await new XlsxReader().read(file),receipt=draft.rows[0].receipt
expect(receipt.customer).toBe('巴南中学 高2028届 张三')
expect(receipt).toMatchObject({school:'巴南中学',grade:'高2028届',studentName:'张三'})
expect(draft.rows[0].errors).toEqual([])
const store=new Ledger(path.join(dir,'db.sqlite'));await store.open()
const batch=await store.reserve({name:'data.xlsx',hash:'hash'},[receipt],defaultSettings(),null)
expect(store.get(batch.id).receipts[0]).toMatchObject({school:'巴南中学',grade:'高2028届',studentName:'张三',customer:'巴南中学 高2028届 张三'})
})
it.each([[1,'学校'],[2,'年级'],[3,'姓名']] as const)('%s 列缺失时,定位具体必填字段',async(index,label)=>{
const row:unknown[]=['2026-10-16','学校','年级','张三','服务费',3500];row[index]=' '
const draft=await new XlsxReader().read(await workbook(row))
expect(draft.rows[0].errors).toContain(label+'不能为空')
})
it('缺少拆分后的表头时提示重新下载模板',async()=>{
const file=await workbook(['2026-10-16','学校 年级 张三','服务费',3500],['收款日期','客户名称','收费项目','金额'])
await expect(new XlsxReader().read(file)).rejects.toThrow('第一行缺少表头:学校、年级、姓名')
})
it('按表头识别三列,而非依赖固定列序',async()=>{
const file=await workbook(['张三',3500,'高2028届','2026-10-16','服务费','学校'],['姓名','金额','年级','收款日期','收费项目','学校'])
const draft=await new XlsxReader().read(file)
expect(draft.rows[0].receipt.customer).toBe('学校 高2028届 张三')
expect(draft.rows[0].errors).toEqual([])
})