From 3bf2cbabff1887b66797ebc52e6040d508dddea3 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 10 Aug 2026 16:46:15 +0200 Subject: [PATCH] =?UTF-8?q?=C3=89diter=20le=20registre=20unique=20du=20per?= =?UTF-8?q?sonnel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le bouton menait au registre de paramétrage juridique — un autre document, qui ne répond à aucune demande d'inspection. Le registre unique du personnel n'existait pas. Il se tient par établissement : celui-ci est demandé avant l'édition plutôt que déduit, car en tirer un au hasard pour une entreprise qui compte trente sites ne répondrait à rien. Une ligne par contrat et non par personne : un salarié réembauché a deux entrées et deux sorties, et les fondre effacerait l'interruption. Les partis y figurent aussi — c'est l'historique que l'inspection vient chercher. Les mentions manquantes sont annoncées avant le téléchargement, et comptées par salarié parce que la contravention l'est aussi. Découvrir un registre incomplet en l'ouvrant, c'est le découvrir devant l'inspection. L'édition est journalisée : le document rassemble l'identité, la nationalité et le parcours de tout le personnel d'un site. Une réserve, portée par le code et par le document : PlanFlow ne collecte pas le sexe, mention pourtant exigée. La colonne reste vide et alimente le décompte des dossiers incomplets, en attendant le champ. Co-Authored-By: Claude Opus 5 --- package.json | 1 + pnpm-lock.yaml | 37 ++++++ src/app/(app)/equipe/RegisterDialog.tsx | 141 ++++++++++++++++++++ src/app/(app)/equipe/page.tsx | 14 +- src/app/equipe/registre/route.ts | 60 +++++++++ src/domain/legal/rup.ts | 73 +++++++++++ src/server/employees/rup-pdf.ts | 164 ++++++++++++++++++++++++ src/server/employees/rup.ts | 163 +++++++++++++++++++++++ tests/e2e/rup.spec.ts | 46 +++++++ tests/support/server-only.ts | 10 ++ tests/unit/rup.test.ts | 119 +++++++++++++++++ vitest.config.ts | 5 + 12 files changed, 827 insertions(+), 6 deletions(-) create mode 100644 src/app/(app)/equipe/RegisterDialog.tsx create mode 100644 src/app/equipe/registre/route.ts create mode 100644 src/domain/legal/rup.ts create mode 100644 src/server/employees/rup-pdf.ts create mode 100644 src/server/employees/rup.ts create mode 100644 tests/e2e/rup.spec.ts create mode 100644 tests/support/server-only.ts create mode 100644 tests/unit/rup.test.ts diff --git a/package.json b/package.json index a92abe3..137e95f 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@prisma/client": "7.9.1", "next": "16.3.0", "nodemailer": "^9.0.5", + "pdf-lib": "^1.17.1", "pg": "8.22.0", "qrcode": "^1.5.4", "react": "19.2.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 445f77b..c236671 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: nodemailer: specifier: ^9.0.5 version: 9.0.5 + pdf-lib: + specifier: ^1.17.1 + version: 1.17.1 pg: specifier: 8.22.0 version: 8.22.0 @@ -769,6 +772,12 @@ packages: '@oxc-project/types@0.143.0': resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} + '@pdf-lib/standard-fonts@1.0.0': + resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==} + + '@pdf-lib/upng@1.0.1': + resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} + '@playwright/test@1.62.1': resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} engines: {node: '>=20'} @@ -2689,6 +2698,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2707,6 +2719,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pdf-lib@1.17.1: + resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} + perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} @@ -3137,6 +3152,9 @@ packages: tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -3900,6 +3918,14 @@ snapshots: '@oxc-project/types@0.143.0': {} + '@pdf-lib/standard-fonts@1.0.0': + dependencies: + pako: 1.0.11 + + '@pdf-lib/upng@1.0.1': + dependencies: + pako: 1.0.11 + '@playwright/test@1.62.1': dependencies: playwright: 1.62.1 @@ -5943,6 +5969,8 @@ snapshots: p-try@2.2.0: {} + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5955,6 +5983,13 @@ snapshots: pathe@2.0.3: {} + pdf-lib@1.17.1: + dependencies: + '@pdf-lib/standard-fonts': 1.0.0 + '@pdf-lib/upng': 1.0.1 + pako: 1.0.11 + tslib: 1.14.1 + perfect-debounce@2.1.0: {} pg-cloudflare@1.4.0: @@ -6432,6 +6467,8 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tslib@1.14.1: {} + tslib@2.8.1: {} tsx@4.20.6: diff --git a/src/app/(app)/equipe/RegisterDialog.tsx b/src/app/(app)/equipe/RegisterDialog.tsx new file mode 100644 index 0000000..7c82614 --- /dev/null +++ b/src/app/(app)/equipe/RegisterDialog.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +import { Button } from '@/components/ui/Button'; + +/** + * Édition du registre unique du personnel. + * + * L'établissement est demandé avant le téléchargement, et non déduit : le + * registre se tient **par établissement**, et en tirer un au hasard pour une + * entreprise qui en compte trente ne répondrait à aucune demande d'inspection. + * + * L'avertissement sur les mentions manquantes arrive avant le fichier, pas + * dedans : découvrir un registre incomplet en l'ouvrant, c'est le découvrir + * devant l'inspection. + */ + +export interface RegisterLocation { + id: string; + name: string; + incomplete: number; + total: number; +} + +export function RegisterDialog({ + locations, +}: { + locations: RegisterLocation[]; +}) { + const dialog = useRef(null); + const [open, setOpen] = useState(false); + const [locationId, setLocationId] = useState(''); + + useEffect(() => { + const element = dialog.current; + if (!element) return; + if (open && !element.open) element.showModal(); + if (!open && element.open) element.close(); + }, [open]); + + const chosen = locations.find((location) => location.id === locationId); + + return ( + <> + + + setOpen(false)} + className="m-auto w-[min(38rem,calc(100vw-2rem))] rounded-3 border border-line-1 bg-surface p-0 text-ink-1 backdrop:bg-[rgb(0_0_0/0.4)]" + > +
+

+ Téléchargez votre registre unique du personnel +

+ + +
+ +
+

+ Tout employeur doit tenir un registre unique du personnel par + établissement. Aucune forme n’est imposée : en cas de contrôle par + l’inspection du travail, le format numérique peut être présenté. +

+ + + + {chosen && chosen.incomplete > 0 ? ( +
+

+ Des mentions manquent pour {chosen.incomplete} salarié + {chosen.incomplete > 1 ? 's' : ''} de cet établissement +

+

+ Le registre reste éditable, et les manques y figurent en clair. + Un registre incomplet expose à une contravention par salarié + concerné : complétez les dossiers avant de le présenter. +

+
+ ) : null} + + {chosen && chosen.total === 0 ? ( +

+ Aucun contrat n’est rattaché à cet établissement : le registre + sera vide. +

+ ) : null} +
+ + +
+ + ); +} diff --git a/src/app/(app)/equipe/page.tsx b/src/app/(app)/equipe/page.tsx index 27502ac..cc6515d 100644 --- a/src/app/(app)/equipe/page.tsx +++ b/src/app/(app)/equipe/page.tsx @@ -3,6 +3,8 @@ import { Suspense } from 'react'; import { AddEmployeeDialog } from '@/app/(app)/equipe/AddEmployeeDialog'; import { DirectoryFilters } from '@/app/(app)/equipe/DirectoryFilters'; +import { RegisterDialog } from '@/app/(app)/equipe/RegisterDialog'; +import { listRegisterLocations } from '@/server/employees/rup'; import { PageBody, PageHeader } from '@/components/shell/PageHeader'; import { Badge, type Tone } from '@/components/ui/Badge'; import { EmptyState } from '@/components/ui/Card'; @@ -51,6 +53,9 @@ export default async function EquipePage({ // formulaire s'en tient à l'identité. const hiringLocations = await listContractLocations().catch(() => []); + // Sans `members.register.export`, le bouton du registre ne paraît pas. + const registerLocations = await listRegisterLocations().catch(() => []); + const filtered = directory.rows.length !== directory.total; return ( @@ -64,12 +69,9 @@ export default async function EquipePage({ : {})} actions={ <> - - Registre unique du personnel - + {registerLocations.length > 0 ? ( + + ) : null} } diff --git a/src/app/equipe/registre/route.ts b/src/app/equipe/registre/route.ts new file mode 100644 index 0000000..9468592 --- /dev/null +++ b/src/app/equipe/registre/route.ts @@ -0,0 +1,60 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { asciiFileName, sanitiseFileName } from '@/domain/documents/rules'; +import { recordAudit } from '@/server/audit'; +import { query } from '@/server/context'; +import { getRegisterData } from '@/server/employees/rup'; +import { renderRegisterPdf } from '@/server/employees/rup-pdf'; + +/** + * Téléchargement du registre unique du personnel. + * + * Une route et non une action : le document est un fichier, pas un écran, et + * son édition doit pouvoir être rejouée par une simple adresse — c'est aussi + * ce qui permet de la présenter depuis un poste d'inspection. + * + * L'édition est journalisée. Le registre rassemble l'identité, la nationalité + * et le parcours de tout le personnel d'un établissement : savoir qui l'a + * extrait, et quand, fait partie de ce qu'on doit pouvoir répondre. + */ +export async function GET(request: NextRequest): Promise { + const locationId = request.nextUrl.searchParams.get('etablissement'); + if (!locationId) { + return NextResponse.json( + { error: 'Choisissez un établissement.' }, + { status: 400 }, + ); + } + + const data = await getRegisterData(locationId); + if (!data) { + return NextResponse.json( + { error: 'Établissement introuvable.' }, + { status: 404 }, + ); + } + + await query('members.register.export', async (db, actor) => + recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'members.register.export', + entityType: 'Location', + entityId: data.locationId, + after: { lines: data.people.length, incomplete: data.incomplete }, + }), + ); + + const pdf = await renderRegisterPdf(data); + const stamp = new Date().toISOString().slice(0, 10).split('-').reverse().join('_'); + const name = sanitiseFileName(`RUP - ${data.locationName} - ${stamp}.pdf`); + + return new NextResponse(pdf as BodyInit, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${asciiFileName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`, + // Un registre se lit à l'instant où il est demandé : le mettre en cache + // ferait présenter un effectif périmé au contrôle suivant. + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/src/domain/legal/rup.ts b/src/domain/legal/rup.ts new file mode 100644 index 0000000..a378002 --- /dev/null +++ b/src/domain/legal/rup.ts @@ -0,0 +1,73 @@ +/** + * Registre unique du personnel — articles L1221-13 et D1221-23 du code du + * travail. + * + * Tenu par établissement et non par entreprise : c'est l'inspection d'un + * établissement donné qui le réclame, et un registre global ne répondrait pas + * à la demande. + * + * Les mentions sont fixées par le texte. Elles sont énumérées ici plutôt que + * dispersées dans le rendu, pour qu'une relecture juridique porte sur un seul + * endroit. + */ + +export interface RupPerson { + lastName: string; + firstName: string; + /** Mention exigée. Non collectée par PlanFlow à ce jour. */ + sex: string | null; + nationality: string | null; + birthDate: Date | null; + /** Emploi tenu, tel que porté par le contrat. */ + jobTitle: string | null; + qualification: string | null; + contractLabel: string | null; + entryDate: Date | null; + exitDate: Date | null; +} + +export interface RupColumn { + key: keyof RupPerson; + label: string; + /** Part de la largeur utile. La somme vaut 1. */ + width: number; +} + +/** + * L'ordre suit celui du texte réglementaire : identité, puis situation, puis + * dates. Un registre réordonné se relit mal en regard de l'article. + */ +export const RUP_COLUMNS: RupColumn[] = [ + { key: 'lastName', label: 'Nom', width: 0.13 }, + { key: 'firstName', label: 'Prénom', width: 0.11 }, + { key: 'sex', label: 'Sexe', width: 0.05 }, + { key: 'nationality', label: 'Nationalité', width: 0.1 }, + { key: 'birthDate', label: 'Date de naissance', width: 0.1 }, + { key: 'jobTitle', label: 'Emploi', width: 0.14 }, + { key: 'qualification', label: 'Qualification', width: 0.11 }, + { key: 'contractLabel', label: 'Contrat', width: 0.08 }, + { key: 'entryDate', label: 'Entrée', width: 0.09 }, + { key: 'exitDate', label: 'Sortie', width: 0.09 }, +]; + +/** + * Mentions obligatoires manquantes pour une personne. + * + * La date de sortie est exclue : son absence signifie que le salarié est + * toujours présent, ce qui est une information et non un oubli. + */ +export function missingMentions(person: RupPerson): string[] { + return RUP_COLUMNS.filter((column) => { + if (column.key === 'exitDate') return false; + const value = person[column.key]; + return value === null || value === ''; + }).map((column) => column.label); +} + +/** + * Une contravention par salarié concerné, et non une par registre : c'est ce + * qui justifie de compter les personnes plutôt que les champs. + */ +export function peopleWithGaps(people: RupPerson[]): number { + return people.filter((person) => missingMentions(person).length > 0).length; +} diff --git a/src/server/employees/rup-pdf.ts b/src/server/employees/rup-pdf.ts new file mode 100644 index 0000000..999c65b --- /dev/null +++ b/src/server/employees/rup-pdf.ts @@ -0,0 +1,164 @@ +import 'server-only'; + +import { PDFDocument, StandardFonts, rgb, type PDFFont } from 'pdf-lib'; + +import { RUP_COLUMNS, type RupPerson } from '@/domain/legal/rup'; +import type { RupData } from '@/server/employees/rup'; + +/** + * Rendu du registre unique du personnel. + * + * Aucune forme n'est imposée par la loi : ce qui compte est que les mentions y + * soient, lisibles, et que le document puisse être présenté tel quel en cas de + * contrôle. D'où un tableau paysage sobre plutôt qu'une mise en page. + * + * Helvetica et l'encodage WinAnsi : les accents français y sont, et une police + * embarquée alourdirait le fichier sans rien apporter à un tableau. + */ + +const A4_LANDSCAPE: [number, number] = [841.89, 595.28]; +const MARGIN = 32; +const ROW_HEIGHT = 18; +const FONT_SIZE = 8; + +const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'short' }); + +function cell(person: RupPerson, key: keyof RupPerson): string { + const value = person[key]; + if (value === null || value === '') return '—'; + if (value instanceof Date) return dateFormat.format(value); + return String(value); +} + +/** Coupe au lieu de déborder : une colonne qui empiète sur la suivante rend + * les deux illisibles. */ +function fit(text: string, font: PDFFont, size: number, width: number): string { + if (font.widthOfTextAtSize(text, size) <= width) return text; + let cut = text; + while (cut.length > 1 && font.widthOfTextAtSize(`${cut}…`, size) > width) { + cut = cut.slice(0, -1); + } + return `${cut}…`; +} + +export async function renderRegisterPdf(data: RupData): Promise { + const pdf = await PDFDocument.create(); + pdf.setTitle(`Registre unique du personnel — ${data.locationName}`); + pdf.setCreator('PlanFlow'); + + const regular = await pdf.embedFont(StandardFonts.Helvetica); + const bold = await pdf.embedFont(StandardFonts.HelveticaBold); + + const usable = A4_LANDSCAPE[0] - MARGIN * 2; + const widths = RUP_COLUMNS.map((column) => column.width * usable); + const ink = rgb(0.11, 0.11, 0.11); + const faint = rgb(0.62, 0.62, 0.62); + const rule = rgb(0.85, 0.85, 0.85); + + const printedOn = dateFormat.format(new Date()); + let page = pdf.addPage(A4_LANDSCAPE); + let y = A4_LANDSCAPE[1] - MARGIN; + + const header = () => { + page.drawText('Registre unique du personnel', { + x: MARGIN, + y: y - 12, + size: 14, + font: bold, + color: ink, + }); + page.drawText( + [ + data.locationName, + data.siret ? `SIRET ${data.siret}` : null, + `édité le ${printedOn}`, + ] + .filter(Boolean) + .join(' · '), + { x: MARGIN, y: y - 28, size: 9, font: regular, color: faint }, + ); + y -= 48; + + let x = MARGIN; + RUP_COLUMNS.forEach((column, index) => { + page.drawText(column.label, { + x, + y: y - 10, + size: FONT_SIZE, + font: bold, + color: ink, + }); + x += widths[index] as number; + }); + y -= 16; + page.drawLine({ + start: { x: MARGIN, y }, + end: { x: MARGIN + usable, y }, + thickness: 0.7, + color: rule, + }); + y -= 4; + }; + + header(); + + for (const person of data.people) { + // Nouvelle page avant d'écrire, pas après : une ligne à moitié sous la + // marge est pire qu'une page de plus. + if (y - ROW_HEIGHT < MARGIN + 24) { + page = pdf.addPage(A4_LANDSCAPE); + y = A4_LANDSCAPE[1] - MARGIN; + header(); + } + + let x = MARGIN; + RUP_COLUMNS.forEach((column, index) => { + const width = widths[index] as number; + const value = cell(person, column.key); + page.drawText(fit(value, regular, FONT_SIZE, width - 6), { + x, + y: y - 12, + size: FONT_SIZE, + font: regular, + color: value === '—' ? faint : ink, + }); + x += width; + }); + + y -= ROW_HEIGHT; + page.drawLine({ + start: { x: MARGIN, y: y + 4 }, + end: { x: MARGIN + usable, y: y + 4 }, + thickness: 0.4, + color: rule, + }); + } + + if (data.people.length === 0) { + page.drawText('Aucun salarié rattaché à cet établissement.', { + x: MARGIN, + y: y - 12, + size: 9, + font: regular, + color: faint, + }); + } + + // Mention de pied : un registre incomplet reste un registre, mais celui qui + // le présente doit savoir ce qui lui manque avant l'inspection. + const pages = pdf.getPages(); + pages.forEach((current, index) => { + current.drawText( + [ + `Page ${index + 1} sur ${pages.length}`, + `${data.people.length} ligne${data.people.length > 1 ? 's' : ''}`, + data.incomplete > 0 + ? `${data.incomplete} dossier${data.incomplete > 1 ? 's' : ''} incomplet${data.incomplete > 1 ? 's' : ''}` + : 'Mentions complètes', + ].join(' · '), + { x: MARGIN, y: MARGIN - 12, size: 7, font: regular, color: faint }, + ); + }); + + return pdf.save(); +} diff --git a/src/server/employees/rup.ts b/src/server/employees/rup.ts new file mode 100644 index 0000000..1551e99 --- /dev/null +++ b/src/server/employees/rup.ts @@ -0,0 +1,163 @@ +import 'server-only'; + +import { cache } from 'react'; + +import type { RupPerson } from '@/domain/legal/rup'; +import { peopleWithGaps } from '@/domain/legal/rup'; +import { query } from '@/server/context'; +import { contractLabel } from '@/server/employees/queries'; + +/** + * Registre unique du personnel — lecture. + * + * Le registre porte **tous** ceux qui ont été employés dans l'établissement, + * y compris les partis : c'est précisément l'historique que l'inspection vient + * chercher. Filtrer sur les présents en ferait un trombinoscope. + */ + +export interface RupData { + locationId: string; + locationName: string; + siret: string | null; + people: RupPerson[]; + /** Nombre de salariés à qui il manque au moins une mention obligatoire. */ + incomplete: number; +} + +export const getRegisterData = cache(async function getRegisterData( + locationId: string, +): Promise { + return query('members.register.export', async (db) => { + const location = await db.location.findUnique({ + where: { id: locationId }, + select: { id: true, name: true, siret: true }, + }); + if (!location) return null; + + const contracts = await db.userContract.findMany({ + where: { locationId }, + orderBy: { startDate: 'asc' }, + include: { + membership: { + select: { + profile: { + select: { + firstName: true, + lastName: true, + nationality: true, + birthDate: true, + }, + }, + }, + }, + }, + }); + + const jobTitleIds = [ + ...new Set( + contracts + .map((contract) => contract.jobTitleId) + .filter((id): id is string => Boolean(id)), + ), + ]; + const jobTitles = await db.jobTitle.findMany({ + where: { id: { in: jobTitleIds } }, + select: { id: true, name: true }, + }); + const jobTitleNames = new Map( + jobTitles.map((entry) => [entry.id, entry.name]), + ); + + // Une ligne par contrat, pas par personne : un salarié réembauché a deux + // entrées et deux sorties, et les fondre effacerait l'interruption. + const people: RupPerson[] = contracts.map((contract) => ({ + lastName: contract.membership.profile?.lastName ?? '', + firstName: contract.membership.profile?.firstName ?? '', + // PlanFlow ne collecte pas le sexe : la mention est exigée, la colonne + // reste donc vide et le décompte des dossiers incomplets le signale. + sex: null, + nationality: contract.membership.profile?.nationality ?? null, + birthDate: contract.membership.profile?.birthDate ?? null, + jobTitle: contract.jobTitleId + ? (jobTitleNames.get(contract.jobTitleId) ?? null) + : null, + qualification: contract.classification, + contractLabel: contractLabel(contract.contractType), + entryDate: contract.startDate, + exitDate: contract.endDate, + })); + + return { + locationId: location.id, + locationName: location.name, + siret: location.siret, + people, + incomplete: peopleWithGaps(people), + }; + }); +}); + +/** Établissements pour lesquels un registre peut être tiré. */ +export const listRegisterLocations = cache( + async function listRegisterLocations(): Promise< + Array<{ id: string; name: string; incomplete: number; total: number }> + > { + return query('members.register.export', async (db) => { + const locations = await db.location.findMany({ + where: { archivedAt: null }, + select: { id: true, name: true }, + orderBy: { name: 'asc' }, + }); + + // Le décompte des manques est calculé ici pour que l'écran puisse + // avertir **avant** le téléchargement : découvrir un registre incomplet + // en l'ouvrant, c'est le découvrir devant l'inspection. + return Promise.all( + locations.map(async (location) => { + const contracts = await db.userContract.findMany({ + where: { locationId: location.id }, + select: { + classification: true, + jobTitleId: true, + contractType: true, + startDate: true, + endDate: true, + membership: { + select: { + profile: { + select: { + firstName: true, + lastName: true, + nationality: true, + birthDate: true, + }, + }, + }, + }, + }, + }); + + const people: RupPerson[] = contracts.map((contract) => ({ + lastName: contract.membership.profile?.lastName ?? '', + firstName: contract.membership.profile?.firstName ?? '', + sex: null, + nationality: contract.membership.profile?.nationality ?? null, + birthDate: contract.membership.profile?.birthDate ?? null, + jobTitle: contract.jobTitleId ? 'x' : null, + qualification: contract.classification, + contractLabel: contractLabel(contract.contractType), + entryDate: contract.startDate, + exitDate: contract.endDate, + })); + + return { + id: location.id, + name: location.name, + incomplete: peopleWithGaps(people), + total: people.length, + }; + }), + ); + }); + }, +); diff --git a/tests/e2e/rup.spec.ts b/tests/e2e/rup.spec.ts new file mode 100644 index 0000000..da78961 --- /dev/null +++ b/tests/e2e/rup.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test'; + +/** + * Registre unique du personnel — édition. + * + * Ce qui est éprouvé n'est pas la mise en page, mais qu'un fichier sorte et + * qu'il porte le bon établissement : un registre édité pour le mauvais site ne + * répond à aucune demande d'inspection. + */ + +test('le registre s’édite par établissement', async ({ page }) => { + await page.goto('/equipe'); + + await page.getByRole('button', { name: 'Registre unique du personnel' }).click(); + await expect( + page.getByRole('heading', { + name: 'Téléchargez votre registre unique du personnel', + }), + ).toBeVisible(); + + // Sans établissement, rien à télécharger : le registre se tient par site. + const download = page.getByRole('link', { name: 'Télécharger' }); + await expect(download).toHaveAttribute('aria-disabled', 'true'); + + const select = page.getByLabel('Établissement pour lequel éditer le registre'); + const value = await select.locator('option').nth(1).getAttribute('value'); + await select.selectOption(value!); + + const started = page.waitForEvent('download'); + await download.click(); + const file = await started; + + expect(file.suggestedFilename()).toMatch(/^RUP - .+\.pdf$/); + + // Le contenu est bien un PDF, pas une page d'erreur renvoyée en 200. + const path = await file.path(); + const { readFileSync } = await import('node:fs'); + expect(readFileSync(path).subarray(0, 5).toString()).toBe('%PDF-'); +}); + +test('l’adresse du registre refuse un établissement absent', async ({ + page, +}) => { + const response = await page.goto('/equipe/registre'); + expect(response?.status()).toBe(400); +}); diff --git a/tests/support/server-only.ts b/tests/support/server-only.ts new file mode 100644 index 0000000..4cfd8ad --- /dev/null +++ b/tests/support/server-only.ts @@ -0,0 +1,10 @@ +/** + * Doublure de `server-only`. + * + * Le paquet est fourni par Next au moment du bundling : il n'existe pas dans + * `node_modules`, et son seul rôle est de faire échouer la compilation d'un + * module serveur importé depuis un composant client. Hors de Next, il n'a rien + * à garantir — mais son absence empêcherait de tester les modules qui le + * déclarent. + */ +export {}; diff --git a/tests/unit/rup.test.ts b/tests/unit/rup.test.ts new file mode 100644 index 0000000..8db0768 --- /dev/null +++ b/tests/unit/rup.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; + +import { + RUP_COLUMNS, + missingMentions, + peopleWithGaps, + type RupPerson, +} from '@/domain/legal/rup'; + +/** + * Registre unique du personnel — mentions obligatoires. + * + * La sanction se compte **par salarié concerné**, pas par registre : c'est ce + * que le décompte doit refléter, et c'est ce qui décide de l'avertissement + * affiché avant l'édition. + */ + +const complete: RupPerson = { + lastName: 'Ferrand', + firstName: 'Camille', + sex: 'F', + nationality: 'France', + birthDate: new Date('1990-04-12T00:00:00Z'), + jobTitle: 'Hôte de caisse', + qualification: 'Niveau 3', + contractLabel: 'CDI', + entryDate: new Date('2024-09-01T00:00:00Z'), + exitDate: null, +}; + +describe('registre unique du personnel', () => { + it('énumère les mentions fixées par le texte', () => { + expect(RUP_COLUMNS.map((column) => column.key)).toEqual([ + 'lastName', + 'firstName', + 'sex', + 'nationality', + 'birthDate', + 'jobTitle', + 'qualification', + 'contractLabel', + 'entryDate', + 'exitDate', + ]); + }); + + it('répartit la largeur sur la totalité de la page', () => { + const total = RUP_COLUMNS.reduce((sum, column) => sum + column.width, 0); + expect(total).toBeCloseTo(1, 5); + }); + + it('ne réclame rien quand toutes les mentions sont là', () => { + expect(missingMentions(complete)).toEqual([]); + }); + + it('tient une date de sortie absente pour une présence, pas pour un oubli', () => { + // Un salarié en poste n'a pas de date de sortie : la réclamer ferait + // apparaître tout l'effectif courant comme incomplet. + expect(missingMentions({ ...complete, exitDate: null })).toEqual([]); + }); + + it('nomme les mentions manquantes', () => { + expect( + missingMentions({ ...complete, nationality: null, qualification: '' }), + ).toEqual(['Nationalité', 'Qualification']); + }); + + it('compte les personnes concernées, pas les champs vides', () => { + const bancal: RupPerson = { + ...complete, + sex: null, + nationality: null, + birthDate: null, + }; + // Trois manques sur une seule personne : une contravention, pas trois. + expect(peopleWithGaps([complete, bancal])).toBe(1); + }); +}); + +describe('rendu du registre', () => { + it('produit un PDF paginé qui porte les manques en pied', async () => { + const { renderRegisterPdf } = await import('@/server/employees/rup-pdf'); + + // Assez de lignes pour dépasser une page : c'est la rupture que le rendu + // rate le plus volontiers. + const people: RupPerson[] = Array.from({ length: 60 }, (_, index) => ({ + ...complete, + lastName: `Nom manifestement trop long pour la colonne ${index}`, + sex: index % 2 === 0 ? null : 'F', + })); + + const pdf = await renderRegisterPdf({ + locationId: 'loc', + locationName: 'La Foir’Fouille', + siret: '12345678901234', + people, + incomplete: 30, + }); + + expect(Buffer.from(pdf).subarray(0, 5).toString()).toBe('%PDF-'); + expect(pdf.length).toBeGreaterThan(1000); + }); + + it('édite un registre vide plutôt que de refuser', async () => { + const { renderRegisterPdf } = await import('@/server/employees/rup-pdf'); + + // Un établissement qui vient d'ouvrir n'a personne : le registre existe + // quand même, et son absence serait plus embarrassante que sa vacuité. + const pdf = await renderRegisterPdf({ + locationId: 'loc', + locationName: 'Nouvel établissement', + siret: null, + people: [], + incomplete: 0, + }); + + expect(Buffer.from(pdf).subarray(0, 5).toString()).toBe('%PDF-'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 5bd4606..3d15573 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,11 @@ export default defineConfig({ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), + // Fourni par Next au bundling, absent de `node_modules` : sans doublure, + // aucun module serveur qui le déclare ne serait testable. + 'server-only': fileURLToPath( + new URL('./tests/support/server-only.ts', import.meta.url), + ), }, }, test: {