From 98eceaf14c7d8a539d960d2642afb5aece39b54e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:07:15 +0000 Subject: [PATCH] =?UTF-8?q?Auftr=C3=A4ge:=20Sichten=20auf=20die=20Auftrags?= =?UTF-8?q?best=C3=A4tigung=20(Team,=20=C3=B6ffentlich,=20Fr=C3=BChbeginn)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/services/auftraege/auftragSicht.js | 105 +++++++++++++++++ test/auftraegeAuftragSicht.test.js | 135 ++++++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 server/services/auftraege/auftragSicht.js create mode 100644 test/auftraegeAuftragSicht.test.js diff --git a/server/services/auftraege/auftragSicht.js b/server/services/auftraege/auftragSicht.js new file mode 100644 index 0000000..1e80a63 --- /dev/null +++ b/server/services/auftraege/auftragSicht.js @@ -0,0 +1,105 @@ +/** + * Sichten auf eine Auftragsbestätigung (Übersicht V3/V4): Kundensnapshot, früheste Leistung bei Privatpersonen, + * Zahlungsplan- und Aufwandstext, Ansicht fürs Team und öffentliche Ansicht für Token- und Kundenrouten. + * Alles rein (kein Appwrite, keine Konfiguration); die Wortlaute kommen aus texte.js. + */ +import { withdrawalDeadline } from '../deadlines.js' +import { addDays, berlinDate } from '../finance/dates.js' +import { euroDe } from '../finance/format.js' +import { AGB_FASSUNG, KUNDENARTEN, LIMITS, STUNDENSATZ_STANDARD_CENTS } from './konstanten.js' +import { AB_TEXTE, BESTAETIGUNG_TEXT, FRUEHBEGINN_TEXT } from './texte.js' + +// Kürzungen, damit der Snapshot samt JSON in die Spalte kundeJson (2000 Zeichen) passt +const SNAPSHOT_FELDER = [ + ['name', 'name', 120], ['firma', 'companyName', 120], ['strasse', 'street', 120], ['plz', 'postalCode', 20], + ['ort', 'city', 80], ['land', 'country', 60], ['email', 'email', 200], ['kundennummer', 'code', 40], +] + +const text = (wert, max) => (typeof wert === 'string' ? wert.trim().slice(0, max).trim() : '') + +export function kundeSnapshot(customerDoc) { + const doc = customerDoc || {} + const snapshot = {} + for (const [ziel, quelle, max] of SNAPSHOT_FELDER) snapshot[ziel] = text(doc[quelle], max) + snapshot.kundenart = KUNDENARTEN.includes(doc.kundenart) ? doc.kundenart : '' + // feste Reihenfolge der Schlüssel wie KundeSnapshot in V3 + const { name, firma, strasse, plz, ort, land, email, kundenart, kundennummer } = snapshot + return { name, firma, strasse, plz, ort, land, email, kundenart, kundennummer } +} + +/** Früheste Leistung bei Privatpersonen ohne Frühbeginn: Tag nach Ablauf der Widerrufsfrist. */ +export function fruehesterBeginn(auftrag) { + if (auftrag.kunde?.kundenart !== 'privat' || auftrag.fruehbeginn || !auftrag.gesendetAm) return null + const gesendet = new Date(auftrag.gesendetAm) + if (Number.isNaN(gesendet.getTime())) return null + return addDays(withdrawalDeadline(gesendet).lastDay, 1) +} + +export function zahlungsplanText(auftrag) { + const ziel = Number.isSafeInteger(auftrag.zahlungszielTage) ? auftrag.zahlungszielTage : LIMITS.standardZahlungszielTage + const plan = auftrag.zahlungsplan || { art: 'nach_abnahme' } + const bausteine = AB_TEXTE.zahlungsplan + if (plan.art === 'monatlich') return bausteine.monatlich({ ziel }) + if (plan.art === 'frei') return bausteine.frei({ text: plan.text || '', ziel }) + if (plan.art === 'anzahlung' && Number.isSafeInteger(auftrag.summeCents) && typeof plan.prozent === 'number' && Number.isFinite(plan.prozent)) { + const anzahlung = Math.round((auftrag.summeCents * plan.prozent) / 100) + return bausteine.anzahlung({ prozent: plan.prozent, anzahlung: euroDe(anzahlung), rest: euroDe(auftrag.summeCents - anzahlung), ziel }) + } + return bausteine.nach_abnahme({ ziel }) +} + +/** Stunden als Text mit Dezimalkomma ohne überflüssige Nullen: 600 → '10', 135 → '2,25', 90 → '1,5'. */ +const stundenText = (minuten) => String(Math.round((minuten / 60) * 100) / 100).replace('.', ',') + +export function aufwandText(auftrag) { + if (auftrag.art !== 'aufwand') return null + const minuten = auftrag.schaetzungMinuten + return AB_TEXTE.aufwand({ + satz: euroDe(auftrag.stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS), + schaetzung: Number.isSafeInteger(minuten) && minuten > 0 ? stundenText(minuten) : null, + }) +} + +/** Auftrag ohne tokenHash, pdfDatei und bestaetigtPdfSha256, plus die abgeleiteten Felder. */ +export function auftragFuerTeam(auftrag) { + const { tokenHash, pdfDatei, bestaetigtPdfSha256, ...rest } = auftrag + return { + ...rest, + pdfVorhanden: Boolean(pdfDatei), + fruehesterBeginn: fruehesterBeginn(auftrag), + zahlungsplanText: zahlungsplanText(auftrag), + aufwandText: aufwandText(auftrag), + } +} + +/** Nur für Token- und Kundenrouten: keine Kundendaten, kein Token, keine Pfade. */ +export function oeffentlicheAnsicht(auftrag) { + const kundenart = auftrag.kunde?.kundenart || '' + return { + nummer: auftrag.nummer, + datum: auftrag.gesendetAm ? berlinDate(new Date(auftrag.gesendetAm)) : null, + titel: auftrag.titel, + art: auftrag.art, + positionen: auftrag.positionen.map((p) => ({ + beschreibung: p.beschreibung, menge: p.menge, einzelpreisCents: p.einzelpreisCents, summeCents: p.menge * p.einzelpreisCents, + })), + summeCents: auftrag.summeCents, + stundensatzCents: auftrag.stundensatzCents, + schaetzungMinuten: auftrag.schaetzungMinuten, + aufwandText: aufwandText(auftrag), + zahlungsplanText: zahlungsplanText(auftrag), + startAm: auftrag.startAm, + fertigBis: auftrag.fertigBis, + kundenart, + status: auftrag.status, + bestaetigtAm: auftrag.bestaetigtAm, + fruehbeginn: auftrag.fruehbeginn, + fruehesterBeginn: fruehesterBeginn(auftrag), + agbUrl: `/rechtliches/agb-${auftrag.agbFassung || AGB_FASSUNG}.pdf`, + texte: { + bestaetigung: BESTAETIGUNG_TEXT, + fruehbeginn: kundenart === 'privat' ? FRUEHBEGINN_TEXT : null, + kleinunternehmer: AB_TEXTE.kleinunternehmer, + }, + } +} diff --git a/test/auftraegeAuftragSicht.test.js b/test/auftraegeAuftragSicht.test.js new file mode 100644 index 0000000..b1fed01 --- /dev/null +++ b/test/auftraegeAuftragSicht.test.js @@ -0,0 +1,135 @@ +// test/auftraegeAuftragSicht.test.js +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { kundeSnapshot, fruehesterBeginn, zahlungsplanText, aufwandText, auftragFuerTeam, oeffentlicheAnsicht } from '../server/services/auftraege/auftragSicht.js' +import { AGB_FASSUNG } from '../server/services/auftraege/konstanten.js' +import { AB_TEXTE, BESTAETIGUNG_TEXT, FRUEHBEGINN_TEXT } from '../server/services/auftraege/texte.js' +import { euroDe } from '../server/services/finance/format.js' + +const KUNDE = { name: 'Anna Beispiel', firma: '', strasse: 'Hauptstr. 1', plz: '67722', ort: 'Winnweiler', land: '', email: 'anna@example.invalid', kundenart: 'privat', kundennummer: 'K-1001' } +const festpreis = (felder = {}) => ({ + id: 'ab1', nummer: 'AB-2026-0002', jahr: 2026, laufnummer: 2, status: 'versendet', kundenId: 'c1', kunde: KUNDE, projektId: 'p1', titel: 'Website Bäckerei', + zeitquelle: ['t1'], art: 'festpreis', + positionen: [{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 30000 }, { beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 20000 }], + stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000, zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14, + auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '2026-11-01', fertigBis: '', ersetztId: '', ersetztDurchId: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30', + pdfDatei: 'c1/ab1.pdf', pdfSha256: 'a'.repeat(64), gesendetAm: '2026-10-06T10:00:00.000Z', gesendetAn: 'anna@example.invalid', mailStatus: 'gesendet', mailFehler: '', + tokenHash: 'b'.repeat(64), tokenGueltigBis: '2026-12-05T10:00:00.000Z', bestaetigtAm: null, bestaetigtUeber: '', bestaetigtPdfSha256: '', fruehbeginn: false, fruehbeginnFassung: '', + storniertAm: null, widerrufenAm: null, erstelltVon: 'kenso@webklar.com', erstelltAm: '2026-10-05T09:00:00.000Z', geaendertAm: '2026-10-06T10:00:00.000Z', ...felder, +}) +const aufwand = (felder = {}) => + festpreis({ art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' }, ...felder }) +const LEER = { name: '', firma: '', strasse: '', plz: '', ort: '', land: '', email: '', kundenart: '', kundennummer: '' } + +test('kundeSnapshot übernimmt die Kundendaten getrimmt; eine unbekannte Kundenart wird leer; null und {} sind leer', () => { + const doc = { $id: 'c1', name: ' Anna Beispiel ', companyName: '', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', country: '', email: 'anna@example.invalid', kundenart: 'privat', code: 'K-1001', phone: '123', customerStatus: 'active' } + assert.deepEqual(kundeSnapshot(doc), KUNDE) + assert.deepEqual(kundeSnapshot({ ...doc, companyName: ' Bäckerei Beispiel GmbH ', kundenart: 'unternehmen' }), { ...KUNDE, firma: 'Bäckerei Beispiel GmbH', kundenart: 'unternehmen' }) + assert.equal(kundeSnapshot({ name: 'X', kundenart: 'firma' }).kundenart, '') + assert.deepEqual(kundeSnapshot({}), LEER) + assert.deepEqual(kundeSnapshot(null), LEER) +}) + +test('kundeSnapshot kürzt lange Werte, so dass die Spalte kundeJson (2000 Zeichen) reicht', () => { + const lang = 'x'.repeat(500) + const snapshot = kundeSnapshot({ name: lang, companyName: lang, street: lang, postalCode: lang, city: lang, country: lang, email: lang, kundenart: 'privat', code: lang }) + assert.ok(JSON.stringify(snapshot).length <= 2000) + assert.equal(snapshot.name.length, 120) +}) + +test('fruehesterBeginn: Tag nach Ablauf der Widerrufsfrist, nur für Privatpersonen ohne Frühbeginn', () => { + const faelle = [ + ['2026-10-06T10:00:00.000Z', '2026-10-21'], + ['2026-10-06T22:30:00.000Z', '2026-10-22'], // in Berlin schon der 07.10. + ['2026-10-10T08:00:00.000Z', '2026-10-27'], // Fristende fiele auf Samstag, 26.10. ist der Montag + ['2026-10-17T08:00:00.000Z', '2026-11-03'], // 31.10. (Samstag, Feiertag) und 01.11. (Sonntag), Fristende Montag 02.11. + ] + for (const [gesendetAm, erwartet] of faelle) assert.equal(fruehesterBeginn(festpreis({ gesendetAm })), erwartet, gesendetAm) + assert.equal(fruehesterBeginn(festpreis({ fruehbeginn: true })), null) + for (const kundenart of ['unternehmen', '']) assert.equal(fruehesterBeginn(festpreis({ kunde: { ...KUNDE, kundenart } })), null, kundenart) + assert.equal(fruehesterBeginn(festpreis({ kunde: null })), null) + assert.equal(fruehesterBeginn(festpreis({ gesendetAm: null, status: 'entwurf' })), null) + assert.equal(fruehesterBeginn(festpreis({ gesendetAm: 'kaputt' })), null) +}) + +test('zahlungsplanText: Anzahlung nach Prozent gerundet, Rest als Differenz', () => { + const text = zahlungsplanText(festpreis()) + assert.equal(text.split(euroDe(40000)).length, 3, text) // Anzahlung und Rest je 400,00 € + assert.match(text, /50\s?%/) + assert.match(text, /\b14\b/) + // Math.round(10001 × 33 / 100) = 3300, Rest 6701 + const ungerade = zahlungsplanText(festpreis({ summeCents: 10001, zahlungsplan: { art: 'anzahlung', prozent: 33, text: '' } })) + assert.ok(ungerade.includes(euroDe(3300)) && ungerade.includes(euroDe(6701)), ungerade) +}) + +test('zahlungsplanText: nach Abnahme, monatlich, frei; Anzahlung ohne Summe fällt auf nach Abnahme zurück', () => { + assert.match(zahlungsplanText(festpreis({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })), /\b14\b/) + assert.match(zahlungsplanText(aufwand({ zahlungszielTage: 7 })), /\b7\b/) + assert.ok(zahlungsplanText(festpreis({ zahlungsplan: { art: 'frei', prozent: null, text: 'Zahlung in zwei Raten.' } })).includes('Zahlung in zwei Raten')) + const ohneSumme = aufwand({ zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' } }) + assert.equal(zahlungsplanText(ohneSumme), zahlungsplanText({ ...ohneSumme, zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })) +}) + +test('aufwandText: nur bei Aufwand, mit Stundensatz und Schätzung in Stunden', () => { + const text = aufwandText(aufwand()) + assert.ok(text.includes(euroDe(10000))) + assert.match(text, /\b10\b/) + assert.doesNotMatch(aufwandText(aufwand({ schaetzungMinuten: null })), /\b10\b/) + assert.match(aufwandText(aufwand({ schaetzungMinuten: 135 })), /2,25/) + assert.match(aufwandText(aufwand({ schaetzungMinuten: 90 })), /1,5\b/) + assert.ok(aufwandText(aufwand({ stundensatzCents: null })).includes(euroDe(10000)), 'ohne Satz gilt der Standard') + assert.equal(aufwandText(festpreis()), null) +}) + +test('auftragFuerTeam: ohne Token-Hash, PDF-Pfad und Bestätigungs-Prüfsumme, mit den abgeleiteten Feldern', () => { + const auftrag = festpreis() + const kopie = structuredClone(auftrag) + const sicht = auftragFuerTeam(auftrag) + assert.deepEqual(auftrag, kopie, 'verändert die AB nicht') + const entfernt = ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256'] + for (const feld of entfernt) assert.equal(Object.hasOwn(sicht, feld), false, feld) + assert.equal(sicht.pdfSha256, 'a'.repeat(64)) + assert.equal(sicht.pdfVorhanden, true) + assert.equal(sicht.fruehesterBeginn, '2026-10-21') + assert.equal(sicht.zahlungsplanText, zahlungsplanText(auftrag)) + assert.equal(sicht.aufwandText, null) + assert.deepEqual( + Object.keys(sicht).sort(), + [...Object.keys(auftrag).filter((k) => !entfernt.includes(k)), 'pdfVorhanden', 'fruehesterBeginn', 'zahlungsplanText', 'aufwandText'].sort(), + ) + assert.equal(auftragFuerTeam(festpreis({ pdfDatei: '' })).pdfVorhanden, false) +}) + +test('oeffentlicheAnsicht: genau die Felder aus V3, Beträge je Position, Berliner Datum, nichts Internes', () => { + const ansicht = oeffentlicheAnsicht(festpreis()) + assert.deepEqual( + Object.keys(ansicht).sort(), + ['agbUrl', 'aufwandText', 'art', 'bestaetigtAm', 'datum', 'fertigBis', 'fruehbeginn', 'fruehesterBeginn', 'kundenart', 'nummer', 'positionen', 'schaetzungMinuten', 'startAm', 'status', 'stundensatzCents', 'summeCents', 'texte', 'titel', 'zahlungsplanText'].sort(), + ) + assert.deepEqual(ansicht.positionen, [ + { beschreibung: 'Startseite', menge: 2, einzelpreisCents: 30000, summeCents: 60000 }, + { beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 20000, summeCents: 20000 }, + ]) + assert.deepEqual( + [ansicht.nummer, ansicht.datum, ansicht.titel, ansicht.art, ansicht.summeCents, ansicht.startAm, ansicht.kundenart, ansicht.status], + ['AB-2026-0002', '2026-10-06', 'Website Bäckerei', 'festpreis', 80000, '2026-11-01', 'privat', 'versendet'], + ) + assert.equal(ansicht.fruehesterBeginn, '2026-10-21') + assert.deepEqual(ansicht.texte, { bestaetigung: BESTAETIGUNG_TEXT, fruehbeginn: FRUEHBEGINN_TEXT, kleinunternehmer: AB_TEXTE.kleinunternehmer }) + assert.equal(ansicht.texte.kleinunternehmer, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.') + assert.equal(ansicht.agbUrl, '/rechtliches/agb-2026-09-30.pdf') + const text = JSON.stringify(ansicht) + for (const geheim of ['b'.repeat(64), 'c1/ab1.pdf', 'anna@example.invalid', 'Hauptstr. 1', 'kenso@webklar.com']) assert.ok(!text.includes(geheim), geheim) +}) + +test('oeffentlicheAnsicht: Unternehmen ohne Frühbeginn-Text, Datum nach Berliner Zeit, AGB-Adresse ohne gespeicherte Fassung', () => { + const firma = oeffentlicheAnsicht(festpreis({ kunde: { ...KUNDE, kundenart: 'unternehmen' }, gesendetAm: '2026-10-06T22:30:00.000Z', agbFassung: '' })) + assert.equal(firma.texte.fruehbeginn, null) + assert.equal(firma.texte.kleinunternehmer, AB_TEXTE.kleinunternehmer) + assert.equal(firma.fruehesterBeginn, null) + assert.equal(firma.datum, '2026-10-07') + assert.equal(firma.agbUrl, `/rechtliches/agb-${AGB_FASSUNG}.pdf`) + const aufwandAnsicht = oeffentlicheAnsicht(aufwand()) + assert.deepEqual([aufwandAnsicht.positionen, aufwandAnsicht.stundensatzCents, aufwandAnsicht.schaetzungMinuten], [[], 10000, 600]) + assert.equal(aufwandAnsicht.aufwandText, aufwandText(aufwand())) +})