Compare commits

..

6 Commits

Author SHA1 Message Date
gitea-actions 9cc5024e25 chore: bump version to v0.1.109
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 36s
2026-06-09 14:04:56 +00:00
tristan b6c0dfb90b feat(heures) : codes d'absence, total en gras et légende sur l'export PDF jour (#25)
Auto Tag Develop / tag (push) Successful in 7s
Affinements de l'export PDF des heures (vue Jour) :

- **Colonne Statut** : affiche le **code** du type d'absence (ex. `AT`) au lieu du libellé, sur sa couleur de fond. Férié sans absence inchangé (nom du férié sur fond bleu clair).
- **Colonne Total** en gras.
- **Légende** sous le tableau : carré coloré contenant le code + libellé à droite, 6 éléments par ligne, triée et dédupliquée (hors férié).
- **Bouton Exporter masqué en vue Semaine** (visible uniquement en vue Jour).

Docs mises à jour : `doc/hours-day-export.md`, `frontend/data/documentation-content.ts`, `CLAUDE.md`. Tests backend verts (173/361).

Reviewed-on: #25
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-09 14:04:50 +00:00
gitea-actions 9dff25d61a chore: bump version to v0.1.108
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 52s
2026-06-09 12:59:10 +00:00
tristan 6f9d19bda3 feat(heures) : export PDF des heures (vue jour) par sites (#24)
Auto Tag Develop / tag (push) Successful in 7s
## Résumé
Ajoute un bouton **Exporter** (admin uniquement) à droite du titre « Heures » qui génère un **PDF d'une journée**, regroupé par site, reprenant les colonnes de la vue Jour **sans la colonne « Valider »**.

- Drawer : champ date (préremplit la date affichée) + cases à cocher des sites (préselectionnées sur le filtre courant).
- Portée identique à l'écran : non-conducteurs, sous contrat à la date, sites cochés (lignes vides incluses).
- Jour/Nuit/Total incluent le crédit d'absence et le crédit virtuel férié.

## Implémentation
- Back : `WorkHourDayExport` (ApiResource) + `WorkHourDayExportProvider`, endpoint `GET /work-hours/day-export?workDate=&siteIds=` (ROLE_ADMIN).
- Calcul des cellules mutualisé via `YearlyHoursExportBuilder::buildDayRowsForEmployees` (source unique de vérité).
- Gabarit `templates/work-hour-day-export/print.html.twig` (A4 portrait compact).
- Front : `HoursDayExportDrawer.vue` + câblage dans `pages/hours.vue`.
- Docs : `doc/hours-day-export.md`, `documentation-content.ts`, `CLAUDE.md`.

## Tests
- Test unitaire `YearlyHoursDayRowsTest` ajouté.
- Suite complète verte : 173 tests, 359 assertions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #24
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-09 12:59:04 +00:00
gitea-actions 2745f4e476 chore: bump version to v0.1.107
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 34s
2026-06-08 13:27:41 +00:00
tristan 1edb8d956f feat(rtt) : paiement RTT rétroactif sur l'exercice précédent (#23)
Auto Tag Develop / tag (push) Successful in 7s
## Besoin RH
Pouvoir saisir un paiement RTT sur l'exercice précédent (ex. RTT de mai réglés après la bascule du 1er juin).

## Implémentation (Option B)
- Paiement autorisé sur l'exercice courant + l'exercice immédiatement précédent (N-1).
- Après saisie sur N-1, le report d'ouverture de l'exercice courant est recalculé automatiquement (computeClosingBalance) dans une transaction → aucun double comptage.
- Refus si ce report est verrouillé (is_locked) : la RH le déverrouille d'abord.
- Fallback EmployeeRttSummaryProvider::resolveCarry aligné sur computeClosingBalance : disponible correct même sans ligne stockée.
- Front : bouton « + Payer les RTT » actif sur l'exercice précédent.
- Docs : CLAUDE.md, doc/rtt-tab.md, documentation-content.ts.

## Vérification
-  172 tests OK, cs-fixer OK, conteneur compile.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #23
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-08 13:27:34 +00:00
19 changed files with 1822 additions and 18 deletions
+8
View File
@@ -35,6 +35,7 @@
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
- **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui). Idem pour le **mode de suivi (TIME/PRESENCE), les heures hebdo et le libellé de contrat** sur la vue Jour : résolus à la date filtrée via `WorkHourDayContext` (`trackingMode`/`weeklyHours`/`contractType`/`contractName`, peuplés depuis `EmployeeContractResolver::resolveForEmployeeAndDate`), pas via `employee.contract` (résolu à aujourd'hui). Côté front, `resolveDayContract()` (`useHoursPage.ts`) pilote l'affichage et `handleSave` (heures vs présence par date).
- **Exports heures annuelles** (par salarié `EmployeeYearlyHoursPrintProvider` + tous `EmployeeYearlyHoursBulkPrintProvider`, via `YearlyHoursExportBuilder`) : **tous les jours sous contrat sont affichés**, même vides ou non saisis (jusqu'à aujourd'hui). Seuls les jours hors contrat sont omis (`buildSegments` : un seul filtre `!$hasData && null === $contract`). Ne pas réintroduire de saut des jours de semaine vides. Samedis/dimanches grisés (`#c0c0c0`) dans les templates `employee-yearly-hours/print*.html.twig`. NB : l'export *tous employés* sur l'année peut dépasser `memory_limit=256M` (Dompdf) — limitation pré-existante, voir avec l'infra si besoin.
- **Export heures vue Jour** (`WorkHourDayExportProvider`, endpoint `GET /work-hours/day-export?workDate=&siteIds=`, `ROLE_ADMIN`) : bouton « Exporter » à droite du titre « Heures », **visible uniquement en vue Jour** (`v-if="isAdmin && viewMode === 'day'"`, masqué en vue Semaine). PDF A4 portrait d'**une seule journée**, **regroupé par site**, colonnes de la vue Jour **sans « Valider »** (colonne **Total en gras**). Mêmes employés que l'écran : non-conducteurs, sous contrat à la date, sites cochés (lignes vides incluses). Calcul des cellules mutualisé via `YearlyHoursExportBuilder::buildDayRowsForEmployees` (Jour/Nuit/Total incluent crédit absence + crédit virtuel férié). Colonne **Statut = code** du type d'absence (`AbsenceType::getCode`, ex. `AT`) sur sa couleur de fond ; férié sans absence → nom du férié sur `#b3e5fc`. Chaque row porte `statut` (code), `statutLabel` (libellé, pour la légende) et `statutColor`. **Légende** sous le tableau (carré coloré contenant le code + libellé à droite), construite côté provider à partir des codes présents (hors férié, dédupliquée par code, triée). Gabarit `templates/work-hour-day-export/print.html.twig`.
- **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`). **L'impression PDF des absences applique le même filtre** côté backend (`AbsencePrintProvider::hasContractInRange` sur la période `[from, to]`) : un salarié parti en avril n'apparaît pas sur une impression de mai. **Le récap salaire applique le même filtre** (`SalaryRecapPrintProvider::hasContractInRange` sur le mois imprimé) : un salarié sans contrat sur le mois (ex. parti en février) n'apparaît pas sur le récap de juin.
- **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`.
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
@@ -96,8 +97,15 @@
- Service mutualisé : `App\Service\Rtt\RttClosingBalanceService` (méthode `computeClosingBalance` + `fold` pur testable). `fold` garantit `somme(tranches) = report + acquis payés` ; la cascade des semaines déficitaires draine la tranche 50% avant la 25%, et la récup non bucketisée (CUSTOM 1h=1h, arrondis) atterrit en `base25` pour que la somme égale le total.
- Options : `--force` (hors 01/06) ; `--recompute` (recalcule/écrase les lignes existantes au lieu de les sauter ; **ne touche jamais** une ligne verrouillée `is_locked`). Reprise d'une bascule erronée : `app:rtt:rollover --force --recompute`.
- ⚠️ Bug historique : la 1ʳᵉ version ne reportait que `acquis(N-1)` (report d'ouverture perdu, paiements non déduits). Corrigé via `RttClosingBalanceService`.
- **Fallback provider** : quand aucune ligne `employee_rtt_balances` n'existe pour l'exercice affiché (avant la bascule), `EmployeeRttSummaryProvider::resolveCarry` calcule le report en direct via `RttClosingBalanceService::computeClosingBalance($year-1)` (et non plus `computeTotalRecoveryForExercise`) — le disponible reste donc correct (report d'ouverture + acquis payés) même sans ligne stockée.
- Doc : `doc/rtt-rollover.md`.
## Paiement RTT rétroactif (exercice précédent) — Option B
- Le paiement RTT est autorisé sur : l'**exercice courant**, l'**exercice immédiatement précédent** (N-1), ou le dernier exercice d'une phase clôturée. Garde back : `EmployeeRttPaymentProcessor::assertYearAllowedForPayment`. Garde front : `RttTab.vue` `isPayDisabled` (bouton actif sur `selectedYear === currentYear - 1`).
- **Cohérence du report** : un paiement sur N-1 modifie la clôture de N-1 = ouverture de N. Le processor **recalcule automatiquement** la ligne `employee_rtt_balances` de l'exercice courant (`computeClosingBalance(N-1)`) dans une **transaction** (le `flush` du paiement le rend visible au recalcul). Pas de double comptage.
- **Verrou** : si le report de l'exercice courant est `is_locked`, le paiement rétroactif est **refusé** (`assertReportNotLocked`) — la RH doit déverrouiller d'abord.
- Portée limitée à N-1 (chaîne de recalcul = 1 étape). Si la ligne courante n'existe pas encore, le fallback provider couvre l'affichage (cf. ci-dessus).
## Vue contrat (sélecteur de phase)
- Picker `Vue contrat` en haut de la fiche employé (`pages/employees/[id].vue`). Caché si l'employé n'a qu'une phase.
- Phase = groupe d'`EmployeeContractPeriod` consécutifs partageant la signature `(contract.type, weeklyHours, isDriver)`. Résolu par `App\Service\Contracts\EmployeeContractPhaseResolver`.
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.106'
app.version: '0.1.109'
+30
View File
@@ -0,0 +1,30 @@
# Export PDF des heures — vue Jour
Bouton **Exporter** à droite du titre « Heures », visible **uniquement pour les
administrateurs** (`ROLE_ADMIN`) et **uniquement en vue Jour** (masqué en vue Semaine).
## Comportement
- Ouvre un drawer : un champ **date** (préremplit la date affichée) et des **cases à
cocher des sites** (présélectionnées sur le filtre courant).
- Génère un **PDF A4 portrait** d'une seule journée, **regroupé par site**.
## Données
- Mêmes employés que la vue Jour : **non-conducteurs**, **sous contrat** à la date
choisie, des sites cochés. Les employés sous contrat sans saisie apparaissent (lignes
vides).
- Colonnes : Nom · Statut · Début matin · Fin matin · Début après-midi · Fin après-midi ·
Début soir · Fin soir · Jour · Nuit · **Total** (en gras). **Pas de colonne « Valider ».**
- Colonne **Statut** : affiche le **code** du type d'absence (ex. `AT`), pas le libellé,
sur la couleur de fond du type. Un jour férié sans absence affiche le **nom du férié**
sur fond bleu clair (`#b3e5fc`).
- Jour / Nuit / Total : identiques à l'écran (crédit d'absence `countAsWorkedHours` et
crédit virtuel férié inclus).
- **Légende** sous le tableau : pour chaque code d'absence présent (hors férié), un carré
de couleur contenant le code et le libellé du type à droite. Triée par code, dédupliquée.
## Technique
- Endpoint : `GET /work-hours/day-export?workDate=YYYY-MM-DD&siteIds=1,2,3` (`ROLE_ADMIN`).
- Provider : `App\State\WorkHourDayExportProvider`.
- Calcul des cellules : `YearlyHoursExportBuilder::buildDayRowsForEmployees` (source
unique de vérité, partagée avec les exports annuels).
- Gabarit : `templates/work-hour-day-export/print.html.twig`.
+11 -2
View File
@@ -34,9 +34,18 @@ Comportement :
## Verrouillage des éditions sur exercices passés
Quand `selectedYear !== currentYear` (consultation d'un exercice antérieur), le bouton **+ Payer les RTT** est désactivé. Justification : un paiement rétroactif sur un exercice clos décalerait les soldes courants et le report N-1 calculé.
Quand `selectedYear !== currentYear` (consultation d'un exercice antérieur), le bouton **+ Payer les RTT** est désactivé**sauf sur l'exercice immédiatement précédent** (`selectedYear === currentYear - 1`), où le paiement rétroactif est autorisé (Option B).
La consultation reste possible, l'édition non.
La consultation des exercices plus anciens reste possible, l'édition non.
### Paiement rétroactif sur l'exercice précédent (Option B)
Un paiement enregistré sur l'exercice N-1 modifie sa clôture, donc le **report d'ouverture de l'exercice courant N**. Pour éviter tout décalage / double comptage :
- garde back `EmployeeRttPaymentProcessor::assertYearAllowedForPayment` : accepte courant, **N-1**, ou dernier exercice d'une phase clôturée ;
- après enregistrement, le processor **recalcule automatiquement** la ligne `employee_rtt_balances` de l'exercice courant via `RttClosingBalanceService::computeClosingBalance(N-1)`, dans une **transaction** (le `flush` du paiement le rend visible au recalcul) ;
- si le report de l'exercice courant est **verrouillé** (`is_locked`), le paiement est **refusé** (`assertReportNotLocked`) : la RH doit déverrouiller d'abord ;
- portée volontairement limitée à N-1 (chaîne de recalcul = 1 étape). Si la ligne courante n'existe pas encore, l'affichage reste correct grâce au fallback de `EmployeeRttSummaryProvider::resolveCarry` (calcul dynamique de la clôture N-1).
## Sélecteur de phase de contrat
@@ -0,0 +1,854 @@
# Export PDF des heures — vue Jour — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ajouter un bouton « Exporter » (admin) sur l'écran Heures qui génère un PDF d'une journée (colonnes de la vue Jour, sans Valider) pour les employés des sites sélectionnés, regroupés par site.
**Architecture:** Réutilisation de `YearlyHoursExportBuilder` (nouvelle méthode `buildDayRowsForEmployees`) pour le calcul des cellules d'une journée — source unique de vérité. Une `ApiResource` GET `/work-hours/day-export` + provider rend un Twig A4 portrait via Dompdf. Côté front, un `AppDrawer` (date + checkboxes sites) déclenche le téléchargement via `usePdfPrinter`.
**Tech Stack:** Symfony + API Platform + Doctrine, Dompdf, Twig ; Nuxt 4 + Vue 3 + TypeScript + `@malio/layer-ui`.
---
## File Structure
**Backend**
- `src/Service/WorkHours/YearlyHoursExportBuilder.php` (modifier) — ajout méthode publique `buildDayRowsForEmployees`.
- `tests/Service/WorkHours/YearlyHoursDayRowsTest.php` (créer) — test unitaire de la nouvelle méthode.
- `src/ApiResource/WorkHourDayExport.php` (créer) — opération GET `/work-hours/day-export`.
- `src/State/WorkHourDayExportProvider.php` (créer) — parse params, scope/filtre/groupe, rend le PDF.
- `templates/work-hour-day-export/print.html.twig` (créer) — gabarit A4 portrait.
**Frontend**
- `frontend/components/hours/HoursDayExportDrawer.vue` (créer) — drawer date + sites.
- `frontend/pages/hours.vue` (modifier) — bouton « Exporter » + câblage drawer + appel export.
**Docs**
- `doc/hours-day-export.md` (créer).
- `frontend/data/documentation-content.ts` (modifier) — entrée admin.
- `CLAUDE.md` (modifier) — note sous la section exports heures.
---
## Task 1 : Méthode `buildDayRowsForEmployees` (backend, TDD)
**Files:**
- Test: `tests/Service/WorkHours/YearlyHoursDayRowsTest.php`
- Modify: `src/Service/WorkHours/YearlyHoursExportBuilder.php`
- [ ] **Step 1 : Écrire le test qui échoue**
Créer `tests/Service/WorkHours/YearlyHoursDayRowsTest.php` :
```php
<?php
declare(strict_types=1);
namespace App\Tests\Service\WorkHours;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\WorkHour;
use App\Repository\AbsenceRepository;
use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use App\Service\WorkHours\YearlyHoursExportBuilder;
use App\Service\PublicHolidayServiceInterface;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class YearlyHoursDayRowsTest extends TestCase
{
public function testTimeContractRowComputesHoursAndExcludesNoContract(): void
{
$date = new DateTimeImmutable('2026-06-08'); // lundi
$contract = new Contract();
$contract->setName('35h');
$contract->setTrackingMode(Contract::TRACKING_TIME);
$contract->setWeeklyHours(35);
$withContract = new Employee();
$withContract->setFirstName('Jean')->setLastName('Dupont');
$this->setEmployeeId($withContract, 1);
$noContract = new Employee();
$noContract->setFirstName('Paul')->setLastName('Martin');
$this->setEmployeeId($noContract, 2);
$workHour = new WorkHour();
$workHour->setEmployee($withContract)
->setWorkDate($date)
->setMorningFrom('08:00')->setMorningTo('12:00')
->setAfternoonFrom('13:00')->setAfternoonTo('17:00');
$workHourRepo = $this->createStub(WorkHourRepository::class);
$workHourRepo->method('findByDateRangeAndEmployees')->willReturn([$workHour]);
$absenceRepo = $this->createStub(AbsenceRepository::class);
$absenceRepo->method('findForPrint')->willReturn([]);
$contractResolver = $this->createStub(EmployeeContractResolver::class);
$contractResolver->method('resolveForEmployeesAndDays')->willReturn([
1 => ['2026-06-08' => $contract],
2 => ['2026-06-08' => null],
]);
$contractResolver->method('resolveIsDriverForEmployeesAndDays')->willReturn([
1 => ['2026-06-08' => false],
2 => ['2026-06-08' => false],
]);
$contractResolver->method('resolveWorkDaysMinutesForEmployeesAndDays')->willReturn([
1 => ['2026-06-08' => null],
2 => ['2026-06-08' => null],
]);
$holidayService = $this->createStub(PublicHolidayServiceInterface::class);
$holidayService->method('getHolidaysDayByYears')->willReturn([]);
$virtualResolver = $this->createStub(HolidayVirtualHoursResolver::class);
$virtualResolver->method('resolveVirtualCredit')->willReturn(0);
$builder = new YearlyHoursExportBuilder(
$workHourRepo,
$absenceRepo,
$contractResolver,
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($contractResolver, new DailyReferenceMinutesResolver()),
$holidayService,
$virtualResolver,
);
$rows = $builder->buildDayRowsForEmployees([$withContract, $noContract], $date);
self::assertCount(1, $rows);
self::assertSame(1, $rows[0]['employeeId']);
self::assertSame('Dupont Jean', $rows[0]['employeeName']);
self::assertSame('08:00', $rows[0]['morningFrom']);
self::assertSame('17:00', $rows[0]['afternoonTo']);
self::assertSame('8:00', $rows[0]['total']);
self::assertSame('8:00', $rows[0]['dayHours']);
self::assertSame('', $rows[0]['nightHours']);
self::assertNull($rows[0]['statut']);
self::assertFalse($rows[0]['isWeekend']);
}
private function setEmployeeId(Employee $employee, int $id): void
{
$ref = new \ReflectionProperty(Employee::class, 'id');
$ref->setAccessible(true);
$ref->setValue($employee, $id);
}
}
```
- [ ] **Step 2 : Lancer le test, vérifier l'échec**
Run: `make test` (ou `docker exec -t -u www-data php-sirh-fpm php vendor/bin/phpunit --filter YearlyHoursDayRowsTest`)
Expected: FAIL — `Call to undefined method ...::buildDayRowsForEmployees()`.
- [ ] **Step 3 : Implémenter la méthode**
Dans `src/Service/WorkHours/YearlyHoursExportBuilder.php`, ajouter cette méthode publique (après `buildForEmployee`, avant `buildContractLabel`) :
```php
/**
* Construit une ligne par employé pour une seule journée (vue Jour de l'écran Heures).
* Réutilise les helpers de calcul de cellule pour rester l'unique source de vérité.
* Les employés sans contrat ce jour sont exclus (comme l'écran).
*
* @param list<Employee> $employees
*
* @return list<array{employeeId:int, employeeName:string, statut:?string,
* morningFrom:string, morningTo:string, afternoonFrom:string, afternoonTo:string,
* eveningFrom:string, eveningTo:string, dayHours:string, nightHours:string,
* total:string, isWeekend:bool, isHoliday:bool}>
*/
public function buildDayRowsForEmployees(array $employees, DateTimeImmutable $date): array
{
$ymd = $date->format('Y-m-d');
$days = [$ymd];
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($date, $date, $employees);
$absences = $this->absenceRepository->findForPrint($date, $date, $employees);
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$workDaysMap = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days);
$holidayMap = $this->buildHolidayMap($date, $date);
$workHourMap = $this->buildWorkHourMap($workHours);
$absenceMap = $this->buildAbsenceMap($absences, $days);
$isoDay = (int) $date->format('N');
$isWeekend = $isoDay >= 6;
$holidayLabel = $holidayMap[$ymd] ?? null;
$rows = [];
foreach ($employees as $employee) {
$employeeId = $employee->getId();
$contract = $contractMap[$employeeId][$ymd] ?? null;
// Hors contrat ce jour → exclu (avant embauche / après départ / suspension).
if (null === $contract) {
continue;
}
$wh = $workHourMap[$employeeId][$ymd] ?? null;
$absenceData = $this->resolveAbsenceDataForEmployee($absenceMap[$employeeId] ?? [], $days, $employee);
$hasAbsence = $absenceData['hasDayAbsence'][$ymd] ?? false;
$isDriver = $driverMap[$employeeId][$ymd] ?? false;
$mode = $this->resolveSegmentMode($contract->getTrackingMode(), $isDriver);
$creditedMinutes = $absenceData['credited'][$ymd] ?? 0;
$virtualMinutes = $this->holidayVirtualHoursResolver->resolveVirtualCredit(
$contract,
$date,
$hasAbsence,
$workDaysMap[$employeeId][$ymd] ?? null,
);
$statut = $absenceData['labels'][$ymd] ?? null;
if (null === $statut && null !== $holidayLabel) {
$statut = $holidayLabel;
}
$row = [
'employeeId' => $employeeId,
'employeeName' => trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')),
'statut' => $statut,
'morningFrom' => '',
'morningTo' => '',
'afternoonFrom' => '',
'afternoonTo' => '',
'eveningFrom' => '',
'eveningTo' => '',
'dayHours' => '',
'nightHours' => '',
'total' => '',
'isWeekend' => $isWeekend,
'isHoliday' => null !== $holidayLabel,
];
if ('presence' === $mode) {
$absentMorning = $absenceData['absentMorning'][$ymd] ?? false;
$absentAfternoon = $absenceData['absentAfternoon'][$ymd] ?? false;
$morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0;
$afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
$total = $morning + $afternoon;
$row['total'] = $total > 0 ? (string) $total : '';
} elseif ('driver' === $mode) {
$dayMin = $wh?->getDayHoursMinutes() ?? 0;
$nightMin = $wh?->getNightHoursMinutes() ?? 0;
$workshop = $wh?->getWorkshopHoursMinutes() ?? 0;
$totalMin = $dayMin + $nightMin + $workshop + $creditedMinutes;
if ($virtualMinutes > $totalMin) {
$totalMin = $virtualMinutes;
}
$row['dayHours'] = $dayMin > 0 ? $this->formatMinutes($dayMin) : '';
$row['nightHours'] = $nightMin > 0 ? $this->formatMinutes($nightMin) : '';
$row['total'] = $totalMin > 0 ? $this->formatMinutes($totalMin) : '';
} else {
$metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics();
$metrics->addCreditedMinutes($creditedMinutes);
$dayMin = $metrics->dayMinutes;
$nightMin = $metrics->nightMinutes;
$totalMin = $metrics->totalMinutes;
if ($virtualMinutes > $totalMin) {
$dayMin += $virtualMinutes - $totalMin;
$totalMin = $virtualMinutes;
}
$row['morningFrom'] = $wh?->getMorningFrom() ?? '';
$row['morningTo'] = $wh?->getMorningTo() ?? '';
$row['afternoonFrom'] = $wh?->getAfternoonFrom() ?? '';
$row['afternoonTo'] = $wh?->getAfternoonTo() ?? '';
$row['eveningFrom'] = $wh?->getEveningFrom() ?? '';
$row['eveningTo'] = $wh?->getEveningTo() ?? '';
$row['dayHours'] = $dayMin > 0 ? $this->formatMinutes($dayMin) : '';
$row['nightHours'] = $nightMin > 0 ? $this->formatMinutes($nightMin) : '';
$row['total'] = $totalMin > 0 ? $this->formatMinutes($totalMin) : '';
}
$rows[] = $row;
}
return $rows;
}
```
- [ ] **Step 4 : Relancer le test, vérifier le succès**
Run: `make test`
Expected: PASS (toute la suite verte).
- [ ] **Step 5 : Commit**
```bash
git add tests/Service/WorkHours/YearlyHoursDayRowsTest.php src/Service/WorkHours/YearlyHoursExportBuilder.php
git commit -m "feat(heures) : calcul des lignes jour pour export PDF"
```
---
## Task 2 : Gabarit Twig
**Files:**
- Create: `templates/work-hour-day-export/print.html.twig`
- [ ] **Step 1 : Créer le gabarit**
```twig
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Heures - {{ dateLabel }}</title>
<style>
@page { size: A4 portrait; margin: 4mm; }
html, body { margin: 0; padding: 2mm; font-family: Helvetica, sans-serif; font-size: 8px; }
.title-bar { position: relative; margin: 0 0 3mm 0; }
h1 { text-align: center; font-size: 15px; margin: 0; }
.export-date { position: absolute; top: 0; right: 0; font-size: 8px; color: #333; padding-top: 4px; }
h2 { font-size: 11px; margin: 3mm 0 1mm 0; padding: 2px 6px; background: #e8e8e8; }
table { width: 100%; border-collapse: collapse; table-layout: auto; border: 2px solid #0a0a0a; }
th, td { border: 1px solid #0a0a0a; padding: 1px 3px; vertical-align: middle; white-space: nowrap; text-align: center; }
th { font-weight: 700; background: #f0f0f0; }
td.name { text-align: left; }
tr.weekend td { background: #c0c0c0; }
td.statut { background: #b3e5fc; }
.site-block { page-break-inside: auto; }
</style>
</head>
<body>
<div class="title-bar">
<h1>Heures du {{ dateLabel }}</h1>
<div class="export-date">Édité le {{ exportedAt }}</div>
</div>
{% for group in groups %}
<div class="site-block">
<h2>{{ group.siteName }}</h2>
<table>
<thead>
<tr>
<th>Nom</th>
<th>Statut</th>
<th>Début matin</th>
<th>Fin matin</th>
<th>Début après-midi</th>
<th>Fin après-midi</th>
<th>Début soir</th>
<th>Fin soir</th>
<th>Jour</th>
<th>Nuit</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for row in group.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<td class="name">{{ row.employeeName }}</td>
<td class="{{ row.statut ? 'statut' : '' }}">{{ row.statut }}</td>
<td>{{ row.morningFrom }}</td>
<td>{{ row.morningTo }}</td>
<td>{{ row.afternoonFrom }}</td>
<td>{{ row.afternoonTo }}</td>
<td>{{ row.eveningFrom }}</td>
<td>{{ row.eveningTo }}</td>
<td>{{ row.dayHours }}</td>
<td>{{ row.nightHours }}</td>
<td>{{ row.total }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
</body>
</html>
```
- [ ] **Step 2 : Commit**
```bash
git add templates/work-hour-day-export/print.html.twig
git commit -m "feat(heures) : gabarit PDF export jour"
```
---
## Task 3 : ApiResource + Provider
**Files:**
- Create: `src/ApiResource/WorkHourDayExport.php`
- Create: `src/State/WorkHourDayExportProvider.php`
- [ ] **Step 1 : Créer l'ApiResource**
`src/ApiResource/WorkHourDayExport.php` :
```php
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\QueryParameter;
use App\State\WorkHourDayExportProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/work-hours/day-export',
provider: WorkHourDayExportProvider::class,
parameters: [
new QueryParameter(key: 'workDate', required: true),
new QueryParameter(key: 'siteIds', required: true),
],
security: "is_granted('ROLE_ADMIN')"
),
]
)]
final class WorkHourDayExport {}
```
- [ ] **Step 2 : Créer le Provider**
`src/State/WorkHourDayExportProvider.php` :
```php
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Repository\EmployeeRepository;
use App\Service\WorkHours\YearlyHoursExportBuilder;
use DateTimeImmutable;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Twig\Environment;
class WorkHourDayExportProvider implements ProviderInterface
{
public function __construct(
private Environment $twig,
private readonly RequestStack $requestStack,
private EmployeeRepository $employeeRepository,
private YearlyHoursExportBuilder $exportBuilder,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
{
$request = $this->requestStack->getCurrentRequest();
if (!$request) {
return new Response('Missing request.', Response::HTTP_BAD_REQUEST);
}
$workDateRaw = (string) $request->query->get('workDate');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $workDateRaw)) {
throw new UnprocessableEntityHttpException('workDate must use YYYY-MM-DD format.');
}
$date = new DateTimeImmutable($workDateRaw);
$siteIdsRaw = (string) $request->query->get('siteIds', '');
$siteIds = array_values(array_filter(array_map(
static fn (string $value): int => (int) trim($value),
explode(',', $siteIdsRaw),
), static fn (int $id): bool => $id > 0));
if ([] === $siteIds) {
throw new UnprocessableEntityHttpException('siteIds is required.');
}
// Feature réservée admin : on charge tous les employés puis on filtre.
$employees = $this->employeeRepository->findAll();
// Regroupement par site (ordre displayOrder), non-conducteurs uniquement.
$bySite = [];
$siteMeta = [];
foreach ($employees as $employee) {
if (true === $employee->getIsDriver()) {
continue;
}
$site = $employee->getSite();
if (null === $site || !in_array($site->getId(), $siteIds, true)) {
continue;
}
$siteId = $site->getId();
$bySite[$siteId][] = $employee;
$siteMeta[$siteId] ??= [
'name' => $site->getName(),
'order' => $site->getDisplayOrder() ?? 0,
];
}
uasort($siteMeta, static function (array $a, array $b): int {
return [$a['order'], $a['name']] <=> [$b['order'], $b['name']];
});
$groups = [];
foreach ($siteMeta as $siteId => $meta) {
$siteEmployees = $bySite[$siteId];
usort($siteEmployees, static fn ($a, $b) => ($a->getLastName() ?? '') <=> ($b->getLastName() ?? ''));
$rows = $this->exportBuilder->buildDayRowsForEmployees($siteEmployees, $date);
if ([] === $rows) {
continue;
}
$groups[] = ['siteName' => $meta['name'], 'rows' => $rows];
}
$options = new Options();
$options->set('isRemoteEnabled', true);
$dompdf = new Dompdf($options);
$html = $this->twig->render('work-hour-day-export/print.html.twig', [
'groups' => $groups,
'dateLabel' => $date->format('d/m/Y'),
'exportedAt' => new DateTimeImmutable('now')->format('d/m/Y H:i'),
]);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
$filename = sprintf('heures_jour_%s.pdf', $date->format('Y-m-d'));
return new Response($dompdf->output(), Response::HTTP_OK, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => sprintf('attachment; filename="%s"', $filename),
]);
}
}
```
- [ ] **Step 3 : Vérifier les getters utilisés**
Run: `grep -n "function getIsDriver\|function getSite\b\|function getDisplayOrder\|function getName" src/Entity/Employee.php src/Entity/Site.php`
Expected: les méthodes `Employee::getIsDriver()`, `Employee::getSite()`, `Site::getDisplayOrder()`, `Site::getName()` existent. Si `getIsDriver` n'existe pas, utiliser le getter réel (ex. `isDriver()`), idem pour `getDisplayOrder`.
- [ ] **Step 4 : Vider le cache et vérifier la route**
Run: `php bin/console cache:clear && php bin/console debug:router | grep day-export`
Expected: la route `/work-hours/day-export` apparaît.
- [ ] **Step 5 : Lancer la suite backend**
Run: `make test`
Expected: PASS.
- [ ] **Step 6 : Commit**
```bash
git add src/ApiResource/WorkHourDayExport.php src/State/WorkHourDayExportProvider.php
git commit -m "feat(heures) : endpoint export PDF heures jour par sites"
```
---
## Task 4 : Drawer frontend
**Files:**
- Create: `frontend/components/hours/HoursDayExportDrawer.vue`
- [ ] **Step 1 : Créer le composant**
```vue
<template>
<AppDrawer v-model="drawerOpen" title="Export des heures (par jour)">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="hours-export-date">
Date <span class="text-red-600">*</span>
</label>
<input
id="hours-export-date"
v-model="selectedDate"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
>
</div>
<div>
<label class="text-md font-semibold text-neutral-700">
Sites <span class="text-red-600">*</span>
</label>
<MalioSelectCheckbox
v-model="selectedSites"
:options="siteOptions"
groupClass="w-full mt-2"
label="Sites"
display-select-all
/>
</div>
<div class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLoading || !selectedDate || selectedSites.length === 0"
>
<template v-if="isLoading">Génération en cours...</template>
<template v-else>Exporter</template>
</button>
</div>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import AppDrawer from '~/components/AppDrawer.vue'
import type { Site } from '~/services/dto/site'
const props = defineProps<{
modelValue: boolean
sites: Site[]
initialDate: string
initialSiteIds: number[]
isLoading?: boolean
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'submit', payload: { date: string; siteIds: number[] }): void
}>()
const drawerOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const selectedDate = ref(props.initialDate)
const selectedSites = ref<number[]>([...props.initialSiteIds])
const siteOptions = computed(() =>
props.sites.map((site) => ({ value: site.id, label: site.name }))
)
const handleSubmit = () => {
if (!selectedDate.value || selectedSites.value.length === 0) return
emit('submit', { date: selectedDate.value, siteIds: [...selectedSites.value] })
}
// Réinitialise sur l'état courant de l'écran à chaque ouverture.
watch(
() => props.modelValue,
(isOpen) => {
if (isOpen) {
selectedDate.value = props.initialDate
selectedSites.value = [...props.initialSiteIds]
}
}
)
</script>
```
- [ ] **Step 2 : Vérifier le type `Site` et l'option `MalioSelectCheckbox`**
Run: `grep -rn "export type Site\|export interface Site" frontend/services/dto/ ; grep -n "value\|label\|options" node_modules/@malio/layer-ui/COMPONENTS.md | grep -i "selectcheckbox" `
Expected: confirmer le chemin d'import `Site` (ajuster `~/services/dto/site` si nécessaire — cf. import existant dans `HoursToolbar.vue`) et la forme des `options` (`{ value, label }`) attendue par `MalioSelectCheckbox`. Aligner sur l'usage existant dans `HoursToolbar.vue`.
- [ ] **Step 3 : Commit**
```bash
git add frontend/components/hours/HoursDayExportDrawer.vue
git commit -m "feat(heures) : drawer d'export PDF jour"
```
---
## Task 5 : Bouton + câblage dans `hours.vue`
**Files:**
- Modify: `frontend/pages/hours.vue`
- [ ] **Step 1 : Ajouter le bouton dans l'en-tête**
Remplacer le bloc titre (lignes ~3-5) :
```html
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Heures</h1>
</div>
```
par :
```html
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Heures</h1>
<button
v-if="isAdmin"
type="button"
class="flex items-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="isExportDrawerOpen = true"
>
<Icon name="mdi:file-export-outline" />
Exporter
</button>
</div>
<HoursDayExportDrawer
v-model="isExportDrawerOpen"
:sites="sites"
:initial-date="selectedDate"
:initial-site-ids="selectedSiteIds"
:is-loading="isExporting"
@submit="handleExport"
/>
```
> Note : si `Icon` n'est pas auto-importé dans ce projet, retirer la balise `<Icon>` et garder uniquement le texte « Exporter ». Vérifier l'usage d'`Icon` ailleurs dans `frontend/` avant.
- [ ] **Step 2 : Ajouter l'état et le handler dans le `<script setup>`**
Dans le `<script setup lang="ts">` de `hours.vue`, ajouter les imports et l'état. Repérer la déstructuration existante de `useHoursPage()` pour confirmer que `isAdmin`, `sites`, `selectedSiteIds`, `selectedDate` en sont issus, puis ajouter :
```ts
import { ref } from 'vue'
import HoursDayExportDrawer from '~/components/hours/HoursDayExportDrawer.vue'
import { usePdfPrinter } from '~/composables/usePdfPrinter'
const { printPdf } = usePdfPrinter()
const isExportDrawerOpen = ref(false)
const isExporting = ref(false)
const handleExport = async (payload: { date: string; siteIds: number[] }) => {
isExporting.value = true
try {
const siteIdsParam = payload.siteIds.join(',')
await printPdf(`/work-hours/day-export?workDate=${payload.date}&siteIds=${siteIdsParam}`)
isExportDrawerOpen.value = false
} finally {
isExporting.value = false
}
}
```
> Note : `selectedDate` côté `useHoursPage` est attendu au format `YYYY-MM-DD` (utilisé tel quel dans `getWorkHourDayContext(selectedDate.value)`). Le passer directement comme `initial-date`. Si son format diffère, normaliser en `YYYY-MM-DD` avant de le transmettre.
- [ ] **Step 3 : Lancer le typecheck / lint frontend**
Run: `cd frontend && npx vue-tsc --noEmit` (ou la commande de typecheck du projet ; **ne pas** lancer `npm run build`).
Expected: pas d'erreur de type sur les fichiers modifiés.
- [ ] **Step 4 : Vérification manuelle**
Démarrer la stack (`make start` + `make dev-nuxt` si besoin), se connecter en admin, écran Heures :
- Le bouton « Exporter » est visible (et absent pour un non-admin).
- Le drawer s'ouvre avec la date courante et les sites cochés.
- « Exporter » télécharge un PDF portrait, une section par site, colonnes attendues sans « Valider ».
- [ ] **Step 5 : Commit**
```bash
git add frontend/pages/hours.vue
git commit -m "feat(heures) : bouton export PDF jour (admin)"
```
---
## Task 6 : Documentation
**Files:**
- Create: `doc/hours-day-export.md`
- Modify: `frontend/data/documentation-content.ts`
- Modify: `CLAUDE.md`
- [ ] **Step 1 : Créer `doc/hours-day-export.md`**
```markdown
# Export PDF des heures — vue Jour
Bouton **Exporter** à droite du titre « Heures », visible **uniquement pour les
administrateurs** (`ROLE_ADMIN`).
## Comportement
- Ouvre un drawer : un champ **date** (préremplit la date affichée) et des **cases à
cocher des sites** (présélectionnées sur le filtre courant).
- Génère un **PDF A4 portrait** d'une seule journée, **regroupé par site**.
## Données
- Mêmes employés que la vue Jour : **non-conducteurs**, **sous contrat** à la date
choisie, des sites cochés. Les employés sous contrat sans saisie apparaissent (lignes
vides).
- Colonnes : Nom · Statut · Début matin · Fin matin · Début après-midi · Fin après-midi ·
Début soir · Fin soir · Jour · Nuit · Total. **Pas de colonne « Valider ».**
- Jour / Nuit / Total : identiques à l'écran (crédit d'absence `countAsWorkedHours` et
crédit virtuel férié inclus).
## Technique
- Endpoint : `GET /work-hours/day-export?workDate=YYYY-MM-DD&siteIds=1,2,3` (`ROLE_ADMIN`).
- Provider : `App\State\WorkHourDayExportProvider`.
- Calcul des cellules : `YearlyHoursExportBuilder::buildDayRowsForEmployees` (source
unique de vérité, partagée avec les exports annuels).
- Gabarit : `templates/work-hour-day-export/print.html.twig`.
```
- [ ] **Step 2 : Ajouter une entrée admin dans `documentation-content.ts`**
Repérer la section « Heures » dans `frontend/data/documentation-content.ts` et ajouter, dans ses `articles` (ou un nouvel article `requiredLevel: 'admin'`), un bloc décrivant l'export. Exemple d'article à insérer (adapter `id`/structure aux types `DocArticle`/`DocBlock` existants dans le fichier) :
```ts
{
id: 'hours-day-export',
title: 'Exporter les heures (PDF par jour)',
requiredLevel: 'admin',
blocks: [
{
type: 'paragraph',
text: "Le bouton « Exporter », à droite du titre « Heures », ouvre un panneau permettant de générer un PDF des heures d'une journée. Choisissez la date et les sites concernés.",
},
{
type: 'paragraph',
text: "Le PDF est organisé par site et reprend les colonnes de la vue Jour (nom, statut, horaires matin/après-midi/soir, jour, nuit, total), sans la colonne de validation. Les employés sous contrat ce jour-là apparaissent même sans saisie.",
},
],
},
```
> Avant d'écrire, lire le haut de `documentation-content.ts` et `frontend/types/documentation.ts` pour respecter exactement la forme des objets `DocArticle`/`DocBlock` (noms de champs, types de blocs autorisés).
- [ ] **Step 3 : Mettre à jour `CLAUDE.md`**
Ajouter, sous la puce « Exports heures annuelles » de la section *Functional Rules*, une nouvelle puce :
```markdown
- **Export heures vue Jour** (`WorkHourDayExportProvider`, endpoint `GET /work-hours/day-export?workDate=&siteIds=`, `ROLE_ADMIN`) : bouton « Exporter » à droite du titre « Heures ». PDF A4 portrait d'**une seule journée**, **regroupé par site**, colonnes de la vue Jour **sans « Valider »**. Mêmes employés que l'écran : non-conducteurs, sous contrat à la date, sites cochés (lignes vides incluses). Calcul des cellules mutualisé via `YearlyHoursExportBuilder::buildDayRowsForEmployees` (Jour/Nuit/Total incluent crédit absence + crédit virtuel férié). Gabarit `templates/work-hour-day-export/print.html.twig`.
```
- [ ] **Step 4 : Commit**
```bash
git add doc/hours-day-export.md frontend/data/documentation-content.ts CLAUDE.md
git commit -m "docs(heures) : documenter l'export PDF jour"
```
---
## Notes de mise en œuvre
- **Conducteurs exclus** : filtrés côté provider (`getIsDriver()`), cohérent avec l'écran.
- **PRESENCE** : géré dans le builder (cellules horaires vides, `Total` en demi-journées).
- **Validation des params** : `workDate` (`YYYY-MM-DD`) et `siteIds` (CSV d'entiers > 0)
rejetés en `422` si invalides.
- **Pas de `npm run build`** (règle projet) — utiliser typecheck/dev pour vérifier le front.
- **Format des commits** : le hook impose `<type>(<scope>) : <message>` (espace avant les
deux-points). Les messages ci-dessus le respectent.
@@ -0,0 +1,170 @@
# Export PDF des heures — vue Jour (par sites)
**Date** : 2026-06-08
**Branche** : feature/SIRH-35-export-des-heures-employe
## Objectif
Ajouter un bouton **Exporter** sur l'écran « Heures », réservé aux administrateurs,
qui produit un **PDF d'une journée** reprenant les colonnes de la vue Jour (sans la
colonne de validation), pour les employés des sites sélectionnés, **regroupés par site**.
## Décisions validées
| Sujet | Choix |
|-------|-------|
| Format | PDF (Twig → Dompdf) |
| Période | Un seul jour |
| Orientation | A4 **portrait**, mise en page compacte (objectif : tenir sur une page ; débordement multipage seulement si le nombre d'employés l'impose) |
| Regroupement | Une section par site |
| Accès | `ROLE_ADMIN` uniquement |
## Comportement frontend
### Bouton
- Dans `frontend/pages/hours.vue`, à droite du titre « Heures » (le conteneur titre est
déjà `flex flex-wrap items-center justify-between`).
- Visible uniquement si `isAdmin` (déjà exposé par `useHoursPage`).
- Style cohérent avec les autres boutons d'action de l'app ; libellé « Exporter »
(préfixe non requis ici, ce n'est pas un « + Ajouter »).
### Drawer `HoursDayExportDrawer.vue`
Nouveau composant utilisant `AppDrawer` (mode create — bouton centré).
Champs :
1. **Date** — champ date (input date), prérempli avec `selectedDate` de l'écran.
2. **Sites**`MalioSelectCheckbox` avec `display-select-all`, mêmes options que la
toolbar (`sites` du composable), présélectionné sur `selectedSiteIds` courants.
Bouton **« Exporter »** : désactivé si aucune date ou aucun site sélectionné.
### Déclenchement
- À la validation : `usePdfPrinter().printPdf(url)` avec
`GET /work-hours/day-export?workDate=YYYY-MM-DD&siteIds=1,2,3`.
- Le téléchargement réutilise le pattern blob existant (`usePdfPrinter`).
- État `isLoading` sur le bouton pendant la génération.
### Câblage dans `hours.vue` / `useHoursPage.ts`
- `hours.vue` gère l'état d'ouverture du drawer et passe `sites`, `selectedSiteIds`,
`selectedDate`, `isAdmin`.
- L'appel d'export peut vivre dans un petit handler local (`hours.vue`) ou dans le
composable ; au choix de l'implémentation, en gardant `useHoursPage` comme source des
données affichées.
## Portée des données (identique à l'écran Jour)
- Employés **non-conducteurs** (`isDriver !== true`).
- **Sous contrat** à la date choisie.
- Appartenant aux **sites cochés**.
- **Tous les employés sous contrat sont affichés**, même sans saisie (lignes vides) —
cohérent avec la règle des exports heures annuelles.
## Colonnes du PDF
Mêmes colonnes que la vue Jour, **sans la colonne Valider** :
`Nom` · `Statut` · `Début matin` · `Fin matin` · `Début après-midi` ·
`Fin après-midi` · `Début soir` · `Fin soir` · `Jour` · `Nuit` · `Total`
- **Statut** : libellé d'absence (ou formation, ou nom du férié) si présent, sinon vide.
- **Heures** (`Début/Fin` matin/après-midi/soir) : valeurs `WorkHour` brutes (`HH:MM`),
vides si non saisies.
- **Jour / Nuit / Total** : calculés comme à l'écran — minutes jour vs nuit, total
incluant le crédit d'absence (`countAsWorkedHours`) et le **crédit virtuel férié**
(`HolidayVirtualHoursResolver`).
- Week-ends / fériés : lignes grisées/colorées comme dans les templates existants.
## Architecture backend
### ApiResource `WorkHourDayExport`
`src/ApiResource/WorkHourDayExport.php` — calqué sur `EmployeeYearlyHoursBulkPrint` :
```php
new Get(
uriTemplate: '/work-hours/day-export',
provider: WorkHourDayExportProvider::class,
parameters: [
new QueryParameter(key: 'workDate', required: true),
new QueryParameter(key: 'siteIds', required: true),
],
security: "is_granted('ROLE_ADMIN')"
)
```
### Provider `WorkHourDayExportProvider`
`src/State/WorkHourDayExportProvider.php` :
1. Lire/valider `workDate` (`Y-m-d`) et `siteIds` (CSV d'entiers).
2. Charger les employés (`EmployeeRepository::findAll()` — feature admin-only),
filtrer : non-drivers, site ∈ siteIds.
3. Pour chaque site (ordre `displayOrder`), trier les employés par nom.
4. Filtrer les employés sous contrat à la date (le builder ignore déjà les jours hors
contrat — un employé sans contrat ce jour produit une ligne vide à exclure).
5. Construire les lignes via `YearlyHoursExportBuilder` (méthode dédiée, voir ci-dessous).
6. Rendre le Twig → Dompdf (`A4`, `portrait`), renvoyer `Response` binaire avec
`Content-Disposition: attachment; filename="heures_jour_YYYY-MM-DD.pdf"`.
### Réutilisation `YearlyHoursExportBuilder`
Ajouter une méthode publique :
```php
/**
* @param list<Employee> $employees
* @return list<array{employeeId:int, employeeName:string, statut:?string,
* morningFrom:string, morningTo:string, afternoonFrom:string, afternoonTo:string,
* eveningFrom:string, eveningTo:string, dayHours:string, nightHours:string,
* total:string, isWeekend:bool, hasContract:bool}>
*/
public function buildDayRowsForEmployees(array $employees, DateTimeImmutable $date): array
```
- Réutilise les helpers privés existants (`computeMetrics`, résolution d'absences,
`HolidayVirtualHoursResolver`, `EmployeeContractResolver`, fériés) — **source unique
de vérité** pour le calcul des cellules d'une journée.
- Émet en plus `dayHours` / `nightHours` (issus de `WorkMetrics.dayMinutes` /
`nightMinutes`) que l'export annuel n'affichait pas par ligne en mode TIME.
- Les employés sans contrat ce jour sont exclus (pas de ligne).
- Le `statut` agrège absence / formation / libellé férié (réutilise la logique de
résolution d'absence/formation déjà présente dans le contexte jour si nécessaire).
> Note : la vue Jour mélange potentiellement modes TIME et PRESENCE selon le contrat à
> la date. Pour l'export, on suit le mode résolu à la date (comme l'écran). En mode
> PRESENCE, les cellules horaires restent vides et `Total` exprime les demi-journées,
> identique à l'affichage écran.
### Template `templates/work-hour-day-export/print.html.twig`
- A4 portrait, marges fines, police ~9px (réf. `employee-yearly-hours/print.html.twig`).
- Barre de titre : « Heures — {date} » + date d'export en haut à droite.
- Une `<h2>` par site, suivie d'un tableau avec les 11 colonnes ci-dessus.
- Week-ends / fériés grisés (`#c0c0c0` / `#b3e5fc`) comme les templates existants.
- `table-layout: auto`, largeurs compactes pour viser une page.
## Limites connues
- Un grand nombre d'employés (beaucoup de sites cochés) peut déborder sur plusieurs
pages — on vise une page sans la garantir.
- Pas de risque mémoire particulier (un seul jour, volume très inférieur à l'export
annuel tous employés).
## Documentation à mettre à jour (règles CLAUDE.md)
1. `doc/` : nouvelle section (ou ajout à un doc heures existant) décrivant l'export jour.
2. `frontend/data/documentation-content.ts` : entrée niveau **admin** dans la section
Heures.
3. `CLAUDE.md` : note sous la section heures/exports (provider, builder réutilisé,
colonnes, scope identique écran, portrait).
## Tests
- Test unitaire `YearlyHoursExportBuilder::buildDayRowsForEmployees` : un employé TIME
avec saisie (vérifier day/night/total), un employé sans contrat (exclu), un jour férié
(crédit virtuel), une absence `countAsWorkedHours`.
- (Optionnel) test provider : validation des paramètres `workDate` / `siteIds`.
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div v-if="modelValue" class="fixed inset-0 z-50">
<div v-if="modelValue" class="fixed inset-0 z-[60]">
<Transition name="drawer-backdrop">
<div class="absolute inset-0 bg-black/40" @click="close" />
</Transition>
+9 -1
View File
@@ -313,8 +313,16 @@ const isLastExerciseOfPhase = computed(() => {
return props.selectedYear === endYear
})
// Retroactive payment is allowed on the immediately previous exercise (Option B):
// the backend recomputes the next exercise's report so the carry stays correct.
const isPreviousExercise = computed(() =>
props.selectedYear !== null
&& props.currentYear !== null
&& props.selectedYear === props.currentYear - 1
)
const isPayDisabled = computed(() =>
isHistoricalYear.value && !isLastExerciseOfPhase.value
isHistoricalYear.value && !isLastExerciseOfPhase.value && !isPreviousExercise.value
)
const handleYearChange = (event: Event) => {
@@ -0,0 +1,87 @@
<template>
<AppDrawer v-model="drawerOpen" title="Export des heures">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="hours-export-date">
Date <span class="text-red-600">*</span>
</label>
<input
id="hours-export-date"
v-model="selectedDate"
type="date"
class="mt-2 w-full rounded-md border border-black px-3 py-2 text-md text-neutral-900"
>
</div>
<div>
<label class="text-md font-semibold text-neutral-700">
Sites <span class="text-red-600">*</span>
</label>
<MalioSelectCheckbox
v-model="selectedSites"
:options="siteOptions"
groupClass="w-full mt-2"
label="Sites"
display-select-all
/>
</div>
<div class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLoading || !selectedDate || selectedSites.length === 0"
>
<template v-if="isLoading">Génération en cours...</template>
<template v-else>Exporter</template>
</button>
</div>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import AppDrawer from '~/components/AppDrawer.vue'
import type { Site } from '~/services/dto/site'
const props = defineProps<{
modelValue: boolean
sites: Site[]
initialDate: string
initialSiteIds: number[]
isLoading?: boolean
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'submit', payload: { date: string; siteIds: number[] }): void
}>()
const drawerOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const selectedDate = ref(props.initialDate)
const selectedSites = ref<number[]>([...props.initialSiteIds])
const siteOptions = computed(() =>
props.sites.map((site) => ({ label: site.name, value: site.id }))
)
const handleSubmit = () => {
if (!selectedDate.value || selectedSites.value.length === 0) return
emit('submit', { date: selectedDate.value, siteIds: [...selectedSites.value] })
}
watch(
() => props.modelValue,
(isOpen) => {
if (isOpen) {
selectedDate.value = props.initialDate
selectedSites.value = [...props.initialSiteIds]
}
}
)
</script>
+12
View File
@@ -81,6 +81,16 @@ export const documentationSections: DocSection[] = [
{ type: 'list', content: 'Jour : total des heures dans la plage 06:0021:00\nNuit : total des heures dans les plages 00:0006:00 et 21:0024:00\nTotal : somme des heures de jour et de nuit' },
],
},
{
id: 'export-heures-jour',
title: 'Exporter les heures (PDF par jour)',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Le bouton « Exporter », à droite du titre « Heures » (visible uniquement en vue Jour), ouvre un panneau permettant de générer un PDF des heures d\'une journée. Choisissez la date et les sites concernés.' },
{ type: 'paragraph', content: 'Le PDF est organisé par site et reprend les colonnes de la vue Jour (nom, statut, horaires matin/après-midi/soir, jour, nuit, total en gras), sans la colonne de validation. Les employés sous contrat ce jour-là apparaissent même sans saisie.' },
{ type: 'paragraph', content: 'La colonne Statut affiche le code du type d\'absence (ex. « AT ») sur sa couleur. Une légende sous le tableau associe chaque code présent à son libellé.' },
],
},
{
id: 'commentaire-semaine',
title: 'Commentaires de semaine (admin)',
@@ -534,6 +544,8 @@ export const documentationSections: DocSection[] = [
blocks: [
{ type: 'paragraph', content: 'L\'administrateur RH peut enregistrer un paiement RTT depuis l\'onglet RTT de la fiche employé.' },
{ type: 'list', content: 'Saisie : mois, nombre de minutes, taux (25% ou 50%)\nPlusieurs paiements possibles par mois\nLes heures payées sont soustraites du solde disponible' },
{ type: 'paragraph', content: 'Le paiement est possible sur l\'exercice courant et sur l\'exercice immédiatement précédent (paiement rétroactif, ex. des RTT de mai réglés après la bascule du 1er juin).' },
{ type: 'note', content: 'Un paiement saisi sur l\'exercice précédent recalcule automatiquement le « Report N-1 » de l\'exercice courant : aucun double comptage. Si ce report a déjà été verrouillé (validé), le paiement rétroactif est refusé — déverrouillez-le d\'abord.' },
],
},
{
+32
View File
@@ -2,8 +2,25 @@
<div class="h-full overflow-hidden flex flex-col">
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Heures</h1>
<MalioButton
v-if="isAdmin && viewMode === 'day'"
label="Export"
variant="secondary"
icon-name="mdi:download"
icon-position="left"
@click="isExportDrawerOpen = true"
/>
</div>
<HoursDayExportDrawer
v-model="isExportDrawerOpen"
:sites="sites"
:initial-date="selectedDate"
:initial-site-ids="selectedSiteIds"
:is-loading="isExporting"
@submit="handleExport"
/>
<HoursToolbar
v-model:selected-date="selectedDate"
v-model:view-mode="viewMode"
@@ -213,6 +230,21 @@ const {
reloadWeeklySummary
} = useHoursPage()
const { printPdf } = usePdfPrinter()
const isExportDrawerOpen = ref(false)
const isExporting = ref(false)
const handleExport = async (payload: { date: string; siteIds: number[] }) => {
isExporting.value = true
try {
const siteIdsParam = payload.siteIds.join(',')
await printPdf(`/work-hours/day-export?workDate=${payload.date}&siteIds=${siteIdsParam}`)
isExportDrawerOpen.value = false
} finally {
isExporting.value = false
}
}
useHead({
title: 'Heures'
})
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\QueryParameter;
use App\State\WorkHourDayExportProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/work-hours/day-export',
provider: WorkHourDayExportProvider::class,
parameters: [
new QueryParameter(key: 'workDate', required: true),
new QueryParameter(key: 'siteIds', required: true),
],
security: "is_granted('ROLE_ADMIN')"
),
]
)]
final class WorkHourDayExport {}
@@ -11,8 +11,8 @@ use App\Entity\WorkHour;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository;
use App\Repository\WorkHourRepository;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use DateInterval;
@@ -22,8 +22,8 @@ use Throwable;
class YearlyHoursExportBuilder
{
public function __construct(
private WorkHourRepository $workHourRepository,
private AbsenceRepository $absenceRepository,
private WorkHourReadRepositoryInterface $workHourRepository,
private AbsenceReadRepositoryInterface $absenceRepository,
private EmployeeContractResolver $contractResolver,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
@@ -103,6 +103,137 @@ class YearlyHoursExportBuilder
return $this->buildForEmployees([$employee], $from, $to);
}
/**
* Construit une ligne par employé pour une seule journée (vue Jour de l'écran Heures).
* Réutilise les helpers de calcul de cellule pour rester l'unique source de vérité.
* Les employés sans contrat ce jour sont exclus (comme l'écran).
*
* @param list<Employee> $employees
*
* @return list<array{employeeId:int, employeeName:string, statut:?string, statutLabel:?string, statutColor:?string,
* morningFrom:string, morningTo:string, afternoonFrom:string, afternoonTo:string,
* eveningFrom:string, eveningTo:string, dayHours:string, nightHours:string,
* total:string, isWeekend:bool, isHoliday:bool}>
*/
public function buildDayRowsForEmployees(array $employees, DateTimeImmutable $date): array
{
$ymd = $date->format('Y-m-d');
$days = [$ymd];
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($date, $date, $employees);
$absences = $this->absenceRepository->findForPrint($date, $date, $employees);
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$workDaysMap = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days);
$holidayMap = $this->buildHolidayMap($date, $date);
$workHourMap = $this->buildWorkHourMap($workHours);
$absenceMap = $this->buildAbsenceMap($absences, $days);
$isoDay = (int) $date->format('N');
$isWeekend = $isoDay >= 6;
$holidayLabel = $holidayMap[$ymd] ?? null;
$rows = [];
foreach ($employees as $employee) {
$employeeId = $employee->getId();
$contract = $contractMap[$employeeId][$ymd] ?? null;
// Hors contrat ce jour → exclu (avant embauche / après départ / suspension).
if (null === $contract) {
continue;
}
$wh = $workHourMap[$employeeId][$ymd] ?? null;
$absenceData = $this->resolveAbsenceDataForEmployee($absenceMap[$employeeId] ?? [], $days, $employee);
$hasAbsence = $absenceData['hasDayAbsence'][$ymd] ?? false;
$isDriver = $driverMap[$employeeId][$ymd] ?? false;
$mode = $this->resolveSegmentMode($contract->getTrackingMode(), $isDriver);
$creditedMinutes = $absenceData['credited'][$ymd] ?? 0;
$virtualMinutes = $this->holidayVirtualHoursResolver->resolveVirtualCredit(
$contract,
$date,
$hasAbsence,
$workDaysMap[$employeeId][$ymd] ?? null,
);
// Colonne Statut = code d'absence (ex. « AT »), pas le libellé.
$statut = ($absenceData['codes'][$ymd] ?? '') ?: null;
$statutLabel = $absenceData['labels'][$ymd] ?? null;
$statutColor = ($absenceData['colors'][$ymd] ?? '') ?: null;
if (null === $statut && null !== $holidayLabel) {
// Férié sans absence : badge bleu clair, comme la vue Jour.
$statut = $holidayLabel;
$statutLabel = null;
$statutColor = '#b3e5fc';
}
$row = [
'employeeId' => $employeeId,
'employeeName' => trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')),
'statut' => $statut,
'statutLabel' => $statutLabel,
'statutColor' => $statutColor,
'morningFrom' => '',
'morningTo' => '',
'afternoonFrom' => '',
'afternoonTo' => '',
'eveningFrom' => '',
'eveningTo' => '',
'dayHours' => '',
'nightHours' => '',
'total' => '',
'isWeekend' => $isWeekend,
'isHoliday' => null !== $holidayLabel,
];
if ('presence' === $mode) {
$absentMorning = $absenceData['absentMorning'][$ymd] ?? false;
$absentAfternoon = $absenceData['absentAfternoon'][$ymd] ?? false;
$morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0;
$afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
$total = $morning + $afternoon;
$row['total'] = $total > 0 ? (string) $total : '';
} elseif ('driver' === $mode) {
$dayMin = $wh?->getDayHoursMinutes() ?? 0;
$nightMin = $wh?->getNightHoursMinutes() ?? 0;
$workshop = $wh?->getWorkshopHoursMinutes() ?? 0;
$totalMin = $dayMin + $nightMin + $workshop + $creditedMinutes;
if ($virtualMinutes > $totalMin) {
$totalMin = $virtualMinutes;
}
$row['dayHours'] = $dayMin > 0 ? $this->formatMinutes($dayMin) : '';
$row['nightHours'] = $nightMin > 0 ? $this->formatMinutes($nightMin) : '';
$row['total'] = $totalMin > 0 ? $this->formatMinutes($totalMin) : '';
} else {
$metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics();
$metrics->addCreditedMinutes($creditedMinutes);
$dayMin = $metrics->dayMinutes;
$nightMin = $metrics->nightMinutes;
$totalMin = $metrics->totalMinutes;
if ($virtualMinutes > $totalMin) {
$dayMin += $virtualMinutes - $totalMin;
$totalMin = $virtualMinutes;
}
$row['morningFrom'] = $wh?->getMorningFrom() ?? '';
$row['morningTo'] = $wh?->getMorningTo() ?? '';
$row['afternoonFrom'] = $wh?->getAfternoonFrom() ?? '';
$row['afternoonTo'] = $wh?->getAfternoonTo() ?? '';
$row['eveningFrom'] = $wh?->getEveningFrom() ?? '';
$row['eveningTo'] = $wh?->getEveningTo() ?? '';
$row['dayHours'] = $dayMin > 0 ? $this->formatMinutes($dayMin) : '';
$row['nightHours'] = $nightMin > 0 ? $this->formatMinutes($nightMin) : '';
$row['total'] = $totalMin > 0 ? $this->formatMinutes($totalMin) : '';
}
$rows[] = $row;
}
return $rows;
}
public function buildContractLabel(Employee $employee): ?string
{
$contract = $employee->getContract();
@@ -169,12 +300,14 @@ class YearlyHoursExportBuilder
}
/**
* @return array{credited: array<string, int>, labels: array<string, string>, absentMorning: array<string, bool>, absentAfternoon: array<string, bool>, hasDayAbsence: array<string, bool>}
* @return array{credited: array<string, int>, codes: array<string, string>, labels: array<string, string>, colors: array<string, string>, absentMorning: array<string, bool>, absentAfternoon: array<string, bool>, hasDayAbsence: array<string, bool>}
*/
private function resolveAbsenceDataForEmployee(array $absences, array $days, Employee $employee): array
{
$credited = [];
$codes = [];
$labels = [];
$colors = [];
$absentMorning = [];
$absentAfternoon = [];
$hasDayAbsence = [];
@@ -194,7 +327,9 @@ class YearlyHoursExportBuilder
$absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning;
$absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon;
if (!isset($labels[$date])) {
$codes[$date] = $absence->getType()?->getCode() ?? '';
$labels[$date] = $absence->getType()?->getLabel() ?? '';
$colors[$date] = $absence->getType()?->getColor() ?? '';
}
}
@@ -205,7 +340,9 @@ class YearlyHoursExportBuilder
return [
'credited' => $credited,
'codes' => $codes,
'labels' => $labels,
'colors' => $colors,
'absentMorning' => $absentMorning,
'absentAfternoon' => $absentAfternoon,
'hasDayAbsence' => $hasDayAbsence,
+54 -7
View File
@@ -8,12 +8,15 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\EmployeeRttPaymentInput;
use App\Entity\Employee;
use App\Entity\EmployeeRttBalance;
use App\Entity\EmployeeRttPayment;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Service\AuditLogger;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use App\Service\Rtt\RttClosingBalanceService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Clock\ClockInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -24,11 +27,13 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
public function __construct(
private EmployeeRepository $employeeRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private EmployeeRttBalanceRepository $rttBalanceRepository,
private EntityManagerInterface $entityManager,
private AuditLogger $auditLogger,
private EmployeeContractPhaseResolver $phaseResolver,
private ClockInterface $clock,
private ExerciseYearResolver $exerciseYearResolver,
private RttClosingBalanceService $rttClosingService,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput
@@ -51,10 +56,20 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
throw new UnprocessableEntityHttpException('month must be between 1 and 12.');
}
$year = $data->year ?? $this->resolveCurrentExerciseYear();
$year = $data->year ?? $this->resolveCurrentExerciseYear();
$currentExerciseYear = $this->resolveCurrentExerciseYear();
$this->assertYearAllowedForPayment($employee, $year);
// Option B — retroactive payment on the previous exercise: the next exercise's
// opening report (a frozen snapshot) must be recomputed so the carry stays correct.
// Refuse upfront if that report has been locked (validated) by RH.
$downstreamBalance = null;
if ($year === $currentExerciseYear - 1) {
$downstreamBalance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $currentExerciseYear);
$this->assertReportNotLocked($downstreamBalance);
}
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month);
if (null === $payment) {
@@ -81,7 +96,24 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
['new' => ['month' => $data->month, 'year' => $year, 'base25' => $data->base25Minutes, 'bonus25' => $data->bonus25Minutes, 'base50' => $data->base50Minutes, 'bonus50' => $data->bonus50Minutes]],
);
$this->entityManager->flush();
// Persist the payment and, atomically, refresh the next exercise's opening report.
// The flush inside the transaction makes the new payment visible to the closing
// recomputation (same DB connection), so the carry reflects it.
$this->entityManager->wrapInTransaction(function () use ($employee, $year, $downstreamBalance): void {
$this->entityManager->flush();
if (null !== $downstreamBalance) {
$closing = $this->rttClosingService->computeClosingBalance($employee, $year);
$downstreamBalance
->setOpeningBase25Minutes($closing->base25Minutes)
->setOpeningBonus25Minutes($closing->bonus25Minutes)
->setOpeningBase50Minutes($closing->base50Minutes)
->setOpeningBonus50Minutes($closing->bonus50Minutes)
->touch()
;
$this->entityManager->flush();
}
});
$data->year = $year;
@@ -94,14 +126,15 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
}
/**
* Allow payment when the requested exercise is either the current one
* or the last exercise of a closed contract phase (the one containing
* the phase end date). Reject any other exercise (past or future).
* Allow payment when the requested exercise is the current one, the
* immediately previous one (retroactive payment — Option B), or the last
* exercise of a closed contract phase (the one containing the phase end
* date). Reject any other exercise (older past or future).
*/
private function assertYearAllowedForPayment(Employee $employee, int $year): void
{
$currentExerciseYear = $this->resolveCurrentExerciseYear();
if ($year === $currentExerciseYear) {
if ($year === $currentExerciseYear || $year === $currentExerciseYear - 1) {
return;
}
@@ -116,7 +149,21 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
}
throw new UnprocessableEntityHttpException(
'RTT payment is only allowed on the current exercise or the last exercise of a closed contract phase.'
'RTT payment is only allowed on the current exercise, the previous one, or the last exercise of a closed contract phase.'
);
}
/**
* Refuse a retroactive payment when the next exercise's opening report has
* been locked (validated) by RH: recomputing it would either be impossible
* or silently desync the carry. A missing report (null) never blocks.
*/
private function assertReportNotLocked(?EmployeeRttBalance $downstreamBalance): void
{
if (null !== $downstreamBalance && $downstreamBalance->isLocked()) {
throw new UnprocessableEntityHttpException(
'Impossible : le report RTT de l\'exercice suivant est verrouillé. Déverrouillez-le pour saisir un paiement rétroactif.'
);
}
}
}
+7 -1
View File
@@ -20,6 +20,7 @@ use App\Repository\WorkHourRepository;
use App\Security\EmployeeScopeService;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use App\Service\Rtt\RttClosingBalanceService;
use App\Service\Rtt\RttRecoveryComputationService;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
@@ -43,6 +44,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
private WorkHourRepository $workHourRepository,
private EmployeeContractPhaseResolver $phaseResolver,
private ExerciseYearResolver $exerciseYearResolver,
private RttClosingBalanceService $rttClosingService,
string $rttStartDate = '',
) {
$this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null;
@@ -231,8 +233,12 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
];
}
// No stored report row yet (before the 1st-June rollover materialises it):
// compute the previous exercise's full closing (opening + earned paid) so the
// carry already reflects retroactive payments and the incoming report — matching
// what the rollover would persist. Falling back to earned-only would drop both.
return [
$this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1),
$this->rttClosingService->computeClosingBalance($employee, $year - 1),
5,
];
}
+126
View File
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Repository\EmployeeRepository;
use App\Service\WorkHours\YearlyHoursExportBuilder;
use DateTimeImmutable;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Twig\Environment;
class WorkHourDayExportProvider implements ProviderInterface
{
public function __construct(
private Environment $twig,
private readonly RequestStack $requestStack,
private EmployeeRepository $employeeRepository,
private YearlyHoursExportBuilder $exportBuilder,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
{
$request = $this->requestStack->getCurrentRequest();
if (!$request) {
return new Response('Missing request.', Response::HTTP_BAD_REQUEST);
}
$workDateRaw = (string) $request->query->get('workDate');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $workDateRaw)) {
throw new UnprocessableEntityHttpException('workDate must use YYYY-MM-DD format.');
}
$date = new DateTimeImmutable($workDateRaw);
$siteIdsRaw = (string) $request->query->get('siteIds', '');
$siteIds = array_values(array_filter(array_map(
static fn (string $value): int => (int) trim($value),
explode(',', $siteIdsRaw),
), static fn (int $id): bool => $id > 0));
if ([] === $siteIds) {
throw new UnprocessableEntityHttpException('siteIds is required.');
}
// Feature réservée admin : on charge tous les employés puis on filtre.
$employees = $this->employeeRepository->findAll();
// Regroupement par site (ordre displayOrder), non-conducteurs uniquement.
$bySite = [];
$siteMeta = [];
foreach ($employees as $employee) {
if (true === $employee->getIsDriver()) {
continue;
}
$site = $employee->getSite();
if (null === $site || !in_array($site->getId(), $siteIds, true)) {
continue;
}
$siteId = $site->getId();
$bySite[$siteId][] = $employee;
$siteMeta[$siteId] ??= [
'name' => $site->getName(),
'order' => $site->getDisplayOrder(),
'color' => $site->getColor(),
];
}
uasort($siteMeta, static function (array $a, array $b): int {
return [$a['order'], $a['name']] <=> [$b['order'], $b['name']];
});
$groups = [];
$legend = [];
foreach ($siteMeta as $siteId => $meta) {
$siteEmployees = $bySite[$siteId];
usort($siteEmployees, static fn ($a, $b) => ($a->getLastName() ?? '') <=> ($b->getLastName() ?? ''));
$rows = $this->exportBuilder->buildDayRowsForEmployees($siteEmployees, $date);
if ([] === $rows) {
continue;
}
$groups[] = ['siteName' => $meta['name'], 'siteColor' => $meta['color'], 'rows' => $rows];
// Légende : codes d'absence présents (hors férié), dédupliqués par code.
foreach ($rows as $row) {
if ($row['isHoliday'] || null === $row['statut'] || null === $row['statutLabel']) {
continue;
}
$legend[$row['statut']] ??= [
'code' => $row['statut'],
'label' => $row['statutLabel'],
'color' => $row['statutColor'] ?? '#e8e8e8',
];
}
}
ksort($legend);
$legend = array_values($legend);
$options = new Options();
$options->set('isRemoteEnabled', true);
$dompdf = new Dompdf($options);
$html = $this->twig->render('work-hour-day-export/print.html.twig', [
'groups' => $groups,
'legend' => $legend,
'dateLabel' => $date->format('d/m/Y'),
'exportedAt' => new DateTimeImmutable('now')->format('d/m/Y H:i'),
]);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
$filename = sprintf('heures_jour_%s.pdf', $date->format('Y-m-d'));
return new Response($dompdf->output(), Response::HTTP_OK, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => sprintf('attachment; filename="%s"', $filename),
]);
}
}
@@ -0,0 +1,90 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Heures - {{ dateLabel }}</title>
<style>
@page { size: A4 portrait; margin: 4mm; }
html, body { margin: 0; padding: 2mm; font-family: Helvetica, sans-serif; font-size: 10px; }
.title-bar { position: relative; margin: 0 0 3mm 0; }
h1 { text-align: center; font-size: 15px; margin: 0; }
.export-date { position: absolute; top: 0; right: 0; font-size: 9px; color: #333; padding-top: 4px; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; border: 2px solid #0a0a0a; }
th, td { border: 1px solid #0a0a0a; padding: 4px 2px; vertical-align: middle; text-align: center; overflow: hidden; }
th { font-weight: 700; background: #f0f0f0; white-space: normal; }
td { white-space: nowrap; }
td.name { text-align: left; }
tr.site-title td { font-weight: bold; font-size: 11px; text-transform: uppercase; text-align: left; padding: 2px 6px; white-space: nowrap; }
tr.weekend td { background: #c0c0c0; }
td.total { font-weight: bold; }
table.legend { width: auto; table-layout: auto; margin-top: 4mm; font-size: 10px; border: 0; border-collapse: collapse; }
table.legend td { border: 0; padding: 2px 0; vertical-align: middle; overflow: visible; white-space: nowrap; }
table.legend .legend-title { font-weight: bold; padding-right: 8px; }
table.legend .legend-box-cell { padding-left: 12px; }
table.legend .legend-box { display: inline-block; box-sizing: content-box; width: 14px; height: 14px; padding: 3px; line-height: 14px; text-align: center; font-weight: bold; font-size: 9px; }
table.legend .legend-label { padding-left: 4px; }
</style>
</head>
<body>
<div class="title-bar">
<h1>Heures du {{ dateLabel }}</h1>
<div class="export-date">Édité le {{ exportedAt }}</div>
</div>
<table>
<thead>
<tr>
<th style="width: 21%;">Nom</th>
<th style="width: 13%;">Statut</th>
<th style="width: 8%;">Début matin</th>
<th style="width: 8%;">Fin matin</th>
<th style="width: 8%;">Début après-midi</th>
<th style="width: 8%;">Fin après-midi</th>
<th style="width: 8%;">Début soir</th>
<th style="width: 8%;">Fin soir</th>
<th style="width: 6%;">Jour</th>
<th style="width: 6%;">Nuit</th>
<th style="width: 6%;">Total</th>
</tr>
</thead>
<tbody>
{% for group in groups %}
<tr class="site-title">
<td colspan="11" style="background: {{ group.siteColor ?: '#e8e8e8' }};">{{ group.siteName }}</td>
</tr>
{% for row in group.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<td class="name">{{ row.employeeName }}</td>
<td{% if row.statutColor %} style="background: {{ row.statutColor }};"{% endif %}>{{ row.statut }}</td>
<td>{{ row.morningFrom }}</td>
<td>{{ row.morningTo }}</td>
<td>{{ row.afternoonFrom }}</td>
<td>{{ row.afternoonTo }}</td>
<td>{{ row.eveningFrom }}</td>
<td>{{ row.eveningTo }}</td>
<td>{{ row.dayHours }}</td>
<td>{{ row.nightHours }}</td>
<td class="total">{{ row.total }}</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
{% if legend is not empty %}
<table class="legend">
{% for chunk in legend|batch(6) %}
<tr>
<td class="legend-title">{% if loop.first %}Légende :{% endif %}</td>
{% for item in chunk %}
<td class="legend-box-cell">
<span class="legend-box" style="background: {{ item.color ?: '#e8e8e8' }};">{{ item.code }}</span>
</td>
<td class="legend-label">{{ item.label }}</td>
{% endfor %}
</tr>
{% endfor %}
</table>
{% endif %}
</body>
</html>
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\WorkHours;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\WorkHour;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use App\Service\WorkHours\YearlyHoursExportBuilder;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
/**
* @internal
*/
final class YearlyHoursDayRowsTest extends TestCase
{
public function testTimeContractRowComputesHoursAndExcludesNoContract(): void
{
$date = new DateTimeImmutable('2026-06-08'); // lundi
$contract = new Contract();
$contract->setName('35h');
$contract->setTrackingMode(Contract::TRACKING_TIME);
$contract->setWeeklyHours(35);
$withContract = new Employee();
$withContract->setFirstName('Jean')->setLastName('Dupont');
$this->setEmployeeId($withContract, 1);
$noContract = new Employee();
$noContract->setFirstName('Paul')->setLastName('Martin');
$this->setEmployeeId($noContract, 2);
$workHour = new WorkHour();
$workHour->setEmployee($withContract)
->setWorkDate($date)
->setMorningFrom('08:00')->setMorningTo('12:00')
->setAfternoonFrom('13:00')->setAfternoonTo('17:00')
;
$workHourRepo = $this->createStub(WorkHourReadRepositoryInterface::class);
$workHourRepo->method('findByDateRangeAndEmployees')->willReturn([$workHour]);
$absenceRepo = $this->createStub(AbsenceReadRepositoryInterface::class);
$absenceRepo->method('findForPrint')->willReturn([]);
$contractResolver = $this->createStub(EmployeeContractResolver::class);
$contractResolver->method('resolveForEmployeesAndDays')->willReturn([
1 => ['2026-06-08' => $contract],
2 => ['2026-06-08' => null],
]);
$contractResolver->method('resolveIsDriverForEmployeesAndDays')->willReturn([
1 => ['2026-06-08' => false],
2 => ['2026-06-08' => false],
]);
$contractResolver->method('resolveWorkDaysMinutesForEmployeesAndDays')->willReturn([
1 => ['2026-06-08' => null],
2 => ['2026-06-08' => null],
]);
$holidayService = $this->createStub(PublicHolidayServiceInterface::class);
$holidayService->method('getHolidaysDayByYears')->willReturn([]);
// No holiday on this Monday → virtual credit resolves to 0 via the real resolver.
$virtualResolver = new HolidayVirtualHoursResolver(
new DailyReferenceMinutesResolver(),
$holidayService,
$contractResolver,
);
$builder = new YearlyHoursExportBuilder(
$workHourRepo,
$absenceRepo,
$contractResolver,
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($contractResolver, new DailyReferenceMinutesResolver()),
$holidayService,
$virtualResolver,
);
$rows = $builder->buildDayRowsForEmployees([$withContract, $noContract], $date);
self::assertCount(1, $rows);
self::assertSame(1, $rows[0]['employeeId']);
self::assertSame('Dupont Jean', $rows[0]['employeeName']);
self::assertSame('08:00', $rows[0]['morningFrom']);
self::assertSame('17:00', $rows[0]['afternoonTo']);
self::assertSame('8h', $rows[0]['total']);
self::assertSame('8h', $rows[0]['dayHours']);
self::assertSame('', $rows[0]['nightHours']);
self::assertNull($rows[0]['statut']);
self::assertNull($rows[0]['statutLabel']);
self::assertNull($rows[0]['statutColor']);
self::assertFalse($rows[0]['isWeekend']);
}
private function setEmployeeId(Employee $employee, int $id): void
{
$ref = new ReflectionProperty(Employee::class, 'id');
$ref->setAccessible(true);
$ref->setValue($employee, $id);
}
}
@@ -7,6 +7,7 @@ namespace App\Tests\State;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Entity\EmployeeRttBalance;
use App\Enum\ContractNature;
use App\Enum\TrackingMode;
use App\Service\Contracts\EmployeeContractPhaseResolver;
@@ -74,6 +75,54 @@ final class EmployeeRttPaymentProcessorTest extends TestCase
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2030);
}
public function testPaymentAllowedOnPreviousExercise(): void
{
// Today = 2026-05-19 → current exercise = 2026. Retroactive payment on the
// immediately previous exercise (2025) is now allowed (Option B).
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19'));
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2025);
// No exception → previous exercise accepted.
self::assertTrue(true);
}
public function testPaymentStillRejectedTwoExercisesBack(): void
{
// 2024 is two exercises before current (2026) and not a closed-phase end → still rejected.
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19'));
$this->expectException(UnprocessableEntityHttpException::class);
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2024);
}
public function testRetroactivePaymentRefusedWhenDownstreamReportLocked(): void
{
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19'));
$locked = new EmployeeRttBalance();
$locked->setIsLocked(true);
$this->expectException(UnprocessableEntityHttpException::class);
$this->invokePrivate($processor, 'assertReportNotLocked', $locked);
}
public function testRetroactivePaymentAllowedWhenDownstreamReportMissingOrUnlocked(): void
{
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19'));
$unlocked = new EmployeeRttBalance();
$unlocked->setIsLocked(false);
// Neither a missing (null) nor an unlocked downstream report must block payment.
$this->invokePrivate($processor, 'assertReportNotLocked', null);
$this->invokePrivate($processor, 'assertReportNotLocked', $unlocked);
self::assertTrue(true);
}
// -----------------------------------------------------------------------
// Test harness helpers.
// -----------------------------------------------------------------------