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:
1 parent
cc95018083
commit
e37c0a664e
2 files changed
+421
No files matched your search
@@ -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'}`,
|
||||
}))
|
||||
}
|
||||
Reference in new issue
Block a user