import { storage } from "./storage"; import { db } from "./db"; import { shifts, shiftAssignments, guards } from "@shared/schema"; import { eq, and, gte, lte, between, sql } from "drizzle-orm"; import { differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, differenceInDays } from "date-fns"; export interface CcnlRule { key: string; value: string | number | boolean; description?: string; } export interface CcnlValidationResult { isValid: boolean; violations: CcnlViolation[]; warnings: CcnlWarning[]; } export interface CcnlViolation { type: "max_hours_exceeded" | "insufficient_rest" | "consecutive_limit_exceeded" | "overtime_violation"; severity: "error" | "warning"; message: string; details?: any; } export interface CcnlWarning { type: string; message: string; details?: any; } /** * Carica tutte le impostazioni CCNL dal database e le converte in un oggetto chiave-valore */ export async function loadCcnlSettings(): Promise> { const settings = await storage.getAllCcnlSettings(); const result: Record = {}; for (const setting of settings) { // Converti i valori stringa nei tipi appropriati if (setting.value === "true" || setting.value === "false") { result[setting.key] = setting.value === "true"; } else if (!isNaN(Number(setting.value))) { result[setting.key] = Number(setting.value); } else { result[setting.key] = setting.value; } } return result; } /** * Ottiene un singolo valore CCNL con tipo convertito */ export async function getCcnlValue(key: string): Promise { const setting = await storage.getCcnlSetting(key); if (!setting) return undefined; if (setting.value === "true" || setting.value === "false") { return (setting.value === "true") as T; } else if (!isNaN(Number(setting.value))) { return Number(setting.value) as T; } else { return setting.value as T; } } /** * Valida se un turno rispetta le regole CCNL per un dato agente */ export async function validateShiftForGuard( guardId: string, shiftStartTime: Date, shiftEndTime: Date, excludeShiftId?: string ): Promise { const violations: CcnlViolation[] = []; const warnings: CcnlWarning[] = []; const ccnlSettings = await loadCcnlSettings(); // Calcola le ore del turno proposto (con precisione decimale) const shiftHours = differenceInMinutes(shiftEndTime, shiftStartTime) / 60; // 1. Verifica ore minime di riposo tra turni const minRestHours = ccnlSettings.min_rest_hours_between_shifts || 11; const lastShift = await getLastShiftBeforeDate(guardId, shiftStartTime, excludeShiftId); if (lastShift) { const restHours = differenceInMinutes(shiftStartTime, new Date(lastShift.endTime)) / 60; if (restHours < minRestHours) { violations.push({ type: "insufficient_rest", severity: "error", message: `Riposo insufficiente: ${restHours.toFixed(1)} ore (minimo: ${minRestHours} ore)`, details: { restHours, minRestHours, lastShiftEnd: lastShift.endTime } }); } } // 2. Verifica ore settimanali massime const maxHoursPerWeek = ccnlSettings.max_hours_per_week || 48; const weekStart = startOfWeek(shiftStartTime, { weekStartsOn: 1 }); const weekEnd = endOfWeek(shiftStartTime, { weekStartsOn: 1 }); const weeklyHours = await getGuardHoursInPeriod(guardId, weekStart, weekEnd, excludeShiftId); if (weeklyHours + shiftHours > maxHoursPerWeek) { violations.push({ type: "max_hours_exceeded", severity: "error", message: `Superamento ore settimanali: ${weeklyHours + shiftHours} ore (massimo: ${maxHoursPerWeek} ore)`, details: { currentWeekHours: weeklyHours, shiftHours, maxHoursPerWeek } }); } else if (weeklyHours + shiftHours > maxHoursPerWeek * 0.9) { warnings.push({ type: "approaching_weekly_limit", message: `Vicino al limite settimanale: ${weeklyHours + shiftHours} ore su ${maxHoursPerWeek} ore`, details: { currentWeekHours: weeklyHours, shiftHours, maxHoursPerWeek } }); } // 3. Verifica ore mensili massime const maxHoursPerMonth = ccnlSettings.max_hours_per_month || 190; const monthStart = startOfMonth(shiftStartTime); const monthEnd = endOfMonth(shiftStartTime); const monthlyHours = await getGuardHoursInPeriod(guardId, monthStart, monthEnd, excludeShiftId); if (monthlyHours + shiftHours > maxHoursPerMonth) { violations.push({ type: "max_hours_exceeded", severity: "error", message: `Superamento ore mensili: ${monthlyHours + shiftHours} ore (massimo: ${maxHoursPerMonth} ore)`, details: { currentMonthHours: monthlyHours, shiftHours, maxHoursPerMonth } }); } else if (monthlyHours + shiftHours > maxHoursPerMonth * 0.9) { warnings.push({ type: "approaching_monthly_limit", message: `Vicino al limite mensile: ${monthlyHours + shiftHours} ore su ${maxHoursPerMonth} ore`, details: { currentMonthHours: monthlyHours, shiftHours, maxHoursPerMonth } }); } // 4. Verifica giorni consecutivi const maxDaysConsecutive = ccnlSettings.max_days_consecutive || 6; const consecutiveDays = await getConsecutiveDaysWorked(guardId, shiftStartTime, excludeShiftId); if (consecutiveDays >= maxDaysConsecutive) { violations.push({ type: "consecutive_limit_exceeded", severity: "error", message: `Superamento giorni consecutivi: ${consecutiveDays + 1} giorni (massimo: ${maxDaysConsecutive} giorni)`, details: { consecutiveDays: consecutiveDays + 1, maxDaysConsecutive } }); } // 5. Verifica straordinario settimanale const overtimeThreshold = ccnlSettings.overtime_threshold_week || 40; if (weeklyHours + shiftHours > overtimeThreshold && ccnlSettings.penalty_if_overtime === true) { const overtimeHours = (weeklyHours + shiftHours) - overtimeThreshold; warnings.push({ type: "overtime_warning", message: `Straordinario settimanale: ${overtimeHours.toFixed(1)} ore oltre ${overtimeThreshold} ore`, details: { overtimeHours, overtimeThreshold } }); } return { isValid: violations.filter(v => v.severity === "error").length === 0, violations, warnings }; } /** * Ottiene l'ultimo turno dell'agente prima di una certa data */ async function getLastShiftBeforeDate(guardId: string, beforeDate: Date, excludeShiftId?: string): Promise { const query = db .select() .from(shifts) .innerJoin(shiftAssignments, eq(shifts.id, shiftAssignments.shiftId)) .where( and( eq(shiftAssignments.guardId, guardId), lte(shifts.endTime, beforeDate), excludeShiftId ? sql`${shifts.id} != ${excludeShiftId}` : sql`true` ) ) .orderBy(sql`${shifts.endTime} DESC`) .limit(1); const results = await query; return results[0]?.shifts; } /** * Calcola le ore totali lavorate da un agente in un periodo */ async function getGuardHoursInPeriod( guardId: string, startDate: Date, endDate: Date, excludeShiftId?: string ): Promise { const query = db .select() .from(shifts) .innerJoin(shiftAssignments, eq(shifts.id, shiftAssignments.shiftId)) .where( and( eq(shiftAssignments.guardId, guardId), gte(shifts.startTime, startDate), lte(shifts.endTime, endDate), excludeShiftId ? sql`${shifts.id} != ${excludeShiftId}` : sql`true` ) ); const results = await query; let totalHours = 0; for (const result of results) { const shiftMinutes = differenceInMinutes( new Date(result.shifts.endTime), new Date(result.shifts.startTime) ); totalHours += shiftMinutes / 60; } return totalHours; } /** * Conta i giorni consecutivi lavorati dall'agente fino a una certa data */ async function getConsecutiveDaysWorked( guardId: string, upToDate: Date, excludeShiftId?: string ): Promise { // Ottieni tutti i turni dell'agente negli ultimi 14 giorni const twoWeeksAgo = new Date(upToDate); twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14); const query = db .select() .from(shifts) .innerJoin(shiftAssignments, eq(shifts.id, shiftAssignments.shiftId)) .where( and( eq(shiftAssignments.guardId, guardId), gte(shifts.startTime, twoWeeksAgo), lte(shifts.startTime, upToDate), excludeShiftId ? sql`${shifts.id} != ${excludeShiftId}` : sql`true` ) ) .orderBy(sql`${shifts.startTime} DESC`); const results = await query; if (results.length === 0) return 0; // Conta i giorni consecutivi partendo dalla data più recente let consecutiveDays = 0; let currentDate = new Date(upToDate); currentDate.setHours(0, 0, 0, 0); const workedDates = new Set( results.map((r: any) => { const d = new Date(r.shifts.startTime); d.setHours(0, 0, 0, 0); return d.getTime(); }) ); while (workedDates.has(currentDate.getTime())) { consecutiveDays++; currentDate.setDate(currentDate.getDate() - 1); } return consecutiveDays; } /** * Valida un batch di turni per più agenti */ export async function validateMultipleShifts( assignments: Array<{ guardId: string; shiftStartTime: Date; shiftEndTime: Date }> ): Promise> { const results: Record = {}; for (const assignment of assignments) { results[assignment.guardId] = await validateShiftForGuard( assignment.guardId, assignment.shiftStartTime, assignment.shiftEndTime ); } return results; } /** * Ottiene un report sulla disponibilità dell'agente in un periodo */ export async function getGuardAvailabilityReport( guardId: string, startDate: Date, endDate: Date ): Promise<{ totalHours: number; weeklyHours: Record; remainingWeeklyHours: number; remainingMonthlyHours: number; consecutiveDaysWorked: number; lastShiftEnd?: Date; }> { const ccnlSettings = await loadCcnlSettings(); const maxHoursPerWeek = ccnlSettings.max_hours_per_week || 48; const maxHoursPerMonth = ccnlSettings.max_hours_per_month || 190; const totalHours = await getGuardHoursInPeriod(guardId, startDate, endDate); const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); const weekEnd = endOfWeek(new Date(), { weekStartsOn: 1 }); const currentWeekHours = await getGuardHoursInPeriod(guardId, weekStart, weekEnd); const monthStart = startOfMonth(new Date()); const monthEnd = endOfMonth(new Date()); const currentMonthHours = await getGuardHoursInPeriod(guardId, monthStart, monthEnd); const consecutiveDaysWorked = await getConsecutiveDaysWorked(guardId, new Date()); const lastShift = await getLastShiftBeforeDate(guardId, new Date()); return { totalHours, weeklyHours: { current: currentWeekHours, }, remainingWeeklyHours: Math.max(0, maxHoursPerWeek - currentWeekHours), remainingMonthlyHours: Math.max(0, maxHoursPerMonth - currentMonthHours), consecutiveDaysWorked, lastShiftEnd: lastShift ? new Date(lastShift.endTime) : undefined, }; }