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{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{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})} })