Time Migration

This commit is contained in:
2025-12-14 16:29:00 +01:00
parent e5c3863eee
commit 267a57c4ea
6 changed files with 1001 additions and 2 deletions

View File

@@ -0,0 +1,85 @@
import {
pgTable,
uuid,
bigint,
text,
timestamp,
jsonb,
index,
check,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import {tenants} from "./tenants";
import {authUsers} from "./auth_users";
export const stafftimeevents = pgTable(
"staff_time_events",
{
id: uuid("id").primaryKey().defaultRandom(),
tenant_id: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id),
user_id: uuid("user_id")
.notNull()
.references(() => authUsers.id),
// Akteur
actortype: text("actor_type").notNull(), // 'user' | 'system'
actoruser_id: uuid("actor_user_id").references(() => authUsers.id),
// Zeit
eventtime: timestamp("event_time", {
withTimezone: true,
}).notNull(),
// Fachliche Bedeutung
eventtype: text("event_type").notNull(),
// Quelle
source: text("source").notNull(), // web | mobile | terminal | system
// Entkräftung
invalidates_event_id: uuid("invalidates_event_id")
.references(() => stafftimeevents.id),
//Beziehung Approval etc
related_event_id: uuid("related_event_id")
.references(() => stafftimeevents.id),
// Zusatzdaten
metadata: jsonb("metadata"),
// Technisch
created_at: timestamp("created_at", {
withTimezone: true,
})
.defaultNow()
.notNull(),
},
(table) => ({
// Indizes
tenantUserTimeIdx: index("idx_time_events_tenant_user_time").on(
table.tenant_id,
table.user_id,
table.eventtime
),
createdAtIdx: index("idx_time_events_created_at").on(table.created_at),
invalidatesIdx: index("idx_time_events_invalidates").on(
table.invalidates_event_id
),
// Constraints
actorUserCheck: check(
"time_events_actor_user_check",
sql`
(actor_type = 'system' AND actor_user_id IS NULL)
OR
(actor_type = 'user' AND actor_user_id IS NOT NULL)
`
),
})
);

View File

