/** * Abläufe der Auftragsbestätigung (AB): Eingabeprüfung, Entwurf, Vorschau, Senden, Erinnern, neue Fassung, Stornieren * und nachträgliches Zuordnen eines Projekts (Spec Abschnitt 2, Übersicht V4 „L3 – AB-Kern“). * Speicher nur über auftragStore.js und projektRechnungenStore.js, PDFs nur über ablage.js; PDF, Token und Mail kommen * über `deps` (L4/L5, Standard: dynamischer Import beim Aufruf). Jede Änderung an einer bestehenden AB läuft unter * sperreAuftrag(id) (konstanten.js), das Senden zusätzlich unter SPERRE_NUMMER; Mails gehen immer erst nach der Sperre. */ import { createHash } from 'node:crypto' import { config } from '../../config.js' import { getDocument, listDocuments, Query, updateDocument } from '../appwriteAdmin.js' import { getSettings } from '../finance/settings.js' import { withLocks } from '../keyedLock.js' import { kundeSnapshot } from './auftragSicht.js' import { entfernePdf, lesePdf, speicherePdf } from './ablage.js' import { createAuftrag, deleteAuftrag, getAuftrag, listAuftraege, updateAuftrag } from './auftragStore.js' import { AuftragError } from './fehler.js' import { vergibNummer } from './nummern.js' import { listProjektRechnungen, umhaengen, updateProjektRechnung } from './projektRechnungenStore.js' import { TEXTE_FASSUNG } from './texte.js' import { AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, KUNDENARTEN, LIMITS, SPERRE_NUMMER, sperreAuftrag, STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS, VIERTELSTUNDE, ZAHLUNGSPLAN_ARTEN, } from './konstanten.js' // --------------------------------------------------------------------------------------------------------------------- // Eingabeprüfung (rein, synchron) // --------------------------------------------------------------------------------------------------------------------- /** Appwrite-Dokument-ID (wie in auftragStore.js). */ export function istDokumentId(wert) { return typeof wert === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(wert) } const fehler400 = (meldung) => new AuftragError(400, meldung) const MELDUNG_POSITIONEN = 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.' const MELDUNG_ART = 'Art: Bitte Festpreis oder Nach Aufwand wählen.' const MELDUNG_PLAN = 'Zahlungsplan: Bitte eine Zahlungsart wählen.' const MELDUNG_VOM = 'Auftrag vom: Bitte ein gültiges Datum angeben (JJJJ-MM-TT).' const MELDUNG_WEG = 'Weg: Bitte den Weg der Absprache wählen.' const MELDUNG_SUMME = 'Die Auftragssumme ist zu hoch (höchstens 999.999,99 €).' const PREIS_GRENZE = 'zwischen 0,01 € und 100.000,00 € liegen.' const istEinfachesObjekt = (wert) => Object.prototype.toString.call(wert) === '[object Object]' function istKalendertag(wert) { if (typeof wert !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(wert)) return false const datum = new Date(`${wert}T00:00:00Z`) return !Number.isNaN(datum.getTime()) && datum.toISOString().slice(0, 10) === wert } function pruefePosition(roh, n) { const p = istEinfachesObjekt(roh) ? roh : {} const beschreibung = typeof p.beschreibung === 'string' ? p.beschreibung.trim() : '' if (beschreibung.length < 1 || beschreibung.length > LIMITS.maxBeschreibung) { throw fehler400(`Position ${n}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`) } if (!Number.isInteger(p.menge) || p.menge < 1 || p.menge > LIMITS.maxMenge) { throw fehler400(`Position ${n}: Die Menge muss eine ganze Zahl von 1 bis ${LIMITS.maxMenge} sein.`) } if (!Number.isInteger(p.einzelpreisCents) || p.einzelpreisCents < 1 || p.einzelpreisCents > LIMITS.maxEinzelpreisCents) { throw fehler400(`Position ${n}: Der Einzelpreis muss ${PREIS_GRENZE}`) } return { beschreibung, menge: p.menge, einzelpreisCents: p.einzelpreisCents } } function pruefePlan(roh) { if (!istEinfachesObjekt(roh) || !ZAHLUNGSPLAN_ARTEN.includes(roh.art)) throw fehler400(MELDUNG_PLAN) let prozent = null let text = '' if (roh.art === 'anzahlung') { prozent = roh.prozent === undefined || roh.prozent === null ? LIMITS.standardAnzahlungProzent : roh.prozent if (!Number.isInteger(prozent) || prozent < 1 || prozent > 99) throw fehler400('Zahlungsplan: Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.') } if (roh.art === 'frei') { text = typeof roh.text === 'string' ? roh.text.trim() : '' if (text.length < 1 || text.length > LIMITS.maxZahlungsplanText) throw fehler400(`Zahlungsplan: Der freie Text braucht 1 bis ${LIMITS.maxZahlungsplanText} Zeichen.`) } return { art: roh.art, prozent, text } } function pruefeOptionalesDatum(wert, meldung) { if (wert === null || wert === '') return '' if (!istKalendertag(wert)) throw fehler400(meldung) return wert } /** Feldregeln: jedes übergebene Feld (Wert !== undefined) wird geprüft und bereinigt. */ const FELDREGELN = { art: (w) => { if (!AUFTRAG_ARTEN.includes(w)) throw fehler400(MELDUNG_ART) return w }, projektId: (w) => { if (w === null || w === '') return '' if (!istDokumentId(w)) throw fehler400('Projekt: Ungültige Projekt-Kennung.') return w }, titel: (w) => { if (w === null) return '' if (typeof w !== 'string' || w.trim().length > LIMITS.maxTitel) throw fehler400(`Titel: Höchstens ${LIMITS.maxTitel} Zeichen.`) return w.trim() }, kundenart: (w) => { if (w === null || w === '') return '' if (!KUNDENARTEN.includes(w)) throw fehler400('Kundenart: Bitte Unternehmen oder Privatperson wählen.') return w }, positionen: (w) => { if (!Array.isArray(w) || w.length > LIMITS.maxPositionen) throw fehler400(MELDUNG_POSITIONEN) return w.map((p, i) => pruefePosition(p, i + 1)) }, stundensatzCents: (w) => { if (w === null) return null if (!Number.isInteger(w) || w < 1 || w > LIMITS.maxEinzelpreisCents) throw fehler400(`Stundensatz: Der Stundensatz muss ${PREIS_GRENZE}`) return w }, schaetzungMinuten: (w) => { if (w === null || w === '' || w === 0) return null if (!Number.isInteger(w) || w % VIERTELSTUNDE !== 0 || w < VIERTELSTUNDE || w > LIMITS.maxSchaetzungMinuten) { throw fehler400(`Schätzung: Die Schätzung muss ein Vielfaches von ${VIERTELSTUNDE} Minuten sein (15 bis 99.960).`) } return w }, zahlungsplan: pruefePlan, zahlungszielTage: (w) => { if (w === null) return null if (!Number.isInteger(w) || w < 0 || w > LIMITS.maxZahlungszielTage) { throw fehler400(`Zahlungsziel: Das Zahlungsziel muss eine ganze Zahl von 0 bis ${LIMITS.maxZahlungszielTage} Tagen sein.`) } return w }, auftragVom: (w) => { if (!istKalendertag(w)) throw fehler400(MELDUNG_VOM) return w }, auftragWeg: (w) => { if (typeof w !== 'string' || !Object.hasOwn(AUFTRAG_WEGE, w)) throw fehler400(MELDUNG_WEG) return w }, startAm: (w) => pruefeOptionalesDatum(w, 'Start: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'), fertigBis: (w) => pruefeOptionalesDatum(w, 'Fertigstellung: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'), zeitquelle: (w) => { if (w === null) return [] if (!Array.isArray(w)) throw fehler400('Zeitquelle: Eine Liste von Ticket-Kennungen erwartet.') if (!w.every(istDokumentId)) throw fehler400('Zeitquelle: Ungültige Ticket-Kennung.') const ohneDoppelte = [...new Set(w)] if (ohneDoppelte.length > LIMITS.maxZeitquellen) throw fehler400(`Zeitquelle: Höchstens ${LIMITS.maxZeitquellen} Tickets.`) return ohneDoppelte }, } const FELD_REIHENFOLGE = Object.keys(FELDREGELN) /** * Prüft und bereinigt die Eingabe einer AB. Nur Felder der Whitelist (FELDREGELN) werden übernommen; status, nummer, * tokenHash & Co. landen nie im Ergebnis. teilweise: nur die übergebenen Felder, ohne Pflicht-, Vorgabe- und Querprüfung. */ export function pruefeEingabe(eingabe, { teilweise = false } = {}) { if (!istEinfachesObjekt(eingabe)) throw fehler400('Ungültige Eingabe.') const felder = {} for (const name of FELD_REIHENFOLGE) { if (eingabe[name] !== undefined) felder[name] = FELDREGELN[name](eingabe[name]) } if (teilweise) return felder // Pflichtfelder: fehlt eines, kommt dieselbe Meldung wie bei einem ungültigen Wert if (felder.art === undefined) throw fehler400(MELDUNG_ART) if (felder.zahlungsplan === undefined) throw fehler400(MELDUNG_PLAN) if (felder.auftragVom === undefined) throw fehler400(MELDUNG_VOM) if (felder.auftragWeg === undefined) throw fehler400(MELDUNG_WEG) const ergebnis = { projektId: felder.projektId ?? '', titel: felder.titel ?? '', kundenart: felder.kundenart ?? '', art: felder.art, positionen: [], stundensatzCents: null, schaetzungMinuten: null, summeCents: null, zahlungsplan: felder.zahlungsplan, zahlungszielTage: felder.zahlungszielTage ?? LIMITS.standardZahlungszielTage, auftragVom: felder.auftragVom, auftragWeg: felder.auftragWeg, startAm: felder.startAm ?? '', fertigBis: felder.fertigBis ?? '', zeitquelle: felder.zeitquelle ?? [], } if (felder.art === 'festpreis') { if (!felder.positionen || felder.positionen.length === 0) throw fehler400(MELDUNG_POSITIONEN) const summe = felder.positionen.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0) if (summe > LIMITS.maxSummeCents) throw fehler400(MELDUNG_SUMME) if (felder.zahlungsplan.art === 'monatlich') throw fehler400('Zahlungsplan: „Monatlich nach Aufwand“ geht nur bei einem Auftrag nach Aufwand.') ergebnis.positionen = felder.positionen ergebnis.summeCents = summe } else { if (felder.zahlungsplan.art === 'anzahlung') throw fehler400('Zahlungsplan: Eine Anzahlung geht nur bei einem Festpreis.') ergebnis.stundensatzCents = felder.stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS ergebnis.schaetzungMinuten = felder.schaetzungMinuten ?? null } return ergebnis } // --------------------------------------------------------------------------------------------------------------------- // Entwurf anlegen, ändern, löschen // --------------------------------------------------------------------------------------------------------------------- const hatText = (wert) => typeof wert === 'string' && wert.trim() !== '' export async function ladeAuftrag(id) { const auftrag = istDokumentId(id) ? await getAuftrag(id) : null if (!auftrag) throw new AuftragError(404, 'Auftragsbestätigung nicht gefunden.') return auftrag } export async function ladeKunde(kundenId) { const nichtGefunden = () => new AuftragError(404, 'Kunde nicht gefunden.') if (!istDokumentId(kundenId)) throw nichtGefunden() try { return await getDocument(config.collections.customers, kundenId) } catch (err) { if (err?.status === 404) throw nichtGefunden() throw err } } /** Die Felder, die die Eingabeprüfung kennt, aus einem gespeicherten Auftrag (ohne kundenId und kundenart). */ function eingabeAusAuftrag(auftrag) { const { projektId, titel, art, positionen, stundensatzCents, schaetzungMinuten, zahlungsplan, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, zeitquelle } = auftrag return { projektId, titel, art, positionen, stundensatzCents, schaetzungMinuten, zahlungsplan, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, zeitquelle } } /** Das Ticket eines Projekts als Zeitquelle (nie ein Leereintrag). */ function ticketsDesProjekts(projekt) { return hatText(projekt?.ticketId) ? [projekt.ticketId.trim()] : [] } async function ladeProjekt(projektId) { try { return await getDocument(config.collections.websiteProjects, projektId) } catch (err) { if (err?.status === 404) return null throw err } } /** Projekt und Tickets müssen dem Kunden gehören; liefert die Felder mit aufgelöstem Titel und aufgelöster Zeitquelle. */ async function pruefeBezuege(kundeDoc, felder) { let titel = felder.titel let zeitquelle = felder.zeitquelle let projekt = null if (felder.projektId) { projekt = await ladeProjekt(felder.projektId) if (!projekt || projekt.customerId !== kundeDoc.$id) throw new AuftragError(400, 'Projekt: Das Projekt gehört nicht zu diesem Kunden.') } if (!titel) titel = String(projekt?.projectName || '').trim().slice(0, LIMITS.maxTitel).trim() if (!titel) throw new AuftragError(400, 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.') if (zeitquelle.length === 0 && projekt) zeitquelle = ticketsDesProjekts(projekt) if (zeitquelle.length > 0) { const tickets = await listDocuments(config.collections.workorders, [Query.equal('$id', zeitquelle), Query.limit(LIMITS.maxZeitquellen)]) const gefunden = new Map(tickets.map((t) => [t.$id, t])) if (!zeitquelle.every((id) => gefunden.get(id)?.customerId === kundeDoc.$id)) { throw new AuftragError(400, 'Zeitquelle: Mindestens ein Ticket gehört nicht zu diesem Kunden.') } } return { ...felder, titel, zeitquelle } } /** Setzt die Kundenart am Kunden, aber nur, wenn dort noch keine steht. */ async function schreibeKundenart(kundeDoc, kundenart) { if (!kundenart || hatText(kundeDoc.kundenart)) return await updateDocument(config.collections.customers, kundeDoc.$id, { kundenart }) } /** Die Felder einer AB, wie createAuftrag und updateAuftrag sie bekommen. */ const speicherFelder = (f) => ({ projektId: f.projektId, titel: f.titel, zeitquelle: f.zeitquelle, art: f.art, positionen: f.positionen, stundensatzCents: f.stundensatzCents, schaetzungMinuten: f.schaetzungMinuten, summeCents: f.summeCents, zahlungsplan: f.zahlungsplan, zahlungszielTage: f.zahlungszielTage, auftragVom: f.auftragVom, auftragWeg: f.auftragWeg, startAm: f.startAm, fertigBis: f.fertigBis, }) export async function legeEntwurfAn(eingabe, { von } = {}) { const felder = pruefeEingabe(eingabe) if (!hatText(eingabe.kundenId)) throw new AuftragError(400, 'Kunde: Bitte einen Kunden angeben.') const kundeDoc = await ladeKunde(eingabe.kundenId) const fertig = await pruefeBezuege(kundeDoc, felder) await schreibeKundenart(kundeDoc, felder.kundenart) return createAuftrag({ kundenId: eingabe.kundenId, ...speicherFelder(fertig), erstelltVon: String(von || '') }) } export async function aendereEntwurf(id, eingabe) { return withLocks(sperreAuftrag(id), async () => { const auftrag = await ladeAuftrag(id) if (auftrag.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich ändern.') if (eingabe?.kundenId !== undefined && eingabe.kundenId !== auftrag.kundenId) { throw new AuftragError(400, 'Kunde: Der Kunde eines Auftrags lässt sich nicht ändern.') } const teil = pruefeEingabe(eingabe, { teilweise: true }) const felder = pruefeEingabe({ ...eingabeAusAuftrag(auftrag), ...teil }) const kundeDoc = await ladeKunde(auftrag.kundenId) const fertig = await pruefeBezuege(kundeDoc, felder) await schreibeKundenart(kundeDoc, felder.kundenart) return updateAuftrag(id, speicherFelder(fertig)) }) } export async function loescheEntwurf(id) { return withLocks(sperreAuftrag(id), async () => { const auftrag = await ladeAuftrag(id) if (auftrag.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich löschen.') await deleteAuftrag(id) }) } // --------------------------------------------------------------------------------------------------------------------- // Abhängigkeiten (PDF, Token, Mail) und Vorschau-PDF // --------------------------------------------------------------------------------------------------------------------- /** Ins Protokoll nur Art, Status und Code, nie die Meldung (sie kann Adressen oder Pfade enthalten). */ function logFehler(was, err) { console.error(`[auftraege] ${was} fehlgeschlagen:`, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '') } // PDF, Token und Mail entstehen in L4/L5 parallel: Standard ist ein dynamischer Import beim Aufruf, Tests übergeben Attrappen const STANDARD_QUELLEN = { erzeugeAuftragPdf: './pdf.js', neuerToken: './token.js', sendeAuftragMail: './mails.js' } let depsFuerTests = null /** Nur für Tests: Ersatz für die Standard-Abhängigkeiten (null stellt die dynamischen Importe wieder her). */ export function setzeDepsFuerTests(deps) { depsFuerTests = deps } async function ladeDeps(deps, namen) { const quelle = { ...depsFuerTests, ...deps } const geladen = {} for (const name of namen) geladen[name] = quelle[name] ?? (await import(STANDARD_QUELLEN[name]))[name] return geladen } /** Erzeugt das PDF; ein AuftragError der PDF-Funktion (z. B. 409 „Entwurf unvollständig“) bleibt erhalten. */ async function pdfErzeugen(d, auftrag, optionen) { let pdf try { pdf = await d.erzeugeAuftragPdf(auftrag, optionen) if (!Buffer.isBuffer(pdf) || pdf.length === 0) throw new Error('leeres PDF') } catch (err) { if (err instanceof AuftragError) throw err logFehler('PDF-Erzeugung', err) throw new AuftragError(500, 'Das PDF konnte nicht erzeugt werden.') } return pdf } export async function vorschauPdf(id, { deps } = {}) { const d = await ladeDeps(deps, ['erzeugeAuftragPdf']) const auftrag = await ladeAuftrag(id) if (auftrag.status !== 'entwurf') throw new AuftragError(409, 'Eine Vorschau gibt es nur für Entwürfe.') const kundeDoc = await ladeKunde(auftrag.kundenId) const { steuernummer, bankDetailsText } = await getSettings() return pdfErzeugen(d, { ...auftrag, nummer: 'Entwurf', kunde: kundeSnapshot(kundeDoc) }, { settings: { steuernummer, bankDetailsText }, vorschau: true }) } // --------------------------------------------------------------------------------------------------------------------- // Senden // --------------------------------------------------------------------------------------------------------------------- /** Prüfreihenfolge: fester Kunde, Kundenart, E-Mail, Anschrift (jeweils mit fester Meldung). */ function pruefeKundeFuerVersand(kundeDoc) { const status = String(kundeDoc.customerStatus || '').trim().toLowerCase() if (status === 'lead' || status === 'lost') throw new AuftragError(400, 'Auftragsbestätigungen nur für feste Kunden.') if (!KUNDENARTEN.includes(String(kundeDoc.kundenart || '').trim())) { throw new AuftragError(400, 'Bitte zuerst die Kundenart angeben (Unternehmen oder Privatperson).') } if (!hatText(kundeDoc.email)) throw new AuftragError(400, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.') if (!hatText(kundeDoc.street) || !hatText(kundeDoc.postalCode) || !hatText(kundeDoc.city)) { throw new AuftragError(409, 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.') } } /** Entfernt die eben abgelegte PDF, wenn danach etwas scheiterte – aber nur, wenn die AB nachweislich noch ein Entwurf ist. */ async function raeumePdfAuf(auftragId, pdfDatei) { try { const aktuell = await getAuftrag(auftragId) if (aktuell?.status === 'entwurf') await entfernePdf(pdfDatei) } catch (err) { logFehler('PDF-Rest entfernen', err) } } /** Fester Text für mailFehler: nie die Meldung von SMTP oder Bibliothek, höchstens ein kurzer Fehlercode. */ function mailFehlerText(err) { const code = err?.code return typeof code === 'string' && /^[A-Za-z0-9_.-]{1,40}$/.test(code) ? `E-Mail konnte nicht gesendet werden (${code}).` : 'E-Mail konnte nicht gesendet werden.' } /** Versendet die K31 mit dem gespeicherten Dokument (nach der Sperre) und hält das Ergebnis in mailStatus fest. */ async function versendeMail(d, gespeichert, { pdf, token, erinnerung = false }) { let mailPatch try { await d.sendeAuftragMail(gespeichert, { pdf, token, erinnerung }) mailPatch = { mailStatus: 'gesendet', mailFehler: '' } } catch (err) { logFehler('Mailversand', err) mailPatch = { mailStatus: 'fehler', mailFehler: mailFehlerText(err) } } const final = await updateAuftrag(gespeichert.id, mailPatch) return { auftrag: final, mailFehler: mailPatch.mailStatus === 'fehler' } } const MELDUNG_ERSETZTE_UNGUELTIG = 'Die ersetzte Auftragsbestätigung ist nicht mehr gültig. Bitte eine neue Fassung anlegen.' /** * Prüft die AB, die der Entwurf ersetzen soll: gültig sind versendet, bestätigt, abgerechnet oder (bei der Wiederholung * nach einem Fehler) schon durch genau diesen Entwurf ersetzt. Zeigt ersetztId auf den Entwurf selbst (beschädigte Daten), * gibt es sofort 409, damit ersetzeAlteAb nicht den schon gehaltenen Schlüssel noch einmal sperrt. */ async function pruefeErsetzteAb(entwurf) { if (entwurf.ersetztId === entwurf.id) throw new AuftragError(409, MELDUNG_ERSETZTE_UNGUELTIG) const alt = await getAuftrag(entwurf.ersetztId) const gueltig = alt && (VEREINBARTE_STATUS.includes(alt.status) || (alt.status === 'ersetzt' && alt.ersetztDurchId === entwurf.id)) if (!gueltig) throw new AuftragError(409, MELDUNG_ERSETZTE_UNGUELTIG) return alt } /** * Ersetzt die alte AB (Status ersetzt + ersetztDurchId) und hängt ihre Projektrechnungen um – vor dem Speichern der neuen * AB, wiederholbar. Der Stand der alten AB wird unter ihrer eigenen Sperre noch einmal geprüft; ihr tokenHash bleibt. */ async function ersetzeAlteAb(entwurf) { await withLocks(sperreAuftrag(entwurf.ersetztId), async () => { const alt = await pruefeErsetzteAb(entwurf) if (alt.status !== 'ersetzt') await updateAuftrag(alt.id, { status: 'ersetzt', ersetztDurchId: entwurf.id }) await umhaengen(alt.id, entwurf.id) }) } export async function sendeAuftrag(id, { von, deps, jetzt = new Date() } = {}) { void von // wird nicht gespeichert (V1 hat kein Feld dafür); die Team-Kopie der Mail belegt den Versand const d = await ladeDeps(deps, ['erzeugeAuftragPdf', 'neuerToken', 'sendeAuftragMail']) // Nummernvergabe und Statuswechsel unter einer Sperre: zwei Klicks ergeben eine Nummer, Ändern und Löschen warten const { gespeichert, pdf, token } = await withLocks([...SPERRE_NUMMER, ...sperreAuftrag(id)], async () => { const entwurf = await ladeAuftrag(id) if (entwurf.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich senden.') pruefeEingabe(eingabeAusAuftrag(entwurf)) const kundeDoc = await ladeKunde(entwurf.kundenId) pruefeKundeFuerVersand(kundeDoc) if (entwurf.ersetztId) await pruefeErsetzteAb(entwurf) // früh: vor Nummer und PDF const kunde = kundeSnapshot(kundeDoc) const { steuernummer, bankDetailsText } = await getSettings() const { nummer, jahr, laufnummer } = await vergibNummer(jetzt) const gesendetAm = jetzt.toISOString() const zuSenden = { ...entwurf, status: 'versendet', nummer, jahr, laufnummer, kunde, gesendetAm, texteFassung: TEXTE_FASSUNG, agbFassung: AGB_FASSUNG } const pdfBytes = await pdfErzeugen(d, zuSenden, { settings: { steuernummer, bankDetailsText } }) let ablage try { ablage = await speicherePdf(entwurf.kundenId, entwurf.id, pdfBytes) } catch (err) { // eine schon vorhandene Datei gehört nicht diesem Aufruf und bleibt unberührt logFehler('PDF-Ablage', err) throw new AuftragError(500, 'Das PDF konnte nicht erzeugt werden.') } try { const neu = d.neuerToken(jetzt) if (entwurf.ersetztId) await ersetzeAlteAb(entwurf) const gespeichertesDokument = await updateAuftrag(entwurf.id, { status: 'versendet', nummer, jahr, laufnummer, kunde, gesendetAm, texteFassung: TEXTE_FASSUNG, agbFassung: AGB_FASSUNG, pdfDatei: ablage.pdfDatei, pdfSha256: ablage.pdfSha256, gesendetAn: kunde.email, tokenHash: neu.hash, tokenGueltigBis: neu.gueltigBis.toISOString(), }) return { gespeichert: gespeichertesDokument, pdf: pdfBytes, token: neu.token } } catch (err) { await raeumePdfAuf(entwurf.id, ablage.pdfDatei) throw err } }) return versendeMail(d, gespeichert, { pdf, token }) } // --------------------------------------------------------------------------------------------------------------------- // Erinnern („Erinnerung senden“ und „Erneut senden“) // --------------------------------------------------------------------------------------------------------------------- /** Liest die gespeicherte PDF und prüft die SHA-256-Summe: eine veränderte Datei wird nie verschickt oder angezeigt. */ export async function ladeGespeichertesPdf(auftrag) { if (!hatText(auftrag.pdfDatei)) throw new AuftragError(404, 'PDF nicht gefunden.') const buffer = await lesePdf(auftrag.pdfDatei) if (createHash('sha256').update(buffer).digest('hex') !== auftrag.pdfSha256) { console.error('[auftraege] Prüfsumme der gespeicherten PDF stimmt nicht:', auftrag.id) throw new AuftragError(500, 'Die gespeicherte PDF-Datei stimmt nicht mit der Prüfsumme überein.') } return buffer } /** * Neuer Bestätigungslink für eine versendete AB (der alte gilt ab dann nicht mehr) und erneute Mail mit der gespeicherten * PDF. Nach einem Mail-Fehler ist es eine gewöhnliche Mail ohne Erinnerungs-Hinweis; gesendetAn wird die aktuelle Adresse. */ export async function erinnereAuftrag(id, { deps, jetzt = new Date() } = {}) { const d = await ladeDeps(deps, ['neuerToken', 'sendeAuftragMail']) const { gespeichert, pdf, token, erinnerung } = await withLocks(sperreAuftrag(id), async () => { const alt = await ladeAuftrag(id) if (alt.status !== 'versendet') throw new AuftragError(409, 'Eine Erinnerung gibt es nur für versendete Auftragsbestätigungen.') const kundeDoc = await ladeKunde(alt.kundenId) if (!hatText(kundeDoc.email)) throw new AuftragError(400, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.') // vor dem Token-Tausch: eine beschädigte Datei darf den alten Link nicht entwerten const pdfBytes = await ladeGespeichertesPdf(alt) const neu = d.neuerToken(jetzt) const dokument = await updateAuftrag(id, { tokenHash: neu.hash, tokenGueltigBis: neu.gueltigBis.toISOString(), gesendetAn: kundeDoc.email.trim() }) return { gespeichert: dokument, pdf: pdfBytes, token: neu.token, erinnerung: alt.mailStatus !== 'fehler' } }) return versendeMail(d, gespeichert, { pdf, token, erinnerung }) } // --------------------------------------------------------------------------------------------------------------------- // Neue Fassung // --------------------------------------------------------------------------------------------------------------------- /** Legt aus einer versendeten, bestätigten oder abgerechneten AB einen neuen Entwurf an (ersetztId); die alte bleibt. */ export async function neueFassung(id, { von } = {}) { return withLocks(sperreAuftrag(id), async () => { const alt = await ladeAuftrag(id) if (!VEREINBARTE_STATUS.includes(alt.status)) { throw new AuftragError(409, 'Eine neue Fassung gibt es nur für versendete, bestätigte oder abgerechnete Aufträge.') } const vorhandene = await listAuftraege({ kundenId: alt.kundenId }) if (vorhandene.some((a) => a.status === 'entwurf' && a.ersetztId === alt.id)) { throw new AuftragError(409, 'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf für eine neue Fassung.') } return createAuftrag({ kundenId: alt.kundenId, ...speicherFelder(alt), ersetztId: alt.id, erstelltVon: String(von || '') }) }) } // --------------------------------------------------------------------------------------------------------------------- // Stornieren // --------------------------------------------------------------------------------------------------------------------- /** Storniert eine versendete oder bestätigte AB (ohne Mail); tokenHash, Nummer und PDF bleiben, der Link erklärt „storniert“. */ export async function storniereAuftrag(id) { return withLocks(sperreAuftrag(id), async () => { const auftrag = await ladeAuftrag(id) // der Stand wird in der Sperre gelesen if (auftrag.status !== 'versendet' && auftrag.status !== 'bestaetigt') { throw new AuftragError(409, 'Nur versendete oder bestätigte Auftragsbestätigungen lassen sich stornieren.') } return updateAuftrag(id, { status: 'storniert', storniertAm: new Date().toISOString() }) }) } // --------------------------------------------------------------------------------------------------------------------- // Projekt nachträglich zuordnen // --------------------------------------------------------------------------------------------------------------------- /** * Ordnet einer AB ohne Projekt nachträglich ein Projekt des Kunden zu: setzt projektId, bei leerer Zeitquelle das Ticket des * Projekts, und hängt die Projektrechnungen der AB mit leerer projektId um (Stripe bleibt unverändert). PDF, Nummer, Status * und Token bleiben. Zuerst die Projektrechnungen, zuletzt die AB: scheitert etwas dazwischen, bleibt die AB ohne Projekt * und die Wiederholung führt alles zu Ende. */ export async function ordneProjektZu(id, projektId) { if (!hatText(projektId)) throw new AuftragError(400, 'Projekt: Bitte ein Projekt angeben.') if (!istDokumentId(projektId)) throw new AuftragError(400, 'Projekt: Ungültige Projekt-Kennung.') return withLocks(sperreAuftrag(id), async () => { const auftrag = await ladeAuftrag(id) if (['storniert', 'ersetzt', 'widerrufen'].includes(auftrag.status)) { throw new AuftragError(409, 'Einer stornierten, ersetzten oder widerrufenen Auftragsbestätigung lässt sich kein Projekt mehr zuordnen.') } if (hatText(auftrag.projektId)) throw new AuftragError(409, 'Diese Auftragsbestätigung gehört schon zu einem Projekt.') const projekt = await ladeProjekt(projektId) if (!projekt || projekt.customerId !== auftrag.kundenId) throw new AuftragError(400, 'Projekt: Das Projekt gehört nicht zu diesem Kunden.') const rechnungen = await listProjektRechnungen({ auftragId: id, nurAktiv: false }) for (const rechnung of rechnungen) { if (rechnung.projektId === '') await updateProjektRechnung(rechnung.invoiceId, { projektId }) } const patch = { projektId } if (auftrag.zeitquelle.length === 0) patch.zeitquelle = ticketsDesProjekts(projekt) return updateAuftrag(id, patch) }) }