Rechnungsdialog: Logik für Projektwahl, Rechnungsarten, Auftragsbestätigungen und Arbeitsblätter mit Tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:32:20 +00:00
1 parent cc95018083
commit e37c0a664e
2 files changed
+421

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
// Dialoglogik der Rechnung „Rechnung schreiben“ (rein, ohne React und Appwrite; unter Node testbar).
// Aufgaben: Projektwahl, waehlbare Rechnungsarten, gültige Auftragsbestätigungen (AB), Vorgaben für Anzahlung und Standardart,
// Arbeitsblätter (Auswahl, Summen, Umwandlung aus Appwrite-Dokumenten) und die Ticketliste für „Weiteres Ticket hinzunehmen“.
import { euro, dateDe, ymdBerlin } from './financeFormat'
import { worksheetMinutes } from './support'
import { germanDateToIso } from './ticketForm'
import { ticketsText } from './projektkontoView'
export const ART_OPTION_LABEL = {
frei: 'Freie Positionen',
anzahlung: 'Anzahlung',
schluss: 'Schlussrechnung',
aufwand: 'Nach Aufwand',
festbetrag: 'Festbetrag',
}
export const AB_HINWEIS = 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.'
export const ENTWURF_HINWEIS = 'Zu dieser Auftragsbestätigung gibt es noch einen Entwurf. Erst senden oder löschen.'
export const OHNE_PROJEKT = 'Ohne Projekt'
const GUELTIG = ['versendet', 'bestaetigt']
const FINALISIERT = ['offen', 'ueberfaellig', 'bezahlt', 'uneinbringlich']
// --- Projektwahl --------------------------------------------------------------------------------------------
/** Vorauswahl der Aufrufer: undefined = keine (null), null = „Ohne Projekt“ (''), Text = dieses Projekt. */
export function startProjekt(propProjektId) {
if (propProjektId === undefined) return null
if (propProjektId === null) return ''
return String(propProjektId)
}
/** Auswahlliste: Projekte mit Ticketnummer, am Ende „Ohne Projekt“. */
export function projektOptionen(konten) {
const liste = Array.isArray(konten) ? konten : []
const optionen = liste
.filter((k) => k && String(k.projektId ?? '') !== '')
.map((k) => {
const name = String(k.name ?? '').trim() || 'Projekt'
const tickets = ticketsText(k.tickets)
return { value: String(k.projektId), label: tickets ? `${name} · ${tickets}` : name }
})
optionen.push({ value: '', label: OHNE_PROJEKT })
return optionen
}
/** null = noch zu wählen, '' = Ohne Projekt. Ein Kunde ohne Projekte hat nur „Ohne Projekt“. */
export function wirksamesProjekt(wahl, konten) {
if (wahl === '') return ''
if (typeof wahl === 'string' && wahl) {
if (!Array.isArray(konten)) return wahl
return konten.some((k) => k?.projektId === wahl) ? wahl : null
}
return Array.isArray(konten) && konten.length === 0 ? '' : null
}
// --- Auftragsbestätigungen ----------------------------------------------------------------------------------
function neuesteZuerst(liste) {
const zeit = (a) => {
const t = Date.parse(a?.gesendetAm)
return Number.isFinite(t) ? t : 0
}
return [...liste].sort((a, b) => zeit(b) - zeit(a) || String(b?.nummer ?? '').localeCompare(String(a?.nummer ?? '')))
}
function gueltige(auftraege, art) {
if (!Array.isArray(auftraege)) return []
return neuesteZuerst(auftraege.filter((a) => a && a.art === art && GUELTIG.includes(a.status)))
}
/** Gültige Festpreis-ABs (versendet oder bestätigt), neueste zuerst. */
export function gueltigeFestpreisAbs(auftraege) {
return gueltige(auftraege, 'festpreis')
}
/** Jüngste gültige Aufwands-AB oder null. */
export function aufwandAuftrag(auftraege) {
return gueltige(auftraege, 'aufwand')[0] ?? null
}
/** 'AB-2026-0002 vom 06.10.2026 · 800,00 €' bzw. '… · nach Aufwand'. */
export function abLabel(auftrag) {
let text = String(auftrag?.nummer ?? '')
const datum = dateDe(auftrag?.gesendetAm)
if (datum !== '–') text += ` vom ${datum}`
text += auftrag?.art === 'aufwand' ? ' · nach Aufwand' : ` · ${euro(auftrag?.summeCents)}`
return text
}
/** Summe der finalisierten Rechnungen zu dieser AB (Cent). */
export function berechnetFuerAuftrag(rechnungen, auftragId) {
if (!Array.isArray(rechnungen)) return 0
return rechnungen
.filter((r) => r && r.auftragId === auftragId && FINALISIERT.includes(r.status))
.reduce((summe, r) => summe + (Number(r.totalCents) || 0), 0)
}
/** Gibt es zu dieser AB einen noch nicht gesendeten Entwurf? */
export function hatEntwurfZurAuftrag(rechnungen, auftragId) {
return Array.isArray(rechnungen) && rechnungen.some((r) => r && r.auftragId === auftragId && r.status === 'entwurf')
}
// --- Rechnungsarten -----------------------------------------------------------------------------------------
/** Wählbare Arten mit Sperrgrund: [{ key, label, enabled, hint }]. */
export function artOptionen({ projektId, auftraege, rechnungen, auftraegeAktiv }) {
if (projektId === null || projektId === undefined) return []
const option = (key, enabled, hint = '') => ({ key, label: ART_OPTION_LABEL[key], enabled, hint: enabled ? '' : hint })
if (projektId === '') return [option('frei', true)]
const abs = auftraegeAktiv ? gueltigeFestpreisAbs(auftraege) : []
const freie = abs.filter((a) => !hatEntwurfZurAuftrag(rechnungen, a.id))
const schlussHinweis = abs.length === 0 ? AB_HINWEIS : ENTWURF_HINWEIS
return [
option('anzahlung', abs.length > 0, AB_HINWEIS),
option('schluss', freie.length > 0, schlussHinweis),
option('aufwand', true),
option('festbetrag', true),
]
}
/** Vorbelegte Art: Anzahlung, Schlussrechnung, Aufwand oder Festbetrag je nach Lage ('' ohne Wahl). */
export function standardArt(optionen, { auftraege, rechnungen, offeneMinuten }) {
const liste = Array.isArray(optionen) ? optionen : []
if (liste.length === 0) return ''
if (liste.length === 1 && liste[0].key === 'frei') return 'frei'
const waehlbar = (key) => liste.some((o) => o.key === key && o.enabled)
const jung = gueltigeFestpreisAbs(auftraege)[0]
if (
waehlbar('anzahlung') &&
jung?.zahlungsplan?.art === 'anzahlung' &&
berechnetFuerAuftrag(rechnungen, jung.id) === 0 &&
!hatEntwurfZurAuftrag(rechnungen, jung.id)
) {
return 'anzahlung'
}
if (waehlbar('schluss')) return 'schluss'
return Number(offeneMinuten) > 0 ? 'aufwand' : 'festbetrag'
}
/** Vorbelegte AB: jüngste passende; bei Schlussrechnung ohne offenen Entwurf; sonst ''. */
export function standardAuftrag(art, auftraege, rechnungen = []) {
const abs = gueltigeFestpreisAbs(auftraege)
if (art === 'anzahlung') return abs[0]?.id ?? ''
if (art === 'schluss') return abs.find((a) => !hatEntwurfZurAuftrag(rechnungen, a.id))?.id ?? ''
return ''
}
/** Vorgabe der Anzahlung: Prozent aus dem Zahlungsplan, sonst 50. */
export function anzahlungStandard(auftrag) {
const plan = auftrag?.zahlungsplan
const prozent = plan?.art === 'anzahlung' && Number(plan.prozent) > 0 ? Number(plan.prozent) : 50
return { modus: 'prozent', wert: String(prozent).replace('.', ','), bezeichnung: 'Anzahlung' }
}
// --- Arbeitsblätter -----------------------------------------------------------------------------------------
/** Kennungen der gewählten Blätter (alle außer den abgewählten) in Reihenfolge der Liste. */
export function ausgewaehlteIds(blaetter, abgewaehlt) {
if (!Array.isArray(blaetter)) return []
const weg = Array.isArray(abgewaehlt) ? abgewaehlt : []
return blaetter.map((b) => b.id).filter((id) => !weg.includes(id))
}
/** Anzahl und Minutensumme der Blätter mit diesen Kennungen. */
export function blaetterSumme(blaetter, ids) {
const liste = (Array.isArray(blaetter) ? blaetter : []).filter((b) => Array.isArray(ids) && ids.includes(b.id))
return { anzahl: liste.length, minuten: liste.reduce((s, b) => s + (Number(b.minuten) || 0), 0) }
}
/** Appwrite-Arbeitsblatt -> Blatt der Portal-Form; null, wenn es nicht abrechenbar ist oder schon abgerechnet wurde. */
export function blattAusAppwrite(ws, abgerechnet) {
if (!ws || !ws.$id) return null
if (ws.isComment || ws.countsToPlan) return null
if (abgerechnet && Object.prototype.hasOwnProperty.call(abgerechnet, ws.$id)) return null
const roh = worksheetMinutes(ws)
if (!(roh > 0)) return null
const zeile = String(ws.details ?? '')
.split('\n')
.map((z) => z.trim())
.find(Boolean)
return {
id: ws.$id,
ticketId: ws.workorderId,
woid: ws.woid,
datum: germanDateToIso(ws.startDate || ws.endDate) || ymdBerlin(ws.$createdAt) || '',
minuten: Math.ceil(roh / 15) * 15,
text: (zeile ?? '').slice(0, 120),
aboZeit: false,
kommentar: false,
}
}
/** Tickets des Kunden mit Nummer, ohne die schon gewählten. */
export function ticketOptionen(tickets, { ausgeschlossen = [] } = {}) {
if (!Array.isArray(tickets)) return []
return tickets
.filter((t) => t && t.$id && t.woid && !ausgeschlossen.includes(t.$id))
.map((t) => ({
value: t.$id,
ticketId: t.$id,
woid: t.woid,
label: `#${t.woid} · ${String(t.topic || t.title || '').trim() || 'Kein Betreff'}`,
}))
}