Recueillir à l'embauche ce qu'exige le contrat

Créer un salarié demandait cinq champs. Le contrat, la déclaration et le
registre en réclament une vingtaine, et rien ne permettait de les saisir :
le sexe, le nom de naissance, le pays et le département de naissance, la
situation de famille, les personnes à charge, le téléphone fixe, le
complément d'adresse et l'heure d'embauche n'existaient pas en base.

La migration les ajoute, toutes facultatives : un dossier incomplet doit
pouvoir exister — c'est au registre de signaler ce qui lui manque, pas à
la base de refuser l'embauche. Seul l'envoi des plannings par SMS fait
exception : c'est un consentement, donc faux par défaut, la charge de la
preuve pesant sur l'employeur.

Le pays et le département de naissance sont des colonnes à part et non une
commune saisie librement : la déclaration sociale les demande séparément,
et les rétro-extraire échouerait au premier « Bar-le-Duc (Meuse) ».

Le formulaire devient un panneau latéral : une trentaine de champs posés
au centre masquaient l'annuaire, et on embauche en regardant qui est déjà
là. Le matricule y est proposé à la suite du dernier, attribué dans la
transaction pour que deux embauches simultanées ne tombent pas sur le même
rang. Le nom de naissance vaut le nom de famille quand il n'en diffère
pas, plutôt que d'imposer une ressaisie à l'immense majorité des dossiers.

Le responsable hiérarchique et la politique RTT se posent enfin à
l'embauche : les deux modèles existaient sans qu'aucun écran ne les
alimente.

Le registre unique du personnel gagne sa colonne « sexe » et se tient
désormais au nom de naissance — le nom d'usage peut changer sans que la
personne change.

La migration s'applique au redéploiement : l'entrypoint du conteneur passe
`prisma migrate deploy` avant de démarrer le serveur. Elle n'ajoute que des
colonnes nullables et deux types énumérés, sans réécriture de table.
Écrite et validée contre le schéma, non exécutée ici : aucune base n'était
ouverte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-10 17:34:10 +02:00
1 parent e27d61dd4c
commit b608aa87cf
20 files changed
+791 -155

No files matched your search

