Files

178 lines
7.1 KiB
JavaScript

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 })
}