Split AccountController (55 methods) and AdminController (30 methods) into smaller controllers
AccountController split into 3: - AccountController (20 public methods): dashboard, settings, Stripe, sub-accounts, event CRUD, exports, payouts - AccountEventCatalogController (13 public methods): categories, billets, design, event toggles, QR code - AccountEventOperationsController (9 public methods): invitations, accreditations, ticket ops, attestation, order cancel/refund AdminController split into 2: - AdminController (20 public methods): dashboard, users, buyers, organizers, invitations, infra - AdminOrdersController (10 public methods): orders, events, exports, logs, analytics Shared helpers extracted to AccountEventOwnershipTrait (requireEventOwnership, requireStripeReady). All route paths and names unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
520
src/Controller/AccountEventCatalogController.php
Normal file
520
src/Controller/AccountEventCatalogController.php
Normal file
@@ -0,0 +1,520 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Controller\Trait\AccountEventOwnershipTrait;
|
||||
use App\Entity\Billet;
|
||||
use App\Entity\BilletDesign;
|
||||
use App\Entity\Category;
|
||||
use App\Entity\Event;
|
||||
use App\Entity\User;
|
||||
use App\Service\AuditService;
|
||||
use App\Service\EventIndexService;
|
||||
use App\Service\StripeService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_USER')]
|
||||
class AccountEventCatalogController extends AbstractController
|
||||
{
|
||||
use AccountEventOwnershipTrait;
|
||||
|
||||
private const BREADCRUMB_HOME = ['name' => 'Accueil', 'url' => '/'];
|
||||
private const BREADCRUMB_ACCOUNT = ['name' => 'Mon compte', 'url' => '/mon-compte'];
|
||||
private const EVENT_BASE_URL = '/mon-compte/evenement/';
|
||||
private const EVENT_CATEGORIES_SUFFIX = '/modifier?tab=categories';
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/categorie/ajouter', name: 'app_account_event_add_category', methods: ['POST'])]
|
||||
public function addCategory(Event $event, Request $request, EntityManagerInterface $em, AuditService $audit): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
|
||||
return $redirect;
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$this->requireEventOwnership($event);
|
||||
|
||||
$name = trim($request->request->getString('name'));
|
||||
if ('' === $name) {
|
||||
$this->addFlash('error', 'Le nom de la categorie est requis.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']);
|
||||
}
|
||||
|
||||
$maxPosition = $em->getRepository(Category::class)->count(['event' => $event]);
|
||||
|
||||
$category = new Category();
|
||||
$category->setName($name);
|
||||
$category->setEvent($event);
|
||||
$category->setPosition($maxPosition);
|
||||
|
||||
$startAt = $request->request->getString('start_at');
|
||||
if ('' !== $startAt) {
|
||||
$category->setStartAt(new \DateTimeImmutable($startAt));
|
||||
}
|
||||
|
||||
$endAt = $request->request->getString('end_at');
|
||||
if ('' !== $endAt) {
|
||||
$category->setEndAt(new \DateTimeImmutable($endAt));
|
||||
}
|
||||
|
||||
if ($category->getEndAt() < $category->getStartAt()) {
|
||||
$category->setEndAt($category->getStartAt()->modify('+30 days'));
|
||||
}
|
||||
|
||||
$em->persist($category);
|
||||
$em->flush();
|
||||
|
||||
$audit->log('category_created', 'Category', $category->getId(), ['name' => $name, 'event' => $event->getTitle()]);
|
||||
|
||||
$this->addFlash('success', sprintf('Categorie "%s" ajoutee.', $name));
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/categorie/{categoryId}/modifier', name: 'app_account_event_edit_category', methods: ['GET', 'POST'])]
|
||||
public function editCategory(Event $event, int $categoryId, Request $request, EntityManagerInterface $em, AuditService $audit): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
|
||||
return $redirect;
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$this->requireEventOwnership($event);
|
||||
$category = $this->requireCategory($categoryId, $event, $em);
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
$category->setName(trim($request->request->getString('name')));
|
||||
|
||||
$startAt = $request->request->getString('start_at');
|
||||
if ('' !== $startAt) {
|
||||
$category->setStartAt(new \DateTimeImmutable($startAt));
|
||||
}
|
||||
|
||||
$endAt = $request->request->getString('end_at');
|
||||
if ('' !== $endAt) {
|
||||
$category->setEndAt(new \DateTimeImmutable($endAt));
|
||||
}
|
||||
|
||||
if ($category->getEndAt() < $category->getStartAt()) {
|
||||
$category->setEndAt($category->getStartAt()->modify('+30 days'));
|
||||
}
|
||||
|
||||
$category->setIsHidden($request->request->getBoolean('is_hidden'));
|
||||
|
||||
$em->flush();
|
||||
|
||||
$audit->log('category_updated', 'Category', $category->getId(), ['name' => $category->getName(), 'event' => $event->getTitle()]);
|
||||
|
||||
$this->addFlash('success', 'Categorie modifiee.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']);
|
||||
}
|
||||
|
||||
return $this->render('account/edit_category.html.twig', [
|
||||
'event' => $event,
|
||||
'category' => $category,
|
||||
'breadcrumbs' => [
|
||||
self::BREADCRUMB_HOME,
|
||||
self::BREADCRUMB_ACCOUNT,
|
||||
['name' => $event->getTitle(), 'url' => self::EVENT_BASE_URL.$event->getId().self::EVENT_CATEGORIES_SUFFIX],
|
||||
['name' => $category->getName(), 'url' => ''],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/categorie/{categoryId}/supprimer', name: 'app_account_event_delete_category', methods: ['POST'])]
|
||||
public function deleteCategory(Event $event, int $categoryId, EntityManagerInterface $em, AuditService $audit): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
|
||||
return $redirect;
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$this->requireEventOwnership($event);
|
||||
|
||||
$category = $em->getRepository(Category::class)->find($categoryId);
|
||||
if ($category && $category->getEvent()->getId() === $event->getId()) {
|
||||
$catName = $category->getName();
|
||||
$catId = $category->getId();
|
||||
$em->remove($category);
|
||||
$em->flush();
|
||||
$audit->log('category_deleted', 'Category', $catId, ['name' => $catName, 'event' => $event->getTitle()]);
|
||||
$this->addFlash('success', 'Categorie supprimee.');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/categorie/reorder', name: 'app_account_event_reorder_categories', methods: ['POST'])]
|
||||
public function reorderCategories(Event $event, Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
|
||||
return $redirect;
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$this->requireEventOwnership($event);
|
||||
|
||||
$order = json_decode($request->getContent(), true);
|
||||
if (\is_array($order)) {
|
||||
foreach ($order as $position => $categoryId) {
|
||||
$category = $em->getRepository(Category::class)->find($categoryId);
|
||||
if ($category && $category->getEvent()->getId() === $event->getId()) {
|
||||
$category->setPosition($position);
|
||||
}
|
||||
}
|
||||
$em->flush();
|
||||
}
|
||||
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/categorie/{categoryId}/billet/ajouter', name: 'app_account_event_add_billet', methods: ['GET', 'POST'])]
|
||||
public function addBillet(Event $event, int $categoryId, Request $request, EntityManagerInterface $em, StripeService $stripeService, AuditService $audit): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
|
||||
return $redirect;
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$user = $this->requireEventOwnership($event);
|
||||
$category = $this->requireCategory($categoryId, $event, $em);
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
$billet = new Billet();
|
||||
$billet->setCategory($category);
|
||||
$billet->setPosition($em->getRepository(Billet::class)->count(['category' => $category]));
|
||||
$this->hydrateBilletFromRequest($billet, $request);
|
||||
|
||||
$em->persist($billet);
|
||||
$this->syncBilletToStripe($billet, $user, $stripeService);
|
||||
$em->flush();
|
||||
|
||||
$audit->log('billet_created', 'Billet', $billet->getId(), ['name' => $billet->getName(), 'event' => $event->getTitle()]);
|
||||
|
||||
$this->addFlash('success', 'Billet ajoute avec succes.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']);
|
||||
}
|
||||
|
||||
return $this->render('account/add_billet.html.twig', [
|
||||
'event' => $event,
|
||||
'category' => $category,
|
||||
'allowedTypes' => self::getAllowedBilletTypes($user->getOffer()),
|
||||
'breadcrumbs' => [
|
||||
self::BREADCRUMB_HOME,
|
||||
self::BREADCRUMB_ACCOUNT,
|
||||
['name' => $event->getTitle(), 'url' => self::EVENT_BASE_URL.$event->getId().self::EVENT_CATEGORIES_SUFFIX],
|
||||
['name' => 'Ajouter un billet'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/billet/{billetId}/modifier', name: 'app_account_event_edit_billet', methods: ['GET', 'POST'])]
|
||||
public function editBillet(Event $event, int $billetId, Request $request, EntityManagerInterface $em, StripeService $stripeService, AuditService $audit): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
|
||||
return $redirect;
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$user = $this->requireEventOwnership($event);
|
||||
|
||||
$billet = $em->getRepository(Billet::class)->find($billetId);
|
||||
if (!$billet || $billet->getCategory()->getEvent()->getId() !== $event->getId()) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
$this->hydrateBilletFromRequest($billet, $request);
|
||||
$this->syncBilletToStripe($billet, $user, $stripeService);
|
||||
$em->flush();
|
||||
|
||||
$audit->log('billet_updated', 'Billet', $billet->getId(), ['name' => $billet->getName(), 'event' => $event->getTitle()]);
|
||||
|
||||
$this->addFlash('success', 'Billet modifie avec succes.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']);
|
||||
}
|
||||
|
||||
return $this->render('account/edit_billet.html.twig', [
|
||||
'event' => $event,
|
||||
'billet' => $billet,
|
||||
'allowedTypes' => self::getAllowedBilletTypes($user->getOffer()),
|
||||
'breadcrumbs' => [
|
||||
self::BREADCRUMB_HOME,
|
||||
self::BREADCRUMB_ACCOUNT,
|
||||
['name' => $event->getTitle(), 'url' => self::EVENT_BASE_URL.$event->getId().self::EVENT_CATEGORIES_SUFFIX],
|
||||
['name' => 'Modifier un billet'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/billet/{billetId}/supprimer', name: 'app_account_event_delete_billet', methods: ['POST'])]
|
||||
public function deleteBillet(Event $event, int $billetId, EntityManagerInterface $em, StripeService $stripeService, AuditService $audit): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
|
||||
return $redirect;
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$user = $this->requireEventOwnership($event);
|
||||
|
||||
$billet = $em->getRepository(Billet::class)->find($billetId);
|
||||
if (!$billet || $billet->getCategory()->getEvent()->getId() !== $event->getId()) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$billetName = $billet->getName();
|
||||
$billetDbId = $billet->getId();
|
||||
$this->deleteBilletFromStripe($billet, $user, $stripeService);
|
||||
|
||||
$em->remove($billet);
|
||||
$em->flush();
|
||||
|
||||
$audit->log('billet_deleted', 'Billet', $billetDbId, ['name' => $billetName, 'event' => $event->getTitle()]);
|
||||
|
||||
$this->addFlash('success', 'Billet supprime avec succes.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/billet/reorder', name: 'app_account_event_reorder_billets', methods: ['POST'])]
|
||||
public function reorderBillets(Event $event, Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
|
||||
return $redirect;
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$this->requireEventOwnership($event);
|
||||
|
||||
$order = json_decode($request->getContent(), true);
|
||||
if (\is_array($order)) {
|
||||
foreach ($order as $position => $billetId) {
|
||||
$billet = $em->getRepository(Billet::class)->find($billetId);
|
||||
if ($billet && $billet->getCategory()->getEvent()->getId() === $event->getId()) {
|
||||
$billet->setPosition($position);
|
||||
}
|
||||
}
|
||||
$em->flush();
|
||||
}
|
||||
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/billet-preview', name: 'app_account_event_billet_preview', methods: ['GET'])]
|
||||
public function billetPreview(Event $event, Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
|
||||
return $redirect;
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$user = $this->requireEventOwnership($event);
|
||||
|
||||
return $this->render('account/billet_preview.html.twig', [
|
||||
'event' => $event,
|
||||
'user' => $user,
|
||||
'bg_color' => '#ffffff',
|
||||
'text_color' => '#111111',
|
||||
'accent_color' => $request->query->getString('accent_color', '#4f46e5'),
|
||||
'show_logo' => true,
|
||||
'show_invitation' => true,
|
||||
'invitation_title' => $request->query->getString('invitation_title', 'Invitation'),
|
||||
'invitation_color' => $request->query->getString('invitation_color', '#d4a017'),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/billet-design', name: 'app_account_event_save_billet_design', methods: ['POST'])]
|
||||
public function saveBilletDesign(Event $event, Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
|
||||
return $redirect;
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$this->requireEventOwnership($event);
|
||||
|
||||
$design = $em->getRepository(BilletDesign::class)->findOneBy(['event' => $event]);
|
||||
if (!$design) {
|
||||
$design = new BilletDesign();
|
||||
$design->setEvent($event);
|
||||
$em->persist($design);
|
||||
}
|
||||
|
||||
$design->setAccentColor($request->request->getString('accent_color', '#4f46e5'));
|
||||
$design->setInvitationTitle($request->request->getString('invitation_title', 'Invitation'));
|
||||
$design->setInvitationColor($request->request->getString('invitation_color', '#d4a017'));
|
||||
$design->setUpdatedAt(new \DateTimeImmutable());
|
||||
|
||||
$em->flush();
|
||||
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/en-ligne', name: 'app_account_toggle_event_online', methods: ['POST'])]
|
||||
public function toggleEventOnline(Event $event, EntityManagerInterface $em, EventIndexService $eventIndex): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
|
||||
return $redirect;
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$user = $this->requireEventOwnership($event);
|
||||
|
||||
if (!$event->isOnline() && (!$user->isStripeChargesEnabled() || !$user->isStripePayoutsEnabled())) { // @codeCoverageIgnoreStart
|
||||
$this->addFlash('error', 'Configuration Stripe requise pour mettre un evenement en ligne.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId()]);
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$event->setIsOnline(!$event->isOnline());
|
||||
$em->flush();
|
||||
|
||||
$eventIndex->indexEvent($event);
|
||||
|
||||
$this->addFlash('success', $event->isOnline() ? 'Evenement mis en ligne.' : 'Evenement passe hors ligne.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId()]);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/secret', name: 'app_account_toggle_event_secret', methods: ['POST'])]
|
||||
public function toggleEventSecret(Event $event, EntityManagerInterface $em, EventIndexService $eventIndex): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
|
||||
return $redirect;
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$this->requireEventOwnership($event);
|
||||
|
||||
$event->setIsSecret(!$event->isSecret());
|
||||
$em->flush();
|
||||
|
||||
$eventIndex->indexEvent($event);
|
||||
|
||||
$this->addFlash('success', $event->isSecret() ? 'Evenement marque comme secret.' : 'Evenement rendu public.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId()]);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/qrcode', name: 'app_account_event_qrcode', methods: ['GET'])]
|
||||
public function eventQrCode(Event $event, UrlGeneratorInterface $urlGenerator): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
|
||||
return $redirect;
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$user = $this->requireEventOwnership($event);
|
||||
|
||||
$eventUrl = $urlGenerator->generate('app_event_detail', [
|
||||
'orgaSlug' => $user->getSlug(),
|
||||
'id' => $event->getId(),
|
||||
'eventSlug' => $event->getSlug(),
|
||||
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
|
||||
$qrCode = (new \Endroid\QrCode\Builder\Builder(
|
||||
writer: new \Endroid\QrCode\Writer\PngWriter(),
|
||||
data: $eventUrl,
|
||||
encoding: new \Endroid\QrCode\Encoding\Encoding('UTF-8'),
|
||||
size: 400,
|
||||
margin: 20,
|
||||
))->build();
|
||||
|
||||
return new Response($qrCode->getString(), 200, [
|
||||
'Content-Type' => 'image/png',
|
||||
'Content-Disposition' => 'attachment; filename="qrcode-'.$event->getSlug().'.png"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public static function getAllowedBilletTypes(?string $offer): array
|
||||
{
|
||||
return match ($offer) {
|
||||
'basic', 'sur-mesure' => ['billet', 'reservation_brocante', 'vote'],
|
||||
default => ['billet'],
|
||||
};
|
||||
}
|
||||
|
||||
private function requireCategory(int $categoryId, Event $event, EntityManagerInterface $em): Category
|
||||
{
|
||||
$category = $em->getRepository(Category::class)->find($categoryId);
|
||||
if (!$category || $category->getEvent()->getId() !== $event->getId()) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
private function hydrateBilletFromRequest(Billet $billet, Request $request): void
|
||||
{
|
||||
$billet->setName(trim($request->request->getString('name')));
|
||||
$billet->setPriceHT((int) round((float) $request->request->getString('price_ht') * 100));
|
||||
$qty = $request->request->getString('quantity');
|
||||
$billet->setQuantity('' === $qty ? null : (int) $qty);
|
||||
$billet->setIsGeneratedBillet($request->request->getBoolean('is_generated_billet'));
|
||||
$billet->setHasDefinedExit($request->request->getBoolean('has_defined_exit'));
|
||||
$billet->setNotBuyable($request->request->getBoolean('not_buyable'));
|
||||
|
||||
$type = $request->request->getString('type', 'billet');
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$allowedTypes = self::getAllowedBilletTypes($user->getOffer());
|
||||
if (!\in_array($type, $allowedTypes, true)) {
|
||||
$type = 'billet';
|
||||
}
|
||||
$billet->setType($type);
|
||||
|
||||
$billet->setDescription(trim($request->request->getString('description')) ?: null);
|
||||
|
||||
$pictureFile = $request->files->get('picture');
|
||||
if ($pictureFile) {
|
||||
$billet->setPictureFile($pictureFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @codeCoverageIgnore Requires live Stripe API
|
||||
*/
|
||||
private function syncBilletToStripe(Billet $billet, User $user, StripeService $stripeService): void
|
||||
{
|
||||
if (!$user->getStripeAccountId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($billet->getStripeProductId()) {
|
||||
$stripeService->updateProduct($billet, $user->getStripeAccountId());
|
||||
} else {
|
||||
$productId = $stripeService->createProduct($billet, $user->getStripeAccountId());
|
||||
$billet->setStripeProductId($productId);
|
||||
}
|
||||
} catch (\Exception) {
|
||||
// Stripe failure is non-blocking
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @codeCoverageIgnore Requires live Stripe API
|
||||
*/
|
||||
private function deleteBilletFromStripe(Billet $billet, User $user, StripeService $stripeService): void
|
||||
{
|
||||
if ($billet->getStripeProductId() && $user->getStripeAccountId()) {
|
||||
try {
|
||||
$stripeService->deleteProduct($billet->getStripeProductId(), $user->getStripeAccountId());
|
||||
} catch (\Exception) {
|
||||
// Stripe failure is non-blocking
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
553
src/Controller/AccountEventOperationsController.php
Normal file
553
src/Controller/AccountEventOperationsController.php
Normal file
@@ -0,0 +1,553 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Controller\Trait\AccountEventOwnershipTrait;
|
||||
use App\Entity\Attestation;
|
||||
use App\Entity\Billet;
|
||||
use App\Entity\BilletBuyer;
|
||||
use App\Entity\BilletBuyerItem;
|
||||
use App\Entity\BilletOrder;
|
||||
use App\Entity\Category;
|
||||
use App\Entity\Event;
|
||||
use App\Entity\User;
|
||||
use App\Service\AuditService;
|
||||
use App\Service\BilletOrderService;
|
||||
use App\Service\StripeService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_USER')]
|
||||
class AccountEventOperationsController extends AbstractController
|
||||
{
|
||||
use AccountEventOwnershipTrait;
|
||||
|
||||
private const DQL_BB_EXCLUDE_INVITATIONS = 'bb.isInvitation = false OR bb.isInvitation IS NULL';
|
||||
private const CONTENT_TYPE_PDF = 'application/pdf';
|
||||
private const PDF_SUFFIX = '.pdf"';
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/invitation', name: 'app_account_event_create_invitation', methods: ['POST'])]
|
||||
public function createInvitation(Event $event, Request $request, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
|
||||
return $redirect;
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$this->requireEventOwnership($event);
|
||||
|
||||
$result = $this->buildInvitationOrder($event, $request, $em);
|
||||
|
||||
if ($result instanceof Response) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$order = $result;
|
||||
$em->persist($order);
|
||||
$em->flush();
|
||||
|
||||
$billetOrderService->generateOrderTickets($order);
|
||||
|
||||
$tickets = $em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]);
|
||||
foreach ($tickets as $ticket) {
|
||||
$ticket->setIsInvitation(true);
|
||||
}
|
||||
$em->flush();
|
||||
|
||||
$billetOrderService->generateAndSendTickets($order);
|
||||
|
||||
$this->addFlash('success', 'Invitation envoyee a '.$order->getEmail().'.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'invitations']);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/accreditation', name: 'app_account_event_create_accreditation', methods: ['POST'])]
|
||||
public function createAccreditation(Event $event, Request $request, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
|
||||
return $redirect;
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$this->requireEventOwnership($event);
|
||||
|
||||
$accreditationType = $request->request->getString('accreditation_type', 'staff');
|
||||
if (!\in_array($accreditationType, ['staff', 'exposant'], true)) {
|
||||
$accreditationType = 'staff';
|
||||
}
|
||||
|
||||
$firstName = trim($request->request->getString('first_name'));
|
||||
$lastName = trim($request->request->getString('last_name'));
|
||||
$email = trim($request->request->getString('email'));
|
||||
$redirectResponse = $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'invitations']);
|
||||
$categories = $em->getRepository(Category::class)->findBy(['event' => $event], ['position' => 'ASC']);
|
||||
|
||||
$missingFields = '' === $firstName || '' === $lastName || '' === $email;
|
||||
if ($missingFields || empty($categories)) {
|
||||
$this->addFlash('error', $missingFields ? 'Tous les champs sont requis.' : 'Creez au moins une categorie avant de generer une accreditation.');
|
||||
|
||||
return $redirectResponse;
|
||||
}
|
||||
|
||||
$category = $categories[0];
|
||||
$label = 'staff' === $accreditationType ? 'Staff' : 'Exposant';
|
||||
|
||||
$billet = new Billet();
|
||||
$billet->setCategory($category);
|
||||
$billet->setName($label.' - '.$firstName.' '.$lastName);
|
||||
$billet->setType($accreditationType);
|
||||
$billet->setPriceHT(0);
|
||||
$billet->setQuantity(1);
|
||||
$billet->setIsGeneratedBillet(true);
|
||||
$billet->setNotBuyable(true);
|
||||
$billet->setPosition(9999);
|
||||
$em->persist($billet);
|
||||
$em->flush();
|
||||
|
||||
$count = $em->getRepository(BilletBuyer::class)->count([]) + 1;
|
||||
|
||||
$order = new BilletBuyer();
|
||||
$order->setEvent($event);
|
||||
$order->setFirstName($firstName);
|
||||
$order->setLastName($lastName);
|
||||
$order->setEmail($email);
|
||||
$order->setOrderNumber(date('Y-m-d').'-'.$count);
|
||||
$order->setTotalHT(0);
|
||||
$order->setIsInvitation(true);
|
||||
|
||||
$item = new BilletBuyerItem();
|
||||
$item->setBillet($billet);
|
||||
$item->setBilletName($billet->getName());
|
||||
$item->setQuantity(1);
|
||||
$item->setUnitPriceHT(0);
|
||||
$order->addItem($item);
|
||||
|
||||
$em->persist($order);
|
||||
$em->flush();
|
||||
|
||||
$billetOrderService->generateOrderTickets($order);
|
||||
|
||||
$tickets = $em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]);
|
||||
foreach ($tickets as $ticket) {
|
||||
$ticket->setIsInvitation(true);
|
||||
}
|
||||
$em->flush();
|
||||
|
||||
$billetOrderService->generateAndSendTickets($order);
|
||||
|
||||
$this->addFlash('success', 'Accreditation '.$label.' envoyee a '.$order->getEmail().'.');
|
||||
|
||||
return $redirectResponse;
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/invitation/{orderId}/renvoyer', name: 'app_account_event_resend_invitation', methods: ['POST'])]
|
||||
public function resendInvitation(Event $event, int $orderId, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
|
||||
return $redirect;
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$this->requireEventOwnership($event);
|
||||
|
||||
$order = $em->getRepository(BilletBuyer::class)->find($orderId);
|
||||
if (!$order || $order->getEvent()->getId() !== $event->getId()) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$billetOrderService->generateAndSendTickets($order);
|
||||
|
||||
$this->addFlash('success', 'Invitation renvoyee a '.$order->getEmail().'.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'invitations']);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/ticket/{ticketId}/telecharger', name: 'app_account_event_download_ticket', requirements: ['ticketId' => '\d+'], methods: ['GET'])]
|
||||
public function downloadTicket(Event $event, int $ticketId, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
$this->requireEventOwnership($event);
|
||||
|
||||
$ticket = $em->getRepository(BilletOrder::class)->find($ticketId);
|
||||
if (!$ticket || $ticket->getBilletBuyer()->getEvent()->getId() !== $event->getId()) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$pdf = $billetOrderService->generatePdf($ticket);
|
||||
|
||||
return new Response($pdf, 200, [
|
||||
'Content-Type' => self::CONTENT_TYPE_PDF,
|
||||
'Content-Disposition' => 'inline; filename="'.$ticket->getReference().self::PDF_SUFFIX,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/ticket/{ticketId}/renvoyer', name: 'app_account_event_resend_ticket', requirements: ['ticketId' => '\d+'], methods: ['POST'])]
|
||||
public function resendTicket(Event $event, int $ticketId, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
$this->requireEventOwnership($event);
|
||||
|
||||
$ticket = $em->getRepository(BilletOrder::class)->find($ticketId);
|
||||
if (!$ticket || $ticket->getBilletBuyer()->getEvent()->getId() !== $event->getId()) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$billetOrderService->generateAndSendTickets($ticket->getBilletBuyer());
|
||||
|
||||
$this->addFlash('success', 'Billet renvoye a '.$ticket->getBilletBuyer()->getEmail().'.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'tickets']);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/ticket/{ticketId}/annuler', name: 'app_account_event_cancel_ticket', requirements: ['ticketId' => '\d+'], methods: ['POST'])]
|
||||
public function cancelTicket(Event $event, int $ticketId, EntityManagerInterface $em, AuditService $audit): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
$this->requireEventOwnership($event);
|
||||
|
||||
$ticket = $em->getRepository(BilletOrder::class)->find($ticketId);
|
||||
if (!$ticket || $ticket->getBilletBuyer()->getEvent()->getId() !== $event->getId()) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$ticket->setState(BilletOrder::STATE_INVALID);
|
||||
$em->flush();
|
||||
|
||||
$audit->log('ticket_cancelled', 'BilletOrder', $ticket->getId(), [
|
||||
'reference' => $ticket->getReference(),
|
||||
'event' => $event->getTitle(),
|
||||
]);
|
||||
|
||||
$this->addFlash('success', 'Billet '.$ticket->getReference().' annule.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'tickets']);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/attestation', name: 'app_account_event_attestation', methods: ['POST'])]
|
||||
public function eventAttestation(Event $event, Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
$user = $this->requireEventOwnership($event);
|
||||
|
||||
$categoryIds = array_map('intval', $request->request->all('categories'));
|
||||
$billetIds = array_map('intval', $request->request->all('billets'));
|
||||
|
||||
$categories = $categoryIds ? $em->getRepository(Category::class)->findBy(['id' => $categoryIds, 'event' => $event]) : [];
|
||||
$billets = $billetIds ? array_filter($em->getRepository(Billet::class)->findBy(['id' => $billetIds]), fn (Billet $b) => $b->getCategory()->getEvent()->getId() === $event->getId()) : [];
|
||||
|
||||
if (empty($categories) && empty($billets)) {
|
||||
$this->addFlash('error', 'Selectionnez au moins une categorie ou un billet.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'attestation']);
|
||||
}
|
||||
|
||||
[$billets, $allBilletIds] = $this->collectAttestationBillets($billets, $categories, $em);
|
||||
[$billetLines, $totalSold] = $this->buildAttestationStats($billets, $allBilletIds, $em);
|
||||
$ticketDetails = $this->buildAttestationTicketDetails($allBilletIds, $em);
|
||||
|
||||
$isSimple = 'simple' === $request->request->getString('mode', 'detail');
|
||||
$generatedAt = new \DateTimeImmutable();
|
||||
$selectedCategoryNames = array_map(fn ($c) => $c->getName(), $categories);
|
||||
|
||||
$attestationData = [
|
||||
'ref' => 'ATT-'.$event->getId().'-'.$generatedAt->format('YmdHis'),
|
||||
'event' => $event->getTitle(),
|
||||
'eventDate' => $event->getStartAt()?->format('d/m/Y H:i').' - '.$event->getEndAt()?->format('d/m/Y H:i'),
|
||||
'eventLocation' => $event->getAddress().', '.$event->getZipcode().' '.$event->getCity(),
|
||||
'organizer' => $user->getCompanyName() ?? $user->getFirstName().' '.$user->getLastName(),
|
||||
'siret' => $user->getSiret(),
|
||||
'categories' => $selectedCategoryNames,
|
||||
'billets' => array_map(fn ($l) => ['cat' => $l['category'], 'name' => $l['name'], 'sold' => $l['sold']], $billetLines),
|
||||
'totalSold' => $totalSold,
|
||||
'generatedAt' => $generatedAt->format('d/m/Y H:i:s'),
|
||||
];
|
||||
|
||||
$signatureHash = hash_hmac('sha256', json_encode($attestationData, \JSON_UNESCAPED_UNICODE), $this->getParameter('kernel.secret'));
|
||||
|
||||
$attestationEntity = new Attestation(
|
||||
$attestationData['ref'],
|
||||
$signatureHash,
|
||||
$event,
|
||||
$user,
|
||||
$totalSold,
|
||||
$attestationData,
|
||||
);
|
||||
$em->persist($attestationEntity);
|
||||
$em->flush();
|
||||
|
||||
$verifyUrl = $this->generateUrl('app_attestation_ventes_ref', ['reference' => $attestationData['ref']], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
|
||||
$qrCode = (new \Endroid\QrCode\Builder\Builder(
|
||||
writer: new \Endroid\QrCode\Writer\PngWriter(),
|
||||
data: $verifyUrl,
|
||||
encoding: new \Endroid\QrCode\Encoding\Encoding('UTF-8'),
|
||||
size: 300,
|
||||
margin: 5,
|
||||
))->build();
|
||||
$qrBase64 = 'data:image/png;base64,'.base64_encode($qrCode->getString());
|
||||
|
||||
$html = $this->renderView('pdf/attestation_ventes.html.twig', [
|
||||
'event' => $event,
|
||||
'organizer' => $user,
|
||||
'billetLines' => $billetLines,
|
||||
'totalSold' => $totalSold,
|
||||
'generatedAt' => $generatedAt,
|
||||
'selectedCategories' => $selectedCategoryNames,
|
||||
'verifyUrl' => $verifyUrl,
|
||||
'qrBase64' => $qrBase64,
|
||||
'attestationRef' => $attestationData['ref'],
|
||||
'signatureHash' => $signatureHash,
|
||||
'isSimple' => $isSimple,
|
||||
'ticketDetails' => $ticketDetails,
|
||||
]);
|
||||
|
||||
$dompdf = new \Dompdf\Dompdf();
|
||||
$dompdf->loadHtml($html);
|
||||
$dompdf->setPaper('A4');
|
||||
$dompdf->render();
|
||||
|
||||
return new Response($dompdf->output(), 200, [
|
||||
'Content-Type' => self::CONTENT_TYPE_PDF,
|
||||
'Content-Disposition' => 'inline; filename="attestation_'.$event->getSlug().'_'.date('Y-m-d').self::PDF_SUFFIX,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/commande/{orderId}/annuler', name: 'app_account_event_cancel_order', methods: ['POST'])]
|
||||
public function cancelOrder(Event $event, int $orderId, EntityManagerInterface $em, AuditService $audit, BilletOrderService $billetOrderService): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
|
||||
return $redirect;
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$this->requireEventOwnership($event);
|
||||
|
||||
$order = $em->getRepository(BilletBuyer::class)->find($orderId);
|
||||
if (!$order || $order->getEvent()->getId() !== $event->getId()) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$order->setStatus(BilletBuyer::STATUS_CANCELLED);
|
||||
|
||||
$tickets = $em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]);
|
||||
foreach ($tickets as $ticket) {
|
||||
$ticket->setState(BilletOrder::STATE_INVALID);
|
||||
}
|
||||
|
||||
$em->flush();
|
||||
|
||||
$audit->log('order_cancelled', 'BilletBuyer', $order->getId(), [
|
||||
'orderNumber' => $order->getOrderNumber(),
|
||||
'event' => $event->getTitle(),
|
||||
]);
|
||||
|
||||
$billetOrderService->notifyOrganizerCancelled($order, 'annulee');
|
||||
|
||||
$this->addFlash('success', 'Commande '.$order->getOrderNumber().' annulee.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'stats']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @codeCoverageIgnore Requires live Stripe API
|
||||
*/
|
||||
#[Route('/mon-compte/evenement/{id}/commande/{orderId}/rembourser', name: 'app_account_event_refund_order', methods: ['POST'])]
|
||||
public function refundOrder(Event $event, int $orderId, EntityManagerInterface $em, StripeService $stripeService, AuditService $audit, BilletOrderService $billetOrderService): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
|
||||
return $redirect;
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$user = $this->requireEventOwnership($event);
|
||||
|
||||
$order = $em->getRepository(BilletBuyer::class)->find($orderId);
|
||||
if (!$order || $order->getEvent()->getId() !== $event->getId()) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
if ($order->getStripeSessionId() && $user->getStripeAccountId()) {
|
||||
try {
|
||||
$stripeService->getClient()->refunds->create([
|
||||
'payment_intent' => $order->getStripeSessionId(),
|
||||
], ['stripe_account' => $user->getStripeAccountId()]);
|
||||
} catch (\Exception) {
|
||||
// Stripe failure is non-blocking
|
||||
}
|
||||
}
|
||||
|
||||
$order->setStatus(BilletBuyer::STATUS_REFUNDED);
|
||||
|
||||
$tickets = $em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]);
|
||||
foreach ($tickets as $ticket) {
|
||||
$ticket->setState(BilletOrder::STATE_INVALID);
|
||||
}
|
||||
|
||||
$em->flush();
|
||||
|
||||
$audit->log('order_refunded', 'BilletBuyer', $order->getId(), [
|
||||
'orderNumber' => $order->getOrderNumber(),
|
||||
'event' => $event->getTitle(),
|
||||
'totalHT' => $order->getTotalHTDecimal(),
|
||||
]);
|
||||
|
||||
$billetOrderService->notifyOrganizerCancelled($order, 'remboursee');
|
||||
|
||||
$this->addFlash('success', 'Commande '.$order->getOrderNumber().' remboursee.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'stats']);
|
||||
}
|
||||
|
||||
private function buildInvitationOrder(Event $event, Request $request, EntityManagerInterface $em): BilletBuyer|Response
|
||||
{
|
||||
$firstName = trim($request->request->getString('first_name'));
|
||||
$lastName = trim($request->request->getString('last_name'));
|
||||
$email = trim($request->request->getString('email'));
|
||||
$items = $request->request->all('items');
|
||||
$redirect = $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'invitations']);
|
||||
|
||||
if ('' === $firstName || '' === $lastName || '' === $email || 0 === \count($items)) {
|
||||
$this->addFlash('error', 'Tous les champs sont requis.');
|
||||
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
$count = $em->getRepository(BilletBuyer::class)->count([]) + 1;
|
||||
|
||||
$order = new BilletBuyer();
|
||||
$order->setEvent($event);
|
||||
$order->setFirstName($firstName);
|
||||
$order->setLastName($lastName);
|
||||
$order->setEmail($email);
|
||||
$order->setOrderNumber(date('Y-m-d').'-'.$count);
|
||||
$order->setTotalHT(0);
|
||||
$order->setIsInvitation(true);
|
||||
|
||||
foreach ($items as $itemData) {
|
||||
$billetId = (int) ($itemData['billet_id'] ?? 0);
|
||||
$qty = max(1, (int) ($itemData['quantity'] ?? 1));
|
||||
|
||||
$billet = $em->getRepository(Billet::class)->find($billetId);
|
||||
if (!$billet || $billet->getCategory()->getEvent()->getId() !== $event->getId()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$item = new BilletBuyerItem();
|
||||
$item->setBillet($billet);
|
||||
$item->setBilletName($billet->getName());
|
||||
$item->setQuantity($qty);
|
||||
$item->setUnitPriceHT(0);
|
||||
$order->addItem($item);
|
||||
}
|
||||
|
||||
if ($order->getItems()->isEmpty()) {
|
||||
$this->addFlash('error', 'Aucun billet valide selectionne.');
|
||||
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Billet> $billets
|
||||
* @param list<Category> $categories
|
||||
*
|
||||
* @return array{list<Billet>, list<int>}
|
||||
*/
|
||||
private function collectAttestationBillets(array $billets, array $categories, EntityManagerInterface $em): array
|
||||
{
|
||||
$allBilletIds = array_map(fn (Billet $b) => $b->getId(), $billets);
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
foreach ($em->getRepository(Billet::class)->findBy(['category' => $cat]) as $b) {
|
||||
if (!\in_array($b->getId(), $allBilletIds, true)) {
|
||||
$allBilletIds[] = $b->getId();
|
||||
$billets[] = $b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [$billets, $allBilletIds];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Billet> $billets
|
||||
* @param list<int> $allBilletIds
|
||||
*
|
||||
* @return array{list<array<string, mixed>>, int}
|
||||
*/
|
||||
private function buildAttestationStats(array $billets, array $allBilletIds, EntityManagerInterface $em): array
|
||||
{
|
||||
$soldCounts = [];
|
||||
if ($allBilletIds) {
|
||||
$rows = $em->createQueryBuilder()
|
||||
->select('IDENTITY(bo.billet) AS billetId, COUNT(bo.id) AS cnt')
|
||||
->from(BilletOrder::class, 'bo')
|
||||
->join('bo.billetBuyer', 'bb')
|
||||
->where('bo.billet IN (:ids)')
|
||||
->andWhere(self::DQL_BB_EXCLUDE_INVITATIONS)
|
||||
->setParameter('ids', $allBilletIds)
|
||||
->groupBy('bo.billet')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
foreach ($rows as $row) {
|
||||
$soldCounts[(int) $row['billetId']] = (int) $row['cnt'];
|
||||
}
|
||||
}
|
||||
|
||||
$billetLines = [];
|
||||
$totalSold = 0;
|
||||
foreach ($billets as $b) {
|
||||
$sold = $soldCounts[$b->getId()] ?? 0;
|
||||
$billetLines[] = [
|
||||
'category' => $b->getCategory()->getName(),
|
||||
'name' => $b->getName(),
|
||||
'sold' => $sold,
|
||||
];
|
||||
$totalSold += $sold;
|
||||
}
|
||||
|
||||
return [$billetLines, $totalSold];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $allBilletIds
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function buildAttestationTicketDetails(array $allBilletIds, EntityManagerInterface $em): array
|
||||
{
|
||||
if (!$allBilletIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tickets = $em->createQueryBuilder()
|
||||
->select('t', 'bb')
|
||||
->from(BilletOrder::class, 't')
|
||||
->join('t.billetBuyer', 'bb')
|
||||
->where('t.billet IN (:ids)')
|
||||
->andWhere(self::DQL_BB_EXCLUDE_INVITATIONS)
|
||||
->setParameter('ids', $allBilletIds)
|
||||
->orderBy('t.createdAt', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$details = [];
|
||||
foreach ($tickets as $t) {
|
||||
$details[] = [
|
||||
'reference' => $t->getReference(),
|
||||
'billetName' => $t->getBilletName(),
|
||||
'orderNumber' => $t->getBilletBuyer()->getOrderNumber(),
|
||||
'buyerName' => $t->getBilletBuyer()->getFirstName().' '.$t->getBilletBuyer()->getLastName(),
|
||||
];
|
||||
}
|
||||
|
||||
return $details;
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,11 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\AnalyticsEvent;
|
||||
use App\Entity\AnalyticsUniqId;
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\BilletBuyer;
|
||||
use App\Entity\BilletOrder;
|
||||
use App\Entity\Event;
|
||||
use App\Entity\OrganizerInvitation;
|
||||
use App\Entity\User;
|
||||
use App\Service\AuditService;
|
||||
use App\Service\BilletOrderService;
|
||||
use App\Service\EventIndexService;
|
||||
use App\Service\ExportService;
|
||||
use App\Service\MailerService;
|
||||
use App\Service\MeilisearchService;
|
||||
use App\Service\SiretService;
|
||||
@@ -534,379 +527,6 @@ class AdminController extends AbstractController
|
||||
return $this->redirectToRoute('app_admin_organizers', ['tab' => 'approved']);
|
||||
}
|
||||
|
||||
#[Route('/commandes', name: 'app_admin_orders', methods: ['GET'])]
|
||||
public function orders(Request $request, EntityManagerInterface $em, PaginatorInterface $paginator): Response
|
||||
{
|
||||
$status = $request->query->getString('status', '');
|
||||
$search = $request->query->getString('q', '');
|
||||
|
||||
$qb = $em->createQueryBuilder()
|
||||
->select('o', 'i')
|
||||
->from(BilletBuyer::class, 'o')
|
||||
->leftJoin('o.items', 'i')
|
||||
->orderBy('o.createdAt', 'DESC');
|
||||
|
||||
if ('' !== $status) {
|
||||
$qb->andWhere('o.status = :status')->setParameter('status', $status);
|
||||
}
|
||||
|
||||
if ('' !== $search) {
|
||||
$qb->andWhere('o.orderNumber LIKE :q OR o.firstName LIKE :q OR o.lastName LIKE :q OR o.email LIKE :q')
|
||||
->setParameter('q', '%'.$search.'%');
|
||||
}
|
||||
|
||||
$orders = $paginator->paginate($qb->getQuery(), $request->query->getInt('page', 1), 20);
|
||||
|
||||
$totalCA = $em->createQueryBuilder()
|
||||
->select('SUM(o.totalHT)')
|
||||
->from(BilletBuyer::class, 'o')
|
||||
->where(self::DQL_STATUS_PAID)
|
||||
->setParameter('paid', BilletBuyer::STATUS_PAID)
|
||||
->getQuery()
|
||||
->getSingleScalarResult() ?? 0;
|
||||
|
||||
$totalOrders = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_PAID]);
|
||||
$totalRefunded = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_REFUNDED]);
|
||||
$totalCancelled = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_CANCELLED]);
|
||||
$totalPending = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_PENDING]);
|
||||
|
||||
return $this->render('admin/orders.html.twig', [
|
||||
'orders' => $orders,
|
||||
'status' => $status,
|
||||
'search' => $search,
|
||||
'totalCA' => (int) $totalCA / 100,
|
||||
'totalOrders' => $totalOrders,
|
||||
'totalRefunded' => $totalRefunded,
|
||||
'totalCancelled' => $totalCancelled,
|
||||
'totalPending' => $totalPending,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/commandes/{id}/forcer-validation', name: 'app_admin_order_force_validate', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function forceValidateOrder(int $id, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response
|
||||
{
|
||||
$order = $em->getRepository(BilletBuyer::class)->find($id);
|
||||
if (!$order) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
if (BilletBuyer::STATUS_PENDING !== $order->getStatus()) {
|
||||
$this->addFlash('error', 'Seules les commandes en attente peuvent etre forcees.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_orders');
|
||||
}
|
||||
|
||||
$billetOrderService->generateOrderTickets($order);
|
||||
$billetOrderService->generateAndSendTickets($order);
|
||||
$billetOrderService->notifyOrganizer($order);
|
||||
|
||||
$this->addFlash('success', 'Commande '.$order->getOrderNumber().' validee avec succes. Billets generes et envoyes.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_orders');
|
||||
}
|
||||
|
||||
#[Route('/commandes/{id}/billets', name: 'app_admin_order_tickets', requirements: ['id' => '\d+'], methods: ['GET'])]
|
||||
public function orderTickets(int $id, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response
|
||||
{
|
||||
$order = $em->getRepository(BilletBuyer::class)->find($id);
|
||||
if (!$order) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$tickets = $em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]);
|
||||
if (!$tickets) {
|
||||
throw $this->createNotFoundException('Aucun billet pour cette commande.');
|
||||
}
|
||||
|
||||
if (1 === \count($tickets)) {
|
||||
$pdf = $billetOrderService->generatePdf($tickets[0]);
|
||||
|
||||
return new Response($pdf, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="'.$tickets[0]->getReference().'.pdf"',
|
||||
]);
|
||||
}
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'tickets_');
|
||||
$zip->open($tmpFile, \ZipArchive::OVERWRITE);
|
||||
|
||||
foreach ($tickets as $ticket) {
|
||||
$pdf = $billetOrderService->generatePdf($ticket);
|
||||
$zip->addFromString($ticket->getReference().'.pdf', $pdf);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
$content = file_get_contents($tmpFile);
|
||||
unlink($tmpFile);
|
||||
|
||||
return new Response($content, 200, [
|
||||
'Content-Type' => 'application/zip',
|
||||
'Content-Disposition' => 'attachment; filename="billets_'.$order->getOrderNumber().'.zip"',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/evenements', name: 'app_admin_events')]
|
||||
public function events(Request $request, PaginatorInterface $paginator, EventIndexService $eventIndex): Response
|
||||
{
|
||||
$searchQuery = $request->query->getString('q', '');
|
||||
$eventsQuery = $eventIndex->searchEvents('event_admin', $searchQuery);
|
||||
|
||||
$events = $paginator->paginate($eventsQuery, $request->query->getInt('page', 1), 10);
|
||||
|
||||
return $this->render('admin/events.html.twig', [
|
||||
'events' => $events,
|
||||
'searchQuery' => $searchQuery,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/evenement/{id}/en-ligne', name: 'app_admin_toggle_event_online', methods: ['POST'])]
|
||||
public function toggleEventOnline(Event $event, EntityManagerInterface $em, EventIndexService $eventIndex): Response
|
||||
{
|
||||
$event->setIsOnline(!$event->isOnline());
|
||||
$em->flush();
|
||||
|
||||
$eventIndex->indexEvent($event);
|
||||
|
||||
$this->addFlash('success', $event->isOnline() ? 'Evenement mis en ligne.' : 'Evenement passe hors ligne.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_events');
|
||||
}
|
||||
|
||||
#[Route('/evenement/{id}/supprimer', name: 'app_admin_delete_event', methods: ['POST'])]
|
||||
public function adminDeleteEvent(Event $event, EntityManagerInterface $em, EventIndexService $eventIndex, AuditService $audit): Response
|
||||
{
|
||||
$eventTitle = $event->getTitle();
|
||||
$eventDbId = $event->getId();
|
||||
$eventIndex->removeEvent($event);
|
||||
|
||||
$em->remove($event);
|
||||
$em->flush();
|
||||
|
||||
$audit->log('event_deleted', 'Event', $eventDbId, ['title' => $eventTitle]);
|
||||
|
||||
$this->addFlash('success', 'Evenement supprime.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_events');
|
||||
}
|
||||
|
||||
#[Route('/export/{year}/{month}', name: 'app_admin_export', requirements: ['year' => '\d{4}', 'month' => '\d{1,2}'], methods: ['GET'])]
|
||||
public function export(int $year, int $month, ExportService $exportService): Response
|
||||
{
|
||||
$stats = $exportService->getMonthlyStats($year, $month);
|
||||
$csv = $exportService->generateCsv($stats['orders'], true);
|
||||
|
||||
$filename = sprintf('export_admin_%04d_%02d.csv', $year, $month);
|
||||
|
||||
return new Response($csv, 200, [
|
||||
'Content-Type' => 'text/csv; charset=utf-8',
|
||||
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/export/{year}/{month}/pdf', name: 'app_admin_export_pdf', requirements: ['year' => '\d{4}', 'month' => '\d{1,2}'], methods: ['GET'])]
|
||||
public function exportPdf(int $year, int $month, ExportService $exportService): Response
|
||||
{
|
||||
$stats = $exportService->getMonthlyStats($year, $month);
|
||||
$pdf = $exportService->generatePdf($stats, $year, $month);
|
||||
|
||||
return new Response($pdf, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="recap_admin_'.sprintf('%04d_%02d', $year, $month).'.pdf"',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/logs', name: 'app_admin_logs', methods: ['GET'])]
|
||||
public function logs(Request $request, EntityManagerInterface $em, PaginatorInterface $paginator): Response
|
||||
{
|
||||
$logs = $em->getRepository(AuditLog::class)->findBy([], ['createdAt' => 'DESC']);
|
||||
$paginatedLogs = $paginator->paginate($logs, $request->query->getInt('page', 1), 30);
|
||||
|
||||
return $this->render('admin/logs.html.twig', [
|
||||
'logs' => $paginatedLogs,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/infra', name: 'app_admin_infra', methods: ['GET'])]
|
||||
public function infra(#[Autowire('%kernel.project_dir%')] string $projectDir): Response
|
||||
{
|
||||
$path = $projectDir.'/var/infra.json';
|
||||
if (!file_exists($path)) {
|
||||
$emptyServer = [
|
||||
'hostname' => '?', 'os' => '?', 'uptime' => '?',
|
||||
'cpu_model' => '?', 'cpu_cores' => '?', 'load_1m' => '?', 'load_5m' => '?', 'load_15m' => '?', 'load_percent' => '?',
|
||||
'ram_total' => '?', 'ram_used' => '?', 'ram_free' => '?', 'ram_percent' => '?',
|
||||
'disk_total' => '?', 'disk_used' => '?', 'disk_free' => '?', 'disk_percent' => '?',
|
||||
'caddy' => ['status' => 'unknown', 'info' => '?'], 'docker' => ['status' => 'unknown', 'info' => '?'],
|
||||
'ssl' => ['domain' => '?', 'issuer' => '?', 'valid_until' => '?', 'days_left' => '?', 'status' => 'unknown'],
|
||||
];
|
||||
|
||||
return $this->render('admin/infra.html.twig', [
|
||||
'snapshot_missing' => true,
|
||||
'server' => $emptyServer, 'containers' => [], 'redis_global' => ['connected' => false, 'error' => self::NO_SNAPSHOT_MSG],
|
||||
'redis_dbs' => [], 'postgres' => ['connected' => false, 'error' => self::NO_SNAPSHOT_MSG], 'pgbouncer' => ['connected' => false, 'error' => self::NO_SNAPSHOT_MSG],
|
||||
'generated_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents($path), true) ?? [];
|
||||
|
||||
return $this->render('admin/infra.html.twig', $data);
|
||||
}
|
||||
|
||||
#[Route('/analytics', name: 'app_admin_analytics', methods: ['GET'])]
|
||||
public function analytics(Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
$period = $request->query->getString('period', '7d');
|
||||
$since = match ($period) {
|
||||
'today' => new \DateTimeImmutable('today'),
|
||||
'30d' => new \DateTimeImmutable('-30 days'),
|
||||
'all' => new \DateTimeImmutable('2020-01-01'),
|
||||
default => new \DateTimeImmutable('-7 days'),
|
||||
};
|
||||
|
||||
$visitors = (int) $em->createQueryBuilder()
|
||||
->select('COUNT(v.id)')
|
||||
->from(AnalyticsUniqId::class, 'v')
|
||||
->where('v.createdAt >= :since')
|
||||
->setParameter('since', $since)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
$pageviews = (int) $em->createQueryBuilder()
|
||||
->select('COUNT(e.id)')
|
||||
->from(AnalyticsEvent::class, 'e')
|
||||
->where('e.createdAt >= :since')
|
||||
->setParameter('since', $since)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
// Bounce rate: visitors with only 1 pageview
|
||||
$bouncedVisitors = (int) $em->createQueryBuilder()
|
||||
->select('COUNT(v.id)')
|
||||
->from(AnalyticsUniqId::class, 'v')
|
||||
->where('v.createdAt >= :since')
|
||||
->andWhere('(SELECT COUNT(e2.id) FROM '.AnalyticsEvent::class.' e2 WHERE e2.visitor = v.id) = 1')
|
||||
->setParameter('since', $since)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
$bounceRate = $visitors > 0 ? round($bouncedVisitors / $visitors * 100, 1) : 0;
|
||||
|
||||
$topPages = $em->createQueryBuilder()
|
||||
->select('e.url, COUNT(e.id) AS hits')
|
||||
->from(AnalyticsEvent::class, 'e')
|
||||
->where('e.createdAt >= :since')
|
||||
->setParameter('since', $since)
|
||||
->groupBy('e.url')
|
||||
->orderBy('hits', 'DESC')
|
||||
->setMaxResults(20)
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$topReferrers = $em->createQueryBuilder()
|
||||
->select('e.referrer, COUNT(e.id) AS hits')
|
||||
->from(AnalyticsEvent::class, 'e')
|
||||
->where('e.createdAt >= :since')
|
||||
->andWhere('e.referrer IS NOT NULL')
|
||||
->andWhere("e.referrer != ''")
|
||||
->andWhere('e.referrer NOT LIKE :self1')
|
||||
->andWhere('e.referrer NOT LIKE :self2')
|
||||
->setParameter('self1', '%ticket.e-cosplay.fr%')
|
||||
->setParameter('self2', '%esyweb.local%')
|
||||
->setParameter('since', $since)
|
||||
->groupBy('e.referrer')
|
||||
->orderBy('hits', 'DESC')
|
||||
->setMaxResults(10)
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$devices = $em->createQueryBuilder()
|
||||
->select('v.deviceType, COUNT(v.id) AS cnt')
|
||||
->from(AnalyticsUniqId::class, 'v')
|
||||
->where('v.createdAt >= :since')
|
||||
->setParameter('since', $since)
|
||||
->groupBy('v.deviceType')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$browsers = $em->createQueryBuilder()
|
||||
->select('v.browser, COUNT(v.id) AS cnt')
|
||||
->from(AnalyticsUniqId::class, 'v')
|
||||
->where('v.createdAt >= :since')
|
||||
->andWhere('v.browser IS NOT NULL')
|
||||
->setParameter('since', $since)
|
||||
->groupBy('v.browser')
|
||||
->orderBy('cnt', 'DESC')
|
||||
->setMaxResults(10)
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$osList = $em->createQueryBuilder()
|
||||
->select('v.os, COUNT(v.id) AS cnt')
|
||||
->from(AnalyticsUniqId::class, 'v')
|
||||
->where('v.createdAt >= :since')
|
||||
->andWhere('v.os IS NOT NULL')
|
||||
->setParameter('since', $since)
|
||||
->groupBy('v.os')
|
||||
->orderBy('cnt', 'DESC')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
// Daily chart data
|
||||
$conn = $em->getConnection();
|
||||
|
||||
$visitorsPerDay = $conn->executeQuery(
|
||||
'SELECT CAST(created_at AS DATE) AS day, COUNT(*) AS cnt FROM analytics_uniq_id WHERE created_at >= :since GROUP BY day ORDER BY day ASC',
|
||||
['since' => $since->format('Y-m-d H:i:s')],
|
||||
)->fetchAllAssociative();
|
||||
|
||||
$pageviewsPerDay = $conn->executeQuery(
|
||||
'SELECT CAST(created_at AS DATE) AS day, COUNT(*) AS cnt FROM analytics_event WHERE created_at >= :since GROUP BY day ORDER BY day ASC',
|
||||
['since' => $since->format('Y-m-d H:i:s')],
|
||||
)->fetchAllAssociative();
|
||||
|
||||
// Merge into aligned arrays
|
||||
$allDays = [];
|
||||
foreach ($visitorsPerDay as $r) {
|
||||
$allDays[$r['day']] = true;
|
||||
}
|
||||
foreach ($pageviewsPerDay as $r) {
|
||||
$allDays[$r['day']] = true;
|
||||
}
|
||||
ksort($allDays);
|
||||
|
||||
$visitorsMap = [];
|
||||
foreach ($visitorsPerDay as $r) {
|
||||
$d = $r['day'] instanceof \DateTimeInterface ? $r['day']->format('Y-m-d') : (string) $r['day'];
|
||||
$visitorsMap[$d] = (int) $r['cnt'];
|
||||
}
|
||||
$pageviewsMap = [];
|
||||
foreach ($pageviewsPerDay as $r) {
|
||||
$d = $r['day'] instanceof \DateTimeInterface ? $r['day']->format('Y-m-d') : (string) $r['day'];
|
||||
$pageviewsMap[$d] = (int) $r['cnt'];
|
||||
}
|
||||
|
||||
$chartLabels = array_keys($allDays);
|
||||
$chartVisitors = array_map(fn ($d) => (int) ($visitorsMap[$d] ?? 0), $chartLabels);
|
||||
$chartPageviews = array_map(fn ($d) => (int) ($pageviewsMap[$d] ?? 0), $chartLabels);
|
||||
|
||||
return $this->render('admin/analytics.html.twig', [
|
||||
'period' => $period,
|
||||
'visitors' => $visitors,
|
||||
'pageviews' => $pageviews,
|
||||
'bounce_rate' => $bounceRate,
|
||||
'top_pages' => $topPages,
|
||||
'top_referrers' => $topReferrers,
|
||||
'devices' => $devices,
|
||||
'browsers' => $browsers,
|
||||
'os_list' => $osList,
|
||||
'chart_labels' => $chartLabels,
|
||||
'chart_visitors' => $chartVisitors,
|
||||
'chart_pageviews' => $chartPageviews,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/organisateurs/inviter', name: 'app_admin_invite_organizer', methods: ['GET', 'POST'])]
|
||||
public function inviteOrganizer(Request $request, EntityManagerInterface $em, MailerService $mailerService): Response
|
||||
{
|
||||
@@ -1013,4 +633,31 @@ class AdminController extends AbstractController
|
||||
|
||||
return $this->redirectToRoute('app_admin_invite_organizer');
|
||||
}
|
||||
|
||||
#[Route('/infra', name: 'app_admin_infra', methods: ['GET'])]
|
||||
public function infra(#[Autowire('%kernel.project_dir%')] string $projectDir): Response
|
||||
{
|
||||
$path = $projectDir.'/var/infra.json';
|
||||
if (!file_exists($path)) {
|
||||
$emptyServer = [
|
||||
'hostname' => '?', 'os' => '?', 'uptime' => '?',
|
||||
'cpu_model' => '?', 'cpu_cores' => '?', 'load_1m' => '?', 'load_5m' => '?', 'load_15m' => '?', 'load_percent' => '?',
|
||||
'ram_total' => '?', 'ram_used' => '?', 'ram_free' => '?', 'ram_percent' => '?',
|
||||
'disk_total' => '?', 'disk_used' => '?', 'disk_free' => '?', 'disk_percent' => '?',
|
||||
'caddy' => ['status' => 'unknown', 'info' => '?'], 'docker' => ['status' => 'unknown', 'info' => '?'],
|
||||
'ssl' => ['domain' => '?', 'issuer' => '?', 'valid_until' => '?', 'days_left' => '?', 'status' => 'unknown'],
|
||||
];
|
||||
|
||||
return $this->render('admin/infra.html.twig', [
|
||||
'snapshot_missing' => true,
|
||||
'server' => $emptyServer, 'containers' => [], 'redis_global' => ['connected' => false, 'error' => self::NO_SNAPSHOT_MSG],
|
||||
'redis_dbs' => [], 'postgres' => ['connected' => false, 'error' => self::NO_SNAPSHOT_MSG], 'pgbouncer' => ['connected' => false, 'error' => self::NO_SNAPSHOT_MSG],
|
||||
'generated_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents($path), true) ?? [];
|
||||
|
||||
return $this->render('admin/infra.html.twig', $data);
|
||||
}
|
||||
}
|
||||
|
||||
375
src/Controller/AdminOrdersController.php
Normal file
375
src/Controller/AdminOrdersController.php
Normal file
@@ -0,0 +1,375 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\AnalyticsEvent;
|
||||
use App\Entity\AnalyticsUniqId;
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\BilletBuyer;
|
||||
use App\Entity\BilletOrder;
|
||||
use App\Entity\Event;
|
||||
use App\Service\AuditService;
|
||||
use App\Service\BilletOrderService;
|
||||
use App\Service\EventIndexService;
|
||||
use App\Service\ExportService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/admin')]
|
||||
#[IsGranted('ROLE_ROOT')]
|
||||
class AdminOrdersController extends AbstractController
|
||||
{
|
||||
private const DQL_STATUS_PAID = 'o.status = :paid';
|
||||
private const DQL_EXCLUDE_INVITATIONS = 'o.isInvitation = false OR o.isInvitation IS NULL';
|
||||
|
||||
#[Route('/commandes', name: 'app_admin_orders', methods: ['GET'])]
|
||||
public function orders(Request $request, EntityManagerInterface $em, PaginatorInterface $paginator): Response
|
||||
{
|
||||
$status = $request->query->getString('status', '');
|
||||
$search = $request->query->getString('q', '');
|
||||
|
||||
$qb = $em->createQueryBuilder()
|
||||
->select('o', 'i')
|
||||
->from(BilletBuyer::class, 'o')
|
||||
->leftJoin('o.items', 'i')
|
||||
->orderBy('o.createdAt', 'DESC');
|
||||
|
||||
if ('' !== $status) {
|
||||
$qb->andWhere('o.status = :status')->setParameter('status', $status);
|
||||
}
|
||||
|
||||
if ('' !== $search) {
|
||||
$qb->andWhere('o.orderNumber LIKE :q OR o.firstName LIKE :q OR o.lastName LIKE :q OR o.email LIKE :q')
|
||||
->setParameter('q', '%'.$search.'%');
|
||||
}
|
||||
|
||||
$orders = $paginator->paginate($qb->getQuery(), $request->query->getInt('page', 1), 20);
|
||||
|
||||
$totalCA = $em->createQueryBuilder()
|
||||
->select('SUM(o.totalHT)')
|
||||
->from(BilletBuyer::class, 'o')
|
||||
->where(self::DQL_STATUS_PAID)
|
||||
->setParameter('paid', BilletBuyer::STATUS_PAID)
|
||||
->getQuery()
|
||||
->getSingleScalarResult() ?? 0;
|
||||
|
||||
$totalOrders = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_PAID]);
|
||||
$totalRefunded = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_REFUNDED]);
|
||||
$totalCancelled = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_CANCELLED]);
|
||||
$totalPending = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_PENDING]);
|
||||
|
||||
return $this->render('admin/orders.html.twig', [
|
||||
'orders' => $orders,
|
||||
'status' => $status,
|
||||
'search' => $search,
|
||||
'totalCA' => (int) $totalCA / 100,
|
||||
'totalOrders' => $totalOrders,
|
||||
'totalRefunded' => $totalRefunded,
|
||||
'totalCancelled' => $totalCancelled,
|
||||
'totalPending' => $totalPending,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/commandes/{id}/forcer-validation', name: 'app_admin_order_force_validate', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function forceValidateOrder(int $id, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response
|
||||
{
|
||||
$order = $em->getRepository(BilletBuyer::class)->find($id);
|
||||
if (!$order) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
if (BilletBuyer::STATUS_PENDING !== $order->getStatus()) {
|
||||
$this->addFlash('error', 'Seules les commandes en attente peuvent etre forcees.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_orders');
|
||||
}
|
||||
|
||||
$billetOrderService->generateOrderTickets($order);
|
||||
$billetOrderService->generateAndSendTickets($order);
|
||||
$billetOrderService->notifyOrganizer($order);
|
||||
|
||||
$this->addFlash('success', 'Commande '.$order->getOrderNumber().' validee avec succes. Billets generes et envoyes.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_orders');
|
||||
}
|
||||
|
||||
#[Route('/commandes/{id}/billets', name: 'app_admin_order_tickets', requirements: ['id' => '\d+'], methods: ['GET'])]
|
||||
public function orderTickets(int $id, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response
|
||||
{
|
||||
$order = $em->getRepository(BilletBuyer::class)->find($id);
|
||||
if (!$order) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$tickets = $em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]);
|
||||
if (!$tickets) {
|
||||
throw $this->createNotFoundException('Aucun billet pour cette commande.');
|
||||
}
|
||||
|
||||
if (1 === \count($tickets)) {
|
||||
$pdf = $billetOrderService->generatePdf($tickets[0]);
|
||||
|
||||
return new Response($pdf, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="'.$tickets[0]->getReference().'.pdf"',
|
||||
]);
|
||||
}
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'tickets_');
|
||||
$zip->open($tmpFile, \ZipArchive::OVERWRITE);
|
||||
|
||||
foreach ($tickets as $ticket) {
|
||||
$pdf = $billetOrderService->generatePdf($ticket);
|
||||
$zip->addFromString($ticket->getReference().'.pdf', $pdf);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
$content = file_get_contents($tmpFile);
|
||||
unlink($tmpFile);
|
||||
|
||||
return new Response($content, 200, [
|
||||
'Content-Type' => 'application/zip',
|
||||
'Content-Disposition' => 'attachment; filename="billets_'.$order->getOrderNumber().'.zip"',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/evenements', name: 'app_admin_events')]
|
||||
public function events(Request $request, PaginatorInterface $paginator, EventIndexService $eventIndex): Response
|
||||
{
|
||||
$searchQuery = $request->query->getString('q', '');
|
||||
$eventsQuery = $eventIndex->searchEvents('event_admin', $searchQuery);
|
||||
|
||||
$events = $paginator->paginate($eventsQuery, $request->query->getInt('page', 1), 10);
|
||||
|
||||
return $this->render('admin/events.html.twig', [
|
||||
'events' => $events,
|
||||
'searchQuery' => $searchQuery,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/evenement/{id}/en-ligne', name: 'app_admin_toggle_event_online', methods: ['POST'])]
|
||||
public function toggleEventOnline(Event $event, EntityManagerInterface $em, EventIndexService $eventIndex): Response
|
||||
{
|
||||
$event->setIsOnline(!$event->isOnline());
|
||||
$em->flush();
|
||||
|
||||
$eventIndex->indexEvent($event);
|
||||
|
||||
$this->addFlash('success', $event->isOnline() ? 'Evenement mis en ligne.' : 'Evenement passe hors ligne.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_events');
|
||||
}
|
||||
|
||||
#[Route('/evenement/{id}/supprimer', name: 'app_admin_delete_event', methods: ['POST'])]
|
||||
public function adminDeleteEvent(Event $event, EntityManagerInterface $em, EventIndexService $eventIndex, AuditService $audit): Response
|
||||
{
|
||||
$eventTitle = $event->getTitle();
|
||||
$eventDbId = $event->getId();
|
||||
$eventIndex->removeEvent($event);
|
||||
|
||||
$em->remove($event);
|
||||
$em->flush();
|
||||
|
||||
$audit->log('event_deleted', 'Event', $eventDbId, ['title' => $eventTitle]);
|
||||
|
||||
$this->addFlash('success', 'Evenement supprime.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_events');
|
||||
}
|
||||
|
||||
#[Route('/export/{year}/{month}', name: 'app_admin_export', requirements: ['year' => '\d{4}', 'month' => '\d{1,2}'], methods: ['GET'])]
|
||||
public function export(int $year, int $month, ExportService $exportService): Response
|
||||
{
|
||||
$stats = $exportService->getMonthlyStats($year, $month);
|
||||
$csv = $exportService->generateCsv($stats['orders'], true);
|
||||
|
||||
$filename = sprintf('export_admin_%04d_%02d.csv', $year, $month);
|
||||
|
||||
return new Response($csv, 200, [
|
||||
'Content-Type' => 'text/csv; charset=utf-8',
|
||||
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/export/{year}/{month}/pdf', name: 'app_admin_export_pdf', requirements: ['year' => '\d{4}', 'month' => '\d{1,2}'], methods: ['GET'])]
|
||||
public function exportPdf(int $year, int $month, ExportService $exportService): Response
|
||||
{
|
||||
$stats = $exportService->getMonthlyStats($year, $month);
|
||||
$pdf = $exportService->generatePdf($stats, $year, $month);
|
||||
|
||||
return new Response($pdf, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="recap_admin_'.sprintf('%04d_%02d', $year, $month).'.pdf"',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/logs', name: 'app_admin_logs', methods: ['GET'])]
|
||||
public function logs(Request $request, EntityManagerInterface $em, PaginatorInterface $paginator): Response
|
||||
{
|
||||
$logs = $em->getRepository(AuditLog::class)->findBy([], ['createdAt' => 'DESC']);
|
||||
$paginatedLogs = $paginator->paginate($logs, $request->query->getInt('page', 1), 30);
|
||||
|
||||
return $this->render('admin/logs.html.twig', [
|
||||
'logs' => $paginatedLogs,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/analytics', name: 'app_admin_analytics', methods: ['GET'])]
|
||||
public function analytics(Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
$period = $request->query->getString('period', '7d');
|
||||
$since = match ($period) {
|
||||
'today' => new \DateTimeImmutable('today'),
|
||||
'30d' => new \DateTimeImmutable('-30 days'),
|
||||
'all' => new \DateTimeImmutable('2020-01-01'),
|
||||
default => new \DateTimeImmutable('-7 days'),
|
||||
};
|
||||
|
||||
$visitors = (int) $em->createQueryBuilder()
|
||||
->select('COUNT(v.id)')
|
||||
->from(AnalyticsUniqId::class, 'v')
|
||||
->where('v.createdAt >= :since')
|
||||
->setParameter('since', $since)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
$pageviews = (int) $em->createQueryBuilder()
|
||||
->select('COUNT(e.id)')
|
||||
->from(AnalyticsEvent::class, 'e')
|
||||
->where('e.createdAt >= :since')
|
||||
->setParameter('since', $since)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
// Bounce rate: visitors with only 1 pageview
|
||||
$bouncedVisitors = (int) $em->createQueryBuilder()
|
||||
->select('COUNT(v.id)')
|
||||
->from(AnalyticsUniqId::class, 'v')
|
||||
->where('v.createdAt >= :since')
|
||||
->andWhere('(SELECT COUNT(e2.id) FROM '.AnalyticsEvent::class.' e2 WHERE e2.visitor = v.id) = 1')
|
||||
->setParameter('since', $since)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
$bounceRate = $visitors > 0 ? round($bouncedVisitors / $visitors * 100, 1) : 0;
|
||||
|
||||
$topPages = $em->createQueryBuilder()
|
||||
->select('e.url, COUNT(e.id) AS hits')
|
||||
->from(AnalyticsEvent::class, 'e')
|
||||
->where('e.createdAt >= :since')
|
||||
->setParameter('since', $since)
|
||||
->groupBy('e.url')
|
||||
->orderBy('hits', 'DESC')
|
||||
->setMaxResults(20)
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$topReferrers = $em->createQueryBuilder()
|
||||
->select('e.referrer, COUNT(e.id) AS hits')
|
||||
->from(AnalyticsEvent::class, 'e')
|
||||
->where('e.createdAt >= :since')
|
||||
->andWhere('e.referrer IS NOT NULL')
|
||||
->andWhere("e.referrer != ''")
|
||||
->andWhere('e.referrer NOT LIKE :self1')
|
||||
->andWhere('e.referrer NOT LIKE :self2')
|
||||
->setParameter('self1', '%ticket.e-cosplay.fr%')
|
||||
->setParameter('self2', '%esyweb.local%')
|
||||
->setParameter('since', $since)
|
||||
->groupBy('e.referrer')
|
||||
->orderBy('hits', 'DESC')
|
||||
->setMaxResults(10)
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$devices = $em->createQueryBuilder()
|
||||
->select('v.deviceType, COUNT(v.id) AS cnt')
|
||||
->from(AnalyticsUniqId::class, 'v')
|
||||
->where('v.createdAt >= :since')
|
||||
->setParameter('since', $since)
|
||||
->groupBy('v.deviceType')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$browsers = $em->createQueryBuilder()
|
||||
->select('v.browser, COUNT(v.id) AS cnt')
|
||||
->from(AnalyticsUniqId::class, 'v')
|
||||
->where('v.createdAt >= :since')
|
||||
->andWhere('v.browser IS NOT NULL')
|
||||
->setParameter('since', $since)
|
||||
->groupBy('v.browser')
|
||||
->orderBy('cnt', 'DESC')
|
||||
->setMaxResults(10)
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$osList = $em->createQueryBuilder()
|
||||
->select('v.os, COUNT(v.id) AS cnt')
|
||||
->from(AnalyticsUniqId::class, 'v')
|
||||
->where('v.createdAt >= :since')
|
||||
->andWhere('v.os IS NOT NULL')
|
||||
->setParameter('since', $since)
|
||||
->groupBy('v.os')
|
||||
->orderBy('cnt', 'DESC')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
// Daily chart data
|
||||
$conn = $em->getConnection();
|
||||
|
||||
$visitorsPerDay = $conn->executeQuery(
|
||||
'SELECT CAST(created_at AS DATE) AS day, COUNT(*) AS cnt FROM analytics_uniq_id WHERE created_at >= :since GROUP BY day ORDER BY day ASC',
|
||||
['since' => $since->format('Y-m-d H:i:s')],
|
||||
)->fetchAllAssociative();
|
||||
|
||||
$pageviewsPerDay = $conn->executeQuery(
|
||||
'SELECT CAST(created_at AS DATE) AS day, COUNT(*) AS cnt FROM analytics_event WHERE created_at >= :since GROUP BY day ORDER BY day ASC',
|
||||
['since' => $since->format('Y-m-d H:i:s')],
|
||||
)->fetchAllAssociative();
|
||||
|
||||
// Merge into aligned arrays
|
||||
$allDays = [];
|
||||
foreach ($visitorsPerDay as $r) {
|
||||
$allDays[$r['day']] = true;
|
||||
}
|
||||
foreach ($pageviewsPerDay as $r) {
|
||||
$allDays[$r['day']] = true;
|
||||
}
|
||||
ksort($allDays);
|
||||
|
||||
$visitorsMap = [];
|
||||
foreach ($visitorsPerDay as $r) {
|
||||
$d = $r['day'] instanceof \DateTimeInterface ? $r['day']->format('Y-m-d') : (string) $r['day'];
|
||||
$visitorsMap[$d] = (int) $r['cnt'];
|
||||
}
|
||||
$pageviewsMap = [];
|
||||
foreach ($pageviewsPerDay as $r) {
|
||||
$d = $r['day'] instanceof \DateTimeInterface ? $r['day']->format('Y-m-d') : (string) $r['day'];
|
||||
$pageviewsMap[$d] = (int) $r['cnt'];
|
||||
}
|
||||
|
||||
$chartLabels = array_keys($allDays);
|
||||
$chartVisitors = array_map(fn ($d) => (int) ($visitorsMap[$d] ?? 0), $chartLabels);
|
||||
$chartPageviews = array_map(fn ($d) => (int) ($pageviewsMap[$d] ?? 0), $chartLabels);
|
||||
|
||||
return $this->render('admin/analytics.html.twig', [
|
||||
'period' => $period,
|
||||
'visitors' => $visitors,
|
||||
'pageviews' => $pageviews,
|
||||
'bounce_rate' => $bounceRate,
|
||||
'top_pages' => $topPages,
|
||||
'top_referrers' => $topReferrers,
|
||||
'devices' => $devices,
|
||||
'browsers' => $browsers,
|
||||
'os_list' => $osList,
|
||||
'chart_labels' => $chartLabels,
|
||||
'chart_visitors' => $chartVisitors,
|
||||
'chart_pageviews' => $chartPageviews,
|
||||
]);
|
||||
}
|
||||
}
|
||||
40
src/Controller/Trait/AccountEventOwnershipTrait.php
Normal file
40
src/Controller/Trait/AccountEventOwnershipTrait.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Trait;
|
||||
|
||||
use App\Entity\Event;
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
trait AccountEventOwnershipTrait
|
||||
{
|
||||
private function requireEventOwnership(Event $event): User
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
if ($this->isGranted('ROLE_ROOT')) {
|
||||
return $event->getAccount();
|
||||
}
|
||||
if ($event->getAccount()->getId() !== $user->getId()) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/** @codeCoverageIgnore Tested via testOrganizerWithoutStripeBlocksEventCreation */
|
||||
private function requireStripeReady(): ?Response
|
||||
{
|
||||
if ($this->isGranted('ROLE_ROOT')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$stripeNotReady = !$user->isStripeChargesEnabled() || !$user->isStripePayoutsEnabled();
|
||||
$billingBlocked = $user->isBilling() && 'good' !== $user->getBillingState();
|
||||
|
||||
return ($stripeNotReady || $billingBlocked) ? $this->redirectToRoute('app_account') : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user