Files
ludikevent_crm/src/Controller/ReserverController.php
Serreau Jovann 1896f83107 ```
 feat(reservation/flow): Améliore le flux de réservation et ajoute des options.

Cette commit améliore le flux de réservation, ajoute une estimation des
frais de livraison et gère les options de produit et les paiements.
```
2026-02-05 08:18:29 +01:00

1434 lines
56 KiB
PHP

<?php
namespace App\Controller;
use App\Entity\Customer;
use App\Entity\CustomerTracking;
use App\Entity\Product;
use App\Entity\ProductReserve;
use App\Entity\SitePerformance;
use App\Repository\CustomerRepository;
use App\Repository\CustomerTrackingRepository;
use App\Repository\FormulesRepository;
use App\Repository\OrderSessionRepository;
use App\Repository\ProductRepository;
use App\Repository\ProductReserveRepository;
use App\Service\Mailer\Mailer;
use App\Service\Search\Client;
use Doctrine\ORM\EntityManagerInterface;
use Fkrzski\RobotsTxt\RobotsTxt;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Vich\UploaderBundle\Templating\Helper\UploaderHelper;
use App\Service\Pdf\DevisPdfService;
use App\Entity\Devis;
use App\Entity\DevisLine;
use App\Entity\CustomerAddress;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ReserverController extends AbstractController
{
private KernelInterface $kernel;
private ?array $simplifiedCommunes = null;
public function __construct(KernelInterface $kernel)
{
$this->kernel = $kernel;
}
#[Route('/flow/{sessionId}/devis', name: 'reservation_generate_devis', methods: ['GET'])]
public function generateDevis(
string $sessionId,
OrderSessionRepository $repository,
ProductRepository $productRepository,
KernelInterface $kernel,
\App\Repository\OptionsRepository $optionsRepository
): Response {
$session = $repository->findOneBy(['uuid' => $sessionId]);
if (!$session) {
return $this->redirectToRoute('reservation');
}
if ($session->getState() === 'send') {
return $this->redirectToRoute('reservation_flow_success', ['sessionId' => $sessionId]);
}
$sessionData = $session->getProducts();
$ids = $sessionData['ids'] ?? [];
$startStr = $sessionData['start'] ?? null;
$endStr = $sessionData['end'] ?? null;
// Calcul de la durée
$duration = 1;
$start = null;
$end = null;
if ($startStr && $endStr) {
try {
$start = new \DateTimeImmutable($startStr);
$end = new \DateTimeImmutable($endStr);
if ($end >= $start) {
$duration = $start->diff($end)->days + 1;
}
} catch (\Exception $e) {
$duration = 1;
}
}
// Création des objets temporaires pour le PDF
$customer = new Customer();
$customer->setName($session->getCustomer() ? $session->getCustomer()->getName() : 'Client');
$customer->setSurname($session->getCustomer() ? $session->getCustomer()->getSurname() : 'Temporaire');
$customer->setEmail($session->getCustomer() ? $session->getCustomer()->getEmail() : '');
$customer->setPhone($session->getCustomer() ? $session->getCustomer()->getPhone() : '');
// Adresse de facturation
$billAddress = new CustomerAddress();
$billAddress->setAddress($session->getBillingAddress() ?? '');
$billAddress->setZipcode($session->getBillingZipCode() ?? '');
$billAddress->setCity($session->getBillingTown() ?? '');
// Adresse de prestation
$shipAddress = new CustomerAddress();
$shipAddress->setAddress($session->getAdressEvent() ?? '');
$shipAddress->setAddress2($session->getAdress2Event() ?? '');
$shipAddress->setZipcode($session->getZipCodeEvent() ?? '');
$shipAddress->setCity($session->getTownEvent() ?? '');
$devis = new Devis();
$devis->setCustomer($customer);
$devis->setBillAddress($billAddress);
$devis->setAddressShip($shipAddress);
$devis->setNum('PROVISOIRE');
$devis->setStartAt($start);
$devis->setEndAt($end);
$selectedOptionsMap = $sessionData['options'] ?? [];
if (!empty($ids)) {
$products = $productRepository->findBy(['id' => $ids]);
$processedProductIds = [];
foreach ($products as $product) {
$processedProductIds[] = $product->getId();
$line = new DevisLine();
$line->setProduct($product->getName());
$line->setPriceHt($product->getPriceDay());
$line->setPriceHtSup($product->getPriceSup());
$line->setDay($duration);
$devis->addDevisLine($line);
if (isset($selectedOptionsMap[$product->getId()])) {
$optionIds = $selectedOptionsMap[$product->getId()];
if (!empty($optionIds)) {
$options = $optionsRepository->findBy(['id' => $optionIds]);
foreach ($options as $option) {
$lineOpt = new DevisLine();
$lineOpt->setProduct("Option : " . $option->getName());
$lineOpt->setPriceHt($option->getPriceHt());
$lineOpt->setPriceHtSup(0);
$lineOpt->setDay($duration);
$devis->addDevisLine($lineOpt);
}
}
}
}
foreach ($selectedOptionsMap as $prodId => $optIds) {
if (!in_array($prodId, $processedProductIds) && !empty($optIds)) {
$options = $optionsRepository->findBy(['id' => $optIds]);
foreach ($options as $option) {
$lineOpt = new DevisLine();
$lineOpt->setProduct("Option : " . $option->getName());
$lineOpt->setPriceHt($option->getPriceHt());
$lineOpt->setPriceHtSup(0);
$lineOpt->setDay($duration);
$devis->addDevisLine($lineOpt);
}
}
}
}
$pdfService = new DevisPdfService($kernel, $devis, $productRepository);
$content = $pdfService->generate();
return new Response($content, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="devis_provisoire.pdf"'
]);
}
private function loadSimplifiedCommunes(): array
{
if ($this->simplifiedCommunes !== null) {
return $this->simplifiedCommunes;
}
$filePath = $this->kernel->getProjectDir() . '/public/simplified_communes_by_zip.json';
if (!file_exists($filePath)) {
// Log an error or throw an exception if the file doesn't exist
// For now, return an empty array if file is missing
return [];
}
$content = file_get_contents($filePath);
if ($content === false) {
// Log an error or throw an exception if reading fails
return [];
}
$this->simplifiedCommunes = json_decode($content, true);
if ($this->simplifiedCommunes === null && json_last_error() !== JSON_ERROR_NONE) {
// Log JSON decoding error
return [];
}
return $this->simplifiedCommunes;
}
#[Route('/robots.txt', name: 'robots_txt', defaults: ['_format' => 'txt'])]
public function index(Request $request): Response
{
$robots = new RobotsTxt();
$robots->disallow('/signature');
$robots->disallow('/payment');
$robots->crawlDelay(60);
$robots->allow('/reservation');
$robots->sitemap($request->getSchemeAndHttpHost().'/seo/sitemap.xml');
return new Response($robots->toString(), Response::HTTP_OK, [
'Content-Type' => 'text/plain'
]);
}
#[Route('/', name: 'reservation')]
public function revervation(FormulesRepository $formulesRepository, ProductRepository $productRepository): Response
{
$products = $productRepository->findBy(['category' => '3-15 ans', 'isPublish' => true], ['updatedAt' => 'DESC'], 3);
$formules = $formulesRepository->findBy(['isPublish' => true], ['pos' => 'ASC'], 3);
return $this->render('revervation/home.twig', [
'products' => $products,
'formules' => $formules,
]);
}
#[Route('/produit/check', name: 'produit_check', methods: ['GET', 'POST'])]
public function productCheck(Request $request, ProductReserveRepository $productReserveRepository, ProductRepository $productRepository): Response
{
$productId = $request->query->get('id');
$startStr = $request->query->get('start');
$endStr = $request->query->get('end');
if (!$productId && $request->isMethod('POST')) {
$payload = $request->getPayload();
$productId = $payload->get('id');
$startStr = $payload->get('start');
$endStr = $payload->get('end');
}
if (!$productId || !$startStr || !$endStr) {
return new JsonResponse(['error' => 'Missing parameters'], Response::HTTP_BAD_REQUEST);
}
$product = $productRepository->find($productId);
if (!$product) {
return new JsonResponse(['error' => 'Product not found'], Response::HTTP_NOT_FOUND);
}
try {
$start = new \DateTimeImmutable($startStr);
$end = new \DateTimeImmutable($endStr);
} catch (\Exception $e) {
return new JsonResponse(['error' => 'Invalid date format'], Response::HTTP_BAD_REQUEST);
}
$reserve = new ProductReserve();
$reserve->setProduct($product);
$reserve->setStartAt($start);
$reserve->setEndAt($end);
$isAvailable = $productReserveRepository->checkAvailability($reserve);
return new JsonResponse(['dispo' => $isAvailable]);
}
#[Route('/produit/check/basket', name: 'produit_check_basket', methods: ['POST'])]
public function productCheckBasket(Request $request, ProductReserveRepository $productReserveRepository, ProductRepository $productRepository): JsonResponse
{
$data = json_decode($request->getContent(), true);
$ids = $data['ids'] ?? [];
$startStr = $data['start'] ?? null;
$endStr = $data['end'] ?? null;
if (!is_array($ids) || empty($ids) || !$startStr || !$endStr) {
return new JsonResponse(['available' => false, 'message' => 'Missing or invalid parameters'], Response::HTTP_BAD_REQUEST);
}
$availability = $this->_checkProductsAvailability($ids, $startStr, $endStr, $productRepository, $productReserveRepository);
if (!$availability['allProductsAvailable']) {
return new JsonResponse([
'available' => false,
'message' => 'Certains produits de votre panier ne sont pas disponibles aux dates sélectionnées.',
'unavailable_products_ids' => $availability['unavailableProductIds']
]);
}
return new JsonResponse(['available' => true, 'message' => 'Tous les produits sont disponibles.']);
}
private function _checkProductsAvailability(
array $ids,
?string $startStr,
?string $endStr,
ProductRepository $productRepository,
ProductReserveRepository $productReserveRepository
): array {
$allProductsAvailable = true;
$unavailableProductIds = [];
if (empty($ids) || !$startStr || !$endStr) {
return ['allProductsAvailable' => false, 'unavailableProductIds' => $ids];
}
try {
$start = new \DateTimeImmutable($startStr);
$end = new \DateTimeImmutable($endStr);
} catch (\Exception $e) {
return ['allProductsAvailable' => false, 'unavailableProductIds' => $ids];
}
foreach ($ids as $productId) {
$product = $productRepository->find($productId);
if (!$product) {
$allProductsAvailable = false;
$unavailableProductIds[] = $productId;
continue;
}
$reserve = new ProductReserve();
$reserve->setProduct($product);
$reserve->setStartAt($start);
$reserve->setEndAt($end);
$isAvailable = $productReserveRepository->checkAvailability($reserve);
if (!$isAvailable) {
$allProductsAvailable = false;
$unavailableProductIds[] = $productId;
}
}
return ['allProductsAvailable' => $allProductsAvailable, 'unavailableProductIds' => $unavailableProductIds];
}
#[Route('/web-vitals', name: 'reservation_web-vitals', methods: ['POST'])]
public function webVitals(Request $request, EntityManagerInterface $em): Response
{
$data = json_decode($request->getContent(), true);
if (!$data || !isset($data['name'], $data['value'])) {
return new Response('Invalid data', Response::HTTP_BAD_REQUEST);
}
$existing = $em->getRepository(SitePerformance::class)->findOneBy(['metricId' => $data['id']]);
$perf = $existing ?? new SitePerformance();
$perf->setName($data['name']);
$perf->setValue((float)$data['value']);
$perf->setPath($data['path'] ?? '/');
$perf->setMetricId($data['id'] ?? null);
$perf->setCreatedAt(new \DateTimeImmutable());
if (!$existing) {
$em->persist($perf);
}
$em->flush();
return new Response('', Response::HTTP_NO_CONTENT);
}
#[Route('/basket/json', name: 'reservation_basket_json', methods: ['POST'])]
public function basketJson(Request $request, ProductRepository $productRepository, \App\Repository\OptionsRepository $optionsRepository, UploaderHelper $uploaderHelper): Response
{
$data = json_decode($request->getContent(), true);
$ids = $data['ids'] ?? [];
$selectedOptionsMap = $data['options'] ?? [];
$startStr = $data['start'] ?? null;
$endStr = $data['end'] ?? null;
// Calcul de la durée
$duration = 1;
if ($startStr && $endStr) {
try {
$start = new \DateTimeImmutable($startStr);
$end = new \DateTimeImmutable($endStr);
if ($end >= $start) {
$duration = $start->diff($end)->days + 1;
}
} catch (\Exception $e) {
$duration = 1;
}
}
$products = [];
if (!empty($ids)) {
$products = $productRepository->findBy(['id' => $ids]);
}
$foundIds = array_map(fn($p) => $p->getId(), $products);
$removedIds = array_values(array_diff($ids, $foundIds));
$items = [];
$totalHT = 0;
$tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true";
$tvaRate = $tvaEnabled ? 0.20 : 0;
$rootOptions = [];
$processedProductIds = [];
foreach ($products as $product) {
$processedProductIds[] = $product->getId();
$price1Day = $product->getPriceDay();
$priceSup = $product->getPriceSup() ?? 0.0;
// Calcul du coût total pour ce produit selon la durée
$productTotalHT = $price1Day + ($priceSup * max(0, $duration - 1));
// Traitement des options
$productOptions = [];
$optionsTotalHT = 0;
if (isset($selectedOptionsMap[$product->getId()])) {
$optionIds = $selectedOptionsMap[$product->getId()];
if (!empty($optionIds)) {
$optionsEntities = $optionsRepository->findBy(['id' => $optionIds]);
foreach ($optionsEntities as $option) {
$optPrice = $option->getPriceHt();
$optData = [
'id' => $option->getId(),
'name' => $option->getName(),
'price' => $optPrice
];
// Vérifier si l'option est réellement liée au produit
if ($product->getOptions()->contains($option)) {
$productOptions[] = $optData;
$optionsTotalHT += $optPrice;
} else {
// Option demandée mais pas liée au produit -> Root options
$rootOptions[] = $optData;
// On ajoute quand même le prix ? Le user a dit "options non lier".
// Généralement si c'est "non lié", c'est une erreur ou une option globale.
// On l'ajoute au root mais pas au total du produit.
// Faut-il l'ajouter au total général ?
// Supposons que oui, c'est un "extra".
$totalHT += $optPrice;
}
}
}
}
$productTotalHT += $optionsTotalHT;
$productTotalTTC = $productTotalHT * (1 + $tvaRate);
$items[] = [
'id' => $product->getId(),
'name' => $product->getName(),
'image' => $uploaderHelper->asset($product, 'imageFile'),
'priceHt1Day' => $price1Day,
'priceHTSupDay' => $priceSup,
'priceTTC1Day' => $price1Day * (1 + $tvaRate),
'totalPriceHT' => $productTotalHT,
'totalPriceTTC' => $productTotalTTC,
'options' => $productOptions
];
$totalHT += $productTotalHT;
}
// Traiter les options pour les produits qui ne sont PAS dans le panier (orphelins de produit)
foreach ($selectedOptionsMap as $prodId => $optIds) {
// Si le produit n'a pas été traité (absent de $products car pas dans $ids ou pas trouvé)
if (!in_array($prodId, $processedProductIds) && !empty($optIds)) {
$optionsEntities = $optionsRepository->findBy(['id' => $optIds]);
foreach ($optionsEntities as $option) {
$optPrice = $option->getPriceHt();
$rootOptions[] = [
'id' => $option->getId(),
'name' => $option->getName(),
'price' => $optPrice,
'orphan_product_id' => $prodId // Info debug
];
$totalHT += $optPrice;
}
}
}
$totalTva = $totalHT * $tvaRate;
$totalTTC = $totalHT + $totalTva;
return new JsonResponse([
'start_date' => $startStr,
'end_date' => $endStr,
'products' => $items,
'options' => $rootOptions,
'unavailable_products_ids' => $removedIds,
'total' => [
'totalHT' => $totalHT,
'totalTva' => $totalTva,
'totalTTC' => $totalTTC
]
]);
}
#[Route('/session', name: 'reservation_session_create', methods: ['POST'])]
public function createSession(Request $request, EntityManagerInterface $em, OrderSessionRepository $sessionRepository): Response
{
$data = json_decode($request->getContent(), true);
$existingUuid = $request->getSession()->get('order_session_uuid');
$session = null;
if ($existingUuid) {
$session = $sessionRepository->findOneBy(['uuid' => $existingUuid]);
}
if (!$session) {
$session = new \App\Entity\OrderSession();
$session->setUuid(\Symfony\Component\Uid\Uuid::v4()->toRfc4122());
$session->setState('created');
}
$session->setProducts($data ?? []);
$user = $this->getUser();
if ($user instanceof Customer) {
$session->setCustomer($user);
}
$em->persist($session);
$em->flush();
$request->getSession()->set('order_session_uuid', $session->getUuid());
return new JsonResponse([
'flowUrl' => $this->generateUrl('reservation_flow', ['sessionId' => $session->getUuid()])
]);
}
#[Route('/flow/{sessionId}/confirmed', name: 'reservation_flow_confirmed', methods: ['GET', 'POST'])]
public function flowConfirmed(
string $sessionId,
AuthenticationUtils $authenticationUtils,
OrderSessionRepository $repository,
ProductRepository $productRepository,
UploaderHelper $uploaderHelper,
ProductReserveRepository $productReserveRepository,
EntityManagerInterface $em,
\App\Repository\OptionsRepository $optionsRepository,
HttpClientInterface $client,
Request $request,
Mailer $mailer
): Response {
$session = $repository->findOneBy(['uuid' => $sessionId]);
if (!$session) {
return $this->render('revervation/session_lost.twig');
}
if ($session->getState() === 'send') {
return $this->redirectToRoute('reservation_flow_success', ['sessionId' => $sessionId]);
}
if ($request->isMethod('POST')) {
$mailer->send(
'contact@ludikevent.fr',
"Ludikevent",
"[Ludikevent] - Nouvelle demande de réservation",
"mails/reserve/confirmation.twig",
['session' => $session]
);
$session->setState('send');
$em->flush();
$request->getSession()->remove('order_session_uuid');
return $this->redirectToRoute('reservation_flow_success', ['sessionId' => $sessionId]);
}
$sessionData = $session->getProducts();
$ids = $sessionData['ids'] ?? [];
$selectedOptionsMap = $sessionData['options'] ?? [];
$startStr = $sessionData['start'] ?? null;
$endStr = $sessionData['end'] ?? null;
// Check product availability
$availability = $this->_checkProductsAvailability($ids, $startStr, $endStr, $productRepository, $productReserveRepository);
if (!$availability['allProductsAvailable']) {
$this->addFlash('danger', 'Certains produits de votre panier ne sont plus disponibles. Veuillez vérifier votre réservation.');
return $this->redirectToRoute('reservation_flow', ['sessionId' => $sessionId]);
}
// Calcul de la durée
$duration = 1;
if ($startStr && $endStr) {
try {
$start = new \DateTimeImmutable($startStr);
$end = new \DateTimeImmutable($endStr);
if ($end >= $start) {
$duration = $start->diff($end)->days + 1;
}
} catch (\Exception $e) {
$duration = 1;
}
}
$products = [];
if (!empty($ids)) {
$products = $productRepository->findBy(['id' => $ids]);
}
// Cleanup missing products from session
$foundIds = array_map(fn($p) => $p->getId(), $products);
if (count($foundIds) !== count($ids)) {
$sessionData['ids'] = $foundIds;
$session->setProducts($sessionData);
$em->flush();
}
$items = [];
$totalHT = 0;
$tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true";
$tvaRate = $tvaEnabled ? 0.20 : 0;
$rootOptions = [];
$processedProductIds = [];
foreach ($products as $product) {
$processedProductIds[] = $product->getId();
$price1Day = $product->getPriceDay();
$priceSup = $product->getPriceSup() ?? 0.0;
// Calcul du coût total pour ce produit selon la durée
$productTotalHT = $price1Day + ($priceSup * max(0, $duration - 1));
// Traitement des options
$productOptions = [];
$optionsTotalHT = 0;
if (isset($selectedOptionsMap[$product->getId()])) {
$optionIds = $selectedOptionsMap[$product->getId()];
if (!empty($optionIds)) {
$optionsEntities = $optionsRepository->findBy(['id' => $optionIds]);
foreach ($optionsEntities as $option) {
$optPrice = $option->getPriceHt();
$optData = [
'id' => $option->getId(),
'name' => $option->getName(),
'price' => $optPrice
];
if ($product->getOptions()->contains($option)) {
$productOptions[] = $optData;
$optionsTotalHT += $optPrice;
} else {
$rootOptions[] = $optData;
$totalHT += $optPrice;
}
}
}
}
$productTotalHT += $optionsTotalHT;
$productTotalTTC = $productTotalHT * (1 + $tvaRate);
$items[] = [
'product' => $product,
'image' => $uploaderHelper->asset($product, 'imageFile'),
'price1Day' => $price1Day,
'priceSup' => $priceSup,
'totalPriceHT' => $productTotalHT,
'totalPriceTTC' => $productTotalTTC,
'options' => $productOptions
];
$totalHT += $productTotalHT;
}
// Traiter les options orphelines
foreach ($selectedOptionsMap as $prodId => $optIds) {
if (!in_array($prodId, $processedProductIds) && !empty($optIds)) {
$optionsEntities = $optionsRepository->findBy(['id' => $optIds]);
foreach ($optionsEntities as $option) {
$optPrice = $option->getPriceHt();
$rootOptions[] = [
'id' => $option->getId(),
'name' => $option->getName(),
'price' => $optPrice
];
$totalHT += $optPrice;
}
}
}
$totalTva = $totalHT * $tvaRate;
$totalTTC = $totalHT + $totalTva;
// --- Calcul Frais de Livraison ---
$deliveryEstimation = null;
$deliveryDetails = null;
$deliveryGeometry = null;
if ($session->getAdressEvent() && $session->getZipCodeEvent() && $session->getTownEvent()) {
$query = sprintf('%s %s %s', $session->getAdressEvent(), $session->getZipCodeEvent(), $session->getTownEvent());
try {
$response = $client->request('GET', 'https://api-adresse.data.gouv.fr/search/', [
'query' => [
'q' => $query,
'limit' => 1
]
]);
$content = $response->toArray();
if (!empty($content['features'])) {
$coords = $content['features'][0]['geometry']['coordinates'];
$lon = $coords[0];
$lat = $coords[1];
// Point de départ (LudikEvent)
$startLat = 49.849;
$startLon = 3.286;
// Calcul itinéraire via API Geoplateforme
$itineraireResponse = $client->request('GET', 'https://data.geopf.fr/navigation/itineraire', [
'query' => [
'resource' => 'bdtopo-osrm',
'start' => $startLon . ',' . $startLat,
'end' => $lon . ',' . $lat,
'profile' => 'car',
'optimization' => 'fastest',
'distanceUnit' => 'kilometer',
'geometryFormat' => 'geojson'
]
]);
$itineraire = $itineraireResponse->toArray();
$distance = $itineraire['distance'];
$deliveryGeometry = $itineraire['geometry'] ?? null;
$rate = 0.50;
$trips = 4;
if ($distance <= 10) {
$deliveryEstimation = 0.0;
$chargedDistance = 0.0;
} else {
$chargedDistance = $distance - 10;
$deliveryEstimation = ($chargedDistance * $trips) * $rate;
}
$deliveryDetails = [
'distance' => $distance,
'chargedDistance' => $chargedDistance,
'trips' => $trips,
'rate' => $rate,
'isFree' => ($distance <= 10)
];
}
} catch (\Exception $e) {
// Silent fail for delivery calculation in flow
}
}
return $this->render('revervation/flow_confirmed.twig', [
'session' => $session,
'cart' => [
'items' => $items,
'options' => $rootOptions,
'startDate' => $startStr ? new \DateTimeImmutable($startStr) : null,
'endDate' => $endStr ? new \DateTimeImmutable($endStr) : null,
'duration' => $duration,
'totalHT' => $totalHT,
'totalTva' => $totalTva,
'totalTTC' => $totalTTC,
'tvaEnabled' => $tvaEnabled,
],
'delivery' => [
'estimation' => $deliveryEstimation,
'details' => $deliveryDetails,
'geometry' => $deliveryGeometry
]
]);
}
#[Route('/flow/{sessionId}/success', name: 'reservation_flow_success', methods: ['GET'])]
public function flowSuccess(string $sessionId, OrderSessionRepository $repository): Response
{
$session = $repository->findOneBy(['uuid' => $sessionId]);
if (!$session) {
return $this->redirectToRoute('reservation');
}
if ($session->getState() !== 'send') {
return $this->redirectToRoute('reservation_flow', ['sessionId' => $sessionId]);
}
return $this->render('revervation/success.twig');
}
#[Route('/flow/{sessionId}', name: 'reservation_flow', methods: ['GET', 'POST'])]
public function flowLogin(
string $sessionId,
AuthenticationUtils $authenticationUtils,
OrderSessionRepository $repository,
ProductRepository $productRepository,
UploaderHelper $uploaderHelper,
ProductReserveRepository $productReserveRepository,
EntityManagerInterface $em,
\App\Repository\OptionsRepository $optionsRepository
): Response {
// This is the POST target for the login form, but also the GET page.
// The authenticator handles the POST. For GET, we just render the page.
$session = $repository->findOneBy(['uuid' => $sessionId]);
if (!$session) {
return $this->render('revervation/session_lost.twig');
}
if ($session->getState() === 'send') {
return $this->redirectToRoute('reservation_flow_success', ['sessionId' => $sessionId]);
}
$sessionData = $session->getProducts();
$ids = $sessionData['ids'] ?? [];
$selectedOptionsMap = $sessionData['options'] ?? [];
$startStr = $sessionData['start'] ?? null;
$endStr = $sessionData['end'] ?? null;
// Check product availability
$availability = $this->_checkProductsAvailability($ids, $startStr, $endStr, $productRepository, $productReserveRepository);
if (!$availability['allProductsAvailable']) {
$this->addFlash('danger', 'Certains produits de votre panier ne sont plus disponibles. Veuillez vérifier votre sélection.');
return $this->redirectToRoute('reservation');
}
// Calcul de la durée
$duration = 1;
if ($startStr && $endStr) {
try {
$start = new \DateTimeImmutable($startStr);
$end = new \DateTimeImmutable($endStr);
if ($end >= $start) {
$duration = $start->diff($end)->days + 1;
}
} catch (\Exception $e) {
$duration = 1;
}
}
$products = [];
if (!empty($ids)) {
$products = $productRepository->findBy(['id' => $ids]);
}
// Cleanup missing products from session
$foundIds = array_map(fn($p) => $p->getId(), $products);
if (count($foundIds) !== count($ids)) {
$sessionData['ids'] = $foundIds;
$session->setProducts($sessionData);
$em->flush();
}
$items = [];
$totalHT = 0;
$tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true";
$tvaRate = $tvaEnabled ? 0.20 : 0;
$rootOptions = [];
$processedProductIds = [];
foreach ($products as $product) {
$processedProductIds[] = $product->getId();
$price1Day = $product->getPriceDay();
$priceSup = $product->getPriceSup() ?? 0.0;
// Calcul du coût total pour ce produit selon la durée
$productTotalHT = $price1Day + ($priceSup * max(0, $duration - 1));
// Traitement des options
$productOptions = [];
$optionsTotalHT = 0;
if (isset($selectedOptionsMap[$product->getId()])) {
$optionIds = $selectedOptionsMap[$product->getId()];
if (!empty($optionIds)) {
$optionsEntities = $optionsRepository->findBy(['id' => $optionIds]);
foreach ($optionsEntities as $option) {
$optPrice = $option->getPriceHt();
$optData = [
'id' => $option->getId(),
'name' => $option->getName(),
'price' => $optPrice
];
if ($product->getOptions()->contains($option)) {
$productOptions[] = $optData;
$optionsTotalHT += $optPrice;
} else {
$rootOptions[] = $optData;
$totalHT += $optPrice;
}
}
}
}
$productTotalHT += $optionsTotalHT;
$productTotalTTC = $productTotalHT * (1 + $tvaRate);
$items[] = [
'product' => $product,
'image' => $uploaderHelper->asset($product, 'imageFile'),
'price1Day' => $price1Day,
'priceSup' => $priceSup,
'totalPriceHT' => $productTotalHT,
'totalPriceTTC' => $productTotalTTC,
'options' => $productOptions
];
$totalHT += $productTotalHT;
}
// Traiter les options orphelines
foreach ($selectedOptionsMap as $prodId => $optIds) {
if (!in_array($prodId, $processedProductIds) && !empty($optIds)) {
$optionsEntities = $optionsRepository->findBy(['id' => $optIds]);
foreach ($optionsEntities as $option) {
$optPrice = $option->getPriceHt();
$rootOptions[] = [
'id' => $option->getId(),
'name' => $option->getName(),
'price' => $optPrice
];
$totalHT += $optPrice;
}
}
}
$totalTva = $totalHT * $tvaRate;
$totalTTC = $totalHT + $totalTva;
return $this->render('revervation/flow.twig', [
'session' => $session,
'last_username' => $authenticationUtils->getLastUsername(),
'error' => $authenticationUtils->getLastAuthenticationError(),
'cart' => [
'items' => $items,
'options' => $rootOptions,
'startDate' => $startStr ? new \DateTimeImmutable($startStr) : null,
'endDate' => $endStr ? new \DateTimeImmutable($endStr) : null,
'duration' => $duration,
'totalHT' => $totalHT,
'totalTva' => $totalTva,
'totalTTC' => $totalTTC,
'tvaEnabled' => $tvaEnabled,
]
]);
}
#[Route('/flow/{sessionId}/update', name: 'reservation_flow_update', methods: ['POST'])]
public function flowUpdate(
string $sessionId,
Request $request,
OrderSessionRepository $repository,
EntityManagerInterface $em
): Response {
$session = $repository->findOneBy(['uuid' => $sessionId]);
if (!$session) {
return $this->redirectToRoute('reservation');
}
if ($session->getState() === 'send') {
return $this->redirectToRoute('reservation_flow_success', ['sessionId' => $sessionId]);
}
$session->setBillingAddress($request->request->get('billingAddress'));
$session->setBillingZipCode($request->request->get('billingZipCode'));
$session->setBillingTown($request->request->get('billingTown'));
$session->setAdressEvent($request->request->get('adressEvent'));
$session->setAdress2Event($request->request->get('adress2Event'));
$session->setZipCodeEvent($request->request->get('zipCodeEvent'));
$session->setTownEvent($request->request->get('townEvent'));
$session->setType($request->request->get('type'));
$session->setDetails($request->request->get('details'));
$session->setTypeSol($request->request->get('typeSol'));
$session->setPente($request->request->get('pente'));
$session->setAccess($request->request->get('access'));
$distance = $request->request->get('distancePower');
if ($distance !== null && $distance !== '') {
$session->setDistancePower((float)$distance);
}
$em->flush();
return $this->redirectToRoute('reservation_flow_confirmed', ['sessionId' => $sessionId]);
}
#[Route('/umami', name: 'reservation_umami', methods: ['POST'])]
public function umami(
Request $request,
CustomerTrackingRepository $customerTrackingRepository,
EntityManagerInterface $em
): Response {
/** @var Customer $user */
$user = $this->getUser();
if (!$user) {
return new JsonResponse(['error' => 'User not found'], Response::HTTP_UNAUTHORIZED);
}
$data = json_decode($request->getContent(), true);
$umamiSessionId = $data['umami_session'] ?? null;
if (!$umamiSessionId) {
return new JsonResponse(['error' => 'No session provided'], Response::HTTP_BAD_REQUEST);
}
$track = $customerTrackingRepository->findOneBy(['trackId' => $umamiSessionId]);
if (!$track) {
$track = new CustomerTracking();
$track->setTrackId($umamiSessionId);
$track->setCreateAT(new \DateTime());
$track->setCustomer($user);
$em->persist($track);
} else {
if ($track->getCustomer() !== $user) {
$track->setCustomer($user);
}
}
$em->flush();
return new JsonResponse(['status' => 'success']);
}
#[Route('/catalogue', name: 'reservation_catalogue')]
public function revervationCatalogue(ProductRepository $productRepository): Response
{
return $this->render('revervation/catalogue.twig', [
'products' => $productRepository->findBy(['isPublish' => true]),
'tvaEnabled' => isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true",
]);
}
#[Route('/formules', name: 'reservation_formules')]
public function revervationFormules(FormulesRepository $formulesRepository): Response
{
return $this->render('revervation/formules.twig', [
'formules' => $formulesRepository->findBy(['isPublish' => true], ['pos' => 'ASC']),
'tvaEnabled' => isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true",
]);
}
#[Route('/formules/{slug}', name: 'reservation_formule_show')]
public function revervationView(string $slug, FormulesRepository $formulesRepository): Response
{
$parts = explode('-', $slug);
$realId = $parts[0];
$formule = $formulesRepository->find($realId);
if (!$formule) {
throw $this->createNotFoundException('Formules introuvable');
}
return $this->render('revervation/formule/show.twig', [
'formule' => $formule,
'tvaEnabled' => isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true",
]);
}
#[Route('/comment-reserver', name: 'reservation_workflow')]
public function revervationWorkfkow(): Response
{
return $this->render('revervation/workflow.twig');
}
#[Route('/options/{id}', name: 'reservation_options_show')]
public function revervationShowOpitons(string $id, ProductRepository $productRepository): Response
{
// TODO: Implement logic
return new Response('Not implemented');
}
#[Route('/produit/{id}', name: 'reservation_product_show')]
public function revervationShowProduct(string $id, ProductRepository $productRepository): Response
{
$parts = explode('-', $id);
$realId = $parts[0];
$product = $productRepository->find($realId);
if (!$product) {
throw $this->createNotFoundException('Produit introuvable');
}
$allInCat = $productRepository->findBy(['category' => $product->getCategory()], [], 5);
$otherProducts = array_filter($allInCat, function ($p) use ($product) {
return $p->getId() !== $product->getId();
});
return $this->render('revervation/produit.twig', [
'product' => $product,
'tvaEnabled' => isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true",
'otherProducts' => array_slice($otherProducts, 0, 4)
]);
}
#[Route('/connexion', name: 'reservation_login')]
public function revervationLogin(AuthenticationUtils $authenticationUtils): Response
{
return $this->render('revervation/login.twig', [
'last_username' => $authenticationUtils->getLastUsername(),
'error' => $authenticationUtils->getLastAuthenticationError()
]);
}
#[Route('/logout', name: 'reservation_logout')]
public function revervationLogout(): Response
{
return $this->redirectToRoute('reservation');
}
#[Route('/creation-compte', name: 'reservation_register')]
public function revervationRegister(
Request $request,
Mailer $mailer,
EntityManagerInterface $em,
UserPasswordHasherInterface $hasher
): Response {
if ($request->isMethod('POST')) {
$payload = $request->getPayload();
$customer = new Customer();
$customer->setEmail($payload->getString('email'));
$customer->setName($payload->getString('name'));
$customer->setSurname($payload->getString('surname'));
$customer->setPhone($payload->getString('phone'));
$customer->setCiv($payload->getString('civ'));
$customer->setType($payload->getString('type'));
if ($customer->getType() === 'buisness') {
$customer->setSiret($payload->getString('siret'));
$customer->setRaisonSocial($payload->getString('raisonSocial'));
$customer->setTypCompany($payload->getString('typCompany'));
}
$hashedPassword = $hasher->hashPassword($customer, $payload->getString('password'));
$customer->setPassword($hashedPassword);
$customer->setRoles(['ROLE_USER']);
$mailer->send(
$customer->getEmail(),
$customer->getName() . " " . $customer->getSurname(),
"[Ludikevent] - Code de récupération",
"mails/welcome.twig",
['customer' => $customer]
);
$em->persist($customer);
$em->flush();
$this->addFlash('success', 'Votre compte a été créé avec succès ! Connectez-vous.');
return $this->redirectToRoute('reservation_login');
}
return $this->render('revervation/register.twig');
}
#[Route('/mot-de-passe', name: 'reservation_password')]
public function forgotPassword(
Request $request,
CustomerRepository $repository,
EntityManagerInterface $em,
Mailer $mailer,
UserPasswordHasherInterface $hasher
): Response {
$session = $request->getSession();
$step = $request->query->get('step', 'request');
if ($request->isMethod('POST')) {
$payload = $request->getPayload();
// ÉTAPE 1 : Générer le code et l'envoyer
if ($payload->has('email_request')) {
$email = $payload->getString('email_request');
$customer = $repository->findOneBy(['email' => $email]);
if ($customer) {
$code = str_pad((string)random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$session->set('reset_password', [
'email' => $email,
'code' => $code,
'expires' => time() + 900 // Valable 15 minutes
]);
$mailer->send(
$customer->getEmail(),
$customer->getName() . " " . $customer->getSurname(),
"[Ludikevent] - Code de récupération",
"mails/code_password.twig",
['code' => $code]
);
return $this->redirectToRoute('reservation_password', ['step' => 'verify']);
}
$this->addFlash('danger', 'Email inconnu.');
}
// ÉTAPE 2 : Vérifier le code en session
if ($payload->has('code_verify')) {
$data = $session->get('reset_password');
$inputCode = $payload->getString('code_verify');
if ($data && $data['code'] === $inputCode && time() < $data['expires']) {
return $this->redirectToRoute('reservation_password', ['step' => 'reset']);
}
$this->addFlash('danger', 'Code invalide ou expiré.');
}
// ÉTAPE 3 : Changer le mot de passe
if ($payload->has('new_password')) {
$data = $session->get('reset_password');
if ($data) {
$customer = $repository->findOneBy(['email' => $data['email']]);
if ($customer) {
$newEncoded = $hasher->hashPassword($customer, $payload->getString('new_password'));
$customer->setPassword($newEncoded);
$em->flush();
$session->remove('reset_password');
$this->addFlash('success', 'Mot de passe mis à jour !');
return $this->redirectToRoute('reservation_login');
}
}
}
}
return $this->render('reservation/password.twig', [
'step' => $step,
'email' => $session->get('reset_password')['email'] ?? null
]);
}
#[Route('/contact', name: 'reservation_contact')]
public function revervationContact(Request $request, Mailer $mailer): Response
{
$form = $this->createFormBuilder()
->add('name', TextType::class, ['label' => 'Nom', 'required' => true])
->add('surname', TextType::class, ['label' => 'Prenom', 'required' => true])
->add('email', EmailType::class, ['label' => 'Email', 'required' => true])
->add('phone', TextType::class, ['label' => 'Telephone', 'required' => true])
->add('message', TextareaType::class, ['label' => 'Message', 'required' => true]);
$formObject = $form->getForm();
$formObject->handleRequest($request);
if ($formObject->isSubmitted() && $formObject->isValid()) {
$data = $formObject->getData();
$mailer->send(
'lilian@ludikevent.fr',
"Ludikevent",
"[Ludikevent] - Demande de contact via la plateforme de reservation",
"mails/reserve/contact.twig",
$data
);
$this->addFlash('success', 'Votre message a bien été envoyé ! Notre équipe vous répondra dans les plus brefs délais.');
return $this->redirectToRoute('reservation_contact');
}
return $this->render('revervation/contact.twig', [
'form' => $formObject->createView()
]);
}
#[Route('/recherche', name: 'reservation_search')]
public function recherche(UploaderHelper $uploaderHelper, Client $client, Request $request, ProductRepository $productRepository): Response
{
$results = $client->search('product', $request->query->get('q', ''));
$items = [];
foreach ($results['hits'] as $result) {
$p = $productRepository->find($result['id']);
if ($p instanceof Product && $p->isPublish()) {
$items[] = [
'image' => $uploaderHelper->asset($p, 'imageFile') ?: "/provider/images/favicon.png",
"name" => $p->getName(),
"price" => $p->getPriceDay(),
"price1day" => $p->getPriceDay(),
"caution" => $p->getCaution(),
"priceSup" => $p->getPriceSup(),
'link' => $this->generateUrl('reservation_product_show', ['id' => $p->slug()]),
];
}
}
return $this->render('revervation/search.twig', [
'products' => $items
]);
}
#[Route('/mentions-legales', name: 'reservation_mentions-legal')]
public function revervationLegal(): Response
{
return $this->render('revervation/legal.twig');
}
#[Route('/cities/lookup', name: 'api_cities_lookup', methods: ['POST'])]
public function getCityByZipCode(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
$zipCode = $data['zipCode'] ?? null;
if (!$zipCode) {
return new JsonResponse(['error' => 'Missing zipCode parameter'], Response::HTTP_BAD_REQUEST);
}
$simplifiedCommunes = $this->loadSimplifiedCommunes();
$cities = $simplifiedCommunes[$zipCode] ?? [];
if (!empty($cities)) {
return new JsonResponse(['cities' => $cities]);
}
return new JsonResponse(['cities' => [], 'message' => 'City not found for this zip code'], Response::HTTP_NOT_FOUND);
}
#[Route('/rgpd', name: 'reservation_rgpd')]
public function revervationRgpd(): Response
{
return $this->render('revervation/rgpd.twig');
}
#[Route('/cookies', name: 'reservation_cookies')]
public function revervationCookies(): Response
{
return $this->render('revervation/cookies.twig');
}
#[Route('/cgv', name: 'reservation_cgv')]
public function revervationCgv(): Response
{
return $this->render('revervation/cgv.twig');
}
#[Route('/hosting', name: 'reservation_hosting')]
public function revervationHosting(): Response
{
return $this->render('revervation/hosting.twig');
}
#[Route('/estimer-la-livraison', name: 'reservation_estimate_delivery')]
public function estimateDelivery(Request $request, HttpClientInterface $client): Response
{
$form = $this->createFormBuilder()
->add('address', TextType::class, ['required' => true])
->add('zipCode', TextType::class, ['required' => true])
->add('city', TextType::class, ['required' => true])
->getForm();
$form->handleRequest($request);
$estimation = null;
if ($form->isSubmitted() && $form->isValid()) {
$data = $form->getData();
$query = sprintf('%s %s %s', $data['address'], $data['zipCode'], $data['city']);
try {
$response = $client->request('GET', 'https://api-adresse.data.gouv.fr/search/', [
'query' => [
'q' => $query,
'limit' => 1
]
]);
$content = $response->toArray();
if (!empty($content['features'])) {
$coords = $content['features'][0]['geometry']['coordinates'];
$lon = $coords[0];
$lat = $coords[1];
// Point de départ
$startLat = 49.849;
$startLon = 3.286;
// Calcul itinéraire via API Geoplateforme
$itineraireResponse = $client->request('GET', 'https://data.geopf.fr/navigation/itineraire', [
'query' => [
'resource' => 'bdtopo-osrm',
'start' => $startLon . ',' . $startLat,
'end' => $lon . ',' . $lat,
'profile' => 'car',
'optimization' => 'fastest',
'distanceUnit' => 'kilometer',
'geometryFormat' => 'geojson'
]
]);
$itineraire = $itineraireResponse->toArray();
$distance = $itineraire['distance'];
$geometry = $itineraire['geometry'] ?? null;
$rate = 0.50;
$trips = 4;
if ($distance <= 10) {
$estimation = 0.0;
$chargedDistance = 0.0;
} else {
$chargedDistance = $distance - 10;
$estimation = ($chargedDistance * $trips) * $rate;
}
$details = [
'distance' => $distance,
'chargedDistance' => $chargedDistance,
'trips' => $trips,
'rate' => $rate,
'isFree' => ($distance <= 10)
];
} else {
$this->addFlash('warning', 'Adresse introuvable.');
}
} catch (\Exception $e) {
$this->addFlash('error', 'Erreur lors du calcul de l\'itinéraire.');
}
}
return $this->render('revervation/estimate_delivery.twig', [
'form' => $form->createView(),
'estimation' => $estimation,
'details' => $details ?? null,
'geometry' => $geometry ?? null
]);
}
}