feat(ansible): Ajoute le dossier PDF aux droits d'écriture et modifie l'URL API

 feat(.env): Met à jour les URLs de signature et Stripe pour Ngrok

 feat(SignatureController): Ajoute le contrôleur de signature

 feat(DevisController): Intègre DocuSeal et la gestion des adresses client

🐛 fix(DevisManager.js): Corrige la sélection et la synchronisation des adresses

 feat(vich_uploader.yaml): Configure le stockage des fichiers PDF

 feat(initTomSelect.js): Améliore la gestion des prix des produits

 feat(DevisPdfService): Intègre la signature DocuSeal et améliore le pied de page

 feat(Client.php): Crée une soumission Docuseal pour les devis
This commit is contained in:
Serreau Jovann
2026-01-19 18:22:53 +01:00
parent 5d6c0fdde7
commit 0afc9e3396
12 changed files with 196 additions and 84 deletions

4
.env
View File

@@ -83,8 +83,8 @@ STRIPE_PK=pk_test_51SUA22173W4aeFB1nO6oFfDZ12HOTffDKtCshhZ8rkUg6kUO2ZaQC0tK72rhE
STRIPE_SK=sk_test_51SUA22173W4aeFB16EB2LxGI0hNvNJzFshDI98zRImWBIhSfzqOGAz5TlPxSpUWbj3x4COm6kmSsaal9FpQR1A7M0022DvjbbR
STRIPE_WEBHOOKS_SECRET=
SIGN_URL=https://e3358705e82c.ngrok-free.app
STRIPE_BASEURL=https://e3358705e82c.ngrok-free.app
SIGN_URL=https://e2221f1f9e85.ngrok-free.app
STRIPE_BASEURL=https://e2221f1f9e85.ngrok-free.app
MINIO_S3_URL=
MINIO_S3_CLIENT_ID=

1
.gitignore vendored
View File

@@ -53,3 +53,4 @@ backup/*.sql
/public/images/**/*.jpeg
/public/images/**/*.webp
/public/images/*/*.png
/public/pdf/**/*.pdf

View File

@@ -107,6 +107,7 @@
- "{{ path }}/var/log" # Specific for log, though var/log might be created by composer later
- "{{ path }}/public/media" # For uploads
- "{{ path }}/public/images" # For uploads
- "{{ path }}/public/pdf" # For uploads
- "{{ path }}/public/tmp-sign" # For upload
- "{{ path }}/sauvegarde"
@@ -245,5 +246,6 @@
- "{{ path }}/public/media"
- "{{ path }}/sauvegarde"
- "{{ path }}/public/images" # For uploads
- "{{ path }}/public/pdf"
- "{{ path }}/public/tmp-sign" # For uploads

View File

