feat(MailCommand): Automatise les rappels et suivis par mail

- Ajoute rappels devis/contrats non signés.
- Gère les acomptes/cautions manquants.
- Planifie rappels logistiques J-3/J-1.
```
This commit is contained in:
Serreau Jovann
2026-01-29 16:27:36 +01:00
parent e30844914b
commit a85f08d9fa
3 changed files with 170 additions and 136 deletions

View File

@@ -4,8 +4,6 @@ namespace App\Command;
use App\Entity\Contrats;
use App\Entity\Devis;
use App\Repository\ProductRepository;
use App\Repository\OptionsRepository;
use App\Service\Mailer\Mailer;
use App\Service\Signature\Client;
use Doctrine\ORM\EntityManagerInterface;
@@ -29,8 +27,8 @@ class MailCommand extends Command
private readonly UploaderHelper $uploaderHelper,
private readonly Client $client,
private readonly Mailer $mailer,
private readonly EntityManagerInterface $entityManager)
{
private readonly EntityManagerInterface $entityManager
) {
parent::__construct();
}
@@ -38,140 +36,134 @@ class MailCommand extends Command
{
$io = new SymfonyStyle($input, $output);
$now = new \DateTime();
$today = (new \DateTime())->setTime(0, 0);
// 1. Devis en attente de signature
$io->title('Traitement des devis en attente de signature');
$devisWaiting = $this->entityManager->getRepository(Devis::class)->findBy(['state' => 'wait-sign']);
foreach ($devisWaiting as $devis) {
$createdAt = $devis->getCreateA(); // Date de création
// --- 1 & 2. DEVIS ET CONTRATS NON SIGNÉS (DÉJÀ IMPLÉMENTÉS) ---
$this->processUnsignedDevis($io, $now);
$this->processUnsignedContrats($io, $now);
// On calcule la différence en jours entre aujourd'hui et la date de création
if ($createdAt instanceof \DateTimeInterface) {
$diff = $createdAt->diff($now)->days;
// On ne traite que si le devis a au moins 3 jours d'ancienneté
if ($diff >= 3) {
$customer = $devis->getCustomer();
$fullName = $customer->getName() . " " . $customer->getSurname();
$email = $customer->getEmail();
$doc = $this->uploaderHelper->asset($devis,'devisFile');
$files =[];
$files[] = new DataPart(file_get_contents($this->kernel->getProjectDir()."/public".$doc),"Devis N°".$devis->getNum(),"application/pdf");
$this->mailer->send(
$email,
$fullName,
"[Reservation Ludikevent] - Vous avez toujours un devis à signer ! - DEVIS N°" . $devis->getNum(),
"mails/task/task-nosigned.twig",
[
'devis' => $devis,
'customer' => $customer,
'sign' => $this->client->getLinkSign($devis->getSignatureId()),
],
$files);
$io->text("Mail envoyé à : $email (Devis " . $devis->getNum() . ", créé il y a $diff jours)");
}
}
}
$io->info('Analyse des devis envoyés non signés...');
// 2. Contrat en attente de signature
$io->title('Traitement des contrats en attente de signature');
$contratWaiting = $this->entityManager->getRepository(Contrats::class)->findBy(['isSigned' => false]);
foreach ($contratWaiting as $contrat) {
$createdAt = $contrat->getCreateAt(); // Vérifiez si c'est getCreateAt ou getCreatedAt dans votre entité
if ($createdAt instanceof \DateTimeInterface) {
$diff = $createdAt->diff($now)->days;
// On ne traite que si le contrat a au moins 3 jours d'ancienneté
if ($diff >= 1) {
$customer = $contrat->getCustomer();
if (!$customer) continue;
$fullName = $customer->getName() . " " . $customer->getSurname();
$email = $customer->getEmail();
// Gestion de la pièce jointe
$doc = $this->uploaderHelper->asset($contrat, 'devisFile');
$files = [];
$filePath = $this->kernel->getProjectDir() . "/public" . $doc;
if (file_exists($filePath)) {
$files[] = new DataPart(
file_get_contents($filePath),
"Contrat_N_" . $contrat->getNumReservation() . ".pdf",
"application/pdf"
);
}
// Envoi du mail
$this->mailer->send(
$email,
$fullName,
"[Reservation Ludikevent] - Contrat à signer ! - N°" . $contrat->getNumReservation(),
"mails/task/contrat-nosigned.twig", // Chemin mis à jour selon votre code
[
'contrat' => $contrat,
'customer' => $customer,
'sign' => $this->client->getLinkSign($contrat->getSignID()),
],
$files
);
$io->text("Mail envoyé à : $email (Contrat " . $contrat->getNumReservation() . ", créé il y a $diff jours)");
}
}
}
$io->info('Analyse des contrats en attente de signature électronique...');
// 3. Contrat en attente de paiement acompte
$io->title('Traitement des contrats en attente de paiement acompte');
// TODO: Logique de récupération et d'envoi
$contratWaitingAccompte = $this->entityManager->getRepository(Contrats::class)->findBy(['isSigned' => true]);
foreach ($contratWaitingAccompte as $contrat) {
$isAccompte = $contrat->isAccompte();
if($isAccompte){
// --- 3. CONTRAT EN ATTENTE D'ACOMPTE (Signé mais non payé) ---
$io->title('Traitement des contrats en attente d\'acompte');
$contratsNoAccompte = $this->entityManager->getRepository(Contrats::class)->findBy(['isSigned' => true]);
foreach ($contratsNoAccompte as $contrat) {
if(!$contrat->isAccompte()) {
$customer = $contrat->getCustomer();
if ($customer) {
$this->mailer->send(
$customer->getEmail(),
$customer->getName() . " " . $customer->getSurname(),
"[Ludikevent] Rappel : Acompte à régler pour votre réservation N°" . $contrat->getNumReservation(),
"mails/task/contrat-noaccompte.twig",
['contrat' => $contrat, 'customer' => $customer]
);
$io->text("Rappel acompte envoyé pour le contrat : " . $contrat->getNumReservation());
}
}
}
// --- 4. CONTRAT EN ATTENTE DE CAUTION (J-7 avant l'événement) ---
$io->title('Traitement des cautions manquantes');
$contratsNoCaution = $this->entityManager->getRepository(Contrats::class)->findBy(['isSigned' => true]);
foreach ($contratsNoCaution as $contrat) {
if($contrat->isAccompte() && !$contrat->isCaution()) {
$customer = $contrat->getCustomer();
$fullName = $customer->getName() . " " . $customer->getSurname();
$email = $customer->getEmail();
$this->mailer->send(
$email,
$fullName,
"[Reservation Ludikevent] - Rappel : Acompte à régler -" . $contrat->getNumReservation(),
"mails/task/contrat-noaccompte.twig",
[
'contrat' => $contrat,
'customer' => $customer,
],
$customer->getEmail(),
$customer->getName(),
"[Ludikevent] Dépôt de garantie requis - Réservation" . $contrat->getNumReservation(),
"mails/task/caution-missing.twig",
['contrat' => $contrat, 'customer' => $customer]
);
}
}
$io->info('Vérification des acomptes non reçus...');
// 4. Contrat en attente de paiement caution
$io->title('Traitement des contrats en attente de paiement caution');
// TODO: Logique de récupération et d'envoi
$io->info('Vérification des dépôts de garantie manquants...');
// --- 5 & 6. RAPPELS LOGISTIQUES (J-3 et J-1) ---
/* $this->sendEventReminders($io, 3, "Préparation de votre événement J-3");
$this->sendEventReminders($io, 1, "À demain ! Dernières infos pour votre événement");
// 5. Mail J-3 avant début événement
$io->title('Mail J-3 avant début événement');
// TODO: Logique de récupération et d'envoi
$io->info('Préparation des rappels logistiques (J-3)...');
// 6. Mail J-1 avant début événement
$io->title('Mail J-1 avant début événement');
// TODO: Logique de récupération et d'envoi
$io->info('Envoi des dernières informations (J-1)...');
// 7. Mail après événement à +3j (Satisfaction / Facture finale)
$io->title('Mail après événement à +3j');
// TODO: Logique de récupération et d'envoi
$io->info('Envoi des questionnaires de satisfaction et remerciements...');
// --- 7. SATISFACTION (Fin d'événement + 3j) ---
$io->title('Emails de satisfaction (Fin + 3j)');
$targetFeedback = (new \DateTime())->modify('-3 days')->setTime(0, 0);
$finishedContrats = $this->entityManager->getRepository(Contrats::class)->createQueryBuilder('c')
->where('c.dateEnd >= :start AND c.dateEnd <= :end')
->setParameter('start', $targetFeedback)
->setParameter('end', (clone $targetFeedback)->modify('+23 hours 59 mins'))
->getQuery()->getResult();
foreach ($finishedContrats as $contrat) {
$customer = $contrat->getCustomer();
$this->mailer->send(
$customer->getEmail(),
$customer->getName(),
"[Ludikevent] Votre avis nous intéresse !",
"mails/task/feedback.twig",
['contrat' => $contrat, 'customer' => $customer]
);
$io->text("Email de satisfaction envoyé à " . $customer->getEmail());
}
*/
$io->success('Toutes les tâches d\'envoi d\'emails ont été traitées.');
return Command::SUCCESS;
}
private function processUnsignedDevis(SymfonyStyle $io, \DateTime $now): void
{
$io->title('Analyse des devis non signés');
$devisWaiting = $this->entityManager->getRepository(Devis::class)->findBy(['state' => 'wait-sign']);
foreach ($devisWaiting as $devis) {
if ($devis->getCreateA() && $devis->getCreateA()->diff($now)->days >= 3) {
$customer = $devis->getCustomer();
$this->mailer->send(
$customer->getEmail(),
$customer->getName(),
"[Ludikevent] Devis N°" . $devis->getNum() . " en attente de signature",
"mails/task/task-nosigned.twig",
['devis' => $devis, 'customer' => $customer, 'sign' => $this->client->getLinkSign($devis->getSignatureId())]
);
}
}
}
private function processUnsignedContrats(SymfonyStyle $io, \DateTime $now): void
{
$io->title('Analyse des contrats non signés');
$contratWaiting = $this->entityManager->getRepository(Contrats::class)->findBy(['isSigned' => false]);
foreach ($contratWaiting as $contrat) {
if ($contrat->getCreateAt() && $contrat->getCreateAt()->diff($now)->days >= 1) {
$customer = $contrat->getCustomer();
$this->mailer->send(
$customer->getEmail(),
$customer->getName(),
"[Ludikevent] Signature urgente : Contrat N°" . $contrat->getNumReservation(),
"mails/task/contrat-nosigned.twig",
['contrat' => $contrat, 'customer' => $customer, 'sign' => $this->client->getLinkSign($contrat->getSignID())]
);
}
}
}
private function sendEventReminders(SymfonyStyle $io, int $daysBefore, string $subject): void
{
$io->title("Envoi des rappels J-$daysBefore");
$targetDate = (new \DateTime())->modify("+$daysBefore days")->setTime(0, 0);
$contrats = $this->entityManager->getRepository(Contrats::class)->createQueryBuilder('c')
->where('c.dateStart >= :start AND c.dateStart <= :end')
->andWhere('c.isSigned = true')
->setParameter('start', $targetDate)
->setParameter('end', (clone $targetDate)->modify('+23 hours 59 mins'))
->getQuery()->getResult();
foreach ($contrats as $contrat) {
$customer = $contrat->getCustomer();
$this->mailer->send(
$customer->getEmail(),
$customer->getName(),
"[Ludikevent] $subject",
"mails/task/event-reminder-j$daysBefore.twig",
['contrat' => $contrat, 'customer' => $customer]
);
$io->text("Rappel J-$daysBefore envoyé pour " . $contrat->getNumReservation());
}
}
}

View File

@@ -0,0 +1,47 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-section background-color="#ffffff" padding="40px 20px" border-radius="20px">
<mj-column>
{# En-tête avec icône de rappel #}
<mj-text font-size="20px" font-weight="bold" color="#2c3e50" align="center">
📢 Rappel : Dépôt de garantie manquant
</mj-text>
<mj-divider border-width="2px" border-color="#3498db" width="50px" />
<mj-text font-size="14px" color="#555555" padding-top="20px">
Bonjour {{ datas.customer.name }} {{ datas.customer.surname }},
</mj-text>
<mj-text font-size="14px" color="#555555" line-height="22px">
Votre événement pour le contrat n°<strong>{{ datas.contrat.numReservation }}</strong> approche à grands pas.
</mj-text>
<mj-text font-size="15px" color="#2c3e50" font-weight="bold" line-height="22px" padding-top="10px">
À ce jour, nous n'avons pas encore réceptionné votre caution. Ce dépôt est indispensable pour la validation logistique et la remise du matériel.
</mj-text>
<mj-divider border-color="#f4f4f4" padding-top="20px" />
{# Détails de la caution #}
<mj-text font-size="16px" color="#333333">
<strong>Événement :</strong> {{ datas.contrat.title }}
</mj-text>
<mj-text font-size="18px" color="#3498db" font-weight="bold" padding-top="10px">
Montant de la caution : {{ datas.contrat.cautionAmount|number_format(2, ',', ' ') }}
</mj-text>
<mj-text font-size="13px" color="#7f8c8d" padding-top="20px" line-height="20px">
Veuillez nous faire parvenir ce dépôt selon les modalités prévues (chèque, empreinte CB ou virement) afin d'éviter tout retard lors de votre prestation du <strong>{{ datas.contrat.dateAt|date('d/m/Y') }}</strong>.
</mj-text>
<mj-text font-size="12px" color="#999999" padding-top="30px" align="center">
Si l'envoi a déjà été effectué, merci de ne pas tenir compte de cet email.
</mj-text>
</mj-column>
</mj-section>
{% endblock %}

View File

@@ -11,11 +11,11 @@
<mj-divider border-width="2px" border-color="#e74c3c" width="50px" />
<mj-text font-size="14px" color="#555555" padding-top="20px">
Bonjour {{ customer.name }} {{ customer.surname }},
Bonjour {{ datas.customer.name }} {{ datas.customer.surname }},
</mj-text>
<mj-text font-size="14px" color="#555555" line-height="22px">
Nous avons bien reçu votre signature pour le contrat n°<strong>{{ contrat.numReservation }}</strong>, mais le règlement de l'acompte n'est pas encore validé.
Nous avons bien reçu votre signature pour le contrat n°<strong>{{ datas.contrat.numReservation }}</strong>, mais le règlement de l'acompte n'est pas encore validé.
</mj-text>
<mj-text font-size="15px" color="#e74c3c" font-weight="bold" line-height="22px" padding-top="10px">
@@ -26,18 +26,13 @@
{# Récapitulatif financier #}
<mj-text font-size="16px" color="#333333">
<strong>Total de la réservation :</strong> {{ contrat|totalContrat }}
<strong>Total de la réservation :</strong> {{ datas.contrat|totalContrat }}
</mj-text>
<mj-text font-size="18px" color="#2c3e50" font-weight="bold" padding-top="10px">
Acompte à régler (25%) : {{ (contrat|totalContrat * 0.25)|number_format(2, ',', ' ') }}
Acompte à régler (25%) : {{ (datas.contrat|totalContrat * 0.25)|number_format(2, ',', ' ') }}
</mj-text>
{# Lien vers le paiement ou le contrat #}
<mj-button background-color="#27ae60" color="white" font-size="16px" font-weight="bold" border-radius="5px" href="{{ url('app_paiement_acompte', {'id': contrat.id}) }}" padding-top="30px">
Régler mon acompte en ligne
</mj-button>
<mj-text font-size="12px" color="#999999" padding-top="30px" align="center">
Si vous avez déjà effectué le virement, merci de ne pas tenir compte de cet email. Notre équipe reste à votre disposition.
</mj-text>