WP-07 : heures prévu/réalisé/payé et périodes de paie verrouillables

Les trois grandeurs
Sans pointeuse, le réalisé est saisi par le manager. La matrice impose de
distinguer trois grandeurs et non deux, avec deux règles qui ne se négocient
pas :
- **Sans heures réelles, le prévu fait foi.** Attendre une saisie qui ne
  viendra pas ne produirait aucune paie. Une saisie partielle — un début sans
  fin — ne suffit pas : elle donnerait une durée fantaisiste.
- **Le paiement ne dépend jamais de la validation.** Une ligne non validée part
  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 ; l'écran l'énonce
  pour qu'aucun manager ne croie l'inverse.

Toute correction d'heures déjà saisies exige un motif — sans lui, une
correction est indistinguable d'une erreur de saisie. Une première saisie n'en
demande pas : il n'y a rien à corriger, et exiger un motif à chaque ligne le
ferait remplir machinalement.

Le verrou de période
- Verrouiller fige les instantanés **et** ferme le mois : plus aucun créneau,
  aucune absence, aucune heure réelle ne peut être écrit sur ces dates. Le
  contrôle passe avant l'écriture, dans planning, absences et heures.
- Déverrouiller exige une justification. Rouvrir périme les fichiers déjà
  transmis au cabinet, et six mois plus tard personne ne saurait pourquoi.
- La **péremption d'un export est déduite** de `generatedAt < unlockedAt`,
  jamais stockée : la stocker obligerait à réécrire une trace qui doit rester
  append-only, et une trace réécrite ne prouve plus rien.
- Supprimer une période demande de **retaper son libellé** : un « êtes-vous
  sûr » se clique sans lire, et la suppression efface des instantanés sur
  lesquels un export a pu être bâti.

Un seul calcul pour trois écrans
Les instantanés viennent de `buildPayrollPeriod`, la même fonction que le
rapport de paie et l'export. C'est ce qui satisfait le critère croisé : trois
calculs séparés divergeraient, et l'écart ne se verrait qu'au bulletin. Un test
d'intégration compare instantané et rapport sur le même jeu de données.

Tests enfin idempotents
La suite e2e échouait au second passage : les absences acceptées d'une
exécution bloquaient les demandes de la suivante. Trois corrections, dans
l'ordre d'importance — chaque test travaille sur **son** salarié (le
chevauchement se juge par personne), chaque test libère ce qu'il a créé, et les
fenêtres de dates sont écartées d'une exécution à l'autre. Trois passages
consécutifs passent désormais.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-08 12:25:02 +00:00
1 parent 54a9f3ee2c
commit 2e9a4c5efe
20 files changed
+2837 -25

No files matched your search