@@ -1,35 +1,39 @@
export class DevisManager extends HTMLDivElement {
connectedCallback() {
this.customerSelect = this.querySelector('select');
// On s'assure de bien cibler les éléments dans le DOM
this.billAddress = this.parentElement.parentElement.querySelector('#billAddress');
this.shipAddress = this.parentElement.parentElement.querySelector('#shipAddress');
// Initialisation du message par défaut au chargement
this.setAddressPlaceholder("Sélectionnez un client...");
if (this.customerSelect) {
this.customerSelect.addEventListener('change', (e) => this.updateCustomerInfo(e.target.value));
// Utiliser TomSelect si présent sur le client, sinon l'event natif
if (this.customerSelect.tomselect) {
this.customerSelect.tomselect.on('change', (value) => this.updateCustomerInfo(value));
} else {
this.customerSelect.addEventListener('change', (e) => this.updateCustomerInfo(e.target.value));
}
}
}
/**
* Met à jour le texte d'aide dans les champs TomSelect
*/
setAddressPlaceholder(text) {
[this.billAddress, this.shipAddress].forEach(el => {
if (el && el.tomselect) {
el.tomselect.settings.placeholder = text;
el.tomselect.inputState(); // Force la mise à jour visuelle
el.tomselect.inputState();
}
});
}
async updateCustomerInfo(customerId) {
// Purge et message d'attente
// Vider les options actuelles proprement
[this.billAddress, this.shipAddress].forEach(el => {
if (el.tomselect) {
if (el && el.tomselect) {
el.tomselect.clear();
el.tomselect.clearOptions();
// On désactive pendant le chargement pour éviter une saisie erronée
el.tomselect.disable();
}
});
@@ -38,25 +42,38 @@ export class DevisManager extends HTMLDivElement {
return;
}
// Pendant le chargement
this.setAddressPlaceholder("Chargement des adresses...");
try {
const resp = await fetch("/crm/customer/address/" + customerId);
const data = await resp.json();
[this.billAddress, this.shipAddress].forEach(el => el?.tomselect?.enable());
if (data.addressList && data.addressList.length > 0) {
this.setAddressPlaceholder("Choisir une adresse...");
data.addressList.forEach(itemList => {
const option = { value: itemList.id, text: itemList.label };
const option = { value: itemList.id, text: itemList.label }; // Adapté aux valueField/labelField par défaut
const option2 = { value: itemList.id, text: itemList.label }; // Adapté aux valueField/labelField par défaut
if (this.billAddress.tomselect) this.billAddress.tomselect.addOption(option);
if (this.shipAddress.tomselect) this.shipAddress.tomselect.addOption(option);
if (this.shipAddress.tomselect) this.shipAddress.tomselect.addOption(option2);
});
// Optionnel : Sélectionner la première adresse par défaut
this.billAddress.tomselect.setValue(data.addressList[0].id);
this.shipAddress.tomselect.setValue(data.addressList[0].id);
// --- SÉLECTION ET SYNCHRONISATION ---
const firstId = data.addressList[0].id;
if (this.billAddress.tomselect) {
this.billAddress.tomselect.setValue(firstId);
this.billAddress.dispatchEvent(new Event('change', { bubbles: true }));
}
if (this.shipAddress.tomselect) {
this.shipAddress.tomselect.setValue(firstId);
this.shipAddress.dispatchEvent(new Event('change', { bubbles: true }));
}
} else {
this.setAddressPlaceholder("Aucune adresse trouvée");
}
@@ -64,6 +81,7 @@ export class DevisManager extends HTMLDivElement {
} catch (error) {
console.error("Erreur adresses:", error);
this.setAddressPlaceholder("Erreur de chargement");
[this.billAddress, this.shipAddress].forEach(el => el?.tomselect?.enable());
}
}
}

View File

@@ -23,20 +23,31 @@ export function initTomSelect(parent = document) {
// Dans admin.js, section onChange de TomSelect :
onChange: (id) => {
if (!id) return;
const product = data.find(p => p.id == id);
// On s'assure de trouver le produit (id peut être string ou int)
const product = data.find(p => String(p.id) === String(id));
if (product) {
// On remonte au parent le plus proche (le bloc de ligne du devis)
const row = el.closest('.form-repeater__row') || el.closest('fieldset');
if (!row) return;
// Remplir Prix J1
// Ciblage précis des inputs
const priceInput = row.querySelector('input[name*="[price_ht]"]');
if (priceInput) priceInput.value = product.price1day;
// Remplir Prix Sup (Nouveau champ)
const priceSupInput = row.querySelector('input[name*="[price_sup_ht]"]');
if (priceSupInput) priceSupInput.value = product.priceSup;
// Déclencher les events
[priceInput, priceSupInput].forEach(i => i?.dispatchEvent(new Event('change', { bubbles: true })));
if (priceInput) {
priceInput.value = product.price1day;
// Indispensable pour que d'autres scripts (calcul totaux) voient le changement
priceInput.dispatchEvent(new Event('input', { bubbles: true }));
priceInput.dispatchEvent(new Event('change', { bubbles: true }));
}
if (priceSupInput) {
priceSupInput.value = product.priceSup;
priceSupInput.dispatchEvent(new Event('input', { bubbles: true }));
priceSupInput.dispatchEvent(new Event('change', { bubbles: true }));
}
}
},
render: {

View File

@@ -6,18 +6,18 @@ vich_uploader:
upload_destination: '%kernel.project_dir%/public/images/image_product'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
devis_file:
uri_prefix: /images/devis_file
upload_destination: '%kernel.project_dir%/public/images/devis_file'
uri_prefix: /pdf/devis_file
upload_destination: '%kernel.project_dir%/public/pdf/devis_file'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
devis_docuseal:
uri_prefix: /images/devis_docusign
upload_destination: '%kernel.project_dir%/public/images/devis_docusign'
uri_prefix: /pdf/devis_docusign
upload_destination: '%kernel.project_dir%/public/pdf/devis_docusign'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
devis_signed:
uri_prefix: /images/devis_signed
upload_destination: '%kernel.project_dir%/public/images/devis_signed'
uri_prefix: /pdf/devis_signed
upload_destination: '%kernel.project_dir%/public/pdf/devis_signed'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
devis_audit:
uri_prefix: /images/devis_audit
upload_destination: '%kernel.project_dir%/public/images/devis_audit'
uri_prefix: /pdf/devis_audit
upload_destination: '%kernel.project_dir%/public/pdf/devis_audit'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer

0
public/pdf/.gitignore vendored Normal file
View File

View File

@@ -2,19 +2,24 @@
namespace App\Controller\Dashboard;
use App\Entity\CustomerAddress;
use App\Entity\Devis;
use App\Entity\DevisLine;
use App\Form\NewDevisType;
use App\Logger\AppLogger;
use App\Repository\AccountRepository;
use App\Repository\CustomerAddressRepository;
use App\Repository\CustomerRepository;
use App\Repository\DevisRepository;
use App\Repository\ProductRepository;
use App\Service\Pdf\DevisPdfService;
use App\Service\Signature\Client;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Bundle\PaginatorBundle\KnpPaginatorBundle;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;
@@ -26,21 +31,15 @@ class DevisController extends AbstractController
* Liste des administrateurs
*/
#[Route(path: '/crm/devis', name: 'app_crm_devis', options: ['sitemap' => false], methods: ['GET'])]
public function devis(KernelInterface $kernel,DevisRepository $devisRepository,AppLogger $appLogger,PaginatorInterface $paginator,Request $request): Response
public function devis(Client $client,EntityManagerInterface $entityManager,KernelInterface $kernel,DevisRepository $devisRepository,AppLogger $appLogger,PaginatorInterface $paginator,Request $request): Response
{
$d = $devisRepository->findAll()[0];
$f = new DevisPdfService($kernel,$d);
$f->generate();
$f->Output('I');
$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),
]);
}
#[Route(path: '/crm/devis/add', name: 'app_crm_devis_add', options: ['sitemap' => false], methods: ['GET','POST'])]
public function devisAdd(ProductRepository $productRepository,EntityManagerInterface $entityManager,CustomerRepository $customerRepository,DevisRepository $devisRepository, AppLogger $appLogger,Request $request): Response
public function devisAdd(Client $client,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');
@@ -53,6 +52,8 @@ class DevisController extends AbstractController
$form = $this->createForm(NewDevisType::class,$devis);
if($request->isMethod('POST')){
$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();
@@ -67,8 +68,33 @@ class DevisController extends AbstractController
$entityManager->persist($rLine);
}
$entityManager->persist($devis);
$entityManager->flush();
$docusealService = new DevisPdfService($kernel, $devis, true);
$contentDocuseal = $docusealService->generate();
$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, 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("created_waitsign");
$devis->setUpdateAt(new \DateTimeImmutable());
$entityManager->flush();
$client->createSubmissionDevis($devis);
return $this->redirectToRoute('app_crm_devis');
}
return $this->render('dashboard/devis/add.twig',[
'form' => $form->createView(),

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Controller;
use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Form\RequestPasswordConfirmType;
use App\Form\RequestPasswordRequestType;
use App\Logger\AppLogger;
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
use App\Service\ResetPassword\Event\ResetPasswordEvent;
use Doctrine\ORM\EntityManagerInterface;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class SignatureController extends AbstractController
{
#[Route('/signature/complete', name: 'app_sign_complete')]
public function appSignComplete()
{
}
}

View File

@@ -11,11 +11,13 @@ class DevisPdfService extends Fpdf
private Devis $devis;
private string $logo;
private bool $isExtraPage = false;
private bool $isIntegrateDocusealFields;
public function __construct(KernelInterface $kernel, Devis $devis, $orientation = 'P', $unit = 'mm', $size = 'A4')
public function __construct(KernelInterface $kernel, Devis $devis,bool $isIntegrateDocusealFields = false, $orientation = 'P', $unit = 'mm', $size = 'A4')
{
parent::__construct($orientation, $unit, $size);
$this->devis = $devis;
$this->isIntegrateDocusealFields = $isIntegrateDocusealFields; // Stockage
$this->logo = $kernel->getProjectDir()."/public/provider/images/favicon.png";
$this->AliasNbPages();
@@ -318,45 +320,49 @@ class DevisPdfService extends Fpdf
foreach ($checkPoints as $label => $role) {
$currentY = $this->GetY();
$this->Rect(15, $currentY + 1, 5, 5);
$this->Rect(15, $currentY + 1, 5, 5); // Le carré reste visible pour tout le monde
$this->SetX(25);
$this->MultiCell(0, 7, $this->clean($label), 0, 'L');
// Balise DocuSeal Check
$this->SetXY(15, $currentY + 1);
$this->SetTextColor(255, 255, 255);
$this->SetFont('Arial', '', 4);
$this->Cell(5, 5, '{{Check;required=true;role=Client;name='.$role.'}}', 0, 0, 'C');
// 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);
$this->SetFont('Arial', '', 10);
}
$this->SetTextColor(0, 0, 0);
$this->SetFont('Arial', '', 10);
$this->Ln(4);
}
$this->Ln(15);
$ySign = $this->GetY();
// --- BLOCS DE SIGNATURE ---
// Cadre Prestataire (Lilian SEGARD)
// --- BLOC PRESTATAIRE ---
$this->SetXY(15, $ySign);
$this->SetFont('Arial', 'B', 10);
$this->Cell(85, 8, $this->clean("Le Prestataire"), 0, 1, 'C');
$this->SetX(15);
$this->Cell(85, 45, "", 1, 0); // Rectangle de signature
$this->Cell(85, 45, "", 1, 0);
// Texte à l'intérieur du rectangle du prestataire
$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');
// Balise Signature Ludikevent (Cachée pour DocuSeal)
$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');
// 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');
}
// Cadre Client
// --- BLOC CLIENT ---
$this->SetTextColor(0, 0, 0);
$this->SetXY(110, $ySign);
$this->SetFont('Arial', 'B', 10);
@@ -364,11 +370,13 @@ class DevisPdfService extends Fpdf
$this->SetX(110);
$this->Cell(85, 45, "", 1, 0);
// Balise Signature Client (Cachée pour DocuSeal)
$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');
// 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');
}
}
private function renderAddressBlock(string $label, $address, string $align, int $x, int $y)
@@ -382,19 +390,28 @@ class DevisPdfService extends Fpdf
foreach ($lines as $line) { if ($line) { $this->SetX($x); $this->Cell(90, 5, $this->clean($line), 0, 1, $align); } }
}
public function Footer()
public function Footer(): void
{
// Positionnement à 1,5 cm du bas
$this->SetY(-15);
$this->SetFont('Arial', 'I', 7);
$this->SetTextColor(150, 150, 150);
// BALISE DOCUSEAL CACHÉE (INITIALES)
$this->SetFont('Arial', '', 4);
$this->SetTextColor(255, 255, 255);
$this->Cell(40, 10, '{{Photo;type=initials;role=Client}}', 0, 0, 'L');
// Texte gauche : Identification du devis
$this->Cell(0, 10, $this->clean('Devis Ludik Event - Lilian SEGARD - SIRET 93048840800012'), 0, 0, 'L');
// TEXTE VISIBLE
// --- 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);
$this->SetFont('Arial', 'I', 8);
$this->SetTextColor(128, 128, 128);
$this->Cell(110, 10, $this->clean('Devis N° ' . $this->devis->getNum() . ' | Page ' . $this->PageNo() . '/{nb}'), 0, 0, 'C');
$this->Cell(0, 10, 'Lilian SEGARD - Ludikevent - 930 488 408', 0, 0, 'R');
$this->Cell(0, 10, 'Page ' . $this->PageNo() . '/{nb}', 0, 0, 'R');
}
}

