WP-05 : moteur de règles de convention, effectif-daté

Le planning cesse d'être un tableur : il est confronté aux durées légales et
conventionnelles à chaque écriture.

Domaine — dix-huit règles pures
- `src/domain/compliance/` : chaque règle est une fonction pure, testée à sa
  borne exacte. La valeur limite 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.
- 58 tests de règles, 14 sur les tranches d'heures : 43 h donnent 8 h à +25 %,
  45 h donnent 8 h à +25 % et 2 h à +50 %.

Aucune valeur dans le code
- Les seuils vivent en base (`CollectiveAgreement.parameters`), validés par un
  schéma Zod qui refuse un jeu amputé : un seuil manquant lu comme `undefined`
  désactiverait silencieusement une règle de sécurité.
- Un test charge deux jeux différents et vérifie que les résultats diffèrent.
- `MAX_DAILY_AMPLITUDE` reste muette : l'IDCC 1517 ne fixe pas d'amplitude
  quotidienne. Inventer une borne ferait désactiver l'ensemble des alertes par
  le premier manager excédé.

Effectif-datage — exigence n° 1 de la matrice
- Les versions de convention coexistent ; un trigger PostgreSQL refuse de
  réécrire le contenu d'une version publiée, tout en laissant enregistrer une
  approbation postérieure.
- Chaque constat mémorise la version appliquée. Un test d'intégration pose deux
  versions et vérifie qu'une semaine de mars n'est pas jugée sur la règle
  publiée en juillet.

Dimanche — la double contrepartie
Le taux de 100 % ne vient pas de la convention : l'IDCC 1517 n'en fixe aucun, et
l'entreprise n'a pas d'accord. Il vient de l'article L3132-27, qui impose la
rémunération doublée **et** un repos compensateur d'égale durée. Le moteur
produit les deux ; un test échoue si l'un manque. Le quota des douze dimanches
du maire est opposable, avec la liste arrêtée par établissement.

Restitution
- Les bloquants — chevauchement, créneau pendant une absence — annulent la
  transaction : mieux vaut refuser une saisie que garder un planning dont les
  heures se comptent deux fois.
- Les avertissements se franchissent, avec un motif enregistré sur chaque
  constat et une entrée d'audit. Le constat acquitté reste affiché avec sa
  justification : le faire disparaître donnerait l'illusion qu'il a été résolu
  alors qu'il a été assumé.
- Une modification revalide la semaine **et ses voisines** : le repos entre
  dimanche soir et lundi matin appartient à deux semaines.

Règles de mineurs — non implémentées, volontairement
La matrice ne couvre que les majeurs et le dossier n'a aucune source primaire
sur les moins de 18 ans. Les codes sont réservés, les seuils absents. Les
inventer donnerait une fausse assurance sur la population que le droit protège
le plus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-08 07:38:39 +00:00
1 parent f8d84c25b9
commit 44b5163713
20 files changed
+3906 -2

No files matched your search

