Aufträge: Sichten auf die Auftragsbestätigung (Team, öffentlich, Frühbeginn)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-07 14:07:15 +00:00
1 parent 47a92811a3
commit 98eceaf14c
2 files changed
+240

No files matched your search

+105
View File
@@ -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,
},
}
}
+135
View File
@@ -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()))
})