Files
pta/app/Models/QuotaModel.php
2026-08-20 13:22:49 +02:00

308 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Models;
use DateTimeImmutable;
use InvalidArgumentException;
/**
* Suivi du quota PTA sur l'année CIVILE.
*
* Le PTA est planifié du 1er janvier au 31 décembre de l'année concernée.
* La date de début du contrat reste une information RH utile, mais elle ne sert
* plus d'ancre pour décaler la période annuelle du quota.
*/
final class QuotaModel extends BaseModel
{
/**
* Les 1 607 h sont un volume NET : la formule légale a déjà retranché les
* week-ends, les 25 jours de congés payés et les 8 jours fériés moyens
* (365 - 104 - 25 - 8 = 228 j × 7 h = 1 596 h, arrondies à 1 600 h, plus
* les 7 h de la journée de solidarité).
*
* Conséquence : ni un jour de congé payé, ni un jour férié ne s'impute au
* quota. Les imputer reviendrait à les décompter une seconde fois. Seul le
* travail effectif alimente le compteur, via `compte_dans_quota`.
*/
public const REFERENCE_MINUTES = 1607 * 60;
/**
* Crée la ligne de quota annuel d'un agent, proratisée par sa quotité.
*
* Appelé à la création d'un agent (AgentModel::createWithDefaults()). Le
* quota cible est FIGÉ à ce moment-là : modifier ensuite la quotité de
* l'agent ne recalcule pas rétroactivement l'année en cours, ce qui
* fausserait un suivi déjà entamé.
*
* @param float $rate Quotité en pourcentage (100 = temps plein).
*/
public function createForAgent(int $agentId, int $year, float $rate): void
{
$target = (int) round(self::REFERENCE_MINUTES * ($rate / 100));
$stmt = $this->pdo->prepare(
'INSERT INTO quota_agent_annuel (
id_agent, annee, quotite_travail, quota_reference_minutes, quota_cible_minutes, commentaire
) VALUES (
:agent_id, :annee, :quotite, :quota_reference, :quota_cible, :commentaire
)'
);
$stmt->execute([
'agent_id' => $agentId,
'annee' => $year,
'quotite' => $rate,
'quota_reference' => self::REFERENCE_MINUTES,
'quota_cible' => $target,
'commentaire' => 'Quota PTA de référence pour l’année civile',
]);
}
/**
* Retourne le quota de l'année civile contenant la date fournie.
*
* Exemple : une date de contexte au 15/09/2026 renvoie toujours la période
* 01/01/2026 -> 31/12/2026, quelle que soit la date de début du contrat.
*
* Les minutes VALIDÉES et les minutes en BROUILLON sont comptées
* séparément mais s'additionnent toutes deux dans `minutes_affectees` :
* un brouillon consomme déjà le quota du point de vue de la planification,
* la distinction ne sert qu'à indiquer ce qui est acquis.
*
* Le filtre `m.compte_dans_quota = TRUE` est le point de bascule métier :
* CONGE, AUTRE_ABSENCE et JNT sont ainsi exclus (voir REFERENCE_MINUTES).
*
* `minutes_restantes` peut être NÉGATIF — c'est un dépassement, signalé en
* ALERTE à la validation, jamais bloqué.
*
* @return array<string, mixed> Période de référence enrichie des compteurs.
*/
public function forDate(int $agentId, string $contextDate): array
{
$period = $this->referencePeriod($agentId, $contextDate);
$quota = $this->quotaProfile($agentId, (int) $period['annee_periode']);
$usedStmt = $this->pdo->prepare(
"SELECT
COALESCE(SUM(CASE WHEN p.statut = 'VALIDE' THEN
TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))
ELSE 0 END), 0) AS minutes_valides,
COALESCE(SUM(CASE WHEN p.statut = 'BROUILLON' THEN
TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))
ELSE 0 END), 0) AS minutes_brouillon
FROM planning p
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.id_agent = :agent_id
AND c.date_jour BETWEEN :date_debut AND :date_fin
AND m.compte_dans_quota = TRUE"
);
$usedStmt->execute([
'agent_id' => $agentId,
'date_debut' => $period['date_debut_periode'],
'date_fin' => $period['date_fin_periode'],
]);
$used = $usedStmt->fetch() ?: ['minutes_valides' => 0, 'minutes_brouillon' => 0];
$validated = (int) $used['minutes_valides'];
$draft = (int) $used['minutes_brouillon'];
$target = (int) $quota['quota_cible_minutes'];
$allocated = $validated + $draft;
return array_merge($period, [
'annee' => (int) $period['annee_periode'],
'quotite_travail' => (float) $quota['quotite_travail'],
'quota_reference_minutes' => (int) $quota['quota_reference_minutes'],
'quota_cible_minutes' => $target,
'quota_source_annee' => (int) $quota['quota_source_annee'],
'minutes_valides' => $validated,
'minutes_brouillon' => $draft,
'minutes_affectees' => $allocated,
'minutes_restantes' => $target - $allocated,
'taux_consommation' => $target > 0 ? round(($allocated / $target) * 100, 2) : 0,
]);
}
/**
* Idem forDate(), avec les libellés lisibles ajoutés par enrich().
* C'est cette variante que consomment tous les endpoints JSON.
*
* @return array<string, mixed>
*/
public function forDateForApi(int $agentId, string $contextDate): array
{
return $this->enrich($this->forDate($agentId, $contextDate));
}
/**
* Quota d'une année entière. Le 1er janvier suffit comme point d'entrée,
* la période étant de toute façon l'année civile complète.
*
* @return array<string, mixed>
*/
public function annual(int $agentId, int $year): array
{
return $this->forDate($agentId, sprintf('%04d-01-01', $year));
}
/**
* Idem annual(), enrichi des libellés.
*
* @return array<string, mixed>
*/
public function annualForApi(int $agentId, int $year): array
{
return $this->forDateForApi($agentId, sprintf('%04d-01-01', $year));
}
/**
* Période de référence PTA : toujours l'année civile.
* La date de contrat est retournée uniquement à titre informatif.
*
* Les clés `periode_contractuelle`, `periode_civile` et
* `date_contrat_manquante` sont des VESTIGES d'une version où la période
* pouvait être ancrée sur la date de contrat. Elles sont figées
* (false / true / false) et conservées pour ne pas casser les écrans qui les
* lisent encore. Ne pas construire de nouvelle logique dessus.
*
* @return array<string, mixed>
*
* @throws InvalidArgumentException Si la date de contexte est malformée.
*/
public function referencePeriod(int $agentId, string $contextDate): array
{
$context = DateTimeImmutable::createFromFormat('!Y-m-d', $contextDate);
if (!$context || $context->format('Y-m-d') !== $contextDate) {
throw new InvalidArgumentException('Date de référence du quota invalide.');
}
$stmt = $this->pdo->prepare(
'SELECT date_debut_contrat FROM agent WHERE id_agent = :agent_id LIMIT 1'
);
$stmt->execute(['agent_id' => $agentId]);
$contractStartValue = $stmt->fetchColumn();
$year = (int) $context->format('Y');
$start = new DateTimeImmutable(sprintf('%04d-01-01', $year));
$end = new DateTimeImmutable(sprintf('%04d-12-31', $year));
return [
'date_reference' => $context->format('Y-m-d'),
'date_debut_contrat' => $contractStartValue ? (string) $contractStartValue : null,
'date_debut_periode' => $start->format('Y-m-d'),
'date_fin_periode' => $end->format('Y-m-d'),
'annee_periode' => $year,
'periode_libelle' => sprintf('année civile %d · du %s au %s', $year, $start->format('d/m/Y'), $end->format('d/m/Y')),
'periode_contractuelle' => false,
'periode_civile' => true,
// Conservé pour compatibilité avec les anciens écrans : la date de
// contrat n'est plus requise pour calculer la période du quota.
'date_contrat_manquante' => false,
];
}
/**
* Ajoute les versions lisibles des compteurs à un quota brut.
*
* Les clés en minutes restent présentes : le JavaScript calcule sur les
* minutes et n'affiche que les libellés.
*
* @param array<string, mixed> $quota
*
* @return array<string, mixed>
*/
public function enrich(array $quota): array
{
return array_merge($quota, [
'quota_cible' => $this->formatMinutes((int) $quota['quota_cible_minutes']),
'valides' => $this->formatMinutes((int) $quota['minutes_valides']),
'brouillons' => $this->formatMinutes((int) $quota['minutes_brouillon']),
'affectees' => $this->formatMinutes((int) $quota['minutes_affectees']),
'restantes' => $this->formatMinutes((int) $quota['minutes_restantes']),
]);
}
/**
* Met en forme une durée en minutes sous ses trois représentations.
*
* Gère les valeurs NÉGATIVES (dépassement de quota) : le signe est extrait
* avant la division, sans quoi intdiv() et le modulo produiraient un libellé
* incohérent du type « -1 h -30 ».
*
* @return array{minutes: int, heures_decimales: float, libelle: string}
* Ex. : 90 → « 1 h 30 » ; -90 → « -1 h 30 ».
*/
public function formatMinutes(int $minutes): array
{
$sign = $minutes < 0 ? -1 : 1;
$absolute = abs($minutes);
$hours = intdiv($absolute, 60);
$mins = $absolute % 60;
return [
'minutes' => $minutes,
'heures_decimales' => round($minutes / 60, 2),
'libelle' => ($sign < 0 ? '-' : '') . $hours . ' h ' . str_pad((string) $mins, 2, '0', STR_PAD_LEFT),
];
}
/**
* Retrouve la ligne de quota la plus pertinente pour une année donnée.
*
* Le tri du ORDER BY applique une règle de repli en trois temps :
* 1. l'année demandée exactement ;
* 2. sinon l'année ANTÉRIEURE la plus proche (la quotité connue la plus
* récente reste la meilleure estimation) ;
* 3. sinon l'année POSTÉRIEURE la plus proche.
*
* Sans aucune ligne — agent créé avant la mise en place des quotas — on
* retombe sur un temps plein théorique plutôt que sur un quota nul, qui
* afficherait un dépassement à la première heure planifiée.
*
* `quota_source_annee` indique quelle année a réellement servi, pour que
* l'interface puisse signaler un quota emprunté à une autre année.
*
* @return array{quotite_travail: float, quota_reference_minutes: int, quota_cible_minutes: int, quota_source_annee: int}
*/
private function quotaProfile(int $agentId, int $year): array
{
$stmt = $this->pdo->prepare(
"SELECT annee, quotite_travail, quota_reference_minutes, quota_cible_minutes
FROM quota_agent_annuel
WHERE id_agent = :agent_id
ORDER BY CASE
WHEN annee = :year_exact THEN 0
WHEN annee < :year_past THEN 1
ELSE 2
END,
CASE WHEN annee <= :year_latest THEN annee END DESC,
CASE WHEN annee > :year_future THEN annee END ASC
LIMIT 1"
);
$stmt->execute([
'agent_id' => $agentId,
'year_exact' => $year,
'year_past' => $year,
'year_latest' => $year,
'year_future' => $year,
]);
$quota = $stmt->fetch();
if (!$quota) {
return [
'quotite_travail' => 100.00,
'quota_reference_minutes' => self::REFERENCE_MINUTES,
'quota_cible_minutes' => self::REFERENCE_MINUTES,
'quota_source_annee' => $year,
];
}
return [
'quotite_travail' => (float) $quota['quotite_travail'],
'quota_reference_minutes' => (int) $quota['quota_reference_minutes'],
'quota_cible_minutes' => (int) $quota['quota_cible_minutes'],
'quota_source_annee' => (int) $quota['annee'],
];
}
}