Füge Logik zur Berechnung wirksamer Arbeits- und Pausenblöcke hinzu und optimiere die Verarbeitung von Zeiteinträgen
All checks were successful
Deploy production / deploy (push) Successful in 53s

This commit is contained in:
Torsten Schulz (local)
2026-08-28 07:30:14 +02:00
parent f47414f095
commit 0eab7d7ad3

View File

@@ -21,6 +21,63 @@ class TimeEntryService {
this.defaultUserId = userId; 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 * Alle Zeiteinträge abrufen
* @param {number} userId - Benutzer-ID (optional) * @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); const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
if (runningEntry) { if (runningEntry) {
// Berechne ALLE Arbeitszeiten des heutigen Tages // Arbeitszeit wird aus der korrigierten Ereignisfolge gebildet. Damit
// Hole alle Einträge von heute (wird auch für Pausen verwendet) // darf eine nachträglich umtypisierte Buchung keine Phantompause mehr
allEntries = await worklogRepository.findByDateRange(uid, todayStart, todayEnd); // erzeugen.
const blocks = this._buildEffectiveWorkBlocks(await this._getEffectiveEntries(uid));
// 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
let totalWorkedMs = 0; let totalWorkedMs = 0;
for (const block of blocks) {
Object.values(startWorks).forEach(block => { const start = new Date(block.start.effectiveTime);
const blockStart = new Date(block.startTime).getTime(); if (start < todayStart || start > todayEnd) continue;
const blockEnd = block.endTime ? new Date(block.endTime).getTime() : now.getTime(); const endMs = block.end ? new Date(block.end.effectiveTime).getTime() : now.getTime();
let duration = endMs - start.getTime();
let blockWorkedMs = blockEnd - blockStart; duration -= block.pauses.reduce((sum, pause) => sum +
new Date(pause.end.effectiveTime).getTime() - new Date(pause.start.effectiveTime).getTime(), 0);
// Ziehe abgeschlossene Pausen ab if (block.runningPause) duration -= now.getTime() - new Date(block.runningPause.effectiveTime).getTime();
block.pauses.forEach(pause => { totalWorkedMs += duration;
if (pause.endTime) { }
const pauseDuration = new Date(pause.endTime).getTime() - new Date(pause.startTime).getTime(); const totalSeconds = Math.max(0, Math.floor(totalWorkedMs / 1000));
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);
const hours = Math.floor(totalSeconds / 3600); const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60); const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60; const seconds = totalSeconds % 60;
currentlyWorked = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; 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; : fix.fix_date_time;
}; };
const pairs = []; const effectiveEntries = [...allEntries]
const openWorkEntries = new Map(); .map(entry => ({ ...entry, effectiveAction: getAction(entry), effectiveTime: getEffectiveTime(entry) }))
[...allEntries] .sort((a, b) => new Date(a.effectiveTime) - new Date(b.effectiveTime) || a.id - b.id);
.sort((a, b) => new Date(getEffectiveTime(a)) - new Date(getEffectiveTime(b))) const pairs = this._buildEffectiveWorkBlocks(effectiveEntries).map(block => ({
.forEach(entry => { id: block.start.id,
const action = getAction(entry); end_id: block.end?.id || null,
if (action === 'start work') { start_time: block.start.tstamp,
openWorkEntries.set(String(entry.id), entry); end_time: block.end?.tstamp || null,
} else if (action === 'stop work' && entry.relatedTo_id) { start_state: block.start.state,
const startEntry = openWorkEntries.get(String(entry.relatedTo_id)); end_state: block.end?.state || null,
if (startEntry) { // Die nachfolgenden Darstellungsfelder verwenden diese bereits
pairs.push({ // normalisierten Pausen, statt die ursprünglichen Referenzen erneut zu
id: startEntry.id, // interpretieren.
end_id: entry.id, effectivePauses: block.pauses,
start_time: startEntry.tstamp, runningPause: block.runningPause
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
});
});
// Hole Vacation-Einträge für diese Woche // Hole Vacation-Einträge für diese Woche
const vacations = await worklogRepository.getVacationsByUserInDateRange(uid, weekStart, weekEnd); const vacations = await worklogRepository.getVacationsByUserInDateRange(uid, weekStart, weekEnd);
@@ -1515,42 +1464,7 @@ class TimeEntryService {
// Berechne Pausen NUR für diesen spezifischen Arbeitsblock // Berechne Pausen NUR für diesen spezifischen Arbeitsblock
// Finde alle Einträge, die zu diesem pair.id gehören (über relatedTo_id) // Finde alle Einträge, die zu diesem pair.id gehören (über relatedTo_id)
const blockEntries = allEntries.filter(e => { const blockEntries = pair.effectivePauses.flatMap(pause => [pause.start, pause.end]);
// 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;
});
// Finde Pausen-Paare NUR für diesen Block // Finde Pausen-Paare NUR für diesen Block
const pausePairs = []; const pausePairs = [];
@@ -1709,41 +1623,10 @@ class TimeEntryService {
} }
// Prüfe auf Pausen NUR für diesen laufenden Arbeitsblock // Prüfe auf Pausen NUR für diesen laufenden Arbeitsblock
const blockEntries = allEntries.filter(e => { const blockEntries = [
// Parse state if it's a JSON string ...pair.effectivePauses.flatMap(pause => [pause.start, pause.end]),
let state = e.state; ...(pair.runningPause ? [pair.runningPause] : [])
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;
});
// Finde laufende Pause // Finde laufende Pause
const pauseStarts = {}; const pauseStarts = {};
@@ -2087,36 +1970,8 @@ class TimeEntryService {
*/ */
async getCurrentState(userId) { async getCurrentState(userId) {
const uid = userId || this.defaultUserId; const uid = userId || this.defaultUserId;
const entries = await this._getEffectiveEntries(uid);
// Hole letzten Worklog-Eintrag (inkl. Timefix-Korrekturen) return entries.at(-1)?.effectiveAction || null;
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;
} }
/** /**
@@ -2196,19 +2051,18 @@ class TimeEntryService {
} }
// stop work und stop pause referenzieren den entsprechenden Start // stop work und stop pause referenzieren den entsprechenden Start
if (newAction === 'stop work') { const blocks = this._buildEffectiveWorkBlocks(await this._getEffectiveEntries(userId));
// Finde letzten 'start work' ohne 'stop work' const activeBlock = blocks.at(-1);
return await this._findLastUnpairedStart(userId, 'start work'); 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') { if (newAction === 'stop pause') {
// Finde letzten 'start pause' ohne 'stop pause' return activeBlock.runningPause?.id || null;
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 null; return null;
@@ -2283,115 +2137,18 @@ class TimeEntryService {
*/ */
async getRunningEntry(userId = null) { async getRunningEntry(userId = null) {
const uid = userId || this.defaultUserId; const uid = userId || this.defaultUserId;
const blocks = this._buildEffectiveWorkBlocks(await this._getEffectiveEntries(uid));
// Hole letzten Eintrag const runningBlock = blocks.at(-1);
const lastEntry = await worklogRepository.findLatestByUser(uid); if (!runningBlock || runningBlock.end) return null;
const startWorkEntry = runningBlock.start;
if (!lastEntry) { const startWorkId = startWorkEntry.id;
return null; const pauseDurations = runningBlock.pauses.map(pause =>
} new Date(pause.end.effectiveTime).getTime() - new Date(pause.start.effectiveTime).getTime()
);
// Parse state const currentPauseStart = runningBlock.runningPause?.effectiveTime || null;
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;
}
// Prüfe auf Timefix-Korrektur für die Start-Zeit (timefixMap bereits geholt in pauseTimefixMap) // 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'); const displayStartTime = startWorkEntry.effectiveTime;
let displayStartTime = startWorkEntry.tstamp;
if (startTimefix && startTimefix.fix_date_time) {
// Verwende korrigierte Zeit
displayStartTime = startTimefix.fix_date_time;
}
// Stelle sicher, dass startTime ein String ist (kein Date-Objekt) // Stelle sicher, dass startTime ein String ist (kein Date-Objekt)
// Konvertiere UTC zu lokaler Zeit für Frontend // Konvertiere UTC zu lokaler Zeit für Frontend
@@ -2419,8 +2176,8 @@ class TimeEntryService {
id: startWorkId, id: startWorkId,
startTime: startTimeStr, // Explizit als String startTime: startTimeStr, // Explizit als String
endTime: null, endTime: null,
description: (state?.description || ''), description: '',
project: (state?.project || 'Allgemein'), project: 'Allgemein',
duration: null, duration: null,
isRunning: true, isRunning: true,
userId: uid, userId: uid,