254 lines
11 KiB
JavaScript
254 lines
11 KiB
JavaScript
/** Nebenkostenabrechnung als PDF (v1.23).
|
||
*
|
||
* Erzeugt pro Wohnung und Abrechnungsjahr eine PDF-Abrechnung direkt aus
|
||
* dem Kosten- und Zählerstandsmodul:
|
||
* - umlagefähige, hausweite Kosten → Verteilung nach Wohnflächenanteil
|
||
* - umlagefähige, wohnungsbezogene Kosten → voll der Wohnung
|
||
* - Abzug der Nebenkosten-Vorauszahlung (Warmmiete − Kaltmiete) × 12
|
||
* - Anlage: Zählerstände der Wohnung (Stand + Datum)
|
||
* Das PDF landet im Upload-Verzeichnis und wird als Dokument
|
||
* („Nebenkostenabrechnung") der Wohnung zugeordnet – erneutes Erzeugen
|
||
* für dieselbe Wohnung + Jahr ersetzt Datei und Dokument.
|
||
*/
|
||
|
||
const fs = require('fs')
|
||
const path = require('path')
|
||
const PDFDocument = require('pdfkit')
|
||
|
||
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads')
|
||
const FARBE_DUNKEL = '#22333b'
|
||
const FARBE_AKZENT = '#f59e0b'
|
||
const FARBE_SOFT = '#64748b'
|
||
|
||
const eur = (n) =>
|
||
Number(n || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
|
||
const zahl = (n) =>
|
||
Number(n || 0).toLocaleString('de-DE', { maximumFractionDigits: 2 })
|
||
const dDatum = () => new Date().toLocaleDateString('de-DE')
|
||
|
||
/** Sammelt alle Positionen und Zahlen für Wohnung + Jahr. */
|
||
async function abrechnungsDaten(pool, unitId, jahr) {
|
||
const { rows: einheiten } = await pool.query('SELECT * FROM units WHERE id = $1', [unitId])
|
||
if (!einheiten.length) throw Object.assign(new Error('Wohnung nicht gefunden.'), { status: 404 })
|
||
const einheit = einheiten[0]
|
||
|
||
const { rows: hausEinheiten } = await pool.query(
|
||
'SELECT * FROM units WHERE building = $1', [einheit.building],
|
||
)
|
||
const hausFlaeche = hausEinheiten.reduce((s, u) => s + Number(u.size || 0), 0)
|
||
const anteil = hausFlaeche > 0 ? Number(einheit.size || 0) / hausFlaeche : 0
|
||
|
||
const { rows: kosten } = await pool.query(
|
||
`SELECT * FROM costs WHERE building = $1 AND jahr = $2 AND umlegbar = TRUE
|
||
AND (unit_id = $3 OR unit_id IS NULL) ORDER BY kostenart`,
|
||
[einheit.building, jahr, einheit.id],
|
||
)
|
||
const positionen = []
|
||
for (const k of kosten) {
|
||
const wohnungsAnteil = k.unit_id
|
||
? Number(k.betrag) // wohnungsbezogen: voller Betrag
|
||
: Number(k.betrag) * anteil // hausweit: nach Wohnflächenanteil
|
||
positionen.push({
|
||
kostenart: k.kostenart,
|
||
notiz: k.notiz,
|
||
hausGesamt: k.unit_id ? null : Number(k.betrag),
|
||
verteilung: k.unit_id ? 'wohnungsbezogen' : `${zahl(einheit.size)} von ${zahl(hausFlaeche)} m²`,
|
||
betrag: wohnungsAnteil,
|
||
})
|
||
}
|
||
// Zusammenfassen nach Kostenart
|
||
const jeArt = new Map()
|
||
for (const p of positionen) {
|
||
const alt = jeArt.get(p.kostenart) || { ...p, betrag: 0, hausGesamt: p.hausGesamt }
|
||
alt.betrag += p.betrag
|
||
if (p.hausGesamt !== null) alt.hausGesamt = (alt.hausGesamt || 0) + p.hausGesamt
|
||
jeArt.set(p.kostenart, alt)
|
||
}
|
||
|
||
const summe = [...jeArt.values()].reduce((s, p) => s + p.betrag, 0)
|
||
// Vorauszahlung aus dem Mieterobjekt (seit v1.36, vorher am Wohnungsobjekt)
|
||
const { rows: mieterRows } = await pool.query(
|
||
'SELECT name, vorname, kaltmiete, warmmiete FROM mieter WHERE unit_id = $1', [einheit.id],
|
||
)
|
||
const nkVorauszahlung = mieterRows.length
|
||
? Number(mieterRows[0].warmmiete) - Number(mieterRows[0].kaltmiete)
|
||
: 0
|
||
const mieterName = mieterRows.length ? `${mieterRows[0].vorname} ${mieterRows[0].name}`.trim() : ''
|
||
const vorauszahlung = nkVorauszahlung * 12
|
||
const differenz = summe - vorauszahlung
|
||
|
||
const { rows: zaehler } = await pool.query(
|
||
`SELECT nummer, art, stand, stand_am FROM meters WHERE unit_id = $1 ORDER BY art, nummer`,
|
||
[einheit.id],
|
||
)
|
||
return { einheit, jahr, positionen: [...jeArt.values()], summe, vorauszahlung, differenz, zaehler, nkVorauszahlung, mieterName }
|
||
}
|
||
|
||
/** Zeichnet das PDF und liefert den Dateinamen (im UPLOAD_DIR). */
|
||
async function bauePdf(daten) {
|
||
const { einheit, jahr, positionen, summe, vorauszahlung, differenz, zaehler, nkVorauszahlung, mieterName } = daten
|
||
const dateiName = `ab-${einheit.id}-${jahr}.pdf`
|
||
const vollerPfad = path.join(UPLOAD_DIR, dateiName)
|
||
|
||
await new Promise((resolve, reject) => {
|
||
const doc = new PDFDocument({ size: 'A4', margin: 50, info: {
|
||
Title: `Nebenkostenabrechnung ${jahr} – ${einheit.building}, Whg. ${einheit.number}`,
|
||
Author: 'Hinterstein Immobilien',
|
||
} })
|
||
const strom = fs.createWriteStream(vollerPfad)
|
||
strom.on('finish', resolve)
|
||
strom.on('error', reject)
|
||
doc.pipe(strom)
|
||
|
||
const breite = doc.page.width - 100 // nutzbare Breite
|
||
let y = doc.y
|
||
|
||
// ── Kopf ──
|
||
doc.font('Helvetica-Bold').fontSize(16).fillColor(FARBE_DUNKEL)
|
||
doc.text('Hinterstein Immobilien', 50, y)
|
||
doc.font('Helvetica').fontSize(9).fillColor(FARBE_SOFT)
|
||
doc.text('Nebenkostenabrechnung', 50, y + 20, { align: 'right', width: breite })
|
||
doc.moveTo(50, y + 36).lineTo(50 + breite, y + 36).lineWidth(1.5).strokeColor(FARBE_AKZENT).stroke()
|
||
y += 50
|
||
|
||
doc.font('Helvetica-Bold').fontSize(13).fillColor(FARBE_DUNKEL)
|
||
doc.text(`Nebenkostenabrechnung ${jahr}`, 50, y)
|
||
y += 22
|
||
doc.font('Helvetica').fontSize(10).fillColor('#0f172a')
|
||
doc.text(`Abrechnungszeitraum: 01.01.${jahr} – 31.12.${jahr}`, 50, y)
|
||
y += 16
|
||
doc.text(`Objekt: ${einheit.building} · Wohnung ${einheit.number} · ${zahl(einheit.size)} m²`, 50, y)
|
||
y += 16
|
||
doc.text(`Mieter: ${mieterName || '–'}`, 50, y)
|
||
y += 16
|
||
doc.text(`Erstellt am ${dDatum()}`, 50, y)
|
||
y += 26
|
||
|
||
// ── Kostenpositionen ──
|
||
doc.font('Helvetica-Bold').fontSize(11).fillColor(FARBE_DUNKEL)
|
||
doc.text('Umlagefähige Betriebskosten', 50, y)
|
||
y += 18
|
||
|
||
const spalten = { art: 50, gesamt: 300, schluessel: 390, betrag: 480 }
|
||
doc.font('Helvetica-Bold').fontSize(8.5).fillColor(FARBE_SOFT)
|
||
doc.text('KOSTENART', spalten.art, y)
|
||
doc.text('HAUS GESAMT', spalten.gesamt, y, { width: 85, align: 'right' })
|
||
doc.text('VERTEILUNG', spalten.schluessel, y)
|
||
doc.text('IHRE KOSTEN', spalten.betrag, y, { width: 65, align: 'right' })
|
||
y += 6
|
||
doc.moveTo(50, y).lineTo(50 + breite, y).lineWidth(0.5).strokeColor('#cbd5e1').stroke()
|
||
y += 12
|
||
|
||
doc.font('Helvetica').fontSize(9.5).fillColor('#0f172a')
|
||
for (const p of positionen) {
|
||
if (y > 730) { doc.addPage(); y = 60 }
|
||
doc.text(p.kostenart, spalten.art, y, { width: 240 })
|
||
doc.text(p.hausGesamt !== null ? eur(p.hausGesamt) : '–', spalten.gesamt, y, { width: 85, align: 'right' })
|
||
doc.fillColor(FARBE_SOFT).text(p.verteilung, spalten.schluessel, y, { width: 85 })
|
||
doc.fillColor('#0f172a').text(eur(p.betrag), spalten.betrag, y, { width: 65, align: 'right' })
|
||
y += 18
|
||
}
|
||
y += 2
|
||
doc.moveTo(50, y).lineTo(50 + breite, y).lineWidth(0.5).strokeColor('#cbd5e1').stroke()
|
||
y += 14
|
||
|
||
// ── Summenblock ──
|
||
const zeile = (label, wert, fett, farbe) => {
|
||
doc.font(fett ? 'Helvetica-Bold' : 'Helvetica').fontSize(10).fillColor(farbe || '#0f172a')
|
||
doc.text(label, 50, y, { width: 400 })
|
||
doc.text(wert, 430, y, { width: 115, align: 'right' })
|
||
y += 17
|
||
}
|
||
zeile('Summe umlagefähige Kosten', eur(summe), true)
|
||
zeile(`Nebenkostenvorauszahlung (${eur(nkVorauszahlung)}/Monat × 12)`, '- ' + eur(vorauszahlung))
|
||
y += 4
|
||
doc.moveTo(320, y - 4).lineTo(545, y - 4).lineWidth(0.5).strokeColor('#cbd5e1').stroke()
|
||
const nachzahlung = differenz > 0
|
||
zeile(
|
||
nachzahlung ? 'Nachzahlung' : 'Erstattung',
|
||
(nachzahlung ? '+ ' : '- ') + eur(Math.abs(differenz)),
|
||
true,
|
||
nachzahlung ? '#b91c1c' : '#047857',
|
||
)
|
||
y += 14
|
||
|
||
// ── Anlage Zählerstände ──
|
||
if (zaehler.length) {
|
||
if (y > 640) { doc.addPage(); y = 60 }
|
||
doc.font('Helvetica-Bold').fontSize(11).fillColor(FARBE_DUNKEL)
|
||
doc.text('Anlage: Zählerstände der Wohnung', 50, y)
|
||
y += 16
|
||
doc.font('Helvetica-Bold').fontSize(8.5).fillColor(FARBE_SOFT)
|
||
doc.text('ZÄHLERART', 50, y)
|
||
doc.text('NUMMER', 160, y)
|
||
doc.text('STAND', 330, y, { width: 90, align: 'right' })
|
||
doc.text('STAND VOM', 440, y, { width: 105, align: 'right' })
|
||
y += 6
|
||
doc.moveTo(50, y).lineTo(50 + breite, y).lineWidth(0.5).strokeColor('#cbd5e1').stroke()
|
||
y += 12
|
||
doc.font('Helvetica').fontSize(9.5).fillColor('#0f172a')
|
||
for (const z of zaehler) {
|
||
if (y > 730) { doc.addPage(); y = 60 }
|
||
doc.text(z.art, 50, y)
|
||
doc.text(z.nummer, 160, y, { width: 150 })
|
||
doc.text(zahl(z.stand), 330, y, { width: 90, align: 'right' })
|
||
doc.text(z.stand_am || '–', 440, y, { width: 105, align: 'right' })
|
||
y += 18
|
||
}
|
||
y += 10
|
||
doc.font('Helvetica').fontSize(8).fillColor(FARBE_SOFT)
|
||
doc.text('Hinweis: Verbrauchskosten (z. B. Wasser) sind im Abrechnungszeitraum in den o. g. Kostenarten enthalten.', 50, y, { width: breite })
|
||
y += 12
|
||
}
|
||
|
||
// ── Fußzeile ──
|
||
doc.font('Helvetica').fontSize(8).fillColor(FARBE_SOFT)
|
||
doc.text(
|
||
'Hinterstein Immobilien · Diese Abrechnung wurde automatisch aus dem Mieterportal erzeugt (Kosten- und Zählerstandsmodul).',
|
||
50, Math.max(y + 8, 770), { width: breite },
|
||
)
|
||
|
||
doc.end()
|
||
})
|
||
return dateiName
|
||
}
|
||
|
||
/** Erzeugt die Abrechnung: PDF + Dokumenten-Eintrag (ersetzt vorhandene). */
|
||
async function erzeugeAbrechnung(pool, unitId, jahr) {
|
||
const daten = await abrechnungsDaten(pool, unitId, jahr)
|
||
const { einheit } = daten
|
||
const dateiName = await bauePdf(daten)
|
||
const groesseBytes = fs.statSync(path.join(UPLOAD_DIR, dateiName)).size
|
||
const groesse = groesseBytes > 1024
|
||
? `${zahl(groesseBytes / 1024)} KB`
|
||
: `${groesseBytes} B`
|
||
const titel = `Nebenkostenabrechnung ${jahr} – ${einheit.building}, Whg. ${einheit.number}`
|
||
|
||
const { rows: vorhanden } = await pool.query(
|
||
'SELECT * FROM documents WHERE title = $1 AND unit_id = $2', [titel, einheit.id],
|
||
)
|
||
let dokument
|
||
if (vorhanden.length) {
|
||
const alt = vorhanden[0].file_path
|
||
if (alt && alt !== dateiName) {
|
||
await fs.promises.unlink(path.join(UPLOAD_DIR, alt)).catch(() => {})
|
||
}
|
||
const { rows } = await pool.query(
|
||
`UPDATE documents SET file_name = $1, file_path = $2, size = $3, date = $4, category = 'Nebenkostenabrechnung'
|
||
WHERE id = $5 RETURNING *`,
|
||
[titel, dateiName, groesse, dDatum(), vorhanden[0].id],
|
||
)
|
||
dokument = rows[0]
|
||
} else {
|
||
const { rows } = await pool.query(
|
||
`INSERT INTO documents (id, unit_id, title, category, size, date, file_name, file_path)
|
||
VALUES ($1,$2,$3,'Nebenkostenabrechnung',$4,$5,$6,$7) RETURNING *`,
|
||
[`d-ab-${einheit.id}-${jahr}`, einheit.id, titel, groesse, dDatum(), titel, dateiName],
|
||
)
|
||
dokument = rows[0]
|
||
}
|
||
return { dokument, daten }
|
||
}
|
||
|
||
module.exports = { erzeugeAbrechnung }
|