@@ -0,0 +1,229 @@
// src/services/buildTimeEvaluationFromSpans.ts
import { FastifyInstance } from "fastify";
import { and, eq, gte, lte, inArray } from "drizzle-orm";
import { authProfiles, holidays } from "../../../db/schema";
import { DerivedSpan } from "./derivetimespans.service"; // Importiert den angereicherten Span-Typ
// Definiert das erwartete Rückgabeformat
export type TimeEvaluationResult = {
user_id: string;
tenant_id: number;
from: string;
to: string;
// Sollzeit
timeSpanWorkingMinutes: number;
// Arbeitszeit Salden
sumWorkingMinutesSubmitted: number;
sumWorkingMinutesApproved: number;
// Abwesenheiten (minuten und Tage)
sumWorkingMinutesRecreationDays: number;
sumRecreationDays: number;
sumWorkingMinutesVacationDays: number;
sumVacationDays: number;
sumWorkingMinutesSickDays: number;
sumSickDays: number;
// Endsalden
saldoApproved: number; // Saldo basierend auf genehmigter Zeit
saldoSubmitted: number; // Saldo basierend auf eingereichter/genehmigter Zeit
spans: DerivedSpan[];
};
// Hilfsfunktion zur Berechnung der Minuten (nur für geschlossene Spannen)
const calcMinutes = (start: Date, end: Date | null): number => {
if (!end) return 0;
return (end.getTime() - start.getTime()) / 60000;
};
export async function buildTimeEvaluationFromSpans(
server: FastifyInstance,
user_id: string,
tenant_id: number,
startDateInput: string,
endDateInput: string,
// Der wichtigste Unterschied: Wir nehmen die angereicherten Spannen als Input
spans: DerivedSpan[]
): Promise<TimeEvaluationResult> {
const startDate = server.dayjs(startDateInput);
const endDate = server.dayjs(endDateInput);
// -------------------------------------------------------------
// 1⃣ Profil und Feiertage laden (WIE IM ALTEN SERVICE)
// -------------------------------------------------------------
const profileRows = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.user_id, user_id),
eq(authProfiles.tenant_id, tenant_id)
)
)
.limit(1);
const profile = profileRows[0];
if (!profile) throw new Error("Profil konnte nicht geladen werden.");
const holidaysRows = await server.db
.select({
date: holidays.date,
})
.from(holidays)
.where(
and(
inArray(holidays.state_code, [profile.state_code, "DE"]),
gte(holidays.date, startDate.format("YYYY-MM-DD")),
lte(holidays.date, endDate.add(1, "day").format("YYYY-MM-DD"))
)
);
// -------------------------------------------------------------
// 2⃣ Sollzeit berechnen (WIE IM ALTEN SERVICE)
// -------------------------------------------------------------
let timeSpanWorkingMinutes = 0;
const totalDays = endDate.add(1, "day").diff(startDate, "days");
for (let i = 0; i < totalDays; i++) {
const date = startDate.add(i, "days");
const weekday = date.day();
timeSpanWorkingMinutes +=
(profile.weekly_regular_working_hours?.[weekday] || 0) * 60;
}
// -------------------------------------------------------------
// 3⃣ Arbeits- und Abwesenheitszeiten berechnen (NEUE LOGIK)
// -------------------------------------------------------------
let sumWorkingMinutesSubmitted = 0;
let sumWorkingMinutesApproved = 0;
let sumWorkingMinutesVacationDays = 0;
let sumVacationDays = 0;
let sumWorkingMinutesSickDays = 0;
let sumSickDays = 0;
// Akkumulieren der Zeiten basierend auf dem abgeleiteten Typ und Status
for (const span of spans) {
// **A. Arbeitszeiten (WORK)**
if (span.type === "work") {
const minutes = calcMinutes(span.startedAt, span.endedAt);
// Zähle zur eingereichten Summe, wenn der Status submitted oder approved ist
if (span.status === "submitted" || span.status === "approved") {
sumWorkingMinutesSubmitted += minutes;
}
// Zähle zur genehmigten Summe, wenn der Status approved ist
if (span.status === "approved") {
sumWorkingMinutesApproved += minutes;
}
}
// **B. Abwesenheiten (VACATION, SICK)**
// Wir verwenden die Logik aus dem alten Service: Berechnung der Sollzeit
// basierend auf den Tagen der Span (Voraussetzung: Spannen sind Volltages-Spannen)
if (span.type === "vacation" || span.type === "sick") {
// Behandle nur genehmigte Abwesenheiten für die Saldenberechnung
if (span.status !== "approved") {
continue;
}
const startDay = server.dayjs(span.startedAt).startOf('day');
// Wenn endedAt null ist (offene Span), nehmen wir das Ende des Zeitraums
const endDay = span.endedAt ? server.dayjs(span.endedAt).startOf('day') : endDate.startOf('day');
// Berechnung der Tage der Span
const days = endDay.diff(startDay, "day") + 1;
for (let i = 0; i < days; i++) {
const day = startDay.add(i, "day");
const weekday = day.day();
const hours = profile.weekly_regular_working_hours?.[weekday] || 0;
if (span.type === "vacation") {
sumWorkingMinutesVacationDays += hours * 60;
} else if (span.type === "sick") {
sumWorkingMinutesSickDays += hours * 60;
}
}
if (span.type === "vacation") {
sumVacationDays += days;
} else if (span.type === "sick") {
sumSickDays += days;
}
}
// PAUSE Spannen werden ignoriert, da sie in der faktischen Ableitung bereits von WORK abgezogen wurden.
}
// -------------------------------------------------------------
// 4⃣ Feiertagsausgleich (WIE IM ALTEN SERVICE)
// -------------------------------------------------------------
let sumWorkingMinutesRecreationDays = 0;
let sumRecreationDays = 0;
if (profile.recreation_days_compensation && holidaysRows?.length) {
holidaysRows.forEach(({ date }) => {
const weekday = server.dayjs(date).day();
const hours = profile.weekly_regular_working_hours?.[weekday] || 0;
sumWorkingMinutesRecreationDays += hours * 60;
sumRecreationDays++;
});
}
// -------------------------------------------------------------
// 5⃣ Salden berechnen (NEUE LOGIK)
// -------------------------------------------------------------
const totalCompensatedMinutes =
sumWorkingMinutesRecreationDays +
sumWorkingMinutesVacationDays +
sumWorkingMinutesSickDays;
// Saldo basierend auf GENEHMIGTER Arbeitszeit
const totalApprovedMinutes = sumWorkingMinutesApproved + totalCompensatedMinutes;
const saldoApproved = totalApprovedMinutes - timeSpanWorkingMinutes;
// Saldo basierend auf EINGEREICHTER und GENEHMIGTER Arbeitszeit
const totalSubmittedMinutes = sumWorkingMinutesSubmitted + totalCompensatedMinutes;
const saldoSubmitted = totalSubmittedMinutes - timeSpanWorkingMinutes;
// -------------------------------------------------------------
// 6⃣ Rückgabe
// -------------------------------------------------------------
return {
user_id,
tenant_id,
from: startDate.format("YYYY-MM-DD"),
to: endDate.format("YYYY-MM-DD"),
timeSpanWorkingMinutes,
sumWorkingMinutesSubmitted,
sumWorkingMinutesApproved,
sumWorkingMinutesRecreationDays,
sumRecreationDays,
sumWorkingMinutesVacationDays,
sumVacationDays,
sumWorkingMinutesSickDays,
sumSickDays,
saldoApproved,
saldoSubmitted,
spans,
};
}

