Projektkonto: Konten je Kunde (Projektwahl, Zeitquelle, Zeitwert je Tag, Ohne Projekt, Schalter, Stripe-Ausfall)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
10679ae726
commit
8032aa67c6
2 files changed
+416
-2
No files matched your search
@@ -1,8 +1,15 @@
|
||||
import { STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS } from './konstanten.js'
|
||||
import { abrechenbar } from './arbeitsblaetter.js'
|
||||
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 { listAuftraege } 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
|
||||
@@ -133,3 +140,120 @@ export async function ladeStripeRechnungen(projektRechnungen) {
|
||||
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 }
|
||||
}
|
||||
Reference in new issue
Block a user