245 lines
12 KiB
JavaScript
245 lines
12 KiB
JavaScript
import { readFileSync } from 'node:fs'
|
||
import * as fontkitModul from 'fontkit'
|
||
import { euroDe } from '../finance/format.js'
|
||
import { berlinDate, formatDe } from '../finance/dates.js'
|
||
import { AuftragError } from './fehler.js'
|
||
import { AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, ZAHLUNGSPLAN_ARTEN } from './konstanten.js'
|
||
import { AB_TEXTE, ABSENDER, WIDERRUFSBELEHRUNG_PROJEKT, WIDERRUFSFORMULAR } from './texte.js'
|
||
import { aufwandText, zahlungsplanText } from './auftragSicht.js'
|
||
|
||
/**
|
||
* PDF der Auftragsbestätigung (A4, pdfkit): Schriften und Zeichenabdeckung, Daten (alle Texte fertig formatiert),
|
||
* Seitenaufteilung (rein) und Zeichnen. Aufbau der Datei: 1 Schriften, 2 Daten, 3 Layout, 4 Zeichnen, 5 erzeugeAuftragPdf.
|
||
*/
|
||
|
||
// ===== 1. Schriften, Logo, Zeichenabdeckung =====
|
||
const fontkit = fontkitModul.default ?? fontkitModul
|
||
const SCHRIFT_VERZEICHNIS = new URL('../../assets/fonts/', import.meta.url)
|
||
const LOGO_DATEI = new URL('../../../public/webklar-logo.png', import.meta.url)
|
||
|
||
const SCHRIFT_DATEIEN = Object.freeze({ regular: 'Figtree-Regular.ttf', semibold: 'Figtree-SemiBold.ttf' })
|
||
|
||
let geladen = null
|
||
|
||
/** { regular: Buffer, semibold: Buffer, logo: Buffer, regularFont, semiboldFont } – bei jedem Aufruf dasselbe Objekt. */
|
||
export function ladeSchriften() {
|
||
if (!geladen) {
|
||
const regular = readFileSync(new URL(SCHRIFT_DATEIEN.regular, SCHRIFT_VERZEICHNIS))
|
||
const semibold = readFileSync(new URL(SCHRIFT_DATEIEN.semibold, SCHRIFT_VERZEICHNIS))
|
||
geladen = Object.freeze({
|
||
regular,
|
||
semibold,
|
||
logo: readFileSync(LOGO_DATEI),
|
||
regularFont: fontkit.create(regular),
|
||
semiboldFont: fontkit.create(semibold),
|
||
})
|
||
}
|
||
return geladen
|
||
}
|
||
|
||
/** Hat der Codepunkt ein Glyph in Regular UND SemiBold? */
|
||
export function hatGlyph(codePoint) {
|
||
const { regularFont, semiboldFont } = ladeSchriften()
|
||
return regularFont.hasGlyphForCodePoint(codePoint) && semiboldFont.hasGlyphForCodePoint(codePoint)
|
||
}
|
||
|
||
const ERSATZ = new Map([
|
||
[0x2010, '-'], [0x2011, '-'], [0x2012, '-'], [0x2013, '-'], [0x2014, '-'], [0x2015, '-'], [0x2212, '-'],
|
||
[0x2018, "'"], [0x2019, "'"], [0x201a, "'"], [0x2032, "'"],
|
||
[0x201c, '"'], [0x201d, '"'], [0x201e, '"'], [0x201f, '"'], [0x2033, '"'],
|
||
[0x2026, '...'], [0x20ac, 'EUR'], [0x2022, '-'], [0x00b7, '-'], [0x00d7, 'x'], [0x2192, '->'],
|
||
])
|
||
// Steuerzeichen (außer \n), weiche Trennung, Zero-Width-Zeichen, Variantenselektoren, BOM
|
||
const ENTFERNEN = /[\u{0}-\u{8}\u{B}-\u{1F}\u{7F}-\u{9F}\u{AD}\u{200B}-\u{200D}\u{2060}\u{FE00}-\u{FE0F}\u{FEFF}]/gu
|
||
|
||
/** Ersetzt Zeichen ohne Glyph, damit nie ein leeres Kästchen im PDF steht. */
|
||
export function bereinigeText(text, hatGlyphFn = hatGlyph) {
|
||
const roh = String(text ?? '')
|
||
.normalize('NFC')
|
||
.replace(/\r\n?|[\u{2028}\u{2029}]/gu, '\n')
|
||
.replace(/\t/g, ' ')
|
||
.replace(ENTFERNEN, '')
|
||
let aus = ''
|
||
for (const zeichen of roh) {
|
||
const cp = zeichen.codePointAt(0)
|
||
if (zeichen === '\n' || hatGlyphFn(cp)) aus += zeichen
|
||
else if (ERSATZ.has(cp)) aus += ERSATZ.get(cp)
|
||
else if (/\p{Zs}/u.test(zeichen)) aus += ' '
|
||
else if (/\p{M}/u.test(zeichen)) continue
|
||
else {
|
||
const basis = zeichen.normalize('NFD').codePointAt(0)
|
||
aus += basis !== cp && hatGlyphFn(basis) ? String.fromCodePoint(basis) : '?'
|
||
}
|
||
}
|
||
return aus
|
||
}
|
||
|
||
/** bereinigeText für jede Zeichenkette in beliebig verschachtelten Objekten und Listen. */
|
||
export function bereinigeTexte(wert, hatGlyphFn = hatGlyph) {
|
||
if (typeof wert === 'string') return bereinigeText(wert, hatGlyphFn)
|
||
if (Array.isArray(wert)) return wert.map((e) => bereinigeTexte(e, hatGlyphFn))
|
||
if (wert && typeof wert === 'object') return Object.fromEntries(Object.entries(wert).map(([k, v]) => [k, bereinigeTexte(v, hatGlyphFn)]))
|
||
return wert
|
||
}
|
||
|
||
// ===== 2. Daten =====
|
||
|
||
export const PDF_BESCHRIFTUNG = Object.freeze({
|
||
nummer: 'Nummer', datum: 'Datum', kundennummer: 'Kundennummer', projekt: 'Projekt',
|
||
leistung: 'Leistungsumfang', zahlung: 'Zahlung', termine: 'Termine', mitwirkung: 'Mitwirkung und Abnahme',
|
||
spalten: Object.freeze(['Pos.', 'Beschreibung', 'Menge', 'Einzelpreis', 'Summe']), gesamt: 'Gesamtpreis',
|
||
steuernummer: 'Steuernummer', telefon: 'Telefon', bank: 'Bankverbindung', kontakt: 'Kontakt', seite: 'Seite', von: 'von',
|
||
entwurf: 'ENTWURF', entwurfHinweis: 'Entwurf – Vorschau, noch keine gültige Auftragsbestätigung',
|
||
})
|
||
|
||
const fehlt = (was) => new AuftragError(409, `Entwurf unvollständig: ${was}`)
|
||
const zeilen = (text) => String(text ?? '').split('\n').map((z) => z.trim()).filter(Boolean)
|
||
const tag = (iso) => formatDe(berlinDate(new Date(iso)))
|
||
|
||
function pruefe(auftrag, vorschau) {
|
||
if (!auftrag?.kunde || typeof auftrag.kunde !== 'object') throw fehlt('Kundendaten fehlen.')
|
||
if (!vorschau && !auftrag.nummer) throw fehlt('Nummer fehlt.')
|
||
if (!String(auftrag.titel ?? '').trim()) throw fehlt('Titel fehlt.')
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(auftrag.auftragVom ?? '')) throw fehlt('Datum der Absprache fehlt.')
|
||
if (!Object.hasOwn(AUFTRAG_WEGE, auftrag.auftragWeg)) throw fehlt('Weg der Absprache fehlt.')
|
||
if (!AUFTRAG_ARTEN.includes(auftrag.art)) throw fehlt('Art des Auftrags fehlt.')
|
||
if (!ZAHLUNGSPLAN_ARTEN.includes(auftrag.zahlungsplan?.art)) throw fehlt('Zahlungsplan fehlt.')
|
||
if (auftrag.art === 'festpreis') {
|
||
if (!Array.isArray(auftrag.positionen) || auftrag.positionen.length === 0) throw fehlt('Positionen fehlen.')
|
||
const summe = auftrag.positionen.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0)
|
||
if (summe !== auftrag.summeCents) throw new AuftragError(409, 'Die Summe der Positionen stimmt nicht mit der Auftragssumme überein.')
|
||
} else if (!Number.isSafeInteger(auftrag.stundensatzCents) || auftrag.stundensatzCents <= 0) {
|
||
throw fehlt('Stundensatz fehlt.')
|
||
}
|
||
}
|
||
|
||
function empfaengerZeilen(kunde) {
|
||
const land = String(kunde.land ?? '').trim()
|
||
const ausland = land && !/^(deutschland|germany|de)$/i.test(land)
|
||
return [kunde.firma, kunde.name, kunde.strasse, [kunde.plz, kunde.ort].filter(Boolean).join(' '), ausland ? land : '']
|
||
.map((z) => String(z ?? '').trim())
|
||
.filter((z, i, alle) => z && alle.indexOf(z) === i)
|
||
}
|
||
|
||
/** Alle Texte und Zahlen des AB-PDFs, fertig formatiert (reine Funktion). */
|
||
export function auftragPdfDaten(auftrag, { settings = {}, vorschau = false, jetzt = new Date(), ersetzt = null } = {}) {
|
||
pruefe(auftrag, vorschau)
|
||
const b = PDF_BESCHRIFTUNG
|
||
const steuernummer = String(settings.steuernummer ?? '').trim()
|
||
const festpreis = auftrag.art === 'festpreis'
|
||
const privat = auftrag.kunde.kundenart === 'privat'
|
||
const termine = AB_TEXTE.termine({ start: auftrag.startAm ? formatDe(auftrag.startAm) : null, fertig: auftrag.fertigBis ? formatDe(auftrag.fertigBis) : null, privat })
|
||
const abschnitte = [
|
||
{ id: 'zahlung', ueberschrift: b.zahlung, text: zahlungsplanText(auftrag) },
|
||
...(termine ? [{ id: 'termine', ueberschrift: b.termine, text: termine }] : []),
|
||
{ id: 'mitwirkung', ueberschrift: b.mitwirkung, text: AB_TEXTE.mitwirkung },
|
||
]
|
||
return {
|
||
vorschau,
|
||
beschriftung: b,
|
||
titel: AB_TEXTE.titel,
|
||
nummer: auftrag.nummer || 'Entwurf',
|
||
datum: tag(auftrag.gesendetAm || jetzt),
|
||
kundennummer: String(auftrag.kunde.kundennummer ?? ''),
|
||
projekt: auftrag.titel.trim(),
|
||
absender: {
|
||
zeilen: [ABSENDER.name, ABSENDER.strasse, ABSENDER.plzOrt, `${b.telefon} ${ABSENDER.telefon}`, ABSENDER.email, ABSENDER.web, ...(steuernummer ? [`${b.steuernummer} ${steuernummer}`] : [])],
|
||
rueckzeile: `${ABSENDER.name} · ${ABSENDER.strasse} · ${ABSENDER.plzOrt}`,
|
||
},
|
||
empfaenger: empfaengerZeilen(auftrag.kunde),
|
||
einleitung: AB_TEXTE.einleitung({ datum: formatDe(auftrag.auftragVom), weg: AUFTRAG_WEGE[auftrag.auftragWeg] }),
|
||
leistung: festpreis
|
||
? {
|
||
art: 'festpreis',
|
||
ueberschrift: b.leistung,
|
||
positionen: auftrag.positionen.map((p, i) => ({
|
||
nr: String(i + 1), beschreibung: p.beschreibung.trim(), menge: String(p.menge),
|
||
einzelpreis: euroDe(p.einzelpreisCents), summe: euroDe(p.menge * p.einzelpreisCents),
|
||
})),
|
||
gesamt: { label: b.gesamt, betrag: euroDe(auftrag.summeCents) },
|
||
aufwandText: null,
|
||
}
|
||
: { art: 'aufwand', ueberschrift: b.leistung, positionen: [], gesamt: null, aufwandText: aufwandText(auftrag) },
|
||
kleinunternehmer: AB_TEXTE.kleinunternehmer,
|
||
abschnitte,
|
||
agb: AB_TEXTE.agb({ fassung: formatDe(auftrag.agbFassung || AGB_FASSUNG) }),
|
||
ersetzt: ersetzt ? AB_TEXTE.ersetzt({ nummer: ersetzt.nummer, datum: formatDe(ersetzt.datum) }) : null,
|
||
gruss: AB_TEXTE.gruss,
|
||
widerruf: privat ? { belehrung: WIDERRUFSBELEHRUNG_PROJEKT, formular: WIDERRUFSFORMULAR } : null,
|
||
fuss: {
|
||
bank: zeilen(settings.bankDetailsText),
|
||
kontakt: [`${ABSENDER.name} · vertreten durch ${ABSENDER.vertretenDurch}`, `${b.telefon} ${ABSENDER.telefon} · ${ABSENDER.email} · ${ABSENDER.web}`],
|
||
},
|
||
}
|
||
}
|
||
|
||
|
||
// ===== 3. Layout (rein, ohne pdfkit): Maße, Bausteine, Seitenaufteilung =====
|
||
|
||
export const SEITE = Object.freeze({ breite: 595.28, hoehe: 841.89, links: 56.7, rechts: 56.7, oben: 48, unten: 96 })
|
||
export const INHALT_BREITE = SEITE.breite - SEITE.links - SEITE.rechts // 481.88
|
||
const FARBEN = Object.freeze({ tinte: '#111111', gedaempft: '#6b6964', linie: '#e8e6e0', linieDunkel: '#c9c6bd', flaeche: '#f7f6f3', entwurf: '#b42318' })
|
||
export const SPALTEN = Object.freeze([28, 237.88, 44, 86, 86]) // Pos., Beschreibung, Menge, Einzelpreis, Summe
|
||
const ZELLEN_RAND = 6
|
||
const STIL = Object.freeze({
|
||
titel: { schrift: 'semibold', groesse: 22, lineGap: 0, farbe: FARBEN.tinte },
|
||
projekt: { schrift: 'regular', groesse: 11, lineGap: 2, farbe: FARBEN.tinte },
|
||
adresse: { schrift: 'regular', groesse: 10, lineGap: 2.5, farbe: FARBEN.tinte },
|
||
ueberschrift: { schrift: 'semibold', groesse: 11, lineGap: 0, farbe: FARBEN.tinte },
|
||
text: { schrift: 'regular', groesse: 9.5, lineGap: 2.5, farbe: FARBEN.tinte },
|
||
klein: { schrift: 'regular', groesse: 8.5, lineGap: 2, farbe: FARBEN.gedaempft },
|
||
tabellenkopf: { schrift: 'semibold', groesse: 8.5, lineGap: 0, farbe: FARBEN.gedaempft },
|
||
summe: { schrift: 'semibold', groesse: 10.5, lineGap: 0, farbe: FARBEN.tinte },
|
||
fussLabel: { schrift: 'semibold', groesse: 7.5, lineGap: 1.5, farbe: FARBEN.gedaempft },
|
||
fuss: { schrift: 'regular', groesse: 7.5, lineGap: 1.5, farbe: FARBEN.gedaempft },
|
||
})
|
||
const MASSE = Object.freeze({
|
||
kopfHoehe: 22, summeHoehe: 30, kastenRand: 8, adresseBreite: 250, infoX: 340, infoZeile: 15,
|
||
rueckY: 80, adresseY: 94, // Rücksendezeile und Anschriftfeld relativ zur Oberkante des Briefkopfs (DIN 5008, Form B)
|
||
fussHoehe: 82, folgeKopf: 40,
|
||
})
|
||
const SICHERHEIT = 1
|
||
export const BEREICHE = Object.freeze({
|
||
erste: { oben: SEITE.oben, unten: SEITE.hoehe - SEITE.unten - SICHERHEIT },
|
||
folge: { oben: SEITE.oben + MASSE.folgeKopf, unten: SEITE.hoehe - SEITE.unten - SICHERHEIT },
|
||
})
|
||
|
||
|
||
/** Teilt Bausteine auf Seiten auf (Ketten bleiben zusammen, Tabellenkopf wiederholt sich). */
|
||
export function paginiere(atome, bereiche = BEREICHE) {
|
||
const seiten = []
|
||
const folgeHoehe = bereiche.folge.unten - bereiche.folge.oben
|
||
let seite
|
||
let y
|
||
const neueSeite = () => {
|
||
const bereich = seiten.length === 0 ? bereiche.erste : bereiche.folge
|
||
seite = { nummer: seiten.length + 1, posten: [], unten: bereich.unten }
|
||
seiten.push(seite)
|
||
y = bereich.oben
|
||
}
|
||
const frisch = () => seite.posten.every((p) => p.wiederholt)
|
||
const abstand = (a) => (frisch() ? 0 : a.abstandDavor)
|
||
neueSeite()
|
||
for (let i = 0; i < atome.length; ) {
|
||
let j = i
|
||
while (j < atome.length - 1 && atome[j].haengtAnNaechstem) j += 1
|
||
const kette = atome.slice(i, j + 1)
|
||
i = j + 1
|
||
if (kette[0].umbruchDavor && !frisch()) neueSeite()
|
||
const bedarf = kette.reduce((summe, a, k) => summe + (k === 0 ? 0 : a.abstandDavor) + a.hoehe, 0)
|
||
const kopfHoehe = kette[0].kopf ? kette[0].kopf.hoehe : 0
|
||
if (!frisch() && y + abstand(kette[0]) + bedarf > seite.unten && bedarf + kopfHoehe <= folgeHoehe) neueSeite()
|
||
for (const a of kette) {
|
||
if (!frisch() && y + abstand(a) + a.hoehe > seite.unten) neueSeite()
|
||
if (seite.nummer > 1 && a.kopf && seite.posten.length === 0) {
|
||
seite.posten.push({ atom: a.kopf, y, wiederholt: true })
|
||
y += a.kopf.hoehe
|
||
}
|
||
y += abstand(a)
|
||
seite.posten.push({ atom: a, y, wiederholt: false })
|
||
y += a.hoehe
|
||
}
|
||
}
|
||
return seiten
|
||
}
|