Unterstützung: Rechenregel und Abzug im Abo-Stundenkonto
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c4fab31603
commit
7c6b0c906b
5 files changed
+148
-5
No files matched your search
+1
-1
@@ -1218,7 +1218,7 @@ function renderHoursView(view, data) {
|
|||||||
const usageRows = (l.usage || []).map((u) => `
|
const usageRows = (l.usage || []).map((u) => `
|
||||||
<tr><td>${fmtDateShort(u.date)}</td>
|
<tr><td>${fmtDateShort(u.date)}</td>
|
||||||
<td>${escapeHtml(u.topic || u.details || 'Arbeitszeit')}${u.woid ? ` <span class="muted">#${escapeHtml(u.woid)}</span>` : ''}</td>
|
<td>${escapeHtml(u.topic || u.details || 'Arbeitszeit')}${u.woid ? ` <span class="muted">#${escapeHtml(u.woid)}</span>` : ''}</td>
|
||||||
<td>−${fmtHours(u.minutes)}${u.overageMinutes ? ` <span class="pill pill-warn" title="Über das Kontingent hinaus">+${fmtHours(u.overageMinutes)} extra</span>` : ''}</td></tr>`).join('')
|
<td>−${fmtHours(u.minutes)}${u.supportMinutes ? ` <span class="muted">inkl. ${fmtHours(u.supportMinutes)} Unterstützung</span>` : ''}${u.overageMinutes ? ` <span class="pill pill-warn" title="Über das Kontingent hinaus">+${fmtHours(u.overageMinutes)} extra</span>` : ''}</td></tr>`).join('')
|
||||||
const creditRows = (l.credits || []).slice().reverse().map((c) => `
|
const creditRows = (l.credits || []).slice().reverse().map((c) => `
|
||||||
<tr><td>${fmtDateShort(c.grantedAt)}</td><td>Monatskontingent</td>
|
<tr><td>${fmtDateShort(c.grantedAt)}</td><td>Monatskontingent</td>
|
||||||
<td>+${fmtHours(c.minutes)} <span class="muted">${c.expired ? 'abgelaufen' : `nutzbar bis ${fmtDateShort(c.expiresAt)}`}</span></td></tr>`).join('')
|
<td>+${fmtHours(c.minutes)} <span class="muted">${c.expired ? 'abgelaufen' : `nutzbar bis ${fmtDateShort(c.expiresAt)}`}</span></td></tr>`).join('')
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { config } from '../config.js'
|
import { config } from '../config.js'
|
||||||
import { listDocuments, Query } from './appwriteAdmin.js'
|
import { listDocuments, Query } from './appwriteAdmin.js'
|
||||||
|
import { worksheetMinutes } from './worksheetMinutes.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abo-Stundenkonto eines Kunden.
|
* Abo-Stundenkonto eines Kunden.
|
||||||
*
|
*
|
||||||
* Gutschriften: je bezahlter Abo-Rechnung 60 min (Collection hourCredits), gueltig 12 Monate.
|
* Gutschriften: je bezahlter Abo-Rechnung 60 min (Collection hourCredits), gueltig 12 Monate.
|
||||||
* Verbrauch: Worksheets mit countsToPlan=true auf Tickets des Kunden, je Eintrag
|
* Verbrauch: Worksheets mit countsToPlan=true auf Tickets des Kunden inkl. Unterstuetzung
|
||||||
* auf 15 min aufgerundet.
|
* (worksheetMinutes), je Eintrag auf 15 min aufgerundet.
|
||||||
* 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).
|
||||||
@@ -55,14 +56,16 @@ export async function listPlanUsage(customerId) {
|
|||||||
.filter((ws) => Number(ws.totalTime) > 0)
|
.filter((ws) => Number(ws.totalTime) > 0)
|
||||||
.map((ws) => {
|
.map((ws) => {
|
||||||
const wo = byId.get(ws.workorderId) || {}
|
const wo = byId.get(ws.workorderId) || {}
|
||||||
|
const total = worksheetMinutes(ws)
|
||||||
return {
|
return {
|
||||||
id: ws.$id,
|
id: ws.$id,
|
||||||
wsid: ws.wsid || '',
|
wsid: ws.wsid || '',
|
||||||
woid: ws.woid || wo.woid || '',
|
woid: ws.woid || wo.woid || '',
|
||||||
topic: wo.topic || wo.title || '',
|
topic: wo.topic || wo.title || '',
|
||||||
date: parseSheetDate(ws).toISOString(),
|
date: parseSheetDate(ws).toISOString(),
|
||||||
rawMinutes: Number(ws.totalTime) || 0,
|
rawMinutes: total,
|
||||||
minutes: roundUsage(ws.totalTime),
|
minutes: roundUsage(total),
|
||||||
|
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 || '',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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] || ''
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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(''), '')
|
||||||
|
})
|
||||||
Reference in new issue
Block a user