Rechnungen: Aufwandsrechnung bauen (Auswahl, Satz aus der AB, Doppelabrechnung, 20 Zeilen)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:14:48 +00:00
1 parent b78ca971af
commit 11531cacb6
2 files changed
+329 -3

No files matched your search

+189 -3
View File
@@ -4,10 +4,22 @@
* Selbstheilung veralteter Projektdatensaetze, siehe unten). Importiert nichts aus stripeFinance.js (Importzyklus).
*/
import { config } from '../../config.js'
import { getDocument } from '../appwriteAdmin.js'
import { getDocument, listAllDocuments, Query } from '../appwriteAdmin.js'
import { getStripe } from '../stripeBilling.js'
import { withLocks } from '../keyedLock.js'
import { AuftragError } from './fehler.js'
import { RECHNUNGS_ARTEN, LIMITS } from './konstanten.js'
import { festePositionen, pruefePositionen, kuerzen, auftragDatum, SCHLUSS_GRENZEN } from './positionen.js'
import {
RECHNUNGS_ARTEN, LIMITS, VEREINBARTE_STATUS, STUNDENSATZ_STANDARD_CENTS, SCHALTER_AUS_MELDUNG, sperreAuftrag,
} from './konstanten.js'
import {
festePositionen, zusatzPositionen, pruefePositionen, kuerzen, auftragDatum, aufwandPositionen, leistungszeitraumText, SCHLUSS_GRENZEN,
} from './positionen.js'
import { auftraegeAktiv } from './schalter.js'
import { getAuftrag, listAuftraege, updateAuftrag } from './auftragStore.js'
import { updateProjektRechnung } from './projektRechnungenStore.js'
import {
ladeArbeitsblaetter, ladeArbeitsblaetterNachIds, abrechenbar, abgerechneteArbeitsblaetter, offeneArbeitsblaetter, zeitquelleFuerProjekt,
} from './arbeitsblaetter.js'
import { formatDe } from '../finance/dates.js'
// Appwrite-Dokument-ID
@@ -43,6 +55,82 @@ async function ladeProjekt(kundenId, projektId) {
return { id: projekt.$id, name: projekt.projectName || projekt.subdomain || 'Projekt', ticketId: projekt.ticketId || '' }
}
/** AB des Kunden; fremde und unbekannte sind nicht zu unterscheiden (404). */
async function ladeAuftrag(kundenId, auftragId) {
if (typeof auftragId !== 'string' || !KENNUNG.test(auftragId)) throw new AuftragError(404, 'Auftrag nicht gefunden.')
const auftrag = await getAuftrag(auftragId)
if (!auftrag || auftrag.kundenId !== kundenId) throw new AuftragError(404, 'Auftrag nicht gefunden.')
return auftrag
}
/**
* Buchung nach einem Stripe-Erfolg: scheitert sie, wird nur protokolliert (eine festgeschriebene Rechnung scheitert nicht an
* Appwrite). Fehler mit Status 404 sind still. Wirft nie.
*/
export async function buche(was, fn) {
try {
await fn()
} catch (err) {
if (err?.status === 404) return
console.error(`[finanzen] ${was} fehlgeschlagen:`, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '')
}
}
/** Schlussrechnung weg (storniert, geloescht): die AB geht von abgerechnet zurueck. Unter der Sperre der AB. */
export async function setzeAbZurueck(auftragId) {
await withLocks(sperreAuftrag(auftragId), async () => {
const ab = await getAuftrag(auftragId)
if (ab && ab.status === 'abgerechnet') await updateAuftrag(ab.id, { status: ab.bestaetigtAm ? 'bestaetigt' : 'versendet' })
})
}
/** Projektdatensatz inaktiv setzen (Blaetter wieder frei); bei einer Schlussrechnung geht die AB zurueck. */
export async function gibProjektRechnungFrei(invoiceId) {
const doc = await updateProjektRechnung(invoiceId, { aktiv: false })
if (doc.art === 'schluss' && doc.auftragId) await setzeAbZurueck(doc.auftragId)
return doc
}
const fehltBeiStripe = (err) => err?.statusCode === 404 || err?.code === 'resource_missing'
/**
* Selbstheilung: belegt ist Map<blattId, { invoiceId, rechnungsnummer }>. Je Rechnung bei Stripe nachsehen: storniert oder
* geloescht -> Datensatz inaktiv, die Belegung gilt nicht; sonst gilt sie (mit der Nummer von Stripe, fehlende Nummer wird
* im Datensatz nachgetragen). Rueckgabe: die noch geltenden Belegungen.
*/
async function pruefeBelegungen(belegt) {
const urteile = new Map()
for (const { invoiceId } of belegt.values()) {
if (urteile.has(invoiceId)) continue
let roh
try {
roh = await getStripe().invoices.retrieve(invoiceId)
} catch (err) {
if (!fehltBeiStripe(err)) throw err
roh = null
}
if (!roh || roh.status === 'void') {
await buche('Rechnung freigeben', () => gibProjektRechnungFrei(invoiceId))
urteile.set(invoiceId, null)
continue
}
const nummer = roh.status === 'draft' ? '' : roh.number || ''
urteile.set(invoiceId, nummer)
}
const gueltig = new Map()
for (const [blattId, belegung] of belegt) {
const nummer = urteile.get(belegung.invoiceId)
if (nummer === null) continue
if (nummer && nummer !== belegung.rechnungsnummer && !gueltig.has(`nachgetragen:${belegung.invoiceId}`)) {
gueltig.set(`nachgetragen:${belegung.invoiceId}`, true)
await buche('Rechnungsnummer nachtragen', () => updateProjektRechnung(belegung.invoiceId, { rechnungsnummer: nummer }))
}
gueltig.set(blattId, { invoiceId: belegung.invoiceId, rechnungsnummer: nummer })
}
for (const schluessel of [...gueltig.keys()]) if (schluessel.startsWith('nachgetragen:')) gueltig.delete(schluessel)
return gueltig
}
function pruefeLeistungszeitraum(wert) {
if (wert === undefined || wert === null) return ''
if (typeof wert !== 'string' || wert.trim().length > LIMITS.maxLeistungszeitraum) {
@@ -94,5 +182,103 @@ export async function baueRechnung(eingabe) {
const projekt = await ladeProjekt(kunde.$id, eingabe.projektId)
return schliesseAb({ ...basis, projekt, positionen: festePositionen(eingabe.items) })
}
if (art === 'aufwand') return baueAufwand(basis, eingabe)
throw new AuftragError(501, 'Rechnungsart noch nicht umgesetzt.')
}
const MAX_BLAETTER = 400
const KEIN_BLATT = 'Arbeitsblatt nicht gefunden.'
function pruefeZeilentexte(wert) {
if (wert === undefined || wert === null) return {}
if (typeof wert !== 'object' || Array.isArray(wert) || Object.values(wert).some((text) => typeof text !== 'string')) {
throw new AuftragError(400, 'Die Zeilentexte müssen ein Objekt mit Texten sein.')
}
return wert
}
/** Auftrag der Aufwandsrechnung: ausdruecklich genannt (muss passen) oder die juengste gueltige Aufwands-AB des Projekts. */
async function ladeAufwandAuftrag(kunde, projekt, auftragId) {
if (auftragId !== undefined && auftragId !== null && auftragId !== '') {
if (!auftraegeAktiv(kunde)) throw new AuftragError(400, SCHALTER_AUS_MELDUNG)
const auftrag = await ladeAuftrag(kunde.$id, auftragId)
if (auftrag.art !== 'aufwand') throw new AuftragError(400, 'Eine Aufwandsrechnung passt nur zu einer Aufwands-Auftragsbestätigung.')
if (!VEREINBARTE_STATUS.includes(auftrag.status)) throw new AuftragError(400, 'Die Auftragsbestätigung ist nicht mehr gültig.')
if (auftrag.projektId && auftrag.projektId !== projekt.id) throw new AuftragError(400, 'Der Auftrag gehört nicht zu diesem Projekt.')
return auftrag
}
if (!auftraegeAktiv(kunde)) return null
const liste = await listAuftraege({ kundenId: kunde.$id, projektId: projekt.id })
return liste.find((ab) => ab.art === 'aufwand' && VEREINBARTE_STATUS.includes(ab.status)) ?? null
}
/** Ausdrueckliche Auswahl der Blaetter (Pruefung, Zugehoerigkeit zum Kunden, Doppelabrechnung mit Selbstheilung). */
async function waehleBlaetter(kunde, ids, eigeneTickets) {
if (!Array.isArray(ids) || ids.some((id) => typeof id !== 'string')) {
throw new AuftragError(400, 'Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.')
}
const eindeutig = [...new Set(ids)]
if (!eindeutig.length) throw new AuftragError(400, 'Keine Arbeitsblätter ausgewählt.')
if (eindeutig.length > MAX_BLAETTER) throw new AuftragError(400, `Zu viele Arbeitsblätter ausgewählt (höchstens ${MAX_BLAETTER}).`)
if (eindeutig.some((id) => !KENNUNG.test(id))) throw new AuftragError(400, KEIN_BLATT)
const blaetter = await ladeArbeitsblaetterNachIds(eindeutig)
if (blaetter.length !== eindeutig.length || blaetter.some((b) => !eigeneTickets.has(b.ticketId))) throw new AuftragError(400, KEIN_BLATT)
if (blaetter.some((b) => !abrechenbar(b))) {
throw new AuftragError(400, 'Mindestens ein Arbeitsblatt ist nicht abrechenbar (Abo-Zeit, Kommentar oder ohne Zeit).')
}
const belegtAlle = await abgerechneteArbeitsblaetter(kunde.$id)
const belegt = new Map(eindeutig.filter((id) => belegtAlle.has(id)).map((id) => [id, belegtAlle.get(id)]))
if (belegt.size) {
const gueltig = await pruefeBelegungen(belegt)
for (const id of eindeutig) {
const b = gueltig.get(id)
if (b) throw new AuftragError(409, `Arbeitsblatt bereits abgerechnet (${b.rechnungsnummer ? `Rechnung ${b.rechnungsnummer}` : 'Entwurf'}).`)
}
}
return blaetter
}
/** Standardauswahl: alle offenen Blaetter der Zeitquelle des Projekts; ist nichts offen, vorher Selbstheilung. */
async function standardBlaetter(kunde, projekt, eigeneTickets) {
const abs = auftraegeAktiv(kunde) ? await listAuftraege({ kundenId: kunde.$id, projektId: projekt.id }) : []
const zeitquelle = zeitquelleFuerProjekt(projekt, abs).filter((id) => eigeneTickets.has(id))
const lade = () => (zeitquelle.length ? offeneArbeitsblaetter(zeitquelle, kunde.$id) : [])
let offene = await lade()
if (!offene.length && zeitquelle.length) {
const belegtAlle = await abgerechneteArbeitsblaetter(kunde.$id)
const belegt = new Map()
for (const blatt of await ladeArbeitsblaetter(zeitquelle)) {
if (abrechenbar(blatt) && belegtAlle.has(blatt.id)) belegt.set(blatt.id, belegtAlle.get(blatt.id))
}
if (belegt.size) {
const gueltig = await pruefeBelegungen(belegt)
if (gueltig.size < belegt.size) offene = await lade()
}
}
if (!offene.length) throw new AuftragError(409, 'Keine offenen Arbeitsblätter zum Abrechnen.')
if (offene.length > MAX_BLAETTER) throw new AuftragError(400, `Zu viele Arbeitsblätter ausgewählt (höchstens ${MAX_BLAETTER}).`)
return offene
}
async function baueAufwand(basis, eingabe) {
const { kunde } = basis
const projekt = await ladeProjekt(kunde.$id, eingabe.projektId)
const auftrag = await ladeAufwandAuftrag(kunde, projekt, eingabe.auftragId)
const satzCents = auftrag?.stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS
const zusatz = zusatzPositionen(eingabe.items)
const zeilentexte = pruefeZeilentexte(eingabe.zeilentexte)
const eigeneTickets = new Set(
(await listAllDocuments(config.collections.workorders, [Query.equal('customerId', kunde.$id)])).map((d) => d.$id)
)
const ausdruecklich = eingabe.arbeitsblattIds !== undefined && eingabe.arbeitsblattIds !== null
const blaetter = ausdruecklich ? await waehleBlaetter(kunde, eingabe.arbeitsblattIds, eigeneTickets) : await standardBlaetter(kunde, projekt, eigeneTickets)
const { positionen: tage, von, bis } = aufwandPositionen(blaetter, { satzCents, zeilentexte, maxZeilen: LIMITS.maxPositionen - zusatz.length })
return schliesseAb({
...basis,
projekt,
auftrag,
positionen: [...tage, ...zusatz],
leistungszeitraum: basis.leistungszeitraum || leistungszeitraumText(von, bis),
arbeitsblattIds: blaetter.map((b) => b.id),
})
}