@@ -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 $$;
+82
View File
@@ -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])
}
+177
View File
@@ -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 (
<PageBody>
<PageHeader
title="Périodes de paie"
subtitle="Aucun établissement n'est encore créé."
/>
</PageBody>
);
}
return (
<PageBody>
<PageHeader
title="Périodes de paie"
subtitle={`${view.location.name} · ${view.periods.length} période${view.periods.length > 1 ? 's' : ''}`}
actions={
<Link
href="/paie"
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
← Rapport de paie
</Link>
}
/>
<div className="flex flex-wrap items-center gap-2">
{view.locations.map((location) => (
<Link
key={location.id}
href={`/paie/periodes?etablissement=${location.id}`}
aria-current={location.id === view.location.id ? 'page' : undefined}
className={
location.id === view.location.id
? 'rounded-2 border border-accent bg-accent-soft px-2.5 py-1 text-xs font-medium text-accent-soft-ink'
: 'rounded-2 border border-line-2 px-2.5 py-1 text-xs text-ink-2 hover:bg-surface-2'
}
>
{location.name}
</Link>
))}
</div>
<CreatePeriodForm
locationId={view.location.id}
suggestedMonth={view.suggestedMonth}
/>
{view.periods.length === 0 ? (
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
Aucune période. Verrouiller une période fige les heures transmises au
cabinet et ferme le mois aux modifications.
</p>
) : null}
{view.periods.map((period) => {
const locked = period.status === 'LOCKED';
const stale = period.exports.filter((entry) => entry.stale).length;
return (
<section
key={period.id}
className="rounded-3 border border-line-1 bg-surface"
>
<header className="flex flex-wrap items-center gap-3 border-b border-line-1 px-4 py-3">
<h2 className="text-base font-semibold text-ink-1">
{period.label}
</h2>
<Badge tone={locked ? 'ok' : 'warn'}>
{locked ? 'Verrouillée' : 'Ouverte'}
</Badge>
<span className="tnum text-xs text-ink-3">
{frenchDate(period.startDate)} → {frenchDate(period.endDate)}
</span>
{locked ? (
<span className="text-xs text-ink-3">
{period.snapshotCount} instantané
{period.snapshotCount > 1 ? 's' : ''}
</span>
) : null}
</header>
<div className="flex flex-wrap gap-6 border-b border-line-1 px-4 py-2 text-xs text-ink-2">
<span>
Entrées <span className="tnum font-semibold">{period.entries}</span>
</span>
<span>
Sorties <span className="tnum font-semibold">{period.exits}</span>
</span>
<span>
Extras <span className="tnum font-semibold">{period.extras}</span>
</span>
</div>
{stale > 0 ? (
<p className="border-b border-line-1 bg-warn-soft px-4 py-2 text-xs text-warn-soft-ink">
{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.
</p>
) : null}
{period.exports.length > 0 ? (
<ul className="divide-y divide-line-1 border-b border-line-1">
{period.exports.map((entry) => (
<li
key={entry.id}
className="flex flex-wrap items-center gap-3 px-4 py-1.5 text-xs"
>
<Badge tone={entry.stale ? 'danger' : 'neutral'}>
{entry.stale ? 'Périmé' : 'À jour'}
</Badge>
<span className="tnum text-ink-2">
{entry.lineCount} lignes
</span>
<span className="tnum text-micro text-ink-3">
{entry.checksum.slice(0, 16)}…
</span>
<span className="ml-auto text-micro text-ink-3">
{entry.generatedAt.toISOString().slice(0, 16).replace('T', ' ')}
</span>
</li>
))}
</ul>
) : null}
<div className="flex flex-wrap items-center gap-4 px-4 py-3">
{!locked && view.canLock ? (
<LockButton periodId={period.id} version={period.version} />
) : null}
{locked && view.canUnlock ? (
<UnlockForm periodId={period.id} version={period.version} />
) : null}
{view.canDelete ? (
<DeletePeriodForm periodId={period.id} label={period.label} />
) : null}
</div>
</section>
);
})}
<p className="text-micro text-ink-3">
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.
</p>
</PageBody>
);
}
function frenchDate(isoDate: string): string {
return isoDate.split('-').reverse().join('/');
}
+221
View File
@@ -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 (
<PageBody>
<PageHeader
title="Heures travaillées"
subtitle="Aucun établissement n'est encore créé."
/>
</PageBody>
);
}
const href = (mois: string, etablissement = report.location.id) =>
`/rapports/heures?mois=${mois}&etablissement=${etablissement}`;
return (
<PageBody>
<PageHeader
title={`Heures travaillées · ${report.label}`}
subtitle={`${report.location.name} · prévu ${formatMinutes(report.totals.plannedMinutes)} · réalisé ${formatMinutes(report.totals.actualMinutes)} · écart ${formatDelta(report.totals.deltaMinutes)}`}
actions={
<>
<Link
href={href(report.previousParam)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
← Mois précédent
</Link>
<Link
href={href(report.nextParam)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
Mois suivant →
</Link>
<Link
href="/paie/periodes"
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
Périodes
</Link>
</>
}
/>
<div className="flex flex-wrap items-center gap-2">
{report.locations.map((location) => (
<Link
key={location.id}
href={href(report.monthParam, location.id)}
aria-current={
location.id === report.location.id ? 'page' : undefined
}
className={
location.id === report.location.id
? 'rounded-2 border border-accent bg-accent-soft px-2.5 py-1 text-xs font-medium text-accent-soft-ink'
: 'rounded-2 border border-line-2 px-2.5 py-1 text-xs text-ink-2 hover:bg-surface-2'
}
>
{location.name}
</Link>
))}
{report.lockedLabels.map((label) => (
<Badge key={label} tone="ok">
{label} verrouillée
</Badge>
))}
</div>
<section className="rounded-3 border border-info bg-info-soft p-4 text-sm text-info-soft-ink">
<p>
<strong>Sans heures réelles saisies, le prévu fait foi.</strong>{' '}
Attendre une saisie qui ne viendra pas ne produirait aucune paie.
</p>
<p className="mt-2">
La validation <strong>qualifie</strong> des heures, elle ne les
autorise pas à être payées : des heures accomplies partent en paie
qu’elles soient validées ou non.
</p>
</section>
{report.rows.length === 0 ? (
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
Aucun créneau planifié sur cette période.
</p>
) : null}
{report.rows.map((row) => (
<section
key={row.membershipId}
className="rounded-3 border border-line-1 bg-surface"
>
<header className="flex flex-wrap items-center gap-3 border-b border-line-1 px-4 py-2">
<h2 className="text-sm font-semibold text-ink-1">{row.name}</h2>
<span className="text-xs text-ink-3">
prévu <span className="tnum">{formatMinutes(row.plannedMinutes)}</span>
</span>
<span className="text-xs text-ink-3">
réalisé{' '}
<span className="tnum">{formatMinutes(row.actualMinutes)}</span>
</span>
<span
className={cx(
'tnum rounded-2 px-1.5 py-0.5 text-xs',
row.deltaMinutes === 0
? 'text-ink-2'
: row.deltaMinutes > 0
? 'bg-warn-soft text-warn-soft-ink'
: 'bg-danger-soft text-danger-soft-ink',
)}
>
{formatDelta(row.deltaMinutes)}
</span>
<Badge tone={row.allValidated ? 'ok' : 'neutral'}>
{row.allValidated ? 'Validé' : 'À valider'}
</Badge>
<span className="ml-auto text-xs text-ink-2">
payé <span className="tnum font-semibold">
{formatMinutes(row.payableMinutes)}
</span>
</span>
</header>
<table className="w-full border-collapse text-xs">
<caption className="sr-only">
Heures de {row.name}, {report.label}
</caption>
<thead>
<tr className="border-b border-line-1 text-left text-micro text-ink-3 uppercase">
<th className="px-4 py-1.5 font-semibold">Jour</th>
<th className="px-2 py-1.5 font-semibold">Prévu</th>
<th className="px-2 py-1.5 font-semibold">Réalisé</th>
<th className="px-2 py-1.5 text-right font-semibold">Écart</th>
<th className="px-2 py-1.5 font-semibold">Saisie</th>
</tr>
</thead>
<tbody>
{row.shifts.map((shift) => (
<tr
key={shift.id}
className="border-b border-line-1 last:border-b-0"
>
<td className="tnum px-4 py-1.5 text-ink-2">
{shift.localDate.split('-').reverse().join('/')}
</td>
<td className="tnum px-2 py-1.5 text-ink-2">
{shift.plannedRange}{' '}
<span className="text-ink-3">
({formatMinutes(shift.plannedMinutes)})
</span>
</td>
<td className="tnum px-2 py-1.5 text-ink-1">
{shift.hasActual ? (
<>
{shift.actualRange}{' '}
<span className="text-ink-3">
({formatMinutes(shift.actualMinutes)})
</span>
</>
) : (
<span className="text-ink-3">
prévu retenu ({formatMinutes(shift.actualMinutes)})
</span>
)}
</td>
<td
className={cx(
'tnum px-2 py-1.5 text-right',
shift.deltaMinutes === 0 ? 'text-ink-3' : 'text-ink-1',
)}
>
{shift.deltaMinutes === 0 ? '—' : formatDelta(shift.deltaMinutes)}
</td>
<td className="px-2 py-1.5">
{report.canEdit ? (
<ActualHoursForm shift={shift} />
) : (
<span className="text-micro text-ink-3">
Lecture seule
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
{report.canValidate ? (
<div className="border-t border-line-1 px-4 py-2">
<ValidateRow
shiftIds={row.shifts
.filter((shift) => !shift.locked)
.map((shift) => shift.id)}
allValidated={row.allValidated}
/>
</div>
) : null}
</section>
))}
</PageBody>
);
}
+135
View File
@@ -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 (
<span className="text-micro text-ink-3">
Période verrouillée — saisie fermée
</span>
);
}
return (
<form action={formAction} className="flex flex-wrap items-center gap-1.5">
<input type="hidden" name="shiftId" value={shift.id} />
<label className="sr-only" htmlFor={`start-${shift.id}`}>
Début réel
</label>
<input
id={`start-${shift.id}`}
name="start"
type="time"
defaultValue={actualStart || plannedStart}
className="h-7 rounded-2 border border-line-2 bg-surface px-1.5 text-xs text-ink-1"
/>
<label className="sr-only" htmlFor={`end-${shift.id}`}>
Fin réelle
</label>
<input
id={`end-${shift.id}`}
name="end"
type="time"
defaultValue={actualEnd || plannedEnd}
className="h-7 rounded-2 border border-line-2 bg-surface px-1.5 text-xs text-ink-1"
/>
{/* Le motif n'apparaît qu'en correction : une première saisie ne corrige
rien, et demander un motif à chaque ligne le ferait remplir
machinalement. */}
{shift.hasActual ? (
<>
<label className="sr-only" htmlFor={`reason-${shift.id}`}>
Motif de la correction
</label>
<input
id={`reason-${shift.id}`}
name="reason"
type="text"
maxLength={500}
placeholder="Motif"
className="h-7 w-32 rounded-2 border border-warn bg-surface px-1.5 text-xs text-ink-1"
/>
</>
) : null}
<Button type="submit" size="sm" disabled={pending}>
{shift.hasActual ? 'Corriger' : 'Saisir'}
</Button>
{state.error ? (
<span role="alert" className="text-micro text-danger">
{state.error}
</span>
) : null}
</form>
);
}
/**
* 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 (
<form action={formAction} className="flex items-center gap-2">
{shiftIds.map((id) => (
<input key={id} type="hidden" name="shiftIds" value={id} />
))}
<input
type="hidden"
name="validate"
value={allValidated ? 'false' : 'true'}
/>
{state.error ? (
<span role="alert" className="text-micro text-danger">
{state.error}
</span>
) : null}
<Button type="submit" size="sm" disabled={pending}>
{allValidated ? 'Dévalider' : 'Valider les heures'}
</Button>
</form>
);
}
+199
View File
@@ -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 (
<form
action={formAction}
className="flex flex-wrap items-end gap-3 rounded-3 border border-line-2 bg-surface-2 p-3"
>
<input type="hidden" name="locationId" value={locationId} />
<label className="flex flex-col gap-1">
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
Mois
</span>
<input
name="month"
type="month"
defaultValue={suggestedMonth}
required
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
Libellé
</span>
<input
name="label"
type="text"
maxLength={80}
placeholder="Facultatif"
className="h-8 w-48 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
</label>
<Button type="submit" variant="primary" size="sm" disabled={pending}>
Créer la période
</Button>
{state.error ? (
<p role="alert" className="w-full text-xs text-danger">
{state.error}
</p>
) : null}
</form>
);
}
/**
* 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 (
<form action={formAction} className="flex flex-wrap items-center gap-2">
<input type="hidden" name="periodId" value={periodId} />
<input type="hidden" name="expectedVersion" value={version} />
{state.error ? (
<span role="alert" className="text-xs text-danger">
{state.error}
</span>
) : null}
{state.message ? (
<span className="text-xs text-ok-soft-ink">{state.message}</span>
) : null}
<Button type="submit" size="sm" variant="primary" disabled={pending}>
Verrouiller la période
</Button>
</form>
);
}
/**
* 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 (
<form action={formAction} className="flex flex-wrap items-center gap-2">
<input type="hidden" name="periodId" value={periodId} />
<input type="hidden" name="expectedVersion" value={version} />
<label className="flex items-center gap-1.5">
<span className="sr-only">Motif du déverrouillage</span>
<input
name="reason"
type="text"
required
maxLength={500}
placeholder="Motif du déverrouillage"
className="h-8 w-56 rounded-2 border border-warn bg-surface px-2 text-sm text-ink-1"
/>
</label>
{state.error ? (
<span role="alert" className="text-xs text-danger">
{state.error}
</span>
) : null}
{state.message ? (
<span className="text-xs text-warn-soft-ink">{state.message}</span>
) : null}
<Button type="submit" size="sm" disabled={pending}>
Déverrouiller
</Button>
</form>
);
}
/**
* 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 (
<form action={formAction} className="flex flex-wrap items-center gap-2">
<input type="hidden" name="periodId" value={periodId} />
<label className="flex items-center gap-1.5">
<span className="sr-only">Confirmer en saisissant « {label} »</span>
<input
name="confirm"
type="text"
placeholder={`Saisir « ${label} »`}
className="h-8 w-44 rounded-2 border border-line-2 bg-surface px-2 text-xs text-ink-1"
/>
</label>
{state.error ? (
<span role="alert" className="text-xs text-danger">
{state.error}
</span>
) : null}
<Button type="submit" size="sm" variant="danger" disabled={pending}>
Supprimer
</Button>
</form>
);
}
+2 -1
View File
@@ -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é" },
],
+166
View File
@@ -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<HoursView>(
(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;
}
+11
View File
@@ -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;
}
+244
View File
@@ -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<HoursActionState> {
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<HoursActionState> {
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;
}
+228
View File
@@ -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<HoursReport | null> {
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<string, typeof shifts>();
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 };
+22 -10
View File
@@ -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,
},
+319
View File
@@ -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<PeriodActionState> {
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<PeriodActionState> {
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<PeriodActionState> {
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<PeriodActionState> {
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;
}
+141
View File
@@ -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<PeriodsView | null> {
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,
);
}
+127
View File
@@ -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<void> {
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<number> {
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;
}
+17
View File
@@ -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;
}
+110 -14
View File
@@ -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));
});
+116
View File
@@ -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,
);
});
+239
View File
@@ -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);
});
});
});
+200
View File
@@ -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> = {}): 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);
});
});