import { config } from '../config.js' import { listDocuments, Query } from './appwriteAdmin.js' import { worksheetMinutes } from './worksheetMinutes.js' /** * Abo-Stundenkonto eines Kunden. * * Gutschriften: je bezahlter Abo-Rechnung 60 min (Collection hourCredits), gueltig 12 Monate. * Verbrauch: Worksheets mit countsToPlan=true auf Tickets des Kunden inkl. Unterstuetzung * (worksheetMinutes), je Eintrag auf 15 min aufgerundet. * Verrechnung: FIFO - die aelteste noch gueltige Gutschrift wird zuerst verbraucht. * Was kein Guthaben mehr findet, ist Mehraufwand (separat abzurechnen). * Vertragsende: Restguthaben verfaellt (siehe Abo-Bedingungen Ziff. 4). * * Vorschuss (Abo-Modell der Funktionen): Arbeitsblaetter auf Tickets mit einrichtungAbrechnung * 'abo' sind Einrichtung. Was davon kein Guthaben findet, ist Vorschuss * (vorschussMinutes), nie Mehraufwand; normale Arbeit bleibt Mehraufwand. Eine spaetere * Gutschrift gleicht zuerst den Vorschuss aus (Durchgang 2: Einrichtung vor den * uebrigen Eintraegen). Nach Vertragsende bleibt der Vorschuss stehen und wird nicht * berechnet (active false setzt nur Guthaben und credits[].remaining auf 0). */ function parseSheetDate(ws) { const raw = ws.startDate || ws.endDate || '' const m = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(raw) if (m) return new Date(`${m[3]}-${m[2]}-${m[1]}T12:00:00Z`) const d = new Date(raw || ws.createdAt || ws.$createdAt) return Number.isNaN(d.getTime()) ? new Date(ws.$createdAt) : d } function roundUsage(minutes) { const step = config.abo.usageRoundingMinutes return Math.ceil(Math.max(0, Number(minutes) || 0) / step) * step } export async function listCredits(customerId) { return listDocuments(config.collections.hourCredits, [ Query.equal('customerId', customerId), Query.orderAsc('grantedAt'), Query.limit(1000), ]) } export async function listPlanUsage(customerId) { const workorders = await listDocuments(config.collections.workorders, [ Query.equal('customerId', customerId), Query.limit(5000), ]) if (!workorders.length) return [] const byId = new Map(workorders.map((w) => [w.$id, w])) const ids = [...byId.keys()] const sheets = [] // Appwrite begrenzt die Werte-Liste pro equal-Query -> in Bloecken abfragen for (let i = 0; i < ids.length; i += 100) { const chunk = await listDocuments(config.collections.worksheets, [ Query.equal('workorderId', ids.slice(i, i + 100)), Query.equal('countsToPlan', true), Query.limit(5000), ]) sheets.push(...chunk) } return sheets .filter((ws) => Number(ws.totalTime) > 0) .map((ws) => { const wo = byId.get(ws.workorderId) || {} const total = worksheetMinutes(ws) return { id: ws.$id, wsid: ws.wsid || '', woid: ws.woid || wo.woid || '', topic: wo.topic || wo.title || '', date: parseSheetDate(ws).toISOString(), rawMinutes: total, minutes: roundUsage(total), supportMinutes: total - (Number(ws.totalTime) || 0), details: String(ws.details || '').slice(0, 300), employee: ws.employeeName || ws.employeeShort || '', einrichtung: wo.einrichtungAbrechnung === 'abo', } }) .sort((a, b) => a.date.localeCompare(b.date)) } /** Reine Berechnung (testbar ohne DB). */ export function computeLedger(credits, usage, { now = new Date(), active = true } = {}) { const pool = credits .map((c) => ({ id: c.$id, minutes: Number(c.minutes) || 0, remaining: Number(c.minutes) || 0, grantedAt: c.grantedAt, expiresAt: c.expiresAt, })) .sort((a, b) => String(a.grantedAt).localeCompare(String(b.grantedAt))) let overageMinutes = 0 let vorschussMinutes = 0 let entries if (!usage.some((u) => u.einrichtung === true)) { entries = usage.map((u) => { let need = u.minutes const at = new Date(u.date) for (const c of pool) { if (!need) break // Nur Gutschriften, die zum Arbeitszeitpunkt noch gueltig waren if (c.remaining <= 0 || new Date(c.expiresAt) <= at) continue const take = Math.min(need, c.remaining) c.remaining -= take need -= take } overageMinutes += need return { ...u, coveredMinutes: u.minutes - need, overageMinutes: need, einrichtung: false, vorschussMinutes: 0 } }) } else { // Zwei Durchgaenge in Datumsreihenfolge (bei gleichem Datum bleibt die Eingabereihenfolge) const ordered = usage .map((u, index) => ({ u, index, at: new Date(u.date), need: u.minutes })) .sort((a, b) => String(a.u.date).localeCompare(String(b.u.date)) || a.index - b.index) const decke = (e, nurGebucht) => { for (const c of pool) { if (!e.need) break if (c.remaining <= 0 || new Date(c.expiresAt) <= e.at) continue if (nurGebucht && new Date(c.grantedAt) > e.at) continue const take = Math.min(e.need, c.remaining) c.remaining -= take e.need -= take } } // Durchgang 1: nur Gutschriften, die zur Arbeitszeit schon gebucht und noch gueltig waren for (const e of ordered) decke(e, true) // Durchgang 2: den Rest rueckwirkend, zuerst alle Einrichtungseintraege, dann die uebrigen for (const e of ordered.filter((x) => x.u.einrichtung === true)) decke(e, false) for (const e of ordered.filter((x) => x.u.einrichtung !== true)) decke(e, false) const byIndex = new Map(ordered.map((e) => [e.index, e])) entries = usage.map((u, index) => { const e = byIndex.get(index) const einrichtung = u.einrichtung === true const vorschuss = einrichtung ? e.need : 0 const mehr = einrichtung ? 0 : e.need overageMinutes += mehr vorschussMinutes += vorschuss return { ...u, coveredMinutes: u.minutes - e.need, overageMinutes: mehr, einrichtung, vorschussMinutes: vorschuss } }) } const valid = pool.filter((c) => c.remaining > 0 && new Date(c.expiresAt) > now) const expiredMinutes = pool .filter((c) => c.remaining > 0 && new Date(c.expiresAt) <= now) .reduce((s, c) => s + c.remaining, 0) const balanceMinutes = active ? valid.reduce((s, c) => s + c.remaining, 0) : 0 const nextExpiry = active && valid.length ? valid.reduce((a, c) => (new Date(c.expiresAt) < new Date(a.expiresAt) ? c : a)) : null return { balanceMinutes, grantedMinutes: pool.reduce((s, c) => s + c.minutes, 0), usedMinutes: entries.reduce((s, e) => s + e.coveredMinutes, 0), overageMinutes, vorschussMinutes, expiredMinutes, nextExpiry: nextExpiry ? { minutes: nextExpiry.remaining, at: nextExpiry.expiresAt } : null, credits: pool.map((c) => ({ grantedAt: c.grantedAt, expiresAt: c.expiresAt, minutes: c.minutes, remaining: active && new Date(c.expiresAt) > now ? c.remaining : 0, expired: new Date(c.expiresAt) <= now, })), usage: entries.reverse(), } } export async function getLedger(customerId, { active = true } = {}) { const [credits, usage] = await Promise.all([listCredits(customerId), listPlanUsage(customerId)]) return computeLedger(credits, usage, { active }) }