V3.16
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
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 scopeAccounts(data,accountId){return accountId&&accountId!=='all'?data.accounts.filter(a=>a.id===accountId):data.accounts;}
|
||||
function inScope(item,kind,accountId){if(!accountId||accountId==='all')return true;if(kind==='transfer')return item.fromAccountId===accountId||item.toAccountId===accountId;return item.accountId===accountId;}
|
||||
|
||||
export function buildAnnualForecast(data,year,accountId='all'){
|
||||
const isAll=!accountId||accountId==='all';
|
||||
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||[]) if(inScope(item,'income',accountId)) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m);rows[m].income+=amount;rows[m].items.push({...item,kind:'income',reportAmount:amount});}
|
||||
for(const item of data.expenses||[]) if(inScope(item,'expense',accountId)) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m);rows[m].expense+=amount;rows[m].items.push({...item,kind:'expense',reportAmount:amount});}
|
||||
for(const item of data.transfers||[]) if(inScope(item,'transfer',accountId)) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){
|
||||
const amount=Number(item.amount||0);
|
||||
if(isAll){rows[m].transferIn+=amount;rows[m].transferOut+=amount;rows[m].items.push({...item,kind:'transfer',direction:'neutral',reportAmount:amount});}
|
||||
else {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=isAll?r.income:r.income+r.transferIn;r.displayExpense=isAll?r.expense:r.expense+r.transferOut;r.balance=r.displayIncome-r.displayExpense;}
|
||||
return {year,accountId,isAll,rows,yearIncome:rows.reduce((s,r)=>s+r.displayIncome,0),yearExpense:rows.reduce((s,r)=>s+r.displayExpense,0)};
|
||||
}
|
||||
|
||||
|
||||
function reportWarnings(data,year,accountId='all'){
|
||||
const warnings=[];
|
||||
for(const account of scopeAccounts(data,accountId)){
|
||||
const forecast=buildAnnualForecast(data,year,account.id);
|
||||
const negatives=forecast.rows.filter(r=>r.balance<0);
|
||||
if(negatives.length){
|
||||
const worst=negatives.reduce((a,b)=>b.balance<a.balance?b:a);
|
||||
warnings.push({level:'Achtung',title:`${account.name}: geplante Unterdeckung im ${worst.name}`,text:`Abflüsse übersteigen die Zuflüsse um ${money.format(Math.abs(worst.balance))}.`,sort:0,month:worst.index});
|
||||
}
|
||||
}
|
||||
for(let m=0;m<12;m++){
|
||||
const due=(data.expenses||[]).filter(x=>inScope(x,'expense',accountId)&&x.interval!=='monthly'&&occursInMonth(x,year,m));
|
||||
if(due.length>=3){
|
||||
const total=due.reduce((sum,x)=>sum+effectiveAmount(x,year,m),0);
|
||||
warnings.push({level:'Hinweis',title:`${due.length} periodische Ausgaben im ${MONTHS[m]}`,text:`Zusammen ${money.format(total)}. Mehrere größere Fälligkeiten liegen im selben Monat.`,sort:1,month:m});
|
||||
}
|
||||
}
|
||||
for(const [kind,items] of [['Eingang',data.incomes||[]],['Ausgang',data.expenses||[]]]){
|
||||
for(const item of items){
|
||||
if(!inScope(item,kind==='Eingang'?'income':'expense',accountId))continue;
|
||||
for(const change of item.amountChanges||[]){
|
||||
if(String(change.effectiveFrom||'').startsWith(`${year}-`))warnings.push({level:'Info',title:`${item.name}: Betrag ändert sich`,text:`${kind} auf ${accountName(data,item.accountId)} ab ${change.effectiveFrom}: ${money.format(Number(change.amount||0))}.`,sort:2,month:Number(change.effectiveFrom.slice(5,7))-1});
|
||||
}
|
||||
if(String(item.endDate||'').startsWith(`${year}-`))warnings.push({level:'Info',title:`${item.name} läuft aus`,text:`${kind} auf ${accountName(data,item.accountId)} endet am ${item.endDate}.`,sort:3,month:Number(item.endDate.slice(5,7))-1});
|
||||
}
|
||||
}
|
||||
return warnings.sort((a,b)=>a.sort-b.sort||a.month-b.month||a.title.localeCompare(b.title,'de')).slice(0,25);
|
||||
}
|
||||
|
||||
function occurrenceRows(data,year,accountId='all'){
|
||||
const result=[];
|
||||
const push=(item,kind,monthIndex,direction='')=>{
|
||||
let amount=kind==='transfer'?Number(item.amount||0):effectiveAmount(item,year,monthIndex);
|
||||
let account='';
|
||||
if(kind==='transfer')account=`${accountName(data,item.fromAccountId)} -> ${accountName(data,item.toAccountId)}`;
|
||||
else account=accountName(data,item.accountId);
|
||||
result.push({monthIndex,month:MONTHS[monthIndex],kind,direction,name:item.name,category:categoryName(data,item.categoryId),account,amount,interval:INTERVALS[item.interval||'monthly']?.label||item.interval,endDate:item.endDate||'',id:item.id});
|
||||
};
|
||||
for(const item of data.incomes||[])if(inScope(item,'income',accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'income',m);
|
||||
for(const item of data.expenses||[])if(inScope(item,'expense',accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'expense',m);
|
||||
for(const item of data.transfers||[])if(inScope(item,'transfer',accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){
|
||||
if(accountId==='all'||!accountId)push(item,'transfer',m,'neutral');
|
||||
else {if(item.toAccountId===accountId)push(item,'transfer',m,'in');if(item.fromAccountId===accountId)push(item,'transfer',m,'out');}
|
||||
}
|
||||
return result.sort((a,b)=>a.monthIndex-b.monthIndex||a.kind.localeCompare(b.kind,'de')||a.name.localeCompare(b.name,'de'));
|
||||
}
|
||||
|
||||
function safeSheetName(name){return name.replace(/[\\/*?:\[\]]/g,' ').slice(0,31)||'Konto';}
|
||||
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=''){
|
||||
sheet.mergeCells('A1:H1');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:H2');sheet.getCell('A2').value=subtitle;sheet.getCell('A2').font={italic:true,color:{argb:'FF667085'}};}
|
||||
}
|
||||
function widths(sheet,widths){widths.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='all'}={}){
|
||||
const reportYear=Number(year)||new Date().getFullYear();const forecast=buildAnnualForecast(data,reportYear,accountId);
|
||||
const wb=new ExcelJS.Workbook();wb.creator='FixFin';wb.created=new Date();wb.modified=new Date();
|
||||
const scope=accountId==='all'?'Alle Konten':accountName(data,accountId);
|
||||
const overview=wb.addWorksheet('Übersicht',{views:[{state:'frozen',ySplit:4}]});addTitle(overview,`FixFin Planungs-Auszug ${reportYear}`,`Umfang: ${scope} · Erstellt am ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`);
|
||||
overview.getRow(4).values=['Kennzahl','Wert'];overview.getRow(5).values=[forecast.isAll?'Einnahmen / Jahr':'Zuflüsse / Jahr',forecast.yearIncome];overview.getRow(6).values=[forecast.isAll?'Ausgaben / Jahr':'Abflüsse / Jahr',forecast.yearExpense];overview.getRow(7).values=[forecast.isAll?'Überschuss / Jahr':'Nettofluss / Jahr',forecast.yearIncome-forecast.yearExpense];styleHeader(overview.getRow(4));for(let r=5;r<=7;r++)euroCell(overview.getCell(r,2));
|
||||
let row=10;overview.getCell(row,1).value='Kontenübersicht';overview.getCell(row,1).font={bold:true,size:14};row++;
|
||||
overview.getRow(row).values=['Konto','Zuflüsse / Jahr','Abflüsse / Jahr','Nettofluss / Jahr'];styleHeader(overview.getRow(row));row++;
|
||||
for(const account of scopeAccounts(data,accountId)){const f=buildAnnualForecast(data,reportYear,account.id);overview.getRow(row).values=[account.name,f.yearIncome,f.yearExpense,f.yearIncome-f.yearExpense];for(let c=2;c<=4;c++)euroCell(overview.getCell(row,c));row++;}
|
||||
widths(overview,[28,20,20,20,14,14,14,14]);
|
||||
|
||||
const annual=wb.addWorksheet('Jahresplan',{views:[{state:'frozen',ySplit:4}]});addTitle(annual,`Jahresplan ${reportYear}`,forecast.isAll?'Wirtschaftliche Sicht: Transfers bleiben saldoneutral.':`Liquiditätssicht für ${scope}: Transfers werden als Zu- bzw. Abfluss berücksichtigt.`);
|
||||
annual.getRow(4).values=['Monat',forecast.isAll?'Einnahmen':'Zuflüsse',forecast.isAll?'Ausgaben':'Abflüsse',forecast.isAll?'Überschuss':'Nettofluss','Echte Einnahmen','Echte Ausgaben','Transfers rein','Transfers raus'];styleHeader(annual.getRow(4));
|
||||
forecast.rows.forEach((r,i)=>{const rr=i+5;annual.getRow(rr).values=[r.name,r.displayIncome,r.displayExpense,r.balance,r.income,r.expense,r.transferIn,r.transferOut];for(let c=2;c<=8;c++)euroCell(annual.getCell(rr,c));});widths(annual,[18,18,18,18,18,18,18,18]);
|
||||
|
||||
const warnings=reportWarnings(data,reportYear,accountId);
|
||||
const warningSheet=wb.addWorksheet('Hinweise');addTitle(warningSheet,`Planungshinweise ${reportYear}`,`Automatisch aus den hinterlegten Zu- und Abflüssen, Laufzeiten und Betragsänderungen abgeleitet.`);warningSheet.getRow(4).values=['Stufe','Hinweis','Details'];styleHeader(warningSheet.getRow(4));warnings.forEach((w,i)=>{warningSheet.getRow(i+5).values=[w.level,w.title,w.text];});if(!warnings.length)warningSheet.getRow(5).values=['OK','Keine auffälligen Planungshinweise','Für den gewählten Zeitraum wurden keine der definierten Warnbedingungen ausgelöst.'];widths(warningSheet,[14,42,74]);
|
||||
|
||||
const moves=wb.addWorksheet('Bewegungen',{views:[{state:'frozen',ySplit:4}]});addTitle(moves,`Geplante Bewegungen ${reportYear}`,`Alle im gewählten Umfang tatsächlich fälligen Positionen, aufbereitet wie ein Planungs-Kontoauszug.`);
|
||||
moves.getRow(4).values=['Monat','Art','Bezeichnung','Kategorie','Konto / Route','Betrag','Intervall','Enddatum'];styleHeader(moves.getRow(4));
|
||||
const kindLabel=r=>r.kind==='income'?'Eingang':r.kind==='expense'?'Ausgang':r.direction==='in'?'Transfer rein':r.direction==='out'?'Transfer raus':'Transfer';
|
||||
occurrenceRows(data,reportYear,accountId).forEach((r,i)=>{const rr=i+5;moves.getRow(rr).values=[r.month,kindLabel(r),r.name,r.category,r.account,r.amount,r.interval,r.endDate];euroCell(moves.getCell(rr,6));});widths(moves,[14,16,30,22,34,16,18,14]);
|
||||
|
||||
const changes=wb.addWorksheet('Änderungen');addTitle(changes,'Geplante Betragsänderungen','Terminierte Änderungen an Eingängen und Ausgängen.');changes.getRow(4).values=['Art','Bezeichnung','Konto','Wirksam ab','Neuer Betrag','Enddatum'];styleHeader(changes.getRow(4));let cr=5;
|
||||
for(const [kind,items] of [['Eingang',data.incomes||[]],['Ausgang',data.expenses||[]]])for(const item of items)if(inScope(item,kind==='Eingang'?'income':'expense',accountId))for(const change of item.amountChanges||[]){changes.getRow(cr).values=[kind,item.name,accountName(data,item.accountId),change.effectiveFrom,Number(change.amount||0),item.endDate||''];euroCell(changes.getCell(cr,5));cr++;}widths(changes,[14,30,28,14,18,14]);
|
||||
|
||||
const master=wb.addWorksheet('Stammdaten');addTitle(master,'Stammdaten','Definitionen der geplanten Einnahmen, Ausgaben und Transfers.');master.getRow(4).values=['Art','Bezeichnung','Konto / Route','Kategorie','Basisbetrag','Intervall','Fälligkeitsmonat','Enddatum','Reminder'];styleHeader(master.getRow(4));let mr=5;
|
||||
const addMaster=(kind,item,route)=>{master.getRow(mr).values=[kind,item.name,route,categoryName(data,item.categoryId),Number(item.amount||0),INTERVALS[item.interval||'monthly']?.label||item.interval,item.dueMonth?MONTHS[Number(item.dueMonth)-1]:'',item.endDate||'',item.pushoverReminder?'Ja':''];euroCell(master.getCell(mr,5));mr++;};
|
||||
for(const x of data.incomes||[])if(inScope(x,'income',accountId))addMaster('Eingang',x,accountName(data,x.accountId));for(const x of data.expenses||[])if(inScope(x,'expense',accountId))addMaster('Ausgang',x,accountName(data,x.accountId));for(const x of data.transfers||[])if(inScope(x,'transfer',accountId))addMaster('Transfer',x,`${accountName(data,x.fromAccountId)} -> ${accountName(data,x.toAccountId)}`);widths(master,[14,30,34,22,16,18,18,14,12]);
|
||||
|
||||
const system=wb.addWorksheet('Konten & Kategorien');addTitle(system,'Konten, Kategorien & Einstellungen','Technische Zugangsdaten wie Pushover-Token werden bewusst nicht exportiert.');
|
||||
system.getRow(4).values=['Einstellung','Wert'];styleHeader(system.getRow(4));system.getRow(5).values=['Periodische Posten im Monats-Saldo',data.settings?.includePeriodicInBalance===false?'Nein':'Ja'];
|
||||
let sysRow=8;system.getCell(sysRow,1).value='Konten';system.getCell(sysRow,1).font={bold:true,size:14};sysRow++;system.getRow(sysRow).values=['Konto-ID','Kontoname'];styleHeader(system.getRow(sysRow));sysRow++;for(const account of scopeAccounts(data,accountId)){system.getRow(sysRow).values=[account.id,account.name];sysRow++;}
|
||||
sysRow+=2;system.getCell(sysRow,1).value='Kategorien';system.getCell(sysRow,1).font={bold:true,size:14};sysRow++;system.getRow(sysRow).values=['Kategorie-ID','Kategoriename'];styleHeader(system.getRow(sysRow));sysRow++;for(const category of data.categories||[]){system.getRow(sysRow).values=[category.id,category.name];sysRow++;}widths(system,[42,42,18,18,18,18,18,18]);
|
||||
|
||||
const scenarios=wb.addWorksheet('Szenarien');addTitle(scenarios,'Szenarien','Was-wäre-wenn-Anpassungen aus FixFin.');scenarios.getRow(4).values=['Szenario','Art','Bezeichnung','Konto / Route','Betrag / Monat'];styleHeader(scenarios.getRow(4));let sr=5;for(const s of data.scenarios||[])for(const a of s.adjustments||[]){const relevant=accountId==='all'||(a.type==='transfer'?(a.fromAccountId===accountId||a.toAccountId===accountId):a.accountId===accountId);if(!relevant)continue;scenarios.getRow(sr).values=[s.name,a.type==='income'?'Eingang':a.type==='expense'?'Ausgang':'Transfer',a.name,a.type==='transfer'?`${accountName(data,a.fromAccountId)} -> ${accountName(data,a.toAccountId)}`:accountName(data,a.accountId),Number(a.amount||0)];euroCell(scenarios.getCell(sr,5));sr++;}widths(scenarios,[28,14,30,34,18]);
|
||||
|
||||
for(const [accountIndex,account] of scopeAccounts(data,accountId).entries()){
|
||||
const f=buildAnnualForecast(data,reportYear,account.id);const sh=wb.addWorksheet(safeSheetName(`Konto ${account.name}`.slice(0,27)+` ${accountIndex+1}`),{views:[{state:'frozen',ySplit:4}]});addTitle(sh,account.name,`Liquiditäts-Auszug ${reportYear} · Zuflüsse inkl. Transfers rein, Abflüsse inkl. Transfers raus.`);sh.getRow(4).values=['Monat','Zuflüsse','Abflüsse','Nettofluss'];styleHeader(sh.getRow(4));f.rows.forEach((r,i)=>{const rr=i+5;sh.getRow(rr).values=[r.name,r.displayIncome,r.displayExpense,r.balance];for(let c=2;c<=4;c++)euroCell(sh.getCell(rr,c));});let rr=19;sh.getCell(rr,1).value='Einzelpositionen';sh.getCell(rr,1).font={bold:true,size:14};rr++;sh.getRow(rr).values=['Monat','Art','Bezeichnung','Kategorie','Betrag'];styleHeader(sh.getRow(rr));rr++;for(const r of occurrenceRows(data,reportYear,account.id)){sh.getRow(rr).values=[r.month,kindLabel(r),r.name,r.category,r.amount];euroCell(sh.getCell(rr,5));rr++;}widths(sh,[16,18,32,24,18]);
|
||||
}
|
||||
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(v){return money.format(Number(v||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 ensurePdfSpace(doc,height=60){if(doc.y+height>770)doc.addPage();}
|
||||
function pdfSection(doc,title,subtitle=''){ensurePdfSpace(doc,50);doc.fillColor('#17233B').font('Helvetica-Bold').fontSize(14).text(title);if(subtitle)doc.fillColor('#667085').font('Helvetica').fontSize(8.5).text(subtitle);doc.moveDown(.45);}
|
||||
function pdfMetricRow(doc,items){ensurePdfSpace(doc,48);const y=doc.y;const width=495/items.length;items.forEach((x,i)=>{const x0=50+i*width;doc.roundedRect(x0,y,width-8,40,6).fillAndStroke('#F4F6FA','#D6DCE7');doc.fillColor('#667085').font('Helvetica').fontSize(7.5).text(x.label,x0+8,y+7,{width:width-24});doc.fillColor(x.negative?'#B42318':x.positive?'#067647':'#17233B').font('Helvetica-Bold').fontSize(11).text(x.value,x0+8,y+20,{width:width-24});});doc.y=y+50;}
|
||||
function pdfTableHeader(doc,cols){ensurePdfSpace(doc,30);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 pdfTableRow(doc,cols,values,{bold=false,tone=null}={}){ensurePdfSpace(doc,24);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(tone&&i===values.length-1?tone:'#344054').font(bold?'Helvetica-Bold':'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='all'}={}){
|
||||
const reportYear=Number(year)||new Date().getFullYear();const scope=accountId==='all'?'Alle Konten':accountName(data,accountId);const forecast=buildAnnualForecast(data,reportYear,accountId);
|
||||
const doc=new PDFDocument({size:'A4',margin:50,info:{Title:`FixFin Planungs-Auszug ${reportYear}`,Author:'FixFin'}});const done=collectPdfBuffer(doc);
|
||||
drawPdfHeader(doc,`FixFin Planungs-Auszug ${reportYear}`,`Umfang: ${scope} · Erstellt ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())} · Planungsdaten, kein Bankkontoauszug`);
|
||||
pdfMetricRow(doc,[{label:forecast.isAll?'Einnahmen / Jahr':'Zuflüsse / Jahr',value:pdfMoney(forecast.yearIncome),positive:true},{label:forecast.isAll?'Ausgaben / Jahr':'Abflüsse / Jahr',value:pdfMoney(forecast.yearExpense),negative:true},{label:forecast.isAll?'Überschuss / Jahr':'Nettofluss / Jahr',value:pdfMoney(forecast.yearIncome-forecast.yearExpense),positive:forecast.yearIncome>=forecast.yearExpense,negative:forecast.yearIncome<forecast.yearExpense}]);
|
||||
const warnings=reportWarnings(data,reportYear,accountId);if(warnings.length){pdfSection(doc,'Planungshinweise');for(const warning of warnings.slice(0,8)){ensurePdfSpace(doc,30);doc.fillColor(warning.level==='Achtung'?'#B42318':'#17233B').font('Helvetica-Bold').fontSize(8.5).text(`${warning.level}: ${warning.title}`);doc.fillColor('#667085').font('Helvetica').fontSize(7.5).text(warning.text);doc.moveDown(.3);}}
|
||||
pdfSection(doc,'Jahresübersicht',forecast.isAll?'Wirtschaftliche Sicht. Transfers werden nicht als Einnahmen oder Ausgaben gezählt.':'Liquiditätssicht. Transfers rein zählen als Zufluss, Transfers raus als Abfluss.');
|
||||
const mcols=[{label:'Monat',width:125},{label:forecast.isAll?'Einnahmen':'Zuflüsse',width:120,align:'right'},{label:forecast.isAll?'Ausgaben':'Abflüsse',width:120,align:'right'},{label:forecast.isAll?'Überschuss':'Nettofluss',width:130,align:'right'}];pdfTableHeader(doc,mcols);for(const r of forecast.rows)pdfTableRow(doc,mcols,[r.name,pdfMoney(r.displayIncome),pdfMoney(r.displayExpense),pdfMoney(r.balance)],{tone:r.balance<0?'#B42318':'#067647'});
|
||||
|
||||
for(const account of scopeAccounts(data,accountId)){
|
||||
doc.addPage();const f=buildAnnualForecast(data,reportYear,account.id);drawPdfHeader(doc,account.name,`Liquiditäts-Auszug ${reportYear} · Geplante Kontobewegungen`);pdfMetricRow(doc,[{label:'Zuflüsse',value:pdfMoney(f.yearIncome),positive:true},{label:'Abflüsse',value:pdfMoney(f.yearExpense),negative:true},{label:'Nettofluss',value:pdfMoney(f.yearIncome-f.yearExpense),positive:f.yearIncome>=f.yearExpense,negative:f.yearIncome<f.yearExpense}]);
|
||||
const accountCols=[{label:'Monat',width:125},{label:'Zuflüsse',width:120,align:'right'},{label:'Abflüsse',width:120,align:'right'},{label:'Nettofluss',width:130,align:'right'}];pdfTableHeader(doc,accountCols);for(const r of f.rows)pdfTableRow(doc,accountCols,[r.name,pdfMoney(r.displayIncome),pdfMoney(r.displayExpense),pdfMoney(r.balance)],{tone:r.balance<0?'#B42318':'#067647'});
|
||||
pdfSection(doc,'Einzelpositionen','Echte Ein-/Ausgaben und interne Transfers bleiben getrennt gekennzeichnet.');
|
||||
const rows=occurrenceRows(data,reportYear,account.id);const dcols=[{label:'Monat',width:72},{label:'Art',width:82},{label:'Bezeichnung',width:166},{label:'Kategorie',width:100},{label:'Betrag',width:75,align:'right'}];pdfTableHeader(doc,dcols);const kindLabel=r=>r.kind==='income'?'Eingang':r.kind==='expense'?'Ausgang':r.direction==='in'?'Transfer rein':'Transfer raus';for(const r of rows)pdfTableRow(doc,dcols,[r.month.slice(0,3),kindLabel(r),r.name,r.category,pdfMoney(r.amount)],{tone:r.kind==='income'||r.direction==='in'?'#067647':'#B42318'});
|
||||
}
|
||||
doc.addPage();drawPdfHeader(doc,'Stammdaten & Termine',`${scope} - Definitionen, Laufzeiten und geplante Betragsänderungen`);
|
||||
pdfSection(doc,'Konten & Einstellungen');doc.fillColor('#344054').font('Helvetica').fontSize(8).text(`Periodische Posten im Monats-Saldo: ${data.settings?.includePeriodicInBalance===false?'Nein':'Ja'}`);doc.moveDown(.35);for(const account of scopeAccounts(data,accountId)){doc.fillColor('#344054').font('Helvetica').fontSize(8).text(`Konto: ${account.name} (${account.id})`);}doc.moveDown(.5);doc.fillColor('#667085').font('Helvetica').fontSize(7.5).text('Pushover-Zugangsdaten werden aus Sicherheitsgründen nicht in Berichte exportiert.');doc.moveDown(.6);
|
||||
const defs=[];
|
||||
for(const x of data.incomes||[])if(inScope(x,'income',accountId))defs.push({kind:'Eingang',item:x,route:accountName(data,x.accountId)});
|
||||
for(const x of data.expenses||[])if(inScope(x,'expense',accountId))defs.push({kind:'Ausgang',item:x,route:accountName(data,x.accountId)});
|
||||
for(const x of data.transfers||[])if(inScope(x,'transfer',accountId))defs.push({kind:'Transfer',item:x,route:`${accountName(data,x.fromAccountId)} -> ${accountName(data,x.toAccountId)}`});
|
||||
for(const def of defs){ensurePdfSpace(doc,46);doc.fillColor('#17233B').font('Helvetica-Bold').fontSize(9).text(`${def.kind} - ${def.item.name}`);const due=def.item.interval==='monthly'?'monatlich':def.item.dueMonth?`${INTERVALS[def.item.interval]?.label||def.item.interval}, ab ${MONTHS[Number(def.item.dueMonth)-1]}`:(INTERVALS[def.item.interval]?.label||def.item.interval);doc.fillColor('#667085').font('Helvetica').fontSize(7.5).text(`${def.route} | ${categoryName(data,def.item.categoryId)} | Basis ${pdfMoney(def.item.amount)} | ${due}${def.item.endDate?` | Ende ${def.item.endDate}`:''}${def.item.pushoverReminder?' | Reminder am 1.':''}`);for(const change of def.item.amountChanges||[]){doc.fillColor('#344054').font('Helvetica').fontSize(7.5).text(` Änderung ab ${change.effectiveFrom}: ${pdfMoney(change.amount)}`);}doc.moveDown(.45);}
|
||||
if((data.categories||[]).length){pdfSection(doc,'Kategorien');doc.fillColor('#344054').font('Helvetica').fontSize(8).text(data.categories.map(c=>c.name).join(' | '));}
|
||||
|
||||
if((data.scenarios||[]).length){doc.addPage();drawPdfHeader(doc,'Szenarien',`Was-wäre-wenn-Planungen - ${scope}`);for(const s of data.scenarios||[]){const adjustments=(s.adjustments||[]).filter(a=>accountId==='all'||(a.type==='transfer'?(a.fromAccountId===accountId||a.toAccountId===accountId):a.accountId===accountId));if(!adjustments.length)continue;pdfSection(doc,s.name,`${adjustments.length} Anpassungen`);for(const a of adjustments){ensurePdfSpace(doc,28);const route=a.type==='transfer'?`${accountName(data,a.fromAccountId)} -> ${accountName(data,a.toAccountId)}`:accountName(data,a.accountId);doc.fillColor('#344054').font('Helvetica-Bold').fontSize(8.5).text(`${a.type==='income'?'Eingang':a.type==='expense'?'Ausgang':'Transfer'} · ${a.name}`);doc.fillColor('#667085').font('Helvetica').fontSize(7.5).text(`${route} · ${pdfMoney(a.amount)} / Monat`);doc.moveDown(.35);}}
|
||||
}
|
||||
doc.end();return done;
|
||||
}
|
||||
Reference in New Issue
Block a user