View File

@@ -24,10 +24,10 @@ class Client
) {
// Configuration via les variables d'environnement
$key = $_ENV['ESYSIGN_APIEY'] ?? '';
$this->baseUrl = "https://signature.esy-web.dev";
$this->baseUrl = $_ENV['SIGN_URL'];
// L'URL API est le point d'entrée pour le SDK Docuseal
$apiUrl = rtrim($this->baseUrl, '/') . '/api';
$apiUrl = rtrim("https://signature.esy-web.dev", '/') . '/api';
$this->docuseal = new \Docuseal\Api($key, $apiUrl);
}
@@ -60,6 +60,11 @@ class Client
],
],
'submitters' => [
[
'role' => 'Ludikevent',
'email' => 'contact@ludikevent.fr',
'completed' => true,
],
[
'role' => 'Client',
'email' => $devis->getCustomer()->getEmail(),
@@ -73,7 +78,7 @@ class Client
]);
// Stockage de l'ID submitter de Docuseal dans ton entité
$devis->setSignatureId($submission['submitters'][0]['id']);
$devis->setSignatureId($submission['submitters'][1]['id']);
$this->entityManager->flush();
}

View File

@@ -63,19 +63,17 @@
{# STATUT DYNAMIQUE #}
<td class="px-6 py-4">
{% set statusClasses = {
'brouillon': 'text-slate-400 bg-slate-500/10 border-slate-500/20',
'crée': 'text-indigo-400 bg-indigo-500/10 border-indigo-500/20',
'envoyée': 'text-blue-400 bg-blue-500/10 border-blue-500/20',
'en attends de signature': 'text-amber-400 bg-amber-500/10 border-amber-500/20',
'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',
'signée': 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20'
} %}
{% set statusLabels = {
'brouillon': 'Brouillon',
'draft': 'Brouillon',
'crée': 'Créé',
'envoyée': 'Envoyé',
'en attends de signature': 'Attente Signature',
'created_waitsign': 'Attente Signature',
'refusée': 'Refusé',
'signée': 'Signé'
} %}