Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
640bb42d3a | ||
| 50712ccb00 | |||
| 265b19a9d0 | |||
|
|
13743738fd | ||
| 085fe0c150 | |||
|
|
a1110069b5 | ||
| 4901c58ebf | |||
| 4de891579c | |||
|
|
a17d6a67cf | ||
| 29db3b5025 | |||
|
|
6df9110187 | ||
| f0dfb30566 | |||
| 049e64288e | |||
|
|
9577a70ea3 | ||
| e85f7b6f4c |
@@ -43,8 +43,10 @@
|
|||||||
## Overtime Rules
|
## Overtime Rules
|
||||||
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
|
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
|
||||||
- Contracts >= 39h: +25% from 39h to 43h, +50% beyond
|
- Contracts >= 39h: +25% from 39h to 43h, +50% beyond
|
||||||
|
- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery), deficit doesn't impact balance
|
||||||
- INTERIM: no overtime bonuses, no recovery time
|
- INTERIM: no overtime bonuses, no recovery time
|
||||||
- Driver contracts: no overtime calculation
|
- 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.
|
||||||
|
|
||||||
## Frais (MileageAllowance)
|
## Frais (MileageAllowance)
|
||||||
- Onglet "Frais" (anciennement "Frais Kms") sur la fiche employé
|
- Onglet "Frais" (anciennement "Frais Kms") sur la fiche employé
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.56'
|
app.version: '0.1.62'
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ Documents complementaires:
|
|||||||
- Semaine en déficit (heures travaillées < heures contrat):
|
- Semaine en déficit (heures travaillées < heures contrat):
|
||||||
- le déficit est déduit du cumul RTT : d'abord des heures à 50%, puis des heures à 25%
|
- le déficit est déduit du cumul RTT : d'abord des heures à 50%, puis des heures à 25%
|
||||||
- si aucun solde 50% ni 25%, les heures à 25% deviennent négatives
|
- si aucun solde 50% ni 25%, les heures à 25% deviennent négatives
|
||||||
|
- Contrats CUSTOM (heures hebdo ≠ 35h et ≠ 39h, hors INTERIM/FORFAIT):
|
||||||
|
- référence heures sup = heures contractuelles réelles (ex: 4h → référence 4h)
|
||||||
|
- pas de bonus 25% ni 50% : 1 heure sup = 1 heure de récupération
|
||||||
|
- le déficit (travail < contrat) ne génère pas de récup mais n'impacte pas le solde
|
||||||
- Nature `INTERIM`:
|
- Nature `INTERIM`:
|
||||||
- pas de bonus 25%
|
- pas de bonus 25%
|
||||||
- pas de bonus 50%
|
- pas de bonus 50%
|
||||||
@@ -150,7 +154,7 @@ Documents complementaires:
|
|||||||
- jour/nuit/atelier par jour + indicateurs repas/dîner/nuitée
|
- jour/nuit/atelier par jour + indicateurs repas/dîner/nuitée
|
||||||
- panier de nuit (PN): affiché par jour si (nightMinutes > dayMinutes) OU (nightMinutes >= 240, soit au moins 4h de travail entre 21h et 6h), et total hebdo dans la colonne Jour/Nuit sem.
|
- panier de nuit (PN): affiché par jour si (nightMinutes > dayMinutes) OU (nightMinutes >= 240, soit au moins 4h de travail entre 21h et 6h), et total hebdo dans la colonne Jour/Nuit sem.
|
||||||
- totaux hebdo: jour, nuit, atelier, total, compteurs petit déj/déjeuner/dîner/nuitée
|
- totaux hebdo: jour, nuit, atelier, total, compteurs petit déj/déjeuner/dîner/nuitée
|
||||||
- pas de calcul d'heures supplémentaires pour les conducteurs
|
- les conducteurs utilisent `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` pour le calcul RTT (au lieu des créneaux morning/afternoon/evening)
|
||||||
- Le flag `isDriver` est sur `EmployeeContractPeriod` (un employé peut changer de statut chauffeur selon la période)
|
- Le flag `isDriver` est sur `EmployeeContractPeriod` (un employé peut changer de statut chauffeur selon la période)
|
||||||
- Exposé en API via un getter virtuel sur `Employee` (`employee:read`) qui résout depuis la période active
|
- Exposé en API via un getter virtuel sur `Employee` (`employee:read`) qui résout depuis la période active
|
||||||
|
|
||||||
@@ -227,6 +231,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
|||||||
- en cas de suspension en cours de mois, l'acquisition est proratisée en jours ouvrés (lun-ven hors fériés) travaillés / 22
|
- en cas de suspension en cours de mois, l'acquisition est proratisée en jours ouvrés (lun-ven hors fériés) travaillés / 22
|
||||||
- contrat `FORFAIT`:
|
- contrat `FORFAIT`:
|
||||||
- base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218`
|
- base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218`
|
||||||
|
- bonus weekend/férié: chaque jour travaillé un weekend ou jour férié donne 1 jour de congé supplémentaire (journée ≥ 5h = 1.0 jour, demi-journée > 0h et < 5h = 0.5 jour), sans plafond
|
||||||
- prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année
|
- prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année
|
||||||
- reste à prendre: `acquis - absences` (toutes absences, demi-journées incluses)
|
- reste à prendre: `acquis - absences` (toutes absences, demi-journées incluses)
|
||||||
- pas de samedi (`0`)
|
- pas de samedi (`0`)
|
||||||
|
|||||||
@@ -191,6 +191,57 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count weekend and public holiday worked days for forfait bonus leave (PRESENCE mode only).
|
||||||
|
* Morning + afternoon = 1.0 day, one only = 0.5 day.
|
||||||
|
*
|
||||||
|
* @param list<string> $publicHolidayDates Y-m-d formatted weekday public holiday dates
|
||||||
|
*/
|
||||||
|
public function countWeekendAndHolidayWorkedDays(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to, array $publicHolidayDates = []): float
|
||||||
|
{
|
||||||
|
$targetDates = [];
|
||||||
|
|
||||||
|
// Collect weekend dates in range
|
||||||
|
for ($cursor = $from; $cursor <= $to; $cursor = $cursor->modify('+1 day')) {
|
||||||
|
if ((int) $cursor->format('N') >= 6) {
|
||||||
|
$targetDates[] = $cursor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add weekday public holidays
|
||||||
|
foreach ($publicHolidayDates as $date) {
|
||||||
|
$targetDates[] = new DateTimeImmutable($date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] === $targetDates) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateStrings = array_map(static fn (DateTimeImmutable $d): string => $d->format('Y-m-d'), $targetDates);
|
||||||
|
|
||||||
|
/** @var list<WorkHour> $rows */
|
||||||
|
$rows = $this->createQueryBuilder('w')
|
||||||
|
->andWhere('w.employee = :employee')
|
||||||
|
->andWhere('w.workDate IN (:dates)')
|
||||||
|
->andWhere('w.isPresentMorning = true OR w.isPresentAfternoon = true')
|
||||||
|
->setParameter('employee', $employee)
|
||||||
|
->setParameter('dates', $dateStrings)
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
$total = 0.0;
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if ($row->isPresentMorning() && $row->isPresentAfternoon()) {
|
||||||
|
$total += 1.0;
|
||||||
|
} else {
|
||||||
|
$total += 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the set of Y-m-d dates where the employee has worked hours on the given dates.
|
* Return the set of Y-m-d dates where the employee has worked hours on the given dates.
|
||||||
*
|
*
|
||||||
@@ -230,26 +281,36 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
|
|||||||
|
|
||||||
public function isWeekFullyValidated(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): bool
|
public function isWeekFullyValidated(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): bool
|
||||||
{
|
{
|
||||||
// At least one validated day must exist
|
// Count weekdays (Mon-Fri) in range
|
||||||
$validatedCount = (int) $this->createQueryBuilder('w')
|
$expectedWeekdays = 0;
|
||||||
|
for ($d = $from; $d <= $to; $d = $d->modify('+1 day')) {
|
||||||
|
if ((int) $d->format('N') <= 5) {
|
||||||
|
++$expectedWeekdays;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (0 === $expectedWeekdays) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every weekday must have a work_hour row
|
||||||
|
$totalCount = (int) $this->createQueryBuilder('w')
|
||||||
->select('COUNT(w.id)')
|
->select('COUNT(w.id)')
|
||||||
->andWhere('w.employee = :employee')
|
->andWhere('w.employee = :employee')
|
||||||
->andWhere('w.workDate >= :from')
|
->andWhere('w.workDate >= :from')
|
||||||
->andWhere('w.workDate <= :to')
|
->andWhere('w.workDate <= :to')
|
||||||
->andWhere('w.isValid = :isValid')
|
|
||||||
->setParameter('employee', $employee)
|
->setParameter('employee', $employee)
|
||||||
->setParameter('from', $from)
|
->setParameter('from', $from)
|
||||||
->setParameter('to', $to)
|
->setParameter('to', $to)
|
||||||
->setParameter('isValid', true)
|
|
||||||
->getQuery()
|
->getQuery()
|
||||||
->getSingleScalarResult()
|
->getSingleScalarResult()
|
||||||
;
|
;
|
||||||
|
|
||||||
if (0 === $validatedCount) {
|
if ($totalCount < $expectedWeekdays) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No non-validated day must exist in the range
|
// All rows must be validated
|
||||||
$nonValidatedCount = (int) $this->createQueryBuilder('w')
|
$nonValidatedCount = (int) $this->createQueryBuilder('w')
|
||||||
->select('COUNT(w.id)')
|
->select('COUNT(w.id)')
|
||||||
->andWhere('w.employee = :employee')
|
->andWhere('w.employee = :employee')
|
||||||
|
|||||||
@@ -206,20 +206,36 @@ final readonly class RttRecoveryComputationService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$weekAnchorNature = $naturesByDate[$employeeId][$weekDays[0]] ?? ContractNature::CDI;
|
$weekAnchorNature = $naturesByDate[$employeeId][$weekDays[0]] ?? ContractNature::CDI;
|
||||||
$weekAnchorContract = $employeeContractsByDate[$weekDays[0]] ?? null;
|
$weekAnchorContract = $employeeContractsByDate[$weekDays[0]] ?? null;
|
||||||
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
|
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
|
||||||
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorNature);
|
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorNature);
|
||||||
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate);
|
$weekContractType = ContractType::resolve(
|
||||||
|
$weekAnchorContract?->getName(),
|
||||||
|
$weekAnchorContract?->getTrackingMode(),
|
||||||
|
$weekAnchorContract?->getWeeklyHours()
|
||||||
|
);
|
||||||
|
$isCustomContract = ContractType::CUSTOM === $weekContractType;
|
||||||
|
$overtimeReferenceMinutes = $isCustomContract
|
||||||
|
? $this->computeWeeklyCustomReferenceMinutes($weekDays, $employeeContractsByDate)
|
||||||
|
: $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate);
|
||||||
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate);
|
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate);
|
||||||
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
|
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
|
||||||
? 0
|
? 0
|
||||||
: $weeklyTotalMinutes - $overtimeReferenceMinutes;
|
: $weeklyTotalMinutes - $overtimeReferenceMinutes;
|
||||||
|
|
||||||
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes);
|
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes);
|
||||||
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base25 * 0.25);
|
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base25 * 0.25);
|
||||||
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60);
|
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60);
|
||||||
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base50 * 0.5);
|
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base50 * 0.5);
|
||||||
|
|
||||||
|
if ($isWeekPresenceTracking || $disableOvertimeBonuses) {
|
||||||
|
$totalMinutes = 0;
|
||||||
|
} elseif ($isCustomContract) {
|
||||||
|
$totalMinutes = max(0, $weeklyOvertimeTotalMinutes);
|
||||||
|
} else {
|
||||||
|
$totalMinutes = $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50;
|
||||||
|
}
|
||||||
|
|
||||||
$results[$weekKey] = new WeekRecoveryDetail(
|
$results[$weekKey] = new WeekRecoveryDetail(
|
||||||
overtimeMinutes: $weeklyOvertimeTotalMinutes,
|
overtimeMinutes: $weeklyOvertimeTotalMinutes,
|
||||||
@@ -227,9 +243,7 @@ final readonly class RttRecoveryComputationService
|
|||||||
bonus25Minutes: $bonus25,
|
bonus25Minutes: $bonus25,
|
||||||
base50Minutes: $base50,
|
base50Minutes: $base50,
|
||||||
bonus50Minutes: $bonus50,
|
bonus50Minutes: $bonus50,
|
||||||
totalMinutes: ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
totalMinutes: $totalMinutes,
|
||||||
? 0
|
|
||||||
: $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +252,20 @@ final readonly class RttRecoveryComputationService
|
|||||||
|
|
||||||
private function computeMetrics(WorkHour $workHour): WorkMetrics
|
private function computeMetrics(WorkHour $workHour): WorkMetrics
|
||||||
{
|
{
|
||||||
|
$driverDay = $workHour->getDayHoursMinutes() ?? 0;
|
||||||
|
$driverNight = $workHour->getNightHoursMinutes() ?? 0;
|
||||||
|
$driverWorkshop = $workHour->getWorkshopHoursMinutes() ?? 0;
|
||||||
|
|
||||||
|
if ($driverDay > 0 || $driverNight > 0 || $driverWorkshop > 0) {
|
||||||
|
$totalMinutes = $driverDay + $driverNight + $driverWorkshop;
|
||||||
|
|
||||||
|
return new WorkMetrics(
|
||||||
|
dayMinutes: $driverDay + $driverWorkshop,
|
||||||
|
nightMinutes: $driverNight,
|
||||||
|
totalMinutes: $totalMinutes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$ranges = [
|
$ranges = [
|
||||||
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
|
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
|
||||||
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
|
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
|
||||||
@@ -326,6 +354,23 @@ final readonly class RttRecoveryComputationService
|
|||||||
return max(0, $end - $start);
|
return max(0, $end - $start);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $days
|
||||||
|
* @param array<string, ?Contract> $contractsByDate
|
||||||
|
*/
|
||||||
|
private function computeWeeklyCustomReferenceMinutes(array $days, array $contractsByDate): int
|
||||||
|
{
|
||||||
|
$total = 0;
|
||||||
|
foreach ($days as $date) {
|
||||||
|
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
||||||
|
$contract = $contractsByDate[$date] ?? null;
|
||||||
|
$hours = $contract?->getWeeklyHours();
|
||||||
|
$total += $this->resolveDailyReferenceMinutes($hours, $isoDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<string> $days
|
* @param list<string> $days
|
||||||
* @param array<string, ?Contract> $contractsByDate
|
* @param array<string, ?Contract> $contractsByDate
|
||||||
|
|||||||
@@ -535,10 +535,21 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$type = $employee->getContract()?->getType();
|
$type = $employee->getContract()?->getType();
|
||||||
if (ContractType::FORFAIT === $type) {
|
if (ContractType::FORFAIT === $type) {
|
||||||
$businessDaysInPeriod = $this->countBusinessDays($from, $to);
|
$businessDaysInPeriod = $this->countBusinessDays($from, $to);
|
||||||
|
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
|
||||||
|
$weekdayHolidays = array_filter(
|
||||||
|
array_keys($publicHolidays),
|
||||||
|
static fn (string $date): bool => (int) new DateTimeImmutable($date)->format('N') <= 5
|
||||||
|
);
|
||||||
|
$bonusDays = $this->workHourRepository->countWeekendAndHolidayWorkedDays(
|
||||||
|
$employee,
|
||||||
|
$from,
|
||||||
|
$to,
|
||||||
|
array_values($weekdayHolidays)
|
||||||
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'ruleCode' => LeaveRuleCode::FORFAIT_218->value,
|
'ruleCode' => LeaveRuleCode::FORFAIT_218->value,
|
||||||
'acquiredDays' => (float) max(0, $businessDaysInPeriod - self::FORFAIT_TARGET_WORKED_DAYS),
|
'acquiredDays' => (float) max(0, $businessDaysInPeriod - self::FORFAIT_TARGET_WORKED_DAYS) + $bonusDays,
|
||||||
'acquiredSaturdays' => 0.0,
|
'acquiredSaturdays' => 0.0,
|
||||||
'accrualPerMonth' => 0.0,
|
'accrualPerMonth' => 0.0,
|
||||||
'saturdayAccrualPerMonth' => 0.0,
|
'saturdayAccrualPerMonth' => 0.0,
|
||||||
|
|||||||
@@ -171,7 +171,8 @@ class LeaveRecapPrintProvider implements ProviderInterface
|
|||||||
$paid = 0;
|
$paid = 0;
|
||||||
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $exerciseYear);
|
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $exerciseYear);
|
||||||
foreach ($payments as $payment) {
|
foreach ($payments as $payment) {
|
||||||
$paid += $payment->getBase25Minutes() + $payment->getBase50Minutes();
|
$paid += $payment->getBase25Minutes() + $payment->getBonus25Minutes()
|
||||||
|
+ $payment->getBase50Minutes() + $payment->getBonus50Minutes();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $carry + $current->totalMinutes - $paid;
|
return $carry + $current->totalMinutes - $paid;
|
||||||
|
|||||||
Reference in New Issue
Block a user