WP-08 : export Silae, format relevé sur un export réel du dossier

Le format n'est plus déduit d'une documentation. Il est vérifié octet par
octet sur un export du dossier (juillet 2026, 55 lignes) : un contrôle
d'aller-retour a reproduit ce fichier **sans aucune ligne divergente**.

Ce que le fichier réel a corrigé dans la spécification
- L'export est en **ASCII pur**, pas en UTF-8 : aucun accent, y compris dans
  les libellés (« Heures travaillees », « Date debut »). Le sérialiseur
  translittère, pour qu'un salarié nommé « Rémi » n'introduise pas le premier
  octet non-ASCII du fichier.
- Fins de ligne **CRLF**, la dernière comprise ; ni guillemet ni point-virgule
  final ; décimale point.
- Heures avec au moins une décimale et au plus deux — `96.0`, `52.5`, `69.67` ;
  jours en entier nu — `14`, `22`. L'arrondi se fait **par ligne**, au
  centième : recomposer un total depuis les lignes peut donc s'écarter de
  quelques centièmes. C'est le comportement de l'export existant, et le
  reproduire est délibéré.
- Un salarié sous contrat sans aucun créneau planifié figure quand même, avec
  sa durée contractuelle entière en heures manquantes.

Ce que le fichier n'a pas dit
Les codes sont maintenant connus — `AB-100`, `AB-200`, `AB-300`, `AB-630`,
`EV-HDimanche`, `EV-HFerie`, `HS-HS25` — mais **savoir qu'un code existe ne dit
pas ce qu'il désigne**. `EV-HDimanche` se lit ; `AB-300` non. Les premiers sont
proposés, les seconds restent vides, et rien n'est confirmé d'office : l'export
refuse de tourner tant que le gestionnaire de paie n'a pas validé chaque
correspondance. C'est le signal d'arrêt de PLAN.md §8.2, maintenu.

Refus plutôt que fichier partiel
Un CSV incomplet se charge sans erreur dans Silae et rend la paie fausse pour
les salariés qui en sont absents — l'échec est silencieux jusqu'au bulletin.
L'export liste donc les manques et ne produit rien.

Données personnelles
L'export de référence porte les heures et les absences de salariés
identifiables. Il **n'est pas versionné**, ni comme fixture ni comme donnée de
démonstration. Ce sont les règles de forme qui sont figées dans les tests, avec
les valeurs observées mais sans les matricules ni les volumes réels. Le fichier
produit revient dans la réponse de l'action plutôt que par une URL : un fichier
de paie ne doit pas rester adressable, mis en cache ou présent dans un
historique de navigation. En base, seule l'empreinte est conservée — elle
suffit à prouver qu'un réexport est identique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-08 07:58:34 +00:00
1 parent 0babdb0936
commit af9429901d
16 files changed
+2050 -15

No files matched your search

