Gemeinsame Auswahl juengsteAufwandAb (gueltig, jueengste nach gesendetAm) im AB-Speicher. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
324 lines
15 KiB
JavaScript
324 lines
15 KiB
JavaScript
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
|
||
}
|