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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -68,7 +68,8 @@ export const NAVIGATION: NavSection[] = [
|
||||
label: 'Rapports',
|
||||
items: [
|
||||
{ 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é" },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user