Merge pull request #12 — WP-05 moteur de règles de convention
WP-05 — Moteur de règles de convention, effectif-daté
This commit is contained in:
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();
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }
|
||||
: {})}
|
||||
|
||||
@@ -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 ?? '');
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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[];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user