diff --git a/PLAN.md b/PLAN.md index 03d486f..b9b53de 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/prisma/migrations/20260808075046_silae_export/migration.sql b/prisma/migrations/20260808075046_silae_export/migration.sql new file mode 100644 index 0000000..a2be3cc --- /dev/null +++ b/prisma/migrations/20260808075046_silae_export/migration.sql @@ -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(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a50ccfb..d977051 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 32b1113..ca73bd0 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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. diff --git a/src/app/(app)/paie/page.tsx b/src/app/(app)/paie/page.tsx new file mode 100644 index 0000000..277ecac --- /dev/null +++ b/src/app/(app)/paie/page.tsx @@ -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 ( + + + + ); + } + + const href = (mois: string, etablissement = period.location.id) => + `/paie?mois=${mois}&etablissement=${etablissement}`; + + return ( + + 1 ? 's' : ''} · période du ${period.startDate.split('-').reverse().join('/')} au ${period.endDate.split('-').reverse().join('/')}`} + actions={ + <> + + ← Mois précédent + + + Mois suivant → + + + Codes Silae + + + } + /> + +
+ {period.locations.map((location) => ( + + {location.name} + + ))} +
+ + {period.blockers.length > 0 ? ( +
+

+ L’export est bloqué tant que ces points ne sont pas réglés +

+ {/* Lister plutôt que compter : « 4 anomalies » n'aide personne à + produire la paie du mois. */} +
    + {period.blockers.map((blocker) => ( +
  • {blocker}
  • + ))} +
+

+ 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. +

+
+ ) : null} + +
+

+ Les heures viennent du réalisé quand il est saisi, du planifié sinon. +

+ 0} + /> +
+ + {period.rows.length === 0 ? ( +

+ Aucun élément de paie sur cette période. +

+ ) : ( +
+ + + + + + + + + + + + + {period.rows.flatMap((row) => + row.elements.map((element, index) => ( + + + + + + + + )), + )} + +
+ Éléments de paie par salarié, {period.label} +
+ Salarié + + Matricule Silae + + Élément + + Valeur + + Code +
+ {index === 0 ? row.name : ''} + {index === 0 && row.forfaitJours ? ( + + Forfait jours + + ) : null} + + {index === 0 + ? (row.silaeMatricule ?? ( + manquant + )) + : ''} + {element.label} + {element.unit === 'DAYS' + ? element.value + : `${formatMinutes(element.value)} (${minutesToDecimalHours(element.value)})`} + + {element.silaeCode ? ( + + {element.silaeCode} + {element.confirmed ? '' : ' ⚠'} + + ) : ( + + à associer + + )} +
+
+ )} +
+ ); +} diff --git a/src/app/(app)/paie/silae/page.tsx b/src/app/(app)/paie/silae/page.tsx new file mode 100644 index 0000000..e4b68a0 --- /dev/null +++ b/src/app/(app)/paie/silae/page.tsx @@ -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 ( + + 1 ? 's' : ''} confirmée${view.rows.length - pending > 1 ? 's' : ''} sur ${view.rows.length}`} + actions={ + + ← Retour à la paie + + } + /> + +
+

+ 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{' '} + ne devine jamais leur signification : savoir que{' '} + AB-300 existe ne dit pas quelle absence il désigne. +

+

+ Tant qu’une correspondance n’est pas confirmée par le gestionnaire de + paie, l’export refuse de produire le fichier. +

+
+ +
+

+ Éléments calculés par PlanFlow +

+ {view.rows.map((row) => ( + + ))} +
+ +
+

+ Codes relevés sur l’export de référence +

+
    + {view.knownCodes.map((code) => ( +
  • + {code} +
  • + ))} +
+
+ + {view.exports.length > 0 ? ( +
+

+ Exports produits +

+ {/* 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. */} +
    + {view.exports.map((entry) => ( +
  • + + {entry.periodStart.toISOString().slice(0, 10)} →{' '} + {entry.periodEnd.toISOString().slice(0, 10)} + + {entry.lineCount} lignes + + {entry.checksum.slice(0, 16)}… + + + {entry.generatedAt.toISOString().slice(0, 16).replace('T', ' ')} + +
  • + ))} +
+
+ ) : null} +
+ ); +} diff --git a/src/components/payroll/ExportButton.tsx b/src/components/payroll/ExportButton.tsx new file mode 100644 index 0000000..0f2b94a --- /dev/null +++ b/src/components/payroll/ExportButton.tsx @@ -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 ( +
+ + + + {state.error ? ( + + {state.error} + + ) : null} + {state.checksum ? ( + + Empreinte {state.checksum.slice(0, 12)}… + + ) : null} + + +
+ ); +} diff --git a/src/components/payroll/MappingForm.tsx b/src/components/payroll/MappingForm.tsx new file mode 100644 index 0000000..a45070c --- /dev/null +++ b/src/components/payroll/MappingForm.tsx @@ -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 ( +
+ + +
+

{row.label}

+

+ {row.unit === 'DAYS' ? 'Jours' : 'Heures'} · famille {row.kind} + {row.suggestedCode ? null : ' · code à obtenir du dossier'} +

+
+ + + + + + {state.error ? ( + + {state.error} + + ) : null} + {state.ok ? ( + Enregistré + ) : null} + + +
+ ); +} diff --git a/src/components/shell/navigation.ts b/src/components/shell/navigation.ts index e07448a..dc2d7e5 100644 --- a/src/components/shell/navigation.ts +++ b/src/components/shell/navigation.ts @@ -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é" }, ], }, diff --git a/src/domain/payroll/elements.ts b/src/domain/payroll/elements.ts new file mode 100644 index 0000000..5a548cd --- /dev/null +++ b/src/domain/payroll/elements.ts @@ -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; +} diff --git a/src/domain/payroll/silae.ts b/src/domain/payroll/silae.ts new file mode 100644 index 0000000..719fd40 --- /dev/null +++ b/src/domain/payroll/silae.ts @@ -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 { + 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(''); +} diff --git a/src/server/payroll/actions.ts b/src/server/payroll/actions.ts new file mode 100644 index 0000000..ef0d0dd --- /dev/null +++ b/src/server/payroll/actions.ts @@ -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 { + 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 { + 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 }; +} diff --git a/src/server/payroll/build.ts b/src/server/payroll/build.ts new file mode 100644 index 0000000..5d387ee --- /dev/null +++ b/src/server/payroll/build.ts @@ -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; + sundayMinutes: number; + holidayMinutes: number; + /** Minutes travaillées par semaine ISO, pour découper les majorations. */ + byWeek: Map; +} + +export async function buildPayrollPeriod( + db: ScopedClient, + month: Month, + locationId: string, +): Promise { + 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(); + 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(); + + 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(); + 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]; +} diff --git a/src/server/payroll/queries.ts b/src/server/payroll/queries.ts new file mode 100644 index 0000000..a55a70a --- /dev/null +++ b/src/server/payroll/queries.ts @@ -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 { + 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 { + 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, + }; + }); +} diff --git a/tests/e2e/paie.spec.ts b/tests/e2e/paie.spec.ts new file mode 100644 index 0000000..53d3b19 --- /dev/null +++ b/tests/e2e/paie.spec.ts @@ -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(); +}); diff --git a/tests/unit/silae.test.ts b/tests/unit/silae.test.ts new file mode 100644 index 0000000..98f53dd --- /dev/null +++ b/tests/unit/silae.test.ts @@ -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 { + 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); + } + }); +});