AB-PDF: auftragPdfDaten mit Kopf, Anschrift, Positionen und Prüfung der Pflichtangaben
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b097fedf5a
commit
7ad9c12bca
3 files changed
+216
No files matched your search
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user