Merge pull request #13 — WP-08 export Silae
WP-08 — Export Silae, format relevé sur un export réel
This commit is contained in:
16 files changed
+2050
-15
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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é" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user