456 lines
18 KiB
TypeScript
456 lines
18 KiB
TypeScript
import { FastifyInstance } from "fastify"
|
|
import {
|
|
stafftimeentries,
|
|
stafftimenetryconnects
|
|
} from "../../../db/schema"
|
|
import {
|
|
eq,
|
|
and,
|
|
gte,
|
|
lte,
|
|
desc
|
|
} from "drizzle-orm"
|
|
import {stafftimeevents} from "../../../db/schema/staff_time_events";
|
|
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
|
|
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
|
|
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
|
|
import {z} from "zod";
|
|
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
|
|
|
|
export default async function staffTimeRoutes(server: FastifyInstance) {
|
|
|
|
|
|
server.post("/staff/time/event", async (req, reply) => {
|
|
try {
|
|
const actorId = req.user.user_id;
|
|
const tenantId = req.user.tenant_id
|
|
|
|
const body = req.body as any
|
|
|
|
const normalizeDate = (val: any) => {
|
|
if (!val) return null
|
|
const d = new Date(val)
|
|
return isNaN(d.getTime()) ? null : d
|
|
}
|
|
|
|
const dataToInsert = {
|
|
tenant_id: tenantId,
|
|
user_id: body.user_id,
|
|
actortype: "user",
|
|
actoruser_id: actorId,
|
|
eventtime: normalizeDate(body.eventtime),
|
|
eventtype: body.eventtype,
|
|
source: "WEB",
|
|
payload: body.payload // Payload (z.B. Description) mit speichern
|
|
}
|
|
|
|
const [created] = await server.db
|
|
.insert(stafftimeevents)
|
|
//@ts-ignore
|
|
.values(dataToInsert)
|
|
.returning()
|
|
|
|
return created
|
|
} catch (err: any) {
|
|
console.error(err)
|
|
return reply.code(400).send({ error: err.message })
|
|
}
|
|
})
|
|
|
|
// 🆕 POST /staff/time/edit (Bearbeiten durch Invalidieren + Neu erstellen)
|
|
server.post("/staff/time/edit", async (req, reply) => {
|
|
try {
|
|
// 1. Der "Actor" ist der, der gerade eingeloggt ist (z.B. Manager)
|
|
const actorId = req.user.user_id;
|
|
const tenantId = req.user.tenant_id;
|
|
|
|
const {
|
|
originalEventIds,
|
|
newStart,
|
|
newEnd,
|
|
newType,
|
|
description,
|
|
reason
|
|
} = req.body as {
|
|
originalEventIds: string[],
|
|
newStart: string,
|
|
newEnd: string | null,
|
|
newType: string,
|
|
description?: string,
|
|
reason?: string
|
|
};
|
|
|
|
if (!originalEventIds || originalEventIds.length === 0) {
|
|
return reply.code(400).send({ error: "Keine Events zum Bearbeiten angegeben." });
|
|
}
|
|
|
|
// -----------------------------------------------------------
|
|
// SCHRITT A: Den eigentlichen Besitzer (Mitarbeiter) ermitteln
|
|
// -----------------------------------------------------------
|
|
// Wir holen uns das erste Event aus der Liste, um zu sehen, wem es gehört.
|
|
const existingEvents = await server.db
|
|
.select({
|
|
user_id: stafftimeevents.user_id,
|
|
tenant_id: stafftimeevents.tenant_id
|
|
})
|
|
.from(stafftimeevents)
|
|
.where(and(
|
|
eq(stafftimeevents.id, originalEventIds[0]),
|
|
eq(stafftimeevents.tenant_id, tenantId) // Sicherheitscheck: Nur im eigenen Tenant
|
|
))
|
|
.limit(1);
|
|
|
|
if (existingEvents.length === 0) {
|
|
return reply.code(404).send({ error: "Ursprüngliches Event nicht gefunden oder Zugriff verweigert." });
|
|
}
|
|
|
|
// Das ist der Mitarbeiter, dem die Zeit gehört
|
|
const targetUserId = existingEvents[0].user_id;
|
|
|
|
|
|
// -----------------------------------------------------------
|
|
// SCHRITT B: Transaktion durchführen
|
|
// -----------------------------------------------------------
|
|
await server.db.transaction(async (tx) => {
|
|
|
|
// 1. INVALIDIEREN
|
|
// Wir nutzen 'targetUserId' als Besitzer des Events, aber 'actorId' als Auslöser
|
|
const invalidations = originalEventIds.map(id => ({
|
|
tenant_id: tenantId,
|
|
user_id: targetUserId, // <--- WICHTIG: Gehört dem Mitarbeiter
|
|
actortype: "user",
|
|
actoruser_id: actorId, // <--- WICHTIG: Geändert durch Manager/Self
|
|
eventtime: new Date(),
|
|
eventtype: "invalidated",
|
|
source: "WEB",
|
|
related_event_id: id,
|
|
invalidates_event_id: id,
|
|
metadata: {
|
|
reason: reason || "Bearbeitung",
|
|
replaced_by_edit: true
|
|
}
|
|
}));
|
|
|
|
// @ts-ignore
|
|
await tx.insert(stafftimeevents).values(invalidations);
|
|
|
|
// 2. NEU ERSTELLEN
|
|
|
|
// Start Event
|
|
// @ts-ignore
|
|
await tx.insert(stafftimeevents).values({
|
|
tenant_id: tenantId,
|
|
user_id: targetUserId, // <--- Gehört dem Mitarbeiter
|
|
actortype: "user",
|
|
actoruser_id: actorId, // <--- Erstellt durch Manager/Self
|
|
eventtime: new Date(newStart),
|
|
eventtype: `${newType}_start`,
|
|
source: "WEB",
|
|
payload: { description: description || "" }
|
|
});
|
|
|
|
// End Event (nur wenn vorhanden)
|
|
if (newEnd) {
|
|
// @ts-ignore
|
|
await tx.insert(stafftimeevents).values({
|
|
tenant_id: tenantId,
|
|
user_id: targetUserId, // <--- Gehört dem Mitarbeiter
|
|
actortype: "user",
|
|
actoruser_id: actorId, // <--- Erstellt durch Manager/Self
|
|
eventtime: new Date(newEnd),
|
|
eventtype: `${newType}_end`,
|
|
source: "WEB"
|
|
});
|
|
}
|
|
});
|
|
|
|
return { success: true };
|
|
|
|
} catch (err: any) {
|
|
console.error("Fehler beim Bearbeiten:", err);
|
|
return reply.code(500).send({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// POST /staff/time/submit
|
|
server.post("/staff/time/submit", async (req, reply) => {
|
|
try {
|
|
const userId = req.user.user_id; // Mitarbeiter, der einreicht
|
|
const tenantId = req.user.tenant_id;
|
|
|
|
// Erwartet eine Liste von IDs der faktischen Events (work_start, work_end, etc.)
|
|
const { eventIds } = req.body as { eventIds: string[] };
|
|
|
|
if (eventIds.length === 0) {
|
|
return reply.code(400).send({ error: "Keine Events zum Einreichen angegeben." });
|
|
}
|
|
|
|
const inserts = eventIds.map((eventId) => ({
|
|
tenant_id: tenantId,
|
|
user_id: userId, // Event gehört zum Mitarbeiter
|
|
actortype: "user",
|
|
actoruser_id: userId, // Mitarbeiter ist der Akteur
|
|
eventtime: new Date(),
|
|
eventtype: "submitted", // NEU: Event-Typ für Einreichung
|
|
source: "WEB",
|
|
related_event_id: eventId, // Verweis auf das faktische Event
|
|
}));
|
|
|
|
const createdEvents = await server.db
|
|
.insert(stafftimeevents)
|
|
//@ts-ignore
|
|
.values(inserts)
|
|
.returning();
|
|
|
|
return { submittedCount: createdEvents.length };
|
|
} catch (err: any) {
|
|
console.error(err);
|
|
return reply.code(500).send({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// POST /staff/time/approve
|
|
server.post("/staff/time/approve", async (req, reply) => {
|
|
try {
|
|
// 🚨 Berechtigungsprüfung (Voraussetzung: req.user enthält Manager-Status)
|
|
/*if (!req.user.isManager) {
|
|
return reply.code(403).send({ error: "Keine Genehmigungsberechtigung." });
|
|
}*/
|
|
|
|
const actorId = req.user.user_id; // Manager ist der Akteur
|
|
const tenantId = req.user.tenant_id;
|
|
|
|
const { eventIds, employeeUserId } = req.body as {
|
|
eventIds: string[];
|
|
employeeUserId: string; // Die ID des Mitarbeiters, dessen Zeit genehmigt wird
|
|
};
|
|
|
|
if (eventIds.length === 0) {
|
|
return reply.code(400).send({ error: "Keine Events zur Genehmigung angegeben." });
|
|
}
|
|
|
|
const inserts = eventIds.map((eventId) => ({
|
|
tenant_id: tenantId,
|
|
user_id: employeeUserId, // Event gehört zum Mitarbeiter
|
|
actortype: "user",
|
|
actoruser_id: actorId, // Manager ist der Akteur
|
|
eventtime: new Date(),
|
|
eventtype: "approved", // NEU: Event-Typ für Genehmigung
|
|
source: "WEB",
|
|
related_event_id: eventId, // Verweis auf das faktische Event
|
|
metadata: {
|
|
// Optional: Genehmigungskommentar
|
|
approvedBy: req.user.email
|
|
}
|
|
}));
|
|
|
|
const createdEvents = await server.db
|
|
.insert(stafftimeevents)
|
|
//@ts-ignore
|
|
.values(inserts)
|
|
.returning();
|
|
|
|
return { approvedCount: createdEvents.length };
|
|
} catch (err: any) {
|
|
console.error(err);
|
|
return reply.code(500).send({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// POST /staff/time/reject
|
|
server.post("/staff/time/reject", async (req, reply) => {
|
|
try {
|
|
// 🚨 Berechtigungsprüfung
|
|
/*if (!req.user.isManager) {
|
|
return reply.code(403).send({ error: "Keine Zurückweisungsberechtigung." });
|
|
}*/
|
|
|
|
const actorId = req.user.user_id; // Manager ist der Akteur
|
|
const tenantId = req.user.tenant_id;
|
|
|
|
const { eventIds, employeeUserId, reason } = req.body as {
|
|
eventIds: string[];
|
|
employeeUserId: string;
|
|
reason?: string; // Optionaler Grund für die Ablehnung
|
|
};
|
|
|
|
if (eventIds.length === 0) {
|
|
return reply.code(400).send({ error: "Keine Events zur Ablehnung angegeben." });
|
|
}
|
|
|
|
const inserts = eventIds.map((eventId) => ({
|
|
tenant_id: tenantId,
|
|
user_id: employeeUserId, // Event gehört zum Mitarbeiter
|
|
actortype: "user",
|
|
actoruser_id: actorId, // Manager ist der Akteur
|
|
eventtime: new Date(),
|
|
eventtype: "rejected", // NEU: Event-Typ für Ablehnung
|
|
source: "WEB",
|
|
related_event_id: eventId, // Verweis auf das faktische Event
|
|
metadata: {
|
|
reason: reason || "Ohne Angabe"
|
|
}
|
|
}));
|
|
|
|
const createdEvents = await server.db
|
|
.insert(stafftimeevents)
|
|
//@ts-ignore
|
|
.values(inserts)
|
|
.returning();
|
|
|
|
return { rejectedCount: createdEvents.length };
|
|
} catch (err: any) {
|
|
console.error(err);
|
|
return reply.code(500).send({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// GET /api/staff/time/spans
|
|
server.get("/staff/time/spans", async (req, reply) => {
|
|
try {
|
|
// Der eingeloggte User (Anfragesteller)
|
|
const actingUserId = req.user.user_id;
|
|
const tenantId = req.user.tenant_id;
|
|
|
|
// Query-Parameter: targetUserId ist optional
|
|
const { targetUserId } = req.query as { targetUserId?: string };
|
|
|
|
// Falls eine targetUserId übergeben wurde, nutzen wir diese, sonst die eigene ID
|
|
const evaluatedUserId = targetUserId || actingUserId;
|
|
|
|
// 💡 "Unendlicher" Zeitraum, wie gewünscht
|
|
const startDate = new Date(0); // 1970
|
|
const endDate = new Date("2100-12-31");
|
|
|
|
// SCHRITT 1: Lade ALLE Events für den ZIEL-USER (evaluatedUserId)
|
|
// WICHTIG: loadValidEvents muss "invalidated" Events und deren related_ids ausfiltern!
|
|
const allEventsInTimeFrame = await loadValidEvents(
|
|
server,
|
|
tenantId,
|
|
evaluatedUserId, // <--- Hier wird jetzt die variable ID genutzt
|
|
startDate,
|
|
endDate
|
|
);
|
|
|
|
// SCHRITT 2: Filtere faktische Events
|
|
const FACTUAL_EVENT_TYPES = new Set([
|
|
"work_start", "work_end", "pause_start", "pause_end",
|
|
"sick_start", "sick_end", "vacation_start", "vacation_end",
|
|
"overtime_compensation_start", "overtime_compensation_end",
|
|
"auto_stop"
|
|
]);
|
|
const factualEvents = allEventsInTimeFrame.filter(e => FACTUAL_EVENT_TYPES.has(e.eventtype));
|
|
|
|
// SCHRITT 3: Hole administrative Events
|
|
const factualEventIds = factualEvents.map(e => e.id);
|
|
|
|
if (factualEventIds.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const relatedAdminEvents = await loadRelatedAdminEvents(server, factualEventIds);
|
|
|
|
// SCHRITT 4: Kombinieren und Sortieren
|
|
const combinedEvents = [
|
|
...factualEvents,
|
|
...relatedAdminEvents,
|
|
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
|
|
|
// SCHRITT 5: Spans ableiten
|
|
const derivedSpans = deriveTimeSpans(combinedEvents);
|
|
|
|
// SCHRITT 6: Spans anreichern
|
|
const enrichedSpans = enrichSpansWithStatus(derivedSpans, combinedEvents);
|
|
|
|
return enrichedSpans;
|
|
|
|
} catch (error) {
|
|
console.error("Fehler beim Laden der Spans:", error);
|
|
return reply.code(500).send({ error: "Interner Fehler beim Laden der Zeitspannen." });
|
|
}
|
|
});
|
|
|
|
server.get("/staff/time/evaluation", async (req, reply) => {
|
|
try {
|
|
// --- 1. Eingangsdaten und Validierung des aktuellen Nutzers ---
|
|
|
|
// Daten des aktuell eingeloggten (anfragenden) Benutzers
|
|
const actingUserId = req.user.user_id;
|
|
const tenantId = req.user.tenant_id;
|
|
|
|
// Query-Parameter extrahieren
|
|
const { from, to, targetUserId } = req.query as {
|
|
from: string,
|
|
to: string,
|
|
targetUserId?: string // Optionale ID des Benutzers, dessen Daten abgerufen werden sollen
|
|
};
|
|
|
|
// Die ID, für die die Auswertung tatsächlich durchgeführt wird
|
|
const evaluatedUserId = targetUserId || actingUserId;
|
|
|
|
const startDate = new Date(from);
|
|
let endDateQuery = new Date(to);
|
|
endDateQuery.setDate(endDateQuery.getDate() + 1);
|
|
const endDate = endDateQuery;
|
|
|
|
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
|
return reply.code(400).send({ error: "Ungültiges Datumsformat." });
|
|
}
|
|
|
|
// --- 3. Ausführung der Logik für den ermittelten Benutzer ---
|
|
|
|
// SCHRITT 1: Lade ALLE gültigen Events im Zeitraum
|
|
// WICHTIG: loadValidEvents muss "invalidated" Events und deren related_ids ausfiltern!
|
|
const allEventsInTimeFrame = await loadValidEvents(
|
|
server, tenantId, evaluatedUserId, startDate, endDate // Verwendung der evaluatedUserId
|
|
);
|
|
|
|
// 1b: Trenne Faktische und Administrative Events
|
|
const FACTUAL_EVENT_TYPES = new Set([
|
|
"work_start", "work_end", "pause_start", "pause_end",
|
|
"sick_start", "sick_end", "vacation_start", "vacation_end",
|
|
"overtime_compensation_start", "overtime_compensation_end",
|
|
"auto_stop"
|
|
]);
|
|
const factualEvents = allEventsInTimeFrame.filter(e => FACTUAL_EVENT_TYPES.has(e.eventtype));
|
|
|
|
// 1c: Sammle alle IDs der faktischen Events im Zeitraum
|
|
const factualEventIds = factualEvents.map(e => e.id);
|
|
|
|
// SCHRITT 2: Lade die administrativen Events, die sich auf diese IDs beziehen (auch NACH dem Zeitraum)
|
|
const relatedAdminEvents = await loadRelatedAdminEvents(server, factualEventIds);
|
|
|
|
// SCHRITT 3: Kombiniere alle Events für die Weiterverarbeitung
|
|
const combinedEvents = [
|
|
...factualEvents,
|
|
...relatedAdminEvents,
|
|
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
|
|
|
// SCHRITT 4: Ableiten und Anreichern
|
|
const derivedSpans = deriveTimeSpans(combinedEvents);
|
|
const enrichedSpans = enrichSpansWithStatus(derivedSpans, combinedEvents);
|
|
|
|
// SCHRITT 5: Erstellung der finalen Auswertung (Summen und Salden)
|
|
const evaluationSummary = await buildTimeEvaluationFromSpans(
|
|
server,
|
|
evaluatedUserId, // Verwendung der evaluatedUserId
|
|
tenantId,
|
|
from,
|
|
to,
|
|
enrichedSpans
|
|
);
|
|
|
|
return {
|
|
userId: evaluatedUserId, // Rückgabe der ID, für die ausgewertet wurde
|
|
spans: enrichedSpans,
|
|
summary: evaluationSummary
|
|
};
|
|
|
|
|
|
} catch (error) {
|
|
console.error("Fehler in /staff/time/evaluation:", error);
|
|
return reply.code(500).send({ error: "Interner Serverfehler bei der Zeitauswertung." });
|
|
}
|
|
});
|
|
|
|
} |