From 7c6b0c906bce6d2a6cd5c10b22e20ab188135c11 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 15:01:57 +0000 Subject: [PATCH] =?UTF-8?q?Unterst=C3=BCtzung:=20Rechenregel=20und=20Abzug?= =?UTF-8?q?=20im=20Abo-Stundenkonto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- public/app.js | 2 +- server/services/hourLedger.js | 11 +++--- server/services/worksheetMinutes.js | 55 +++++++++++++++++++++++++++++ test/hourLedger.test.js | 36 +++++++++++++++++++ test/worksheetMinutes.test.js | 49 +++++++++++++++++++++++++ 5 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 server/services/worksheetMinutes.js create mode 100644 test/hourLedger.test.js create mode 100644 test/worksheetMinutes.test.js diff --git a/public/app.js b/public/app.js index 61b6cc6..6aa429b 100644 --- a/public/app.js +++ b/public/app.js @@ -1218,7 +1218,7 @@ function renderHoursView(view, data) { const usageRows = (l.usage || []).map((u) => ` ${fmtDateShort(u.date)} ${escapeHtml(u.topic || u.details || 'Arbeitszeit')}${u.woid ? ` #${escapeHtml(u.woid)}` : ''} - −${fmtHours(u.minutes)}${u.overageMinutes ? ` +${fmtHours(u.overageMinutes)} extra` : ''}`).join('') + −${fmtHours(u.minutes)}${u.supportMinutes ? ` inkl. ${fmtHours(u.supportMinutes)} Unterstützung` : ''}${u.overageMinutes ? ` +${fmtHours(u.overageMinutes)} extra` : ''}`).join('') const creditRows = (l.credits || []).slice().reverse().map((c) => ` ${fmtDateShort(c.grantedAt)}Monatskontingent +${fmtHours(c.minutes)} ${c.expired ? 'abgelaufen' : `nutzbar bis ${fmtDateShort(c.expiresAt)}`}`).join('') diff --git a/server/services/hourLedger.js b/server/services/hourLedger.js index 4beab42..ac7bf12 100644 --- a/server/services/hourLedger.js +++ b/server/services/hourLedger.js @@ -1,12 +1,13 @@ 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, je Eintrag - * auf 15 min aufgerundet. + * 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). @@ -55,14 +56,16 @@ export async function listPlanUsage(customerId) { .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: Number(ws.totalTime) || 0, - minutes: roundUsage(ws.totalTime), + rawMinutes: total, + minutes: roundUsage(total), + supportMinutes: total - (Number(ws.totalTime) || 0), details: String(ws.details || '').slice(0, 300), employee: ws.employeeName || ws.employeeShort || '', } diff --git a/server/services/worksheetMinutes.js b/server/services/worksheetMinutes.js new file mode 100644 index 0000000..14df9d5 --- /dev/null +++ b/server/services/worksheetMinutes.js @@ -0,0 +1,55 @@ +/** + * Unterstuetzung bei Arbeitsblaettern (WOMS-Feld "supporters", JSON-Liste [{ employeeId, name, task }]). + * Jede unterstuetzende Person bekommt die Haelfte der Arbeitszeit, aufgerundet auf 15 Minuten + * (Abo-Bedingungen § 4). Gleiche Regel wie WOMS src/lib/support.js; die Minuten werden nie + * gespeichert, sondern immer aus totalTime berechnet. + */ + +const STEP = 15 +const MAX_TASK = 200 + +export function supportMinutes(totalTime) { + const total = Number(totalTime) || 0 + if (total <= 0) return 0 + return Math.ceil(total / 2 / STEP) * STEP +} + +/** Nie eine Exception: kaputte Werte ergeben [] (einmal gemeldet), kaputte Eintraege fallen weg. context z. B. die Worksheet-ID. */ +export function parseSupporters(raw, context) { + let list = raw + if (typeof raw === 'string') { + if (!raw.trim()) return [] + try { + list = JSON.parse(raw) + } catch { + console.warn('[unterstuetzung] supporters unlesbar', context ?? '') + return [] + } + } + if (list == null) return [] + if (!Array.isArray(list)) { + console.warn('[unterstuetzung] supporters unlesbar', context ?? '') + return [] + } + const seen = new Set() + const result = [] + for (const entry of list) { + if (!entry || typeof entry !== 'object') continue + const employeeId = String(entry.employeeId || '').trim() + const task = String(entry.task || '').trim().slice(0, MAX_TASK) + if (!employeeId || !task || seen.has(employeeId)) continue + seen.add(employeeId) + result.push({ employeeId, name: String(entry.name || '').trim(), task }) + } + return result +} + +/** Arbeitszeit des Blatts plus die Unterstuetzung aller Personen */ +export function worksheetMinutes(ws) { + const base = Number(ws?.totalTime) || 0 + return base + parseSupporters(ws?.supporters, ws?.$id).length * supportMinutes(base) +} + +export function firstName(name) { + return String(name || '').trim().split(/\s+/)[0] || '' +} diff --git a/test/hourLedger.test.js b/test/hourLedger.test.js new file mode 100644 index 0000000..ed68c59 --- /dev/null +++ b/test/hourLedger.test.js @@ -0,0 +1,36 @@ +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 { listPlanUsage, computeLedger } = await import('../server/services/hourLedger.js') + +const andrej = { employeeId: 'andrej', name: 'Andrej Stevanovski', task: 'Farbkonzept' } +aw.seed('workorders', { $id: 'wo1', customerId: 'c1', woid: '100200', topic: 'Startseite' }) +const sheet = (id, over) => ({ + $id: id, workorderId: 'wo1', woid: '100200', wsid: id, countsToPlan: true, totalTime: 60, + startDate: '30.09.2026', employeeName: 'Justin Klein', details: 'Arbeit', ...over, +}) +aw.seed('worksheets', sheet('ws1', { supporters: JSON.stringify([andrej]) })) +aw.seed('worksheets', sheet('ws2', { supporters: 'kaputt', startDate: '29.09.2026' })) + +test('Stundenkonto zieht die Unterstützung mit ab', async () => { + const usage = await listPlanUsage('c1') + const ws1 = usage.find((u) => u.id === 'ws1') + assert.equal(ws1.minutes, 90) + assert.equal(ws1.supportMinutes, 30) + const ws2 = usage.find((u) => u.id === 'ws2') + assert.equal(ws2.minutes, 60) + assert.equal(ws2.supportMinutes, 0) +}) + +test('90-min-Eintrag bei 60 min Guthaben: 30 min Mehraufwand', () => { + const credits = [{ $id: 'k1', minutes: 60, grantedAt: '2026-09-01T00:00:00Z', expiresAt: '2027-09-01T00:00:00Z' }] + const ledger = computeLedger(credits, [{ id: 'ws1', date: '2026-09-30T12:00:00Z', minutes: 90, supportMinutes: 30 }], { now: new Date('2026-09-30T13:00:00Z') }) + assert.equal(ledger.entries?.[0]?.overageMinutes ?? ledger.usage?.[0]?.overageMinutes, 30) +}) diff --git a/test/worksheetMinutes.test.js b/test/worksheetMinutes.test.js new file mode 100644 index 0000000..a5455b7 --- /dev/null +++ b/test/worksheetMinutes.test.js @@ -0,0 +1,49 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { supportMinutes, parseSupporters, worksheetMinutes, firstName } from '../server/services/worksheetMinutes.js' + +// Gleiche Faelle wie WOMS tests/unit/support.test.mjs (Rechenregel muss in beiden Systemen gleich sein) +const andrej = { employeeId: 'a', name: 'Andrej Stevanovski', task: 'Farbkonzept' } +const nico = { employeeId: 'n', name: 'Nico Rech', task: 'Texte' } + +test('supportMinutes: Hälfte, aufgerundet auf 15', () => { + const cases = [[15, 15], [30, 15], [45, 30], [60, 30], [75, 45], [70, 45], [0, 0], [-5, 0], ['60', 30], [null, 0]] + for (const [total, expected] of cases) assert.equal(supportMinutes(total), expected, `${total}`) +}) + +test('parseSupporters ist fehlertolerant', () => { + assert.deepEqual(parseSupporters('kaputt'), []) + assert.deepEqual(parseSupporters('{}'), []) + assert.deepEqual(parseSupporters(null), []) + assert.deepEqual(parseSupporters(''), []) + const raw = '[{"employeeId":"a","name":"Andrej S","task":" Farbe "},{"employeeId":"a","name":"x","task":"y"},{"employeeId":"b","name":"Nico"},null,"x"]' + assert.deepEqual(parseSupporters(raw), [{ employeeId: 'a', name: 'Andrej S', task: 'Farbe' }]) + assert.equal(parseSupporters([{ employeeId: 'a', name: 'A', task: 'x'.repeat(250) }])[0].task.length, 200) +}) + +test('parseSupporters meldet kaputte Werte einmal mit Kontext', () => { + const warned = [] + const orig = console.warn + console.warn = (...args) => warned.push(args.join(' ')) + try { + parseSupporters('kaputt', 'ws9') + parseSupporters('[]', 'ws8') + } finally { + console.warn = orig + } + assert.equal(warned.length, 1) + assert.match(warned[0], /\[unterstuetzung\] supporters unlesbar ws9/) +}) + +test('worksheetMinutes: Arbeitszeit plus je Person die Unterstützung', () => { + assert.equal(worksheetMinutes({ totalTime: 60, supporters: JSON.stringify([andrej, nico]) }), 120) + assert.equal(worksheetMinutes({ totalTime: 70, supporters: JSON.stringify([andrej]) }), 115) + assert.equal(worksheetMinutes({ totalTime: 45 }), 45) + assert.equal(worksheetMinutes({ totalTime: 0, supporters: JSON.stringify([andrej]) }), 0) + assert.equal(worksheetMinutes({ totalTime: 60, supporters: 'kaputt' }), 60) +}) + +test('firstName', () => { + assert.equal(firstName('Andrej Stevanovski'), 'Andrej') + assert.equal(firstName(''), '') +})