Compare commits

..

2 Commits

Author SHA1 Message Date
gitea-actions fe317f37b4 chore: bump version to v0.1.127
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 50s
2026-06-25 07:29:13 +00:00
tristan d66288d061 fix(heures) : garde backend anti-suppression sur grille périmée (delete explicite) (#36)
Auto Tag Develop / tag (push) Successful in 13s
## 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: #36
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-25 07:29:02 +00:00
12 changed files with 248 additions and 18 deletions
+2 -1
View File
@@ -68,7 +68,8 @@
- `isSiteValid` (site manager): locks for non-admin, admin can still edit - `isSiteValid` (site manager): locks for non-admin, admin can still edit
- Any real modification resets both `isSiteValid=false` and `isValid=false` - Any real modification resets both `isSiteValid=false` and `isValid=false`
- No-op saves preserve existing validations - 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 ## Overtime Rules
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond - Contracts <= 35h: +25% from 35h to 43h, +50% beyond
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.126' app.version: '0.1.127'
+47 -6
View File
@@ -45,10 +45,51 @@ Conséquences :
Implémenté symétriquement dans `frontend/composables/useHoursPage.ts` (non-conducteurs) et Implémenté symétriquement dans `frontend/composables/useHoursPage.ts` (non-conducteurs) et
`frontend/composables/useDriverHoursPage.ts` (conducteurs). `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 Le suivi front **ne protège que les sessions qui chargent le nouveau bundle**. Un **vieil
ligne sur des données périmées (il voit la ligne vide, tape une valeur, écrasant une saisie onglet** ouvert avant le déploiement continue de tourner sur l'ancien JavaScript (sans
concurrente sur cette même ligne). Ce cas résiduel relèverait d'un **verrou optimiste** dirty-tracking) et envoie toute la grille périmée → **le bug s'est reproduit en prod** (un onglet
(comparaison d'`updatedAt`/version côté backend), non implémenté ici. Le backend n'a aucune ouvert le matin a supprimé une dizaine de lignes saisies dans la journée par d'autres
détection de conflit concurrent (pas de version, pas d'horodatage comparé). 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).
+9 -1
View File
@@ -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<typeof buildEntry>) =>
!entry.dayHoursMinutes && !entry.nightHoursMinutes && !entry.workshopHoursMinutes
&& !entry.hasBreakfast && !entry.hasLunch && !entry.hasDinner && !entry.hasOvernight
const handleSave = async () => { const handleSave = async () => {
if (isSubmitting.value || employees.value.length === 0) return 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 // 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. // 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)) .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 if (entries.length === 0) return
+12 -1
View File
@@ -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<typeof buildEntry>) =>
!entry.morningFrom && !entry.morningTo
&& !entry.afternoonFrom && !entry.afternoonTo
&& !entry.eveningFrom && !entry.eveningTo
&& !entry.isPresentMorning && !entry.isPresentAfternoon
const handleSave = async () => { const handleSave = async () => {
if (isSubmitting.value || employees.value.length === 0) return 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 // 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. // 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)) .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) { if (entries.length === 0) {
return return
+1
View File
@@ -363,6 +363,7 @@ export const documentationSections: DocSection[] = [
{ type: 'paragraph', content: 'Les validations sont automatiquement réinitialisées dans certaines conditions.' }, { 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: '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: '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.' },
], ],
}, },
], ],
+4
View File
@@ -42,6 +42,10 @@ export type WorkHourEntryPayload = {
hasLunch?: boolean hasLunch?: boolean
hasDinner?: boolean hasDinner?: boolean
hasOvernight?: 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 = { export type WeeklyWorkHourDailySummary = {
+6 -1
View File
@@ -37,8 +37,13 @@ final class WorkHourBulkUpsert
* nightHoursMinutes?:?int, * nightHoursMinutes?:?int,
* hasBreakfast?:bool, * hasBreakfast?:bool,
* hasLunch?: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 = []; public array $entries = [];
} }
@@ -13,4 +13,11 @@ interface EmployeeScopedRepositoryInterface
* @return list<Employee> * @return list<Employee>
*/ */
public function findScoped(User $user): array; public function findScoped(User $user): array;
/**
* @param list<int> $employeeIds
*
* @return array<int, Employee>
*/
public function findAccessibleByIds(array $employeeIds, User $user): array;
} }
@@ -18,6 +18,13 @@ interface WorkHourReadRepositoryInterface
*/ */
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array; public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array;
/**
* @param list<Employee> $employees
*
* @return array<int, WorkHour>
*/
public function findByDateAndEmployeesIndexedByEmployeeId(DateTimeImmutable $workDate, array $employees): array;
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour; public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour;
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool; public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
+13 -7
View File
@@ -12,8 +12,8 @@ use App\Entity\User;
use App\Entity\WorkHour; use App\Entity\WorkHour;
use App\Enum\TrackingMode; use App\Enum\TrackingMode;
use App\Repository\Contract\AbsenceReadRepositoryInterface; use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\EmployeeRepository; use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\WorkHourRepository; use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\AuditLogger; use App\Service\AuditLogger;
use App\Service\Contracts\EmployeeContractResolver; use App\Service\Contracts\EmployeeContractResolver;
use DateTimeImmutable; use DateTimeImmutable;
@@ -28,8 +28,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
public function __construct( public function __construct(
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private Security $security, private Security $security,
private EmployeeRepository $employeeRepository, private EmployeeScopedRepositoryInterface $employeeRepository,
private WorkHourRepository $workHourRepository, private WorkHourReadRepositoryInterface $workHourRepository,
private AbsenceReadRepositoryInterface $absenceRepository, private AbsenceReadRepositoryInterface $absenceRepository,
private EmployeeContractResolver $contractResolver, private EmployeeContractResolver $contractResolver,
private AuditLogger $auditLogger, private AuditLogger $auditLogger,
@@ -142,8 +142,14 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')); $empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
if ($this->isEntryEmpty($normalized)) { if ($this->isEntryEmpty($normalized)) {
// Convention choisie: une ligne vide supprime l'enregistrement existant. // Garde anti-perte de données : une ligne vide ne supprime l'enregistrement
if ($existing) { // 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( $this->auditLogger->log(
$employee, $employee,
'delete', 'delete',
@@ -155,7 +161,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
); );
$this->entityManager->remove($existing); $this->entityManager->remove($existing);
++$result->deleted; ++$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. // Si une absence existe ce jour ou contrat 4h, on garde une ligne technique pour pouvoir valider la journée.
$workHour = new WorkHour() $workHour = new WorkHour()
->setEmployee($employee) ->setEmployee($employee)
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Post;
use App\ApiResource\WorkHourBulkUpsert;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Enum\TrackingMode;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\AuditLogger;
use App\Service\Contracts\EmployeeContractResolver;
use App\State\WorkHourBulkUpsertProcessor;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
/**
* Garde anti-perte de données : une entrée vide ne supprime une ligne existante
* QUE si la suppression est explicitement demandée (flag `delete: true`).
* Sans ce flag, une grille périmée (vieil onglet) ne peut plus rien détruire.
*
* @internal
*/
final class WorkHourBulkUpsertProcessorTest extends TestCase
{
public function testEmptyEntryWithoutDeleteFlagPreservesExistingRow(): 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]),
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<string, mixed> $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),
);
}
}