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
This commit is contained in:
parent
918c5f0226
commit
42a60fd32f
@ -0,0 +1,271 @@
|
||||
Perfetto, Marco. Ti preparo un “task brief” chiaro e operativo da dare a Replit (o ad un dev) così implementano senza ambiguità. Puoi incollarlo così com’è.
|
||||
|
||||
---
|
||||
|
||||
# Task: Vista “Pianificazione Operativa” con disponibilità per **data**
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Realizzare nella pagina **Pianificazione Operativa**:
|
||||
|
||||
1. Un **selettore data** che filtra e calcola **disponibilità** di automezzi e agenti **alla data selezionata**.
|
||||
2. Un’area centrale **divisa in due colonne**:
|
||||
|
||||
* **Sinistra**: automezzi (ordinati per disponibilità).
|
||||
* **Destra**: agenti (ordinati per disponibilità + priorità CCNL).
|
||||
3. Ogni card (mezzo/agente) mostra **stato**: “Disponibile” (in alto) oppure “Non disponibile: assegnato a Turno #ID/Servizio”.
|
||||
4. **Pulsante “Assegna servizio”** su ciascuna card, che apre il flusso d’assegnazione (mezzo/agente → servizio/turno alla data).
|
||||
5. Per gli **agenti**: l’ordinamento “disponibili” deve privilegiare chi ha **meno ore già programmate** nel periodo di riferimento e **rispetta i vincoli CCNL**.
|
||||
|
||||
---
|
||||
|
||||
## Definizioni rapide
|
||||
|
||||
* **Data di riferimento D**: la data impostata dal selettore (default = oggi).
|
||||
* **Periodo contabile**: ISO week della data D (lun–dom) **configurabile** (`settings.ccnl.reference_period = 'week'|'month'`); default = settimana corrente.
|
||||
* **Turno**: intervallo con inizio/fine, collegato a un servizio e a risorse (agenti/mezzi).
|
||||
* **Disponibilità**:
|
||||
|
||||
* Mezzo disponibile se **non assegnato** in alcun turno che **intersechi D** e non in manutenzione/fermo.
|
||||
* Agente disponibile se **non in turno** in D e se l’assegnazione proposta **rispetta i vincoli CCNL** (vedi sotto).
|
||||
|
||||
---
|
||||
|
||||
## Vincoli CCNL (parametrizzabili)
|
||||
|
||||
Creare un modulo `ccnlRules` con parametri in tabella/config:
|
||||
|
||||
```
|
||||
max_hours_per_week: 48
|
||||
max_hours_per_month: 190
|
||||
min_rest_hours_between_shifts: 11
|
||||
max_night_shifts_consecutive: 6
|
||||
max_days_consecutive: 6
|
||||
min_weekly_rest_hours: 24
|
||||
overtime_threshold_week: 40
|
||||
penalty_if_overtime: true
|
||||
```
|
||||
|
||||
> NB: i valori sono **segnaposto**; leggere da `settings_ccnl` per cliente. Se non presenti, usare default.
|
||||
|
||||
**Funzione chiave**: `ccnlRules.isEligible(agentId, proposedShift, dateD) -> {eligible: boolean, reasons: string[]}`
|
||||
Controlli minimi:
|
||||
|
||||
* Riposo minimo tra fine ultimo turno e inizio `proposedShift.start` ≥ `min_rest_hours_between_shifts`.
|
||||
* Ore accumulate nel periodo di riferimento + ore del turno proposto ≤ limiti (`max_hours_per_week`/`month`).
|
||||
* Vincoli su notturno/consecutività/giorni consecutivi/rest settimanale (se dati).
|
||||
|
||||
---
|
||||
|
||||
## Ordinamento
|
||||
|
||||
### Automezzi (colonna sinistra)
|
||||
|
||||
1. **Disponibili** in alto, poi **Non disponibili**.
|
||||
2. Tra i disponibili: opzionale secondario per **priorità mezzo** (es. min km, categoria, stato batteria/serbatoio se disponibile).
|
||||
3. Tra i non disponibili: ordina per **ora di fine del turno corrente** (prima quelli che si liberano prima).
|
||||
|
||||
### Agenti (colonna destra)
|
||||
|
||||
1. **Disponibili & CCNL-eligible** in alto.
|
||||
2. Ordinare i disponibili per:
|
||||
|
||||
* **OreTotaliPeriodo (crescente)** = somma ore dei turni dell’agente nel periodo di riferimento della data D.
|
||||
* In caso di pari ore: meno straordinari > più straordinari; poi alfabetico.
|
||||
3. Poi **Non disponibili** con motivazione (es. “Assegnato a Turno #123 08:00–16:00”).
|
||||
|
||||
---
|
||||
|
||||
## UI/UX (wireframe funzionale)
|
||||
|
||||
* Header: `DatePicker [D]` + (opz) `Periodo: settimana/mese` (solo admin).
|
||||
* Body: **Grid 2 colonne responsive** (>= md: 2 colonne; < md: stack).
|
||||
|
||||
* **Colonna Sinistra – Automezzi**
|
||||
|
||||
* Card Mezzo: `targa | modello | stato`
|
||||
|
||||
* Badge: `Disponibile` / `Non disponibile – Turno #ID hh:mm–hh:mm (Servizio)`
|
||||
* (se non disponibile) small link “vedi turno/servizio”
|
||||
* CTA: **Assegna servizio**
|
||||
* **Colonna Destra – Agenti**
|
||||
|
||||
* Card Agente: `cognome nome | qualifica`
|
||||
|
||||
* Badge: `Disponibile` / `Non disponibile – Turno #ID`
|
||||
* Righe info: `Ore settimana: Xh`, `Straordinari: Yh`, eventuali warning CCNL (tooltip)
|
||||
* CTA: **Assegna servizio**
|
||||
* Click “Assegna servizio” → apre **modal**:
|
||||
|
||||
* Se chiamato da un **mezzo**: pre-seleziona mezzo, mostra servizi/turni disponibili in D, scegli agente.
|
||||
* Se chiamato da un **agente**: pre-seleziona agente, mostra servizi/turni disponibili in D, scegli mezzo.
|
||||
* **Validazione live CCNL**: mostra `✅ Conforme` o `❌ Motivi`.
|
||||
|
||||
---
|
||||
|
||||
## Modello dati (minimo)
|
||||
|
||||
```ts
|
||||
// Tabella veicoli
|
||||
Vehicle { id, plate, model, status: 'active'|'maintenance'|'out', priority?: number }
|
||||
|
||||
// Tabella agenti
|
||||
Agent { id, first_name, last_name, qualification, enabled: boolean }
|
||||
|
||||
// Turni (calendar)
|
||||
Shift {
|
||||
id, service_id, start_at: datetime, end_at: datetime,
|
||||
assigned_agent_id?: id, assigned_vehicle_id?: id
|
||||
}
|
||||
|
||||
// Servizi
|
||||
Service { id, name, site, notes }
|
||||
|
||||
// Parametri CCNL (per tenant/cliente)
|
||||
SettingsCCNL { key, value }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API (contratti)
|
||||
|
||||
* `GET /planning?date=YYYY-MM-DD`
|
||||
|
||||
* Ritorna:
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2025-10-17",
|
||||
"vehicles": [
|
||||
{ "id": 12, "plate": "AB123CD", "status": "available"|"busy",
|
||||
"busy_shift": { "id": 77, "start_at": "...", "end_at": "...", "service_name": "..." } | null }
|
||||
],
|
||||
"agents": [
|
||||
{ "id": 9, "name": "Rossi Mario",
|
||||
"status": "available"|"busy",
|
||||
"ccnl_eligible": true|false,
|
||||
"hours_period": 28.0,
|
||||
"overtime_hours": 3.0,
|
||||
"busy_shift": { ... } | null,
|
||||
"ccnl_notes": ["min rest ok", "within weekly cap"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
* `POST /assign`
|
||||
|
||||
* Body:
|
||||
|
||||
```json
|
||||
{ "date": "YYYY-MM-DD", "service_id": 10, "shift": {"start_at":"...","end_at":"..."}, "agent_id": 9, "vehicle_id": 12 }
|
||||
```
|
||||
* Validazioni: overlap, veicolo libero, `ccnlRules.isEligible(...)`.
|
||||
* Risposte: `200 {ok:true, created_shift_id}` | `422 {ok:false, errors:[...]}`
|
||||
* `GET /ccnl/preview?agent_id=&start_at=&end_at=`
|
||||
|
||||
* Ritorna esito eleggibilità e motivi.
|
||||
|
||||
---
|
||||
|
||||
## Logica (pseudocodice)
|
||||
|
||||
```pseudo
|
||||
function getPlanning(date D):
|
||||
period = resolvePeriod(D, settings.ccnl.reference_period)
|
||||
shiftsOnD = shifts.where(overlaps(shift, D))
|
||||
shiftsInPeriod = shifts.where(overlaps(shift, period))
|
||||
|
||||
vehicles = allVehicles.map(v):
|
||||
s = find(shiftsOnD, assigned_vehicle_id == v.id)
|
||||
if s exists or v.status == 'maintenance':
|
||||
status = 'busy'
|
||||
busy_shift = s
|
||||
else status = 'available'
|
||||
return { ... }
|
||||
|
||||
agents = allAgents.enabledOnly().map(a):
|
||||
s = find(shiftsOnD, assigned_agent_id == a.id)
|
||||
hours = sumHours(shiftsInPeriod, assigned_agent_id == a.id)
|
||||
if s exists:
|
||||
status='busy'; eligible=false
|
||||
else:
|
||||
eligible = ccnlRules.isEligible(a.id, proposedShiftForD?, D).eligible
|
||||
status='available' if eligible else 'available' (ma con ccnl_eligible=false)
|
||||
overtime = max(0, hours - settings.ccnl.overtime_threshold_week)
|
||||
return { id:a.id, name, status, ccnl_eligible:eligible, hours_period:hours,
|
||||
overtime_hours:overtime, busy_shift:s, ccnl_notes:... }
|
||||
|
||||
sort vehicles: available first, then by (busy_shift.end_at asc)
|
||||
sort agents:
|
||||
1) available & ccnl_eligible first
|
||||
2) by hours_period asc
|
||||
3) by overtime_hours asc
|
||||
4) by last_name asc
|
||||
|
||||
return { date:D, vehicles, agents }
|
||||
```
|
||||
|
||||
> `proposedShiftForD?`: se l’assegnazione è per uno slot predefinito quel giorno (es. 08–16), passarlo per validazione live; se non c’è, `isEligible` valuta solo i vincoli “statici” (riposo/limiti alla data).
|
||||
|
||||
---
|
||||
|
||||
## Casi limite da coprire (test)
|
||||
|
||||
* Mezzo in **manutenzione** → sempre **Non disponibile** con label “Manutenzione”.
|
||||
* Agente con turno che **sconfina** su D (es. notte 22:00–06:00) → è **Non disponibile** in D.
|
||||
* Overlap di turni → `POST /assign` deve restituire `422` con errore “overlap”.
|
||||
* CCNL: riposo < 11h tra fine turno precedente e inizio proposto → `eligible=false` + motivo.
|
||||
* Periodo mensile vs settimanale: calcolo ore coerente al setting.
|
||||
* Ordinamento agenti: a pari ore e overtime, ordinamento alfabetico stabile.
|
||||
* Fuso orario: usare TZ server/tenant, no ambiguità su DST.
|
||||
|
||||
---
|
||||
|
||||
## Accettazione (DoD)
|
||||
|
||||
* Selettore data funziona; ricarica lista con calcoli corretti.
|
||||
* Colonne mezzi/agentI con **badge stato** e **CTA “Assegna servizio”** operative.
|
||||
* Ordinamenti rispettati come da regole.
|
||||
* API `GET /planning` e `POST /assign` implementate con validazioni CCNL.
|
||||
* Copertura test unit ≥ 80% per `ccnlRules` e funzioni di calcolo ore/overlap.
|
||||
* Performance: risposta `GET /planning` ≤ 300 ms con dataset medio (500 agenti, 200 mezzi, 2k turni/sett).
|
||||
|
||||
---
|
||||
|
||||
## Esempi (mock)
|
||||
|
||||
### GET /planning?date=2025-10-17
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2025-10-17",
|
||||
"vehicles": [
|
||||
{ "id": 12, "plate": "AB123CD", "status": "available", "busy_shift": null },
|
||||
{ "id": 7, "plate": "ZZ987YY", "status": "busy",
|
||||
"busy_shift": { "id": 331, "start_at":"2025-10-17T06:00:00Z","end_at":"2025-10-17T14:00:00Z","service_name":"Pattuglia 3"}}
|
||||
],
|
||||
"agents": [
|
||||
{ "id": 9, "name": "Rossi Mario", "status":"available", "ccnl_eligible": true, "hours_period": 24, "overtime_hours": 0, "busy_shift": null, "ccnl_notes": ["rest ok", "within weekly cap"] },
|
||||
{ "id": 4, "name": "Bianchi Luca", "status":"available", "ccnl_eligible": false, "hours_period": 38, "overtime_hours": 0, "busy_shift": null, "ccnl_notes": ["rest < 11h"] },
|
||||
{ "id": 2, "name": "Verdi Anna", "status":"busy", "ccnl_eligible": false, "hours_period": 32, "overtime_hours": 0,
|
||||
"busy_shift": { "id": 329, "start_at":"2025-10-17T08:00:00Z","end_at":"2025-10-17T16:00:00Z","service_name":"Portierato Ospedale"}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Note di implementazione
|
||||
|
||||
* **Timezone**: salvare in UTC, presentare in TZ tenant. Usare libreria affidabile per overlap con turni notturni.
|
||||
* **Configurazione CCNL** per cliente/ente: tabella `settings_ccnl(tenant_id, key, value)` + cache.
|
||||
* **Sicurezza**: endpoint protetti (ruolo operatore/dispatcher).
|
||||
* **Accessibilità**: badge con testo + icona, contrasto AA.
|
||||
* **Telemetry**: loggare motivi di `ineligible` per audit.
|
||||
|
||||
---
|
||||
|
||||
Se vuoi, alla prossima iterazione posso trasformare questo brief in:
|
||||
|
||||
* scheletro **API (Node/Express + Prisma)**,
|
||||
* componenti **React/Next** per la UI (grid 2 colonne, card, modal assegnazione),
|
||||
* modulo `ccnlRules` completo con test unit.
|
||||
346
server/ccnlRules.ts
Normal file
346
server/ccnlRules.ts
Normal file
@ -0,0 +1,346 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
127
server/routes.ts
127
server/routes.ts
@ -407,6 +407,133 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// ============= CCNL SETTINGS ROUTES =============
|
||||
app.get("/api/ccnl-settings", isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const currentUserId = getUserId(req);
|
||||
const currentUser = await storage.getUser(currentUserId);
|
||||
|
||||
// Only admins and coordinators can view CCNL settings
|
||||
if (currentUser?.role !== "admin" && currentUser?.role !== "coordinator") {
|
||||
return res.status(403).json({ message: "Forbidden: Admin or Coordinator access required" });
|
||||
}
|
||||
|
||||
const settings = await storage.getAllCcnlSettings();
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
console.error("Error fetching CCNL settings:", error);
|
||||
res.status(500).json({ message: "Failed to fetch CCNL settings" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/ccnl-settings/:key", isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const currentUserId = getUserId(req);
|
||||
const currentUser = await storage.getUser(currentUserId);
|
||||
|
||||
// Only admins and coordinators can view CCNL settings
|
||||
if (currentUser?.role !== "admin" && currentUser?.role !== "coordinator") {
|
||||
return res.status(403).json({ message: "Forbidden: Admin or Coordinator access required" });
|
||||
}
|
||||
|
||||
const setting = await storage.getCcnlSetting(req.params.key);
|
||||
if (!setting) {
|
||||
return res.status(404).json({ message: "CCNL setting not found" });
|
||||
}
|
||||
res.json(setting);
|
||||
} catch (error) {
|
||||
console.error("Error fetching CCNL setting:", error);
|
||||
res.status(500).json({ message: "Failed to fetch CCNL setting" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/ccnl-settings", isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const currentUserId = getUserId(req);
|
||||
const currentUser = await storage.getUser(currentUserId);
|
||||
|
||||
// Only admins can create/update CCNL settings
|
||||
if (currentUser?.role !== "admin") {
|
||||
return res.status(403).json({ message: "Forbidden: Admin access required" });
|
||||
}
|
||||
|
||||
const { insertCcnlSettingSchema } = await import("@shared/schema");
|
||||
const validationResult = insertCcnlSettingSchema.safeParse(req.body);
|
||||
|
||||
if (!validationResult.success) {
|
||||
return res.status(400).json({
|
||||
message: "Invalid CCNL setting data",
|
||||
errors: validationResult.error.errors
|
||||
});
|
||||
}
|
||||
|
||||
const setting = await storage.upsertCcnlSetting(validationResult.data);
|
||||
res.json(setting);
|
||||
} catch (error) {
|
||||
console.error("Error creating/updating CCNL setting:", error);
|
||||
res.status(500).json({ message: "Failed to create/update CCNL setting" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/ccnl-settings/:key", isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const currentUserId = getUserId(req);
|
||||
const currentUser = await storage.getUser(currentUserId);
|
||||
|
||||
// Only admins can delete CCNL settings
|
||||
if (currentUser?.role !== "admin") {
|
||||
return res.status(403).json({ message: "Forbidden: Admin access required" });
|
||||
}
|
||||
|
||||
await storage.deleteCcnlSetting(req.params.key);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting CCNL setting:", error);
|
||||
res.status(500).json({ message: "Failed to delete CCNL setting" });
|
||||
}
|
||||
});
|
||||
|
||||
// ============= CCNL VALIDATION ROUTES =============
|
||||
app.post("/api/ccnl/validate-shift", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { validateShiftForGuard } = await import("./ccnlRules");
|
||||
const { guardId, shiftStartTime, shiftEndTime, excludeShiftId } = req.body;
|
||||
|
||||
if (!guardId || !shiftStartTime || !shiftEndTime) {
|
||||
return res.status(400).json({
|
||||
message: "Missing required fields: guardId, shiftStartTime, shiftEndTime"
|
||||
});
|
||||
}
|
||||
|
||||
const result = await validateShiftForGuard(
|
||||
guardId,
|
||||
new Date(shiftStartTime),
|
||||
new Date(shiftEndTime),
|
||||
excludeShiftId
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error("Error validating shift:", error);
|
||||
res.status(500).json({ message: "Failed to validate shift" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/ccnl/guard-availability/:guardId", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { getGuardAvailabilityReport } = await import("./ccnlRules");
|
||||
const { guardId } = req.params;
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : new Date();
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : new Date();
|
||||
|
||||
const report = await getGuardAvailabilityReport(guardId, startDate, endDate);
|
||||
res.json(report);
|
||||
} catch (error) {
|
||||
console.error("Error fetching guard availability:", error);
|
||||
res.status(500).json({ message: "Failed to fetch guard availability" });
|
||||
}
|
||||
});
|
||||
|
||||
// ============= CERTIFICATION ROUTES =============
|
||||
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { db } from "./db";
|
||||
import { users, guards, sites, vehicles, contractParameters, serviceTypes } from "@shared/schema";
|
||||
import { users, guards, sites, vehicles, contractParameters, serviceTypes, ccnlSettings } from "@shared/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
@ -53,6 +53,29 @@ async function seed() {
|
||||
}
|
||||
}
|
||||
|
||||
// Create CCNL Settings
|
||||
console.log("⚙️ Creazione impostazioni CCNL...");
|
||||
const defaultCcnlSettings = [
|
||||
{ key: "max_hours_per_week", value: "48", description: "Massimo ore lavorative settimanali" },
|
||||
{ key: "max_hours_per_month", value: "190", description: "Massimo ore lavorative mensili" },
|
||||
{ key: "min_rest_hours_between_shifts", value: "11", description: "Ore minime di riposo tra turni" },
|
||||
{ key: "max_night_shifts_consecutive", value: "6", description: "Massimo turni notturni consecutivi" },
|
||||
{ key: "max_days_consecutive", value: "6", description: "Massimo giorni lavorativi consecutivi" },
|
||||
{ key: "min_weekly_rest_hours", value: "24", description: "Ore minime di riposo settimanale" },
|
||||
{ key: "overtime_threshold_week", value: "40", description: "Soglia ore per straordinario settimanale" },
|
||||
{ key: "penalty_if_overtime", value: "true", description: "Penalità se supera straordinario" },
|
||||
];
|
||||
|
||||
for (const setting of defaultCcnlSettings) {
|
||||
const existing = await db.select().from(ccnlSettings).where(eq(ccnlSettings.key, setting.key)).limit(1);
|
||||
if (existing.length === 0) {
|
||||
await db.insert(ccnlSettings).values(setting);
|
||||
console.log(` + Creata impostazione: ${setting.key}`);
|
||||
} else {
|
||||
console.log(` ✓ Impostazione esistente: ${setting.key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create CCNL contract parameters
|
||||
console.log("📋 Creazione parametri contrattuali CCNL...");
|
||||
const existingParams = await db.select().from(contractParameters).limit(1);
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
absenceAffectedShifts,
|
||||
contractParameters,
|
||||
serviceTypes,
|
||||
ccnlSettings,
|
||||
type User,
|
||||
type UpsertUser,
|
||||
type Guard,
|
||||
@ -51,6 +52,8 @@ import {
|
||||
type InsertContractParameters,
|
||||
type ServiceType,
|
||||
type InsertServiceType,
|
||||
type CcnlSetting,
|
||||
type InsertCcnlSetting,
|
||||
} from "@shared/schema";
|
||||
import { db } from "./db";
|
||||
import { eq, and, gte, lte, desc } from "drizzle-orm";
|
||||
@ -144,6 +147,12 @@ export interface IStorage {
|
||||
getContractParameters(): Promise<ContractParameters | undefined>;
|
||||
createContractParameters(params: InsertContractParameters): Promise<ContractParameters>;
|
||||
updateContractParameters(id: string, params: Partial<InsertContractParameters>): Promise<ContractParameters | undefined>;
|
||||
|
||||
// CCNL Settings operations
|
||||
getAllCcnlSettings(): Promise<CcnlSetting[]>;
|
||||
getCcnlSetting(key: string): Promise<CcnlSetting | undefined>;
|
||||
upsertCcnlSetting(setting: InsertCcnlSetting): Promise<CcnlSetting>;
|
||||
deleteCcnlSetting(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class DatabaseStorage implements IStorage {
|
||||
@ -614,6 +623,36 @@ export class DatabaseStorage implements IStorage {
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
// CCNL Settings operations
|
||||
async getAllCcnlSettings(): Promise<CcnlSetting[]> {
|
||||
return await db.select().from(ccnlSettings);
|
||||
}
|
||||
|
||||
async getCcnlSetting(key: string): Promise<CcnlSetting | undefined> {
|
||||
const [setting] = await db.select().from(ccnlSettings).where(eq(ccnlSettings.key, key));
|
||||
return setting;
|
||||
}
|
||||
|
||||
async upsertCcnlSetting(setting: InsertCcnlSetting): Promise<CcnlSetting> {
|
||||
const existing = await this.getCcnlSetting(setting.key);
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db
|
||||
.update(ccnlSettings)
|
||||
.set({ ...setting, updatedAt: new Date() })
|
||||
.where(eq(ccnlSettings.key, setting.key))
|
||||
.returning();
|
||||
return updated;
|
||||
} else {
|
||||
const [newSetting] = await db.insert(ccnlSettings).values(setting).returning();
|
||||
return newSetting;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCcnlSetting(key: string): Promise<void> {
|
||||
await db.delete(ccnlSettings).where(eq(ccnlSettings.key, key));
|
||||
}
|
||||
}
|
||||
|
||||
export const storage = new DatabaseStorage();
|
||||
|
||||
@ -222,6 +222,9 @@ export const shifts = pgTable("shifts", {
|
||||
endTime: timestamp("end_time").notNull(),
|
||||
status: shiftStatusEnum("status").notNull().default("planned"),
|
||||
|
||||
// Veicolo assegnato al turno (opzionale)
|
||||
vehicleId: varchar("vehicle_id").references(() => vehicles.id, { onDelete: "set null" }),
|
||||
|
||||
notes: text("notes"),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
updatedAt: timestamp("updated_at").defaultNow(),
|
||||
@ -240,6 +243,17 @@ export const shiftAssignments = pgTable("shift_assignments", {
|
||||
checkOutTime: timestamp("check_out_time"),
|
||||
});
|
||||
|
||||
// ============= CCNL SETTINGS =============
|
||||
|
||||
export const ccnlSettings = pgTable("ccnl_settings", {
|
||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
key: varchar("key").notNull().unique(),
|
||||
value: varchar("value").notNull(),
|
||||
description: text("description"),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
updatedAt: timestamp("updated_at").defaultNow(),
|
||||
});
|
||||
|
||||
// ============= NOTIFICATIONS =============
|
||||
|
||||
export const notifications = pgTable("notifications", {
|
||||
@ -484,6 +498,10 @@ export const shiftsRelations = relations(shifts, ({ one, many }) => ({
|
||||
fields: [shifts.siteId],
|
||||
references: [sites.id],
|
||||
}),
|
||||
vehicle: one(vehicles, {
|
||||
fields: [shifts.vehicleId],
|
||||
references: [vehicles.id],
|
||||
}),
|
||||
assignments: many(shiftAssignments),
|
||||
}));
|
||||
|
||||
@ -658,6 +676,12 @@ export const insertShiftAssignmentSchema = createInsertSchema(shiftAssignments).
|
||||
assignedAt: true,
|
||||
});
|
||||
|
||||
export const insertCcnlSettingSchema = createInsertSchema(ccnlSettings).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
|
||||
export const insertNotificationSchema = createInsertSchema(notifications).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
@ -731,6 +755,9 @@ export type Shift = typeof shifts.$inferSelect;
|
||||
export type InsertShiftAssignment = z.infer<typeof insertShiftAssignmentSchema>;
|
||||
export type ShiftAssignment = typeof shiftAssignments.$inferSelect;
|
||||
|
||||
export type InsertCcnlSetting = z.infer<typeof insertCcnlSettingSchema>;
|
||||
export type CcnlSetting = typeof ccnlSettings.$inferSelect;
|
||||
|
||||
export type InsertNotification = z.infer<typeof insertNotificationSchema>;
|
||||
export type Notification = typeof notifications.$inferSelect;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user