View File

@@ -0,0 +1,165 @@
type State = "IDLE" | "WORKING" | "PAUSED" | "ABSENT";
export type SpanStatus = "factual" | "submitted" | "approved" | "rejected";
export type DerivedSpan = {
type: "work" | "pause" | "vacation" | "sick" | "overtime_compensation";
startedAt: Date;
endedAt: Date | null;
sourceEventIds: string[];
status: SpanStatus;
statusActorId?: string;
};
type TimeEvent = {
id: string;
eventtype: string;
eventtime: Date;
};
// Liste aller faktischen Event-Typen, die die Zustandsmaschine beeinflussen
const FACTUAL_EVENT_TYPES = new Set([
"work_start",
"pause_start",
"pause_end",
"work_end",
"auto_stop", // Wird als work_end behandelt
"vacation_start",
"vacation_end",
"sick_start",
"sick_end",
"overtime_compensation_start",
"overtime_compensation_end",
]);
export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
// 1. FILTERN: Nur faktische Events verarbeiten
const events = allValidEvents.filter(event =>
FACTUAL_EVENT_TYPES.has(event.eventtype)
);
const spans: DerivedSpan[] = [];
let state: State = "IDLE";
let currentStart: Date | null = null;
let currentType: DerivedSpan["type"] | null = null;
let sourceEventIds: string[] = [];
const closeSpan = (end: Date) => {
if (!currentStart || !currentType) return;
spans.push({
type: currentType,
startedAt: currentStart,
endedAt: end,
sourceEventIds: [...sourceEventIds],
// Standardstatus ist "factual", wird später angereichert
status: "factual"
});
currentStart = null;
currentType = null;
sourceEventIds = [];
};
const closeOpenSpanAsRunning = () => {
if (!currentStart || !currentType) return;
spans.push({
type: currentType,
startedAt: currentStart,
endedAt: null,
sourceEventIds: [...sourceEventIds],
// Standardstatus ist "factual", wird später angereichert
status: "factual"
});
currentStart = null;
currentType = null;
sourceEventIds = [];
};
for (const event of events) {
sourceEventIds.push(event.id);
switch (event.eventtype) {
/* =========================
ARBEITSZEIT
========================= */
case "work_start":
if (state === "WORKING" || state === "PAUSED" || state === "ABSENT") {
// Schließt die vorherige Spanne (falls z.B. work_start nach sick_start kommt)
closeSpan(event.eventtime);
}
state = "WORKING";
currentStart = event.eventtime;
currentType = "work";
break;
case "pause_start":
if (state === "WORKING") {
closeSpan(event.eventtime);
state = "PAUSED";
currentStart = event.eventtime;
currentType = "pause";
}
break;
case "pause_end":
if (state === "PAUSED") {
closeSpan(event.eventtime);
state = "WORKING";
currentStart = event.eventtime;
currentType = "work";
}
break;
case "work_end":
case "auto_stop":
if (state === "WORKING" || state === "PAUSED") {
closeSpan(event.eventtime);
}
state = "IDLE";
break;
/* =========================
ABWESENHEITEN
========================= */
case "vacation_start":
case "sick_start":
case "overtime_compensation_start":
// Mappt den Event-Typ direkt auf den Span-Typ
const newType = event.eventtype.split('_')[0] as DerivedSpan["type"];
if (state !== "IDLE") {
closeSpan(event.eventtime);
}
state = "ABSENT";
currentStart = event.eventtime;
currentType = newType;
break;
case "vacation_end":
case "sick_end":
case "overtime_compensation_end":
// Extrahiert den Typ der zu beendenden Spanne
const endedType = event.eventtype.split('_')[0] as DerivedSpan["type"];
if (state === "ABSENT" && currentType === endedType) {
closeSpan(event.eventtime);
}
state = "IDLE";
break;
}
}
// 🔴 WICHTIG: Offene Spannen als laufend zurückgeben
if (state !== "IDLE") {
closeOpenSpanAsRunning();
}
return spans;
}

