Files
Webklar-Kundenbereich/server/services/auftraege/projektkonto.js
T
ClaudeandClaude Sonnet 5.5 c2245b6905 Kontrolle: Projektkonto und Rechnungsbau waehlen dieselbe Aufwands-AB
Gemeinsame Auswahl juengsteAufwandAb (gueltig, jueengste nach gesendetAm) im AB-Speicher.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:19:48 +00:00

324 lines
15 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 { config } from '../../config.js'
import { Query, getDocument, listDocuments } from '../appwriteAdmin.js'
import { getStripe } from '../stripeBilling.js'
import { berlinDate } from '../finance/dates.js'
import { listInvoices, mapInvoice } from '../finance/stripeFinance.js'
import { kindLabel } from '../projectKinds.js'
import { KUNDENARTEN, STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS } from './konstanten.js'
import { AuftragError } from './fehler.js'
import { auftraegeAktiv } from './schalter.js'
import { auftragFuerTeam } from './auftragSicht.js'
import { listAuftraege, juengsteAufwandAb } from './auftragStore.js'
import { listProjektRechnungen } from './projektRechnungenStore.js'
import { abgerechneteArbeitsblaetter, abrechenbar, ladeArbeitsblaetter, zeitquelleFuerProjekt } 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 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 = juengsteAufwandAb(gueltig)
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 }
}
// Einzellesen fehlender Rechnungen: wie die Leseanfragen in stripeFinance.js (15 s, eine Wiederholung); stripeFinance.js
// haelt seine Optionen privat, daher hier eine eigene Konstante
const LESE_OPTIONEN = { timeout: 15_000, maxNetworkRetries: 1 }
const EINZELN_PARALLEL = 10
// Format der Rechnungs-ID wie im Schema der Projektrechnungen (V1); die IDs des Stripe-Nachbaus (in_test_<n>) passen auch
const STRIPE_RECHNUNG = /^in_\w{1,33}$/
/** Eine Rechnung frisch von Stripe in mapInvoice-Form; null, wenn es sie nicht (mehr) gibt (404); andere Fehler fliegen. */
async function leseEinzeln(invoiceId) {
try {
const raw = await getStripe().invoices.retrieve(invoiceId, { expand: ['customer'] }, LESE_OPTIONEN)
return mapInvoice(raw, { today: berlinDate() })
} catch (err) {
if (err?.statusCode === 404 || err?.code === 'resource_missing') return null
throw err
}
}
/**
* Rechnungsdaten fuer eine Liste von Projektrechnungen: die Rechnungen der letzten 400 Tage aus listInvoices() (60-Sekunden-
* Zwischenspeicher); was dort fehlt (aelter, ueber 1000, frisch angelegt), wird einzeln gelesen, hoechstens 10 gleichzeitig.
* Scheitert Stripe (Liste oder Einzellesen ausser 404), ist ok false und alles leer; ins Protokoll kommen nur Art und Status.
*/
export async function ladeStripeRechnungen(projektRechnungen) {
try {
const alle = await listInvoices()
const nachId = new Map(alle.map((invoice) => [invoice.id, invoice]))
const fehlend = [...new Set(projektRechnungen.map((eintrag) => eintrag.invoiceId))].filter((id) => STRIPE_RECHNUNG.test(id) && !nachId.has(id))
for (let i = 0; i < fehlend.length; i += EINZELN_PARALLEL) {
const gelesen = await Promise.all(fehlend.slice(i, i + EINZELN_PARALLEL).map(leseEinzeln))
for (const invoice of gelesen) if (invoice) nachId.set(invoice.id, invoice)
}
return { ok: true, alle, nachId }
} catch (err) {
console.warn('[projektkonto] Stripe nicht lesbar:', err?.type || err?.name || 'Error', err?.statusCode ?? err?.status ?? '')
return { ok: false, alle: [], nachId: new Map() }
}
}
/** Dokument lesen; unbekannt -> AuftragError 404 "<was> nicht gefunden." */
async function ladeDokument(collection, id, was) {
try {
return await getDocument(collection, id)
} catch (err) {
if (err?.status === 404) throw new AuftragError(404, `${was} nicht gefunden.`)
throw err
}
}
const kundeAnsicht = (doc) => ({
id: doc.$id,
name: String(doc.companyName || '').trim() || String(doc.name || '').trim(),
kundenart: KUNDENARTEN.includes(doc.kundenart) ? doc.kundenart : '',
})
/** Ticket-Dokumente (fuer die Ticketnummer woid) zu den IDs, in Bloecken zu 100. */
async function ladeTickets(ids) {
const nachId = new Map()
for (let i = 0; i < ids.length; i += 100) {
const seite = await listDocuments(config.collections.workorders, [Query.equal('$id', ids.slice(i, i + 100)), Query.limit(100)])
for (const ticket of seite) nachId.set(ticket.$id, ticket)
}
return nachId
}
/**
* Tickets, Arbeitsblaetter (je Ticket gruppiert) und das Abgerechnete des Kunden fuer alle Zeitquellen auf einmal: je
* Anfrage einmal laden, egal wie viele Projekte. Ohne Ticket wird nichts abgefragt.
*/
async function ladeZeitDaten(kundenId, ticketIds) {
if (ticketIds.length === 0) return { tickets: new Map(), nachTicket: new Map(), abgerechnet: new Map() }
const [tickets, blaetter, abgerechnet] = await Promise.all([
ladeTickets(ticketIds),
ladeArbeitsblaetter(ticketIds),
abgerechneteArbeitsblaetter(kundenId),
])
const nachTicket = new Map()
for (const blatt of blaetter) {
if (!nachTicket.has(blatt.ticketId)) nachTicket.set(blatt.ticketId, [])
nachTicket.get(blatt.ticketId).push(blatt)
}
return { tickets, nachTicket, abgerechnet }
}
/** Zeitquelle des Projekts ohne Doppelte (Vereinigung der gueltigen ABs, sonst das Projektticket; siehe arbeitsblaetter.js). */
const zeitquelleVon = (projekt, abs) => [...new Set(zeitquelleFuerProjekt(projekt, abs))]
/**
* Ein Projektkonto (V3). abs: ABs des Projekts; prs: aktive Projektrechnungen des Projekts; stripeDaten: Ergebnis von
* ladeStripeRechnungen; zeit: Ergebnis von ladeZeitDaten. Liefert zusaetzlich die Rechnungen und Blaetter fuer das Detail.
*/
function baueKonto(projekt, { abs, zeitquelle, prs, stripeDaten, zeit }) {
const rechnungen = prs
.map((eintrag) => ({ art: eintrag.art, auftragId: eintrag.auftragId, invoice: stripeDaten.nachId.get(eintrag.invoiceId) }))
.filter((eintrag) => eintrag.invoice)
const blaetter = zeitquelle.flatMap((id) => zeit.nachTicket.get(id) ?? [])
return {
konto: {
projektId: projekt.$id,
name: projekt.projectName || '',
projektTyp: kindLabel(projekt.kind),
tickets: zeitquelle.map((id) => ({ id, woid: zeit.tickets.get(id)?.woid || '' })),
zeitquelle,
...rechneKonto({ auftraege: abs, rechnungen, blaetter, abgerechnet: zeit.abgerechnet, stripeOk: stripeDaten.ok }),
},
rechnungen,
blaetter,
}
}
/**
* Projektkonten eines Kunden (V4 "L1"): je sichtbarem Projekt ein Konto, dazu die Zeile "Ohne Projekt". Sichtbar sind alle
* Projekte des Kunden ausser Vorlagen; archivierte nur mit Geldbezug (aktive Projektrechnung oder AB mit Status aus
* VEREINBARTE_STATUS). Ist der AB-Schalter fuer den Kunden aus, werden ABs nicht gelesen (Vereinbart: keine AB).
*/
export async function projektkontenFuerKunde(kundenId) {
const kundeDoc = await ladeDokument(config.collections.customers, kundenId, 'Kunde')
const aktiv = auftraegeAktiv(kundeDoc)
const [projekte, auftraege, prs] = await Promise.all([
listDocuments(config.collections.websiteProjects, [Query.equal('customerId', kundenId), Query.orderDesc('$createdAt'), Query.limit(1000)]),
aktiv ? listAuftraege({ kundenId }) : [],
listProjektRechnungen({ kundenId }),
])
const stripeDaten = await ladeStripeRechnungen(prs)
const mitGeld = new Set([
...prs.map((eintrag) => eintrag.projektId),
...auftraege.filter((auftrag) => VEREINBARTE_STATUS.includes(auftrag.status)).map((auftrag) => auftrag.projektId),
])
const sichtbar = projekte.filter((projekt) => projekt.isTemplate !== true && (!projekt.archived || mitGeld.has(projekt.$id)))
const eintraege = sichtbar.map((projekt) => {
const abs = auftraege.filter((auftrag) => auftrag.projektId === projekt.$id)
return { projekt, abs, zeitquelle: zeitquelleVon(projekt, abs) }
})
const zeit = await ladeZeitDaten(kundenId, [...new Set(eintraege.flatMap((eintrag) => eintrag.zeitquelle))])
const konten = eintraege.map(
({ projekt, abs, zeitquelle }) =>
baueKonto(projekt, { abs, zeitquelle, prs: prs.filter((eintrag) => eintrag.projektId === projekt.$id), stripeDaten, zeit }).konto
)
// Ohne Projekt: finalisierte Projekt-Rechnungen des Kunden, die kein angezeigtes Konto beansprucht (keine Abo-Rechnungen)
const kandidaten = new Map()
for (const invoice of stripeDaten.alle) {
if (invoice.webklarCustomerId === kundenId && invoice.source === 'projekt') kandidaten.set(invoice.id, invoice)
}
for (const eintrag of prs) {
const invoice = stripeDaten.nachId.get(eintrag.invoiceId)
if (invoice) kandidaten.set(invoice.id, invoice)
}
const angezeigt = new Set(sichtbar.map((projekt) => projekt.$id))
const zugeordnet = new Set(prs.filter((eintrag) => angezeigt.has(eintrag.projektId)).map((eintrag) => eintrag.invoiceId))
const ohneProjekt = rechneOhneProjekt([...kandidaten.values()].filter((invoice) => !zugeordnet.has(invoice.id)))
return { auftraegeAktiv: aktiv, kunde: kundeAnsicht(kundeDoc), konten, ohneProjekt, stripeOk: stripeDaten.ok }
}
const neuesteZuerst = (a, b) => (b.createdDate ?? '').localeCompare(a.createdDate ?? '') || b.number.localeCompare(a.number) || b.id.localeCompare(a.id)
/**
* Ein Projektkonto mit seinen ABs, Rechnungen (aktive Projektrechnungen, soweit Stripe sie kennt) und den noch nicht
* abgerechneten Arbeitsblaettern (V4 "L1"). Bei Stripe-Ausfall: keine Rechnungen, stripeOk false.
*/
export async function projektkontoDetail(projektId) {
const projekt = await ladeDokument(config.collections.websiteProjects, projektId, 'Projekt')
if (!projekt.customerId) throw new AuftragError(404, 'Projekt nicht gefunden.')
const kundenId = projekt.customerId
const kundeDoc = await ladeDokument(config.collections.customers, kundenId, 'Kunde')
const aktiv = auftraegeAktiv(kundeDoc)
const [abs, prs] = await Promise.all([
aktiv ? listAuftraege({ kundenId, projektId }) : [],
listProjektRechnungen({ kundenId, projektId }),
])
const stripeDaten = await ladeStripeRechnungen(prs)
const zeitquelle = zeitquelleVon(projekt, abs)
const zeit = await ladeZeitDaten(kundenId, zeitquelle)
const { konto, rechnungen, blaetter } = baueKonto(projekt, { abs, zeitquelle, prs, stripeDaten, zeit })
return {
konto,
auftraege: abs.map((auftrag) => auftragFuerTeam(auftrag)),
rechnungen: rechnungen
.map(({ art, auftragId, invoice }) => ({
id: invoice.id,
number: invoice.number,
status: invoice.status,
createdDate: invoice.createdDate,
totalCents: invoice.totalCents,
paidCents: invoice.paidCents,
art,
auftragId,
}))
.sort(neuesteZuerst),
offeneArbeitsblaetter: blaetter
.filter((blatt) => abrechenbar(blatt) && !zeit.abgerechnet.has(blatt.id))
.sort((a, b) => a.datum.localeCompare(b.datum) || a.id.localeCompare(b.id)),
stripeOk: stripeDaten.ok,
}
}
/**
* Abzeichen "abgerechnet" an den Arbeitsblaettern eines Tickets (alle Mitarbeiter, ohne Betraege): { Blatt-ID: Rechnungsnummer
* oder 'Entwurf' }. Die Blatt-IDs kommen aus dem Ticket, die Zuordnung aus den aktiven Projektrechnungen seines Kunden.
*/
export async function abgerechnetFuerTicket(ticketId) {
let ticket
try {
ticket = await getDocument(config.collections.workorders, ticketId)
} catch (err) {
if (err?.status === 404) return {}
throw err
}
if (!ticket.customerId) return {}
const abgerechnet = await abgerechneteArbeitsblaetter(ticket.customerId)
if (abgerechnet.size === 0) return {}
const ergebnis = {}
for (const blatt of await ladeArbeitsblaetter([ticketId])) {
const eintrag = abgerechnet.get(blatt.id)
if (eintrag) ergebnis[blatt.id] = eintrag.rechnungsnummer || 'Entwurf'
}
return ergebnis
}