feat(DevisController): Améliore la gestion et l'édition des devis

Corrige des bugs et améliore la création/édition des devis, incluant options et lignes, et la gestion des signatures.
```
This commit is contained in:
Serreau Jovann
2026-01-29 10:06:39 +01:00
parent ea54f86fe8
commit 9a4d7b6ae1
14 changed files with 182 additions and 92 deletions

6
.env
View File

@@ -83,9 +83,9 @@ STRIPE_PK=pk_test_51SUA22173W4aeFB1nO6oFfDZ12HOTffDKtCshhZ8rkUg6kUO2ZaQC0tK72rhE
STRIPE_SK=sk_test_51SUA22173W4aeFB16EB2LxGI0hNvNJzFshDI98zRImWBIhSfzqOGAz5TlPxSpUWbj3x4COm6kmSsaal9FpQR1A7M0022DvjbbR
STRIPE_WEBHOOKS_SECRET=
SIGN_URL=https://a292239e6756.ngrok-free.app
STRIPE_BASEURL=https://a292239e6756.ngrok-free.app
CONTRAT_BASEURL=https://a292239e6756.ngrok-free.app
SIGN_URL=https://790f740ccd60.ngrok-free.app
STRIPE_BASEURL=https://790f740ccd60.ngrok-free.app
CONTRAT_BASEURL=https://790f740ccd60.ngrok-free.app
MINIO_S3_URL=
MINIO_S3_CLIENT_ID=

View File

@@ -47,6 +47,12 @@ details {
}
}
.ts-control {
.item {
color: white !important;
}
}
/* --- TOMSELECT CUSTOM DARK THEME --- */
/* On augmente la spécificité par le nesting pour éviter les !important */
.ts-wrapper {

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260129083756 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE devis_options ADD details TEXT DEFAULT NULL');
}
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 devis_options DROP details');
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260129084026 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE devis_options DROP CONSTRAINT fk_42db61dba7c41d6f');
$this->addSql('DROP INDEX idx_42db61dba7c41d6f');
$this->addSql('ALTER TABLE devis_options ADD option TEXT NOT NULL');
$this->addSql('ALTER TABLE devis_options DROP option_id');
}
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 devis_options ADD option_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE devis_options DROP option');
$this->addSql('ALTER TABLE devis_options ADD CONSTRAINT fk_42db61dba7c41d6f FOREIGN KEY (option_id) REFERENCES options (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX idx_42db61dba7c41d6f ON devis_options (option_id)');
}
}

BIN
sign_ludikevent.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -6,6 +6,7 @@ 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;
@@ -46,16 +47,17 @@ class DevisController extends AbstractController
PaginatorInterface $paginator,
Request $request,
KernelInterface $kernel,
): 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);
@@ -145,7 +147,7 @@ class DevisController extends AbstractController
$rLine = new DevisLine();
$rLine->setDevi($devis);
$rLine->setPos($cd);
$rLine->setProduct($productRepository->find($line['product_id']));
$rLine->setProduct($line['product']);
$rLine->setDay($day);
$rLine->setPriceHt(floatval($line['price_ht']));
$rLine->setPriceHtSup(floatval($line['price_sup_ht']));
@@ -154,14 +156,14 @@ class DevisController extends AbstractController
foreach ($_POST['options'] as $line) {
$rLineOptions = new DevisOptions();
$rLineOptions->setDevis($devis);
$rLineOptions->setOption($optionsRepository->find($line['product_id']));
$rLineOptions->setOption($line['product']);
$rLineOptions->setPriceHt(floatval($line['price_ht']));
$entityManager->persist($rLineOptions);
}
$entityManager->persist($devis);
$entityManager->flush();
$docusealService = new DevisPdfService($kernel, $devis, true);
$docusealService = new DevisPdfService($kernel, $devis, $entityManager->getRepository(Product::class),true);
$contentDocuseal = $docusealService->generate();
@@ -172,7 +174,7 @@ class DevisController extends AbstractController
$devis->setDevisDocuSealFile($fileDocuseal);
$devisService = new DevisPdfService($kernel, $devis, false);
$devisService = new DevisPdfService($kernel, $devis, $entityManager->getRepository(Product::class), false);
$contentDevis = $devisService->generate();
$tmpPathDevis = sys_get_temp_dir() . '/devis_' . uniqid() . '.pdf';
@@ -182,13 +184,10 @@ class DevisController extends AbstractController
$devis->setDevisFile($fileDevis);
$devis->setState("created_waitsign");
$devis->setState("wait-send");
$devis->setUpdateAt(new \DateTimeImmutable());
$entityManager->flush();
$client->createSubmissionDevis($devis);
$event = new DevisSend($devis);
$eventDispatcher->dispatch($event);
$this->addFlash('success', sprintf('Le devis %s a été crée.', $devis->getNum()));
return $this->redirectToRoute('app_crm_devis');
@@ -206,6 +205,7 @@ class DevisController extends AbstractController
'options' => [
[
'product' => '',
'details' => '',
'price_ht' => '',
]
]
@@ -223,27 +223,39 @@ class DevisController extends AbstractController
$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) {
$rLine = $devisLineRepository->find($line['id']);
$rLine->setDevi($devis);
$rLine->setPos($cd);
$rLine->setProduct($productRepository->find($line['product_id']));
$rLine->setDay($line['days']);
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);
}
foreach ($_POST['options'] as $line) {
$rLineOptions = $devisOptionsRepository->find($line['id']);
$rLineOptions->setOption($optionsRepository->find($line['product_id']));
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();
$docusealService = new DevisPdfService($kernel, $devis, true);
$docusealService = new DevisPdfService($kernel, $devis, $entityManager->getRepository(Product::class),true);
$contentDocuseal = $docusealService->generate();
@@ -254,7 +266,7 @@ class DevisController extends AbstractController
$devis->setDevisDocuSealFile($fileDocuseal);
$devisService = new DevisPdfService($kernel, $devis, false);
$devisService = new DevisPdfService($kernel, $devis, $entityManager->getRepository(Product::class),false);
$contentDevis = $devisService->generate();
$tmpPathDevis = sys_get_temp_dir() . '/devis_' . uniqid() . '.pdf';
@@ -264,11 +276,10 @@ class DevisController extends AbstractController
$devis->setDevisFile($fileDevis);
$devis->setUpdateAt(new \DateTimeImmutable());
$devis->setState("wait-send");
$entityManager->flush();
$client->createSubmissionDevis($devis);
$event = new DevisSend($devis);
$eventDispatcher->dispatch($event);
$this->addFlash('success', sprintf('Le devis %s a été modifiée.', $devis->getNum()));
return $this->redirectToRoute('app_crm_devis');
@@ -277,7 +288,7 @@ class DevisController extends AbstractController
$lines =[
[
'id' => '',
'product_id' => '',
'product' => '',
'days'=>'',
'price_ht' => '',
'price_sup_ht' =>''
@@ -286,7 +297,8 @@ class DevisController extends AbstractController
$options = [
[
'id' => '',
'product_id' => '',
'product' => '',
'details' => '',
'price_ht' => '',
]
];
@@ -295,7 +307,7 @@ class DevisController extends AbstractController
foreach ($devis->getDevisLines() as $key => $line) {
$lines[$key] = [
'id' => $line->getId(),
'product_id' => $line->getProduct()->getId(),
'product' => $line->getProduct(),
'days' => $line->getDay(),
'price_ht' => $line->getPriceHt(),
'price_sup_ht' => $line->getPriceHtSup()
@@ -304,7 +316,8 @@ class DevisController extends AbstractController
foreach ($devis->getDevisOptions() as $key => $line) {
$options[$key] = [
'id' => $line->getId(),
'product_id' => $line->getOption()->getId(),
'details' => $line->getDetails(),
'product' => $line->getOption(),
'price_ht' => $line->getPriceHt(),
];
}

View File

@@ -213,6 +213,7 @@ class ReserverController extends AbstractController
#[Route('/connexion', name: 'reservation_login')]
public function revervationLogin(AuthenticationUtils $authenticationUtils): Response
{
return $this->redirectToRoute('reservation');
return $this->render('revervation/login.twig',[
'last_username' => $authenticationUtils->getLastUsername(),
'error' => $authenticationUtils->getLastAuthenticationError()
@@ -231,6 +232,8 @@ class ReserverController extends AbstractController
EntityManagerInterface $em,
UserPasswordHasherInterface $hasher
): Response {
return $this->redirectToRoute('reservation');
if ($request->isMethod('POST')) {
$payload = $request->getPayload();

View File

@@ -3,6 +3,7 @@
namespace App\Entity;
use App\Repository\DevisOptionsRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: DevisOptionsRepository::class)]
@@ -16,12 +17,16 @@ class DevisOptions
#[ORM\ManyToOne(inversedBy: 'devisOptions')]
private ?Devis $devis = null;
#[ORM\ManyToOne(inversedBy: 'devisOptions')]
private ?Options $option = null;
#[ORM\Column]
private ?float $priceHt = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $details = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $option = null;
public function getId(): ?int
{
return $this->id;
@@ -39,17 +44,6 @@ class DevisOptions
return $this;
}
public function getOption(): ?Options
{
return $this->option;
}
public function setOption(?Options $option): static
{
$this->option = $option;
return $this;
}
public function getPriceHt(): ?float
{
@@ -62,4 +56,28 @@ class DevisOptions
return $this;
}
public function getDetails(): ?string
{
return $this->details;
}
public function setDetails(?string $details): static
{
$this->details = $details;
return $this;
}
public function getOption(): ?string
{
return $this->option;
}
public function setOption(string $option): static
{
$this->option = $option;
return $this;
}
}

View File

@@ -41,15 +41,9 @@ class Options
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
/**
* @var Collection<int, DevisOptions>
*/
#[ORM\OneToMany(targetEntity: DevisOptions::class, mappedBy: 'option')]
private Collection $devisOptions;
public function __construct()
{
$this->devisOptions = new ArrayCollection();
}
@@ -153,33 +147,4 @@ class Options
return$s->slugify($this->id."-".$this->name);
}
/**
* @return Collection<int, DevisOptions>
*/
public function getDevisOptions(): Collection
{
return $this->devisOptions;
}
public function addDevisOption(DevisOptions $devisOption): static
{
if (!$this->devisOptions->contains($devisOption)) {
$this->devisOptions->add($devisOption);
$devisOption->setOption($this);
}
return $this;
}
public function removeDevisOption(DevisOptions $devisOption): static
{
if ($this->devisOptions->removeElement($devisOption)) {
// set the owning side to null (unless already changed)
if ($devisOption->getOption() === $this) {
$devisOption->setOption(null);
}
}
return $this;
}
}

View File

@@ -3,6 +3,9 @@
namespace App\Service\Pdf;
use App\Entity\Devis;
use App\Entity\Product;
use App\Repository\ProductRepository;
use Doctrine\ORM\EntityManagerInterface;
use Fpdf\Fpdf;
use Symfony\Component\HttpKernel\KernelInterface;
@@ -12,8 +15,9 @@ class DevisPdfService extends Fpdf
private string $logo;
private bool $isExtraPage = false;
private bool $isIntegrateDocusealFields;
private ProductRepository $productRepository;
public function __construct(KernelInterface $kernel, Devis $devis,bool $isIntegrateDocusealFields = false, $orientation = 'P', $unit = 'mm', $size = 'A4')
public function __construct( KernelInterface $kernel, Devis $devis, ProductRepository $productRepository, bool $isIntegrateDocusealFields = false, $orientation = 'P', $unit = 'mm', $size = 'A4')
{
parent::__construct($orientation, $unit, $size);
$this->devis = $devis;
@@ -22,6 +26,7 @@ class DevisPdfService extends Fpdf
$this->AliasNbPages();
$this->SetAutoPageBreak(true, 35);
$this->productRepository = $productRepository;
}
/**
@@ -139,7 +144,11 @@ class DevisPdfService extends Fpdf
$totalCaution = 0;
$totalHT = 0;
foreach ($this->devis->getDevisLines() as $line) {
$totalCaution = $totalCaution + $line->getProduct()->getCaution();
$p = $this->productRepository->findOneBy(['name'=>$line->getProduct()]);
if($p instanceof Product) {
$totalCaution = $totalCaution + $p->getCaution();
}
$price1Day = $line->getPriceHt();
$priceSupHT = $line->getPriceHtSup() ?? 0;
$nbDays = $line->getDay();
@@ -148,8 +157,11 @@ class DevisPdfService extends Fpdf
$lineTotalHT = $price1Day + (max(0, $nbDays - 1) * $priceSupHT);
$totalHT += $lineTotalHT;
$productName = $line->getProduct()->getName();
$ref = $line->getProduct()->getRef();
$productName = $line->getProduct();
$ref= "";
if($p instanceof Product) {
$ref = $totalCaution + $p->getRef();
}
$currentY = $this->GetY();
@@ -191,14 +203,14 @@ class DevisPdfService extends Fpdf
foreach ($this->devis->getDevisOptions() as $devisOption) {
$option = $devisOption->getOption();
$priceHT = $option->getPriceHt();
$priceHT = $devisOption->getPriceHt();
$totalHT += $priceHT; // On l'ajoute au total général
$currentY = $this->GetY();
// Colonne Désignation
$this->SetXY(10, $currentY);
$this->Cell(150, 8, $this->clean($option->getName()), 'LRB', 0, 'L');
$this->Cell(150, 8, $this->clean($option." - ".$devisOption->getDetails()), 'LRB', 0, 'L');
// Colonne Prix
$this->Cell(40, 8, number_format($priceHT, 2, ',', ' ') . $this->euro(), 'RB', 1, 'R');

View File

@@ -32,7 +32,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";
$this->logo = $kernel->getProjectDir()."/sign_ludikevent.jpeg";
}
/**
@@ -42,7 +42,6 @@ class Client
{
// Si aucune signature n'est lancée, on initialise la soumission
if ($devis->getSignatureId() === null) {
// URL où le client sera redirigé après signature
$completedRedirectUrl = $this->baseUrl . $this->urlGenerator->generate(
'app_sign_complete',

View File

@@ -5,6 +5,7 @@ namespace App\Twig;
use App\Entity\Contrats;
use App\Entity\ContratsPayments;
use App\Entity\Devis;
use App\Entity\DevisOptions;
use App\Entity\Product;
use App\Service\Stripe\Client;
use Doctrine\ORM\EntityManagerInterface;
@@ -62,12 +63,9 @@ class StripeExtension extends AbstractExtension
}
// 2. Calcul des options additionnelles
/** @var DevisOptions $devisOption */
foreach ($devis->getDevisOptions() as $devisOption) {
// On récupère l'entité Option liée à la ligne de liaison
$option = $devisOption->getOption();
if ($option) {
$totalHT += $option->getPriceHt() ?? 0;
}
$totalHT += $devisOption->getPriceHt() ?? 0;
}
return (float) $totalHT;

View File

@@ -172,10 +172,10 @@
<input type="hidden" name="options[{{ key }}][id]" value="{{ option.id }}">
{% endif %}
{# 1. PRODUIT #}
<div class="lg:col-span-9">
<div class="lg:col-span-7">
<label class="text-[9px] font-black text-slate-500 uppercase tracking-widest ml-1 mb-2 block">Produit / Prestation</label>
<div class="relative flex items-center">
<input type="text" name="lines[{{ key }}][product]" value="{{ option.product }}" required class="w-full bg-slate-950/50 border-white/5 rounded-2xl text-white focus:ring-purple-500/20 focus:border-purple-500 transition-all py-3 pl-5 pr-12 text-sm">
<input type="text" name="options[{{ key }}][product]" value="{{ option.product }}" required class="w-full bg-slate-950/50 border-white/5 rounded-2xl text-white focus:ring-purple-500/20 focus:border-purple-500 transition-all py-3 pl-5 pr-12 text-sm">
{# BOUTON RECHERCHER #}
<button is="search-optionsdevis" type="button" class="absolute right-2 p-2 bg-purple-500/10 hover:bg-purple-500 text-purple-400 hover:text-white rounded-xl transition-all duration-300 group/search" title="Rechercher un produit">
@@ -183,6 +183,12 @@
</button>
</div>
</div>
<div class="lg:col-span-2">
<label class="text-[9px] font-black text-slate-500 uppercase tracking-widest ml-1 mb-2 block">Détails</label>
<div class="relative flex items-center">
<input type="text" name="options[{{ key }}][details]" value="{{ option.details }}" class="w-full bg-slate-950/50 border-white/5 rounded-2xl text-white focus:ring-purple-500/20 focus:border-purple-500 transition-all py-3 pl-5 pr-12 text-sm">
</div>
</div>
{# 3. PRIX HT J1 #}
<div class="lg:col-span-2">
<label class="{{ label_class }}">Prix Ht (€)</label>

View File

@@ -102,7 +102,7 @@
<div class="flex items-center justify-end space-x-2">
{# Renvoyer lien de signature #}
{% if quote.state == "created_waitsign" %}
{% if quote.state == "created_waitsign" and quote.state == "wait-send" %}
<a data-turbo="false" href="{{ path('app_crm_devis', {resend: quote.id}) }}"
title="Renvoyer le lien de signature"
class="p-2 bg-indigo-600/10 hover:bg-indigo-600 text-indigo-500 hover:text-white rounded-xl transition-all border border-indigo-500/20 shadow-lg shadow-indigo-600/5">