diff --git a/prisma/migrations/20260808072932_compliance/migration.sql b/prisma/migrations/20260808072932_compliance/migration.sql new file mode 100644 index 0000000..233d10c --- /dev/null +++ b/prisma/migrations/20260808072932_compliance/migration.sql @@ -0,0 +1,132 @@ +-- CreateEnum +CREATE TYPE "Severity" AS ENUM ('INFO', 'WARNING', 'BLOCKING'); + +-- CreateTable +CREATE TABLE "CollectiveAgreement" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "idcc" TEXT NOT NULL, + "name" TEXT NOT NULL, + "parameters" JSONB NOT NULL, + "version" INTEGER NOT NULL, + "effectiveFrom" DATE NOT NULL, + "source" TEXT, + "approvedBy" TEXT, + "approvedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CollectiveAgreement_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ComplianceViolation" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "weeklyScheduleId" TEXT NOT NULL, + "membershipId" TEXT, + "ruleCode" TEXT NOT NULL, + "severity" "Severity" NOT NULL, + "localDate" DATE, + "message" TEXT NOT NULL, + "context" JSONB NOT NULL, + "shiftIds" TEXT[], + "agreementId" TEXT, + "detectedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "acknowledgedBy" TEXT, + "acknowledgedAt" TIMESTAMP(3), + "acknowledgementReason" TEXT, + + CONSTRAINT "ComplianceViolation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Holiday" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "locationId" TEXT NOT NULL, + "localDate" DATE NOT NULL, + "name" TEXT NOT NULL, + "isPaidOff" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "Holiday_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AuthorisedSunday" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "locationId" TEXT NOT NULL, + "localDate" DATE NOT NULL, + "reference" TEXT, + + CONSTRAINT "AuthorisedSunday_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "CollectiveAgreement_accountId_idcc_effectiveFrom_idx" ON "CollectiveAgreement"("accountId", "idcc", "effectiveFrom"); + +-- CreateIndex +CREATE INDEX "CollectiveAgreement_accountId_idx" ON "CollectiveAgreement"("accountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "CollectiveAgreement_accountId_idcc_version_key" ON "CollectiveAgreement"("accountId", "idcc", "version"); + +-- CreateIndex +CREATE INDEX "ComplianceViolation_accountId_idx" ON "ComplianceViolation"("accountId"); + +-- CreateIndex +CREATE INDEX "ComplianceViolation_weeklyScheduleId_idx" ON "ComplianceViolation"("weeklyScheduleId"); + +-- CreateIndex +CREATE INDEX "ComplianceViolation_membershipId_localDate_idx" ON "ComplianceViolation"("membershipId", "localDate"); + +-- CreateIndex +CREATE INDEX "Holiday_accountId_idx" ON "Holiday"("accountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Holiday_locationId_localDate_key" ON "Holiday"("locationId", "localDate"); + +-- CreateIndex +CREATE INDEX "AuthorisedSunday_accountId_idx" ON "AuthorisedSunday"("accountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AuthorisedSunday_locationId_localDate_key" ON "AuthorisedSunday"("locationId", "localDate"); + +-- AddForeignKey +ALTER TABLE "ComplianceViolation" ADD CONSTRAINT "ComplianceViolation_weeklyScheduleId_fkey" FOREIGN KEY ("weeklyScheduleId") REFERENCES "WeeklySchedule"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Isolation : toute table portant accountId doit porter sa politique. +ALTER TABLE "CollectiveAgreement" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "CollectiveAgreement" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON "CollectiveAgreement" + USING ("accountId" = planflow_current_account()); +CREATE POLICY tenant_insert ON "CollectiveAgreement" + FOR INSERT WITH CHECK ("accountId" = planflow_current_account()); + +ALTER TABLE "ComplianceViolation" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "ComplianceViolation" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON "ComplianceViolation" + USING ("accountId" = planflow_current_account()); +CREATE POLICY tenant_insert ON "ComplianceViolation" + FOR INSERT WITH CHECK ("accountId" = planflow_current_account()); + +ALTER TABLE "Holiday" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "Holiday" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON "Holiday" + USING ("accountId" = planflow_current_account()); +CREATE POLICY tenant_insert ON "Holiday" + FOR INSERT WITH CHECK ("accountId" = planflow_current_account()); + +ALTER TABLE "AuthorisedSunday" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "AuthorisedSunday" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON "AuthorisedSunday" + USING ("accountId" = planflow_current_account()); +CREATE POLICY tenant_insert ON "AuthorisedSunday" + FOR INSERT WITH CHECK ("accountId" = planflow_current_account()); + +-- Une version de convention ne se modifie pas : elle se remplace par une +-- nouvelle version datée. Sans cela, une paie antérieure cesse d'être +-- reproductible, ce qui est précisément l'exigence n° 1 de la matrice. +CREATE TRIGGER collective_agreement_append_only + BEFORE UPDATE OR DELETE ON "CollectiveAgreement" + FOR EACH ROW EXECUTE FUNCTION planflow_deny_write(); diff --git a/prisma/migrations/20260808073500_agreement_immutability/migration.sql b/prisma/migrations/20260808073500_agreement_immutability/migration.sql new file mode 100644 index 0000000..5f9baf7 --- /dev/null +++ b/prisma/migrations/20260808073500_agreement_immutability/migration.sql @@ -0,0 +1,36 @@ +-- Immutabilité ciblée d'une version de convention. +-- +-- Le blocage total posé avec la table interdisait aussi d'y consigner une +-- approbation, qui est pourtant un acte postérieur légitime : une version est +-- chargée, puis relue et approuvée par le gestionnaire de paie. +-- +-- Ce qui doit rester figé, c'est le **contenu** — paramètres, IDCC, version, +-- date d'effet — parce que c'est lui qui rend une paie antérieure +-- reproductible. Corriger une valeur passe par une nouvelle version datée. + +DROP TRIGGER IF EXISTS collective_agreement_append_only ON "CollectiveAgreement"; + +CREATE OR REPLACE FUNCTION planflow_agreement_frozen() RETURNS trigger AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION + 'Une version de convention ne se supprime pas : une paie antérieure doit rester reproductible.' + USING ERRCODE = 'restrict_violation'; + END IF; + + IF NEW."parameters"::text IS DISTINCT FROM OLD."parameters"::text + OR NEW."idcc" IS DISTINCT FROM OLD."idcc" + OR NEW."version" IS DISTINCT FROM OLD."version" + OR NEW."effectiveFrom" IS DISTINCT FROM OLD."effectiveFrom" THEN + RAISE EXCEPTION + 'Le contenu d''une version de convention est figé. Publier une nouvelle version datée.' + USING ERRCODE = 'restrict_violation'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER collective_agreement_frozen + BEFORE UPDATE OR DELETE ON "CollectiveAgreement" + FOR EACH ROW EXECUTE FUNCTION planflow_agreement_frozen(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4ad05fc..a50ccfb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -595,6 +595,7 @@ model WeeklySchedule { shifts Shift[] rests Rest[] dailyNotes DailyNote[] + violations ComplianceViolation[] @@unique([teamId, isoYear, isoWeek]) @@index([accountId]) @@ -673,3 +674,105 @@ model DailyNote { @@index([accountId]) } + +// ============================================================================ +// Conformité conventionnelle — PLAN.md §6 et §12.2, WP-05 +// ============================================================================ + +/// Convention collective, **effectif-datée**. +/// +/// Les versions coexistent et ne se remplacent pas : la matrice n° 1 impose de +/// pouvoir reproduire à l'identique une paie antérieure après un changement de +/// règle. Une mise à jour en place rendrait cette reproduction impossible, et +/// l'impossibilité ne se découvrirait qu'au premier contrôle. +model CollectiveAgreement { + id String @id @default(cuid()) + accountId String + idcc String + name String + /// Jeu de paramètres validé par `agreementParametersSchema`. + parameters Json + version Int + effectiveFrom DateTime @db.Date + /// Origine du texte : URL Legifrance, référence de PDF, note d'entretien. + source String? + approvedBy String? + approvedAt DateTime? + createdAt DateTime @default(now()) + + @@unique([accountId, idcc, version]) + @@index([accountId, idcc, effectiveFrom]) + @@index([accountId]) +} + +/// Constat produit par le moteur de règles. +/// +/// Reconstruit à chaque évaluation d'une semaine : ce n'est pas un journal mais +/// l'état courant. L'acquittement, lui, survit — c'est la décision d'un manager +/// de publier malgré une alerte, et elle doit rester opposable. +model ComplianceViolation { + id String @id @default(cuid()) + accountId String + weeklyScheduleId String + membershipId String? + ruleCode String + severity Severity + localDate DateTime? @db.Date + message String + context Json + /// Créneaux mis en cause, pour poser le badge sur la bonne cellule. + shiftIds String[] + /// Version de convention appliquée : sans elle, un constat ancien devient + /// inexplicable dès que les paramètres changent. + agreementId String? + detectedAt DateTime @default(now()) + + acknowledgedBy String? + acknowledgedAt DateTime? + acknowledgementReason String? + + schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade) + + @@index([accountId]) + @@index([weeklyScheduleId]) + @@index([membershipId, localDate]) +} + +enum Severity { + INFO + WARNING + BLOCKING +} + +/// Jour férié d'un établissement. +/// +/// Par établissement et non par compte : l'Alsace-Moselle a deux jours fériés +/// de plus, et une chaîne peut ouvrir un magasin de l'autre côté de la limite. +model Holiday { + id String @id @default(cuid()) + accountId String + locationId String + localDate DateTime @db.Date + name String + /// Chômé par décision de l'employeur, au titre des jours garantis. + isPaidOff Boolean @default(false) + + @@unique([locationId, localDate]) + @@index([accountId]) +} + +/// Dimanche du maire autorisé — L3132-26. +/// +/// La liste est arrêtée avant le 31 décembre pour l'année suivante. La stocker +/// rend le quota opposable au lieu de purement indicatif. +model AuthorisedSunday { + id String @id @default(cuid()) + accountId String + locationId String + localDate DateTime @db.Date + /// Arrêté municipal ou décision de référence. + reference String? + + @@unique([locationId, localDate]) + @@index([accountId]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts index b4dfdc5..32b1113 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -16,6 +16,12 @@ import { zonedInstant, } from '../src/domain/planning/week'; import { POSTE_CODES, POSTE_LABELS } from '../src/lib/design/postes'; +import { + IDCC_1517_PARAMETERS, + IDCC_1517_PROVENANCE, +} from '../src/domain/compliance/idcc1517'; +import { evaluateSchedule } from '../src/server/compliance/evaluate'; +import { withTenant } from '../src/server/tenant'; /** * Jeu de données de départ — PLAN.md §11. @@ -406,6 +412,128 @@ async function main() { console.log('→ Plannings'); await seedPlanning(account.id); + + console.log('→ Convention collective'); + // Effectif-datée : la version ne se met pas à jour, elle se remplace par une + // suivante datée. Un trigger PostgreSQL refuse de modifier son contenu. + const agreement = await prisma.collectiveAgreement.findFirst({ + where: { accountId: account.id, idcc: '1517', version: 1 }, + }); + if (!agreement) { + await prisma.collectiveAgreement.create({ + data: { + accountId: account.id, + idcc: '1517', + name: 'Commerces de détail non alimentaires', + parameters: IDCC_1517_PARAMETERS, + version: 1, + effectiveFrom: new Date('2026-01-01'), + source: + 'Sources secondaires publiques — À VALIDER contre le texte consolidé Legifrance', + }, + }); + } + console.log(' IDCC 1517 version 1'); + + console.log('→ Registre de paramétrage juridique'); + // L'origine de chaque valeur — ordre public, convention, accord d'entreprise + // — décide de ce qui se négocie et de ce qui s'impose. Elle est enregistrée + // avec sa source, pas seulement commentée dans le code. + for (const entry of IDCC_1517_PROVENANCE) { + await prisma.legalConfigEntry.upsert({ + where: { + accountId_domain_key_effectiveFrom: { + accountId: account.id, + domain: 'temps', + key: entry.key, + effectiveFrom: new Date('2026-01-01'), + }, + }, + update: { value: entry.value, source: entry.source }, + create: { + accountId: account.id, + domain: 'temps', + key: entry.key, + value: `${entry.label} : ${entry.value}`, + source: `[${entry.origin}] ${entry.source}`, + population: 'Tous salariés', + effectiveFrom: new Date('2026-01-01'), + }, + }); + } + console.log(` ${IDCC_1517_PROVENANCE.length} paramètres tracés`); + + console.log('→ Jours fériés et dimanches autorisés'); + // Jours fériés légaux français, hors Alsace-Moselle. + const holidays2026: Array<[string, string]> = [ + ['2026-01-01', 'Jour de l’an'], + ['2026-04-06', 'Lundi de Pâques'], + ['2026-05-01', 'Fête du travail'], + ['2026-05-08', 'Victoire 1945'], + ['2026-05-14', 'Ascension'], + ['2026-05-25', 'Lundi de Pentecôte'], + ['2026-07-14', 'Fête nationale'], + ['2026-08-15', 'Assomption'], + ['2026-11-01', 'Toussaint'], + ['2026-11-11', 'Armistice 1918'], + ['2026-12-25', 'Noël'], + ]; + + for (const location of locations) { + for (const [date, name] of holidays2026) { + await prisma.holiday.upsert({ + where: { + locationId_localDate: { + locationId: location.id, + localDate: new Date(`${date}T00:00:00Z`), + }, + }, + update: { name }, + create: { + accountId: account.id, + locationId: location.id, + localDate: new Date(`${date}T00:00:00Z`), + name, + // Les trois jours chômés garantis par la convention sont choisis par + // l'employeur : ils ne sont pas devinés ici. + isPaidOff: date === '2026-05-01', + }, + }); + } + + // Douze dimanches du maire, liste arrêtée avant le 31 décembre pour + // l'année suivante (L3132-26). Valeurs de démonstration. + const sundays = [ + '2026-01-11', + '2026-06-28', + '2026-07-05', + '2026-08-30', + '2026-09-06', + '2026-11-29', + '2026-12-06', + '2026-12-13', + '2026-12-20', + ]; + for (const date of sundays) { + await prisma.authorisedSunday.upsert({ + where: { + locationId_localDate: { + locationId: location.id, + localDate: new Date(`${date}T00:00:00Z`), + }, + }, + update: {}, + create: { + accountId: account.id, + locationId: location.id, + localDate: new Date(`${date}T00:00:00Z`), + reference: 'Arrêté municipal de démonstration', + }, + }); + } + } + console.log(` ${holidays2026.length} jours fériés par établissement`); + console.log('→ Durées de conservation'); const retention = [ ['Shift', 12, 'creation', 'Décompte des horaires : 1 an minimum (matrice n° 21).'], @@ -437,6 +565,21 @@ async function main() { } console.log(` ${retention.length} politiques`); + console.log('→ Évaluation de conformité'); + // Le seed produit des plannings, donc des constats : les laisser à calculer + // au premier affichage donnerait une grille faussement conforme. + const schedules = await prisma.weeklySchedule.findMany({ + where: { accountId: account.id }, + select: { id: true }, + }); + let violations = 0; + await withTenant(account.id, async (db) => { + for (const schedule of schedules) { + violations += (await evaluateSchedule(db, schedule.id)).length; + } + }); + console.log(` ${violations} constats sur ${schedules.length} semaines`); + console.log(`\nMot de passe de démonstration : ${DEMO_PASSWORD}`); } diff --git a/src/components/planning/AlertPanel.tsx b/src/components/planning/AlertPanel.tsx new file mode 100644 index 0000000..92718aa --- /dev/null +++ b/src/components/planning/AlertPanel.tsx @@ -0,0 +1,81 @@ +import { Badge, type Tone } from '@/components/ui/Badge'; +import type { BoardAlert } from '@/server/planning/queries'; + +/** + * Panneau des constats de convention — PLAN.md §6.5. + * + * Trois choix délibérés : + * + * - Les constats sont **listés**, pas comptés. « 3 alertes » n'aide personne ; + * « repos de 9 h 30 entre mardi et mercredi » se corrige. + * - Un constat acquitté reste affiché, avec son motif. Le faire disparaître + * donnerait l'illusion qu'il a été résolu alors qu'il a été assumé. + * - Les informatives (dimanche, jour férié) sont montrées à part : ce ne sont + * pas des anomalies mais des contreparties à ne pas oublier en paie. + */ + +export interface AlertPanelProps { + alerts: BoardAlert[]; + /** Nom affichable par membership, pour situer chaque constat. */ + names: Map; +} + +const SEVERITY_TONE: Record = { + BLOCKING: 'danger', + WARNING: 'warn', + INFO: 'info', +}; + +const SEVERITY_LABEL: Record = { + BLOCKING: 'Bloquant', + WARNING: 'Alerte', + INFO: 'À reporter en paie', +}; + +export function AlertPanel({ alerts, names }: AlertPanelProps) { + if (alerts.length === 0) return null; + + const anomalies = alerts.filter((alert) => alert.severity !== 'INFO'); + const informative = alerts.filter((alert) => alert.severity === 'INFO'); + + return ( +
+

+ Convention collective +

+ +
    + {[...anomalies, ...informative].map((alert) => ( +
  • + + {SEVERITY_LABEL[alert.severity]} + + +
    +

    {alert.message}

    +

    + {[ + alert.membershipId + ? (names.get(alert.membershipId) ?? 'Salarié') + : null, + alert.localDate, + alert.ruleCode, + ] + .filter(Boolean) + .join(' · ')} +

    + {alert.acknowledged ? ( +

    + Publié malgré l’alerte + {alert.acknowledgementReason + ? ` — ${alert.acknowledgementReason}` + : ''} +

    + ) : null} +
    +
  • + ))} +
+
+ ); +} diff --git a/src/components/planning/TeamSection.tsx b/src/components/planning/TeamSection.tsx index d0d407b..48c8230 100644 --- a/src/components/planning/TeamSection.tsx +++ b/src/components/planning/TeamSection.tsx @@ -2,6 +2,7 @@ import { useActionState, useEffect, useState } from 'react'; +import { AlertPanel } from '@/components/planning/AlertPanel'; import { WeekGrid } from '@/components/planning/WeekGrid'; import { Badge } from '@/components/ui/Badge'; import { Button } from '@/components/ui/Button'; @@ -68,6 +69,16 @@ export function TeamSection({ }: TeamSectionProps) { const [target, setTarget] = useState(null); + const names = new Map( + section.rows + .filter((row) => row.membershipId) + .map((row) => [row.membershipId as string, displayName(row)]), + ); + const flagged = new Set(section.flaggedShiftIds); + const pendingWarnings = section.alerts.filter( + (alert) => alert.severity === 'WARNING' && !alert.acknowledged, + ).length; + const published = section.status === 'PUBLISHED'; const plannedMinutes = section.rows.reduce( (total, row) => total + row.counters.plannedMinutes, @@ -121,6 +132,7 @@ export function TeamSection({ weekParam={weekParam} version={section.version} published={published} + pendingWarnings={pendingWarnings} /> ) : null} @@ -131,6 +143,7 @@ export function TeamSection({ dates={dates} rows={section.rows} unassignedRow={section.unassignedRow} + flaggedShiftIds={flagged} {...(canEdit ? { cellAction: (row: BoardRow, dayIndex: number) => ( @@ -176,6 +189,8 @@ export function TeamSection({ : {})} /> + + {target ? ( +
{/* Verrou optimiste : la version lue au rendu est renvoyée telle quelle. */} + + {/* Le motif n'apparaît que s'il y a quelque chose à assumer : demander + une justification quand tout est conforme apprendrait à la remplir + machinalement. */} + {!published && pendingWarnings > 0 ? ( + + ) : null} + {state.error ? ( {state.error} diff --git a/src/components/planning/WeekGrid.tsx b/src/components/planning/WeekGrid.tsx index 5fab950..723cb57 100644 --- a/src/components/planning/WeekGrid.tsx +++ b/src/components/planning/WeekGrid.tsx @@ -14,6 +14,8 @@ export interface WeekGridProps { dates: readonly string[]; rows: BoardRow[]; unassignedRow?: BoardRow | null; + /** Créneaux portant un constat de convention : ils prennent le liseré. */ + flaggedShiftIds?: ReadonlySet; /** Rendu de la case d'un jour : sert à greffer l'ajout d'un créneau. */ cellAction?: (row: BoardRow, dayIndex: number) => React.ReactNode; /** Rendu attaché à un créneau : suppression, édition. */ @@ -37,6 +39,7 @@ export function WeekGrid({ dates, rows, unassignedRow, + flaggedShiftIds, cellAction, shiftAction, }: WeekGridProps) { @@ -52,6 +55,7 @@ export function WeekGrid({ row={unassignedRow} days={days} dates={dates} + {...(flaggedShiftIds ? { flaggedShiftIds } : {})} {...(cellAction ? { cellAction } : {})} {...(shiftAction ? { shiftAction } : {})} /> @@ -67,6 +71,7 @@ export function WeekGrid({ row={row} days={days} dates={dates} + {...(flaggedShiftIds ? { flaggedShiftIds } : {})} {...(cellAction ? { cellAction } : {})} {...(shiftAction ? { shiftAction } : {})} /> @@ -110,12 +115,14 @@ function Row({ row, days, dates, + flaggedShiftIds, cellAction, shiftAction, }: { row: BoardRow; days: readonly string[]; dates: readonly string[]; + flaggedShiftIds?: ReadonlySet; cellAction?: (row: BoardRow, dayIndex: number) => React.ReactNode; shiftAction?: ( shift: BoardShift, @@ -191,7 +198,11 @@ function Row({ ; +} + +export function evaluate(context: ComplianceContext): EvaluationResult { + const violations: Violation[] = []; + const failures: EvaluationResult['failures'] = []; + + for (const ruleCode of RULE_CODES) { + try { + violations.push(...RULES[ruleCode](context)); + } catch (error) { + // Une règle qui lève ne doit pas emporter les dix-sept autres : le + // planning resterait sans aucun contrôle, et rien ne le signalerait. + // L'échec est collecté et remonté explicitement. + failures.push({ + ruleCode, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return { + violations, + blocking: violations.filter((entry) => entry.severity === 'BLOCKING'), + warnings: violations.filter((entry) => entry.severity === 'WARNING'), + failures, + }; +} + +/** Évalue plusieurs salariés et agrège, en conservant leur rattachement. */ +export function evaluateAll( + contexts: ComplianceContext[], +): Map { + return new Map( + contexts.map((context) => [context.membershipId, evaluate(context)]), + ); +} + +const SEVERITY_ORDER: Record = { + BLOCKING: 0, + WARNING: 1, + INFO: 2, +}; + +/** Trie du plus grave au plus anodin, puis par date. */ +export function sortViolations(violations: Violation[]): Violation[] { + return [...violations].sort((a, b) => { + const bySeverity = + SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]; + if (bySeverity !== 0) return bySeverity; + return (a.localDate ?? '').localeCompare(b.localDate ?? ''); + }); +} diff --git a/src/domain/compliance/idcc1517.ts b/src/domain/compliance/idcc1517.ts new file mode 100644 index 0000000..6a0058e --- /dev/null +++ b/src/domain/compliance/idcc1517.ts @@ -0,0 +1,258 @@ +import type { AgreementParameters } from '@/domain/compliance/parameters'; + +/** + * Jeu de paramètres d'amorce pour l'IDCC 1517 — PLAN.md §6.3. + * + * **Ce fichier n'est pas lu par le moteur.** Il sert au seed, qui l'écrit en + * base ; le moteur lit toujours la base. La distinction n'est pas cosmétique : + * corriger une valeur fausse doit être un acte de paramétrage tracé, daté et + * approuvé, pas une livraison de code. + * + * `À VALIDER` — ces valeurs proviennent de sources secondaires publiques, pas + * du texte consolidé sur Legifrance. Elles suffisent à construire et tester le + * moteur ; elles ne suffisent pas à engager une paie. Avant production : + * recouper avec le texte primaire, dater la version chargée, et faire + * confirmer par le gestionnaire de paie. + */ + +const h = (hours: number, minutes = 0) => hours * 60 + minutes; + +export const IDCC_1517_PARAMETERS: AgreementParameters = { + weeklyReferenceMinutes: h(35), + maxDailyWorkMinutes: h(10), + // La convention ne fixe pas d'amplitude quotidienne : la règle reste muette + // plutôt que d'appliquer une borne inventée. + maxDailyAmplitudeMinutes: null, + minDailyRestMinutes: h(11), + minWeeklyRestMinutes: h(35), + maxWeeklyWorkAbsoluteMinutes: h(48), + averagedWeeklyWork: { windowWeeks: 12, maxAverageMinutes: h(44) }, + maxConsecutiveWorkDays: 10, + breakAfterThreshold: { thresholdMinutes: h(6), minBreakMinutes: 20 }, + + partTime: { + minWeeklyMinutes: h(24), + derogations: [ + { + code: 'AIDE_ETALAGISTE', + label: 'aide-étalagiste, employé niveau 2', + minWeeklyMinutes: h(21), + }, + { + code: 'NETTOYAGE', + label: 'nettoyage, démonstrateurs, marchés', + minWeeklyMinutes: h(6), + }, + ], + }, + + contractDeviationToleranceMinutes: 30, + + overtime: { + // « De la 36ᵉ à la 43ᵉ heure » : les huit premières au-delà de 35 h, donc + // de 35 h à 43 h en minutes cumulées depuis le début de la semaine. + tiers: [ + { fromMinutes: h(35), toMinutes: h(43), ratePercent: 25 }, + { fromMinutes: h(43), toMinutes: null, ratePercent: 50 }, + ], + annualQuotaMinutes: h(180), + // Plus de 20 salariés dans l'entreprise auditée. + beyondQuotaRestPercent: 100, + }, + + complementaryHours: { + firstTierRatePercent: 10, + beyondRatePercent: 25, + firstTierFraction: 0.1, + capFraction: 1 / 3, + }, + + sunday: { + // Article L3132-27 : rémunération au moins doublée **et** repos + // compensateur d'égale durée. Les deux, pas l'un ou l'autre. + premiumPercent: 100, + compensatoryRestPercent: 100, + mayorQuotaPerYear: 12, + }, + + holiday: { + workedPremiumPercent: 50, + labourDayPremiumPercent: 100, + paidOffDaysPerYear: 3, + substitutionRestPercent: 50, + }, + + night: { + startMinutes: h(21), + endMinutes: h(6), + notEnforceableFromAge: 55, + }, + + forfaitJours: { + maxDaysPerYear: 218, + workloadReviewIntervalMonths: 12, + }, +}; + +export type ParameterOrigin = 'OP' | 'CCN' | 'ENT'; + +export interface ParameterProvenance { + key: string; + label: string; + value: string; + origin: ParameterOrigin; + source: string; +} + +/** + * Origine et source de chaque valeur, pour le registre de paramétrage. + * + * **OP** = ordre public (s'impose quelle que soit la convention) · **CCN** = + * disposition propre à l'IDCC 1517 · **ENT** = relève d'un accord d'entreprise. + * + * Distinguer les trois n'est pas de la documentation : une valeur d'ordre + * public ne se négocie pas, une valeur CCN change si la convention change, et + * une valeur ENT n'existe pas ici — l'entreprise auditée n'a pas d'accord. + */ +export const IDCC_1517_PROVENANCE: ParameterProvenance[] = [ + { + key: 'duree-hebdomadaire-reference', + label: 'Durée hebdomadaire de référence', + value: '35 h (151,67 h/mois, 1 607 h/an)', + origin: 'OP', + source: 'Code du travail, art. L3121-27', + }, + { + key: 'max-daily-work', + label: 'Durée quotidienne maximale', + value: '10 h', + origin: 'CCN', + source: 'IDCC 1517 — commerces de détail non alimentaires', + }, + { + key: 'max-weekly-work-absolute', + label: 'Durée hebdomadaire maximale absolue', + value: '48 h', + origin: 'CCN', + source: 'IDCC 1517', + }, + { + key: 'max-weekly-work-averaged', + label: 'Durée hebdomadaire moyenne', + value: '44 h sur 12 semaines consécutives', + origin: 'CCN', + source: 'IDCC 1517', + }, + { + key: 'min-daily-rest', + label: 'Repos quotidien minimal', + value: '11 h consécutives', + origin: 'OP', + source: 'Code du travail, art. L3131-1', + }, + { + key: 'min-weekly-rest', + label: 'Repos hebdomadaire minimal', + value: '35 h consécutives (24 + 11)', + origin: 'OP', + source: 'Code du travail, art. L3132-2', + }, + { + key: 'min-break', + label: 'Pause obligatoire', + value: '20 min après 6 h de travail', + origin: 'OP', + source: 'Code du travail, art. L3121-16', + }, + { + key: 'max-consecutive-days', + label: 'Jours consécutifs maximum', + value: '10 jours', + origin: 'CCN', + source: 'IDCC 1517', + }, + { + key: 'part-time-minimum', + label: 'Durée minimale de temps partiel', + value: '24 h (dérogations : 21 h, 6 h)', + origin: 'CCN', + source: 'IDCC 1517', + }, + { + key: 'overtime-tiers', + label: 'Majorations pour heures supplémentaires', + value: '+25 % de la 36ᵉ à la 43ᵉ heure, +50 % au-delà', + origin: 'CCN', + source: 'IDCC 1517', + }, + { + key: 'overtime-quota', + label: 'Contingent annuel', + value: '180 h, contrepartie en repos de 100 % au-delà (> 20 salariés)', + origin: 'CCN', + source: 'IDCC 1517', + }, + { + key: 'complementary-hours', + label: 'Heures complémentaires', + value: '+10 % jusqu’au dixième, +25 % au-delà, plafond au tiers', + origin: 'CCN', + source: 'IDCC 1517', + }, + { + key: 'sunday-premium', + label: 'Travail du dimanche — rémunération', + value: 'Au moins le double, soit +100 %', + origin: 'OP', + source: 'Code du travail, art. L3132-27 (dimanches du maire)', + }, + { + key: 'sunday-rest', + label: 'Travail du dimanche — repos compensateur', + value: 'Repos d’égale durée, en plus de la majoration', + origin: 'OP', + source: 'Code du travail, art. L3132-27', + }, + { + key: 'sunday-quota', + label: 'Dimanches du maire', + value: '12 par année civile, liste arrêtée avant le 31 décembre', + origin: 'OP', + source: 'Code du travail, art. L3132-26', + }, + { + key: 'holiday-worked', + label: 'Jour férié travaillé', + value: 'Indemnité de 50 % des heures effectuées', + origin: 'CCN', + source: 'IDCC 1517', + }, + { + key: 'labour-day', + label: '1er mai', + value: 'Chômé ; si travaillé, +100 %', + origin: 'OP', + source: 'Code du travail, art. L3133-6', + }, + { + key: 'holiday-paid-off', + label: 'Jours fériés chômés garantis', + value: '3 par année civile, choisis par l’employeur', + origin: 'CCN', + source: 'IDCC 1517', + }, + { + key: 'night-window', + label: 'Travail de nuit', + value: '21 h – 6 h ; non imposable à partir de 55 ans', + origin: 'CCN', + source: 'IDCC 1517', + }, + { + key: 'forfait-jours', + label: 'Forfait jours', + value: '218 jours, journée de solidarité incluse', + origin: 'CCN', + source: 'IDCC 1517 — cadres autonomes niveaux VII à IX', + }, +]; diff --git a/src/domain/compliance/overtime.ts b/src/domain/compliance/overtime.ts new file mode 100644 index 0000000..49ba196 --- /dev/null +++ b/src/domain/compliance/overtime.ts @@ -0,0 +1,134 @@ +import type { AgreementParameters } from '@/domain/compliance/parameters'; + +/** + * Découpage des heures supplémentaires et complémentaires — PLAN.md §6.3. + * + * Le calcul est ici, une fois, parce qu'il alimente trois consommateurs : le + * bandeau de compteurs, le rapport d'heures et l'export de paie. Trois + * implémentations qui divergent d'un quart d'heure produisent un écart entre + * le planning et le bulletin, et l'écart ne se voit qu'au moment où le + * comptable le réclame. + * + * Les tranches viennent des paramètres, jamais du code : l'IDCC 1517 majore de + * 25 % de la 36ᵉ à la 43ᵉ heure et de 50 % au-delà, mais une autre convention + * — ou une version postérieure de celle-ci — dira autre chose. + */ + +export interface OvertimeSlice { + fromMinutes: number; + toMinutes: number | null; + ratePercent: number; + minutes: number; +} + +export interface OvertimeBreakdown { + /** Heures au taux normal, dans la limite de la durée de référence. */ + baseMinutes: number; + /** Heures supplémentaires, par tranche de majoration. */ + slices: OvertimeSlice[]; + overtimeMinutes: number; +} + +/** + * Répartit une durée hebdomadaire entre le taux normal et les tranches. + * + * Les tranches sont exprimées **en minutes depuis le début de la semaine**, pas + * en durée de tranche : c'est ainsi que la convention les écrit (« de la 36ᵉ à + * la 43ᵉ heure »), et traduire à l'écriture évite de traduire à chaque lecture. + */ +export function splitOvertime( + weeklyMinutes: number, + parameters: AgreementParameters, +): OvertimeBreakdown { + const reference = parameters.weeklyReferenceMinutes; + const baseMinutes = Math.min(weeklyMinutes, reference); + + const slices: OvertimeSlice[] = []; + for (const tier of parameters.overtime.tiers) { + const start = Math.max(tier.fromMinutes, reference); + const end = tier.toMinutes ?? Number.POSITIVE_INFINITY; + const minutes = Math.max(0, Math.min(weeklyMinutes, end) - start); + if (minutes === 0) continue; + + slices.push({ + fromMinutes: tier.fromMinutes, + toMinutes: tier.toMinutes, + ratePercent: tier.ratePercent, + minutes, + }); + } + + return { + baseMinutes, + slices, + overtimeMinutes: slices.reduce((sum, slice) => sum + slice.minutes, 0), + }; +} + +export interface ComplementaryBreakdown { + /** Heures complémentaires à la première majoration. */ + firstTierMinutes: number; + firstTierRatePercent: number; + /** Heures complémentaires au-delà de la première fraction. */ + beyondMinutes: number; + beyondRatePercent: number; + /** Heures au-delà du plafond légal : elles requalifient le contrat. */ + overCapMinutes: number; + totalMinutes: number; +} + +/** + * Heures complémentaires d'un temps partiel. + * + * Distinctes des heures supplémentaires : un temps partiel qui dépasse sa durée + * contractuelle ne fait pas d'heures supplémentaires tant qu'il reste sous la + * durée légale. Confondre les deux fausse la paie dans les deux sens. + * + * Le dépassement du plafond (un tiers de la durée contractuelle) est isolé + * parce qu'il ne relève pas de la majoration mais de la **requalification du + * contrat** — une conséquence juridique, pas un taux. + */ +export function splitComplementary( + weeklyMinutes: number, + contractMinutes: number, + parameters: AgreementParameters, +): ComplementaryBreakdown { + const { firstTierRatePercent, beyondRatePercent, firstTierFraction, capFraction } = + parameters.complementaryHours; + + const extra = Math.max(0, weeklyMinutes - contractMinutes); + const firstTierLimit = Math.round(contractMinutes * firstTierFraction); + const cap = Math.round(contractMinutes * capFraction); + + const firstTierMinutes = Math.min(extra, firstTierLimit); + const beyondMinutes = Math.max(0, Math.min(extra, cap) - firstTierLimit); + const overCapMinutes = Math.max(0, extra - cap); + + return { + firstTierMinutes, + firstTierRatePercent, + beyondMinutes, + beyondRatePercent, + overCapMinutes, + totalMinutes: extra, + }; +} + +/** + * Contingent annuel d'heures supplémentaires. + * + * Au-delà, chaque heure ouvre une contrepartie obligatoire en repos, dont le + * taux dépend de l'effectif. Le dépassement n'interdit pas l'heure : il la + * rend plus chère et crée un droit. + */ +export function quotaStatus( + usedMinutes: number, + parameters: AgreementParameters, +): { remainingMinutes: number; exceededMinutes: number; restPercent: number } { + const quota = parameters.overtime.annualQuotaMinutes; + return { + remainingMinutes: Math.max(0, quota - usedMinutes), + exceededMinutes: Math.max(0, usedMinutes - quota), + restPercent: parameters.overtime.beyondQuotaRestPercent, + }; +} diff --git a/src/domain/compliance/parameters.ts b/src/domain/compliance/parameters.ts new file mode 100644 index 0000000..1922a17 --- /dev/null +++ b/src/domain/compliance/parameters.ts @@ -0,0 +1,159 @@ +import { z } from 'zod'; + +/** + * Paramètres d'une convention collective. + * + * **Aucune de ces valeurs n'est écrite dans le code du moteur.** Elles vivent + * en base, dans `CollectiveAgreement.parameters`, et sont chargées à + * l'évaluation. C'est ce qui permet de corriger une valeur fausse sans + * livraison, et surtout d'en faire coexister deux versions : une paie de mars + * doit rester reproductible après un changement de règle en juin (PLAN.md + * §12.2). + * + * Les durées sont en **minutes entières**, comme partout ailleurs. + * + * Ce schéma valide ce qui vient de la base. Une convention mal renseignée doit + * échouer bruyamment au chargement plutôt que produire des alertes fausses — + * un seuil manquant lu comme `undefined` désactiverait silencieusement une + * règle de sécurité. + */ + +const minutes = z.number().int().min(0).max(60 * 24 * 14); +const percent = z.number().min(0).max(1000); + +export const agreementParametersSchema = z.object({ + /** Durée hebdomadaire de référence : 35 h en droit commun. */ + weeklyReferenceMinutes: minutes, + + /** Durée quotidienne maximale de travail effectif. */ + maxDailyWorkMinutes: minutes, + + /** + * Amplitude quotidienne maximale (premier début → dernière fin). + * + * `null` quand la convention n'en fixe pas : la règle ne se déclenche alors + * pas du tout. Inventer une borne serait pire que de n'en avoir aucune — une + * alerte sans fondement se fait désactiver, et emporte les vraies avec elle. + */ + maxDailyAmplitudeMinutes: minutes.nullable(), + + minDailyRestMinutes: minutes, + minWeeklyRestMinutes: minutes, + maxWeeklyWorkAbsoluteMinutes: minutes, + + /** Moyenne sur une fenêtre glissante : 44 h sur 12 semaines en IDCC 1517. */ + averagedWeeklyWork: z.object({ + windowWeeks: z.number().int().min(1).max(52), + maxAverageMinutes: minutes, + }), + + maxConsecutiveWorkDays: z.number().int().min(1).max(31), + + /** Pause obligatoire au-delà d'un temps de travail continu. */ + breakAfterThreshold: z.object({ + thresholdMinutes: minutes, + minBreakMinutes: minutes, + }), + + partTime: z.object({ + minWeeklyMinutes: minutes, + /** Dérogations conventionnelles, par code d'emploi ou de population. */ + derogations: z.array( + z.object({ + code: z.string().min(1), + label: z.string().min(1), + minWeeklyMinutes: minutes, + }), + ), + }), + + /** + * Écart toléré entre le planning et la durée contractuelle avant alerte. + * + * Un planning n'est jamais au quart d'heure près ; alerter sur cinq minutes + * d'écart rendrait l'indicateur inutilisable. + */ + contractDeviationToleranceMinutes: minutes, + + overtime: z.object({ + /** Tranches cumulatives, exprimées depuis le début de la semaine. */ + tiers: z.array( + z.object({ + fromMinutes: minutes, + /** `null` = jusqu'à l'infini. */ + toMinutes: minutes.nullable(), + ratePercent: percent, + }), + ), + annualQuotaMinutes: z.number().int().min(0), + /** Contrepartie obligatoire en repos au-delà du contingent. */ + beyondQuotaRestPercent: percent, + }), + + complementaryHours: z.object({ + firstTierRatePercent: percent, + beyondRatePercent: percent, + /** Fraction de la durée contractuelle au-delà de laquelle on bascule. */ + firstTierFraction: z.number().min(0).max(1), + /** Plafond absolu, en fraction de la durée contractuelle. */ + capFraction: z.number().min(0).max(1), + }), + + sunday: z.object({ + /** Majoration en pourcentage : 100 = rémunération doublée. */ + premiumPercent: percent, + /** + * Repos compensateur dû, en pourcentage du temps travaillé. + * + * L3132-27 impose un repos **d'égale durée** en plus de la majoration : + * 100. Ne générer que la majoration serait un manquement. + */ + compensatoryRestPercent: percent, + /** Dimanches du maire : 12 par année civile (L3132-26). */ + mayorQuotaPerYear: z.number().int().min(0).max(53), + }), + + holiday: z.object({ + /** Indemnité pour un jour férié travaillé. */ + workedPremiumPercent: percent, + /** 1er mai : chômé obligatoire, doublé s'il est travaillé. */ + labourDayPremiumPercent: percent, + /** Jours fériés chômés garantis par la convention, hors 1er mai. */ + paidOffDaysPerYear: z.number().int().min(0).max(11), + /** Repos de substitution, **sur demande du salarié** uniquement. */ + substitutionRestPercent: percent, + }), + + night: z.object({ + /** Minutes depuis minuit ; la plage franchit minuit. */ + startMinutes: minutes, + endMinutes: minutes, + /** Âge à partir duquel le travail de nuit n'est pas imposable. */ + notEnforceableFromAge: z.number().int().min(0).max(99), + }), + + forfaitJours: z.object({ + maxDaysPerYear: z.number().int().min(0).max(366), + /** Intervalle maximal entre deux entretiens de charge. */ + workloadReviewIntervalMonths: z.number().int().min(1).max(36), + }), +}); + +export type AgreementParameters = z.infer; + +/** + * Charge et valide un jeu de paramètres venu de la base. + * + * Lève plutôt que de retourner un jeu partiel : mieux vaut une page en erreur + * qu'un planning validé par un moteur dont la moitié des règles dorment. + */ +export function parseAgreementParameters(value: unknown): AgreementParameters { + const parsed = agreementParametersSchema.safeParse(value); + if (!parsed.success) { + const first = parsed.error.issues[0]; + throw new Error( + `Paramètres de convention invalides (${first?.path.join('.') ?? '?'}) : ${first?.message ?? 'inconnu'}`, + ); + } + return parsed.data; +} diff --git a/src/domain/compliance/rules.ts b/src/domain/compliance/rules.ts new file mode 100644 index 0000000..acf7789 --- /dev/null +++ b/src/domain/compliance/rules.ts @@ -0,0 +1,754 @@ +import { shiftMinutes } from '@/domain/counters/week'; +import { weekDates, zonedDate } from '@/domain/planning/week'; +import { + RULE_SEVERITY, + type ComplianceContext, + type ComplianceShift, + type Rule, + type RuleCode, + type Violation, +} from '@/domain/compliance/types'; + +/** + * Les règles de convention — PLAN.md §6.2. + * + * Chaque règle est une fonction pure et indépendante. Deux conséquences + * voulues : elle se teste à sa borne exacte sans base de données, et une règle + * fausse n'en contamine aucune autre. + * + * Deux principes gouvernent l'écriture : + * + * 1. **Aucun seuil dans le code.** Tout vient de `context.parameters`. Un test + * charge deux jeux différents et vérifie que les résultats diffèrent. + * 2. **Les durées se mesurent entre instants**, jamais en heures murales. La + * nuit du changement d'heure, un repos de 11 h à l'horloge en fait 10 ou 12. + */ + +const MINUTE = 60_000; + +function violation( + ruleCode: RuleCode, + message: string, + options: { + localDate?: string | null; + context?: Violation['context']; + shiftIds?: string[]; + } = {}, +): Violation { + return { + ruleCode, + severity: RULE_SEVERITY[ruleCode], + localDate: options.localDate ?? null, + message, + context: options.context ?? {}, + shiftIds: options.shiftIds ?? [], + }; +} + +/** Créneaux affectés, triés par début. Les besoins non couverts sont exclus. */ +function assignedShifts(context: ComplianceContext): ComplianceShift[] { + return context.shifts + .filter((shift) => shift.assigned) + .sort((a, b) => a.startAt.getTime() - b.startAt.getTime()); +} + +/** Créneaux de la semaine évaluée seulement, regroupés par date locale. */ +function shiftsByDay( + context: ComplianceContext, +): Map { + const inWeek = new Set(weekDates(context.week)); + const byDay = new Map(); + + for (const shift of assignedShifts(context)) { + const date = zonedDate(shift.startAt, context.timeZone); + if (!inWeek.has(date)) continue; + const list = byDay.get(date) ?? []; + list.push(shift); + byDay.set(date, list); + } + return byDay; +} + +function worked(shift: ComplianceShift): number { + return shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes); +} + +function totalWorked(shifts: ComplianceShift[]): number { + return shifts.reduce((sum, shift) => sum + worked(shift), 0); +} + +function hours(minutes: number): string { + const sign = minutes < 0 ? '−' : ''; + const abs = Math.abs(minutes); + return `${sign}${Math.floor(abs / 60)} h ${String(abs % 60).padStart(2, '0')}`; +} + +/** Un contrat au forfait jours échappe aux règles horaires (PLAN.md §6.4). */ +function isForfait(context: ComplianceContext): boolean { + return context.contract.workTimeArrangement === 'FORFAIT_JOURS'; +} + +// --------------------------------------------------------------------------- +// Durées de travail +// --------------------------------------------------------------------------- + +export const maxDailyWork: Rule = (context) => { + if (isForfait(context)) return []; + const limit = context.parameters.maxDailyWorkMinutes; + + return [...shiftsByDay(context)].flatMap(([date, shifts]) => { + const minutes = totalWorked(shifts); + if (minutes <= limit) return []; + return [ + violation( + 'MAX_DAILY_WORK', + `${hours(minutes)} travaillées, au-delà du maximum de ${hours(limit)}.`, + { + localDate: date, + context: { workedMinutes: minutes, limitMinutes: limit }, + shiftIds: shifts.map((shift) => shift.id), + }, + ), + ]; + }); +}; + +export const maxDailyAmplitude: Rule = (context) => { + const limit = context.parameters.maxDailyAmplitudeMinutes; + // La convention n'en fixe pas : la règle se tait plutôt que d'inventer une + // borne qui ferait désactiver l'ensemble des alertes. + if (limit === null) return []; + + return [...shiftsByDay(context)].flatMap(([date, shifts]) => { + const first = shifts[0]; + const last = shifts[shifts.length - 1]; + if (!first || !last) return []; + + const amplitude = Math.round( + (last.endAt.getTime() - first.startAt.getTime()) / MINUTE, + ); + if (amplitude <= limit) return []; + return [ + violation( + 'MAX_DAILY_AMPLITUDE', + `Amplitude de ${hours(amplitude)}, au-delà du maximum de ${hours(limit)}.`, + { + localDate: date, + context: { amplitudeMinutes: amplitude, limitMinutes: limit }, + shiftIds: shifts.map((shift) => shift.id), + }, + ), + ]; + }); +}; + +/** + * Repos quotidien entre deux créneaux consécutifs. + * + * S'applique **aussi** au forfait jours : le forfait dispense des durées + * maximales, pas des repos (PLAN.md §6.4). + */ +export const minDailyRest: Rule = (context) => { + const limit = context.parameters.minDailyRestMinutes; + const shifts = assignedShifts(context); + const inWeek = new Set(weekDates(context.week)); + const violations: Violation[] = []; + + for (let index = 1; index < shifts.length; index += 1) { + const previous = shifts[index - 1] as ComplianceShift; + const current = shifts[index] as ComplianceShift; + + const rest = Math.round( + (current.startAt.getTime() - previous.endAt.getTime()) / MINUTE, + ); + // Chevauchement : traité par OVERLAPPING_SHIFTS, pas ici. + if (rest < 0) continue; + if (rest >= limit) continue; + + const date = zonedDate(current.startAt, context.timeZone); + // Les semaines voisines ne sont chargées que pour mesurer les bords ; le + // constat se rattache à la semaine évaluée. + if (!inWeek.has(date) && !inWeek.has(zonedDate(previous.startAt, context.timeZone))) { + continue; + } + + violations.push( + violation( + 'MIN_DAILY_REST', + `Repos de ${hours(rest)} entre deux journées, minimum ${hours(limit)}.`, + { + localDate: date, + context: { restMinutes: rest, limitMinutes: limit }, + shiftIds: [previous.id, current.id], + }, + ), + ); + } + + return violations; +}; + +/** + * Repos hebdomadaire : un intervalle continu d'au moins 35 h dans la semaine. + * + * Mesuré sur la semaine évaluée **bordée** par les créneaux adjacents : sans + * eux, un salarié qui travaille du samedi au samedi paraîtrait reposé du + * dimanche précédent au lundi suivant. + */ +export const minWeeklyRest: Rule = (context) => { + const limit = context.parameters.minWeeklyRestMinutes; + const dates = weekDates(context.week); + const shifts = assignedShifts(context); + if (shifts.length === 0) return []; + + const first = dates[0] as string; + const last = dates[6] as string; + const inWeek = shifts.filter((shift) => { + const date = zonedDate(shift.startAt, context.timeZone); + return date >= first && date <= last; + }); + if (inWeek.length === 0) return []; + + const windowStart = (inWeek[0] as ComplianceShift).startAt; + const windowEnd = (inWeek[inWeek.length - 1] as ComplianceShift).endAt; + + // Bornes : dernier créneau avant la semaine, premier après. + const before = shifts.filter((shift) => shift.endAt <= windowStart).pop(); + const after = shifts.find((shift) => shift.startAt >= windowEnd); + + const points = [ + before?.endAt ?? null, + ...inWeek.flatMap((shift) => [shift.startAt, shift.endAt]), + after?.startAt ?? null, + ].filter((value): value is Date => value !== null); + + let longest = 0; + for (let index = 1; index < points.length; index += 2) { + const gapStart = points[index] as Date; + const gapEnd = points[index + 1]; + if (!gapEnd) break; + longest = Math.max( + longest, + Math.round((gapEnd.getTime() - gapStart.getTime()) / MINUTE), + ); + } + + if (longest >= limit) return []; + + return [ + violation( + 'MIN_WEEKLY_REST', + `Plus long repos de la semaine : ${hours(longest)}, minimum ${hours(limit)}.`, + { + context: { restMinutes: longest, limitMinutes: limit }, + shiftIds: inWeek.map((shift) => shift.id), + }, + ), + ]; +}; + +export const maxWeeklyWorkAbsolute: Rule = (context) => { + if (isForfait(context)) return []; + const limit = context.parameters.maxWeeklyWorkAbsoluteMinutes; + const minutes = weeklyMinutes(context); + if (minutes <= limit) return []; + + return [ + violation( + 'MAX_WEEKLY_WORK_ABSOLUTE', + `${hours(minutes)} sur la semaine, au-delà du maximum de ${hours(limit)}.`, + { context: { workedMinutes: minutes, limitMinutes: limit } }, + ), + ]; +}; + +export const maxWeeklyWorkAveraged: Rule = (context) => { + if (isForfait(context)) return []; + const { windowWeeks, maxAverageMinutes } = context.parameters.averagedWeeklyWork; + + const window = [ + weeklyMinutes(context), + ...context.previousWeeklyMinutes.slice(0, windowWeeks - 1), + ]; + // Une moyenne sur une fenêtre incomplète n'a pas de sens : les premières + // semaines d'un contrat déclencheraient une alerte à chaque planning. + if (window.length < windowWeeks) return []; + + const average = Math.round( + window.reduce((sum, value) => sum + value, 0) / window.length, + ); + if (average <= maxAverageMinutes) return []; + + return [ + violation( + 'MAX_WEEKLY_WORK_AVERAGED', + `Moyenne de ${hours(average)} sur ${windowWeeks} semaines, au-delà de ${hours(maxAverageMinutes)}.`, + { + context: { + averageMinutes: average, + limitMinutes: maxAverageMinutes, + windowWeeks, + }, + }, + ), + ]; +}; + +export const maxConsecutiveWorkDays: Rule = (context) => { + const limit = context.parameters.maxConsecutiveWorkDays; + const workedDays = new Set( + assignedShifts(context).map((shift) => + zonedDate(shift.startAt, context.timeZone), + ), + ); + if (workedDays.size === 0) return []; + + const sorted = [...workedDays].sort(); + let run = 1; + let longest = 1; + let runStart = sorted[0] as string; + let longestStart = runStart; + let longestEnd = runStart; + + for (let index = 1; index < sorted.length; index += 1) { + const previous = sorted[index - 1] as string; + const current = sorted[index] as string; + const consecutive = + new Date(`${current}T00:00:00Z`).getTime() - + new Date(`${previous}T00:00:00Z`).getTime() === + 86_400_000; + + if (consecutive) { + run += 1; + } else { + run = 1; + runStart = current; + } + + if (run > longest) { + longest = run; + longestStart = runStart; + longestEnd = current; + } + } + + if (longest <= limit) return []; + + return [ + violation( + 'MAX_CONSECUTIVE_WORK_DAYS', + `${longest} jours travaillés d'affilée, au-delà du maximum de ${limit}.`, + { + localDate: longestEnd, + context: { consecutiveDays: longest, limitDays: limit, from: longestStart }, + }, + ), + ]; +}; + +export const minBreakAfterThreshold: Rule = (context) => { + if (isForfait(context)) return []; + const { thresholdMinutes, minBreakMinutes } = + context.parameters.breakAfterThreshold; + + return assignedShifts(context) + .filter((shift) => { + const date = zonedDate(shift.startAt, context.timeZone); + return weekDates(context.week).includes(date); + }) + .flatMap((shift) => { + // Le seuil porte sur le travail **continu** : c'est la durée du créneau + // pauses comprises qui déclenche l'obligation, pas le total de la journée. + const span = Math.round( + (shift.endAt.getTime() - shift.startAt.getTime()) / MINUTE, + ); + if (span <= thresholdMinutes) return []; + if (shift.breakMinutes >= minBreakMinutes) return []; + + return [ + violation( + 'MIN_BREAK_AFTER_THRESHOLD', + `${shift.breakMinutes} min de pause pour ${hours(span)} de présence : minimum ${minBreakMinutes} min au-delà de ${hours(thresholdMinutes)}.`, + { + localDate: zonedDate(shift.startAt, context.timeZone), + context: { + breakMinutes: shift.breakMinutes, + requiredMinutes: minBreakMinutes, + spanMinutes: span, + }, + shiftIds: [shift.id], + }, + ), + ]; + }); +}; + +// --------------------------------------------------------------------------- +// Contrat +// --------------------------------------------------------------------------- + +export const partTimeMinWeeklyHours: Rule = (context) => { + if (isForfait(context)) return []; + const { minWeeklyMinutes, derogations } = context.parameters.partTime; + const contract = context.contract; + + // Un temps plein n'est pas concerné ; un contrat à 0 h non plus (extra, + // intermittent), faute de durée contractuelle à comparer. + if (contract.weeklyMinutes <= 0) return []; + if (contract.weeklyMinutes >= context.parameters.weeklyReferenceMinutes) { + return []; + } + + const derogation = contract.partTimeDerogationCode + ? derogations.find((entry) => entry.code === contract.partTimeDerogationCode) + : undefined; + const floor = derogation?.minWeeklyMinutes ?? minWeeklyMinutes; + + if (contract.weeklyMinutes >= floor) return []; + + return [ + violation( + 'PART_TIME_MIN_WEEKLY_HOURS', + `Contrat à ${hours(contract.weeklyMinutes)}, en dessous du minimum de ${hours(floor)}${derogation ? ` (${derogation.label})` : ''}.`, + { + context: { + contractMinutes: contract.weeklyMinutes, + floorMinutes: floor, + derogation: derogation?.code ?? null, + }, + }, + ), + ]; +}; + +export const contractHoursDeviation: Rule = (context) => { + if (isForfait(context)) return []; + const tolerance = context.parameters.contractDeviationToleranceMinutes; + const contracted = context.contract.weeklyMinutes; + if (contracted <= 0) return []; + + const planned = weeklyMinutes(context); + const deviation = planned - contracted; + if (Math.abs(deviation) <= tolerance) return []; + + return [ + violation( + 'CONTRACT_HOURS_DEVIATION', + `${hours(planned)} planifiées pour ${hours(contracted)} au contrat (écart ${deviation > 0 ? '+' : ''}${hours(deviation)}).`, + { + context: { + plannedMinutes: planned, + contractMinutes: contracted, + deviationMinutes: deviation, + }, + }, + ), + ]; +}; + +// --------------------------------------------------------------------------- +// Cohérence — bloquantes +// --------------------------------------------------------------------------- + +export const overlappingShifts: Rule = (context) => { + const shifts = assignedShifts(context); + const violations: Violation[] = []; + + for (let index = 1; index < shifts.length; index += 1) { + const previous = shifts[index - 1] as ComplianceShift; + const current = shifts[index] as ComplianceShift; + if (current.startAt >= previous.endAt) continue; + + violations.push( + violation( + 'OVERLAPPING_SHIFTS', + 'Deux créneaux se recouvrent : les heures seraient comptées deux fois.', + { + localDate: zonedDate(current.startAt, context.timeZone), + context: { + overlapMinutes: Math.round( + (previous.endAt.getTime() - current.startAt.getTime()) / MINUTE, + ), + }, + shiftIds: [previous.id, current.id], + }, + ), + ); + } + + return violations; +}; + +export const shiftDuringAbsence: Rule = (context) => { + return assignedShifts(context).flatMap((shift) => { + const date = zonedDate(shift.startAt, context.timeZone); + // `endDate` porte le **dernier jour d'absence**, pas la date de reprise : + // la comparaison est donc inclusive aux deux bornes. + const absence = context.absences.find( + (entry) => date >= entry.startDate && date <= entry.endDate, + ); + if (!absence) return []; + + return [ + violation( + 'SHIFT_DURING_ABSENCE', + `Créneau planifié pendant une absence (${absence.label}).`, + { + localDate: date, + context: { absence: absence.label }, + shiftIds: [shift.id], + }, + ), + ]; + }); +}; + +// --------------------------------------------------------------------------- +// Dimanches et jours fériés +// --------------------------------------------------------------------------- + +/** + * Travail dominical. + * + * Informative, jamais bloquante : le refus d'un salarié de travailler le + * dimanche ne peut être sanctionné, et l'outil n'a pas à trancher. Elle porte + * la double contrepartie de l'article L3132-27 — majoration **et** repos + * compensateur d'égale durée. N'annoncer que la majoration serait un + * manquement, pas une simplification. + */ +export const sundayWork: Rule = (context) => { + const { premiumPercent, compensatoryRestPercent } = context.parameters.sunday; + const sunday = weekDates(context.week)[6]; + if (!sunday) return []; + + const shifts = shiftsByDay(context).get(sunday) ?? []; + if (shifts.length === 0) return []; + + const minutes = totalWorked(shifts); + const rest = Math.round((minutes * compensatoryRestPercent) / 100); + + return [ + violation( + 'SUNDAY_WORK', + `Dimanche travaillé : ${hours(minutes)} majorées de ${premiumPercent} % et ${hours(rest)} de repos compensateur.`, + { + localDate: sunday, + context: { + workedMinutes: minutes, + premiumPercent, + compensatoryRestMinutes: rest, + }, + shiftIds: shifts.map((shift) => shift.id), + }, + ), + ]; +}; + +/** + * Quota des dimanches du maire — L3132-26. + * + * Douze par année civile, sur une liste arrêtée avant le 31 décembre pour + * l'année suivante. Deux manquements distincts sont possibles : dépasser le + * quota, ou travailler un dimanche qui n'est pas sur la liste. + */ +export const sundayMayorQuota: Rule = (context) => { + const quota = context.parameters.sunday.mayorQuotaPerYear; + const sunday = weekDates(context.week)[6]; + if (!sunday) return []; + + const shifts = shiftsByDay(context).get(sunday) ?? []; + if (shifts.length === 0) return []; + + const violations: Violation[] = []; + const rank = context.sundaysWorkedBefore + 1; + + if (rank > quota) { + violations.push( + violation( + 'SUNDAY_MAYOR_QUOTA', + `${rank}ᵉ dimanche travaillé dans l'année : le quota légal est de ${quota}.`, + { + localDate: sunday, + context: { rank, quota }, + shiftIds: shifts.map((shift) => shift.id), + }, + ), + ); + } + + if ( + context.authorisedSundays.length > 0 && + !context.authorisedSundays.includes(sunday) + ) { + violations.push( + violation( + 'SUNDAY_MAYOR_QUOTA', + "Ce dimanche ne figure pas sur la liste arrêtée pour l'année.", + { + localDate: sunday, + context: { authorised: false }, + shiftIds: shifts.map((shift) => shift.id), + }, + ), + ); + } + + return violations; +}; + +/** + * Jour férié travaillé. + * + * Le repos de substitution existe mais ne se déclenche pas ici : la convention + * le subordonne à une **demande du salarié**. L'outil signale le droit, il ne + * le substitue pas d'office. + */ +export const holidayWork: Rule = (context) => { + const { workedPremiumPercent, labourDayPremiumPercent } = + context.parameters.holiday; + + return [...shiftsByDay(context)] + .filter(([date]) => context.holidays.includes(date)) + .map(([date, shifts]) => { + const minutes = totalWorked(shifts); + const labourDay = date.slice(5) === '05-01'; + const premium = labourDay ? labourDayPremiumPercent : workedPremiumPercent; + + return violation( + 'HOLIDAY_WORK', + labourDay + ? `1er mai travaillé : ${hours(minutes)} majorées de ${premium} %. Le 1er mai est en principe chômé.` + : `Jour férié travaillé : indemnité de ${premium} % des ${hours(minutes)} effectuées.`, + { + localDate: date, + context: { + workedMinutes: minutes, + premiumPercent: premium, + labourDay, + }, + shiftIds: shifts.map((shift) => shift.id), + }, + ); + }); +}; + +// --------------------------------------------------------------------------- +// Forfait jours +// --------------------------------------------------------------------------- + +export const forfaitDaysExceeded: Rule = (context) => { + if (!isForfait(context) || !context.forfait) return []; + const cap = + context.contract.forfaitDaysPerYear ?? + context.parameters.forfaitJours.maxDaysPerYear; + + const workedThisWeek = new Set( + assignedShifts(context) + .map((shift) => zonedDate(shift.startAt, context.timeZone)) + .filter((date) => weekDates(context.week).includes(date)), + ).size; + + const total = context.forfait.daysUsed + workedThisWeek; + if (total <= cap) return []; + + return [ + violation( + 'FORFAIT_DAYS_EXCEEDED', + `${total} jours décomptés sur la période, au-delà du forfait de ${cap} jours.`, + { context: { daysUsed: total, cap } }, + ), + ]; +}; + +export const forfaitWorkloadReviewMissing: Rule = (context) => { + if (!isForfait(context) || !context.forfait) return []; + const months = context.parameters.forfaitJours.workloadReviewIntervalMonths; + const last = context.forfait.lastWorkloadReviewAt; + + const reference = (weekDates(context.week)[0] ?? '') + 'T00:00:00Z'; + const dueSince = new Date(reference); + dueSince.setUTCMonth(dueSince.getUTCMonth() - months); + + if (last && last >= dueSince) return []; + + return [ + violation( + 'FORFAIT_WORKLOAD_REVIEW_MISSING', + last + ? `Dernier entretien de charge le ${last.toISOString().slice(0, 10)} : l'intervalle maximal est de ${months} mois.` + : `Aucun entretien annuel de charge enregistré ; l'intervalle maximal est de ${months} mois.`, + { + context: { + lastReview: last ? last.toISOString().slice(0, 10) : null, + intervalMonths: months, + }, + }, + ), + ]; +}; + +/** + * Repos insuffisant au forfait jours. + * + * Le forfait dispense des durées maximales, pas du repos. C'est justement la + * population où la dérive passe inaperçue : sans compteur d'heures, seule + * l'absence de repos reste observable. + */ +export const forfaitRestInsufficient: Rule = (context) => { + if (!isForfait(context)) return []; + + const daily = minDailyRest(context); + const weekly = minWeeklyRest(context); + if (daily.length === 0 && weekly.length === 0) return []; + + return [ + violation( + 'FORFAIT_REST_INSUFFICIENT', + `Repos insuffisant sur la semaine pour un salarié au forfait jours (${daily.length + weekly.length} constat${daily.length + weekly.length > 1 ? 's' : ''}).`, + { + context: { + dailyBreaches: daily.length, + weeklyBreaches: weekly.length, + }, + shiftIds: [...daily, ...weekly].flatMap((entry) => entry.shiftIds), + }, + ), + ]; +}; + +// --------------------------------------------------------------------------- + +/** Minutes travaillées sur la semaine évaluée. */ +export function weeklyMinutes(context: ComplianceContext): number { + return [...shiftsByDay(context).values()].reduce( + (sum, shifts) => sum + totalWorked(shifts), + 0, + ); +} + +/** + * Registre des règles. + * + * L'ordre est celui de PLAN.md §6.2 pour que la comparaison avec la + * spécification reste immédiate. + */ +export const RULES: Record = { + MAX_DAILY_WORK: maxDailyWork, + MAX_DAILY_AMPLITUDE: maxDailyAmplitude, + MIN_DAILY_REST: minDailyRest, + MIN_WEEKLY_REST: minWeeklyRest, + MAX_WEEKLY_WORK_ABSOLUTE: maxWeeklyWorkAbsolute, + MAX_WEEKLY_WORK_AVERAGED: maxWeeklyWorkAveraged, + MAX_CONSECUTIVE_WORK_DAYS: maxConsecutiveWorkDays, + MIN_BREAK_AFTER_THRESHOLD: minBreakAfterThreshold, + PART_TIME_MIN_WEEKLY_HOURS: partTimeMinWeeklyHours, + CONTRACT_HOURS_DEVIATION: contractHoursDeviation, + OVERLAPPING_SHIFTS: overlappingShifts, + SHIFT_DURING_ABSENCE: shiftDuringAbsence, + SUNDAY_WORK: sundayWork, + SUNDAY_MAYOR_QUOTA: sundayMayorQuota, + HOLIDAY_WORK: holidayWork, + FORFAIT_DAYS_EXCEEDED: forfaitDaysExceeded, + FORFAIT_WORKLOAD_REVIEW_MISSING: forfaitWorkloadReviewMissing, + FORFAIT_REST_INSUFFICIENT: forfaitRestInsufficient, +}; diff --git a/src/domain/compliance/types.ts b/src/domain/compliance/types.ts new file mode 100644 index 0000000..88f5db6 --- /dev/null +++ b/src/domain/compliance/types.ts @@ -0,0 +1,159 @@ +import type { AgreementParameters } from '@/domain/compliance/parameters'; +import type { IsoWeek } from '@/domain/planning/week'; + +/** + * Vocabulaire du moteur de règles — PLAN.md §6. + * + * Le moteur ne connaît ni Prisma ni React : il prend un contexte, rend des + * violations. C'est ce qui le rend testable aux bornes sans base de données, + * et c'est la seule façon d'avoir confiance dans des seuils dont dépendent des + * durées de travail réelles. + */ + +export const RULE_CODES = [ + 'MAX_DAILY_WORK', + 'MAX_DAILY_AMPLITUDE', + 'MIN_DAILY_REST', + 'MIN_WEEKLY_REST', + 'MAX_WEEKLY_WORK_ABSOLUTE', + 'MAX_WEEKLY_WORK_AVERAGED', + 'MAX_CONSECUTIVE_WORK_DAYS', + 'MIN_BREAK_AFTER_THRESHOLD', + 'PART_TIME_MIN_WEEKLY_HOURS', + 'CONTRACT_HOURS_DEVIATION', + 'OVERLAPPING_SHIFTS', + 'SHIFT_DURING_ABSENCE', + 'SUNDAY_WORK', + 'SUNDAY_MAYOR_QUOTA', + 'HOLIDAY_WORK', + 'FORFAIT_DAYS_EXCEEDED', + 'FORFAIT_WORKLOAD_REVIEW_MISSING', + 'FORFAIT_REST_INSUFFICIENT', +] as const; + +export type RuleCode = (typeof RULE_CODES)[number]; + +/** + * Règles de mineurs — **non implémentées, volontairement.** + * + * La matrice de conformité ne couvre que les majeurs. Les seuils applicables + * aux moins de 18 ans (durée quotidienne, repos, interdiction de nuit) n'ont + * pas de source primaire dans le dossier. Les inventer donnerait une fausse + * assurance sur la population que le droit protège le plus. + * + * Ces codes sont réservés ici pour que le jour où les valeurs sont fournies, + * l'ajout soit un fichier de règle et une entrée de paramètres — pas une + * migration de données. + */ +export const RESERVED_MINOR_RULE_CODES = [ + 'MINOR_MAX_DAILY_WORK', + 'MINOR_MIN_DAILY_REST', + 'MINOR_NIGHT_WORK', +] as const; + +export type Severity = 'INFO' | 'WARNING' | 'BLOCKING'; + +/** + * Sévérité par règle. + * + * `OVERLAPPING_SHIFTS` et `SHIFT_DURING_ABSENCE` bloquent : ce sont des + * incohérences de données, pas des arbitrages d'organisation. Les autres + * avertissent — un manager doit pouvoir passer outre en connaissance de cause, + * et cette décision est tracée. + */ +export const RULE_SEVERITY: Record = { + MAX_DAILY_WORK: 'WARNING', + MAX_DAILY_AMPLITUDE: 'WARNING', + MIN_DAILY_REST: 'WARNING', + MIN_WEEKLY_REST: 'WARNING', + MAX_WEEKLY_WORK_ABSOLUTE: 'WARNING', + MAX_WEEKLY_WORK_AVERAGED: 'WARNING', + MAX_CONSECUTIVE_WORK_DAYS: 'WARNING', + MIN_BREAK_AFTER_THRESHOLD: 'WARNING', + PART_TIME_MIN_WEEKLY_HOURS: 'WARNING', + CONTRACT_HOURS_DEVIATION: 'WARNING', + OVERLAPPING_SHIFTS: 'BLOCKING', + SHIFT_DURING_ABSENCE: 'BLOCKING', + SUNDAY_WORK: 'INFO', + SUNDAY_MAYOR_QUOTA: 'WARNING', + HOLIDAY_WORK: 'INFO', + FORFAIT_DAYS_EXCEEDED: 'WARNING', + FORFAIT_WORKLOAD_REVIEW_MISSING: 'WARNING', + FORFAIT_REST_INSUFFICIENT: 'WARNING', +}; + +export interface Violation { + ruleCode: RuleCode; + severity: Severity; + /** Jour concerné, `null` quand la règle porte sur la semaine entière. */ + localDate: string | null; + message: string; + /** Éléments chiffrés du constat, pour l'affichage et la preuve. */ + context: Record; + /** Créneaux mis en cause, pour poser le badge sur la bonne cellule. */ + shiftIds: string[]; +} + +export interface ComplianceShift { + id: string; + startAt: Date; + endAt: Date; + breakMinutes: number; + /** Faux pour un besoin non couvert : aucune règle de personne ne s'applique. */ + assigned: boolean; +} + +export interface ComplianceAbsence { + /** Premier jour d'absence, en date civile. */ + startDate: string; + /** **Dernier** jour d'absence, pas la date de reprise. */ + endDate: string; + label: string; +} + +export interface ComplianceContract { + workTimeArrangement: 'HOURLY' | 'FORFAIT_JOURS'; + weeklyMinutes: number; + forfaitDaysPerYear: number | null; + /** Code de dérogation au minimum de temps partiel, s'il y en a une. */ + partTimeDerogationCode: string | null; +} + +export interface ForfaitState { + /** Jours déjà décomptés sur la période de référence. */ + daysUsed: number; + lastWorkloadReviewAt: Date | null; +} + +/** + * Tout ce dont les règles ont besoin, et rien de plus. + * + * Les créneaux couvrent la semaine évaluée **et ses voisines** : le repos + * quotidien se mesure entre dimanche soir et lundi matin, donc à cheval sur + * deux semaines. Une évaluation qui ne regarderait que sept jours manquerait + * précisément les infractions de bord. + */ +export interface ComplianceContext { + week: IsoWeek; + timeZone: string; + membershipId: string; + contract: ComplianceContract; + /** Semaine évaluée et semaines adjacentes, triés ou non. */ + shifts: ComplianceShift[]; + absences: ComplianceAbsence[]; + /** Jours fériés de l'établissement, en dates civiles. */ + holidays: string[]; + /** Dimanches du maire autorisés pour l'année, en dates civiles. */ + authorisedSundays: string[]; + /** Dimanches déjà travaillés dans l'année, hors semaine évaluée. */ + sundaysWorkedBefore: number; + /** + * Durées hebdomadaires des semaines précédentes, la plus récente en tête. + * Sert à la moyenne glissante. + */ + previousWeeklyMinutes: number[]; + forfait: ForfaitState | null; + parameters: AgreementParameters; +} + +export type Rule = (context: ComplianceContext) => Violation[]; diff --git a/src/server/compliance/evaluate.ts b/src/server/compliance/evaluate.ts new file mode 100644 index 0000000..bb245a3 --- /dev/null +++ b/src/server/compliance/evaluate.ts @@ -0,0 +1,438 @@ +import { evaluate } from '@/domain/compliance/engine'; +import { + parseAgreementParameters, + type AgreementParameters, +} from '@/domain/compliance/parameters'; +import type { + ComplianceContext, + ComplianceShift, + Violation, +} from '@/domain/compliance/types'; +import { shiftMinutes } from '@/domain/counters/week'; +import { + nextIsoWeek, + previousIsoWeek, + weekBounds, + weekDates, + zonedDate, + type IsoWeek, +} from '@/domain/planning/week'; +import type { ScopedClient } from '@/server/tenant'; + +/** + * Branchement du moteur de règles sur la base — PLAN.md §6.5. + * + * Ce module fait une seule chose : rassembler le contexte d'évaluation et + * ranger les constats. Il ne contient **aucune règle** — c'est ce qui permet de + * tester les règles sans base et de tester le branchement sans les rejouer. + */ + +export interface AgreementVersion { + id: string; + idcc: string; + version: number; + effectiveFrom: Date; + parameters: AgreementParameters; +} + +/** + * Version de convention applicable à une date. + * + * La plus récente **dont la date d'effet est antérieure** — pas simplement la + * dernière. Recalculer une semaine de mars avec les paramètres publiés en juin + * changerait le passé, ce que la matrice n° 1 interdit. + */ +export async function agreementFor( + db: ScopedClient, + onDate: Date, +): Promise { + const row = await db.collectiveAgreement.findFirst({ + where: { effectiveFrom: { lte: onDate } }, + orderBy: [{ effectiveFrom: 'desc' }, { version: 'desc' }], + }); + if (!row) return null; + + return { + id: row.id, + idcc: row.idcc, + version: row.version, + effectiveFrom: row.effectiveFrom, + parameters: parseAgreementParameters(row.parameters), + }; +} + +interface LoadedShift { + id: string; + membershipId: string | null; + startAt: Date; + endAt: Date; + breakMinutes: number; +} + +/** + * Évalue la semaine d'une équipe et remplace ses constats. + * + * Les acquittements sont **reportés** sur les constats identiques : un manager + * qui a justifié une alerte ne doit pas la revoir surgir parce qu'un collègue a + * déplacé un créneau ailleurs dans la semaine. + */ +export async function evaluateSchedule( + db: ScopedClient, + scheduleId: string, +): Promise { + const schedule = await db.weeklySchedule.findUnique({ + where: { id: scheduleId }, + }); + if (!schedule) return []; + + const location = await db.location.findUnique({ + where: { id: schedule.locationId }, + select: { timezone: true }, + }); + if (!location) return []; + + const week: IsoWeek = { + isoYear: schedule.isoYear, + isoWeek: schedule.isoWeek, + }; + const dates = weekDates(week); + const agreement = await agreementFor( + db, + new Date(`${dates[0]}T00:00:00Z`), + ); + // Sans convention chargée, on ne prétend pas contrôler : mieux vaut aucun + // constat qu'un contrôle silencieusement partiel. + if (!agreement) return []; + + const contexts = await buildContexts( + db, + schedule.teamId, + week, + location.timezone, + agreement.parameters, + ); + + const violations = contexts.flatMap((context) => { + const result = evaluate(context); + return result.violations.map((entry) => ({ + ...entry, + membershipId: context.membershipId, + })); + }); + + const previous = await db.complianceViolation.findMany({ + where: { weeklyScheduleId: scheduleId }, + }); + const acknowledgedKeys = new Map( + previous + .filter((entry) => entry.acknowledgedAt !== null) + .map((entry) => [ + violationKey( + entry.membershipId, + entry.ruleCode, + entry.localDate?.toISOString().slice(0, 10) ?? null, + ), + entry, + ]), + ); + + await db.complianceViolation.deleteMany({ + where: { weeklyScheduleId: scheduleId }, + }); + + for (const entry of violations) { + const carried = acknowledgedKeys.get( + violationKey(entry.membershipId, entry.ruleCode, entry.localDate), + ); + + await db.complianceViolation.create({ + data: { + weeklyScheduleId: scheduleId, + membershipId: entry.membershipId, + ruleCode: entry.ruleCode, + severity: entry.severity, + localDate: entry.localDate ? new Date(`${entry.localDate}T00:00:00Z`) : null, + message: entry.message, + context: entry.context, + shiftIds: entry.shiftIds, + agreementId: agreement.id, + acknowledgedBy: carried?.acknowledgedBy ?? null, + acknowledgedAt: carried?.acknowledgedAt ?? null, + acknowledgementReason: carried?.acknowledgementReason ?? null, + } as never, + }); + } + + return violations; +} + +function violationKey( + membershipId: string | null, + ruleCode: string, + localDate: string | null, +): string { + return `${membershipId ?? '-'}|${ruleCode}|${localDate ?? '-'}`; +} + +/** + * Évalue une semaine **et ses voisines**. + * + * Le repos quotidien se mesure entre dimanche soir et lundi matin : déplacer un + * créneau du lundi peut créer une infraction dans la semaine précédente, qui + * resterait sinon marquée conforme. + */ +export async function evaluateAround( + db: ScopedClient, + scheduleId: string, +): Promise { + const schedule = await db.weeklySchedule.findUnique({ + where: { id: scheduleId }, + select: { teamId: true, isoYear: true, isoWeek: true }, + }); + if (!schedule) return; + + const current = { isoYear: schedule.isoYear, isoWeek: schedule.isoWeek }; + const neighbours = [previousIsoWeek(current), nextIsoWeek(current)]; + + await evaluateSchedule(db, scheduleId); + + for (const week of neighbours) { + const sibling = await db.weeklySchedule.findUnique({ + where: { + teamId_isoYear_isoWeek: { + teamId: schedule.teamId, + isoYear: week.isoYear, + isoWeek: week.isoWeek, + }, + }, + select: { id: true }, + }); + if (sibling) await evaluateSchedule(db, sibling.id); + } +} + +/** Un contexte par salarié rattaché à l'équipe. */ +async function buildContexts( + db: ScopedClient, + teamId: string, + week: IsoWeek, + timeZone: string, + parameters: AgreementParameters, +): Promise { + const assignments = await db.teamMember.findMany({ + where: { teamId }, + include: { + membership: { + include: { + contracts: { + where: { status: 'ACTIVE' }, + orderBy: { startDate: 'desc' }, + take: 1, + }, + }, + }, + }, + }); + + const team = await db.team.findUnique({ + where: { id: teamId }, + select: { locationId: true }, + }); + if (!team) return []; + + const dates = weekDates(week); + const first = dates[0] as string; + const last = dates[6] as string; + + // Fenêtre élargie aux semaines voisines : c'est là que se mesurent les repos + // de bord, qu'une lecture de sept jours manquerait par construction. + const before = weekBounds(previousIsoWeek(week), timeZone); + const after = weekBounds(nextIsoWeek(week), timeZone); + + const shifts = await db.shift.findMany({ + where: { + membershipId: { + in: assignments.map((assignment) => assignment.membershipId), + }, + startAt: { gte: before.from, lt: after.to }, + }, + select: { + id: true, + membershipId: true, + startAt: true, + endAt: true, + breakMinutes: true, + }, + }); + + const unassigned = await db.shift.findMany({ + where: { + membershipId: null, + startAt: { gte: before.from, lt: after.to }, + schedule: { teamId }, + }, + select: { + id: true, + membershipId: true, + startAt: true, + endAt: true, + breakMinutes: true, + }, + }); + + const holidays = await db.holiday.findMany({ + where: { + locationId: team.locationId, + localDate: { + gte: new Date(`${first}T00:00:00Z`), + lte: new Date(`${last}T00:00:00Z`), + }, + }, + select: { localDate: true }, + }); + + const year = Number(first.slice(0, 4)); + const authorisedSundays = await db.authorisedSunday.findMany({ + where: { + locationId: team.locationId, + localDate: { + gte: new Date(`${year}-01-01T00:00:00Z`), + lte: new Date(`${year}-12-31T00:00:00Z`), + }, + }, + select: { localDate: true }, + }); + + const byMember = new Map(); + for (const shift of [...shifts, ...unassigned]) { + const key = shift.membershipId ?? '__unassigned__'; + const list = byMember.get(key) ?? []; + list.push(shift); + byMember.set(key, list); + } + + return Promise.all( + assignments.map(async (assignment) => { + const contract = assignment.membership.contracts[0]; + const own = byMember.get(assignment.membershipId) ?? []; + + const complianceShifts: ComplianceShift[] = own.map((shift) => ({ + id: shift.id, + startAt: shift.startAt, + endAt: shift.endAt, + breakMinutes: shift.breakMinutes, + assigned: shift.membershipId !== null, + })); + + const sundaysWorkedBefore = await countSundaysWorked( + db, + assignment.membershipId, + year, + timeZone, + first, + ); + + return { + week, + timeZone, + membershipId: assignment.membershipId, + contract: { + workTimeArrangement: + contract?.workTimeArrangement === 'FORFAIT_JOURS' + ? 'FORFAIT_JOURS' + : 'HOURLY', + weeklyMinutes: contract + ? Math.round(Number(contract.weeklyHours.toString()) * 60) + : 0, + forfaitDaysPerYear: contract?.forfaitDaysPerYear + ? Number(contract.forfaitDaysPerYear.toString()) + : null, + partTimeDerogationCode: null, + }, + shifts: complianceShifts, + // Les absences arrivent avec WP-06 ; la règle est déjà écrite et + // testée, elle attend seulement sa source. + absences: [], + holidays: holidays.map((holiday) => + holiday.localDate.toISOString().slice(0, 10), + ), + authorisedSundays: authorisedSundays.map((sunday) => + sunday.localDate.toISOString().slice(0, 10), + ), + sundaysWorkedBefore, + previousWeeklyMinutes: await previousWeeks( + db, + assignment.membershipId, + week, + timeZone, + parameters.averagedWeeklyWork.windowWeeks - 1, + ), + forfait: null, + parameters, + } satisfies ComplianceContext; + }), + ); +} + +/** Dimanches déjà travaillés dans l'année, avant la semaine évaluée. */ +async function countSundaysWorked( + db: ScopedClient, + membershipId: string, + year: number, + timeZone: string, + beforeDate: string, +): Promise { + const shifts = await db.shift.findMany({ + where: { + membershipId, + startAt: { + gte: new Date(`${year}-01-01T00:00:00Z`), + lt: new Date(`${beforeDate}T00:00:00Z`), + }, + }, + select: { startAt: true }, + }); + + const sundays = new Set( + shifts + .map((shift) => zonedDate(shift.startAt, timeZone)) + .filter((date) => new Date(`${date}T00:00:00Z`).getUTCDay() === 0), + ); + return sundays.size; +} + +/** Durées des semaines précédentes, la plus récente en tête. */ +async function previousWeeks( + db: ScopedClient, + membershipId: string, + week: IsoWeek, + timeZone: string, + count: number, +): Promise { + const result: number[] = []; + let cursor = week; + + for (let index = 0; index < count; index += 1) { + cursor = previousIsoWeek(cursor); + const { from, to } = weekBounds(cursor, timeZone); + const shifts = await db.shift.findMany({ + where: { membershipId, startAt: { gte: from, lt: to } }, + select: { startAt: true, endAt: true, breakMinutes: true }, + }); + + // Une semaine sans créneau **enregistré** n'est pas une semaine à zéro + // heure : c'est une semaine inconnue. La distinction compte pour la + // moyenne glissante, qui serait sinon systématiquement tirée vers le bas. + if (shifts.length === 0) break; + + result.push( + shifts.reduce( + (sum, shift) => + sum + shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes), + 0, + ), + ); + } + + return result; +} diff --git a/src/server/planning/actions.ts b/src/server/planning/actions.ts index a5cf339..ad184c4 100644 --- a/src/server/planning/actions.ts +++ b/src/server/planning/actions.ts @@ -14,6 +14,7 @@ import { zonedInstant, } from '@/domain/planning/week'; import { recordAudit } from '@/server/audit'; +import { evaluateAround, evaluateSchedule } from '@/server/compliance/evaluate'; import { mutate } from '@/server/context'; import type { ScopedClient } from '@/server/tenant'; @@ -186,6 +187,8 @@ export async function createShiftAction( minutes: worked, }, }); + + await assertNoBlocking(db, schedule.id); }); } catch (error) { return toState(error, 'Vous ne pouvez pas créer de créneau ici.'); @@ -290,6 +293,8 @@ export async function updateShiftAction( endAt: endAt.toISOString(), }, }); + + await assertNoBlocking(db, shift.weeklyScheduleId); }); } catch (error) { return toState(error, 'Vous ne pouvez pas modifier ce créneau.'); @@ -333,6 +338,11 @@ export async function deleteShiftAction( }); await db.shift.delete({ where: { id: shift.id } }); + + // Supprimer ne peut pas créer d'incohérence, mais peut en résoudre une : + // la semaine et ses voisines sont réévaluées pour que les badges + // disparaissent avec le créneau. + await evaluateAround(db, shift.weeklyScheduleId); }); } catch (error) { return toState(error, 'Vous ne pouvez pas supprimer ce créneau.'); @@ -346,6 +356,8 @@ const publishInput = z.object({ teamId: z.string().min(1), week: z.string().min(1), expectedVersion: z.coerce.number().int().min(0), + /** Justification exigée dès qu'une alerte non acquittée subsiste. */ + acknowledgement: z.string().trim().max(500).optional(), }); export async function publishWeekAction( @@ -480,6 +492,8 @@ export async function duplicateWeekAction( copied += 1; } + await assertNoBlocking(db, to.id); + await recordAudit(db, { actorMembershipId: actor.membershipId, action: 'planning.week.duplicate', @@ -516,6 +530,7 @@ async function setPublication( teamId: formData.get('teamId'), week: formData.get('week'), expectedVersion: formData.get('expectedVersion') ?? 0, + acknowledgement: formData.get('acknowledgement') || undefined, }); if (!parsed.success) { @@ -545,6 +560,16 @@ async function setPublication( 'Cette semaine ne contient aucun créneau : rien à publier.', ); } + + // Contrôle complet du périmètre publié, pas seulement des créneaux + // touchés depuis la dernière saisie : publier est l'engagement. + await evaluateSchedule(db, schedule.id); + await acknowledgeBeforePublishing( + db, + schedule.id, + actor.membershipId, + parsed.data.acknowledgement ?? null, + ); } const updated = await db.weeklySchedule.updateMany({ @@ -590,6 +615,94 @@ async function setPublication( return { ok: true }; } + + +/** + * Publier malgré une alerte suppose de l'assumer. + * + * Un `BLOCKING` interdit la publication : c'est une incohérence, pas un choix. + * Un `WARNING` reste franchissable, mais la justification est enregistrée sur + * chaque constat et laisse une entrée d'audit — c'est ce qui distingue une + * dérogation assumée d'une alerte ignorée. + */ +async function acknowledgeBeforePublishing( + db: ScopedClient, + scheduleId: string, + actorMembershipId: string, + reason: string | null, +): Promise { + const blocking = await db.complianceViolation.findFirst({ + where: { weeklyScheduleId: scheduleId, severity: 'BLOCKING' }, + select: { message: true }, + }); + if (blocking) { + throw new ValidationError( + `Publication impossible — ${blocking.message}`, + ); + } + + const pending = await db.complianceViolation.findMany({ + where: { + weeklyScheduleId: scheduleId, + severity: 'WARNING', + acknowledgedAt: null, + }, + select: { id: true, ruleCode: true, message: true }, + }); + if (pending.length === 0) return; + + if (!reason) { + throw new ValidationError( + `${pending.length} alerte${pending.length > 1 ? 's' : ''} de convention non justifiée${pending.length > 1 ? 's' : ''} : ${pending[0]?.message ?? ''} Indiquez un motif pour publier malgré tout.`, + ); + } + + const now = new Date(); + await db.complianceViolation.updateMany({ + where: { id: { in: pending.map((entry) => entry.id) } }, + data: { + acknowledgedBy: actorMembershipId, + acknowledgedAt: now, + acknowledgementReason: reason, + }, + }); + + await recordAudit(db, { + actorMembershipId, + action: 'planning.alert.acknowledge', + entityType: 'WeeklySchedule', + entityId: scheduleId, + after: { + acknowledged: pending.length, + rules: pending.map((entry) => entry.ruleCode).join(', '), + }, + reason, + }); +} + +/** + * Réévalue la semaine touchée et ses voisines, et refuse l'enregistrement si un + * constat bloquant apparaît. + * + * Les `BLOCKING` sont des incohérences de données — chevauchement, créneau + * pendant une absence — pas des arbitrages d'organisation. La transaction est + * annulée : mieux vaut refuser une saisie que garder en base un planning dont + * les heures se comptent deux fois. + */ +async function assertNoBlocking( + db: ScopedClient, + scheduleId: string, +): Promise { + await evaluateAround(db, scheduleId); + + const blocking = await db.complianceViolation.findFirst({ + where: { weeklyScheduleId: scheduleId, severity: 'BLOCKING' }, + select: { message: true }, + }); + + if (blocking) throw new ValidationError(blocking.message); +} + /** * Deux créneaux qui se chevauchent pour le même salarié sont impossibles dans * la réalité, et compteraient deux fois en heures. Le contrôle est en base et diff --git a/src/server/planning/queries.ts b/src/server/planning/queries.ts index e1c7850..9f77a04 100644 --- a/src/server/planning/queries.ts +++ b/src/server/planning/queries.ts @@ -41,6 +41,21 @@ export interface BoardSection { publishedAt: Date | null; rows: BoardRow[]; unassignedRow: BoardRow | null; + alerts: BoardAlert[]; + /** Créneaux portant au moins un constat, pour le badge de cellule. */ + flaggedShiftIds: string[]; +} + +export interface BoardAlert { + id: string; + ruleCode: string; + severity: 'INFO' | 'WARNING' | 'BLOCKING'; + message: string; + localDate: string | null; + membershipId: string | null; + shiftIds: string[]; + acknowledged: boolean; + acknowledgementReason: string | null; } export interface BoardLabel { @@ -163,6 +178,14 @@ export async function getWeekBoard( const people = await loadPeople(db, teams.map((team) => team.id)); + // Les constats sont lus, jamais recalculés à l'affichage : ils datent de + // la dernière écriture, avec la version de convention qui s'appliquait + // alors. Les recalculer ici les ferait diverger de ce qui a été acquitté. + const alerts = await db.complianceViolation.findMany({ + where: { weeklyScheduleId: { in: schedules.map((s) => s.id) } }, + orderBy: [{ severity: 'asc' }, { localDate: 'asc' }], + }); + const sections = teams.map((team) => { const schedule = scheduleByTeam.get(team.id) ?? null; const isPublished = schedule?.status === 'PUBLISHED'; @@ -188,6 +211,20 @@ export async function getWeekBoard( isPublished, ); + const teamAlerts = alerts + .filter((alert) => alert.weeklyScheduleId === schedule?.id) + .map((alert) => ({ + id: alert.id, + ruleCode: alert.ruleCode, + severity: alert.severity, + message: alert.message, + localDate: alert.localDate?.toISOString().slice(0, 10) ?? null, + membershipId: alert.membershipId, + shiftIds: alert.shiftIds, + acknowledged: alert.acknowledgedAt !== null, + acknowledgementReason: alert.acknowledgementReason, + })); + return { teamId: team.id, teamName: team.name, @@ -198,6 +235,14 @@ export async function getWeekBoard( publishedAt: schedule?.publishedAt ?? null, rows, unassignedRow, + alerts: teamAlerts, + flaggedShiftIds: [ + ...new Set( + teamAlerts + .filter((alert) => alert.severity !== 'INFO') + .flatMap((alert) => alert.shiftIds), + ), + ], } satisfies BoardSection; }); diff --git a/tests/e2e/planning.spec.ts b/tests/e2e/planning.spec.ts index bf95e94..d0587d1 100644 --- a/tests/e2e/planning.spec.ts +++ b/tests/e2e/planning.spec.ts @@ -99,6 +99,11 @@ test('un manager pose un créneau, publie, puis dépublie', async ({ page }) => await expect(composer.getByText(/recouvre cette plage/)).toBeVisible(); await composer.getByRole('button', { name: 'Annuler' }).click(); + // Une semaine d'un seul créneau s'écarte forcément de la durée + // contractuelle : le moteur le signale, et publier suppose de l'assumer. + await section + .getByPlaceholder(/^Motif —/) + .fill('Semaine partielle, reprise progressive'); await section.getByRole('button', { name: 'Publier' }).click(); await expect(section.getByText('Publiée')).toBeVisible(); @@ -159,3 +164,62 @@ test('un créneau posé se retrouve dans les quatre vues', async ({ page }) => { // ce fichier planifie le même salarié ailleurs dans le mois. await expect(row.getByText('7,3').first()).toBeVisible(); }); + +/** + * Publier malgré une alerte de convention. + * + * Le critère d'acceptation de WP-05 : un avertissement ne bloque pas, mais il + * exige une justification, qui reste attachée au constat. C'est ce qui + * distingue une dérogation assumée d'une alerte ignorée. + */ +test('une alerte de convention exige une justification pour publier', async ({ + page, +}) => { + const week = '2027-W24'; + await resetWeek(page, week); + const section = venteSection(page); + + // 12 h de travail dans la journée : au-delà du maximum conventionnel de + // 10 h, et sans les 20 min de pause dues au-delà de 6 h. + await addShift(page, section, { start: '07:00', end: '19:00', pause: '0' }); + + await expect( + section.getByText(/au-delà du maximum de 10 h/).first(), + ).toBeVisible(); + + await section.getByRole('button', { name: 'Publier' }).click(); + await expect(section.getByText(/non justifiée/).first()).toBeVisible(); + await expect(section.getByText('Brouillon')).toBeVisible(); + + await section + .getByPlaceholder(/^Motif —/) + .fill('Inventaire annuel, accord du salarié'); + await section.getByRole('button', { name: 'Publier' }).click(); + + await expect(section.getByText('Publiée')).toBeVisible(); + // Le motif reste attaché au constat : l'alerte ne disparaît pas, elle est + // assumée. + await expect( + section.getByText(/Inventaire annuel, accord du salarié/).first(), + ).toBeVisible(); +}); + +test('un créneau qui recouvre un autre est refusé, pas seulement signalé', async ({ + page, +}) => { + const week = '2027-W26'; + await resetWeek(page, week); + const section = venteSection(page); + + await addShift(page, section, { start: '09:00', end: '13:00' }); + await expect(section.getByText('09:00–13:00').first()).toBeVisible(); + + const composer = await addShift(page, section, { + start: '12:00', + end: '17:00', + }); + // Le chevauchement est une incohérence de données : la saisie est refusée, + // pas enregistrée avec un badge. + await expect(composer.getByText(/recouvre cette plage/)).toBeVisible(); + await expect(section.getByText('12:00–17:00')).toHaveCount(0); +}); diff --git a/tests/integration/compliance.test.ts b/tests/integration/compliance.test.ts new file mode 100644 index 0000000..1530fd1 --- /dev/null +++ b/tests/integration/compliance.test.ts @@ -0,0 +1,232 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { IDCC_1517_PARAMETERS } from '@/domain/compliance/idcc1517'; +import { zonedInstant } from '@/domain/planning/week'; + +/** + * Le moteur branché sur la base — PLAN.md §12.2. + * + * Ce que ces tests vérifient, et que les tests de règles ne peuvent pas + * vérifier : qu'une semaine ancienne reste évaluée avec les paramètres qui + * s'appliquaient **alors**. C'est l'exigence n° 1 de la matrice — reproduire à + * l'identique une paie antérieure après un changement de règle — et elle ne se + * démontre qu'avec deux versions coexistantes en base. + */ + +const enabled = (process.env.DATABASE_URL ?? '').length > 0; +const describeIfDb = enabled ? describe : describe.skip; + +const TZ = 'Europe/Paris'; +const suffix = `cmp-${Date.now()}`; +const accountId = `${suffix}-account`; +const locationId = `${suffix}-loc`; +const teamId = `${suffix}-team`; +const membershipId = `${suffix}-member`; + +let agreementFor: typeof import('@/server/compliance/evaluate').agreementFor; +let evaluateSchedule: typeof import('@/server/compliance/evaluate').evaluateSchedule; +let withTenant: typeof import('@/server/tenant').withTenant; +let unscoped: typeof import('@/server/tenant').unscoped; + +/** 10 h de travail : conforme à 10 h, non conforme à 8 h. */ +function tenHourShift(scheduleId: string, date: string) { + return { + accountId, + weeklyScheduleId: scheduleId, + membershipId, + localDate: new Date(`${date}T00:00:00Z`), + startAt: zonedInstant(date, '08:00', TZ), + endAt: zonedInstant(date, '18:00', TZ), + breakMinutes: 0, + }; +} + +describeIfDb('moteur de conformité en base', () => { + let marchSchedule = ''; + let augustSchedule = ''; + + beforeAll(async () => { + process.env.ENCRYPTION_KEY ??= Buffer.alloc(32, 3).toString('base64'); + ({ agreementFor, evaluateSchedule } = await import( + '@/server/compliance/evaluate' + )); + ({ withTenant, unscoped } = await import('@/server/tenant')); + + const db = unscoped(); + + await db.account.create({ + data: { id: accountId, name: `Compte ${suffix}` }, + }); + await db.location.create({ + data: { + id: locationId, + accountId, + name: 'Établissement de test', + timezone: TZ, + employerContributionRate: 0, + }, + }); + await db.team.create({ + data: { id: teamId, accountId, locationId, name: 'Vente' }, + }); + + const role = await db.role.create({ + data: { accountId, key: 'employee', name: 'Employé' }, + }); + await db.membership.create({ + data: { + id: membershipId, + accountId, + roleId: role.id, + employeeNumber: 'T0001', + status: 'ACTIVE', + }, + }); + await db.employeeProfile.create({ + data: { membershipId, accountId, firstName: 'Test', lastName: 'Salarié' }, + }); + await db.userContract.create({ + data: { + accountId, + membershipId, + locationId, + contractType: 'CDI', + startDate: new Date('2024-01-01'), + workTimeArrangement: 'HOURLY', + weeklyHours: 35, + }, + }); + await db.teamMember.create({ data: { accountId, teamId, membershipId } }); + + // Deux versions coexistantes. La v2 durcit la durée quotidienne et ne + // prend effet qu'au 1er juillet. + await db.collectiveAgreement.create({ + data: { + accountId, + idcc: '1517', + name: 'Version initiale', + parameters: IDCC_1517_PARAMETERS, + version: 1, + effectiveFrom: new Date('2026-01-01'), + }, + }); + await db.collectiveAgreement.create({ + data: { + accountId, + idcc: '1517', + name: 'Version durcie', + parameters: { ...IDCC_1517_PARAMETERS, maxDailyWorkMinutes: 8 * 60 }, + version: 2, + effectiveFrom: new Date('2026-07-01'), + }, + }); + + // Semaine 11 de 2026 (9 mars) et semaine 33 (10 août) : mêmes créneaux. + const march = await db.weeklySchedule.create({ + data: { accountId, teamId, locationId, isoYear: 2026, isoWeek: 11 }, + }); + const august = await db.weeklySchedule.create({ + data: { accountId, teamId, locationId, isoYear: 2026, isoWeek: 33 }, + }); + marchSchedule = march.id; + augustSchedule = august.id; + + await db.shift.create({ data: tenHourShift(march.id, '2026-03-09') }); + await db.shift.create({ data: tenHourShift(august.id, '2026-08-10') }); + }); + + afterAll(async () => { + if (!enabled) return; + await unscoped().account.delete({ where: { id: accountId } }); + }); + + it('choisit la version en vigueur à la date, pas la plus récente', async () => { + await withTenant(accountId, async (db) => { + const inMarch = await agreementFor(db, new Date('2026-03-09')); + const inAugust = await agreementFor(db, new Date('2026-08-10')); + + expect(inMarch?.version).toBe(1); + expect(inAugust?.version).toBe(2); + }); + }); + + it('n’applique pas rétroactivement une règle plus dure', async () => { + // Le cœur de l'exigence : 10 h de travail passent en mars sous la v1 et + // déclenchent en août sous la v2, avec exactement les mêmes créneaux. + await withTenant(accountId, async (db) => { + const march = await evaluateSchedule(db, marchSchedule); + const august = await evaluateSchedule(db, augustSchedule); + + expect(march.map((entry) => entry.ruleCode)).not.toContain( + 'MAX_DAILY_WORK', + ); + expect(august.map((entry) => entry.ruleCode)).toContain('MAX_DAILY_WORK'); + }); + }); + + it('mémorise la version appliquée sur chaque constat', async () => { + await withTenant(accountId, async (db) => { + const stored = await db.complianceViolation.findFirst({ + where: { weeklyScheduleId: augustSchedule, ruleCode: 'MAX_DAILY_WORK' }, + }); + const agreement = await agreementFor(db, new Date('2026-08-10')); + + // Sans elle, un constat ancien devient inexplicable dès que les + // paramètres changent. + expect(stored?.agreementId).toBe(agreement?.id); + }); + }); + + it('conserve l’acquittement d’une réévaluation à l’autre', async () => { + await withTenant(accountId, async (db) => { + const before = await db.complianceViolation.findFirst({ + where: { weeklyScheduleId: augustSchedule, ruleCode: 'MAX_DAILY_WORK' }, + }); + if (!before) throw new Error('Constat attendu introuvable.'); + await db.complianceViolation.update({ + where: { id: before.id }, + data: { + acknowledgedBy: membershipId, + acknowledgedAt: new Date(), + acknowledgementReason: 'Inventaire annuel', + }, + }); + + // Un manager qui a justifié une alerte ne doit pas la revoir surgir + // parce qu'un collègue a déplacé un créneau ailleurs dans la semaine. + await evaluateSchedule(db, augustSchedule); + + const after = await db.complianceViolation.findFirst({ + where: { weeklyScheduleId: augustSchedule, ruleCode: 'MAX_DAILY_WORK' }, + }); + expect(after?.acknowledgementReason).toBe('Inventaire annuel'); + expect(after?.id).not.toBe(before.id); + }); + }); + + it('refuse de réécrire le contenu d’une version de convention', async () => { + // Le figeage est en base, pas seulement dans l'application : une paie + // antérieure doit rester reproductible même si quelqu'un écrit + // directement en SQL. + const db = unscoped(); + const agreement = await db.collectiveAgreement.findFirst({ + where: { accountId, version: 1 }, + }); + if (!agreement) throw new Error('Convention de test introuvable.'); + + await expect( + db.collectiveAgreement.update({ + where: { id: agreement.id }, + data: { parameters: { altered: true } }, + }), + ).rejects.toThrow(/figé/); + + // L'approbation, elle, reste possible : c'est un acte postérieur légitime. + await expect( + db.collectiveAgreement.update({ + where: { id: agreement.id }, + data: { approvedBy: membershipId, approvedAt: new Date() }, + }), + ).resolves.toBeTruthy(); + }); +}); diff --git a/tests/unit/compliance-overtime.test.ts b/tests/unit/compliance-overtime.test.ts new file mode 100644 index 0000000..7319d2c --- /dev/null +++ b/tests/unit/compliance-overtime.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; + +import { IDCC_1517_PARAMETERS } from '@/domain/compliance/idcc1517'; +import { + quotaStatus, + splitComplementary, + splitOvertime, +} from '@/domain/compliance/overtime'; + +const h = (hours: number, minutes = 0) => hours * 60 + minutes; +const P = IDCC_1517_PARAMETERS; + +describe('heures supplémentaires — tranches IDCC 1517', () => { + it('ne majore rien à 35 h', () => { + const result = splitOvertime(h(35), P); + expect(result.baseMinutes).toBe(h(35)); + expect(result.overtimeMinutes).toBe(0); + expect(result.slices).toHaveLength(0); + }); + + it('donne 8 h à +25 % pour 43 h', () => { + // Critère d'acceptation de WP-05, mot pour mot. + const result = splitOvertime(h(43), P); + expect(result.baseMinutes).toBe(h(35)); + expect(result.slices).toEqual([ + { fromMinutes: h(35), toMinutes: h(43), ratePercent: 25, minutes: h(8) }, + ]); + }); + + it('donne 8 h à +25 % et 2 h à +50 % pour 45 h', () => { + const result = splitOvertime(h(45), P); + expect(result.slices).toEqual([ + { fromMinutes: h(35), toMinutes: h(43), ratePercent: 25, minutes: h(8) }, + { fromMinutes: h(43), toMinutes: null, ratePercent: 50, minutes: h(2) }, + ]); + expect(result.overtimeMinutes).toBe(h(10)); + }); + + it('bascule à la minute près', () => { + const justUnder = splitOvertime(h(43), P); + expect(justUnder.slices).toHaveLength(1); + + const justOver = splitOvertime(h(43, 1), P); + expect(justOver.slices).toHaveLength(2); + expect(justOver.slices[1]?.minutes).toBe(1); + }); + + it('conserve le total', () => { + for (const minutes of [0, h(20), h(35), h(36), h(43), h(48), h(60)]) { + const result = splitOvertime(minutes, P); + expect(result.baseMinutes + result.overtimeMinutes).toBe(minutes); + } + }); + + it('suit les tranches d’un autre jeu de paramètres', () => { + // Les taux légaux de droit commun sont 25 % puis 50 % à la 44ᵉ heure ; + // une convention peut dire autre chose. Rien n'est écrit dans le code. + const alternative = { + ...P, + overtime: { + ...P.overtime, + tiers: [ + { fromMinutes: h(35), toMinutes: h(39), ratePercent: 10 }, + { fromMinutes: h(39), toMinutes: null, ratePercent: 20 }, + ], + }, + }; + const result = splitOvertime(h(45), alternative); + expect(result.slices.map((slice) => [slice.ratePercent, slice.minutes])).toEqual( + [ + [10, h(4)], + [20, h(6)], + ], + ); + }); +}); + +describe('heures complémentaires — temps partiel', () => { + const contract = h(24); + + it('ne compte rien à la durée contractuelle', () => { + const result = splitComplementary(contract, contract, P); + expect(result.totalMinutes).toBe(0); + expect(result.firstTierMinutes).toBe(0); + }); + + it('majore de 10 % jusqu’au dixième de la durée contractuelle', () => { + // Un dixième de 24 h = 2 h 24. + const result = splitComplementary(contract + h(2, 24), contract, P); + expect(result.firstTierMinutes).toBe(h(2, 24)); + expect(result.beyondMinutes).toBe(0); + expect(result.firstTierRatePercent).toBe(10); + }); + + it('majore de 25 % au-delà du dixième', () => { + const result = splitComplementary(contract + h(4), contract, P); + expect(result.firstTierMinutes).toBe(h(2, 24)); + expect(result.beyondMinutes).toBe(h(1, 36)); + expect(result.beyondRatePercent).toBe(25); + }); + + it('isole le dépassement du tiers', () => { + // Au-delà du tiers, ce n'est plus une question de taux mais de + // requalification du contrat : la conséquence est juridique. + const result = splitComplementary(contract + h(10), contract, P); + expect(result.overCapMinutes).toBe(h(2)); + expect( + result.firstTierMinutes + result.beyondMinutes + result.overCapMinutes, + ).toBe(result.totalMinutes); + }); + + it('ne compte pas d’heures complémentaires en négatif', () => { + const result = splitComplementary(h(20), contract, P); + expect(result.totalMinutes).toBe(0); + expect(result.overCapMinutes).toBe(0); + }); +}); + +describe('contingent annuel', () => { + it('décompte ce qui reste', () => { + const status = quotaStatus(h(100), P); + expect(status.remainingMinutes).toBe(h(80)); + expect(status.exceededMinutes).toBe(0); + }); + + it('ouvre la contrepartie en repos au-delà', () => { + // Le dépassement n'interdit pas l'heure : il la rend plus chère et crée + // un droit à repos. + const status = quotaStatus(h(190), P); + expect(status.exceededMinutes).toBe(h(10)); + expect(status.restPercent).toBe(100); + }); + + it('accepte le contingent exact', () => { + const status = quotaStatus(h(180), P); + expect(status.remainingMinutes).toBe(0); + expect(status.exceededMinutes).toBe(0); + }); +}); diff --git a/tests/unit/compliance-rules.test.ts b/tests/unit/compliance-rules.test.ts new file mode 100644 index 0000000..9a756a1 --- /dev/null +++ b/tests/unit/compliance-rules.test.ts @@ -0,0 +1,794 @@ +import { describe, expect, it } from 'vitest'; + +import { evaluate, sortViolations } from '@/domain/compliance/engine'; +import { IDCC_1517_PARAMETERS } from '@/domain/compliance/idcc1517'; +import { parseAgreementParameters } from '@/domain/compliance/parameters'; +import { RULE_CODES } from '@/domain/compliance/types'; +import type { + ComplianceContext, + ComplianceShift, + RuleCode, +} from '@/domain/compliance/types'; +import { zonedInstant } from '@/domain/planning/week'; + +/** + * Tests aux bornes — critère d'acceptation de WP-05. + * + * Pour chaque règle : la valeur limite exacte **passe**, un cran au-delà + * déclenche. C'est la seule forme de test qui protège d'une inégalité écrite à + * l'envers, et une inégalité à l'envers sur un repos quotidien est une + * infraction que personne ne verra. + */ + +const TZ = 'Europe/Paris'; +/** Semaine 33 de 2026 : lundi 10 → dimanche 16 août. */ +const WEEK = { isoYear: 2026, isoWeek: 33 }; + +let sequence = 0; + +function shift( + date: string, + start: string, + end: string, + breakMinutes = 0, + assigned = true, +): ComplianceShift { + sequence += 1; + const startAt = zonedInstant(date, start, TZ); + let endAt = zonedInstant(date, end, TZ); + if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000); + return { id: `s${sequence}`, startAt, endAt, breakMinutes, assigned }; +} + +function context(over: Partial = {}): ComplianceContext { + return { + week: WEEK, + timeZone: TZ, + membershipId: 'm1', + contract: { + workTimeArrangement: 'HOURLY', + weeklyMinutes: 35 * 60, + forfaitDaysPerYear: null, + partTimeDerogationCode: null, + }, + shifts: [], + absences: [], + holidays: [], + authorisedSundays: [], + sundaysWorkedBefore: 0, + previousWeeklyMinutes: [], + forfait: null, + parameters: IDCC_1517_PARAMETERS, + ...over, + }; +} + +function codes(ctx: ComplianceContext): RuleCode[] { + return evaluate(ctx).violations.map((entry) => entry.ruleCode); +} + +describe('le jeu de paramètres IDCC 1517 est valide', () => { + it('passe le schéma', () => { + expect(() => parseAgreementParameters(IDCC_1517_PARAMETERS)).not.toThrow(); + }); + + it('rejette un jeu amputé', () => { + // Un seuil manquant lu comme `undefined` désactiverait silencieusement une + // règle : l'échec doit être bruyant. + const { minDailyRestMinutes, ...partial } = IDCC_1517_PARAMETERS; + expect(minDailyRestMinutes).toBe(660); + expect(() => parseAgreementParameters(partial)).toThrow(/minDailyRest/); + }); +}); + +describe('MAX_DAILY_WORK', () => { + it('accepte exactement 10 h', () => { + const ctx = context({ shifts: [shift('2026-08-10', '08:00', '18:00')] }); + expect(codes(ctx)).not.toContain('MAX_DAILY_WORK'); + }); + + it('déclenche à 10 h 01', () => { + const ctx = context({ shifts: [shift('2026-08-10', '08:00', '18:01')] }); + expect(codes(ctx)).toContain('MAX_DAILY_WORK'); + }); + + it('additionne les créneaux d’une même journée', () => { + const ctx = context({ + shifts: [ + shift('2026-08-10', '06:00', '12:00'), + shift('2026-08-10', '13:00', '18:01'), + ], + }); + expect(codes(ctx)).toContain('MAX_DAILY_WORK'); + }); + + it('déduit la pause avant de comparer', () => { + // 10 h 30 de présence moins 30 min de pause : la limite est respectée. + const ctx = context({ + shifts: [shift('2026-08-10', '08:00', '18:30', 30)], + }); + expect(codes(ctx)).not.toContain('MAX_DAILY_WORK'); + }); +}); + +describe('MAX_DAILY_AMPLITUDE', () => { + it('reste muette quand la convention ne fixe rien', () => { + // Inventer une borne ferait désactiver l'ensemble des alertes par le + // premier manager excédé. + const ctx = context({ + shifts: [ + shift('2026-08-10', '06:00', '09:00'), + shift('2026-08-10', '18:00', '22:00'), + ], + }); + expect(codes(ctx)).not.toContain('MAX_DAILY_AMPLITUDE'); + }); + + it('déclenche dès qu’une amplitude est paramétrée', () => { + const ctx = context({ + shifts: [ + shift('2026-08-10', '06:00', '09:00'), + shift('2026-08-10', '18:00', '22:00'), + ], + parameters: { ...IDCC_1517_PARAMETERS, maxDailyAmplitudeMinutes: 13 * 60 }, + }); + expect(codes(ctx)).toContain('MAX_DAILY_AMPLITUDE'); + }); + + it('accepte l’amplitude limite exacte', () => { + const ctx = context({ + shifts: [ + shift('2026-08-10', '06:00', '09:00'), + shift('2026-08-10', '17:00', '19:00'), + ], + parameters: { ...IDCC_1517_PARAMETERS, maxDailyAmplitudeMinutes: 13 * 60 }, + }); + expect(codes(ctx)).not.toContain('MAX_DAILY_AMPLITUDE'); + }); +}); + +describe('MIN_DAILY_REST', () => { + it('accepte exactement 11 h de repos', () => { + const ctx = context({ + shifts: [ + shift('2026-08-10', '09:00', '20:00'), + shift('2026-08-11', '07:00', '12:00'), + ], + }); + expect(codes(ctx)).not.toContain('MIN_DAILY_REST'); + }); + + it('déclenche à 10 h 59', () => { + const ctx = context({ + shifts: [ + shift('2026-08-10', '09:00', '20:00'), + shift('2026-08-11', '06:59', '12:00'), + ], + }); + expect(codes(ctx)).toContain('MIN_DAILY_REST'); + }); + + it('voit le repos à cheval sur deux semaines', () => { + // Le repos entre dimanche soir et lundi matin appartient à deux semaines. + // Une évaluation limitée à sept jours manquerait exactement ce cas. + const ctx = context({ + shifts: [ + shift('2026-08-09', '14:00', '22:00'), + shift('2026-08-10', '07:00', '15:00'), + ], + }); + expect(codes(ctx)).toContain('MIN_DAILY_REST'); + }); + + it('mesure le repos entre instants, pas à l’horloge', () => { + // Nuit du 25 octobre 2026 : retour à l'heure d'hiver. De 21 h à 07 h il + // s'écoule 11 h réelles, alors que l'horloge n'affiche que 10 h d'écart. + const ctx = context({ + week: { isoYear: 2026, isoWeek: 43 }, + shifts: [ + shift('2026-10-24', '13:00', '21:00'), + shift('2026-10-25', '07:00', '12:00'), + ], + }); + expect(codes(ctx)).not.toContain('MIN_DAILY_REST'); + }); +}); + +describe('MIN_WEEKLY_REST', () => { + it('accepte 35 h de repos continu', () => { + const ctx = context({ + shifts: [ + shift('2026-08-10', '09:00', '17:00'), + shift('2026-08-11', '09:00', '17:00'), + shift('2026-08-12', '09:00', '17:00'), + shift('2026-08-13', '09:00', '17:00'), + // Vendredi 17 h → dimanche 10 h : 41 h. + shift('2026-08-16', '10:00', '17:00'), + ], + }); + expect(codes(ctx)).not.toContain('MIN_WEEKLY_REST'); + }); + + it('déclenche quand le plus long repos tombe sous 35 h', () => { + const ctx = context({ + shifts: [ + shift('2026-08-10', '09:00', '17:00'), + shift('2026-08-11', '09:00', '17:00'), + shift('2026-08-12', '09:00', '17:00'), + shift('2026-08-13', '09:00', '17:00'), + shift('2026-08-14', '09:00', '17:00'), + shift('2026-08-15', '09:00', '17:00'), + shift('2026-08-16', '09:00', '17:00'), + ], + }); + expect(codes(ctx)).toContain('MIN_WEEKLY_REST'); + }); +}); + +describe('MAX_WEEKLY_WORK_ABSOLUTE', () => { + it('accepte exactement 48 h', () => { + const ctx = context({ + shifts: [ + shift('2026-08-10', '08:00', '16:00'), + shift('2026-08-11', '08:00', '16:00'), + shift('2026-08-12', '08:00', '16:00'), + shift('2026-08-13', '08:00', '16:00'), + shift('2026-08-14', '08:00', '16:00'), + shift('2026-08-15', '08:00', '16:00'), + ], + }); + expect(codes(ctx)).not.toContain('MAX_WEEKLY_WORK_ABSOLUTE'); + }); + + it('déclenche à 48 h 01', () => { + const ctx = context({ + shifts: [ + shift('2026-08-10', '08:00', '16:00'), + shift('2026-08-11', '08:00', '16:00'), + shift('2026-08-12', '08:00', '16:00'), + shift('2026-08-13', '08:00', '16:00'), + shift('2026-08-14', '08:00', '16:00'), + shift('2026-08-15', '08:00', '16:01'), + ], + }); + expect(codes(ctx)).toContain('MAX_WEEKLY_WORK_ABSOLUTE'); + }); +}); + +describe('MAX_WEEKLY_WORK_AVERAGED', () => { + const fullWeek = [ + shift('2026-08-10', '08:00', '16:00'), + shift('2026-08-11', '08:00', '16:00'), + shift('2026-08-12', '08:00', '16:00'), + shift('2026-08-13', '08:00', '16:00'), + shift('2026-08-14', '08:00', '16:00'), + shift('2026-08-15', '08:00', '16:00'), + ]; + + it('se tait sur une fenêtre incomplète', () => { + // Les premières semaines d'un contrat déclencheraient sinon une alerte à + // chaque planning, sans qu'aucune moyenne ne soit encore mesurable. + const ctx = context({ + shifts: fullWeek, + previousWeeklyMinutes: [48 * 60, 48 * 60], + }); + expect(codes(ctx)).not.toContain('MAX_WEEKLY_WORK_AVERAGED'); + }); + + it('accepte une moyenne de 44 h exactement', () => { + const ctx = context({ + shifts: fullWeek, + // 48 h cette semaine, 43 h 38 sur les onze précédentes → moyenne 44 h. + previousWeeklyMinutes: Array.from({ length: 11 }, () => 2640 - 22), + }); + expect(codes(ctx)).not.toContain('MAX_WEEKLY_WORK_AVERAGED'); + }); + + it('déclenche au-delà de la moyenne', () => { + const ctx = context({ + shifts: fullWeek, + previousWeeklyMinutes: Array.from({ length: 11 }, () => 45 * 60), + }); + expect(codes(ctx)).toContain('MAX_WEEKLY_WORK_AVERAGED'); + }); +}); + +describe('MAX_CONSECUTIVE_WORK_DAYS', () => { + const days = (isoDates: string[]) => + isoDates.map((date) => shift(date, '09:00', '12:00')); + + it('accepte exactement 10 jours d’affilée', () => { + const ctx = context({ + shifts: days([ + '2026-08-07', + '2026-08-08', + '2026-08-09', + '2026-08-10', + '2026-08-11', + '2026-08-12', + '2026-08-13', + '2026-08-14', + '2026-08-15', + '2026-08-16', + ]), + }); + expect(codes(ctx)).not.toContain('MAX_CONSECUTIVE_WORK_DAYS'); + }); + + it('déclenche au onzième', () => { + const ctx = context({ + shifts: days([ + '2026-08-06', + '2026-08-07', + '2026-08-08', + '2026-08-09', + '2026-08-10', + '2026-08-11', + '2026-08-12', + '2026-08-13', + '2026-08-14', + '2026-08-15', + '2026-08-16', + ]), + }); + expect(codes(ctx)).toContain('MAX_CONSECUTIVE_WORK_DAYS'); + }); + + it('remet le compteur à zéro sur un jour de repos', () => { + const ctx = context({ + shifts: days([ + '2026-08-06', + '2026-08-07', + '2026-08-08', + '2026-08-09', + '2026-08-10', + // repos le 11 + '2026-08-12', + '2026-08-13', + '2026-08-14', + '2026-08-15', + '2026-08-16', + ]), + }); + expect(codes(ctx)).not.toContain('MAX_CONSECUTIVE_WORK_DAYS'); + }); +}); + +describe('MIN_BREAK_AFTER_THRESHOLD', () => { + it('accepte exactement 6 h sans pause', () => { + const ctx = context({ shifts: [shift('2026-08-10', '09:00', '15:00')] }); + expect(codes(ctx)).not.toContain('MIN_BREAK_AFTER_THRESHOLD'); + }); + + it('déclenche à 6 h 01 sans pause', () => { + const ctx = context({ shifts: [shift('2026-08-10', '09:00', '15:01')] }); + expect(codes(ctx)).toContain('MIN_BREAK_AFTER_THRESHOLD'); + }); + + it('accepte 20 min de pause exactement', () => { + const ctx = context({ + shifts: [shift('2026-08-10', '09:00', '17:00', 20)], + }); + expect(codes(ctx)).not.toContain('MIN_BREAK_AFTER_THRESHOLD'); + }); + + it('déclenche à 19 min', () => { + const ctx = context({ + shifts: [shift('2026-08-10', '09:00', '17:00', 19)], + }); + expect(codes(ctx)).toContain('MIN_BREAK_AFTER_THRESHOLD'); + }); +}); + +describe('PART_TIME_MIN_WEEKLY_HOURS', () => { + const partTime = (minutes: number, derogation: string | null = null) => + context({ + contract: { + workTimeArrangement: 'HOURLY', + weeklyMinutes: minutes, + forfaitDaysPerYear: null, + partTimeDerogationCode: derogation, + }, + }); + + it('accepte 24 h exactement', () => { + expect(codes(partTime(24 * 60))).not.toContain( + 'PART_TIME_MIN_WEEKLY_HOURS', + ); + }); + + it('déclenche à 23 h 59', () => { + expect(codes(partTime(24 * 60 - 1))).toContain( + 'PART_TIME_MIN_WEEKLY_HOURS', + ); + }); + + it('applique la dérogation conventionnelle', () => { + expect(codes(partTime(21 * 60, 'AIDE_ETALAGISTE'))).not.toContain( + 'PART_TIME_MIN_WEEKLY_HOURS', + ); + expect(codes(partTime(21 * 60 - 1, 'AIDE_ETALAGISTE'))).toContain( + 'PART_TIME_MIN_WEEKLY_HOURS', + ); + }); + + it('ignore un temps plein et un contrat sans durée', () => { + expect(codes(partTime(35 * 60))).not.toContain( + 'PART_TIME_MIN_WEEKLY_HOURS', + ); + // Extra ou intermittent : il n'y a pas de durée contractuelle à comparer. + expect(codes(partTime(0))).not.toContain('PART_TIME_MIN_WEEKLY_HOURS'); + }); +}); + +describe('CONTRACT_HOURS_DEVIATION', () => { + it('tolère l’écart paramétré', () => { + const ctx = context({ + shifts: [ + shift('2026-08-10', '09:00', '17:00'), + shift('2026-08-11', '09:00', '17:00'), + shift('2026-08-12', '09:00', '17:00'), + shift('2026-08-13', '09:00', '17:00'), + // 4 × 8 h + 3 h 30 = 35 h 30, soit 30 min d'écart : la tolérance. + shift('2026-08-14', '09:00', '12:30'), + ], + }); + expect(codes(ctx)).not.toContain('CONTRACT_HOURS_DEVIATION'); + }); + + it('déclenche au-delà', () => { + const ctx = context({ + shifts: [ + shift('2026-08-10', '09:00', '17:00'), + shift('2026-08-11', '09:00', '17:00'), + shift('2026-08-12', '09:00', '17:00'), + shift('2026-08-13', '09:00', '17:00'), + shift('2026-08-14', '09:00', '12:31'), + ], + }); + expect(codes(ctx)).toContain('CONTRACT_HOURS_DEVIATION'); + }); + + it('signale aussi la sous-réalisation', () => { + // Un salarié à qui on ne donne pas ses heures est un problème au moins + // aussi sérieux qu'un salarié qui en fait trop. + const ctx = context({ shifts: [shift('2026-08-10', '09:00', '17:00')] }); + expect(codes(ctx)).toContain('CONTRACT_HOURS_DEVIATION'); + }); +}); + +describe('OVERLAPPING_SHIFTS', () => { + it('bloque deux créneaux qui se recouvrent', () => { + const ctx = context({ + shifts: [ + shift('2026-08-10', '09:00', '17:00'), + shift('2026-08-10', '16:00', '20:00'), + ], + }); + const result = evaluate(ctx); + expect(result.blocking.map((entry) => entry.ruleCode)).toContain( + 'OVERLAPPING_SHIFTS', + ); + }); + + it('accepte deux créneaux jointifs', () => { + const ctx = context({ + shifts: [ + shift('2026-08-10', '09:00', '13:00'), + shift('2026-08-10', '13:00', '17:00'), + ], + }); + expect(codes(ctx)).not.toContain('OVERLAPPING_SHIFTS'); + }); + + it('ignore les besoins non couverts', () => { + // Deux besoins non couverts sur la même plage sont légitimes : c'est + // « il manque deux personnes », pas un doublon. + const ctx = context({ + shifts: [ + shift('2026-08-10', '09:00', '17:00', 0, false), + shift('2026-08-10', '09:00', '17:00', 0, false), + ], + }); + expect(codes(ctx)).not.toContain('OVERLAPPING_SHIFTS'); + }); +}); + +describe('SHIFT_DURING_ABSENCE', () => { + it('bloque un créneau posé pendant une absence', () => { + const ctx = context({ + shifts: [shift('2026-08-12', '09:00', '17:00')], + absences: [ + { startDate: '2026-08-10', endDate: '2026-08-14', label: 'Congés payés' }, + ], + }); + expect(evaluate(ctx).blocking.map((entry) => entry.ruleCode)).toContain( + 'SHIFT_DURING_ABSENCE', + ); + }); + + it('traite endDate comme le dernier jour d’absence', () => { + // Confondre `endDate` avec la date de reprise ferait accepter un créneau + // le dernier jour de congé, ou refuser le jour du retour. + const last = context({ + shifts: [shift('2026-08-14', '09:00', '17:00')], + absences: [ + { startDate: '2026-08-10', endDate: '2026-08-14', label: 'Congés payés' }, + ], + }); + expect(codes(last)).toContain('SHIFT_DURING_ABSENCE'); + + const back = context({ + shifts: [shift('2026-08-15', '09:00', '17:00')], + absences: [ + { startDate: '2026-08-10', endDate: '2026-08-14', label: 'Congés payés' }, + ], + }); + expect(codes(back)).not.toContain('SHIFT_DURING_ABSENCE'); + }); +}); + +describe('SUNDAY_WORK', () => { + it('annonce la majoration **et** le repos compensateur', () => { + // L3132-27 impose les deux. N'en produire qu'un serait un manquement, pas + // une simplification — c'est le cœur du critère d'acceptation. + const ctx = context({ shifts: [shift('2026-08-16', '10:00', '18:00')] }); + const found = evaluate(ctx).violations.find( + (entry) => entry.ruleCode === 'SUNDAY_WORK', + ); + + expect(found).toBeDefined(); + expect(found?.context.premiumPercent).toBe(100); + expect(found?.context.compensatoryRestMinutes).toBe(8 * 60); + }); + + it('ne dit rien quand le dimanche est libre', () => { + const ctx = context({ shifts: [shift('2026-08-15', '10:00', '18:00')] }); + expect(codes(ctx)).not.toContain('SUNDAY_WORK'); + }); + + it('reste informative, jamais bloquante', () => { + // Le refus d'un salarié de travailler le dimanche ne peut être sanctionné : + // l'outil signale, il ne tranche pas. + const ctx = context({ shifts: [shift('2026-08-16', '10:00', '18:00')] }); + expect(evaluate(ctx).blocking).toHaveLength(0); + }); +}); + +describe('SUNDAY_MAYOR_QUOTA', () => { + it('accepte le douzième dimanche', () => { + const ctx = context({ + shifts: [shift('2026-08-16', '10:00', '18:00')], + sundaysWorkedBefore: 11, + }); + expect(codes(ctx)).not.toContain('SUNDAY_MAYOR_QUOTA'); + }); + + it('déclenche au treizième', () => { + const ctx = context({ + shifts: [shift('2026-08-16', '10:00', '18:00')], + sundaysWorkedBefore: 12, + }); + expect(codes(ctx)).toContain('SUNDAY_MAYOR_QUOTA'); + }); + + it('déclenche sur un dimanche hors liste', () => { + const ctx = context({ + shifts: [shift('2026-08-16', '10:00', '18:00')], + authorisedSundays: ['2026-08-09', '2026-08-23'], + }); + expect(codes(ctx)).toContain('SUNDAY_MAYOR_QUOTA'); + }); + + it('se tait sur un dimanche de la liste', () => { + const ctx = context({ + shifts: [shift('2026-08-16', '10:00', '18:00')], + authorisedSundays: ['2026-08-16'], + }); + expect(codes(ctx)).not.toContain('SUNDAY_MAYOR_QUOTA'); + }); +}); + +describe('HOLIDAY_WORK', () => { + it('produit l’indemnité de 50 % sur un jour férié ordinaire', () => { + const ctx = context({ + shifts: [shift('2026-08-15', '10:00', '18:00')], + holidays: ['2026-08-15'], + }); + const found = evaluate(ctx).violations.find( + (entry) => entry.ruleCode === 'HOLIDAY_WORK', + ); + expect(found?.context.premiumPercent).toBe(50); + expect(found?.context.labourDay).toBe(false); + }); + + it('double la majoration le 1er mai', () => { + const ctx = context({ + week: { isoYear: 2026, isoWeek: 18 }, + shifts: [shift('2026-05-01', '10:00', '18:00')], + holidays: ['2026-05-01'], + }); + const found = evaluate(ctx).violations.find( + (entry) => entry.ruleCode === 'HOLIDAY_WORK', + ); + expect(found?.context.premiumPercent).toBe(100); + expect(found?.context.labourDay).toBe(true); + }); + + it('ne propose pas la substitution en repos d’office', () => { + // La convention la subordonne à une demande du salarié : la proposer + // automatiquement reviendrait à décider à sa place. + const ctx = context({ + shifts: [shift('2026-08-15', '10:00', '18:00')], + holidays: ['2026-08-15'], + }); + const found = evaluate(ctx).violations.find( + (entry) => entry.ruleCode === 'HOLIDAY_WORK', + ); + expect(found?.context.substitutionRestMinutes).toBeUndefined(); + }); +}); + +describe('forfait jours', () => { + const forfaitContext = (over: Partial = {}) => + context({ + contract: { + workTimeArrangement: 'FORFAIT_JOURS', + weeklyMinutes: 0, + forfaitDaysPerYear: 218, + partTimeDerogationCode: null, + }, + forfait: { daysUsed: 100, lastWorkloadReviewAt: new Date('2026-06-01') }, + ...over, + }); + + it('ne déclenche aucune règle horaire', () => { + // Les appliquer produirait un bruit d'alertes qui masquerait le vrai + // contrôle — repos et charge de travail. + const ctx = forfaitContext({ + shifts: [ + shift('2026-08-10', '07:00', '20:00'), + shift('2026-08-11', '07:00', '20:00'), + shift('2026-08-12', '07:00', '20:00'), + shift('2026-08-13', '07:00', '20:00'), + shift('2026-08-14', '07:00', '20:00'), + ], + }); + const found = codes(ctx); + + for (const rule of [ + 'MAX_DAILY_WORK', + 'MAX_WEEKLY_WORK_ABSOLUTE', + 'MAX_WEEKLY_WORK_AVERAGED', + 'MIN_BREAK_AFTER_THRESHOLD', + 'PART_TIME_MIN_WEEKLY_HOURS', + 'CONTRACT_HOURS_DEVIATION', + ] as const) { + expect(found, `${rule} ne devrait pas s'appliquer`).not.toContain(rule); + } + }); + + it('reste soumis aux repos', () => { + const ctx = forfaitContext({ + shifts: [ + shift('2026-08-10', '09:00', '22:00'), + shift('2026-08-11', '06:00', '20:00'), + ], + }); + const found = codes(ctx); + expect(found).toContain('MIN_DAILY_REST'); + expect(found).toContain('FORFAIT_REST_INSUFFICIENT'); + }); + + it('accepte le plafond exact et refuse un jour de plus', () => { + const atCap = forfaitContext({ + forfait: { daysUsed: 213, lastWorkloadReviewAt: new Date('2026-06-01') }, + shifts: [ + shift('2026-08-10', '09:00', '17:00'), + shift('2026-08-11', '09:00', '17:00'), + shift('2026-08-12', '09:00', '17:00'), + shift('2026-08-13', '09:00', '17:00'), + shift('2026-08-14', '09:00', '17:00'), + ], + }); + expect(codes(atCap)).not.toContain('FORFAIT_DAYS_EXCEEDED'); + + const overCap = forfaitContext({ + forfait: { daysUsed: 214, lastWorkloadReviewAt: new Date('2026-06-01') }, + shifts: [ + shift('2026-08-10', '09:00', '17:00'), + shift('2026-08-11', '09:00', '17:00'), + shift('2026-08-12', '09:00', '17:00'), + shift('2026-08-13', '09:00', '17:00'), + shift('2026-08-14', '09:00', '17:00'), + ], + }); + expect(codes(overCap)).toContain('FORFAIT_DAYS_EXCEEDED'); + }); + + it('exige un entretien de charge dans l’intervalle', () => { + const stale = forfaitContext({ + forfait: { daysUsed: 10, lastWorkloadReviewAt: new Date('2025-07-01') }, + shifts: [shift('2026-08-10', '09:00', '17:00')], + }); + expect(codes(stale)).toContain('FORFAIT_WORKLOAD_REVIEW_MISSING'); + + const never = forfaitContext({ + forfait: { daysUsed: 10, lastWorkloadReviewAt: null }, + shifts: [shift('2026-08-10', '09:00', '17:00')], + }); + expect(codes(never)).toContain('FORFAIT_WORKLOAD_REVIEW_MISSING'); + }); +}); + +describe('le moteur ne code aucune valeur en dur', () => { + it('produit des résultats différents avec deux jeux de paramètres', () => { + const shifts = [shift('2026-08-10', '08:00', '18:30', 0)]; + + const strict = context({ + shifts, + parameters: { ...IDCC_1517_PARAMETERS, maxDailyWorkMinutes: 8 * 60 }, + }); + const lenient = context({ + shifts, + parameters: { ...IDCC_1517_PARAMETERS, maxDailyWorkMinutes: 12 * 60 }, + }); + + expect(codes(strict)).toContain('MAX_DAILY_WORK'); + expect(codes(lenient)).not.toContain('MAX_DAILY_WORK'); + }); + + it('suit le paramètre de repos quotidien', () => { + const shifts = [ + shift('2026-08-10', '09:00', '20:00'), + shift('2026-08-11', '07:30', '12:00'), + ]; + const strict = context({ + shifts, + parameters: { ...IDCC_1517_PARAMETERS, minDailyRestMinutes: 12 * 60 }, + }); + const lenient = context({ + shifts, + parameters: { ...IDCC_1517_PARAMETERS, minDailyRestMinutes: 9 * 60 }, + }); + + expect(codes(strict)).toContain('MIN_DAILY_REST'); + expect(codes(lenient)).not.toContain('MIN_DAILY_REST'); + }); +}); + +describe('robustesse du moteur', () => { + it('évalue toutes les règles déclarées', () => { + expect(Object.keys(evaluate(context()))).toContain('violations'); + expect(RULE_CODES).toHaveLength(18); + }); + + it('trie du plus grave au plus anodin', () => { + const ctx = context({ + shifts: [ + shift('2026-08-16', '10:00', '18:00'), + shift('2026-08-16', '17:00', '20:00'), + ], + }); + const sorted = sortViolations(evaluate(ctx).violations); + expect(sorted[0]?.severity).toBe('BLOCKING'); + }); + + it('ne rend rien sur une semaine vide', () => { + // Une grille vierge ne doit pas s'ouvrir couverte d'alertes : le seul + // constat légitime serait l'écart au contrat, qui n'a pas de sens tant que + // rien n'est planifié. + const empty = evaluate( + context({ + contract: { + workTimeArrangement: 'HOURLY', + weeklyMinutes: 0, + forfaitDaysPerYear: null, + partTimeDerogationCode: null, + }, + }), + ); + expect(empty.violations).toHaveLength(0); + expect(empty.failures).toHaveLength(0); + }); +});