Files
Webklar-Kundenbereich/server/services/auftraege/pdf.js
T

160 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { AUFTRAG_ARTEN, AUFTRAG_WEGE, ZAHLUNGSPLAN_ARTEN } from './konstanten.js'
import { AB_TEXTE, ABSENDER } from './texte.js'
import { aufwandText } 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'
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,
}
}