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;
}
/**
* 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,