@@ -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();
@@ -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();
+103
View File
@@ -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])
}
+143
View File
@@ -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}`);
}
+81
View File
@@ -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<string, string>;
}
const SEVERITY_TONE: Record<BoardAlert['severity'], Tone> = {
BLOCKING: 'danger',
WARNING: 'warn',
INFO: 'info',
};
const SEVERITY_LABEL: Record<BoardAlert['severity'], string> = {
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 (
<section className="rounded-3 border border-line-1 bg-surface">
<h3 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Convention collective
</h3>
<ul className="divide-y divide-line-1">
{[...anomalies, ...informative].map((alert) => (
<li key={alert.id} className="flex flex-wrap items-start gap-2 px-4 py-2.5">
<Badge tone={SEVERITY_TONE[alert.severity]}>
{SEVERITY_LABEL[alert.severity]}
</Badge>
<div className="min-w-0 flex-1">
<p className="text-sm text-ink-1">{alert.message}</p>
<p className="mt-0.5 text-micro text-ink-3">
{[
alert.membershipId
? (names.get(alert.membershipId) ?? 'Salarié')
: null,
alert.localDate,
alert.ruleCode,
]
.filter(Boolean)
.join(' · ')}
</p>
{alert.acknowledged ? (
<p className="mt-1 rounded-2 bg-surface-2 px-2 py-1 text-micro text-ink-2">
Publié malgré l’alerte
{alert.acknowledgementReason
? ` — ${alert.acknowledgementReason}`
: ''}
</p>
) : null}
</div>
</li>
))}
</ul>
</section>
);
}
+35 -1
View File
@@ -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<Target | null>(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}
</div>
@@ -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({
: {})}
/>
<AlertPanel alerts={section.alerts} names={names} />
{target ? (
<ShiftComposer
key={target.kind === 'edit' ? target.shift.id : 'create'}
@@ -198,11 +213,13 @@ function PublishControl({
weekParam,
version,
published,
pendingWarnings,
}: {
teamId: string;
weekParam: string;
version: number;
published: boolean;
pendingWarnings: number;
}) {
const [state, formAction, pending] = useActionState(
published ? unpublishWeekAction : publishWeekAction,
@@ -210,11 +227,28 @@ function PublishControl({
);
return (
<form action={formAction} className="flex items-center gap-2">
<form action={formAction} className="flex flex-wrap items-center gap-2">
<input type="hidden" name="teamId" value={teamId} />
<input type="hidden" name="week" value={weekParam} />
{/* Verrou optimiste : la version lue au rendu est renvoyée telle quelle. */}
<input type="hidden" name="expectedVersion" value={version} />
{/* 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 ? (
<label className="flex items-center gap-1.5 text-xs text-ink-2">
<span className="sr-only">Motif de publication malgré les alertes</span>
<input
name="acknowledgement"
type="text"
maxLength={500}
placeholder={`Motif — ${pendingWarnings} alerte${pendingWarnings > 1 ? 's' : ''}`}
className="h-8 w-56 rounded-2 border border-warn bg-surface px-2 text-sm text-ink-1"
/>
</label>
) : null}
{state.error ? (
<span role="alert" className="text-xs text-danger">
{state.error}
+12 -1
View File
@@ -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<string>;
/** 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<string>;
cellAction?: (row: BoardRow, dayIndex: number) => React.ReactNode;
shiftAction?: (
shift: BoardShift,
@@ -191,7 +198,11 @@ function Row({
<ShiftChip
poste={shift.poste}
time={shift.time}
state={shift.state}
// Le liseré d'alerte prime sur l'état : un créneau
// publié mais non conforme doit se voir comme tel.
state={
flaggedShiftIds?.has(shift.id) ? 'alert' : shift.state
}
{...(shift.breakMinutes
? { breakMinutes: shift.breakMinutes }
: {})}
+75
View File
@@ -0,0 +1,75 @@
import { RULES } from '@/domain/compliance/rules';
import {
RULE_CODES,
type ComplianceContext,
type RuleCode,
type Severity,
type Violation,
} from '@/domain/compliance/types';
/**
* Exécution du moteur — PLAN.md §6.5.
*
* Le moteur n'écrit rien et ne décide rien : il constate. C'est la couche
* serveur qui refuse un enregistrement sur un `BLOCKING`, et le manager qui
* assume un `WARNING` en le justifiant.
*/
export interface EvaluationResult {
violations: Violation[];
blocking: Violation[];
warnings: Violation[];
/** Règles qui ont levé pendant l'évaluation, avec leur message d'erreur. */
failures: Array<{ ruleCode: RuleCode; error: string }>;
}
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<string, EvaluationResult> {
return new Map(
contexts.map((context) => [context.membershipId, evaluate(context)]),
);
}
const SEVERITY_ORDER: Record<Severity, number> = {
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 ?? '');
});
}
+258
View File
@@ -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',
},
];
+134
View File
@@ -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,
};
}
+159
View File
@@ -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<typeof agreementParametersSchema>;
/**
* 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;
}
+754
View File
@@ -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<string, ComplianceShift[]> {
const inWeek = new Set(weekDates(context.week));
const byDay = new Map<string, ComplianceShift[]>();
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<RuleCode, Rule> = {
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,
};
+159
View File
@@ -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<RuleCode, Severity> = {
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<string, string | number | boolean | null>;
/** 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[];
+438
View File
@@ -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<AgreementVersion | null> {
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<Violation[]> {
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<void> {
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<ComplianceContext[]> {
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<string, LoadedShift[]>();
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<number> {
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<number[]> {
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;
}
+113
View File
@@ -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<void> {
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<void> {
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
+45
View File
@@ -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;
});
+64
View File
@@ -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);
});
+232
View File
@@ -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();
});
});
+139
View File
@@ -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);
});
});
+794
View File
@@ -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> = {}): 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<ComplianceContext> = {}) =>
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);
});
});