diff --git a/.dockerignore b/.dockerignore index b49f8eb..a6a6e3f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ .env.local .env.test docker/ +!docker/php/config/php.ini deploy/docker/docker-compose.prod.yml deploy/docker/deploy.sh deploy/docker/.env.example diff --git a/CLAUDE.md b/CLAUDE.md index 2d0f0f2..635947a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ ## Stack - Backend: Symfony + API Platform + Doctrine ORM - Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind CSS +- UI library: `@malio/layer-ui` (Nuxt layer, `extends: ['@malio/layer-ui']` dans `nuxt.config.ts`). Composants auto-importés avec préfixe `Malio*` (ex. `MalioSelectCheckbox`, `MalioInputText`…). Doc d'usage dans `node_modules/@malio/layer-ui/COMPONENTS.md`. Tokens Tailwind `m-*` (primary/muted/danger/success/…) et variables CSS `--m-*` fournies par la couche. ## Project Structure - `src/` — Symfony domain, API resources, state providers/processors, services @@ -30,7 +31,10 @@ - Contracts: `trackingMode` (TIME=hours, PRESENCE=half-days), `weeklyHours` - Contract types: FORFAIT, THIRTY_FIVE_HOURS, THIRTY_NINE_HOURS, INTERIM, CUSTOM - Contract nature (per period): CDI, CDD, INTERIM +- **Agence d'intérim** (`InterimAgency` entity, table `interim_agencies`): optionnelle sur `EmployeeContractPeriod` quand nature = INTERIM. Pas de CRUD UI — gérée en BDD. API lecture seule `GET /interim_agencies`. Affichée "Intérim (NomAgence)" sur la liste employés et l'historique contrat. - 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). +- **É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`). - **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 - Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE) @@ -63,6 +67,7 @@ - INTERIM: no overtime bonuses, no recovery time - Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges - FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only. +- **FORFAIT — jours de présence et N-1** : les congés posés et imputés sur le stock N-1 ne décrémentent **pas** les jours de présence affichés (`presenceDaysByMonth` et `presenceDaysToToday`). Implémenté dans `EmployeeLeaveSummaryProvider::computePresenceDaysByMonth` via un budget N-1 (= `previousYearTakenDays`) consommé chronologiquement avant comptage des absences. Pour les non-forfait, ce budget vaut toujours 0 → comportement inchangé. ## Récap. congés (écran) - Accès via sidebar `Récap. congés`, conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`) — activé au create/edit user. Le flag s'applique à tous les profils, y compris admin. diff --git a/config/version.yaml b/config/version.yaml index 7121b50..3d7ea44 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.88' + app.version: '0.1.98' diff --git a/deploy/docker/Dockerfile.prod b/deploy/docker/Dockerfile.prod index b31759e..6b0a936 100644 --- a/deploy/docker/Dockerfile.prod +++ b/deploy/docker/Dockerfile.prod @@ -45,6 +45,7 @@ RUN apt-get update && apt-get install -y \ # PHP production config RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" +COPY docker/php/config/php.ini "$PHP_INI_DIR/conf.d/99-app.ini" # PHP-FPM: forward worker output to stderr for docker logs RUN echo "catch_workers_output = yes" >> /usr/local/etc/php-fpm.d/www.conf \ diff --git a/doc/functional-rules.md b/doc/functional-rules.md index 49df2bf..b155bdd 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -58,6 +58,9 @@ Documents complementaires: - mise à jour uniquement quand un employé (`ROLE_SELF`) modifie ses propres heures - non mise à jour lors de modifications admin ou chef de site - affichée sous le nom de l'employé (visible admin uniquement) +- Libellé nature de contrat (CDI/CDD/Intérim) affiché sous le nom: + - résolu à la date filtrée (période de contrat couvrant ce jour), pas à aujourd'hui + - masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré) ## 4) Absences @@ -71,6 +74,10 @@ Documents complementaires: - Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`) - demi-journée: dégradé diagonal - journée complète: fond plein +- Visibilité des employés dans le Calendrier: + - un employé est affiché si au moins une de ses périodes de contrat intersecte le mois affiché + - un employé dont toutes les périodes se terminent avant le 1er du mois (ou commencent après la fin du mois) est masqué + - même logique que l'écran Heures : « pas de contrat sur la période → masqué » ### Effet absence sur les heures @@ -130,6 +137,7 @@ Documents complementaires: - pas de bonus 25% - pas de bonus 50% - pas de total récup + - agence d'intérim optionnelle (table `interim_agencies`): affichée sur la fiche employé et le détail contrat sous la forme "Intérim (NomAgence)" ## 6bis) Heures Conducteurs @@ -165,8 +173,9 @@ Documents complementaires: - Exclusions configurables: variable d'env `EXCLUDED_PUBLIC_HOLIDAYS` (liste de libellés séparés par virgules). Par défaut `"Lundi de Pentecôte"` — journée de solidarité généralement travaillée. Le filtre s'applique à tous les consommateurs (frontend + calculs backend) en amont du retour du service. - Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol - Écran Heures et Heures Conducteurs (vue jour): le nom du férié est affiché dans la colonne Absence sous forme de pill (fond `#b3e5fc`, icône `mdi:calendar-star`), distinct du pill absence +- Écran Heures et Heures Conducteurs (vue semaine): la cellule du jour férié prend le fond `#b3e5fc` quand l'employé n'a pas d'absence ce jour-là, avec le nom du férié au survol (`title`). Si une absence est posée, la couleur de l'absence prime ; le `title` cumule les deux libellés (`Absence — Férié : Nom`). - Règle courante: - - absences bloquées sur jour férié (création/édition) — bouton "Modifier" masqué comme pour les formations + - absences autorisées sur jour férié (création/édition depuis l'écran Heures et le Calendrier). Quand une absence est posée, le crédit virtuel férié est désactivé — c'est le `countAsWorkedHours` du type d'absence qui pilote - saisie d'heures ou de jours de présence autorisée — les heures saisies comptent normalement dans le total hebdo et le calcul RTT - la référence hebdomadaire n'est pas réduite par un férié: un salarié qui ne saisit rien sur un férié est en déficit de la journée correspondante @@ -305,6 +314,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer. - les heures payées sont soustraites du disponible RTT (`availableMinutes -= totalPaidMinutes`) - affichage: 2 lignes par mois dans le tableau (25% et 50%) - colonnes Total 25% et Total 50%: somme base + bonus de chaque tranche + - colonne Cumul (dernière colonne): solde RTT à la fin de chaque semaine = `report N-1 + somme totalMinutes des semaines jusqu'à celle-ci − paiements RTT des mois antérieurs au mois de la semaine`. Le paiement d'un mois M n'est déduit qu'à partir des semaines du mois M+1 (cohérent avec la logique de la ligne "Report mois précédent"). Permet la comparaison ligne à ligne avec un suivi RH externe (Excel) - ligne Report N-1 (carry rollover): affichée en juin uniquement si carry > 0 - ligne Report mois précédent: solde cumulé (carry N-1 + semaines antérieures − paiements antérieurs), affichée à partir de juillet (masquée si nul) - Reste = Report cumulé + Total du mois − Payé du mois (balance courante en fin de mois) @@ -327,7 +337,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer. | Contrat | Contract.name | | CP N-1 restant | CDI/CDD: acquis N-1 − pris sur N-1. Forfait: report N-1 restant | | Samedi restant | CDI/CDD: samedis acquis N-1 − pris. Forfait: `-` | -| CP N | Forfait: jours acquis année civile. Non-forfait: en cours d'acquisition | +| CP N | Forfait: restant sur quota année civile (acquis − pris depuis N, sans toucher au stock N-1). Non-forfait: en cours d'acquisition | | RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` | ## 10bis) Écran Récap. congés (tableau) @@ -370,7 +380,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer. | Maladie - Nombre | Absence code 'M' ou 'AT' | Jours (demi-journées = 0.5) | | Maladie - Date | Absence code 'M' ou 'AT' | Dates formatées dd/mm | | CHAUFFEUR - PDJ | WorkHour.hasBreakfast | Comptage mois (chauffeurs uniquement) | -| CHAUFFEUR - REPAS | WorkHour.hasLunch + hasDinner | Comptage mois (chauffeurs uniquement) | +| CHAUFFEUR - REPAS | WorkHour.hasLunch + hasDinner | Somme sur le mois : +1 par déjeuner coché et +1 par dîner coché (un jour avec les deux compte 2 repas, chauffeurs uniquement) | | CHAUFFEUR - NUITEE | WorkHour.hasOvernight | Comptage mois (chauffeurs uniquement) | | CHAUFFEUR - samedi | WorkHour (samedi) | Samedis travaillés (chauffeurs uniquement) | | Observations | — | Colonne vide pour saisie manuelle | @@ -434,7 +444,8 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer. - Accessible depuis la fiche employé (bouton imprimante à droite du nom) - Ouvre un drawer pour choisir l'année (civile, Jan-Déc) - Génère un PDF avec le détail jour par jour des heures de l'employé -- Seuls les jours avec heures saisies ou absence sont affichés +- Seuls les jours avec heures saisies, absence, week-end ou jour férié sont affichés +- Les jours fériés apparaissent toujours sur une ligne dédiée (fond bleu clair) avec la mention "Férié : {nom}" dans la colonne Absence (même si aucune saisie) ### Colonnes selon le mode de suivi @@ -452,6 +463,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer. - TIME non-chauffeur: somme des créneaux matin + après-midi + soir, plus minutes créditées des absences `countAsWorkedHours` - Chauffeur: `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` + minutes créditées - PRESENCE: 0.5 par demi-journée présente (matin/après-midi), max 1.0 +- Jour férié Lun-Ven (hors Forfait, sans absence) : `total = max(saisie + crédit absence, référence contractuelle)` — même règle que l'écran Heures (cf. `HolidayVirtualHoursResolver`). Pour Forfait : pas de crédit virtuel, la ligne férié affiche juste l'éventuelle présence saisie. ### Nom du fichier diff --git a/docker/php/config/php.ini b/docker/php/config/php.ini index cad41f0..1b4c2bd 100644 --- a/docker/php/config/php.ini +++ b/docker/php/config/php.ini @@ -1,4 +1,7 @@ [Date] ; Defines the default timezone used by the date functions ; http://php.net/date.timezone -date.timezone = Europe/Paris \ No newline at end of file +date.timezone = Europe/Paris + +[PHP] +memory_limit = 256M diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..303a6d3 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +@malio:registry=https://gitea.malio.fr/api/packages/MALIO-DEV/npm/ diff --git a/frontend/components/AbsenceFormDrawer.vue b/frontend/components/AbsenceFormDrawer.vue index ec25735..d663372 100644 --- a/frontend/components/AbsenceFormDrawer.vue +++ b/frontend/components/AbsenceFormDrawer.vue @@ -1,44 +1,26 @@ @@ -146,22 +134,6 @@ const isFormValid = computed(() => isNameValid.value) const showNameError = computed(() => validationTouched.name && !isNameValid.value) -const baseInputClass = - 'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20' -const nameFieldClass = computed(() => { - if (showNameError.value) { - return `${baseInputClass} border-red-500` - } - return `${baseInputClass} border-neutral-300` -}) - -const submitButtonClass = computed(() => { - if (isSubmitting.value || !isFormValid.value) { - return 'opacity-50 cursor-not-allowed' - } - return '' -}) - const loadSites = async () => { isLoading.value = true try { diff --git a/frontend/pages/users.vue b/frontend/pages/users.vue index 2757209..79b3e34 100644 --- a/frontend/pages/users.vue +++ b/frontend/pages/users.vue @@ -1,14 +1,13 @@ @@ -311,27 +308,13 @@ const getSiteLabels = (user: User) => { return names.length > 0 ? names.join(', ') : 'Sites sélectionnés' } -const baseInputClass = - 'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20' -const usernameFieldClass = computed(() => { - if (showUsernameError.value) { - return `${baseInputClass} border-red-500` - } - return `${baseInputClass} border-neutral-300` -}) -const passwordFieldClass = computed(() => { - if (showPasswordError.value) { - return `${baseInputClass} border-red-500` - } - return `${baseInputClass} border-neutral-300` -}) +const employeeOptions = computed(() => + employees.value.map((e) => ({ label: `${e.firstName} ${e.lastName}`, value: e.id })) +) -const submitButtonClass = computed(() => { - if (isSubmitting.value || !isFormValid.value) { - return 'opacity-50 cursor-not-allowed' - } - return '' -}) +const onEmployeeChange = (value: string | number | null) => { + form.employeeId = value === null ? '' : Number(value) +} const loadData = async () => { isLoading.value = true diff --git a/frontend/services/dto/employee-leave-summary.ts b/frontend/services/dto/employee-leave-summary.ts index 20a85e0..7150778 100644 --- a/frontend/services/dto/employee-leave-summary.ts +++ b/frontend/services/dto/employee-leave-summary.ts @@ -15,5 +15,6 @@ export type EmployeeLeaveSummary = { previousYearRemainingDays: number previousYearPaidDays: number presenceDaysByMonth: Record + presenceDaysToToday: number } diff --git a/frontend/services/dto/employee-rtt-summary.ts b/frontend/services/dto/employee-rtt-summary.ts index 5d0dbe5..bf09a03 100644 --- a/frontend/services/dto/employee-rtt-summary.ts +++ b/frontend/services/dto/employee-rtt-summary.ts @@ -9,6 +9,7 @@ export type EmployeeRttWeekSummary = { base50Minutes: number bonus50Minutes: number totalMinutes: number + cumulativeBalanceMinutes: number } export type RttMonthPayment = { diff --git a/frontend/services/dto/employee.ts b/frontend/services/dto/employee.ts index 6ad156c..b7764b2 100644 --- a/frontend/services/dto/employee.ts +++ b/frontend/services/dto/employee.ts @@ -20,6 +20,8 @@ export type ContractHistoryItem = { suspensions?: ContractSuspension[] isDriver?: boolean workDaysHours?: Record | null + interimAgencyId?: number | null + interimAgencyName?: string | null } export type Employee = { @@ -37,4 +39,6 @@ export type Employee = { displayOrder?: number entryDate?: string | null currentSuspensions?: ContractSuspension[] + currentInterimAgencyId?: number | null + currentInterimAgencyName?: string | null } diff --git a/frontend/services/dto/work-hour.ts b/frontend/services/dto/work-hour.ts index 16651b5..03e6a41 100644 --- a/frontend/services/dto/work-hour.ts +++ b/frontend/services/dto/work-hour.ts @@ -60,6 +60,7 @@ export type WeeklyWorkHourDailySummary = { hasDinner?: boolean hasOvernight?: boolean virtualHolidayMinutes?: number + holidayLabel?: string | null } export type WeeklyWorkHourRowSummary = { @@ -113,6 +114,7 @@ export type WorkHourDayContextRow = { hasFormation?: boolean formationLabel?: string | null virtualHolidayMinutes?: number + contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null } export type WorkHourDayContext = { diff --git a/frontend/services/employees.ts b/frontend/services/employees.ts index ad32ca5..6587b7c 100644 --- a/frontend/services/employees.ts +++ b/frontend/services/employees.ts @@ -36,6 +36,7 @@ export const createEmployee = async (payload: { contractEndDate?: string | null isDriverInput?: boolean workDaysHoursInput?: Record | null + interimAgencyId?: number | null }) => { const api = useApi() return api.post('/employees', { @@ -47,7 +48,8 @@ export const createEmployee = async (payload: { contractStartDate: payload.contractStartDate, contractEndDate: payload.contractEndDate ?? null, isDriverInput: payload.isDriverInput ?? false, - workDaysHoursInput: payload.workDaysHoursInput ?? null + workDaysHoursInput: payload.workDaysHoursInput ?? null, + interimAgencyId: payload.interimAgencyId ?? null }, { toastSuccessKey: 'success.employee.create', toastErrorKey: 'errors.employee.create' @@ -69,6 +71,7 @@ export const updateEmployee = async ( displayOrder?: number isDriverInput?: boolean workDaysHoursInput?: Record | null + interimAgencyId?: number | null } ) => { const api = useApi() @@ -103,6 +106,9 @@ export const updateEmployee = async ( if (payload.workDaysHoursInput !== undefined) { body.workDaysHoursInput = payload.workDaysHoursInput } + if (payload.interimAgencyId !== undefined) { + body.interimAgencyId = payload.interimAgencyId + } return api.patch(`/employees/${id}`, body, { toastSuccessKey: 'success.employee.update', diff --git a/frontend/services/interim-agencies.ts b/frontend/services/interim-agencies.ts new file mode 100644 index 0000000..f4bc102 --- /dev/null +++ b/frontend/services/interim-agencies.ts @@ -0,0 +1,16 @@ +import { extractItems } from '~/utils/api' + +export type InterimAgency = { + id: number + name: string +} + +export const listInterimAgencies = async (): Promise => { + const api = useApi() + const data = await api.get( + '/interim_agencies', + {}, + { toast: false } + ) + return extractItems(data) +} diff --git a/frontend/utils/contract.ts b/frontend/utils/contract.ts index 95b5ced..f3c70b1 100644 --- a/frontend/utils/contract.ts +++ b/frontend/utils/contract.ts @@ -13,7 +13,7 @@ export const showsContractEndDate = (nature: ContractNature) => { } export const requiresContractEndDate = (nature: ContractNature) => { - return nature === 'CDD' + return nature === 'CDD' || nature === 'INTERIM' } export const isContractNature = (value: string): value is ContractNature => { diff --git a/migrations/Version20260417120000.php b/migrations/Version20260417120000.php new file mode 100644 index 0000000..52f47aa --- /dev/null +++ b/migrations/Version20260417120000.php @@ -0,0 +1,32 @@ +addSql('CREATE TABLE interim_agencies (id SERIAL PRIMARY KEY, name VARCHAR(150) NOT NULL UNIQUE)'); + $this->addSql('ALTER TABLE employee_contract_periods ADD interim_agency_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE employee_contract_periods ADD CONSTRAINT fk_ecp_interim_agency FOREIGN KEY (interim_agency_id) REFERENCES interim_agencies (id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX idx_ecp_interim_agency ON employee_contract_periods (interim_agency_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE employee_contract_periods DROP CONSTRAINT IF EXISTS fk_ecp_interim_agency'); + $this->addSql('DROP INDEX IF EXISTS idx_ecp_interim_agency'); + $this->addSql('ALTER TABLE employee_contract_periods DROP COLUMN interim_agency_id'); + $this->addSql('DROP TABLE interim_agencies'); + } +} diff --git a/src/ApiResource/EmployeeLeaveSummary.php b/src/ApiResource/EmployeeLeaveSummary.php index 0b275bd..de27fac 100644 --- a/src/ApiResource/EmployeeLeaveSummary.php +++ b/src/ApiResource/EmployeeLeaveSummary.php @@ -38,4 +38,7 @@ final class EmployeeLeaveSummary /** @var array YYYY-MM => count (0.5 for half-days) */ public array $presenceDaysByMonth = []; + + /** Cumul des jours de présence depuis le début de l'année de congé jusqu'à aujourd'hui (forfait). */ + public float $presenceDaysToToday = 0.0; } diff --git a/src/ApiResource/EmployeeYearlyHoursBulkPrint.php b/src/ApiResource/EmployeeYearlyHoursBulkPrint.php new file mode 100644 index 0000000..12a9ac1 --- /dev/null +++ b/src/ApiResource/EmployeeYearlyHoursBulkPrint.php @@ -0,0 +1,24 @@ + $this->hasFormation, 'formationLabel' => $this->formationLabel, 'virtualHolidayMinutes' => $this->virtualHolidayMinutes, + 'contractNature' => $this->contractNature, ]; } diff --git a/src/Dto/WorkHours/WeeklyDaySummary.php b/src/Dto/WorkHours/WeeklyDaySummary.php index 85416db..7d1b3b8 100644 --- a/src/Dto/WorkHours/WeeklyDaySummary.php +++ b/src/Dto/WorkHours/WeeklyDaySummary.php @@ -22,5 +22,6 @@ final class WeeklyDaySummary public bool $hasDinner = false, public bool $hasOvernight = false, public int $virtualHolidayMinutes = 0, + public ?string $holidayLabel = null, ) {} } diff --git a/src/Entity/Employee.php b/src/Entity/Employee.php index 7e08e71..7b79a67 100644 --- a/src/Entity/Employee.php +++ b/src/Entity/Employee.php @@ -98,6 +98,9 @@ class Employee #[Groups(['employee:write'])] private ?array $workDaysHoursInput = null; + #[Groups(['employee:write'])] + private ?int $interimAgencyId = null; + public function __construct() { $this->createdAt = new DateTimeImmutable(); @@ -295,6 +298,30 @@ class Employee return $this; } + public function getInterimAgencyId(): ?int + { + return $this->interimAgencyId; + } + + public function setInterimAgencyId(?int $interimAgencyId): self + { + $this->interimAgencyId = $interimAgencyId; + + return $this; + } + + #[Groups(['employee:read'])] + public function getCurrentInterimAgencyId(): ?int + { + return $this->resolveCurrentContractPeriod()?->getInterimAgency()?->getId(); + } + + #[Groups(['employee:read'])] + public function getCurrentInterimAgencyName(): ?string + { + return $this->resolveCurrentContractPeriod()?->getInterimAgency()?->getName(); + } + #[Groups(['employee:read'])] public function getHasActiveContract(): bool { @@ -393,6 +420,8 @@ class Employee suspensions: $suspensionData, isDriver: $period->getIsDriver(), workDaysHours: $period->getWorkDaysHours(), + interimAgencyId: $period->getInterimAgency()?->getId(), + interimAgencyName: $period->getInterimAgency()?->getName(), ); }, $periods diff --git a/src/Entity/EmployeeContractPeriod.php b/src/Entity/EmployeeContractPeriod.php index 941a47c..b89e2cb 100644 --- a/src/Entity/EmployeeContractPeriod.php +++ b/src/Entity/EmployeeContractPeriod.php @@ -55,6 +55,10 @@ class EmployeeContractPeriod #[ORM\Column(type: 'json', nullable: true)] private ?array $workDaysHours = null; + #[ORM\ManyToOne(targetEntity: InterimAgency::class)] + #[ORM\JoinColumn(nullable: true)] + private ?InterimAgency $interimAgency = null; + #[ORM\Column(type: 'text', nullable: true)] private ?string $comment = null; @@ -204,6 +208,18 @@ class EmployeeContractPeriod return $this; } + public function getInterimAgency(): ?InterimAgency + { + return $this->interimAgency; + } + + public function setInterimAgency(?InterimAgency $interimAgency): self + { + $this->interimAgency = $interimAgency; + + return $this; + } + /** * @return Collection */ diff --git a/src/Entity/InterimAgency.php b/src/Entity/InterimAgency.php new file mode 100644 index 0000000..a73278b --- /dev/null +++ b/src/Entity/InterimAgency.php @@ -0,0 +1,51 @@ + ['interim_agency:read']], + paginationEnabled: false, + security: "is_granted('ROLE_USER')", + order: ['name' => 'ASC'], +)] +#[ORM\Entity] +#[ORM\Table(name: 'interim_agencies')] +class InterimAgency +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + #[Groups(['interim_agency:read', 'employee:read'])] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 150, unique: true)] + #[Groups(['interim_agency:read', 'employee:read'])] + private string $name = ''; + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } +} diff --git a/src/Service/Contracts/EmployeeContractChangeRequest.php b/src/Service/Contracts/EmployeeContractChangeRequest.php index fadf5c0..a3c4fc5 100644 --- a/src/Service/Contracts/EmployeeContractChangeRequest.php +++ b/src/Service/Contracts/EmployeeContractChangeRequest.php @@ -20,6 +20,7 @@ final readonly class EmployeeContractChangeRequest public ?string $contractComment, public ?bool $isDriver = null, public ?array $workDaysHours = null, + public ?int $interimAgencyId = null, ) {} public function hasPeriodChangeRequest(): bool diff --git a/src/Service/Contracts/EmployeeContractChangeRequestFactory.php b/src/Service/Contracts/EmployeeContractChangeRequestFactory.php index 7a3e733..a596a97 100644 --- a/src/Service/Contracts/EmployeeContractChangeRequestFactory.php +++ b/src/Service/Contracts/EmployeeContractChangeRequestFactory.php @@ -21,6 +21,7 @@ final class EmployeeContractChangeRequestFactory contractComment: $employee->getContractComment(), isDriver: $employee->getIsDriverInput(), workDaysHours: $employee->getWorkDaysHoursInput(), + interimAgencyId: $employee->getInterimAgencyId(), ); } diff --git a/src/Service/Contracts/EmployeeContractPeriodBuilder.php b/src/Service/Contracts/EmployeeContractPeriodBuilder.php index 16e34b6..cc017a7 100644 --- a/src/Service/Contracts/EmployeeContractPeriodBuilder.php +++ b/src/Service/Contracts/EmployeeContractPeriodBuilder.php @@ -7,6 +7,7 @@ namespace App\Service\Contracts; use App\Entity\Contract; use App\Entity\Employee; use App\Entity\EmployeeContractPeriod; +use App\Entity\InterimAgency; use App\Enum\ContractNature; use DateTimeImmutable; @@ -23,6 +24,7 @@ final class EmployeeContractPeriodBuilder ContractNature $nature, bool $isDriver = false, ?array $workDaysHours = null, + ?InterimAgency $interimAgency = null, ): EmployeeContractPeriod { return new EmployeeContractPeriod() ->setEmployee($employee) @@ -32,6 +34,7 @@ final class EmployeeContractPeriodBuilder ->setContractNature($nature) ->setIsDriver($isDriver) ->setWorkDaysHours($workDaysHours) + ->setInterimAgency($interimAgency) ; } } diff --git a/src/Service/Contracts/EmployeeContractPeriodManager.php b/src/Service/Contracts/EmployeeContractPeriodManager.php index 473f669..ece7f1f 100644 --- a/src/Service/Contracts/EmployeeContractPeriodManager.php +++ b/src/Service/Contracts/EmployeeContractPeriodManager.php @@ -7,6 +7,7 @@ namespace App\Service\Contracts; use App\Entity\Contract; use App\Entity\Employee; use App\Entity\EmployeeContractPeriod; +use App\Entity\InterimAgency; use App\Enum\ContractNature; use App\Repository\EmployeeContractPeriodRepository; use DateTimeImmutable; @@ -30,6 +31,7 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe ContractNature $nature, bool $isDriver = false, ?array $workDaysHours = null, + ?int $interimAgencyId = null, ): void { $this->periodValidator->assertPeriodDates($startDate, $endDate, $nature); $this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours); @@ -39,7 +41,8 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe return; } - $this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours); + $interimAgency = $this->resolveInterimAgency($interimAgencyId); + $this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours, $interimAgency); $this->entityManager->flush(); } @@ -78,6 +81,7 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe ?EmployeeContractPeriod $todayPeriod, bool $isDriver = false, ?array $workDaysHours = null, + ?int $interimAgencyId = null, ): void { $this->periodValidator->assertPeriodDates($startDate, $endDate, $nature); $this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours); @@ -90,7 +94,8 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe } } - $this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours); + $interimAgency = $this->resolveInterimAgency($interimAgencyId); + $this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours, $interimAgency); $this->entityManager->flush(); } @@ -105,8 +110,23 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe ContractNature $nature, bool $isDriver = false, ?array $workDaysHours = null, + ?InterimAgency $interimAgency = null, ): void { - $period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours); + $period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours, $interimAgency); $this->entityManager->persist($period); } + + private function resolveInterimAgency(?int $id): ?InterimAgency + { + if (null === $id) { + return null; + } + + $agency = $this->entityManager->find(InterimAgency::class, $id); + if (null === $agency) { + throw new UnprocessableEntityHttpException(sprintf('Interim agency with id %d not found.', $id)); + } + + return $agency; + } } diff --git a/src/Service/Contracts/EmployeeContractPeriodManagerInterface.php b/src/Service/Contracts/EmployeeContractPeriodManagerInterface.php index 8103ed5..cb24c4b 100644 --- a/src/Service/Contracts/EmployeeContractPeriodManagerInterface.php +++ b/src/Service/Contracts/EmployeeContractPeriodManagerInterface.php @@ -23,6 +23,7 @@ interface EmployeeContractPeriodManagerInterface ContractNature $nature, bool $isDriver = false, ?array $workDaysHours = null, + ?int $interimAgencyId = null, ): void; public function closeCurrentPeriod( @@ -45,5 +46,6 @@ interface EmployeeContractPeriodManagerInterface ?EmployeeContractPeriod $todayPeriod, bool $isDriver = false, ?array $workDaysHours = null, + ?int $interimAgencyId = null, ): void; } diff --git a/src/Service/Leave/LeaveRecapRowBuilder.php b/src/Service/Leave/LeaveRecapRowBuilder.php index 633a357..46a0d4e 100644 --- a/src/Service/Leave/LeaveRecapRowBuilder.php +++ b/src/Service/Leave/LeaveRecapRowBuilder.php @@ -73,7 +73,7 @@ final readonly class LeaveRecapRowBuilder } } $cpN1Remaining = round($yearSummary['previousYearRemainingDays'], 2); - $cpN = (string) round($yearSummary['acquiredDays'], 2); + $cpN = (string) round($yearSummary['remainingDays'], 2); $acquiredSaturdays = '-'; } else { $cpN1Remaining = round($yearSummary['remainingDays'], 2); diff --git a/src/Service/WorkHours/YearlyHoursExportBuilder.php b/src/Service/WorkHours/YearlyHoursExportBuilder.php new file mode 100644 index 0000000..4e1a377 --- /dev/null +++ b/src/Service/WorkHours/YearlyHoursExportBuilder.php @@ -0,0 +1,503 @@ + + */ + public function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array + { + $days = []; + $current = $from; + + while ($current <= $to) { + $days[] = $current->format('Y-m-d'); + $current = $current->add(new DateInterval('P1D')); + } + + return $days; + } + + /** + * @param list $employees + * + * @return list}> + */ + public function buildForEmployees(array $employees, DateTimeImmutable $from, DateTimeImmutable $to): array + { + $days = $this->buildDays($from, $to); + + $workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees); + $absences = $this->absenceRepository->findForPrint($from, $to, $employees); + $contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days); + $driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days); + $workDaysMap = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days); + $holidayMap = $this->buildHolidayMap($from, $to); + + $workHourMap = $this->buildWorkHourMap($workHours); + $absenceMap = $this->buildAbsenceMap($absences, $days); + + $results = []; + foreach ($employees as $employee) { + $employeeId = $employee->getId(); + $absenceData = $this->resolveAbsenceDataForEmployee($absenceMap[$employeeId] ?? [], $days, $employee); + + $segments = $this->buildSegments( + $days, + $contractMap[$employeeId] ?? [], + $driverMap[$employeeId] ?? [], + $workHourMap[$employeeId] ?? [], + $absenceData, + $workDaysMap[$employeeId] ?? [], + $holidayMap, + ); + + if ([] === $segments) { + continue; + } + + $results[] = [ + 'employeeName' => trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')), + 'contractLabel' => $this->buildContractLabel($employee), + 'segments' => $segments, + ]; + } + + return $results; + } + + /** + * @return list}> + */ + public function buildForEmployee(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array + { + return $this->buildForEmployees([$employee], $from, $to); + } + + public function buildContractLabel(Employee $employee): ?string + { + $contract = $employee->getContract(); + if (null === $contract) { + return null; + } + + $natureRaw = $employee->getCurrentContractNature(); + $nature = ContractNature::tryFrom($natureRaw) ?? ContractNature::CDI; + $natureLabel = match ($nature) { + ContractNature::CDI => 'CDI', + ContractNature::CDD => 'CDD', + ContractNature::INTERIM => 'Intérim', + }; + + $contractType = $contract->getType(); + if (ContractType::FORFAIT === $contractType) { + return $natureLabel.' Forfait'; + } + + $weeklyHours = $contract->getWeeklyHours(); + if (null !== $weeklyHours && $weeklyHours > 0) { + return sprintf('%s %d heures', $natureLabel, $weeklyHours); + } + + $name = $contract->getName(); + + return null !== $name && '' !== $name ? $natureLabel.' '.$name : $natureLabel; + } + + /** + * @return array> + */ + private function buildWorkHourMap(array $workHours): array + { + $map = []; + foreach ($workHours as $wh) { + $employeeId = $wh->getEmployee()?->getId(); + if (!$employeeId) { + continue; + } + $date = $wh->getWorkDate()->format('Y-m-d'); + $map[$employeeId][$date] = $wh; + } + + return $map; + } + + /** + * @return array> + */ + private function buildAbsenceMap(array $absences, array $days): array + { + $map = []; + foreach ($absences as $absence) { + $employeeId = $absence->getEmployee()?->getId(); + if (!$employeeId) { + continue; + } + $map[$employeeId][] = $absence; + } + + return $map; + } + + /** + * @return array{credited: array, labels: array, absentMorning: array, absentAfternoon: array, hasDayAbsence: array} + */ + private function resolveAbsenceDataForEmployee(array $absences, array $days, Employee $employee): array + { + $credited = []; + $labels = []; + $absentMorning = []; + $absentAfternoon = []; + $hasDayAbsence = []; + + foreach ($absences as $absence) { + $start = $absence->getStartDate()->format('Y-m-d'); + $end = $absence->getEndDate()->format('Y-m-d'); + + foreach ($days as $date) { + if ($date < $start || $date > $end) { + continue; + } + + [$isMorning, $isAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date); + if ($isMorning || $isAfternoon) { + $hasDayAbsence[$date] = true; + $absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning; + $absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon; + if (!isset($labels[$date])) { + $labels[$date] = $absence->getType()?->getLabel() ?? ''; + } + } + + $credited[$date] = ($credited[$date] ?? 0) + + $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $isMorning, $isAfternoon); + } + } + + return [ + 'credited' => $credited, + 'labels' => $labels, + 'absentMorning' => $absentMorning, + 'absentAfternoon' => $absentAfternoon, + 'hasDayAbsence' => $hasDayAbsence, + ]; + } + + /** + * @param array> $workDaysMinutesByDate + * @param array $holidayMap + * + * @return list}> + */ + private function buildSegments( + array $days, + array $contractsByDate, + array $driverByDate, + array $workHoursByDate, + array $absenceData, + array $workDaysMinutesByDate, + array $holidayMap, + ): array { + $segments = []; + $currentMode = null; + $currentRows = []; + $currentName = null; + + $firstDataDate = null; + foreach ($days as $date) { + $hasRow = null !== ($workHoursByDate[$date] ?? null) + || ($absenceData['hasDayAbsence'][$date] ?? false) + || isset($holidayMap[$date]); + if ($hasRow) { + $firstDataDate = $date; + + break; + } + } + + if (null === $firstDataDate) { + return []; + } + + $todayYmd = new DateTimeImmutable('today')->format('Y-m-d'); + + foreach ($days as $date) { + if ($date < $firstDataDate || $date > $todayYmd) { + continue; + } + + $contract = $contractsByDate[$date] ?? null; + $isDriver = $driverByDate[$date] ?? false; + $wh = $workHoursByDate[$date] ?? null; + $hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false); + $holidayLabel = $holidayMap[$date] ?? null; + $isHoliday = null !== $holidayLabel; + $isoDay = (int) new DateTimeImmutable($date)->format('N'); + $isWeekend = $isoDay >= 6; + + if (!$hasData && !$isWeekend && !$isHoliday) { + continue; + } + + if (!$hasData && null === $contract) { + continue; + } + + $trackingMode = $contract?->getTrackingMode() ?? TrackingMode::TIME->value; + $mode = $this->resolveSegmentMode($trackingMode, $isDriver); + $contractName = $contract?->getName(); + + if ($mode !== $currentMode) { + if (null !== $currentMode && [] !== $currentRows) { + $segments[] = [ + 'mode' => $currentMode, + 'contractName' => $currentName, + 'rows' => $currentRows, + ]; + } + $currentMode = $mode; + $currentRows = []; + $currentName = $contractName; + } + + $creditedMinutes = $absenceData['credited'][$date] ?? 0; + $absenceLabel = $absenceData['labels'][$date] ?? null; + $hasAbsence = $absenceData['hasDayAbsence'][$date] ?? false; + $virtualMinutes = $this->holidayVirtualHoursResolver->resolveVirtualCredit( + $contract, + new DateTimeImmutable($date), + $hasAbsence, + $workDaysMinutesByDate[$date] ?? null, + ); + + $row = [ + 'date' => new DateTimeImmutable($date)->format('d/m/Y'), + 'absenceLabel' => $absenceLabel, + 'holidayLabel' => $holidayLabel, + 'isWeekend' => $isWeekend, + ]; + + if ('presence' === $mode) { + $absentMorning = $absenceData['absentMorning'][$date] ?? false; + $absentAfternoon = $absenceData['absentAfternoon'][$date] ?? false; + $morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0; + $afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0; + $total = $morning + $afternoon; + + $row['presentMorning'] = $morning > 0; + $row['presentAfternoon'] = $afternoon > 0; + $row['total'] = $total > 0 ? (string) $total : ''; + } elseif ('driver' === $mode) { + $dayMin = $wh?->getDayHoursMinutes() ?? 0; + $nightMin = $wh?->getNightHoursMinutes() ?? 0; + $workshopMin = $wh?->getWorkshopHoursMinutes() ?? 0; + $totalMin = $dayMin + $nightMin + $workshopMin + $creditedMinutes; + if ($virtualMinutes > $totalMin) { + $totalMin = $virtualMinutes; + } + + $row['dayHours'] = $this->formatMinutes($dayMin); + $row['nightHours'] = $this->formatMinutes($nightMin); + $row['workshopHours'] = $this->formatMinutes($workshopMin); + $row['total'] = $this->formatMinutes($totalMin); + } else { + $metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics(); + $metrics->addCreditedMinutes($creditedMinutes); + $totalMin = $metrics->totalMinutes; + if ($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['total'] = $this->formatMinutes($totalMin); + } + + $currentRows[] = $row; + } + + if (null !== $currentMode && [] !== $currentRows) { + $segments[] = [ + 'mode' => $currentMode, + 'contractName' => $currentName, + 'rows' => $currentRows, + ]; + } + + return $segments; + } + + /** + * @return array Y-m-d => label + */ + private function buildHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array + { + $map = []; + $startYear = (int) $from->format('Y'); + $endYear = (int) $to->format('Y'); + + try { + for ($year = $startYear; $year <= $endYear; ++$year) { + $holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year); + foreach ($holidays as $date => $label) { + $map[(string) $date] = (string) $label; + } + } + } catch (Throwable) { + return []; + } + + return $map; + } + + private function resolveSegmentMode(string $trackingMode, bool $isDriver): string + { + if ($isDriver) { + return 'driver'; + } + + if (TrackingMode::PRESENCE->value === $trackingMode) { + return 'presence'; + } + + return 'time'; + } + + private function computeMetrics(WorkHour $workHour): WorkMetrics + { + $ranges = [ + [$workHour->getMorningFrom(), $workHour->getMorningTo()], + [$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()], + [$workHour->getEveningFrom(), $workHour->getEveningTo()], + ]; + + $totalMinutes = 0; + $nightMinutes = 0; + + foreach ($ranges as [$from, $to]) { + $totalMinutes += $this->intervalMinutes($from, $to); + $nightMinutes += $this->nightIntervalMinutes($from, $to); + } + + $dayMinutes = max(0, $totalMinutes - $nightMinutes); + + return new WorkMetrics( + dayMinutes: $dayMinutes, + nightMinutes: $nightMinutes, + totalMinutes: $totalMinutes, + ); + } + + /** + * @return null|array{int, int} + */ + private function resolveInterval(?string $from, ?string $to): ?array + { + $fromMinutes = $this->toMinutes($from); + $toMinutes = $this->toMinutes($to); + if (null === $fromMinutes || null === $toMinutes) { + return null; + } + + $end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes; + + return [$fromMinutes, $end]; + } + + private function toMinutes(?string $time): ?int + { + if (null === $time || '' === $time) { + return null; + } + + [$hours, $minutes] = array_map('intval', explode(':', $time)); + + return ($hours * 60) + $minutes; + } + + private function intervalMinutes(?string $from, ?string $to): int + { + $interval = $this->resolveInterval($from, $to); + if (null === $interval) { + return 0; + } + + [$start, $end] = $interval; + + return max(0, $end - $start); + } + + private function nightIntervalMinutes(?string $from, ?string $to): int + { + $interval = $this->resolveInterval($from, $to); + if (null === $interval) { + return 0; + } + + [$start, $end] = $interval; + $windows = [[0, 360], [1260, 1440]]; + $total = 0; + + for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) { + $shift = $dayOffset * 1440; + foreach ($windows as [$windowStart, $windowEnd]) { + $total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift); + } + } + + return $total; + } + + private function overlap(int $startA, int $endA, int $startB, int $endB): int + { + $start = max($startA, $startB); + $end = min($endA, $endB); + + return max(0, $end - $start); + } + + private function formatMinutes(int $minutes): string + { + if (0 === $minutes) { + return ''; + } + + $h = intdiv($minutes, 60); + $m = $minutes % 60; + + return 0 === $m ? "{$h}h" : "{$h}h{$m}m"; + } +} diff --git a/src/State/EmployeeLeaveSummaryProvider.php b/src/State/EmployeeLeaveSummaryProvider.php index e1b7c28..24ed690 100644 --- a/src/State/EmployeeLeaveSummaryProvider.php +++ b/src/State/EmployeeLeaveSummaryProvider.php @@ -119,8 +119,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays']; $summary->previousYearPaidDays = $paidLeaveDays; - [$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year); - $summary->presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $periodFrom, $periodTo); + [$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year); + // Forfait-only: leaves taken from N-1 stock do NOT decrement presence days. + // For non-forfait, previousYearTakenDays is always 0, so the budget has no effect. + $n1AbsencesBudget = $yearSummary['previousYearTakenDays']; + $summary->presenceDaysByMonth = $this->computePresenceDaysByMonth( + $employee, + $periodFrom, + $periodTo, + $n1AbsencesBudget + ); + + // Same logic as presenceDaysByMonth but bounded at today: number of presence days + // accumulated from leave year start up to today (inclusive). + $today = new DateTimeImmutable('today'); + $cappedTo = $today < $periodTo ? $today : $periodTo; + $summary->presenceDaysToToday = $today < $periodFrom + ? 0.0 + : array_sum($this->computePresenceDaysByMonth( + $employee, + $periodFrom, + $cappedTo, + $n1AbsencesBudget + )); return $summary; } @@ -686,8 +707,12 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface * * @return array YYYY-MM => presence day count */ - private function computePresenceDaysByMonth(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array - { + private function computePresenceDaysByMonth( + Employee $employee, + DateTimeImmutable $from, + DateTimeImmutable $to, + float $n1AbsencesBudget = 0.0 + ): array { $publicHolidays = $this->buildPublicHolidayMap($from, $to); $weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to); $absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to); @@ -697,10 +722,20 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface ? $this->workHourRepository->findWorkedDatesAmong($employee, array_keys($publicHolidays)) : []; + // Sort absences chronologically so N-1 budget (forfait only) is consumed in date order: + // earliest absences attribute to N-1 first, later ones overflow to N and reduce presence. + $sortedAbsences = $absences; + usort( + $sortedAbsences, + static fn ($a, $b): int => $a->getStartDate() <=> $b->getStartDate() + ); + + $remainingN1Budget = $n1AbsencesBudget; + // Count absence days per month, iterating day by day to handle multi-day absences // and properly distribute across months. $absenceDaysByMonth = []; - foreach ($absences as $absence) { + foreach ($sortedAbsences as $absence) { $start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0); $end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0); @@ -718,6 +753,17 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface continue; } + // Forfait: leaves taken from N-1 stock do NOT decrement presence days. + // We chronologically consume the N-1 budget before counting any absence. + if ($remainingN1Budget > 0.0) { + $consumed = min($remainingN1Budget, $dayAmount); + $remainingN1Budget -= $consumed; + $dayAmount -= $consumed; + if ($dayAmount <= 0.0) { + continue; + } + } + $absenceDaysByMonth[$monthKey] = ($absenceDaysByMonth[$monthKey] ?? 0.0) + $dayAmount; } } diff --git a/src/State/EmployeeRttSummaryProvider.php b/src/State/EmployeeRttSummaryProvider.php index dc28e53..bf091e4 100644 --- a/src/State/EmployeeRttSummaryProvider.php +++ b/src/State/EmployeeRttSummaryProvider.php @@ -164,6 +164,18 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface $monthBuckets[$m]['bonus50'] += $payment->getBonus50Minutes(); } + $runningCumul = $summary->carryFromPreviousYearMinutes; + $prevMonth = null; + foreach ($summary->weeks as $week) { + if (null !== $prevMonth && $week->month !== $prevMonth && isset($monthBuckets[$prevMonth])) { + $b = $monthBuckets[$prevMonth]; + $runningCumul -= $b['base25'] + $b['bonus25'] + $b['base50'] + $b['bonus50']; + } + $runningCumul += $week->totalMinutes; + $week->cumulativeBalanceMinutes = $runningCumul; + $prevMonth = $week->month; + } + $monthPayments = []; $totalPaidMinutes = 0; diff --git a/src/State/EmployeeWriteProcessor.php b/src/State/EmployeeWriteProcessor.php index 5ec3f42..a91d61a 100644 --- a/src/State/EmployeeWriteProcessor.php +++ b/src/State/EmployeeWriteProcessor.php @@ -70,6 +70,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface nature: $nature, isDriver: $changeRequest->isDriver ?? false, workDaysHours: $changeRequest->workDaysHours, + interimAgencyId: $changeRequest->interimAgencyId, ); $data->setEntryDate($startDate); @@ -140,6 +141,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface todayPeriod: $effectivePeriod, isDriver: $changeRequest->isDriver ?? false, workDaysHours: $changeRequest->workDaysHours, + interimAgencyId: $changeRequest->interimAgencyId, ); return $result; diff --git a/src/State/EmployeeYearlyHoursBulkPrintProvider.php b/src/State/EmployeeYearlyHoursBulkPrintProvider.php new file mode 100644 index 0000000..363adf0 --- /dev/null +++ b/src/State/EmployeeYearlyHoursBulkPrintProvider.php @@ -0,0 +1,86 @@ +requestStack->getCurrentRequest(); + if (!$request) { + return new Response('Missing request.', Response::HTTP_BAD_REQUEST); + } + + $yearRaw = (string) $request->query->get('year'); + if (!preg_match('/^\d{4}$/', $yearRaw)) { + throw new UnprocessableEntityHttpException('year must use YYYY format.'); + } + $year = (int) $yearRaw; + + $monthRaw = (string) $request->query->get('month', ''); + $month = null; + if ('' !== $monthRaw) { + if (!preg_match('/^(?:0?[1-9]|1[0-2])$/', $monthRaw)) { + throw new UnprocessableEntityHttpException('month must be between 1 and 12.'); + } + $month = (int) $monthRaw; + } + + if (null !== $month) { + $from = new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month)); + $to = $from->modify('last day of this month'); + } else { + $from = new DateTimeImmutable("{$year}-01-01"); + $to = new DateTimeImmutable("{$year}-12-31"); + } + + $employees = $this->employeeRepository->findAll(); + usort($employees, fn ($a, $b) => ($a->getLastName() ?? '') <=> ($b->getLastName() ?? '')); + + $entries = $this->exportBuilder->buildForEmployees($employees, $from, $to); + + $options = new Options(); + $options->set('isRemoteEnabled', true); + + $dompdf = new Dompdf($options); + $html = $this->twig->render('employee-yearly-hours/print-all.html.twig', [ + 'entries' => $entries, + 'year' => $year, + 'month' => $month, + ]); + + $dompdf->loadHtml($html); + $dompdf->setPaper('A4', 'portrait'); + $dompdf->render(); + + $filename = null !== $month + ? sprintf('heures_tous_%d-%02d.pdf', $year, $month) + : sprintf('heures_tous_%d.pdf', $year); + + return new Response($dompdf->output(), Response::HTTP_OK, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="'.$filename.'"', + ]); + } +} diff --git a/src/State/EmployeeYearlyHoursPrintProvider.php b/src/State/EmployeeYearlyHoursPrintProvider.php index c2d90c5..75eb032 100644 --- a/src/State/EmployeeYearlyHoursPrintProvider.php +++ b/src/State/EmployeeYearlyHoursPrintProvider.php @@ -6,19 +6,9 @@ namespace App\State; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; -use App\Dto\WorkHours\WorkMetrics; use App\Entity\Employee; -use App\Entity\WorkHour; -use App\Enum\ContractNature; -use App\Enum\ContractType; -use App\Enum\TrackingMode; -use App\Repository\AbsenceRepository; use App\Repository\EmployeeRepository; -use App\Repository\WorkHourRepository; -use App\Service\Contracts\EmployeeContractResolver; -use App\Service\WorkHours\AbsenceSegmentsResolver; -use App\Service\WorkHours\WorkedHoursCreditPolicy; -use DateInterval; +use App\Service\WorkHours\YearlyHoursExportBuilder; use DateTimeImmutable; use Dompdf\Dompdf; use Dompdf\Options; @@ -34,11 +24,7 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface private Environment $twig, private readonly RequestStack $requestStack, private EmployeeRepository $employeeRepository, - private WorkHourRepository $workHourRepository, - private AbsenceRepository $absenceRepository, - private EmployeeContractResolver $contractResolver, - private AbsenceSegmentsResolver $absenceSegmentsResolver, - private WorkedHoursCreditPolicy $workedHoursCreditPolicy, + private YearlyHoursExportBuilder $exportBuilder, ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response @@ -80,27 +66,11 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface $from = new DateTimeImmutable("{$year}-01-01"); $to = new DateTimeImmutable("{$year}-12-31"); } - $days = $this->buildDays($from, $to); - $workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, [$employee]); - $absences = $this->absenceRepository->findForPrint($from, $to, [$employee]); - $contractMap = $this->contractResolver->resolveForEmployeesAndDays([$employee], $days); - $driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays([$employee], $days); - - $workHourMap = $this->buildWorkHourMap($workHours); - $absenceData = $this->buildAbsenceData($absences, $days, $employee); - - $segments = $this->buildSegments( - $employee, - $days, - $contractMap[$employee->getId()] ?? [], - $driverMap[$employee->getId()] ?? [], - $workHourMap[$employee->getId()] ?? [], - $absenceData, - ); + $entries = $this->exportBuilder->buildForEmployee($employee, $from, $to); $employeeName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')); - $contractLabel = $this->buildContractLabel($employee); + $contractLabel = $this->exportBuilder->buildContractLabel($employee); $options = new Options(); $options->set('isRemoteEnabled', true); @@ -111,7 +81,7 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface 'contractLabel' => $contractLabel, 'year' => $year, 'month' => $month, - 'segments' => $segments, + 'segments' => $entries[0]['segments'] ?? [], ]); $dompdf->loadHtml($html); @@ -139,367 +109,6 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface ]); } - private function buildContractLabel(Employee $employee): ?string - { - $contract = $employee->getContract(); - if (null === $contract) { - return null; - } - - $natureRaw = $employee->getCurrentContractNature(); - $nature = ContractNature::tryFrom($natureRaw) ?? ContractNature::CDI; - $natureLabel = match ($nature) { - ContractNature::CDI => 'CDI', - ContractNature::CDD => 'CDD', - ContractNature::INTERIM => 'Intérim', - }; - - $contractType = $contract->getType(); - if (ContractType::FORFAIT === $contractType) { - return $natureLabel.' Forfait'; - } - - $weeklyHours = $contract->getWeeklyHours(); - if (null !== $weeklyHours && $weeklyHours > 0) { - return sprintf('%s %d heures', $natureLabel, $weeklyHours); - } - - $name = $contract->getName(); - - return null !== $name && '' !== $name ? $natureLabel.' '.$name : $natureLabel; - } - - /** - * @return list - */ - private function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array - { - $days = []; - $current = $from; - - while ($current <= $to) { - $days[] = $current->format('Y-m-d'); - $current = $current->add(new DateInterval('P1D')); - } - - return $days; - } - - /** - * @return array> - */ - private function buildWorkHourMap(array $workHours): array - { - $map = []; - foreach ($workHours as $wh) { - $employeeId = $wh->getEmployee()?->getId(); - if (!$employeeId) { - continue; - } - $date = $wh->getWorkDate()->format('Y-m-d'); - $map[$employeeId][$date] = $wh; - } - - return $map; - } - - /** - * @return array{credited: array, labels: array, absentMorning: array, absentAfternoon: array, hasDayAbsence: array} - */ - private function buildAbsenceData(array $absences, array $days, Employee $employee): array - { - $credited = []; - $labels = []; - $absentMorning = []; - $absentAfternoon = []; - $hasDayAbsence = []; - - foreach ($absences as $absence) { - $absEmployeeId = $absence->getEmployee()?->getId(); - if ($absEmployeeId !== $employee->getId()) { - continue; - } - - $start = $absence->getStartDate()->format('Y-m-d'); - $end = $absence->getEndDate()->format('Y-m-d'); - - foreach ($days as $date) { - if ($date < $start || $date > $end) { - continue; - } - - [$isMorning, $isAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date); - if ($isMorning || $isAfternoon) { - $hasDayAbsence[$date] = true; - $absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning; - $absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon; - if (!isset($labels[$date])) { - $labels[$date] = $absence->getType()?->getLabel() ?? ''; - } - } - - $credited[$date] = ($credited[$date] ?? 0) - + $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $isMorning, $isAfternoon); - } - } - - return [ - 'credited' => $credited, - 'labels' => $labels, - 'absentMorning' => $absentMorning, - 'absentAfternoon' => $absentAfternoon, - 'hasDayAbsence' => $hasDayAbsence, - ]; - } - - /** - * @return list}> - */ - private function buildSegments( - Employee $employee, - array $days, - array $contractsByDate, - array $driverByDate, - array $workHoursByDate, - array $absenceData, - ): array { - $segments = []; - $currentMode = null; - $currentRows = []; - $currentName = null; - - // Crop the output window to [first data day, today] to avoid padding the - // export with empty rows (notably weekends before the first saisie or after today). - $firstDataDate = null; - foreach ($days as $date) { - $hasRow = null !== ($workHoursByDate[$date] ?? null) - || ($absenceData['hasDayAbsence'][$date] ?? false); - if ($hasRow) { - $firstDataDate = $date; - - break; - } - } - - if (null === $firstDataDate) { - return []; - } - - $todayYmd = new DateTimeImmutable('today')->format('Y-m-d'); - - foreach ($days as $date) { - if ($date < $firstDataDate || $date > $todayYmd) { - continue; - } - - $contract = $contractsByDate[$date] ?? null; - $isDriver = $driverByDate[$date] ?? false; - $wh = $workHoursByDate[$date] ?? null; - $hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false); - $isoDay = (int) new DateTimeImmutable($date)->format('N'); - $isWeekend = $isoDay >= 6; - - // Keep weekend rows even when empty so the reader can distinguish - // worked vs non-worked Saturdays/Sundays at a glance. - if (!$hasData && !$isWeekend) { - continue; - } - - if (!$hasData && null === $contract) { - continue; - } - - $trackingMode = $contract?->getTrackingMode() ?? TrackingMode::TIME->value; - $mode = $this->resolveSegmentMode($trackingMode, $isDriver); - $contractName = $contract?->getName(); - - if ($mode !== $currentMode) { - if (null !== $currentMode && [] !== $currentRows) { - $segments[] = [ - 'mode' => $currentMode, - 'contractName' => $currentName, - 'rows' => $currentRows, - ]; - } - $currentMode = $mode; - $currentRows = []; - $currentName = $contractName; - } - - $creditedMinutes = $absenceData['credited'][$date] ?? 0; - $absenceLabel = $absenceData['labels'][$date] ?? null; - - $row = [ - 'date' => new DateTimeImmutable($date)->format('d/m/Y'), - 'absenceLabel' => $absenceLabel, - 'isWeekend' => $isWeekend, - ]; - - if ('presence' === $mode) { - $absentMorning = $absenceData['absentMorning'][$date] ?? false; - $absentAfternoon = $absenceData['absentAfternoon'][$date] ?? false; - $morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0; - $afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0; - $total = $morning + $afternoon; - - $row['presentMorning'] = $morning > 0; - $row['presentAfternoon'] = $afternoon > 0; - $row['total'] = $total > 0 ? (string) $total : ''; - } elseif ('driver' === $mode) { - $dayMin = $wh?->getDayHoursMinutes() ?? 0; - $nightMin = $wh?->getNightHoursMinutes() ?? 0; - $workshopMin = $wh?->getWorkshopHoursMinutes() ?? 0; - $totalMin = $dayMin + $nightMin + $workshopMin + $creditedMinutes; - - $row['dayHours'] = $this->formatMinutes($dayMin); - $row['nightHours'] = $this->formatMinutes($nightMin); - $row['workshopHours'] = $this->formatMinutes($workshopMin); - $row['total'] = $this->formatMinutes($totalMin); - } else { - $metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics(); - $metrics->addCreditedMinutes($creditedMinutes); - - $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['total'] = $this->formatMinutes($metrics->totalMinutes); - } - - $currentRows[] = $row; - } - - if (null !== $currentMode && [] !== $currentRows) { - $segments[] = [ - 'mode' => $currentMode, - 'contractName' => $currentName, - 'rows' => $currentRows, - ]; - } - - return $segments; - } - - private function resolveSegmentMode(string $trackingMode, bool $isDriver): string - { - if ($isDriver) { - return 'driver'; - } - - if (TrackingMode::PRESENCE->value === $trackingMode) { - return 'presence'; - } - - return 'time'; - } - - private function computeMetrics(WorkHour $workHour): WorkMetrics - { - $ranges = [ - [$workHour->getMorningFrom(), $workHour->getMorningTo()], - [$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()], - [$workHour->getEveningFrom(), $workHour->getEveningTo()], - ]; - - $totalMinutes = 0; - $nightMinutes = 0; - - foreach ($ranges as [$from, $to]) { - $totalMinutes += $this->intervalMinutes($from, $to); - $nightMinutes += $this->nightIntervalMinutes($from, $to); - } - - $dayMinutes = max(0, $totalMinutes - $nightMinutes); - - return new WorkMetrics( - dayMinutes: $dayMinutes, - nightMinutes: $nightMinutes, - totalMinutes: $totalMinutes, - ); - } - - /** - * @return null|array{int, int} - */ - private function resolveInterval(?string $from, ?string $to): ?array - { - $fromMinutes = $this->toMinutes($from); - $toMinutes = $this->toMinutes($to); - if (null === $fromMinutes || null === $toMinutes) { - return null; - } - - $end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes; - - return [$fromMinutes, $end]; - } - - private function toMinutes(?string $time): ?int - { - if (null === $time || '' === $time) { - return null; - } - - [$hours, $minutes] = array_map('intval', explode(':', $time)); - - return ($hours * 60) + $minutes; - } - - private function intervalMinutes(?string $from, ?string $to): int - { - $interval = $this->resolveInterval($from, $to); - if (null === $interval) { - return 0; - } - - [$start, $end] = $interval; - - return max(0, $end - $start); - } - - private function nightIntervalMinutes(?string $from, ?string $to): int - { - $interval = $this->resolveInterval($from, $to); - if (null === $interval) { - return 0; - } - - [$start, $end] = $interval; - $windows = [[0, 360], [1260, 1440]]; - $total = 0; - - for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) { - $shift = $dayOffset * 1440; - foreach ($windows as [$windowStart, $windowEnd]) { - $total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift); - } - } - - return $total; - } - - private function overlap(int $startA, int $endA, int $startB, int $endB): int - { - $start = max($startA, $startB); - $end = min($endA, $endB); - - return max(0, $end - $start); - } - - private function formatMinutes(int $minutes): string - { - if (0 === $minutes) { - return ''; - } - - $h = intdiv($minutes, 60); - $m = $minutes % 60; - - return 0 === $m ? "{$h}h" : "{$h}h{$m}m"; - } - private function sanitizeFilename(string $name): string { $name = str_replace(' ', '_', $name); diff --git a/src/State/SalaryRecapPrintProvider.php b/src/State/SalaryRecapPrintProvider.php index 34973fa..0786e01 100644 --- a/src/State/SalaryRecapPrintProvider.php +++ b/src/State/SalaryRecapPrintProvider.php @@ -363,7 +363,10 @@ class SalaryRecapPrintProvider implements ProviderInterface if ($wh->getHasBreakfast()) { ++$driverBreakfast; } - if ($wh->getHasLunch() || $wh->getHasDinner()) { + if ($wh->getHasLunch()) { + ++$driverMeals; + } + if ($wh->getHasDinner()) { ++$driverMeals; } if ($wh->getHasOvernight()) { diff --git a/src/State/WorkHourDayContextProvider.php b/src/State/WorkHourDayContextProvider.php index a5bd0d0..4fcf4fa 100644 --- a/src/State/WorkHourDayContextProvider.php +++ b/src/State/WorkHourDayContextProvider.php @@ -57,13 +57,17 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface } // On initialise toutes les lignes, même sans absence ce jour-là. - $contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate); - $workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $workDate); + $contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate); + $workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $workDate); + $contractNature = null !== $contract + ? $this->contractResolver->resolveNatureForEmployeeAndDate($employee, $workDate)->value + : null; $rowsByEmployeeId[$employeeId] = new DayContextRow( employeeId: $employeeId, hasContractAtDate: null !== $contract, isDriverContract: $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate), virtualHolidayMinutes: $this->holidayVirtualHoursResolver->resolveVirtualCredit($contract, $workDate, false, $workDaysMinutes), + contractNature: $contractNature, ); } diff --git a/src/State/WorkHourWeeklySummaryProvider.php b/src/State/WorkHourWeeklySummaryProvider.php index aeffd4a..00ae3f1 100644 --- a/src/State/WorkHourWeeklySummaryProvider.php +++ b/src/State/WorkHourWeeklySummaryProvider.php @@ -24,6 +24,7 @@ use App\Repository\Contract\EmployeeScopedRepositoryInterface; use App\Repository\Contract\WorkHourReadRepositoryInterface; use App\Repository\EmployeeWeekCommentRepository; use App\Service\Contracts\EmployeeContractResolver; +use App\Service\PublicHolidayServiceInterface; use App\Service\WorkHours\AbsenceSegmentsResolver; use App\Service\WorkHours\DailyReferenceMinutesResolver; use App\Service\WorkHours\HolidayVirtualHoursResolver; @@ -33,6 +34,7 @@ use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; +use Throwable; final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface { @@ -47,6 +49,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface private EmployeeContractResolver $contractResolver, private DailyReferenceMinutesResolver $dailyReferenceResolver, private HolidayVirtualHoursResolver $holidayVirtualHoursResolver, + private PublicHolidayServiceInterface $publicHolidayService, private EmployeeWeekCommentRepository $weekCommentRepository, ) {} @@ -128,6 +131,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface $contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days); $isDriverByEmployeeDate = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days); $workDaysByEmployeeDate = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days); + $holidayLabelsByDate = $this->buildHolidayLabelsForDays($days); $metricsByEmployeeDate = []; foreach ($workHours as $workHour) { $employeeId = $workHour->getEmployee()?->getId(); @@ -330,6 +334,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface hasDinner: $hasDinner, hasOvernight: $hasOvernight, virtualHolidayMinutes: $virtualHolidayMinutes, + holidayLabel: $holidayLabelsByDate[$date] ?? null, ); } @@ -384,6 +389,38 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface return $rows; } + /** + * @param list $days + * + * @return array + */ + private function buildHolidayLabelsForDays(array $days): array + { + if ([] === $days) { + return []; + } + + $years = []; + foreach ($days as $day) { + $years[substr($day, 0, 4)] = true; + } + + $map = []; + + try { + foreach (array_keys($years) as $year) { + $holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year); + foreach ($holidays as $date => $label) { + $map[(string) $date] = (string) $label; + } + } + } catch (Throwable) { + return []; + } + + return $map; + } + private function computeMetrics(WorkHour $workHour): WorkMetrics { $ranges = [ diff --git a/templates/employee-yearly-hours/print-all.html.twig b/templates/employee-yearly-hours/print-all.html.twig new file mode 100644 index 0000000..94fe44e --- /dev/null +++ b/templates/employee-yearly-hours/print-all.html.twig @@ -0,0 +1,283 @@ + + + + + Export heures - {% set months = { + 1:'Janvier', 2:'Février', 3:'Mars', 4:'Avril', 5:'Mai', 6:'Juin', + 7:'Juillet', 8:'Août', 9:'Septembre', 10:'Octobre', 11:'Novembre', 12:'Décembre' + } %}{% if month %}{{ months[month] }} {{ year }}{% else %}{{ year }}{% endif %} + + + + + +{% set months = { + 1:'Janvier', 2:'Février', 3:'Mars', 4:'Avril', 5:'Mai', 6:'Juin', + 7:'Juillet', 8:'Août', 9:'Septembre', 10:'Octobre', 11:'Novembre', 12:'Décembre' +} %} + +{% for entry in entries %} +
+
+

