From 7ad9c12bca65169ca85f0f3384700e8993f79b7f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:12:18 +0000 Subject: [PATCH] =?UTF-8?q?AB-PDF:=20auftragPdfDaten=20mit=20Kopf,=20Ansch?= =?UTF-8?q?rift,=20Positionen=20und=20Pr=C3=BCfung=20der=20Pflichtangaben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/pdf.js | 81 ++++++++++++++++++++++++++ test/auftraegePdfDaten.test.js | 99 ++++++++++++++++++++++++++++++++ test/auftraegePdfHilfe.js | 36 ++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 test/auftraegePdfDaten.test.js create mode 100644 test/auftraegePdfHilfe.js diff --git a/server/services/auftraege/pdf.js b/server/services/auftraege/pdf.js index 0df6385..5d10632 100644 --- a/server/services/auftraege/pdf.js +++ b/server/services/auftraege/pdf.js @@ -1,5 +1,11 @@ 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), @@ -76,3 +82,78 @@ export function bereinigeTexte(wert, hatGlyphFn = hatGlyph) { 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, + } +} + diff --git a/test/auftraegePdfDaten.test.js b/test/auftraegePdfDaten.test.js new file mode 100644 index 0000000..fdc2351 --- /dev/null +++ b/test/auftraegePdfDaten.test.js @@ -0,0 +1,99 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { useTestEnv } from './support/testEnv.js' +import { beispielAuftrag, beispielSettings } from './auftraegePdfHilfe.js' + +useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' }) +const { auftragPdfDaten, PDF_BESCHRIFTUNG, bereinigeTexte } = await import('../server/services/auftraege/pdf.js') +const { AB_TEXTE, WIDERRUFSBELEHRUNG_PROJEKT, WIDERRUFSFORMULAR } = await import('../server/services/auftraege/texte.js') +const { zahlungsplanText, aufwandText } = await import('../server/services/auftraege/auftragSicht.js') +const { AuftragError } = await import('../server/services/auftraege/fehler.js') +const { euroDe } = await import('../server/services/finance/format.js') + +const JETZT = new Date('2026-10-06T10:00:00.000Z') +const NBSP = String.fromCodePoint(0xa0) +const FRUEHBEGINN_SATZ = 'Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.' // V7: AB_TEXTE.termine bei Privatpersonen +const daten = (extra = {}, optionen = {}) => auftragPdfDaten(beispielAuftrag(extra), { settings: beispielSettings, jetzt: JETZT, ...optionen }) +const aufwandAuftrag = { art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' } } + +test('euroDe liefert Beträge mit geschütztem Leerzeichen vor dem €-Zeichen (Grundlage aller Betragsvergleiche hier)', () => { + assert.equal(euroDe(60000), `600,00${NBSP}€`) +}) + +test('Festpreis: Kopf, Anschrift, Angaben und Positionen sind fertig formatiert', () => { + const d = daten() + assert.equal(d.vorschau, false) + assert.equal(d.titel, AB_TEXTE.titel) + assert.equal(d.nummer, 'AB-2026-0007') + assert.equal(d.datum, '06.10.2026') + assert.equal(d.kundennummer, 'K-1042') + assert.equal(d.projekt, 'Neue Website Müller Bäckerei') + assert.deepEqual(d.absender.zeilen, [ + 'WEBklar GbR', 'Am Schwimmbad 10', '67722 Winnweiler', 'Telefon 0176 23726355', 'support@webklar.com', 'webklar.com', 'Steuernummer 12/345/67890', + ]) + assert.equal(d.absender.rueckzeile, 'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler') + assert.deepEqual(d.empfaenger, ['Müller Bäckerei GmbH', 'Anna Müller', 'Hauptstraße 12', '67722 Winnweiler']) + assert.equal(d.einleitung, AB_TEXTE.einleitung({ datum: '05.10.2026', weg: 'telefonisch' })) + assert.ok(d.einleitung.includes('05.10.2026') && d.einleitung.includes('telefonisch')) + assert.equal(d.leistung.art, 'festpreis') + assert.equal(d.leistung.ueberschrift, 'Leistungsumfang') + assert.deepEqual(d.leistung.positionen, [ + { nr: '1', beschreibung: 'Gestaltung und Umsetzung der Website (5 Seiten)', menge: '1', einzelpreis: euroDe(60000), summe: euroDe(60000) }, + { nr: '2', beschreibung: 'Kontaktformular mit E-Mail-Versand', menge: '2', einzelpreis: euroDe(10000), summe: euroDe(20000) }, + ]) + assert.deepEqual(d.leistung.gesamt, { label: 'Gesamtpreis', betrag: euroDe(80000) }) + assert.equal(d.leistung.aufwandText, null) + assert.equal(d.kleinunternehmer, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.') + assert.deepEqual(PDF_BESCHRIFTUNG.spalten, ['Pos.', 'Beschreibung', 'Menge', 'Einzelpreis', 'Summe']) +}) + +test('Datum der AB ist der Berliner Tag von gesendetAm, ohne gesendetAm der heutige Berliner Tag', () => { + assert.equal(daten({ gesendetAm: '2026-10-06T22:30:00.000Z' }).datum, '07.10.2026') + assert.equal(daten({ gesendetAm: '2026-10-06T21:30:00+00:00' }).datum, '06.10.2026') + assert.equal(daten({ gesendetAm: null }, { jetzt: new Date('2026-12-31T23:30:00.000Z') }).datum, '01.01.2027') +}) + +test('Aufwand: Aufwandsregel statt Positionen, keine Gesamtsumme', () => { + const auftrag = beispielAuftrag(aufwandAuftrag) + const d = auftragPdfDaten(auftrag, { settings: beispielSettings, jetzt: JETZT }) + assert.equal(d.leistung.art, 'aufwand') + assert.deepEqual(d.leistung.positionen, []) + assert.equal(d.leistung.gesamt, null) + assert.equal(d.leistung.aufwandText, aufwandText(auftrag)) + assert.ok(d.leistung.aufwandText.includes(euroDe(10000))) + assert.equal(d.kleinunternehmer, AB_TEXTE.kleinunternehmer) +}) + +test('Vorschau: Nummer „Entwurf“, heutiges Datum, Kennzeichnung; ohne Vorschau braucht die AB eine Nummer', () => { + const entwurf = { nummer: '', gesendetAm: null } + const d = daten(entwurf, { vorschau: true }) + assert.equal(d.vorschau, true) + assert.equal(d.nummer, 'Entwurf') + assert.equal(d.datum, '06.10.2026') + assert.equal(d.beschriftung.entwurf, 'ENTWURF') + assert.throws(() => daten(entwurf), (e) => e instanceof AuftragError && e.status === 409 && /Nummer/.test(e.message)) +}) + +test('Anschrift: Firma und Name nur einmal, Land nur außerhalb Deutschlands, leere Teile entfallen', () => { + const kunde = (extra) => ({ ...beispielAuftrag().kunde, ...extra }) + assert.deepEqual(daten({ kunde: kunde({ firma: 'Anna Müller' }) }).empfaenger, ['Anna Müller', 'Hauptstraße 12', '67722 Winnweiler']) + assert.deepEqual(daten({ kunde: kunde({ land: 'Österreich', plz: '1010', ort: 'Wien' }) }).empfaenger, ['Müller Bäckerei GmbH', 'Anna Müller', 'Hauptstraße 12', '1010 Wien', 'Österreich']) + assert.deepEqual(daten({ kunde: kunde({ firma: '', land: '', strasse: '' }) }).empfaenger, ['Anna Müller', '67722 Winnweiler']) +}) + +test('Fehlende Pflichtangaben und falsche Summe: AuftragError 409 mit Klartext statt kaputtem PDF', () => { + const faelle = [ + [{ kunde: null }, /Kundendaten/], + [{ titel: ' ' }, /Titel/], + [{ auftragVom: '' }, /Datum der Absprache/], + [{ auftragWeg: 'brieftaube' }, /Weg der Absprache/], + [{ art: 'pauschal' }, /Art des Auftrags/], + [{ zahlungsplan: null }, /Zahlungsplan/], + [{ positionen: [], summeCents: 0 }, /Positionen/], + [{ summeCents: 79999 }, /Summe der Positionen/], + [{ ...aufwandAuftrag, stundensatzCents: null }, /Stundensatz/], + ] + for (const [extra, muster] of faelle) { + assert.throws(() => daten(extra, { vorschau: true }), (e) => e instanceof AuftragError && e.status === 409 && muster.test(e.message), JSON.stringify(extra)) + } +}) diff --git a/test/auftraegePdfHilfe.js b/test/auftraegePdfHilfe.js new file mode 100644 index 0000000..de0dff0 --- /dev/null +++ b/test/auftraegePdfHilfe.js @@ -0,0 +1,36 @@ +/** Beispiel-Auftrag (V3-Form) für die PDF-Tests; `extra` überschreibt einzelne Felder. */ +export function beispielAuftrag(extra = {}) { + return { + id: 'ab1', nummer: 'AB-2026-0007', jahr: 2026, laufnummer: 7, status: 'versendet', kundenId: 'k1', + kunde: { + name: 'Anna Müller', firma: 'Müller Bäckerei GmbH', strasse: 'Hauptstraße 12', plz: '67722', ort: 'Winnweiler', + land: 'Deutschland', email: 'anna@example.test', kundenart: 'unternehmen', kundennummer: 'K-1042', + }, + projektId: 'p1', titel: 'Neue Website Müller Bäckerei', zeitquelle: ['t1'], art: 'festpreis', + positionen: [ + { beschreibung: 'Gestaltung und Umsetzung der Website (5 Seiten)', menge: 1, einzelpreisCents: 60000 }, + { beschreibung: 'Kontaktformular mit E-Mail-Versand', menge: 2, einzelpreisCents: 10000 }, + ], + stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000, + zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14, + auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '2026-10-12', fertigBis: '2026-11-15', + ersetztId: '', ersetztDurchId: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30', pdfDatei: '', pdfSha256: '', + gesendetAm: '2026-10-06T08:30:00.000Z', gesendetAn: 'anna@example.test', mailStatus: '', mailFehler: '', + tokenHash: '', tokenGueltigBis: null, bestaetigtAm: null, bestaetigtUeber: '', bestaetigtPdfSha256: '', + fruehbeginn: false, fruehbeginnFassung: '', storniertAm: null, widerrufenAm: null, + erstelltVon: 'kenso', erstelltAm: '2026-10-06T08:00:00.000Z', geaendertAm: '2026-10-06T08:00:00.000Z', + ...extra, + } +} + +export const beispielSettings = { steuernummer: '12/345/67890', bankDetailsText: 'Finom Bank\nIBAN DE12 3456 7890 1234 5678 90\nBIC FNOMDEB2' } + +/** Alle Zeichenketten in verschachtelten Objekten und Listen (flach gesammelt); Werte unter den Schlüsseln id und art sind keine Texte. */ +export function alleTexte(wert, aus = []) { + if (typeof wert === 'string') aus.push(wert) + else if (Array.isArray(wert)) wert.forEach((e) => alleTexte(e, aus)) + else if (wert && typeof wert === 'object') { + for (const [schluessel, e] of Object.entries(wert)) if (schluessel !== 'id' && schluessel !== 'art') alleTexte(e, aus) + } + return aus +}