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

+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>
);
}