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
|
## 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
|
### 8.1 Format — **relevé sur un export réel**
|
||||||
- **CSV, UTF-8, séparateur `;`**
|
|
||||||
- En-tête : `matricule;code paie;décompte;date début;date fin;`
|
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**.
|
||||||
- Une ligne par couple (salarié, code de paie) sur la période.
|
|
||||||
|
| É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
|
### 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
|
### 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`.
|
- 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.
|
- 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
|
## 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])
|
@@unique([locationId, localDate])
|
||||||
@@index([accountId])
|
@@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_PARAMETERS,
|
||||||
IDCC_1517_PROVENANCE,
|
IDCC_1517_PROVENANCE,
|
||||||
} from '../src/domain/compliance/idcc1517';
|
} from '../src/domain/compliance/idcc1517';
|
||||||
|
import { PAYROLL_ELEMENT_DEFINITIONS } from '../src/domain/payroll/elements';
|
||||||
import { evaluateSchedule } from '../src/server/compliance/evaluate';
|
import { evaluateSchedule } from '../src/server/compliance/evaluate';
|
||||||
import { withTenant } from '../src/server/tenant';
|
import { withTenant } from '../src/server/tenant';
|
||||||
|
|
||||||
@@ -565,6 +566,48 @@ async function main() {
|
|||||||
}
|
}
|
||||||
console.log(` ${retention.length} politiques`);
|
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é');
|
console.log('→ Évaluation de conformité');
|
||||||
// Le seed produit des plannings, donc des constats : les laisser à calculer
|
// Le seed produit des plannings, donc des constats : les laisser à calculer
|
||||||
// au premier affichage donnerait une grille faussement conforme.
|
// 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',
|
label: 'Rapports',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'heures', label: 'Heures travaillées' },
|
{ 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é" },
|
{ 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