feat(devis): Gère l'affichage et les calculs des promotions, cautions et formules

This commit is contained in:
Serreau Jovann
2026-02-10 09:40:01 +01:00
parent cced4e8bd9
commit 916d19062e
7 changed files with 223 additions and 45 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://012d-212-114-31-239.ngrok-free.app
STRIPE_BASEURL=https://012d-212-114-31-239.ngrok-free.app
CONTRAT_BASEURL=https://012d-212-114-31-239.ngrok-free.app
SIGN_URL=https://eefa-82-67-166-187.ngrok-free.app
STRIPE_BASEURL=https://eefa-82-67-166-187.ngrok-free.app
CONTRAT_BASEURL=https://eefa-82-67-166-187.ngrok-free.app
MINIO_S3_URL=
MINIO_S3_CLIENT_ID=

View File

@@ -43,7 +43,7 @@ class DevisController extends AbstractController
private readonly ProductRepository $productRepository,
private readonly CustomerAddressRepository $customerAddressRepository,
private readonly CustomerRepository $customerRepository,
private readonly PrestaireRepository $prestaireRepository
private readonly PrestaireRepository $prestaireRepository,
) {
}
@@ -55,6 +55,8 @@ class DevisController extends AbstractController
return $this->handleResend($resendId);
}
$this->appLogger->record('VIEW', 'Consultation de la liste des devis');
$pagination = $paginator->paginate(
@@ -259,7 +261,7 @@ class DevisController extends AbstractController
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']));
if (!empty($devisData['paymentMethod'])) $devis->setPaymentMethod($devisData['paymentMethod']);
if (!empty($devisData['prestataire'])) $devis->setPrestataire($this->prestaireRepository->find($devisData['prestataire']));

View File

@@ -4,6 +4,7 @@ namespace App\Service;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class NotifuseClient
{
@@ -20,8 +21,10 @@ class NotifuseClient
public function __construct(
HttpClientInterface $httpClient,
LoggerInterface $logger,
?string $rootEmail = null, // From env
?string $rootSecretKey = null // From env
#[Autowire('%env(resolve:NOTIFUSE_ROOT_EMAIL)%')]
?string $rootEmail = null,
#[Autowire('%env(resolve:NOTIFUSE_SECRET_KEY)%')]
?string $rootSecretKey = null
) {
$this->httpClient = $httpClient;
$this->logger = $logger;

View File

@@ -155,14 +155,24 @@ class DevisPdfService extends Fpdf
$totalCaution = 0;
$totalHT = 0;
$isFormuleLineAdded = false;
foreach ($this->devis->getDevisLines() as $line) {
$p = $this->productRepository->findOneBy(['name'=>$line->getProduct()]);
if($p instanceof Product) {
if($p instanceof Product && !$formule) {
$totalCaution += $p->getCaution();
}
$price1Day = $line->getPriceHt();
$priceSupHT = $line->getPriceHtSup() ?? 0;
// Si une formule est présente, on force les prix à 0 pour afficher "Inclus"
// et on ajoute la ligne de la formule (géré plus bas)
if ($formule) {
$price1Day = 0;
$priceSupHT = 0;
}
$nbDays = $line->getDay();
$lineTotalHT = $price1Day + (max(0, $nbDays - 1) * $priceSupHT);
@@ -173,6 +183,35 @@ class DevisPdfService extends Fpdf
$currentY = $this->GetY();
// Gestion de l'affichage de la ligne Formule si les prix sont à 0
if ($formule && !$isFormuleLineAdded && $price1Day == 0 && $priceSupHT == 0) {
$fPrice = $formule->getPrice1j() ?? 0;
if ($nbDays >= 2 && $formule->getPrice2j()) $fPrice = $formule->getPrice2j();
if ($nbDays >= 5 && $formule->getPrice5j()) $fPrice = $formule->getPrice5j();
$totalHT += $fPrice;
$totalCaution += $formule->getCaution() ?? 0;
$this->SetXY(10, $currentY+2.5);
$this->SetFont('Arial', 'B', 8);
$this->Cell(70, 5, $this->clean("Formule : " . $formule->getName()), 0, 0, 'L');
$this->SetXY(80, $currentY);
$this->SetFont('Arial', '', 8);
$this->Cell(15, 10, $nbDays, 'LRB', 0, 'C');
$this->Cell(25, 10, number_format($fPrice, 2, ',', ' ') . $this->euro(), 'RB', 0, 'R');
$this->Cell(25, 10, "-", 'RB', 0, 'R');
$this->Cell(15, 10, $tvaLabel, 'RB', 0, 'C');
$this->Cell(40, 10, number_format($fPrice, 2, ',', ' ') . $this->euro(), 'RB', 1, 'R');
$this->Line(10, $currentY, 10, $currentY + 10);
$this->Line(10, $currentY + 10, 80, $currentY + 10);
$isFormuleLineAdded = true;
$currentY = $this->GetY(); // Update Y for the next line (the actual product line)
if ($this->GetY() > 250) { $this->AddPage(); $currentY = $this->GetY(); }
}
// Ligne Produit
$this->SetXY(10, $currentY+2.5);
$this->SetFont('Arial', 'B', 8);
@@ -199,22 +238,57 @@ class DevisPdfService extends Fpdf
$this->SetFillColor(230, 235, 245);
$this->Cell(135, 8, $this->clean('Options & Services additionnels'), 1, 0, 'L', true);
$this->Cell(15, 8, $this->clean('TVA'), 1, 0, 'C', true);
$this->Cell(40, 8, $this->clean('Total HT'), 1, 1, 'R', true);
$this->Cell(40, 8, $this->clean('i'), 1, 1, 'R', true);
$this->SetFont('Arial', '', 9);
foreach ($this->devis->getDevisOptions() as $devisOption) {
$priceHT = $devisOption->getPriceHt();
$optionName = $devisOption->getOption();
// Si une formule est présente, les options sont incluses (prix 0) sauf les frais de livraison
if ($formule) {
$isDelivery = stripos($optionName, 'livraison') !== false || stripos($optionName, 'déplacement') !== false;
if (!$isDelivery) {
$priceHT = 0;
}
}
$totalHT += $priceHT;
$this->Cell(135, 8, $this->clean($devisOption->getOption()." - ".$devisOption->getDetails()), 'LRB', 0, 'L');
$this->Cell(135, 8, $this->clean($optionName." - ".$devisOption->getDetails()), 'LRB', 0, 'L');
$this->Cell(15, 8, $tvaLabel, 'RB', 0, 'C');
$this->Cell(40, 8, number_format($priceHT, 2, ',', ' ') . $this->euro(), 'RB', 1, 'R');
$this->Cell(40, 8, ($priceHT == 0 ? "Inclus" : number_format($priceHT, 2, ',', ' ') . $this->euro()), 'RB', 1, 'R');
if ($this->GetY() > 260) { $this->AddPage(); }
}
}
// --- CALCULS FINAUX ---
// Gestion de la Promotion
$promotion = $this->devis->getOrderSession()?->getPromotion();
if ($promotion) {
$percentage = $promotion['percentage'] ?? 0;
$name = $promotion['name'] ?? 'Remise';
$discountAmount = $totalHT * ($percentage / 100);
// Affichage du Total HT Avant Remise
$this->SetFont('Arial', '', 9);
$this->Cell(130);
$this->SetTextColor(100, 100, 100);
$this->Cell(30, 8, $this->clean("Total HT Avant Remise"), 0, 0, 'L');
$this->Cell(30, 8, number_format($totalHT, 2, ',', ' ') . $this->euro(), 0, 1, 'R');
// Affichage de la ligne de promotion
$this->Cell(130);
$this->SetTextColor(37, 99, 235); // Bleu pour la promo
$this->Cell(30, 8, $this->clean("Promotion : $name (-$percentage%)"), 0, 0, 'L');
$this->Cell(30, 8, "- " . number_format($discountAmount, 2, ',', ' ') . $this->euro(), 0, 1, 'R');
$this->SetTextColor(0, 0, 0); // Reset couleur
$totalHT -= $discountAmount;
}
$amountTVA = $totalHT * $tvaRate;
$totalTTC = $totalHT + $amountTVA;
@@ -222,7 +296,10 @@ class DevisPdfService extends Fpdf
$this->Ln(5);
$this->SetFont('Arial', 'B', 10);
$this->Cell(130);
$this->Cell(30, 8, 'TOTAL HT', 0, 0, 'L');
$lblTotal = $promotion ? 'TOTAL HT REMISE' : 'TOTAL HT';
$this->Cell(30, 8, $this->clean($lblTotal), 0, 0, 'L');
$this->Cell(30, 8, number_format($totalHT, 2, ',', ' ') . $this->euro(), 0, 1, 'R');
$this->Cell(130);

View File

@@ -34,6 +34,10 @@ class StripeExtension extends AbstractExtension
{
return [
new TwigFilter('totalQuoto',[$this,'totalQuoto']),
new TwigFilter('discountAmount',[$this,'discountAmount']),
new TwigFilter('totalQuotoBeforeDiscount',[$this,'totalQuotoBeforeDiscount']),
new TwigFilter('totalQuotoHT',[$this,'totalQuotoHT']),
new TwigFilter('totalCaution',[$this,'totalCaution']),
new TwigFilter('totalQuotoAccompte', [$this, 'totalQuotoAccompte']),
new TwigFilter('totalContrat',[$this,'totalContrat']),
new TwigFilter('totalPayContrat',[$this,'totalPayContrat']),
@@ -55,51 +59,108 @@ class StripeExtension extends AbstractExtension
}
/**
* Calcule le total HT du devis en tenant compte des tarifs dégressifs (J1 + Jours Sup)
*/
public function totalQuoto(Devis $devis): float
private function calculateDevis(Devis $devis): array
{
$totalHT = 0;
$totalCautions = 0;
$tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true";
$tvaRate = 0.20; // 20% de TVA
$tvaRate = 0.20;
// 1. Calcul du montant HT des lignes et des cautions
$formule = $devis->getFormule();
$nbDays = 1;
if ($devis->getStartAt() && $devis->getEndAt()) {
$diff = $devis->getEndAt()->diff($devis->getStartAt());
$nbDays = $diff->days + 1;
}
// 1. Products & Cautions
foreach ($devis->getDevisLines() as $line) {
$price1Day = (float) ($line->getPriceHt() ?? 0);
$priceSupHT = (float) ($line->getPriceHtSup() ?? 0);
$nbDays = (int) ($line->getDay() ?? 1);
// Calcul HT de la ligne
$lineHT = $price1Day + (max(0, $nbDays - 1) * $priceSupHT);
$totalHT += $lineHT;
// Récupération de la caution liée au produit
$product = $this->em->getRepository(Product::class)->findOneBy([
'name' => $line->getProduct()
]);
if ($product) {
$totalCautions += (float) ($product->getCaution() ?? 0);
if (!$formule) {
$price1Day = (float) ($line->getPriceHt() ?? 0);
$priceSupHT = (float) ($line->getPriceHtSup() ?? 0);
$lDays = (int) ($line->getDay() ?? 1);
$totalHT += $price1Day + (max(0, $lDays - 1) * $priceSupHT);
}
if (!$formule) {
$product = $this->em->getRepository(Product::class)->findOneBy(['name' => $line->getProduct()]);
if ($product) {
$totalCautions += (float) ($product->getCaution() ?? 0);
}
}
}
// 2. Ajout des options au montant HT
foreach ($devis->getDevisOptions() as $option) {
$totalHT += (float) ($option->getPriceHt() ?? 0);
// 2. Formule
if ($formule) {
$fPrice = $formule->getPrice1j() ?? 0;
if ($nbDays >= 2 && $formule->getPrice2j()) $fPrice = $formule->getPrice2j();
if ($nbDays >= 5 && $formule->getPrice5j()) $fPrice = $formule->getPrice5j();
$totalHT += $fPrice;
$totalCautions += (float) ($formule->getCaution() ?? 0);
}
// 3. Application de la TVA si activée
// 3. Options
foreach ($devis->getDevisOptions() as $option) {
$price = (float) ($option->getPriceHt() ?? 0);
$name = $option->getOption();
if ($formule) {
$isDelivery = stripos($name, 'livraison') !== false || stripos($name, 'déplacement') !== false;
if (!$isDelivery) $price = 0;
}
$totalHT += $price;
}
$totalBeforeDiscount = $totalHT;
$discount = 0;
// 4. Promotion
$promotion = $devis->getOrderSession()?->getPromotion();
if ($promotion) {
$percentage = $promotion['percentage'] ?? 0;
$discount = $totalHT * ($percentage / 100);
$totalHT -= $discount;
}
// 5. TVA
$montantPrestation = $totalHT;
if ($tvaEnabled) {
$montantPrestation = $totalHT * (1 + $tvaRate);
}
// 4. Total final = Prestation (HT ou TTC) + Cautions
$totalFinal = $montantPrestation + $totalCautions;
return [
'discount' => $discount,
'final' => $montantPrestation + $totalCautions,
'prestation' => $montantPrestation,
'totalBeforeDiscount' => $totalBeforeDiscount,
'caution' => $totalCautions
];
}
return round($totalFinal, 2);
public function totalQuoto(Devis $devis): float
{
return round($this->calculateDevis($devis)['final'], 2);
}
public function totalQuotoHT(Devis $devis): float
{
return round($this->calculateDevis($devis)['prestation'], 2);
}
public function discountAmount(Devis $devis): float
{
return round($this->calculateDevis($devis)['discount'], 2);
}
public function totalQuotoBeforeDiscount(Devis $devis): float
{
return round($this->calculateDevis($devis)['totalBeforeDiscount'], 2);
}
public function totalCaution(Devis $devis): float
{
return round($this->calculateDevis($devis)['caution'], 2);
}
public function totalContratAccompte(Contrats $devis): float
{

View File

@@ -25,6 +25,7 @@
<th class="px-6 py-5 text-[10px] font-black text-slate-300 uppercase tracking-[0.2em]">Date</th>
<th class="px-6 py-5 text-[10px] font-black text-slate-300 uppercase tracking-[0.2em]">Statut</th>
<th class="px-6 py-5 text-[10px] font-black text-slate-300 uppercase tracking-[0.2em] text-center">Total HT</th>
<th class="px-6 py-5 text-[10px] font-black text-slate-300 uppercase tracking-[0.2em] text-center">Caution</th>
<th class="px-6 py-5 text-[10px] font-black text-slate-300 uppercase tracking-[0.2em] text-right">Actions</th>
</tr>
</thead>
@@ -92,9 +93,30 @@
{# MONTANT #}
<td class="px-6 py-4 text-center">
<span class="text-sm font-black text-white">
{{ (quote|totalQuoto)|number_format(2, ',', ' ') }}
</span>
<div class="flex flex-col items-center">
{% if quote.orderSession and quote.orderSession.promotion %}
<span class="text-sm font-black text-white">
{{ (quote|totalQuotoHT)|number_format(2, ',', ' ') }}
</span>
<span class="text-[10px] text-slate-500 line-through font-bold">
{{ (quote|totalQuotoBeforeDiscount)|number_format(2, ',', ' ') }}
</span>
<span class="text-[9px] text-emerald-400 font-bold uppercase tracking-wider mt-1">
Promo -{{ quote.orderSession.promotion.percentage }}%
</span>
{% else %}
<span class="text-sm font-black text-white">
{{ (quote|totalQuotoHT)|number_format(2, ',', ' ') }}
</span>
{% endif %}
</div>
</td>
{# CAUTION #}
<td class="px-6 py-4 text-center">
<span class="text-xs font-bold text-slate-400">
{{ (quote|totalCaution)|number_format(2, ',', ' ') }}
</span>
</td>
{# ACTIONS #}

View File

@@ -23,9 +23,22 @@
<th style="padding: 10px 0;">Référence :</th>
<th style="padding: 10px 0; text-align:right;">#{{ datas.devis.num }}</th>
</tr>
{% if datas.devis.orderSession and datas.devis.orderSession.promotion %}
{% set discount = datas.devis|discountAmount %}
{% if discount > 0 %}
<tr>
<td style="padding: 10px 0; color: #888888;">Total HT Initial :</td>
<td style="padding: 10px 0; text-align:right; color: #888888; text-decoration: line-through;">{{ (datas.devis|totalQuotoBeforeDiscount)|number_format(2, ',', ' ') }} €</td>
</tr>
<tr>
<td style="padding: 10px 0; color: #2563eb;">Promotion : {{ datas.devis.orderSession.promotion.name }} (-{{ datas.devis.orderSession.promotion.percentage }}%)</td>
<td style="padding: 10px 0; text-align:right; color: #2563eb;">- {{ discount|number_format(2, ',', ' ') }} €</td>
</tr>
{% endif %}
{% endif %}
<tr>
<td style="padding: 10px 0;">Montant Total HT :</td>
<td style="padding: 10px 0; text-align:right; font-weight:bold;">{{ (datas.devis|totalQuoto)|number_format(2, ',', ' ') }} €</td>
<td style="padding: 10px 0;">Montant Total HT{% if datas.devis.orderSession and datas.devis.orderSession.promotion %} Remisé{% endif %} :</td>
<td style="padding: 10px 0; text-align:right; font-weight:bold;">{{ (datas.devis|totalQuotoHT)|number_format(2, ',', ' ') }} €</td>
</tr>
<tr>
<td style="padding: 5px 0; color:#888888; font-size:12px;" colspan="2">