From f016d7ae6c3f66b216ca0881c468b943ac2ddbae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:23:39 +0000 Subject: [PATCH] Kundenansicht P6: Vorschuss im Stundenkonto (Einrichtung statt Mehraufwand) Co-Authored-By: Claude Sonnet 5.5 --- server/services/hourLedger.js | 69 ++++++++++++++++++++++++++------ test/hourLedgerVorschuss.test.js | 52 ++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 test/hourLedgerVorschuss.test.js diff --git a/server/services/hourLedger.js b/server/services/hourLedger.js index ac7bf12..7a7cd69 100644 --- a/server/services/hourLedger.js +++ b/server/services/hourLedger.js @@ -11,6 +11,13 @@ import { worksheetMinutes } from './worksheetMinutes.js' * 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) { @@ -68,6 +75,7 @@ export async function listPlanUsage(customerId) { 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)) @@ -86,20 +94,54 @@ export function computeLedger(credits, usage, { now = new Date(), active = true .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 + 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 + } } - overageMinutes += need - return { ...u, coveredMinutes: u.minutes - need, overageMinutes: need } - }) + // 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 @@ -115,6 +157,7 @@ export function computeLedger(credits, usage, { now = new Date(), active = true 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) => ({ diff --git a/test/hourLedgerVorschuss.test.js b/test/hourLedgerVorschuss.test.js new file mode 100644 index 0000000..70cb167 --- /dev/null +++ b/test/hourLedgerVorschuss.test.js @@ -0,0 +1,52 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' + +const aw = await startAppwriteMock() +const st = await startFakeStripe() +after(() => Promise.all([aw.close(), st.close()])) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { computeLedger, listPlanUsage, getLedger } = await import('../server/services/hourLedger.js') + +const K = (id, minutes, grantedAt) => ({ $id: id, minutes, grantedAt, expiresAt: '2099-01-01T00:00:00.000Z' }) +const U = (id, tag, minutes, einrichtung = false) => ({ id, date: `2026-${tag}T12:00:00.000Z`, minutes, einrichtung }) +const NOW = new Date('2026-12-01T00:00:00.000Z') +const je = (l) => Object.fromEntries(l.usage.map((e) => [e.id, [e.coveredMinutes, e.overageMinutes, e.vorschussMinutes]])) // [gedeckt, Mehraufwand, Vorschuss] + +test('Einrichtung ohne volles Guthaben: Vorschuss, nie Mehraufwand; normale Arbeit bleibt Mehraufwand', () => { + const k = [K('k1', 60, '2026-09-01T00:00:00.000Z')] + const e = computeLedger(k, [U('e1', '09-10', 150, true)], { now: NOW }) + assert.deepEqual(je(e), { e1: [60, 0, 90] }); assert.deepEqual([e.vorschussMinutes, e.overageMinutes, e.balanceMinutes, e.usedMinutes], [90, 0, 0, 60]) + const n = computeLedger(k, [U('n1', '09-10', 90)], { now: NOW }) + assert.deepEqual(je(n), { n1: [60, 30, 0] }); assert.deepEqual([n.vorschussMinutes, n.overageMinutes], [0, 30]) +}) +test('spätere Gutschrift gleicht zuerst den Vorschuss aus, vor jeder anderen Arbeit', () => { + const use = [U('n1', '09-05', 60), U('n2', '09-06', 60), U('e1', '09-10', 60, true)] + const [k1, k2, k3] = [K('k1', 60, '2026-09-01T00:00:00.000Z'), K('k2', 60, '2026-10-01T00:00:00.000Z'), K('k3', 60, '2026-11-01T00:00:00.000Z')] + assert.deepEqual(je(computeLedger([k1], use, { now: NOW })), { n1: [60, 0, 0], n2: [0, 60, 0], e1: [0, 0, 60] }) // beides sichtbar + assert.deepEqual(je(computeLedger([k1, k2], use, { now: NOW })), { n1: [60, 0, 0], n2: [0, 60, 0], e1: [60, 0, 0] }) // k2 deckt e1 vor n2 + const voll = computeLedger([k1, k2, k3], use, { now: NOW }) + assert.deepEqual(je(voll), { n1: [60, 0, 0], n2: [60, 0, 0], e1: [60, 0, 0] }); assert.deepEqual([voll.overageMinutes, voll.vorschussMinutes, voll.balanceMinutes], [0, 0, 0]) +}) +test('ohne Einrichtung bleibt die Zuordnung wie bisher (Eintrag für Eintrag, rückwirkend)', () => { + const l = computeLedger([K('a', 30, '2026-01-01T00:00:00.000Z'), K('c', 60, '2026-01-15T00:00:00.000Z')], [U('n1', '01-10', 60), U('n2', '01-20', 60)], { now: NOW }) + assert.deepEqual(je(l), { n1: [60, 0, 0], n2: [30, 30, 0] }); assert.deepEqual([l.overageMinutes, l.vorschussMinutes], [30, 0]) +}) +test('Vertragsende (active false): Vorschuss bleibt stehen, Guthaben 0', () => { + const l = computeLedger([K('k1', 60, '2026-09-01T00:00:00.000Z')], [U('e1', '09-10', 150, true)], { now: NOW, active: false }) + assert.deepEqual([l.vorschussMinutes, l.balanceMinutes, l.nextExpiry], [90, 0, null]) +}) +test('listPlanUsage und getLedger: Einrichtung nur bei einrichtungAbrechnung abo; Git-Blatt zählt mit; projekt und fehlendes Feld nicht', async () => { + aw.seed('workorders', { $id: 'wo_e', customerId: 'c9', woid: '100301', topic: 'Einrichtung', einrichtungAbrechnung: 'abo' }) + aw.seed('workorders', { $id: 'wo_p', customerId: 'c9', woid: '100302', topic: 'Projekt', einrichtungAbrechnung: 'projekt' }) + aw.seed('workorders', { $id: 'wo_n', customerId: 'c9', woid: '100303', topic: 'Alt' }) + const ws = (id, wo, tag, over = {}) => aw.seed('worksheets', { $id: id, workorderId: wo, wsid: id, countsToPlan: true, totalTime: 60, startDate: `${tag}.10.2026`, employeeName: 'Justin', details: 'x', ...over }) + ws('s_e', 'wo_e', '05'); ws('s_git', 'wo_e', '06', { serviceType: 'GIT', isComment: true, totalTime: 30, employeeShort: 'GIT' }) + ws('s_p', 'wo_p', '07'); ws('s_n', 'wo_n', '08'); ws('s_aus', 'wo_e', '09', { countsToPlan: false }) + assert.deepEqual(Object.fromEntries((await listPlanUsage('c9')).map((x) => [x.id, x.einrichtung])), { s_e: true, s_git: true, s_p: false, s_n: false }) + aw.seed('hourCredits', { ...K('h1', 60, '2026-09-01T00:00:00.000Z'), customerId: 'c9' }) + const l = await getLedger('c9') // 60 min gedeckt (s_e), s_git 30 min Vorschuss, s_p und s_n je 60 min Mehraufwand + assert.deepEqual([l.usedMinutes, l.vorschussMinutes, l.overageMinutes], [60, 30, 120]) +})