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
201 lines
6.3 KiB
TypeScript
201 lines
6.3 KiB
TypeScript
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);
|
||
});
|
||
});
|