Files
Webklar-Kundenbereich/server/services/auftraege/projektkonto.js
T

94 lines
4.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS } from './konstanten.js'
import { abrechenbar } from './arbeitsblaetter.js'
/**
* Projektkonto (Spec 2026-10-06, Abschnitt 4; Uebersicht V4 "L1 - Projektkonto"): reine Rechenregeln und die Ladefunktionen
* dazu. Alles hier liest nur, weder Appwrite noch Stripe werden geschrieben.
*/
// Rechnungsstatus laut mapInvoice (stripeFinance.js), die als "berechnet" zaehlen; Entwurf und storniert nie
const FINALISIERT = new Set(['offen', 'ueberfaellig', 'bezahlt', 'uneinbringlich'])
const summe = (liste, feld) => liste.reduce((s, eintrag) => s + eintrag[feld], 0)
const zeitpunkt = (auftrag) => new Date(auftrag.gesendetAm || auftrag.erstelltAm || 0).getTime() || 0
const juengsteZuerst = (a, b) => zeitpunkt(b) - zeitpunkt(a) || String(b.id).localeCompare(String(a.id))
const zeitWert = (minuten, satzCents) => Math.round((minuten * satzCents) / 60)
// Wert der Zeit = Summe der je Berliner Tag gerundeten Werte (Karte: Tag -> Minuten), genau wie die Zeilen einer Aufwandsrechnung
// (aufwandPositionen in L2: eine Zeile je Tag mit Math.round(Minuten × Satz / 60)); so laufen Konto und Rechnung nicht um Cent auseinander
const tageswert = (minutenJeTag, satzCents) => [...minutenJeTag.values()].reduce((s, minuten) => s + zeitWert(minuten, satzCents), 0)
const addiere = (karte, tag, minuten) => karte.set(tag, (karte.get(tag) ?? 0) + minuten)
/**
* Die Zahlen eines Projekts. auftraege: alle ABs des Projekts (jeder Status); rechnungen: [{ art, auftragId, invoice }] mit
* der Rechnung in mapInvoice-Form (nur aktive Projektrechnungen, deren Rechnung gefunden wurde); blaetter: Arbeitsblaetter der
* Zeitquelle (datum = Berliner Tag); abgerechnet: Map oder Set der Blatt-IDs aus aktiven Projektrechnungen (auch Entwuerfe);
* stripeOk false = die Rechnungen sind unbekannt (berechnet und bezahlt 0, offen null).
*/
export function rechneKonto({ auftraege = [], rechnungen = [], blaetter = [], abgerechnet = new Map(), stripeOk = true } = {}) {
const gueltig = auftraege.filter((auftrag) => VEREINBARTE_STATUS.includes(auftrag.status))
const festpreise = gueltig.filter((auftrag) => auftrag.art === 'festpreis')
const festpreisIds = new Set(festpreise.map((auftrag) => auftrag.id))
const festpreisCents = festpreise.length ? festpreise.reduce((s, auftrag) => s + (Number(auftrag.summeCents) || 0), 0) : null
const aufwandAb = gueltig.filter((auftrag) => auftrag.art === 'aufwand').sort(juengsteZuerst)[0] ?? null
const aufwand = aufwandAb
? {
stundensatzCents: aufwandAb.stundensatzCents > 0 ? aufwandAb.stundensatzCents : STUNDENSATZ_STANDARD_CENTS,
schaetzungMinuten: aufwandAb.schaetzungMinuten ?? null,
}
: null
const satzCents = aufwand ? aufwand.stundensatzCents : STUNDENSATZ_STANDARD_CENTS
let berechnetCents = 0
let bezahltCents = 0
let aufFestpreisCents = 0
if (stripeOk) {
for (const { invoice, auftragId } of rechnungen) {
if (!FINALISIERT.has(invoice.status)) continue
berechnetCents += invoice.totalCents
bezahltCents += invoice.paidCents
// nur Rechnungen zu einer als vereinbart zaehlenden Festpreis-AB mindern das Offene
if (festpreisIds.has(auftragId)) aufFestpreisCents += invoice.totalCents
}
}
const offenCents = festpreisCents === null || !stripeOk ? null : Math.max(0, festpreisCents - aufFestpreisCents)
let minuten = 0
let aboMinuten = 0
let offeneMinuten = 0
const minutenJeTag = new Map() // alle abrechenbaren Blaetter (auch schon berechnete)
const offeneMinutenJeTag = new Map() // nur die offenen; ein Blatt ohne Datum (nur theoretisch) bildet einen eigenen Tag ''
for (const blatt of blaetter) {
if (abrechenbar(blatt)) {
minuten += blatt.minuten
addiere(minutenJeTag, blatt.datum, blatt.minuten)
if (!abgerechnet.has(blatt.id)) {
offeneMinuten += blatt.minuten
addiere(offeneMinutenJeTag, blatt.datum, blatt.minuten)
}
} else if (blatt.aboZeit && !blatt.kommentar) {
aboMinuten += blatt.minuten
}
}
return {
vereinbart: { festpreisCents, aufwand },
berechnetCents,
bezahltCents,
offenCents,
zeit: {
minuten,
aboMinuten,
offeneMinuten,
satzCents,
wertCents: tageswert(minutenJeTag, satzCents),
offenerWertCents: tageswert(offeneMinutenJeTag, satzCents),
},
}
}
/** Zeile "Ohne Projekt": Rechnungen in mapInvoice-Form; gezaehlt werden nur finalisierte. */
export function rechneOhneProjekt(rechnungen) {
const gezaehlt = rechnungen.filter((invoice) => FINALISIERT.has(invoice.status))
return { berechnetCents: summe(gezaehlt, 'totalCents'), bezahltCents: summe(gezaehlt, 'paidCents'), anzahl: gezaehlt.length }
}