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
347 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|