VigilanzaTurni/server/ccnlRules.ts
marco370 42a60fd32f Add CCNL settings management and validation for shift planning
Implement API endpoints for managing CCNL settings and introduce a rules engine for validating shifts against these settings, enhancing compliance with labor regulations.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/EEOXc3D
2025-10-17 10:07:50 +00:00

347 lines
11 KiB
TypeScript

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<Record<string, any>> {
const settings = await storage.getAllCcnlSettings();
const result: Record<string, any> = {};
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<T = any>(key: string): Promise<T | undefined> {
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<CcnlValidationResult> {
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<any> {
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<number> {
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<number> {
// 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<Record<string, CcnlValidationResult>> {
const results: Record<string, CcnlValidationResult> = {};
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<string, number>;
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,
};
}