Optimise
This commit is contained in:
@@ -16,6 +16,7 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
@@ -24,38 +25,37 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[Route('/crm/administrateur')]
|
||||
class AccountController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* Liste des administrateurs
|
||||
*/
|
||||
#[Route(path: '/crm/administrateur', name: 'app_crm_administrateur', options: ['sitemap' => false], methods: ['GET'])]
|
||||
public function administrateur(AccountRepository $accountRepository, AppLogger $appLogger): Response
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly AppLogger $appLogger,
|
||||
private readonly Mailer $mailer,
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
private readonly TokenGeneratorInterface $tokenGenerator,
|
||||
private readonly UrlGeneratorInterface $urlGenerator
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('', name: 'app_crm_administrateur', options: ['sitemap' => false], methods: ['GET'])]
|
||||
public function index(AccountRepository $accountRepository): Response
|
||||
{
|
||||
$appLogger->record('VIEW', 'Consultation de la liste des Administrateurs');
|
||||
$this->appLogger->record('VIEW', 'Consultation de la liste des Administrateurs');
|
||||
|
||||
return $this->render('dashboard/administrateur.twig', [
|
||||
'admins' => $accountRepository->findAdmin(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajouter un administrateur
|
||||
*/
|
||||
#[Route(path: '/crm/administrateur/add', name: 'app_crm_administrateur_add', options: ['sitemap' => false], methods: ['GET', 'POST'])]
|
||||
public function administrateurAdd(
|
||||
Request $request,
|
||||
EntityManagerInterface $entityManager,
|
||||
UserPasswordHasherInterface $passwordHasher,
|
||||
AppLogger $appLogger,
|
||||
EventDispatcherInterface $eventDispatcher
|
||||
): Response
|
||||
#[Route('/add', name: 'app_crm_administrateur_add', options: ['sitemap' => false], methods: ['GET', 'POST'])]
|
||||
public function add(Request $request, UserPasswordHasherInterface $passwordHasher): Response
|
||||
{
|
||||
$account = new Account();
|
||||
$account->setIsFirstLogin(true);
|
||||
$account->setIsActif(false);
|
||||
$account->setUuid(Uuid::v4()->toRfc4122());
|
||||
$account->setRoles(['ROLE_ADMIN']);
|
||||
$account->setIsFirstLogin(true)
|
||||
->setIsActif(false)
|
||||
->setUuid(Uuid::v4()->toRfc4122())
|
||||
->setRoles(['ROLE_ADMIN']);
|
||||
|
||||
$form = $this->createForm(AccountType::class, $account);
|
||||
$form->handleRequest($request);
|
||||
@@ -64,18 +64,16 @@ class AccountController extends AbstractController
|
||||
$tempPassword = bin2hex(random_bytes(20));
|
||||
$account->setPassword($passwordHasher->hashPassword($account, $tempPassword));
|
||||
|
||||
$entityManager->persist($account);
|
||||
$entityManager->flush();
|
||||
$this->em->persist($account);
|
||||
$this->em->flush();
|
||||
|
||||
$appLogger->record(
|
||||
'CREATE',
|
||||
sprintf("Création de l'Administrateur : %s %s (%s)",
|
||||
$account->getFirstName(), $account->getName(), $account->getEmail()
|
||||
)
|
||||
);
|
||||
$this->appLogger->record('CREATE', sprintf(
|
||||
"Création de l'Administrateur : %s %s (%s)",
|
||||
$account->getFirstName(), $account->getName(), $account->getEmail()
|
||||
));
|
||||
|
||||
$eventDispatcher->dispatch(new EventAdminCreate($account, $this->getUser()));
|
||||
$this->addFlash('success', "L'Administrateur " . $account->getFirstName() . " a été créé avec succès.");
|
||||
$this->eventDispatcher->dispatch(new EventAdminCreate($account, $this->getUser()));
|
||||
$this->addFlash('success', "L'Administrateur {$account->getFirstName()} a été créé avec succès.");
|
||||
|
||||
return $this->redirectToRoute('app_crm_administrateur');
|
||||
}
|
||||
@@ -83,142 +81,179 @@ class AccountController extends AbstractController
|
||||
return $this->render('dashboard/administrateur/add.twig', ['form' => $form->createView()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/crm/administrateur/{id}', name: 'app_crm_administrateur_view', options: ['sitemap' => false], methods: ['GET', 'POST'])]
|
||||
public function administrateurView(
|
||||
#[Route('/{id}', name: 'app_crm_administrateur_view', options: ['sitemap' => false], methods: ['GET', 'POST'])]
|
||||
public function view(
|
||||
?Account $account,
|
||||
PaginatorInterface $paginator,
|
||||
Request $request,
|
||||
AppLogger $appLogger,
|
||||
Mailer $mailer,
|
||||
AccountLoginRegisterRepository $accountLoginRegisterRepository,
|
||||
EntityManagerInterface $em,
|
||||
AuditLogRepository $auditLogRepository,
|
||||
UserPasswordHasherInterface $passwordHasher,
|
||||
TokenGeneratorInterface $tokenGenerator
|
||||
PaginatorInterface $paginator,
|
||||
AccountLoginRegisterRepository $loginRepo,
|
||||
AuditLogRepository $auditRepo,
|
||||
UserPasswordHasherInterface $passwordHasher
|
||||
): Response {
|
||||
if (!$account) {
|
||||
$this->addFlash('error', 'Administrateur introuvable.');
|
||||
return $this->redirectToRoute('app_crm_administrateur');
|
||||
}
|
||||
|
||||
// --- SÉCURITÉ : PROTECTION ROOT ---
|
||||
if (in_array('ROLE_ROOT', $account->getRoles())) {
|
||||
$title = "Tentative d'accès non autorisée";
|
||||
$details = sprintf("L'utilisateur %s a tenté de consulter l'Administrateur ROOT protégé : %s %s (ID: %d)",
|
||||
$this->getUser()->getUserIdentifier(), $account->getFirstName(), $account->getName(), $account->getId()
|
||||
);
|
||||
$this->sendSecurityEmail($mailer, $title, $details);
|
||||
$appLogger->record('SECURITY_ALERT', $details);
|
||||
|
||||
$this->addFlash('error', 'Sécurité : Le compte est protégé');
|
||||
return $this->redirectToRoute('app_crm_administrateur');
|
||||
}
|
||||
if ($request->query->get('act') === 'disable2fa') {
|
||||
if($account->isGoogleAuthenticatorEnabled()) {
|
||||
$account->setGoogleAuthenticatorSecret(null);
|
||||
$em->flush();
|
||||
|
||||
// Récupération de l'admin qui fait l'action
|
||||
$currentUser = $this->getUser();
|
||||
|
||||
$mailer->send(
|
||||
$account->getEmail(),
|
||||
$account->getFirstName() . " " . $account->getName(),
|
||||
"[Alerte Sécurité] Désactivation de votre double authentification",
|
||||
"mails/account/2fa-disable.twig",
|
||||
[
|
||||
'account' => $account,
|
||||
'who' => $currentUser // On passe l'objet admin connecté
|
||||
]
|
||||
);
|
||||
|
||||
$logMessage = sprintf(
|
||||
"2FA désactivée pour %s %s par %s %s (ID: %d)",
|
||||
$account->getFirstName(), $account->getName(),
|
||||
$currentUser->getFirstName(), $currentUser->getName(),
|
||||
$account->getId()
|
||||
);
|
||||
$appLogger->record('2FA_DISABLED', $logMessage);
|
||||
|
||||
$this->addFlash('warning', "La protection 2FA de " . $account->getFirstName() . " a été retirée.");
|
||||
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
|
||||
}
|
||||
// 1. Vérification Sécurité ROOT
|
||||
if ($redirect = $this->checkRootProtection($account)) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
if ($request->query->get('act') === 'sendLink2faenable') {
|
||||
$currentUser = $this->getUser();
|
||||
// 2. Gestion des Actions Rapides (Query Params)
|
||||
if ($action = $request->query->get('act')) {
|
||||
return match ($action) {
|
||||
'disable2fa' => $this->handleDisable2FA($account),
|
||||
'sendLink2faenable' => $this->handleInvite2FA($account),
|
||||
'updateStatut' => $this->handleUpdateStatus($account, $request->query->get('status') === 'true'),
|
||||
'updateRoles' => $this->handleUpdateRoles($account, $request),
|
||||
default => $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()])
|
||||
};
|
||||
}
|
||||
|
||||
$account->setConfirmationTokenName($tokenGenerator->generateToken());
|
||||
$setupUrl = $this->generateUrl('app_2fa_setup_confirm', [
|
||||
'id' => $account->getId(),
|
||||
'token' => $account->getConfirmationToken()
|
||||
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
$em->persist($account);
|
||||
$em->flush();
|
||||
// 3. Formulaire Mot de passe
|
||||
$passwordForm = $this->createForm(AccountPasswordType::class, $account);
|
||||
$passwordForm->handleRequest($request);
|
||||
|
||||
$mailer->send(
|
||||
if ($passwordForm->isSubmitted() && $passwordForm->isValid()) {
|
||||
$plainPassword = $passwordForm->get('password')->getData();
|
||||
$account->setPassword($passwordHasher->hashPassword($account, $plainPassword));
|
||||
$this->em->flush();
|
||||
|
||||
$this->mailer->send(
|
||||
$account->getEmail(),
|
||||
$account->getFirstName() . " " . $account->getName(),
|
||||
"[Sécurité] Configuration de votre double authentification (2FA)",
|
||||
"mails/account/2fa-invite.twig",
|
||||
[
|
||||
'account' => $account,
|
||||
'setup_url' => $setupUrl,
|
||||
'who' => $currentUser, // On passe l'objet admin connecté
|
||||
'expires_at' => (new \DateTime('+1 hour'))->format('H:i')
|
||||
]
|
||||
"{$account->getName()} {$account->getFirstName()}",
|
||||
"[Intranet Ludikevent] - Modification de votre mot de passe",
|
||||
"mails/account/password-modify.twig",
|
||||
['who' => $this->getUser(), 'account' => $account, 'password' => $plainPassword]
|
||||
);
|
||||
|
||||
$logMessage = sprintf(
|
||||
"Invitation 2FA envoyée à %s %s par %s %s",
|
||||
$account->getFirstName(), $account->getName(),
|
||||
$currentUser->getFirstName(), $currentUser->getName()
|
||||
);
|
||||
$appLogger->record('2FA_INVITE', $logMessage);
|
||||
|
||||
$this->addFlash('success', "Lien envoyé à " . $account->getFirstName());
|
||||
$this->appLogger->record('UPDATE_PASSWORD', "Mot de passe modifié pour l'admin {$account->getId()}");
|
||||
$this->addFlash('success', 'Le mot de passe a été mis à jour et envoyé à l\'utilisateur.');
|
||||
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
|
||||
}
|
||||
|
||||
if ($request->query->get('act') === 'updateStatut') {
|
||||
$statusParam = $request->query->get('status');
|
||||
$newStatus = ($statusParam === 'true');
|
||||
|
||||
// On définit un sujet dynamique pour plus de clarté
|
||||
$subject = $newStatus
|
||||
? "[Intranet Ludikevent] - Activation de votre accès"
|
||||
: "[Intranet Ludikevent] - Suspension de votre accès";
|
||||
|
||||
// Envoi du mail de notification
|
||||
$mailer->send(
|
||||
$account->getEmail(),
|
||||
$account->getFirstName() . " " . $account->getName(),
|
||||
$subject,
|
||||
"mails/account/status-update.twig",
|
||||
[
|
||||
'who' => $this->getUser(),
|
||||
'account' => $account,
|
||||
'stats' => $newStatus,
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// Mise à jour en base de données
|
||||
// Vérifiez si votre entité utilise setActif() ou setIsActif()
|
||||
$account->setIsActif($newStatus);
|
||||
$em->flush();
|
||||
|
||||
$label = $newStatus ? 'activé' : 'désactivé';
|
||||
|
||||
// Log et Message Flash
|
||||
$appLogger->record('UPDATE_STATUS', sprintf("%s du compte pour l'admin %d", ucfirst($label), $account->getId()));
|
||||
$this->addFlash('success', "Le compte de " . $account->getFirstName() . " a été $label avec succès.");
|
||||
// 4. Formulaire Identité
|
||||
$form = $this->createForm(AccountType::class, $account);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->em->flush();
|
||||
$this->appLogger->record('UPDATE_IDENTITY', "Mise à jour identité : {$account->getFirstName()} {$account->getName()}");
|
||||
$this->addFlash('success', 'Informations personnelles mises à jour.');
|
||||
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
|
||||
}
|
||||
|
||||
// --- 2. LOGIQUE DE MISE À JOUR DES RÔLES (POST) ---
|
||||
if ($request->isMethod('POST') && $request->query->get('act') === 'updateRoles') {
|
||||
// 5. Affichage
|
||||
$this->appLogger->record('VIEW', "Consultation : {$account->getFirstName()} {$account->getName()}");
|
||||
|
||||
return $this->render('dashboard/administrateur/view.twig', [
|
||||
'admin' => $account,
|
||||
'form' => $form->createView(),
|
||||
'passwordForm' => $passwordForm->createView(),
|
||||
'loginRegisters' => $paginator->paginate($loginRepo->findBy(['account' => $account], ['loginAt' => 'DESC']), $request->query->getInt('page', 1), 10),
|
||||
'auditLogs' => $paginator->paginate($auditRepo->findBy(['account' => $account], ['actionAt' => 'DESC']), $request->query->getInt('audit_page', 1), 10, ['pageParameterName' => 'audit_page'])
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/delete/{id}', name: 'app_crm_administrateur_delete', options: ['sitemap' => false], methods: ['POST'])]
|
||||
public function delete(Account $account, Request $request): Response
|
||||
{
|
||||
if ($redirect = $this->checkRootProtection($account)) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
if ($this->isCsrfTokenValid('delete' . $account->getId(), $request->request->get('_token'))) {
|
||||
$name = "{$account->getFirstName()} {$account->getName()}";
|
||||
|
||||
$this->eventDispatcher->dispatch(new EventAdminDeleted($account, $this->getUser()));
|
||||
|
||||
$this->em->remove($account);
|
||||
$this->em->flush();
|
||||
|
||||
$this->appLogger->record('DELETE', "Suppression définitive : $name");
|
||||
$this->addFlash('success', "L'Administrateur $name a été supprimé.");
|
||||
} else {
|
||||
$this->addFlash('error', 'Jeton de sécurité invalide.');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_crm_administrateur');
|
||||
}
|
||||
|
||||
// --- PRIVATE HELPERS ---
|
||||
|
||||
private function handleDisable2FA(Account $account): RedirectResponse
|
||||
{
|
||||
if ($account->isGoogleAuthenticatorEnabled()) {
|
||||
$account->setGoogleAuthenticatorSecret(null);
|
||||
$this->em->flush();
|
||||
|
||||
$this->mailer->send(
|
||||
$account->getEmail(),
|
||||
"{$account->getFirstName()} {$account->getName()}",
|
||||
"[Alerte Sécurité] Désactivation de votre double authentification",
|
||||
"mails/account/2fa-disable.twig",
|
||||
['account' => $account, 'who' => $this->getUser()]
|
||||
);
|
||||
|
||||
$this->appLogger->record('2FA_DISABLED', "2FA désactivée pour {$account->getId()}");
|
||||
$this->addFlash('warning', "La protection 2FA de {$account->getFirstName()} a été retirée.");
|
||||
}
|
||||
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
|
||||
}
|
||||
|
||||
private function handleInvite2FA(Account $account): RedirectResponse
|
||||
{
|
||||
$account->setConfirmationTokenName($this->tokenGenerator->generateToken());
|
||||
$this->em->flush();
|
||||
|
||||
$setupUrl = $this->urlGenerator->generate('app_2fa_setup_confirm', [
|
||||
'id' => $account->getId(),
|
||||
'token' => $account->getConfirmationToken()
|
||||
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
|
||||
$this->mailer->send(
|
||||
$account->getEmail(),
|
||||
"{$account->getFirstName()} {$account->getName()}",
|
||||
"[Sécurité] Configuration de votre double authentification (2FA)",
|
||||
"mails/account/2fa-invite.twig",
|
||||
[
|
||||
'account' => $account,
|
||||
'setup_url' => $setupUrl,
|
||||
'who' => $this->getUser(),
|
||||
'expires_at' => (new \DateTime('+1 hour'))->format('H:i')
|
||||
]
|
||||
);
|
||||
|
||||
$this->appLogger->record('2FA_INVITE', "Invitation 2FA envoyée à {$account->getId()}");
|
||||
$this->addFlash('success', "Lien 2FA envoyé à {$account->getFirstName()}");
|
||||
|
||||
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
|
||||
}
|
||||
|
||||
private function handleUpdateStatus(Account $account, bool $newStatus): RedirectResponse
|
||||
{
|
||||
$account->setIsActif($newStatus);
|
||||
$this->em->flush();
|
||||
|
||||
$this->mailer->send(
|
||||
$account->getEmail(),
|
||||
"{$account->getFirstName()} {$account->getName()}",
|
||||
$newStatus ? "[Intranet Ludikevent] - Activation de votre accès" : "[Intranet Ludikevent] - Suspension de votre accès",
|
||||
"mails/account/status-update.twig",
|
||||
['who' => $this->getUser(), 'account' => $account, 'stats' => $newStatus]
|
||||
);
|
||||
|
||||
$label = $newStatus ? 'activé' : 'désactivé';
|
||||
$this->appLogger->record('UPDATE_STATUS', "Compte $label pour l'admin {$account->getId()}");
|
||||
$this->addFlash('success', "Le compte de {$account->getFirstName()} a été $label.");
|
||||
|
||||
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
|
||||
}
|
||||
|
||||
private function handleUpdateRoles(Account $account, Request $request): RedirectResponse
|
||||
{
|
||||
if ($request->isMethod('POST')) {
|
||||
$submittedRoles = $request->request->all('roles');
|
||||
$mandatoryRoles = ['ROLE_USER', 'ROLE_ADMIN'];
|
||||
|
||||
@@ -234,156 +269,38 @@ class AccountController extends AbstractController
|
||||
}
|
||||
|
||||
$account->setRoles($submittedRoles);
|
||||
$em->flush();
|
||||
$this->em->flush();
|
||||
|
||||
$appLogger->record('UPDATE_ROLES', sprintf("Mise à jour des rôles pour l'admin %d", $account->getId()));
|
||||
$this->addFlash('success', 'Les permissions ont été mises à jour.');
|
||||
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
|
||||
$this->appLogger->record('UPDATE_ROLES', "Mise à jour rôles admin {$account->getId()}");
|
||||
$this->addFlash('success', 'Permissions mises à jour.');
|
||||
}
|
||||
|
||||
// --- 3. LOGIQUE CHANGEMENT DE MOT DE PASSE ---
|
||||
$passwordForm = $this->createForm(AccountPasswordType::class, $account);
|
||||
$passwordForm->handleRequest($request);
|
||||
|
||||
if ($passwordForm->isSubmitted() && $passwordForm->isValid()) {
|
||||
// 1. On récupère le mot de passe en clair
|
||||
$plainPassword = $passwordForm->get('password')->getData();
|
||||
|
||||
// 2. On hache pour la base de données
|
||||
$hashedPassword = $passwordHasher->hashPassword($account, $plainPassword);
|
||||
$account->setPassword($hashedPassword);
|
||||
$em->flush();
|
||||
|
||||
// 3. Envoi du mail avec les bons paramètres
|
||||
$mailer->send(
|
||||
$account->getEmail(),
|
||||
$account->getName() . " " . $account->getFirstName(),
|
||||
"[Intranet Ludikevent] - Modification de votre mot de passe",
|
||||
"mails/account/password-modify.twig", // Correction orthographe 'account'
|
||||
[
|
||||
'who' => $this->getUser(),
|
||||
'account' => $account,
|
||||
'password' => $plainPassword // On envoie le mot de passe en clair ici
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
$appLogger->record('UPDATE_PASSWORD', sprintf("Mot de passe modifié et envoyé pour l'admin %d", $account->getId()));
|
||||
$this->addFlash('success', 'Le mot de passe a été mis à jour et envoyé à l\'utilisateur.');
|
||||
|
||||
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
|
||||
}
|
||||
|
||||
// --- 4. LOGIQUE DU FORMULAIRE D'IDENTITÉ ---
|
||||
$form = $this->createForm(AccountType::class, $account);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$em->flush();
|
||||
$appLogger->record('UPDATE_IDENTITY', sprintf("Mise à jour identité : %s %s", $account->getFirstName(), $account->getName()));
|
||||
$this->addFlash('success', 'Informations personnelles mises à jour.');
|
||||
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
|
||||
}
|
||||
|
||||
// --- 5. AFFICHAGE & PAGINATION ---
|
||||
$appLogger->record('VIEW', sprintf("Consultation : %s %s", $account->getFirstName(), $account->getName()));
|
||||
|
||||
$loginPagination = $paginator->paginate(
|
||||
$accountLoginRegisterRepository->findBy(['account' => $account], ['loginAt' => 'DESC']),
|
||||
$request->query->getInt('page', 1),
|
||||
10,
|
||||
['pageParameterName' => 'page']
|
||||
);
|
||||
|
||||
$auditPagination = $paginator->paginate(
|
||||
$auditLogRepository->findBy(['account' => $account], ['actionAt' => 'DESC']),
|
||||
$request->query->getInt('audit_page', 1),
|
||||
10,
|
||||
['pageParameterName' => 'audit_page']
|
||||
);
|
||||
|
||||
return $this->render('dashboard/administrateur/view.twig', [
|
||||
'admin' => $account,
|
||||
'form' => $form->createView(),
|
||||
'passwordForm' => $passwordForm->createView(),
|
||||
'loginRegisters' => $loginPagination,
|
||||
'auditLogs' => $auditPagination
|
||||
]);
|
||||
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifier un administrateur
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supprimer un administrateur
|
||||
*/
|
||||
#[Route(path: '/crm/administrateur/delete/{id}', name: 'app_crm_administrateur_delete', options: ['sitemap' => false], methods: ['POST'])]
|
||||
public function administrateurDelete(
|
||||
EventDispatcherInterface $eventDispatcher,
|
||||
?Account $account,
|
||||
Request $request,
|
||||
AppLogger $appLogger,
|
||||
EntityManagerInterface $entityManager,
|
||||
Mailer $mailer
|
||||
): Response
|
||||
private function checkRootProtection(Account $account): ?RedirectResponse
|
||||
{
|
||||
if (!$account) {
|
||||
$this->addFlash('error', 'Administrateur introuvable.');
|
||||
return $this->redirectToRoute('app_crm_administrateur');
|
||||
}
|
||||
|
||||
// --- SÉCURITÉ : Interdiction suppression ROOT ou CLIENT_MAIN ---
|
||||
// On vérifie si l'un des rôles protégés est présent dans le tableau des rôles du compte
|
||||
$protectedRoles = ['ROLE_ROOT'];
|
||||
$accountRoles = $account->getRoles();
|
||||
|
||||
// Si l'intersection entre les rôles du compte et les rôles protégés n'est pas vide
|
||||
if (array_intersect($protectedRoles, $accountRoles)) {
|
||||
$title = "Blocage suppression compte HAUTE-HIÉRARCHIE";
|
||||
$details = sprintf("Tentative de suppression de l'Administrateur protégé %s (Rôles: %s) par l'utilisateur %s",
|
||||
$account->getEmail(),
|
||||
implode(', ', $accountRoles),
|
||||
$this->getUser()->getUserIdentifier()
|
||||
if (in_array('ROLE_ROOT', $account->getRoles())) {
|
||||
$title = "Tentative d'accès non autorisée";
|
||||
$details = sprintf("Tentative de modification/suppression de l'Admin ROOT protégé : %s (ID: %d) par %s",
|
||||
$account->getEmail(), $account->getId(), $this->getUser()->getUserIdentifier()
|
||||
);
|
||||
|
||||
$appLogger->record('SECURITY_CRITICAL', $details);
|
||||
$this->sendSecurityEmail($mailer, $title, $details);
|
||||
$this->appLogger->record('SECURITY_ALERT', $details);
|
||||
$this->sendSecurityEmail($title, $details);
|
||||
$this->addFlash('error', 'Sécurité : Le compte est protégé');
|
||||
|
||||
$this->addFlash('error', 'Sécurité : Ce compte est protégé et ne peut pas être supprimé.');
|
||||
return $this->redirectToRoute('app_crm_administrateur');
|
||||
}
|
||||
|
||||
$token = $request->request->get('_token') ?? $request->query->get('_token');
|
||||
|
||||
if ($this->isCsrfTokenValid('delete' . $account->getId(), $token)) {
|
||||
$name = $account->getFirstName() . ' ' . $account->getName();
|
||||
$email = $account->getEmail();
|
||||
|
||||
$appLogger->record(
|
||||
'DELETE',
|
||||
sprintf("Suppression définitive de l'Administrateur : %s (%s)", $name, $email)
|
||||
);
|
||||
|
||||
$eventDispatcher->dispatch(new EventAdminDeleted($account, $this->getUser()));
|
||||
|
||||
$entityManager->remove($account);
|
||||
$entityManager->flush();
|
||||
|
||||
$this->addFlash('success', "L'Administrateur $name a été supprimé.");
|
||||
} else {
|
||||
$this->addFlash('error', 'Jeton de sécurité invalide.');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_crm_administrateur');
|
||||
return null;
|
||||
}
|
||||
|
||||
private function sendSecurityEmail(Mailer $mailer, string $message, string $content): void
|
||||
private function sendSecurityEmail(string $message, string $content): void
|
||||
{
|
||||
$mailer->send(
|
||||
$this->mailer->send(
|
||||
'notification@siteconseil.fr',
|
||||
"Notification Intranet Ludikevent",
|
||||
"[Intranet Ludikevent] - " . $message,
|
||||
"[Intranet Ludikevent] - $message",
|
||||
"mails/notification/security_violation.twig",
|
||||
[
|
||||
'message' => $message,
|
||||
|
||||
@@ -8,48 +8,47 @@ use App\Repository\BackupRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/crm/sauvegarde')]
|
||||
class BackupController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AppLogger $appLogger,
|
||||
private readonly EntityManagerInterface $entityManager
|
||||
) {}
|
||||
private readonly EntityManagerInterface $em
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/crm/sauvegarde', name: 'app_crm_backup', methods: ['GET'])]
|
||||
public function crmSauvegarde(BackupRepository $backupRepository): Response
|
||||
#[Route('', name: 'app_crm_backup', options: ['sitemap' => false], methods: ['GET'])]
|
||||
public function index(BackupRepository $backupRepository): Response
|
||||
{
|
||||
$this->appLogger->record('VIEW', 'Consultation de la liste des sauvegardes système');
|
||||
|
||||
return $this->render('dashboard/backup.twig', [
|
||||
'backups' => $backupRepository->findBy([], ['createdAt' => 'DESC']),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/crm/sauvegarde/download/{id}', name: 'app_crm_backup_download', methods: ['GET'])]
|
||||
#[Route('/download/{id}', name: 'app_crm_backup_download', options: ['sitemap' => false], methods: ['GET'])]
|
||||
public function download(Backup $backup): Response
|
||||
{
|
||||
$projectDir = $this->getParameter('kernel.project_dir');
|
||||
$fileName = sprintf('db_backup_%s.zip', $backup->getCreatedAt()->format('Y-m-d_H-i'));
|
||||
$filePath = $projectDir . '/sauvegarde/' . $fileName;
|
||||
$filePath = $this->getBackupFilePath($backup);
|
||||
$fileName = basename($filePath);
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
// Utilisation de record(string, string)
|
||||
$this->appLogger->record(
|
||||
'SECURITY_ALERT',
|
||||
"Fichier de sauvegarde introuvable : $fileName (ID: {$backup->getId()})"
|
||||
sprintf("Fichier de sauvegarde introuvable : %s (ID: %d)", $fileName, $backup->getId())
|
||||
);
|
||||
|
||||
$this->addFlash('error', "Le fichier physique est introuvable sur le serveur.");
|
||||
return $this->redirectToRoute('app_crm_backup');
|
||||
}
|
||||
|
||||
$this->appLogger->record(
|
||||
'INFO',
|
||||
"Téléchargement de la sauvegarde : $fileName"
|
||||
);
|
||||
$this->appLogger->record('INFO', "Téléchargement de la sauvegarde : $fileName");
|
||||
|
||||
$response = new BinaryFileResponse($filePath);
|
||||
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $fileName);
|
||||
@@ -57,8 +56,8 @@ class BackupController extends AbstractController
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route(path: '/crm/sauvegarde/delete/{id}', name: 'app_crm_backup_delete', methods: ['GET'])]
|
||||
public function delete(Backup $backup): Response
|
||||
#[Route('/delete/{id}', name: 'app_crm_backup_delete', options: ['sitemap' => false], methods: ['GET'])]
|
||||
public function delete(Backup $backup): RedirectResponse
|
||||
{
|
||||
if (!$this->isGranted('ROLE_ROOT')) {
|
||||
$this->appLogger->record(
|
||||
@@ -70,9 +69,8 @@ class BackupController extends AbstractController
|
||||
return $this->redirectToRoute('app_crm_backup');
|
||||
}
|
||||
|
||||
$projectDir = $this->getParameter('kernel.project_dir');
|
||||
$fileName = sprintf('db_backup_%s.zip', $backup->getCreatedAt()->format('Y-m-d_H-i'));
|
||||
$filePath = $projectDir . '/sauvegarde/' . $fileName;
|
||||
$filePath = $this->getBackupFilePath($backup);
|
||||
$fileName = basename($filePath);
|
||||
|
||||
try {
|
||||
if (file_exists($filePath)) {
|
||||
@@ -80,8 +78,8 @@ class BackupController extends AbstractController
|
||||
}
|
||||
|
||||
$backupId = $backup->getId();
|
||||
$this->entityManager->remove($backup);
|
||||
$this->entityManager->flush();
|
||||
$this->em->remove($backup);
|
||||
$this->em->flush();
|
||||
|
||||
$this->appLogger->record(
|
||||
'SECURITY_ALERT',
|
||||
@@ -100,4 +98,15 @@ class BackupController extends AbstractController
|
||||
|
||||
return $this->redirectToRoute('app_crm_backup');
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le chemin absolu vers le fichier de sauvegarde
|
||||
*/
|
||||
private function getBackupFilePath(Backup $backup): string
|
||||
{
|
||||
$projectDir = $this->getParameter('kernel.project_dir');
|
||||
$fileName = sprintf('db_backup_%s.zip', $backup->getCreatedAt()->format('Y-m-d_H-i'));
|
||||
|
||||
return $projectDir . '/sauvegarde/' . $fileName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,11 @@ use App\Entity\ContratsLine;
|
||||
use App\Entity\ContratsOption;
|
||||
use App\Entity\ContratsPayments;
|
||||
use App\Entity\Devis;
|
||||
use App\Entity\Product;
|
||||
use App\Event\Signature\ContratEvent;
|
||||
use App\Form\Type\ContratsType;
|
||||
use App\Logger\AppLogger;
|
||||
use App\Repository\DevisRepository;
|
||||
use App\Repository\ContratsRepository;
|
||||
use App\Repository\DevisRepository;
|
||||
use App\Service\Mailer\Mailer;
|
||||
use App\Service\Pdf\ContratPdfService;
|
||||
use App\Service\Pdf\PlPdf;
|
||||
@@ -22,76 +21,70 @@ use Knp\Component\Pager\PaginatorInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
#[Route('/crm/contrats')]
|
||||
class ContratsController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* Liste des contrats avec pagination et renvoi de mail
|
||||
*/
|
||||
#[Route(path: '/crm/contrats', name: 'app_crm_contrats', options: ['sitemap' => false], methods: ['GET'])]
|
||||
public function contrats(
|
||||
PaginatorInterface $paginator,
|
||||
AppLogger $appLogger,
|
||||
EventDispatcherInterface $eventDispatcher,
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly AppLogger $appLogger,
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
private readonly Client $signatureClient,
|
||||
private readonly Mailer $mailer,
|
||||
private readonly KernelInterface $kernel,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('', name: 'app_crm_contrats', options: ['sitemap' => false], methods: ['GET'])]
|
||||
public function index(
|
||||
ContratsRepository $contratsRepository,
|
||||
PaginatorInterface $paginator,
|
||||
Request $request
|
||||
): Response {
|
||||
|
||||
if ($request->query->has('idSend')) {
|
||||
$contrat = $contratsRepository->find($request->query->get('idSend'));
|
||||
if (!$contrat) {
|
||||
$this->addFlash("danger", "Contrat introuvable.");
|
||||
return $this->redirectToRoute('app_crm_contrats');
|
||||
}
|
||||
|
||||
$event = new ContratEvent($contrat);
|
||||
$eventDispatcher->dispatch($event);
|
||||
|
||||
$this->addFlash("success", "Le contrat a bien été envoyé à " . $contrat->getCustomer()->getEmail());
|
||||
$appLogger->record('RESEND', "Renvoi du contrat N°" . $contrat->getNumReservation());
|
||||
|
||||
return $this->redirectToRoute('app_crm_contrats');
|
||||
// Gestion du renvoi de contrat (Action rapide)
|
||||
if ($idSend = $request->query->get('idSend')) {
|
||||
return $this->handleResendContract($idSend, $contratsRepository);
|
||||
}
|
||||
|
||||
$appLogger->record('VIEW', 'Consultation de la liste des contrats');
|
||||
$this->appLogger->record('VIEW', 'Consultation de la liste des contrats');
|
||||
|
||||
$query = $contratsRepository->findBy([], ['createAt' => 'DESC']);
|
||||
$pagination = $paginator->paginate($query, $request->query->getInt('page', 1), 10);
|
||||
$pagination = $paginator->paginate(
|
||||
$contratsRepository->findBy([], ['createAt' => 'DESC']),
|
||||
$request->query->getInt('page', 1),
|
||||
10
|
||||
);
|
||||
|
||||
return $this->render('dashboard/contrats/list.twig', [
|
||||
'contrats' => $pagination,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue détaillée et gestion des paiements/actions
|
||||
*/
|
||||
#[Route(path: '/crm/contrats/view/{id}', name: 'app_crm_contrats_view', options: ['sitemap' => false], methods: ['GET', 'POST'])]
|
||||
public function contratsView(
|
||||
?Contrats $contrat,
|
||||
EntityManagerInterface $entityManager,
|
||||
Request $request,
|
||||
Client $client,
|
||||
AppLogger $appLogger,
|
||||
KernelInterface $kernel,
|
||||
Mailer $mailer,
|
||||
): Response {
|
||||
if (!$contrat) {
|
||||
throw $this->createNotFoundException('Contrat non trouvé.');
|
||||
#[Route('/view/{id}', name: 'app_crm_contrats_view', options: ['sitemap' => false], methods: ['GET', 'POST'])]
|
||||
public function view(Contrats $contrat, Request $request): Response
|
||||
{
|
||||
// 1. Actions sur la Caution (Libérer / Encaisser)
|
||||
if ($action = $request->query->get('action')) {
|
||||
return $this->handleCautionAction($contrat, $action);
|
||||
}
|
||||
|
||||
// --- CALCULS DES MONTANTS ---
|
||||
// 2. Paiement Manuel (Accompte / Solde / Caution)
|
||||
if ($type = $request->query->get('type')) {
|
||||
return $this->handleManualPayment($contrat, $type);
|
||||
}
|
||||
|
||||
// 3. Calculs financiers pour l'affichage
|
||||
$totalHt = 0;
|
||||
$totalCaution = 0;
|
||||
$days = $contrat->getDateAt()->diff($contrat->getEndAt())->days + 1;
|
||||
|
||||
foreach ($contrat->getContratsLines() as $line) {
|
||||
$priceLine = $line->getPrice1DayHt() + ($line->getPriceSupDayHt() * ($days - 1));
|
||||
$totalHt += $priceLine;
|
||||
$totalHt += $line->getPrice1DayHt() + ($line->getPriceSupDayHt() * max(0, $days - 1));
|
||||
$totalCaution += $line->getCaution();
|
||||
}
|
||||
|
||||
@@ -105,185 +98,253 @@ class ContratsController extends AbstractController
|
||||
$dejaPaye += $p->getAmount();
|
||||
}
|
||||
}
|
||||
$solde = $totalHt - $dejaPaye;
|
||||
$customer = $contrat->getCustomer();
|
||||
$customerName = $customer->getSurname() . ' ' . $customer->getName();
|
||||
|
||||
// --- TRAITEMENT : ACTIONS SUR CAUTION (Libérer / Encaisser) ---
|
||||
if ($request->query->has('action')) {
|
||||
$action = $request->query->get('action');
|
||||
$paymentCaution = $entityManager->getRepository(ContratsPayments::class)->findOneBy([
|
||||
'contrat' => $contrat,
|
||||
'type' => 'caution',
|
||||
]);
|
||||
|
||||
if ($paymentCaution) {
|
||||
if ($action === 'liberer') {
|
||||
$paymentCaution->setState("release");
|
||||
$subject = "[Ludikevent] Votre caution a été libérée - #" . $contrat->getNumReservation();
|
||||
$template = "mails/customer/caution_release.twig";
|
||||
$logAction = "Libération caution";
|
||||
} else {
|
||||
$paymentCaution->setState("recup");
|
||||
$subject = "[Ludikevent] Votre caution a été encaissée - #" . $contrat->getNumReservation();
|
||||
$template = "mails/customer/caution_encaissement.twig";
|
||||
$logAction = "Encaissement caution";
|
||||
}
|
||||
|
||||
$entityManager->flush();
|
||||
$mailer->send($customer->getEmail(), $customerName, $subject, $template, [
|
||||
'datas' => [
|
||||
'contrat' => $contrat,
|
||||
'payment' => $paymentCaution,
|
||||
'customer' => $customer,
|
||||
'reservationLink' => "https://reservation.ludikevent.fr" . $this->generateUrl('gestion_contrat_view', ['num' => $contrat->getNumReservation()])
|
||||
]
|
||||
]);
|
||||
|
||||
$appLogger->record('PAYMENT', "$logAction pour #" . $contrat->getNumReservation());
|
||||
$this->addFlash("success", "Action effectuée avec succès.");
|
||||
return $this->redirectToRoute('app_crm_contrats_view', ['id' => $contrat->getId()]);
|
||||
}
|
||||
}
|
||||
|
||||
// --- TRAITEMENT : ENREGISTREMENT PAIEMENT MANUEL ---
|
||||
if ($request->query->has('type')) {
|
||||
$type = $request->query->get('type');
|
||||
$amount = match($type) {
|
||||
'accompte' => $totalHt * 0.25,
|
||||
'caution' => $totalCaution,
|
||||
'solde' => $solde,
|
||||
default => 0
|
||||
};
|
||||
|
||||
$payment = $entityManager->getRepository(ContratsPayments::class)->findOneBy([
|
||||
'contrat' => $contrat,
|
||||
'type' => $type,
|
||||
]) ?? new ContratsPayments();
|
||||
|
||||
$payment->setContrat($contrat);
|
||||
$payment->setType($type);
|
||||
$payment->setAmount($amount);
|
||||
$payment->setState("complete");
|
||||
$payment->setPaymentAt(new \DateTimeImmutable());
|
||||
$payment->setValidateAt(new \DateTimeImmutable());
|
||||
$payment->setUpdateAt(new \DateTimeImmutable());
|
||||
$payment->setCard(['type' => 'manuel']);
|
||||
$payment->setPaymentId("MANUAL-" . uniqid());
|
||||
|
||||
// Génération PDF et signature
|
||||
$pdf = new PlPdf($kernel, $payment, $contrat);
|
||||
$pdf->generate();
|
||||
$tmpPath = sys_get_temp_dir() . '/pay_' . uniqid() . '.pdf';
|
||||
file_put_contents($tmpPath, $pdf->Output('S'));
|
||||
|
||||
$payment->setPaymentFile(new UploadedFile($tmpPath, "recu-" . $type . ".pdf", "application/pdf", null, true));
|
||||
$entityManager->persist($payment);
|
||||
$entityManager->flush();
|
||||
|
||||
$signedUrl = $client->autoSignConfirmedPayment($payment);
|
||||
$tmpSigned = sys_get_temp_dir() . '/signed_' . uniqid() . '.pdf';
|
||||
file_put_contents($tmpSigned, file_get_contents($signedUrl));
|
||||
$payment->setPaymentSignedFile(new UploadedFile($tmpSigned, "sign-" . $type . ".pdf", "application/pdf", null, true));
|
||||
|
||||
$entityManager->flush();
|
||||
|
||||
$mailer->send($customer->getEmail(), $customerName, "[Ludikevent] Confirmation de paiement - " . $type, "mails/customer/accompte_confirmation.twig", [
|
||||
'datas' => [
|
||||
'contrat' => $contrat,
|
||||
'payment' => $payment,
|
||||
'customer' => $customer,
|
||||
'reservationLink' => "https://reservation.ludikevent.fr" . $this->generateUrl('gestion_contrat_view', ['num' => $contrat->getNumReservation()])
|
||||
]
|
||||
]);
|
||||
|
||||
$appLogger->record('PAYMENT', "Validation manuelle $type pour #" . $contrat->getNumReservation());
|
||||
$this->addFlash("success", "Paiement $type enregistré.");
|
||||
return $this->redirectToRoute('app_crm_contrats_view', ['id' => $contrat->getId()]);
|
||||
}
|
||||
|
||||
return $this->render('dashboard/contrats/view.twig', [
|
||||
'contrat' => $contrat,
|
||||
'days' => $days,
|
||||
'solde' => $solde,
|
||||
'solde' => $totalHt - $dejaPaye,
|
||||
'totalHT' => $totalHt,
|
||||
'totalCaution' => $totalCaution,
|
||||
'arrhes' => $totalHt * 0.25,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Création d'un contrat
|
||||
*/
|
||||
#[Route(path: '/crm/contrats/add', name: 'app_crm_contrats_create', options: ['sitemap' => false], methods: ['GET', 'POST'])]
|
||||
public function contratsAdd(
|
||||
EntityManagerInterface $entityManager,
|
||||
Request $request,
|
||||
Client $client,
|
||||
DevisRepository $devisRepository,
|
||||
AppLogger $appLogger,
|
||||
KernelInterface $kernel,
|
||||
): Response {
|
||||
#[Route('/add', name: 'app_crm_contrats_create', options: ['sitemap' => false], methods: ['GET', 'POST'])]
|
||||
public function add(Request $request, DevisRepository $devisRepository): Response
|
||||
{
|
||||
$devis = $devisRepository->find($request->get('idDevis', 0));
|
||||
$c = new Contrats();
|
||||
$contrat = new Contrats();
|
||||
|
||||
// Logique de pré-remplissage via Devis...
|
||||
// Pré-remplissage depuis le devis
|
||||
if ($devis instanceof Devis) {
|
||||
$c->setDateAt($devis->getStartAt());
|
||||
$c->setEndAt($devis->getEndAt());
|
||||
$c->setCustomer($devis->getCustomer());
|
||||
$c->setDevis($devis);
|
||||
if ($devis->getAddressShip()) {
|
||||
$c->setAddressEvent($devis->getAddressShip()->getAddress());
|
||||
$c->setZipCodeEvent($devis->getAddressShip()->getZipcode());
|
||||
$c->setTownEvent($devis->getAddressShip()->getCity());
|
||||
}
|
||||
$this->hydrateFromDevis($contrat, $devis);
|
||||
}
|
||||
|
||||
$form = $this->createForm(ContratsType::class, $c);
|
||||
$form = $this->createForm(ContratsType::class, $contrat);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$postData = $request->request->all();
|
||||
|
||||
foreach ($postData['lines'] ?? [] as $line) {
|
||||
$vc = (new ContratsLine())->setContrat($c)->setName($line['name'])->setPrice1DayHt($line['priceHt1Day'])->setPriceSupDayHt($line['priceHtSupDay'])->setCaution($line['caution']);
|
||||
$entityManager->persist($vc);
|
||||
}
|
||||
|
||||
foreach ($postData['options'] ?? [] as $opt) {
|
||||
$vo = (new ContratsOption())->setContrat($c)->setName($opt['name'])->setDetails($opt['details'])->setPrice($opt['priceHt']);
|
||||
$entityManager->persist($vo);
|
||||
}
|
||||
|
||||
$c->setNumReservation($this->generateReservationNumber());
|
||||
$c->setCreateAt(new \DateTimeImmutable());
|
||||
|
||||
// PDFs
|
||||
foreach ([true, false] as $isDocuseal) {
|
||||
$service = new ContratPdfService($kernel, $c, $isDocuseal);
|
||||
$tmp = sys_get_temp_dir() . '/' . uniqid() . '.pdf';
|
||||
file_put_contents($tmp, $service->generate());
|
||||
$file = new UploadedFile($tmp, 'doc.pdf', 'application/pdf', null, true);
|
||||
$isDocuseal ? $c->setDevisDocuSealFile($file) : $c->setDevisFile($file);
|
||||
}
|
||||
|
||||
$entityManager->persist($c);
|
||||
$entityManager->flush();
|
||||
$client->createSubmissionContrat($c);
|
||||
|
||||
$appLogger->record('CREATE', "Contrat généré : " . $c->getNumReservation());
|
||||
return $this->redirectToRoute('app_crm_contrats');
|
||||
return $this->handleCreateContract($contrat, $request);
|
||||
}
|
||||
|
||||
return $this->render('dashboard/contrats/add.twig', [
|
||||
'devis' => $devis,
|
||||
'form' => $form->createView(),
|
||||
'lines' => $lines ?? [],
|
||||
'options' => $options ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
// --- PRIVATE HELPERS ---
|
||||
|
||||
private function handleResendContract(int $id, ContratsRepository $repo): RedirectResponse
|
||||
{
|
||||
$contrat = $repo->find($id);
|
||||
if (!$contrat) {
|
||||
$this->addFlash("danger", "Contrat introuvable.");
|
||||
return $this->redirectToRoute('app_crm_contrats');
|
||||
}
|
||||
|
||||
$this->eventDispatcher->dispatch(new ContratEvent($contrat));
|
||||
|
||||
$this->appLogger->record('RESEND', "Renvoi du contrat N°" . $contrat->getNumReservation());
|
||||
$this->addFlash("success", "Le contrat a bien été envoyé à " . $contrat->getCustomer()->getEmail());
|
||||
|
||||
return $this->redirectToRoute('app_crm_contrats');
|
||||
}
|
||||
|
||||
private function handleCautionAction(Contrats $contrat, string $action): RedirectResponse
|
||||
{
|
||||
$paymentCaution = $this->em->getRepository(ContratsPayments::class)->findOneBy([
|
||||
'contrat' => $contrat,
|
||||
'type' => 'caution',
|
||||
]);
|
||||
|
||||
if (!$paymentCaution) {
|
||||
return $this->redirectToRoute('app_crm_contrats_view', ['id' => $contrat->getId()]);
|
||||
}
|
||||
|
||||
$isRelease = ($action === 'liberer');
|
||||
$newState = $isRelease ? "release" : "recup";
|
||||
$subject = $isRelease
|
||||
? "[Ludikevent] Votre caution a été libérée - #" . $contrat->getNumReservation()
|
||||
: "[Ludikevent] Votre caution a été encaissée - #" . $contrat->getNumReservation();
|
||||
$template = $isRelease
|
||||
? "mails/customer/caution_release.twig"
|
||||
: "mails/customer/caution_encaissement.twig";
|
||||
$logAction = $isRelease ? "Libération caution" : "Encaissement caution";
|
||||
|
||||
$paymentCaution->setState($newState);
|
||||
$this->em->flush();
|
||||
|
||||
$customer = $contrat->getCustomer();
|
||||
$this->mailer->send(
|
||||
$customer->getEmail(),
|
||||
$customer->getSurname() . ' ' . $customer->getName(),
|
||||
$subject,
|
||||
$template,
|
||||
[
|
||||
'datas' => [
|
||||
'contrat' => $contrat,
|
||||
'payment' => $paymentCaution,
|
||||
'customer' => $customer,
|
||||
'reservationLink' => "https://reservation.ludikevent.fr" . $this->generateUrl('gestion_contrat_view', ['num' => $contrat->getNumReservation()])
|
||||
]
|
||||
]
|
||||
);
|
||||
|
||||
$this->appLogger->record('PAYMENT', "$logAction pour #" . $contrat->getNumReservation());
|
||||
$this->addFlash("success", "Action effectuée avec succès.");
|
||||
|
||||
return $this->redirectToRoute('app_crm_contrats_view', ['id' => $contrat->getId()]);
|
||||
}
|
||||
|
||||
private function handleManualPayment(Contrats $contrat, string $type): RedirectResponse
|
||||
{
|
||||
// Calculs basiques pour le montant (à affiner selon vos besoins réels si différent de la vue)
|
||||
$totalHt = 0;
|
||||
$totalCaution = 0;
|
||||
$days = $contrat->getDateAt()->diff($contrat->getEndAt())->days + 1;
|
||||
|
||||
foreach ($contrat->getContratsLines() as $line) {
|
||||
$totalHt += $line->getPrice1DayHt() + ($line->getPriceSupDayHt() * ($days - 1));
|
||||
$totalCaution += $line->getCaution();
|
||||
}
|
||||
foreach ($contrat->getContratsOptions() as $opt) {
|
||||
$totalHt += $opt->getPrice();
|
||||
}
|
||||
|
||||
// Calcul solde restant
|
||||
$dejaPaye = 0;
|
||||
foreach ($contrat->getContratsPayments() as $p) {
|
||||
if ($p->getState() === 'complete' && $p->getType() !== 'caution') {
|
||||
$dejaPaye += $p->getAmount();
|
||||
}
|
||||
}
|
||||
|
||||
$amount = match($type) {
|
||||
'accompte' => $totalHt * 0.25,
|
||||
'caution' => $totalCaution,
|
||||
'solde' => $totalHt - $dejaPaye,
|
||||
default => 0
|
||||
};
|
||||
|
||||
$payment = $this->em->getRepository(ContratsPayments::class)->findOneBy([
|
||||
'contrat' => $contrat,
|
||||
'type' => $type,
|
||||
]) ?? new ContratsPayments();
|
||||
|
||||
$payment->setContrat($contrat)
|
||||
->setType($type)
|
||||
->setAmount($amount)
|
||||
->setState("complete")
|
||||
->setPaymentAt(new \DateTimeImmutable())
|
||||
->setValidateAt(new \DateTimeImmutable())
|
||||
->setUpdateAt(new \DateTimeImmutable())
|
||||
->setCard(['type' => 'manuel'])
|
||||
->setPaymentId("MANUAL-" . uniqid());
|
||||
|
||||
// Génération PDF Reçu
|
||||
$pdf = new PlPdf($this->kernel, $payment, $contrat);
|
||||
$tmpPath = sys_get_temp_dir() . '/pay_' . uniqid() . '.pdf';
|
||||
file_put_contents($tmpPath, $pdf->generate()); // generate() retourne le contenu ou Output('S')
|
||||
|
||||
$payment->setPaymentFile(new UploadedFile($tmpPath, "recu-" . $type . ".pdf", "application/pdf", null, true));
|
||||
|
||||
$this->em->persist($payment);
|
||||
$this->em->flush();
|
||||
|
||||
// Signature automatique (si nécessaire pour manuel ?)
|
||||
$signedUrl = $this->signatureClient->autoSignConfirmedPayment($payment);
|
||||
if ($signedUrl) {
|
||||
$tmpSigned = sys_get_temp_dir() . '/signed_' . uniqid() . '.pdf';
|
||||
file_put_contents($tmpSigned, file_get_contents($signedUrl));
|
||||
$payment->setPaymentSignedFile(new UploadedFile($tmpSigned, "sign-" . $type . ".pdf", "application/pdf", null, true));
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
// Notification Client
|
||||
$customer = $contrat->getCustomer();
|
||||
$this->mailer->send(
|
||||
$customer->getEmail(),
|
||||
$customer->getSurname() . ' ' . $customer->getName(),
|
||||
"[Ludikevent] Confirmation de paiement - " . $type,
|
||||
"mails/customer/accompte_confirmation.twig",
|
||||
[
|
||||
'datas' => [
|
||||
'contrat' => $contrat,
|
||||
'payment' => $payment,
|
||||
'customer' => $customer,
|
||||
'reservationLink' => "https://reservation.ludikevent.fr" . $this->generateUrl('gestion_contrat_view', ['num' => $contrat->getNumReservation()])
|
||||
]
|
||||
]
|
||||
);
|
||||
|
||||
$this->appLogger->record('PAYMENT', "Validation manuelle $type pour #" . $contrat->getNumReservation());
|
||||
$this->addFlash("success", "Paiement $type enregistré.");
|
||||
|
||||
return $this->redirectToRoute('app_crm_contrats_view', ['id' => $contrat->getId()]);
|
||||
}
|
||||
|
||||
private function handleCreateContract(Contrats $contrat, Request $request): RedirectResponse
|
||||
{
|
||||
$postData = $request->request->all();
|
||||
|
||||
// Traitement manuel des lignes et options car non mappés directement dans le formulaire Symfony
|
||||
foreach ($postData['lines'] ?? [] as $line) {
|
||||
$vc = (new ContratsLine())
|
||||
->setContrat($contrat)
|
||||
->setName($line['name'])
|
||||
->setPrice1DayHt($line['priceHt1Day'])
|
||||
->setPriceSupDayHt($line['priceHtSupDay'])
|
||||
->setCaution($line['caution']);
|
||||
$this->em->persist($vc);
|
||||
}
|
||||
|
||||
foreach ($postData['options'] ?? [] as $opt) {
|
||||
$vo = (new ContratsOption())
|
||||
->setContrat($contrat)
|
||||
->setName($opt['name'])
|
||||
->setDetails($opt['details'])
|
||||
->setPrice($opt['priceHt']);
|
||||
$this->em->persist($vo);
|
||||
}
|
||||
|
||||
$contrat->setNumReservation($this->generateReservationNumber());
|
||||
$contrat->setCreateAt(new \DateTimeImmutable());
|
||||
|
||||
// Génération des PDFs (Interne & DocuSeal)
|
||||
foreach ([true, false] as $isDocuseal) {
|
||||
$service = new ContratPdfService($this->kernel, $contrat, $isDocuseal);
|
||||
$tmp = sys_get_temp_dir() . '/' . uniqid() . '.pdf';
|
||||
file_put_contents($tmp, $service->generate());
|
||||
|
||||
$file = new UploadedFile($tmp, 'doc.pdf', 'application/pdf', null, true);
|
||||
$isDocuseal ? $contrat->setDevisDocuSealFile($file) : $contrat->setDevisFile($file);
|
||||
}
|
||||
|
||||
$this->em->persist($contrat);
|
||||
$this->em->flush();
|
||||
|
||||
// Création de la soumission de signature
|
||||
$this->signatureClient->createSubmissionContrat($contrat);
|
||||
|
||||
$this->appLogger->record('CREATE', "Contrat généré : " . $contrat->getNumReservation());
|
||||
return $this->redirectToRoute('app_crm_contrats');
|
||||
}
|
||||
|
||||
private function hydrateFromDevis(Contrats $contrat, Devis $devis): void
|
||||
{
|
||||
$contrat->setDateAt($devis->getStartAt())
|
||||
->setEndAt($devis->getEndAt())
|
||||
->setCustomer($devis->getCustomer())
|
||||
->setDevis($devis);
|
||||
|
||||
if ($address = $devis->getAddressShip()) {
|
||||
$contrat->setAddressEvent($address->getAddress())
|
||||
->setZipCodeEvent($address->getZipcode())
|
||||
->setTownEvent($address->getCity());
|
||||
}
|
||||
}
|
||||
|
||||
private function generateReservationNumber(): string
|
||||
{
|
||||
return 'RESERV-' . date('Ymd') . '-' . substr(str_shuffle('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, 10);
|
||||
|
||||
@@ -10,99 +10,95 @@ use App\Form\CustomerType;
|
||||
use App\Logger\AppLogger;
|
||||
use App\Repository\CustomerAddressRepository;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Service\Search\Client;
|
||||
use App\Service\Stripe\Client as StripeClient;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/crm/customer')]
|
||||
class CustomerController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly AppLogger $appLogger,
|
||||
private readonly EntityManagerInterface $entityManager
|
||||
) {}
|
||||
private readonly StripeClient $stripeClient,
|
||||
private readonly CustomerRepository $customerRepository,
|
||||
private readonly CustomerAddressRepository $addressRepository
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/crm/customer/address/{id}', name: 'app_crm_customer_address', methods: ['GET'])]
|
||||
public function customerAddress(?Customer $customer)
|
||||
#[Route('/address/{id}', name: 'app_crm_customer_address', methods: ['GET'])]
|
||||
public function addressList(Customer $customer): JsonResponse
|
||||
{
|
||||
$addressList = [];
|
||||
foreach ($customer->getCustomerAddresses() as $address) {
|
||||
$addressList[] = [
|
||||
'id' => $address->getId(),
|
||||
'label' => $address->getAddress()." ".$address->getZipcode()." ".$address->getCity(),
|
||||
'label' => sprintf('%s %s %s', $address->getAddress(), $address->getZipcode(), $address->getCity()),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->json([
|
||||
'addressList' => $addressList,
|
||||
]);
|
||||
return $this->json(['addressList' => $addressList]);
|
||||
}
|
||||
#[Route(path: '/crm/customer', name: 'app_crm_customer', methods: ['GET'])]
|
||||
public function index(
|
||||
PaginatorInterface $paginator,
|
||||
CustomerRepository $customerRepository,
|
||||
Request $request
|
||||
): Response {
|
||||
|
||||
#[Route('', name: 'app_crm_customer', methods: ['GET'])]
|
||||
public function index(PaginatorInterface $paginator, Request $request): Response
|
||||
{
|
||||
$this->appLogger->record('VIEW', 'Consultation de la liste des clients');
|
||||
|
||||
// Utilisation d'un QueryBuilder (recommandé pour KNP) ou findAll
|
||||
$query = $customerRepository->createQueryBuilder('c')
|
||||
$query = $this->customerRepository->createQueryBuilder('c')
|
||||
->orderBy('c.surname', 'ASC')
|
||||
->getQuery();
|
||||
|
||||
$pagination = $paginator->paginate(
|
||||
$query,
|
||||
$request->query->getInt('page', 1),
|
||||
20
|
||||
);
|
||||
|
||||
return $this->render('dashboard/customer.twig', [
|
||||
'customers' => $pagination,
|
||||
'customers' => $paginator->paginate($query, $request->query->getInt('page', 1), 20),
|
||||
]);
|
||||
}
|
||||
#[Route(path: '/crm/customer/add', name: 'app_crm_customer_add', methods: ['GET', 'POST'])]
|
||||
public function add(Request $request, EntityManagerInterface $entityManager,\App\Service\Stripe\Client $client): Response
|
||||
|
||||
#[Route('/add', name: 'app_crm_customer_add', methods: ['GET', 'POST'])]
|
||||
public function add(Request $request): Response
|
||||
{
|
||||
$this->appLogger->record('VIEW', 'Consultation de la page de création client');
|
||||
|
||||
$customer = new Customer();
|
||||
$form = $this->createForm(CustomerAddType::class, $customer);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
// Gestion manuelle de l'adresse initiale (hors formulaire mappé)
|
||||
$data = $request->request->all('customer_add');
|
||||
|
||||
$data= $request->request->all()['customer_add'];
|
||||
$address = new CustomerAddress();
|
||||
$address->setCustomer($customer)
|
||||
->setAddress($data['adresse'] ?? '')
|
||||
->setCity($data['city'] ?? '')
|
||||
->setZipcode($data['zipcode'] ?? '')
|
||||
->setCountry($data['country'] ?? '')
|
||||
->setAddress2($data['adresse2'] ?? null)
|
||||
->setAddress3($data['adresse3'] ?? null);
|
||||
|
||||
$userAddresse = new CustomerAddress();
|
||||
$userAddresse->setCustomer($customer);
|
||||
$userAddresse->setAddress($data['adresse']);
|
||||
$userAddresse->setCity($data['city']);
|
||||
$userAddresse->setZipcode($data['zipcode']);
|
||||
$userAddresse->setCountry($data['country']);
|
||||
$userAddresse->setAddress2($data['adresse2']);
|
||||
$userAddresse->setAddress3($data['adresse3']);
|
||||
$entityManager->persist($userAddresse);
|
||||
// 1. Sauvegarde en base de données
|
||||
$entityManager->persist($customer);
|
||||
$entityManager->flush();
|
||||
$this->em->persist($address);
|
||||
$this->em->persist($customer);
|
||||
$this->em->flush();
|
||||
|
||||
// Création Stripe
|
||||
try {
|
||||
$this->stripeClient->createCustomer($customer);
|
||||
} catch (\Exception $e) {
|
||||
$this->addFlash('warning', 'Erreur création Stripe : ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$client->createCustomer($customer);
|
||||
// 2. Log de l'action de création
|
||||
$this->appLogger->record('CREATE', sprintf(
|
||||
'Nouveau client créé : %s %s (Type: %s)',
|
||||
$customer->getSurname(),
|
||||
$customer->getName(),
|
||||
$customer->getType()
|
||||
$customer->getSurname(), $customer->getName(), $customer->getType()
|
||||
));
|
||||
|
||||
// 3. Notification flash pour l'utilisateur
|
||||
$this->addFlash('success', 'Le client a été enregistré avec succès.');
|
||||
|
||||
// 4. Redirection vers la liste
|
||||
return $this->redirectToRoute('app_crm_customer');
|
||||
}
|
||||
|
||||
@@ -111,38 +107,24 @@ class CustomerController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/crm/customer/edit/{id}', name: 'app_crm_customer_edit', methods: ['GET', 'POST'])]
|
||||
public function edit(
|
||||
int $id,
|
||||
CustomerRepository $customerRepository,
|
||||
CustomerAddressRepository $addressRepository, // Injecté pour charger l'adresse
|
||||
Request $request,
|
||||
EntityManagerInterface $entityManager,
|
||||
\App\Service\Stripe\Client $stripeClient,
|
||||
AppLogger $appLogger
|
||||
): Response {
|
||||
$customer = $customerRepository->find($id);
|
||||
|
||||
#[Route('/edit/{id}', name: 'app_crm_customer_edit', methods: ['GET', 'POST'])]
|
||||
public function edit(int $id, Request $request): Response
|
||||
{
|
||||
$customer = $this->customerRepository->find($id);
|
||||
if (!$customer) {
|
||||
throw $this->createNotFoundException('Client introuvable');
|
||||
}
|
||||
|
||||
// --- LOGIQUE ADRESSE (ADD OU EDIT) ---
|
||||
// --- 1. GESTION ADRESSE (Ajout / Modification) ---
|
||||
$idAddr = $request->query->get('idAddr');
|
||||
|
||||
if ($idAddr) {
|
||||
// Mode ÉDITION d'adresse : on cherche l'adresse existante
|
||||
$address = $addressRepository->findOneBy([
|
||||
'id' => $idAddr,
|
||||
'customer' => $customer // Sécurité : on vérifie que l'adresse appartient bien au client
|
||||
]);
|
||||
|
||||
$address = $this->addressRepository->findOneBy(['id' => $idAddr, 'customer' => $customer]);
|
||||
if (!$address) {
|
||||
$this->addFlash('error', 'Adresse introuvable ou non associée à ce client.');
|
||||
return $this->redirectToRoute('app_crm_customer_edit', ['id' => $customer->getId()]);
|
||||
}
|
||||
} else {
|
||||
// Mode AJOUT : nouvelle instance
|
||||
$address = new CustomerAddress();
|
||||
$address->setCustomer($customer);
|
||||
}
|
||||
@@ -151,88 +133,83 @@ class CustomerController extends AbstractController
|
||||
$formAddress->handleRequest($request);
|
||||
|
||||
if ($formAddress->isSubmitted() && $formAddress->isValid()) {
|
||||
// Si c'est une nouvelle adresse, on doit faire persist
|
||||
if (!$address->getId()) {
|
||||
$entityManager->persist($address);
|
||||
$logAction = 'Ajout';
|
||||
} else {
|
||||
$logAction = 'Modification';
|
||||
$isNew = !$address->getId();
|
||||
if ($isNew) {
|
||||
$this->em->persist($address);
|
||||
}
|
||||
$this->em->flush();
|
||||
|
||||
$entityManager->flush();
|
||||
$this->appLogger->record($isNew ? 'CREATE' : 'EDIT', sprintf(
|
||||
'%s adresse pour le client : %s',
|
||||
$isNew ? 'Ajout' : 'Modification',
|
||||
$customer->getName()
|
||||
));
|
||||
|
||||
$appLogger->record($idAddr ? 'EDIT' : 'CREATE', sprintf('%s adresse pour le client : %s', $logAction, $customer->getName()));
|
||||
$this->addFlash('success', sprintf('L\'adresse a été %s avec succès.', $idAddr ? 'modifiée' : 'ajoutée'));
|
||||
|
||||
// On redirige vers la fiche sans le paramètre idAddr pour nettoyer l'URL
|
||||
$this->addFlash('success', sprintf('L\'adresse a été %s avec succès.', $isNew ? 'ajoutée' : 'modifiée'));
|
||||
return $this->redirectToRoute('app_crm_customer_edit', ['id' => $customer->getId()]);
|
||||
}
|
||||
|
||||
// --- LOGIQUE FORMULAIRE CLIENT (Reste identique) ---
|
||||
// --- 2. GESTION CLIENT ---
|
||||
$form = $this->createForm(CustomerType::class, $customer);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if ($customer->getCustomerId()) {
|
||||
try {
|
||||
$stripeClient->updateCustomer($customer);
|
||||
$this->stripeClient->updateCustomer($customer);
|
||||
} catch (\Exception $e) {
|
||||
$this->addFlash('warning', 'Erreur synchro Stripe : ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
$entityManager->flush();
|
||||
$appLogger->record('EDIT', sprintf('Modification du client : %s %s', $customer->getSurname(), $customer->getName()));
|
||||
$this->em->flush();
|
||||
|
||||
$this->appLogger->record('EDIT', sprintf('Modification du client : %s %s', $customer->getSurname(), $customer->getName()));
|
||||
$this->addFlash('success', 'Les informations du client ont été mises à jour.');
|
||||
|
||||
return $this->redirectToRoute('app_crm_customer_edit', ['id' => $customer->getId()]);
|
||||
}
|
||||
|
||||
if ($request->isMethod('GET')) {
|
||||
$appLogger->record('VIEW', sprintf('Consultation de la fiche client : %s', $customer->getName()));
|
||||
if ($request->isMethod('GET') && !$idAddr) {
|
||||
$this->appLogger->record('VIEW', sprintf('Consultation de la fiche client : %s', $customer->getName()));
|
||||
}
|
||||
|
||||
return $this->render('dashboard/customer/show.twig', [
|
||||
'customer' => $customer,
|
||||
'formAddress' => $formAddress->createView(),
|
||||
'form' => $form->createView(),
|
||||
'editingAddress' => (bool)$idAddr // Pour changer le texte du bouton dans le Twig
|
||||
'editingAddress' => (bool)$idAddr
|
||||
]);
|
||||
}
|
||||
#[Route(path: '/crm/customer/delete/{id}', name: 'app_crm_customer_delete', methods: ['GET', 'POST'])]
|
||||
public function delete(
|
||||
int $id,
|
||||
CustomerRepository $customerRepository,
|
||||
Request $request,
|
||||
\App\Service\Stripe\Client $client,
|
||||
AppLogger $appLogger,
|
||||
EntityManagerInterface $entityManager
|
||||
): Response {
|
||||
$customer = $customerRepository->find($id);
|
||||
|
||||
#[Route('/delete/{id}', name: 'app_crm_customer_delete', methods: ['GET', 'POST'])]
|
||||
public function delete(int $id, Request $request): RedirectResponse
|
||||
{
|
||||
$customer = $this->customerRepository->find($id);
|
||||
|
||||
if (!$customer) {
|
||||
$this->addFlash('error', 'Le client demandé n\'existe pas.');
|
||||
return $this->redirectToRoute('app_crm_customer');
|
||||
}
|
||||
|
||||
// Récupération du token CSRF envoyé via data-turbo-method (dans l'URL)
|
||||
$token = $request->query->get('_token');
|
||||
if ($this->isCsrfTokenValid('delete' . $customer->getId(), $token)) {
|
||||
if ($this->isCsrfTokenValid('delete' . $customer->getId(), $request->query->get('_token'))) {
|
||||
$name = sprintf('%s %s', $customer->getSurname(), $customer->getName());
|
||||
|
||||
|
||||
// Log de l'action de suppression (avant suppression réelle)
|
||||
$appLogger->record('DELETE', sprintf('Suppression définitive du client : %s %s', $customer->getSurname(), $customer->getName()));
|
||||
|
||||
// Suppression sur Stripe si l'ID existe
|
||||
// Suppression Stripe
|
||||
if ($customer->getCustomerId()) {
|
||||
$client->deleteCustomer($customer->getCustomerId());
|
||||
try {
|
||||
$this->stripeClient->deleteCustomer($customer->getCustomerId());
|
||||
} catch (\Exception $e) {
|
||||
$this->appLogger->record('ERROR', "Erreur suppression Stripe : " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$entityManager->remove($customer);
|
||||
$entityManager->flush();
|
||||
$this->em->remove($customer);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash('success', sprintf('Le client %s %s a été supprimé définitivement.', $customer->getSurname(), $customer->getName()));
|
||||
$this->appLogger->record('DELETE', "Suppression définitive du client : $name");
|
||||
$this->addFlash('success', "Le client $name a été supprimé définitivement.");
|
||||
} else {
|
||||
$this->addFlash('error', 'Jeton de sécurité invalide. La suppression a été annulée.');
|
||||
$this->addFlash('error', 'Jeton de sécurité invalide.');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_crm_customer');
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
|
||||
namespace App\Controller\Dashboard;
|
||||
|
||||
use App\Entity\CustomerAddress;
|
||||
use App\Entity\Devis;
|
||||
use App\Entity\DevisLine;
|
||||
use App\Entity\DevisOptions;
|
||||
use App\Entity\Product;
|
||||
use App\Event\Signature\DevisSend;
|
||||
use App\Form\NewDevisType;
|
||||
use App\Logger\AppLogger;
|
||||
use App\Repository\AccountRepository;
|
||||
use App\Repository\CustomerAddressRepository;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\DevisLineRepository;
|
||||
@@ -22,61 +19,44 @@ use App\Service\Pdf\DevisPdfService;
|
||||
use App\Service\Signature\Client;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Knp\Bundle\PaginatorBundle\KnpPaginatorBundle;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/crm/devis')]
|
||||
class DevisController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* Liste des administrateurs
|
||||
*/
|
||||
#[Route(path: '/crm/devis', name: 'app_crm_devis', options: ['sitemap' => false], methods: ['GET', 'POST'])]
|
||||
public function devis(
|
||||
EventDispatcherInterface $eventDispatcher,
|
||||
EntityManagerInterface $entityManager,
|
||||
DevisRepository $devisRepository,
|
||||
AppLogger $appLogger,
|
||||
PaginatorInterface $paginator,
|
||||
Request $request,
|
||||
KernelInterface $kernel,
|
||||
): Response
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly AppLogger $appLogger,
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
private readonly Client $signatureClient,
|
||||
private readonly KernelInterface $kernel,
|
||||
private readonly DevisRepository $devisRepository,
|
||||
private readonly ProductRepository $productRepository,
|
||||
private readonly CustomerAddressRepository $customerAddressRepository,
|
||||
private readonly CustomerRepository $customerRepository
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('', name: 'app_crm_devis', options: ['sitemap' => false], methods: ['GET', 'POST'])]
|
||||
public function index(PaginatorInterface $paginator, Request $request): Response
|
||||
{
|
||||
// Gestion du renvoi de la signature
|
||||
if ($request->query->has('resend')) {
|
||||
$quoteId = $request->query->get('resend');
|
||||
$quote = $devisRepository->find($quoteId);
|
||||
|
||||
if ($quote instanceof Devis) {
|
||||
$quote->setState("created_waitsign");
|
||||
$entityManager->persist($quote);
|
||||
$entityManager->flush();
|
||||
// Déclenchement de l'événement de renvoi
|
||||
$event = new DevisSend($quote);
|
||||
$eventDispatcher->dispatch($event);
|
||||
|
||||
// Journalisation et notification
|
||||
$appLogger->record('RESEND', 'Relance signature pour le devis ' . $quote->getNum());
|
||||
$this->addFlash("success", "Le lien de signature pour le devis " . $quote->getNum() . " a été renvoyé au client.");
|
||||
|
||||
return $this->redirectToRoute('app_crm_devis');
|
||||
}
|
||||
|
||||
$this->addFlash("error", "Devis introuvable.");
|
||||
// Gestion de la relance (Action Rapide)
|
||||
if ($resendId = $request->query->get('resend')) {
|
||||
return $this->handleResend($resendId);
|
||||
}
|
||||
|
||||
$appLogger->record('VIEW', 'Consultation de la liste des devis');
|
||||
$this->appLogger->record('VIEW', 'Consultation de la liste des devis');
|
||||
|
||||
// Pagination (Tri décroissant sur la date de création pour voir les plus récents en premier)
|
||||
$pagination = $paginator->paginate(
|
||||
$devisRepository->findBy([], ['createA' => 'DESC']),
|
||||
$this->devisRepository->findBy([], ['createA' => 'DESC']),
|
||||
$request->query->getInt('page', 1),
|
||||
20
|
||||
);
|
||||
@@ -86,226 +66,86 @@ class DevisController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/crm/devis/delete/{id}', name: 'app_crm_devis_delete', options: ['sitemap' => false], methods: ['GET', 'POST'])]
|
||||
public function devisDelete(string $id, AppLogger $appLogger, Client $client, Request $request, DevisRepository $devisRepository, EntityManagerInterface $entityManager): Response
|
||||
#[Route('/add', name: 'app_crm_devis_add', options: ['sitemap' => false], methods: ['GET', 'POST'])]
|
||||
public function add(Request $request, OptionsRepository $optionsRepository): Response
|
||||
{
|
||||
$customer = $devisRepository->find($id);
|
||||
|
||||
if (!$customer) {
|
||||
$this->addFlash('error', 'Le devis demandé n\'existe pas.');
|
||||
return $this->redirectToRoute('app_crm_devis');
|
||||
}
|
||||
$token = $request->query->get('_token');
|
||||
|
||||
if ($this->isCsrfTokenValid('delete' . $customer->getId(), $token)) {
|
||||
$appLogger->record('DELETE', sprintf('Suppression définitive du devis : %s', $customer->getNum()));
|
||||
|
||||
if ($customer->getSignatureId() != "") {
|
||||
$client->cancelSign($customer->getSignatureId());
|
||||
}
|
||||
foreach ($customer->getDevisLines() as $devisLine) {
|
||||
$entityManager->remove($devisLine);
|
||||
}
|
||||
foreach ($customer->getDevisOptions() as $devisOption) {
|
||||
$entityManager->remove($devisOption);
|
||||
}
|
||||
$entityManager->remove($customer);
|
||||
$entityManager->flush();
|
||||
$this->addFlash('success', sprintf('Le devis %s a été supprimé définitivement.', $customer->getNum()));
|
||||
|
||||
} else {
|
||||
$this->addFlash('error', 'Jeton de sécurité invalide. La suppression a été annulée.');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_crm_devis');
|
||||
}
|
||||
|
||||
#[Route(path: '/crm/devis/add', name: 'app_crm_devis_add', options: ['sitemap' => false], methods: ['GET', 'POST'])]
|
||||
public function devisAdd(Client $client, OptionsRepository $optionsRepository, EventDispatcherInterface $eventDispatcher, KernelInterface $kernel, CustomerAddressRepository $customerAddress, ProductRepository $productRepository, EntityManagerInterface $entityManager, CustomerRepository $customerRepository, DevisRepository $devisRepository, AppLogger $appLogger, Request $request): Response
|
||||
{
|
||||
$devisNumber = "DEVIS-" . sprintf('%05d', $devisRepository->count() + 1);
|
||||
$appLogger->record('VIEW', 'Consultation de la création d\'un devis');
|
||||
$devisNumber = "DEVIS-" . sprintf('%05d', $this->devisRepository->count() + 1);
|
||||
$this->appLogger->record('VIEW', 'Consultation de la création d\'un devis');
|
||||
|
||||
$devis = new Devis();
|
||||
$devis->setNum($devisNumber);
|
||||
$devis->setState("draft");
|
||||
$devis->setCreateA(new \DateTimeImmutable());
|
||||
$devis->setUpdateAt(new \DateTimeImmutable());
|
||||
$devis->setNum($devisNumber)
|
||||
->setState("draft")
|
||||
->setCreateA(new \DateTimeImmutable())
|
||||
->setUpdateAt(new \DateTimeImmutable());
|
||||
|
||||
$form = $this->createForm(NewDevisType::class, $devis);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
$this->processDevisForm($devis, $request->request->all());
|
||||
|
||||
$devis->setStartAt(new DateTimeImmutable($_POST['new_devis']['startAt']));
|
||||
$devis->setEndAt(new DateTimeImmutable($_POST['new_devis']['endAt']));
|
||||
$this->generateAndSavePdfs($devis);
|
||||
|
||||
$interval = $devis->getStartAt()->diff($devis->getEndAt());
|
||||
$day = $interval->days;
|
||||
$devis->setBillAddress($customerAddress->find($_POST['devis']['bill_address']));
|
||||
$devis->setAddressShip($customerAddress->find($_POST['devis']['ship_address']));
|
||||
$devis->setCustomer($customerRepository->find($_POST['new_devis']['customer']));
|
||||
foreach ($_POST['lines'] as $cd => $line) {
|
||||
$rLine = new DevisLine();
|
||||
$rLine->setDevi($devis);
|
||||
$rLine->setPos($cd);
|
||||
$rLine->setProduct($line['product']);
|
||||
$rLine->setDay($day);
|
||||
$rLine->setPriceHt(floatval($line['price_ht']));
|
||||
$rLine->setPriceHtSup(floatval($line['price_sup_ht']));
|
||||
$entityManager->persist($rLine);
|
||||
}
|
||||
foreach ($_POST['options'] as $line) {
|
||||
$rLineOptions = new DevisOptions();
|
||||
$rLineOptions->setDevis($devis);
|
||||
$rLineOptions->setOption($line['product']);
|
||||
$rLineOptions->setPriceHt(floatval($line['price_ht']));
|
||||
$entityManager->persist($rLineOptions);
|
||||
}
|
||||
$entityManager->persist($devis);
|
||||
$entityManager->flush();
|
||||
$devis->setState("wait-send")
|
||||
->setUpdateAt(new \DateTimeImmutable());
|
||||
|
||||
$docusealService = new DevisPdfService($kernel, $devis, $entityManager->getRepository(Product::class),true);
|
||||
$contentDocuseal = $docusealService->generate();
|
||||
$this->em->persist($devis);
|
||||
$this->em->flush();
|
||||
|
||||
$this->signatureClient->createSubmissionDevis($devis);
|
||||
|
||||
$tmpPathDocuseal = sys_get_temp_dir() . '/docuseal_' . uniqid() . '.pdf';
|
||||
file_put_contents($tmpPathDocuseal, $contentDocuseal);
|
||||
|
||||
$fileDocuseal = new UploadedFile($tmpPathDocuseal, 'dc_' . $devis->getNum() . '.pdf', 'application/pdf', 0, true);
|
||||
$devis->setDevisDocuSealFile($fileDocuseal);
|
||||
|
||||
|
||||
$devisService = new DevisPdfService($kernel, $devis, $entityManager->getRepository(Product::class), false);
|
||||
$contentDevis = $devisService->generate();
|
||||
|
||||
$tmpPathDevis = sys_get_temp_dir() . '/devis_' . uniqid() . '.pdf';
|
||||
file_put_contents($tmpPathDevis, $contentDevis);
|
||||
|
||||
$fileDevis = new UploadedFile($tmpPathDevis, 'devis_' . $devis->getNum() . '.pdf', 'application/pdf', 0, true);
|
||||
$devis->setDevisFile($fileDevis);
|
||||
|
||||
|
||||
$devis->setState("wait-send");
|
||||
$devis->setUpdateAt(new \DateTimeImmutable());
|
||||
$entityManager->flush();
|
||||
$client->createSubmissionDevis($devis);
|
||||
$this->addFlash('success', sprintf('Le devis %s a été crée.', $devis->getNum()));
|
||||
|
||||
$this->addFlash('success', sprintf('Le devis %s a été créé.', $devis->getNum()));
|
||||
return $this->redirectToRoute('app_crm_devis');
|
||||
}
|
||||
|
||||
return $this->render('dashboard/devis/add.twig', [
|
||||
'form' => $form->createView(),
|
||||
'lines' => [
|
||||
[
|
||||
'product' => '',
|
||||
'days'=>'',
|
||||
'price_ht' => '',
|
||||
'price_sup_ht' =>''
|
||||
],
|
||||
],
|
||||
'options' => [
|
||||
[
|
||||
'product' => '',
|
||||
'details' => '',
|
||||
'price_ht' => '',
|
||||
]
|
||||
]
|
||||
'lines' => [['product' => '', 'days' => '', 'price_ht' => '', 'price_sup_ht' => '']],
|
||||
'options' => [['product' => '', 'details' => '', 'price_ht' => '']],
|
||||
// Variables nécessaires pour la vue d'ajout si elle utilise les mêmes blocks que edit
|
||||
'products' => $this->productRepository->findAll(),
|
||||
'optionsList' => $optionsRepository->findAll(),
|
||||
'shipAddress' => [],
|
||||
'billAddress' => [],
|
||||
'devis' => $devis
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/crm/devis/{id}', name: 'app_crm_devis_edit', options: ['sitemap' => false], methods: ['GET', 'POST'])]
|
||||
public function devisEdit(?Devis $devis,Client $client,DevisLineRepository $devisLineRepository,DevisOptionsRepository $devisOptionsRepository, OptionsRepository $optionsRepository, EventDispatcherInterface $eventDispatcher, KernelInterface $kernel, CustomerAddressRepository $customerAddress, ProductRepository $productRepository, EntityManagerInterface $entityManager, CustomerRepository $customerRepository, DevisRepository $devisRepository, AppLogger $appLogger, Request $request): Response
|
||||
#[Route('/{id}', name: 'app_crm_devis_edit', options: ['sitemap' => false], methods: ['GET', 'POST'])]
|
||||
public function edit(
|
||||
Devis $devis,
|
||||
Request $request,
|
||||
DevisLineRepository $devisLineRepository,
|
||||
DevisOptionsRepository $devisOptionsRepository,
|
||||
OptionsRepository $optionsRepository
|
||||
): Response
|
||||
{
|
||||
$appLogger->record('VIEW', 'Consultation pour modifier le devis '.$devis->getNum());
|
||||
$this->appLogger->record('VIEW', 'Consultation pour modifier le devis ' . $devis->getNum());
|
||||
|
||||
$form = $this->createForm(NewDevisType::class, $devis);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
$devis->setStartAt(new DateTimeImmutable($_POST['new_devis']['startAt']));
|
||||
$devis->setEndAt(new DateTimeImmutable($_POST['new_devis']['endAt']));
|
||||
$devis->setBillAddress($customerAddress->find($_POST['devis']['bill_address']));
|
||||
$devis->setAddressShip($customerAddress->find($_POST['devis']['ship_address']));
|
||||
$devis->setCustomer($customerRepository->find($_POST['new_devis']['customer']));
|
||||
$interval = $devis->getStartAt()->diff($devis->getEndAt());
|
||||
$day = $interval->days;
|
||||
foreach ($_POST['lines'] as $cd => $line) {
|
||||
if($line['id'] != "") {
|
||||
$rLine = $devisLineRepository->find($line['id']);
|
||||
} else {
|
||||
$rLine = new DevisLine();
|
||||
$rLine->setDevi($devis);
|
||||
$rLine->setPos($cd);
|
||||
}
|
||||
$rLine->setDay($day);
|
||||
$rLine->setProduct($line['product']);
|
||||
$rLine->setPriceHt(floatval($line['price_ht']));
|
||||
$rLine->setPriceHtSup(floatval($line['price_sup_ht']));
|
||||
$entityManager->persist($rLine);
|
||||
}
|
||||
$this->processDevisForm($devis, $request->request->all(), $devisLineRepository, $devisOptionsRepository);
|
||||
|
||||
foreach ($_POST['options'] as $line) {
|
||||
if($line['id'] != "") {
|
||||
$rLineOptions = $devisOptionsRepository->find($line['id']);
|
||||
} else {
|
||||
$rLineOptions = new DevisOptions();
|
||||
$rLineOptions->setDevis($devis);
|
||||
}
|
||||
$rLineOptions->setOption($line['product']);
|
||||
$rLineOptions->setDetails($line['details']);
|
||||
$rLineOptions->setPriceHt(floatval($line['price_ht']));
|
||||
$entityManager->persist($rLineOptions);
|
||||
}
|
||||
$entityManager->persist($devis);
|
||||
$entityManager->flush();
|
||||
$this->generateAndSavePdfs($devis);
|
||||
|
||||
$docusealService = new DevisPdfService($kernel, $devis, $entityManager->getRepository(Product::class),true);
|
||||
$contentDocuseal = $docusealService->generate();
|
||||
$devis->setUpdateAt(new \DateTimeImmutable())
|
||||
->setState("wait-send");
|
||||
|
||||
$this->em->persist($devis);
|
||||
$this->em->flush();
|
||||
|
||||
$tmpPathDocuseal = sys_get_temp_dir() . '/docuseal_' . uniqid() . '.pdf';
|
||||
file_put_contents($tmpPathDocuseal, $contentDocuseal);
|
||||
|
||||
$fileDocuseal = new UploadedFile($tmpPathDocuseal, 'dc_' . $devis->getNum() . '.pdf', 'application/pdf', 0, true);
|
||||
$devis->setDevisDocuSealFile($fileDocuseal);
|
||||
|
||||
|
||||
$devisService = new DevisPdfService($kernel, $devis, $entityManager->getRepository(Product::class),false);
|
||||
$contentDevis = $devisService->generate();
|
||||
|
||||
$tmpPathDevis = sys_get_temp_dir() . '/devis_' . uniqid() . '.pdf';
|
||||
file_put_contents($tmpPathDevis, $contentDevis);
|
||||
|
||||
$fileDevis = new UploadedFile($tmpPathDevis, 'devis_' . $devis->getNum() . '.pdf', 'application/pdf', 0, true);
|
||||
$devis->setDevisFile($fileDevis);
|
||||
|
||||
$devis->setUpdateAt(new \DateTimeImmutable());
|
||||
$devis->setState("wait-send");
|
||||
$entityManager->flush();
|
||||
$client->createSubmissionDevis($devis);
|
||||
|
||||
$this->addFlash('success', sprintf('Le devis %s a été modifiée.', $devis->getNum()));
|
||||
$this->signatureClient->createSubmissionDevis($devis);
|
||||
|
||||
$this->addFlash('success', sprintf('Le devis %s a été modifié.', $devis->getNum()));
|
||||
return $this->redirectToRoute('app_crm_devis');
|
||||
|
||||
}
|
||||
$lines =[
|
||||
[
|
||||
'id' => '',
|
||||
'product' => '',
|
||||
'days'=>'',
|
||||
'price_ht' => '',
|
||||
'price_sup_ht' =>''
|
||||
]
|
||||
];
|
||||
$options = [
|
||||
[
|
||||
'id' => '',
|
||||
'product' => '',
|
||||
'details' => '',
|
||||
'price_ht' => '',
|
||||
]
|
||||
];
|
||||
$shipAddress = [];
|
||||
$billAddress =[];
|
||||
foreach ($devis->getDevisLines() as $key => $line) {
|
||||
$lines[$key] = [
|
||||
|
||||
// Préparation des données pour la vue
|
||||
$lines = [];
|
||||
foreach ($devis->getDevisLines() as $line) {
|
||||
$lines[] = [
|
||||
'id' => $line->getId(),
|
||||
'product' => $line->getProduct(),
|
||||
'days' => $line->getDay(),
|
||||
@@ -313,28 +153,184 @@ class DevisController extends AbstractController
|
||||
'price_sup_ht' => $line->getPriceHtSup()
|
||||
];
|
||||
}
|
||||
foreach ($devis->getDevisOptions() as $key => $line) {
|
||||
$options[$key] = [
|
||||
if (empty($lines)) $lines[] = ['id' => '', 'product' => '', 'days' => '', 'price_ht' => '', 'price_sup_ht' => ''];
|
||||
|
||||
$options = [];
|
||||
foreach ($devis->getDevisOptions() as $line) {
|
||||
$options[] = [
|
||||
'id' => $line->getId(),
|
||||
'details' => $line->getDetails(),
|
||||
'product' => $line->getOption(),
|
||||
'price_ht' => $line->getPriceHt(),
|
||||
];
|
||||
}
|
||||
if (empty($options)) $options[] = ['id' => '', 'product' => '', 'details' => '', 'price_ht' => ''];
|
||||
|
||||
foreach ($devis->getCustomer()->getCustomerAddresses() as $customerAddress) {
|
||||
$shipAddress[$customerAddress->getId()] = $customerAddress->getAddress()." ".$customerAddress->getZipcode()." ".$customerAddress->getCity();
|
||||
$billAddress[$customerAddress->getId()] = $customerAddress->getAddress()." ".$customerAddress->getZipcode()." ".$customerAddress->getCity();
|
||||
$shipAddress = [];
|
||||
$billAddress = [];
|
||||
if ($customer = $devis->getCustomer()) {
|
||||
foreach ($customer->getCustomerAddresses() as $addr) {
|
||||
$label = $addr->getAddress() . " " . $addr->getZipcode() . " " . $addr->getCity();
|
||||
$shipAddress[$addr->getId()] = $label;
|
||||
$billAddress[$addr->getId()] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('dashboard/devis/add.twig', [
|
||||
'form' => $form->createView(),
|
||||
'devis' => $devis,
|
||||
'lines' => $lines,
|
||||
'options' => $options,
|
||||
'products' => $productRepository->findAll(),
|
||||
'products' => $this->productRepository->findAll(),
|
||||
'optionsList' => $optionsRepository->findAll(),
|
||||
'shipAddress' => $shipAddress,
|
||||
'billAddress' => $billAddress,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/delete/{id}', name: 'app_crm_devis_delete', options: ['sitemap' => false], methods: ['GET', 'POST'])]
|
||||
public function delete(Devis $devis, Request $request): RedirectResponse
|
||||
{
|
||||
if ($this->isCsrfTokenValid('delete' . $devis->getId(), $request->query->get('_token'))) {
|
||||
$this->appLogger->record('DELETE', sprintf('Suppression définitive du devis : %s', $devis->getNum()));
|
||||
|
||||
if ($devis->getSignatureId()) {
|
||||
$this->signatureClient->cancelSign($devis->getSignatureId());
|
||||
}
|
||||
|
||||
// Suppression manuelle des lignes et options pour éviter les orphelins si cascade non configurée
|
||||
foreach ($devis->getDevisLines() as $line) $this->em->remove($line);
|
||||
foreach ($devis->getDevisOptions() as $opt) $this->em->remove($opt);
|
||||
|
||||
$this->em->remove($devis);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash('success', sprintf('Le devis %s a été supprimé définitivement.', $devis->getNum()));
|
||||
} else {
|
||||
$this->addFlash('error', 'Jeton de sécurité invalide. La suppression a été annulée.');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_crm_devis');
|
||||
}
|
||||
|
||||
// --- PRIVATE HELPERS ---
|
||||
|
||||
private function handleResend(string $id): RedirectResponse
|
||||
{
|
||||
$quote = $this->devisRepository->find($id);
|
||||
|
||||
if (!$quote) {
|
||||
$this->addFlash("error", "Devis introuvable.");
|
||||
return $this->redirectToRoute('app_crm_devis');
|
||||
}
|
||||
|
||||
$quote->setState("created_waitsign");
|
||||
$this->em->flush();
|
||||
|
||||
$this->eventDispatcher->dispatch(new DevisSend($quote));
|
||||
|
||||
$this->appLogger->record('RESEND', 'Relance signature pour le devis ' . $quote->getNum());
|
||||
$this->addFlash("success", "Le lien de signature pour le devis " . $quote->getNum() . " a été renvoyé au client.");
|
||||
|
||||
return $this->redirectToRoute('app_crm_devis');
|
||||
}
|
||||
|
||||
/**
|
||||
* Traite les données brutes POST pour hydrater le Devis (Lignes, Options, Dates, Adresses)
|
||||
*/
|
||||
private function processDevisForm(
|
||||
Devis $devis,
|
||||
array $postData,
|
||||
?DevisLineRepository $lineRepo = null,
|
||||
?DevisOptionsRepository $optRepo = null
|
||||
): void
|
||||
{
|
||||
$formData = $postData['new_devis'] ?? [];
|
||||
$devisData = $postData['devis'] ?? [];
|
||||
|
||||
// 1. Champs de base
|
||||
if (!empty($formData['startAt'])) $devis->setStartAt(new DateTimeImmutable($formData['startAt']));
|
||||
if (!empty($formData['endAt'])) $devis->setEndAt(new DateTimeImmutable($formData['endAt']));
|
||||
|
||||
if (!empty($devisData['bill_address'])) $devis->setBillAddress($this->customerAddressRepository->find($devisData['bill_address']));
|
||||
if (!empty($devisData['ship_address'])) $devis->setAddressShip($this->customerAddressRepository->find($devisData['ship_address']));
|
||||
if (!empty($formData['customer'])) $devis->setCustomer($this->customerRepository->find($formData['customer']));
|
||||
|
||||
// Calcul durée
|
||||
$day = 1;
|
||||
if ($devis->getStartAt() && $devis->getEndAt()) {
|
||||
$interval = $devis->getStartAt()->diff($devis->getEndAt());
|
||||
$day = $interval->days ?: 1;
|
||||
}
|
||||
|
||||
// 2. Lignes Produits
|
||||
if (!empty($postData['lines']) && is_array($postData['lines'])) {
|
||||
foreach ($postData['lines'] as $cd => $lineData) {
|
||||
if (empty($lineData['product'])) continue;
|
||||
|
||||
$line = null;
|
||||
if ($lineRepo && !empty($lineData['id'])) {
|
||||
$line = $lineRepo->find($lineData['id']);
|
||||
}
|
||||
|
||||
if (!$line) {
|
||||
$line = new DevisLine();
|
||||
$line->setDevi($devis);
|
||||
$line->setPos($cd);
|
||||
}
|
||||
|
||||
$line->setDay($day)
|
||||
->setProduct($lineData['product'])
|
||||
->setPriceHt(floatval($lineData['price_ht'] ?? 0))
|
||||
->setPriceHtSup(floatval($lineData['price_sup_ht'] ?? 0));
|
||||
|
||||
$this->em->persist($line);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Lignes Options
|
||||
if (!empty($postData['options']) && is_array($postData['options'])) {
|
||||
foreach ($postData['options'] as $optData) {
|
||||
if (empty($optData['product'])) continue;
|
||||
|
||||
$opt = null;
|
||||
if ($optRepo && !empty($optData['id'])) {
|
||||
$opt = $optRepo->find($optData['id']);
|
||||
}
|
||||
|
||||
if (!$opt) {
|
||||
$opt = new DevisOptions();
|
||||
$opt->setDevis($devis);
|
||||
}
|
||||
|
||||
$opt->setOption($optData['product'])
|
||||
->setDetails($optData['details'] ?? '')
|
||||
->setPriceHt(floatval($optData['price_ht'] ?? 0));
|
||||
|
||||
$this->em->persist($opt);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->persist($devis);
|
||||
}
|
||||
|
||||
private function generateAndSavePdfs(Devis $devis): void
|
||||
{
|
||||
// 1. DocuSeal (Version pour signature)
|
||||
$docusealService = new DevisPdfService($this->kernel, $devis, $this->productRepository, true);
|
||||
$this->savePdfFile($devis, $docusealService->generate(), 'dc_', 'setDevisDocuSealFile');
|
||||
|
||||
// 2. Interne (Version simple)
|
||||
$devisService = new DevisPdfService($this->kernel, $devis, $this->productRepository, false);
|
||||
$this->savePdfFile($devis, $devisService->generate(), 'devis_', 'setDevisFile');
|
||||
}
|
||||
|
||||
private function savePdfFile(Devis $devis, string $content, string $prefix, string $setterMethod): void
|
||||
{
|
||||
$tmpPath = sys_get_temp_dir() . '/' . $prefix . uniqid() . '.pdf';
|
||||
file_put_contents($tmpPath, $content);
|
||||
|
||||
$file = new UploadedFile($tmpPath, $prefix . $devis->getNum() . '.pdf', 'application/pdf', null, true);
|
||||
$devis->$setterMethod($file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
namespace App\Controller\Dashboard;
|
||||
|
||||
use App\Entity\ContratsPayments;
|
||||
use App\Logger\AppLogger;
|
||||
use App\Repository\ContratsPaymentsRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Color;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -17,39 +17,25 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Vich\UploaderBundle\Templating\Helper\UploaderHelper;
|
||||
|
||||
#[Route('/crm/facture')]
|
||||
class FactureController extends AbstractController
|
||||
{
|
||||
#[Route(path: '/crm/facture', name: 'app_crm_facture', methods: ['GET'])]
|
||||
public function index(
|
||||
Request $request,
|
||||
ContratsPaymentsRepository $contratsPaymentsRepo,
|
||||
AppLogger $appLogger,
|
||||
UploaderHelper $uploaderHelper,
|
||||
PaginatorInterface $paginator
|
||||
): Response {
|
||||
public function __construct(
|
||||
private readonly ContratsPaymentsRepository $contratsPaymentsRepo,
|
||||
private readonly AppLogger $appLogger,
|
||||
private readonly UploaderHelper $uploaderHelper
|
||||
) {
|
||||
}
|
||||
|
||||
// 1. Gestion des dates par défaut (Début et Fin du mois en cours)
|
||||
$startDateStr = $request->query->get('startDate');
|
||||
$endDateStr = $request->query->get('endDate');
|
||||
#[Route('', name: 'app_crm_facture', methods: ['GET'])]
|
||||
public function index(Request $request, PaginatorInterface $paginator): Response
|
||||
{
|
||||
// 1. Récupération des dates et filtres
|
||||
[$startDate, $endDate] = $this->getFilterDates($request);
|
||||
$searchTerm = $request->query->get('q', '');
|
||||
|
||||
if (!$startDateStr) {
|
||||
$startDate = new \DateTime('first day of this month 00:00:00');
|
||||
$startDateStr = $startDate->format('Y-m-d');
|
||||
} else {
|
||||
$startDate = new \DateTime($startDateStr . ' 00:00:00');
|
||||
}
|
||||
|
||||
if (!$endDateStr) {
|
||||
$endDate = new \DateTime('last day of this month 23:59:59');
|
||||
$endDateStr = $endDate->format('Y-m-d');
|
||||
} else {
|
||||
$endDate = new \DateTime($endDateStr . ' 23:59:59');
|
||||
}
|
||||
|
||||
// 2. Construction de la requête de recherche (QueryBuilder)
|
||||
// On récupère une instance de QueryBuilder pour la pagination ou l'export
|
||||
$queryBuilder = $contratsPaymentsRepo->createQueryBuilder('p')
|
||||
// 2. Construction de la requête
|
||||
$queryBuilder = $this->contratsPaymentsRepo->createQueryBuilder('p')
|
||||
->leftJoin('p.contrat', 'c')
|
||||
->leftJoin('c.customer', 'u')
|
||||
->where('p.paymentAt BETWEEN :start AND :end')
|
||||
@@ -62,56 +48,67 @@ class FactureController extends AbstractController
|
||||
->setParameter('q', '%' . $searchTerm . '%');
|
||||
}
|
||||
|
||||
// 3. Gestion de l'extraction Excel
|
||||
// 3. Export Excel (Action Rapide)
|
||||
if ($request->query->has('extract')) {
|
||||
// Pour l'export, on récupère tous les résultats filtrés sans pagination
|
||||
$allFilteredPayments = $queryBuilder->getQuery()->getResult();
|
||||
|
||||
// On ne garde que les paiements complétés pour l'export comptable
|
||||
$dataToExport = array_filter($allFilteredPayments, function($p) {
|
||||
return $p->getState() === 'complete';
|
||||
});
|
||||
|
||||
if (empty($dataToExport)) {
|
||||
$this->addFlash('warning', 'Aucune donnée validée à exporter pour cette période.');
|
||||
return $this->redirectToRoute('app_crm_facture', [
|
||||
'startDate' => $startDateStr,
|
||||
'endDate' => $endDateStr,
|
||||
'q' => $searchTerm
|
||||
]);
|
||||
}
|
||||
|
||||
$appLogger->record('EXPORT_EXCEL', "Export des factures du {$startDateStr} au {$endDateStr}");
|
||||
|
||||
return $this->generateExcelExport(
|
||||
$dataToExport,
|
||||
$uploaderHelper,
|
||||
$request->getSchemeAndHttpHost(),
|
||||
$startDateStr,
|
||||
$endDateStr
|
||||
);
|
||||
return $this->handleExcelExport($queryBuilder, $request, $startDate, $endDate);
|
||||
}
|
||||
|
||||
// 4. Pagination des résultats pour l'affichage Web
|
||||
$pagination = $paginator->paginate(
|
||||
$queryBuilder, // Query object, pas le résultat final
|
||||
$request->query->getInt('page', 1), // Numéro de page
|
||||
15 // Nombre d'éléments par page
|
||||
);
|
||||
// 4. Affichage standard
|
||||
// (Log optionnel ici si vous souhaitez logger chaque consultation)
|
||||
|
||||
return $this->render('dashboard/contrats/facture.twig', [
|
||||
'pagination' => $pagination,
|
||||
'startDate' => $startDateStr,
|
||||
'endDate' => $endDateStr,
|
||||
'pagination' => $paginator->paginate($queryBuilder, $request->query->getInt('page', 1), 15),
|
||||
'startDate' => $startDate->format('Y-m-d'),
|
||||
'endDate' => $endDate->format('Y-m-d'),
|
||||
'searchTerm' => $searchTerm,
|
||||
'active' => $request->query->get('active', 'facture'),
|
||||
]);
|
||||
}
|
||||
|
||||
// --- PRIVATE HELPERS ---
|
||||
|
||||
/**
|
||||
* Génère un fichier Excel XLSX formaté
|
||||
* Retourne [Start DateTime, End DateTime] basé sur la requête ou les valeurs par défaut
|
||||
*/
|
||||
private function generateExcelExport(array $payments, UploaderHelper $uploaderHelper, string $host, string $start, string $end): StreamedResponse
|
||||
private function getFilterDates(Request $request): array
|
||||
{
|
||||
$startStr = $request->query->get('startDate');
|
||||
$endStr = $request->query->get('endDate');
|
||||
|
||||
$start = $startStr ? new \DateTime($startStr . ' 00:00:00') : new \DateTime('first day of this month 00:00:00');
|
||||
$end = $endStr ? new \DateTime($endStr . ' 23:59:59') : new \DateTime('last day of this month 23:59:59');
|
||||
|
||||
return [$start, $end];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gère la logique de récupération des données et déclenche la génération du stream Excel
|
||||
*/
|
||||
private function handleExcelExport(QueryBuilder $qb, Request $request, \DateTime $start, \DateTime $end): Response
|
||||
{
|
||||
$payments = $qb->getQuery()->getResult();
|
||||
|
||||
// On garde uniquement les paiements validés pour la compta
|
||||
$dataToExport = array_filter($payments, fn($p) => $p->getState() === 'complete');
|
||||
|
||||
if (empty($dataToExport)) {
|
||||
$this->addFlash('warning', 'Aucune donnée validée à exporter pour cette période.');
|
||||
return $this->redirectToRoute('app_crm_facture', [
|
||||
'startDate' => $start->format('Y-m-d'),
|
||||
'endDate' => $end->format('Y-m-d'),
|
||||
'q' => $request->query->get('q')
|
||||
]);
|
||||
}
|
||||
|
||||
$this->appLogger->record('EXPORT_EXCEL', sprintf("Export des factures du %s au %s", $start->format('Y-m-d'), $end->format('Y-m-d')));
|
||||
|
||||
return $this->generateExcelStream($dataToExport, $request->getSchemeAndHttpHost(), $start->format('Y-m-d'), $end->format('Y-m-d'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le fichier Excel physique via StreamedResponse
|
||||
*/
|
||||
private function generateExcelStream(array $payments, string $host, string $startStr, string $endStr): StreamedResponse
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
@@ -130,25 +127,26 @@ class FactureController extends AbstractController
|
||||
|
||||
foreach ($headers as $cell => $label) {
|
||||
$sheet->setCellValue($cell, $label);
|
||||
$sheet->getStyle($cell)->getFont()->setBold(true)->setColor(new Color('FFFFFF'));
|
||||
$sheet->getStyle($cell)->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('4F46E5');
|
||||
$style = $sheet->getStyle($cell);
|
||||
$style->getFont()->setBold(true)->setColor(new Color('FFFFFF'));
|
||||
$style->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('4F46E5');
|
||||
}
|
||||
|
||||
$row = 2;
|
||||
foreach ($payments as $payment) {
|
||||
$contrat = $payment->getContrat();
|
||||
$customer = $contrat ? $contrat->getCustomer() : null;
|
||||
$customer = $contrat?->getCustomer();
|
||||
|
||||
$sheet->setCellValue('A' . $row, $payment->getPaymentAt() ? $payment->getPaymentAt()->format('d/m/Y H:i') : '');
|
||||
$sheet->setCellValue('B' . $row, $customer ? strtoupper((string)$customer->getName()) . ' ' . $customer->getSurname() : 'Inconnu');
|
||||
$sheet->setCellValue('C' . $row, $contrat ? $contrat->getNumReservation() : 'N/A');
|
||||
$sheet->setCellValue('D' . $row, strtoupper((string)$payment->getType()));
|
||||
$sheet->setCellValue('A' . $row, $payment->getPaymentAt()?->format('d/m/Y H:i') ?? '');
|
||||
$sheet->setCellValue('B' . $row, $customer ? strtoupper($customer->getName()) . ' ' . $customer->getSurname() : 'Inconnu');
|
||||
$sheet->setCellValue('C' . $row, $contrat?->getNumReservation() ?? 'N/A');
|
||||
$sheet->setCellValue('D' . $row, strtoupper($payment->getType()));
|
||||
$sheet->setCellValue('E' . $row, $payment->getAmount());
|
||||
$sheet->getStyle('E' . $row)->getNumberFormat()->setFormatCode('#,##0.00" €"');
|
||||
$sheet->setCellValue('F' . $row, $payment->getState());
|
||||
|
||||
// Gestion du lien vers le fichier justificatif (VichUploader)
|
||||
$assetPath = $uploaderHelper->asset($payment, 'paymentFile');
|
||||
// Lien VichUploader
|
||||
$assetPath = $this->uploaderHelper->asset($payment, 'paymentFile');
|
||||
if ($assetPath) {
|
||||
$sheet->setCellValue('G' . $row, 'Voir le document');
|
||||
$sheet->getCell('G' . $row)->getHyperlink()->setUrl($host . $assetPath);
|
||||
@@ -160,17 +158,16 @@ class FactureController extends AbstractController
|
||||
$row++;
|
||||
}
|
||||
|
||||
// Auto-size des colonnes
|
||||
foreach (range('A', 'G') as $col) {
|
||||
$sheet->getColumnDimension($col)->setAutoSize(true);
|
||||
}
|
||||
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
$response = new StreamedResponse(function() use ($writer) {
|
||||
$response = new StreamedResponse(function () use ($writer) {
|
||||
$writer->save('php://output');
|
||||
});
|
||||
|
||||
$filename = "compta_export_{$start}_au_{$end}.xlsx";
|
||||
$filename = sprintf("compta_export_%s_au_%s.xlsx", $startStr, $endStr);
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', 'attachment;filename="' . $filename . '"');
|
||||
$response->headers->set('Cache-Control', 'max-age=0');
|
||||
|
||||
@@ -6,299 +6,341 @@ use App\Entity\Formules;
|
||||
use App\Entity\FormulesOptionsInclus;
|
||||
use App\Entity\FormulesProductInclus;
|
||||
use App\Entity\FormulesRestriction;
|
||||
use App\Entity\Options;
|
||||
use App\Entity\Product;
|
||||
use App\Entity\ProductDoc;
|
||||
use App\Form\FormulesType;
|
||||
use App\Form\OptionsType;
|
||||
use App\Form\ProductDocType;
|
||||
use App\Form\ProductType;
|
||||
use App\Logger\AppLogger;
|
||||
use App\Repository\FormulesProductInclusRepository;
|
||||
use App\Repository\FormulesRepository;
|
||||
use App\Repository\OptionsRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use App\Service\Stripe\Client;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Presta\SitemapBundle\Messenger\DumpSitemapMessage;
|
||||
use Vich\UploaderBundle\Templating\Helper\UploaderHelper;
|
||||
|
||||
#[Route('/crm/formules')]
|
||||
class FormulesController extends AbstractController
|
||||
{
|
||||
// --- JSON ENDPOINTS ---
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly AppLogger $appLogger,
|
||||
private readonly FormulesRepository $formulesRepository
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/crm/formules', name: 'app_crm_formules', methods: ['GET','POST'])]
|
||||
public function formules(PaginatorInterface $paginator,EntityManagerInterface $entityManager,AppLogger $appLogger,Request $request,FormulesRepository $formulesRepository): Response
|
||||
#[Route('', name: 'app_crm_formules', methods: ['GET', 'POST'])]
|
||||
public function index(Request $request, PaginatorInterface $paginator): Response
|
||||
{
|
||||
if($request->isMethod('POST')) {
|
||||
$items = $request->getContent();
|
||||
$items = json_decode($items, true)['items'];
|
||||
foreach ($items as $key => $item) {
|
||||
$fv = $formulesRepository->find($item);
|
||||
$fv->setPos($key);
|
||||
$entityManager->persist($fv);
|
||||
// Gestion du réordonnancement (Drag & Drop via JSON)
|
||||
if ($request->isMethod('POST')) {
|
||||
$data = json_decode($request->getContent(), true);
|
||||
if (isset($data['items']) && is_array($data['items'])) {
|
||||
foreach ($data['items'] as $key => $id) {
|
||||
$formule = $this->formulesRepository->find($id);
|
||||
if ($formule) {
|
||||
$formule->setPos($key);
|
||||
$this->em->persist($formule);
|
||||
}
|
||||
}
|
||||
$this->em->flush();
|
||||
}
|
||||
$entityManager->flush();
|
||||
return $this->json([]);
|
||||
return $this->json(['status' => 'success']);
|
||||
}
|
||||
$appLogger->record('VIEW', 'Consultation des formules');
|
||||
|
||||
$this->appLogger->record('VIEW', 'Consultation des formules');
|
||||
|
||||
$query = $this->formulesRepository->findBy([], ['pos' => 'asc']);
|
||||
|
||||
return $this->render('dashboard/formules.twig', [
|
||||
'formules' => $paginator->paginate($formulesRepository->findBy([],['pos'=>'asc']), $request->query->getInt('page', 1), 10),
|
||||
'formules' => $paginator->paginate($query, $request->query->getInt('page', 1), 10),
|
||||
]);
|
||||
}
|
||||
#[Route(path: '/crm/formules/delete/{id}', name: 'app_crm_formules_delete', methods: ['POST', 'GET'])]
|
||||
public function formulesDelete(
|
||||
?Formules $formules,
|
||||
AppLogger $appLogger,
|
||||
EntityManagerInterface $entityManager,
|
||||
Request $request
|
||||
): Response {
|
||||
// 1. Vérification de l'existence de la formule
|
||||
if (!$formules instanceof Formules) {
|
||||
$this->addFlash('error', 'Formule introuvable.');
|
||||
return $this->redirectToRoute('app_crm_formules');
|
||||
|
||||
#[Route('/add', name: 'app_crm_formules_add', methods: ['GET', 'POST'])]
|
||||
public function add(Request $request): Response
|
||||
{
|
||||
$this->appLogger->record('VIEW', 'Consultation page création formules');
|
||||
|
||||
$formule = new Formules();
|
||||
$formule->setIsPublish(false)
|
||||
->setUpdatedAt(new \DateTimeImmutable())
|
||||
->setType($request->query->get('type', 'pack'));
|
||||
|
||||
$form = $this->createForm(FormulesType::class, $formule);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->em->persist($formule);
|
||||
|
||||
// Création automatique des restrictions pour les formules gratuites
|
||||
if ($formule->getType() === "free") {
|
||||
$restriction = new FormulesRestriction();
|
||||
$restriction->setFormule($formule)
|
||||
->setNbStructureMax(0)
|
||||
->setNbAlimentaireMax(0)
|
||||
->setNbBarhumsMax(0)
|
||||
->setRestrictionConfig([]);
|
||||
$this->em->persist($restriction);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
$this->appLogger->record('CREATED', "Création de la formule : " . $formule->getName());
|
||||
$this->addFlash("success", "La formule a été créée avec succès.");
|
||||
|
||||
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formule->getId()]);
|
||||
}
|
||||
|
||||
// 2. Vérification du jeton CSRF (Sécurité contre les suppressions malveillantes)
|
||||
// Note: Dans ton Twig, tu passes déjà le token en query string
|
||||
if ($this->isCsrfTokenValid('delete' . $formules->getId(), $request->query->get('_token'))) {
|
||||
$nomFormule = $formules->getName();
|
||||
return $this->render('dashboard/formules/add.twig', [
|
||||
'formule' => $formule,
|
||||
'form' => $form->createView(),
|
||||
'type' => $formule->getType(),
|
||||
]);
|
||||
}
|
||||
|
||||
if($formules->getFormulesRestriction() instanceof FormulesRestriction)
|
||||
$entityManager->remove($formules->getFormulesRestriction());
|
||||
// 3. Suppression
|
||||
foreach ($formules->getFormulesProductIncluses() as $formulesProductInclus)
|
||||
$entityManager->remove($formulesProductInclus);
|
||||
$entityManager->remove($formules);
|
||||
$entityManager->flush();
|
||||
#[Route('/{id}', name: 'app_crm_formules_view', methods: ['GET', 'POST'])]
|
||||
public function view(
|
||||
Formules $formule,
|
||||
Request $request,
|
||||
ProductRepository $productRepository
|
||||
): Response {
|
||||
// Changement de type via query param si nécessaire
|
||||
if ($type = $request->query->get('type')) {
|
||||
$formule->setType($type);
|
||||
}
|
||||
|
||||
// 4. Logging & Feedback
|
||||
$appLogger->record('DELETE', 'Suppression définitive de la formule : ' . $nomFormule);
|
||||
$this->addFlash('success', 'La formule "' . $nomFormule . '" a été supprimée.');
|
||||
// 1. Actions Rapides (Query Param 'act')
|
||||
if ($request->query->get('act') === 'togglePublish') {
|
||||
return $this->handleTogglePublish($formule, $request->query->get('status') === 'true');
|
||||
}
|
||||
|
||||
// 2. Traitement des formulaires manuels (détection via clés POST)
|
||||
if ($request->isMethod('POST')) {
|
||||
if ($request->request->has('lines')) {
|
||||
return $this->handleUpdateProducts($formule, $request->request->all('lines'), $productRepository);
|
||||
}
|
||||
if ($request->request->has('option')) {
|
||||
return $this->handleUpdateOptions($formule, $request->request->all('option'));
|
||||
}
|
||||
if ($request->request->has('rest')) {
|
||||
return $this->handleUpdateRestrictionConfig($formule, $request->request->all('rest'));
|
||||
}
|
||||
if ($request->request->has('price')) {
|
||||
return $this->handleUpdatePrices($formule, $request->request->all('price'));
|
||||
}
|
||||
if ($request->request->has('nbStructureMax')) {
|
||||
return $this->handleUpdateLimits($formule, $request);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Formulaire Symfony principal
|
||||
$form = $this->createForm(FormulesType::class, $formule);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->em->persist($formule);
|
||||
$this->em->flush();
|
||||
|
||||
$this->appLogger->record('UPDATE', "Modification de la formule (infos) : " . $formule->getName());
|
||||
$this->addFlash("success", "La formule a été modifiée avec succès.");
|
||||
|
||||
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formule->getId()]);
|
||||
}
|
||||
|
||||
return $this->render('dashboard/formules/view.twig', [
|
||||
'formule' => $formule,
|
||||
'form' => $form->createView(),
|
||||
'type' => $formule->getType(),
|
||||
'lines' => $this->prepareLines($formule),
|
||||
'option' => $this->prepareOptions($formule),
|
||||
'restriction' => $this->prepareRestrictions($formule),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/delete/{id}', name: 'app_crm_formules_delete', methods: ['POST', 'GET'])]
|
||||
public function delete(Formules $formule, Request $request): RedirectResponse
|
||||
{
|
||||
if ($this->isCsrfTokenValid('delete' . $formule->getId(), $request->query->get('_token'))) {
|
||||
$name = $formule->getName();
|
||||
|
||||
if ($restriction = $formule->getFormulesRestriction()) {
|
||||
$this->em->remove($restriction);
|
||||
}
|
||||
foreach ($formule->getFormulesProductIncluses() as $inclus) {
|
||||
$this->em->remove($inclus);
|
||||
}
|
||||
|
||||
$this->em->remove($formule);
|
||||
$this->em->flush();
|
||||
|
||||
$this->appLogger->record('DELETE', "Suppression définitive de la formule : $name");
|
||||
$this->addFlash('success', "La formule \"$name\" a été supprimée.");
|
||||
} else {
|
||||
$this->addFlash('error', 'Jeton de sécurité invalide.');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_crm_formules');
|
||||
}
|
||||
#[Route(path: '/crm/formules/add', name: 'app_crm_formules_add', methods: ['GET','POST'])]
|
||||
public function formulesAdd(EntityManagerInterface $entityManager,PaginatorInterface $paginator,AppLogger $appLogger,Request $request,FormulesRepository $formulesRepository): Response
|
||||
|
||||
// --- PRIVATE HELPERS : ACTIONS ---
|
||||
|
||||
private function handleTogglePublish(Formules $formule, bool $status): RedirectResponse
|
||||
{
|
||||
$appLogger->record('VIEW', 'Consultation page création formules');
|
||||
$formule->setIsPublish($status);
|
||||
$this->em->flush();
|
||||
|
||||
$formules = new Formules();
|
||||
$formules->setIsPublish(false);
|
||||
$formules->setUpdatedAt(new \DateTimeImmutable());
|
||||
$formules->setType($request->get('type',"pack")); // pack selected // free add listing allow product
|
||||
$action = $status ? 'Publication' : 'Désactivation';
|
||||
$this->appLogger->record('UPDATE', "$action de la formule " . $formule->getName());
|
||||
$this->addFlash('success', 'Statut mis à jour avec succès.');
|
||||
|
||||
$form = $this->createForm(FormulesType::class,$formules);
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$entityManager->persist($formules);
|
||||
if($formules->getType() == "free") {
|
||||
$formuleRestriction = new FormulesRestriction();
|
||||
$formuleRestriction->setFormule($formules);
|
||||
$formuleRestriction->setNbStructureMax(0);
|
||||
$formuleRestriction->setNbAlimentaireMax(0);
|
||||
$formuleRestriction->setNbBarhumsMax(0);
|
||||
$formuleRestriction->setRestrictionConfig([]);
|
||||
$entityManager->persist($formuleRestriction);
|
||||
}
|
||||
$entityManager->flush();
|
||||
$appLogger->record('CREATED', "Création de la formule : " . $formules->getName());
|
||||
$this->addFlash("success", "La formule a été créée avec succès.");
|
||||
return $this->redirectToRoute('app_crm_formules_view',['id'=>$formules->getId()]);
|
||||
}
|
||||
|
||||
return $this->render('dashboard/formules/add.twig', [
|
||||
'formule' => $formules,
|
||||
'form' => $form->createView(),
|
||||
'type' => $request->get('type',"pack"),
|
||||
]);
|
||||
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formule->getId()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/crm/formules/{id}', name: 'app_crm_formules_view', methods: ['GET', 'POST'])]
|
||||
public function formulesView(?Formules $formules,FormulesProductInclusRepository $formulesProductInclusRepository,ProductRepository $productRepository, Request $request, EntityManagerInterface $entityManager, AppLogger $appLogger): Response
|
||||
private function handleUpdateProducts(Formules $formule, array $lines, ProductRepository $productRepo): RedirectResponse
|
||||
{
|
||||
foreach ($lines as $line) {
|
||||
if (empty($line['product'])) continue;
|
||||
|
||||
if (!$formules instanceof Formules) {
|
||||
$this->addFlash('error', 'Formule introuvable.');
|
||||
return $this->redirectToRoute('app_crm_formules');
|
||||
}
|
||||
if($request->query->has('type'))
|
||||
$formules->setType($request->get('type',"pack"));
|
||||
// 1. GESTION DU STATUT (Toggle Publish)
|
||||
if ($request->get('act') === 'togglePublish') {
|
||||
$status = $request->get('status') === 'true';
|
||||
$formules->setIsPublish($status);
|
||||
$entityManager->flush();
|
||||
|
||||
$appLogger->record('UPDATE', ($status ? 'Publication' : 'Désactivation') . ' de la formule ' . $formules->getName());
|
||||
$this->addFlash('success', 'Statut mis à jour avec succès.');
|
||||
|
||||
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formules->getId()]);
|
||||
}
|
||||
|
||||
if ($request->isMethod('POST') && $request->request->has('lines')) {
|
||||
$lines = $request->request->all('lines');
|
||||
foreach ($lines as $line) {
|
||||
if(isset($line['id']) && $line['id'] !="") {
|
||||
$productInclus = $entityManager->getRepository(FormulesProductInclus::class)->find($line['id']);
|
||||
} else {
|
||||
$productInclus = new FormulesProductInclus();
|
||||
$productInclus->setFormules($formules);
|
||||
$productInclus->setConfig([]);
|
||||
}
|
||||
$productInclus->setPRODUCT($productRepository->findOneBy(['name'=>$line['product']]));
|
||||
$entityManager->persist($productInclus);
|
||||
}
|
||||
$entityManager->flush();
|
||||
$appLogger->record('UPDATE', 'Mise à jour des produit inclus dans la formule' . $formules->getName());
|
||||
$this->addFlash('success', 'Produit mis à jour avec succès.');
|
||||
|
||||
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formules->getId()]);
|
||||
}
|
||||
|
||||
if ($request->isMethod('POST') && $request->request->has('option')) {
|
||||
$options = $request->request->all('option');
|
||||
foreach ($options as $option) {
|
||||
if(isset($option['id']) && $option['id'] !="") {
|
||||
$productInclus = $entityManager->getRepository(FormulesOptionsInclus::class)->find($option['id']);
|
||||
} else {
|
||||
$productInclus = new FormulesOptionsInclus();
|
||||
$productInclus->setFormule($formules);
|
||||
}
|
||||
$productInclus->setName($option['product']);
|
||||
$entityManager->persist($productInclus);
|
||||
}
|
||||
$entityManager->flush();
|
||||
$appLogger->record('UPDATE', 'Mise à jour des options inclus dans la formule' . $formules->getName());
|
||||
$this->addFlash('success', 'Options mis à jour avec succès.');
|
||||
|
||||
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formules->getId()]);
|
||||
}
|
||||
if ($request->isMethod('POST') && $request->request->has('rest')) {
|
||||
$rest = $request->request->all('rest');
|
||||
$f = $formules->getFormulesRestriction()->setRestrictionConfig($rest);
|
||||
$entityManager->persist($f);
|
||||
$entityManager->flush();
|
||||
|
||||
$appLogger->record('UPDATE', "Mise à jour des restriction pour : " . $formules->getName());
|
||||
$this->addFlash("success", "Les restriction ont été mis à jour.");
|
||||
|
||||
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formules->getId()]);
|
||||
}
|
||||
|
||||
// 2. GESTION DES PRIX (Formulaire Manuel price[])
|
||||
// On vérifie si le tableau 'price' existe dans la requête POST
|
||||
if ($request->isMethod('POST') && $request->request->has('price')) {
|
||||
$prices = $request->request->all('price');
|
||||
|
||||
// Mapping manuel des champs du tableau HTML vers l'entité
|
||||
$formules->setPrice1j($prices['1j'] ?? $formules->getPrice1j());
|
||||
$formules->setPrice2j($prices['2j'] ?? $formules->getPrice2j());
|
||||
$formules->setPrice5j($prices['5j'] ?? $formules->getPrice5j());
|
||||
$formules->setCaution($prices['caution'] ?? $formules->getCaution());
|
||||
|
||||
$entityManager->flush();
|
||||
|
||||
$appLogger->record('UPDATE', "Mise à jour des tarifs pour : " . $formules->getName());
|
||||
$this->addFlash("success", "Les tarifs ont été mis à jour.");
|
||||
|
||||
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formules->getId()]);
|
||||
}
|
||||
|
||||
// 3. GESTION DU FORMULAIRE CLASSIQUE (Symfony Form)
|
||||
$form = $this->createForm(FormulesType::class, $formules);
|
||||
$form->handleRequest($request);
|
||||
|
||||
|
||||
if ($request->isMethod('POST') && $request->request->has('nbStructureMax')) {
|
||||
// Vérification du type de formule
|
||||
if ($formules->getType() === "free") {
|
||||
$nbStructureMax = $request->request->get('nbStructureMax');
|
||||
$nbBarhumsMax = $request->request->get('nbBarhumsMax');
|
||||
$nbAlimentaireMax = $request->request->get('nbAlimentaireMax');
|
||||
$rc = $formules->getFormulesRestriction();
|
||||
|
||||
// Si la restriction existe, on met à jour la valeur
|
||||
if ($rc) {
|
||||
$rc->setNbStructureMax((int)$nbStructureMax);
|
||||
$rc->setNbBarhumsMax((int)$nbBarhumsMax);
|
||||
$rc->setNbAlimentaireMax((int)$nbAlimentaireMax);
|
||||
|
||||
// Persistance des modifications
|
||||
$entityManager->persist($rc);
|
||||
$entityManager->flush();
|
||||
|
||||
// Log de l'activité
|
||||
$appLogger->record('UPDATE', "Modification de la formule (restriction) : " . $formules->getName());
|
||||
|
||||
$this->addFlash("success", "La restriction de la formule a été modifiée avec succès.");
|
||||
} else {
|
||||
$this->addFlash("error", "Aucune restriction trouvée pour cette formule.");
|
||||
}
|
||||
} else {
|
||||
$this->addFlash("warning", "Le nombre de structures max n'est modifiable que pour les formules gratuites.");
|
||||
$productInclus = null;
|
||||
if (!empty($line['id'])) {
|
||||
$productInclus = $this->em->getRepository(FormulesProductInclus::class)->find($line['id']);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formules->getId()]);
|
||||
}
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$entityManager->persist($formules);
|
||||
// 2. Sauvegarde globale
|
||||
$entityManager->flush();
|
||||
if (!$productInclus) {
|
||||
$productInclus = new FormulesProductInclus();
|
||||
$productInclus->setFormules($formule)->setConfig([]);
|
||||
}
|
||||
|
||||
$appLogger->record('UPDATE', "Modification de la formule (infos) : " . $formules->getName());
|
||||
$this->addFlash("success", "La formule a été modifiée avec succès.");
|
||||
|
||||
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formules->getId()]);
|
||||
}
|
||||
|
||||
$lines =[
|
||||
[
|
||||
'product' => '',
|
||||
]
|
||||
];
|
||||
$options = [
|
||||
[
|
||||
'product' => '',
|
||||
]
|
||||
];
|
||||
foreach ($formules->getFormulesProductIncluses() as $key=>$fc){
|
||||
$lines[$key]['product'] = $fc->getProduct()->getName();
|
||||
$lines[$key]['id'] = $fc->getId();
|
||||
}
|
||||
foreach ($formules->getFormulesOptionsIncluses() as $key=>$fc){
|
||||
$options[$key]['product'] = $fc->getName();
|
||||
$options[$key]['id'] = $fc->getId();
|
||||
}
|
||||
$restriction =[
|
||||
[
|
||||
'type' => 'structure',
|
||||
'product' => ''
|
||||
]
|
||||
];
|
||||
if($formules->getFormulesRestriction() instanceof FormulesRestriction) {
|
||||
if (!empty($formules->getFormulesRestriction()->getRestrictionConfig())) {
|
||||
$restriction = $formules->getFormulesRestriction()->getRestrictionConfig();
|
||||
$product = $productRepo->findOneBy(['name' => $line['product']]);
|
||||
if ($product) {
|
||||
$productInclus->setPRODUCT($product);
|
||||
$this->em->persist($productInclus);
|
||||
}
|
||||
}
|
||||
return $this->render('dashboard/formules/view.twig', [
|
||||
'formule' => $formules,
|
||||
'form' => $form->createView(),
|
||||
'type' => $formules->getType(),
|
||||
'lines' => $lines,
|
||||
'option' => $options,
|
||||
'restriction' => $restriction,
|
||||
]);
|
||||
$this->em->flush();
|
||||
|
||||
$this->appLogger->record('UPDATE', 'Mise à jour des produits inclus dans ' . $formule->getName());
|
||||
$this->addFlash('success', 'Produits mis à jour avec succès.');
|
||||
|
||||
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formule->getId()]);
|
||||
}
|
||||
|
||||
private function handleUpdateOptions(Formules $formule, array $options): RedirectResponse
|
||||
{
|
||||
foreach ($options as $opt) {
|
||||
if (empty($opt['product'])) continue;
|
||||
|
||||
$optionInclus = null;
|
||||
if (!empty($opt['id'])) {
|
||||
$optionInclus = $this->em->getRepository(FormulesOptionsInclus::class)->find($opt['id']);
|
||||
}
|
||||
|
||||
if (!$optionInclus) {
|
||||
$optionInclus = new FormulesOptionsInclus();
|
||||
$optionInclus->setFormule($formule);
|
||||
}
|
||||
|
||||
$optionInclus->setName($opt['product']);
|
||||
$this->em->persist($optionInclus);
|
||||
}
|
||||
$this->em->flush();
|
||||
|
||||
$this->appLogger->record('UPDATE', 'Mise à jour des options incluses dans ' . $formule->getName());
|
||||
$this->addFlash('success', 'Options mises à jour avec succès.');
|
||||
|
||||
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formule->getId()]);
|
||||
}
|
||||
|
||||
private function handleUpdateRestrictionConfig(Formules $formule, array $rest): RedirectResponse
|
||||
{
|
||||
$restriction = $formule->getFormulesRestriction();
|
||||
if ($restriction) {
|
||||
$restriction->setRestrictionConfig($rest);
|
||||
$this->em->persist($restriction);
|
||||
$this->em->flush();
|
||||
|
||||
$this->appLogger->record('UPDATE', "Mise à jour configuration restriction pour : " . $formule->getName());
|
||||
$this->addFlash("success", "Les restrictions ont été mises à jour.");
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formule->getId()]);
|
||||
}
|
||||
|
||||
private function handleUpdatePrices(Formules $formule, array $prices): RedirectResponse
|
||||
{
|
||||
$formule->setPrice1j($prices['1j'] ?? $formule->getPrice1j());
|
||||
$formule->setPrice2j($prices['2j'] ?? $formule->getPrice2j());
|
||||
$formule->setPrice5j($prices['5j'] ?? $formule->getPrice5j());
|
||||
$formule->setCaution($prices['caution'] ?? $formule->getCaution());
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
$this->appLogger->record('UPDATE', "Mise à jour des tarifs pour : " . $formule->getName());
|
||||
$this->addFlash("success", "Les tarifs ont été mis à jour.");
|
||||
|
||||
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formule->getId()]);
|
||||
}
|
||||
|
||||
private function handleUpdateLimits(Formules $formule, Request $request): RedirectResponse
|
||||
{
|
||||
if ($formule->getType() !== "free") {
|
||||
$this->addFlash("warning", "Le nombre de structures max n'est modifiable que pour les formules gratuites.");
|
||||
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formule->getId()]);
|
||||
}
|
||||
|
||||
$restriction = $formule->getFormulesRestriction();
|
||||
if ($restriction) {
|
||||
$restriction->setNbStructureMax((int) $request->request->get('nbStructureMax'));
|
||||
$restriction->setNbBarhumsMax((int) $request->request->get('nbBarhumsMax'));
|
||||
$restriction->setNbAlimentaireMax((int) $request->request->get('nbAlimentaireMax'));
|
||||
|
||||
$this->em->persist($restriction);
|
||||
$this->em->flush();
|
||||
|
||||
$this->appLogger->record('UPDATE', "Modification limites (restrictions) : " . $formule->getName());
|
||||
$this->addFlash("success", "Les limites de la formule ont été modifiées.");
|
||||
} else {
|
||||
$this->addFlash("error", "Aucune entité de restriction trouvée.");
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formule->getId()]);
|
||||
}
|
||||
|
||||
// --- PRIVATE HELPERS : VIEW PREPARATION ---
|
||||
|
||||
private function prepareLines(Formules $formule): array
|
||||
{
|
||||
$lines = [];
|
||||
foreach ($formule->getFormulesProductIncluses() as $fc) {
|
||||
$lines[] = [
|
||||
'id' => $fc->getId(),
|
||||
'product' => $fc->getProduct()->getName()
|
||||
];
|
||||
}
|
||||
if (empty($lines)) $lines[] = ['product' => ''];
|
||||
return $lines;
|
||||
}
|
||||
|
||||
private function prepareOptions(Formules $formule): array
|
||||
{
|
||||
$options = [];
|
||||
foreach ($formule->getFormulesOptionsIncluses() as $fc) {
|
||||
$options[] = [
|
||||
'id' => $fc->getId(),
|
||||
'product' => $fc->getName()
|
||||
];
|
||||
}
|
||||
if (empty($options)) $options[] = ['product' => ''];
|
||||
return $options;
|
||||
}
|
||||
|
||||
private function prepareRestrictions(Formules $formule): array
|
||||
{
|
||||
$restriction = $formule->getFormulesRestriction();
|
||||
if ($restriction && !empty($restriction->getRestrictionConfig())) {
|
||||
return $restriction->getRestrictionConfig();
|
||||
}
|
||||
return [['type' => 'structure', 'product' => '']];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,58 +2,99 @@
|
||||
|
||||
namespace App\Controller\Dashboard;
|
||||
|
||||
use App\Entity\SitePerformance;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\DevisRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use App\Repository\SitePerformanceRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
#[Route('/crm')]
|
||||
class HomeController extends AbstractController
|
||||
{
|
||||
#[Route(path: '/crm', name: 'app_crm', options: ['sitemap' => false], methods: ['GET'])]
|
||||
public function crm(
|
||||
ProductRepository $productRepository,
|
||||
CustomerRepository $customerRepository,
|
||||
DevisRepository $devisRepository,
|
||||
HttpClientInterface $httpClient,
|
||||
SitePerformanceRepository $sitePerformanceRepository,
|
||||
CacheInterface $cache
|
||||
): Response {
|
||||
private const UMAMI_WEBSITE_ID = "38d713c3-3923-4791-875a-dfe5f45372c3";
|
||||
private const UMAMI_BASE_URL = "https://tools-security.esy-web.dev/api";
|
||||
|
||||
$websiteId = "38d713c3-3923-4791-875a-dfe5f45372c3";
|
||||
$baseUrl = "https://tools-security.esy-web.dev/api";
|
||||
public function __construct(
|
||||
private readonly ProductRepository $productRepository,
|
||||
private readonly CustomerRepository $customerRepository,
|
||||
private readonly DevisRepository $devisRepository,
|
||||
private readonly SitePerformanceRepository $sitePerformanceRepository,
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly CacheInterface $cache,
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private readonly string $projectDir
|
||||
) {
|
||||
}
|
||||
|
||||
// 1. Récupération sécurisée du Token (Cache 2h)
|
||||
$token = $cache->get('umami_token', function (ItemInterface $item) use ($httpClient, $baseUrl) {
|
||||
#[Route('', name: 'app_crm', options: ['sitemap' => false], methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$stats = $this->getUmamiStats();
|
||||
$updates = $this->getSystemUpdates();
|
||||
|
||||
return $this->render('dashboard/home.twig', [
|
||||
'product' => $this->productRepository->count([]),
|
||||
'devis_wait_sign' => $this->devisRepository->waitSign(),
|
||||
'customers' => $this->customerRepository->count([]),
|
||||
'nbVisitor' => $stats['visitors'],
|
||||
'nbView' => $stats['views'],
|
||||
'statview' => "https://tools-security.esy-web.dev/share/o9j2XMjV4Trnnbfb",
|
||||
'updates' => $updates,
|
||||
'avg_lcp' => $this->sitePerformanceRepository->getMoyenStat('lcp'),
|
||||
'avg_inp' => $this->sitePerformanceRepository->getMoyenStat('inp'),
|
||||
'avg_cls' => $this->sitePerformanceRepository->getMoyenStat('cls'),
|
||||
]);
|
||||
}
|
||||
|
||||
// --- PRIVATE HELPERS ---
|
||||
|
||||
/**
|
||||
* Récupère les statistiques de visite via l'API Umami (avec mise en cache)
|
||||
*/
|
||||
private function getUmamiStats(): array
|
||||
{
|
||||
// 1. Récupération du Token (Cache 2h)
|
||||
$token = $this->cache->get('umami_token', function (ItemInterface $item) {
|
||||
$item->expiresAfter(7200);
|
||||
$response = $httpClient->request('POST', "$baseUrl/auth/login", [
|
||||
'json' => [
|
||||
'username' => $_ENV['UMAMI_USER'],
|
||||
'password' => $_ENV['UMAMI_PASSWORD'],
|
||||
]
|
||||
]);
|
||||
return $response->toArray()['token'] ?? null;
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('POST', self::UMAMI_BASE_URL . '/auth/login', [
|
||||
'json' => [
|
||||
'username' => $_ENV['UMAMI_USER'],
|
||||
'password' => $_ENV['UMAMI_PASSWORD'],
|
||||
]
|
||||
]);
|
||||
return $response->toArray()['token'] ?? null;
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Récupération des Stats Umami
|
||||
$stats = $cache->get('umami_stats_24h', function (ItemInterface $item) use ($httpClient, $baseUrl, $token, $websiteId) {
|
||||
// 2. Récupération des Stats (Cache 15min)
|
||||
return $this->cache->get('umami_stats_24h', function (ItemInterface $item) use ($token) {
|
||||
$item->expiresAfter(900);
|
||||
if (!$token) return ['visitors' => 0, 'views' => 0];
|
||||
|
||||
if (!$token) {
|
||||
return ['visitors' => 0, 'views' => 0];
|
||||
}
|
||||
|
||||
try {
|
||||
$startAt = (time() - (24 * 3600)) * 1000;
|
||||
$endAt = time() * 1000;
|
||||
$response = $httpClient->request('GET', "$baseUrl/websites/$websiteId/stats", [
|
||||
|
||||
$response = $this->httpClient->request('GET', self::UMAMI_BASE_URL . '/websites/' . self::UMAMI_WEBSITE_ID . '/stats', [
|
||||
'headers' => ['Authorization' => "Bearer $token"],
|
||||
'query' => ['startAt' => $startAt, 'endAt' => $endAt],
|
||||
]);
|
||||
|
||||
$data = $response->toArray();
|
||||
|
||||
return [
|
||||
'visitors' => $data['visitors']['value'] ?? $data['visitors'] ?? 0,
|
||||
'views' => $data['pageviews']['value'] ?? $data['pageviews'] ?? 0
|
||||
@@ -62,38 +103,30 @@ class HomeController extends AbstractController
|
||||
return ['visitors' => 0, 'views' => 0];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Récupération des Updates (Journal de bord client)
|
||||
$updateFile = $this->getParameter('kernel.project_dir') . '/var/update.json';
|
||||
$updates = [];
|
||||
/**
|
||||
* Récupère et formate le journal de bord des mises à jour
|
||||
*/
|
||||
private function getSystemUpdates(): array
|
||||
{
|
||||
$updateFile = $this->projectDir . '/var/update.json';
|
||||
|
||||
if (file_exists($updateFile)) {
|
||||
$rawUpdates = json_decode(file_get_contents($updateFile), true) ?? [];
|
||||
|
||||
// On enrichit les données avec les couleurs Tailwind
|
||||
$updates = array_map(function ($update) {
|
||||
$update['tag_color'] = match ($update['type'] ?? 'new') {
|
||||
'feature' => 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
'fix' => 'bg-rose-100 text-rose-700 border-rose-200',
|
||||
'optimise' => 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
'new' => 'bg-slate-100 text-slate-700 border-slate-200',
|
||||
default => 'bg-gray-100 text-gray-700 border-gray-200',
|
||||
};
|
||||
return $update;
|
||||
}, $rawUpdates);
|
||||
if (!file_exists($updateFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->render('dashboard/home.twig', [
|
||||
'product' => $productRepository->count(),
|
||||
'devis_wait_sign' => $devisRepository->waitSign(),
|
||||
'customers' => $customerRepository->count(),
|
||||
'nbVisitor' => $stats['visitors'],
|
||||
'nbView' => $stats['views'],
|
||||
'statview' => "https://tools-security.esy-web.dev/share/o9j2XMjV4Trnnbfb",
|
||||
'updates' => $updates,
|
||||
'avg_lcp' => $sitePerformanceRepository->getMoyenStat('lcp'),
|
||||
'avg_inp' => $sitePerformanceRepository->getMoyenStat('inp'),
|
||||
'avg_cls' => $sitePerformanceRepository->getMoyenStat('cls'),
|
||||
]);
|
||||
$rawUpdates = json_decode(file_get_contents($updateFile), true) ?? [];
|
||||
|
||||
return array_map(function ($update) {
|
||||
$update['tag_color'] = match ($update['type'] ?? 'new') {
|
||||
'feature' => 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
'fix' => 'bg-rose-100 text-rose-700 border-rose-200',
|
||||
'optimise' => 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
'new' => 'bg-slate-100 text-slate-700 border-slate-200',
|
||||
default => 'bg-gray-100 text-gray-700 border-gray-200',
|
||||
};
|
||||
return $update;
|
||||
}, $rawUpdates);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
namespace App\Controller\Dashboard;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Logger\AppLogger;
|
||||
use App\Repository\AccountRepository;
|
||||
use App\Repository\AuditLogRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -16,150 +18,131 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/crm/logs')]
|
||||
class LogsController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly AuditLogRepository $auditLogRepository,
|
||||
private readonly AccountRepository $accountRepository,
|
||||
private readonly PaginatorInterface $paginator,
|
||||
private readonly AppLogger $appLogger
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('', name: 'app_crm_audit_logs', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->appLogger->record('VIEW', 'Consultation des journaux d\'audit');
|
||||
|
||||
#[Route(path: '/crm/logs', name: 'app_crm_audit_logs', methods: ['GET'])]
|
||||
public function crmLogs(
|
||||
AuditLogRepository $auditLogRepository,
|
||||
AccountRepository $userRepository,
|
||||
PaginatorInterface $paginator,
|
||||
Request $request
|
||||
): Response {
|
||||
// On récupère l'ID du compte à filtrer depuis l'URL (?account=12)
|
||||
$filterAccountId = $request->query->get('account');
|
||||
$query = $this->auditLogRepository->getQueryForUser($this->getUser(), (int)$filterAccountId);
|
||||
|
||||
// On appelle la méthode du repository avec l'utilisateur actuel ET le filtre
|
||||
$query = $auditLogRepository->getQueryForUser($this->getUser(), (int)$filterAccountId);
|
||||
|
||||
// Extraction Excel (qui respectera le filtre et la sécurité ROOT)
|
||||
if ($request->query->get('extract')) {
|
||||
// Export Excel
|
||||
if ($request->query->has('extract')) {
|
||||
return $this->generateExcelExport($query->getResult());
|
||||
}
|
||||
|
||||
$pagination = $paginator->paginate(
|
||||
$pagination = $this->paginator->paginate(
|
||||
$query,
|
||||
$request->query->getInt('page', 1),
|
||||
25
|
||||
);
|
||||
|
||||
$users = $userRepository->findAdmin();
|
||||
if($this->isGranted('ROLE_ROOT')) {
|
||||
$users = $userRepository->findAll();
|
||||
}
|
||||
$users = $this->isGranted('ROLE_ROOT')
|
||||
? $this->accountRepository->findAll()
|
||||
: $this->accountRepository->findAdmin();
|
||||
|
||||
return $this->render('dashboard/audit_logs.twig', [
|
||||
'logs' => $pagination,
|
||||
'users_list' => $users // Liste pour ton select Twig
|
||||
'users_list' => $users
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/delete/{id}', name: 'app_crm_audit_logs_delete', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ROOT')]
|
||||
public function delete(AuditLog $log, Request $request): Response
|
||||
{
|
||||
if ($this->isCsrfTokenValid('delete' . $log->getId(), $request->request->get('_token'))) {
|
||||
$this->em->remove($log);
|
||||
$this->em->flush();
|
||||
|
||||
$this->appLogger->record('DELETE', "Suppression manuelle d'une entrée de log (ID: {$log->getId()})");
|
||||
$this->addFlash('success', 'L\'entrée du journal a été supprimée.');
|
||||
} else {
|
||||
$this->addFlash('error', 'Action non autorisée (Token invalide).');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_crm_audit_logs');
|
||||
}
|
||||
|
||||
// --- PRIVATE HELPERS ---
|
||||
|
||||
private function generateExcelExport(array $logs): StreamedResponse
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('Logs Audit');
|
||||
|
||||
// 1. Génération d'un mot de passe aléatoire pour ce fichier
|
||||
// On utilise bin2hex pour un mot de passe robuste
|
||||
$ultraSecurePassword = bin2hex(random_bytes(32));
|
||||
// Génération d'un mot de passe robuste pour le fichier
|
||||
$password = bin2hex(random_bytes(32));
|
||||
|
||||
// En-têtes (A à J)
|
||||
$headers = ['Date', 'Heure', 'Admin', 'Email', 'Action', 'Message', 'URL', 'Navigateur/OS', 'Signature', 'État'];
|
||||
$columns = range('A', 'J');
|
||||
|
||||
foreach ($columns as $i => $column) {
|
||||
$sheet->setCellValue($column . '1', $headers[$i]);
|
||||
$sheet->getStyle($column . '1')->getFont()->setBold(true);
|
||||
$sheet->getStyle($column . '1')->getFill()
|
||||
->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
|
||||
->getStartColor()->setARGB('F1F5F9');
|
||||
foreach ($columns as $i => $col) {
|
||||
$sheet->setCellValue($col . '1', $headers[$i]);
|
||||
$sheet->getStyle($col . '1')->getFont()->setBold(true);
|
||||
$sheet->getStyle($col . '1')->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('F1F5F9');
|
||||
}
|
||||
|
||||
$row = 2;
|
||||
/** @var AuditLog $log */
|
||||
foreach ($logs as $log) {
|
||||
$isValid = ($log->getHashCode() === $log->generateSignature());
|
||||
$statusText = $isValid ? 'VALIDE' : 'CORROMPU';
|
||||
$account = $log->getAccount();
|
||||
|
||||
$sheet->setCellValue('A' . $row, $log->getActionAt()->format('d/m/Y'));
|
||||
$sheet->setCellValue('B' . $row, $log->getActionAt()->format('H:i:s'));
|
||||
$sheet->setCellValue('C' . $row, $log->getAccount()->getFirstName() . ' ' . $log->getAccount()->getName());
|
||||
$sheet->setCellValue('D' . $row, $log->getAccount()->getEmail());
|
||||
$sheet->setCellValue('C' . $row, $account ? $account->getFirstName() . ' ' . $account->getName() : 'Système');
|
||||
$sheet->setCellValue('D' . $row, $account ? $account->getEmail() : 'N/A');
|
||||
$sheet->setCellValue('E' . $row, $log->getType());
|
||||
$sheet->setCellValue('F' . $row, $log->getMessage());
|
||||
$sheet->setCellValue('G' . $row, $log->getPath());
|
||||
$sheet->setCellValue('H' . $row, $log->getUserAgent());
|
||||
$sheet->setCellValue('I' . $row, $log->getHashCode());
|
||||
$sheet->setCellValue('J' . $row, $statusText);
|
||||
$sheet->setCellValue('J' . $row, $isValid ? 'VALIDE' : 'CORROMPU');
|
||||
|
||||
// Couleur d'état
|
||||
$color = $isValid ? '059669' : 'DC2626';
|
||||
$sheet->getStyle('J' . $row)->getFont()->setBold(true)->getColor()->setARGB($color);
|
||||
$sheet->getStyle('J' . $row)->getFont()->setBold(true)->getColor()->setARGB($isValid ? '059669' : 'DC2626');
|
||||
|
||||
$row++;
|
||||
}
|
||||
|
||||
// Auto-ajustement des colonnes
|
||||
foreach ($columns as $column) {
|
||||
$sheet->getColumnDimension($column)->setAutoSize(true);
|
||||
foreach ($columns as $col) {
|
||||
$sheet->getColumnDimension($col)->setAutoSize(true);
|
||||
}
|
||||
|
||||
// --- VERROUILLAGE DU FICHIER ---
|
||||
|
||||
// On protège la feuille contre toute modification
|
||||
$sheet->getProtection()->setPassword($ultraSecurePassword);
|
||||
// Protection du fichier
|
||||
$sheet->getProtection()->setPassword($password);
|
||||
$sheet->getProtection()->setSheet(true);
|
||||
|
||||
// On interdit la sélection des cellules verrouillées (optionnel)
|
||||
$sheet->getProtection()->setSelectLockedCells(true);
|
||||
$sheet->getProtection()->setSort(false);
|
||||
$sheet->getProtection()->setInsertRows(false);
|
||||
|
||||
// On verrouille la structure du classeur
|
||||
$spreadsheet->getSecurity()->setLockStructure(true);
|
||||
$spreadsheet->getSecurity()->setWorkbookPassword($ultraSecurePassword);
|
||||
$spreadsheet->getProperties()
|
||||
->setCreator("Système Audit Ludikevent")
|
||||
->setLastModifiedBy("Automate")
|
||||
->setCompany("SARL SITECONSEIL")
|
||||
->setCategory("Système Audit Ludikevent")
|
||||
->setTitle("Journal d'audit protégé")
|
||||
->setSubject("Confidentiel")
|
||||
->setDescription("Ce fichier est protégé par signature numérique et verrouillage structurel.");
|
||||
// --- GÉNÉRATION DE LA RÉPONSE ---
|
||||
$spreadsheet->getSecurity()->setWorkbookPassword($password);
|
||||
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
|
||||
$response = new StreamedResponse(function() use ($writer) {
|
||||
$writer->save('php://output');
|
||||
});
|
||||
|
||||
// On ajoute la date et un identifiant court dans le nom du fichier
|
||||
$fileName = 'audit_ludikevent_' . date('d-m-Y_H-i') . '.xlsx';
|
||||
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', 'attachment;filename="' . $fileName . '"');
|
||||
$response->headers->set('Cache-Control', 'max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
#[Route(path: '/crm/logs/delete/{id}', name: 'app_crm_audit_logs_delete', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ROOT')]
|
||||
public function deleteLog(
|
||||
AuditLog $log,
|
||||
Request $request,
|
||||
EntityManagerInterface $entityManager
|
||||
): Response {
|
||||
// Vérification du token CSRF dynamique (lié à l'ID du log)
|
||||
if (!$this->isCsrfTokenValid('delete' . $log->getId(), $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Action non autorisée.');
|
||||
return $this->redirectToRoute('app_crm_audit_logs');
|
||||
}
|
||||
|
||||
$entityManager->remove($log);
|
||||
$entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'L\'entrée du journal a été supprimée.');
|
||||
|
||||
return $this->redirectToRoute('app_crm_audit_logs');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user