diff --git a/prisma/migrations/20260808120507_pay_periods/migration.sql b/prisma/migrations/20260808120507_pay_periods/migration.sql
new file mode 100644
index 0000000..a6c0d7c
--- /dev/null
+++ b/prisma/migrations/20260808120507_pay_periods/migration.sql
@@ -0,0 +1,81 @@
+-- CreateEnum
+CREATE TYPE "PayPeriodKind" AS ENUM ('MAIN', 'ALTERNATIVE');
+
+-- CreateEnum
+CREATE TYPE "PayPeriodStatus" AS ENUM ('OPEN', 'LOCKED');
+
+-- AlterTable
+ALTER TABLE "PayrollExport" ADD COLUMN "payPeriodId" TEXT;
+
+-- CreateTable
+CREATE TABLE "PayPeriod" (
+ "id" TEXT NOT NULL,
+ "accountId" TEXT NOT NULL,
+ "locationId" TEXT NOT NULL,
+ "label" TEXT NOT NULL,
+ "startDate" DATE NOT NULL,
+ "endDate" DATE NOT NULL,
+ "kind" "PayPeriodKind" NOT NULL DEFAULT 'MAIN',
+ "populations" "ContractType"[],
+ "status" "PayPeriodStatus" NOT NULL DEFAULT 'OPEN',
+ "lockedAt" TIMESTAMP(3),
+ "lockedBy" TEXT,
+ "unlockedAt" TIMESTAMP(3),
+ "unlockedBy" TEXT,
+ "version" INTEGER NOT NULL DEFAULT 0,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "PayPeriod_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "PayPeriodSnapshot" (
+ "id" TEXT NOT NULL,
+ "accountId" TEXT NOT NULL,
+ "payPeriodId" TEXT NOT NULL,
+ "membershipId" TEXT NOT NULL,
+ "plannedMinutes" INTEGER NOT NULL,
+ "actualMinutes" INTEGER NOT NULL,
+ "absenceMinutes" INTEGER NOT NULL,
+ "workedDays" INTEGER NOT NULL,
+ "overtimeByBracket" JSONB NOT NULL,
+ "absenceBreakdown" JSONB NOT NULL,
+ "variables" JSONB NOT NULL,
+ "agreementId" TEXT,
+ "computedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "PayPeriodSnapshot_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "PayPeriod_accountId_idx" ON "PayPeriod"("accountId");
+
+-- CreateIndex
+CREATE INDEX "PayPeriod_accountId_startDate_idx" ON "PayPeriod"("accountId", "startDate");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "PayPeriod_locationId_startDate_endDate_kind_key" ON "PayPeriod"("locationId", "startDate", "endDate", "kind");
+
+-- CreateIndex
+CREATE INDEX "PayPeriodSnapshot_accountId_idx" ON "PayPeriodSnapshot"("accountId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "PayPeriodSnapshot_payPeriodId_membershipId_key" ON "PayPeriodSnapshot"("payPeriodId", "membershipId");
+
+-- AddForeignKey
+ALTER TABLE "PayPeriodSnapshot" ADD CONSTRAINT "PayPeriodSnapshot_payPeriodId_fkey" FOREIGN KEY ("payPeriodId") REFERENCES "PayPeriod"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- Isolation : toute table portant accountId doit porter sa politique.
+DO $$
+DECLARE t text;
+BEGIN
+ FOREACH t IN ARRAY ARRAY['PayPeriod','PayPeriodSnapshot']
+ LOOP
+ EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
+ EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t);
+ EXECUTE format(
+ 'CREATE POLICY tenant_isolation ON %I USING ("accountId" = planflow_current_account())', t);
+ EXECUTE format(
+ 'CREATE POLICY tenant_insert ON %I FOR INSERT WITH CHECK ("accountId" = planflow_current_account())', t);
+ END LOOP;
+END $$;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index b13ebd5..844d4ba 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -825,6 +825,10 @@ model PayrollExport {
id String @id @default(cuid())
accountId String
locationId String?
+ /// Période couverte. La péremption se **déduit** de
+ /// `generatedAt < PayPeriod.unlockedAt` : la stocker obligerait à réécrire
+ /// une trace qui doit rester append-only.
+ payPeriodId String?
periodStart DateTime @db.Date
periodEnd DateTime @db.Date
checksum String
@@ -1012,3 +1016,81 @@ model LeaveNotice {
@@index([accountId])
@@index([membershipId])
}
+
+// ============================================================================
+// Périodes de paie — PLAN.md §4.6 et §7.3, WP-07
+// ============================================================================
+
+/// Période de paie d'un établissement.
+///
+/// Le **verrouillage** fige les instantanés et interdit toute mutation dont la
+/// date tombe dans la période. Le **déverrouillage** existe et rouvre les
+/// mutations : c'est une décision qui exige une justification, pas un bouton
+/// anodin — tout export produit avant devient périmé.
+model PayPeriod {
+ id String @id @default(cuid())
+ accountId String
+ locationId String
+ label String
+ startDate DateTime @db.Date
+ endDate DateTime @db.Date
+ kind PayPeriodKind @default(MAIN)
+ /// Types de contrat inclus. Vide = tous.
+ populations ContractType[]
+ status PayPeriodStatus @default(OPEN)
+ lockedAt DateTime?
+ lockedBy String?
+ /// Dernier déverrouillage : la péremption des exports s'en **déduit**, elle
+ /// n'est jamais stockée, pour que `PayrollExport` reste append-only.
+ unlockedAt DateTime?
+ unlockedBy String?
+ version Int @default(0)
+ createdAt DateTime @default(now())
+
+ snapshots PayPeriodSnapshot[]
+
+ @@unique([locationId, startDate, endDate, kind])
+ @@index([accountId])
+ @@index([accountId, startDate])
+}
+
+enum PayPeriodKind {
+ MAIN
+ ALTERNATIVE
+}
+
+enum PayPeriodStatus {
+ OPEN
+ LOCKED
+}
+
+/// Instantané figé au verrouillage.
+///
+/// Recalculé intégralement à chaque nouveau verrouillage : il n'est donc pas
+/// immuable au sens strict. C'est le couple (instantané, `PayrollExport`) qui
+/// porte la preuve — et `PayrollExport`, lui, est append-only.
+model PayPeriodSnapshot {
+ id String @id @default(cuid())
+ accountId String
+ payPeriodId String
+ membershipId String
+ plannedMinutes Int
+ actualMinutes Int
+ absenceMinutes Int
+ workedDays Int
+ /// [{ ratePercent, minutes }]
+ overtimeByBracket Json
+ /// [{ absenceTypeId, code, days, minutes }]
+ absenceBreakdown Json
+ /// [{ key, value, unit }] — les éléments prêts pour l'export.
+ variables Json
+ /// Version de convention appliquée : sans elle, un instantané ancien devient
+ /// inexplicable dès que les paramètres changent.
+ agreementId String?
+ computedAt DateTime @default(now())
+
+ period PayPeriod @relation(fields: [payPeriodId], references: [id], onDelete: Cascade)
+
+ @@unique([payPeriodId, membershipId])
+ @@index([accountId])
+}
diff --git a/src/app/(app)/paie/periodes/page.tsx b/src/app/(app)/paie/periodes/page.tsx
new file mode 100644
index 0000000..f758eef
--- /dev/null
+++ b/src/app/(app)/paie/periodes/page.tsx
@@ -0,0 +1,177 @@
+import Link from 'next/link';
+
+import {
+ CreatePeriodForm,
+ DeletePeriodForm,
+ LockButton,
+ UnlockForm,
+} from '@/components/payroll/PeriodActions';
+import { PageBody, PageHeader } from '@/components/shell/PageHeader';
+import { Badge } from '@/components/ui/Badge';
+import { getPeriods } from '@/server/payroll/period-queries';
+
+export const metadata = { title: 'Périodes de paie · PlanFlow' };
+
+interface PageProps {
+ searchParams: Promise<{ etablissement?: string }>;
+}
+
+export default async function PeriodesPage({ searchParams }: PageProps) {
+ const params = await searchParams;
+ const view = await getPeriods(params.etablissement);
+
+ if (!view) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ 1 ? 's' : ''}`}
+ actions={
+
+ ← Rapport de paie
+
+ }
+ />
+
+
+ {view.locations.map((location) => (
+
+ {location.name}
+
+ ))}
+
+
+
+
+ {view.periods.length === 0 ? (
+
+ Aucune période. Verrouiller une période fige les heures transmises au
+ cabinet et ferme le mois aux modifications.
+
+ ) : null}
+
+ {view.periods.map((period) => {
+ const locked = period.status === 'LOCKED';
+ const stale = period.exports.filter((entry) => entry.stale).length;
+
+ return (
+
+
+
+ {period.label}
+
+
+ {locked ? 'Verrouillée' : 'Ouverte'}
+
+
+ {frenchDate(period.startDate)} → {frenchDate(period.endDate)}
+
+ {locked ? (
+
+ {period.snapshotCount} instantané
+ {period.snapshotCount > 1 ? 's' : ''}
+
+ ) : null}
+
+
+
+
+ Entrées {period.entries}
+
+
+ Sorties {period.exits}
+
+
+ Extras {period.extras}
+
+
+
+ {stale > 0 ? (
+
+ {stale} export{stale > 1 ? 's' : ''} périmé{stale > 1 ? 's' : ''} :
+ produit{stale > 1 ? 's' : ''} avant le déverrouillage du{' '}
+ {period.unlockedAt?.toISOString().slice(0, 10)}, le fichier
+ transmis ne correspond plus aux données.
+
+ ) : null}
+
+ {period.exports.length > 0 ? (
+
+ {period.exports.map((entry) => (
+
+
+ {entry.stale ? 'Périmé' : 'À jour'}
+
+
+ {entry.lineCount} lignes
+
+
+ {entry.checksum.slice(0, 16)}…
+
+
+ {entry.generatedAt.toISOString().slice(0, 16).replace('T', ' ')}
+
+
+ ))}
+
+ ) : null}
+
+
+ {!locked && view.canLock ? (
+
+ ) : null}
+ {locked && view.canUnlock ? (
+
+ ) : null}
+ {view.canDelete ? (
+
+ ) : null}
+
+
+ );
+ })}
+
+
+ Une période verrouillée refuse toute création, modification ou
+ suppression de créneau et d’absence sur ses dates. Une correction
+ nécessaire passe soit par un déverrouillage justifié, soit par une
+ régularisation sur la période suivante — le passé ne se réécrit pas.
+
+
+ );
+}
+
+function frenchDate(isoDate: string): string {
+ return isoDate.split('-').reverse().join('/');
+}
diff --git a/src/app/(app)/rapports/heures/page.tsx b/src/app/(app)/rapports/heures/page.tsx
new file mode 100644
index 0000000..f91505e
--- /dev/null
+++ b/src/app/(app)/rapports/heures/page.tsx
@@ -0,0 +1,221 @@
+import Link from 'next/link';
+
+import { ActualHoursForm, ValidateRow } from '@/components/hours/ActualHoursForm';
+import { PageBody, PageHeader } from '@/components/shell/PageHeader';
+import { Badge } from '@/components/ui/Badge';
+import { formatDelta, formatMinutes } from '@/domain/counters/week';
+import { monthOf, parseMonthParam } from '@/domain/planning/month';
+import { cx } from '@/lib/cx';
+import { getHoursReport } from '@/server/hours/queries';
+
+export const metadata = { title: 'Heures travaillées · PlanFlow' };
+
+interface PageProps {
+ searchParams: Promise<{ mois?: string; etablissement?: string }>;
+}
+
+export default async function HeuresPage({ searchParams }: PageProps) {
+ const params = await searchParams;
+ const month = parseMonthParam(params.mois) ?? monthOf(new Date());
+ const report = await getHoursReport(month, params.etablissement);
+
+ if (!report) {
+ return (
+
+
+
+ );
+ }
+
+ const href = (mois: string, etablissement = report.location.id) =>
+ `/rapports/heures?mois=${mois}&etablissement=${etablissement}`;
+
+ return (
+
+
+
+ ← Mois précédent
+
+
+ Mois suivant →
+
+
+ Périodes
+
+ >
+ }
+ />
+
+
+ {report.locations.map((location) => (
+
+ {location.name}
+
+ ))}
+ {report.lockedLabels.map((label) => (
+
+ {label} verrouillée
+
+ ))}
+
+
+
+
+ Sans heures réelles saisies, le prévu fait foi. {' '}
+ Attendre une saisie qui ne viendra pas ne produirait aucune paie.
+
+
+ La validation qualifie des heures, elle ne les
+ autorise pas à être payées : des heures accomplies partent en paie
+ qu’elles soient validées ou non.
+
+
+
+ {report.rows.length === 0 ? (
+
+ Aucun créneau planifié sur cette période.
+
+ ) : null}
+
+ {report.rows.map((row) => (
+
+
+ {row.name}
+
+ prévu {formatMinutes(row.plannedMinutes)}
+
+
+ réalisé{' '}
+ {formatMinutes(row.actualMinutes)}
+
+ 0
+ ? 'bg-warn-soft text-warn-soft-ink'
+ : 'bg-danger-soft text-danger-soft-ink',
+ )}
+ >
+ {formatDelta(row.deltaMinutes)}
+
+
+ {row.allValidated ? 'Validé' : 'À valider'}
+
+
+ payé
+ {formatMinutes(row.payableMinutes)}
+
+
+
+
+
+
+ Heures de {row.name}, {report.label}
+
+
+
+ Jour
+ Prévu
+ Réalisé
+ Écart
+ Saisie
+
+
+
+ {row.shifts.map((shift) => (
+
+
+ {shift.localDate.split('-').reverse().join('/')}
+
+
+ {shift.plannedRange}{' '}
+
+ ({formatMinutes(shift.plannedMinutes)})
+
+
+
+ {shift.hasActual ? (
+ <>
+ {shift.actualRange}{' '}
+
+ ({formatMinutes(shift.actualMinutes)})
+
+ >
+ ) : (
+
+ prévu retenu ({formatMinutes(shift.actualMinutes)})
+
+ )}
+
+
+ {shift.deltaMinutes === 0 ? '—' : formatDelta(shift.deltaMinutes)}
+
+
+ {report.canEdit ? (
+
+ ) : (
+
+ Lecture seule
+
+ )}
+
+
+ ))}
+
+
+
+ {report.canValidate ? (
+
+ !shift.locked)
+ .map((shift) => shift.id)}
+ allValidated={row.allValidated}
+ />
+
+ ) : null}
+
+ ))}
+
+ );
+}
diff --git a/src/components/hours/ActualHoursForm.tsx b/src/components/hours/ActualHoursForm.tsx
new file mode 100644
index 0000000..939f7ea
--- /dev/null
+++ b/src/components/hours/ActualHoursForm.tsx
@@ -0,0 +1,135 @@
+'use client';
+
+import { useActionState } from 'react';
+
+import { Button } from '@/components/ui/Button';
+import {
+ saveActualHoursAction,
+ validateHoursAction,
+ type HoursActionState,
+} from '@/server/hours/actions';
+import type { HoursShiftRow } from '@/server/hours/queries';
+
+const empty: HoursActionState = {};
+
+/**
+ * Saisie des heures réellement faites.
+ *
+ * Les champs sont **pré-remplis avec le prévu** quand rien n'a été saisi : le
+ * cas courant est « comme prévu, sauf que », et retaper deux horaires pour
+ * corriger un quart d'heure décourage la saisie — donc fausse le réalisé.
+ */
+export function ActualHoursForm({ shift }: { shift: HoursShiftRow }) {
+ const [state, formAction, pending] = useActionState(
+ saveActualHoursAction,
+ empty,
+ );
+
+ const [plannedStart, plannedEnd] = shift.plannedRange.split('–');
+ const [actualStart, actualEnd] = (shift.actualRange ?? '').split('–');
+
+ if (shift.locked) {
+ return (
+
+ Période verrouillée — saisie fermée
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+/**
+ * Validation groupée d'un salarié.
+ *
+ * Le libellé dit ce que fait l'action : elle **qualifie** des heures, elle ne
+ * les autorise pas à être payées. Elles le sont déjà.
+ */
+export function ValidateRow({
+ shiftIds,
+ allValidated,
+}: {
+ shiftIds: string[];
+ allValidated: boolean;
+}) {
+ const [state, formAction, pending] = useActionState(
+ validateHoursAction,
+ empty,
+ );
+
+ return (
+
+ );
+}
diff --git a/src/components/payroll/PeriodActions.tsx b/src/components/payroll/PeriodActions.tsx
new file mode 100644
index 0000000..7a95218
--- /dev/null
+++ b/src/components/payroll/PeriodActions.tsx
@@ -0,0 +1,199 @@
+'use client';
+
+import { useActionState } from 'react';
+
+import { Button } from '@/components/ui/Button';
+import {
+ createPeriodAction,
+ deletePeriodAction,
+ lockPeriodAction,
+ unlockPeriodAction,
+ type PeriodActionState,
+} from '@/server/payroll/period-actions';
+
+const empty: PeriodActionState = {};
+
+export function CreatePeriodForm({
+ locationId,
+ suggestedMonth,
+}: {
+ locationId: string;
+ suggestedMonth: string;
+}) {
+ const [state, formAction, pending] = useActionState(
+ createPeriodAction,
+ empty,
+ );
+
+ return (
+
+ );
+}
+
+/**
+ * Verrouillage d'une période.
+ *
+ * Le verrou fige les instantanés **et** ferme la période aux mutations : plus
+ * aucun créneau ni congé ne peut être posé sur ces dates tant qu'elle n'est pas
+ * rouverte.
+ */
+export function LockButton({
+ periodId,
+ version,
+}: {
+ periodId: string;
+ version: number;
+}) {
+ const [state, formAction, pending] = useActionState(lockPeriodAction, empty);
+
+ return (
+
+ );
+}
+
+/**
+ * Déverrouillage.
+ *
+ * La justification est exigée à la saisie, pas seulement côté serveur : rouvrir
+ * une période périme les fichiers déjà transmis au cabinet, et six mois plus
+ * tard personne ne saura pourquoi.
+ */
+export function UnlockForm({
+ periodId,
+ version,
+}: {
+ periodId: string;
+ version: number;
+}) {
+ const [state, formAction, pending] = useActionState(
+ unlockPeriodAction,
+ empty,
+ );
+
+ return (
+
+ );
+}
+
+/**
+ * Suppression d'une période.
+ *
+ * La confirmation demande de **retaper le libellé** : supprimer une période
+ * verrouillée efface les instantanés sur lesquels un export a pu être bâti, et
+ * un simple « êtes-vous sûr » se clique sans lire.
+ */
+export function DeletePeriodForm({
+ periodId,
+ label,
+}: {
+ periodId: string;
+ label: string;
+}) {
+ const [state, formAction, pending] = useActionState(
+ deletePeriodAction,
+ empty,
+ );
+
+ return (
+
+ );
+}
diff --git a/src/components/shell/navigation.ts b/src/components/shell/navigation.ts
index dc2d7e5..735fbb2 100644
--- a/src/components/shell/navigation.ts
+++ b/src/components/shell/navigation.ts
@@ -67,8 +67,9 @@ export const NAVIGATION: NavSection[] = [
id: 'rapports',
label: 'Rapports',
items: [
- { id: 'heures', label: 'Heures travaillées' },
+ { id: 'heures', label: 'Heures travaillées', href: '/rapports/heures' },
{ id: 'paie', label: 'Préparation de paie', href: '/paie' },
+ { id: 'periodes', label: 'Périodes de paie', href: '/paie/periodes' },
{ id: 'silae', label: 'Codes Silae', href: '/paie/silae' },
{ id: 'activite', label: "Journal d'activité" },
],
diff --git a/src/domain/hours/states.ts b/src/domain/hours/states.ts
new file mode 100644
index 0000000..096d265
--- /dev/null
+++ b/src/domain/hours/states.ts
@@ -0,0 +1,166 @@
+import { shiftMinutes } from '@/domain/counters/week';
+
+/**
+ * Les trois états d'une heure — PLAN.md §7.3.
+ *
+ * Il n'y a pas de pointeuse : le réalisé est saisi par le manager. La matrice
+ * de conformité impose néanmoins de distinguer **trois** grandeurs et non deux.
+ *
+ * | État | Source |
+ * |---|---|
+ * | **Prévu** | le planning publié |
+ * | **Réalisé** | ce que le salarié a effectivement fait |
+ * | **Payé** | ce qui part en paie après application des règles |
+ *
+ * Deux règles ne se négocient pas :
+ *
+ * 1. **Sans heures réelles, le prévu fait foi.** Attendre une saisie qui ne
+ * viendra pas ne produirait aucune paie.
+ * 2. **Le paiement n'est jamais conditionné à la validation.** Une ligne non
+ * validée par un manager part quand même en paie sur la base du réalisé.
+ * Bloquer le paiement d'heures accomplies faute de validation est
+ * précisément ce que la matrice interdit.
+ */
+
+export interface ShiftHours {
+ startAt: Date;
+ endAt: Date;
+ breakMinutes: number;
+ actualStartAt: Date | null;
+ actualEndAt: Date | null;
+ actualBreakMinutes: number | null;
+ isValidated: boolean;
+}
+
+export interface HoursView {
+ plannedMinutes: number;
+ /** Réalisé saisi, ou prévu à défaut. */
+ actualMinutes: number;
+ /** Réalisé − prévu, signé. */
+ deltaMinutes: number;
+ /** Vrai quand le réalisé a été saisi, faux quand c'est le prévu qui sert. */
+ hasActual: boolean;
+ isValidated: boolean;
+ /**
+ * Ce qui part en paie. Égal au réalisé, **indépendamment** de la validation.
+ */
+ payableMinutes: number;
+}
+
+export function plannedMinutesOf(shift: ShiftHours): number {
+ return shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes);
+}
+
+/**
+ * Le réalisé d'un créneau.
+ *
+ * Une saisie partielle — un début sans fin — ne suffit pas : elle produirait
+ * une durée fantaisiste. Tant que les deux bornes ne sont pas là, le prévu
+ * reste la meilleure information disponible.
+ */
+export function actualMinutesOf(shift: ShiftHours): number | null {
+ if (!shift.actualStartAt || !shift.actualEndAt) return null;
+ return shiftMinutes(
+ shift.actualStartAt,
+ shift.actualEndAt,
+ shift.actualBreakMinutes ?? shift.breakMinutes,
+ );
+}
+
+export function hoursView(shift: ShiftHours): HoursView {
+ const planned = plannedMinutesOf(shift);
+ const actual = actualMinutesOf(shift);
+ const effective = actual ?? planned;
+
+ return {
+ plannedMinutes: planned,
+ actualMinutes: effective,
+ deltaMinutes: effective - planned,
+ hasActual: actual !== null,
+ isValidated: shift.isValidated,
+ // Volontairement identique au réalisé : la validation qualifie, elle ne
+ // conditionne pas le paiement.
+ payableMinutes: effective,
+ };
+}
+
+/** Agrégat d'un ensemble de créneaux. */
+export function sumHours(shifts: ShiftHours[]): HoursView {
+ return shifts.reduce(
+ (total, shift) => {
+ const view = hoursView(shift);
+ return {
+ plannedMinutes: total.plannedMinutes + view.plannedMinutes,
+ actualMinutes: total.actualMinutes + view.actualMinutes,
+ deltaMinutes: total.deltaMinutes + view.deltaMinutes,
+ hasActual: total.hasActual || view.hasActual,
+ isValidated: total.isValidated && view.isValidated,
+ payableMinutes: total.payableMinutes + view.payableMinutes,
+ };
+ },
+ {
+ plannedMinutes: 0,
+ actualMinutes: 0,
+ deltaMinutes: 0,
+ hasActual: false,
+ // Un ensemble vide est validé par vacuité : c'est ce qui permet à
+ // l'agrégat d'une équipe sans écart de s'afficher comme traité.
+ isValidated: true,
+ payableMinutes: 0,
+ },
+ );
+}
+
+/**
+ * Une correction conserve valeur avant, valeur après, motif, auteur et date.
+ *
+ * Le type existe pour que l'appelant ne puisse pas l'oublier : sans motif, une
+ * correction d'heures est indistinguable d'une erreur de saisie.
+ */
+export interface HoursCorrection {
+ beforeMinutes: number;
+ afterMinutes: number;
+ reason: string;
+ actorMembershipId: string;
+ at: Date;
+}
+
+export function describeCorrection(correction: HoursCorrection): string {
+ const sign = correction.afterMinutes >= correction.beforeMinutes ? '+' : '−';
+ const delta = Math.abs(correction.afterMinutes - correction.beforeMinutes);
+ return `${sign}${Math.floor(delta / 60)} h ${String(delta % 60).padStart(2, '0')} — ${correction.reason}`;
+}
+
+/**
+ * Une date tombe-t-elle dans une période verrouillée ?
+ *
+ * Fonction pure pour que le garde-fou soit testable sans base : c'est lui qui
+ * empêche de modifier un mois déjà transmis au cabinet.
+ */
+export function fallsInLockedPeriod(
+ isoDate: string,
+ periods: Array<{ startDate: string; endDate: string; status: string }>,
+): boolean {
+ return periods.some(
+ (period) =>
+ period.status === 'LOCKED' &&
+ isoDate >= period.startDate &&
+ isoDate <= period.endDate,
+ );
+}
+
+/**
+ * Un export est-il périmé ?
+ *
+ * **Déduit**, jamais stocké : marquer l'export obligerait à le réécrire, alors
+ * qu'il doit rester append-only. Un fichier transmis à Silae avant un
+ * déverrouillage ne correspond plus aux données — sans ce signalement, rien ne
+ * l'indiquerait.
+ */
+export function isExportStale(
+ generatedAt: Date,
+ unlockedAt: Date | null,
+): boolean {
+ if (!unlockedAt) return false;
+ return generatedAt < unlockedAt;
+}
diff --git a/src/server/absences/actions.ts b/src/server/absences/actions.ts
index f220e50..7e14a2b 100644
--- a/src/server/absences/actions.ts
+++ b/src/server/absences/actions.ts
@@ -11,6 +11,7 @@ import {
} from '@/domain/absences/count';
import { recordAudit } from '@/server/audit';
import { mutate } from '@/server/context';
+import { assertPeriodOpen, PeriodLockedError } from '@/server/payroll/periods';
import type { ScopedClient } from '@/server/tenant';
/**
@@ -186,6 +187,15 @@ export async function requestTimeOffAction(
.join(' ');
}
+ if (contract) {
+ // Poser un congé sur un mois déjà transmis fausserait la paie sans
+ // qu'aucun fichier ne le reflète.
+ await assertPeriodOpen(db, contract.locationId, [
+ parsed.data.startDate,
+ parsed.data.endDate,
+ ]);
+ }
+
const created = await db.timeOff.create({
data: {
membershipId: parsed.data.membershipId,
@@ -581,6 +591,7 @@ function counterTypeFor(
function toState(error: unknown, denied: string): AbsenceActionState {
if (error instanceof ValidationError) return { error: error.message };
+ if (error instanceof PeriodLockedError) return { error: error.message };
if (error instanceof AuthorizationError) return { error: denied };
throw error;
}
diff --git a/src/server/hours/actions.ts b/src/server/hours/actions.ts
new file mode 100644
index 0000000..e534530
--- /dev/null
+++ b/src/server/hours/actions.ts
@@ -0,0 +1,244 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { z } from 'zod';
+
+import { AuthorizationError } from '@/domain/access/authorize';
+import { hoursView } from '@/domain/hours/states';
+import { zonedDate, zonedInstant } from '@/domain/planning/week';
+import { recordAudit } from '@/server/audit';
+import { mutate } from '@/server/context';
+import { assertPeriodOpen, PeriodLockedError } from '@/server/payroll/periods';
+
+/**
+ * Saisie des heures réelles — PLAN.md §7.3.
+ *
+ * Sans pointeuse, c'est le manager qui saisit. Deux règles encadrent cette
+ * saisie :
+ *
+ * 1. **Toute correction conserve valeur avant, valeur après, motif, auteur et
+ * date.** Sans motif, une correction est indistinguable d'une erreur.
+ * 2. **La validation qualifie, elle ne conditionne pas le paiement.** Valider
+ * ou non ne change pas ce qui part en paie ; c'est le réalisé qui compte.
+ */
+
+export interface HoursActionState {
+ error?: string;
+ ok?: boolean;
+}
+
+class ValidationError extends Error {}
+
+const HOUR = /^([01]\d|2[0-3]):([0-5]\d)$/;
+
+const actualInput = z.object({
+ shiftId: z.string().min(1),
+ start: z.string().regex(HOUR, 'Heure de début invalide').or(z.literal('')),
+ end: z.string().regex(HOUR, 'Heure de fin invalide').or(z.literal('')),
+ breakMinutes: z.string().optional(),
+ reason: z.string().trim().max(500).optional(),
+});
+
+export async function saveActualHoursAction(
+ _previous: HoursActionState,
+ formData: FormData,
+): Promise {
+ const parsed = actualInput.safeParse({
+ shiftId: formData.get('shiftId'),
+ start: formData.get('start') ?? '',
+ end: formData.get('end') ?? '',
+ breakMinutes: formData.get('breakMinutes') || undefined,
+ reason: formData.get('reason') || undefined,
+ });
+
+ if (!parsed.success) {
+ return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
+ }
+
+ try {
+ await mutate('hours.edit_actual', async (db, actor) => {
+ const shift = await db.shift.findUnique({
+ where: { id: parsed.data.shiftId },
+ include: { schedule: true },
+ });
+ if (!shift) throw new AuthorizationError('hours.edit_actual');
+
+ const location = await db.location.findUnique({
+ where: { id: shift.schedule.locationId },
+ select: { id: true, timezone: true },
+ });
+ if (!location) throw new AuthorizationError('hours.edit_actual');
+
+ const localDate = zonedDate(shift.startAt, location.timezone);
+ await assertPeriodOpen(db, location.id, [localDate]);
+
+ const before = hoursView({
+ startAt: shift.startAt,
+ endAt: shift.endAt,
+ breakMinutes: shift.breakMinutes,
+ actualStartAt: shift.actualStartAt,
+ actualEndAt: shift.actualEndAt,
+ actualBreakMinutes: shift.actualBreakMinutes,
+ isValidated: shift.isValidated,
+ });
+
+ // Vider les deux champs efface le réalisé : le prévu reprend force de
+ // vérité, ce qui est un retour en arrière légitime après une saisie
+ // erronée.
+ const clearing = !parsed.data.start && !parsed.data.end;
+ if (!clearing && (!parsed.data.start || !parsed.data.end)) {
+ throw new ValidationError(
+ 'Renseignez le début **et** la fin : une saisie partielle produirait une durée fantaisiste.',
+ );
+ }
+
+ let actualStartAt: Date | null = null;
+ let actualEndAt: Date | null = null;
+
+ if (!clearing) {
+ actualStartAt = zonedInstant(
+ localDate,
+ parsed.data.start,
+ location.timezone,
+ );
+ actualEndAt = zonedInstant(localDate, parsed.data.end, location.timezone);
+ // Fin avant début = créneau de nuit, comme au planning.
+ if (actualEndAt <= actualStartAt) {
+ actualEndAt = new Date(actualEndAt.getTime() + 86_400_000);
+ }
+ }
+
+ const actualBreakMinutes =
+ clearing || parsed.data.breakMinutes === undefined
+ ? null
+ : Math.max(0, Math.min(600, Number(parsed.data.breakMinutes) || 0));
+
+ await db.shift.update({
+ where: { id: shift.id },
+ data: {
+ actualStartAt,
+ actualEndAt,
+ actualBreakMinutes,
+ version: { increment: 1 },
+ },
+ });
+
+ const after = hoursView({
+ startAt: shift.startAt,
+ endAt: shift.endAt,
+ breakMinutes: shift.breakMinutes,
+ actualStartAt,
+ actualEndAt,
+ actualBreakMinutes,
+ isValidated: shift.isValidated,
+ });
+
+ // Une correction d'heures déjà saisies exige un motif ; une première
+ // saisie n'en exige pas — il n'y a rien à corriger.
+ if (before.hasActual && !parsed.data.reason) {
+ throw new ValidationError(
+ 'Modifier des heures déjà saisies exige un motif.',
+ );
+ }
+
+ await recordAudit(db, {
+ actorMembershipId: actor.membershipId,
+ action: 'hours.actual.update',
+ entityType: 'Shift',
+ entityId: shift.id,
+ before: {
+ actualMinutes: before.actualMinutes,
+ hasActual: before.hasActual,
+ },
+ after: {
+ actualMinutes: after.actualMinutes,
+ hasActual: after.hasActual,
+ deltaMinutes: after.deltaMinutes,
+ },
+ reason: parsed.data.reason ?? null,
+ });
+ });
+ } catch (error) {
+ return toState(error, "Vous n'avez pas le droit de saisir des heures.");
+ }
+
+ revalidatePath('/rapports/heures');
+ return { ok: true };
+}
+
+const validateInput = z.object({
+ shiftIds: z.array(z.string().min(1)).min(1),
+ validate: z.boolean(),
+});
+
+/**
+ * Valide ou dévalide des heures.
+ *
+ * **Sans effet sur la paie.** La validation qualifie une ligne — vue et
+ * acceptée par un responsable — mais des heures accomplies partent en paie
+ * qu'elles soient validées ou non.
+ */
+export async function validateHoursAction(
+ _previous: HoursActionState,
+ formData: FormData,
+): Promise {
+ const parsed = validateInput.safeParse({
+ shiftIds: formData.getAll('shiftIds').map(String).filter(Boolean),
+ validate: formData.get('validate') !== 'false',
+ });
+
+ if (!parsed.success) return { error: 'Aucune ligne sélectionnée.' };
+
+ try {
+ await mutate('hours.validate', async (db, actor) => {
+ const shifts = await db.shift.findMany({
+ where: { id: { in: parsed.data.shiftIds } },
+ include: { schedule: true },
+ });
+ if (shifts.length === 0) throw new ValidationError('Lignes introuvables.');
+
+ for (const shift of shifts) {
+ const location = await db.location.findUnique({
+ where: { id: shift.schedule.locationId },
+ select: { id: true, timezone: true },
+ });
+ if (!location) continue;
+ await assertPeriodOpen(db, location.id, [
+ zonedDate(shift.startAt, location.timezone),
+ ]);
+ }
+
+ await db.shift.updateMany({
+ where: { id: { in: parsed.data.shiftIds } },
+ data: {
+ isValidated: parsed.data.validate,
+ validatedAt: parsed.data.validate ? new Date() : null,
+ validatedBy: parsed.data.validate ? actor.membershipId : null,
+ },
+ });
+
+ await recordAudit(db, {
+ actorMembershipId: actor.membershipId,
+ action: parsed.data.validate ? 'hours.validate' : 'hours.invalidate',
+ entityType: 'Shift',
+ entityId: parsed.data.shiftIds[0] as string,
+ after: {
+ count: parsed.data.shiftIds.length,
+ validated: parsed.data.validate,
+ },
+ });
+ });
+ } catch (error) {
+ return toState(error, "Vous n'avez pas le droit de valider des heures.");
+ }
+
+ revalidatePath('/rapports/heures');
+ return { ok: true };
+}
+
+function toState(error: unknown, denied: string): HoursActionState {
+ if (error instanceof ValidationError) return { error: error.message };
+ if (error instanceof PeriodLockedError) return { error: error.message };
+ if (error instanceof AuthorizationError) return { error: denied };
+ throw error;
+}
diff --git a/src/server/hours/queries.ts b/src/server/hours/queries.ts
new file mode 100644
index 0000000..6b17f65
--- /dev/null
+++ b/src/server/hours/queries.ts
@@ -0,0 +1,228 @@
+import { can } from '@/domain/access/authorize';
+import { formatMinutes } from '@/domain/counters/week';
+import { hoursView, sumHours, type ShiftHours } from '@/domain/hours/states';
+import { monthDates, type Month } from '@/domain/planning/month';
+import { zonedClock, zonedDate, zonedMidnight } from '@/domain/planning/week';
+import { query } from '@/server/context';
+
+/**
+ * Rapport d'heures — PLAN.md §7.3.
+ *
+ * Les trois grandeurs sont affichées côte à côte : prévu, réalisé, écart. C'est
+ * la seule façon de voir qu'un salarié fait systématiquement une heure de plus
+ * que son planning — un écart isolé se remarque, un écart chronique ne se voit
+ * que sur un tableau.
+ */
+
+export interface HoursShiftRow {
+ id: string;
+ localDate: string;
+ plannedRange: string;
+ actualRange: string | null;
+ plannedMinutes: number;
+ actualMinutes: number;
+ deltaMinutes: number;
+ hasActual: boolean;
+ isValidated: boolean;
+ locked: boolean;
+}
+
+export interface HoursEmployeeRow {
+ membershipId: string;
+ name: string;
+ plannedMinutes: number;
+ actualMinutes: number;
+ deltaMinutes: number;
+ payableMinutes: number;
+ allValidated: boolean;
+ shifts: HoursShiftRow[];
+}
+
+export interface HoursReport {
+ month: Month;
+ label: string;
+ monthParam: string;
+ previousParam: string;
+ nextParam: string;
+ location: { id: string; name: string; timezone: string };
+ locations: Array<{ id: string; name: string }>;
+ rows: HoursEmployeeRow[];
+ totals: {
+ plannedMinutes: number;
+ actualMinutes: number;
+ deltaMinutes: number;
+ payableMinutes: number;
+ };
+ canEdit: boolean;
+ canValidate: boolean;
+ /** Périodes verrouillées recouvrant le mois. */
+ lockedLabels: string[];
+}
+
+export async function getHoursReport(
+ month: Month,
+ locationId?: string,
+): Promise {
+ return query(
+ 'hours.view',
+ async (db, actor) => {
+ const locations = await db.location.findMany({
+ where: { archivedAt: null },
+ select: { id: true, name: true, timezone: true },
+ orderBy: { name: 'asc' },
+ });
+ const location =
+ locations.find((candidate) => candidate.id === locationId) ??
+ locations[0];
+ if (!location) return null;
+
+ const dates = monthDates(month);
+ const startDate = dates[0] as string;
+ const endDate = dates[dates.length - 1] as string;
+ const from = zonedMidnight(startDate, location.timezone);
+ const to = zonedMidnight(
+ new Date(new Date(`${endDate}T00:00:00Z`).getTime() + 86_400_000)
+ .toISOString()
+ .slice(0, 10),
+ location.timezone,
+ );
+
+ const teams = await db.team.findMany({
+ where: { locationId: location.id, archivedAt: null },
+ select: { id: true },
+ });
+ const assignments = await db.teamMember.findMany({
+ where: { teamId: { in: teams.map((team) => team.id) } },
+ include: {
+ membership: {
+ include: {
+ profile: { select: { firstName: true, lastName: true } },
+ },
+ },
+ },
+ });
+
+ const memberIds = [
+ ...new Set(assignments.map((assignment) => assignment.membershipId)),
+ ];
+
+ const shifts = await db.shift.findMany({
+ where: {
+ membershipId: { in: memberIds },
+ startAt: { gte: from, lt: to },
+ },
+ orderBy: { startAt: 'asc' },
+ });
+
+ // Les périodes verrouillées ferment la saisie : afficher un champ qui
+ // sera refusé à l'envoi serait une invitation à perdre son temps.
+ const lockedPeriods = await db.payPeriod.findMany({
+ where: {
+ locationId: location.id,
+ status: 'LOCKED',
+ startDate: { lte: new Date(`${endDate}T00:00:00Z`) },
+ endDate: { gte: new Date(`${startDate}T00:00:00Z`) },
+ },
+ select: { label: true, startDate: true, endDate: true },
+ });
+ const isLocked = (isoDate: string) =>
+ lockedPeriods.some(
+ (period) =>
+ isoDate >= period.startDate.toISOString().slice(0, 10) &&
+ isoDate <= period.endDate.toISOString().slice(0, 10),
+ );
+
+ const byMember = new Map();
+ for (const shift of shifts) {
+ if (!shift.membershipId) continue;
+ const list = byMember.get(shift.membershipId) ?? [];
+ list.push(shift);
+ byMember.set(shift.membershipId, list);
+ }
+
+ const rows: HoursEmployeeRow[] = [];
+ for (const assignment of assignments) {
+ const own = byMember.get(assignment.membershipId) ?? [];
+ if (own.length === 0) continue;
+
+ const asHours: ShiftHours[] = own.map((shift) => ({
+ startAt: shift.startAt,
+ endAt: shift.endAt,
+ breakMinutes: shift.breakMinutes,
+ actualStartAt: shift.actualStartAt,
+ actualEndAt: shift.actualEndAt,
+ actualBreakMinutes: shift.actualBreakMinutes,
+ isValidated: shift.isValidated,
+ }));
+ const total = sumHours(asHours);
+
+ const profile = assignment.membership.profile;
+ rows.push({
+ membershipId: assignment.membershipId,
+ name: `${profile?.firstName ?? ''} ${profile?.lastName ?? assignment.membership.employeeNumber}`.trim(),
+ plannedMinutes: total.plannedMinutes,
+ actualMinutes: total.actualMinutes,
+ deltaMinutes: total.deltaMinutes,
+ payableMinutes: total.payableMinutes,
+ allValidated: total.isValidated,
+ shifts: own.map((shift) => {
+ const view = hoursView({
+ startAt: shift.startAt,
+ endAt: shift.endAt,
+ breakMinutes: shift.breakMinutes,
+ actualStartAt: shift.actualStartAt,
+ actualEndAt: shift.actualEndAt,
+ actualBreakMinutes: shift.actualBreakMinutes,
+ isValidated: shift.isValidated,
+ });
+ const localDate = zonedDate(shift.startAt, location.timezone);
+
+ return {
+ id: shift.id,
+ localDate,
+ plannedRange: `${zonedClock(shift.startAt, location.timezone)}–${zonedClock(shift.endAt, location.timezone)}`,
+ actualRange:
+ shift.actualStartAt && shift.actualEndAt
+ ? `${zonedClock(shift.actualStartAt, location.timezone)}–${zonedClock(shift.actualEndAt, location.timezone)}`
+ : null,
+ plannedMinutes: view.plannedMinutes,
+ actualMinutes: view.actualMinutes,
+ deltaMinutes: view.deltaMinutes,
+ hasActual: view.hasActual,
+ isValidated: view.isValidated,
+ locked: isLocked(localDate),
+ };
+ }),
+ });
+ }
+
+ rows.sort((a, b) => a.name.localeCompare(b.name, 'fr'));
+
+ const { formatMonthParam, monthLabel, nextMonth, previousMonth } =
+ await import('@/domain/planning/month');
+
+ return {
+ month,
+ label: monthLabel(month),
+ monthParam: formatMonthParam(month),
+ previousParam: formatMonthParam(previousMonth(month)),
+ nextParam: formatMonthParam(nextMonth(month)),
+ location,
+ locations: locations.map(({ id, name }) => ({ id, name })),
+ rows,
+ totals: {
+ plannedMinutes: rows.reduce((sum, row) => sum + row.plannedMinutes, 0),
+ actualMinutes: rows.reduce((sum, row) => sum + row.actualMinutes, 0),
+ deltaMinutes: rows.reduce((sum, row) => sum + row.deltaMinutes, 0),
+ payableMinutes: rows.reduce((sum, row) => sum + row.payableMinutes, 0),
+ },
+ canEdit: can(actor, 'hours.edit_actual'),
+ canValidate: can(actor, 'hours.validate'),
+ lockedLabels: lockedPeriods.map((period) => period.label),
+ };
+ },
+ locationId ? { locationId } : undefined,
+ );
+}
+
+export { formatMinutes };
diff --git a/src/server/payroll/actions.ts b/src/server/payroll/actions.ts
index ef0d0dd..5a6cecc 100644
--- a/src/server/payroll/actions.ts
+++ b/src/server/payroll/actions.ts
@@ -154,12 +154,12 @@ export async function exportSilaeAction(
await mutate(
'payroll.export.silae',
async (db, actor) => {
- const period = await buildPayrollPeriod(
+ const payroll = await buildPayrollPeriod(
db,
month,
parsed.data.locationId,
);
- if (!period) {
+ if (!payroll) {
throw new ValidationError(
"Aucune convention collective n'est chargée pour cette période.",
);
@@ -167,25 +167,37 @@ export async function exportSilaeAction(
// Un export partiel se charge sans erreur et rend la paie fausse pour
// les salariés absents du fichier : il vaut mieux ne rien produire.
- if (period.blockers.length > 0) {
- throw new ValidationError(period.blockers.join(' · '));
+ if (payroll.blockers.length > 0) {
+ throw new ValidationError(payroll.blockers.join(' · '));
}
- if (period.rows.length === 0) {
+ if (payroll.rows.length === 0) {
throw new ValidationError(
'Aucun élément de paie sur cette période : rien à exporter.',
);
}
- const result = formatSilaeCsv(toSilaeLines(period));
+ const result = formatSilaeCsv(toSilaeLines(payroll));
csv = result.csv;
digest = await checksum(csv);
- filename = `silae-${period.location.name.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase()}-${parsed.data.month}.csv`;
+ filename = `silae-${payroll.location.name.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase()}-${parsed.data.month}.csv`;
+
+ // Rattacher l'export à sa période rend sa péremption déductible : sans
+ // ce lien, un déverrouillage ne pourrait pas signaler les fichiers
+ // devenus faux.
+ const payPeriod = await db.payPeriod.findFirst({
+ where: {
+ locationId: parsed.data.locationId,
+ startDate: new Date(`${payroll.startDate}T00:00:00Z`),
+ },
+ select: { id: true },
+ });
const record = await db.payrollExport.create({
data: {
locationId: parsed.data.locationId,
- periodStart: new Date(`${period.startDate}T00:00:00Z`),
- periodEnd: new Date(`${period.endDate}T00:00:00Z`),
+ payPeriodId: payPeriod?.id ?? null,
+ periodStart: new Date(`${payroll.startDate}T00:00:00Z`),
+ periodEnd: new Date(`${payroll.endDate}T00:00:00Z`),
checksum: digest,
lineCount: result.lineCount,
generatedBy: actor.membershipId,
@@ -198,7 +210,7 @@ export async function exportSilaeAction(
entityType: 'PayrollExport',
entityId: record.id,
after: {
- period: `${period.startDate} → ${period.endDate}`,
+ period: `${payroll.startDate} → ${payroll.endDate}`,
lines: result.lineCount,
checksum: digest,
},
diff --git a/src/server/payroll/period-actions.ts b/src/server/payroll/period-actions.ts
new file mode 100644
index 0000000..7f584d3
--- /dev/null
+++ b/src/server/payroll/period-actions.ts
@@ -0,0 +1,319 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { z } from 'zod';
+
+import { AuthorizationError } from '@/domain/access/authorize';
+import { monthDates, parseMonthParam } from '@/domain/planning/month';
+import { recordAudit } from '@/server/audit';
+import { mutate } from '@/server/context';
+import { computeSnapshots } from '@/server/payroll/periods';
+
+/**
+ * Cycle de vie d'une période de paie — PLAN.md §4.6.
+ *
+ * Trois actions, trois exigences distinctes :
+ *
+ * - **Verrouiller** fige les instantanés et ferme la période aux mutations.
+ * - **Déverrouiller** rouvre, exige une justification et périme tout export
+ * déjà produit. La péremption est déduite de `generatedAt < unlockedAt` —
+ * jamais stockée, pour que la trace d'export reste append-only.
+ * - **Supprimer** reste possible sur une période verrouillée, avec une
+ * capacité dédiée et une trace conservant le périmètre supprimé.
+ */
+
+export interface PeriodActionState {
+ error?: string;
+ ok?: boolean;
+ message?: string;
+}
+
+class ValidationError extends Error {}
+
+const createInput = z.object({
+ locationId: z.string().min(1),
+ month: z.string().min(1),
+ label: z.string().trim().max(80).optional(),
+});
+
+export async function createPeriodAction(
+ _previous: PeriodActionState,
+ formData: FormData,
+): Promise {
+ const parsed = createInput.safeParse({
+ locationId: formData.get('locationId'),
+ month: formData.get('month'),
+ label: formData.get('label') || undefined,
+ });
+ if (!parsed.success) return { error: 'Période invalide.' };
+
+ const month = parseMonthParam(parsed.data.month);
+ if (!month) return { error: 'Période invalide.' };
+
+ try {
+ await mutate(
+ 'payroll.period.create',
+ async (db, actor) => {
+ const dates = monthDates(month);
+ const startDate = new Date(`${dates[0]}T00:00:00Z`);
+ const endDate = new Date(`${dates[dates.length - 1]}T00:00:00Z`);
+
+ const existing = await db.payPeriod.findFirst({
+ where: {
+ locationId: parsed.data.locationId,
+ startDate,
+ endDate,
+ kind: 'MAIN',
+ },
+ });
+ if (existing) {
+ throw new ValidationError('Cette période existe déjà.');
+ }
+
+ const { monthLabel } = await import('@/domain/planning/month');
+ const created = await db.payPeriod.create({
+ data: {
+ locationId: parsed.data.locationId,
+ label: parsed.data.label || monthLabel(month),
+ startDate,
+ endDate,
+ kind: 'MAIN',
+ } as never,
+ });
+
+ await recordAudit(db, {
+ actorMembershipId: actor.membershipId,
+ action: 'payroll.period.create',
+ entityType: 'PayPeriod',
+ entityId: created.id,
+ after: { label: created.label, from: dates[0], to: dates.at(-1) },
+ });
+ },
+ { locationId: parsed.data.locationId },
+ );
+ } catch (error) {
+ return toState(error, "Vous n'avez pas le droit de créer une période.");
+ }
+
+ revalidatePath('/paie/periodes');
+ return { ok: true };
+}
+
+const lockInput = z.object({
+ periodId: z.string().min(1),
+ expectedVersion: z.coerce.number().int().min(0),
+});
+
+export async function lockPeriodAction(
+ _previous: PeriodActionState,
+ formData: FormData,
+): Promise {
+ const parsed = lockInput.safeParse({
+ periodId: formData.get('periodId'),
+ expectedVersion: formData.get('expectedVersion') ?? 0,
+ });
+ if (!parsed.success) return { error: 'Période introuvable.' };
+
+ let written = 0;
+
+ try {
+ await mutate('payroll.period.lock', async (db, actor) => {
+ const period = await db.payPeriod.findUnique({
+ where: { id: parsed.data.periodId },
+ });
+ if (!period) throw new AuthorizationError('payroll.period.lock');
+ if (period.status === 'LOCKED') {
+ throw new ValidationError('Cette période est déjà verrouillée.');
+ }
+
+ const start = period.startDate.toISOString().slice(0, 10);
+ written = await computeSnapshots(
+ db,
+ period.id,
+ {
+ year: Number(start.slice(0, 4)),
+ month: Number(start.slice(5, 7)),
+ },
+ period.locationId,
+ );
+
+ const updated = await db.payPeriod.updateMany({
+ where: { id: period.id, version: parsed.data.expectedVersion },
+ data: {
+ status: 'LOCKED',
+ lockedAt: new Date(),
+ lockedBy: actor.membershipId,
+ version: { increment: 1 },
+ },
+ });
+ if (updated.count === 0) {
+ throw new ValidationError(
+ 'Cette période a été modifiée entre-temps. Rechargez la page.',
+ );
+ }
+
+ await recordAudit(db, {
+ actorMembershipId: actor.membershipId,
+ action: 'payroll.period.lock',
+ entityType: 'PayPeriod',
+ entityId: period.id,
+ before: { status: period.status },
+ after: { status: 'LOCKED', snapshots: written },
+ });
+ });
+ } catch (error) {
+ return toState(error, "Vous n'avez pas le droit de verrouiller une période.");
+ }
+
+ revalidatePath('/paie/periodes');
+ revalidatePath('/paie');
+ return {
+ ok: true,
+ message: `${written} instantané${written > 1 ? 's' : ''} figé${written > 1 ? 's' : ''}.`,
+ };
+}
+
+const unlockInput = z.object({
+ periodId: z.string().min(1),
+ expectedVersion: z.coerce.number().int().min(0),
+ reason: z.string().trim().min(1, 'Justification obligatoire').max(500),
+});
+
+/**
+ * Déverrouille une période.
+ *
+ * La justification est **obligatoire** : rouvrir une période périme les
+ * fichiers déjà transmis au cabinet, et six mois plus tard personne ne saura
+ * pourquoi le mois de juillet a été rouvert.
+ */
+export async function unlockPeriodAction(
+ _previous: PeriodActionState,
+ formData: FormData,
+): Promise {
+ const parsed = unlockInput.safeParse({
+ periodId: formData.get('periodId'),
+ expectedVersion: formData.get('expectedVersion') ?? 0,
+ reason: formData.get('reason') ?? '',
+ });
+ if (!parsed.success) {
+ return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
+ }
+
+ let staleExports = 0;
+
+ try {
+ await mutate('payroll.period.unlock', async (db, actor) => {
+ const period = await db.payPeriod.findUnique({
+ where: { id: parsed.data.periodId },
+ });
+ if (!period) throw new AuthorizationError('payroll.period.unlock');
+ if (period.status !== 'LOCKED') {
+ throw new ValidationError('Cette période n’est pas verrouillée.');
+ }
+
+ const now = new Date();
+ const updated = await db.payPeriod.updateMany({
+ where: { id: period.id, version: parsed.data.expectedVersion },
+ data: {
+ status: 'OPEN',
+ unlockedAt: now,
+ unlockedBy: actor.membershipId,
+ version: { increment: 1 },
+ },
+ });
+ if (updated.count === 0) {
+ throw new ValidationError(
+ 'Cette période a été modifiée entre-temps. Rechargez la page.',
+ );
+ }
+
+ // Rien n'est écrit sur les exports : leur péremption se déduit de la date
+ // de déverrouillage. On les compte seulement pour le dire à l'écran.
+ staleExports = await db.payrollExport.count({
+ where: { payPeriodId: period.id, generatedAt: { lt: now } },
+ });
+
+ await recordAudit(db, {
+ actorMembershipId: actor.membershipId,
+ action: 'payroll.period.unlock',
+ entityType: 'PayPeriod',
+ entityId: period.id,
+ before: { status: 'LOCKED' },
+ after: { status: 'OPEN', staleExports },
+ reason: parsed.data.reason,
+ });
+ });
+ } catch (error) {
+ return toState(error, "Vous n'avez pas le droit de déverrouiller une période.");
+ }
+
+ revalidatePath('/paie/periodes');
+ revalidatePath('/paie');
+ return {
+ ok: true,
+ message:
+ staleExports > 0
+ ? `${staleExports} export${staleExports > 1 ? 's' : ''} désormais périmé${staleExports > 1 ? 's' : ''} : le fichier transmis ne correspond plus aux données.`
+ : 'Période rouverte aux modifications.',
+ };
+}
+
+export async function deletePeriodAction(
+ _previous: PeriodActionState,
+ formData: FormData,
+): Promise {
+ const periodId = String(formData.get('periodId') ?? '');
+ const confirmation = String(formData.get('confirm') ?? '');
+ if (!periodId) return { error: 'Période introuvable.' };
+
+ try {
+ await mutate('payroll.period.delete', async (db, actor) => {
+ const period = await db.payPeriod.findUnique({
+ where: { id: periodId },
+ });
+ if (!period) throw new AuthorizationError('payroll.period.delete');
+
+ // Confirmation explicite : supprimer une période verrouillée efface les
+ // instantanés sur lesquels un export a pu être bâti.
+ if (confirmation !== period.label) {
+ throw new ValidationError(
+ `Pour confirmer, saisissez le libellé exact de la période : « ${period.label} ».`,
+ );
+ }
+
+ const snapshots = await db.payPeriodSnapshot.count({
+ where: { payPeriodId: period.id },
+ });
+
+ // La trace est écrite **avant** la suppression : après, l'identifiant ne
+ // désigne plus rien.
+ await recordAudit(db, {
+ actorMembershipId: actor.membershipId,
+ action: 'payroll.period.delete',
+ entityType: 'PayPeriod',
+ entityId: period.id,
+ before: {
+ label: period.label,
+ from: period.startDate.toISOString().slice(0, 10),
+ to: period.endDate.toISOString().slice(0, 10),
+ status: period.status,
+ snapshots,
+ },
+ reason: `Suppression de la période « ${period.label} »`,
+ });
+
+ await db.payPeriod.delete({ where: { id: period.id } });
+ });
+ } catch (error) {
+ return toState(error, "Vous n'avez pas le droit de supprimer une période.");
+ }
+
+ revalidatePath('/paie/periodes');
+ return { ok: true };
+}
+
+function toState(error: unknown, denied: string): PeriodActionState {
+ if (error instanceof ValidationError) return { error: error.message };
+ if (error instanceof AuthorizationError) return { error: denied };
+ throw error;
+}
diff --git a/src/server/payroll/period-queries.ts b/src/server/payroll/period-queries.ts
new file mode 100644
index 0000000..c6188f0
--- /dev/null
+++ b/src/server/payroll/period-queries.ts
@@ -0,0 +1,141 @@
+import { isExportStale } from '@/domain/hours/states';
+import { formatMonthParam, monthOf } from '@/domain/planning/month';
+import { query } from '@/server/context';
+
+/**
+ * Lectures des périodes de paie.
+ *
+ * La **péremption d'un export est déduite**, jamais lue : `generatedAt <
+ * unlockedAt`. La stocker obligerait à réécrire une trace qui doit rester
+ * append-only, et une trace réécrite ne prouve plus rien.
+ */
+
+export interface PeriodExport {
+ id: string;
+ checksum: string;
+ lineCount: number;
+ generatedAt: Date;
+ stale: boolean;
+}
+
+export interface PeriodCard {
+ id: string;
+ label: string;
+ startDate: string;
+ endDate: string;
+ status: 'OPEN' | 'LOCKED';
+ version: number;
+ lockedAt: Date | null;
+ unlockedAt: Date | null;
+ snapshotCount: number;
+ /** Salariés entrés, sortis et extras sur la période. */
+ entries: number;
+ exits: number;
+ extras: number;
+ exports: PeriodExport[];
+}
+
+export interface PeriodsView {
+ location: { id: string; name: string };
+ locations: Array<{ id: string; name: string }>;
+ periods: PeriodCard[];
+ /** Mois proposé par défaut à la création. */
+ suggestedMonth: string;
+ canLock: boolean;
+ canUnlock: boolean;
+ canDelete: boolean;
+}
+
+export async function getPeriods(locationId?: string): Promise {
+ const { can } = await import('@/domain/access/authorize');
+
+ return query(
+ 'payroll.access',
+ async (db, actor) => {
+ const locations = await db.location.findMany({
+ where: { archivedAt: null },
+ select: { id: true, name: true },
+ orderBy: { name: 'asc' },
+ });
+ const location =
+ locations.find((candidate) => candidate.id === locationId) ??
+ locations[0];
+ if (!location) return null;
+
+ const periods = await db.payPeriod.findMany({
+ where: { locationId: location.id },
+ orderBy: { startDate: 'desc' },
+ take: 24,
+ });
+
+ const exports = await db.payrollExport.findMany({
+ where: { payPeriodId: { in: periods.map((period) => period.id) } },
+ orderBy: { generatedAt: 'desc' },
+ });
+
+ const cards: PeriodCard[] = [];
+ for (const period of periods) {
+ const snapshotCount = await db.payPeriodSnapshot.count({
+ where: { payPeriodId: period.id },
+ });
+
+ // Entrées et sorties de la période : ce sont les mouvements qui
+ // expliquent un écart d'effectif au bulletin.
+ const entries = await db.userContract.count({
+ where: {
+ locationId: location.id,
+ startDate: { gte: period.startDate, lte: period.endDate },
+ },
+ });
+ const exits = await db.userContract.count({
+ where: {
+ locationId: location.id,
+ endDate: { gte: period.startDate, lte: period.endDate },
+ },
+ });
+ const extras = await db.userContract.count({
+ where: {
+ locationId: location.id,
+ contractType: { in: ['EXTRA', 'SAISONNIER', 'INTERIM'] },
+ startDate: { lte: period.endDate },
+ },
+ });
+
+ cards.push({
+ id: period.id,
+ label: period.label,
+ startDate: period.startDate.toISOString().slice(0, 10),
+ endDate: period.endDate.toISOString().slice(0, 10),
+ status: period.status,
+ version: period.version,
+ lockedAt: period.lockedAt,
+ unlockedAt: period.unlockedAt,
+ snapshotCount,
+ entries,
+ exits,
+ extras,
+ exports: exports
+ .filter((entry) => entry.payPeriodId === period.id)
+ .map((entry) => ({
+ id: entry.id,
+ checksum: entry.checksum,
+ lineCount: entry.lineCount,
+ generatedAt: entry.generatedAt,
+ stale: isExportStale(entry.generatedAt, period.unlockedAt),
+ })),
+ });
+ }
+
+ return {
+ location,
+ locations,
+ periods: cards,
+ suggestedMonth: formatMonthParam(monthOf(new Date())),
+ canLock: can(actor, 'payroll.period.lock'),
+ canUnlock: can(actor, 'payroll.period.unlock'),
+ canDelete: can(actor, 'payroll.period.delete'),
+ };
+ },
+ locationId ? { locationId } : undefined,
+ );
+}
diff --git a/src/server/payroll/periods.ts b/src/server/payroll/periods.ts
new file mode 100644
index 0000000..db0eda6
--- /dev/null
+++ b/src/server/payroll/periods.ts
@@ -0,0 +1,127 @@
+import { fallsInLockedPeriod } from '@/domain/hours/states';
+import type { Month } from '@/domain/planning/month';
+import { agreementFor } from '@/server/compliance/evaluate';
+import { buildPayrollPeriod } from '@/server/payroll/build';
+import type { ScopedClient } from '@/server/tenant';
+
+/**
+ * Périodes de paie — PLAN.md §4.6 et WP-07.
+ *
+ * Le verrouillage fige les instantanés et **interdit** toute mutation dont la
+ * date tombe dans la période. Le déverrouillage existe : c'est une décision qui
+ * exige une justification, parce qu'elle périme tout export déjà transmis au
+ * cabinet.
+ */
+
+export class PeriodLockedError extends Error {
+ constructor(label: string) {
+ super(
+ `La période de paie « ${label} » est verrouillée. Déverrouillez-la, ou passez la correction en régularisation sur la période suivante.`,
+ );
+ this.name = 'PeriodLockedError';
+ }
+}
+
+/**
+ * Refuse une mutation qui tomberait dans une période verrouillée.
+ *
+ * Appelé **avant** l'écriture, jamais après : une transaction annulée laisse
+ * quand même passer les effets de bord non transactionnels.
+ */
+export async function assertPeriodOpen(
+ db: ScopedClient,
+ locationId: string,
+ isoDates: string[],
+): Promise {
+ if (isoDates.length === 0) return;
+
+ const sorted = [...isoDates].sort();
+ const periods = await db.payPeriod.findMany({
+ where: {
+ locationId,
+ status: 'LOCKED',
+ startDate: { lte: new Date(`${sorted[sorted.length - 1]}T00:00:00Z`) },
+ endDate: { gte: new Date(`${sorted[0]}T00:00:00Z`) },
+ },
+ select: { label: true, startDate: true, endDate: true, status: true },
+ });
+ if (periods.length === 0) return;
+
+ const asStrings = periods.map((period) => ({
+ startDate: period.startDate.toISOString().slice(0, 10),
+ endDate: period.endDate.toISOString().slice(0, 10),
+ status: period.status,
+ }));
+
+ for (const isoDate of sorted) {
+ if (fallsInLockedPeriod(isoDate, asStrings)) {
+ const blocking = periods.find(
+ (period) =>
+ isoDate >= period.startDate.toISOString().slice(0, 10) &&
+ isoDate <= period.endDate.toISOString().slice(0, 10),
+ );
+ throw new PeriodLockedError(blocking?.label ?? 'période');
+ }
+ }
+}
+
+/**
+ * Écrit les instantanés de la période.
+ *
+ * Ils viennent de **la même fonction** que le rapport de paie et l'export :
+ * `buildPayrollPeriod`. C'est ce qui garantit le critère d'acceptation — grille,
+ * rapport d'heures et instantané donnent des chiffres identiques. Trois
+ * calculs séparés divergeraient, et l'écart ne se verrait qu'au bulletin.
+ */
+export async function computeSnapshots(
+ db: ScopedClient,
+ payPeriodId: string,
+ month: Month,
+ locationId: string,
+): Promise {
+ const period = await buildPayrollPeriod(db, month, locationId);
+ if (!period) return 0;
+
+ const agreement = await agreementFor(
+ db,
+ new Date(`${period.startDate}T00:00:00Z`),
+ );
+
+ // Un nouveau verrouillage **recalcule intégralement** : les instantanés ne
+ // sont pas immuables au sens strict, c'est le couple (instantané, export)
+ // qui porte la preuve.
+ await db.payPeriodSnapshot.deleteMany({ where: { payPeriodId } });
+
+ let written = 0;
+ for (const row of period.rows) {
+ const elements = new Map(
+ row.elements.map((element) => [element.key, element.value]),
+ );
+
+ await db.payPeriodSnapshot.create({
+ data: {
+ payPeriodId,
+ membershipId: row.membershipId,
+ plannedMinutes: elements.get('WORKED_HOURS') ?? 0,
+ actualMinutes: elements.get('WORKED_HOURS') ?? 0,
+ absenceMinutes: 0,
+ workedDays:
+ elements.get('WORKED_DAYS') ?? elements.get('FORFAIT_DAYS') ?? 0,
+ overtimeByBracket: [
+ { ratePercent: 25, minutes: elements.get('OVERTIME_25') ?? 0 },
+ { ratePercent: 50, minutes: elements.get('OVERTIME_50') ?? 0 },
+ ],
+ absenceBreakdown: [],
+ variables: row.elements.map((element) => ({
+ key: element.key,
+ value: element.value,
+ unit: element.unit,
+ })),
+ agreementId: agreement?.id ?? null,
+ } as never,
+ });
+ written += 1;
+ }
+
+ return written;
+}
diff --git a/src/server/planning/actions.ts b/src/server/planning/actions.ts
index ad184c4..dc4ebae 100644
--- a/src/server/planning/actions.ts
+++ b/src/server/planning/actions.ts
@@ -16,6 +16,7 @@ import {
import { recordAudit } from '@/server/audit';
import { evaluateAround, evaluateSchedule } from '@/server/compliance/evaluate';
import { mutate } from '@/server/context';
+import { assertPeriodOpen, PeriodLockedError } from '@/server/payroll/periods';
import type { ScopedClient } from '@/server/tenant';
/**
@@ -131,6 +132,10 @@ export async function createShiftAction(
// interdirait de planifier un inventaire 22 h–02 h.
if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000);
+ // Un mois transmis au cabinet ne se modifie pas par inadvertance : le
+ // contrôle passe **avant** l'écriture.
+ await assertPeriodOpen(db, location.id, [parsed.data.localDate]);
+
const worked = shiftMinutes(startAt, endAt, parsed.data.breakMinutes);
if (worked <= 0) {
throw new ValidationError(
@@ -242,6 +247,11 @@ export async function updateShiftAction(
assertMayEditPublished(actor);
}
+ await assertPeriodOpen(db, shift.schedule.locationId, [
+ shift.localDate.toISOString().slice(0, 10),
+ parsed.data.localDate,
+ ]);
+
const startAt = zonedInstant(
parsed.data.localDate,
parsed.data.start,
@@ -323,6 +333,10 @@ export async function deleteShiftAction(
assertMayEditPublished(actor);
}
+ await assertPeriodOpen(db, shift.schedule.locationId, [
+ shift.localDate.toISOString().slice(0, 10),
+ ]);
+
// La trace est écrite **avant** la suppression : après, l'identifiant ne
// désigne plus rien, et l'état supprimé serait perdu.
await recordAudit(db, {
@@ -749,6 +763,9 @@ function assertMayEditPublished(actor: Actor): void {
function toState(error: unknown, denied: string): PlanningActionState {
if (error instanceof ValidationError) return { error: error.message };
+ // Le verrou de période porte son propre message, qui explique la sortie :
+ // déverrouiller, ou régulariser sur la période suivante.
+ if (error instanceof PeriodLockedError) return { error: error.message };
if (error instanceof AuthorizationError) return { error: denied };
throw error;
}
diff --git a/tests/e2e/conges.spec.ts b/tests/e2e/conges.spec.ts
index dfd0d4c..abda7ac 100644
--- a/tests/e2e/conges.spec.ts
+++ b/tests/e2e/conges.spec.ts
@@ -18,8 +18,11 @@ import { frenchHolidays } from '../../src/domain/absences/holidays';
* acceptée bloque toute demande qui la recouvre : des dates fixes feraient
* échouer le deuxième passage pour une raison sans rapport avec ce qui est
* testé. Chaque exécution travaille donc sur sa propre fenêtre de dates.
+ *
+ * Le multiplicateur écarte deux exécutions voisines : sans lui, deux passages
+ * à une minute d'intervalle retomberaient sur des fenêtres qui se recouvrent.
*/
-const RUN_OFFSET = 150 + (Math.floor(Date.now() / 1000) % 700);
+const RUN_OFFSET = 200 + ((Math.floor(Date.now() / 1000) * 137) % 3000);
/** Une date libre, loin de tout ce que le seed pose. */
function isoDate(offsetDays: number): string {
@@ -30,18 +33,69 @@ function isoDate(offsetDays: number): string {
/** Prochain jour de la semaine demandé (0 = dimanche) dans la fenêtre du run. */
function nextWeekday(weekday: number): string {
- const cursor = new Date(Date.now() + (RUN_OFFSET + 40) * 86_400_000);
+ const cursor = new Date(Date.now() + (RUN_OFFSET + 17) * 86_400_000);
while (cursor.getUTCDay() !== weekday) {
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return cursor.toISOString().slice(0, 10);
}
+/**
+ * Libère les dates utilisées par un test.
+ *
+ * Une absence en attente ou acceptée bloque toute demande qui la recouvre : la
+ * laisser derrière soi ferait échouer le passage suivant pour une raison sans
+ * rapport avec ce qui est testé. Refuser suffit — seuls les statuts « en
+ * attente » et « acceptée » bloquent.
+ */
+async function release(page: Page, marker: string, month?: string) {
+ await page.goto(month ? `/conges?mois=${month}` : '/conges');
+
+ const pending = page
+ .locator('section')
+ .filter({ hasText: 'Demandes en attente' })
+ .locator('li')
+ .filter({ hasText: marker });
+
+ // Boucle : un passage précédent interrompu a pu en laisser plusieurs, et
+ // `marker` peut être un préfixe commun à toutes les demandes d'un test.
+ for (let guard = 0; guard < 20; guard += 1) {
+ const before = await pending.count();
+ if (before === 0) break;
+ await pending.first().getByRole('button', { name: 'Refuser' }).click();
+ await expect(pending).toHaveCount(before - 1);
+ }
+}
+
+/**
+ * Chaque test travaille sur **son** salarié.
+ *
+ * Le chevauchement se juge par salarié : deux tests qui partageraient la même
+ * personne se bloqueraient l'un l'autre dès que leurs fenêtres de dates se
+ * croisent, ce qui arrive d'autant plus que les fenêtres sont larges. Les deux
+ * salariés porteurs d'absences dans le seed sont écartés.
+ */
+const WHO = {
+ queue: 'Yanis Trabelsi',
+ overlap: 'Marius Kowalski',
+ countable: 'Awa Diallo',
+ holiday: 'Théo Berger',
+ inverted: 'Clara Fontaine',
+ roundTrip: 'Noé Perrin',
+} as const;
+
async function request(
page: Page,
- options: { from: string; to: string; type?: string; comment?: string },
+ options: {
+ from: string;
+ to: string;
+ who: string;
+ type?: string;
+ comment?: string;
+ },
) {
const form = page.locator('form').filter({ hasText: 'Demander' }).first();
+ await form.getByLabel('Salarié').selectOption({ label: options.who });
if (options.type) {
await form.getByLabel('Type').selectOption({ label: options.type });
}
@@ -73,7 +127,13 @@ test('une demande apparaît dans la file, puis se décide', async ({ page }) =>
const from = isoDate(0);
const to = isoDate(4);
const comment = `Test ${Date.now()}`;
- await request(page, { from, to, type: 'Congés payés', comment });
+ await request(page, {
+ from,
+ to,
+ who: WHO.queue,
+ type: 'Congés payés',
+ comment,
+ });
const pending = page
.locator('section')
@@ -87,15 +147,31 @@ test('une demande apparaît dans la file, puis se décide', async ({ page }) =>
await expect(pending.locator('li').filter({ hasText: comment })).toHaveCount(
0,
);
+
+ // Puis on libère les dates : annuler contre-passe la prise sans rien
+ // effacer, ce qui est exactement le comportement voulu en production.
+ await page.goto(`/conges?mois=${from.slice(0, 7)}`);
+ const accepted = page
+ .locator('section')
+ .filter({ hasText: 'Absences du mois' })
+ .locator('li')
+ .filter({ hasText: WHO.queue })
+ .first();
+ if (await accepted.isVisible()) {
+ await accepted.getByRole('button', { name: 'Annuler' }).click();
+ }
});
test('deux absences qui se recouvrent sont refusées', async ({ page }) => {
await page.goto('/conges');
+ await release(page, 'Chevauchement ');
+
const comment = `Chevauchement ${Date.now()}`;
await request(page, {
- from: isoDate(60),
- to: isoDate(65),
+ from: isoDate(8),
+ to: isoDate(12),
+ who: WHO.overlap,
type: 'Congés payés',
comment,
});
@@ -111,11 +187,14 @@ test('deux absences qui se recouvrent sont refusées', async ({ page }) => {
// La seconde chevauche la première d'un seul jour : bornes inclusives des
// deux côtés, puisque la date de fin est un jour d'absence.
const form = await request(page, {
- from: isoDate(65),
- to: isoDate(68),
+ from: isoDate(12),
+ to: isoDate(15),
+ who: WHO.overlap,
type: 'Congés payés',
});
await expect(form.getByText(/couvre déjà/)).toBeVisible();
+
+ await release(page, comment);
});
test('une période sans jour décomptable est refusée', async ({ page }) => {
@@ -126,6 +205,7 @@ test('une période sans jour décomptable est refusée', async ({ page }) => {
const form = await request(page, {
from: sunday,
to: sunday,
+ who: WHO.countable,
type: 'Congés payés',
});
await expect(form.getByText(/aucun jour décomptable/)).toBeVisible();
@@ -137,6 +217,10 @@ test('un jour férié dans la période n’est pas décompté', async ({ page })
// Un jour férié pris au hasard parmi ceux de l'année : la période qui
// l'englobe ne doit pas le décompter, et l'utilisateur n'a pas eu à scinder
// sa demande. Le tirage évite de retomber sur la même semaine à chaque run.
+ // Les dates fériées sont fixes : un passage précédent a pu y laisser une
+ // demande. On libère avant de reposer la sienne.
+ await release(page, 'Ferie ');
+
const holidays = frenchHolidays(new Date().getUTCFullYear() + 2);
const holiday = holidays[
Math.floor(Date.now() / 1000) % holidays.length
@@ -153,6 +237,7 @@ test('un jour férié dans la période n’est pas décompté', async ({ page })
await request(page, {
from: day(-1),
to: day(1),
+ who: WHO.holiday,
type: 'Congés payés',
comment,
});
@@ -161,6 +246,8 @@ test('un jour férié dans la période n’est pas décompté', async ({ page })
await expect(row).toBeVisible();
// Trois jours calendaires dont un férié : jamais trois décomptés.
await expect(row.getByText(/3 jours décomptés/)).toHaveCount(0);
+
+ await release(page, comment);
});
test('une date de fin antérieure au début rappelle la règle', async ({
@@ -169,8 +256,9 @@ test('une date de fin antérieure au début rappelle la règle', async ({
await page.goto('/conges');
const form = await request(page, {
- from: isoDate(100),
- to: isoDate(98),
+ from: isoDate(22),
+ to: isoDate(20),
+ who: WHO.inverted,
type: 'Congés payés',
});
await expect(form.getByRole('alert')).toContainText(
@@ -184,10 +272,16 @@ test('le solde revient à son niveau après un aller-retour', async ({ page }) =
const counters = page.locator('section').filter({ hasText: 'Compteurs' });
await expect(counters).toBeVisible();
- const from = isoDate(120);
- const to = isoDate(122);
+ const from = isoDate(26);
+ const to = isoDate(28);
const comment = `Aller-retour ${Date.now()}`;
- await request(page, { from, to, type: 'Congés payés', comment });
+ await request(page, {
+ from,
+ to,
+ who: WHO.roundTrip,
+ type: 'Congés payés',
+ comment,
+ });
const pending = page
.locator('section')
@@ -201,8 +295,10 @@ test('le solde revient à son niveau après un aller-retour', async ({ page }) =
// Annuler contre-passe la prise : le solde revient au même chiffre, sans
// qu'aucune écriture ait été effacée.
const month = page.locator('section').filter({ hasText: 'Absences du mois' });
- const accepted = month.locator('li').filter({ hasText: 'Acceptée' }).first();
+ const accepted = month.locator('li').filter({ hasText: WHO.roundTrip }).first();
if (await accepted.isVisible()) {
await accepted.getByRole('button', { name: 'Annuler' }).click();
}
+
+ await release(page, comment, from.slice(0, 7));
});
diff --git a/tests/e2e/heures.spec.ts b/tests/e2e/heures.spec.ts
new file mode 100644
index 0000000..e2beb40
--- /dev/null
+++ b/tests/e2e/heures.spec.ts
@@ -0,0 +1,116 @@
+import { expect, test } from '@playwright/test';
+
+import { formatMonthParam, monthOf, previousMonth } from '../../src/domain/planning/month';
+
+/**
+ * Heures et périodes de paie.
+ *
+ * Ce que ces tests protègent : qu'un mois transmis au cabinet ne se modifie
+ * plus par inadvertance, et que rouvrir ce mois soit une décision justifiée
+ * plutôt qu'un bouton.
+ */
+
+const MONTH = formatMonthParam(previousMonth(monthOf(new Date())));
+
+/**
+ * Mois propre à cette exécution.
+ *
+ * Une période est unique par (établissement, bornes) et la base n'est pas
+ * remise à zéro entre deux passages : un mois calculé sur une plage étroite
+ * finit par retomber sur une période déjà créée, et le test échoue pour une
+ * raison sans rapport avec ce qu'il vérifie.
+ */
+const SALT = Math.floor(Date.now() / 1000);
+
+function uniqueMonth(bucket: number): string {
+ const year = 2100 + ((SALT + bucket * 997) % 400);
+ const month = ((Math.floor(SALT / 400) + bucket) % 12) + 1;
+ return `${year}-${String(month).padStart(2, '0')}`;
+}
+
+test('le rapport d’heures affiche les trois grandeurs', async ({ page }) => {
+ await page.goto(`/rapports/heures?mois=${MONTH}`);
+
+ await expect(
+ page.getByRole('heading', { name: /^Heures travaillées · / }),
+ ).toBeVisible();
+
+ // Sans saisie, le prévu fait foi — et l'écran le dit plutôt que d'afficher
+ // un réalisé vide qu'on prendrait pour zéro heure.
+ await expect(page.getByText(/le prévu fait foi/)).toBeVisible();
+ await expect(page.getByText('prévu retenu').first()).toBeVisible();
+});
+
+test('l’écran dit que la validation ne conditionne pas le paiement', async ({
+ page,
+}) => {
+ await page.goto(`/rapports/heures?mois=${MONTH}`);
+ // Bloquer le paiement d'heures accomplies faute de validation est ce que la
+ // matrice de conformité interdit : l'écran l'énonce pour qu'aucun manager ne
+ // croie l'inverse.
+ await expect(
+ page.getByText(/elle ne les autorise pas à être payées/),
+ ).toBeVisible();
+});
+
+test('verrouiller une période ferme le mois aux modifications', async ({
+ page,
+}) => {
+ await page.goto('/paie/periodes');
+ await expect(
+ page.getByRole('heading', { name: 'Périodes de paie' }),
+ ).toBeVisible();
+
+ const month = uniqueMonth(0);
+ const label = `Test ${Date.now()}`;
+
+ const create = page.locator('form').filter({ hasText: 'Créer la période' });
+ await create.getByLabel('Mois').fill(month);
+ await create.getByLabel('Libellé').fill(label);
+ await create.getByRole('button', { name: 'Créer la période' }).click();
+
+ const card = page.locator('section').filter({ hasText: label }).first();
+ await expect(card).toBeVisible();
+ await expect(card.getByText('Ouverte')).toBeVisible();
+
+ await card.getByRole('button', { name: 'Verrouiller la période' }).click();
+ await expect(card.getByText('Verrouillée')).toBeVisible();
+
+ // Déverrouiller sans motif est refusé : rouvrir périme les fichiers déjà
+ // transmis, et six mois plus tard personne ne saurait pourquoi.
+ const unlock = card.locator('form').filter({ hasText: 'Déverrouiller' });
+ await expect(unlock.getByPlaceholder('Motif du déverrouillage')).toBeVisible();
+
+ await unlock
+ .getByPlaceholder('Motif du déverrouillage')
+ .fill('Correction demandée par le cabinet');
+ await unlock.getByRole('button', { name: 'Déverrouiller' }).click();
+ await expect(card.getByText('Ouverte')).toBeVisible();
+});
+
+test('supprimer une période exige de retaper son libellé', async ({ page }) => {
+ await page.goto('/paie/periodes');
+
+ const month = uniqueMonth(1);
+ const label = `Suppr ${Date.now()}`;
+
+ const create = page.locator('form').filter({ hasText: 'Créer la période' });
+ await create.getByLabel('Mois').fill(month);
+ await create.getByLabel('Libellé').fill(label);
+ await create.getByRole('button', { name: 'Créer la période' }).click();
+
+ const card = page.locator('section').filter({ hasText: label }).first();
+ await expect(card).toBeVisible();
+
+ // Un simple « êtes-vous sûr » se clique sans lire : la confirmation demande
+ // le libellé exact.
+ const remove = card.locator('form').filter({ hasText: 'Supprimer' });
+ await remove.getByRole('button', { name: 'Supprimer' }).click();
+ await expect(remove.getByText(/saisissez le libellé exact/i)).toBeVisible();
+
+ await remove.getByPlaceholder(`Saisir « ${label} »`).fill(label);
+ await remove.getByRole('button', { name: 'Supprimer' }).click();
+ await expect(page.locator('section').filter({ hasText: label })).toHaveCount(
+ 0,
+ );
+});
diff --git a/tests/integration/pay-period.test.ts b/tests/integration/pay-period.test.ts
new file mode 100644
index 0000000..e1d29b7
--- /dev/null
+++ b/tests/integration/pay-period.test.ts
@@ -0,0 +1,239 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { IDCC_1517_PARAMETERS } from '@/domain/compliance/idcc1517';
+import { zonedInstant } from '@/domain/planning/week';
+
+/**
+ * Périodes de paie — WP-07.
+ *
+ * Deux critères d'acceptation ne se démontrent qu'en base :
+ *
+ * - **Le verrouillage refuse toute mutation** dont la date tombe dans la
+ * période. Un contrôle applicatif qui laisserait passer une écriture rendrait
+ * faux un mois déjà transmis au cabinet.
+ * - **Grille, rapport d'heures et instantané donnent des chiffres identiques.**
+ * Trois calculs séparés divergent, et l'écart ne se voit qu'au bulletin.
+ */
+
+const enabled = (process.env.DATABASE_URL ?? '').length > 0;
+const describeIfDb = enabled ? describe : describe.skip;
+
+const TZ = 'Europe/Paris';
+const suffix = `period-${Date.now()}`;
+const accountId = `${suffix}-account`;
+const locationId = `${suffix}-loc`;
+const teamId = `${suffix}-team`;
+const membershipId = `${suffix}-member`;
+
+let unscoped: typeof import('@/server/tenant').unscoped;
+let withTenant: typeof import('@/server/tenant').withTenant;
+let assertPeriodOpen: typeof import('@/server/payroll/periods').assertPeriodOpen;
+let PeriodLockedError: typeof import('@/server/payroll/periods').PeriodLockedError;
+let computeSnapshots: typeof import('@/server/payroll/periods').computeSnapshots;
+let buildPayrollPeriod: typeof import('@/server/payroll/build').buildPayrollPeriod;
+
+let periodId = '';
+
+/** Août de l'an prochain : loin de tout ce que le seed pose. */
+const YEAR = new Date().getUTCFullYear() + 3;
+const MONTH = { year: YEAR, month: 8 };
+
+describeIfDb('période de paie', () => {
+ beforeAll(async () => {
+ process.env.ENCRYPTION_KEY ??= Buffer.alloc(32, 3).toString('base64');
+ ({ unscoped, withTenant } = await import('@/server/tenant'));
+ ({ assertPeriodOpen, PeriodLockedError, computeSnapshots } = await import(
+ '@/server/payroll/periods'
+ ));
+ ({ buildPayrollPeriod } = await import('@/server/payroll/build'));
+
+ 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: 'P0001',
+ silaeMatricule: '00001',
+ status: 'ACTIVE',
+ },
+ });
+ await db.employeeProfile.create({
+ data: { membershipId, accountId, firstName: 'Test', lastName: 'Période' },
+ });
+ 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 } });
+
+ await db.collectiveAgreement.create({
+ data: {
+ accountId,
+ idcc: '1517',
+ name: 'Test',
+ parameters: IDCC_1517_PARAMETERS,
+ version: 1,
+ effectiveFrom: new Date('2020-01-01'),
+ },
+ });
+
+ // Deux créneaux : l'un avec réalisé saisi, l'autre sans — c'est le cas
+ // qui vérifie que le prévu fait foi à défaut.
+ const schedule = await db.weeklySchedule.create({
+ data: { accountId, teamId, locationId, isoYear: YEAR, isoWeek: 32 },
+ });
+ await db.shift.create({
+ data: {
+ accountId,
+ weeklyScheduleId: schedule.id,
+ membershipId,
+ localDate: new Date(`${YEAR}-08-03T00:00:00Z`),
+ startAt: zonedInstant(`${YEAR}-08-03`, '09:00', TZ),
+ endAt: zonedInstant(`${YEAR}-08-03`, '17:00', TZ),
+ breakMinutes: 0,
+ },
+ });
+ await db.shift.create({
+ data: {
+ accountId,
+ weeklyScheduleId: schedule.id,
+ membershipId,
+ localDate: new Date(`${YEAR}-08-04T00:00:00Z`),
+ startAt: zonedInstant(`${YEAR}-08-04`, '09:00', TZ),
+ endAt: zonedInstant(`${YEAR}-08-04`, '17:00', TZ),
+ breakMinutes: 0,
+ actualStartAt: zonedInstant(`${YEAR}-08-04`, '09:00', TZ),
+ actualEndAt: zonedInstant(`${YEAR}-08-04`, '19:00', TZ),
+ },
+ });
+
+ const period = await db.payPeriod.create({
+ data: {
+ accountId,
+ locationId,
+ label: `Août ${YEAR}`,
+ startDate: new Date(`${YEAR}-08-01T00:00:00Z`),
+ endDate: new Date(`${YEAR}-08-31T00:00:00Z`),
+ },
+ });
+ periodId = period.id;
+ });
+
+ afterAll(async () => {
+ if (!enabled) return;
+ await unscoped().account.delete({ where: { id: accountId } });
+ });
+
+ it('laisse passer une mutation sur une période ouverte', async () => {
+ await withTenant(accountId, async (db) => {
+ await expect(
+ assertPeriodOpen(db, locationId, [`${YEAR}-08-10`]),
+ ).resolves.toBeUndefined();
+ });
+ });
+
+ it('retient le réalisé quand il est saisi, le prévu sinon', async () => {
+ await withTenant(accountId, async (db) => {
+ const payroll = await buildPayrollPeriod(db, MONTH, locationId);
+ const hours = payroll?.rows[0]?.elements.find(
+ (element) => element.key === 'WORKED_HOURS',
+ );
+
+ // 8 h prévues le 3, 10 h réalisées le 4 : 18 h au total, pas 16.
+ expect(hours?.value).toBe(18 * 60);
+ });
+ });
+
+ it('fige des instantanés identiques au rapport', async () => {
+ // Le critère croisé : l'instantané vient de la **même fonction** que le
+ // rapport et l'export. Trois calculs séparés divergeraient.
+ await withTenant(accountId, async (db) => {
+ const written = await computeSnapshots(db, periodId, MONTH, locationId);
+ expect(written).toBe(1);
+
+ const snapshot = await db.payPeriodSnapshot.findFirst({
+ where: { payPeriodId: periodId },
+ });
+ const payroll = await buildPayrollPeriod(db, MONTH, locationId);
+ const hours = payroll?.rows[0]?.elements.find(
+ (element) => element.key === 'WORKED_HOURS',
+ );
+
+ expect(snapshot?.plannedMinutes).toBe(hours?.value);
+ expect(snapshot?.workedDays).toBe(2);
+ });
+ });
+
+ it('refuse toute mutation une fois la période verrouillée', async () => {
+ await unscoped().payPeriod.update({
+ where: { id: periodId },
+ data: { status: 'LOCKED', lockedAt: new Date() },
+ });
+
+ await withTenant(accountId, async (db) => {
+ await expect(
+ assertPeriodOpen(db, locationId, [`${YEAR}-08-10`]),
+ ).rejects.toBeInstanceOf(PeriodLockedError);
+
+ // Bornes comprises : le 1er et le 31 sont dans la période.
+ await expect(
+ assertPeriodOpen(db, locationId, [`${YEAR}-08-01`]),
+ ).rejects.toBeInstanceOf(PeriodLockedError);
+ await expect(
+ assertPeriodOpen(db, locationId, [`${YEAR}-08-31`]),
+ ).rejects.toBeInstanceOf(PeriodLockedError);
+ });
+ });
+
+ it('laisse passer une date hors de la période verrouillée', async () => {
+ await withTenant(accountId, async (db) => {
+ await expect(
+ assertPeriodOpen(db, locationId, [`${YEAR}-09-01`]),
+ ).resolves.toBeUndefined();
+ });
+ });
+
+ it('recalcule intégralement les instantanés au nouveau verrouillage', async () => {
+ await withTenant(accountId, async (db) => {
+ const before = await db.payPeriodSnapshot.findFirst({
+ where: { payPeriodId: periodId },
+ });
+ const again = await computeSnapshots(db, periodId, MONTH, locationId);
+ const after = await db.payPeriodSnapshot.findFirst({
+ where: { payPeriodId: periodId },
+ });
+
+ expect(again).toBe(1);
+ // Nouvel enregistrement, même contenu : ils ne sont pas immuables au sens
+ // strict, c'est le couple (instantané, export) qui porte la preuve.
+ expect(after?.id).not.toBe(before?.id);
+ expect(after?.plannedMinutes).toBe(before?.plannedMinutes);
+ });
+ });
+});
diff --git a/tests/unit/hours.test.ts b/tests/unit/hours.test.ts
new file mode 100644
index 0000000..ab3cd77
--- /dev/null
+++ b/tests/unit/hours.test.ts
@@ -0,0 +1,200 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ actualMinutesOf,
+ describeCorrection,
+ fallsInLockedPeriod,
+ hoursView,
+ isExportStale,
+ plannedMinutesOf,
+ sumHours,
+ type ShiftHours,
+} from '@/domain/hours/states';
+import { zonedInstant } from '@/domain/planning/week';
+
+const TZ = 'Europe/Paris';
+
+function shift(over: Partial = {}): ShiftHours {
+ return {
+ startAt: zonedInstant('2026-08-10', '09:00', TZ),
+ endAt: zonedInstant('2026-08-10', '17:00', TZ),
+ breakMinutes: 60,
+ actualStartAt: null,
+ actualEndAt: null,
+ actualBreakMinutes: null,
+ isValidated: false,
+ ...over,
+ };
+}
+
+describe('sans heures réelles, le prévu fait foi', () => {
+ it('reprend le prévu quand rien n’est saisi', () => {
+ // Attendre une saisie qui ne viendra pas ne produirait aucune paie.
+ const view = hoursView(shift());
+ expect(view.plannedMinutes).toBe(7 * 60);
+ expect(view.actualMinutes).toBe(7 * 60);
+ expect(view.deltaMinutes).toBe(0);
+ expect(view.hasActual).toBe(false);
+ });
+
+ it('ignore une saisie incomplète', () => {
+ // Un début sans fin produirait une durée fantaisiste : le prévu reste la
+ // meilleure information disponible.
+ const partial = shift({
+ actualStartAt: zonedInstant('2026-08-10', '08:30', TZ),
+ });
+ expect(actualMinutesOf(partial)).toBeNull();
+ expect(hoursView(partial).actualMinutes).toBe(7 * 60);
+ });
+
+ it('prend le réalisé dès qu’il est complet', () => {
+ const done = shift({
+ actualStartAt: zonedInstant('2026-08-10', '08:30', TZ),
+ actualEndAt: zonedInstant('2026-08-10', '18:00', TZ),
+ });
+ const view = hoursView(done);
+ expect(view.actualMinutes).toBe(8 * 60 + 30);
+ expect(view.deltaMinutes).toBe(90);
+ expect(view.hasActual).toBe(true);
+ });
+
+ it('utilise la pause réelle quand elle est saisie', () => {
+ const done = shift({
+ actualStartAt: zonedInstant('2026-08-10', '09:00', TZ),
+ actualEndAt: zonedInstant('2026-08-10', '17:00', TZ),
+ actualBreakMinutes: 30,
+ });
+ expect(hoursView(done).actualMinutes).toBe(7 * 60 + 30);
+ });
+});
+
+describe('le paiement ne dépend jamais de la validation', () => {
+ it('paie des heures non validées', () => {
+ // Bloquer le paiement d'heures accomplies faute de validation est
+ // précisément ce que la matrice interdit.
+ const done = shift({
+ actualStartAt: zonedInstant('2026-08-10', '08:00', TZ),
+ actualEndAt: zonedInstant('2026-08-10', '18:00', TZ),
+ isValidated: false,
+ });
+ const view = hoursView(done);
+ expect(view.isValidated).toBe(false);
+ expect(view.payableMinutes).toBe(view.actualMinutes);
+ expect(view.payableMinutes).toBe(9 * 60);
+ });
+
+ it('paie la même chose une fois validé', () => {
+ const done = shift({
+ actualStartAt: zonedInstant('2026-08-10', '08:00', TZ),
+ actualEndAt: zonedInstant('2026-08-10', '18:00', TZ),
+ isValidated: true,
+ });
+ expect(hoursView(done).payableMinutes).toBe(9 * 60);
+ });
+});
+
+describe('agrégat', () => {
+ it('additionne prévu, réalisé et écart', () => {
+ const total = sumHours([
+ shift(),
+ shift({
+ actualStartAt: zonedInstant('2026-08-11', '09:00', TZ),
+ actualEndAt: zonedInstant('2026-08-11', '18:00', TZ),
+ startAt: zonedInstant('2026-08-11', '09:00', TZ),
+ endAt: zonedInstant('2026-08-11', '17:00', TZ),
+ }),
+ ]);
+
+ expect(total.plannedMinutes).toBe(14 * 60);
+ expect(total.actualMinutes).toBe(15 * 60);
+ expect(total.deltaMinutes).toBe(60);
+ expect(total.hasActual).toBe(true);
+ });
+
+ it('n’est validé que si tout l’est', () => {
+ expect(sumHours([shift({ isValidated: true }), shift()]).isValidated).toBe(
+ false,
+ );
+ expect(
+ sumHours([shift({ isValidated: true }), shift({ isValidated: true })])
+ .isValidated,
+ ).toBe(true);
+ });
+
+ it('traite l’ensemble vide comme validé', () => {
+ expect(sumHours([]).isValidated).toBe(true);
+ expect(sumHours([]).payableMinutes).toBe(0);
+ });
+});
+
+describe('durée d’un créneau', () => {
+ it('déduit la pause du prévu', () => {
+ expect(plannedMinutesOf(shift({ breakMinutes: 45 }))).toBe(7 * 60 + 15);
+ });
+});
+
+describe('corrections', () => {
+ it('décrit l’écart et son motif', () => {
+ // Sans motif, une correction d'heures est indistinguable d'une erreur de
+ // saisie.
+ expect(
+ describeCorrection({
+ beforeMinutes: 420,
+ afterMinutes: 510,
+ reason: 'Inventaire prolongé',
+ actorMembershipId: 'm1',
+ at: new Date(),
+ }),
+ ).toBe('+1 h 30 — Inventaire prolongé');
+ });
+
+ it('marque une correction à la baisse', () => {
+ expect(
+ describeCorrection({
+ beforeMinutes: 510,
+ afterMinutes: 420,
+ reason: 'Départ anticipé',
+ actorMembershipId: 'm1',
+ at: new Date(),
+ }),
+ ).toBe('−1 h 30 — Départ anticipé');
+ });
+});
+
+describe('garde-fou de période verrouillée', () => {
+ const periods = [
+ { startDate: '2026-07-01', endDate: '2026-07-31', status: 'LOCKED' },
+ { startDate: '2026-08-01', endDate: '2026-08-31', status: 'OPEN' },
+ ];
+
+ it('bloque une date dans la période verrouillée, bornes comprises', () => {
+ expect(fallsInLockedPeriod('2026-07-01', periods)).toBe(true);
+ expect(fallsInLockedPeriod('2026-07-31', periods)).toBe(true);
+ expect(fallsInLockedPeriod('2026-07-15', periods)).toBe(true);
+ });
+
+ it('laisse passer une date hors période verrouillée', () => {
+ expect(fallsInLockedPeriod('2026-06-30', periods)).toBe(false);
+ expect(fallsInLockedPeriod('2026-08-01', periods)).toBe(false);
+ });
+});
+
+describe('péremption des exports', () => {
+ it('périme un export antérieur au déverrouillage', () => {
+ // Un fichier transmis à Silae avant un déverrouillage ne correspond plus
+ // aux données ; sans ce signalement, rien ne l'indiquerait.
+ expect(
+ isExportStale(new Date('2026-08-01T10:00:00Z'), new Date('2026-08-05T09:00:00Z')),
+ ).toBe(true);
+ });
+
+ it('laisse valide un export postérieur', () => {
+ expect(
+ isExportStale(new Date('2026-08-06T10:00:00Z'), new Date('2026-08-05T09:00:00Z')),
+ ).toBe(false);
+ });
+
+ it('ne périme rien sans déverrouillage', () => {
+ expect(isExportStale(new Date('2026-08-01T10:00:00Z'), null)).toBe(false);
+ });
+});