src/Services/GestionEngagementService.php line 69

Open in your IDE?
  1. <?php
  2. namespace App\Services;
  3. use App\Entity\Engagement\EngagementBudget;
  4. use App\Entity\Engagement\EngagementBudgetEngage;
  5. use App\Entity\Engagement\EngagementBudgetEngageComment;
  6. use App\Entity\Engagement\EngagementBudgetEngageDetail;
  7. use App\Entity\Engagement\EngagementBudgetPrevisionel;
  8. use App\Entity\Engagement\EngagementJalon;
  9. use App\Entity\Engagement\EngagementProjet;
  10. use App\Entity\Engagement\EngagementVersion;
  11. use App\Entity\Engagement\EngagementVersionLog;
  12. use App\Entity\Engagement\TypeDepense;
  13. use App\Entity\Engagement\TypeDepenseStatutAdministatif;
  14. use App\Entity\BonDeCommande;
  15. use App\Entity\DocUpload;
  16. use App\Entity\Etaps;
  17. use App\Entity\EtapsCategory;
  18. use App\Entity\FactureFinance;
  19. use App\Entity\Fournisseur;
  20. use App\Entity\Project;
  21. use App\Entity\Spv;
  22. use App\Repository\EngagementBudgetEngageRepository;
  23. use App\Repository\EngagementBudgetRepository;
  24. use App\Repository\EngagementProjetRepository;
  25. use App\Repository\EngagementVersionRepository;
  26. use Doctrine\ORM\EntityManagerInterface;
  27. use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
  28. use Symfony\Component\Routing\RouterInterface;
  29. use Symfony\Component\Security\Core\Security;
  30. use Twig\Environment;
  31. class GestionEngagementService
  32. {
  33.     private $em;
  34.     private $router;
  35.     private $security;
  36.     private $twig;
  37.     private $params;
  38.     /** Cache local du facturé par engagé : "bonDeCommandeId|classifErpi" => montant. */
  39.     private $factureEngageCache = [];
  40.     /** @var EngagementProjetRepository */
  41.     private $engagementProjetRepo;
  42.     /** @var EngagementVersionRepository */
  43.     private $engagementVersionRepo;
  44.     /** @var EngagementBudgetRepository */
  45.     private $engagementBudgetRepo;
  46.     /** @var EngagementBudgetEngageRepository */
  47.     private $engagementBudgetEngageRepo;
  48.     /** @var \App\Repository\ProjectRepository */
  49.     private $projectRepo;
  50.     public function __construct(
  51.         EntityManagerInterface $em,
  52.         RouterInterface $router,
  53.         Security $security,
  54.         Environment $twig,
  55.         ParameterBagInterface $params
  56.     ) {
  57.         $this->em       = $em;
  58.         $this->router   = $router;
  59.         $this->security = $security;
  60.         $this->twig     = $twig;
  61.         $this->params   = $params;
  62.         $this->engagementProjetRepo      = $em->getRepository(EngagementProjet::class);
  63.         $this->engagementVersionRepo     = $em->getRepository(EngagementVersion::class);
  64.         $this->engagementBudgetRepo      = $em->getRepository(EngagementBudget::class);
  65.         $this->engagementBudgetEngageRepo = $em->getRepository(EngagementBudgetEngage::class);
  66.         $this->projectRepo                = $em->getRepository(Project::class);
  67.     }
  68.     // ─── PAGINATION LISTE ─────────────────────────────────────────────────────
  69.     public function paginateProjets(array $params): array
  70.     {
  71.         $filters = [
  72.             'search'              => $params['search']['value'] ?? '',
  73.             'spvId'               => $params['spv'] ?? null,
  74.             'statutAdministratif' => $params['statutAdministratif'] ?? null,
  75.         ];
  76.         $start  = (int) ($params['start'] ?? 0);
  77.         $length = (int) ($params['length'] ?? 20);
  78.         $draw   = (int) ($params['draw'] ?? 1);
  79.         // La liste est désormais basée sur les projets (et non sur EngagementProjet) :
  80.         // les projets sans engagement apparaissent aussi.
  81.         $total   = $this->projectRepo->countForEngagementPagination($filters);
  82.         $projets = $this->projectRepo->findForEngagementPagination($filters, $start, $length);
  83.         $data = [];
  84.         foreach ($projets as $projet) {
  85.             $data[] = $this->buildProjetRow($projet);
  86.         }
  87.         return [
  88.             'draw'            => $draw,
  89.             'recordsTotal'    => $total,
  90.             'recordsFiltered' => $total,
  91.             'data'            => $data,
  92.         ];
  93.     }
  94.     private function buildProjetRow(Project $projet): array
  95.     {
  96.         // Données issues de la DERNIÈRE version d'engagement du projet (si elle existe)
  97.         $ep         = $this->engagementProjetRepo->findOneBy(['projet' => $projet]);
  98.         $version    = $ep ? $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId()) : null;
  99.         $hasVersion = $version !== null;
  100.         $totaux = ['totalPrevisionnel' => 0, 'totalEngage' => 0, 'totalFacture' => 0];
  101.         if ($version) {
  102.             $totaux = $this->engagementBudgetRepo->getTotauxByVersion($version->getId());
  103.         }
  104.         $previsionnel = (float) ($totaux['totalPrevisionnel'] ?? 0);
  105.         $engage       = (float) ($totaux['totalEngage'] ?? 0);
  106.         // Budget facturé : calculé par classification (factures payées du projet),
  107.         // comme dans l'arbre de la page détail.
  108.         $facture = 0;
  109.         if ($version) {
  110.             $factureMap = $this->buildFactureMapByClassification($projet);
  111.             if (!empty($factureMap)) {
  112.                 foreach ($this->engagementBudgetRepo->findByVersionWithRelations($version->getId()) as $eb) {
  113.                     $classif = $eb->getTypeDepense() ? $eb->getTypeDepense()->getClassificationDepenseErpi() : null;
  114.                     if ($classif && isset($factureMap[$classif])) {
  115.                         $facture += (float) $factureMap[$classif];
  116.                     }
  117.                 }
  118.             }
  119.         }
  120.         // Budget consommé = (Budget engagé / Budget prévisionnel) * 100
  121.         $consomme     = $previsionnel > 0 ? (int) round(($engage / $previsionnel) * 100) : 0;
  122.         // Liens basés sur le projet (l'EngagementProjet est créé à la volée si nécessaire)
  123.         $showUrl   = $this->router->generate('admin_gestion_engagement_open_by_project', ['projectId' => $projet->getId()]);
  124.         $engageUrl = $this->router->generate('admin_gestion_engagement_engage_by_project', ['projectId' => $projet->getId()]);
  125.         $budgetUrl = $this->router->generate('admin_gestion_engagement_budget_by_project', ['projectId' => $projet->getId()]);
  126.         $statut    = $this->formatStatutAdministratif($projet->getStatusAdministratif());
  127.         $spvName   = $projet->getSpv() ? $projet->getSpv()->getName() : '-';
  128.         $tpl = 'admin/gestion_engagement/default/';
  129.         return [
  130.             'inputCheckbox'       => $this->twig->render($tpl . 'row-checkbox.html.twig', ['id' => $projet->getId()]),
  131.             'name'                => $this->twig->render($tpl . 'projet-link.html.twig', ['url' => $showUrl, 'nom' => $this->formatProjetNom($projet), 'hasVersion' => $hasVersion]),
  132.             'spv'                 => $spvName,
  133.             'statutAdministratif' => $this->twig->render($tpl . 'statut-badge.html.twig', ['statut' => $statut]),
  134.             'previsionnel'        => number_format($previsionnel, 0, ',', ' '),
  135.             'engage'              => number_format($engage, 0, ',', ' '),
  136.             'facture'             => number_format($facture, 0, ',', ' '),
  137.             'consomme'            => $this->twig->render($tpl . 'progress-budget.html.twig', ['pct' => $consomme]),
  138.             'action'              => $this->twig->render($tpl . 'action-menu.html.twig', ['showUrl' => $showUrl, 'engageUrl' => $engageUrl, 'budgetUrl' => $budgetUrl]),
  139.         ];
  140.     }
  141.     // ─── DÉTAIL PROJET (ARBRE) ────────────────────────────────────────────────
  142.     public function getBudgetTree(EngagementProjet $ep, ?int $versionId): array
  143.     {
  144.         $version = $versionId
  145.             ? $this->engagementVersionRepo->findWithLogsByVersion($versionId)
  146.             : $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId());
  147.         if (!$version) {
  148.             return ['rows' => [], 'version' => null, 'jalon' => null, 'totaux' => $this->emptyTotaux(), 'seuils' => [], 'ratios' => ['ebeCapex' => null, 'equity' => null, 'dette' => null]];
  149.         }
  150.         $budgets = $this->engagementBudgetRepo->findByVersionWithRelations($version->getId());
  151.         $totaux  = $this->engagementBudgetRepo->getTotauxByVersion($version->getId());
  152.         // Budget facturé : niveau 1 + total = mapping par classification (inchangé) ;
  153.         // niveaux 2 et 3 = roll-up des sous-lignes (facturé par engagé), géré dans buildTree.
  154.         $factureMap   = $this->buildFactureMapByClassification($ep->getProjet());
  155.         $factureTotal = 0;
  156.         foreach ($budgets as $eb) {
  157.             $classif = $eb->getTypeDepense() ? $eb->getTypeDepense()->getClassificationDepenseErpi() : null;
  158.             if ($classif && isset($factureMap[$classif])) {
  159.                 $factureTotal += (float) $factureMap[$classif];
  160.             }
  161.         }
  162.         $tree = $this->buildTree($budgets, $ep->getId(), $factureMap, $version->getId());
  163.         return [
  164.             'rows'   => $tree,
  165.             'jalon'  => $version->getEngagementJalon() ? $version->getEngagementJalon()->getName() : null,
  166.             'totaux' => [
  167.                 'previsionnel' => number_format((float) ($totaux['totalPrevisionnel'] ?? 0), 0, ',', ' '),
  168.                 'engage'       => number_format((float) ($totaux['totalEngage'] ?? 0), 0, ',', ' '),
  169.                 'facture'      => number_format($factureTotal, 0, ',', ' '),
  170.             ],
  171.             'seuils'  => $this->buildSeuilsMax($version, $budgets),
  172.             // Ratios financiers issus de la fiche transfert (dernier tableau)
  173.             'ratios'  => [
  174.                 'ebeCapex' => $version->getEbeCapex(),
  175.                 'equity'   => $version->getEquity(),
  176.                 'dette'    => $version->getDette(),
  177.             ],
  178.             'version' => [
  179.                 'id'        => $version->getId(),
  180.                 'createdAt' => $version->getCreatedAt() ? $version->getCreatedAt()->format('d/m/Y') : null,
  181.             ],
  182.         ];
  183.     }
  184.     /**
  185.      * Bloc « Seuils max » : pour chaque catégorie (statut administratif), calcule le total engagé
  186.      * et le pourcentage par rapport au seuil configuré sur la version.
  187.      *
  188.      * - Catégorie d'une ligne = statut administratif lié à son TypeDepense (typeDepenseStatutAdministatif),
  189.      *   en remontant la hiérarchie si nécessaire.
  190.      * - Pourcentage = montant total engagé de la catégorie / seuil configuré × 100.
  191.      * - Montant affiché sur chaque barre = montant total engagé de la catégorie.
  192.      *
  193.      * @param EngagementBudget[] $budgets
  194.      */
  195.     private function buildSeuilsMax(EngagementVersion $version, array $budgets): array
  196.     {
  197.         // Seuils configurés par catégorie
  198.         $seuils = [
  199.             EngagementVersion::CAT_Prospection           => (float) $version->getProspectionMontant(),
  200.             EngagementVersion::CAT_Montage_Administratif => (float) $version->getMontageAdministratifMontant(),
  201.             EngagementVersion::CAT_Developpement         => (float) $version->getDeveloppementMontant(),
  202.             EngagementVersion::CAT_Construction          => (float) $version->getConstructionMontant(),
  203.         ];
  204.         // Montant total engagé cumulé par catégorie
  205.         $engages = array_fill_keys(array_keys($seuils), 0.0);
  206.         foreach ($budgets as $eb) {
  207.             $montant = 0.0;
  208.             foreach ($this->ebEngageDetails($eb) as $detail) {
  209.                 $montant += (float) ($detail->getEngageMontant() ?? 0);
  210.             }
  211.             if ($montant === 0.0) {
  212.                 continue;
  213.             }
  214.             $cat = $this->resolveStatutAdministratif($eb->getTypeDepense());
  215.             if ($cat !== null && isset($engages[$cat])) {
  216.                 $engages[$cat] += $montant;
  217.             }
  218.         }
  219.         $result = [];
  220.         foreach ($this->getStatutsAdministratifs() as $cat => $label) {
  221.             $seuil  = $seuils[$cat];
  222.             $engage = $engages[$cat];
  223.             $pct    = $seuil > 0 ? ($engage / $seuil) * 100 : 0;
  224.             $result[] = [
  225.                 'cat'         => $cat,
  226.                 'label'       => $label,
  227.                 'seuil'       => $seuil,
  228.                 'engage'      => $engage,
  229.                 'pourcentage' => $pct,
  230.                 'depasse'     => $pct > 100,
  231.             ];
  232.         }
  233.         return $result;
  234.     }
  235.     // ─── DÉPASSEMENTS BUDGÉTAIRES (alertes / notifications) ────────────────────
  236.     /**
  237.      * Type comptable racine (niveau 1) d'une ligne : DEVEX | CAPEX | OPEX (ou null),
  238.      * lu depuis le champ `type` du TypeDepense de niveau 1 (racine de la hiérarchie).
  239.      */
  240.     private function rootType(?TypeDepense $type): ?string
  241.     {
  242.         if ($type === null) {
  243.             return null;
  244.         }
  245.         $node = $type;
  246.         while ($node->getParent() !== null) {
  247.             $node = $node->getParent();
  248.         }
  249.         $t = $node->getType();
  250.         return $t !== null ? strtoupper(trim($t)) : null;
  251.     }
  252.     /**
  253.      * Prévisionnel / engagé par type comptable (DEVEX, CAPEX) et global (DEVEX + CAPEX) d'une version.
  254.      * DEVEX / CAPEX déterminés via le champ `type` de TypeDepense (racine de niveau 1).
  255.      *
  256.      * @return array{devex:array,capex:array,global:array}
  257.      *   chaque sous-tableau : {prev:float, eng:float, depasse:bool, depassement:float}
  258.      */
  259.     public function computeDevexCapexGlobal(EngagementVersion $version): array
  260.     {
  261.         $budgets = $this->engagementBudgetRepo->findByVersionWithRelations($version->getId());
  262.         $prev = ['DEVEX' => 0.0, 'CAPEX' => 0.0];
  263.         $eng  = ['DEVEX' => 0.0, 'CAPEX' => 0.0];
  264.         foreach ($budgets as $eb) {
  265.             $root = $this->rootType($eb->getTypeDepense());
  266.             if ($root !== 'DEVEX' && $root !== 'CAPEX') {
  267.                 continue;
  268.             }
  269.             // Prévisionnel : porté par les lignes de niveau « code budget » (comme buildTree)
  270.             if ($eb->getTypeDepense() && (int) $eb->getTypeDepense()->getNiveau() === TypeDepense::NIVEAU_CODE_BUDGET) {
  271.                 $ebP = $eb->getEngagementBudgetPrevisionel();
  272.                 $prev[$root] += $ebP ? (float) ($ebP->getMontant() ?? 0) : 0.0;
  273.             }
  274.             // Engagé : somme des détails d'engagé de la ligne
  275.             $m = 0.0;
  276.             foreach ($this->ebEngageDetails($eb) as $d) {
  277.                 $m += (float) ($d->getEngageMontant() ?? 0);
  278.             }
  279.             $eng[$root] += $m;
  280.         }
  281.         $mk = function (float $p, float $e): array {
  282.             return [
  283.                 'prev'        => $p,
  284.                 'eng'         => $e,
  285.                 // Dépassement actif seulement si le prévisionnel est défini (> 0)
  286.                 'depasse'     => $p > 0 && $e > $p,
  287.                 'depassement' => max(0.0, $e - $p),
  288.             ];
  289.         };
  290.         return [
  291.             'devex'  => $mk($prev['DEVEX'], $eng['DEVEX']),
  292.             'capex'  => $mk($prev['CAPEX'], $eng['CAPEX']),
  293.             'global' => $mk($prev['DEVEX'] + $prev['CAPEX'], $eng['DEVEX'] + $eng['CAPEX']),
  294.         ];
  295.     }
  296.     /**
  297.      * Seuils (catégories) d'une version avec l'engagé cumulé et le drapeau de dépassement
  298.      * (Prospection / Montage administratif / Développement / Construction) — utilisés pour le
  299.      * blocage du bouton « Ajouter un engagé » et l'autorisation de dépassement.
  300.      *
  301.      * @return array<int, array{cat:int,label:string,seuil:float,engage:float,pourcentage:float,depasse:bool}>
  302.      */
  303.     public function getSeuilsForVersion(EngagementVersion $version): array
  304.     {
  305.         $budgets = $this->engagementBudgetRepo->findByVersionWithRelations($version->getId());
  306.         return $this->buildSeuilsMax($version, $budgets);
  307.     }
  308.     /**
  309.      * Vrai si au moins une catégorie est en dépassement → bloque l'ajout d'un engagé (Action 6).
  310.      */
  311.     public function isEngageBloque(?EngagementVersion $version): bool
  312.     {
  313.         if (!$version) {
  314.             return false;
  315.         }
  316.         foreach ($this->getSeuilsForVersion($version) as $s) {
  317.             if (!empty($s['depasse'])) {
  318.                 return true;
  319.             }
  320.         }
  321.         return false;
  322.     }
  323.     /**
  324.      * Dépassement d'une SPV (N4) : par projet, prévisionnel/engagé global = DEVEX + CAPEX ;
  325.      * on somme sur tous les projets de la SPV. Renvoie le détail par projet pour l'email admin.
  326.      */
  327.     public function computeSpvDepassement(Spv $spv): array
  328.     {
  329.         $totalPrev = 0.0;
  330.         $totalEng  = 0.0;
  331.         $lignes    = [];
  332.         foreach ($this->projectRepo->findBy(['spv' => $spv]) as $projet) {
  333.             $ep = $this->engagementProjetRepo->findOneBy(['projet' => $projet]);
  334.             if (!$ep) {
  335.                 continue;
  336.             }
  337.             $version = $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId());
  338.             if (!$version) {
  339.                 continue;
  340.             }
  341.             $g = $this->computeDevexCapexGlobal($version)['global'];
  342.             $p = (float) $g['prev'];
  343.             $e = (float) $g['eng'];
  344.             $totalPrev += $p;
  345.             $totalEng  += $e;
  346.             $lignes[]   = [
  347.                 'projet'       => $projet->getName(),
  348.                 'previsionnel' => $p,
  349.                 'engage'       => $e,
  350.                 'depassement'  => max(0.0, $e - $p),
  351.             ];
  352.         }
  353.         return [
  354.             'previsionnel' => $totalPrev,
  355.             'engage'       => $totalEng,
  356.             'depasse'      => $totalPrev > 0 && $totalEng > $totalPrev,
  357.             'depassement'  => max(0.0, $totalEng - $totalPrev),
  358.             'projets'      => $lignes,
  359.         ];
  360.     }
  361.     /**
  362.      * Statut administratif (catégorie) associé à un TypeDepense, en remontant la hiérarchie
  363.      * jusqu'à trouver un statut configuré. Retourne la valeur entière (1..4) ou null.
  364.      */
  365.     private function resolveStatutAdministratif(?TypeDepense $type): ?int
  366.     {
  367.         while ($type !== null) {
  368.             foreach ($type->getTypeDepenseStatutAdministatif() as $s) {
  369.                 $val = $s->getStatutAdministratif();
  370.                 if ($val !== null && trim((string) $val) !== '') {
  371.                     return (int) $val;
  372.                 }
  373.             }
  374.             $type = $type->getParent();
  375.         }
  376.         return null;
  377.     }
  378.     /**
  379.      * Getter/setter du montant seuil de chaque catégorie sur la version.
  380.      *
  381.      * @return array<int, array{0:string,1:string}>
  382.      */
  383.     private function seuilAccessors(): array
  384.     {
  385.         return [
  386.             EngagementVersion::CAT_Prospection           => ['getProspectionMontant', 'setProspectionMontant'],
  387.             EngagementVersion::CAT_Montage_Administratif => ['getMontageAdministratifMontant', 'setMontageAdministratifMontant'],
  388.             EngagementVersion::CAT_Developpement         => ['getDeveloppementMontant', 'setDeveloppementMontant'],
  389.             EngagementVersion::CAT_Construction          => ['getConstructionMontant', 'setConstructionMontant'],
  390.         ];
  391.     }
  392.     /**
  393.      * Cibles autorisables : seuils par catégorie (clé numérique = valeur CAT_* :
  394.      * Prospection / Montage administratif / Développement / Construction).
  395.      *
  396.      * @return array<string, array{0:string,1:string,2:string}>  [getter, setter, libellé]
  397.      */
  398.     private function depassementAccessors(): array
  399.     {
  400.         $labels = $this->getStatutsAdministratifs();
  401.         $map    = [];
  402.         foreach ($this->seuilAccessors() as $cat => $acc) {
  403.             $map[(string) $cat] = [$acc[0], $acc[1], $labels[$cat] ?? ('Seuil ' . $cat)];
  404.         }
  405.         return $map;
  406.     }
  407.     /**
  408.      * Autorise un dépassement (Action 8) : remplace le seuil de la catégorie sur la version et
  409.      * journalise l'ancienne → la nouvelle valeur dans EngagementVersionLog.
  410.      *
  411.      * @param string|int $cible          '1'..'4' (catégorie de seuil)
  412.      * @param mixed      $nouveauMontant
  413.      */
  414.     public function autoriserDepassement(EngagementProjet $ep, ?int $versionId, $cible, $nouveauMontant, ?object $user): array
  415.     {
  416.         $version = $versionId
  417.             ? $this->engagementVersionRepo->find($versionId)
  418.             : $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId());
  419.         if (!$version || ($version->getEngagementProjet() && $version->getEngagementProjet()->getId() !== $ep->getId())) {
  420.             return ['success' => false, 'message' => 'Version introuvable.'];
  421.         }
  422.         $accessors = $this->depassementAccessors();
  423.         $cible     = (string) $cible;
  424.         if (!isset($accessors[$cible])) {
  425.             return ['success' => false, 'message' => 'Cible de dépassement invalide.'];
  426.         }
  427.         $newMontant = $this->toFloat($nouveauMontant);
  428.         if ($newMontant === null || $newMontant < 0) {
  429.             return ['success' => false, 'message' => 'Le montant autorisé est invalide.'];
  430.         }
  431.         [$getter, $setter, $label] = $accessors[$cible];
  432.         $oldMontant = (float) $version->{$getter}();
  433.         $version->{$setter}($newMontant);
  434.         $oldStr = number_format($oldMontant, 0, ',', ' ') . ' €';
  435.         $newStr = number_format($newMontant, 0, ',', ' ') . ' €';
  436.         $action = 'Autorisation de dépassement — ' . $label . $this->formatOldNew($oldStr, $newStr);
  437.         $this->logAction($version, $action, $user);
  438.         $this->em->flush();
  439.         return ['success' => true, 'message' => 'Le dépassement « ' . $label . ' » a été autorisé.'];
  440.     }
  441.     /**
  442.      * Détails d'engagé d'une ligne budget (un par engagé rattaché). Utilise la collection
  443.      * engagementBudgetEngageDetails ; repli sur l'ancien lien unique pour les données non migrées.
  444.      *
  445.      * @return EngagementBudgetEngageDetail[]
  446.      */
  447.     private function ebEngageDetails(EngagementBudget $eb): array
  448.     {
  449.         $details = $eb->getEngagementBudgetEngageDetails()->toArray();
  450.         if (empty($details) && $eb->getEngagementBudgetEngageDetail()) {
  451.             $details = [$eb->getEngagementBudgetEngageDetail()];
  452.         }
  453.         return $details;
  454.     }
  455.     /** Détail d'une ligne budget correspondant à un engagé donné (ou null). */
  456.     private function ebDetailForEngage(EngagementBudget $eb, int $engageId): ?EngagementBudgetEngageDetail
  457.     {
  458.         foreach ($this->ebEngageDetails($eb) as $d) {
  459.             $e = $d->getEngagementBudgetEngage();
  460.             if ($e && $e->getId() === $engageId) {
  461.                 return $d;
  462.             }
  463.         }
  464.         return null;
  465.     }
  466.     private function buildTree(array $budgets, int $epId, array $factureMap = [], ?int $versionId = null): array
  467.     {
  468.         $grouped = [];
  469.         // URL du modal détails, basée sur le type de dépense (toujours disponible, même sans engagé)
  470.         $detailsUrl = function (?int $typeId) use ($epId, $versionId) {
  471.             if (!$typeId) {
  472.                 return null;
  473.             }
  474.             $params = ['id' => $epId, 'typeId' => $typeId];
  475.             if ($versionId) {
  476.                 $params['version'] = $versionId;
  477.             }
  478.             return $this->router->generate('admin_gestion_engagement_details', $params);
  479.         };
  480.         foreach ($budgets as $eb) {
  481.             // Hiérarchie reconstruite à partir du TypeDepense de la ligne :
  482.             // chaîne racine → feuille suivant le niveau (parent).
  483.             $chain = [];
  484.             $node  = $eb->getTypeDepense();
  485.             while ($node !== null) {
  486.                 array_unshift($chain, $node);
  487.                 $node = $node->getParent();
  488.             }
  489.             $n1     = $chain[0] ?? null;             // niveau 1
  490.             $n2     = $chain[1] ?? null;             // niveau 2
  491.             $leaf   = $eb->getTypeDepense();         // TypeDepense porté par la ligne
  492.             $niveau = $leaf ? (int) $leaf->getNiveau() : 0;
  493.             // Classification ERPI de la ligne (sert au facturé par classification — niveaux n1/n2/n3).
  494.             $classifErpi = $leaf ? $leaf->getClassificationDepenseErpi() : null;
  495.             // Classification ERPI du NIVEAU 1 : base du filtre facturé par engagé (niveau 4).
  496.             $classifErpiN1 = $n1 ? $n1->getClassificationDepenseErpi() : null;
  497.             $fournisseur  = '-';
  498.             $engageId     = null;
  499.             // Budget engagé : une ligne peut porter PLUSIEURS engagés (un détail par engagé).
  500.             // On construit la liste des engagés + la somme des montants propres à la ligne.
  501.             $engagesList = [];
  502.             $engage      = 0.0;
  503.             foreach ($this->ebEngageDetails($eb) as $d) {
  504.                 $dEbe = $d->getEngagementBudgetEngage();
  505.                 $m    = (float) ($d->getEngageMontant() ?? 0);
  506.                 $engage += $m;
  507.                 // Libellé de l'engagé : n° de devis du bon de commande si présent, sinon l'id de l'engagé.
  508.                 $bdc         = $dEbe ? $dEbe->getBonDeCommande() : null;
  509.                 $numeroDevis = $bdc ? trim((string) $bdc->getNumeroDevis()) : '';
  510.                 $fournisseurBonDeCommande = '-';
  511.                 if ($bdc && $bdc->getFournisseur()) {
  512.                     $fournisseurBonDeCommande = trim((string) $bdc->getFournisseur());
  513.                 } elseif ($dEbe && $dEbe->getFournisseur()) {
  514.                     $fournisseurBonDeCommande = $dEbe->getFournisseur()->getName();
  515.                 }
  516.                 $engagesList[] = [
  517.                     'engageId'    => $dEbe ? $dEbe->getId() : null,
  518.                     'fournisseur' => $fournisseurBonDeCommande,
  519.                     'montantRaw'  => $m,
  520.                     // Facturé de l'engagé : factures payées correspondant à son bon de commande
  521.                     // (classification = celle du NIVEAU 1).
  522.                     'factureRaw'  => $dEbe ? $this->factureMontantForEngage($dEbe, $classifErpiN1) : 0.0,
  523.                     'label'       => $numeroDevis !== '' ? $numeroDevis : ($dEbe ? (string) $dEbe->getId() : '-'),
  524.                 ];
  525.             }
  526.             // rétro-compat : premier engagé (fournisseur / lien unique des niveaux agrégés)
  527.             if (!empty($engagesList)) {
  528.                 $fournisseur = $engagesList[0]['fournisseur'];
  529.                 $engageId    = $engagesList[0]['engageId'];
  530.             }
  531.             // Budget prévisionnel : porté par EngagementBudgetPrevisionel->montant
  532.             $ebP          = $eb->getEngagementBudgetPrevisionel();
  533.             $previsionnel = $ebP ? (float) ($ebP->getMontant() ?? 0) : 0;
  534.             // Budget facturé — niveau 1 : mapping par classification (ancienne version, inchangé).
  535.             $factureClassif = ($classifErpi && isset($factureMap[$classifErpi])) ? (float) $factureMap[$classifErpi] : 0;
  536.             // Budget facturé — niveaux 2 et 3 : somme des sous-lignes (facturé par engagé), en roll-up.
  537.             $factureEngage = 0.0;
  538.             foreach ($engagesList as $e) {
  539.                 $factureEngage += (float) ($e['factureRaw'] ?? 0);
  540.             }
  541.             // ── Groupe Niveau 1 ──
  542.             $n1Key   = $n1 ? $n1->getId() : 0;
  543.             if (!isset($grouped[$n1Key])) {
  544.                 $grouped[$n1Key] = [
  545.                     'typeId'       => $n1 ? $n1->getId() : null,
  546.                     'classif'      => $n1 ? $n1->getClassificationDepenseErpi() : null,
  547.                     'label'        => $n1 ? $n1->getLibelle() : 'Autre',
  548.                     'previsionnel' => 0,
  549.                     'engage'       => 0,
  550.                     'facture'      => 0,
  551.                     'fournisseur'  => '-',
  552.                     'engageId'     => null,
  553.                     'detailsUrl'   => $detailsUrl($n1 ? $n1->getId() : null),
  554.                     'children'     => [],
  555.                 ];
  556.             }
  557.             // Le prévisionnel est porté par la ligne de SON propre niveau (pas de cumul
  558.             // depuis les niveaux enfants → évite le double comptage). L'engagé est porté
  559.             // par les lignes de dépense (niveau 3) et remonte vers la catégorie / code budget.
  560.             if ($niveau === TypeDepense::NIVEAU_CODE_BUDGET) {
  561.                 $grouped[$n1Key]['previsionnel'] += $previsionnel;
  562.             }
  563.             $grouped[$n1Key]['engage']  += $engage;
  564.             $grouped[$n1Key]['facture'] += $factureClassif;
  565.             // Engagé porté directement par le code budget (niveau 1) → utilisé par l'onglet Engagé
  566.             if ($niveau === TypeDepense::NIVEAU_CODE_BUDGET && $engageId) {
  567.                 $grouped[$n1Key]['engageId']    = $engageId;
  568.                 $grouped[$n1Key]['fournisseur'] = $fournisseur;
  569.             }
  570.             // ── Groupe Niveau 2 (uniquement s'il existe) ──
  571.             if ($n2) {
  572.                 $n2Key = $n2->getId();
  573.                 if (!isset($grouped[$n1Key]['children'][$n2Key])) {
  574.                     $grouped[$n1Key]['children'][$n2Key] = [
  575.                         'typeId'       => $n2->getId(),
  576.                         'label'        => $n2->getLibelle(),
  577.                         'previsionnel' => 0,
  578.                         'engage'       => 0,
  579.                         'facture'      => 0,
  580.                         'fournisseur'  => '-',
  581.                         'engageId'     => null,
  582.                         'detailsUrl'   => $detailsUrl($n2->getId()),
  583.                         'children'     => [],
  584.                     ];
  585.                 }
  586.                 if ($niveau === TypeDepense::NIVEAU_CATEGORIE) {
  587.                     $grouped[$n1Key]['children'][$n2Key]['previsionnel'] += $previsionnel;
  588.                 }
  589.                 $grouped[$n1Key]['children'][$n2Key]['engage']  += $engage;
  590.                 $grouped[$n1Key]['children'][$n2Key]['facture'] += $factureEngage;
  591.                 // Engagé porté directement par la catégorie (niveau 2) → utilisé par l'onglet Engagé
  592.                 if ($niveau === TypeDepense::NIVEAU_CATEGORIE && $engageId) {
  593.                     $grouped[$n1Key]['children'][$n2Key]['engageId']    = $engageId;
  594.                     $grouped[$n1Key]['children'][$n2Key]['fournisseur'] = $fournisseur;
  595.                 }
  596.                 // ── Feuille : uniquement les vraies lignes de dépense (niveau 3) ──
  597.                 if ($niveau === TypeDepense::NIVEAU_LIGNE) {
  598.                     $grouped[$n1Key]['children'][$n2Key]['children'][] = [
  599.                         'id'          => $eb->getId(),
  600.                         'typeId'      => $leaf->getId(),
  601.                         'label'       => $leaf->getLibelle(),
  602.                         'fournisseur' => $fournisseur,
  603.                         'previsionnel'=> $previsionnel,
  604.                         'engage'      => $engage,
  605.                         'facture'     => $factureEngage,
  606.                         'engageId'    => $engageId,
  607.                         'engages'     => $engagesList, // un élément par engagé (montantRaw à formater)
  608.                         'detailsUrl'  => $detailsUrl($leaf->getId()),
  609.                     ];
  610.                 }
  611.             }
  612.         }
  613.         // Montant nul → « - » (dans les 4 tableaux : tous / prévisionnel / engagé / facturé)
  614.         $fmt = function (float $v): string {
  615.             $s = number_format($v, 0, ',', ' ');
  616.             return $s === '0' ? '-' : $s;
  617.         };
  618.         $result = [];
  619.         foreach ($grouped as $typeGroup) {
  620.             $cats = [];
  621.             foreach ($typeGroup['children'] as $cat) {
  622.                 foreach ($cat['children'] as &$leaf) {
  623.                     $leaf['previsionnel'] = $fmt($leaf['previsionnel']);
  624.                     $leaf['engage']       = $fmt($leaf['engage']);
  625.                     $leaf['facture']      = $fmt($leaf['facture']);
  626.                     foreach ($leaf['engages'] as &$eng) {
  627.                         $eng['montant'] = $fmt((float) ($eng['montantRaw'] ?? 0));
  628.                         $eng['facture'] = $fmt((float) ($eng['factureRaw'] ?? 0));
  629.                     }
  630.                     unset($eng);
  631.                 }
  632.                 $cats[] = [
  633.                     'typeId'       => $cat['typeId'] ?? null,
  634.                     'label'        => $cat['label'],
  635.                     'previsionnel' => $fmt($cat['previsionnel']),
  636.                     'engage'       => $fmt($cat['engage']),
  637.                     'facture'      => $fmt($cat['facture']),
  638.                     'fournisseur'  => $cat['fournisseur'] ?? '-',
  639.                     'engageId'     => $cat['engageId'] ?? null,
  640.                     'detailsUrl'   => $cat['detailsUrl'] ?? null,
  641.                     'children'     => array_values($cat['children']),
  642.                 ];
  643.             }
  644.             $result[] = [
  645.                 'typeId'       => $typeGroup['typeId'] ?? null,
  646.                 'classif'      => $typeGroup['classif'] ?? null,
  647.                 'label'        => $typeGroup['label'],
  648.                 'previsionnel' => $fmt($typeGroup['previsionnel']),
  649.                 'engage'       => $fmt($typeGroup['engage']),
  650.                 'facture'      => $fmt($typeGroup['facture']),
  651.                 'factureRaw'   => $typeGroup['facture'],
  652.                 'fournisseur'  => $typeGroup['fournisseur'] ?? '-',
  653.                 'engageId'     => $typeGroup['engageId'] ?? null,
  654.                 'detailsUrl'   => $typeGroup['detailsUrl'] ?? null,
  655.                 'children'     => $cats,
  656.             ];
  657.         }
  658.         return $result;
  659.     }
  660.     /**
  661.      * Construit la table [classification => montant facturé] pour un projet.
  662.      *
  663.      * Pour chaque FactureFinance liée au projet (via ProjectFactureFinance) :
  664.      *  - tauxTva ≠ « Autre » (≠ 0) : on ajoute FactureFinance.monatantTtc sur sa classification ;
  665.      *  - tauxTva = « Autre » (= 0) : on ajoute, pour chaque ligne TVA (FactureFinanceMontantTva),
  666.      *    son montantTtc sur la classification de la ligne.
  667.      *
  668.      * @return array<string,float>
  669.      */
  670.     /**
  671.      * Montant facturé d'un engagé : somme des factures PAYÉES correspondant à son bon de commande,
  672.      * filtrées par fournisseur + SPV + projet (intersection des collections) + classification ERPI
  673.      * de la ligne. Retourne 0 si l'engagé n'a pas de bon de commande.
  674.      */
  675.     private function factureMontantForEngage(EngagementBudgetEngage $ebe, ?string $classifErpi): float
  676.     {
  677.         $bdc = $ebe->getBonDeCommande();
  678.         if (!$bdc || !$classifErpi) {
  679.             return 0.0;
  680.         }
  681.         $cacheKey = $bdc->getId() . '|' . $classifErpi;
  682.         if (isset($this->factureEngageCache[$cacheKey])) {
  683.             return $this->factureEngageCache[$cacheKey];
  684.         }
  685.         // Projets du bon de commande + fournisseur (obligatoires pour un rapprochement)
  686.         $projetIds = [];
  687.         foreach ($bdc->getProjectBonCommande() as $pbc) {
  688.             if ($pbc->getProject()) {
  689.                 $projetIds[] = $pbc->getProject()->getId();
  690.             }
  691.         }
  692.         $fournisseur = $bdc->getFournisseur();
  693.         if (empty($projetIds) || !$fournisseur) {
  694.             return $this->factureEngageCache[$cacheKey] = 0.0;
  695.         }
  696.         $statutsPayes = [
  697.             FactureFinance::STATUS_PAYER,
  698.             FactureFinance::STATUS_PAYER_NEW,
  699.             FactureFinance::STATUS_DATE_PAIEMENT_PAYER,
  700.             FactureFinance::STATUS_DATE_PAIEMENT_PAYER_NEW,
  701.         ];
  702.         $factures = $this->em->getRepository(FactureFinance::class)->findPayeesByFournisseurSpvClassification(
  703.             $fournisseur->getId(),
  704.             $bdc->getSpv() ? $bdc->getSpv()->getId() : null,
  705.             $classifErpi,
  706.             $projetIds,
  707.             $statutsPayes
  708.         );
  709.         // Somme : classification au niveau facture ET/OU au niveau des lignes TVA.
  710.         $total = 0.0;
  711.         $seen  = [];
  712.         foreach ($factures as $facture) {
  713.             if (isset($seen[$facture->getId()])) {
  714.                 continue;
  715.             }
  716.             $seen[$facture->getId()] = true;
  717.             if ($facture->getClassification() === $classifErpi) {
  718.                 $total += (float) $this->toFloat($facture->getMonatantTtc());
  719.             }
  720.             foreach ($facture->getTvaFactureFinance() as $ligneTva) {
  721.                 if ($ligneTva->getClassification() === $classifErpi) {
  722.                     $total += (float) $this->toFloat($ligneTva->getMontantTtc());
  723.                 }
  724.             }
  725.         }
  726.         return $this->factureEngageCache[$cacheKey] = $total;
  727.     }
  728.     /**
  729.      * Factures d'un engagé (niveau 4, onglet Facturé) : mêmes critères que le montant facturé —
  730.      * bon de commande de l'engagé (fournisseur + SPV + projets) et classification du NIVEAU 1
  731.      * (déduite du typeId de niveau 1 passé en paramètre).
  732.      *
  733.      * @return array{classif: ?string, factures: array<int,array<string,mixed>>}
  734.      */
  735.     public function getFacturesByEngage(int $engageId, int $n1TypeId, EngagementProjet $ep): array
  736.     {
  737.         return $this->getFacturesByEngages([$engageId], $n1TypeId, $ep);
  738.     }
  739.     /**
  740.      * Union des factures de plusieurs engagés (niveaux 2 et 3 de l'onglet Facturé) :
  741.      * pour chaque engagé, mêmes critères que le montant facturé (bon de commande : fournisseur +
  742.      * SPV + projets) et classification du NIVEAU 1 (typeId). Dédoublonnage par facture.
  743.      *
  744.      * @param int[] $engageIds
  745.      * @return array{classif: ?string, factures: array<int,array<string,mixed>>}
  746.      */
  747.     public function getFacturesByEngages(array $engageIds, int $n1TypeId, EngagementProjet $ep): array
  748.     {
  749.         $n1Type  = $this->em->getRepository(TypeDepense::class)->find($n1TypeId);
  750.         $classif = $n1Type ? $n1Type->getClassificationDepenseErpi() : null;
  751.         if (!$classif || empty($engageIds)) {
  752.             return ['classif' => $classif, 'factures' => []];
  753.         }
  754.         $statutsPayes = [
  755.             FactureFinance::STATUS_PAYER,
  756.             FactureFinance::STATUS_PAYER_NEW,
  757.             FactureFinance::STATUS_DATE_PAIEMENT_PAYER,
  758.             FactureFinance::STATUS_DATE_PAIEMENT_PAYER_NEW,
  759.         ];
  760.         $rows = [];
  761.         $seen = [];
  762.         foreach (array_unique(array_map('intval', $engageIds)) as $engageId) {
  763.             $ebe = $this->engagementBudgetEngageRepo->find($engageId);
  764.             if (!$ebe) {
  765.                 continue;
  766.             }
  767.             $bdc = $ebe->getBonDeCommande();
  768.             if (!$bdc) {
  769.                 continue;
  770.             }
  771.             $projetIds = [];
  772.             foreach ($bdc->getProjectBonCommande() as $pbc) {
  773.                 if ($pbc->getProject()) {
  774.                     $projetIds[] = $pbc->getProject()->getId();
  775.                 }
  776.             }
  777.             $fournisseur = $bdc->getFournisseur();
  778.             if (empty($projetIds) || !$fournisseur) {
  779.                 continue;
  780.             }
  781.             $factures = $this->em->getRepository(FactureFinance::class)->findPayeesByFournisseurSpvClassification(
  782.                 $fournisseur->getId(),
  783.                 $bdc->getSpv() ? $bdc->getSpv()->getId() : null,
  784.                 $classif,
  785.                 $projetIds,
  786.                 $statutsPayes
  787.             );
  788.             foreach ($factures as $facture) {
  789.                 if (isset($seen[$facture->getId()])) {
  790.                     continue;
  791.                 }
  792.                 $seen[$facture->getId()] = true;
  793.                 $montant = 0.0;
  794.                 if ($facture->getClassification() === $classif) {
  795.                     $montant += (float) $this->toFloat($facture->getMonatantTtc());
  796.                 }
  797.                 foreach ($facture->getTvaFactureFinance() as $ligneTva) {
  798.                     if ($ligneTva->getClassification() === $classif) {
  799.                         $montant += (float) $this->toFloat($ligneTva->getMontantTtc());
  800.                     }
  801.                 }
  802.                 $rows[] = [
  803.                     'numero'      => $facture->getNumeroFacture(),
  804.                     'fournisseur' => $facture->getFournisseur() ? $facture->getFournisseur()->getName() : '-',
  805.                     'spv'         => $facture->getSpv() ? $facture->getSpv()->getName() : '-',
  806.                     'montantTtc'  => $montant,
  807.                     'statut'      => $facture->getStatusString(),
  808.                     'token'       => $facture->getToken(),
  809.                 ];
  810.             }
  811.         }
  812.         return ['classif' => $classif, 'factures' => $rows];
  813.     }
  814.     private function buildFactureMapByClassification(?Project $project): array
  815.     {
  816.         $map = [];
  817.         if (!$project) {
  818.             return $map;
  819.         }
  820.         // On ne retient que les factures payées
  821.         $statutsPayes = [
  822.             FactureFinance::STATUS_PAYER,
  823.             FactureFinance::STATUS_PAYER_NEW,
  824.             FactureFinance::STATUS_DATE_PAIEMENT_PAYER,
  825.             FactureFinance::STATUS_DATE_PAIEMENT_PAYER_NEW,
  826.         ];
  827.         $factures = $this->em->getRepository(FactureFinance::class)
  828.             ->findPayeesByProjet($project->getId(), $statutsPayes);
  829.         // Sans condition sur le tauxTva : on additionne les montants correspondant à la
  830.         // classification, à la fois au niveau facture ET au niveau des lignes TVA.
  831.         $seen = [];
  832.         foreach ($factures as $facture) {
  833.             if (isset($seen[$facture->getId()])) {
  834.                 continue;
  835.             }
  836.             $seen[$facture->getId()] = true;
  837.             // Niveau facture
  838.             $classif = $facture->getClassification();
  839.             $montant = $this->toFloat($facture->getMonatantTtc());
  840.             if ($classif && $montant !== null) {
  841.                 $map[$classif] = ($map[$classif] ?? 0) + $montant;
  842.             }
  843.             // Niveau lignes TVA
  844.             foreach ($facture->getTvaFactureFinance() as $ligneTva) {
  845.                 $c = $ligneTva->getClassification();
  846.                 $m = $this->toFloat($ligneTva->getMontantTtc());
  847.                 if ($c && $m !== null) {
  848.                     $map[$c] = ($map[$c] ?? 0) + $m;
  849.                 }
  850.             }
  851.         }
  852.         return $map;
  853.     }
  854.     // ─── MODAL DÉTAILS ────────────────────────────────────────────────────────
  855.     public function getDetailsEngage(int $engageId, int $epId): array
  856.     {
  857.         $ebe     = $this->engagementBudgetEngageRepo->findWithRelations($engageId);
  858.         $budgets = $this->engagementBudgetRepo->findByEngage($engageId);
  859.         $previsionnel        = 0;
  860.         $classificationLabel = '-';
  861.         $classifs            = [];
  862.         foreach ($budgets as $eb) {
  863.             $ebP           = $eb->getEngagementBudgetPrevisionel();
  864.             $previsionnel += $ebP ? (float) ($ebP->getMontant() ?? 0) : 0;
  865.             if ($classificationLabel === '-') {
  866.                 $cat = $eb->getCategorie() ? $eb->getCategorie() . '-' : '';
  867.                 $classificationLabel = $cat . ($eb->getClassificationBudget() ?? '-');
  868.             }
  869.             if ($eb->getTypeDepense() && $eb->getTypeDepense()->getClassificationDepenseErpi()) {
  870.                 $classifs[] = $eb->getTypeDepense()->getClassificationDepenseErpi();
  871.             }
  872.         }
  873.         // Factures liées à l'engagé (via les classifications de ses lignes)
  874.         $ep       = $this->engagementProjetRepo->find($epId);
  875.         $factures = $this->matchFacturesByClassifs($ep ? $ep->getProjet() : null, $classifs);
  876.         $fournisseurs = [];
  877.         $numeros      = [];
  878.         $factureTotal = 0;
  879.         foreach ($factures as $f) {
  880.             $factureTotal += (float) $f['montantTtc'];
  881.             if ($f['fournisseur'] && $f['fournisseur'] !== '-') {
  882.                 $fournisseurs[$f['fournisseur']] = true;
  883.             }
  884.             if ($f['numero']) {
  885.                 $numeros[] = $f['numero'];
  886.             }
  887.         }
  888.         $engage  = $ebe ? (float) ($ebe->getEngageMontant() ?? 0) : 0;
  889.         $depasse = $previsionnel > 0 && $engage > $previsionnel;
  890.         return [
  891.             'ebe'                 => $ebe,
  892.             'classificationLabel' => $classificationLabel,
  893.             'budgetEngage'        => $engage,
  894.             'budgetFacture'       => $factureTotal,
  895.             'budgetPrevisionnel'  => $previsionnel,
  896.             'depasse'             => $depasse,
  897.             'factures'            => $factures,
  898.             'factureFournisseurs' => array_keys($fournisseurs),
  899.             'factureNumeros'      => $numeros,
  900.         ];
  901.     }
  902.     /**
  903.      * Détails d'une LIGNE (type de dépense) pour le modal, indépendamment de l'existence
  904.      * d'un engagé : prévisionnel, engagé (s'il y en a un), et factures liées par classification.
  905.      */
  906.     public function getDetailsByType(int $typeId, EngagementProjet $ep, ?int $versionId): array
  907.     {
  908.         $type    = $this->em->getRepository(TypeDepense::class)->find($typeId);
  909.         $version = $versionId
  910.             ? $this->engagementVersionRepo->findWithLogsByVersion($versionId)
  911.             : $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId());
  912.         $eb = ($type && $version)
  913.             ? $this->engagementBudgetRepo->findOneBy(['engagementVersion' => $version, 'typeDepense' => $type])
  914.             : null;
  915.         $previsionnel        = 0;
  916.         $engage              = 0;
  917.         $ebe                 = null;
  918.         $classificationLabel = $type ? $type->getLibelle() : '-';
  919.         if ($eb) {
  920.             $ebP          = $eb->getEngagementBudgetPrevisionel();
  921.             $previsionnel = $ebP ? (float) ($ebP->getMontant() ?? 0) : 0;
  922.             $ebe      = $eb->getEngagementBudgetEngage();
  923.             $ebDetail = $eb->getEngagementBudgetEngageDetail();
  924.             $engage   = $ebDetail ? (float) ($ebDetail->getEngageMontant() ?? 0) : 0;
  925.             $cat                 = $eb->getCategorie() ? $eb->getCategorie() . '-' : '';
  926.             $classificationLabel = $cat . ($eb->getClassificationBudget() ?? $classificationLabel);
  927.         }
  928.         // Factures liées par la classification ERPI du type
  929.         $classif  = $type ? $type->getClassificationDepenseErpi() : null;
  930.         $factures = $this->matchFacturesByClassifs($ep->getProjet(), $classif ? [$classif] : []);
  931.         $fournisseurs = [];
  932.         $numeros      = [];
  933.         $factureTotal = 0;
  934.         foreach ($factures as $f) {
  935.             $factureTotal += (float) $f['montantTtc'];
  936.             if ($f['fournisseur'] && $f['fournisseur'] !== '-') {
  937.                 $fournisseurs[$f['fournisseur']] = true;
  938.             }
  939.             if ($f['numero']) {
  940.                 $numeros[] = $f['numero'];
  941.             }
  942.         }
  943.         $depasse = $previsionnel > 0 && $engage > $previsionnel;
  944.         return [
  945.             'ebe'                 => $ebe,
  946.             'classificationLabel' => $classificationLabel,
  947.             'budgetEngage'        => $engage,
  948.             'budgetFacture'       => $factureTotal,
  949.             'budgetPrevisionnel'  => $previsionnel,
  950.             'depasse'             => $depasse,
  951.             'factures'            => $factures,
  952.             'factureFournisseurs' => array_keys($fournisseurs),
  953.             'factureNumeros'      => $numeros,
  954.         ];
  955.     }
  956.     /**
  957.      * Factures (payées) d'un projet correspondant à une liste de classifications
  958.      * (au niveau facture OU ligne TVA). Inclut le document fileFacture pour l'affichage.
  959.      *
  960.      * @param string[] $classifs
  961.      */
  962.     private function matchFacturesByClassifs(?Project $project, array $classifs): array
  963.     {
  964.         $classifs = array_values(array_unique(array_filter($classifs)));
  965.         if (!$project || empty($classifs)) {
  966.             return [];
  967.         }
  968.         $statutsPayes = [
  969.             FactureFinance::STATUS_PAYER,
  970.             FactureFinance::STATUS_PAYER_NEW,
  971.             FactureFinance::STATUS_DATE_PAIEMENT_PAYER,
  972.             FactureFinance::STATUS_DATE_PAIEMENT_PAYER_NEW,
  973.         ];
  974.         $rows = [];
  975.         foreach ($this->em->getRepository(FactureFinance::class)->findPayeesByProjet($project->getId(), $statutsPayes) as $facture) {
  976.             $montant = 0;
  977.             $matched = false;
  978.             if (in_array($facture->getClassification(), $classifs, true)) {
  979.                 $montant += (float) $this->toFloat($facture->getMonatantTtc());
  980.                 $matched = true;
  981.             }
  982.             foreach ($facture->getTvaFactureFinance() as $ligneTva) {
  983.                 if (in_array($ligneTva->getClassification(), $classifs, true)) {
  984.                     $montant += (float) $this->toFloat($ligneTva->getMontantTtc());
  985.                     $matched = true;
  986.                 }
  987.             }
  988.             if (!$matched) {
  989.                 continue;
  990.             }
  991.             $doc = $facture->getFileFacture();
  992.             $rows[] = [
  993.                 'numero'      => $facture->getNumeroFacture(),
  994.                 'fournisseur' => $facture->getFournisseur() ? $facture->getFournisseur()->getName() : '-',
  995.                 'spv'         => $facture->getSpv() ? $facture->getSpv()->getName() : '-',
  996.                 'montantTtc'  => $montant,
  997.                 'statut'      => $facture->getStatusString(),
  998.                 'token'       => $facture->getToken(),
  999.                 'file'        => $doc ? [
  1000.                     'name'         => $doc->getNomenclature() ?: $doc->getName(),
  1001.                     'user'         => $doc->getUserCreated() ? $doc->getUserCreated()->getName() : null,
  1002.                     'date'         => $doc->getCreatedAt(),
  1003.                     'token'        => $doc->getToken(),
  1004.                 ] : null,
  1005.             ];
  1006.         }
  1007.         return $rows;
  1008.     }
  1009.     // ─── PAGE DÉTAIL D'UN ENGAGÉ ──────────────────────────────────────────────
  1010.     /**
  1011.      * Données de la page « Détails » d'un engagé : informations, lignes de classification,
  1012.      * note interne (commentaires) et historique (logs de la version).
  1013.      */
  1014.     public function getEngageDetailData(int $engageId, EngagementProjet $ep): array
  1015.     {
  1016.         $ebe     = $this->engagementBudgetEngageRepo->findWithRelations($engageId);
  1017.         $budgets = $this->engagementBudgetRepo->findByEngage($engageId);
  1018.         $lignes  = [];
  1019.         $statut  = null;
  1020.         $version = null;
  1021.         foreach ($budgets as $eb) {
  1022.             $version = $version ?: $eb->getEngagementVersion();
  1023.             $detail   = $this->ebDetailForEngage($eb, $engageId);
  1024.             $lignes[] = [
  1025.                 'label'   => $eb->getClassificationBudget(),
  1026.                 'montant' => $detail ? $this->toFloat($detail->getEngageMontant()) : null,
  1027.                 'projet'  => ($detail && $detail->getProjet()) ? $detail->getProjet()->getName() : null,
  1028.             ];
  1029.             // Statut administratif : première phase configurée sur le type de dépense
  1030.             if ($statut === null && $eb->getTypeDepense()) {
  1031.                 foreach ($eb->getTypeDepense()->getTypeDepenseStatutAdministatif() as $s) {
  1032.                     $statut = $this->statutLabel($s->getStatutAdministratif());
  1033.                     break;
  1034.                 }
  1035.             }
  1036.         }
  1037.         // Historique propre à CET engagé
  1038.         $logs = $this->em->getRepository(EngagementVersionLog::class)
  1039.             ->findByEngagementBudgetEngage($engageId);
  1040.         return [
  1041.             'engagementProjet' => $ep,
  1042.             'projet'           => $ep->getProjet(),
  1043.             'ebe'              => $ebe,
  1044.             'engageId'         => $engageId,
  1045.             'lignes'           => $lignes,
  1046.             'statut'           => $statut,
  1047.             'comments'         => $ebe ? $ebe->getComment() : [],
  1048.             'logs'             => $logs,
  1049.         ];
  1050.     }
  1051.     /** Ajoute un commentaire (note interne) à un engagé, éventuellement en réponse à un autre. */
  1052.     public function addEngageComment(int $engageId, ?string $comment, ?object $user, ?int $parentId = null): bool
  1053.     {
  1054.         $comment = trim((string) $comment);
  1055.         $ebe     = $this->engagementBudgetEngageRepo->find($engageId);
  1056.         if (!$ebe || $comment === '') {
  1057.             return false;
  1058.         }
  1059.         $c = new EngagementBudgetEngageComment();
  1060.         $c->setComment($comment);
  1061.         if ($user) {
  1062.             $c->setUser($user);
  1063.         }
  1064.         $c->setEngagementBudgetEngage($ebe);
  1065.         if ($parentId) {
  1066.             $parent = $this->em->getRepository(EngagementBudgetEngageComment::class)->find($parentId);
  1067.             // On ne rattache la réponse que si le parent appartient bien à cet engagé
  1068.             if ($parent && $parent->getEngagementBudgetEngage() === $ebe) {
  1069.                 $c->setParent($parent);
  1070.                 $parent->addChild($c);
  1071.             }
  1072.         }
  1073.         $this->em->persist($c);
  1074.         $ebe->addComment($c);
  1075.         $this->em->flush();
  1076.         return true;
  1077.     }
  1078.     /** Supprime un commentaire (et ses réponses) d'un engagé. */
  1079.     public function deleteEngageComment(int $commentId, int $engageId): bool
  1080.     {
  1081.         $c = $this->em->getRepository(EngagementBudgetEngageComment::class)->find($commentId);
  1082.         if (!$c || !$c->getEngagementBudgetEngage() || $c->getEngagementBudgetEngage()->getId() !== $engageId) {
  1083.             return false;
  1084.         }
  1085.         $this->removeCommentTree($c);
  1086.         $this->em->flush();
  1087.         return true;
  1088.     }
  1089.     private function removeCommentTree(EngagementBudgetEngageComment $c): void
  1090.     {
  1091.         foreach ($c->getChildren()->toArray() as $child) {
  1092.             $this->removeCommentTree($child);
  1093.         }
  1094.         $this->em->remove($c);
  1095.     }
  1096.     // ─── MODAL FACTURES D'UNE LIGNE ───────────────────────────────────────────
  1097.     /**
  1098.      * Factures (payées) du projet liées à un type de dépense via sa classification ERPI.
  1099.      * Retourne la liste pour le modal : numéro, fournisseur, montant TTC (part de la
  1100.      * classification), statut.
  1101.      */
  1102.     public function getFacturesByType(int $typeId, EngagementProjet $ep): array
  1103.     {
  1104.         $type    = $this->em->getRepository(TypeDepense::class)->find($typeId);
  1105.         $classif = $type ? $type->getClassificationDepenseErpi() : null;
  1106.         if (!$classif) {
  1107.             return ['classif' => $classif, 'factures' => []];
  1108.         }
  1109.         return [
  1110.             'classif'  => $classif,
  1111.             'factures' => $this->matchFacturesByClassifs($ep->getProjet(), [$classif]),
  1112.         ];
  1113.     }
  1114.     /** Libellé d'une phase (statut administratif) à partir de sa valeur. */
  1115.     private function statutLabel(?string $value): ?string
  1116.     {
  1117.         if ($value === null || trim($value) === '') {
  1118.             return null;
  1119.         }
  1120.         $map = $this->getStatutsAdministratifs();
  1121.         return $map[(int) $value] ?? $value;
  1122.     }
  1123.     // ─── AJOUT D'UN ENGAGÉ ────────────────────────────────────────────────────
  1124.     public function createEngage(EngagementProjet $ep, array $data, ?object $user): void
  1125.     {
  1126.         $version = $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId());
  1127.         if (!$version) {
  1128.             $version = new EngagementVersion();
  1129.             $version->setEngagementProjet($ep);
  1130.             $version->setIsVersion(false);
  1131.             $this->em->persist($version);
  1132.         }
  1133.         // Garantir une ligne EngagementBudget par TypeDepense (suivi) AVANT l'ajout de l'engagé
  1134.         $this->ensureBudgetLinesForAllTypes($version);
  1135.         $ebe = new EngagementBudgetEngage();
  1136.         $ebe->setEngageMontant((float) ($data['montantTotalEngage'] ?? 0));
  1137.         if (!empty($data['fournisseurId'])) {
  1138.             $fournisseur = $this->em->getRepository(Fournisseur::class)->find($data['fournisseurId']);
  1139.             if ($fournisseur) {
  1140.                 $ebe->setFournisseur($fournisseur);
  1141.             }
  1142.         }
  1143.         // Devis (DocUpload référencé par token)
  1144.         if (!empty($data['fileDevis'])) {
  1145.             $doc = $this->em->getRepository(DocUpload::class)->findOneBy(['token' => $data['fileDevis']]);
  1146.             if ($doc) {
  1147.                 $ebe->setDevis($doc);
  1148.             }
  1149.         }
  1150.         // Bon de commande (généré via la modale « Générer un bon de commande »)
  1151.         if (!empty($data['bonDeCommandeId'])) {
  1152.             $bdc = $this->em->getRepository(BonDeCommande::class)->find($data['bonDeCommandeId']);
  1153.             if ($bdc) {
  1154.                 $ebe->setBonDeCommande($bdc);
  1155.             }
  1156.         }
  1157.         $this->em->persist($ebe);
  1158.         // Détail du log : type de dépense + montant pour chaque classification engagée
  1159.         $logParts = [];
  1160.         foreach ($data['classifications'] ?? [] as $classifData) {
  1161.             // La valeur "classification" correspond à l'id d'un TypeDepense du référentiel.
  1162.             $typeDepense = !empty($classifData['classification'])
  1163.                 ? $this->em->getRepository(TypeDepense::class)->find($classifData['classification'])
  1164.                 : null;
  1165.             $classificationLibelle = $typeDepense ? $typeDepense->getLibelle() : ($classifData['classification'] ?? null);
  1166.             if ($classificationLibelle) {
  1167.                 $montantLigne = (float) ($classifData['montant'] ?? 0);
  1168.                 $logParts[]   = $classificationLibelle . ' : ' . number_format($montantLigne, 0, ',', ' ') . ' €';
  1169.             }
  1170.             $categorieLibelle       = ($typeDepense && $typeDepense->getParent())
  1171.                 ? $typeDepense->getParent()->getLibelle()
  1172.                 : ($classifData['categorie'] ?? null);
  1173.             // Réutiliser la ligne existante du TypeDepense (garantie par ensureBudgetLinesForAllTypes),
  1174.             // sinon la créer (cas d'un type hors suivi).
  1175.             $eb = $typeDepense ? $this->findBudgetLineByType($version, $typeDepense) : null;
  1176.             if (!$eb) {
  1177.                 $eb = new EngagementBudget();
  1178.                 $eb->setEngagementVersion($version);
  1179.                 $eb->setTypeDepense($typeDepense);
  1180.                 $eb->setClassificationBudget($classificationLibelle);
  1181.                 $eb->setCategorie($categorieLibelle);
  1182.                 $this->em->persist($eb);
  1183.                 $version->addEngagementBudget($eb);
  1184.             }
  1185.             $eb->setEngagementBudgetEngage($ebe);
  1186.             // Montant PROPRE de la dépense (et non la somme) : porté par le détail de chaque ligne,
  1187.             // désormais rattaché à l'engagé (EngagementBudgetEngage 1—N EngagementBudgetEngageDetail).
  1188.             $detail = new EngagementBudgetEngageDetail();
  1189.             $detail->setClassificationBudget($classificationLibelle);
  1190.             $detail->setEngageMontant((float) ($classifData['montant'] ?? 0));
  1191.             $detail->setEngagementBudgetEngage($ebe);
  1192.             if (!empty($classifData['projetId'])) {
  1193.                 $projetLie = $this->em->getRepository(Project::class)->find($classifData['projetId']);
  1194.                 if ($projetLie) {
  1195.                     $detail->setProjet($projetLie);
  1196.                 }
  1197.             }
  1198.             $this->em->persist($detail);
  1199.             $ebe->addEngagementBudgetEngageDetail($detail);
  1200.             $eb->setEngagementBudgetEngageDetail($detail);
  1201.             $eb->addEngagementBudgetEngageDetail($detail); // rattachement à la ligne (plusieurs engagés possibles)
  1202.         }
  1203.         // Note interne : enregistrée directement sur l'engagé (distincte des commentaires)
  1204.         if (isset($data['commentaire'])) {
  1205.             $note = trim((string) $data['commentaire']);
  1206.             $ebe->setNoteInterne($note !== '' ? $note : null);
  1207.         }
  1208.         $action = 'Ajout d\'un engagé';
  1209.         if (!empty($logParts)) {
  1210.             $action .= ' — ' . implode(', ', $logParts);
  1211.         }
  1212.         $this->logAction($version, $action, $user, $ebe);
  1213.         $this->em->flush();
  1214.     }
  1215.     /**
  1216.      * Crée un engagé associé à plusieurs projets (page « Ajout d'un engagé »).
  1217.      *
  1218.      * Structure attendue dans $data :
  1219.      *   - montantTotalEngage, fileDevis, commentaire (note interne)
  1220.      *   - projets[] : [projetId, timeline (EtapsCategory id), etape (Etaps id),
  1221.      *                  classifications[] => [classification (TypeDepense id), montant]]
  1222.      *
  1223.      * Un seul EngagementBudgetEngage est créé et partagé : chaque ligne de classification
  1224.      * devient un EngagementBudgetEngageDetail (projet / timeline / étape / montant) et est
  1225.      * rattachée à la version d'engagement du projet correspondant.
  1226.      *
  1227.      * @return EngagementProjet|null Projet d'engagement du premier projet (pour redirection).
  1228.      */
  1229.     public function createEngageMultiProjet(array $data, ?object $user): ?EngagementProjet
  1230.     {
  1231.         $projets = $data['projets'] ?? [];
  1232.         if (empty($projets)) {
  1233.             return null;
  1234.         }
  1235.         // Engagé partagé par tous les projets associés
  1236.         $ebe = new EngagementBudgetEngage();
  1237.         $ebe->setEngageMontant((float) ($data['montantTotalEngage'] ?? 0));
  1238.         if (!empty($data['fileDevis'])) {
  1239.             $doc = $this->em->getRepository(DocUpload::class)->findOneBy(['token' => $data['fileDevis']]);
  1240.             if ($doc) {
  1241.                 $ebe->setDevis($doc);
  1242.             }
  1243.         }
  1244.         if (!empty($data['commentaire'])) {
  1245.             $ebe->setNoteInterne(trim((string) $data['commentaire']));
  1246.         }
  1247.         // Bon de commande (généré via la modale « Générer un bon de commande »)
  1248.         if (!empty($data['bonDeCommandeId'])) {
  1249.             $bdc = $this->em->getRepository(BonDeCommande::class)->find($data['bonDeCommandeId']);
  1250.             if ($bdc) {
  1251.                 $ebe->setBonDeCommande($bdc);
  1252.             }
  1253.         }
  1254.         $this->em->persist($ebe);
  1255.         $firstEp     = null;
  1256.         $totalEngage = 0.0;
  1257.         $hasLine     = false;
  1258.         foreach ($projets as $projData) {
  1259.             if (empty($projData['projetId'])) {
  1260.                 continue;
  1261.             }
  1262.             $projet = $this->em->getRepository(Project::class)->find($projData['projetId']);
  1263.             if (!$projet) {
  1264.                 continue;
  1265.             }
  1266.             $ep      = $this->getOrCreateByProject($projet);
  1267.             $firstEp = $firstEp ?: $ep;
  1268.             $version = $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId());
  1269.             if (!$version) {
  1270.                 $version = new EngagementVersion();
  1271.                 $version->setEngagementProjet($ep);
  1272.                 $version->setIsVersion(false);
  1273.                 $this->em->persist($version);
  1274.             }
  1275.             $this->ensureBudgetLinesForAllTypes($version);
  1276.             $timeline = !empty($projData['timeline'])
  1277.                 ? $this->em->getRepository(EtapsCategory::class)->find($projData['timeline'])
  1278.                 : null;
  1279.             $etape = !empty($projData['etape'])
  1280.                 ? $this->em->getRepository(Etaps::class)->find($projData['etape'])
  1281.                 : null;
  1282.             $logParts = [];
  1283.             foreach ($projData['classifications'] ?? [] as $classifData) {
  1284.                 $typeDepense = !empty($classifData['classification'])
  1285.                     ? $this->em->getRepository(TypeDepense::class)->find($classifData['classification'])
  1286.                     : null;
  1287.                 $classificationLibelle = $typeDepense ? $typeDepense->getLibelle() : ($classifData['classification'] ?? null);
  1288.                 if (!$classificationLibelle) {
  1289.                     continue;
  1290.                 }
  1291.                 $hasLine      = true;
  1292.                 $montantLigne = (float) ($classifData['montant'] ?? 0);
  1293.                 $totalEngage += $montantLigne;
  1294.                 $logParts[]   = $classificationLibelle . ' : ' . number_format($montantLigne, 0, ',', ' ') . ' €';
  1295.                 $categorieLibelle = ($typeDepense && $typeDepense->getParent())
  1296.                     ? $typeDepense->getParent()->getLibelle()
  1297.                     : ($classifData['categorie'] ?? null);
  1298.                 $eb = $typeDepense ? $this->findBudgetLineByType($version, $typeDepense) : null;
  1299.                 if (!$eb) {
  1300.                     $eb = new EngagementBudget();
  1301.                     $eb->setEngagementVersion($version);
  1302.                     $eb->setTypeDepense($typeDepense);
  1303.                     $eb->setClassificationBudget($classificationLibelle);
  1304.                     $eb->setCategorie($categorieLibelle);
  1305.                     $this->em->persist($eb);
  1306.                     $version->addEngagementBudget($eb);
  1307.                 }
  1308.                 $eb->setEngagementBudgetEngage($ebe);
  1309.                 $detail = new EngagementBudgetEngageDetail();
  1310.                 $detail->setClassificationBudget($classificationLibelle);
  1311.                 $detail->setEngageMontant($montantLigne);
  1312.                 $detail->setEngagementBudgetEngage($ebe);
  1313.                 $detail->setProjet($projet);
  1314.                 $detail->setTimeline($timeline);
  1315.                 $detail->setEtape($etape);
  1316.                 $this->em->persist($detail);
  1317.                 $ebe->addEngagementBudgetEngageDetail($detail);
  1318.                 $eb->setEngagementBudgetEngageDetail($detail);
  1319.                 $eb->addEngagementBudgetEngageDetail($detail); // rattachement à la ligne (plusieurs engagés possibles)
  1320.             }
  1321.             $action = 'Ajout d\'un engagé — ' . $this->formatProjetNom($projet);
  1322.             if (!empty($logParts)) {
  1323.                 $action .= ' (' . implode(', ', $logParts) . ')';
  1324.             }
  1325.             $this->logAction($version, $action, $user, $ebe);
  1326.         }
  1327.         if (!$firstEp || !$hasLine) {
  1328.             return null;
  1329.         }
  1330.         // Montant total : déduit de la somme des lignes s'il n'a pas été fourni
  1331.         if (empty($data['montantTotalEngage'])) {
  1332.             $ebe->setEngageMontant($totalEngage);
  1333.         }
  1334.         $this->em->flush();
  1335.         return $firstEp;
  1336.     }
  1337.     // ─── ÉDITION D'UN ENGAGÉ ──────────────────────────────────────────────────
  1338.     /**
  1339.      * Données de pré-remplissage du formulaire d'édition d'un engagé.
  1340.      * Structure identique à l'ajout (« new ») : les lignes sont regroupées par projet
  1341.      * pour alimenter les blocs « Projets associés » (projets[][classifications][]).
  1342.      */
  1343.     public function getEngageEditData(int $engageId, EngagementProjet $ep): ?array
  1344.     {
  1345.         $ebe = $this->engagementBudgetEngageRepo->find($engageId);
  1346.         if (!$ebe) {
  1347.             return null;
  1348.         }
  1349.         $projets = [];
  1350.         $version = null;
  1351.         foreach ($this->engagementBudgetRepo->findByEngage($engageId) as $eb) {
  1352.             $version = $version ?: $eb->getEngagementVersion();
  1353.             $detail  = $this->ebDetailForEngage($eb, $engageId);
  1354.             $type    = $eb->getTypeDepense();
  1355.             // Projet du détail ; repli sur le projet de l'engagement (données sans projet sur le détail)
  1356.             $projet  = ($detail && $detail->getProjet()) ? $detail->getProjet() : $ep->getProjet();
  1357.             $pid     = $projet ? $projet->getId() : 0;
  1358.             if (!isset($projets[$pid])) {
  1359.                 $projets[$pid] = [
  1360.                     'projetId'        => $projet ? $projet->getId() : null,
  1361.                     'projetName'      => $projet ? $projet->getName() : null,
  1362.                     'timeline'        => $detail && $detail->getTimeline() ? $detail->getTimeline()->getId() : null,
  1363.                     'etape'           => $detail && $detail->getEtape() ? $detail->getEtape()->getId() : null,
  1364.                     'classifications' => [],
  1365.                 ];
  1366.             }
  1367.             $projets[$pid]['classifications'][] = [
  1368.                 'classification'      => $type ? $type->getId() : null,
  1369.                 'classificationLabel' => $type ? $type->getCheminComplet() : null,
  1370.                 'montant'             => $detail ? $detail->getEngageMontant() : null,
  1371.             ];
  1372.         }
  1373.         return ['ebe' => $ebe, 'projets' => array_values($projets), 'version' => $version];
  1374.     }
  1375.     /**
  1376.      * Ids des TypeDepense déjà engagés dans une version (pour désactiver ces classifications
  1377.      * dans le formulaire d'ajout/édition d'un engagé).
  1378.      *
  1379.      * @return int[]
  1380.      */
  1381.     public function getEngagedTypeIdsForVersion(?EngagementVersion $version, ?int $excludeEngageId = null): array
  1382.     {
  1383.         if (!$version) {
  1384.             return [];
  1385.         }
  1386.         return $this->engagementBudgetRepo->findEngagedTypeIdsByVersion($version->getId(), $excludeEngageId);
  1387.     }
  1388.     /**
  1389.      * Met à jour un engagé. Accepte le même payload multi-projet que l'ajout
  1390.      * (« new » : projets[][projetId], projets[][classifications][], bonDeCommandeId,
  1391.      * montantTotalEngage, fileDevis, commentaire). Rétro-compatible avec l'ancien
  1392.      * format plat (classifications[]). Les détails de CET engagé sont remplacés.
  1393.      */
  1394.     public function updateEngage(int $engageId, array $data, ?object $user): bool
  1395.     {
  1396.         $ebe = $this->engagementBudgetEngageRepo->find($engageId);
  1397.         if (!$ebe) {
  1398.             return false;
  1399.         }
  1400.         // État AVANT modification (pour journaliser chaque champ modifié : ancienne → nouvelle valeur)
  1401.         $oldValues = [
  1402.             'engageMontant' => $ebe->getEngageMontant(),
  1403.             'devis'         => $ebe->getDevis() ? ($ebe->getDevis()->getNomenclature() ?: $ebe->getDevis()->getName()) : null,
  1404.             'noteInterne'   => $ebe->getNoteInterne(),
  1405.         ];
  1406.         $ebe->setEngageMontant((float) ($data['montantTotalEngage'] ?? 0));
  1407.         // Fournisseur : uniquement si le champ est fourni (le formulaire « new » n'en a pas)
  1408.         if (array_key_exists('fournisseurId', $data)) {
  1409.             $fournisseur = !empty($data['fournisseurId'])
  1410.                 ? $this->em->getRepository(Fournisseur::class)->find($data['fournisseurId'])
  1411.                 : null;
  1412.             $ebe->setFournisseur($fournisseur ?: null);
  1413.         }
  1414.         // Devis (DocUpload référencé par token)
  1415.         if (!empty($data['fileDevis'])) {
  1416.             $doc = $this->em->getRepository(DocUpload::class)->findOneBy(['token' => $data['fileDevis']]);
  1417.             if ($doc) {
  1418.                 $ebe->setDevis($doc);
  1419.             }
  1420.         }
  1421.         // Bon de commande (généré via la modale « Générer un bon de commande »)
  1422.         if (array_key_exists('bonDeCommandeId', $data)) {
  1423.             $bdc = !empty($data['bonDeCommandeId'])
  1424.                 ? $this->em->getRepository(BonDeCommande::class)->find($data['bonDeCommandeId'])
  1425.                 : null;
  1426.             $ebe->setBonDeCommande($bdc ?: null);
  1427.         }
  1428.         // Détacher UNIQUEMENT les détails de CET engagé (les autres engagés de la même ligne restent)
  1429.         $primaryVersion = null;
  1430.         $oldLignes      = [];
  1431.         foreach ($this->engagementBudgetRepo->findByEngage($engageId) as $eb) {
  1432.             $primaryVersion = $primaryVersion ?: $eb->getEngagementVersion();
  1433.             foreach ($this->ebEngageDetails($eb) as $detail) {
  1434.                 $dEbe = $detail->getEngagementBudgetEngage();
  1435.                 if (!$dEbe || $dEbe->getId() !== $engageId) {
  1436.                     continue;
  1437.                 }
  1438.                 $oldLignes[$eb->getClassificationBudget()] = $this->toFloat($detail->getEngageMontant());
  1439.                 if ($eb->getEngagementBudgetEngageDetail() === $detail) {
  1440.                     $eb->setEngagementBudgetEngageDetail(null);
  1441.                 }
  1442.                 $eb->removeEngagementBudgetEngageDetail($detail);
  1443.                 $this->em->remove($detail);
  1444.             }
  1445.             if ($eb->getEngagementBudgetEngage() === $ebe) {
  1446.                 $eb->setEngagementBudgetEngage(null);
  1447.             }
  1448.         }
  1449.         // Payload : format multi-projet (« new ») ou repli sur l'ancien format plat.
  1450.         $projets = $data['projets'] ?? null;
  1451.         if ($projets === null) {
  1452.             $projets = [['projetId' => null, 'classifications' => $data['classifications'] ?? []]];
  1453.         }
  1454.         // Recréer les lignes à partir des projets / classifications soumis
  1455.         $newLignes = [];
  1456.         $version   = $primaryVersion;
  1457.         $totalSum  = 0.0;
  1458.         foreach ($projets as $projData) {
  1459.             $projet = !empty($projData['projetId'])
  1460.                 ? $this->em->getRepository(Project::class)->find($projData['projetId'])
  1461.                 : null;
  1462.             // Version du projet ciblé (sinon version primaire de l'engagé)
  1463.             if ($projet) {
  1464.                 $ep      = $this->getOrCreateByProject($projet);
  1465.                 $pVersion = $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId());
  1466.                 if (!$pVersion) {
  1467.                     $pVersion = new EngagementVersion();
  1468.                     $pVersion->setEngagementProjet($ep);
  1469.                     $pVersion->setIsVersion(false);
  1470.                     $this->em->persist($pVersion);
  1471.                 }
  1472.                 $this->ensureBudgetLinesForAllTypes($pVersion);
  1473.             } else {
  1474.                 $pVersion = $primaryVersion;
  1475.             }
  1476.             if (!$pVersion) {
  1477.                 continue;
  1478.             }
  1479.             $version = $pVersion;
  1480.             $timeline = !empty($projData['timeline'])
  1481.                 ? $this->em->getRepository(EtapsCategory::class)->find($projData['timeline'])
  1482.                 : null;
  1483.             $etape = !empty($projData['etape'])
  1484.                 ? $this->em->getRepository(Etaps::class)->find($projData['etape'])
  1485.                 : null;
  1486.             foreach ($projData['classifications'] ?? [] as $classifData) {
  1487.                 $typeDepense = !empty($classifData['classification'])
  1488.                     ? $this->em->getRepository(TypeDepense::class)->find($classifData['classification'])
  1489.                     : null;
  1490.                 $classificationLibelle = $typeDepense ? $typeDepense->getLibelle() : ($classifData['classification'] ?? null);
  1491.                 if (!$classificationLibelle) {
  1492.                     continue;
  1493.                 }
  1494.                 $montantLigne = (float) ($classifData['montant'] ?? 0);
  1495.                 $newLignes[$classificationLibelle] = $montantLigne;
  1496.                 $totalSum += $montantLigne;
  1497.                 $categorieLibelle = ($typeDepense && $typeDepense->getParent())
  1498.                     ? $typeDepense->getParent()->getLibelle()
  1499.                     : ($classifData['categorie'] ?? null);
  1500.                 $eb = $typeDepense ? $this->findBudgetLineByType($pVersion, $typeDepense) : null;
  1501.                 if (!$eb) {
  1502.                     $eb = new EngagementBudget();
  1503.                     $eb->setEngagementVersion($pVersion);
  1504.                     $eb->setTypeDepense($typeDepense);
  1505.                     $eb->setClassificationBudget($classificationLibelle);
  1506.                     $eb->setCategorie($categorieLibelle);
  1507.                     $this->em->persist($eb);
  1508.                     $pVersion->addEngagementBudget($eb);
  1509.                 }
  1510.                 $eb->setEngagementBudgetEngage($ebe);
  1511.                 $detail = new EngagementBudgetEngageDetail();
  1512.                 $detail->setClassificationBudget($classificationLibelle);
  1513.                 $detail->setEngageMontant($montantLigne);
  1514.                 $detail->setEngagementBudgetEngage($ebe);
  1515.                 if ($projet) {
  1516.                     $detail->setProjet($projet);
  1517.                 }
  1518.                 if ($timeline) {
  1519.                     $detail->setTimeline($timeline);
  1520.                 }
  1521.                 if ($etape) {
  1522.                     $detail->setEtape($etape);
  1523.                 }
  1524.                 $this->em->persist($detail);
  1525.                 $ebe->addEngagementBudgetEngageDetail($detail);
  1526.                 $eb->setEngagementBudgetEngageDetail($detail);
  1527.                 $eb->addEngagementBudgetEngageDetail($detail); // rattachement à la ligne (plusieurs engagés possibles)
  1528.             }
  1529.         }
  1530.         if (!$version) {
  1531.             return false;
  1532.         }
  1533.         // Montant total : déduit de la somme des lignes s'il n'a pas été fourni
  1534.         if (empty($data['montantTotalEngage'])) {
  1535.             $ebe->setEngageMontant($totalSum);
  1536.         }
  1537.         // Note interne : enregistrée directement sur l'engagé (distincte des commentaires)
  1538.         if (isset($data['commentaire'])) {
  1539.             $note = trim((string) $data['commentaire']);
  1540.             $ebe->setNoteInterne($note !== '' ? $note : null);
  1541.         }
  1542.         // ── Journalisation : un log par champ modifié, avec ancienne et nouvelle valeur ──
  1543.         $newValues = [
  1544.             'engageMontant' => $ebe->getEngageMontant(),
  1545.             'devis'         => $ebe->getDevis() ? ($ebe->getDevis()->getNomenclature() ?: $ebe->getDevis()->getName()) : null,
  1546.             'noteInterne'   => $ebe->getNoteInterne(),
  1547.         ];
  1548.         $changes = [];
  1549.         // Champs simples
  1550.         foreach ($newValues as $field => $newVal) {
  1551.             $oldVal = $oldValues[$field];
  1552.             if ($field === 'engageMontant') {
  1553.                 if ((float) $oldVal === (float) $newVal) {
  1554.                     continue;
  1555.                 }
  1556.                 $oldStr = $oldVal !== null ? number_format((float) $oldVal, 0, ',', ' ') . ' €' : null;
  1557.                 $newStr = $newVal !== null ? number_format((float) $newVal, 0, ',', ' ') . ' €' : null;
  1558.             } else {
  1559.                 if ((string) $oldVal === (string) $newVal) {
  1560.                     continue;
  1561.                 }
  1562.                 $oldStr = $oldVal;
  1563.                 $newStr = $newVal;
  1564.             }
  1565.             $label     = $this->getEngageFieldLabel($field) ?: $field;
  1566.             $changes[] = 'Modification : ' . $label . $this->formatOldNew($oldStr, $newStr);
  1567.         }
  1568.         // Lignes de classification (ajout / suppression / modification de montant)
  1569.         foreach (array_keys($oldLignes + $newLignes) as $label) {
  1570.             $o = $oldLignes[$label] ?? null;
  1571.             $n = $newLignes[$label] ?? null;
  1572.             if ((float) $o === (float) $n && array_key_exists($label, $oldLignes) === array_key_exists($label, $newLignes)) {
  1573.                 continue;
  1574.             }
  1575.             $oStr = $o !== null ? number_format((float) $o, 0, ',', ' ') . ' €' : null;
  1576.             $nStr = $n !== null ? number_format((float) $n, 0, ',', ' ') . ' €' : null;
  1577.             if (!array_key_exists($label, $newLignes)) {
  1578.                 $changes[] = 'Suppression classification « ' . $label . ' » ( `' . $oStr . '` )';
  1579.             } elseif (!array_key_exists($label, $oldLignes)) {
  1580.                 $changes[] = 'Ajout classification « ' . $label . ' » ( `' . $nStr . '` )';
  1581.             } else {
  1582.                 $changes[] = 'Modification classification « ' . $label . ' »' . $this->formatOldNew($oStr, $nStr);
  1583.             }
  1584.         }
  1585.         if (empty($changes)) {
  1586.             $this->logAction($version, 'Modification d\'un engagé (aucun changement)', $user, $ebe);
  1587.         } else {
  1588.             foreach ($changes as $change) {
  1589.                 $this->logAction($version, $change, $user, $ebe);
  1590.             }
  1591.         }
  1592.         $this->em->flush();
  1593.         return true;
  1594.     }
  1595.     /**
  1596.      * Construit le suffixe de log « ( de `ancien` à `nouveau` ) » (ou « ( `nouveau` ) » si pas d'ancienne valeur),
  1597.      * dans le même esprit que DetectUpdateListener::postUpdate.
  1598.      */
  1599.     private function formatOldNew($old, $new): string
  1600.     {
  1601.         $old = ($old === null || $old === '') ? null : $old;
  1602.         $new = ($new === null || $new === '') ? null : $new;
  1603.         if ($old !== null) {
  1604.             return ' ( de `' . $old . '` à `' . ($new ?? '—') . '` )';
  1605.         }
  1606.         return ' ( `' . ($new ?? '—') . '` )';
  1607.     }
  1608.     /** Libellé lisible d'un champ de l'engagé, lu depuis l'annotation @CustomAnnotation (comme les listeners). */
  1609.     private function getEngageFieldLabel(string $field): ?string
  1610.     {
  1611.         try {
  1612.             $reflector = new \ReflectionClass(EngagementBudgetEngage::class);
  1613.             if (!$reflector->hasProperty($field)) {
  1614.                 return null;
  1615.             }
  1616.             $doc = (string) $reflector->getProperty($field)->getDocComment();
  1617.             if (preg_match('/@CustomAnnotation\((.*?)\)/', $doc, $m)) {
  1618.                 return trim($m[1], '"');
  1619.             }
  1620.         } catch (\ReflectionException $e) {
  1621.             // ignore
  1622.         }
  1623.         return null;
  1624.     }
  1625.     // ─── SUPPRESSION D'UN ENGAGÉ ──────────────────────────────────────────────
  1626.     public function deleteEngage(int $engageId, ?object $user): bool
  1627.     {
  1628.         $ebe = $this->engagementBudgetEngageRepo->find($engageId);
  1629.         if (!$ebe) {
  1630.             return false;
  1631.         }
  1632.         $budgets = $this->engagementBudgetRepo->findByEngage($engageId);
  1633.         $version = null;
  1634.         foreach ($budgets as $eb) {
  1635.             $version = $eb->getEngagementVersion();
  1636.             // détacher les détails de CET engagé (la ligne subsiste si d'autres engagés y restent)
  1637.             foreach ($this->ebEngageDetails($eb) as $detail) {
  1638.                 $dEbe = $detail->getEngagementBudgetEngage();
  1639.                 if ($dEbe && $dEbe->getId() === $engageId) {
  1640.                     if ($eb->getEngagementBudgetEngageDetail() === $detail) {
  1641.                         $eb->setEngagementBudgetEngageDetail(null);
  1642.                     }
  1643.                     $eb->removeEngagementBudgetEngageDetail($detail);
  1644.                 }
  1645.             }
  1646.             if ($eb->getEngagementBudgetEngage() === $ebe) {
  1647.                 $eb->setEngagementBudgetEngage(null);
  1648.             }
  1649.         }
  1650.         if ($version) {
  1651.             $this->logAction($version, 'Suppression d\'un engagé', $user);
  1652.         }
  1653.         $this->em->remove($ebe); // les détails restants sont supprimés en cascade (FK onDelete=CASCADE)
  1654.         $this->em->flush();
  1655.         return true;
  1656.     }
  1657.     // ─── CRÉATION / RÉCUPÉRATION ──────────────────────────────────────────────
  1658.     public function getOrCreateByProject(Project $project): EngagementProjet
  1659.     {
  1660.         $ep = $this->engagementProjetRepo->findOneBy(['projet' => $project]);
  1661.         if (!$ep) {
  1662.             $ep = new EngagementProjet();
  1663.             $ep->setProjet($project);
  1664.             $this->em->persist($ep);
  1665.             $this->em->flush();
  1666.         }
  1667.         return $ep;
  1668.     }
  1669.     // ─── VERSIONS ─────────────────────────────────────────────────────────────
  1670.     public function getAllVersions(EngagementProjet $ep): array
  1671.     {
  1672.         return $this->engagementVersionRepo->findAllByEngagementProjet($ep->getId());
  1673.     }
  1674.     public function getLatestVersion(EngagementProjet $ep): ?EngagementVersion
  1675.     {
  1676.         return $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId());
  1677.     }
  1678.     /**
  1679.      * Logs d'un projet d'engagement (toutes versions confondues) + libellés de version.
  1680.      * Retourne :
  1681.      *   - 'logs'          : EngagementVersionLog[] (du plus récent au plus ancien)
  1682.      *   - 'versionLabels' : map [versionId => ['num' => int, 'jalon' => ?string]]
  1683.      */
  1684.     public function getLogsData(EngagementProjet $ep): array
  1685.     {
  1686.         $logs = $this->em->getRepository(EngagementVersionLog::class)
  1687.             ->findByEngagementProjet($ep->getId());
  1688.         // Numérotation des versions identique à l'écran (la plus ancienne = Version 1)
  1689.         $versions = $this->getAllVersions($ep); // triées DESC par createdAt
  1690.         $total    = count($versions);
  1691.         $labels   = [];
  1692.         foreach ($versions as $i => $v) {
  1693.             $labels[$v->getId()] = [
  1694.                 'num'   => $total - $i,
  1695.                 'jalon' => $v->getEngagementJalon() ? $v->getEngagementJalon()->getName() : null,
  1696.             ];
  1697.         }
  1698.         return ['logs' => $logs, 'versionLabels' => $labels];
  1699.     }
  1700.     /** @return EngagementJalon[] */
  1701.     public function getAllJalons(): array
  1702.     {
  1703.         return $this->em->getRepository(EngagementJalon::class)->findBy([], ['name' => 'ASC']);
  1704.     }
  1705.     // ─── IMPORT D'UN BUDGET (CSV "Fiche transfert") ──────────────────────────
  1706.     public function importBudget(EngagementProjet $ep, array $data, ?object $user): array
  1707.     {
  1708.         // 1. Récupération du fichier téléversé (DocUpload via token)
  1709.         $token = $data['fileBudget'] ?? null;
  1710.         if (!$token) {
  1711.             return ['success' => false, 'message' => "Aucun document budget n'a été téléversé."];
  1712.         }
  1713.         $doc = $this->em->getRepository(DocUpload::class)->findOneBy(['token' => $token]);
  1714.         if (!$doc) {
  1715.             return ['success' => false, 'message' => "Document introuvable."];
  1716.         }
  1717.         $path = rtrim($this->params->get('uploads_directory'), '/\\') . '/' . $doc->getPath();
  1718.         if (!is_file($path)) {
  1719.             return ['success' => false, 'message' => "Le fichier est introuvable sur le serveur."];
  1720.         }
  1721.         // 2. Lecture + validation du fichier "Fiche transfert"
  1722.         $rows = $this->parseCsvFile($path);
  1723.         if (count($rows) < 3) {
  1724.             return ['success' => false, 'message' => "Le fichier ne correspond pas au modèle attendu (Fiche transfert)."];
  1725.         }
  1726.         // Entête : ligne 1 (NIVEAU 1 / NIVEAU 2) et ligne 2 (CODE BUDGET / Montant / CATÉGORIE DE DÉPENSE / Montant)
  1727.         $l1 = array_map([$this, 'norm'], $rows[0]);
  1728.         $l2 = array_map([$this, 'norm'], $rows[1]);
  1729.         $headerOk = in_array('NIVEAU 1', $l1, true)
  1730.             && in_array('NIVEAU 2', $l1, true)
  1731.             && ($l2[0] ?? '') === 'CODE BUDGET'
  1732.             && ($l2[2] ?? '') === 'CATEGORIE DE DEPENSE';
  1733.         if (!$headerOk) {
  1734.             return ['success' => false, 'message' => "L'entête du fichier ne correspond pas au modèle Fiche transfert."];
  1735.         }
  1736.         // 3. Référentiel TypeDepense indexé par libellé normalisé
  1737.         $mapN1 = $this->indexTypeDepenseByLibelle(TypeDepense::NIVEAU_CODE_BUDGET);
  1738.         $mapN2 = $this->indexTypeDepenseByLibelle(TypeDepense::NIVEAU_CATEGORIE);
  1739.         // 4. Lignes de données → (TypeDepense, montant)
  1740.         $lignes = [];
  1741.         for ($i = 2; $i < count($rows); $i++) {
  1742.             $row = $rows[$i];
  1743.             $codeBudget = $this->norm($row[0] ?? '');
  1744.             if ($codeBudget !== '' && isset($mapN1[$codeBudget])) {
  1745.                 $lignes[] = ['type' => $mapN1[$codeBudget], 'montant' => $this->toFloat($row[1] ?? null)];
  1746.             }
  1747.             $categorie = $this->norm($row[2] ?? '');
  1748.             if ($categorie !== '' && isset($mapN2[$categorie])) {
  1749.                 $lignes[] = ['type' => $mapN2[$categorie], 'montant' => $this->toFloat($row[3] ?? null)];
  1750.             }
  1751.         }
  1752.         if (empty($lignes)) {
  1753.             return ['success' => false, 'message' => "Aucune ligne du fichier ne correspond au référentiel des types de dépense."];
  1754.         }
  1755.         // 4b. Ratios financiers (dernier tableau : EBE/CAPEX ; TRI Equity ; Dette)
  1756.         $ratios = $this->parseRatiosFromRows($rows);
  1757.         // 5. Version cible :
  1758.         //    - dernière version = brouillon (isVersion = false) → on la réutilise et on la fige (isVersion = true)
  1759.         //    - pas de version OU dernière déjà figée → on crée une nouvelle version et on reprend ses engagés
  1760.         $version = $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId());
  1761.         if ($version && !$version->getIsVersion()) {
  1762.             $version->setIsVersion(true);
  1763.         } else {
  1764.             $previous = $version;
  1765.             $version  = new EngagementVersion();
  1766.             $version->setEngagementProjet($ep);
  1767.             $version->setIsVersion(true);
  1768.             $this->em->persist($version);
  1769.             // Reprise des engagés (engagementBudgetEngage) de la version précédente
  1770.             if ($previous) {
  1771.                 $this->carryOverEngages($previous, $version);
  1772.             }
  1773.         }
  1774.         // 6. Jalon (champ libre) : réutilise un jalon existant du même nom, sinon le crée.
  1775.         $jalon     = null;
  1776.         $jalonName = isset($data['newJalon']) ? trim((string) $data['newJalon']) : '';
  1777.         if ($jalonName !== '') {
  1778.             $jalon = $this->em->getRepository(EngagementJalon::class)->findOneBy(['name' => $jalonName]);
  1779.             if (!$jalon) {
  1780.                 $jalon = new EngagementJalon();
  1781.                 $jalon->setName($jalonName);
  1782.                 $this->em->persist($jalon);
  1783.             }
  1784.         } elseif (!empty($data['jalonId'])) {
  1785.             $jalon = $this->em->getRepository(EngagementJalon::class)->find($data['jalonId']);
  1786.         }
  1787.         if ($jalon) {
  1788.             $version->setEngagementJalon($jalon);
  1789.         }
  1790.         // 7. Document, montants des seuils et commentaire
  1791.         $version->setFileBudget($doc);
  1792.         // Business plan (fichier optionnel téléversé dans la modale d'import)
  1793.         if (!empty($data['businessPlan'])) {
  1794.             $docBp = $this->em->getRepository(DocUpload::class)->findOneBy(['token' => $data['businessPlan']]);
  1795.             if ($docBp) {
  1796.                 $version->setFileBusinessPlan($docBp);
  1797.             }
  1798.         }
  1799.         $version->setProspectionMontant($this->toFloat($data['prospectionMontant'] ?? null));
  1800.         $version->setMontageAdministratifMontant($this->toFloat($data['montageAdministratifMontant'] ?? null));
  1801.         $version->setDeveloppementMontant($this->toFloat($data['developpementMontant'] ?? null));
  1802.         $version->setConstructionMontant($this->toFloat($data['constructionMontant'] ?? null));
  1803.         // Ratios financiers du dernier tableau de la fiche transfert
  1804.         $version->setEbeCapex($ratios['ebeCapex']);
  1805.         $version->setEquity($ratios['equity']);
  1806.         $version->setDette($ratios['dette']);
  1807.         if (isset($data['comment'])) {
  1808.             $version->setComment($data['comment']);
  1809.         }
  1810.         // 8. Garantir une ligne EngagementBudget par TypeDepense (suivi) avant l'import
  1811.         $this->ensureBudgetLinesForAllTypes($version);
  1812.         // 9. Lignes budget : upsert par TypeDepense dans la version
  1813.         foreach ($lignes as $ligne) {
  1814.             /** @var TypeDepense $type */
  1815.             $type    = $ligne['type'];
  1816.             $montant = $ligne['montant'];
  1817.             $eb = $this->findBudgetLineByType($version, $type);
  1818.             if (!$eb) {
  1819.                 $eb = new EngagementBudget();
  1820.                 $eb->setEngagementVersion($version);
  1821.                 $eb->setTypeDepense($type);
  1822.                 $eb->setClassificationBudget($type->getLibelle());
  1823.                 $eb->setCategorie($type->getParent() ? $type->getParent()->getLibelle() : null);
  1824.                 $this->em->persist($eb);
  1825.                 $version->addEngagementBudget($eb);
  1826.             }
  1827.             // Le montant prévisionnel est porté UNIQUEMENT par EngagementBudgetPrevisionel
  1828.             $prev = $eb->getEngagementBudgetPrevisionel() ?: new EngagementBudgetPrevisionel();
  1829.             $prev->setMontant($montant);
  1830.             $this->em->persist($prev);
  1831.             $eb->setEngagementBudgetPrevisionel($prev);
  1832.         }
  1833.         $this->logAction($version, "Import d'un budget", $user);
  1834.         $this->em->flush();
  1835.         return ['success' => true, 'message' => "Le budget a été importé avec succès."];
  1836.     }
  1837.     /**
  1838.      * Reprend les engagés (EngagementBudgetEngage) de la version $from vers la version $to.
  1839.      * On partage la MÊME entité EngagementBudgetEngage (pas de clone) : une nouvelle ligne
  1840.      * EngagementBudget est créée dans $to pour chaque ligne de $from qui porte un engagé,
  1841.      * en réutilisant son TypeDepense, sa classification et son engagé.
  1842.      * Le prévisionnel n'est pas recopié : il proviendra du nouveau CSV importé.
  1843.      */
  1844.     private function carryOverEngages(EngagementVersion $from, EngagementVersion $to): void
  1845.     {
  1846.         foreach ($this->engagementBudgetRepo->findByVersionWithRelations($from->getId()) as $source) {
  1847.             $details = $this->ebEngageDetails($source);
  1848.             if (empty($details)) {
  1849.                 continue;
  1850.             }
  1851.             $eb = new EngagementBudget();
  1852.             $eb->setEngagementVersion($to);
  1853.             $eb->setTypeDepense($source->getTypeDepense());
  1854.             $eb->setClassificationBudget($source->getClassificationBudget());
  1855.             $eb->setCategorie($source->getCategorie());
  1856.             // Un détail par engagé : on CLONE chaque détail dans la nouvelle version (l'engagé
  1857.             // EngagementBudgetEngage reste partagé, mais chaque version possède ses propres détails).
  1858.             foreach ($details as $srcDetail) {
  1859.                 $srcEbe = $srcDetail->getEngagementBudgetEngage();
  1860.                 if (!$srcEbe) {
  1861.                     continue;
  1862.                 }
  1863.                 $clone = new EngagementBudgetEngageDetail();
  1864.                 $clone->setClassificationBudget($srcDetail->getClassificationBudget());
  1865.                 $clone->setEngageMontant($srcDetail->getEngageMontant());
  1866.                 $clone->setEngagementBudgetEngage($srcEbe);
  1867.                 $clone->setProjet($srcDetail->getProjet());
  1868.                 $clone->setTimeline($srcDetail->getTimeline());
  1869.                 $clone->setEtape($srcDetail->getEtape());
  1870.                 $this->em->persist($clone);
  1871.                 $srcEbe->addEngagementBudgetEngageDetail($clone);
  1872.                 $eb->addEngagementBudgetEngageDetail($clone);
  1873.                 // liens uniques legacy (dernier engagé de la ligne)
  1874.                 $eb->setEngagementBudgetEngage($srcEbe);
  1875.                 $eb->setEngagementBudgetEngageDetail($clone);
  1876.             }
  1877.             $this->em->persist($eb);
  1878.             $to->addEngagementBudget($eb);
  1879.         }
  1880.     }
  1881.     // ─── CONFIGURATION DES TYPES DE DÉPENSE ───────────────────────────────────
  1882.     /**
  1883.      * Liste des phases / statuts administratifs (constantes CAT_* de EngagementVersion)
  1884.      * indexées par valeur → libellé, pour alimenter le <select>.
  1885.      */
  1886.     public function getStatutsAdministratifs(): array
  1887.     {
  1888.         return [
  1889.             EngagementVersion::CAT_Prospection            => 'Prospection',
  1890.             EngagementVersion::CAT_Montage_Administratif   => 'Montage administratif',
  1891.             EngagementVersion::CAT_Developpement           => 'Développement',
  1892.             EngagementVersion::CAT_Construction            => 'Construction',
  1893.         ];
  1894.     }
  1895.     /**
  1896.      * Enregistre la classification de dépense ERPI d'un type de dépense.
  1897.      */
  1898.     public function updateClassificationErpi(TypeDepense $type, ?string $value): void
  1899.     {
  1900.         $type->setClassificationDepenseErpi((string) $value);
  1901.         $this->em->flush();
  1902.     }
  1903.     /**
  1904.      * Synchronise les statuts administratifs d'un type de dépense : supprime les lignes
  1905.      * existantes et recrée une TypeDepenseStatutAdministatif par valeur sélectionnée.
  1906.      *
  1907.      * @param string[] $values
  1908.      */
  1909.     public function syncStatutsAdministratifs(TypeDepense $type, array $values): void
  1910.     {
  1911.         foreach ($type->getTypeDepenseStatutAdministatif()->toArray() as $existing) {
  1912.             $type->removeTypeDepenseStatutAdministatif($existing);
  1913.             $this->em->remove($existing);
  1914.         }
  1915.         $seen = [];
  1916.         foreach ($values as $val) {
  1917.             $val = trim((string) $val);
  1918.             if ($val === '' || isset($seen[$val])) {
  1919.                 continue;
  1920.             }
  1921.             $seen[$val] = true;
  1922.             $statut = new TypeDepenseStatutAdministatif();
  1923.             $statut->setStatutAdministratif($val);
  1924.             $statut->setTypeDepense($type);
  1925.             $this->em->persist($statut);
  1926.             $type->addTypeDepenseStatutAdministatif($statut);
  1927.         }
  1928.         $this->em->flush();
  1929.     }
  1930.     private function findBudgetLineByType(EngagementVersion $version, TypeDepense $type): ?EngagementBudget
  1931.     {
  1932.         foreach ($version->getEngagementBudget() as $eb) {
  1933.             if ($eb->getTypeDepense() && $eb->getTypeDepense()->getId() === $type->getId()) {
  1934.                 return $eb;
  1935.             }
  1936.         }
  1937.         return null;
  1938.     }
  1939.     /**
  1940.      * Garantit qu'il existe une ligne EngagementBudget par ligne de dépense
  1941.      * (TypeDepense niveau 3) suivie dans la version : crée les lignes manquantes.
  1942.      * Les niveaux 1 (code budget) et 2 (catégorie) ne sont que des regroupements
  1943.      * dans l'arbre, pas des lignes de dépense.
  1944.      */
  1945.     private function ensureBudgetLinesForAllTypes(EngagementVersion $version): void
  1946.     {
  1947.         $existing = [];
  1948.         foreach ($version->getEngagementBudget() as $eb) {
  1949.             if ($eb->getTypeDepense()) {
  1950.                 $existing[$eb->getTypeDepense()->getId()] = true;
  1951.             }
  1952.         }
  1953.         $types = $this->em->getRepository(TypeDepense::class)->findBy([
  1954.             'suiviEngagement' => true,
  1955.             'niveau'          => TypeDepense::NIVEAU_LIGNE,
  1956.         ]);
  1957.         foreach ($types as $type) {
  1958.             if (isset($existing[$type->getId()])) {
  1959.                 continue;
  1960.             }
  1961.             $eb = new EngagementBudget();
  1962.             $eb->setEngagementVersion($version);
  1963.             $eb->setTypeDepense($type);
  1964.             $eb->setClassificationBudget($type->getLibelle());
  1965.             $eb->setCategorie($type->getParent() ? $type->getParent()->getLibelle() : null);
  1966.             $this->em->persist($eb);
  1967.             $version->addEngagementBudget($eb);
  1968.             $existing[$type->getId()] = true;
  1969.         }
  1970.     }
  1971.     /** @return array<string,TypeDepense> libellé normalisé => TypeDepense */
  1972.     private function indexTypeDepenseByLibelle(int $niveau): array
  1973.     {
  1974.         $map = [];
  1975.         foreach ($this->em->getRepository(TypeDepense::class)->findByNiveau($niveau) as $td) {
  1976.             $map[$this->norm($td->getLibelle())] = $td;
  1977.         }
  1978.         return $map;
  1979.     }
  1980.     private function parseCsvFile(string $path): array
  1981.     {
  1982.         $rows    = [];
  1983.         $content = file_get_contents($path);
  1984.         if ($content === false) {
  1985.             return $rows;
  1986.         }
  1987.         // Suppression du BOM UTF-8 éventuel
  1988.         $content = preg_replace('/^\xEF\xBB\xBF/', '', $content);
  1989.         $handle = fopen('php://temp', 'r+');
  1990.         fwrite($handle, $content);
  1991.         rewind($handle);
  1992.         while (($cols = fgetcsv($handle, 0, ';')) !== false) {
  1993.             $rows[] = $cols;
  1994.         }
  1995.         fclose($handle);
  1996.         return $rows;
  1997.     }
  1998.     /**
  1999.      * Extrait les ratios financiers du dernier tableau de la Fiche transfert :
  2000.      * ligne d'entête « EBE/CAPEX ; TRI Equity ; Dette » suivie de la ligne de valeurs.
  2001.      *
  2002.      * @param array<int,array<int,string>> $rows
  2003.      * @return array{ebeCapex: ?float, equity: ?float, dette: ?float}
  2004.      */
  2005.     private function parseRatiosFromRows(array $rows): array
  2006.     {
  2007.         $default = ['ebeCapex' => null, 'equity' => null, 'dette' => null];
  2008.         for ($i = 0, $n = count($rows); $i < $n; $i++) {
  2009.             if ($this->norm($rows[$i][0] ?? '') === 'EBE/CAPEX') {
  2010.                 $valRow = $rows[$i + 1] ?? [];
  2011.                 return [
  2012.                     'ebeCapex' => $this->toFloat($valRow[0] ?? null),
  2013.                     'equity'   => $this->toFloat($valRow[1] ?? null),
  2014.                     'dette'    => $this->toFloat($valRow[2] ?? null),
  2015.                 ];
  2016.             }
  2017.         }
  2018.         return $default;
  2019.     }
  2020.     /** Normalise une chaîne pour comparaison : trim, majuscules, sans accents, espaces compactés. */
  2021.     private function norm(?string $value): string
  2022.     {
  2023.         $value = trim((string) $value);
  2024.         if ($value === '') {
  2025.             return '';
  2026.         }
  2027.         $value = strtr($value, [
  2028.             'É' => 'E', 'È' => 'E', 'Ê' => 'E', 'Ë' => 'E',
  2029.             'À' => 'A', 'Â' => 'A', 'Ä' => 'A',
  2030.             'Î' => 'I', 'Ï' => 'I', 'Ô' => 'O', 'Ö' => 'O',
  2031.             'Û' => 'U', 'Ü' => 'U', 'Ù' => 'U', 'Ç' => 'C', 'Œ' => 'OE',
  2032.             'é' => 'E', 'è' => 'E', 'ê' => 'E', 'ë' => 'E',
  2033.             'à' => 'A', 'â' => 'A', 'ä' => 'A',
  2034.             'î' => 'I', 'ï' => 'I', 'ô' => 'O', 'ö' => 'O',
  2035.             'û' => 'U', 'ü' => 'U', 'ù' => 'U', 'ç' => 'C', 'œ' => 'OE',
  2036.         ]);
  2037.         $value = mb_strtoupper($value, 'UTF-8');
  2038.         $value = preg_replace('/\s+/', ' ', $value);
  2039.         return trim($value);
  2040.     }
  2041.     private function toFloat($value): ?float
  2042.     {
  2043.         if ($value === null || $value === '') {
  2044.             return null;
  2045.         }
  2046.         $value = str_replace([' ', "\xC2\xA0"], '', (string) $value);
  2047.         $value = str_replace(',', '.', $value);
  2048.         return is_numeric($value) ? (float) $value : null;
  2049.     }
  2050.     // ─── HELPERS ──────────────────────────────────────────────────────────────
  2051.     private function logAction(EngagementVersion $version, string $action, ?object $user, ?EngagementBudgetEngage $ebe = null): void
  2052.     {
  2053.         $log = new EngagementVersionLog();
  2054.         $log->setEngagementVersion($version);
  2055.         $log->setAction($action);
  2056.         if ($user) {
  2057.             $log->setUser($user);
  2058.         }
  2059.         if ($ebe) {
  2060.             $log->setEngagementBudgetEngage($ebe);
  2061.         }
  2062.         $this->em->persist($log);
  2063.     }
  2064.     private function formatProjetNom(Project $projet): string
  2065.     {
  2066.         return (string) $projet->getName();
  2067.     }
  2068.     private function formatStatutAdministratif(?string $statut): string
  2069.     {
  2070.         $labels = [
  2071.             (string) Project::STATUS_CUSTOM_EN_ATTENT       => 'Prospection',
  2072.             (string) Project::STATUS_CUSTOM_DEVELOPPEMENT   => 'Développement',
  2073.             (string) Project::STATUS_CUSTOM_CONSTRUCTION    => 'Construction',
  2074.             (string) Project::STATUS_CUSTOM_EXPLOITATION    => 'Exploitation',
  2075.         ];
  2076.         return $labels[$statut] ?? ($statut ?: '-');
  2077.     }
  2078.     private function emptyTotaux(): array
  2079.     {
  2080.         return ['previsionnel' => '0', 'engage' => '0', 'facture' => '0'];
  2081.     }
  2082. }