135 lines
4.8 KiB
JavaScript
135 lines
4.8 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).
|
|
*/
|
|
|
|
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 || '',
|
|
}
|
|
})
|
|
.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
|
|
const 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 }
|
|
})
|
|
|
|
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,
|
|
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 })
|
|
}
|