View File

@@ -0,0 +1,91 @@
// src/services/enrichSpansWithStatus.ts (Korrigierte Version)
import { DerivedSpan, SpanStatus } from "./derivetimespans.service";
import { TimeEvent } from "./loadvalidevents.service"; // Jetzt mit related_event_id und actoruser_id
// ... (Rest der Imports)
export function enrichSpansWithStatus(
factualSpans: DerivedSpan[],
allValidEvents: TimeEvent[]
): DerivedSpan[] {
// 1. Map der administrativen Aktionen erstellen
const eventStatusMap = new Map<string, { status: SpanStatus, actorId: string }>();
const administrativeEvents = allValidEvents.filter(e =>
e.eventtype === 'submitted' || e.eventtype === 'approved' || e.eventtype === 'rejected'
);
// allValidEvents ist nach Zeit sortiert
for (const event of administrativeEvents) {
// **Verwendung des expliziten Feldes**
const relatedId = event.related_event_id;
const actorId = event.actoruser_id;
if (relatedId) { // Nur fortfahren, wenn ein Bezugs-Event existiert
let status: SpanStatus = "factual";
// Wir überschreiben den Status des relatedId basierend auf der Event-Historie
if (event.eventtype === 'submitted') status = 'submitted';
else if (event.eventtype === 'approved') status = 'approved';
else if (event.eventtype === 'rejected') status = 'rejected';
eventStatusMap.set(relatedId, { status, actorId });
}
}
// 2. Status der Spannen bestimmen und anreichern
return factualSpans.map(span => {
let approvedCount = 0;
let rejectedCount = 0;
let submittedCount = 0;
let isFactualCount = 0;
for (const sourceId of span.sourceEventIds) {
const statusInfo = eventStatusMap.get(sourceId);
if (statusInfo) {
// Ein faktisches Event kann durch mehrere administrative Events betroffen sein
// Wir speichern im Map nur den letzten Status (z.B. approved überschreibt submitted)
if (statusInfo.status === 'approved') approvedCount++;
else if (statusInfo.status === 'rejected') rejectedCount++;
else if (statusInfo.status === 'submitted') submittedCount++;
} else {
// Wenn kein administratives Event existiert
isFactualCount++;
}
}
// Regel zur Bestimmung des Span-Status:
const totalSourceEvents = span.sourceEventIds.length;
let finalStatus: SpanStatus = "factual";
if (totalSourceEvents > 0) {
// Priorität 1: Rejection
if (rejectedCount > 0) {
finalStatus = "rejected";
}
// Priorität 2: Full Approval
else if (approvedCount === totalSourceEvents) {
finalStatus = "approved";
}
// Priorität 3: Submitted (wenn nicht fully approved oder rejected, aber mindestens eines submitted ist)
else if (submittedCount > 0 || approvedCount > 0) {
finalStatus = "submitted";
// Ein Span ist submitted, wenn es zumindest teilweise eingereicht (oder genehmigt) ist,
// aber nicht alle Events den finalen Status "approved" haben.
}
// Ansonsten bleibt es "factual" (wenn z.B. nur work_start aber nicht work_end eingereicht wurde, oder nichts)
}
// Rückgabe der angereicherten Span
return {
...span,
status: finalStatus,
};
});
}