+57 -14
View File
@@ -866,29 +866,72 @@ Coût prévisionnel = somme des heures × taux horaire du contrat × (1 + `emplo
## 8. Export Silae ## 8. Export Silae
Seule intégration de paie du périmètre v1. `src/domain/payroll/adapters/silae.ts`, derrière une interface `PayrollExportAdapter` qui laisse la place à d'autres formats. Seule intégration de paie du périmètre v1. `src/domain/payroll/silae.ts`, derrière une interface `PayrollExportAdapter` qui laisse la place à d'autres formats.
### 8.1 Format ### 8.1 Format — **relevé sur un export réel**
- **CSV, UTF-8, séparateur `;`**
- En-tête : `matricule;code paie;décompte;date début;date fin;` Le format ci-dessous n'est pas déduit d'une documentation : il est vérifié octet par octet sur un export du dossier (période 01/07/2026 – 31/07/2026, 55 lignes). Un contrôle d'aller-retour a reproduit ce fichier **sans aucune ligne divergente**.
- Une ligne par couple (salarié, code de paie) sur la période.
| Élément | Valeur constatée |
|---|---|
| En-tête | `Matricule;Code;Valeur;Date debut;Date fin` — sans accent, sans point-virgule final |
| Encodage | **ASCII pur** — aucun caractère accentué ni composé, y compris dans les libellés |
| Fins de ligne | **CRLF**, y compris après la dernière ligne |
| Séparateur | `;` — aucun guillemet, aucune échappement |
| Décimale | **point**, jamais virgule |
| Dates | **JJ/MM/AAAA** |
| Heures | au moins une décimale, au plus deux : `96.0`, `52.5`, `69.67` |
| Jours | entier nu : `14`, `22`, `3` |
| Arrondi | heures décimales au centième, **par ligne** : 4 h 50 → `4.83`, 69 h 40 → `69.67` |
> L'arrondi par ligne fait que la somme des lignes peut s'écarter de quelques centièmes du total réel. C'est le comportement de l'export existant : le reproduire est délibéré. Le « corriger » ferait diverger du fichier que le gestionnaire de paie sait relire.
**Encodage ASCII :** émettre de l'UTF-8 accenté s'écarterait de ce que le dossier reçoit. Le sérialiseur translittère (`toAscii`), pour qu'un salarié nommé « Rémi » n'introduise pas le premier octet non-ASCII du fichier.
### 8.2 Codes ### 8.2 Codes
Trois familles, préfixées :
| Famille | Préfixe | Source du code |
|---|---|---|
| Heures | `HS-` | `SilaeCodeMapping` kind `HOURS` |
| Absences | `AB-` | `AbsenceType.silaeCode` |
| Éléments variables | `EV-` | `SilaeCodeMapping` kind `VARIABLE` |
> **Signal d'arrêt.** Les codes réels appartiennent au dossier Silae du client et se lisent dans « Saisie des éléments variables ». **Ne pas les inventer.** Livrer l'écran de correspondance (`/settings/integrations/silae`) et une table vide ; demander les codes avant la première mise en production. Deux familles cohabitent dans l'export réel.
**Codes de service** — décrivent le décompte, sans préfixe :
| Code | Nature | Portée constatée |
|---|---|---|
| `Nombre total de jours travailles` | jours entiers | période de paie entière |
| `Heures travaillees` | heures | période de paie entière |
| `Heures manquantes au contrat` | heures | période de paie entière |
| `Entree / Sortie` | heures | période d'emploi sur le mois |
**Codes de rubrique** — préfixés :
| Famille | Préfixe | Codes relevés |
|---|---|---|
| Heures supplémentaires | `HS-` | `HS-HS25` |
| Absences | `AB-` | `AB-100`, `AB-200`, `AB-300`, `AB-630` |
| Éléments variables | `EV-` | `EV-HDimanche`, `EV-HFerie` |
> **Signal d'arrêt maintenu — les codes sont connus, leur *sens* ne l'est pas.** Savoir que `AB-300` existe ne dit pas quel type d'absence il désigne. Cette correspondance appartient au dossier Silae du client et se lit dans « Saisie des éléments variables ». Elle se saisit dans l'écran de correspondance ; **elle ne se devine pas**.
>
> Observations à faire confirmer, sans les traiter comme acquises :
> - Un même salarié enchaîne `AB-100` (01–10/07) puis `AB-200` (11–31/07) : deux natures distinctes, ou une prolongation ?
> - `AB-300` apparaît sur des périodes courtes avec des volumes modestes.
> - `AB-630` n'apparaît que sur une journée isolée.
> - `Entree / Sortie` accompagne un départ en cours de mois — quelle grandeur porte sa valeur ?
> - Aucun code de **forfait jours** n'apparaît : il reste à obtenir.
**Portée des périodes.** Les agrégats couvrent la période de paie entière ; une absence couvre **ses propres dates**. Les confondre décalerait le décompte d'un mois.
**Salariés sans planning.** L'export de référence contient des salariés portant uniquement `Heures manquantes au contrat` égal à leur durée mensuelle : un contrat existe, aucun temps n'est planifié. L'export doit produire ces lignes plutôt que d'omettre le salarié.
### 8.3 Règles d'export ### 8.3 Règles d'export
- **Pré-contrôle bloquant** : tout salarié inclus doit avoir un `silaeMatricule` et tout élément exporté un code mappé. À défaut, l'export échoue en listant précisément les manques — il ne produit jamais un fichier partiel silencieux. - **Pré-contrôle bloquant** : tout salarié inclus doit avoir un `silaeMatricule` et tout élément exporté un code mappé. À défaut, l'export échoue en **listant** les manques — il ne produit jamais un fichier partiel silencieux, qui se chargerait sans erreur et rendrait la paie fausse pour les salariés absents du fichier.
- L'export ne porte que sur une `PayPeriod` **verrouillée**, et lit exclusivement les `PayPeriodSnapshot`. - L'export ne porte que sur une `PayPeriod` **verrouillée**, et lit exclusivement les `PayPeriodSnapshot`.
- **Idempotence** : un réexport de la même période produit le même fichier et le même `checksum`. L'import Silae écrase les données de la même période pour les mêmes salariés ; l'export doit donc être rejouable sans effet de bord. - **Idempotence** : ordre déterministe (matricule, puis code, puis date), donc même fichier et même `checksum` à chaque réexport. L'import Silae écrase les données de la période pour les salariés concernés ; l'export doit être rejouable sans effet de bord.
- Chaque génération écrit un `PayrollExport` et une entrée d'audit. - Chaque génération écrit un `PayrollExport` et une entrée d'audit.
### 8.4 Données réelles — ce qui ne rentre pas au dépôt
L'export ayant servi de référence contient les heures et les absences de salariés identifiables. **Il n'est pas versionné**, ni comme fixture de test, ni comme donnée de démonstration. Ce sont les *règles de forme* qui sont figées dans `tests/unit/silae.test.ts`, avec les valeurs exactes observées mais sans les matricules ni les volumes réels.
--- ---
## 9. Routes et écrans ## 9. Routes et écrans
@@ -0,0 +1,64 @@
-- CreateEnum
CREATE TYPE "SilaeMappingKind" AS ENUM ('SERVICE', 'OVERTIME', 'ABSENCE', 'VARIABLE');
-- CreateTable
CREATE TABLE "SilaeCodeMapping" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"sourceKey" TEXT NOT NULL,
"silaeCode" TEXT NOT NULL,
"label" TEXT NOT NULL,
"kind" "SilaeMappingKind" NOT NULL,
"confirmed" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SilaeCodeMapping_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PayrollExport" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"locationId" TEXT,
"periodStart" DATE NOT NULL,
"periodEnd" DATE NOT NULL,
"checksum" TEXT NOT NULL,
"lineCount" INTEGER NOT NULL,
"generatedBy" TEXT,
"generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PayrollExport_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SilaeCodeMapping_accountId_idx" ON "SilaeCodeMapping"("accountId");
-- CreateIndex
CREATE UNIQUE INDEX "SilaeCodeMapping_accountId_sourceKey_key" ON "SilaeCodeMapping"("accountId", "sourceKey");
-- CreateIndex
CREATE INDEX "PayrollExport_accountId_periodStart_idx" ON "PayrollExport"("accountId", "periodStart");
-- CreateIndex
CREATE INDEX "PayrollExport_accountId_idx" ON "PayrollExport"("accountId");
-- Isolation : toute table portant accountId doit porter sa politique.
ALTER TABLE "SilaeCodeMapping" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "SilaeCodeMapping" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "SilaeCodeMapping"
USING ("accountId" = planflow_current_account());
CREATE POLICY tenant_insert ON "SilaeCodeMapping"
FOR INSERT WITH CHECK ("accountId" = planflow_current_account());
ALTER TABLE "PayrollExport" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "PayrollExport" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "PayrollExport"
USING ("accountId" = planflow_current_account());
CREATE POLICY tenant_insert ON "PayrollExport"
FOR INSERT WITH CHECK ("accountId" = planflow_current_account());
-- Une trace d'export ne se réécrit pas : elle atteste de ce qui a été transmis
-- au gestionnaire de paie, à une date donnée.
CREATE TRIGGER payroll_export_append_only
BEFORE UPDATE OR DELETE ON "PayrollExport"
FOR EACH ROW EXECUTE FUNCTION planflow_deny_write();
+58
View File
@@ -776,3 +776,61 @@ model AuthorisedSunday {
@@unique([locationId, localDate]) @@unique([locationId, localDate])
@@index([accountId]) @@index([accountId])
} }
// ============================================================================
// Export de paie — PLAN.md §8, WP-08
// ============================================================================
/// Correspondance entre un élément calculé par PlanFlow et un code du dossier
/// Silae.
///
/// En base et non dans le code : les codes appartiennent au dossier du client
/// et se lisent dans « Saisie des éléments variables ». Deux clients du même
/// cabinet n'ont pas nécessairement les mêmes.
model SilaeCodeMapping {
id String @id @default(cuid())
accountId String
/// Élément calculé — voir `PayrollElementKey`.
sourceKey String
silaeCode String
label String
kind SilaeMappingKind
/// Faux tant que la correspondance n'a pas été confirmée par le gestionnaire
/// de paie. Un export refuse de tourner sur une correspondance non confirmée.
confirmed Boolean @default(false)
createdAt DateTime @default(now())
@@unique([accountId, sourceKey])
@@index([accountId])
}
enum SilaeMappingKind {
/// Décompte de service, sans préfixe : jours travaillés, heures travaillées.
SERVICE
/// Heures supplémentaires, préfixe `HS-`.
OVERTIME
/// Absences, préfixe `AB-`.
ABSENCE
/// Éléments variables, préfixe `EV-`.
VARIABLE
}
/// Trace d'une génération d'export.
///
/// Le contenu n'est **pas** conservé : il porte les heures et les absences de
/// salariés identifiables, et le regénérer est déterministe. L'empreinte suffit
/// à prouver qu'un réexport est identique.
model PayrollExport {
id String @id @default(cuid())
accountId String
locationId String?
periodStart DateTime @db.Date
periodEnd DateTime @db.Date
checksum String
lineCount Int
generatedBy String?
generatedAt DateTime @default(now())
@@index([accountId, periodStart])
@@index([accountId])
}
+43
View File
@@ -20,6 +20,7 @@ import {
IDCC_1517_PARAMETERS, IDCC_1517_PARAMETERS,
IDCC_1517_PROVENANCE, IDCC_1517_PROVENANCE,
} from '../src/domain/compliance/idcc1517'; } from '../src/domain/compliance/idcc1517';
import { PAYROLL_ELEMENT_DEFINITIONS } from '../src/domain/payroll/elements';
import { evaluateSchedule } from '../src/server/compliance/evaluate'; import { evaluateSchedule } from '../src/server/compliance/evaluate';
import { withTenant } from '../src/server/tenant'; import { withTenant } from '../src/server/tenant';
@@ -565,6 +566,48 @@ async function main() {
} }
console.log(` ${retention.length} politiques`); console.log(` ${retention.length} politiques`);
console.log('→ Correspondances Silae');
// Semées **non confirmées**, y compris quand le code se lit dans son libellé
// (`EV-HDimanche`). Proposer n'est pas confirmer : seul le gestionnaire de
// paie sait si le code est le bon dans ce dossier, et l'export refuse de
// tourner tant qu'il ne l'a pas dit.
for (const definition of PAYROLL_ELEMENT_DEFINITIONS) {
if (!definition.suggestedCode) continue;
const existing = await prisma.silaeCodeMapping.findFirst({
where: { accountId: account.id, sourceKey: definition.key },
});
if (existing) continue;
await prisma.silaeCodeMapping.create({
data: {
accountId: account.id,
sourceKey: definition.key,
silaeCode: definition.suggestedCode,
label: definition.label,
kind: definition.kind,
confirmed: false,
},
});
}
console.log(
` ${PAYROLL_ELEMENT_DEFINITIONS.filter((d) => d.suggestedCode).length} proposées, aucune confirmée`,
);
console.log('→ Matricules Silae');
// Fictifs, à la forme observée dans le dossier : cinq chiffres cadrés à zéro.
const withoutMatricule = await prisma.membership.findMany({
where: { accountId: account.id, silaeMatricule: null },
orderBy: { employeeNumber: 'asc' },
});
for (const [index, membership] of withoutMatricule.entries()) {
await prisma.membership.update({
where: { id: membership.id },
data: { silaeMatricule: String(90_000 + index + 1).padStart(5, '0') },
});
}
console.log(` ${withoutMatricule.length} matricules attribués`);
console.log('→ Évaluation de conformité'); console.log('→ Évaluation de conformité');
// Le seed produit des plannings, donc des constats : les laisser à calculer // Le seed produit des plannings, donc des constats : les laisser à calculer
// au premier affichage donnerait une grille faussement conforme. // au premier affichage donnerait une grille faussement conforme.
+214
View File
@@ -0,0 +1,214 @@
import Link from 'next/link';
import { ExportButton } from '@/components/payroll/ExportButton';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { formatMinutes } from '@/domain/counters/week';
import { monthOf, parseMonthParam } from '@/domain/planning/month';
import { minutesToDecimalHours } from '@/domain/payroll/silae';
import { cx } from '@/lib/cx';
import { getPayrollPeriod } from '@/server/payroll/queries';
export const metadata = { title: 'Paie · PlanFlow' };
interface PageProps {
searchParams: Promise<{ mois?: string; etablissement?: string }>;
}
export default async function PaiePage({ searchParams }: PageProps) {
const params = await searchParams;
const month = parseMonthParam(params.mois) ?? monthOf(new Date());
const period = await getPayrollPeriod(month, params.etablissement);
if (!period) {
return (
<PageBody>
<PageHeader
title="Paie"
subtitle="Aucun établissement, ou aucune convention chargée pour cette période."
/>
</PageBody>
);
}
const href = (mois: string, etablissement = period.location.id) =>
`/paie?mois=${mois}&etablissement=${etablissement}`;
return (
<PageBody>
<PageHeader
title={`Paie · ${period.label}`}
subtitle={`${period.location.name} · ${period.rows.length} salarié${period.rows.length > 1 ? 's' : ''} · période du ${period.startDate.split('-').reverse().join('/')} au ${period.endDate.split('-').reverse().join('/')}`}
actions={
<>
<Link
href={href(period.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(period.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/silae"
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
Codes Silae
</Link>
</>
}
/>
<div className="flex flex-wrap items-center gap-2">
{period.locations.map((location) => (
<Link
key={location.id}
href={href(period.monthParam, location.id)}
aria-current={
location.id === period.location.id ? 'page' : undefined
}
className={
location.id === period.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>
{period.blockers.length > 0 ? (
<section className="rounded-3 border border-warn bg-warn-soft p-4">
<h2 className="text-sm font-semibold text-warn-soft-ink">
L’export est bloqué tant que ces points ne sont pas réglés
</h2>
{/* Lister plutôt que compter : « 4 anomalies » n'aide personne à
produire la paie du mois. */}
<ul className="mt-2 list-disc space-y-1 pl-5 text-xs text-warn-soft-ink">
{period.blockers.map((blocker) => (
<li key={blocker}>{blocker}</li>
))}
</ul>
<p className="mt-3 text-xs text-warn-soft-ink">
Un fichier partiel se charge sans erreur dans Silae et rend la paie
fausse pour les salariés qui en sont absents. L’export refuse donc
de produire quoi que ce soit tant qu’il manque un matricule ou un
code.
</p>
</section>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-ink-3">
Les heures viennent du réalisé quand il est saisi, du planifié sinon.
</p>
<ExportButton
month={period.monthParam}
locationId={period.location.id}
disabled={period.blockers.length > 0}
/>
</div>
{period.rows.length === 0 ? (
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
Aucun élément de paie sur cette période.
</p>
) : (
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
<table className="w-full border-collapse text-sm">
<caption className="sr-only">
Éléments de paie par salarié, {period.label}
</caption>
<thead>
<tr className="border-b border-line-2 bg-surface-2 text-left">
<th scope="col" className="px-3 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Salarié
</th>
<th scope="col" className="px-3 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Matricule Silae
</th>
<th scope="col" className="px-3 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Élément
</th>
<th scope="col" className="px-3 py-2 text-right text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Valeur
</th>
<th scope="col" className="px-3 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Code
</th>
</tr>
</thead>
<tbody>
{period.rows.flatMap((row) =>
row.elements.map((element, index) => (
<tr
key={`${row.membershipId}-${element.key}`}
className={cx(
'border-b border-line-1 last:border-b-0',
index === 0 && 'border-t border-line-2',
)}
>
<th
scope="row"
className="px-3 py-1.5 text-left font-medium text-ink-1"
>
{index === 0 ? row.name : ''}
{index === 0 && row.forfaitJours ? (
<Badge tone="info" className="ml-2">
Forfait jours
</Badge>
) : null}
</th>
<td className="tnum px-3 py-1.5 text-ink-2">
{index === 0
? (row.silaeMatricule ?? (
<span className="text-danger">manquant</span>
))
: ''}
</td>
<td className="px-3 py-1.5 text-ink-2">{element.label}</td>
<td className="tnum px-3 py-1.5 text-right text-ink-1">
{element.unit === 'DAYS'
? element.value
: `${formatMinutes(element.value)} (${minutesToDecimalHours(element.value)})`}
</td>
<td className="px-3 py-1.5">
{element.silaeCode ? (
<span
className={cx(
'tnum text-xs',
element.confirmed ? 'text-ink-2' : 'text-warn-soft-ink',
)}
title={
element.confirmed
? undefined
: 'Correspondance non confirmée par le gestionnaire de paie'
}
>
{element.silaeCode}
{element.confirmed ? '' : ' ⚠'}
</span>
) : (
<Link
href="/paie/silae"
className="text-xs text-danger underline"
>
à associer
</Link>
)}
</td>
</tr>
)),
)}
</tbody>
</table>
</div>
)}
</PageBody>
);
}
+97
View File
@@ -0,0 +1,97 @@
import Link from 'next/link';
import { MappingForm } from '@/components/payroll/MappingForm';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { getSilaeMapping } from '@/server/payroll/queries';
export const metadata = { title: 'Codes Silae · PlanFlow' };
export default async function SilaeMappingPage() {
const view = await getSilaeMapping();
const pending = view.rows.filter((row) => !row.confirmed).length;
return (
<PageBody>
<PageHeader
title="Codes de paie Silae"
subtitle={`${view.rows.length - pending} correspondance${view.rows.length - pending > 1 ? 's' : ''} confirmée${view.rows.length - pending > 1 ? 's' : ''} sur ${view.rows.length}`}
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"
>
← Retour à la paie
</Link>
}
/>
<section className="rounded-3 border border-info bg-info-soft p-4 text-sm text-info-soft-ink">
<p>
Les codes appartiennent au dossier Silae du client et se lisent dans
« Saisie des éléments variables ». PlanFlow en propose quelques-uns,
relevés sur un export réel du dossier, mais{' '}
<strong>ne devine jamais leur signification</strong> : savoir que{' '}
<code>AB-300</code> existe ne dit pas quelle absence il désigne.
</p>
<p className="mt-2">
Tant qu’une correspondance n’est pas confirmée par le gestionnaire de
paie, l’export refuse de produire le fichier.
</p>
</section>
<div className="rounded-3 border border-line-1 bg-surface">
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Éléments calculés par PlanFlow
</h2>
{view.rows.map((row) => (
<MappingForm key={row.key} row={row} knownCodes={view.knownCodes} />
))}
</div>
<section className="rounded-3 border border-line-1 bg-surface">
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Codes relevés sur l’export de référence
</h2>
<ul className="flex flex-wrap gap-2 p-4">
{view.knownCodes.map((code) => (
<li key={code}>
<Badge tone="neutral">{code}</Badge>
</li>
))}
</ul>
</section>
{view.exports.length > 0 ? (
<section className="rounded-3 border border-line-1 bg-surface">
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Exports produits
</h2>
{/* Le contenu n'est pas conservé : il porte les heures de salariés
identifiables, et sa génération est déterministe. L'empreinte
suffit à prouver qu'un réexport est identique. */}
<ul className="divide-y divide-line-1">
{view.exports.map((entry) => (
<li
key={entry.id}
className="flex flex-wrap items-center gap-3 px-4 py-2 text-sm"
>
<span className="tnum text-ink-1">
{entry.periodStart.toISOString().slice(0, 10)} →{' '}
{entry.periodEnd.toISOString().slice(0, 10)}
</span>
<span className="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>
</section>
) : null}
</PageBody>
);
}
+82
View File
@@ -0,0 +1,82 @@
'use client';
import { useActionState, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/Button';
import {
exportSilaeAction,
type PayrollActionState,
} from '@/server/payroll/actions';
const empty: PayrollActionState = {};
/**
* Génération et téléchargement du fichier Silae.
*
* Le contenu revient dans la réponse de l'action plutôt que par une URL : un
* fichier de paie ne doit pas rester adressable après coup. Il porte les heures
* de salariés identifiables, et une URL se partage, se met en cache et se
* retrouve dans un historique de navigation.
*/
export function ExportButton({
month,
locationId,
disabled,
}: {
month: string;
locationId: string;
disabled: boolean;
}) {
const [state, formAction, pending] = useActionState(
exportSilaeAction,
empty,
);
const downloaded = useRef('');
useEffect(() => {
if (!state.csv || !state.filename) return;
if (downloaded.current === state.checksum) return;
downloaded.current = state.checksum ?? '';
// Le CSV est produit en ASCII : `text/csv` sans jeu de caractères, comme
// le fichier de référence.
const blob = new Blob([state.csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = state.filename;
link.click();
URL.revokeObjectURL(url);
}, [state.csv, state.filename, state.checksum]);
return (
<form action={formAction} className="flex flex-wrap items-center gap-2">
<input type="hidden" name="month" value={month} />
<input type="hidden" name="locationId" value={locationId} />
{state.error ? (
<span role="alert" className="max-w-xl text-xs text-danger">
{state.error}
</span>
) : null}
{state.checksum ? (
<span className="text-micro text-ink-3">
Empreinte {state.checksum.slice(0, 12)}…
</span>
) : null}
<Button
type="submit"
variant="primary"
disabled={pending || disabled}
title={
disabled
? 'Des correspondances de codes restent à confirmer.'
: undefined
}
>
Exporter vers Silae
</Button>
</form>
);
}
+90
View File
@@ -0,0 +1,90 @@
'use client';
import { useActionState } from 'react';
import { Button } from '@/components/ui/Button';
import {
saveMappingAction,
type PayrollActionState,
} from '@/server/payroll/actions';
import type { MappingRow } from '@/server/payroll/queries';
const empty: PayrollActionState = {};
/**
* Correspondance d'un élément calculé vers un code du dossier Silae.
*
* La confirmation est un acte distinct de la saisie. Un code peut être proposé
* — `EV-HDimanche` se lit sans ambiguïté — mais seul le gestionnaire de paie
* sait s'il est le bon dans **ce** dossier. Tant qu'il n'a pas confirmé,
* l'export refuse de tourner.
*/
export function MappingForm({
row,
knownCodes,
}: {
row: MappingRow;
knownCodes: string[];
}) {
const [state, formAction, pending] = useActionState(saveMappingAction, empty);
const listId = `codes-${row.key}`;
return (
<form
action={formAction}
className="flex flex-wrap items-center gap-3 border-b border-line-1 px-4 py-3 last:border-b-0"
>
<input type="hidden" name="sourceKey" value={row.key} />
<div className="min-w-52 flex-1">
<p className="text-sm font-medium text-ink-1">{row.label}</p>
<p className="text-micro text-ink-3">
{row.unit === 'DAYS' ? 'Jours' : 'Heures'} · famille {row.kind}
{row.suggestedCode ? null : ' · code à obtenir du dossier'}
</p>
</div>
<label className="flex flex-col gap-1">
<span className="sr-only">Code Silae pour {row.label}</span>
<input
name="silaeCode"
type="text"
list={listId}
maxLength={80}
defaultValue={row.silaeCode ?? row.suggestedCode ?? ''}
placeholder="Code du dossier"
className="h-8 w-64 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
<datalist id={listId}>
{knownCodes.map((code) => (
<option key={code} value={code} />
))}
</datalist>
</label>
<label className="flex items-center gap-1.5 text-xs text-ink-2">
<input
name="confirmed"
type="checkbox"
defaultChecked={row.confirmed}
className="size-4"
/>
Confirmé par la paie
</label>
{state.error ? (
<span role="alert" className="text-xs text-danger">
{state.error}
</span>
) : null}
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Enregistré</span>
) : null}
<Button type="submit" size="sm" disabled={pending}>
Enregistrer
</Button>
</form>
);
}
+2 -1
View File
@@ -68,7 +68,8 @@ export const NAVIGATION: NavSection[] = [
label: 'Rapports', label: 'Rapports',
items: [ items: [
{ id: 'heures', label: 'Heures travaillées' }, { id: 'heures', label: 'Heures travaillées' },
{ id: 'paie', label: 'Préparation de paie' }, { id: 'paie', label: 'Préparation de paie', href: '/paie' },
{ id: 'silae', label: 'Codes Silae', href: '/paie/silae' },
{ id: 'activite', label: "Journal d'activité" }, { id: 'activite', label: "Journal d'activité" },
], ],
}, },
+127
View File
@@ -0,0 +1,127 @@
/**
* Éléments de paie calculés par PlanFlow — PLAN.md §8.2.
*
* Ces clés sont **stables et internes**. Elles ne sont pas des codes Silae :
* la correspondance vit en base, parce que les codes appartiennent au dossier
* du client et que deux clients du même cabinet n'ont pas les mêmes.
*
* La distinction n'est pas théorique. Écrire `EV-HDimanche` dans le calcul
* rendrait l'outil inutilisable pour le deuxième client, et impossible à
* corriger sans livraison le jour où le cabinet renumérote ses rubriques.
*/
export const PAYROLL_ELEMENTS = {
WORKED_DAYS: 'WORKED_DAYS',
WORKED_HOURS: 'WORKED_HOURS',
MISSING_HOURS: 'MISSING_HOURS',
SUNDAY_HOURS: 'SUNDAY_HOURS',
HOLIDAY_HOURS: 'HOLIDAY_HOURS',
OVERTIME_25: 'OVERTIME_25',
OVERTIME_50: 'OVERTIME_50',
COMPLEMENTARY_10: 'COMPLEMENTARY_10',
COMPLEMENTARY_25: 'COMPLEMENTARY_25',
FORFAIT_DAYS: 'FORFAIT_DAYS',
} as const;
export type PayrollElementKey =
(typeof PAYROLL_ELEMENTS)[keyof typeof PAYROLL_ELEMENTS];
export interface PayrollElementDefinition {
key: PayrollElementKey;
label: string;
/** Nature de la valeur, qui décide de la mise en forme du CSV. */
unit: 'HOURS' | 'DAYS';
/** Famille Silae attendue, qui oriente le choix du code dans l'écran. */
kind: 'SERVICE' | 'OVERTIME' | 'VARIABLE' | 'ABSENCE';
/**
* Code proposé par défaut lorsqu'il se **lit** dans le libellé du code
* observé — `EV-HDimanche` désigne des heures du dimanche sans ambiguïté.
* `null` quand le code est opaque : `AB-300` ne dit pas quelle absence il
* désigne, et le deviner serait une faute.
*/
suggestedCode: string | null;
}
export const PAYROLL_ELEMENT_DEFINITIONS: PayrollElementDefinition[] = [
{
key: 'WORKED_DAYS',
label: 'Jours travaillés',
unit: 'DAYS',
kind: 'SERVICE',
suggestedCode: 'Nombre total de jours travailles',
},
{
key: 'WORKED_HOURS',
label: 'Heures travaillées',
unit: 'HOURS',
kind: 'SERVICE',
suggestedCode: 'Heures travaillees',
},
{
key: 'MISSING_HOURS',
label: 'Heures manquantes au contrat',
unit: 'HOURS',
kind: 'SERVICE',
suggestedCode: 'Heures manquantes au contrat',
},
{
key: 'SUNDAY_HOURS',
label: 'Heures du dimanche',
unit: 'HOURS',
kind: 'VARIABLE',
suggestedCode: 'EV-HDimanche',
},
{
key: 'HOLIDAY_HOURS',
label: 'Heures de jour férié',
unit: 'HOURS',
kind: 'VARIABLE',
suggestedCode: 'EV-HFerie',
},
{
key: 'OVERTIME_25',
label: 'Heures supplémentaires à 25 %',
unit: 'HOURS',
kind: 'OVERTIME',
suggestedCode: 'HS-HS25',
},
{
key: 'OVERTIME_50',
label: 'Heures supplémentaires à 50 %',
unit: 'HOURS',
kind: 'OVERTIME',
// Aucune ligne à 50 % dans l'export de référence : le code existe
// probablement, il n'a simplement pas été observé.
suggestedCode: null,
},
{
key: 'COMPLEMENTARY_10',
label: 'Heures complémentaires à 10 %',
unit: 'HOURS',
kind: 'OVERTIME',
suggestedCode: null,
},
{
key: 'COMPLEMENTARY_25',
label: 'Heures complémentaires à 25 %',
unit: 'HOURS',
kind: 'OVERTIME',
suggestedCode: null,
},
{
key: 'FORFAIT_DAYS',
label: 'Jours de forfait',
unit: 'DAYS',
kind: 'SERVICE',
// À obtenir du dossier : aucun salarié au forfait dans l'export observé.
suggestedCode: null,
},
];
export function elementDefinition(
key: PayrollElementKey,
): PayrollElementDefinition {
const found = PAYROLL_ELEMENT_DEFINITIONS.find((entry) => entry.key === key);
if (!found) throw new Error(`Élément de paie inconnu : ${key}`);
return found;
}
+247
View File
@@ -0,0 +1,247 @@
/**
* Format d'export Silae — PLAN.md §8.
*
* Ce module ne décide de rien : il **sérialise**. Le format décrit ici est
* relevé sur un export réel du dossier (juillet 2026), pas déduit d'une
* documentation. Chaque règle de mise en forme ci-dessous a été vérifiée octet
* par octet sur ce fichier, parce qu'un import de paie refusé pour une virgule
* ou un accent coûte une demi-journée au gestionnaire, et qu'un import
* *accepté* avec des valeurs mal arrondies coûte bien davantage.
*
* Ce qui a été constaté :
*
* - Encodage **ASCII pur** : ni accent ni caractère composé, y compris dans
* les libellés (« Heures travaillees », « Date debut »). Émettre de l'UTF-8
* accentué serait s'écarter de ce que le dossier reçoit aujourd'hui.
* - Fins de ligne **CRLF**, y compris après la dernière ligne.
* - Séparateur `;`, aucun guillemet, aucun point-virgule final.
* - Décimale **point**, jamais virgule.
* - Dates **JJ/MM/AAAA**.
*/
export const SILAE_HEADER = 'Matricule;Code;Valeur;Date debut;Date fin';
const CRLF = '\r\n';
/**
* Nature d'une valeur, qui décide de sa mise en forme.
*
* Les jours sortent en entier nu (`14`), les heures avec au moins une décimale
* (`96.0`, `69.67`). Ce n'est pas cosmétique : c'est ce que produit l'export
* de référence, et l'import est le seul juge.
*/
export type SilaeValueKind = 'HOURS' | 'DAYS';
export interface SilaeLine {
matricule: string;
code: string;
/** En minutes pour `HOURS`, en jours entiers pour `DAYS`. */
value: number;
kind: SilaeValueKind;
/** Date civile ISO `AAAA-MM-JJ`. */
startDate: string;
endDate: string;
}
/**
* Codes de service, sans préfixe.
*
* Ils décrivent le décompte lui-même plutôt qu'une rubrique de paie, d'où
* l'absence de préfixe `HS-` / `AB-` / `EV-`.
*/
export const SILAE_SERVICE_CODES = {
workedDays: 'Nombre total de jours travailles',
workedHours: 'Heures travaillees',
missingHours: 'Heures manquantes au contrat',
entryExit: 'Entree / Sortie',
} as const;
/**
* Vocabulaire relevé dans l'export de référence.
*
* **Ce sont les codes, pas leur signification.** Savoir que `AB-300` existe ne
* dit pas quel type d'absence il désigne : cette correspondance appartient au
* dossier Silae du client et se lit dans « Saisie des éléments variables ».
* Elle est saisie dans l'écran de correspondance, jamais devinée ici.
*/
export const SILAE_OBSERVED_CODES = [
'AB-100',
'AB-200',
'AB-300',
'AB-630',
'EV-HDimanche',
'EV-HFerie',
'HS-HS25',
] as const;
/** `2026-07-01` → `01/07/2026`. */
export function formatSilaeDate(isoDate: string): string {
const [year, month, day] = isoDate.split('-');
if (!year || !month || !day) {
throw new Error(`Date invalide pour l'export Silae : ${isoDate}`);
}
return `${day}/${month}/${year}`;
}
/**
* Minutes → heures décimales, arrondies au centième.
*
* 4 h 50 donne `4.83`, 69 h 40 donne `69.67`. L'arrondi se fait **par ligne**,
* comme dans l'export de référence : recomposer un total à partir des lignes
* peut donc s'écarter de quelques centièmes du total réel. C'est le
* comportement attendu, pas un défaut à corriger — corriger ferait diverger du
* fichier que le gestionnaire de paie sait relire.
*/
export function minutesToDecimalHours(minutes: number): number {
return Math.round((minutes / 60) * 100) / 100;
}
/**
* Met en forme une valeur.
*
* Les heures gardent **au moins une décimale et au plus deux**, zéros
* superflus retirés : `96.0`, `52.5`, `69.67`. Les jours sortent en entier.
*/
export function formatSilaeValue(value: number, kind: SilaeValueKind): string {
if (kind === 'DAYS') return String(Math.round(value));
const hours = minutesToDecimalHours(value);
const withTwo = hours.toFixed(2);
// `96.00` → `96.0` ; `52.50` → `52.5` ; `69.67` inchangé.
return withTwo.endsWith('0') ? withTwo.slice(0, -1) : withTwo;
}
/**
* Retire les accents et tout caractère hors ASCII imprimable.
*
* L'export de référence ne contient aucun caractère composé. Un salarié nommé
* « Rémi » ou un libellé « Absence rémunérée » ne doit pas introduire le
* premier octet non-ASCII du fichier.
*/
export function toAscii(value: string): string {
return value
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^\x20-\x7E]/g, '');
}
export interface SilaeExportIssue {
matricule: string | null;
code: string | null;
message: string;
}
export interface SilaeExportResult {
csv: string;
lineCount: number;
}
/**
* Contrôles préalables — PLAN.md §8.3.
*
* Un export partiel est pire qu'un export refusé : il se charge sans erreur et
* la paie est fausse pour les salariés absents du fichier. D'où un refus
* explicite qui **liste** les manques.
*/
export function checkSilaeLines(lines: SilaeLine[]): SilaeExportIssue[] {
const issues: SilaeExportIssue[] = [];
for (const line of lines) {
if (!line.matricule.trim()) {
issues.push({
matricule: null,
code: line.code,
message: 'Matricule Silae manquant : ce salarié ne peut pas être exporté.',
});
}
if (!line.code.trim()) {
issues.push({
matricule: line.matricule,
code: null,
message: 'Code de paie non renseigné pour cet élément.',
});
}
if (!Number.isFinite(line.value)) {
issues.push({
matricule: line.matricule,
code: line.code,
message: 'Valeur non numérique.',
});
}
if (line.value < 0) {
issues.push({
matricule: line.matricule,
code: line.code,
message: 'Valeur négative : Silae attend des décomptes positifs.',
});
}
if (line.endDate < line.startDate) {
issues.push({
matricule: line.matricule,
code: line.code,
message: 'Date de fin antérieure à la date de début.',
});
}
}
return issues;
}
/**
* Sérialise les lignes en CSV Silae.
*
* L'ordre est **déterministe** — matricule, puis code — pour que deux exports
* de la même période produisent le même fichier au bit près. L'import Silae
* écrase la période pour les salariés concernés : un export doit pouvoir être
* rejoué sans effet de bord, et son empreinte doit le prouver.
*/
export function formatSilaeCsv(lines: SilaeLine[]): SilaeExportResult {
const issues = checkSilaeLines(lines);
if (issues.length > 0) {
throw new SilaeExportError(issues);
}
const sorted = [...lines].sort(
(a, b) =>
a.matricule.localeCompare(b.matricule) ||
a.code.localeCompare(b.code) ||
a.startDate.localeCompare(b.startDate),
);
const rows = sorted.map((line) =>
[
toAscii(line.matricule),
toAscii(line.code),
formatSilaeValue(line.value, line.kind),
formatSilaeDate(line.startDate),
formatSilaeDate(line.endDate),
].join(';'),
);
// CRLF final compris : c'est ce que produit l'export de référence.
return {
csv: [SILAE_HEADER, ...rows].join(CRLF) + CRLF,
lineCount: rows.length,
};
}
export class SilaeExportError extends Error {
readonly issues: SilaeExportIssue[];
constructor(issues: SilaeExportIssue[]) {
super(
`Export Silae refusé : ${issues.length} anomalie${issues.length > 1 ? 's' : ''}.`,
);
this.name = 'SilaeExportError';
this.issues = issues;
}
}
/** Empreinte du contenu, pour prouver qu'un réexport est identique. */
export async function checksum(csv: string): Promise<string> {
const bytes = new TextEncoder().encode(csv);
const digest = await crypto.subtle.digest('SHA-256', bytes);
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
+222
View File
@@ -0,0 +1,222 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import { PAYROLL_ELEMENT_DEFINITIONS } from '@/domain/payroll/elements';
import {
checksum,
formatSilaeCsv,
SilaeExportError,
} from '@/domain/payroll/silae';
import { parseMonthParam } from '@/domain/planning/month';
import { recordAudit } from '@/server/audit';
import { mutate } from '@/server/context';
import { buildPayrollPeriod, toSilaeLines } from '@/server/payroll/build';
export interface PayrollActionState {
error?: string;
ok?: boolean;
/** Contenu du fichier, remis au navigateur pour téléchargement. */
csv?: string;
filename?: string;
checksum?: string;
}
class ValidationError extends Error {}
const ELEMENT_KEYS = PAYROLL_ELEMENT_DEFINITIONS.map(
(definition) => definition.key,
) as [string, ...string[]];
const mappingInput = z.object({
sourceKey: z.enum(ELEMENT_KEYS),
silaeCode: z.string().trim().max(80),
confirmed: z.boolean(),
});
/**
* Enregistre la correspondance d'un élément vers un code Silae.
*
* `confirmed` n'est pas une case décorative : l'export refuse de tourner tant
* qu'une correspondance n'a pas été confirmée. Les codes appartiennent au
* dossier du cabinet, et une correspondance devinée produirait une paie fausse
* qui se chargerait sans erreur.
*/
export async function saveMappingAction(
_previous: PayrollActionState,
formData: FormData,
): Promise<PayrollActionState> {
const parsed = mappingInput.safeParse({
sourceKey: formData.get('sourceKey'),
silaeCode: formData.get('silaeCode') ?? '',
confirmed: formData.get('confirmed') === 'on',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const definition = PAYROLL_ELEMENT_DEFINITIONS.find(
(entry) => entry.key === parsed.data.sourceKey,
);
if (!definition) return { error: 'Élément de paie inconnu.' };
try {
await mutate('payroll.export.silae', async (db, actor) => {
if (parsed.data.confirmed && !parsed.data.silaeCode) {
throw new ValidationError(
'Une correspondance ne peut pas être confirmée sans code.',
);
}
const existing = await db.silaeCodeMapping.findFirst({
where: { sourceKey: parsed.data.sourceKey },
});
if (existing) {
await db.silaeCodeMapping.update({
where: { id: existing.id },
data: {
silaeCode: parsed.data.silaeCode,
confirmed: parsed.data.confirmed,
},
});
} else {
await db.silaeCodeMapping.create({
data: {
sourceKey: parsed.data.sourceKey,
silaeCode: parsed.data.silaeCode,
label: definition.label,
kind: definition.kind,
confirmed: parsed.data.confirmed,
} as never,
});
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'payroll.mapping.update',
entityType: 'SilaeCodeMapping',
entityId: parsed.data.sourceKey,
before: existing
? { silaeCode: existing.silaeCode, confirmed: existing.confirmed }
: null,
after: {
silaeCode: parsed.data.silaeCode,
confirmed: parsed.data.confirmed,
},
});
});
} catch (error) {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de modifier les correspondances." };
}
throw error;
}
revalidatePath('/paie/silae');
return { ok: true };
}
const exportInput = z.object({
month: z.string().min(1),
locationId: z.string().min(1),
});
/**
* Produit le fichier Silae de la période.
*
* Le fichier n'est **pas** conservé en base : il porte les heures et les
* absences de salariés identifiables, et sa génération est déterministe. Seule
* l'empreinte est écrite, ce qui suffit à prouver qu'un réexport est identique.
*/
export async function exportSilaeAction(
_previous: PayrollActionState,
formData: FormData,
): Promise<PayrollActionState> {
const parsed = exportInput.safeParse({
month: formData.get('month'),
locationId: formData.get('locationId'),
});
if (!parsed.success) return { error: 'Période ou établissement invalide.' };
const month = parseMonthParam(parsed.data.month);
if (!month) return { error: 'Période invalide.' };
let csv = '';
let digest = '';
let filename = '';
try {
await mutate(
'payroll.export.silae',
async (db, actor) => {
const period = await buildPayrollPeriod(
db,
month,
parsed.data.locationId,
);
if (!period) {
throw new ValidationError(
"Aucune convention collective n'est chargée pour cette période.",
);
}
// 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 (period.rows.length === 0) {
throw new ValidationError(
'Aucun élément de paie sur cette période : rien à exporter.',
);
}
const result = formatSilaeCsv(toSilaeLines(period));
csv = result.csv;
digest = await checksum(csv);
filename = `silae-${period.location.name.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase()}-${parsed.data.month}.csv`;
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`),
checksum: digest,
lineCount: result.lineCount,
generatedBy: actor.membershipId,
} as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'payroll.export.silae',
entityType: 'PayrollExport',
entityId: record.id,
after: {
period: `${period.startDate} → ${period.endDate}`,
lines: result.lineCount,
checksum: digest,
},
});
},
{ locationId: parsed.data.locationId },
);
} catch (error) {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof SilaeExportError) {
return { error: error.issues.map((issue) => issue.message).join(' · ') };
}
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de produire cet export." };
}
throw error;
}
revalidatePath('/paie');
return { ok: true, csv, filename, checksum: digest };
}
+334
View File
@@ -0,0 +1,334 @@
import { splitOvertime, splitComplementary } from '@/domain/compliance/overtime';
import { shiftMinutes } from '@/domain/counters/week';
import {
PAYROLL_ELEMENTS,
elementDefinition,
type PayrollElementKey,
} from '@/domain/payroll/elements';
import type { SilaeLine } from '@/domain/payroll/silae';
import { monthDates, type Month } from '@/domain/planning/month';
import {
isoWeekOf,
weekBounds,
zonedDate,
zonedMidnight,
} from '@/domain/planning/week';
import { agreementFor } from '@/server/compliance/evaluate';
import type { ScopedClient } from '@/server/tenant';
/**
* Construction des éléments de paie d'une période — PLAN.md §8.
*
* Les heures viennent des créneaux **réalisés quand ils le sont**, planifiés
* sinon. Sans pointeuse, c'est le manager qui saisit le réalisé ; tant qu'il ne
* l'a pas fait, le planifié est la meilleure information disponible, et
* l'attendre indéfiniment ne produirait aucune paie.
*/
export interface PayrollRowElement {
key: PayrollElementKey;
label: string;
/** Minutes pour les heures, jours entiers pour les jours. */
value: number;
unit: 'HOURS' | 'DAYS';
silaeCode: string | null;
confirmed: boolean;
}
export interface PayrollRow {
membershipId: string;
employeeNumber: string;
silaeMatricule: string | null;
name: string;
forfaitJours: boolean;
elements: PayrollRowElement[];
}
export interface PayrollPeriod {
month: Month;
startDate: string;
endDate: string;
location: { id: string; name: string; timezone: string };
rows: PayrollRow[];
/** Manques qui empêchent l'export : matricule absent, code non mappé. */
blockers: string[];
}
/** Un salarié dont on suit le mois entier, minute par minute. */
interface Tally {
workedMinutes: number;
workedDays: Set<string>;
sundayMinutes: number;
holidayMinutes: number;
/** Minutes travaillées par semaine ISO, pour découper les majorations. */
byWeek: Map<string, number>;
}
export async function buildPayrollPeriod(
db: ScopedClient,
month: Month,
locationId: string,
): Promise<PayrollPeriod | null> {
const location = await db.location.findUnique({
where: { id: locationId },
select: { id: true, name: true, timezone: true },
});
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 agreement = await agreementFor(db, new Date(`${startDate}T00:00:00Z`));
if (!agreement) return null;
const teams = await db.team.findMany({
where: { locationId, 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 } },
contracts: {
where: { status: 'ACTIVE' },
orderBy: { startDate: 'desc' },
take: 1,
},
},
},
},
});
const memberIds = [
...new Set(assignments.map((assignment) => assignment.membershipId)),
];
const shifts = await db.shift.findMany({
where: { membershipId: { in: memberIds }, startAt: { gte: from, lt: to } },
select: {
membershipId: true,
startAt: true,
endAt: true,
breakMinutes: true,
actualStartAt: true,
actualEndAt: true,
actualBreakMinutes: true,
},
});
const holidays = await db.holiday.findMany({
where: {
locationId,
localDate: {
gte: new Date(`${startDate}T00:00:00Z`),
lte: new Date(`${endDate}T00:00:00Z`),
},
},
select: { localDate: true },
});
const holidayDates = new Set(
holidays.map((holiday) => holiday.localDate.toISOString().slice(0, 10)),
);
const mappings = await db.silaeCodeMapping.findMany();
const mappingByKey = new Map(
mappings.map((mapping) => [mapping.sourceKey, mapping]),
);
const tallies = new Map<string, Tally>();
const tallyFor = (membershipId: string): Tally => {
const existing = tallies.get(membershipId);
if (existing) return existing;
const created: Tally = {
workedMinutes: 0,
workedDays: new Set(),
sundayMinutes: 0,
holidayMinutes: 0,
byWeek: new Map(),
};
tallies.set(membershipId, created);
return created;
};
for (const shift of shifts) {
if (!shift.membershipId) continue;
const tally = tallyFor(shift.membershipId);
// Réalisé s'il est saisi, planifié sinon. Le réalisé prime dès qu'il
// existe : c'est lui qui est dû.
const start = shift.actualStartAt ?? shift.startAt;
const end = shift.actualEndAt ?? shift.endAt;
const pause = shift.actualBreakMinutes ?? shift.breakMinutes;
const minutes = shiftMinutes(start, end, pause);
if (minutes === 0) continue;
const date = zonedDate(start, location.timezone);
tally.workedMinutes += minutes;
tally.workedDays.add(date);
const weekday = new Date(`${date}T00:00:00Z`).getUTCDay();
if (weekday === 0) tally.sundayMinutes += minutes;
if (holidayDates.has(date)) tally.holidayMinutes += minutes;
// Les majorations se calculent par semaine, pas sur le mois : 45 h une
// semaine et 25 h la suivante ne font pas 70 h sans majoration.
const week = isoWeekOf(start);
const key = `${week.isoYear}-${week.isoWeek}`;
tally.byWeek.set(key, (tally.byWeek.get(key) ?? 0) + minutes);
}
const rows: PayrollRow[] = [];
const blockers = new Set<string>();
for (const assignment of assignments) {
const membership = assignment.membership;
const contract = membership.contracts[0];
const tally = tallies.get(assignment.membershipId);
const forfaitJours = contract?.workTimeArrangement === 'FORFAIT_JOURS';
const weeklyMinutes = contract
? Math.round(Number(contract.weeklyHours.toString()) * 60)
: 0;
// Durée mensuelle contractuelle : 35 h hebdomadaires valent 151,67 h par
// mois, soit 52 semaines réparties sur 12 mois.
const monthlyContractMinutes = Math.round((weeklyMinutes * 52) / 12);
const elements: PayrollRowElement[] = [];
const push = (key: PayrollElementKey, value: number) => {
if (value <= 0) return;
const definition = elementDefinition(key);
const mapping = mappingByKey.get(key);
elements.push({
key,
label: definition.label,
value,
unit: definition.unit,
silaeCode: mapping?.silaeCode ?? null,
confirmed: mapping?.confirmed ?? false,
});
};
if (forfaitJours) {
// Les jours de forfait ne sont pas des heures : les exporter comme telles
// produirait une paie fausse (PLAN.md §6.4).
push(PAYROLL_ELEMENTS.FORFAIT_DAYS, tally?.workedDays.size ?? 0);
} else {
push(PAYROLL_ELEMENTS.WORKED_DAYS, tally?.workedDays.size ?? 0);
push(PAYROLL_ELEMENTS.WORKED_HOURS, tally?.workedMinutes ?? 0);
const missing = monthlyContractMinutes - (tally?.workedMinutes ?? 0);
// Un salarié sous contrat sans aucun créneau planifié apparaît quand
// même, avec sa durée contractuelle entière en heures manquantes :
// l'export de référence contient exactement ce cas.
push(PAYROLL_ELEMENTS.MISSING_HOURS, missing);
let overtime25 = 0;
let overtime50 = 0;
let complementary10 = 0;
let complementary25 = 0;
for (const weekMinutes of tally?.byWeek.values() ?? []) {
if (weeklyMinutes > 0 && weeklyMinutes < agreement.parameters.weeklyReferenceMinutes) {
const split = splitComplementary(
weekMinutes,
weeklyMinutes,
agreement.parameters,
);
complementary10 += split.firstTierMinutes;
complementary25 += split.beyondMinutes + split.overCapMinutes;
} else {
const split = splitOvertime(weekMinutes, agreement.parameters);
for (const slice of split.slices) {
if (slice.ratePercent >= 50) overtime50 += slice.minutes;
else overtime25 += slice.minutes;
}
}
}
push(PAYROLL_ELEMENTS.OVERTIME_25, overtime25);
push(PAYROLL_ELEMENTS.OVERTIME_50, overtime50);
push(PAYROLL_ELEMENTS.COMPLEMENTARY_10, complementary10);
push(PAYROLL_ELEMENTS.COMPLEMENTARY_25, complementary25);
}
push(PAYROLL_ELEMENTS.SUNDAY_HOURS, tally?.sundayMinutes ?? 0);
push(PAYROLL_ELEMENTS.HOLIDAY_HOURS, tally?.holidayMinutes ?? 0);
if (elements.length === 0) continue;
const name =
`${membership.profile?.firstName ?? ''} ${membership.profile?.lastName ?? membership.employeeNumber}`.trim();
if (!membership.silaeMatricule) {
blockers.add(`${name} (${membership.employeeNumber}) : matricule Silae absent.`);
}
for (const element of elements) {
if (!element.silaeCode) {
blockers.add(`« ${element.label} » : aucun code Silae associé.`);
} else if (!element.confirmed) {
blockers.add(
`« ${element.label} » → ${element.silaeCode} : correspondance non confirmée par le gestionnaire de paie.`,
);
}
}
rows.push({
membershipId: assignment.membershipId,
employeeNumber: membership.employeeNumber,
silaeMatricule: membership.silaeMatricule,
name,
forfaitJours,
elements,
});
}
rows.sort((a, b) => a.name.localeCompare(b.name, 'fr'));
return {
month,
startDate,
endDate,
location,
rows,
blockers: [...blockers],
};
}
/** Traduit une période en lignes CSV. */
export function toSilaeLines(period: PayrollPeriod): SilaeLine[] {
return period.rows.flatMap((row) =>
row.elements.map((element) => ({
matricule: row.silaeMatricule ?? '',
code: element.silaeCode ?? '',
value: element.value,
kind: element.unit,
// Les agrégats couvrent la période de paie entière. Les absences, qui
// portent leurs propres dates, arriveront avec le lot suivant.
startDate: period.startDate,
endDate: period.endDate,
})),
);
}
/** Semaines ISO couvrant le mois, pour information à l'écran. */
export function weeksOfMonth(month: Month, timeZone: string): string[] {
const dates = monthDates(month);
const weeks = new Set<string>();
for (const date of dates) {
const week = isoWeekOf(new Date(`${date}T12:00:00Z`));
weeks.add(`${week.isoYear}-W${String(week.isoWeek).padStart(2, '0')}`);
void weekBounds(week, timeZone);
}
return [...weeks];
}
+115
View File
@@ -0,0 +1,115 @@
import {
PAYROLL_ELEMENT_DEFINITIONS,
type PayrollElementDefinition,
} from '@/domain/payroll/elements';
import { SILAE_OBSERVED_CODES, SILAE_SERVICE_CODES } from '@/domain/payroll/silae';
import type { Month } from '@/domain/planning/month';
import { query } from '@/server/context';
import { buildPayrollPeriod, type PayrollPeriod } from '@/server/payroll/build';
/**
* Lectures de l'écran de paie.
*
* `payroll.access` ouvre le rapport, `payroll.export.silae` produit le
* fichier : un responsable de magasin doit pouvoir contrôler ses heures sans
* pouvoir transmettre au cabinet.
*/
export interface PayrollView extends PayrollPeriod {
locations: Array<{ id: string; name: string }>;
monthParam: string;
previousParam: string;
nextParam: string;
label: string;
}
export async function getPayrollPeriod(
month: Month,
locationId?: string,
): Promise<PayrollView | null> {
return query(
'payroll.access',
async (db) => {
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 period = await buildPayrollPeriod(db, month, location.id);
if (!period) return null;
const { formatMonthParam, monthLabel, nextMonth, previousMonth } =
await import('@/domain/planning/month');
return {
...period,
locations,
label: monthLabel(month),
monthParam: formatMonthParam(month),
previousParam: formatMonthParam(previousMonth(month)),
nextParam: formatMonthParam(nextMonth(month)),
};
},
locationId ? { locationId } : undefined,
);
}
export interface MappingRow extends PayrollElementDefinition {
silaeCode: string | null;
confirmed: boolean;
}
export interface MappingView {
rows: MappingRow[];
/** Codes relevés dans l'export de référence, proposés à la saisie. */
knownCodes: string[];
exports: Array<{
id: string;
periodStart: Date;
periodEnd: Date;
checksum: string;
lineCount: number;
generatedAt: Date;
}>;
}
export async function getSilaeMapping(): Promise<MappingView> {
return query('payroll.export.silae', async (db) => {
const mappings = await db.silaeCodeMapping.findMany();
const byKey = new Map(mappings.map((entry) => [entry.sourceKey, entry]));
const exports = await db.payrollExport.findMany({
orderBy: { generatedAt: 'desc' },
take: 10,
select: {
id: true,
periodStart: true,
periodEnd: true,
checksum: true,
lineCount: true,
generatedAt: true,
},
});
return {
rows: PAYROLL_ELEMENT_DEFINITIONS.map((definition) => {
const mapping = byKey.get(definition.key);
return {
...definition,
silaeCode: mapping?.silaeCode ?? null,
confirmed: mapping?.confirmed ?? false,
};
}),
knownCodes: [
...Object.values(SILAE_SERVICE_CODES),
...SILAE_OBSERVED_CODES,
],
exports,
};
});
}
+80
View File
@@ -0,0 +1,80 @@
import { expect, test } from '@playwright/test';
import { formatMonthParam, monthOf, previousMonth } from '../../src/domain/planning/month';
/**
* Préparation de paie et export Silae.
*
* Ce que ces tests protègent : le refus de produire un fichier partiel. Un CSV
* incomplet se charge sans erreur dans Silae et rend la paie fausse pour les
* salariés qui en sont absents — l'échec est silencieux jusqu'au bulletin.
*/
// Le seed plante deux semaines autour d'aujourd'hui ; le mois précédent en
// contient donc une partie, quel que soit le jour d'exécution.
const MONTH = formatMonthParam(monthOf(new Date()));
const PREVIOUS = formatMonthParam(previousMonth(monthOf(new Date())));
test('le rapport de paie liste les éléments calculés', async ({ page }) => {
await page.goto(`/paie?mois=${MONTH}`);
await expect(page.getByRole('heading', { name: /^Paie · / })).toBeVisible();
await expect(page.getByText('Heures travaillées').first()).toBeVisible();
await expect(page.getByText('Jours travaillés').first()).toBeVisible();
});
test('l’export refuse de produire un fichier tant qu’un code n’est pas confirmé', async ({
page,
}) => {
await page.goto(`/paie?mois=${MONTH}`);
// Le seed propose les codes lisibles mais n'en confirme aucun.
await expect(
page.getByText(/correspondance non confirmée/i).first(),
).toBeVisible();
const exportButton = page.getByRole('button', { name: 'Exporter vers Silae' });
await expect(exportButton).toBeDisabled();
});
test('l’écran des codes distingue proposer et confirmer', async ({ page }) => {
await page.goto('/paie/silae');
await expect(
page.getByRole('heading', { name: 'Codes de paie Silae' }),
).toBeVisible();
// Les codes relevés sur l'export réel sont proposés à la saisie…
await expect(page.getByText('EV-HDimanche').first()).toBeVisible();
await expect(page.getByText('AB-300').first()).toBeVisible();
// …mais l'écran dit explicitement qu'il ne devine pas leur sens.
await expect(page.getByText(/ne devine jamais leur signification/)).toBeVisible();
// Un code sans confirmation ne suffit pas : la case est un acte distinct.
const row = page
.locator('form')
.filter({ hasText: 'Heures du dimanche' })
.first();
await expect(row.getByRole('checkbox')).not.toBeChecked();
});
test('une correspondance ne peut pas être confirmée sans code', async ({
page,
}) => {
await page.goto('/paie/silae');
const row = page
.locator('form')
.filter({ hasText: 'Jours de forfait' })
.first();
await row.getByRole('checkbox').check();
await row.getByRole('button', { name: 'Enregistrer' }).click();
await expect(row.getByText(/sans code/)).toBeVisible();
});
test('le mois précédent reste consultable', async ({ page }) => {
await page.goto(`/paie?mois=${PREVIOUS}`);
await expect(page.getByRole('heading', { name: /^Paie · / })).toBeVisible();
});
+218
View File
@@ -0,0 +1,218 @@
import { describe, expect, it } from 'vitest';
import {
checkSilaeLines,
checksum,
formatSilaeCsv,
formatSilaeDate,
formatSilaeValue,
minutesToDecimalHours,
SILAE_HEADER,
SilaeExportError,
toAscii,
type SilaeLine,
} from '@/domain/payroll/silae';
/**
* Ces tests reproduisent les conventions relevées sur un export réel du
* dossier (juillet 2026). Le fichier lui-même n'est pas versionné : il contient
* les heures et les absences de salariés identifiables.
*
* Ce sont donc les **règles de forme** qui sont figées ici, avec les valeurs
* exactes observées — un import de paie refusé pour un accent ou une virgule
* coûte une demi-journée, un import accepté avec de mauvais arrondis coûte
* bien davantage.
*/
const h = (hours: number, minutes = 0) => hours * 60 + minutes;
function line(over: Partial<SilaeLine> = {}): SilaeLine {
return {
matricule: '00061',
code: 'Heures travaillees',
value: h(96),
kind: 'HOURS',
startDate: '2026-07-01',
endDate: '2026-07-31',
...over,
};
}
describe('mise en forme des valeurs', () => {
it('rend les heures avec au moins une décimale', () => {
expect(formatSilaeValue(h(96), 'HOURS')).toBe('96.0');
expect(formatSilaeValue(h(105), 'HOURS')).toBe('105.0');
expect(formatSilaeValue(h(1), 'HOURS')).toBe('1.0');
});
it('retire le zéro superflu sans perdre la décimale utile', () => {
expect(formatSilaeValue(h(52, 30), 'HOURS')).toBe('52.5');
expect(formatSilaeValue(h(0, 30), 'HOURS')).toBe('0.5');
expect(formatSilaeValue(h(6, 30), 'HOURS')).toBe('6.5');
});
it('reproduit les arrondis observés au centième', () => {
// Chaque valeur de gauche vient de l'export de référence.
expect(formatSilaeValue(h(69, 40), 'HOURS')).toBe('69.67');
expect(formatSilaeValue(h(4, 50), 'HOURS')).toBe('4.83');
expect(formatSilaeValue(h(3, 5), 'HOURS')).toBe('3.08');
expect(formatSilaeValue(h(4, 35), 'HOURS')).toBe('4.58');
expect(formatSilaeValue(h(5, 45), 'HOURS')).toBe('5.75');
expect(formatSilaeValue(h(116, 40), 'HOURS')).toBe('116.67');
expect(formatSilaeValue(h(166, 15), 'HOURS')).toBe('166.25');
});
it('rend les jours en entier nu', () => {
// Les jours travaillés sortent « 14 », jamais « 14.0 ».
expect(formatSilaeValue(14, 'DAYS')).toBe('14');
expect(formatSilaeValue(22, 'DAYS')).toBe('22');
expect(formatSilaeValue(3, 'DAYS')).toBe('3');
});
it('convertit les minutes en heures décimales', () => {
expect(minutesToDecimalHours(h(4, 50))).toBe(4.83);
expect(minutesToDecimalHours(h(69, 40))).toBe(69.67);
expect(minutesToDecimalHours(0)).toBe(0);
});
});
describe('dates', () => {
it('inverse en JJ/MM/AAAA', () => {
expect(formatSilaeDate('2026-07-01')).toBe('01/07/2026');
expect(formatSilaeDate('2026-07-31')).toBe('31/07/2026');
});
it('refuse une date qui n’est pas une date', () => {
expect(() => formatSilaeDate('juillet')).toThrow(/invalide/);
});
});
describe('ASCII', () => {
it('retire les accents', () => {
// L'export de référence ne contient aucun caractère composé : un salarié
// nommé « Rémi » ne doit pas introduire le premier octet non-ASCII.
expect(toAscii('Absence rémunérée')).toBe('Absence remuneree');
expect(toAscii('Heures travaillées')).toBe('Heures travaillees');
});
it('écarte ce qui n’est pas imprimable en ASCII', () => {
expect(toAscii('AB‑300')).toBe('AB300');
expect(toAscii('café ☕')).toBe('cafe ');
});
});
describe('sérialisation', () => {
it('produit l’en-tête exact', () => {
const { csv } = formatSilaeCsv([line()]);
expect(csv.split('\r\n')[0]).toBe(SILAE_HEADER);
expect(SILAE_HEADER).toBe('Matricule;Code;Valeur;Date debut;Date fin');
});
it('termine chaque ligne par CRLF, la dernière comprise', () => {
const { csv } = formatSilaeCsv([line()]);
expect(csv.endsWith('\r\n')).toBe(true);
expect(csv).not.toMatch(/[^\r]\n/);
});
it('reproduit une ligne de l’export de référence', () => {
const { csv } = formatSilaeCsv([
line({ code: 'Heures travaillees', value: h(96) }),
]);
expect(csv).toContain('00061;Heures travaillees;96.0;01/07/2026;31/07/2026');
});
it('reproduit une ligne d’absence bornée sur ses propres dates', () => {
// Les agrégats couvrent la période de paie ; une absence couvre la période
// d'absence. Les confondre décalerait le décompte d'un mois entier.
const { csv } = formatSilaeCsv([
line({
matricule: '00173',
code: 'AB-630',
value: h(4, 50),
startDate: '2026-07-27',
endDate: '2026-07-27',
}),
]);
expect(csv).toContain('00173;AB-630;4.83;27/07/2026;27/07/2026');
});
it('n’ajoute ni guillemet ni point-virgule final', () => {
const { csv } = formatSilaeCsv([line()]);
const row = csv.split('\r\n')[1] ?? '';
expect(row).not.toContain('"');
expect(row.endsWith(';')).toBe(false);
expect(row.split(';')).toHaveLength(5);
});
it('accepte les deux formes de matricule du dossier', () => {
const { csv } = formatSilaeCsv([
line({ matricule: '00201' }),
line({ matricule: 'COPIGA' }),
]);
expect(csv).toContain('00201;');
expect(csv).toContain('COPIGA;');
});
});
describe('déterminisme', () => {
it('trie de façon stable quel que soit l’ordre d’entrée', () => {
const lines = [
line({ matricule: '00201', code: 'Heures travaillees' }),
line({ matricule: '00061', code: 'Nombre total de jours travailles', value: 14, kind: 'DAYS' }),
line({ matricule: '00061', code: 'AB-300', value: h(11) }),
];
const first = formatSilaeCsv(lines).csv;
const second = formatSilaeCsv([...lines].reverse()).csv;
expect(first).toBe(second);
});
it('produit la même empreinte pour un réexport identique', async () => {
// L'import Silae écrase la période pour les salariés concernés : un export
// doit pouvoir être rejoué sans effet de bord, et l'empreinte le prouve.
const lines = [line(), line({ code: 'AB-300', value: h(11) })];
const a = await checksum(formatSilaeCsv(lines).csv);
const b = await checksum(formatSilaeCsv([...lines].reverse()).csv);
expect(a).toBe(b);
expect(a).toHaveLength(64);
});
});
describe('contrôles préalables', () => {
it('refuse un matricule manquant', () => {
// Un export partiel se charge sans erreur et la paie est fausse pour les
// salariés absents du fichier : il vaut mieux ne rien produire.
const issues = checkSilaeLines([line({ matricule: ' ' })]);
expect(issues[0]?.message).toMatch(/Matricule Silae manquant/);
});
it('refuse un code non renseigné', () => {
const issues = checkSilaeLines([line({ code: '' })]);
expect(issues[0]?.message).toMatch(/Code de paie non renseigné/);
});
it('refuse une valeur négative', () => {
const issues = checkSilaeLines([line({ value: -60 })]);
expect(issues[0]?.message).toMatch(/négative/);
});
it('refuse une période inversée', () => {
const issues = checkSilaeLines([
line({ startDate: '2026-07-31', endDate: '2026-07-01' }),
]);
expect(issues[0]?.message).toMatch(/antérieure/);
});
it('échoue en listant les manques plutôt qu’en produisant un fichier', () => {
expect(() => formatSilaeCsv([line({ matricule: '' })])).toThrow(
SilaeExportError,
);
try {
formatSilaeCsv([line({ matricule: '' }), line({ code: '' })]);
expect.unreachable('Un export incomplet ne doit pas aboutir.');
} catch (error) {
expect((error as SilaeExportError).issues).toHaveLength(2);
}
});
});