From 822f187dfbb09d7f4aeda65c55e08ce92447d4a6 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Mon, 9 Feb 2026 14:06:26 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(formule):=20Int=C3=A8gre=20la?= =?UTF-8?q?=20gestion=20des=20formules=20et=20packs=20libres=20dans=20le?= =?UTF-8?q?=20parcours=20de=20r=C3=A9servation=20et=20les=20documents.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/libs/SearchProductFormule.js | 4 + assets/reserve.js | 8 + assets/tools/FlowFormuleConfigurator.js | 190 ++++++++++++++++ assets/tools/FlowReserve.js | 19 +- assets/tools/SubmitClearStorage.js | 12 + assets/tools/UtmEvent.js | 2 +- config/packages/nelmio_security.yaml | 1 + migrations/Version20260209123602.php | 35 +++ .../Dashboard/FormulesController.php | 14 +- src/Controller/ReserverController.php | 209 ++++++++++++++++-- src/Controller/Webhooks.php | 20 ++ src/Entity/Formules.php | 4 +- src/Entity/OrderSession.php | 15 ++ src/Service/Pdf/DevisPdfService.php | 6 +- src/Twig/StripeExtension.php | 54 ++++- templates/dashboard/flow/view.twig | 30 ++- templates/dashboard/formules/config-free.twig | 2 +- templates/dashboard/formules/view.twig | 4 +- templates/mails/prestataire/new_contrat.twig | 28 +++ templates/reservation/contrat/view.twig | 7 +- templates/revervation/flow.twig | 15 ++ templates/revervation/flow_confirmed.twig | 17 +- templates/revervation/formule/show.twig | 130 +++++++---- 23 files changed, 734 insertions(+), 92 deletions(-) create mode 100644 assets/tools/FlowFormuleConfigurator.js create mode 100644 assets/tools/SubmitClearStorage.js create mode 100644 migrations/Version20260209123602.php create mode 100644 templates/mails/prestataire/new_contrat.twig diff --git a/assets/libs/SearchProductFormule.js b/assets/libs/SearchProductFormule.js index 309efd3..4deb1c5 100644 --- a/assets/libs/SearchProductFormule.js +++ b/assets/libs/SearchProductFormule.js @@ -130,7 +130,9 @@ export class SearchOptionsFormule extends HTMLButtonElement { const row = this.closest('.form-repeater__row'); if (row) { const nameInput = row.querySelector('input[name*="[product]"]'); + const nameId = row.querySelector('input[name*="[id]"]'); if(nameInput) nameInput.value = option.name; + if(nameId) nameId.value = option.id; const fieldset = row.querySelector('fieldset'); if (fieldset) { @@ -275,8 +277,10 @@ export class SearchProductFormule extends HTMLButtonElement { fillFormLine(product) { const row = this.closest('.form-repeater__row'); if (row) { + const nameId = row.querySelector('input[name*="[id]"]'); const nameInput = row.querySelector('input[name*="[product]"]'); if(nameInput) nameInput.value = product.name; + if(nameId) nameId.value = product.id; const fieldset = row.querySelector('fieldset'); if (fieldset) { diff --git a/assets/reserve.js b/assets/reserve.js index c630edb..afdee5b 100644 --- a/assets/reserve.js +++ b/assets/reserve.js @@ -4,6 +4,8 @@ import { CookieBanner } from "./tools/CookieBanner.js"; import { FlowReserve } from "./tools/FlowReserve.js"; import { FlowDatePicker } from "./tools/FlowDatePicker.js"; import { FlowAddToCart } from "./tools/FlowAddToCart.js"; +import { FlowFormuleConfigurator } from "./tools/FlowFormuleConfigurator.js"; +import { SubmitClearStorage } from "./tools/SubmitClearStorage.js"; import { LeafletMap } from "./tools/LeafletMap.js"; import { LocalStorageClear } from "./tools/LocalStorageClear.js"; import * as Turbo from "@hotwired/turbo"; @@ -265,6 +267,12 @@ const registerComponents = () => { if(!customElements.get('flow-add-to-cart')) customElements.define('flow-add-to-cart',FlowAddToCart) + + if(!customElements.get('flow-formule-configurator')) + customElements.define('flow-formule-configurator',FlowFormuleConfigurator) + + if(!customElements.get('submit-clear-storage')) + customElements.define('submit-clear-storage',SubmitClearStorage, { extends: 'form' }) }; document.addEventListener('DOMContentLoaded', () => { diff --git a/assets/tools/FlowFormuleConfigurator.js b/assets/tools/FlowFormuleConfigurator.js new file mode 100644 index 0000000..9a778b6 --- /dev/null +++ b/assets/tools/FlowFormuleConfigurator.js @@ -0,0 +1,190 @@ +export class FlowFormuleConfigurator extends HTMLElement { + constructor() { + super(); + this.counts = { structure: 0, alimentaire: 0, barnum: 0 }; + this.selection = []; + this.blocked = false; + this.mode = 'free'; + } + + connectedCallback() { + try { + this.limits = JSON.parse(this.getAttribute('data-limits') || '{}'); + this.prices = JSON.parse(this.getAttribute('data-prices') || '{}'); + } catch (e) { + console.error('Invalid limits/prices JSON', e); + return; + } + + this.formuleId = this.getAttribute('data-formule-id'); + this.mode = this.getAttribute('data-mode') || 'free'; + this.validateBtn = this.querySelector('#btn-validate-pack'); + + if (this.mode === 'pack') { + try { + this.selection = JSON.parse(this.getAttribute('data-preselected-ids') || '[]'); + } catch (e) { console.error('Invalid preselected-ids JSON', e); } + } else { + this.querySelectorAll('.product-card').forEach(card => { + card.addEventListener('click', (e) => { + e.preventDefault(); + this.toggleItem(card); + }); + }); + } + + if (this.validateBtn) { + this.validateBtn.addEventListener('click', (e) => { + e.preventDefault(); + this.validate(); + }); + } + + this.checkDuration(); + this.updateUI(); + } + + checkDuration() { + const datesStr = sessionStorage.getItem('reservation_dates'); + if (!datesStr) return; + + try { + const dates = JSON.parse(datesStr); + if (!dates.start || !dates.end) return; + + const start = new Date(dates.start); + const end = new Date(dates.end); + const diffTime = Math.abs(end - start); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; + + let message = null; + let block = false; + + if (diffDays > 5) { + message = `Attention : La durée de votre réservation (${diffDays} jours) dépasse la limite autorisée de 5 jours pour cette formule.`; + block = true; + } else if (diffDays >= 3 && diffDays <= 5) { + message = `Information : Pour une durée de ${diffDays} jours, le tarif "5 jours" sera appliqué automatiquement.`; + } + + if (message) { + let msgContainer = this.querySelector('.formule-warning'); + if (!msgContainer) { + msgContainer = document.createElement('div'); + this.prepend(msgContainer); + } + msgContainer.className = `formule-warning p-4 mb-6 rounded-2xl text-sm font-bold text-center border-2 ${block ? 'bg-red-50 border-red-100 text-red-600' : 'bg-blue-50 border-blue-100 text-blue-600'}`; + msgContainer.innerHTML = message; + } + + this.blocked = block; + this.updateUI(); + + } catch (e) { console.error(e); } + } + + toggleItem(el) { + if (this.blocked || this.mode === 'pack') return; + + const category = el.getAttribute('data-category'); + const id = el.getAttribute('data-id'); + const isSelected = el.getAttribute('data-selected') === 'true'; + + if (isSelected) { + this.deselect(el, category, id); + } else { + if (this.counts[category] < this.limits[category]) { + this.select(el, category, id); + } else { + this.shake(el); + } + } + this.updateUI(); + } + + select(el, category, id) { + el.setAttribute('data-selected', 'true'); + el.classList.add('ring-4', 'ring-[#f39e36]', 'scale-95'); + + const indicator = el.querySelector('.selection-indicator'); + if (indicator) { + indicator.classList.remove('opacity-0', 'group-hover:opacity-100'); + indicator.classList.add('opacity-100'); + indicator.querySelector('div')?.classList.remove('hidden'); + } + + this.counts[category]++; + this.selection.push(id); + } + + deselect(el, category, id) { + el.setAttribute('data-selected', 'false'); + el.classList.remove('ring-4', 'ring-[#f39e36]', 'scale-95'); + + const indicator = el.querySelector('.selection-indicator'); + if (indicator) { + indicator.classList.remove('opacity-100'); + indicator.classList.add('opacity-0', 'group-hover:opacity-100'); + indicator.querySelector('div')?.classList.add('hidden'); + } + + this.counts[category]--; + this.selection = this.selection.filter(item => item !== id); + } + + shake(el) { + el.classList.add('animate-pulse'); + setTimeout(() => el.classList.remove('animate-pulse'), 500); + alert("Limite atteinte pour cette catégorie."); + } + + updateUI() { + if (this.mode === 'free') { + ['structure', 'alimentaire', 'barnum'].forEach(cat => { + const span = this.querySelector(`#count-${cat}`); + if (span) span.innerText = this.counts[cat]; + }); + } + + const total = this.selection.length; + if (this.validateBtn) { + if (this.blocked) { + this.validateBtn.innerText = 'Durée non autorisée (> 5j)'; + this.validateBtn.classList.add('opacity-50', 'pointer-events-none', 'translate-y-4', 'bg-red-600'); + this.validateBtn.classList.remove('bg-slate-900', 'hover:bg-[#fc0e50]'); + } else { + this.validateBtn.innerText = this.mode === 'pack' ? 'Réserver ce pack' : `Valider mon pack (${total})`; + this.validateBtn.classList.remove('bg-red-600'); + this.validateBtn.classList.add('bg-slate-900', 'hover:bg-[#fc0e50]'); + + if (total > 0) { + this.validateBtn.classList.remove('opacity-50', 'pointer-events-none', 'translate-y-4'); + } else { + this.validateBtn.classList.add('opacity-50', 'pointer-events-none', 'translate-y-4'); + } + } + } + } + + validate() { + if (this.blocked || this.selection.length === 0) return; + + sessionStorage.setItem('active_formule', this.formuleId); + + let list = []; + try { + list = JSON.parse(sessionStorage.getItem('pl_list') || '[]'); + } catch(e) {} + + const newItems = this.selection.filter(id => !list.includes(id)); + if (newItems.length > 0) { + list = [...list, ...newItems]; + sessionStorage.setItem('pl_list', JSON.stringify(list)); + } + + window.dispatchEvent(new CustomEvent('cart:updated')); + + const cart = document.querySelector('[is="flow-reserve"]'); + if (cart) cart.open(); + } +} diff --git a/assets/tools/FlowReserve.js b/assets/tools/FlowReserve.js index da092bd..47d0368 100644 --- a/assets/tools/FlowReserve.js +++ b/assets/tools/FlowReserve.js @@ -267,7 +267,8 @@ export class FlowReserve extends HTMLAnchorElement { ids, options, start: dates.start, - end: dates.end + end: dates.end, + formule: sessionStorage.getItem('active_formule') }) }); @@ -362,6 +363,7 @@ export class FlowReserve extends HTMLAnchorElement {
+ ${product.in_formule ? `Inclus` : ''}

${product.name}

1J: ${this.formatPrice(product.priceHt1Day)} HT @@ -395,13 +397,23 @@ export class FlowReserve extends HTMLAnchorElement { const total = data.total || {}; const hasTva = total.totalTva > 0; const promotion = data.promotion; + const formuleName = total.formule; let promoHtml = ''; let originalTotalHT = total.totalHT; + if (formuleName) { + promoHtml += ` +
+ Formule + ${formuleName} +
+ `; + } + if (promotion && total.discount > 0) { originalTotalHT = total.totalHT + total.discount; - promoHtml = ` + promoHtml += `
${promotion.name} (-${promotion.percentage}%) -${this.formatPrice(total.discount)} @@ -460,7 +472,8 @@ export class FlowReserve extends HTMLAnchorElement { ids, options, start: dates.start, - end: dates.end + end: dates.end, + formule: sessionStorage.getItem('active_formule') }) }); diff --git a/assets/tools/SubmitClearStorage.js b/assets/tools/SubmitClearStorage.js new file mode 100644 index 0000000..6c58741 --- /dev/null +++ b/assets/tools/SubmitClearStorage.js @@ -0,0 +1,12 @@ +export class SubmitClearStorage extends HTMLFormElement { + constructor() { + super(); + } + + connectedCallback() { + this.addEventListener('submit', () => { + localStorage.clear(); + sessionStorage.clear(); + }); + } +} diff --git a/assets/tools/UtmEvent.js b/assets/tools/UtmEvent.js index 06b38a1..5aaf1e1 100644 --- a/assets/tools/UtmEvent.js +++ b/assets/tools/UtmEvent.js @@ -38,7 +38,7 @@ export class UtmAccount extends HTMLElement { // 4. Envoi du sessionId à ton backend Symfony if (sessionId) { - await fetch('/reservation/umami', { + await fetch('/umami', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ umami_session: sessionId }) diff --git a/config/packages/nelmio_security.yaml b/config/packages/nelmio_security.yaml index 297bbb7..d34dc83 100644 --- a/config/packages/nelmio_security.yaml +++ b/config/packages/nelmio_security.yaml @@ -46,6 +46,7 @@ nelmio_security: - "https://challenges.cloudflare.com" - "https://tools-security.esy-web.dev" - "https://checkout.stripe.com/" + - "https://unpkg.com/" frame-src: - "'self'" - "https://chat.esy-web.dev" diff --git a/migrations/Version20260209123602.php b/migrations/Version20260209123602.php new file mode 100644 index 0000000..add44e7 --- /dev/null +++ b/migrations/Version20260209123602.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE order_session ADD formule_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE order_session ADD CONSTRAINT FK_263E7C9F2A68F4D1 FOREIGN KEY (formule_id) REFERENCES formules (id)'); + $this->addSql('CREATE INDEX IDX_263E7C9F2A68F4D1 ON order_session (formule_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE order_session DROP CONSTRAINT FK_263E7C9F2A68F4D1'); + $this->addSql('DROP INDEX IDX_263E7C9F2A68F4D1'); + $this->addSql('ALTER TABLE order_session DROP formule_id'); + } +} diff --git a/src/Controller/Dashboard/FormulesController.php b/src/Controller/Dashboard/FormulesController.php index 0a85d4e..4f0bfc4 100644 --- a/src/Controller/Dashboard/FormulesController.php +++ b/src/Controller/Dashboard/FormulesController.php @@ -139,6 +139,18 @@ class FormulesController extends AbstractController $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { + if($formule->getType() == "free") { + $r = $formule->getFormulesRestriction(); + if(!$r) { + $r = new FormulesRestriction(); + $r->setNbStructureMax(0); + $r->setNbAlimentaireMax(0); + $r->setNbBarhumsMax(0); + $r->setRestrictionConfig([]); + $this->em->persist($r); + $formule->setFormulesRestriction($r); + } + } $this->em->persist($formule); $this->em->flush(); @@ -341,6 +353,6 @@ class FormulesController extends AbstractController if ($restriction && !empty($restriction->getRestrictionConfig())) { return $restriction->getRestrictionConfig(); } - return [['type' => 'structure', 'product' => '']]; + return [['type' => 'structure', 'product' => '','id'=>0]]; } } diff --git a/src/Controller/ReserverController.php b/src/Controller/ReserverController.php index b6951f4..535a111 100644 --- a/src/Controller/ReserverController.php +++ b/src/Controller/ReserverController.php @@ -4,6 +4,7 @@ namespace App\Controller; use App\Entity\Customer; use App\Entity\CustomerTracking; +use App\Entity\Formules; use App\Entity\Product; use App\Entity\ProductReserve; use App\Entity\Promotion; @@ -103,6 +104,29 @@ class ReserverController extends AbstractController $selectedOptionsMap = $sessionData['options'] ?? []; $runningTotalHT = 0; + $formule = $session->getFormule(); + $formuleConfig = []; + if ($formule) { + $restriction = $formule->getFormulesRestriction(); + if ($restriction) { + $formuleConfig = $restriction->getRestrictionConfig() ?? []; + } + + $formulaBasePrice = 0; + if ($duration <= 1) $formulaBasePrice = $formule->getPrice1j(); + elseif ($duration <= 2) $formulaBasePrice = $formule->getPrice2j(); + else $formulaBasePrice = $formule->getPrice5j(); + + $line = new DevisLine(); + $line->setProduct("Formule : " . $formule->getName()); + $line->setPriceHt($formulaBasePrice); + $line->setPriceHtSup(0); + $line->setDay(1); + $devis->addDevisLine($line); + + $runningTotalHT += $formulaBasePrice; + } + if (!empty($ids)) { $products = $productRepository->findBy(['id' => $ids]); $processedProductIds = []; @@ -110,15 +134,31 @@ class ReserverController extends AbstractController foreach ($products as $product) { $processedProductIds[] = $product->getId(); + $isInFormule = false; + if ($formule) { + foreach ($formuleConfig as $c) { + if (($c['product'] ?? '') === $product->getName()) { + $isInFormule = true; + break; + } + } + } + $line = new DevisLine(); $line->setProduct($product->getName()); - $line->setPriceHt($product->getPriceDay()); - $line->setPriceHtSup($product->getPriceSup()); + + if ($formule && $isInFormule) { + $line->setPriceHt(0); + $line->setPriceHtSup(0); + } else { + $line->setPriceHt($product->getPriceDay()); + $line->setPriceHtSup($product->getPriceSup()); + $runningTotalHT += $product->getPriceDay() + ($product->getPriceSup() * max(0, $duration - 1)); + } + $line->setDay($duration); $devis->addDevisLine($line); - $runningTotalHT += $product->getPriceDay() + ($product->getPriceSup() * max(0, $duration - 1)); - if (isset($selectedOptionsMap[$product->getId()])) { $optionIds = $selectedOptionsMap[$product->getId()]; if (!empty($optionIds)) { @@ -377,7 +417,8 @@ class ReserverController extends AbstractController ProductRepository $productRepository, OptionsRepository $optionsRepository, UploaderHelper $uploaderHelper, - PromotionRepository $promotionRepository + PromotionRepository $promotionRepository, + FormulesRepository $formulesRepository ): Response { $data = json_decode($request->getContent(), true); $ids = $data['ids'] ?? []; @@ -398,7 +439,13 @@ class ReserverController extends AbstractController $promotions = $promotionRepository->findActivePromotions(new \DateTime()); $promotion = $promotions[0] ?? null; - $cartData = $this->buildCartData($products, $selectedOptionsMap, $duration, $optionsRepository, $uploaderHelper, $promotion); + $formuleId = $data['formule'] ?? null; + $formule = null; + if ($formuleId) { + $formule = $formulesRepository->find($formuleId); + } + + $cartData = $this->buildCartData($products, $selectedOptionsMap, $duration, $optionsRepository, $uploaderHelper, $promotion, $formule); return new JsonResponse([ 'start_date' => $startStr, @@ -407,12 +454,13 @@ class ReserverController extends AbstractController 'options' => $cartData['rootOptions'], 'unavailable_products_ids' => $removedIds, 'total' => $cartData['total'], - 'promotion' => $promotion ? ['name' => $promotion->getName(), 'percentage' => $promotion->getPercentage()] : null + 'promotion' => $promotion ? ['name' => $promotion->getName(), 'percentage' => $promotion->getPercentage()] : null, + 'formule' => $formule ? ['name' => $formule->getName()] : null ]); } #[Route('/session', name: 'reservation_session_create', methods: ['POST'])] - public function createSession(Request $request, EntityManagerInterface $em, OrderSessionRepository $sessionRepository, PromotionRepository $promotionRepository): Response + public function createSession(Request $request, EntityManagerInterface $em, OrderSessionRepository $sessionRepository, PromotionRepository $promotionRepository, FormulesRepository $formulesRepository, ProductRepository $productRepository): Response { $data = json_decode($request->getContent(), true); $existingUuid = $request->getSession()->get('order_session_uuid'); @@ -442,6 +490,65 @@ class ReserverController extends AbstractController $session->setPromotion(null); } + $formuleId = $data['formule'] ?? null; + if ($formuleId) { + $formule = $formulesRepository->find($formuleId); + if ($formule) { + // --- DURATION CHECK --- + $startStr = $data['start'] ?? null; + $endStr = $data['end'] ?? null; + $duration = $this->calculateDuration($startStr, $endStr); + + if ($duration > 5) { + return new JsonResponse(['error' => "Impossible d'ajouter cette formule : la durée de réservation excède 5 jours."], Response::HTTP_BAD_REQUEST); + } + + // --- SECURITY CHECK --- + $restriction = $formule->getFormulesRestriction(); + if ($restriction) { + $ids = $data['ids'] ?? []; + if (!empty($ids)) { + $products = $productRepository->findBy(['id' => $ids]); + $config = $restriction->getRestrictionConfig() ?? []; + + $counts = ['structure' => 0, 'alimentaire' => 0, 'barhnums' => 0]; + + foreach ($products as $product) { + $pName = $product->getName(); + $type = null; + foreach ($config as $c) { + if (($c['product'] ?? '') === $pName) { + $type = $c['type'] ?? null; + break; + } + } + + if ($type === 'structure') { + $counts['structure']++; + } elseif ($type === 'alimentaire') { + $counts['alimentaire']++; + } elseif ($type) { + // Only count if type is defined in config (meaning it's part of the formula) + $counts['barhnums']++; + } + // Products not in the formula config are considered "extras" and allowed. + } + + if ($counts['structure'] > $restriction->getNbStructureMax()) { + return new JsonResponse(['error' => "Le nombre maximum de structures est dépassé ({$restriction->getNbStructureMax()})."], Response::HTTP_BAD_REQUEST); + } + if ($counts['alimentaire'] > $restriction->getNbAlimentaireMax()) { + return new JsonResponse(['error' => "Le nombre maximum d'éléments alimentaires est dépassé ({$restriction->getNbAlimentaireMax()})."], Response::HTTP_BAD_REQUEST); + } + if ($counts['barhnums'] > $restriction->getNbBarhumsMax()) { + return new JsonResponse(['error' => "Le nombre maximum de barnums/mobilier est dépassé ({$restriction->getNbBarhumsMax()})."], Response::HTTP_BAD_REQUEST); + } + } + } + $session->setFormule($formule); + } + } + $user = $this->getUser(); if ($user instanceof Customer) { $session->setCustomer($user); @@ -531,7 +638,9 @@ class ReserverController extends AbstractController $promotion->setPercentage($promoData['percentage']); } - $cartData = $this->buildCartData($products, $selectedOptionsMap, $duration, $optionsRepository, $uploaderHelper, $promotion); + $formule = $session->getFormule(); + + $cartData = $this->buildCartData($products, $selectedOptionsMap, $duration, $optionsRepository, $uploaderHelper, $promotion, $formule); // --- Calcul Frais de Livraison --- $deliveryData = $this->calculateDelivery( @@ -554,6 +663,7 @@ class ReserverController extends AbstractController 'totalTTC' => $cartData['total']['totalTTC'], 'discount' => $cartData['total']['discount'], 'promotion' => $cartData['total']['promotion'], + 'formule' => $cartData['total']['formule'], 'tvaEnabled' => $cartData['tvaEnabled'], ], 'delivery' => $deliveryData @@ -633,7 +743,9 @@ class ReserverController extends AbstractController $promotion->setPercentage($promoData['percentage']); } - $cartData = $this->buildCartData($products, $selectedOptionsMap, $duration, $optionsRepository, $uploaderHelper, $promotion); + $formule = $session->getFormule(); + + $cartData = $this->buildCartData($products, $selectedOptionsMap, $duration, $optionsRepository, $uploaderHelper, $promotion, $formule); return $this->render('revervation/flow.twig', [ 'session' => $session, @@ -650,6 +762,7 @@ class ReserverController extends AbstractController 'totalTTC' => $cartData['total']['totalTTC'], 'discount' => $cartData['total']['discount'], 'promotion' => $cartData['total']['promotion'], + 'formule' => $cartData['total']['formule'], 'tvaEnabled' => $cartData['tvaEnabled'], ] ]); @@ -1209,15 +1322,24 @@ class ReserverController extends AbstractController return $result; } - private function buildCartData(array $products, array $selectedOptionsMap, int $duration, OptionsRepository $optionsRepository, UploaderHelper $uploaderHelper, ?Promotion $promotion = null): array + private function buildCartData(array $products, array $selectedOptionsMap, int $duration, OptionsRepository $optionsRepository, UploaderHelper $uploaderHelper, ?Promotion $promotion = null, ?Formules $formule = null): array { $items = []; $rootOptions = []; $totalHT = 0; + $formulaExtras = 0; $tvaEnabled = $this->isTvaEnabled(); $tvaRate = $tvaEnabled ? 0.20 : 0; $processedProductIds = []; + $formuleConfig = []; + if ($formule) { + $restriction = $formule->getFormulesRestriction(); + if ($restriction) { + $formuleConfig = $restriction->getRestrictionConfig() ?? []; + } + } + foreach ($products as $product) { $processedProductIds[] = $product->getId(); $price1Day = $product->getPriceDay(); @@ -1247,7 +1369,11 @@ class ReserverController extends AbstractController $optionsTotalHT += $optPrice; } else { $rootOptions[] = $optData; - $totalHT += $optPrice; + if ($formule) { + $formulaExtras += $optPrice; + } else { + $totalHT += $optPrice; + } } } } @@ -1256,22 +1382,41 @@ class ReserverController extends AbstractController $productTotalHT += $optionsTotalHT; $productTotalTTC = $productTotalHT * (1 + $tvaRate); + $isInFormule = false; + if ($formule) { + foreach ($formuleConfig as $c) { + if (($c['product'] ?? '') === $product->getName()) { + $isInFormule = true; + break; + } + } + } + + if ($formule) { + if ($isInFormule) { + $formulaExtras += $optionsTotalHT; + } else { + $formulaExtras += $productTotalHT; + } + } else { + $totalHT += $productTotalHT; + } + $items[] = [ - 'id' => $product->getId(), // Ensure ID is present for basketJson + 'id' => $product->getId(), 'name' => $product->getName(), - 'product' => $product, // Kept for twig compatibility if needed + 'product' => $product, 'image' => $uploaderHelper->asset($product, 'imageFile'), - 'priceHt1Day' => $price1Day, // For basketJson - 'priceHTSupDay' => $priceSup, // For basketJson + 'priceHt1Day' => $price1Day, + 'priceHTSupDay' => $priceSup, 'priceTTC1Day' => $price1Day * (1 + $tvaRate), - 'price1Day' => $price1Day, // For flow template - 'priceSup' => $priceSup, // For flow template + 'price1Day' => $price1Day, + 'priceSup' => $priceSup, 'totalPriceHT' => $productTotalHT, 'totalPriceTTC' => $productTotalTTC, - 'options' => $productOptions + 'options' => $productOptions, + 'in_formule' => $isInFormule ]; - - $totalHT += $productTotalHT; } // Traiter les options orphelines @@ -1286,11 +1431,28 @@ class ReserverController extends AbstractController 'price' => $optPrice, 'orphan_product_id' => $prodId ]; - $totalHT += $optPrice; + if ($formule) { + $formulaExtras += $optPrice; + } else { + $totalHT += $optPrice; + } } } } + if ($formule) { + $formulaBasePrice = 0; + if ($duration <= 1) { + $formulaBasePrice = $formule->getPrice1j(); + } elseif ($duration <= 2) { + $formulaBasePrice = $formule->getPrice2j(); + } else { + $formulaBasePrice = $formule->getPrice5j(); + } + + $totalHT = $formulaBasePrice + $formulaExtras; + } + $discountAmount = 0; if ($promotion) { $discountAmount = $totalHT * ($promotion->getPercentage() / 100); @@ -1308,7 +1470,8 @@ class ReserverController extends AbstractController 'totalTva' => $totalTva, 'totalTTC' => $totalTTC, 'discount' => $discountAmount, - 'promotion' => $promotion ? $promotion->getName() : null + 'promotion' => $promotion ? $promotion->getName() : null, + 'formule' => $formule ? $formule->getName() : null ], 'tvaEnabled' => $tvaEnabled ]; diff --git a/src/Controller/Webhooks.php b/src/Controller/Webhooks.php index 262e0a8..1820704 100644 --- a/src/Controller/Webhooks.php +++ b/src/Controller/Webhooks.php @@ -76,6 +76,26 @@ class Webhooks extends AbstractController if ($pl->getType() === 'accompte' && $contrat->isSigned()) { $contrat->setReservationState('ready'); $entityManager->persist($contrat); + + // Send email to Prestataire + $prestataire = $contrat->getPrestataire(); + if ($prestataire) { + $dateStart = $contrat->getDateAt()->format('d/m/Y'); + $dateEnd = $contrat->getEndAt()->format('d/m/Y'); + + $mailer->send( + $prestataire->getEmail(), + $prestataire->getSurname() . ' ' . $prestataire->getName(), + "Nouveau contrat attribué - " . $contrat->getNumReservation(), + "mails/prestataire/new_contrat.twig", + [ + 'contrat' => $contrat, + 'prestataire' => $prestataire, + 'dateStart' => $dateStart, + 'dateEnd' => $dateEnd + ] + ); + } } $entityManager->flush(); diff --git a/src/Entity/Formules.php b/src/Entity/Formules.php index a67b724..248ec25 100644 --- a/src/Entity/Formules.php +++ b/src/Entity/Formules.php @@ -134,9 +134,11 @@ class Formules /** * @param \DateTimeImmutable|null $updatedAt */ - public function setUpdatedAt(?\DateTimeImmutable $updatedAt): void + public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static { $this->updatedAt = $updatedAt; + + return $this; } /** diff --git a/src/Entity/OrderSession.php b/src/Entity/OrderSession.php index f09f335..b6a12b4 100644 --- a/src/Entity/OrderSession.php +++ b/src/Entity/OrderSession.php @@ -93,6 +93,9 @@ class OrderSession #[ORM\Column(type: Types::JSON, nullable: true)] private ?array $promotion = null; + #[ORM\ManyToOne(targetEntity: Formules::class)] + private ?Formules $formule = null; + #[ORM\OneToOne(mappedBy: 'orderSession', cascade: ['persist', 'remove'])] private ?Devis $devis = null; @@ -469,4 +472,16 @@ class OrderSession return $this; } + + public function getFormule(): ?Formules + { + return $this->formule; + } + + public function setFormule(?Formules $formule): static + { + $this->formule = $formule; + + return $this; + } } diff --git a/src/Service/Pdf/DevisPdfService.php b/src/Service/Pdf/DevisPdfService.php index 50638b7..1ead686 100644 --- a/src/Service/Pdf/DevisPdfService.php +++ b/src/Service/Pdf/DevisPdfService.php @@ -173,10 +173,10 @@ class DevisPdfService extends Fpdf $this->SetXY(80, $currentY); $this->SetFont('Arial', '', 8); $this->Cell(15, 10, $nbDays, 'LRB', 0, 'C'); - $this->Cell(25, 10, number_format($price1Day, 2, ',', ' ') . $this->euro(), 'RB', 0, 'R'); - $this->Cell(25, 10, number_format($priceSupHT, 2, ',', ' ') . $this->euro(), 'RB', 0, 'R'); + $this->Cell(25, 10, ($price1Day == 0 ? "Inclus" : number_format($price1Day, 2, ',', ' ') . $this->euro()), 'RB', 0, 'R'); + $this->Cell(25, 10, ($price1Day == 0 ? "-" : number_format($priceSupHT, 2, ',', ' ') . $this->euro()), 'RB', 0, 'R'); $this->Cell(15, 10, $tvaLabel, 'RB', 0, 'C'); - $this->Cell(40, 10, number_format($lineTotalHT, 2, ',', ' ') . $this->euro(), 'RB', 1, 'R'); + $this->Cell(40, 10, ($lineTotalHT == 0 && $price1Day == 0 ? "Inclus" : number_format($lineTotalHT, 2, ',', ' ') . $this->euro()), 'RB', 1, 'R'); $this->Line(10, $currentY, 10, $currentY + 10); $this->Line(10, $currentY + 10, 80, $currentY + 10); diff --git a/src/Twig/StripeExtension.php b/src/Twig/StripeExtension.php index 663c777..9890070 100644 --- a/src/Twig/StripeExtension.php +++ b/src/Twig/StripeExtension.php @@ -48,6 +48,7 @@ class StripeExtension extends AbstractExtension $p = $this->em->getRepository(Product::class)->findOneBy(['name' => $name]); return [ + 'id' => $p->getId(), 'name' => $p->getName(), 'image' => $this->uploaderHelper->asset($p,'imageFile'), ]; @@ -276,7 +277,7 @@ class StripeExtension extends AbstractExtension $sessionData = $session->getProducts(); $ids = $sessionData['ids'] ?? []; $selectedOptionsMap = $sessionData['options'] ?? []; - + $startStr = $sessionData['start'] ?? null; $endStr = $sessionData['end'] ?? null; @@ -294,15 +295,39 @@ class StripeExtension extends AbstractExtension } $totalHT = 0; + $formulaExtras = 0; $productRepo = $this->em->getRepository(Product::class); $optionsRepo = $this->em->getRepository(Options::class); + $formule = $session->getFormule(); + $config = []; + if ($formule && ($restrict = $formule->getFormulesRestriction())) { + $config = $restrict->getRestrictionConfig() ?? []; + } + // Products foreach ($ids as $id) { $product = $productRepo->find($id); if ($product) { $price = $product->getPriceDay() + ($product->getPriceSup() * max(0, $duration - 1)); - $totalHT += $price; + + $isInFormule = false; + if ($formule) { + foreach ($config as $c) { + if (($c['product'] ?? '') === $product->getName()) { + $isInFormule = true; + break; + } + } + } + + if ($formule) { + if (!$isInFormule) { + $formulaExtras += $price; + } + } else { + $totalHT += $price; + } } } @@ -311,15 +336,38 @@ class StripeExtension extends AbstractExtension foreach ($optIds as $optId) { $option = $optionsRepo->find($optId); if ($option) { - $totalHT += $option->getPriceHt(); + if ($formule) { + $formulaExtras += $option->getPriceHt(); + } else { + $totalHT += $option->getPriceHt(); + } } } } + if ($formule) { + $formulaBasePrice = 0; + if ($duration <= 1) $formulaBasePrice = $formule->getPrice1j(); + elseif ($duration <= 2) $formulaBasePrice = $formule->getPrice2j(); + else $formulaBasePrice = $formule->getPrice5j(); + + $totalHT = $formulaBasePrice + $formulaExtras; + } + + $originalHT = $totalHT; + $discountAmount = 0; + + if ($promo = $session->getPromotion()) { + $discountAmount = $totalHT * ($promo['percentage'] / 100); + $totalHT -= $discountAmount; + } + $tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true"; return [ 'ht' => $totalHT, + 'originalHT' => $originalHT, + 'discount' => $discountAmount, 'duration' => $duration, 'tvaEnabled' => $tvaEnabled ]; diff --git a/templates/dashboard/flow/view.twig b/templates/dashboard/flow/view.twig index c6e0a4d..4c7b9ca 100644 --- a/templates/dashboard/flow/view.twig +++ b/templates/dashboard/flow/view.twig @@ -9,7 +9,7 @@ crossorigin=""/> + crossorigin="" nonce="{{ csp_nonce('script') }}">
@@ -55,12 +55,12 @@
-
-
@@ -167,7 +167,7 @@ {% if session.deliveryDistance is not null %}

Détails Livraison

- +
{# Map #} {% if session.deliveryGeometry %} @@ -219,6 +219,18 @@ Contenu de la demande + {% if session.formule %} +
+
+ +
+
+

Formule appliquée

+

{{ session.formule.name }}

+
+
+ {% endif %} + {% if session.products['start'] is defined and session.products['end'] is defined %}
@@ -329,6 +341,16 @@ {% set totalData = totalSession(session) %}
+ {% if session.promotion %} +
+ Promotion : {{ session.promotion.name }} + -{{ session.promotion.percentage }}% +
+
+ {{ totalData.originalHT|number_format(2, ',', ' ') }} € HT +
+ {% endif %} +
Durée : {{ totalData.duration }} jour{{ totalData.duration > 1 ? 's' : '' }}
diff --git a/templates/dashboard/formules/config-free.twig b/templates/dashboard/formules/config-free.twig index 1b4b491..9f06b3f 100644 --- a/templates/dashboard/formules/config-free.twig +++ b/templates/dashboard/formules/config-free.twig @@ -63,7 +63,7 @@
  • - + {# 1. PRODUIT #}
    diff --git a/templates/dashboard/formules/view.twig b/templates/dashboard/formules/view.twig index 93c10a5..c7948f1 100644 --- a/templates/dashboard/formules/view.twig +++ b/templates/dashboard/formules/view.twig @@ -24,8 +24,8 @@
    En ligne
    - Désactiver diff --git a/templates/mails/prestataire/new_contrat.twig b/templates/mails/prestataire/new_contrat.twig new file mode 100644 index 0000000..99775d6 --- /dev/null +++ b/templates/mails/prestataire/new_contrat.twig @@ -0,0 +1,28 @@ +{% extends 'mails/base.twig' %} + +{% block content %} + + + + Nouveau Contrat Attribué + + + + + + + + + Bonjour {{ datas.prestataire.surname }} {{ datas.prestataire.name }}, + + + + Un nouveaux contrat à vous été attribuer pour les date du {{ datas.dateStart }} au {{ datas.dateEnd }}. + + + + Référence : #{{ datas.contrat.numReservation }} + + + +{% endblock %} diff --git a/templates/reservation/contrat/view.twig b/templates/reservation/contrat/view.twig index f920be8..6724e75 100644 --- a/templates/reservation/contrat/view.twig +++ b/templates/reservation/contrat/view.twig @@ -82,7 +82,12 @@
    -

    Détail des prestations & Options

    +
    +

    Détail des prestations & Options

    + {% if contrat.devis and contrat.devis.orderSession and contrat.devis.orderSession.formule %} +

    Formule : {{ contrat.devis.orderSession.formule.name }}

    + {% endif %} +
    {{ days }} Jours
    diff --git a/templates/revervation/flow.twig b/templates/revervation/flow.twig index faa0548..b7d8dad 100644 --- a/templates/revervation/flow.twig +++ b/templates/revervation/flow.twig @@ -70,6 +70,18 @@
    + {% if cart.formule %} +
    +
    + +
    +
    +

    Formule appliquée

    +

    {{ cart.formule }}

    +
    +
    + {% endif %} +
    {% for item in cart.items %}
    @@ -83,6 +95,9 @@
    {% endif %}
    + {% if item.in_formule is defined and item.in_formule %} + Inclus formule + {% endif %}

    {{ item.product.name }}

    diff --git a/templates/revervation/flow_confirmed.twig b/templates/revervation/flow_confirmed.twig index b348258..2b8eed6 100644 --- a/templates/revervation/flow_confirmed.twig +++ b/templates/revervation/flow_confirmed.twig @@ -32,6 +32,18 @@
    + {% if cart.formule %} +
    +
    + +
    +
    +

    Formule appliquée

    +

    {{ cart.formule }}

    +
    +
    + {% endif %} +
    {% for item in cart.items %}
    @@ -45,6 +57,9 @@
    {% endif %}
    + {% if item.in_formule is defined and item.in_formule %} + Inclus formule + {% endif %}

    {{ item.product.name }}

    {{ item.product.description|raw }} @@ -361,7 +376,7 @@ Télécharger le devis -
    + diff --git a/templates/revervation/formule/show.twig b/templates/revervation/formule/show.twig index 6389cd5..32038a3 100644 --- a/templates/revervation/formule/show.twig +++ b/templates/revervation/formule/show.twig @@ -92,58 +92,92 @@
    {% if formule.type == "free" %} - {# --- DESIGN BENTO POUR FORMULE FREE --- #} -
    -

    Composez votre pack

    -
    -
    - - {# Grille Bento des Quotas #} -
    -
    -
    -

    {{ formule.formulesRestriction.nbStructureMax|default(0) }}

    -

    Structures au choix

    -
    + + + {# --- DESIGN BENTO POUR FORMULE FREE --- #} +
    +

    Composez votre pack

    +
    -
    -
    -

    {{ formule.formulesRestriction.nbAlimentaireMax|default(0) }}

    -

    Animations Alimentaires

    -
    -
    - -
    -
    -

    {{ formule.formulesRestriction.nbBarhumsMax|default(0) }}

    -

    Barnums & Mobilier

    -
    -
    -
    - - {# Catalogue des produits éligibles #} -

    — Catalogue éligible à cette formule —

    -
    - {% for item in formule.formulesRestriction.restrictionConfig %} - {% set product = loadProductByName(item.product) %} -
    -
    - {% if product.image %} - {{ product.name }} - {% else %} -
    Image non disp.
    - {% endif %} -
    -
    - - {{ item.type }} - -

    {{ product.name|default(item.product) }}

    + {# Grille Bento des Quotas #} +
    +
    +
    +

    + 0/{{ formule.formulesRestriction.nbStructureMax|default(0) }} +

    +

    Structures au choix

    - {% endfor %} -
    + +
    +
    +

    + 0/{{ formule.formulesRestriction.nbAlimentaireMax|default(0) }} +

    +

    Animations Alimentaires

    +
    +
    + +
    +
    +

    + 0/{{ formule.formulesRestriction.nbBarhumsMax|default(0) }} +

    +

    Barnums & Mobilier

    +
    +
    +
    + + {# Catalogue des produits éligibles #} +

    — Catalogue éligible à cette formule —

    + +
    + {% for item in formule.formulesRestriction.restrictionConfig %} + {% set product = loadProductByName(item.product) %} + {% set catKey = item.type %} + {% if item.type != 'structure' and item.type != 'alimentaire' %} + {% set catKey = 'barnum' %} + {% endif %} + +
    + + {# Checkbox Indicator #} +
    + +
    + +
    + {% if product.image %} + {{ product.name }} + {% else %} +
    Image non disp.
    + {% endif %} +
    +
    + + {{ item.type }} + +

    {{ product.name|default(item.product) }}

    +
    +
    + {% endfor %} +
    + + {# Action Bar #} + + {% endif %} {% if formule.type == "pack" %}