From cd45a37d73cf7d91a31dd357de3f5a5d9932c65d Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Mon, 19 Jan 2026 19:40:27 +0100 Subject: [PATCH] =?UTF-8?q?```=20=E2=9C=A8=20feat(Product.php):=20Ajoute?= =?UTF-8?q?=20la=20relation=20avec=20ProductReserve.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ feat(DevisSubscriber.php): Crée un subscriber pour l'envoi de devis. ✨ feat(Devis.php): Ajoute la relation avec ProductReserve. ✨ feat: Crée le template de mail pour la notification de signature. ✨ feat(DevisSend.php): Crée l'événement DevisSend. ✨ feat(Customer.php): Ajoute la relation avec ProductReserve. 🐛 fix(SignatureController.php): Corrige la gestion de la signature complétée. ✨ feat(DevisController.php): Ajoute la relance de signature et pagination. ✨ feat: Crée le template de mail pour l'envoi du devis à signer. ✨ feat: Crée le template de mail pour la confirmation de signature. ✨ feat(Client.php): Gère la création et le suivi de la signature DocuSeal. ✨ feat(DevisPdfService.php): Intègre les champs Docuseal. ✨ feat(list.twig): Affiche la liste des devis avec actions et statuts. ✨ feat: Crée la page de succès de signature. ✨ feat(StripeExtension.php): Ajoute le filtre totalQuoto pour calculer le total HT. ``` --- migrations/Version20260119183356.php | 43 +++++++ migrations/Version20260119183526.php | 31 +++++ src/Controller/Dashboard/DevisController.php | 53 ++++++++- src/Controller/SignatureController.php | 106 +++++++++++++++++- src/Entity/Customer.php | 37 ++++++ src/Entity/Devis.php | 25 +++++ src/Entity/Product.php | 37 ++++++ src/Entity/ProductReserve.php | 95 ++++++++++++++++ src/Event/Signature/DevisSend.php | 20 ++++ src/Event/Signature/DevisSubscriber.php | 36 ++++++ src/Repository/ProductReserveRepository.php | 43 +++++++ src/Service/Pdf/DevisPdfService.php | 36 ++---- src/Service/Signature/Client.php | 44 +++++++- src/Twig/StripeExtension.php | 31 +++++ templates/dashboard/devis/list.twig | 76 +++++++++---- templates/mails/sign/devis.twig | 49 ++++++++ templates/mails/sign/signed.twig | 42 +++++++ templates/mails/sign/signed_notification.twig | 45 ++++++++ templates/sign/sign_success.twig | 85 ++++++++++++++ 19 files changed, 879 insertions(+), 55 deletions(-) create mode 100644 migrations/Version20260119183356.php create mode 100644 migrations/Version20260119183526.php create mode 100644 src/Entity/ProductReserve.php create mode 100644 src/Event/Signature/DevisSend.php create mode 100644 src/Event/Signature/DevisSubscriber.php create mode 100644 src/Repository/ProductReserveRepository.php create mode 100644 templates/mails/sign/devis.twig create mode 100644 templates/mails/sign/signed.twig create mode 100644 templates/mails/sign/signed_notification.twig create mode 100644 templates/sign/sign_success.twig diff --git a/migrations/Version20260119183356.php b/migrations/Version20260119183356.php new file mode 100644 index 0000000..55c99d2 --- /dev/null +++ b/migrations/Version20260119183356.php @@ -0,0 +1,43 @@ +addSql('CREATE TABLE product_reserve (id SERIAL NOT NULL, product_id INT DEFAULT NULL, customer_id INT DEFAULT NULL, devis_id INT DEFAULT NULL, start_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, end_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_CE39F1924584665A ON product_reserve (product_id)'); + $this->addSql('CREATE INDEX IDX_CE39F1929395C3F3 ON product_reserve (customer_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_CE39F19241DEFADA ON product_reserve (devis_id)'); + $this->addSql('COMMENT ON COLUMN product_reserve.start_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN product_reserve.end_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE product_reserve ADD CONSTRAINT FK_CE39F1924584665A FOREIGN KEY (product_id) REFERENCES product (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE product_reserve ADD CONSTRAINT FK_CE39F1929395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE product_reserve ADD CONSTRAINT FK_CE39F19241DEFADA FOREIGN KEY (devis_id) REFERENCES devis (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE product_reserve DROP CONSTRAINT FK_CE39F1924584665A'); + $this->addSql('ALTER TABLE product_reserve DROP CONSTRAINT FK_CE39F1929395C3F3'); + $this->addSql('ALTER TABLE product_reserve DROP CONSTRAINT FK_CE39F19241DEFADA'); + $this->addSql('DROP TABLE product_reserve'); + } +} diff --git a/migrations/Version20260119183526.php b/migrations/Version20260119183526.php new file mode 100644 index 0000000..fddf44d --- /dev/null +++ b/migrations/Version20260119183526.php @@ -0,0 +1,31 @@ +addSql('CREATE SCHEMA public'); + } +} diff --git a/src/Controller/Dashboard/DevisController.php b/src/Controller/Dashboard/DevisController.php index ba1d94c..4a05521 100644 --- a/src/Controller/Dashboard/DevisController.php +++ b/src/Controller/Dashboard/DevisController.php @@ -5,6 +5,7 @@ namespace App\Controller\Dashboard; use App\Entity\CustomerAddress; use App\Entity\Devis; use App\Entity\DevisLine; +use App\Event\Signature\DevisSend; use App\Form\NewDevisType; use App\Logger\AppLogger; use App\Repository\AccountRepository; @@ -18,6 +19,7 @@ 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\Request; @@ -30,16 +32,52 @@ class DevisController extends AbstractController /** * Liste des administrateurs */ - #[Route(path: '/crm/devis', name: 'app_crm_devis', options: ['sitemap' => false], methods: ['GET'])] - public function devis(Client $client,EntityManagerInterface $entityManager,KernelInterface $kernel,DevisRepository $devisRepository,AppLogger $appLogger,PaginatorInterface $paginator,Request $request): Response - { + #[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, + + ): 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) { + // 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."); + } + $appLogger->record('VIEW', 'Consultation de la liste des devis'); - return $this->render('dashboard/devis/list.twig',[ - 'quotes' => $paginator->paginate($devisRepository->findBy([],['createA'=>'asc']),$request->get('page', 1),20), + + // 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']), + $request->query->getInt('page', 1), + 20 + ); + + return $this->render('dashboard/devis/list.twig', [ + 'quotes' => $pagination, ]); } #[Route(path: '/crm/devis/add', name: 'app_crm_devis_add', options: ['sitemap' => false], methods: ['GET','POST'])] - public function devisAdd(Client $client,KernelInterface $kernel,CustomerAddressRepository $customerAddress,ProductRepository $productRepository,EntityManagerInterface $entityManager,CustomerRepository $customerRepository,DevisRepository $devisRepository, AppLogger $appLogger,Request $request): Response + public function devisAdd(Client $client,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'); @@ -94,6 +132,9 @@ class DevisController extends AbstractController $devis->setUpdateAt(new \DateTimeImmutable()); $entityManager->flush(); $client->createSubmissionDevis($devis); + + $event = new DevisSend($devis); + $eventDispatcher->dispatch($event); return $this->redirectToRoute('app_crm_devis'); } return $this->render('dashboard/devis/add.twig',[ diff --git a/src/Controller/SignatureController.php b/src/Controller/SignatureController.php index 181d8c0..8bbe7c7 100644 --- a/src/Controller/SignatureController.php +++ b/src/Controller/SignatureController.php @@ -4,18 +4,25 @@ namespace App\Controller; use App\Entity\Account; use App\Entity\AccountResetPasswordRequest; +use App\Entity\Devis; +use App\Entity\ProductReserve; use App\Form\RequestPasswordConfirmType; use App\Form\RequestPasswordRequestType; use App\Logger\AppLogger; +use App\Repository\DevisRepository; +use App\Service\Mailer\Mailer; use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent; use App\Service\ResetPassword\Event\ResetPasswordEvent; +use App\Service\Signature\Client; use Doctrine\ORM\EntityManagerInterface; use KnpU\OAuth2ClientBundle\Client\ClientRegistry; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Mime\Part\DataPart; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; @@ -26,9 +33,104 @@ class SignatureController extends AbstractController { #[Route('/signature/complete', name: 'app_sign_complete')] - public function appSignComplete() - { + public function appSignComplete( + Client $client, + DevisRepository $devisRepository, + EntityManagerInterface $entityManager, + Request $request, + Mailer $mailer, + ): Response { + if ($request->get('type') === "devis") { + $devis = $devisRepository->find($request->get('id')); + if (!$devis) { + throw $this->createNotFoundException("Devis introuvable."); + } + + // On évite de retraiter un devis déjà marqué comme signé + if ($devis->getState() === 'signed') { + return $this->render('sign/sign_success.twig', ['devis' => $devis]); + } + + $submiter = $client->getSubmiter($devis->getSignatureId()); + $submission = $client->getSubmition($submiter['submission_id']); + + if ($submission['status'] === "completed") { + $devis->setState("signed"); + + $auditUrl = $submission['audit_log_url']; + $signedDocUrl = $submission['documents'][0]['url']; + + try { + // 1. Gestion du PDF SIGNÉ + $tmpSigned = sys_get_temp_dir() . '/sign_' . uniqid() . '.pdf'; + $signedContent = file_get_contents($signedDocUrl); + file_put_contents($tmpSigned, $signedContent); + + // On utilise UploadedFile pour simuler un upload propre pour VichUploader + $devis->setDevisSignFile(new UploadedFile($tmpSigned, "sign-" . $devis->getNum() . ".pdf", "application/pdf", null, true)); + + // 2. Gestion de l'AUDIT LOG + $tmpAudit = sys_get_temp_dir() . '/audit_' . uniqid() . '.pdf'; + $auditContent = file_get_contents($auditUrl); + file_put_contents($tmpAudit, $auditContent); + + $devis->setDevisAuditFile(new UploadedFile($tmpAudit, "audit-" . $devis->getNum() . ".pdf", "application/pdf", null, true)); + + // 3. Préparation des pièces jointes pour le mail (Le PDF signé est le plus important) + $attachments = [ + new DataPart($signedContent, "Devis-" . $devis->getNum() . "-Signe.pdf", "application/pdf"), + new DataPart($auditContent, "Certificat-Signature-" . $devis->getNum() . ".pdf", "application/pdf"), + ]; + + + // 4. Sauvegarde en base de données + $devis->setUpdateAt(new \DateTimeImmutable()); + + foreach ($devis->getDevisLines() as $line) { + $product = $line->getProduct(); + + $productReserve = new ProductReserve(); + $productReserve->setProduct($product); + $productReserve->setCustomer($devis->getCustomer()); + $productReserve->setStartAt($line->getStartAt()); + $productReserve->setEndAt($line->getEndAt()); + $productReserve->setDevis($devis); + $entityManager->persist($productReserve); + } + $entityManager->persist($devis); + $entityManager->flush(); + + // 5. Envoi du mail de confirmation avec le récapitulatif + $mailer->send( + $devis->getCustomer()->getEmail(), + $devis->getCustomer()->getName() . " " . $devis->getCustomer()->getSurname(), + "[Ludikevent] Confirmation de signature - Devis " . $devis->getNum(), + "mails/sign/signed.twig", + [ + 'devis' => $devis // Correction ici : passage de l'objet, pas d'un string + ], + $attachments + ); + $mailer->send( + "contact@ludikevent.fr", + "Ludikevent", + "[Intranet Ludikevent] Confirmation signature d'un client pour - Devis " . $devis->getNum(), + "mails/sign/signed_notification.twig", + [ + 'devis' => $devis // Correction ici : passage de l'objet, pas d'un string + ], + $attachments + ); + } catch (\Exception $e) { + return new Response("Erreur lors de la récupération ou de l'envoi des documents : " . $e->getMessage(), 500); + } + } + } + + return $this->render('sign/sign_success.twig', [ + 'devis' => $devis ?? null + ]); } } diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php index f36a7fc..3898665 100644 --- a/src/Entity/Customer.php +++ b/src/Entity/Customer.php @@ -51,10 +51,17 @@ class Customer #[ORM\OneToMany(targetEntity: Devis::class, mappedBy: 'customer')] private Collection $devis; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: ProductReserve::class, mappedBy: 'customer')] + private Collection $productReserves; + public function __construct() { $this->customerAddresses = new ArrayCollection(); $this->devis = new ArrayCollection(); + $this->productReserves = new ArrayCollection(); } public function getId(): ?int @@ -217,4 +224,34 @@ class Customer return $this; } + + /** + * @return Collection + */ + public function getProductReserves(): Collection + { + return $this->productReserves; + } + + public function addProductReserf(ProductReserve $productReserf): static + { + if (!$this->productReserves->contains($productReserf)) { + $this->productReserves->add($productReserf); + $productReserf->setCustomer($this); + } + + return $this; + } + + public function removeProductReserf(ProductReserve $productReserf): static + { + if ($this->productReserves->removeElement($productReserf)) { + // set the owning side to null (unless already changed) + if ($productReserf->getCustomer() === $this) { + $productReserf->setCustomer(null); + } + } + + return $this; + } } diff --git a/src/Entity/Devis.php b/src/Entity/Devis.php index 3e414e6..b346fc9 100644 --- a/src/Entity/Devis.php +++ b/src/Entity/Devis.php @@ -77,6 +77,9 @@ class Devis #[ORM\ManyToOne(inversedBy: 'devisBill')] private ?CustomerAddress $billAddress = null; + #[ORM\OneToOne(mappedBy: 'devis', cascade: ['persist', 'remove'])] + private ?ProductReserve $productReserve = null; + public function __construct() { $this->devisLines = new ArrayCollection(); @@ -425,4 +428,26 @@ class Devis return $this; } + public function getProductReserve(): ?ProductReserve + { + return $this->productReserve; + } + + public function setProductReserve(?ProductReserve $productReserve): static + { + // unset the owning side of the relation if necessary + if ($productReserve === null && $this->productReserve !== null) { + $this->productReserve->setDevis(null); + } + + // set the owning side of the relation if necessary + if ($productReserve !== null && $productReserve->getDevis() !== $this) { + $productReserve->setDevis($this); + } + + $this->productReserve = $productReserve; + + return $this; + } + } diff --git a/src/Entity/Product.php b/src/Entity/Product.php index f1b1304..8d0b140 100644 --- a/src/Entity/Product.php +++ b/src/Entity/Product.php @@ -61,9 +61,16 @@ class Product #[ORM\OneToMany(targetEntity: DevisLine::class, mappedBy: 'product')] private Collection $devisLines; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: ProductReserve::class, mappedBy: 'product')] + private Collection $productReserves; + public function __construct() { $this->devisLines = new ArrayCollection(); + $this->productReserves = new ArrayCollection(); } public function getId(): ?int @@ -232,4 +239,34 @@ class Product return $this; } + + /** + * @return Collection + */ + public function getProductReserves(): Collection + { + return $this->productReserves; + } + + public function addProductReserf(ProductReserve $productReserf): static + { + if (!$this->productReserves->contains($productReserf)) { + $this->productReserves->add($productReserf); + $productReserf->setProduct($this); + } + + return $this; + } + + public function removeProductReserf(ProductReserve $productReserf): static + { + if ($this->productReserves->removeElement($productReserf)) { + // set the owning side to null (unless already changed) + if ($productReserf->getProduct() === $this) { + $productReserf->setProduct(null); + } + } + + return $this; + } } diff --git a/src/Entity/ProductReserve.php b/src/Entity/ProductReserve.php new file mode 100644 index 0000000..066cbab --- /dev/null +++ b/src/Entity/ProductReserve.php @@ -0,0 +1,95 @@ +id; + } + + public function getProduct(): ?Product + { + return $this->product; + } + + public function setProduct(?Product $product): static + { + $this->product = $product; + + return $this; + } + + public function getStartAt(): ?\DateTimeImmutable + { + return $this->startAt; + } + + public function setStartAt(\DateTimeImmutable $startAt): static + { + $this->startAt = $startAt; + + return $this; + } + + public function getEndAt(): ?\DateTimeImmutable + { + return $this->endAt; + } + + public function setEndAt(\DateTimeImmutable $endAt): static + { + $this->endAt = $endAt; + + return $this; + } + + public function getCustomer(): ?Customer + { + return $this->customer; + } + + public function setCustomer(?Customer $customer): static + { + $this->customer = $customer; + + return $this; + } + + public function getDevis(): ?Devis + { + return $this->devis; + } + + public function setDevis(?Devis $devis): static + { + $this->devis = $devis; + + return $this; + } +} diff --git a/src/Event/Signature/DevisSend.php b/src/Event/Signature/DevisSend.php new file mode 100644 index 0000000..a3b94b3 --- /dev/null +++ b/src/Event/Signature/DevisSend.php @@ -0,0 +1,20 @@ +devis; + } +} diff --git a/src/Event/Signature/DevisSubscriber.php b/src/Event/Signature/DevisSubscriber.php new file mode 100644 index 0000000..c074522 --- /dev/null +++ b/src/Event/Signature/DevisSubscriber.php @@ -0,0 +1,36 @@ +getDevis()->getCustomer(); + $devis = $devisSend->getDevis(); + $signLink = $this->client->getLinkSign($devis->getSignatureId()); + + + $this->mailer->send($customer->getEmail(), $customer->getName()." ".$customer->getSurname(),"[Signature Ludikevent] - Signature de votre devis pour votre location","mails/sign/devis.twig",[ + 'devis' => $devis, + 'signLink' => $signLink, + ]); + } +} diff --git a/src/Repository/ProductReserveRepository.php b/src/Repository/ProductReserveRepository.php new file mode 100644 index 0000000..85d3a23 --- /dev/null +++ b/src/Repository/ProductReserveRepository.php @@ -0,0 +1,43 @@ + + */ +class ProductReserveRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ProductReserve::class); + } + + // /** + // * @return ProductReserve[] Returns an array of ProductReserve objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('p') + // ->andWhere('p.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('p.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?ProductReserve + // { + // return $this->createQueryBuilder('p') + // ->andWhere('p.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Service/Pdf/DevisPdfService.php b/src/Service/Pdf/DevisPdfService.php index 051e6a1..329533c 100644 --- a/src/Service/Pdf/DevisPdfService.php +++ b/src/Service/Pdf/DevisPdfService.php @@ -327,15 +327,15 @@ class DevisPdfService extends Fpdf // AJOUT CONDITIONNEL DOCUSEAL if ($this->isIntegrateDocusealFields) { $this->SetXY(15, $currentY + 1); - $this->SetTextColor(255, 255, 255); // Blanc (invisible) - $this->SetFont('Arial', '', 4); - $this->Cell(5, 5, '{{Check;required=true;role=Client;name='.$role.'}}', 0, 0, 'C'); + $this->SetTextColor(0, 0, 0); // Blanc (invisible) + $this->SetFont('Arial', '', 10); + $this->Cell(5, 5, '{{type=checkbox;required=true;role=Client;name='.$role.';}}', 0, 0, 'C'); $this->SetTextColor(0, 0, 0); $this->SetFont('Arial', '', 10); } - $this->Ln(4); + $this->Ln(10); } $this->Ln(15); @@ -349,17 +349,13 @@ class DevisPdfService extends Fpdf $this->Cell(85, 45, "", 1, 0); $this->SetXY(17, $ySign + 12); - $this->SetFont('Arial', 'I', 8); - $this->SetTextColor(100, 100, 100); - $mention = "Signée par Lilian SEGARD - Ludikevent - Le ".date('d/m/Y')." par signature numérique validée"; - $this->MultiCell(81, 5, $this->clean($mention), 0, 'C'); // AJOUT CONDITIONNEL SIGNATURE PRESTATAIRE if ($this->isIntegrateDocusealFields) { - $this->SetXY(15, $ySign + 25); - $this->SetTextColor(255, 255, 255); - $this->SetFont('Arial', '', 4); - $this->Cell(85, 5, '{{Sign;type=signature;role=Ludikevent}}', 0, 0, 'C'); + $this->SetXY(22, $ySign + 9); + $this->SetTextColor(0, 0, 0); + $this->SetFont('Arial', '', 10); + $this->Cell(85, 5, '{{Sign;type=signature;role=Ludikevent;height=120;width=236}}', 0, 0, 'C'); } // --- BLOC CLIENT --- @@ -372,10 +368,10 @@ class DevisPdfService extends Fpdf // AJOUT CONDITIONNEL SIGNATURE CLIENT if ($this->isIntegrateDocusealFields) { - $this->SetXY(110, $ySign + 20); - $this->SetTextColor(255, 255, 255); - $this->SetFont('Arial', '', 4); - $this->Cell(85, 5, '{{Sign;type=signature;role=Client}}', 0, 0, 'C'); + $this->SetXY(113, $ySign+9); + $this->SetTextColor(0, 0, 0); + $this->SetFont('Arial', '', 10); + $this->Cell(85, 5, '{{Sign;type=signature;role=Client;height=120;width=236}}', 0, 0, 'C'); } } @@ -400,14 +396,6 @@ class DevisPdfService extends Fpdf // Texte gauche : Identification du devis $this->Cell(0, 10, $this->clean('Devis Ludik Event - Lilian SEGARD - SIRET 93048840800012'), 0, 0, 'L'); - // --- AJOUT DU CHAMP DOCUSEAL DANS LE FOOTER --- - if ($this->isIntegrateDocusealFields) { - $this->SetX(-60); // On se place vers la droite - $this->SetTextColor(255, 255, 255); // Invisible - $this->SetFont('Arial', '', 4); - // Utilisation d'un champ "Initials" qui est souvent utilisé en bas de page - $this->Cell(30, 10, '{{Initials;role=Client;name=paraphe}}', 0, 0, 'R'); - } // Numérotation des pages à droite $this->SetTextColor(150, 150, 150); diff --git a/src/Service/Signature/Client.php b/src/Service/Signature/Client.php index c3f9b73..68070a0 100644 --- a/src/Service/Signature/Client.php +++ b/src/Service/Signature/Client.php @@ -29,6 +29,7 @@ class Client // L'URL API est le point d'entrée pour le SDK Docuseal $apiUrl = rtrim("https://signature.esy-web.dev", '/') . '/api'; $this->docuseal = new \Docuseal\Api($key, $apiUrl); + $this->logo = $kernel->getProjectDir()."/public/provider/images/favicon.png"; } /** @@ -64,11 +65,20 @@ class Client 'role' => 'Ludikevent', 'email' => 'contact@ludikevent.fr', 'completed' => true, + 'fields' => [ + ['name'=>'Sign','default_value'=>$this->logoBase64()] + ] ], [ 'role' => 'Client', 'email' => $devis->getCustomer()->getEmail(), 'name' => $devis->getCustomer()->getSurname() . ' ' . $devis->getCustomer()->getName(), + 'fields' => [ + ['name'=>'cgv','default_value'=>true], + ['name'=>'assurance','default_value'=>true], + ['name'=>'securite','default_value'=>true], + ['name'=>'arrhes','default_value'=>true], + ], 'metadata' => [ 'id' => $devis->getId(), 'type' => 'devis' @@ -77,8 +87,11 @@ class Client ], ]); + // Stockage de l'ID submitter de Docuseal dans ton entité $devis->setSignatureId($submission['submitters'][1]['id']); + + dd($this->getLinkSign($devis->getSignatureId())); $this->entityManager->flush(); } @@ -96,7 +109,7 @@ class Client $submissionData = $this->docuseal->getSubmitter($submitterId); - return rtrim($this->baseUrl, '/') . "/s/" . $submissionData['slug']; + return rtrim("https://signature.esy-web.dev", '/') . "/s/" . $submissionData['slug']; } /** @@ -116,4 +129,33 @@ class Client return false; } } + + /** + * Récupère le fichier logo et le convertit en chaîne Base64 + * Utile pour l'intégration directe dans certains flux HTML ou API + */ + private function logoBase64(): ?string + { + // Vérifie si le fichier existe pour éviter une erreur + if (!file_exists($this->logo)) { + return null; + } + + // Lecture du contenu du fichier + $binaryData = file_get_contents($this->logo); + + // Récupération de l'extension pour le type MIME (png, jpg, etc.) + $extension = pathinfo($this->logo, PATHINFO_EXTENSION); + + // Encodage en Base64 + $base64 = base64_encode($binaryData); + + // Retourne le format complet data:image/... + return 'data:image/' . $extension . ';base64,' . $base64; + } + + public function getSubmition(mixed $submission_id) + { + return $this->docuseal->getSubmission($submission_id); + } } diff --git a/src/Twig/StripeExtension.php b/src/Twig/StripeExtension.php index 2e9e51a..83a9bbb 100644 --- a/src/Twig/StripeExtension.php +++ b/src/Twig/StripeExtension.php @@ -2,8 +2,10 @@ namespace App\Twig; +use App\Entity\Devis; use App\Service\Stripe\Client; use Twig\Extension\AbstractExtension; +use Twig\TwigFilter; use Twig\TwigFunction; class StripeExtension extends AbstractExtension @@ -12,6 +14,35 @@ class StripeExtension extends AbstractExtension { } + public function getFilters() + { + return [ + new TwigFilter('totalQuoto',[$this,'totalQuoto']) + ]; + } + + /** + * Calcule le total HT du devis en tenant compte des tarifs dégressifs (J1 + Jours Sup) + */ + public function totalQuoto(Devis $devis): float + { + $totalHT = 0; + + foreach ($devis->getDevisLines() as $line) { + $price1Day = $line->getPriceHt() ?? 0; + $priceSupHT = $line->getPriceHtSup() ?? 0; + $nbDays = $line->getDay() ?? 1; + + // Formule : Le premier jour est au prix plein, les suivants au prix Sup + // J1 + ( (Total Jours - 1) * Prix Sup ) + $lineTotalHT = $price1Day + (max(0, $nbDays - 1) * $priceSupHT); + + $totalHT += $lineTotalHT; + } + + return (float) $totalHT; + } + public function getFunctions() { return [ diff --git a/templates/dashboard/devis/list.twig b/templates/dashboard/devis/list.twig index 6164a7e..5c006c3 100644 --- a/templates/dashboard/devis/list.twig +++ b/templates/dashboard/devis/list.twig @@ -24,7 +24,7 @@ Client Date Statut - Total TTC + Total HT Actions @@ -66,57 +66,89 @@ 'draft': 'text-slate-400 bg-slate-500/10 border-slate-500/20', 'created_waitsign': 'text-amber-400 bg-amber-500/10 border-amber-500/20', 'refusée': 'text-rose-400 bg-rose-500/10 border-rose-500/20', + 'signed': 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20', 'signée': 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20' } %} {% set statusLabels = { 'draft': 'Brouillon', - 'crée': 'Créé', - 'envoyée': 'Envoyé', 'created_waitsign': 'Attente Signature', 'refusée': 'Refusé', + 'signed': 'Signé', 'signée': 'Signé' } %} {% set currentStatus = quote.state|lower %} - {% if currentStatus == 'en attends de signature' %} + {% if currentStatus == 'created_waitsign' %} - {% elseif currentStatus == 'signée' %} + {% elseif currentStatus == 'signed' or currentStatus == 'signée' %} {% endif %} {{ statusLabels[currentStatus] ?? currentStatus }} - + {# MONTANT #} - {{ 0|number_format(2, ',', ' ') }}€ + {{ (quote|totalQuoto)|number_format(2, ',', ' ') }}€ {# ACTIONS #}
- {# Modifier #} - - - - {# PDF #} - - - + {# Renvoyer lien de signature #} + {% if quote.state == "created_waitsign" %} + + + + + + {% endif %} - {# Delete #} - - - + {# Modifier : Interdit si signé #} + {% if quote.state != "signed" and quote.state != "signée" %} + + + + {% endif %} + + {# PDF Conditionnel #} + {% if quote.state == "signed" or quote.state == "signée" %} + {# PDF Signé #} + + + + {# Certificat Audit #} + + + + {% else %} + {# PDF Brouillon #} + + + + {% endif %} + + {# Delete : Interdit si signé #} + {% if quote.state != "signed" and quote.state != "signée" %} + + + + {% else %} +
+ +
+ {% endif %}
diff --git a/templates/mails/sign/devis.twig b/templates/mails/sign/devis.twig new file mode 100644 index 0000000..573a007 --- /dev/null +++ b/templates/mails/sign/devis.twig @@ -0,0 +1,49 @@ +{% extends 'mails/base.twig' %} + +{% block content %} + + + + Signature de votre devis + + + + Bonjour {{ datas.devis.customer.name }} {{ datas.devis.customer.surname }}, + + + Merci de nous avoir sollicités pour votre événement. Vous trouverez ci-dessous les détails de votre devis. Pour confirmer votre réservation, merci de le signer numériquement via le bouton ci-dessous. + + + + + + + + + Référence : + #{{ datas.devis.num }} + + + Montant Total HT : + {{ (datas.devis|totalQuoto)|number_format(2, ',', ' ') }} € + + + + (TVA non applicable, art. 293 B du CGI) + + + + + + + + + + SIGNER MON DEVIS EN LIGNE + + + Le lien de signature est sécurisé et conforme à la réglementation en vigueur. + + + +{% endblock %} diff --git a/templates/mails/sign/signed.twig b/templates/mails/sign/signed.twig new file mode 100644 index 0000000..499eab2 --- /dev/null +++ b/templates/mails/sign/signed.twig @@ -0,0 +1,42 @@ +{% extends 'mails/base.twig' %} + +{% block content %} + + + + Signature confirmée ! + + + Votre devis #{{ datas.devis.num }} est désormais signé. + + + + + + + + ⚠️ ACTION REQUISE : PAIEMENT DE L'ACOMPTE + + + Pour valider définitivement votre réservation, vous disposez de 3 jours pour effectuer le paiement de l'acompte. +

+ Passé ce délai, votre réservation sera automatiquement annulée et les dates seront libérées. +
+
+
+ + + + + Bonjour {{ datas.devis.customer.name }},

+ Nous avons bien reçu votre signature électronique. Vous trouverez en pièces jointes de cet e-mail : +
+ +
    +
  • Votre devis signé au format PDF
  • +
  • Le certificat d'audit de la signature
  • +
+
+
+
+{% endblock %} diff --git a/templates/mails/sign/signed_notification.twig b/templates/mails/sign/signed_notification.twig new file mode 100644 index 0000000..42ff9e4 --- /dev/null +++ b/templates/mails/sign/signed_notification.twig @@ -0,0 +1,45 @@ +{% extends 'mails/base.twig' %} + +{% block content %} + + + + Bonne nouvelle ! Le client {{ datas.devis.customer.name }} {{ datas.devis.customer.surname }} vient de signer numériquement son devis. + + + + + + + + + N° Devis : + {{ datas.devis.num }} + + + Client : + {{ datas.devis.customer.name }} + + + Montant HT : + {{ datas.devis.totalHt|number_format(2, ',', ' ') }} € + + + Date de signature : + {{ "now"|date("d/m/Y à H:i") }} + + + + + + + + + Rappel : Le client a reçu l'instruction de régler l'acompte sous 3 jours. + + + VOIR LE DEVIS SUR LE CRM + + + +{% endblock %} diff --git a/templates/sign/sign_success.twig b/templates/sign/sign_success.twig new file mode 100644 index 0000000..9c216d6 --- /dev/null +++ b/templates/sign/sign_success.twig @@ -0,0 +1,85 @@ +{% extends 'base.twig' %} + +{% block title %}Signature confirmée - Ludikevent{% endblock %} + +{% block body %} +
+
+ + {# Logo #} +
+ Ludikevent +
+ + {# Icône de succès #} +
+
+ + + +
+
+ + {# Contenu principal #} +
+

+ Signature effectuée ! +

+

+ La signature numérique du devis #{{ devis.num }} a été validée avec succès. +

+
+ + {# ALERT PAIEMENT ACOMPTE #} +
+
+
+ + + +
+
+

Action requise : Paiement de l'acompte

+

+ Vous disposez de 3 jours pour effectuer le paiement de l'acompte afin de valider définitivement votre réservation. Passé ce délai, votre réservation sera automatiquement annulée. +

+
+
+
+ +
+ + {# Prochaines étapes #} +
+
+
+ 1 +
+

+ Vous allez recevoir prochainement votre contrat de location par e-mail. +

+
+ +
+
+ 2 +
+

+ Un lien sécurisé vous sera transmis pour gérer vos documents et effectuer votre paiement. +

+
+
+ + {# Footer / Bouton retour #} +
+

+ Une copie du devis signé a été envoyée à : {{ devis.customer.email }} +

+ + Retour au site + +
+ +
+
+{% endblock %}