Merge auftraege/l3-ab-kern: Auftragsbestätigung Kern (Strang L3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
095694bc44
12 files changed
+2330
-2
No files matched your search
@@ -1,11 +1,145 @@
|
||||
/**
|
||||
* Gerüst von Strang L0: Auftragsbestätigungen (Admin). Mount: /api/admin/finanzen/auftraege (vor dem Finanzen-Router).
|
||||
* L3 füllt: Liste, Anlegen, Ändern, Löschen, PDF, Projekt zuordnen, Senden, Erinnern, Neue Fassung, Stornieren.
|
||||
* Auftragsbestätigungen (Admin), Mount: /api/admin/finanzen/auftraege (vor dem Finanzen-Router, Übersicht V5 „L3“).
|
||||
* GET / (?kunde=), POST /, GET|PATCH|DELETE /:id, GET /:id/pdf, POST /:id/projekt, /:id/senden, /:id/erinnern,
|
||||
* /:id/neue-fassung, /:id/stornieren. Nur dünne HTTP-Schicht: Kunde laden, Schalter prüfen, Dienst aufrufen, Fehler über
|
||||
* sendeFehler. Ist der Schalter für den Kunden aus, antworten alle Routen 404 mit SCHALTER_AUS_MELDUNG.
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { auftraegeAktiv } from '../../services/auftraege/schalter.js'
|
||||
import { SCHALTER_AUS_MELDUNG } from '../../services/auftraege/konstanten.js'
|
||||
import { AuftragError, sendeFehler } from '../../services/auftraege/fehler.js'
|
||||
import { auftragFuerTeam } from '../../services/auftraege/auftragSicht.js'
|
||||
import { listAuftraege } from '../../services/auftraege/auftragStore.js'
|
||||
import {
|
||||
aendereEntwurf, erinnereAuftrag, ladeAuftrag, ladeGespeichertesPdf, ladeKunde, legeEntwurfAn, loescheEntwurf, neueFassung,
|
||||
ordneProjektZu, sendeAuftrag, storniereAuftrag, vorschauPdf,
|
||||
} from '../../services/auftraege/auftraege.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireAppwriteStaff)
|
||||
|
||||
async function ladeKundeMitSchalter(kundenId) {
|
||||
const kunde = await ladeKunde(kundenId)
|
||||
if (!auftraegeAktiv(kunde)) throw new AuftragError(404, SCHALTER_AUS_MELDUNG)
|
||||
return kunde
|
||||
}
|
||||
|
||||
async function ladeMitSchalter(id) {
|
||||
const auftrag = await ladeAuftrag(id)
|
||||
await ladeKundeMitSchalter(auftrag.kundenId)
|
||||
return auftrag
|
||||
}
|
||||
|
||||
const vonAdmin = (req) => req.appwriteUser?.email
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const kundenId = req.query.kunde
|
||||
if (typeof kundenId !== 'string' || kundenId === '') return res.status(400).json({ error: 'Kunde fehlt.' })
|
||||
await ladeKundeMitSchalter(kundenId)
|
||||
return res.json({ auftraege: (await listAuftraege({ kundenId })).map(auftragFuerTeam) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigungen laden', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const kundenId = req.body?.kundenId
|
||||
if (typeof kundenId === 'string' && kundenId !== '') await ladeKundeMitSchalter(kundenId)
|
||||
return res.json({ auftrag: auftragFuerTeam(await legeEntwurfAn(req.body, { von: vonAdmin(req) })) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung anlegen', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
return res.json({ auftrag: auftragFuerTeam(await ladeMitSchalter(req.params.id)) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung laden', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.patch('/:id', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
return res.json({ auftrag: auftragFuerTeam(await aendereEntwurf(req.params.id, req.body ?? {})) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung ändern', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
await loescheEntwurf(req.params.id)
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung löschen', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const auftrag = await ladeMitSchalter(req.params.id)
|
||||
const entwurf = auftrag.status === 'entwurf'
|
||||
const buffer = entwurf ? await vorschauPdf(auftrag.id) : await ladeGespeichertesPdf(auftrag)
|
||||
const name = entwurf ? 'Auftragsbestaetigung-Entwurf.pdf' : /^AB-\d{4}-\d{4}$/.test(auftrag.nummer) ? `${auftrag.nummer}.pdf` : 'Auftragsbestaetigung.pdf'
|
||||
res.set({ 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="${name}"`, 'Cache-Control': 'no-store' })
|
||||
return res.send(buffer)
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung-PDF laden', err)
|
||||
}
|
||||
})
|
||||
|
||||
/** Antwort der Aktionen, die eine Mail auslösen: { auftrag, mailFehler }. */
|
||||
const mitMail = ({ auftrag, mailFehler }) => ({ auftrag: auftragFuerTeam(auftrag), mailFehler })
|
||||
|
||||
router.post('/:id/senden', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
return res.json(mitMail(await sendeAuftrag(req.params.id, { von: vonAdmin(req) })))
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung senden', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:id/erinnern', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
return res.json(mitMail(await erinnereAuftrag(req.params.id)))
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung erinnern', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:id/neue-fassung', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
return res.json({ auftrag: auftragFuerTeam(await neueFassung(req.params.id, { von: vonAdmin(req) })) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Neue Fassung anlegen', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:id/stornieren', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
return res.json({ auftrag: auftragFuerTeam(await storniereAuftrag(req.params.id)) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung stornieren', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:id/projekt', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
return res.json({ auftrag: auftragFuerTeam(await ordneProjektZu(req.params.id, req.body?.projektId)) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Projekt zuordnen', err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,577 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Nummernvergabe der Auftragsbestätigungen: AB-JJJJ-NNNN, lückenlos je Berliner Kalenderjahr (Übersicht V4, L3).
|
||||
* vergibNummer schreibt nichts und sperrt nicht selbst: der Aufrufer hält SPERRE_NUMMER (konstanten.js), und die Nummer
|
||||
* ist erst mit dem Speichern der AB vergeben. Eine Nummer, die schon in Gebrauch ist, wird übersprungen.
|
||||
*/
|
||||
import { berlinDate } from '../finance/dates.js'
|
||||
import { findAuftragByNummer, listAuftraege } from './auftragStore.js'
|
||||
|
||||
export function formatNummer(jahr, laufnummer) {
|
||||
if (!Number.isInteger(jahr) || jahr < 2000 || jahr > 9999 || !Number.isInteger(laufnummer) || laufnummer < 1 || laufnummer > 9999) {
|
||||
throw new RangeError('Ungültige Auftragsnummer.')
|
||||
}
|
||||
return `AB-${jahr}-${String(laufnummer).padStart(4, '0')}`
|
||||
}
|
||||
|
||||
export async function vergibNummer(jetzt = new Date()) {
|
||||
const jahr = Number(berlinDate(jetzt).slice(0, 4))
|
||||
const vorhandene = await listAuftraege({ jahr })
|
||||
let laufnummer = vorhandene.reduce((groesste, a) => Math.max(groesste, Number.isSafeInteger(a.laufnummer) ? a.laufnummer : 0), 0) + 1
|
||||
while (await findAuftragByNummer(formatNummer(jahr, laufnummer))) laufnummer += 1
|
||||
return { nummer: formatNummer(jahr, laufnummer), jahr, laufnummer }
|
||||
}
|
||||
Reference in new issue
Block a user