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:
16 files changed
+2050
-15
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user