From d66288d061a0ca6720f8062932c5eb43ce3decf3 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 25 Jun 2026 07:29:02 +0000 Subject: [PATCH] =?UTF-8?q?fix(heures)=20:=20garde=20backend=20anti-suppre?= =?UTF-8?q?ssion=20sur=20grille=20p=C3=A9rim=C3=A9e=20(delete=20explicite)?= =?UTF-8?q?=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Contexte (incident prod) Le correctif #31 (dirty-tracking front) ne protège que les sessions chargeant le nouveau bundle. Un **vieil onglet** ouvert avant déploiement tourne encore sur l'ancien JS et envoie toute la grille périmée. Hier soir, un onglet ouvert le matin a **supprimé ~10 lignes d'heures** saisies dans la journée par d'autres utilisateurs (journal BDD à l'appui : 1 save = 2 updates + 8 deletes de lignes intactes). Cause : le backend traitait toute **entrée vide comme une suppression**, sans aucune garde indépendante du client. ## Correctif — suppression sur intention explicite (`delete: true`) `WorkHourBulkUpsertProcessor` ne supprime une ligne existante sur entrée vide **que si l'entrée porte `delete: true`**. Sinon → **no-op** (ligne préservée). Aucune grille périmée, quel que soit le client (vieil onglet inclus), ne peut plus détruire une saisie concurrente. La création de ligne technique de validation reste limitée à `null === $existing`. Le front (à jour) pose `delete: true` sur une ligne **vidée volontairement** (helper `isEntryEmpty`, appliqué après le filtre dirty-tracking) → suppression métier conservée. Flag optionnel ajouté au DTO front (`WorkHourEntryPayload`) et back (`WorkHourBulkUpsert`), défaut `false`. ## Testabilité Le processor dépend désormais des interfaces repo (`EmployeeScopedRepositoryInterface` / `WorkHourReadRepositoryInterface`, repos concrets `final` non mockables) → nouveau test unitaire `WorkHourBulkUpsertProcessorTest` (no-op sans flag / suppression avec flag / update normal). ## Limite résiduelle (choix : suppression explicite, pas verrou optimiste) L'**édition explicite** d'une ligne sur données périmées peut encore écraser une saisie concurrente sur cette même ligne. Seule la **suppression** est blindée. ## Vérification - **267 tests PHPUnit OK** (dont 3 nouveaux), via le pre-commit hook. - Front : revue de code (pas de harnais de tests front). ## Doc - `doc/hours-save-dirty-tracking.md`, `CLAUDE.md`, doc in-app (`documentation-content.ts`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://gitea.malio.fr/MALIO-DEV/SIRH/pulls/36 Co-authored-by: tristan Co-committed-by: tristan --- CLAUDE.md | 3 +- doc/hours-save-dirty-tracking.md | 53 ++++++- frontend/composables/useDriverHoursPage.ts | 10 +- frontend/composables/useHoursPage.ts | 13 +- frontend/data/documentation-content.ts | 1 + frontend/services/dto/work-hour.ts | 4 + src/ApiResource/WorkHourBulkUpsert.php | 7 +- .../EmployeeScopedRepositoryInterface.php | 7 + .../WorkHourReadRepositoryInterface.php | 7 + src/State/WorkHourBulkUpsertProcessor.php | 20 ++- .../State/WorkHourBulkUpsertProcessorTest.php | 139 ++++++++++++++++++ 11 files changed, 247 insertions(+), 17 deletions(-) create mode 100644 tests/State/WorkHourBulkUpsertProcessorTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 062bb93..64e6fde 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,8 @@ - `isSiteValid` (site manager): locks for non-admin, admin can still edit - Any real modification resets both `isSiteValid=false` and `isValid=false` - No-op saves preserve existing validations -- **Enregistrement = seules les lignes modifiées sont envoyées (anti-écrasement concurrent)** : l'écran Heures / Heures Conducteurs affiche toute la journée, et le bulk-upsert (`WorkHourBulkUpsertProcessor`) traite une **entrée vide comme une suppression**. Pour éviter qu'un admin avec une grille **périmée** ne supprime une ligne saisie entre-temps par un autre utilisateur (ex. `ROLE_SELF` non encore validé → non verrouillé), `handleSave` ne transmet **que les lignes dont l'état courant diffère de l'instantané chargé** (`loadedRows`, capturé dans `hydrateRows` ; comparaison `JSON.stringify(buildEntry(current)) !== buildEntry(original)`). Une ligne intouchée n'est jamais envoyée → jamais supprimée. Vidée volontairement → envoyée vide → supprimée (métier conservé). Symétrique dans `useHoursPage.ts` et `useDriverHoursPage.ts`. **Limite** : pas de verrou optimiste backend — l'édition explicite d'une ligne sur données périmées peut toujours écraser une saisie concurrente sur cette même ligne. Doc : `doc/hours-save-dirty-tracking.md`. +- **Enregistrement = seules les lignes modifiées sont envoyées (anti-écrasement concurrent)** : l'écran Heures / Heures Conducteurs affiche toute la journée, et le bulk-upsert (`WorkHourBulkUpsertProcessor`) traite une **entrée vide comme une suppression**. Pour éviter qu'un admin avec une grille **périmée** ne supprime une ligne saisie entre-temps par un autre utilisateur (ex. `ROLE_SELF` non encore validé → non verrouillé), `handleSave` ne transmet **que les lignes dont l'état courant diffère de l'instantané chargé** (`loadedRows`, capturé dans `hydrateRows` ; comparaison `JSON.stringify(buildEntry(current)) !== buildEntry(original)`). Une ligne intouchée n'est jamais envoyée → jamais supprimée. Vidée volontairement → envoyée vide → supprimée (métier conservé). Symétrique dans `useHoursPage.ts` et `useDriverHoursPage.ts`. + - **Garde backend : suppression sur intention explicite (`delete: true`)** — la protection front ci-dessus **ne couvre que les sessions chargeant le nouveau bundle**. Un **vieil onglet** ouvert avant déploiement tourne encore sur l'ancien JS (sans dirty-tracking) et envoie toute la grille périmée → **le bug s'est reproduit en prod** (onglet ouvert le matin → ~10 lignes saisies dans la journée supprimées). Donc `WorkHourBulkUpsertProcessor` ne supprime désormais une ligne existante sur entrée vide **que si l'entrée porte `delete: true`** (`$deleteRequested = true === ($entry['delete'] ?? false)`). Sinon : **no-op** (ligne préservée) — aucune grille périmée, quel que soit le client, ne peut plus détruire une saisie concurrente. Le front (à jour) pose `delete: true` sur une ligne **vidée volontairement** via `isEntryEmpty(...)` appliqué après le filtre dirty-tracking (les deux composables). Flag **optionnel** (`WorkHourEntryPayload` front + DTO `WorkHourBulkUpsert` back, défaut `false`). Branche de **création de ligne technique de validation** (toggle quand aucune ligne) inchangée : `null === $existing && (absence || contrat 4h)`. Le processor dépend des **interfaces** `EmployeeScopedRepositoryInterface`/`WorkHourReadRepositoryInterface` (repos concrets `final` non mockables) → testé dans `tests/State/WorkHourBulkUpsertProcessorTest.php`. **Limite résiduelle** : pas de verrou optimiste — l'édition **explicite** d'une ligne sur données périmées peut toujours écraser une saisie concurrente sur cette même ligne (seule la **suppression** est protégée). Doc : `doc/hours-save-dirty-tracking.md`. ## Overtime Rules - Contracts <= 35h: +25% from 35h to 43h, +50% beyond diff --git a/doc/hours-save-dirty-tracking.md b/doc/hours-save-dirty-tracking.md index 4405b03..b7eb8dc 100644 --- a/doc/hours-save-dirty-tracking.md +++ b/doc/hours-save-dirty-tracking.md @@ -45,10 +45,51 @@ Conséquences : Implémenté symétriquement dans `frontend/composables/useHoursPage.ts` (non-conducteurs) et `frontend/composables/useDriverHoursPage.ts` (conducteurs). -## Limite connue (hors périmètre de ce correctif) +## Garde backend : suppression sur intention explicite (`delete: true`) -Le suivi des lignes modifiées **ne couvre pas** le cas où l'admin **édite explicitement** une -ligne sur des données périmées (il voit la ligne vide, tape une valeur, écrasant une saisie -concurrente sur cette même ligne). Ce cas résiduel relèverait d'un **verrou optimiste** -(comparaison d'`updatedAt`/version côté backend), non implémenté ici. Le backend n'a aucune -détection de conflit concurrent (pas de version, pas d'horodatage comparé). +Le suivi front **ne protège que les sessions qui chargent le nouveau bundle**. Un **vieil +onglet** ouvert avant le déploiement continue de tourner sur l'ancien JavaScript (sans +dirty-tracking) et envoie toute la grille périmée → **le bug s'est reproduit en prod** (un onglet +ouvert le matin a supprimé une dizaine de lignes saisies dans la journée par d'autres +utilisateurs). La protection front est donc **insuffisante** : il faut une garde **côté +backend**, indépendante de la version du client. + +`WorkHourBulkUpsertProcessor` ne supprime désormais une ligne existante sur entrée vide **que si +la suppression est explicitement demandée** par le flag `delete: true` sur l'entrée : + +```php +$deleteRequested = true === ($entry['delete'] ?? false); +if ($existing && $deleteRequested) { + // suppression (audit 'delete' + remove) +} elseif (null === $existing && ($absence || $is4hContract)) { + // création d'une ligne technique (validation d'une journée d'absence / contrat 4h) +} +// existing && !deleteRequested → NO-OP : la ligne existante est préservée +``` + +Conséquences : + +- Une entrée vide **sans flag** sur une ligne existante est un **no-op** → une grille périmée + (n'importe quel client, vieil onglet inclus) **ne peut plus détruire** une saisie concurrente. +- Le front (à jour) pose `delete: true` sur une ligne **vidée volontairement** (entrée vide qui + diffère de l'instantané chargé, donc transmise) → la suppression métier reste possible. + Helper `isEntryEmpty(...)` dans les deux composables, appliqué après le filtre dirty-tracking. + +Le `delete` est **optionnel** (`WorkHourEntryPayload` front, DTO `WorkHourBulkUpsert` back) et +vaut `false` par défaut. Les appels de **création de ligne technique de validation** (toggles de +validation quand aucune ligne n'existe) envoient une entrée vide sans flag → inchangés (branche +`null === $existing`). + +Tests : `tests/State/WorkHourBulkUpsertProcessorTest.php` (no-op sans flag / suppression avec +flag / mise à jour d'une entrée non vide). Le processor dépend des interfaces +`EmployeeScopedRepositoryInterface` / `WorkHourReadRepositoryInterface` (repos concrets `final`, +non mockables) pour permettre ces tests unitaires. + +## Limite connue résiduelle (hors périmètre) + +Reste le cas où l'admin **édite explicitement** une ligne sur des données périmées (il voit la +ligne vide, tape une valeur, écrasant une saisie concurrente sur cette même ligne). Ce cas +relèverait d'un **verrou optimiste** (comparaison d'`updatedAt`/version côté backend), non +implémenté ici — choix métier (option « suppression explicite » retenue plutôt que verrou +optimiste). Le backend n'a aucune détection de conflit concurrent sur l'**édition** (seule la +**suppression** est désormais protégée par l'intention explicite). diff --git a/frontend/composables/useDriverHoursPage.ts b/frontend/composables/useDriverHoursPage.ts index 08d5ccf..18eacb8 100644 --- a/frontend/composables/useDriverHoursPage.ts +++ b/frontend/composables/useDriverHoursPage.ts @@ -959,6 +959,12 @@ export const useDriverHoursPage = () => { } } + // Une entrée conducteur est vide quand aucune minute (jour/nuit/atelier) ni repas/nuitée + // n'est renseignée. Sert à marquer une suppression explicite (`delete: true`). + const isEntryEmpty = (entry: ReturnType) => + !entry.dayHoursMinutes && !entry.nightHoursMinutes && !entry.workshopHoursMinutes + && !entry.hasBreakfast && !entry.hasLunch && !entry.hasDinner && !entry.hasOvernight + const handleSave = async () => { if (isSubmitting.value || employees.value.length === 0) return @@ -977,7 +983,9 @@ export const useDriverHoursPage = () => { // N'envoie que les lignes réellement modifiées : une ligne intouchée n'est jamais // transmise, donc jamais supprimée même si un autre utilisateur l'a saisie entre-temps. .filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original)) - .map(({ current }) => current) + // Une ligne vidée par l'utilisateur porte le flag `delete` : le backend n'autorise la + // suppression d'une ligne existante que sur intention explicite (anti-grille périmée). + .map(({ current }) => (isEntryEmpty(current) ? { ...current, delete: true } : current)) if (entries.length === 0) return diff --git a/frontend/composables/useHoursPage.ts b/frontend/composables/useHoursPage.ts index ac653e3..a7695c1 100644 --- a/frontend/composables/useHoursPage.ts +++ b/frontend/composables/useHoursPage.ts @@ -1175,6 +1175,14 @@ export const useHoursPage = () => { } } + // Une entrée est vide quand aucune plage horaire ni présence n'est renseignée. + // Sert à marquer une suppression explicite (`delete: true`) côté bulk-upsert. + const isEntryEmpty = (entry: ReturnType) => + !entry.morningFrom && !entry.morningTo + && !entry.afternoonFrom && !entry.afternoonTo + && !entry.eveningFrom && !entry.eveningTo + && !entry.isPresentMorning && !entry.isPresentAfternoon + const handleSave = async () => { if (isSubmitting.value || employees.value.length === 0) return @@ -1190,7 +1198,10 @@ export const useHoursPage = () => { // N'envoie que les lignes réellement modifiées : une ligne intouchée n'est jamais // transmise, donc jamais supprimée même si un autre utilisateur l'a saisie entre-temps. .filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original)) - .map(({ current }) => current) + // Une ligne vidée par l'utilisateur (donc différente de l'instantané chargé) porte le + // flag `delete` : le backend n'autorise la suppression d'une ligne existante que sur + // intention explicite. Sans ce flag, une grille périmée ne peut rien détruire. + .map(({ current }) => (isEntryEmpty(current) ? { ...current, delete: true } : current)) if (entries.length === 0) { return diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index 0a4ff0c..d8a0ff2 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -363,6 +363,7 @@ export const documentationSections: DocSection[] = [ { type: 'paragraph', content: 'Les validations sont automatiquement réinitialisées dans certaines conditions.' }, { type: 'list', content: 'Toute vraie modification d\'une ligne remet les deux validations (site et RH) à faux\nUn enregistrement sans changement réel préserve les validations existantes\nLa date de modification est mise à jour uniquement quand un employé modifie ses propres heures' }, { type: 'note', content: 'La date de modification est visible uniquement par les administrateurs, sous le nom de l\'employé dans la vue jour.' }, + { type: 'note', content: 'Sécurité anti-écrasement : « Enregistrer » ne touche que les lignes que vous avez réellement modifiées ; les lignes auxquelles vous n\'avez pas touché ne sont jamais envoyées, donc jamais écrasées même si un autre utilisateur les a saisies pendant que votre écran était ouvert. Pour supprimer les heures d\'un salarié, videz explicitement sa ligne puis Enregistrer. Conseil : si votre écran est resté ouvert longtemps, rechargez la page avant de saisir pour repartir des données à jour.' }, ], }, ], diff --git a/frontend/services/dto/work-hour.ts b/frontend/services/dto/work-hour.ts index 5bf32c6..8c7ec0a 100644 --- a/frontend/services/dto/work-hour.ts +++ b/frontend/services/dto/work-hour.ts @@ -42,6 +42,10 @@ export type WorkHourEntryPayload = { hasLunch?: boolean hasDinner?: boolean hasOvernight?: boolean + // Autorise la suppression d'une ligne existante quand l'entrée est vide. + // Sans ce flag, le backend ignore une entrée vide sur une ligne existante + // (garde anti-perte de données contre les grilles périmées). + delete?: boolean } export type WeeklyWorkHourDailySummary = { diff --git a/src/ApiResource/WorkHourBulkUpsert.php b/src/ApiResource/WorkHourBulkUpsert.php index 5208aac..4d3e04e 100644 --- a/src/ApiResource/WorkHourBulkUpsert.php +++ b/src/ApiResource/WorkHourBulkUpsert.php @@ -37,8 +37,13 @@ final class WorkHourBulkUpsert * nightHoursMinutes?:?int, * hasBreakfast?:bool, * hasLunch?:bool, - * hasOvernight?:bool + * hasOvernight?:bool, + * delete?:bool * }> + * + * Le flag `delete` (défaut false) autorise la suppression d'une ligne existante + * quand l'entrée est vide. Sans lui, une entrée vide sur une ligne existante est + * un no-op (garde anti-perte de données contre les grilles périmées). */ public array $entries = []; } diff --git a/src/Repository/Contract/EmployeeScopedRepositoryInterface.php b/src/Repository/Contract/EmployeeScopedRepositoryInterface.php index e9bb07c..e962502 100644 --- a/src/Repository/Contract/EmployeeScopedRepositoryInterface.php +++ b/src/Repository/Contract/EmployeeScopedRepositoryInterface.php @@ -13,4 +13,11 @@ interface EmployeeScopedRepositoryInterface * @return list */ public function findScoped(User $user): array; + + /** + * @param list $employeeIds + * + * @return array + */ + public function findAccessibleByIds(array $employeeIds, User $user): array; } diff --git a/src/Repository/Contract/WorkHourReadRepositoryInterface.php b/src/Repository/Contract/WorkHourReadRepositoryInterface.php index 8659efe..0da7eef 100644 --- a/src/Repository/Contract/WorkHourReadRepositoryInterface.php +++ b/src/Repository/Contract/WorkHourReadRepositoryInterface.php @@ -18,6 +18,13 @@ interface WorkHourReadRepositoryInterface */ public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array; + /** + * @param list $employees + * + * @return array + */ + public function findByDateAndEmployeesIndexedByEmployeeId(DateTimeImmutable $workDate, array $employees): array; + public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour; public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool; diff --git a/src/State/WorkHourBulkUpsertProcessor.php b/src/State/WorkHourBulkUpsertProcessor.php index 70e7f71..1eab85d 100644 --- a/src/State/WorkHourBulkUpsertProcessor.php +++ b/src/State/WorkHourBulkUpsertProcessor.php @@ -12,8 +12,8 @@ use App\Entity\User; use App\Entity\WorkHour; use App\Enum\TrackingMode; use App\Repository\Contract\AbsenceReadRepositoryInterface; -use App\Repository\EmployeeRepository; -use App\Repository\WorkHourRepository; +use App\Repository\Contract\EmployeeScopedRepositoryInterface; +use App\Repository\Contract\WorkHourReadRepositoryInterface; use App\Service\AuditLogger; use App\Service\Contracts\EmployeeContractResolver; use DateTimeImmutable; @@ -28,8 +28,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface public function __construct( private EntityManagerInterface $entityManager, private Security $security, - private EmployeeRepository $employeeRepository, - private WorkHourRepository $workHourRepository, + private EmployeeScopedRepositoryInterface $employeeRepository, + private WorkHourReadRepositoryInterface $workHourRepository, private AbsenceReadRepositoryInterface $absenceRepository, private EmployeeContractResolver $contractResolver, private AuditLogger $auditLogger, @@ -142,8 +142,14 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface $empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')); if ($this->isEntryEmpty($normalized)) { - // Convention choisie: une ligne vide supprime l'enregistrement existant. - if ($existing) { + // Garde anti-perte de données : une ligne vide ne supprime l'enregistrement + // existant QUE si la suppression est explicitement demandée (`delete: true`). + // Sans ce flag, une grille périmée (ex. vieil onglet sans dirty-tracking front) + // ne peut plus détruire une ligne saisie entre-temps par un autre utilisateur : + // l'entrée vide est traitée comme un no-op. + $deleteRequested = true === ($entry['delete'] ?? false); + + if ($existing && $deleteRequested) { $this->auditLogger->log( $employee, 'delete', @@ -155,7 +161,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface ); $this->entityManager->remove($existing); ++$result->deleted; - } elseif (($absenceByEmployeeId[$employeeId] ?? false) === true || $is4hContract) { + } elseif (null === $existing && (($absenceByEmployeeId[$employeeId] ?? false) === true || $is4hContract)) { // Si une absence existe ce jour ou contrat 4h, on garde une ligne technique pour pouvoir valider la journée. $workHour = new WorkHour() ->setEmployee($employee) diff --git a/tests/State/WorkHourBulkUpsertProcessorTest.php b/tests/State/WorkHourBulkUpsertProcessorTest.php new file mode 100644 index 0000000..84f2311 --- /dev/null +++ b/tests/State/WorkHourBulkUpsertProcessorTest.php @@ -0,0 +1,139 @@ +createMock(EntityManagerInterface::class); + $em->expects(self::never())->method('remove'); + $em->expects(self::once())->method('flush'); + + $result = $this->buildProcessor($em)->process( + $this->payload(['employeeId' => 7]), + new Post(), + ); + + self::assertSame(0, $result->deleted); + self::assertSame(1, $result->processed); + } + + public function testEmptyEntryWithDeleteFlagRemovesExistingRow(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('remove'); + $em->expects(self::once())->method('flush'); + + $result = $this->buildProcessor($em)->process( + $this->payload(['employeeId' => 7, 'delete' => true]), + new Post(), + ); + + self::assertSame(1, $result->deleted); + } + + public function testNonEmptyEntryStillUpdatesRegardlessOfFlag(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::never())->method('remove'); + $em->expects(self::once())->method('flush'); + + $result = $this->buildProcessor($em)->process( + $this->payload([ + 'employeeId' => 7, + 'morningFrom' => '08:00', + 'morningTo' => '12:30', + 'afternoonFrom' => '14:00', + 'afternoonTo' => '18:00', + ]), + new Post(), + ); + + self::assertSame(1, $result->updated); + } + + /** + * @param array $entry + */ + private function payload(array $entry): WorkHourBulkUpsert + { + $payload = new WorkHourBulkUpsert(); + $payload->workDate = '2026-06-24'; + $payload->entries = [$entry]; + + return $payload; + } + + private function buildProcessor(EntityManagerInterface $em): WorkHourBulkUpsertProcessor + { + $user = new User()->setUsername('Elodie')->setRoles(['ROLE_ADMIN']); + + $employee = new Employee()->setFirstName('Delphine')->setLastName('BACHELIER'); + + // Ligne existante NON vide (journée complète saisie entre-temps par un autre utilisateur). + $existing = new WorkHour() + ->setEmployee($employee) + ->setWorkDate(new DateTimeImmutable('2026-06-24')) + ->setMorningFrom('08:00') + ->setMorningTo('12:00') + ->setAfternoonFrom('14:00') + ->setAfternoonTo('18:00') + ; + + $contract = new Contract()->setTrackingMode(TrackingMode::TIME)->setWeeklyHours(35); + + $security = $this->createStub(Security::class); + $security->method('getUser')->willReturn($user); + + $employeeRepository = $this->createStub(EmployeeScopedRepositoryInterface::class); + $employeeRepository->method('findAccessibleByIds')->willReturn([7 => $employee]); + + $workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class); + $workHourRepository->method('findByDateAndEmployeesIndexedByEmployeeId')->willReturn([7 => $existing]); + + $absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class); + $absenceRepository->method('findByDateAndEmployees')->willReturn([]); + + $contractResolver = $this->createStub(EmployeeContractResolver::class); + $contractResolver->method('resolveForEmployeeAndDate')->willReturn($contract); + $contractResolver->method('resolveIsDriverForEmployeeAndDate')->willReturn(false); + + return new WorkHourBulkUpsertProcessor( + $em, + $security, + $employeeRepository, + $workHourRepository, + $absenceRepository, + $contractResolver, + $this->createStub(AuditLogger::class), + ); + } +}