+8 -6
View File
@@ -116,14 +116,15 @@ test('une pièce sans politique n’est jamais purgée', async ({ page }) => {
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
// en modale, à la demande.
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
const create = page.locator('form').filter({ hasText: 'Ajouter' });
const create = page.locator('form').filter({ hasText: 'Informations salarié' });
await create.getByLabel('Prénom').fill('Garde');
await create.getByLabel('Nom', { exact: true }).fill(`Garde${suffix}`);
await create.getByLabel('Nom de famille').fill(`Garde${suffix}`);
await create.getByRole('button', { name: 'Saisir un matricule' }).click();
await create.getByLabel('Matricule').fill(`RET${suffix}`);
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await create.getByLabel('Ouvrir un contrat maintenant').uncheck();
await create.getByRole('button', { name: 'Ajouter' }).click();
await create.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(`Garde${suffix}`) }).click();
@@ -173,14 +174,15 @@ test('une pièce échue est effectivement effacée', async ({ page }) => {
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
// en modale, à la demande.
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
const create = page.locator('form').filter({ hasText: 'Ajouter' });
const create = page.locator('form').filter({ hasText: 'Informations salarié' });
await create.getByLabel('Prénom').fill('Purge');
await create.getByLabel('Nom', { exact: true }).fill(`Purge${suffix}`);
await create.getByLabel('Nom de famille').fill(`Purge${suffix}`);
await create.getByRole('button', { name: 'Saisir un matricule' }).click();
await create.getByLabel('Matricule').fill(`PUR${suffix}`);
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await create.getByLabel('Ouvrir un contrat maintenant').uncheck();
await create.getByRole('button', { name: 'Ajouter' }).click();
await create.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(`Purge${suffix}`) }).click();
+4 -3
View File
@@ -30,14 +30,15 @@ async function createEmployee(page: Page, tag: string) {
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
// en modale, à la demande.
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
const form = page.locator('form').filter({ hasText: 'Ajouter' });
const form = page.locator('form').filter({ hasText: 'Informations salarié' });
await form.getByLabel('Prénom').fill('Inès');
await form.getByLabel('Nom', { exact: true }).fill(lastName);
await form.getByLabel('Nom de famille').fill(lastName);
await form.getByRole('button', { name: 'Saisir un matricule' }).click();
await form.getByLabel('Matricule').fill(`DOC${suffix}`);
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await form.getByLabel('Ouvrir un contrat maintenant').uncheck();
await form.getByRole('button', { name: 'Ajouter' }).click();
await form.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(lastName) }).click();
+8 -6
View File
@@ -17,14 +17,15 @@ async function createEmployee(page: Page) {
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
// en modale, à la demande.
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
const form = page.locator('form').filter({ hasText: 'Ajouter' });
const form = page.locator('form').filter({ hasText: 'Informations salarié' });
await form.getByLabel('Prénom').fill('Awa');
await form.getByLabel('Nom', { exact: true }).fill(lastName);
await form.getByLabel('Nom de famille').fill(lastName);
await form.getByRole('button', { name: 'Saisir un matricule' }).click();
await form.getByLabel('Matricule').fill(`FIC${suffix}`);
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await form.getByLabel('Ouvrir un contrat maintenant').uncheck();
await form.getByRole('button', { name: 'Ajouter' }).click();
await form.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(lastName) }).click();
@@ -105,9 +106,10 @@ test('une embauche pose le contrat en même temps que le dossier', async ({
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
// en modale, à la demande.
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
const form = page.locator('form').filter({ hasText: 'Ajouter' });
const form = page.locator('form').filter({ hasText: 'Informations salarié' });
await form.getByLabel('Prénom').fill('Sofia');
await form.getByLabel('Nom', { exact: true }).fill(lastName);
await form.getByLabel('Nom de famille').fill(lastName);
await form.getByRole('button', { name: 'Saisir un matricule' }).click();
await form.getByLabel('Matricule').fill(`EMB${suffix}`);
// Le contrat est proposé coché : un dossier créé sans lui n'apparaît sur
@@ -116,7 +118,7 @@ test('une embauche pose le contrat en même temps que le dossier', async ({
form.getByLabel('Ouvrir un contrat maintenant'),
).toBeChecked();
await form.getByLabel('Début du contrat').fill('2026-01-05');
await form.getByRole('button', { name: 'Ajouter' }).click();
await form.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
// Le rattachement paraît dans l'annuaire : un dossier sans contrat y porte
+4 -3
View File
@@ -24,15 +24,16 @@ async function createEmployee(page: Page, tag: string) {
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
// en modale, à la demande.
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
const form = page.locator('form').filter({ hasText: 'Ajouter' });
const form = page.locator('form').filter({ hasText: 'Informations salarié' });
await form.getByLabel('Prénom').fill(firstName);
await form.getByLabel('Nom', { exact: true }).fill(lastName);
await form.getByLabel('Nom de famille').fill(lastName);
await form.getByRole('button', { name: 'Saisir un matricule' }).click();
await form.getByLabel('Matricule').fill(`E2E${suffix}`);
await form.getByLabel('Adresse électronique').fill(email);
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await form.getByLabel('Ouvrir un contrat maintenant').uncheck();
await form.getByRole('button', { name: 'Ajouter' }).click();
await form.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
const row = page.getByRole('link', { name: new RegExp(lastName) });
+5 -2
View File
@@ -58,14 +58,17 @@ test('un salarié sans compte applicatif est créable', async ({ page }) => {
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
// en modale, à la demande.
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
const form = page.locator('form').filter({ hasText: 'Ajouter' });
const form = page.locator('form').filter({ hasText: 'Informations salarié' });
await form.locator('input[name="firstName"]').fill('Sans');
await form.locator('input[name="lastName"]').fill(nom);
// Le matricule est proposé par l'application : ce test en impose un, parce
// qu'il vérifie ensuite qu'on retrouve ce salarié par ce numéro.
await form.getByRole('button', { name: 'Saisir un matricule' }).click();
await form.locator('input[name="employeeNumber"]').fill(matricule);
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await form.getByLabel('Ouvrir un contrat maintenant').uncheck();
await page.getByRole('button', { name: 'Ajouter' }).click();
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
// Rechargement explicite : ce qui est vérifié ici est la persistance et la
+4 -3
View File
@@ -161,15 +161,16 @@ async function inviteWithRole(page: Page, roleLabel: string) {
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
// en modale, à la demande.
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
const create = page.locator('form').filter({ hasText: 'Ajouter' });
const create = page.locator('form').filter({ hasText: 'Informations salarié' });
await create.getByLabel('Prénom').fill('Alix');
await create.getByLabel('Nom', { exact: true }).fill(lastName);
await create.getByLabel('Nom de famille').fill(lastName);
await create.getByRole('button', { name: 'Saisir un matricule' }).click();
await create.getByLabel('Matricule').fill(`ROL${suffix}`);
await create.getByLabel('Adresse électronique').fill(email);
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await create.getByLabel('Ouvrir un contrat maintenant').uncheck();
await create.getByRole('button', { name: 'Ajouter' }).click();
await create.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(lastName) }).click();
+4 -3
View File
@@ -154,15 +154,16 @@ async function createAccessibleEmployee(page: Page) {
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
// en modale, à la demande.
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
const form = page.locator('form').filter({ hasText: 'Ajouter' });
const form = page.locator('form').filter({ hasText: 'Informations salarié' });
await form.getByLabel('Prénom').fill('Noé');
await form.getByLabel('Nom', { exact: true }).fill(lastName);
await form.getByLabel('Nom de famille').fill(lastName);
await form.getByRole('button', { name: 'Saisir un matricule' }).click();
await form.getByLabel('Matricule').fill(`MFA${suffix}`);
await form.getByLabel('Adresse électronique').fill(email);
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await form.getByLabel('Ouvrir un contrat maintenant').uncheck();
await form.getByRole('button', { name: 'Ajouter' }).click();
await form.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(lastName) }).click();
+19
View File
@@ -117,3 +117,22 @@ describe('rendu du registre', () => {
expect(Buffer.from(pdf).subarray(0, 5).toString()).toBe('%PDF-');
});
});
describe('matricule proposé', () => {
it('part à E0001 sur un effectif vide', async () => {
const { nextEmployeeNumber } = await import('@/domain/hr/civil-status');
expect(nextEmployeeNumber([])).toBe('E0001');
});
it('suit le dernier rang attribué', async () => {
const { nextEmployeeNumber } = await import('@/domain/hr/civil-status');
expect(nextEmployeeNumber(['E0001', 'E0007', 'E0003'])).toBe('E0008');
});
it('ignore les matricules repris d’un autre outil', async () => {
const { nextEmployeeNumber } = await import('@/domain/hr/civil-status');
// Un client qui migre apporte ses propres formes : les compter fausserait
// le rang, et refuser l'embauche pour cette raison serait absurde.
expect(nextEmployeeNumber(['SILAE-42', '00012', 'E0002'])).toBe('E0003');
});
});