From 0eab7d7ad3d19ca553651c1fe00efb6fd35564c4 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 28 Aug 2026 07:30:14 +0200 Subject: [PATCH] =?UTF-8?q?F=C3=BCge=20Logik=20zur=20Berechnung=20wirksame?= =?UTF-8?q?r=20Arbeits-=20und=20Pausenbl=C3=B6cke=20hinzu=20und=20optimier?= =?UTF-8?q?e=20die=20Verarbeitung=20von=20Zeiteintr=C3=A4gen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/services/TimeEntryService.js | 475 ++++++----------------- 1 file changed, 116 insertions(+), 359 deletions(-) diff --git a/backend/src/services/TimeEntryService.js b/backend/src/services/TimeEntryService.js index d236afd..f33a626 100755 --- a/backend/src/services/TimeEntryService.js +++ b/backend/src/services/TimeEntryService.js @@ -21,6 +21,63 @@ class TimeEntryService { this.defaultUserId = userId; } + /** + * Liefert die wirksame Ereignisfolge. `relatedTo_id` beschreibt die + * ursprüngliche Buchungskette und ist nach einer Action-Korrektur (z. B. + * Pause -> Arbeitsende) nicht mehr zuverlässig. Alle Auswertungen, die + * einen Zustand ableiten, müssen deshalb diese Folge verwenden. + * @private + */ + async _getEffectiveEntries(userId) { + const entries = await worklogRepository.findByUser(userId); + const fixes = await worklogRepository.getTimefixesByWorklogIds(entries.map(entry => entry.id)); + return entries.map(entry => { + const fix = fixes.get(entry.id)?.at(-1); + let action = entry.state; + if (typeof action === 'string') { + try { action = JSON.parse(action); } catch (_) { /* action is already a string */ } + } + action = fix?.fix_type || action?.action || action; + const time = fix?.fix_date_time + ? (typeof fix.fix_date_time === 'string' ? fix.fix_date_time.replace(' ', 'T') + 'Z' : fix.fix_date_time) + : entry.tstamp; + return { ...entry, effectiveAction: action, effectiveTime: time, fix }; + }).sort((a, b) => new Date(a.effectiveTime) - new Date(b.effectiveTime) || a.id - b.id); + } + + /** Bildet Arbeits- und Pausenblöcke ausschließlich aus der wirksamen Folge. */ + _buildEffectiveWorkBlocks(entries) { + const blocks = []; + let work = null; + let pause = null; + for (const entry of entries) { + switch (entry.effectiveAction) { + case 'start work': + // Ein neuer Arbeitsbeginn schließt keinen bestehenden Block künstlich. + if (!work) work = { start: entry, pauses: [] }; + break; + case 'start pause': + if (work && !pause) pause = entry; + break; + case 'stop pause': + if (work && pause) { + work.pauses.push({ start: pause, end: entry }); + pause = null; + } + break; + case 'stop work': + if (work) { + blocks.push({ ...work, end: entry, runningPause: null }); + work = null; + pause = null; + } + break; + } + } + if (work) blocks.push({ ...work, end: null, runningPause: pause }); + return blocks; + } + /** * Alle Zeiteinträge abrufen * @param {number} userId - Benutzer-ID (optional) @@ -228,116 +285,25 @@ class TimeEntryService { const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999); if (runningEntry) { - // Berechne ALLE Arbeitszeiten des heutigen Tages - // Hole alle Einträge von heute (wird auch für Pausen verwendet) - allEntries = await worklogRepository.findByDateRange(uid, todayStart, todayEnd); - - // Hole Timefixes für alle Einträge von heute - const allEntryIds = allEntries.map(e => e.id); - const timefixMap = await worklogRepository.getTimefixesByWorklogIds(allEntryIds); - - // Finde alle Start-Work-Paare von heute - const workBlocks = []; - const startWorks = {}; - - allEntries.forEach(entry => { - let state = entry.state; - if (typeof state === 'string') { - try { - state = JSON.parse(state); - } catch (e) { - // ignore - } - } - - const action = state?.action || state; - - if (action === 'start work') { - // Prüfe auf Timefix-Korrektur - const startFix = timefixMap.get(entry.id)?.find(f => f.fix_type === 'start work'); - const startTime = startFix?.fix_date_time || entry.tstamp; - - startWorks[entry.id] = { - id: entry.id, - startTime: startTime, - endTime: null, - pauses: [] - }; - } else if (action === 'stop work' && entry.relatedTo_id && startWorks[entry.relatedTo_id]) { - // Prüfe auf Timefix-Korrektur - const endFix = timefixMap.get(entry.id)?.find(f => f.fix_type === 'stop work'); - const endTime = endFix?.fix_date_time || entry.tstamp; - - startWorks[entry.relatedTo_id].endTime = endTime; - } - }); - - // Sammle Pausen für jeden Block - allEntries.forEach(entry => { - let state = entry.state; - if (typeof state === 'string') { - try { - state = JSON.parse(state); - } catch (e) { - // ignore - } - } - - const action = state?.action || state; - - if (action === 'start pause' && entry.relatedTo_id && startWorks[entry.relatedTo_id]) { - // Prüfe auf Timefix-Korrektur - const pauseStartFix = timefixMap.get(entry.id)?.find(f => f.fix_type === 'start pause'); - const pauseStartTime = pauseStartFix?.fix_date_time || entry.tstamp; - - startWorks[entry.relatedTo_id].pauses.push({ - id: entry.id, - startTime: pauseStartTime, - endTime: null - }); - } else if (action === 'stop pause' && entry.relatedTo_id) { - // Finde das zugehörige start pause - Object.values(startWorks).forEach(block => { - const pause = block.pauses.find(p => p.id === entry.relatedTo_id); - if (pause) { - // Prüfe auf Timefix-Korrektur - const pauseEndFix = timefixMap.get(entry.id)?.find(f => f.fix_type === 'stop pause'); - pause.endTime = pauseEndFix?.fix_date_time || entry.tstamp; - } - }); - } - }); - - // Berechne Gesamtarbeitszeit aller Blöcke + // Arbeitszeit wird aus der korrigierten Ereignisfolge gebildet. Damit + // darf eine nachträglich umtypisierte Buchung keine Phantompause mehr + // erzeugen. + const blocks = this._buildEffectiveWorkBlocks(await this._getEffectiveEntries(uid)); let totalWorkedMs = 0; - - Object.values(startWorks).forEach(block => { - const blockStart = new Date(block.startTime).getTime(); - const blockEnd = block.endTime ? new Date(block.endTime).getTime() : now.getTime(); - - let blockWorkedMs = blockEnd - blockStart; - - // Ziehe abgeschlossene Pausen ab - block.pauses.forEach(pause => { - if (pause.endTime) { - const pauseDuration = new Date(pause.endTime).getTime() - new Date(pause.startTime).getTime(); - blockWorkedMs -= pauseDuration; - } else { - // Laufende Pause - const pauseDuration = now.getTime() - new Date(pause.startTime).getTime(); - blockWorkedMs -= pauseDuration; - } - }); - - totalWorkedMs += blockWorkedMs; - }); - - // Formatiere als HH:MM:SS - const totalSeconds = Math.floor(totalWorkedMs / 1000); + for (const block of blocks) { + const start = new Date(block.start.effectiveTime); + if (start < todayStart || start > todayEnd) continue; + const endMs = block.end ? new Date(block.end.effectiveTime).getTime() : now.getTime(); + let duration = endMs - start.getTime(); + duration -= block.pauses.reduce((sum, pause) => sum + + new Date(pause.end.effectiveTime).getTime() - new Date(pause.start.effectiveTime).getTime(), 0); + if (block.runningPause) duration -= now.getTime() - new Date(block.runningPause.effectiveTime).getTime(); + totalWorkedMs += duration; + } + const totalSeconds = Math.max(0, Math.floor(totalWorkedMs / 1000)); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; - currentlyWorked = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; } @@ -1318,39 +1284,22 @@ class TimeEntryService { : fix.fix_date_time; }; - const pairs = []; - const openWorkEntries = new Map(); - [...allEntries] - .sort((a, b) => new Date(getEffectiveTime(a)) - new Date(getEffectiveTime(b))) - .forEach(entry => { - const action = getAction(entry); - if (action === 'start work') { - openWorkEntries.set(String(entry.id), entry); - } else if (action === 'stop work' && entry.relatedTo_id) { - const startEntry = openWorkEntries.get(String(entry.relatedTo_id)); - if (startEntry) { - pairs.push({ - id: startEntry.id, - end_id: entry.id, - start_time: startEntry.tstamp, - end_time: entry.tstamp, - start_state: startEntry.state, - end_state: entry.state - }); - openWorkEntries.delete(String(entry.relatedTo_id)); - } - } - }); - - openWorkEntries.forEach(startEntry => { - pairs.push({ - id: startEntry.id, - start_time: startEntry.tstamp, - end_time: null, - start_state: startEntry.state, - end_state: null - }); - }); + const effectiveEntries = [...allEntries] + .map(entry => ({ ...entry, effectiveAction: getAction(entry), effectiveTime: getEffectiveTime(entry) })) + .sort((a, b) => new Date(a.effectiveTime) - new Date(b.effectiveTime) || a.id - b.id); + const pairs = this._buildEffectiveWorkBlocks(effectiveEntries).map(block => ({ + id: block.start.id, + end_id: block.end?.id || null, + start_time: block.start.tstamp, + end_time: block.end?.tstamp || null, + start_state: block.start.state, + end_state: block.end?.state || null, + // Die nachfolgenden Darstellungsfelder verwenden diese bereits + // normalisierten Pausen, statt die ursprünglichen Referenzen erneut zu + // interpretieren. + effectivePauses: block.pauses, + runningPause: block.runningPause + })); // Hole Vacation-Einträge für diese Woche const vacations = await worklogRepository.getVacationsByUserInDateRange(uid, weekStart, weekEnd); @@ -1515,42 +1464,7 @@ class TimeEntryService { // Berechne Pausen NUR für diesen spezifischen Arbeitsblock // Finde alle Einträge, die zu diesem pair.id gehören (über relatedTo_id) - const blockEntries = allEntries.filter(e => { - // Parse state if it's a JSON string - let state = e.state; - if (typeof state === 'string') { - try { - state = JSON.parse(state); - } catch (err) { - // state bleibt als String - } - } - - const action = state?.action || state; - - // Einträge gehören zum Block wenn: - // 1. Sie start/stop pause sind UND ihr relatedTo_id auf pair.id zeigt (direkt oder indirekt) - // 2. Oder sie stop work sind mit relatedTo_id = pair.id - - if (action === 'start pause' && e.relatedTo_id === pair.id) { - return true; // Pause gehört zu diesem Arbeitsblock - } - - if (action === 'stop pause') { - // Finde das zugehörige start pause - const startPause = allEntries.find(sp => { - const spState = typeof sp.state === 'string' ? JSON.parse(sp.state) : sp.state; - const spAction = spState?.action || spState; - return spAction === 'start pause' && sp.id === e.relatedTo_id; - }); - - if (startPause && startPause.relatedTo_id === pair.id) { - return true; // Stop pause gehört zu einem start pause, das zu diesem Block gehört - } - } - - return false; - }); + const blockEntries = pair.effectivePauses.flatMap(pause => [pause.start, pause.end]); // Finde Pausen-Paare NUR für diesen Block const pausePairs = []; @@ -1709,41 +1623,10 @@ class TimeEntryService { } // Prüfe auf Pausen NUR für diesen laufenden Arbeitsblock - const blockEntries = allEntries.filter(e => { - // Parse state if it's a JSON string - let state = e.state; - if (typeof state === 'string') { - try { - state = JSON.parse(state); - } catch (err) { - // state bleibt als String - } - } - - const action = state?.action || state; - - // Einträge gehören zum Block wenn: - // 1. Sie start/stop pause sind UND ihr relatedTo_id auf pair.id zeigt (direkt oder indirekt) - - if (action === 'start pause' && e.relatedTo_id === pair.id) { - return true; // Pause gehört zu diesem Arbeitsblock - } - - if (action === 'stop pause') { - // Finde das zugehörige start pause - const startPause = allEntries.find(sp => { - const spState = typeof sp.state === 'string' ? JSON.parse(sp.state) : sp.state; - const spAction = spState?.action || spState; - return spAction === 'start pause' && sp.id === e.relatedTo_id; - }); - - if (startPause && startPause.relatedTo_id === pair.id) { - return true; // Stop pause gehört zu einem start pause, das zu diesem Block gehört - } - } - - return false; - }); + const blockEntries = [ + ...pair.effectivePauses.flatMap(pause => [pause.start, pause.end]), + ...(pair.runningPause ? [pair.runningPause] : []) + ]; // Finde laufende Pause const pauseStarts = {}; @@ -2087,36 +1970,8 @@ class TimeEntryService { */ async getCurrentState(userId) { const uid = userId || this.defaultUserId; - - // Hole letzten Worklog-Eintrag (inkl. Timefix-Korrekturen) - const lastEntry = await worklogRepository.findLatestByUser(uid); - - if (!lastEntry) { - return null; - } - - // Parse state - let state = lastEntry.state; - if (typeof state === 'string') { - try { - state = JSON.parse(state); - } catch (e) { - // state bleibt als String - } - } - - const action = state?.action || state; - - // Prüfe auf Timefix-Korrektur für diesen Eintrag - const timefixMap = await worklogRepository.getTimefixesByWorklogIds([lastEntry.id]); - const timefix = timefixMap.get(lastEntry.id)?.[0]; - - if (timefix) { - // Verwende korrigierten Action-Typ - return timefix.fix_type; - } - - return action; + const entries = await this._getEffectiveEntries(uid); + return entries.at(-1)?.effectiveAction || null; } /** @@ -2196,19 +2051,18 @@ class TimeEntryService { } // stop work und stop pause referenzieren den entsprechenden Start - if (newAction === 'stop work') { - // Finde letzten 'start work' ohne 'stop work' - return await this._findLastUnpairedStart(userId, 'start work'); + const blocks = this._buildEffectiveWorkBlocks(await this._getEffectiveEntries(userId)); + const activeBlock = blocks.at(-1); + if (!activeBlock || activeBlock.end) return null; + + if (newAction === 'stop work' || newAction === 'start pause') { + // Auch neu erzeugte Folgebuchungen müssen auf den wirksamen Start + // zeigen, wenn dieser ursprünglich eine andere Action hatte. + return activeBlock.start.id; } if (newAction === 'stop pause') { - // Finde letzten 'start pause' ohne 'stop pause' - return await this._findLastUnpairedStart(userId, 'start pause'); - } - - if (newAction === 'start pause') { - // start pause referenziert den laufenden 'start work' - return await this._findLastUnpairedStart(userId, 'start work'); + return activeBlock.runningPause?.id || null; } return null; @@ -2283,115 +2137,18 @@ class TimeEntryService { */ async getRunningEntry(userId = null) { const uid = userId || this.defaultUserId; - - // Hole letzten Eintrag - const lastEntry = await worklogRepository.findLatestByUser(uid); - - if (!lastEntry) { - return null; - } - - // Parse state - let state = lastEntry.state; - if (typeof state === 'string') { - try { - state = JSON.parse(state); - } catch (e) { - // state bleibt als String - } - } - - const action = state?.action || state; - - // Wenn letzter Eintrag "stop work" oder null ist, läuft nichts - if (!action || action === 'stop work') { - return null; - } - - // Finde den zugehörigen "start work" - const startWorkId = await this._findLastUnpairedStart(uid, 'start work'); - - if (!startWorkId) { - return null; - } - - // Hole alle Einträge seit dem Start - const allEntries = await worklogRepository.findByUser(uid); - const startWorkEntry = allEntries.find(e => e.id === startWorkId); - - if (!startWorkEntry) { - return null; - } - - // Sammle alle abgeschlossenen Pausen - const pauseDurations = []; - let currentPauseStart = null; - - // Finde alle Pausen-Paare - const relevantEntries = allEntries.filter(e => { - const eDate = new Date(e.tstamp); - const startDate = new Date(startWorkEntry.tstamp); - return eDate >= startDate; - }); - - - const pauseStarts = {}; - - // Hole Timefixes für alle relevanten Einträge (inklusive Pausen) - const allEntryIds = relevantEntries.map(e => e.id); - const pauseTimefixMap = await worklogRepository.getTimefixesByWorklogIds(allEntryIds); - - relevantEntries.forEach(entry => { - let eState = entry.state; - if (typeof eState === 'string') { - try { - eState = JSON.parse(eState); - } catch (e) { - // ignore - } - } - - const eAction = eState?.action || eState; - - if (eAction === 'start pause') { - pauseStarts[entry.id] = entry; - } else if (eAction === 'stop pause' && entry.relatedTo_id) { - const startPause = pauseStarts[entry.relatedTo_id]; - if (startPause) { - // Prüfe auf Timefix-Korrekturen für Pausen - const pauseStartFix = pauseTimefixMap.get(startPause.id)?.find(f => f.fix_type === 'start pause'); - const pauseEndFix = pauseTimefixMap.get(entry.id)?.find(f => f.fix_type === 'stop pause'); - - // Verwende korrigierte Zeiten falls vorhanden - const pStartTime = pauseStartFix?.fix_date_time || startPause.tstamp; - const pEndTime = pauseEndFix?.fix_date_time || entry.tstamp; - - const duration = new Date(pEndTime).getTime() - new Date(pStartTime).getTime(); - pauseDurations.push(duration); - delete pauseStarts[entry.relatedTo_id]; - } - } - }); - - // Wenn noch ein pauseStart übrig ist, ist das die laufende Pause - const runningPauseIds = Object.keys(pauseStarts); - if (runningPauseIds.length > 0) { - const pauseId = parseInt(runningPauseIds[0]); - const pauseEntry = pauseStarts[pauseId]; - - // Prüfe auf Timefix-Korrektur für laufende Pause - const currentPauseTimefix = pauseTimefixMap.get(pauseId)?.find(f => f.fix_type === 'start pause'); - currentPauseStart = currentPauseTimefix?.fix_date_time || pauseEntry.tstamp; - } + const blocks = this._buildEffectiveWorkBlocks(await this._getEffectiveEntries(uid)); + const runningBlock = blocks.at(-1); + if (!runningBlock || runningBlock.end) return null; + const startWorkEntry = runningBlock.start; + const startWorkId = startWorkEntry.id; + const pauseDurations = runningBlock.pauses.map(pause => + new Date(pause.end.effectiveTime).getTime() - new Date(pause.start.effectiveTime).getTime() + ); + const currentPauseStart = runningBlock.runningPause?.effectiveTime || null; // Prüfe auf Timefix-Korrektur für die Start-Zeit (timefixMap bereits geholt in pauseTimefixMap) - const startTimefix = pauseTimefixMap.get(startWorkId)?.find(f => f.fix_type === 'start work'); - - let displayStartTime = startWorkEntry.tstamp; - if (startTimefix && startTimefix.fix_date_time) { - // Verwende korrigierte Zeit - displayStartTime = startTimefix.fix_date_time; - } + const displayStartTime = startWorkEntry.effectiveTime; // Stelle sicher, dass startTime ein String ist (kein Date-Objekt) // Konvertiere UTC zu lokaler Zeit für Frontend @@ -2419,8 +2176,8 @@ class TimeEntryService { id: startWorkId, startTime: startTimeStr, // Explizit als String endTime: null, - description: (state?.description || ''), - project: (state?.project || 'Allgemein'), + description: '', + project: 'Allgemein', duration: null, isRunning: true, userId: uid,