diff --git a/PLAN.md b/PLAN.md
index 03d486f..b9b53de 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -866,29 +866,72 @@ Coût prévisionnel = somme des heures × taux horaire du contrat × (1 + `emplo
## 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
-- **CSV, UTF-8, séparateur `;`**
-- En-tête : `matricule;code paie;décompte;date début;date fin;`
-- Une ligne par couple (salarié, code de paie) sur la période.
+### 8.1 Format — **relevé sur un export réel**
+
+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**.
+
+| É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
-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
-- **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`.
-- **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.
+### 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
diff --git a/prisma/migrations/20260808075046_silae_export/migration.sql b/prisma/migrations/20260808075046_silae_export/migration.sql
new file mode 100644
index 0000000..a2be3cc
--- /dev/null
+++ b/prisma/migrations/20260808075046_silae_export/migration.sql
@@ -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();
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index a50ccfb..d977051 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -776,3 +776,61 @@ model AuthorisedSunday {
@@unique([locationId, localDate])
@@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])
+}
diff --git a/prisma/seed.ts b/prisma/seed.ts
index 32b1113..ca73bd0 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -20,6 +20,7 @@ import {
IDCC_1517_PARAMETERS,
IDCC_1517_PROVENANCE,
} from '../src/domain/compliance/idcc1517';
+import { PAYROLL_ELEMENT_DEFINITIONS } from '../src/domain/payroll/elements';
import { evaluateSchedule } from '../src/server/compliance/evaluate';
import { withTenant } from '../src/server/tenant';
@@ -565,6 +566,48 @@ async function main() {
}
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é');
// Le seed produit des plannings, donc des constats : les laisser à calculer
// au premier affichage donnerait une grille faussement conforme.
diff --git a/src/app/(app)/paie/page.tsx b/src/app/(app)/paie/page.tsx
new file mode 100644
index 0000000..277ecac
--- /dev/null
+++ b/src/app/(app)/paie/page.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ const href = (mois: string, etablissement = period.location.id) =>
+ `/paie?mois=${mois}&etablissement=${etablissement}`;
+
+ return (
+
+ 1 ? 's' : ''} · période du ${period.startDate.split('-').reverse().join('/')} au ${period.endDate.split('-').reverse().join('/')}`}
+ actions={
+ <>
+
+ ← Mois précédent
+
+
+ Mois suivant →
+
+
+ Codes Silae
+
+ >
+ }
+ />
+
+
+ L’export est bloqué tant que ces points ne sont pas réglés
+
+ {/* Lister plutôt que compter : « 4 anomalies » n'aide personne à
+ produire la paie du mois. */}
+
+ {period.blockers.map((blocker) => (
+
{blocker}
+ ))}
+
+
+ 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.
+
+
+ ) : null}
+
+
+
+ Les heures viennent du réalisé quand il est saisi, du planifié sinon.
+
+ 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{' '}
+ ne devine jamais leur signification : savoir que{' '}
+ AB-300 existe ne dit pas quelle absence il désigne.
+
+
+ Tant qu’une correspondance n’est pas confirmée par le gestionnaire de
+ paie, l’export refuse de produire le fichier.
+
+
+
+
+
+ Éléments calculés par PlanFlow
+
+ {view.rows.map((row) => (
+
+ ))}
+
+
+
+
+ Codes relevés sur l’export de référence
+
+
+ {view.knownCodes.map((code) => (
+
+ {code}
+
+ ))}
+
+
+
+ {view.exports.length > 0 ? (
+
+
+ Exports produits
+
+ {/* 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. */}
+