Kundenansicht P6: Vorschuss im Stundenkonto (Einrichtung statt Mehraufwand)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
57d5db2344
commit
f016d7ae6c
2 files changed
+108
-13
No files matched your search
@@ -11,6 +11,13 @@ import { worksheetMinutes } from './worksheetMinutes.js'
|
|||||||
* Verrechnung: FIFO - die aelteste noch gueltige Gutschrift wird zuerst verbraucht.
|
* Verrechnung: FIFO - die aelteste noch gueltige Gutschrift wird zuerst verbraucht.
|
||||||
* Was kein Guthaben mehr findet, ist Mehraufwand (separat abzurechnen).
|
* Was kein Guthaben mehr findet, ist Mehraufwand (separat abzurechnen).
|
||||||
* Vertragsende: Restguthaben verfaellt (siehe Abo-Bedingungen Ziff. 4).
|
* 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) {
|
function parseSheetDate(ws) {
|
||||||
@@ -68,6 +75,7 @@ export async function listPlanUsage(customerId) {
|
|||||||
supportMinutes: total - (Number(ws.totalTime) || 0),
|
supportMinutes: total - (Number(ws.totalTime) || 0),
|
||||||
details: String(ws.details || '').slice(0, 300),
|
details: String(ws.details || '').slice(0, 300),
|
||||||
employee: ws.employeeName || ws.employeeShort || '',
|
employee: ws.employeeName || ws.employeeShort || '',
|
||||||
|
einrichtung: wo.einrichtungAbrechnung === 'abo',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.date.localeCompare(b.date))
|
.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)))
|
.sort((a, b) => String(a.grantedAt).localeCompare(String(b.grantedAt)))
|
||||||
|
|
||||||
let overageMinutes = 0
|
let overageMinutes = 0
|
||||||
const entries = usage.map((u) => {
|
let vorschussMinutes = 0
|
||||||
let need = u.minutes
|
let entries
|
||||||
const at = new Date(u.date)
|
if (!usage.some((u) => u.einrichtung === true)) {
|
||||||
for (const c of pool) {
|
entries = usage.map((u) => {
|
||||||
if (!need) break
|
let need = u.minutes
|
||||||
// Nur Gutschriften, die zum Arbeitszeitpunkt noch gueltig waren
|
const at = new Date(u.date)
|
||||||
if (c.remaining <= 0 || new Date(c.expiresAt) <= at) continue
|
for (const c of pool) {
|
||||||
const take = Math.min(need, c.remaining)
|
if (!need) break
|
||||||
c.remaining -= take
|
// Nur Gutschriften, die zum Arbeitszeitpunkt noch gueltig waren
|
||||||
need -= take
|
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
|
// Durchgang 1: nur Gutschriften, die zur Arbeitszeit schon gebucht und noch gueltig waren
|
||||||
return { ...u, coveredMinutes: u.minutes - need, overageMinutes: need }
|
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 valid = pool.filter((c) => c.remaining > 0 && new Date(c.expiresAt) > now)
|
||||||
const expiredMinutes = pool
|
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),
|
grantedMinutes: pool.reduce((s, c) => s + c.minutes, 0),
|
||||||
usedMinutes: entries.reduce((s, e) => s + e.coveredMinutes, 0),
|
usedMinutes: entries.reduce((s, e) => s + e.coveredMinutes, 0),
|
||||||
overageMinutes,
|
overageMinutes,
|
||||||
|
vorschussMinutes,
|
||||||
expiredMinutes,
|
expiredMinutes,
|
||||||
nextExpiry: nextExpiry ? { minutes: nextExpiry.remaining, at: nextExpiry.expiresAt } : null,
|
nextExpiry: nextExpiry ? { minutes: nextExpiry.remaining, at: nextExpiry.expiresAt } : null,
|
||||||
credits: pool.map((c) => ({
|
credits: pool.map((c) => ({
|
||||||
|
|||||||
@@ -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])
|
||||||
|
})
|
||||||
Reference in new issue
Block a user