diff --git a/prisma/migrations/20260808120507_pay_periods/migration.sql b/prisma/migrations/20260808120507_pay_periods/migration.sql new file mode 100644 index 0000000..a6c0d7c --- /dev/null +++ b/prisma/migrations/20260808120507_pay_periods/migration.sql @@ -0,0 +1,81 @@ +-- CreateEnum +CREATE TYPE "PayPeriodKind" AS ENUM ('MAIN', 'ALTERNATIVE'); + +-- CreateEnum +CREATE TYPE "PayPeriodStatus" AS ENUM ('OPEN', 'LOCKED'); + +-- AlterTable +ALTER TABLE "PayrollExport" ADD COLUMN "payPeriodId" TEXT; + +-- CreateTable +CREATE TABLE "PayPeriod" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "locationId" TEXT NOT NULL, + "label" TEXT NOT NULL, + "startDate" DATE NOT NULL, + "endDate" DATE NOT NULL, + "kind" "PayPeriodKind" NOT NULL DEFAULT 'MAIN', + "populations" "ContractType"[], + "status" "PayPeriodStatus" NOT NULL DEFAULT 'OPEN', + "lockedAt" TIMESTAMP(3), + "lockedBy" TEXT, + "unlockedAt" TIMESTAMP(3), + "unlockedBy" TEXT, + "version" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PayPeriod_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PayPeriodSnapshot" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "payPeriodId" TEXT NOT NULL, + "membershipId" TEXT NOT NULL, + "plannedMinutes" INTEGER NOT NULL, + "actualMinutes" INTEGER NOT NULL, + "absenceMinutes" INTEGER NOT NULL, + "workedDays" INTEGER NOT NULL, + "overtimeByBracket" JSONB NOT NULL, + "absenceBreakdown" JSONB NOT NULL, + "variables" JSONB NOT NULL, + "agreementId" TEXT, + "computedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PayPeriodSnapshot_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "PayPeriod_accountId_idx" ON "PayPeriod"("accountId"); + +-- CreateIndex +CREATE INDEX "PayPeriod_accountId_startDate_idx" ON "PayPeriod"("accountId", "startDate"); + +-- CreateIndex +CREATE UNIQUE INDEX "PayPeriod_locationId_startDate_endDate_kind_key" ON "PayPeriod"("locationId", "startDate", "endDate", "kind"); + +-- CreateIndex +CREATE INDEX "PayPeriodSnapshot_accountId_idx" ON "PayPeriodSnapshot"("accountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PayPeriodSnapshot_payPeriodId_membershipId_key" ON "PayPeriodSnapshot"("payPeriodId", "membershipId"); + +-- AddForeignKey +ALTER TABLE "PayPeriodSnapshot" ADD CONSTRAINT "PayPeriodSnapshot_payPeriodId_fkey" FOREIGN KEY ("payPeriodId") REFERENCES "PayPeriod"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Isolation : toute table portant accountId doit porter sa politique. +DO $$ +DECLARE t text; +BEGIN + FOREACH t IN ARRAY ARRAY['PayPeriod','PayPeriodSnapshot'] + LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t); + EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t); + EXECUTE format( + 'CREATE POLICY tenant_isolation ON %I USING ("accountId" = planflow_current_account())', t); + EXECUTE format( + 'CREATE POLICY tenant_insert ON %I FOR INSERT WITH CHECK ("accountId" = planflow_current_account())', t); + END LOOP; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b13ebd5..844d4ba 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -825,6 +825,10 @@ model PayrollExport { id String @id @default(cuid()) accountId String locationId String? + /// Période couverte. La péremption se **déduit** de + /// `generatedAt < PayPeriod.unlockedAt` : la stocker obligerait à réécrire + /// une trace qui doit rester append-only. + payPeriodId String? periodStart DateTime @db.Date periodEnd DateTime @db.Date checksum String @@ -1012,3 +1016,81 @@ model LeaveNotice { @@index([accountId]) @@index([membershipId]) } + +// ============================================================================ +// Périodes de paie — PLAN.md §4.6 et §7.3, WP-07 +// ============================================================================ + +/// Période de paie d'un établissement. +/// +/// Le **verrouillage** fige les instantanés et interdit toute mutation dont la +/// date tombe dans la période. Le **déverrouillage** existe et rouvre les +/// mutations : c'est une décision qui exige une justification, pas un bouton +/// anodin — tout export produit avant devient périmé. +model PayPeriod { + id String @id @default(cuid()) + accountId String + locationId String + label String + startDate DateTime @db.Date + endDate DateTime @db.Date + kind PayPeriodKind @default(MAIN) + /// Types de contrat inclus. Vide = tous. + populations ContractType[] + status PayPeriodStatus @default(OPEN) + lockedAt DateTime? + lockedBy String? + /// Dernier déverrouillage : la péremption des exports s'en **déduit**, elle + /// n'est jamais stockée, pour que `PayrollExport` reste append-only. + unlockedAt DateTime? + unlockedBy String? + version Int @default(0) + createdAt DateTime @default(now()) + + snapshots PayPeriodSnapshot[] + + @@unique([locationId, startDate, endDate, kind]) + @@index([accountId]) + @@index([accountId, startDate]) +} + +enum PayPeriodKind { + MAIN + ALTERNATIVE +} + +enum PayPeriodStatus { + OPEN + LOCKED +} + +/// Instantané figé au verrouillage. +/// +/// Recalculé intégralement à chaque nouveau verrouillage : il n'est donc pas +/// immuable au sens strict. C'est le couple (instantané, `PayrollExport`) qui +/// porte la preuve — et `PayrollExport`, lui, est append-only. +model PayPeriodSnapshot { + id String @id @default(cuid()) + accountId String + payPeriodId String + membershipId String + plannedMinutes Int + actualMinutes Int + absenceMinutes Int + workedDays Int + /// [{ ratePercent, minutes }] + overtimeByBracket Json + /// [{ absenceTypeId, code, days, minutes }] + absenceBreakdown Json + /// [{ key, value, unit }] — les éléments prêts pour l'export. + variables Json + /// Version de convention appliquée : sans elle, un instantané ancien devient + /// inexplicable dès que les paramètres changent. + agreementId String? + computedAt DateTime @default(now()) + + period PayPeriod @relation(fields: [payPeriodId], references: [id], onDelete: Cascade) + + @@unique([payPeriodId, membershipId]) + @@index([accountId]) +} diff --git a/src/app/(app)/paie/periodes/page.tsx b/src/app/(app)/paie/periodes/page.tsx new file mode 100644 index 0000000..f758eef --- /dev/null +++ b/src/app/(app)/paie/periodes/page.tsx @@ -0,0 +1,177 @@ +import Link from 'next/link'; + +import { + CreatePeriodForm, + DeletePeriodForm, + LockButton, + UnlockForm, +} from '@/components/payroll/PeriodActions'; +import { PageBody, PageHeader } from '@/components/shell/PageHeader'; +import { Badge } from '@/components/ui/Badge'; +import { getPeriods } from '@/server/payroll/period-queries'; + +export const metadata = { title: 'Périodes de paie · PlanFlow' }; + +interface PageProps { + searchParams: Promise<{ etablissement?: string }>; +} + +export default async function PeriodesPage({ searchParams }: PageProps) { + const params = await searchParams; + const view = await getPeriods(params.etablissement); + + if (!view) { + return ( + + + + ); + } + + return ( + + 1 ? 's' : ''}`} + actions={ + + ← Rapport de paie + + } + /> + +
+ {view.locations.map((location) => ( + + {location.name} + + ))} +
+ + + + {view.periods.length === 0 ? ( +

+ Aucune période. Verrouiller une période fige les heures transmises au + cabinet et ferme le mois aux modifications. +

+ ) : null} + + {view.periods.map((period) => { + const locked = period.status === 'LOCKED'; + const stale = period.exports.filter((entry) => entry.stale).length; + + return ( +
+
+

+ {period.label} +

+ + {locked ? 'Verrouillée' : 'Ouverte'} + + + {frenchDate(period.startDate)} → {frenchDate(period.endDate)} + + {locked ? ( + + {period.snapshotCount} instantané + {period.snapshotCount > 1 ? 's' : ''} + + ) : null} +
+ +
+ + Entrées {period.entries} + + + Sorties {period.exits} + + + Extras {period.extras} + +
+ + {stale > 0 ? ( +

+ {stale} export{stale > 1 ? 's' : ''} périmé{stale > 1 ? 's' : ''} : + produit{stale > 1 ? 's' : ''} avant le déverrouillage du{' '} + {period.unlockedAt?.toISOString().slice(0, 10)}, le fichier + transmis ne correspond plus aux données. +

+ ) : null} + + {period.exports.length > 0 ? ( +
    + {period.exports.map((entry) => ( +
  • + + {entry.stale ? 'Périmé' : 'À jour'} + + + {entry.lineCount} lignes + + + {entry.checksum.slice(0, 16)}… + + + {entry.generatedAt.toISOString().slice(0, 16).replace('T', ' ')} + +
  • + ))} +
+ ) : null} + +
+ {!locked && view.canLock ? ( + + ) : null} + {locked && view.canUnlock ? ( + + ) : null} + {view.canDelete ? ( + + ) : null} +
+
+ ); + })} + +

+ Une période verrouillée refuse toute création, modification ou + suppression de créneau et d’absence sur ses dates. Une correction + nécessaire passe soit par un déverrouillage justifié, soit par une + régularisation sur la période suivante — le passé ne se réécrit pas. +

+
+ ); +} + +function frenchDate(isoDate: string): string { + return isoDate.split('-').reverse().join('/'); +} diff --git a/src/app/(app)/rapports/heures/page.tsx b/src/app/(app)/rapports/heures/page.tsx new file mode 100644 index 0000000..f91505e --- /dev/null +++ b/src/app/(app)/rapports/heures/page.tsx @@ -0,0 +1,221 @@ +import Link from 'next/link'; + +import { ActualHoursForm, ValidateRow } from '@/components/hours/ActualHoursForm'; +import { PageBody, PageHeader } from '@/components/shell/PageHeader'; +import { Badge } from '@/components/ui/Badge'; +import { formatDelta, formatMinutes } from '@/domain/counters/week'; +import { monthOf, parseMonthParam } from '@/domain/planning/month'; +import { cx } from '@/lib/cx'; +import { getHoursReport } from '@/server/hours/queries'; + +export const metadata = { title: 'Heures travaillées · PlanFlow' }; + +interface PageProps { + searchParams: Promise<{ mois?: string; etablissement?: string }>; +} + +export default async function HeuresPage({ searchParams }: PageProps) { + const params = await searchParams; + const month = parseMonthParam(params.mois) ?? monthOf(new Date()); + const report = await getHoursReport(month, params.etablissement); + + if (!report) { + return ( + + + + ); + } + + const href = (mois: string, etablissement = report.location.id) => + `/rapports/heures?mois=${mois}&etablissement=${etablissement}`; + + return ( + + + + ← Mois précédent + + + Mois suivant → + + + Périodes + + + } + /> + +
+ {report.locations.map((location) => ( + + {location.name} + + ))} + {report.lockedLabels.map((label) => ( + + {label} verrouillée + + ))} +
+ +
+

+ Sans heures réelles saisies, le prévu fait foi.{' '} + Attendre une saisie qui ne viendra pas ne produirait aucune paie. +

+

+ La validation qualifie des heures, elle ne les + autorise pas à être payées : des heures accomplies partent en paie + qu’elles soient validées ou non. +

+
+ + {report.rows.length === 0 ? ( +

+ Aucun créneau planifié sur cette période. +

+ ) : null} + + {report.rows.map((row) => ( +
+
+

{row.name}

+ + prévu {formatMinutes(row.plannedMinutes)} + + + réalisé{' '} + {formatMinutes(row.actualMinutes)} + + 0 + ? 'bg-warn-soft text-warn-soft-ink' + : 'bg-danger-soft text-danger-soft-ink', + )} + > + {formatDelta(row.deltaMinutes)} + + + {row.allValidated ? 'Validé' : 'À valider'} + + + payé + {formatMinutes(row.payableMinutes)} + + +
+ + + + + + + + + + + + + + {row.shifts.map((shift) => ( + + + + + + + + ))} + +
+ Heures de {row.name}, {report.label} +
JourPrévuRéaliséÉcartSaisie
+ {shift.localDate.split('-').reverse().join('/')} + + {shift.plannedRange}{' '} + + ({formatMinutes(shift.plannedMinutes)}) + + + {shift.hasActual ? ( + <> + {shift.actualRange}{' '} + + ({formatMinutes(shift.actualMinutes)}) + + + ) : ( + + prévu retenu ({formatMinutes(shift.actualMinutes)}) + + )} + + {shift.deltaMinutes === 0 ? '—' : formatDelta(shift.deltaMinutes)} + + {report.canEdit ? ( + + ) : ( + + Lecture seule + + )} +
+ + {report.canValidate ? ( +
+ !shift.locked) + .map((shift) => shift.id)} + allValidated={row.allValidated} + /> +
+ ) : null} +
+ ))} +
+ ); +} diff --git a/src/components/hours/ActualHoursForm.tsx b/src/components/hours/ActualHoursForm.tsx new file mode 100644 index 0000000..939f7ea --- /dev/null +++ b/src/components/hours/ActualHoursForm.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { useActionState } from 'react'; + +import { Button } from '@/components/ui/Button'; +import { + saveActualHoursAction, + validateHoursAction, + type HoursActionState, +} from '@/server/hours/actions'; +import type { HoursShiftRow } from '@/server/hours/queries'; + +const empty: HoursActionState = {}; + +/** + * Saisie des heures réellement faites. + * + * Les champs sont **pré-remplis avec le prévu** quand rien n'a été saisi : le + * cas courant est « comme prévu, sauf que », et retaper deux horaires pour + * corriger un quart d'heure décourage la saisie — donc fausse le réalisé. + */ +export function ActualHoursForm({ shift }: { shift: HoursShiftRow }) { + const [state, formAction, pending] = useActionState( + saveActualHoursAction, + empty, + ); + + const [plannedStart, plannedEnd] = shift.plannedRange.split('–'); + const [actualStart, actualEnd] = (shift.actualRange ?? '').split('–'); + + if (shift.locked) { + return ( + + Période verrouillée — saisie fermée + + ); + } + + return ( +
+ + + + + + + + + {/* 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 ? ( + <> + + + + ) : null} + + + + {state.error ? ( + + {state.error} + + ) : null} +
+ ); +} + +/** + * 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 ( +
+ {shiftIds.map((id) => ( + + ))} + + {state.error ? ( + + {state.error} + + ) : null} + +
+ ); +} diff --git a/src/components/payroll/PeriodActions.tsx b/src/components/payroll/PeriodActions.tsx new file mode 100644 index 0000000..7a95218 --- /dev/null +++ b/src/components/payroll/PeriodActions.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { useActionState } from 'react'; + +import { Button } from '@/components/ui/Button'; +import { + createPeriodAction, + deletePeriodAction, + lockPeriodAction, + unlockPeriodAction, + type PeriodActionState, +} from '@/server/payroll/period-actions'; + +const empty: PeriodActionState = {}; + +export function CreatePeriodForm({ + locationId, + suggestedMonth, +}: { + locationId: string; + suggestedMonth: string; +}) { + const [state, formAction, pending] = useActionState( + createPeriodAction, + empty, + ); + + return ( +
+ + + + + + + + + {state.error ? ( +

+ {state.error} +

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