View File

@@ -0,0 +1,105 @@
// src/services/loadValidEvents.ts
import { stafftimeevents } from "../../../db/schema";
import {sql, and, eq, gte, lte, inArray} from "drizzle-orm";
import { FastifyInstance } from "fastify";
export type TimeType = "work_start" | "work_end" | "pause_start" | "pause_end" | "sick_start" | "sick_end" | "vacation_start" | "vacation_end" | "ovetime_compensation_start" | "overtime_compensation_end";
// Die Definition des TimeEvent Typs, der zurückgegeben wird (muss mit dem tatsächlichen Typ übereinstimmen)
export type TimeEvent = {
id: string;
eventtype: string;
eventtime: Date;
actoruser_id: string;
related_event_id: string | null;
// Fügen Sie hier alle weiteren Felder hinzu, die Sie benötigen
};
export async function loadValidEvents(
server: FastifyInstance,
tenantId: number,
userId: string,
from: Date,
to: Date
): Promise<TimeEvent[]> {
// Definieren Sie einen Alias für die stafftimeevents Tabelle in der äußeren Abfrage
const baseEvents = stafftimeevents;
// Die Subquery, um alle IDs zu finden, die ungültig gemacht wurden
// Wir nennen die innere Tabelle 'invalidatingEvents'
const invalidatingEvents = server.db
.select({
invalidatedId: baseEvents.invalidates_event_id
})
.from(baseEvents)
.as('invalidating_events');
// Die Hauptabfrage
const result = await server.db
.select()
.from(baseEvents)
.where(
and(
// 1. Tenant und User filtern
eq(baseEvents.tenant_id, tenantId),
eq(baseEvents.user_id, userId),
// 2. Zeitbereich filtern (Typensicher)
gte(baseEvents.eventtime, from),
lte(baseEvents.eventtime, to),
// 3. WICHTIG: Korrekturen ausschließen (NOT EXISTS)
// Schließe jedes Event aus, dessen ID in der Liste der invalidates_event_id erscheint.
sql`
not exists (
select 1
from ${stafftimeevents} i
where i.invalidates_event_id = ${baseEvents.id}
)
`
)
)
.orderBy(
// Wichtig für die deriveTimeSpans Logik: Event-Zeit muss primär sein
baseEvents.eventtime,
baseEvents.created_at, // Wenn Eventtime identisch ist, das später erstellte zuerst
baseEvents.id
);
// Mapping auf den sauberen TimeEvent Typ
return result.map(e => ({
id: e.id,
eventtype: e.eventtype,
eventtime: e.eventtime,
// Fügen Sie hier alle weiteren benötigten Felder hinzu (z.B. metadata, actoruser_id)
// ...
})) as TimeEvent[];
}
export async function loadRelatedAdminEvents(server, eventIds) {
if (eventIds.length === 0) return [];
// Lädt alle administrativen Events, die sich auf die faktischen Event-IDs beziehen
const adminEvents = await server.db
.select()
.from(stafftimeevents)
.where(
and(
inArray(stafftimeevents.related_event_id, eventIds),
// Wir müssen hier die Entkräftung prüfen, um z.B. einen abgelehnten submitted-Event auszuschließen
sql`
not exists (
select 1
from ${stafftimeevents} i
where i.invalidates_event_id = ${stafftimeevents}.id
)
`,
)
)
// Muss nach Zeit sortiert sein, um den Status korrekt zu bestimmen!
.orderBy(stafftimeevents.eventtime);
return adminEvents;
}

View File

@@ -10,10 +10,334 @@ import {
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 userId = 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: userId,
actortype: "user",
actoruser_id: userId,
eventtime: normalizeDate(body.eventtime),
eventtype: body.eventtype,
source: "WEB"
}
console.log(dataToInsert)
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/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)
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);
const endDate = new Date(to);
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
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." });
}
});
/*// -------------------------------------------------------------
// ▶ Neue Zeit starten
// -------------------------------------------------------------
server.post("/staff/time", async (req, reply) => {
@@ -232,5 +556,5 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
} catch (err) {
return reply.code(400).send({ error: (err as Error).message })
}
})
})*/
}