Files

107 lines
7.8 KiB
TypeScript

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