WP-07 : heures prévu/réalisé/payé et périodes de paie verrouillables
Les trois grandeurs Sans pointeuse, le réalisé est saisi par le manager. La matrice impose de distinguer trois grandeurs et non deux, avec deux règles qui ne se négocient pas : - **Sans heures réelles, le prévu fait foi.** Attendre une saisie qui ne viendra pas ne produirait aucune paie. Une saisie partielle — un début sans fin — ne suffit pas : elle donnerait une durée fantaisiste. - **Le paiement ne dépend jamais de la validation.** Une ligne non validée part en paie sur la base du réalisé. Bloquer le paiement d'heures accomplies faute de validation est précisément ce que la matrice interdit ; l'écran l'énonce pour qu'aucun manager ne croie l'inverse. Toute correction d'heures déjà saisies exige un motif — sans lui, une correction est indistinguable d'une erreur de saisie. Une première saisie n'en demande pas : il n'y a rien à corriger, et exiger un motif à chaque ligne le ferait remplir machinalement. Le verrou de période - Verrouiller fige les instantanés **et** ferme le mois : plus aucun créneau, aucune absence, aucune heure réelle ne peut être écrit sur ces dates. Le contrôle passe avant l'écriture, dans planning, absences et heures. - Déverrouiller exige une justification. Rouvrir périme les fichiers déjà transmis au cabinet, et six mois plus tard personne ne saurait pourquoi. - La **péremption d'un export est déduite** de `generatedAt < unlockedAt`, jamais stockée : la stocker obligerait à réécrire une trace qui doit rester append-only, et une trace réécrite ne prouve plus rien. - Supprimer une période demande de **retaper son libellé** : un « êtes-vous sûr » se clique sans lire, et la suppression efface des instantanés sur lesquels un export a pu être bâti. Un seul calcul pour trois écrans Les instantanés viennent de `buildPayrollPeriod`, la même fonction que le rapport de paie et l'export. C'est ce qui satisfait le critère croisé : trois calculs séparés divergeraient, et l'écart ne se verrait qu'au bulletin. Un test d'intégration compare instantané et rapport sur le même jeu de données. Tests enfin idempotents La suite e2e échouait au second passage : les absences acceptées d'une exécution bloquaient les demandes de la suivante. Trois corrections, dans l'ordre d'importance — chaque test travaille sur **son** salarié (le chevauchement se juge par personne), chaque test libère ce qu'il a créé, et les fenêtres de dates sont écartées d'une exécution à l'autre. Trois passages consécutifs passent désormais. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
20 files changed
+2837
-25
No files matched your search
+110
-14
@@ -18,8 +18,11 @@ import { frenchHolidays } from '../../src/domain/absences/holidays';
|
||||
* acceptée bloque toute demande qui la recouvre : des dates fixes feraient
|
||||
* échouer le deuxième passage pour une raison sans rapport avec ce qui est
|
||||
* testé. Chaque exécution travaille donc sur sa propre fenêtre de dates.
|
||||
*
|
||||
* Le multiplicateur écarte deux exécutions voisines : sans lui, deux passages
|
||||
* à une minute d'intervalle retomberaient sur des fenêtres qui se recouvrent.
|
||||
*/
|
||||
const RUN_OFFSET = 150 + (Math.floor(Date.now() / 1000) % 700);
|
||||
const RUN_OFFSET = 200 + ((Math.floor(Date.now() / 1000) * 137) % 3000);
|
||||
|
||||
/** Une date libre, loin de tout ce que le seed pose. */
|
||||
function isoDate(offsetDays: number): string {
|
||||
@@ -30,18 +33,69 @@ function isoDate(offsetDays: number): string {
|
||||
|
||||
/** Prochain jour de la semaine demandé (0 = dimanche) dans la fenêtre du run. */
|
||||
function nextWeekday(weekday: number): string {
|
||||
const cursor = new Date(Date.now() + (RUN_OFFSET + 40) * 86_400_000);
|
||||
const cursor = new Date(Date.now() + (RUN_OFFSET + 17) * 86_400_000);
|
||||
while (cursor.getUTCDay() !== weekday) {
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
return cursor.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Libère les dates utilisées par un test.
|
||||
*
|
||||
* Une absence en attente ou acceptée bloque toute demande qui la recouvre : la
|
||||
* laisser derrière soi ferait échouer le passage suivant pour une raison sans
|
||||
* rapport avec ce qui est testé. Refuser suffit — seuls les statuts « en
|
||||
* attente » et « acceptée » bloquent.
|
||||
*/
|
||||
async function release(page: Page, marker: string, month?: string) {
|
||||
await page.goto(month ? `/conges?mois=${month}` : '/conges');
|
||||
|
||||
const pending = page
|
||||
.locator('section')
|
||||
.filter({ hasText: 'Demandes en attente' })
|
||||
.locator('li')
|
||||
.filter({ hasText: marker });
|
||||
|
||||
// Boucle : un passage précédent interrompu a pu en laisser plusieurs, et
|
||||
// `marker` peut être un préfixe commun à toutes les demandes d'un test.
|
||||
for (let guard = 0; guard < 20; guard += 1) {
|
||||
const before = await pending.count();
|
||||
if (before === 0) break;
|
||||
await pending.first().getByRole('button', { name: 'Refuser' }).click();
|
||||
await expect(pending).toHaveCount(before - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chaque test travaille sur **son** salarié.
|
||||
*
|
||||
* Le chevauchement se juge par salarié : deux tests qui partageraient la même
|
||||
* personne se bloqueraient l'un l'autre dès que leurs fenêtres de dates se
|
||||
* croisent, ce qui arrive d'autant plus que les fenêtres sont larges. Les deux
|
||||
* salariés porteurs d'absences dans le seed sont écartés.
|
||||
*/
|
||||
const WHO = {
|
||||
queue: 'Yanis Trabelsi',
|
||||
overlap: 'Marius Kowalski',
|
||||
countable: 'Awa Diallo',
|
||||
holiday: 'Théo Berger',
|
||||
inverted: 'Clara Fontaine',
|
||||
roundTrip: 'Noé Perrin',
|
||||
} as const;
|
||||
|
||||
async function request(
|
||||
page: Page,
|
||||
options: { from: string; to: string; type?: string; comment?: string },
|
||||
options: {
|
||||
from: string;
|
||||
to: string;
|
||||
who: string;
|
||||
type?: string;
|
||||
comment?: string;
|
||||
},
|
||||
) {
|
||||
const form = page.locator('form').filter({ hasText: 'Demander' }).first();
|
||||
await form.getByLabel('Salarié').selectOption({ label: options.who });
|
||||
if (options.type) {
|
||||
await form.getByLabel('Type').selectOption({ label: options.type });
|
||||
}
|
||||
@@ -73,7 +127,13 @@ test('une demande apparaît dans la file, puis se décide', async ({ page }) =>
|
||||
const from = isoDate(0);
|
||||
const to = isoDate(4);
|
||||
const comment = `Test ${Date.now()}`;
|
||||
await request(page, { from, to, type: 'Congés payés', comment });
|
||||
await request(page, {
|
||||
from,
|
||||
to,
|
||||
who: WHO.queue,
|
||||
type: 'Congés payés',
|
||||
comment,
|
||||
});
|
||||
|
||||
const pending = page
|
||||
.locator('section')
|
||||
@@ -87,15 +147,31 @@ test('une demande apparaît dans la file, puis se décide', async ({ page }) =>
|
||||
await expect(pending.locator('li').filter({ hasText: comment })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
// Puis on libère les dates : annuler contre-passe la prise sans rien
|
||||
// effacer, ce qui est exactement le comportement voulu en production.
|
||||
await page.goto(`/conges?mois=${from.slice(0, 7)}`);
|
||||
const accepted = page
|
||||
.locator('section')
|
||||
.filter({ hasText: 'Absences du mois' })
|
||||
.locator('li')
|
||||
.filter({ hasText: WHO.queue })
|
||||
.first();
|
||||
if (await accepted.isVisible()) {
|
||||
await accepted.getByRole('button', { name: 'Annuler' }).click();
|
||||
}
|
||||
});
|
||||
|
||||
test('deux absences qui se recouvrent sont refusées', async ({ page }) => {
|
||||
await page.goto('/conges');
|
||||
|
||||
await release(page, 'Chevauchement ');
|
||||
|
||||
const comment = `Chevauchement ${Date.now()}`;
|
||||
await request(page, {
|
||||
from: isoDate(60),
|
||||
to: isoDate(65),
|
||||
from: isoDate(8),
|
||||
to: isoDate(12),
|
||||
who: WHO.overlap,
|
||||
type: 'Congés payés',
|
||||
comment,
|
||||
});
|
||||
@@ -111,11 +187,14 @@ test('deux absences qui se recouvrent sont refusées', async ({ page }) => {
|
||||
// La seconde chevauche la première d'un seul jour : bornes inclusives des
|
||||
// deux côtés, puisque la date de fin est un jour d'absence.
|
||||
const form = await request(page, {
|
||||
from: isoDate(65),
|
||||
to: isoDate(68),
|
||||
from: isoDate(12),
|
||||
to: isoDate(15),
|
||||
who: WHO.overlap,
|
||||
type: 'Congés payés',
|
||||
});
|
||||
await expect(form.getByText(/couvre déjà/)).toBeVisible();
|
||||
|
||||
await release(page, comment);
|
||||
});
|
||||
|
||||
test('une période sans jour décomptable est refusée', async ({ page }) => {
|
||||
@@ -126,6 +205,7 @@ test('une période sans jour décomptable est refusée', async ({ page }) => {
|
||||
const form = await request(page, {
|
||||
from: sunday,
|
||||
to: sunday,
|
||||
who: WHO.countable,
|
||||
type: 'Congés payés',
|
||||
});
|
||||
await expect(form.getByText(/aucun jour décomptable/)).toBeVisible();
|
||||
@@ -137,6 +217,10 @@ test('un jour férié dans la période n’est pas décompté', async ({ page })
|
||||
// Un jour férié pris au hasard parmi ceux de l'année : la période qui
|
||||
// l'englobe ne doit pas le décompter, et l'utilisateur n'a pas eu à scinder
|
||||
// sa demande. Le tirage évite de retomber sur la même semaine à chaque run.
|
||||
// Les dates fériées sont fixes : un passage précédent a pu y laisser une
|
||||
// demande. On libère avant de reposer la sienne.
|
||||
await release(page, 'Ferie ');
|
||||
|
||||
const holidays = frenchHolidays(new Date().getUTCFullYear() + 2);
|
||||
const holiday = holidays[
|
||||
Math.floor(Date.now() / 1000) % holidays.length
|
||||
@@ -153,6 +237,7 @@ test('un jour férié dans la période n’est pas décompté', async ({ page })
|
||||
await request(page, {
|
||||
from: day(-1),
|
||||
to: day(1),
|
||||
who: WHO.holiday,
|
||||
type: 'Congés payés',
|
||||
comment,
|
||||
});
|
||||
@@ -161,6 +246,8 @@ test('un jour férié dans la période n’est pas décompté', async ({ page })
|
||||
await expect(row).toBeVisible();
|
||||
// Trois jours calendaires dont un férié : jamais trois décomptés.
|
||||
await expect(row.getByText(/3 jours décomptés/)).toHaveCount(0);
|
||||
|
||||
await release(page, comment);
|
||||
});
|
||||
|
||||
test('une date de fin antérieure au début rappelle la règle', async ({
|
||||
@@ -169,8 +256,9 @@ test('une date de fin antérieure au début rappelle la règle', async ({
|
||||
await page.goto('/conges');
|
||||
|
||||
const form = await request(page, {
|
||||
from: isoDate(100),
|
||||
to: isoDate(98),
|
||||
from: isoDate(22),
|
||||
to: isoDate(20),
|
||||
who: WHO.inverted,
|
||||
type: 'Congés payés',
|
||||
});
|
||||
await expect(form.getByRole('alert')).toContainText(
|
||||
@@ -184,10 +272,16 @@ test('le solde revient à son niveau après un aller-retour', async ({ page }) =
|
||||
const counters = page.locator('section').filter({ hasText: 'Compteurs' });
|
||||
await expect(counters).toBeVisible();
|
||||
|
||||
const from = isoDate(120);
|
||||
const to = isoDate(122);
|
||||
const from = isoDate(26);
|
||||
const to = isoDate(28);
|
||||
const comment = `Aller-retour ${Date.now()}`;
|
||||
await request(page, { from, to, type: 'Congés payés', comment });
|
||||
await request(page, {
|
||||
from,
|
||||
to,
|
||||
who: WHO.roundTrip,
|
||||
type: 'Congés payés',
|
||||
comment,
|
||||
});
|
||||
|
||||
const pending = page
|
||||
.locator('section')
|
||||
@@ -201,8 +295,10 @@ test('le solde revient à son niveau après un aller-retour', async ({ page }) =
|
||||
// Annuler contre-passe la prise : le solde revient au même chiffre, sans
|
||||
// qu'aucune écriture ait été effacée.
|
||||
const month = page.locator('section').filter({ hasText: 'Absences du mois' });
|
||||
const accepted = month.locator('li').filter({ hasText: 'Acceptée' }).first();
|
||||
const accepted = month.locator('li').filter({ hasText: WHO.roundTrip }).first();
|
||||
if (await accepted.isVisible()) {
|
||||
await accepted.getByRole('button', { name: 'Annuler' }).click();
|
||||
}
|
||||
|
||||
await release(page, comment, from.slice(0, 7));
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { formatMonthParam, monthOf, previousMonth } from '../../src/domain/planning/month';
|
||||
|
||||
/**
|
||||
* Heures et périodes de paie.
|
||||
*
|
||||
* Ce que ces tests protègent : qu'un mois transmis au cabinet ne se modifie
|
||||
* plus par inadvertance, et que rouvrir ce mois soit une décision justifiée
|
||||
* plutôt qu'un bouton.
|
||||
*/
|
||||
|
||||
const MONTH = formatMonthParam(previousMonth(monthOf(new Date())));
|
||||
|
||||
/**
|
||||
* Mois propre à cette exécution.
|
||||
*
|
||||
* Une période est unique par (établissement, bornes) et la base n'est pas
|
||||
* remise à zéro entre deux passages : un mois calculé sur une plage étroite
|
||||
* finit par retomber sur une période déjà créée, et le test échoue pour une
|
||||
* raison sans rapport avec ce qu'il vérifie.
|
||||
*/
|
||||
const SALT = Math.floor(Date.now() / 1000);
|
||||
|
||||
function uniqueMonth(bucket: number): string {
|
||||
const year = 2100 + ((SALT + bucket * 997) % 400);
|
||||
const month = ((Math.floor(SALT / 400) + bucket) % 12) + 1;
|
||||
return `${year}-${String(month).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
test('le rapport d’heures affiche les trois grandeurs', async ({ page }) => {
|
||||
await page.goto(`/rapports/heures?mois=${MONTH}`);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /^Heures travaillées · / }),
|
||||
).toBeVisible();
|
||||
|
||||
// Sans saisie, le prévu fait foi — et l'écran le dit plutôt que d'afficher
|
||||
// un réalisé vide qu'on prendrait pour zéro heure.
|
||||
await expect(page.getByText(/le prévu fait foi/)).toBeVisible();
|
||||
await expect(page.getByText('prévu retenu').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('l’écran dit que la validation ne conditionne pas le paiement', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/rapports/heures?mois=${MONTH}`);
|
||||
// Bloquer le paiement d'heures accomplies faute de validation est ce que la
|
||||
// matrice de conformité interdit : l'écran l'énonce pour qu'aucun manager ne
|
||||
// croie l'inverse.
|
||||
await expect(
|
||||
page.getByText(/elle ne les autorise pas à être payées/),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('verrouiller une période ferme le mois aux modifications', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/paie/periodes');
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Périodes de paie' }),
|
||||
).toBeVisible();
|
||||
|
||||
const month = uniqueMonth(0);
|
||||
const label = `Test ${Date.now()}`;
|
||||
|
||||
const create = page.locator('form').filter({ hasText: 'Créer la période' });
|
||||
await create.getByLabel('Mois').fill(month);
|
||||
await create.getByLabel('Libellé').fill(label);
|
||||
await create.getByRole('button', { name: 'Créer la période' }).click();
|
||||
|
||||
const card = page.locator('section').filter({ hasText: label }).first();
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.getByText('Ouverte')).toBeVisible();
|
||||
|
||||
await card.getByRole('button', { name: 'Verrouiller la période' }).click();
|
||||
await expect(card.getByText('Verrouillée')).toBeVisible();
|
||||
|
||||
// Déverrouiller sans motif est refusé : rouvrir périme les fichiers déjà
|
||||
// transmis, et six mois plus tard personne ne saurait pourquoi.
|
||||
const unlock = card.locator('form').filter({ hasText: 'Déverrouiller' });
|
||||
await expect(unlock.getByPlaceholder('Motif du déverrouillage')).toBeVisible();
|
||||
|
||||
await unlock
|
||||
.getByPlaceholder('Motif du déverrouillage')
|
||||
.fill('Correction demandée par le cabinet');
|
||||
await unlock.getByRole('button', { name: 'Déverrouiller' }).click();
|
||||
await expect(card.getByText('Ouverte')).toBeVisible();
|
||||
});
|
||||
|
||||
test('supprimer une période exige de retaper son libellé', async ({ page }) => {
|
||||
await page.goto('/paie/periodes');
|
||||
|
||||
const month = uniqueMonth(1);
|
||||
const label = `Suppr ${Date.now()}`;
|
||||
|
||||
const create = page.locator('form').filter({ hasText: 'Créer la période' });
|
||||
await create.getByLabel('Mois').fill(month);
|
||||
await create.getByLabel('Libellé').fill(label);
|
||||
await create.getByRole('button', { name: 'Créer la période' }).click();
|
||||
|
||||
const card = page.locator('section').filter({ hasText: label }).first();
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
// Un simple « êtes-vous sûr » se clique sans lire : la confirmation demande
|
||||
// le libellé exact.
|
||||
const remove = card.locator('form').filter({ hasText: 'Supprimer' });
|
||||
await remove.getByRole('button', { name: 'Supprimer' }).click();
|
||||
await expect(remove.getByText(/saisissez le libellé exact/i)).toBeVisible();
|
||||
|
||||
await remove.getByPlaceholder(`Saisir « ${label} »`).fill(label);
|
||||
await remove.getByRole('button', { name: 'Supprimer' }).click();
|
||||
await expect(page.locator('section').filter({ hasText: label })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { IDCC_1517_PARAMETERS } from '@/domain/compliance/idcc1517';
|
||||
import { zonedInstant } from '@/domain/planning/week';
|
||||
|
||||
/**
|
||||
* Périodes de paie — WP-07.
|
||||
*
|
||||
* Deux critères d'acceptation ne se démontrent qu'en base :
|
||||
*
|
||||
* - **Le verrouillage refuse toute mutation** dont la date tombe dans la
|
||||
* période. Un contrôle applicatif qui laisserait passer une écriture rendrait
|
||||
* faux un mois déjà transmis au cabinet.
|
||||
* - **Grille, rapport d'heures et instantané donnent des chiffres identiques.**
|
||||
* Trois calculs séparés divergent, et l'écart ne se voit qu'au bulletin.
|
||||
*/
|
||||
|
||||
const enabled = (process.env.DATABASE_URL ?? '').length > 0;
|
||||
const describeIfDb = enabled ? describe : describe.skip;
|
||||
|
||||
const TZ = 'Europe/Paris';
|
||||
const suffix = `period-${Date.now()}`;
|
||||
const accountId = `${suffix}-account`;
|
||||
const locationId = `${suffix}-loc`;
|
||||
const teamId = `${suffix}-team`;
|
||||
const membershipId = `${suffix}-member`;
|
||||
|
||||
let unscoped: typeof import('@/server/tenant').unscoped;
|
||||
let withTenant: typeof import('@/server/tenant').withTenant;
|
||||
let assertPeriodOpen: typeof import('@/server/payroll/periods').assertPeriodOpen;
|
||||
let PeriodLockedError: typeof import('@/server/payroll/periods').PeriodLockedError;
|
||||
let computeSnapshots: typeof import('@/server/payroll/periods').computeSnapshots;
|
||||
let buildPayrollPeriod: typeof import('@/server/payroll/build').buildPayrollPeriod;
|
||||
|
||||
let periodId = '';
|
||||
|
||||
/** Août de l'an prochain : loin de tout ce que le seed pose. */
|
||||
const YEAR = new Date().getUTCFullYear() + 3;
|
||||
const MONTH = { year: YEAR, month: 8 };
|
||||
|
||||
describeIfDb('période de paie', () => {
|
||||
beforeAll(async () => {
|
||||
process.env.ENCRYPTION_KEY ??= Buffer.alloc(32, 3).toString('base64');
|
||||
({ unscoped, withTenant } = await import('@/server/tenant'));
|
||||
({ assertPeriodOpen, PeriodLockedError, computeSnapshots } = await import(
|
||||
'@/server/payroll/periods'
|
||||
));
|
||||
({ buildPayrollPeriod } = await import('@/server/payroll/build'));
|
||||
|
||||
const db = unscoped();
|
||||
|
||||
await db.account.create({ data: { id: accountId, name: `Compte ${suffix}` } });
|
||||
await db.location.create({
|
||||
data: {
|
||||
id: locationId,
|
||||
accountId,
|
||||
name: 'Établissement de test',
|
||||
timezone: TZ,
|
||||
employerContributionRate: 0,
|
||||
},
|
||||
});
|
||||
await db.team.create({
|
||||
data: { id: teamId, accountId, locationId, name: 'Vente' },
|
||||
});
|
||||
|
||||
const role = await db.role.create({
|
||||
data: { accountId, key: 'employee', name: 'Employé' },
|
||||
});
|
||||
await db.membership.create({
|
||||
data: {
|
||||
id: membershipId,
|
||||
accountId,
|
||||
roleId: role.id,
|
||||
employeeNumber: 'P0001',
|
||||
silaeMatricule: '00001',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
await db.employeeProfile.create({
|
||||
data: { membershipId, accountId, firstName: 'Test', lastName: 'Période' },
|
||||
});
|
||||
await db.userContract.create({
|
||||
data: {
|
||||
accountId,
|
||||
membershipId,
|
||||
locationId,
|
||||
contractType: 'CDI',
|
||||
startDate: new Date('2024-01-01'),
|
||||
workTimeArrangement: 'HOURLY',
|
||||
weeklyHours: 35,
|
||||
},
|
||||
});
|
||||
await db.teamMember.create({ data: { accountId, teamId, membershipId } });
|
||||
|
||||
await db.collectiveAgreement.create({
|
||||
data: {
|
||||
accountId,
|
||||
idcc: '1517',
|
||||
name: 'Test',
|
||||
parameters: IDCC_1517_PARAMETERS,
|
||||
version: 1,
|
||||
effectiveFrom: new Date('2020-01-01'),
|
||||
},
|
||||
});
|
||||
|
||||
// Deux créneaux : l'un avec réalisé saisi, l'autre sans — c'est le cas
|
||||
// qui vérifie que le prévu fait foi à défaut.
|
||||
const schedule = await db.weeklySchedule.create({
|
||||
data: { accountId, teamId, locationId, isoYear: YEAR, isoWeek: 32 },
|
||||
});
|
||||
await db.shift.create({
|
||||
data: {
|
||||
accountId,
|
||||
weeklyScheduleId: schedule.id,
|
||||
membershipId,
|
||||
localDate: new Date(`${YEAR}-08-03T00:00:00Z`),
|
||||
startAt: zonedInstant(`${YEAR}-08-03`, '09:00', TZ),
|
||||
endAt: zonedInstant(`${YEAR}-08-03`, '17:00', TZ),
|
||||
breakMinutes: 0,
|
||||
},
|
||||
});
|
||||
await db.shift.create({
|
||||
data: {
|
||||
accountId,
|
||||
weeklyScheduleId: schedule.id,
|
||||
membershipId,
|
||||
localDate: new Date(`${YEAR}-08-04T00:00:00Z`),
|
||||
startAt: zonedInstant(`${YEAR}-08-04`, '09:00', TZ),
|
||||
endAt: zonedInstant(`${YEAR}-08-04`, '17:00', TZ),
|
||||
breakMinutes: 0,
|
||||
actualStartAt: zonedInstant(`${YEAR}-08-04`, '09:00', TZ),
|
||||
actualEndAt: zonedInstant(`${YEAR}-08-04`, '19:00', TZ),
|
||||
},
|
||||
});
|
||||
|
||||
const period = await db.payPeriod.create({
|
||||
data: {
|
||||
accountId,
|
||||
locationId,
|
||||
label: `Août ${YEAR}`,
|
||||
startDate: new Date(`${YEAR}-08-01T00:00:00Z`),
|
||||
endDate: new Date(`${YEAR}-08-31T00:00:00Z`),
|
||||
},
|
||||
});
|
||||
periodId = period.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!enabled) return;
|
||||
await unscoped().account.delete({ where: { id: accountId } });
|
||||
});
|
||||
|
||||
it('laisse passer une mutation sur une période ouverte', async () => {
|
||||
await withTenant(accountId, async (db) => {
|
||||
await expect(
|
||||
assertPeriodOpen(db, locationId, [`${YEAR}-08-10`]),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('retient le réalisé quand il est saisi, le prévu sinon', async () => {
|
||||
await withTenant(accountId, async (db) => {
|
||||
const payroll = await buildPayrollPeriod(db, MONTH, locationId);
|
||||
const hours = payroll?.rows[0]?.elements.find(
|
||||
(element) => element.key === 'WORKED_HOURS',
|
||||
);
|
||||
|
||||
// 8 h prévues le 3, 10 h réalisées le 4 : 18 h au total, pas 16.
|
||||
expect(hours?.value).toBe(18 * 60);
|
||||
});
|
||||
});
|
||||
|
||||
it('fige des instantanés identiques au rapport', async () => {
|
||||
// Le critère croisé : l'instantané vient de la **même fonction** que le
|
||||
// rapport et l'export. Trois calculs séparés divergeraient.
|
||||
await withTenant(accountId, async (db) => {
|
||||
const written = await computeSnapshots(db, periodId, MONTH, locationId);
|
||||
expect(written).toBe(1);
|
||||
|
||||
const snapshot = await db.payPeriodSnapshot.findFirst({
|
||||
where: { payPeriodId: periodId },
|
||||
});
|
||||
const payroll = await buildPayrollPeriod(db, MONTH, locationId);
|
||||
const hours = payroll?.rows[0]?.elements.find(
|
||||
(element) => element.key === 'WORKED_HOURS',
|
||||
);
|
||||
|
||||
expect(snapshot?.plannedMinutes).toBe(hours?.value);
|
||||
expect(snapshot?.workedDays).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuse toute mutation une fois la période verrouillée', async () => {
|
||||
await unscoped().payPeriod.update({
|
||||
where: { id: periodId },
|
||||
data: { status: 'LOCKED', lockedAt: new Date() },
|
||||
});
|
||||
|
||||
await withTenant(accountId, async (db) => {
|
||||
await expect(
|
||||
assertPeriodOpen(db, locationId, [`${YEAR}-08-10`]),
|
||||
).rejects.toBeInstanceOf(PeriodLockedError);
|
||||
|
||||
// Bornes comprises : le 1er et le 31 sont dans la période.
|
||||
await expect(
|
||||
assertPeriodOpen(db, locationId, [`${YEAR}-08-01`]),
|
||||
).rejects.toBeInstanceOf(PeriodLockedError);
|
||||
await expect(
|
||||
assertPeriodOpen(db, locationId, [`${YEAR}-08-31`]),
|
||||
).rejects.toBeInstanceOf(PeriodLockedError);
|
||||
});
|
||||
});
|
||||
|
||||
it('laisse passer une date hors de la période verrouillée', async () => {
|
||||
await withTenant(accountId, async (db) => {
|
||||
await expect(
|
||||
assertPeriodOpen(db, locationId, [`${YEAR}-09-01`]),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('recalcule intégralement les instantanés au nouveau verrouillage', async () => {
|
||||
await withTenant(accountId, async (db) => {
|
||||
const before = await db.payPeriodSnapshot.findFirst({
|
||||
where: { payPeriodId: periodId },
|
||||
});
|
||||
const again = await computeSnapshots(db, periodId, MONTH, locationId);
|
||||
const after = await db.payPeriodSnapshot.findFirst({
|
||||
where: { payPeriodId: periodId },
|
||||
});
|
||||
|
||||
expect(again).toBe(1);
|
||||
// Nouvel enregistrement, même contenu : ils ne sont pas immuables au sens
|
||||
// strict, c'est le couple (instantané, export) qui porte la preuve.
|
||||
expect(after?.id).not.toBe(before?.id);
|
||||
expect(after?.plannedMinutes).toBe(before?.plannedMinutes);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
actualMinutesOf,
|
||||
describeCorrection,
|
||||
fallsInLockedPeriod,
|
||||
hoursView,
|
||||
isExportStale,
|
||||
plannedMinutesOf,
|
||||
sumHours,
|
||||
type ShiftHours,
|
||||
} from '@/domain/hours/states';
|
||||
import { zonedInstant } from '@/domain/planning/week';
|
||||
|
||||
const TZ = 'Europe/Paris';
|
||||
|
||||
function shift(over: Partial<ShiftHours> = {}): ShiftHours {
|
||||
return {
|
||||
startAt: zonedInstant('2026-08-10', '09:00', TZ),
|
||||
endAt: zonedInstant('2026-08-10', '17:00', TZ),
|
||||
breakMinutes: 60,
|
||||
actualStartAt: null,
|
||||
actualEndAt: null,
|
||||
actualBreakMinutes: null,
|
||||
isValidated: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('sans heures réelles, le prévu fait foi', () => {
|
||||
it('reprend le prévu quand rien n’est saisi', () => {
|
||||
// Attendre une saisie qui ne viendra pas ne produirait aucune paie.
|
||||
const view = hoursView(shift());
|
||||
expect(view.plannedMinutes).toBe(7 * 60);
|
||||
expect(view.actualMinutes).toBe(7 * 60);
|
||||
expect(view.deltaMinutes).toBe(0);
|
||||
expect(view.hasActual).toBe(false);
|
||||
});
|
||||
|
||||
it('ignore une saisie incomplète', () => {
|
||||
// Un début sans fin produirait une durée fantaisiste : le prévu reste la
|
||||
// meilleure information disponible.
|
||||
const partial = shift({
|
||||
actualStartAt: zonedInstant('2026-08-10', '08:30', TZ),
|
||||
});
|
||||
expect(actualMinutesOf(partial)).toBeNull();
|
||||
expect(hoursView(partial).actualMinutes).toBe(7 * 60);
|
||||
});
|
||||
|
||||
it('prend le réalisé dès qu’il est complet', () => {
|
||||
const done = shift({
|
||||
actualStartAt: zonedInstant('2026-08-10', '08:30', TZ),
|
||||
actualEndAt: zonedInstant('2026-08-10', '18:00', TZ),
|
||||
});
|
||||
const view = hoursView(done);
|
||||
expect(view.actualMinutes).toBe(8 * 60 + 30);
|
||||
expect(view.deltaMinutes).toBe(90);
|
||||
expect(view.hasActual).toBe(true);
|
||||
});
|
||||
|
||||
it('utilise la pause réelle quand elle est saisie', () => {
|
||||
const done = shift({
|
||||
actualStartAt: zonedInstant('2026-08-10', '09:00', TZ),
|
||||
actualEndAt: zonedInstant('2026-08-10', '17:00', TZ),
|
||||
actualBreakMinutes: 30,
|
||||
});
|
||||
expect(hoursView(done).actualMinutes).toBe(7 * 60 + 30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('le paiement ne dépend jamais de la validation', () => {
|
||||
it('paie des heures non validées', () => {
|
||||
// Bloquer le paiement d'heures accomplies faute de validation est
|
||||
// précisément ce que la matrice interdit.
|
||||
const done = shift({
|
||||
actualStartAt: zonedInstant('2026-08-10', '08:00', TZ),
|
||||
actualEndAt: zonedInstant('2026-08-10', '18:00', TZ),
|
||||
isValidated: false,
|
||||
});
|
||||
const view = hoursView(done);
|
||||
expect(view.isValidated).toBe(false);
|
||||
expect(view.payableMinutes).toBe(view.actualMinutes);
|
||||
expect(view.payableMinutes).toBe(9 * 60);
|
||||
});
|
||||
|
||||
it('paie la même chose une fois validé', () => {
|
||||
const done = shift({
|
||||
actualStartAt: zonedInstant('2026-08-10', '08:00', TZ),
|
||||
actualEndAt: zonedInstant('2026-08-10', '18:00', TZ),
|
||||
isValidated: true,
|
||||
});
|
||||
expect(hoursView(done).payableMinutes).toBe(9 * 60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agrégat', () => {
|
||||
it('additionne prévu, réalisé et écart', () => {
|
||||
const total = sumHours([
|
||||
shift(),
|
||||
shift({
|
||||
actualStartAt: zonedInstant('2026-08-11', '09:00', TZ),
|
||||
actualEndAt: zonedInstant('2026-08-11', '18:00', TZ),
|
||||
startAt: zonedInstant('2026-08-11', '09:00', TZ),
|
||||
endAt: zonedInstant('2026-08-11', '17:00', TZ),
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(total.plannedMinutes).toBe(14 * 60);
|
||||
expect(total.actualMinutes).toBe(15 * 60);
|
||||
expect(total.deltaMinutes).toBe(60);
|
||||
expect(total.hasActual).toBe(true);
|
||||
});
|
||||
|
||||
it('n’est validé que si tout l’est', () => {
|
||||
expect(sumHours([shift({ isValidated: true }), shift()]).isValidated).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
sumHours([shift({ isValidated: true }), shift({ isValidated: true })])
|
||||
.isValidated,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('traite l’ensemble vide comme validé', () => {
|
||||
expect(sumHours([]).isValidated).toBe(true);
|
||||
expect(sumHours([]).payableMinutes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('durée d’un créneau', () => {
|
||||
it('déduit la pause du prévu', () => {
|
||||
expect(plannedMinutesOf(shift({ breakMinutes: 45 }))).toBe(7 * 60 + 15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('corrections', () => {
|
||||
it('décrit l’écart et son motif', () => {
|
||||
// Sans motif, une correction d'heures est indistinguable d'une erreur de
|
||||
// saisie.
|
||||
expect(
|
||||
describeCorrection({
|
||||
beforeMinutes: 420,
|
||||
afterMinutes: 510,
|
||||
reason: 'Inventaire prolongé',
|
||||
actorMembershipId: 'm1',
|
||||
at: new Date(),
|
||||
}),
|
||||
).toBe('+1 h 30 — Inventaire prolongé');
|
||||
});
|
||||
|
||||
it('marque une correction à la baisse', () => {
|
||||
expect(
|
||||
describeCorrection({
|
||||
beforeMinutes: 510,
|
||||
afterMinutes: 420,
|
||||
reason: 'Départ anticipé',
|
||||
actorMembershipId: 'm1',
|
||||
at: new Date(),
|
||||
}),
|
||||
).toBe('−1 h 30 — Départ anticipé');
|
||||
});
|
||||
});
|
||||
|
||||
describe('garde-fou de période verrouillée', () => {
|
||||
const periods = [
|
||||
{ startDate: '2026-07-01', endDate: '2026-07-31', status: 'LOCKED' },
|
||||
{ startDate: '2026-08-01', endDate: '2026-08-31', status: 'OPEN' },
|
||||
];
|
||||
|
||||
it('bloque une date dans la période verrouillée, bornes comprises', () => {
|
||||
expect(fallsInLockedPeriod('2026-07-01', periods)).toBe(true);
|
||||
expect(fallsInLockedPeriod('2026-07-31', periods)).toBe(true);
|
||||
expect(fallsInLockedPeriod('2026-07-15', periods)).toBe(true);
|
||||
});
|
||||
|
||||
it('laisse passer une date hors période verrouillée', () => {
|
||||
expect(fallsInLockedPeriod('2026-06-30', periods)).toBe(false);
|
||||
expect(fallsInLockedPeriod('2026-08-01', periods)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('péremption des exports', () => {
|
||||
it('périme un export antérieur au déverrouillage', () => {
|
||||
// Un fichier transmis à Silae avant un déverrouillage ne correspond plus
|
||||
// aux données ; sans ce signalement, rien ne l'indiquerait.
|
||||
expect(
|
||||
isExportStale(new Date('2026-08-01T10:00:00Z'), new Date('2026-08-05T09:00:00Z')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('laisse valide un export postérieur', () => {
|
||||
expect(
|
||||
isExportStale(new Date('2026-08-06T10:00:00Z'), new Date('2026-08-05T09:00:00Z')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('ne périme rien sans déverrouillage', () => {
|
||||
expect(isExportStale(new Date('2026-08-01T10:00:00Z'), null)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user