160 lines
16 KiB
JavaScript
160 lines
16 KiB
JavaScript
import ExcelJS from 'exceljs';
|
|
import PDFDocument from 'pdfkit';
|
|
|
|
const MONTHS=['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
|
|
const INTERVALS={monthly:{label:'Monatlich',step:1},quarterly:{label:'Vierteljährlich',step:3},semiannual:{label:'Halbjährlich',step:6},yearly:{label:'Jährlich',step:12}};
|
|
const money=new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR'});
|
|
|
|
function monthKey(year,monthIndex){return `${year}-${String(monthIndex+1).padStart(2,'0')}`;}
|
|
function dateMonth(value){return typeof value==='string'&&/^\d{4}-\d{2}-\d{2}$/.test(value)?value.slice(0,7):null;}
|
|
function activeInMonth(item,year,monthIndex){const key=monthKey(year,monthIndex);const end=dateMonth(item.endDate);return !end||key<=end;}
|
|
function occursInMonth(item,year,monthIndex){
|
|
if(!activeInMonth(item,year,monthIndex))return false;
|
|
const interval=item.interval||'monthly'; if(interval==='monthly')return true;
|
|
const dueMonth=Number(item.dueMonth); if(!Number.isInteger(dueMonth)||dueMonth<1||dueMonth>12)return false;
|
|
const start=dueMonth-1; const step=INTERVALS[interval]?.step||12; return monthIndex>=start&&(monthIndex-start)%step===0;
|
|
}
|
|
function effectiveAmount(item,year,monthIndex){
|
|
const key=monthKey(year,monthIndex); let amount=Number(item.amount||0); let best='';
|
|
for(const change of item.amountChanges||[]){const mk=dateMonth(change.effectiveFrom);if(mk&&mk<=key&&mk>=best){best=mk;amount=Number(change.amount||0);}}
|
|
return amount;
|
|
}
|
|
function accountName(data,id){return data.accounts.find(a=>a.id===id)?.name||'Unbekannt';}
|
|
function categoryName(data,id){return data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie';}
|
|
function personName(data,id){return data.persons?.find(p=>p.id===id)?.name||'Ohne Person';}
|
|
function personIdsOf(item){return Array.isArray(item?.personIds)?item.personIds.filter(Boolean):typeof item?.personId==='string'&&item.personId?[item.personId]:[];}
|
|
function personLabel(data,item){const ids=personIdsOf(item);return ids.length?ids.map(id=>personName(data,id)).join(', '):'Ohne Person';}
|
|
function personFactor(item,personId='all'){if(personId==='all')return 1;const ids=personIdsOf(item);return ids.includes(personId)&&ids.length?1/ids.length:0;}
|
|
function ownerIdsOf(account){return Array.isArray(account?.ownerPersonIds)?[...new Set(account.ownerPersonIds.filter(Boolean))]:[];}
|
|
function transferFactorForAccount(data,item,personId='all',accountId=''){
|
|
if(personId==='all')return 1;
|
|
const from=data.accounts.find(a=>a.id===item.fromAccountId);const to=data.accounts.find(a=>a.id===item.toAccountId);const selected=data.accounts.find(a=>a.id===accountId);
|
|
let ids=[];
|
|
if(selected?.ownership==='personal')ids=ownerIdsOf(selected);
|
|
else if(item.toAccountId===accountId&&from?.ownership==='personal')ids=ownerIdsOf(from);
|
|
else if(item.fromAccountId===accountId&&to?.ownership==='personal')ids=ownerIdsOf(to);
|
|
else return personFactor(item,personId);
|
|
return ids.includes(personId)?1:0;
|
|
}
|
|
function transferPersonLabel(data,item){
|
|
const from=data.accounts.find(a=>a.id===item.fromAccountId);const to=data.accounts.find(a=>a.id===item.toAccountId);
|
|
if(from?.ownership==='personal'&&to?.ownership==='personal'&&ownerIdsOf(from).length&&ownerIdsOf(to).length)return `${ownerIdsOf(from).map(id=>personName(data,id)).join(', ')} → ${ownerIdsOf(to).map(id=>personName(data,id)).join(', ')}`;
|
|
return personLabel(data,item);
|
|
}
|
|
|
|
export function buildAnnualForecast(data,year,accountId,personId='all',includeTransfers=true){
|
|
const rows=MONTHS.map((name,index)=>({name,index,income:0,expense:0,transferIn:0,transferOut:0,displayIncome:0,displayExpense:0,balance:0,items:[]}));
|
|
for(const item of data.incomes||[]){
|
|
const factor=personFactor(item,personId); if(!factor||item.accountId!==accountId)continue;
|
|
for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m)*factor;rows[m].income+=amount;rows[m].items.push({...item,kind:'income',reportAmount:amount});}
|
|
}
|
|
for(const item of data.expenses||[]){
|
|
const factor=personFactor(item,personId); if(!factor||item.accountId!==accountId)continue;
|
|
for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m)*factor;rows[m].expense+=amount;rows[m].items.push({...item,kind:'expense',reportAmount:amount});}
|
|
}
|
|
if(includeTransfers) for(const item of data.transfers||[]){
|
|
const factor=transferFactorForAccount(data,item,personId,accountId); if(!factor||(item.fromAccountId!==accountId&&item.toAccountId!==accountId))continue;
|
|
for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){
|
|
const amount=Number(item.amount||0)*factor;
|
|
if(item.toAccountId===accountId){rows[m].transferIn+=amount;rows[m].items.push({...item,kind:'transfer',direction:'in',reportAmount:amount});}
|
|
if(item.fromAccountId===accountId){rows[m].transferOut+=amount;rows[m].items.push({...item,kind:'transfer',direction:'out',reportAmount:amount});}
|
|
}
|
|
}
|
|
for(const r of rows){r.displayIncome=r.income+(includeTransfers?r.transferIn:0);r.displayExpense=r.expense+(includeTransfers?r.transferOut:0);r.balance=r.displayIncome-r.displayExpense;}
|
|
return {year,accountId,rows,yearIncome:rows.reduce((s,r)=>s+r.displayIncome,0),yearExpense:rows.reduce((s,r)=>s+r.displayExpense,0)};
|
|
}
|
|
|
|
function occurrenceRows(data,year,accountId,personId='all',includeTransfers=true){
|
|
const result=[];
|
|
const push=(item,kind,monthIndex,direction='',factor=1)=>{
|
|
const amount=(kind==='transfer'?Number(item.amount||0):effectiveAmount(item,year,monthIndex))*factor;
|
|
result.push({monthIndex,month:MONTHS[monthIndex],kind,direction,name:item.name,person:kind==='transfer'?transferPersonLabel(data,item):personLabel(data,item),category:categoryName(data,item.categoryId),amount});
|
|
};
|
|
for(const item of data.incomes||[]){const factor=personFactor(item,personId);if(factor&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'income',m,'',factor);}
|
|
for(const item of data.expenses||[]){const factor=personFactor(item,personId);if(factor&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'expense',m,'',factor);}
|
|
if(includeTransfers) for(const item of data.transfers||[]){const factor=transferFactorForAccount(data,item,personId,accountId);if(factor&&(item.fromAccountId===accountId||item.toAccountId===accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){
|
|
if(item.toAccountId===accountId)push(item,'transfer',m,'in',factor);
|
|
if(item.fromAccountId===accountId)push(item,'transfer',m,'out',factor);
|
|
}}
|
|
const order={income:0,transferIn:1,expense:2,transferOut:3};
|
|
const typeOrder=r=>r.kind==='income'?order.income:r.kind==='expense'?order.expense:r.direction==='in'?order.transferIn:order.transferOut;
|
|
return result.sort((a,b)=>a.monthIndex-b.monthIndex||typeOrder(a)-typeOrder(b)||a.name.localeCompare(b.name,'de'));
|
|
}
|
|
|
|
function kindLabel(row){return row.kind==='income'?'Eingang':row.kind==='expense'?'Ausgabe':row.direction==='in'?'Transfer rein':'Transfer raus';}
|
|
function isInflow(row){return row.kind==='income'||row.direction==='in';}
|
|
|
|
function styleHeader(row){row.font={bold:true,color:{argb:'FFFFFFFF'}};row.fill={type:'pattern',pattern:'solid',fgColor:{argb:'FF263A67'}};row.alignment={vertical:'middle'};row.height=22;}
|
|
function addTitle(sheet,title,subtitle='',lastColumn='F'){
|
|
sheet.mergeCells(`A1:${lastColumn}1`);const c=sheet.getCell('A1');c.value=title;c.font={bold:true,size:20,color:{argb:'FF17233B'}};c.alignment={vertical:'middle'};sheet.getRow(1).height=32;
|
|
if(subtitle){sheet.mergeCells(`A2:${lastColumn}2`);sheet.getCell('A2').value=subtitle;sheet.getCell('A2').font={italic:true,color:{argb:'FF667085'}};}
|
|
}
|
|
function widths(sheet,values){values.forEach((w,i)=>sheet.getColumn(i+1).width=w);}
|
|
function euroCell(cell){cell.numFmt='#,##0.00 [$€-de-DE]';}
|
|
|
|
export async function createExcelReport(data,{year,accountId,personId='all',includeTransfers=true}={}){
|
|
const reportYear=Number(year)||new Date().getFullYear();
|
|
const account=data.accounts.find(a=>a.id===accountId); if(!account)throw new Error('Bitte ein gültiges Konto für den Kontoauszug auswählen.');
|
|
const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId);
|
|
const forecast=buildAnnualForecast(data,reportYear,accountId,personId,includeTransfers); const rows=occurrenceRows(data,reportYear,accountId,personId,includeTransfers);
|
|
const wb=new ExcelJS.Workbook();wb.creator='FixFin';wb.created=new Date();wb.modified=new Date();
|
|
|
|
const statement=wb.addWorksheet('Kontoauszug',{views:[{state:'frozen',ySplit:4}]});
|
|
addTitle(statement,`FixFin Kontoauszug ${reportYear}`,`${account.name} · ${selectedPerson} · Planungsdaten · Erstellt am ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`,'G');
|
|
statement.getRow(4).values=['Monat','Art','Bezeichnung','Personen','Kategorie','Zufluss','Abfluss'];styleHeader(statement.getRow(4));
|
|
rows.forEach((r,i)=>{const rr=i+5;statement.getRow(rr).values=[r.month,kindLabel(r),r.name,r.person,r.category,isInflow(r)?r.amount:null,isInflow(r)?null:r.amount];euroCell(statement.getCell(rr,6));euroCell(statement.getCell(rr,7));if(isInflow(r))statement.getCell(rr,6).font={color:{argb:'FF067647'}};else statement.getCell(rr,7).font={color:{argb:'FFB42318'}};});
|
|
const totalRow=rows.length+6;statement.getCell(totalRow,1).value='Jahressumme';statement.getCell(totalRow,1).font={bold:true};statement.getCell(totalRow,6).value=forecast.yearIncome;statement.getCell(totalRow,7).value=forecast.yearExpense;euroCell(statement.getCell(totalRow,6));euroCell(statement.getCell(totalRow,7));statement.getCell(totalRow,6).font={bold:true,color:{argb:'FF067647'}};statement.getCell(totalRow,7).font={bold:true,color:{argb:'FFB42318'}};
|
|
statement.autoFilter={from:'A4',to:'G4'};widths(statement,[16,18,30,20,24,18,18]);
|
|
|
|
const summary=wb.addWorksheet('Monatsübersicht',{views:[{state:'frozen',ySplit:4}]});
|
|
addTitle(summary,`Monatsübersicht ${reportYear}`,`${account.name} · ${selectedPerson} · ${includeTransfers?'Liquiditätssicht inklusive Transfers':'Auswertung ohne Transfers'}`,'D');
|
|
summary.getRow(4).values=['Monat','Zuflüsse','Abflüsse','Nettofluss'];styleHeader(summary.getRow(4));
|
|
forecast.rows.forEach((r,i)=>{const rr=i+5;summary.getRow(rr).values=[r.name,r.displayIncome,r.displayExpense,r.balance];for(let c=2;c<=4;c++)euroCell(summary.getCell(rr,c));summary.getCell(rr,4).font={color:{argb:r.balance<0?'FFB42318':'FF067647'}};});
|
|
summary.getRow(18).values=['Jahr',forecast.yearIncome,forecast.yearExpense,forecast.yearIncome-forecast.yearExpense];summary.getRow(18).font={bold:true};for(let c=2;c<=4;c++)euroCell(summary.getCell(18,c));summary.getCell(18,4).font={bold:true,color:{argb:forecast.yearIncome-forecast.yearExpense<0?'FFB42318':'FF067647'}};widths(summary,[20,20,20,20]);
|
|
|
|
return wb.xlsx.writeBuffer();
|
|
}
|
|
|
|
function collectPdfBuffer(doc){return new Promise((resolve,reject)=>{const chunks=[];doc.on('data',c=>chunks.push(c));doc.on('end',()=>resolve(Buffer.concat(chunks)));doc.on('error',reject);});}
|
|
function pdfMoney(value){return money.format(Number(value||0));}
|
|
function drawPdfHeader(doc,title,subtitle){doc.fillColor('#17233B').font('Helvetica-Bold').fontSize(20).text(title);doc.moveDown(.2).fillColor('#667085').font('Helvetica').fontSize(9).text(subtitle);doc.moveDown(.7);doc.strokeColor('#CCD5E1').moveTo(50,doc.y).lineTo(545,doc.y).stroke();doc.moveDown(.7);}
|
|
function addPageIfNeeded(doc,height=80){if(doc.y+height>760)doc.addPage();}
|
|
function pdfMetricRow(doc,items){addPageIfNeeded(doc,48);const y=doc.y;const width=495/items.length;items.forEach((item,i)=>{const x=50+i*width;doc.roundedRect(x,y,width-8,40,6).fillAndStroke('#F4F6FA','#D6DCE7');doc.fillColor('#667085').font('Helvetica').fontSize(7.5).text(item.label,x+8,y+7,{width:width-24});doc.fillColor(item.negative?'#B42318':item.positive?'#067647':'#17233B').font('Helvetica-Bold').fontSize(11).text(item.value,x+8,y+20,{width:width-24});});doc.y=y+50;}
|
|
function pdfTableHeader(doc,cols){const y=doc.y;doc.rect(50,y,495,20).fill('#263A67');let x=50;for(const col of cols){doc.fillColor('#FFFFFF').font('Helvetica-Bold').fontSize(7.5).text(col.label,x+5,y+6,{width:col.width-10,align:col.align||'left'});x+=col.width;}doc.y=y+20;}
|
|
function drawTableRow(doc,cols,values,{toneCols={}}={}){const y=doc.y;doc.rect(50,y,495,21).fill('#FFFFFF');doc.strokeColor('#E4E7EC').moveTo(50,y+21).lineTo(545,y+21).stroke();let x=50;cols.forEach((col,i)=>{doc.fillColor(toneCols[i]||'#344054').font('Helvetica').fontSize(7.5).text(String(values[i]??''),x+5,y+6,{width:col.width-10,align:col.align||'left',ellipsis:true});x+=col.width;});doc.y=y+21;}
|
|
|
|
export async function createPdfReport(data,{year,accountId,personId='all',includeTransfers=true}={}){
|
|
const reportYear=Number(year)||new Date().getFullYear();
|
|
const account=data.accounts.find(a=>a.id===accountId); if(!account)throw new Error('Bitte ein gültiges Konto für den Kontoauszug auswählen.');
|
|
const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId);
|
|
const forecast=buildAnnualForecast(data,reportYear,accountId,personId,includeTransfers);const rows=occurrenceRows(data,reportYear,accountId,personId,includeTransfers);
|
|
const doc=new PDFDocument({size:'A4',margin:50,info:{Title:`FixFin Kontoauszug ${reportYear} - ${account.name}`,Author:'FixFin'}});const done=collectPdfBuffer(doc);
|
|
drawPdfHeader(doc,`FixFin Kontoauszug ${reportYear}`,`${account.name} · ${selectedPerson} · ${includeTransfers?'inkl. Transfers':'ohne Transfers'} · Erstellt ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`);
|
|
pdfMetricRow(doc,[{label:'Zuflüsse / Jahr',value:pdfMoney(forecast.yearIncome),positive:true},{label:'Abflüsse / Jahr',value:pdfMoney(forecast.yearExpense),negative:true},{label:'Nettofluss / Jahr',value:pdfMoney(forecast.yearIncome-forecast.yearExpense),positive:forecast.yearIncome>=forecast.yearExpense,negative:forecast.yearIncome<forecast.yearExpense}]);
|
|
|
|
const cols=[{label:'Art',width:70},{label:'Bezeichnung',width:130},{label:'Personen',width:80},{label:'Kategorie',width:90},{label:'Zufluss',width:63,align:'right'},{label:'Abfluss',width:62,align:'right'}];
|
|
for(let monthIndex=0;monthIndex<12;monthIndex++){
|
|
const monthRows=rows.filter(r=>r.monthIndex===monthIndex);const month=forecast.rows[monthIndex];
|
|
|
|
// Jeder Monat beginnt auf einer eigenen Seite. Der Monatsname steht immer
|
|
// links und nutzt die volle Inhaltsbreite, damit er niemals umbrechen kann.
|
|
doc.addPage();
|
|
drawPdfHeader(doc,`Kontoauszug ${reportYear}`,`${account.name} · ${selectedPerson}`);
|
|
const monthY=doc.y;
|
|
doc.fillColor('#17233B').font('Helvetica-Bold').fontSize(16)
|
|
.text(month.name,50,monthY,{width:495,align:'left',lineBreak:false});
|
|
doc.y=monthY+24;
|
|
|
|
pdfMetricRow(doc,[{label:'Zuflüsse',value:pdfMoney(month.displayIncome),positive:true},{label:'Abflüsse',value:pdfMoney(month.displayExpense),negative:true},{label:'Nettofluss',value:pdfMoney(month.balance),positive:month.balance>=0,negative:month.balance<0}]);
|
|
if(!monthRows.length){doc.fillColor('#667085').font('Helvetica').fontSize(8).text('Keine geplanten Kontobewegungen.');doc.moveDown(.8);continue;}
|
|
pdfTableHeader(doc,cols);
|
|
for(const row of monthRows){
|
|
if(doc.y+22>760){doc.addPage();drawPdfHeader(doc,`Kontoauszug ${reportYear}`,`${account.name} · ${selectedPerson} · ${month.name} · Fortsetzung`);pdfTableHeader(doc,cols);}
|
|
const inflow=isInflow(row);drawTableRow(doc,cols,[kindLabel(row),row.name,row.person,row.category,inflow?pdfMoney(row.amount):'',inflow?'':pdfMoney(row.amount)],{toneCols:inflow?{4:'#067647'}:{5:'#B42318'}});
|
|
}
|
|
doc.moveDown(.8);
|
|
}
|
|
addPageIfNeeded(doc,40);doc.strokeColor('#D6DCE7').moveTo(50,doc.y).lineTo(545,doc.y).stroke();doc.moveDown(.45);doc.fillColor('#667085').font('Helvetica').fontSize(7).text('Erstellt aus FixFin-Planungsdaten. Kein von einer Bank ausgestellter Kontoauszug.');
|
|
doc.end();return done;
|
|
}
|