+ {{ entry.employeeName }}{% if entry.contractLabel %} - {{ entry.contractLabel }}{% endif %}
+ {% if month %}{{ months[month] }} {{ year }}{% else %}{{ year }}{% endif %} +

+
Exporté le {{ "now"|date('d/m/Y') }} à {{ "now"|date('H:i:s') }}
+
+ + {% for segment in entry.segments %} + {% if entry.segments|length > 1 %} +

{{ segment.contractName ?? 'Contrat inconnu' }}{% if segment.mode == 'driver' %} (Chauffeur){% endif %}

+ {% endif %} + + {% if segment.mode == 'presence' %} + + + + + + + + + + + + {% for row in segment.rows %} + + + + + + + + {% endfor %} + +
DateAbsencePrésence matinPrésence après-midiTotal
{{ row.date }} + {{ row.absenceLabel ?? '' }} + {% if row.holidayLabel %}Férié : {{ row.holidayLabel }}{% endif %} + {{ row.presentMorning ? 'X' : '' }}{{ row.presentAfternoon ? 'X' : '' }}{{ row.total }}
+ {% elseif segment.mode == 'driver' %} + + + + + + + + + + + + + {% for row in segment.rows %} + + + + + + + + + {% endfor %} + +
DateAbsenceHeures jourHeures nuitHeures atelierTotal
{{ row.date }} + {{ row.absenceLabel ?? '' }} + {% if row.holidayLabel %}Férié : {{ row.holidayLabel }}{% endif %} + {{ row.dayHours }}{{ row.nightHours }}{{ row.workshopHours }}{{ row.total }}
+ {% else %} + + + + + + + + + + + + + + + + {% for row in segment.rows %} + + + + + + + + + + + + {% endfor %} + +
DateAbsenceDébut matinFin matinDébut après-midiFin après-midiDébut soirFin soirTotal
{{ row.date }} + {{ row.absenceLabel ?? '' }} + {% if row.holidayLabel %}Férié : {{ row.holidayLabel }}{% endif %} + {{ row.morningFrom }}{{ row.morningTo }}{{ row.afternoonFrom }}{{ row.afternoonTo }}{{ row.eveningFrom }}{{ row.eveningTo }}{{ row.total }}
+ {% endif %} + {% endfor %} + + +
+{% endfor %} + + + diff --git a/templates/employee-yearly-hours/print.html.twig b/templates/employee-yearly-hours/print.html.twig index 4843491..6c7d10d 100644 --- a/templates/employee-yearly-hours/print.html.twig +++ b/templates/employee-yearly-hours/print.html.twig @@ -65,11 +65,14 @@ td { font-size: 9px; } td.date { text-align: left; font-weight: bold; } td.absence { text-align: left; color: #c00; } + td.absence .holiday { color: #0277bd; font-weight: 600; } + td.absence .holiday.with-absence { display: block; } td.time { text-align: center; } td.presence { text-align: center; } td.total { text-align: center; font-weight: bold; } tr.weekend td { background: #f3f3f3; color: #555; } tr.weekend td.date { color: #333; } + tr.holiday td { background: #e1f5fe; } .signature-footer { page-break-inside: avoid; @@ -151,9 +154,12 @@ {% for row in segment.rows %} - + {{ row.date }} - {{ row.absenceLabel ?? '' }} + + {{ row.absenceLabel ?? '' }} + {% if row.holidayLabel %}Férié : {{ row.holidayLabel }}{% endif %} + {{ row.presentMorning ? 'X' : '' }} {{ row.presentAfternoon ? 'X' : '' }} {{ row.total }} @@ -175,9 +181,12 @@ {% for row in segment.rows %} - + {{ row.date }} - {{ row.absenceLabel ?? '' }} + + {{ row.absenceLabel ?? '' }} + {% if row.holidayLabel %}Férié : {{ row.holidayLabel }}{% endif %} + {{ row.dayHours }} {{ row.nightHours }} {{ row.workshopHours }} @@ -203,9 +212,12 @@ {% for row in segment.rows %} - + {{ row.date }} - {{ row.absenceLabel ?? '' }} + + {{ row.absenceLabel ?? '' }} + {% if row.holidayLabel %}Férié : {{ row.holidayLabel }}{% endif %} + {{ row.morningFrom }} {{ row.morningTo }} {{ row.afternoonFrom }} diff --git a/tests/State/WorkHourDayContextProviderTest.php b/tests/State/WorkHourDayContextProviderTest.php index 6227b7c..37525b6 100644 --- a/tests/State/WorkHourDayContextProviderTest.php +++ b/tests/State/WorkHourDayContextProviderTest.php @@ -10,6 +10,7 @@ use App\Entity\AbsenceType; use App\Entity\Contract; use App\Entity\Employee; use App\Entity\User; +use App\Enum\ContractNature; use App\Enum\HalfDay; use App\Repository\Contract\AbsenceReadRepositoryInterface; use App\Repository\Contract\EmployeeScopedRepositoryInterface; @@ -176,6 +177,10 @@ final class WorkHourDayContextProviderTest extends TestCase ->method('resolveForEmployeeAndDate') ->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract()) ; + $resolver + ->method('resolveNatureForEmployeeAndDate') + ->willReturn(ContractNature::CDI) + ; return $resolver; } diff --git a/tests/State/WorkHourWeeklySummaryProviderTest.php b/tests/State/WorkHourWeeklySummaryProviderTest.php index 9b8a0de..63ba1de 100644 --- a/tests/State/WorkHourWeeklySummaryProviderTest.php +++ b/tests/State/WorkHourWeeklySummaryProviderTest.php @@ -67,6 +67,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase $this->buildResolverStub(), new DailyReferenceMinutesResolver(), $this->buildHolidayResolver(), + $this->buildHolidayService(), $this->buildWeekCommentRepoStub(), ); @@ -130,6 +131,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase $this->buildWeeklyResolverStub($employees), new DailyReferenceMinutesResolver(), $this->buildHolidayResolver(), + $this->buildHolidayService(), $this->buildWeekCommentRepoStub(), ); @@ -190,15 +192,20 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase } private function buildHolidayResolver(array $holidayMap = []): HolidayVirtualHoursResolver + { + return new HolidayVirtualHoursResolver( + new DailyReferenceMinutesResolver(), + $this->buildHolidayService($holidayMap), + $this->createStub(EmployeeContractResolver::class), + ); + } + + private function buildHolidayService(array $holidayMap = []): PublicHolidayServiceInterface { $service = $this->createStub(PublicHolidayServiceInterface::class); $service->method('getHolidaysDayByYears')->willReturn($holidayMap); - return new HolidayVirtualHoursResolver( - new DailyReferenceMinutesResolver(), - $service, - $this->createStub(EmployeeContractResolver::class), - ); + return $service; } private function buildResolverStub(): EmployeeContractResolver