<?php
namespace App\Services;
use App\Entity\Engagement\EngagementBudget;
use App\Entity\Engagement\EngagementBudgetEngage;
use App\Entity\Engagement\EngagementBudgetEngageComment;
use App\Entity\Engagement\EngagementBudgetEngageDetail;
use App\Entity\Engagement\EngagementBudgetPrevisionel;
use App\Entity\Engagement\EngagementJalon;
use App\Entity\Engagement\EngagementProjet;
use App\Entity\Engagement\EngagementVersion;
use App\Entity\Engagement\EngagementVersionLog;
use App\Entity\Engagement\TypeDepense;
use App\Entity\Engagement\TypeDepenseStatutAdministatif;
use App\Entity\BonDeCommande;
use App\Entity\DocUpload;
use App\Entity\Etaps;
use App\Entity\EtapsCategory;
use App\Entity\FactureFinance;
use App\Entity\Fournisseur;
use App\Entity\Project;
use App\Entity\Spv;
use App\Repository\EngagementBudgetEngageRepository;
use App\Repository\EngagementBudgetRepository;
use App\Repository\EngagementProjetRepository;
use App\Repository\EngagementVersionRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Security;
use Twig\Environment;
class GestionEngagementService
{
private $em;
private $router;
private $security;
private $twig;
private $params;
/** Cache local du facturé par engagé : "bonDeCommandeId|classifErpi" => montant. */
private $factureEngageCache = [];
/** @var EngagementProjetRepository */
private $engagementProjetRepo;
/** @var EngagementVersionRepository */
private $engagementVersionRepo;
/** @var EngagementBudgetRepository */
private $engagementBudgetRepo;
/** @var EngagementBudgetEngageRepository */
private $engagementBudgetEngageRepo;
/** @var \App\Repository\ProjectRepository */
private $projectRepo;
public function __construct(
EntityManagerInterface $em,
RouterInterface $router,
Security $security,
Environment $twig,
ParameterBagInterface $params
) {
$this->em = $em;
$this->router = $router;
$this->security = $security;
$this->twig = $twig;
$this->params = $params;
$this->engagementProjetRepo = $em->getRepository(EngagementProjet::class);
$this->engagementVersionRepo = $em->getRepository(EngagementVersion::class);
$this->engagementBudgetRepo = $em->getRepository(EngagementBudget::class);
$this->engagementBudgetEngageRepo = $em->getRepository(EngagementBudgetEngage::class);
$this->projectRepo = $em->getRepository(Project::class);
}
// ─── PAGINATION LISTE ─────────────────────────────────────────────────────
public function paginateProjets(array $params): array
{
$filters = [
'search' => $params['search']['value'] ?? '',
'spvId' => $params['spv'] ?? null,
'statutAdministratif' => $params['statutAdministratif'] ?? null,
];
$start = (int) ($params['start'] ?? 0);
$length = (int) ($params['length'] ?? 20);
$draw = (int) ($params['draw'] ?? 1);
// La liste est désormais basée sur les projets (et non sur EngagementProjet) :
// les projets sans engagement apparaissent aussi.
$total = $this->projectRepo->countForEngagementPagination($filters);
$projets = $this->projectRepo->findForEngagementPagination($filters, $start, $length);
$data = [];
foreach ($projets as $projet) {
$data[] = $this->buildProjetRow($projet);
}
return [
'draw' => $draw,
'recordsTotal' => $total,
'recordsFiltered' => $total,
'data' => $data,
];
}
private function buildProjetRow(Project $projet): array
{
// Données issues de la DERNIÈRE version d'engagement du projet (si elle existe)
$ep = $this->engagementProjetRepo->findOneBy(['projet' => $projet]);
$version = $ep ? $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId()) : null;
$hasVersion = $version !== null;
$totaux = ['totalPrevisionnel' => 0, 'totalEngage' => 0, 'totalFacture' => 0];
if ($version) {
$totaux = $this->engagementBudgetRepo->getTotauxByVersion($version->getId());
}
$previsionnel = (float) ($totaux['totalPrevisionnel'] ?? 0);
$engage = (float) ($totaux['totalEngage'] ?? 0);
// Budget facturé : calculé par classification (factures payées du projet),
// comme dans l'arbre de la page détail.
$facture = 0;
if ($version) {
$factureMap = $this->buildFactureMapByClassification($projet);
if (!empty($factureMap)) {
foreach ($this->engagementBudgetRepo->findByVersionWithRelations($version->getId()) as $eb) {
$classif = $eb->getTypeDepense() ? $eb->getTypeDepense()->getClassificationDepenseErpi() : null;
if ($classif && isset($factureMap[$classif])) {
$facture += (float) $factureMap[$classif];
}
}
}
}
// Budget consommé = (Budget engagé / Budget prévisionnel) * 100
$consomme = $previsionnel > 0 ? (int) round(($engage / $previsionnel) * 100) : 0;
// Liens basés sur le projet (l'EngagementProjet est créé à la volée si nécessaire)
$showUrl = $this->router->generate('admin_gestion_engagement_open_by_project', ['projectId' => $projet->getId()]);
$engageUrl = $this->router->generate('admin_gestion_engagement_engage_by_project', ['projectId' => $projet->getId()]);
$budgetUrl = $this->router->generate('admin_gestion_engagement_budget_by_project', ['projectId' => $projet->getId()]);
$statut = $this->formatStatutAdministratif($projet->getStatusAdministratif());
$spvName = $projet->getSpv() ? $projet->getSpv()->getName() : '-';
$tpl = 'admin/gestion_engagement/default/';
return [
'inputCheckbox' => $this->twig->render($tpl . 'row-checkbox.html.twig', ['id' => $projet->getId()]),
'name' => $this->twig->render($tpl . 'projet-link.html.twig', ['url' => $showUrl, 'nom' => $this->formatProjetNom($projet), 'hasVersion' => $hasVersion]),
'spv' => $spvName,
'statutAdministratif' => $this->twig->render($tpl . 'statut-badge.html.twig', ['statut' => $statut]),
'previsionnel' => number_format($previsionnel, 0, ',', ' '),
'engage' => number_format($engage, 0, ',', ' '),
'facture' => number_format($facture, 0, ',', ' '),
'consomme' => $this->twig->render($tpl . 'progress-budget.html.twig', ['pct' => $consomme]),
'action' => $this->twig->render($tpl . 'action-menu.html.twig', ['showUrl' => $showUrl, 'engageUrl' => $engageUrl, 'budgetUrl' => $budgetUrl]),
];
}
// ─── DÉTAIL PROJET (ARBRE) ────────────────────────────────────────────────
public function getBudgetTree(EngagementProjet $ep, ?int $versionId): array
{
$version = $versionId
? $this->engagementVersionRepo->findWithLogsByVersion($versionId)
: $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId());
if (!$version) {
return ['rows' => [], 'version' => null, 'jalon' => null, 'totaux' => $this->emptyTotaux(), 'seuils' => [], 'ratios' => ['ebeCapex' => null, 'equity' => null, 'dette' => null]];
}
$budgets = $this->engagementBudgetRepo->findByVersionWithRelations($version->getId());
$totaux = $this->engagementBudgetRepo->getTotauxByVersion($version->getId());
// Budget facturé : niveau 1 + total = mapping par classification (inchangé) ;
// niveaux 2 et 3 = roll-up des sous-lignes (facturé par engagé), géré dans buildTree.
$factureMap = $this->buildFactureMapByClassification($ep->getProjet());
$factureTotal = 0;
foreach ($budgets as $eb) {
$classif = $eb->getTypeDepense() ? $eb->getTypeDepense()->getClassificationDepenseErpi() : null;
if ($classif && isset($factureMap[$classif])) {
$factureTotal += (float) $factureMap[$classif];
}
}
$tree = $this->buildTree($budgets, $ep->getId(), $factureMap, $version->getId());
return [
'rows' => $tree,
'jalon' => $version->getEngagementJalon() ? $version->getEngagementJalon()->getName() : null,
'totaux' => [
'previsionnel' => number_format((float) ($totaux['totalPrevisionnel'] ?? 0), 0, ',', ' '),
'engage' => number_format((float) ($totaux['totalEngage'] ?? 0), 0, ',', ' '),
'facture' => number_format($factureTotal, 0, ',', ' '),
],
'seuils' => $this->buildSeuilsMax($version, $budgets),
// Ratios financiers issus de la fiche transfert (dernier tableau)
'ratios' => [
'ebeCapex' => $version->getEbeCapex(),
'equity' => $version->getEquity(),
'dette' => $version->getDette(),
],
'version' => [
'id' => $version->getId(),
'createdAt' => $version->getCreatedAt() ? $version->getCreatedAt()->format('d/m/Y') : null,
],
];
}
/**
* Bloc « Seuils max » : pour chaque catégorie (statut administratif), calcule le total engagé
* et le pourcentage par rapport au seuil configuré sur la version.
*
* - Catégorie d'une ligne = statut administratif lié à son TypeDepense (typeDepenseStatutAdministatif),
* en remontant la hiérarchie si nécessaire.
* - Pourcentage = montant total engagé de la catégorie / seuil configuré × 100.
* - Montant affiché sur chaque barre = montant total engagé de la catégorie.
*
* @param EngagementBudget[] $budgets
*/
private function buildSeuilsMax(EngagementVersion $version, array $budgets): array
{
// Seuils configurés par catégorie
$seuils = [
EngagementVersion::CAT_Prospection => (float) $version->getProspectionMontant(),
EngagementVersion::CAT_Montage_Administratif => (float) $version->getMontageAdministratifMontant(),
EngagementVersion::CAT_Developpement => (float) $version->getDeveloppementMontant(),
EngagementVersion::CAT_Construction => (float) $version->getConstructionMontant(),
];
// Montant total engagé cumulé par catégorie
$engages = array_fill_keys(array_keys($seuils), 0.0);
foreach ($budgets as $eb) {
$montant = 0.0;
foreach ($this->ebEngageDetails($eb) as $detail) {
$montant += (float) ($detail->getEngageMontant() ?? 0);
}
if ($montant === 0.0) {
continue;
}
$cat = $this->resolveStatutAdministratif($eb->getTypeDepense());
if ($cat !== null && isset($engages[$cat])) {
$engages[$cat] += $montant;
}
}
$result = [];
foreach ($this->getStatutsAdministratifs() as $cat => $label) {
$seuil = $seuils[$cat];
$engage = $engages[$cat];
$pct = $seuil > 0 ? ($engage / $seuil) * 100 : 0;
$result[] = [
'cat' => $cat,
'label' => $label,
'seuil' => $seuil,
'engage' => $engage,
'pourcentage' => $pct,
'depasse' => $pct > 100,
];
}
return $result;
}
// ─── DÉPASSEMENTS BUDGÉTAIRES (alertes / notifications) ────────────────────
/**
* Type comptable racine (niveau 1) d'une ligne : DEVEX | CAPEX | OPEX (ou null),
* lu depuis le champ `type` du TypeDepense de niveau 1 (racine de la hiérarchie).
*/
private function rootType(?TypeDepense $type): ?string
{
if ($type === null) {
return null;
}
$node = $type;
while ($node->getParent() !== null) {
$node = $node->getParent();
}
$t = $node->getType();
return $t !== null ? strtoupper(trim($t)) : null;
}
/**
* Prévisionnel / engagé par type comptable (DEVEX, CAPEX) et global (DEVEX + CAPEX) d'une version.
* DEVEX / CAPEX déterminés via le champ `type` de TypeDepense (racine de niveau 1).
*
* @return array{devex:array,capex:array,global:array}
* chaque sous-tableau : {prev:float, eng:float, depasse:bool, depassement:float}
*/
public function computeDevexCapexGlobal(EngagementVersion $version): array
{
$budgets = $this->engagementBudgetRepo->findByVersionWithRelations($version->getId());
$prev = ['DEVEX' => 0.0, 'CAPEX' => 0.0];
$eng = ['DEVEX' => 0.0, 'CAPEX' => 0.0];
foreach ($budgets as $eb) {
$root = $this->rootType($eb->getTypeDepense());
if ($root !== 'DEVEX' && $root !== 'CAPEX') {
continue;
}
// Prévisionnel : porté par les lignes de niveau « code budget » (comme buildTree)
if ($eb->getTypeDepense() && (int) $eb->getTypeDepense()->getNiveau() === TypeDepense::NIVEAU_CODE_BUDGET) {
$ebP = $eb->getEngagementBudgetPrevisionel();
$prev[$root] += $ebP ? (float) ($ebP->getMontant() ?? 0) : 0.0;
}
// Engagé : somme des détails d'engagé de la ligne
$m = 0.0;
foreach ($this->ebEngageDetails($eb) as $d) {
$m += (float) ($d->getEngageMontant() ?? 0);
}
$eng[$root] += $m;
}
$mk = function (float $p, float $e): array {
return [
'prev' => $p,
'eng' => $e,
// Dépassement actif seulement si le prévisionnel est défini (> 0)
'depasse' => $p > 0 && $e > $p,
'depassement' => max(0.0, $e - $p),
];
};
return [
'devex' => $mk($prev['DEVEX'], $eng['DEVEX']),
'capex' => $mk($prev['CAPEX'], $eng['CAPEX']),
'global' => $mk($prev['DEVEX'] + $prev['CAPEX'], $eng['DEVEX'] + $eng['CAPEX']),
];
}
/**
* Seuils (catégories) d'une version avec l'engagé cumulé et le drapeau de dépassement
* (Prospection / Montage administratif / Développement / Construction) — utilisés pour le
* blocage du bouton « Ajouter un engagé » et l'autorisation de dépassement.
*
* @return array<int, array{cat:int,label:string,seuil:float,engage:float,pourcentage:float,depasse:bool}>
*/
public function getSeuilsForVersion(EngagementVersion $version): array
{
$budgets = $this->engagementBudgetRepo->findByVersionWithRelations($version->getId());
return $this->buildSeuilsMax($version, $budgets);
}
/**
* Vrai si au moins une catégorie est en dépassement → bloque l'ajout d'un engagé (Action 6).
*/
public function isEngageBloque(?EngagementVersion $version): bool
{
if (!$version) {
return false;
}
foreach ($this->getSeuilsForVersion($version) as $s) {
if (!empty($s['depasse'])) {
return true;
}
}
return false;
}
/**
* Dépassement d'une SPV (N4) : par projet, prévisionnel/engagé global = DEVEX + CAPEX ;
* on somme sur tous les projets de la SPV. Renvoie le détail par projet pour l'email admin.
*/
public function computeSpvDepassement(Spv $spv): array
{
$totalPrev = 0.0;
$totalEng = 0.0;
$lignes = [];
foreach ($this->projectRepo->findBy(['spv' => $spv]) as $projet) {
$ep = $this->engagementProjetRepo->findOneBy(['projet' => $projet]);
if (!$ep) {
continue;
}
$version = $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId());
if (!$version) {
continue;
}
$g = $this->computeDevexCapexGlobal($version)['global'];
$p = (float) $g['prev'];
$e = (float) $g['eng'];
$totalPrev += $p;
$totalEng += $e;
$lignes[] = [
'projet' => $projet->getName(),
'previsionnel' => $p,
'engage' => $e,
'depassement' => max(0.0, $e - $p),
];
}
return [
'previsionnel' => $totalPrev,
'engage' => $totalEng,
'depasse' => $totalPrev > 0 && $totalEng > $totalPrev,
'depassement' => max(0.0, $totalEng - $totalPrev),
'projets' => $lignes,
];
}
/**
* Statut administratif (catégorie) associé à un TypeDepense, en remontant la hiérarchie
* jusqu'à trouver un statut configuré. Retourne la valeur entière (1..4) ou null.
*/
private function resolveStatutAdministratif(?TypeDepense $type): ?int
{
while ($type !== null) {
foreach ($type->getTypeDepenseStatutAdministatif() as $s) {
$val = $s->getStatutAdministratif();
if ($val !== null && trim((string) $val) !== '') {
return (int) $val;
}
}
$type = $type->getParent();
}
return null;
}
/**
* Getter/setter du montant seuil de chaque catégorie sur la version.
*
* @return array<int, array{0:string,1:string}>
*/
private function seuilAccessors(): array
{
return [
EngagementVersion::CAT_Prospection => ['getProspectionMontant', 'setProspectionMontant'],
EngagementVersion::CAT_Montage_Administratif => ['getMontageAdministratifMontant', 'setMontageAdministratifMontant'],
EngagementVersion::CAT_Developpement => ['getDeveloppementMontant', 'setDeveloppementMontant'],
EngagementVersion::CAT_Construction => ['getConstructionMontant', 'setConstructionMontant'],
];
}
/**
* Cibles autorisables : seuils par catégorie (clé numérique = valeur CAT_* :
* Prospection / Montage administratif / Développement / Construction).
*
* @return array<string, array{0:string,1:string,2:string}> [getter, setter, libellé]
*/
private function depassementAccessors(): array
{
$labels = $this->getStatutsAdministratifs();
$map = [];
foreach ($this->seuilAccessors() as $cat => $acc) {
$map[(string) $cat] = [$acc[0], $acc[1], $labels[$cat] ?? ('Seuil ' . $cat)];
}
return $map;
}
/**
* Autorise un dépassement (Action 8) : remplace le seuil de la catégorie sur la version et
* journalise l'ancienne → la nouvelle valeur dans EngagementVersionLog.
*
* @param string|int $cible '1'..'4' (catégorie de seuil)
* @param mixed $nouveauMontant
*/
public function autoriserDepassement(EngagementProjet $ep, ?int $versionId, $cible, $nouveauMontant, ?object $user): array
{
$version = $versionId
? $this->engagementVersionRepo->find($versionId)
: $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId());
if (!$version || ($version->getEngagementProjet() && $version->getEngagementProjet()->getId() !== $ep->getId())) {
return ['success' => false, 'message' => 'Version introuvable.'];
}
$accessors = $this->depassementAccessors();
$cible = (string) $cible;
if (!isset($accessors[$cible])) {
return ['success' => false, 'message' => 'Cible de dépassement invalide.'];
}
$newMontant = $this->toFloat($nouveauMontant);
if ($newMontant === null || $newMontant < 0) {
return ['success' => false, 'message' => 'Le montant autorisé est invalide.'];
}
[$getter, $setter, $label] = $accessors[$cible];
$oldMontant = (float) $version->{$getter}();
$version->{$setter}($newMontant);
$oldStr = number_format($oldMontant, 0, ',', ' ') . ' €';
$newStr = number_format($newMontant, 0, ',', ' ') . ' €';
$action = 'Autorisation de dépassement — ' . $label . $this->formatOldNew($oldStr, $newStr);
$this->logAction($version, $action, $user);
$this->em->flush();
return ['success' => true, 'message' => 'Le dépassement « ' . $label . ' » a été autorisé.'];
}
/**
* Détails d'engagé d'une ligne budget (un par engagé rattaché). Utilise la collection
* engagementBudgetEngageDetails ; repli sur l'ancien lien unique pour les données non migrées.
*
* @return EngagementBudgetEngageDetail[]
*/
private function ebEngageDetails(EngagementBudget $eb): array
{
$details = $eb->getEngagementBudgetEngageDetails()->toArray();
if (empty($details) && $eb->getEngagementBudgetEngageDetail()) {
$details = [$eb->getEngagementBudgetEngageDetail()];
}
return $details;
}
/** Détail d'une ligne budget correspondant à un engagé donné (ou null). */
private function ebDetailForEngage(EngagementBudget $eb, int $engageId): ?EngagementBudgetEngageDetail
{
foreach ($this->ebEngageDetails($eb) as $d) {
$e = $d->getEngagementBudgetEngage();
if ($e && $e->getId() === $engageId) {
return $d;
}
}
return null;
}
private function buildTree(array $budgets, int $epId, array $factureMap = [], ?int $versionId = null): array
{
$grouped = [];
// URL du modal détails, basée sur le type de dépense (toujours disponible, même sans engagé)
$detailsUrl = function (?int $typeId) use ($epId, $versionId) {
if (!$typeId) {
return null;
}
$params = ['id' => $epId, 'typeId' => $typeId];
if ($versionId) {
$params['version'] = $versionId;
}
return $this->router->generate('admin_gestion_engagement_details', $params);
};
foreach ($budgets as $eb) {
// Hiérarchie reconstruite à partir du TypeDepense de la ligne :
// chaîne racine → feuille suivant le niveau (parent).
$chain = [];
$node = $eb->getTypeDepense();
while ($node !== null) {
array_unshift($chain, $node);
$node = $node->getParent();
}
$n1 = $chain[0] ?? null; // niveau 1
$n2 = $chain[1] ?? null; // niveau 2
$leaf = $eb->getTypeDepense(); // TypeDepense porté par la ligne
$niveau = $leaf ? (int) $leaf->getNiveau() : 0;
// Classification ERPI de la ligne (sert au facturé par classification — niveaux n1/n2/n3).
$classifErpi = $leaf ? $leaf->getClassificationDepenseErpi() : null;
// Classification ERPI du NIVEAU 1 : base du filtre facturé par engagé (niveau 4).
$classifErpiN1 = $n1 ? $n1->getClassificationDepenseErpi() : null;
$fournisseur = '-';
$engageId = null;
// Budget engagé : une ligne peut porter PLUSIEURS engagés (un détail par engagé).
// On construit la liste des engagés + la somme des montants propres à la ligne.
$engagesList = [];
$engage = 0.0;
foreach ($this->ebEngageDetails($eb) as $d) {
$dEbe = $d->getEngagementBudgetEngage();
$m = (float) ($d->getEngageMontant() ?? 0);
$engage += $m;
// Libellé de l'engagé : n° de devis du bon de commande si présent, sinon l'id de l'engagé.
$bdc = $dEbe ? $dEbe->getBonDeCommande() : null;
$numeroDevis = $bdc ? trim((string) $bdc->getNumeroDevis()) : '';
$fournisseurBonDeCommande = '-';
if ($bdc && $bdc->getFournisseur()) {
$fournisseurBonDeCommande = trim((string) $bdc->getFournisseur());
} elseif ($dEbe && $dEbe->getFournisseur()) {
$fournisseurBonDeCommande = $dEbe->getFournisseur()->getName();
}
$engagesList[] = [
'engageId' => $dEbe ? $dEbe->getId() : null,
'fournisseur' => $fournisseurBonDeCommande,
'montantRaw' => $m,
// Facturé de l'engagé : factures payées correspondant à son bon de commande
// (classification = celle du NIVEAU 1).
'factureRaw' => $dEbe ? $this->factureMontantForEngage($dEbe, $classifErpiN1) : 0.0,
'label' => $numeroDevis !== '' ? $numeroDevis : ($dEbe ? (string) $dEbe->getId() : '-'),
];
}
// rétro-compat : premier engagé (fournisseur / lien unique des niveaux agrégés)
if (!empty($engagesList)) {
$fournisseur = $engagesList[0]['fournisseur'];
$engageId = $engagesList[0]['engageId'];
}
// Budget prévisionnel : porté par EngagementBudgetPrevisionel->montant
$ebP = $eb->getEngagementBudgetPrevisionel();
$previsionnel = $ebP ? (float) ($ebP->getMontant() ?? 0) : 0;
// Budget facturé — niveau 1 : mapping par classification (ancienne version, inchangé).
$factureClassif = ($classifErpi && isset($factureMap[$classifErpi])) ? (float) $factureMap[$classifErpi] : 0;
// Budget facturé — niveaux 2 et 3 : somme des sous-lignes (facturé par engagé), en roll-up.
$factureEngage = 0.0;
foreach ($engagesList as $e) {
$factureEngage += (float) ($e['factureRaw'] ?? 0);
}
// ── Groupe Niveau 1 ──
$n1Key = $n1 ? $n1->getId() : 0;
if (!isset($grouped[$n1Key])) {
$grouped[$n1Key] = [
'typeId' => $n1 ? $n1->getId() : null,
'classif' => $n1 ? $n1->getClassificationDepenseErpi() : null,
'label' => $n1 ? $n1->getLibelle() : 'Autre',
'previsionnel' => 0,
'engage' => 0,
'facture' => 0,
'fournisseur' => '-',
'engageId' => null,
'detailsUrl' => $detailsUrl($n1 ? $n1->getId() : null),
'children' => [],
];
}
// Le prévisionnel est porté par la ligne de SON propre niveau (pas de cumul
// depuis les niveaux enfants → évite le double comptage). L'engagé est porté
// par les lignes de dépense (niveau 3) et remonte vers la catégorie / code budget.
if ($niveau === TypeDepense::NIVEAU_CODE_BUDGET) {
$grouped[$n1Key]['previsionnel'] += $previsionnel;
}
$grouped[$n1Key]['engage'] += $engage;
$grouped[$n1Key]['facture'] += $factureClassif;
// Engagé porté directement par le code budget (niveau 1) → utilisé par l'onglet Engagé
if ($niveau === TypeDepense::NIVEAU_CODE_BUDGET && $engageId) {
$grouped[$n1Key]['engageId'] = $engageId;
$grouped[$n1Key]['fournisseur'] = $fournisseur;
}
// ── Groupe Niveau 2 (uniquement s'il existe) ──
if ($n2) {
$n2Key = $n2->getId();
if (!isset($grouped[$n1Key]['children'][$n2Key])) {
$grouped[$n1Key]['children'][$n2Key] = [
'typeId' => $n2->getId(),
'label' => $n2->getLibelle(),
'previsionnel' => 0,
'engage' => 0,
'facture' => 0,
'fournisseur' => '-',
'engageId' => null,
'detailsUrl' => $detailsUrl($n2->getId()),
'children' => [],
];
}
if ($niveau === TypeDepense::NIVEAU_CATEGORIE) {
$grouped[$n1Key]['children'][$n2Key]['previsionnel'] += $previsionnel;
}
$grouped[$n1Key]['children'][$n2Key]['engage'] += $engage;
$grouped[$n1Key]['children'][$n2Key]['facture'] += $factureEngage;
// Engagé porté directement par la catégorie (niveau 2) → utilisé par l'onglet Engagé
if ($niveau === TypeDepense::NIVEAU_CATEGORIE && $engageId) {
$grouped[$n1Key]['children'][$n2Key]['engageId'] = $engageId;
$grouped[$n1Key]['children'][$n2Key]['fournisseur'] = $fournisseur;
}
// ── Feuille : uniquement les vraies lignes de dépense (niveau 3) ──
if ($niveau === TypeDepense::NIVEAU_LIGNE) {
$grouped[$n1Key]['children'][$n2Key]['children'][] = [
'id' => $eb->getId(),
'typeId' => $leaf->getId(),
'label' => $leaf->getLibelle(),
'fournisseur' => $fournisseur,
'previsionnel'=> $previsionnel,
'engage' => $engage,
'facture' => $factureEngage,
'engageId' => $engageId,
'engages' => $engagesList, // un élément par engagé (montantRaw à formater)
'detailsUrl' => $detailsUrl($leaf->getId()),
];
}
}
}
// Montant nul → « - » (dans les 4 tableaux : tous / prévisionnel / engagé / facturé)
$fmt = function (float $v): string {
$s = number_format($v, 0, ',', ' ');
return $s === '0' ? '-' : $s;
};
$result = [];
foreach ($grouped as $typeGroup) {
$cats = [];
foreach ($typeGroup['children'] as $cat) {
foreach ($cat['children'] as &$leaf) {
$leaf['previsionnel'] = $fmt($leaf['previsionnel']);
$leaf['engage'] = $fmt($leaf['engage']);
$leaf['facture'] = $fmt($leaf['facture']);
foreach ($leaf['engages'] as &$eng) {
$eng['montant'] = $fmt((float) ($eng['montantRaw'] ?? 0));
$eng['facture'] = $fmt((float) ($eng['factureRaw'] ?? 0));
}
unset($eng);
}
$cats[] = [
'typeId' => $cat['typeId'] ?? null,
'label' => $cat['label'],
'previsionnel' => $fmt($cat['previsionnel']),
'engage' => $fmt($cat['engage']),
'facture' => $fmt($cat['facture']),
'fournisseur' => $cat['fournisseur'] ?? '-',
'engageId' => $cat['engageId'] ?? null,
'detailsUrl' => $cat['detailsUrl'] ?? null,
'children' => array_values($cat['children']),
];
}
$result[] = [
'typeId' => $typeGroup['typeId'] ?? null,
'classif' => $typeGroup['classif'] ?? null,
'label' => $typeGroup['label'],
'previsionnel' => $fmt($typeGroup['previsionnel']),
'engage' => $fmt($typeGroup['engage']),
'facture' => $fmt($typeGroup['facture']),
'factureRaw' => $typeGroup['facture'],
'fournisseur' => $typeGroup['fournisseur'] ?? '-',
'engageId' => $typeGroup['engageId'] ?? null,
'detailsUrl' => $typeGroup['detailsUrl'] ?? null,
'children' => $cats,
];
}
return $result;
}
/**
* Construit la table [classification => montant facturé] pour un projet.
*
* Pour chaque FactureFinance liée au projet (via ProjectFactureFinance) :
* - tauxTva ≠ « Autre » (≠ 0) : on ajoute FactureFinance.monatantTtc sur sa classification ;
* - tauxTva = « Autre » (= 0) : on ajoute, pour chaque ligne TVA (FactureFinanceMontantTva),
* son montantTtc sur la classification de la ligne.
*
* @return array<string,float>
*/
/**
* Montant facturé d'un engagé : somme des factures PAYÉES correspondant à son bon de commande,
* filtrées par fournisseur + SPV + projet (intersection des collections) + classification ERPI
* de la ligne. Retourne 0 si l'engagé n'a pas de bon de commande.
*/
private function factureMontantForEngage(EngagementBudgetEngage $ebe, ?string $classifErpi): float
{
$bdc = $ebe->getBonDeCommande();
if (!$bdc || !$classifErpi) {
return 0.0;
}
$cacheKey = $bdc->getId() . '|' . $classifErpi;
if (isset($this->factureEngageCache[$cacheKey])) {
return $this->factureEngageCache[$cacheKey];
}
// Projets du bon de commande + fournisseur (obligatoires pour un rapprochement)
$projetIds = [];
foreach ($bdc->getProjectBonCommande() as $pbc) {
if ($pbc->getProject()) {
$projetIds[] = $pbc->getProject()->getId();
}
}
$fournisseur = $bdc->getFournisseur();
if (empty($projetIds) || !$fournisseur) {
return $this->factureEngageCache[$cacheKey] = 0.0;
}
$statutsPayes = [
FactureFinance::STATUS_PAYER,
FactureFinance::STATUS_PAYER_NEW,
FactureFinance::STATUS_DATE_PAIEMENT_PAYER,
FactureFinance::STATUS_DATE_PAIEMENT_PAYER_NEW,
];
$factures = $this->em->getRepository(FactureFinance::class)->findPayeesByFournisseurSpvClassification(
$fournisseur->getId(),
$bdc->getSpv() ? $bdc->getSpv()->getId() : null,
$classifErpi,
$projetIds,
$statutsPayes
);
// Somme : classification au niveau facture ET/OU au niveau des lignes TVA.
$total = 0.0;
$seen = [];
foreach ($factures as $facture) {
if (isset($seen[$facture->getId()])) {
continue;
}
$seen[$facture->getId()] = true;
if ($facture->getClassification() === $classifErpi) {
$total += (float) $this->toFloat($facture->getMonatantTtc());
}
foreach ($facture->getTvaFactureFinance() as $ligneTva) {
if ($ligneTva->getClassification() === $classifErpi) {
$total += (float) $this->toFloat($ligneTva->getMontantTtc());
}
}
}
return $this->factureEngageCache[$cacheKey] = $total;
}
/**
* Factures d'un engagé (niveau 4, onglet Facturé) : mêmes critères que le montant facturé —
* bon de commande de l'engagé (fournisseur + SPV + projets) et classification du NIVEAU 1
* (déduite du typeId de niveau 1 passé en paramètre).
*
* @return array{classif: ?string, factures: array<int,array<string,mixed>>}
*/
public function getFacturesByEngage(int $engageId, int $n1TypeId, EngagementProjet $ep): array
{
return $this->getFacturesByEngages([$engageId], $n1TypeId, $ep);
}
/**
* Union des factures de plusieurs engagés (niveaux 2 et 3 de l'onglet Facturé) :
* pour chaque engagé, mêmes critères que le montant facturé (bon de commande : fournisseur +
* SPV + projets) et classification du NIVEAU 1 (typeId). Dédoublonnage par facture.
*
* @param int[] $engageIds
* @return array{classif: ?string, factures: array<int,array<string,mixed>>}
*/
public function getFacturesByEngages(array $engageIds, int $n1TypeId, EngagementProjet $ep): array
{
$n1Type = $this->em->getRepository(TypeDepense::class)->find($n1TypeId);
$classif = $n1Type ? $n1Type->getClassificationDepenseErpi() : null;
if (!$classif || empty($engageIds)) {
return ['classif' => $classif, 'factures' => []];
}
$statutsPayes = [
FactureFinance::STATUS_PAYER,
FactureFinance::STATUS_PAYER_NEW,
FactureFinance::STATUS_DATE_PAIEMENT_PAYER,
FactureFinance::STATUS_DATE_PAIEMENT_PAYER_NEW,
];
$rows = [];
$seen = [];
foreach (array_unique(array_map('intval', $engageIds)) as $engageId) {
$ebe = $this->engagementBudgetEngageRepo->find($engageId);
if (!$ebe) {
continue;
}
$bdc = $ebe->getBonDeCommande();
if (!$bdc) {
continue;
}
$projetIds = [];
foreach ($bdc->getProjectBonCommande() as $pbc) {
if ($pbc->getProject()) {
$projetIds[] = $pbc->getProject()->getId();
}
}
$fournisseur = $bdc->getFournisseur();
if (empty($projetIds) || !$fournisseur) {
continue;
}
$factures = $this->em->getRepository(FactureFinance::class)->findPayeesByFournisseurSpvClassification(
$fournisseur->getId(),
$bdc->getSpv() ? $bdc->getSpv()->getId() : null,
$classif,
$projetIds,
$statutsPayes
);
foreach ($factures as $facture) {
if (isset($seen[$facture->getId()])) {
continue;
}
$seen[$facture->getId()] = true;
$montant = 0.0;
if ($facture->getClassification() === $classif) {
$montant += (float) $this->toFloat($facture->getMonatantTtc());
}
foreach ($facture->getTvaFactureFinance() as $ligneTva) {
if ($ligneTva->getClassification() === $classif) {
$montant += (float) $this->toFloat($ligneTva->getMontantTtc());
}
}
$rows[] = [
'numero' => $facture->getNumeroFacture(),
'fournisseur' => $facture->getFournisseur() ? $facture->getFournisseur()->getName() : '-',
'spv' => $facture->getSpv() ? $facture->getSpv()->getName() : '-',
'montantTtc' => $montant,
'statut' => $facture->getStatusString(),
'token' => $facture->getToken(),
];
}
}
return ['classif' => $classif, 'factures' => $rows];
}
private function buildFactureMapByClassification(?Project $project): array
{
$map = [];
if (!$project) {
return $map;
}
// On ne retient que les factures payées
$statutsPayes = [
FactureFinance::STATUS_PAYER,
FactureFinance::STATUS_PAYER_NEW,
FactureFinance::STATUS_DATE_PAIEMENT_PAYER,
FactureFinance::STATUS_DATE_PAIEMENT_PAYER_NEW,
];
$factures = $this->em->getRepository(FactureFinance::class)
->findPayeesByProjet($project->getId(), $statutsPayes);
// Sans condition sur le tauxTva : on additionne les montants correspondant à la
// classification, à la fois au niveau facture ET au niveau des lignes TVA.
$seen = [];
foreach ($factures as $facture) {
if (isset($seen[$facture->getId()])) {
continue;
}
$seen[$facture->getId()] = true;
// Niveau facture
$classif = $facture->getClassification();
$montant = $this->toFloat($facture->getMonatantTtc());
if ($classif && $montant !== null) {
$map[$classif] = ($map[$classif] ?? 0) + $montant;
}
// Niveau lignes TVA
foreach ($facture->getTvaFactureFinance() as $ligneTva) {
$c = $ligneTva->getClassification();
$m = $this->toFloat($ligneTva->getMontantTtc());
if ($c && $m !== null) {
$map[$c] = ($map[$c] ?? 0) + $m;
}
}
}
return $map;
}
// ─── MODAL DÉTAILS ────────────────────────────────────────────────────────
public function getDetailsEngage(int $engageId, int $epId): array
{
$ebe = $this->engagementBudgetEngageRepo->findWithRelations($engageId);
$budgets = $this->engagementBudgetRepo->findByEngage($engageId);
$previsionnel = 0;
$classificationLabel = '-';
$classifs = [];
foreach ($budgets as $eb) {
$ebP = $eb->getEngagementBudgetPrevisionel();
$previsionnel += $ebP ? (float) ($ebP->getMontant() ?? 0) : 0;
if ($classificationLabel === '-') {
$cat = $eb->getCategorie() ? $eb->getCategorie() . '-' : '';
$classificationLabel = $cat . ($eb->getClassificationBudget() ?? '-');
}
if ($eb->getTypeDepense() && $eb->getTypeDepense()->getClassificationDepenseErpi()) {
$classifs[] = $eb->getTypeDepense()->getClassificationDepenseErpi();
}
}
// Factures liées à l'engagé (via les classifications de ses lignes)
$ep = $this->engagementProjetRepo->find($epId);
$factures = $this->matchFacturesByClassifs($ep ? $ep->getProjet() : null, $classifs);
$fournisseurs = [];
$numeros = [];
$factureTotal = 0;
foreach ($factures as $f) {
$factureTotal += (float) $f['montantTtc'];
if ($f['fournisseur'] && $f['fournisseur'] !== '-') {
$fournisseurs[$f['fournisseur']] = true;
}
if ($f['numero']) {
$numeros[] = $f['numero'];
}
}
$engage = $ebe ? (float) ($ebe->getEngageMontant() ?? 0) : 0;
$depasse = $previsionnel > 0 && $engage > $previsionnel;
return [
'ebe' => $ebe,
'classificationLabel' => $classificationLabel,
'budgetEngage' => $engage,
'budgetFacture' => $factureTotal,
'budgetPrevisionnel' => $previsionnel,
'depasse' => $depasse,
'factures' => $factures,
'factureFournisseurs' => array_keys($fournisseurs),
'factureNumeros' => $numeros,
];
}
/**
* Détails d'une LIGNE (type de dépense) pour le modal, indépendamment de l'existence
* d'un engagé : prévisionnel, engagé (s'il y en a un), et factures liées par classification.
*/
public function getDetailsByType(int $typeId, EngagementProjet $ep, ?int $versionId): array
{
$type = $this->em->getRepository(TypeDepense::class)->find($typeId);
$version = $versionId
? $this->engagementVersionRepo->findWithLogsByVersion($versionId)
: $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId());
$eb = ($type && $version)
? $this->engagementBudgetRepo->findOneBy(['engagementVersion' => $version, 'typeDepense' => $type])
: null;
$previsionnel = 0;
$engage = 0;
$ebe = null;
$classificationLabel = $type ? $type->getLibelle() : '-';
if ($eb) {
$ebP = $eb->getEngagementBudgetPrevisionel();
$previsionnel = $ebP ? (float) ($ebP->getMontant() ?? 0) : 0;
$ebe = $eb->getEngagementBudgetEngage();
$ebDetail = $eb->getEngagementBudgetEngageDetail();
$engage = $ebDetail ? (float) ($ebDetail->getEngageMontant() ?? 0) : 0;
$cat = $eb->getCategorie() ? $eb->getCategorie() . '-' : '';
$classificationLabel = $cat . ($eb->getClassificationBudget() ?? $classificationLabel);
}
// Factures liées par la classification ERPI du type
$classif = $type ? $type->getClassificationDepenseErpi() : null;
$factures = $this->matchFacturesByClassifs($ep->getProjet(), $classif ? [$classif] : []);
$fournisseurs = [];
$numeros = [];
$factureTotal = 0;
foreach ($factures as $f) {
$factureTotal += (float) $f['montantTtc'];
if ($f['fournisseur'] && $f['fournisseur'] !== '-') {
$fournisseurs[$f['fournisseur']] = true;
}
if ($f['numero']) {
$numeros[] = $f['numero'];
}
}
$depasse = $previsionnel > 0 && $engage > $previsionnel;
return [
'ebe' => $ebe,
'classificationLabel' => $classificationLabel,
'budgetEngage' => $engage,
'budgetFacture' => $factureTotal,
'budgetPrevisionnel' => $previsionnel,
'depasse' => $depasse,
'factures' => $factures,
'factureFournisseurs' => array_keys($fournisseurs),
'factureNumeros' => $numeros,
];
}
/**
* Factures (payées) d'un projet correspondant à une liste de classifications
* (au niveau facture OU ligne TVA). Inclut le document fileFacture pour l'affichage.
*
* @param string[] $classifs
*/
private function matchFacturesByClassifs(?Project $project, array $classifs): array
{
$classifs = array_values(array_unique(array_filter($classifs)));
if (!$project || empty($classifs)) {
return [];
}
$statutsPayes = [
FactureFinance::STATUS_PAYER,
FactureFinance::STATUS_PAYER_NEW,
FactureFinance::STATUS_DATE_PAIEMENT_PAYER,
FactureFinance::STATUS_DATE_PAIEMENT_PAYER_NEW,
];
$rows = [];
foreach ($this->em->getRepository(FactureFinance::class)->findPayeesByProjet($project->getId(), $statutsPayes) as $facture) {
$montant = 0;
$matched = false;
if (in_array($facture->getClassification(), $classifs, true)) {
$montant += (float) $this->toFloat($facture->getMonatantTtc());
$matched = true;
}
foreach ($facture->getTvaFactureFinance() as $ligneTva) {
if (in_array($ligneTva->getClassification(), $classifs, true)) {
$montant += (float) $this->toFloat($ligneTva->getMontantTtc());
$matched = true;
}
}
if (!$matched) {
continue;
}
$doc = $facture->getFileFacture();
$rows[] = [
'numero' => $facture->getNumeroFacture(),
'fournisseur' => $facture->getFournisseur() ? $facture->getFournisseur()->getName() : '-',
'spv' => $facture->getSpv() ? $facture->getSpv()->getName() : '-',
'montantTtc' => $montant,
'statut' => $facture->getStatusString(),
'token' => $facture->getToken(),
'file' => $doc ? [
'name' => $doc->getNomenclature() ?: $doc->getName(),
'user' => $doc->getUserCreated() ? $doc->getUserCreated()->getName() : null,
'date' => $doc->getCreatedAt(),
'token' => $doc->getToken(),
] : null,
];
}
return $rows;
}
// ─── PAGE DÉTAIL D'UN ENGAGÉ ──────────────────────────────────────────────
/**
* Données de la page « Détails » d'un engagé : informations, lignes de classification,
* note interne (commentaires) et historique (logs de la version).
*/
public function getEngageDetailData(int $engageId, EngagementProjet $ep): array
{
$ebe = $this->engagementBudgetEngageRepo->findWithRelations($engageId);
$budgets = $this->engagementBudgetRepo->findByEngage($engageId);
$lignes = [];
$statut = null;
$version = null;
foreach ($budgets as $eb) {
$version = $version ?: $eb->getEngagementVersion();
$detail = $this->ebDetailForEngage($eb, $engageId);
$lignes[] = [
'label' => $eb->getClassificationBudget(),
'montant' => $detail ? $this->toFloat($detail->getEngageMontant()) : null,
'projet' => ($detail && $detail->getProjet()) ? $detail->getProjet()->getName() : null,
];
// Statut administratif : première phase configurée sur le type de dépense
if ($statut === null && $eb->getTypeDepense()) {
foreach ($eb->getTypeDepense()->getTypeDepenseStatutAdministatif() as $s) {
$statut = $this->statutLabel($s->getStatutAdministratif());
break;
}
}
}
// Historique propre à CET engagé
$logs = $this->em->getRepository(EngagementVersionLog::class)
->findByEngagementBudgetEngage($engageId);
return [
'engagementProjet' => $ep,
'projet' => $ep->getProjet(),
'ebe' => $ebe,
'engageId' => $engageId,
'lignes' => $lignes,
'statut' => $statut,
'comments' => $ebe ? $ebe->getComment() : [],
'logs' => $logs,
];
}
/** Ajoute un commentaire (note interne) à un engagé, éventuellement en réponse à un autre. */
public function addEngageComment(int $engageId, ?string $comment, ?object $user, ?int $parentId = null): bool
{
$comment = trim((string) $comment);
$ebe = $this->engagementBudgetEngageRepo->find($engageId);
if (!$ebe || $comment === '') {
return false;
}
$c = new EngagementBudgetEngageComment();
$c->setComment($comment);
if ($user) {
$c->setUser($user);
}
$c->setEngagementBudgetEngage($ebe);
if ($parentId) {
$parent = $this->em->getRepository(EngagementBudgetEngageComment::class)->find($parentId);
// On ne rattache la réponse que si le parent appartient bien à cet engagé
if ($parent && $parent->getEngagementBudgetEngage() === $ebe) {
$c->setParent($parent);
$parent->addChild($c);
}
}
$this->em->persist($c);
$ebe->addComment($c);
$this->em->flush();
return true;
}
/** Supprime un commentaire (et ses réponses) d'un engagé. */
public function deleteEngageComment(int $commentId, int $engageId): bool
{
$c = $this->em->getRepository(EngagementBudgetEngageComment::class)->find($commentId);
if (!$c || !$c->getEngagementBudgetEngage() || $c->getEngagementBudgetEngage()->getId() !== $engageId) {
return false;
}
$this->removeCommentTree($c);
$this->em->flush();
return true;
}
private function removeCommentTree(EngagementBudgetEngageComment $c): void
{
foreach ($c->getChildren()->toArray() as $child) {
$this->removeCommentTree($child);
}
$this->em->remove($c);
}
// ─── MODAL FACTURES D'UNE LIGNE ───────────────────────────────────────────
/**
* Factures (payées) du projet liées à un type de dépense via sa classification ERPI.
* Retourne la liste pour le modal : numéro, fournisseur, montant TTC (part de la
* classification), statut.
*/
public function getFacturesByType(int $typeId, EngagementProjet $ep): array
{
$type = $this->em->getRepository(TypeDepense::class)->find($typeId);
$classif = $type ? $type->getClassificationDepenseErpi() : null;
if (!$classif) {
return ['classif' => $classif, 'factures' => []];
}
return [
'classif' => $classif,
'factures' => $this->matchFacturesByClassifs($ep->getProjet(), [$classif]),
];
}
/** Libellé d'une phase (statut administratif) à partir de sa valeur. */
private function statutLabel(?string $value): ?string
{
if ($value === null || trim($value) === '') {
return null;
}
$map = $this->getStatutsAdministratifs();
return $map[(int) $value] ?? $value;
}
// ─── AJOUT D'UN ENGAGÉ ────────────────────────────────────────────────────
public function createEngage(EngagementProjet $ep, array $data, ?object $user): void
{
$version = $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId());
if (!$version) {
$version = new EngagementVersion();
$version->setEngagementProjet($ep);
$version->setIsVersion(false);
$this->em->persist($version);
}
// Garantir une ligne EngagementBudget par TypeDepense (suivi) AVANT l'ajout de l'engagé
$this->ensureBudgetLinesForAllTypes($version);
$ebe = new EngagementBudgetEngage();
$ebe->setEngageMontant((float) ($data['montantTotalEngage'] ?? 0));
if (!empty($data['fournisseurId'])) {
$fournisseur = $this->em->getRepository(Fournisseur::class)->find($data['fournisseurId']);
if ($fournisseur) {
$ebe->setFournisseur($fournisseur);
}
}
// Devis (DocUpload référencé par token)
if (!empty($data['fileDevis'])) {
$doc = $this->em->getRepository(DocUpload::class)->findOneBy(['token' => $data['fileDevis']]);
if ($doc) {
$ebe->setDevis($doc);
}
}
// Bon de commande (généré via la modale « Générer un bon de commande »)
if (!empty($data['bonDeCommandeId'])) {
$bdc = $this->em->getRepository(BonDeCommande::class)->find($data['bonDeCommandeId']);
if ($bdc) {
$ebe->setBonDeCommande($bdc);
}
}
$this->em->persist($ebe);
// Détail du log : type de dépense + montant pour chaque classification engagée
$logParts = [];
foreach ($data['classifications'] ?? [] as $classifData) {
// La valeur "classification" correspond à l'id d'un TypeDepense du référentiel.
$typeDepense = !empty($classifData['classification'])
? $this->em->getRepository(TypeDepense::class)->find($classifData['classification'])
: null;
$classificationLibelle = $typeDepense ? $typeDepense->getLibelle() : ($classifData['classification'] ?? null);
if ($classificationLibelle) {
$montantLigne = (float) ($classifData['montant'] ?? 0);
$logParts[] = $classificationLibelle . ' : ' . number_format($montantLigne, 0, ',', ' ') . ' €';
}
$categorieLibelle = ($typeDepense && $typeDepense->getParent())
? $typeDepense->getParent()->getLibelle()
: ($classifData['categorie'] ?? null);
// Réutiliser la ligne existante du TypeDepense (garantie par ensureBudgetLinesForAllTypes),
// sinon la créer (cas d'un type hors suivi).
$eb = $typeDepense ? $this->findBudgetLineByType($version, $typeDepense) : null;
if (!$eb) {
$eb = new EngagementBudget();
$eb->setEngagementVersion($version);
$eb->setTypeDepense($typeDepense);
$eb->setClassificationBudget($classificationLibelle);
$eb->setCategorie($categorieLibelle);
$this->em->persist($eb);
$version->addEngagementBudget($eb);
}
$eb->setEngagementBudgetEngage($ebe);
// Montant PROPRE de la dépense (et non la somme) : porté par le détail de chaque ligne,
// désormais rattaché à l'engagé (EngagementBudgetEngage 1—N EngagementBudgetEngageDetail).
$detail = new EngagementBudgetEngageDetail();
$detail->setClassificationBudget($classificationLibelle);
$detail->setEngageMontant((float) ($classifData['montant'] ?? 0));
$detail->setEngagementBudgetEngage($ebe);
if (!empty($classifData['projetId'])) {
$projetLie = $this->em->getRepository(Project::class)->find($classifData['projetId']);
if ($projetLie) {
$detail->setProjet($projetLie);
}
}
$this->em->persist($detail);
$ebe->addEngagementBudgetEngageDetail($detail);
$eb->setEngagementBudgetEngageDetail($detail);
$eb->addEngagementBudgetEngageDetail($detail); // rattachement à la ligne (plusieurs engagés possibles)
}
// Note interne : enregistrée directement sur l'engagé (distincte des commentaires)
if (isset($data['commentaire'])) {
$note = trim((string) $data['commentaire']);
$ebe->setNoteInterne($note !== '' ? $note : null);
}
$action = 'Ajout d\'un engagé';
if (!empty($logParts)) {
$action .= ' — ' . implode(', ', $logParts);
}
$this->logAction($version, $action, $user, $ebe);
$this->em->flush();
}
/**
* Crée un engagé associé à plusieurs projets (page « Ajout d'un engagé »).
*
* Structure attendue dans $data :
* - montantTotalEngage, fileDevis, commentaire (note interne)
* - projets[] : [projetId, timeline (EtapsCategory id), etape (Etaps id),
* classifications[] => [classification (TypeDepense id), montant]]
*
* Un seul EngagementBudgetEngage est créé et partagé : chaque ligne de classification
* devient un EngagementBudgetEngageDetail (projet / timeline / étape / montant) et est
* rattachée à la version d'engagement du projet correspondant.
*
* @return EngagementProjet|null Projet d'engagement du premier projet (pour redirection).
*/
public function createEngageMultiProjet(array $data, ?object $user): ?EngagementProjet
{
$projets = $data['projets'] ?? [];
if (empty($projets)) {
return null;
}
// Engagé partagé par tous les projets associés
$ebe = new EngagementBudgetEngage();
$ebe->setEngageMontant((float) ($data['montantTotalEngage'] ?? 0));
if (!empty($data['fileDevis'])) {
$doc = $this->em->getRepository(DocUpload::class)->findOneBy(['token' => $data['fileDevis']]);
if ($doc) {
$ebe->setDevis($doc);
}
}
if (!empty($data['commentaire'])) {
$ebe->setNoteInterne(trim((string) $data['commentaire']));
}
// Bon de commande (généré via la modale « Générer un bon de commande »)
if (!empty($data['bonDeCommandeId'])) {
$bdc = $this->em->getRepository(BonDeCommande::class)->find($data['bonDeCommandeId']);
if ($bdc) {
$ebe->setBonDeCommande($bdc);
}
}
$this->em->persist($ebe);
$firstEp = null;
$totalEngage = 0.0;
$hasLine = false;
foreach ($projets as $projData) {
if (empty($projData['projetId'])) {
continue;
}
$projet = $this->em->getRepository(Project::class)->find($projData['projetId']);
if (!$projet) {
continue;
}
$ep = $this->getOrCreateByProject($projet);
$firstEp = $firstEp ?: $ep;
$version = $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId());
if (!$version) {
$version = new EngagementVersion();
$version->setEngagementProjet($ep);
$version->setIsVersion(false);
$this->em->persist($version);
}
$this->ensureBudgetLinesForAllTypes($version);
$timeline = !empty($projData['timeline'])
? $this->em->getRepository(EtapsCategory::class)->find($projData['timeline'])
: null;
$etape = !empty($projData['etape'])
? $this->em->getRepository(Etaps::class)->find($projData['etape'])
: null;
$logParts = [];
foreach ($projData['classifications'] ?? [] as $classifData) {
$typeDepense = !empty($classifData['classification'])
? $this->em->getRepository(TypeDepense::class)->find($classifData['classification'])
: null;
$classificationLibelle = $typeDepense ? $typeDepense->getLibelle() : ($classifData['classification'] ?? null);
if (!$classificationLibelle) {
continue;
}
$hasLine = true;
$montantLigne = (float) ($classifData['montant'] ?? 0);
$totalEngage += $montantLigne;
$logParts[] = $classificationLibelle . ' : ' . number_format($montantLigne, 0, ',', ' ') . ' €';
$categorieLibelle = ($typeDepense && $typeDepense->getParent())
? $typeDepense->getParent()->getLibelle()
: ($classifData['categorie'] ?? null);
$eb = $typeDepense ? $this->findBudgetLineByType($version, $typeDepense) : null;
if (!$eb) {
$eb = new EngagementBudget();
$eb->setEngagementVersion($version);
$eb->setTypeDepense($typeDepense);
$eb->setClassificationBudget($classificationLibelle);
$eb->setCategorie($categorieLibelle);
$this->em->persist($eb);
$version->addEngagementBudget($eb);
}
$eb->setEngagementBudgetEngage($ebe);
$detail = new EngagementBudgetEngageDetail();
$detail->setClassificationBudget($classificationLibelle);
$detail->setEngageMontant($montantLigne);
$detail->setEngagementBudgetEngage($ebe);
$detail->setProjet($projet);
$detail->setTimeline($timeline);
$detail->setEtape($etape);
$this->em->persist($detail);
$ebe->addEngagementBudgetEngageDetail($detail);
$eb->setEngagementBudgetEngageDetail($detail);
$eb->addEngagementBudgetEngageDetail($detail); // rattachement à la ligne (plusieurs engagés possibles)
}
$action = 'Ajout d\'un engagé — ' . $this->formatProjetNom($projet);
if (!empty($logParts)) {
$action .= ' (' . implode(', ', $logParts) . ')';
}
$this->logAction($version, $action, $user, $ebe);
}
if (!$firstEp || !$hasLine) {
return null;
}
// Montant total : déduit de la somme des lignes s'il n'a pas été fourni
if (empty($data['montantTotalEngage'])) {
$ebe->setEngageMontant($totalEngage);
}
$this->em->flush();
return $firstEp;
}
// ─── ÉDITION D'UN ENGAGÉ ──────────────────────────────────────────────────
/**
* Données de pré-remplissage du formulaire d'édition d'un engagé.
* Structure identique à l'ajout (« new ») : les lignes sont regroupées par projet
* pour alimenter les blocs « Projets associés » (projets[][classifications][]).
*/
public function getEngageEditData(int $engageId, EngagementProjet $ep): ?array
{
$ebe = $this->engagementBudgetEngageRepo->find($engageId);
if (!$ebe) {
return null;
}
$projets = [];
$version = null;
foreach ($this->engagementBudgetRepo->findByEngage($engageId) as $eb) {
$version = $version ?: $eb->getEngagementVersion();
$detail = $this->ebDetailForEngage($eb, $engageId);
$type = $eb->getTypeDepense();
// Projet du détail ; repli sur le projet de l'engagement (données sans projet sur le détail)
$projet = ($detail && $detail->getProjet()) ? $detail->getProjet() : $ep->getProjet();
$pid = $projet ? $projet->getId() : 0;
if (!isset($projets[$pid])) {
$projets[$pid] = [
'projetId' => $projet ? $projet->getId() : null,
'projetName' => $projet ? $projet->getName() : null,
'timeline' => $detail && $detail->getTimeline() ? $detail->getTimeline()->getId() : null,
'etape' => $detail && $detail->getEtape() ? $detail->getEtape()->getId() : null,
'classifications' => [],
];
}
$projets[$pid]['classifications'][] = [
'classification' => $type ? $type->getId() : null,
'classificationLabel' => $type ? $type->getCheminComplet() : null,
'montant' => $detail ? $detail->getEngageMontant() : null,
];
}
return ['ebe' => $ebe, 'projets' => array_values($projets), 'version' => $version];
}
/**
* Ids des TypeDepense déjà engagés dans une version (pour désactiver ces classifications
* dans le formulaire d'ajout/édition d'un engagé).
*
* @return int[]
*/
public function getEngagedTypeIdsForVersion(?EngagementVersion $version, ?int $excludeEngageId = null): array
{
if (!$version) {
return [];
}
return $this->engagementBudgetRepo->findEngagedTypeIdsByVersion($version->getId(), $excludeEngageId);
}
/**
* Met à jour un engagé. Accepte le même payload multi-projet que l'ajout
* (« new » : projets[][projetId], projets[][classifications][], bonDeCommandeId,
* montantTotalEngage, fileDevis, commentaire). Rétro-compatible avec l'ancien
* format plat (classifications[]). Les détails de CET engagé sont remplacés.
*/
public function updateEngage(int $engageId, array $data, ?object $user): bool
{
$ebe = $this->engagementBudgetEngageRepo->find($engageId);
if (!$ebe) {
return false;
}
// État AVANT modification (pour journaliser chaque champ modifié : ancienne → nouvelle valeur)
$oldValues = [
'engageMontant' => $ebe->getEngageMontant(),
'devis' => $ebe->getDevis() ? ($ebe->getDevis()->getNomenclature() ?: $ebe->getDevis()->getName()) : null,
'noteInterne' => $ebe->getNoteInterne(),
];
$ebe->setEngageMontant((float) ($data['montantTotalEngage'] ?? 0));
// Fournisseur : uniquement si le champ est fourni (le formulaire « new » n'en a pas)
if (array_key_exists('fournisseurId', $data)) {
$fournisseur = !empty($data['fournisseurId'])
? $this->em->getRepository(Fournisseur::class)->find($data['fournisseurId'])
: null;
$ebe->setFournisseur($fournisseur ?: null);
}
// Devis (DocUpload référencé par token)
if (!empty($data['fileDevis'])) {
$doc = $this->em->getRepository(DocUpload::class)->findOneBy(['token' => $data['fileDevis']]);
if ($doc) {
$ebe->setDevis($doc);
}
}
// Bon de commande (généré via la modale « Générer un bon de commande »)
if (array_key_exists('bonDeCommandeId', $data)) {
$bdc = !empty($data['bonDeCommandeId'])
? $this->em->getRepository(BonDeCommande::class)->find($data['bonDeCommandeId'])
: null;
$ebe->setBonDeCommande($bdc ?: null);
}
// Détacher UNIQUEMENT les détails de CET engagé (les autres engagés de la même ligne restent)
$primaryVersion = null;
$oldLignes = [];
foreach ($this->engagementBudgetRepo->findByEngage($engageId) as $eb) {
$primaryVersion = $primaryVersion ?: $eb->getEngagementVersion();
foreach ($this->ebEngageDetails($eb) as $detail) {
$dEbe = $detail->getEngagementBudgetEngage();
if (!$dEbe || $dEbe->getId() !== $engageId) {
continue;
}
$oldLignes[$eb->getClassificationBudget()] = $this->toFloat($detail->getEngageMontant());
if ($eb->getEngagementBudgetEngageDetail() === $detail) {
$eb->setEngagementBudgetEngageDetail(null);
}
$eb->removeEngagementBudgetEngageDetail($detail);
$this->em->remove($detail);
}
if ($eb->getEngagementBudgetEngage() === $ebe) {
$eb->setEngagementBudgetEngage(null);
}
}
// Payload : format multi-projet (« new ») ou repli sur l'ancien format plat.
$projets = $data['projets'] ?? null;
if ($projets === null) {
$projets = [['projetId' => null, 'classifications' => $data['classifications'] ?? []]];
}
// Recréer les lignes à partir des projets / classifications soumis
$newLignes = [];
$version = $primaryVersion;
$totalSum = 0.0;
foreach ($projets as $projData) {
$projet = !empty($projData['projetId'])
? $this->em->getRepository(Project::class)->find($projData['projetId'])
: null;
// Version du projet ciblé (sinon version primaire de l'engagé)
if ($projet) {
$ep = $this->getOrCreateByProject($projet);
$pVersion = $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId());
if (!$pVersion) {
$pVersion = new EngagementVersion();
$pVersion->setEngagementProjet($ep);
$pVersion->setIsVersion(false);
$this->em->persist($pVersion);
}
$this->ensureBudgetLinesForAllTypes($pVersion);
} else {
$pVersion = $primaryVersion;
}
if (!$pVersion) {
continue;
}
$version = $pVersion;
$timeline = !empty($projData['timeline'])
? $this->em->getRepository(EtapsCategory::class)->find($projData['timeline'])
: null;
$etape = !empty($projData['etape'])
? $this->em->getRepository(Etaps::class)->find($projData['etape'])
: null;
foreach ($projData['classifications'] ?? [] as $classifData) {
$typeDepense = !empty($classifData['classification'])
? $this->em->getRepository(TypeDepense::class)->find($classifData['classification'])
: null;
$classificationLibelle = $typeDepense ? $typeDepense->getLibelle() : ($classifData['classification'] ?? null);
if (!$classificationLibelle) {
continue;
}
$montantLigne = (float) ($classifData['montant'] ?? 0);
$newLignes[$classificationLibelle] = $montantLigne;
$totalSum += $montantLigne;
$categorieLibelle = ($typeDepense && $typeDepense->getParent())
? $typeDepense->getParent()->getLibelle()
: ($classifData['categorie'] ?? null);
$eb = $typeDepense ? $this->findBudgetLineByType($pVersion, $typeDepense) : null;
if (!$eb) {
$eb = new EngagementBudget();
$eb->setEngagementVersion($pVersion);
$eb->setTypeDepense($typeDepense);
$eb->setClassificationBudget($classificationLibelle);
$eb->setCategorie($categorieLibelle);
$this->em->persist($eb);
$pVersion->addEngagementBudget($eb);
}
$eb->setEngagementBudgetEngage($ebe);
$detail = new EngagementBudgetEngageDetail();
$detail->setClassificationBudget($classificationLibelle);
$detail->setEngageMontant($montantLigne);
$detail->setEngagementBudgetEngage($ebe);
if ($projet) {
$detail->setProjet($projet);
}
if ($timeline) {
$detail->setTimeline($timeline);
}
if ($etape) {
$detail->setEtape($etape);
}
$this->em->persist($detail);
$ebe->addEngagementBudgetEngageDetail($detail);
$eb->setEngagementBudgetEngageDetail($detail);
$eb->addEngagementBudgetEngageDetail($detail); // rattachement à la ligne (plusieurs engagés possibles)
}
}
if (!$version) {
return false;
}
// Montant total : déduit de la somme des lignes s'il n'a pas été fourni
if (empty($data['montantTotalEngage'])) {
$ebe->setEngageMontant($totalSum);
}
// Note interne : enregistrée directement sur l'engagé (distincte des commentaires)
if (isset($data['commentaire'])) {
$note = trim((string) $data['commentaire']);
$ebe->setNoteInterne($note !== '' ? $note : null);
}
// ── Journalisation : un log par champ modifié, avec ancienne et nouvelle valeur ──
$newValues = [
'engageMontant' => $ebe->getEngageMontant(),
'devis' => $ebe->getDevis() ? ($ebe->getDevis()->getNomenclature() ?: $ebe->getDevis()->getName()) : null,
'noteInterne' => $ebe->getNoteInterne(),
];
$changes = [];
// Champs simples
foreach ($newValues as $field => $newVal) {
$oldVal = $oldValues[$field];
if ($field === 'engageMontant') {
if ((float) $oldVal === (float) $newVal) {
continue;
}
$oldStr = $oldVal !== null ? number_format((float) $oldVal, 0, ',', ' ') . ' €' : null;
$newStr = $newVal !== null ? number_format((float) $newVal, 0, ',', ' ') . ' €' : null;
} else {
if ((string) $oldVal === (string) $newVal) {
continue;
}
$oldStr = $oldVal;
$newStr = $newVal;
}
$label = $this->getEngageFieldLabel($field) ?: $field;
$changes[] = 'Modification : ' . $label . $this->formatOldNew($oldStr, $newStr);
}
// Lignes de classification (ajout / suppression / modification de montant)
foreach (array_keys($oldLignes + $newLignes) as $label) {
$o = $oldLignes[$label] ?? null;
$n = $newLignes[$label] ?? null;
if ((float) $o === (float) $n && array_key_exists($label, $oldLignes) === array_key_exists($label, $newLignes)) {
continue;
}
$oStr = $o !== null ? number_format((float) $o, 0, ',', ' ') . ' €' : null;
$nStr = $n !== null ? number_format((float) $n, 0, ',', ' ') . ' €' : null;
if (!array_key_exists($label, $newLignes)) {
$changes[] = 'Suppression classification « ' . $label . ' » ( `' . $oStr . '` )';
} elseif (!array_key_exists($label, $oldLignes)) {
$changes[] = 'Ajout classification « ' . $label . ' » ( `' . $nStr . '` )';
} else {
$changes[] = 'Modification classification « ' . $label . ' »' . $this->formatOldNew($oStr, $nStr);
}
}
if (empty($changes)) {
$this->logAction($version, 'Modification d\'un engagé (aucun changement)', $user, $ebe);
} else {
foreach ($changes as $change) {
$this->logAction($version, $change, $user, $ebe);
}
}
$this->em->flush();
return true;
}
/**
* Construit le suffixe de log « ( de `ancien` à `nouveau` ) » (ou « ( `nouveau` ) » si pas d'ancienne valeur),
* dans le même esprit que DetectUpdateListener::postUpdate.
*/
private function formatOldNew($old, $new): string
{
$old = ($old === null || $old === '') ? null : $old;
$new = ($new === null || $new === '') ? null : $new;
if ($old !== null) {
return ' ( de `' . $old . '` à `' . ($new ?? '—') . '` )';
}
return ' ( `' . ($new ?? '—') . '` )';
}
/** Libellé lisible d'un champ de l'engagé, lu depuis l'annotation @CustomAnnotation (comme les listeners). */
private function getEngageFieldLabel(string $field): ?string
{
try {
$reflector = new \ReflectionClass(EngagementBudgetEngage::class);
if (!$reflector->hasProperty($field)) {
return null;
}
$doc = (string) $reflector->getProperty($field)->getDocComment();
if (preg_match('/@CustomAnnotation\((.*?)\)/', $doc, $m)) {
return trim($m[1], '"');
}
} catch (\ReflectionException $e) {
// ignore
}
return null;
}
// ─── SUPPRESSION D'UN ENGAGÉ ──────────────────────────────────────────────
public function deleteEngage(int $engageId, ?object $user): bool
{
$ebe = $this->engagementBudgetEngageRepo->find($engageId);
if (!$ebe) {
return false;
}
$budgets = $this->engagementBudgetRepo->findByEngage($engageId);
$version = null;
foreach ($budgets as $eb) {
$version = $eb->getEngagementVersion();
// détacher les détails de CET engagé (la ligne subsiste si d'autres engagés y restent)
foreach ($this->ebEngageDetails($eb) as $detail) {
$dEbe = $detail->getEngagementBudgetEngage();
if ($dEbe && $dEbe->getId() === $engageId) {
if ($eb->getEngagementBudgetEngageDetail() === $detail) {
$eb->setEngagementBudgetEngageDetail(null);
}
$eb->removeEngagementBudgetEngageDetail($detail);
}
}
if ($eb->getEngagementBudgetEngage() === $ebe) {
$eb->setEngagementBudgetEngage(null);
}
}
if ($version) {
$this->logAction($version, 'Suppression d\'un engagé', $user);
}
$this->em->remove($ebe); // les détails restants sont supprimés en cascade (FK onDelete=CASCADE)
$this->em->flush();
return true;
}
// ─── CRÉATION / RÉCUPÉRATION ──────────────────────────────────────────────
public function getOrCreateByProject(Project $project): EngagementProjet
{
$ep = $this->engagementProjetRepo->findOneBy(['projet' => $project]);
if (!$ep) {
$ep = new EngagementProjet();
$ep->setProjet($project);
$this->em->persist($ep);
$this->em->flush();
}
return $ep;
}
// ─── VERSIONS ─────────────────────────────────────────────────────────────
public function getAllVersions(EngagementProjet $ep): array
{
return $this->engagementVersionRepo->findAllByEngagementProjet($ep->getId());
}
public function getLatestVersion(EngagementProjet $ep): ?EngagementVersion
{
return $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId());
}
/**
* Logs d'un projet d'engagement (toutes versions confondues) + libellés de version.
* Retourne :
* - 'logs' : EngagementVersionLog[] (du plus récent au plus ancien)
* - 'versionLabels' : map [versionId => ['num' => int, 'jalon' => ?string]]
*/
public function getLogsData(EngagementProjet $ep): array
{
$logs = $this->em->getRepository(EngagementVersionLog::class)
->findByEngagementProjet($ep->getId());
// Numérotation des versions identique à l'écran (la plus ancienne = Version 1)
$versions = $this->getAllVersions($ep); // triées DESC par createdAt
$total = count($versions);
$labels = [];
foreach ($versions as $i => $v) {
$labels[$v->getId()] = [
'num' => $total - $i,
'jalon' => $v->getEngagementJalon() ? $v->getEngagementJalon()->getName() : null,
];
}
return ['logs' => $logs, 'versionLabels' => $labels];
}
/** @return EngagementJalon[] */
public function getAllJalons(): array
{
return $this->em->getRepository(EngagementJalon::class)->findBy([], ['name' => 'ASC']);
}
// ─── IMPORT D'UN BUDGET (CSV "Fiche transfert") ──────────────────────────
public function importBudget(EngagementProjet $ep, array $data, ?object $user): array
{
// 1. Récupération du fichier téléversé (DocUpload via token)
$token = $data['fileBudget'] ?? null;
if (!$token) {
return ['success' => false, 'message' => "Aucun document budget n'a été téléversé."];
}
$doc = $this->em->getRepository(DocUpload::class)->findOneBy(['token' => $token]);
if (!$doc) {
return ['success' => false, 'message' => "Document introuvable."];
}
$path = rtrim($this->params->get('uploads_directory'), '/\\') . '/' . $doc->getPath();
if (!is_file($path)) {
return ['success' => false, 'message' => "Le fichier est introuvable sur le serveur."];
}
// 2. Lecture + validation du fichier "Fiche transfert"
$rows = $this->parseCsvFile($path);
if (count($rows) < 3) {
return ['success' => false, 'message' => "Le fichier ne correspond pas au modèle attendu (Fiche transfert)."];
}
// Entête : ligne 1 (NIVEAU 1 / NIVEAU 2) et ligne 2 (CODE BUDGET / Montant / CATÉGORIE DE DÉPENSE / Montant)
$l1 = array_map([$this, 'norm'], $rows[0]);
$l2 = array_map([$this, 'norm'], $rows[1]);
$headerOk = in_array('NIVEAU 1', $l1, true)
&& in_array('NIVEAU 2', $l1, true)
&& ($l2[0] ?? '') === 'CODE BUDGET'
&& ($l2[2] ?? '') === 'CATEGORIE DE DEPENSE';
if (!$headerOk) {
return ['success' => false, 'message' => "L'entête du fichier ne correspond pas au modèle Fiche transfert."];
}
// 3. Référentiel TypeDepense indexé par libellé normalisé
$mapN1 = $this->indexTypeDepenseByLibelle(TypeDepense::NIVEAU_CODE_BUDGET);
$mapN2 = $this->indexTypeDepenseByLibelle(TypeDepense::NIVEAU_CATEGORIE);
// 4. Lignes de données → (TypeDepense, montant)
$lignes = [];
for ($i = 2; $i < count($rows); $i++) {
$row = $rows[$i];
$codeBudget = $this->norm($row[0] ?? '');
if ($codeBudget !== '' && isset($mapN1[$codeBudget])) {
$lignes[] = ['type' => $mapN1[$codeBudget], 'montant' => $this->toFloat($row[1] ?? null)];
}
$categorie = $this->norm($row[2] ?? '');
if ($categorie !== '' && isset($mapN2[$categorie])) {
$lignes[] = ['type' => $mapN2[$categorie], 'montant' => $this->toFloat($row[3] ?? null)];
}
}
if (empty($lignes)) {
return ['success' => false, 'message' => "Aucune ligne du fichier ne correspond au référentiel des types de dépense."];
}
// 4b. Ratios financiers (dernier tableau : EBE/CAPEX ; TRI Equity ; Dette)
$ratios = $this->parseRatiosFromRows($rows);
// 5. Version cible :
// - dernière version = brouillon (isVersion = false) → on la réutilise et on la fige (isVersion = true)
// - pas de version OU dernière déjà figée → on crée une nouvelle version et on reprend ses engagés
$version = $this->engagementVersionRepo->findLatestByEngagementProjet($ep->getId());
if ($version && !$version->getIsVersion()) {
$version->setIsVersion(true);
} else {
$previous = $version;
$version = new EngagementVersion();
$version->setEngagementProjet($ep);
$version->setIsVersion(true);
$this->em->persist($version);
// Reprise des engagés (engagementBudgetEngage) de la version précédente
if ($previous) {
$this->carryOverEngages($previous, $version);
}
}
// 6. Jalon (champ libre) : réutilise un jalon existant du même nom, sinon le crée.
$jalon = null;
$jalonName = isset($data['newJalon']) ? trim((string) $data['newJalon']) : '';
if ($jalonName !== '') {
$jalon = $this->em->getRepository(EngagementJalon::class)->findOneBy(['name' => $jalonName]);
if (!$jalon) {
$jalon = new EngagementJalon();
$jalon->setName($jalonName);
$this->em->persist($jalon);
}
} elseif (!empty($data['jalonId'])) {
$jalon = $this->em->getRepository(EngagementJalon::class)->find($data['jalonId']);
}
if ($jalon) {
$version->setEngagementJalon($jalon);
}
// 7. Document, montants des seuils et commentaire
$version->setFileBudget($doc);
// Business plan (fichier optionnel téléversé dans la modale d'import)
if (!empty($data['businessPlan'])) {
$docBp = $this->em->getRepository(DocUpload::class)->findOneBy(['token' => $data['businessPlan']]);
if ($docBp) {
$version->setFileBusinessPlan($docBp);
}
}
$version->setProspectionMontant($this->toFloat($data['prospectionMontant'] ?? null));
$version->setMontageAdministratifMontant($this->toFloat($data['montageAdministratifMontant'] ?? null));
$version->setDeveloppementMontant($this->toFloat($data['developpementMontant'] ?? null));
$version->setConstructionMontant($this->toFloat($data['constructionMontant'] ?? null));
// Ratios financiers du dernier tableau de la fiche transfert
$version->setEbeCapex($ratios['ebeCapex']);
$version->setEquity($ratios['equity']);
$version->setDette($ratios['dette']);
if (isset($data['comment'])) {
$version->setComment($data['comment']);
}
// 8. Garantir une ligne EngagementBudget par TypeDepense (suivi) avant l'import
$this->ensureBudgetLinesForAllTypes($version);
// 9. Lignes budget : upsert par TypeDepense dans la version
foreach ($lignes as $ligne) {
/** @var TypeDepense $type */
$type = $ligne['type'];
$montant = $ligne['montant'];
$eb = $this->findBudgetLineByType($version, $type);
if (!$eb) {
$eb = new EngagementBudget();
$eb->setEngagementVersion($version);
$eb->setTypeDepense($type);
$eb->setClassificationBudget($type->getLibelle());
$eb->setCategorie($type->getParent() ? $type->getParent()->getLibelle() : null);
$this->em->persist($eb);
$version->addEngagementBudget($eb);
}
// Le montant prévisionnel est porté UNIQUEMENT par EngagementBudgetPrevisionel
$prev = $eb->getEngagementBudgetPrevisionel() ?: new EngagementBudgetPrevisionel();
$prev->setMontant($montant);
$this->em->persist($prev);
$eb->setEngagementBudgetPrevisionel($prev);
}
$this->logAction($version, "Import d'un budget", $user);
$this->em->flush();
return ['success' => true, 'message' => "Le budget a été importé avec succès."];
}
/**
* Reprend les engagés (EngagementBudgetEngage) de la version $from vers la version $to.
* On partage la MÊME entité EngagementBudgetEngage (pas de clone) : une nouvelle ligne
* EngagementBudget est créée dans $to pour chaque ligne de $from qui porte un engagé,
* en réutilisant son TypeDepense, sa classification et son engagé.
* Le prévisionnel n'est pas recopié : il proviendra du nouveau CSV importé.
*/
private function carryOverEngages(EngagementVersion $from, EngagementVersion $to): void
{
foreach ($this->engagementBudgetRepo->findByVersionWithRelations($from->getId()) as $source) {
$details = $this->ebEngageDetails($source);
if (empty($details)) {
continue;
}
$eb = new EngagementBudget();
$eb->setEngagementVersion($to);
$eb->setTypeDepense($source->getTypeDepense());
$eb->setClassificationBudget($source->getClassificationBudget());
$eb->setCategorie($source->getCategorie());
// Un détail par engagé : on CLONE chaque détail dans la nouvelle version (l'engagé
// EngagementBudgetEngage reste partagé, mais chaque version possède ses propres détails).
foreach ($details as $srcDetail) {
$srcEbe = $srcDetail->getEngagementBudgetEngage();
if (!$srcEbe) {
continue;
}
$clone = new EngagementBudgetEngageDetail();
$clone->setClassificationBudget($srcDetail->getClassificationBudget());
$clone->setEngageMontant($srcDetail->getEngageMontant());
$clone->setEngagementBudgetEngage($srcEbe);
$clone->setProjet($srcDetail->getProjet());
$clone->setTimeline($srcDetail->getTimeline());
$clone->setEtape($srcDetail->getEtape());
$this->em->persist($clone);
$srcEbe->addEngagementBudgetEngageDetail($clone);
$eb->addEngagementBudgetEngageDetail($clone);
// liens uniques legacy (dernier engagé de la ligne)
$eb->setEngagementBudgetEngage($srcEbe);
$eb->setEngagementBudgetEngageDetail($clone);
}
$this->em->persist($eb);
$to->addEngagementBudget($eb);
}
}
// ─── CONFIGURATION DES TYPES DE DÉPENSE ───────────────────────────────────
/**
* Liste des phases / statuts administratifs (constantes CAT_* de EngagementVersion)
* indexées par valeur → libellé, pour alimenter le <select>.
*/
public function getStatutsAdministratifs(): array
{
return [
EngagementVersion::CAT_Prospection => 'Prospection',
EngagementVersion::CAT_Montage_Administratif => 'Montage administratif',
EngagementVersion::CAT_Developpement => 'Développement',
EngagementVersion::CAT_Construction => 'Construction',
];
}
/**
* Enregistre la classification de dépense ERPI d'un type de dépense.
*/
public function updateClassificationErpi(TypeDepense $type, ?string $value): void
{
$type->setClassificationDepenseErpi((string) $value);
$this->em->flush();
}
/**
* Synchronise les statuts administratifs d'un type de dépense : supprime les lignes
* existantes et recrée une TypeDepenseStatutAdministatif par valeur sélectionnée.
*
* @param string[] $values
*/
public function syncStatutsAdministratifs(TypeDepense $type, array $values): void
{
foreach ($type->getTypeDepenseStatutAdministatif()->toArray() as $existing) {
$type->removeTypeDepenseStatutAdministatif($existing);
$this->em->remove($existing);
}
$seen = [];
foreach ($values as $val) {
$val = trim((string) $val);
if ($val === '' || isset($seen[$val])) {
continue;
}
$seen[$val] = true;
$statut = new TypeDepenseStatutAdministatif();
$statut->setStatutAdministratif($val);
$statut->setTypeDepense($type);
$this->em->persist($statut);
$type->addTypeDepenseStatutAdministatif($statut);
}
$this->em->flush();
}
private function findBudgetLineByType(EngagementVersion $version, TypeDepense $type): ?EngagementBudget
{
foreach ($version->getEngagementBudget() as $eb) {
if ($eb->getTypeDepense() && $eb->getTypeDepense()->getId() === $type->getId()) {
return $eb;
}
}
return null;
}
/**
* Garantit qu'il existe une ligne EngagementBudget par ligne de dépense
* (TypeDepense niveau 3) suivie dans la version : crée les lignes manquantes.
* Les niveaux 1 (code budget) et 2 (catégorie) ne sont que des regroupements
* dans l'arbre, pas des lignes de dépense.
*/
private function ensureBudgetLinesForAllTypes(EngagementVersion $version): void
{
$existing = [];
foreach ($version->getEngagementBudget() as $eb) {
if ($eb->getTypeDepense()) {
$existing[$eb->getTypeDepense()->getId()] = true;
}
}
$types = $this->em->getRepository(TypeDepense::class)->findBy([
'suiviEngagement' => true,
'niveau' => TypeDepense::NIVEAU_LIGNE,
]);
foreach ($types as $type) {
if (isset($existing[$type->getId()])) {
continue;
}
$eb = new EngagementBudget();
$eb->setEngagementVersion($version);
$eb->setTypeDepense($type);
$eb->setClassificationBudget($type->getLibelle());
$eb->setCategorie($type->getParent() ? $type->getParent()->getLibelle() : null);
$this->em->persist($eb);
$version->addEngagementBudget($eb);
$existing[$type->getId()] = true;
}
}
/** @return array<string,TypeDepense> libellé normalisé => TypeDepense */
private function indexTypeDepenseByLibelle(int $niveau): array
{
$map = [];
foreach ($this->em->getRepository(TypeDepense::class)->findByNiveau($niveau) as $td) {
$map[$this->norm($td->getLibelle())] = $td;
}
return $map;
}
private function parseCsvFile(string $path): array
{
$rows = [];
$content = file_get_contents($path);
if ($content === false) {
return $rows;
}
// Suppression du BOM UTF-8 éventuel
$content = preg_replace('/^\xEF\xBB\xBF/', '', $content);
$handle = fopen('php://temp', 'r+');
fwrite($handle, $content);
rewind($handle);
while (($cols = fgetcsv($handle, 0, ';')) !== false) {
$rows[] = $cols;
}
fclose($handle);
return $rows;
}
/**
* Extrait les ratios financiers du dernier tableau de la Fiche transfert :
* ligne d'entête « EBE/CAPEX ; TRI Equity ; Dette » suivie de la ligne de valeurs.
*
* @param array<int,array<int,string>> $rows
* @return array{ebeCapex: ?float, equity: ?float, dette: ?float}
*/
private function parseRatiosFromRows(array $rows): array
{
$default = ['ebeCapex' => null, 'equity' => null, 'dette' => null];
for ($i = 0, $n = count($rows); $i < $n; $i++) {
if ($this->norm($rows[$i][0] ?? '') === 'EBE/CAPEX') {
$valRow = $rows[$i + 1] ?? [];
return [
'ebeCapex' => $this->toFloat($valRow[0] ?? null),
'equity' => $this->toFloat($valRow[1] ?? null),
'dette' => $this->toFloat($valRow[2] ?? null),
];
}
}
return $default;
}
/** Normalise une chaîne pour comparaison : trim, majuscules, sans accents, espaces compactés. */
private function norm(?string $value): string
{
$value = trim((string) $value);
if ($value === '') {
return '';
}
$value = strtr($value, [
'É' => 'E', 'È' => 'E', 'Ê' => 'E', 'Ë' => 'E',
'À' => 'A', 'Â' => 'A', 'Ä' => 'A',
'Î' => 'I', 'Ï' => 'I', 'Ô' => 'O', 'Ö' => 'O',
'Û' => 'U', 'Ü' => 'U', 'Ù' => 'U', 'Ç' => 'C', 'Œ' => 'OE',
'é' => 'E', 'è' => 'E', 'ê' => 'E', 'ë' => 'E',
'à' => 'A', 'â' => 'A', 'ä' => 'A',
'î' => 'I', 'ï' => 'I', 'ô' => 'O', 'ö' => 'O',
'û' => 'U', 'ü' => 'U', 'ù' => 'U', 'ç' => 'C', 'œ' => 'OE',
]);
$value = mb_strtoupper($value, 'UTF-8');
$value = preg_replace('/\s+/', ' ', $value);
return trim($value);
}
private function toFloat($value): ?float
{
if ($value === null || $value === '') {
return null;
}
$value = str_replace([' ', "\xC2\xA0"], '', (string) $value);
$value = str_replace(',', '.', $value);
return is_numeric($value) ? (float) $value : null;
}
// ─── HELPERS ──────────────────────────────────────────────────────────────
private function logAction(EngagementVersion $version, string $action, ?object $user, ?EngagementBudgetEngage $ebe = null): void
{
$log = new EngagementVersionLog();
$log->setEngagementVersion($version);
$log->setAction($action);
if ($user) {
$log->setUser($user);
}
if ($ebe) {
$log->setEngagementBudgetEngage($ebe);
}
$this->em->persist($log);
}
private function formatProjetNom(Project $projet): string
{
return (string) $projet->getName();
}
private function formatStatutAdministratif(?string $statut): string
{
$labels = [
(string) Project::STATUS_CUSTOM_EN_ATTENT => 'Prospection',
(string) Project::STATUS_CUSTOM_DEVELOPPEMENT => 'Développement',
(string) Project::STATUS_CUSTOM_CONSTRUCTION => 'Construction',
(string) Project::STATUS_CUSTOM_EXPLOITATION => 'Exploitation',
];
return $labels[$statut] ?? ($statut ?: '-');
}
private function emptyTotaux(): array
{
return ['previsionnel' => '0', 'engage' => '0', 'facture' => '0'];
}
}