Merge pull request #15 — WP-07 heures et périodes de paie
WP-07 — Heures prévu/réalisé/payé et périodes de paie verrouillables
This commit is contained in:
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 $$;
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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('/');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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é" },
|
||||
],
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user