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:
1 parent
47a92811a3
commit
98eceaf14c
2 files changed
+240
No files matched your search
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
})
|
||||
Reference in new issue
Block a user