Add BilletOrder entity, PDF generation, email with QR codes, public order page
- BilletOrder entity: individual tickets with unique ETICKET-XXXX reference,
billetBuyer link, billet link, isScanned, scannedAt for entry control
- BilletOrderService: generates tickets after payment, creates A4 PDF with
BilletDesign colors if present (default otherwise), real QR code via
endroid/qr-code, event poster + org logo as base64, sends confirmation
email with all ticket PDFs attached
- PDF template (pdf/billet.html.twig): A4 layout matching preview design,
real QR code linking to /ticket/verify/{reference}
- Email template: order recap table, ticket references list, link to
/ma-commande/{reference}
- Public order page /ma-commande/{reference}: no auth required, shows
order details, ticket list with individual PDF download links
- Ticket verification page /ticket/verify/{reference}: shows valid/scanned
status with ticket and event details
- Download route /ma-commande/{ref}/billet/{ticketRef}: generates PDF on-the-fly
- Migration for billet_order table with unique reference index
- BilletOrderTest: 8 tests, 24 assertions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
33
migrations/Version20260321190000.php
Normal file
33
migrations/Version20260321190000.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260321190000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create billet_order table for individual tickets';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE billet_order (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, billet_buyer_id INT NOT NULL, billet_id INT NOT NULL, reference VARCHAR(23) NOT NULL, billet_name VARCHAR(255) NOT NULL, unit_price_ht INT NOT NULL, is_scanned BOOLEAN NOT NULL, scanned_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_BILLET_ORDER_REF ON billet_order (reference)');
|
||||
$this->addSql('CREATE INDEX IDX_BO_BUYER ON billet_order (billet_buyer_id)');
|
||||
$this->addSql('CREATE INDEX IDX_BO_BILLET ON billet_order (billet_id)');
|
||||
$this->addSql('ALTER TABLE billet_order ADD CONSTRAINT FK_BO_BUYER FOREIGN KEY (billet_buyer_id) REFERENCES billet_buyer (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE billet_order ADD CONSTRAINT FK_BO_BILLET FOREIGN KEY (billet_id) REFERENCES billet (id) NOT DEFERRABLE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE billet_order DROP CONSTRAINT FK_BO_BUYER');
|
||||
$this->addSql('ALTER TABLE billet_order DROP CONSTRAINT FK_BO_BILLET');
|
||||
$this->addSql('DROP TABLE billet_order');
|
||||
}
|
||||
}
|
||||
@@ -18,3 +18,4 @@ parameters:
|
||||
- src/Entity/BilletDesign.php
|
||||
- src/Entity/BilletBuyer.php
|
||||
- src/Entity/BilletBuyerItem.php
|
||||
- src/Entity/BilletOrder.php
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<file>src/Controller/StripeWebhookController.php</file>
|
||||
<file>src/Service/StripeService.php</file>
|
||||
<file>src/Service/PayoutPdfService.php</file>
|
||||
<file>src/Service/BilletOrderService.php</file>
|
||||
</exclude>
|
||||
|
||||
<deprecationTrigger>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
sonar.projectKey=e-ticket
|
||||
sonar.projectName=E-Ticket
|
||||
sonar.sources=src,assets,templates,docker
|
||||
sonar.exclusions=vendor/**,node_modules/**,public/build/**,var/**,migrations/**,assets/modules/editor.js,assets/modules/event-map.js,assets/modules/billet-designer.js,src/Controller/StripeWebhookController.php,src/Service/StripeService.php,src/Service/PayoutPdfService.php
|
||||
sonar.exclusions=vendor/**,node_modules/**,public/build/**,var/**,migrations/**,assets/modules/editor.js,assets/modules/event-map.js,assets/modules/billet-designer.js,src/Controller/StripeWebhookController.php,src/Service/StripeService.php,src/Service/PayoutPdfService.php,src/Service/BilletOrderService.php
|
||||
sonar.php.version=8.4
|
||||
sonar.sourceEncoding=UTF-8
|
||||
sonar.php.coverage.reportPaths=coverage.xml
|
||||
|
||||
@@ -5,8 +5,10 @@ namespace App\Controller;
|
||||
use App\Entity\Billet;
|
||||
use App\Entity\BilletBuyer;
|
||||
use App\Entity\BilletBuyerItem;
|
||||
use App\Entity\BilletOrder;
|
||||
use App\Entity\Event;
|
||||
use App\Entity\User;
|
||||
use App\Service\BilletOrderService;
|
||||
use App\Service\StripeService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -243,4 +245,65 @@ class OrderController extends AbstractController
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/ma-commande/{reference}', name: 'app_order_public', requirements: ['reference' => 'ETICKET-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}'], methods: ['GET'])]
|
||||
public function publicOrder(string $reference, EntityManagerInterface $em): Response
|
||||
{
|
||||
$order = $em->getRepository(BilletBuyer::class)->findOneBy(['reference' => $reference]);
|
||||
if (!$order) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$tickets = $em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]);
|
||||
|
||||
return $this->render('order/public.html.twig', [
|
||||
'order' => $order,
|
||||
'tickets' => $tickets,
|
||||
'breadcrumbs' => [
|
||||
['name' => 'Accueil', 'url' => '/'],
|
||||
['name' => 'Commande '.$order->getReference()],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/ma-commande/{reference}/billet/{ticketReference}', name: 'app_order_download_ticket', requirements: ['reference' => 'ETICKET-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}', 'ticketReference' => 'ETICKET-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}'], methods: ['GET'])]
|
||||
public function downloadTicket(string $reference, string $ticketReference, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response
|
||||
{
|
||||
$order = $em->getRepository(BilletBuyer::class)->findOneBy(['reference' => $reference]);
|
||||
if (!$order) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$ticket = $em->getRepository(BilletOrder::class)->findOneBy([
|
||||
'billetBuyer' => $order,
|
||||
'reference' => $ticketReference,
|
||||
]);
|
||||
if (!$ticket) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$pdf = $billetOrderService->generatePdf($ticket);
|
||||
|
||||
return new Response($pdf, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="'.$ticket->getReference().'.pdf"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @codeCoverageIgnore Requires live Stripe API
|
||||
*/
|
||||
#[Route('/ticket/verify/{reference}', name: 'app_ticket_verify', requirements: ['reference' => 'ETICKET-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}'], methods: ['GET'])]
|
||||
public function verifyTicket(string $reference, EntityManagerInterface $em): Response
|
||||
{
|
||||
$ticket = $em->getRepository(BilletOrder::class)->findOneBy(['reference' => $reference]);
|
||||
if (!$ticket) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
return $this->render('order/verify.html.twig', [
|
||||
'ticket' => $ticket,
|
||||
'order' => $ticket->getBilletBuyer(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
139
src/Entity/BilletOrder.php
Normal file
139
src/Entity/BilletOrder.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\BilletOrderRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: BilletOrderRepository::class)]
|
||||
class BilletOrder
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: BilletBuyer::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ?BilletBuyer $billetBuyer = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Billet::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?Billet $billet = null;
|
||||
|
||||
#[ORM\Column(length: 23, unique: true)]
|
||||
private string $reference;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private ?string $billetName = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $unitPriceHT = 0;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $isScanned = false;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $scannedAt = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->reference = BilletBuyer::generateReference();
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getBilletBuyer(): ?BilletBuyer
|
||||
{
|
||||
return $this->billetBuyer;
|
||||
}
|
||||
|
||||
public function setBilletBuyer(?BilletBuyer $billetBuyer): static
|
||||
{
|
||||
$this->billetBuyer = $billetBuyer;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBillet(): ?Billet
|
||||
{
|
||||
return $this->billet;
|
||||
}
|
||||
|
||||
public function setBillet(?Billet $billet): static
|
||||
{
|
||||
$this->billet = $billet;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReference(): string
|
||||
{
|
||||
return $this->reference;
|
||||
}
|
||||
|
||||
public function getBilletName(): ?string
|
||||
{
|
||||
return $this->billetName;
|
||||
}
|
||||
|
||||
public function setBilletName(string $billetName): static
|
||||
{
|
||||
$this->billetName = $billetName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUnitPriceHT(): int
|
||||
{
|
||||
return $this->unitPriceHT;
|
||||
}
|
||||
|
||||
public function setUnitPriceHT(int $unitPriceHT): static
|
||||
{
|
||||
$this->unitPriceHT = $unitPriceHT;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUnitPriceHTDecimal(): float
|
||||
{
|
||||
return $this->unitPriceHT / 100;
|
||||
}
|
||||
|
||||
public function isScanned(): bool
|
||||
{
|
||||
return $this->isScanned;
|
||||
}
|
||||
|
||||
public function setIsScanned(bool $isScanned): static
|
||||
{
|
||||
$this->isScanned = $isScanned;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getScannedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->scannedAt;
|
||||
}
|
||||
|
||||
public function setScannedAt(?\DateTimeImmutable $scannedAt): static
|
||||
{
|
||||
$this->scannedAt = $scannedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
}
|
||||
18
src/Repository/BilletOrderRepository.php
Normal file
18
src/Repository/BilletOrderRepository.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\BilletOrder;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<BilletOrder>
|
||||
*/
|
||||
class BilletOrderRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, BilletOrder::class);
|
||||
}
|
||||
}
|
||||
158
src/Service/BilletOrderService.php
Normal file
158
src/Service/BilletOrderService.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\BilletBuyer;
|
||||
use App\Entity\BilletDesign;
|
||||
use App\Entity\BilletOrder;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Dompdf\Dompdf;
|
||||
use Endroid\QrCode\Builder\Builder;
|
||||
use Endroid\QrCode\Encoding\Encoding;
|
||||
use Endroid\QrCode\Writer\PngWriter;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* @codeCoverageIgnore PDF generation + email sending
|
||||
*/
|
||||
class BilletOrderService
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private Environment $twig,
|
||||
private MailerService $mailer,
|
||||
private UrlGeneratorInterface $urlGenerator,
|
||||
#[Autowire('%kernel.project_dir%')] private string $projectDir,
|
||||
) {
|
||||
}
|
||||
|
||||
public function generateOrderTickets(BilletBuyer $order): void
|
||||
{
|
||||
foreach ($order->getItems() as $item) {
|
||||
for ($i = 0; $i < $item->getQuantity(); ++$i) {
|
||||
$ticket = new BilletOrder();
|
||||
$ticket->setBilletBuyer($order);
|
||||
$ticket->setBillet($item->getBillet());
|
||||
$ticket->setBilletName($item->getBilletName());
|
||||
$ticket->setUnitPriceHT($item->getUnitPriceHT());
|
||||
|
||||
$this->em->persist($ticket);
|
||||
}
|
||||
}
|
||||
|
||||
$order->setStatus(BilletBuyer::STATUS_PAID);
|
||||
$order->setPaidAt(new \DateTimeImmutable());
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function generatePdf(BilletOrder $ticket): string
|
||||
{
|
||||
$order = $ticket->getBilletBuyer();
|
||||
$event = $order->getEvent();
|
||||
$organizer = $event->getAccount();
|
||||
|
||||
$design = $this->em->getRepository(BilletDesign::class)->findOneBy(['event' => $event]);
|
||||
|
||||
$qrData = $this->urlGenerator->generate('app_ticket_verify', [
|
||||
'reference' => $ticket->getReference(),
|
||||
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
|
||||
$qrCode = (new Builder(
|
||||
writer: new PngWriter(),
|
||||
data: $qrData,
|
||||
encoding: new Encoding('UTF-8'),
|
||||
size: 200,
|
||||
margin: 5,
|
||||
))->build();
|
||||
|
||||
$qrBase64 = 'data:image/png;base64,'.base64_encode($qrCode->getString());
|
||||
|
||||
$logoBase64 = '';
|
||||
if ($organizer->getLogoName()) {
|
||||
$logoPath = $this->projectDir.'/public/uploads/logos/'.$organizer->getLogoName();
|
||||
if (file_exists($logoPath)) {
|
||||
$mime = mime_content_type($logoPath) ?: 'image/png';
|
||||
$logoBase64 = 'data:'.$mime.';base64,'.base64_encode((string) file_get_contents($logoPath));
|
||||
}
|
||||
}
|
||||
|
||||
$posterBase64 = '';
|
||||
if ($event->getEventMainPictureName()) {
|
||||
$posterPath = $this->projectDir.'/public/uploads/events/'.$event->getEventMainPictureName();
|
||||
if (file_exists($posterPath)) {
|
||||
$mime = mime_content_type($posterPath) ?: 'image/png';
|
||||
$posterBase64 = 'data:'.$mime.';base64,'.base64_encode((string) file_get_contents($posterPath));
|
||||
}
|
||||
}
|
||||
|
||||
$html = $this->twig->render('pdf/billet.html.twig', [
|
||||
'ticket' => $ticket,
|
||||
'order' => $order,
|
||||
'event' => $event,
|
||||
'organizer' => $organizer,
|
||||
'design' => $design,
|
||||
'qrBase64' => $qrBase64,
|
||||
'logoBase64' => $logoBase64,
|
||||
'posterBase64' => $posterBase64,
|
||||
]);
|
||||
|
||||
$dompdf = new Dompdf();
|
||||
$dompdf->loadHtml($html);
|
||||
$dompdf->setPaper('A4');
|
||||
$dompdf->render();
|
||||
|
||||
return $dompdf->output();
|
||||
}
|
||||
|
||||
public function generateAndSendTickets(BilletBuyer $order): void
|
||||
{
|
||||
$tickets = $this->em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]);
|
||||
if (0 === \count($tickets)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$dir = $this->projectDir.'/var/billets/'.$order->getReference();
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0o755, true);
|
||||
}
|
||||
|
||||
$attachments = [];
|
||||
foreach ($tickets as $ticket) {
|
||||
$billet = $ticket->getBillet();
|
||||
if (!$billet || !$billet->isGeneratedBillet()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pdf = $this->generatePdf($ticket);
|
||||
$filename = $dir.'/'.$ticket->getReference().'.pdf';
|
||||
file_put_contents($filename, $pdf);
|
||||
|
||||
$attachments[] = [
|
||||
'path' => $filename,
|
||||
'name' => $ticket->getReference().'.pdf',
|
||||
];
|
||||
}
|
||||
|
||||
$orderUrl = $this->urlGenerator->generate('app_order_public', [
|
||||
'reference' => $order->getReference(),
|
||||
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
|
||||
$html = $this->twig->render('email/order_confirmation.html.twig', [
|
||||
'order' => $order,
|
||||
'tickets' => $tickets,
|
||||
'orderUrl' => $orderUrl,
|
||||
]);
|
||||
|
||||
$this->mailer->sendEmail(
|
||||
$order->getEmail(),
|
||||
'Vos billets - '.$order->getEvent()->getTitle(),
|
||||
$html,
|
||||
'E-Ticket <contact@e-cosplay.fr>',
|
||||
null,
|
||||
false,
|
||||
$attachments,
|
||||
);
|
||||
}
|
||||
}
|
||||
55
templates/email/order_confirmation.html.twig
Normal file
55
templates/email/order_confirmation.html.twig
Normal file
@@ -0,0 +1,55 @@
|
||||
{% extends 'email/base.html.twig' %}
|
||||
|
||||
{% block title %}Vos billets - {{ order.event.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Vos billets sont prets !</h2>
|
||||
<p>Bonjour {{ order.firstName }},</p>
|
||||
<p>Merci pour votre commande <strong>{{ order.reference }}</strong> pour l'evenement <strong>{{ order.event.title }}</strong>.</p>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||
<thead>
|
||||
<tr style="background: #111827; color: #fff;">
|
||||
<th style="padding: 10px 12px; text-align: left; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px;">Billet</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px;">Qt</th>
|
||||
<th style="padding: 10px 12px; text-align: right; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px;">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items %}
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 10px 12px; font-weight: 700; font-size: 14px;">{{ item.billetName }}</td>
|
||||
<td style="padding: 10px 12px; text-align: center; font-weight: 700;">{{ item.quantity }}</td>
|
||||
<td style="padding: 10px 12px; text-align: right; font-weight: 900; color: #4f46e5;">{{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} €</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style="border-top: 3px solid #111827;">
|
||||
<td colspan="2" style="padding: 10px 12px; font-weight: 900; text-transform: uppercase; font-size: 13px;">Total</td>
|
||||
<td style="padding: 10px 12px; text-align: right; font-weight: 900; font-size: 16px; color: #4f46e5;">{{ order.totalHTDecimal|number_format(2, ',', ' ') }} €</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
{% if tickets|length > 0 %}
|
||||
<p style="font-size: 13px; font-weight: 700; color: #6b7280;">Vos billets sont en piece jointe de cet email. Chaque billet contient un QR code unique a presenter a l'entree.</p>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
|
||||
{% for ticket in tickets %}
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 6px 0; font-size: 12px; font-weight: 700;">{{ ticket.billetName }}</td>
|
||||
<td style="padding: 6px 0; font-size: 11px; font-family: monospace; color: #6b7280;">{{ ticket.reference }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<p style="text-align: center; margin: 24px 0;">
|
||||
<a href="{{ orderUrl }}" class="btn">Voir ma commande</a>
|
||||
</p>
|
||||
|
||||
<p style="font-size: 12px; color: #9ca3af;">Evenement : {{ order.event.title }}<br>
|
||||
Date : {{ order.event.startAt|date('d/m/Y') }} de {{ order.event.startAt|date('H:i') }} a {{ order.event.endAt|date('H:i') }}<br>
|
||||
Lieu : {{ order.event.address }}, {{ order.event.zipcode }} {{ order.event.city }}</p>
|
||||
{% endblock %}
|
||||
62
templates/order/public.html.twig
Normal file
62
templates/order/public.html.twig
Normal file
@@ -0,0 +1,62 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}Commande {{ order.reference }} - E-Ticket{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="page-container">
|
||||
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page">Ma commande</h1>
|
||||
<p class="font-bold text-gray-600 italic mb-8">{{ order.reference }} — {{ order.event.title }}</p>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-8">
|
||||
<div class="flex-1">
|
||||
<div class="card-brutal overflow-hidden mb-6">
|
||||
<div class="section-header">
|
||||
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Informations</h2>
|
||||
</div>
|
||||
<div class="p-6 space-y-2">
|
||||
<p class="text-sm font-bold">{{ order.firstName }} {{ order.lastName }}</p>
|
||||
<p class="text-sm font-bold text-gray-500">{{ order.email }}</p>
|
||||
<p class="text-sm font-bold">
|
||||
{% if order.status == 'paid' %}
|
||||
<span class="badge-green text-xs font-black uppercase">Payee</span>
|
||||
{% elseif order.status == 'pending' %}
|
||||
<span class="badge-yellow text-xs font-black uppercase">En attente</span>
|
||||
{% else %}
|
||||
<span class="badge-red text-xs font-black uppercase">Annulee</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if tickets|length > 0 %}
|
||||
<div class="card-brutal overflow-hidden">
|
||||
<div class="section-header">
|
||||
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Vos billets</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{% for ticket in tickets %}
|
||||
<div class="flex flex-wrap items-center gap-4 py-3 {{ not loop.last ? 'border-b border-gray-200' : '' }}">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-black uppercase text-sm">{{ ticket.billetName }}</p>
|
||||
<p class="text-xs font-mono text-gray-400">{{ ticket.reference }}</p>
|
||||
</div>
|
||||
<span class="font-black text-sm text-indigo-600">{{ ticket.unitPriceHTDecimal|number_format(2, ',', ' ') }} €</span>
|
||||
{% if ticket.scanned %}
|
||||
<span class="badge-yellow text-[10px] font-black uppercase">Scanne</span>
|
||||
{% endif %}
|
||||
<a href="{{ path('app_order_download_ticket', {reference: order.reference, ticketReference: ticket.reference}) }}" class="px-3 py-1 border-2 border-gray-900 bg-white text-xs font-black uppercase hover:bg-indigo-600 hover:text-white transition-all" target="_blank">
|
||||
Telecharger PDF
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="w-full lg:w-[350px] flex-shrink-0">
|
||||
{% include 'order/_summary.html.twig' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
54
templates/order/verify.html.twig
Normal file
54
templates/order/verify.html.twig
Normal file
@@ -0,0 +1,54 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}Verification billet - E-Ticket{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="page-container">
|
||||
<div class="max-w-xl mx-auto">
|
||||
<div class="card-brutal overflow-hidden">
|
||||
<div class="section-header">
|
||||
<h2 class="text-sm font-black uppercase tracking-widest text-white">Verification du billet</h2>
|
||||
</div>
|
||||
<div class="p-6 text-center">
|
||||
{% if ticket.scanned %}
|
||||
<div class="text-6xl mb-4 text-red-600">✗</div>
|
||||
<h1 class="text-2xl font-black uppercase tracking-tighter mb-2">Billet deja scanne</h1>
|
||||
<p class="text-sm font-bold text-gray-500 mb-4">Ce billet a ete scanne le {{ ticket.scannedAt|date('d/m/Y a H:i') }}</p>
|
||||
{% else %}
|
||||
<div class="text-6xl mb-4 text-green-600">✓</div>
|
||||
<h1 class="text-2xl font-black uppercase tracking-tighter mb-2">Billet valide</h1>
|
||||
{% endif %}
|
||||
|
||||
<div class="border-2 border-gray-900 p-4 bg-gray-50 text-left mt-6 space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="font-bold text-gray-500">Reference</span>
|
||||
<span class="font-mono font-black">{{ ticket.reference }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="font-bold text-gray-500">Billet</span>
|
||||
<span class="font-black">{{ ticket.billetName }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="font-bold text-gray-500">Evenement</span>
|
||||
<span class="font-black">{{ order.event.title }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="font-bold text-gray-500">Acheteur</span>
|
||||
<span class="font-black">{{ order.firstName }} {{ order.lastName }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="font-bold text-gray-500">Date</span>
|
||||
<span class="font-black">{{ order.event.startAt|date('d/m/Y H:i') }}</span>
|
||||
</div>
|
||||
{% if ticket.billet.definedExit %}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="font-bold text-gray-500">Sortie</span>
|
||||
<span class="font-black text-red-600">Definitive</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
196
templates/pdf/billet.html.twig
Normal file
196
templates/pdf/billet.html.twig
Normal file
@@ -0,0 +1,196 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
{% set ac = design ? design.accentColor : '#4f46e5' %}
|
||||
{% set inv_color = design ? design.invitationColor : '#d4a017' %}
|
||||
{% set inv_title = design ? design.invitationTitle : 'Invitation' %}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
@page { size: A4; margin: 0; }
|
||||
body {
|
||||
width: 595px;
|
||||
height: 842px;
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
background: #fff;
|
||||
color: #111;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ticket {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.zone-top {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.zone-hg {
|
||||
flex: 1;
|
||||
padding: 28px 20px 20px 32px;
|
||||
border-right: 3px solid {{ ac }};
|
||||
border-bottom: 3px solid {{ ac }};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.zone-hd {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 3px solid {{ ac }};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fafafa;
|
||||
overflow: hidden;
|
||||
}
|
||||
.zone-hd img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.zone-bottom {
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
background: {{ ac }};
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.event-title { font-size: 20px; font-weight: 900; text-transform: uppercase; letter-spacing: -0.5px; line-height: 1.15; margin-bottom: 3px; }
|
||||
.event-badge { font-size: 8px; font-weight: 700; color: {{ ac }}; text-transform: uppercase; letter-spacing: 2px; margin-bottom: 14px; }
|
||||
|
||||
.info-row { display: flex; align-items: baseline; padding: 4px 0; border-bottom: 1px solid #11111110; }
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-label { width: 65px; flex-shrink: 0; font-size: 7px; font-weight: 900; text-transform: uppercase; letter-spacing: 1.5px; opacity: 0.4; }
|
||||
.info-value { font-size: 11px; font-weight: 700; }
|
||||
|
||||
.separator { height: 3px; background: {{ ac }}; margin: 12px 0; }
|
||||
|
||||
.billet-name { font-size: 15px; font-weight: 900; text-transform: uppercase; margin-bottom: 2px; }
|
||||
.billet-price { font-size: 20px; font-weight: 900; color: {{ ac }}; margin-bottom: 10px; }
|
||||
|
||||
.meta-grid { display: flex; gap: 20px; flex-wrap: wrap; }
|
||||
.meta-label { font-size: 7px; font-weight: 900; text-transform: uppercase; letter-spacing: 1.5px; opacity: 0.4; }
|
||||
.meta-value { font-size: 10px; font-weight: 700; margin-bottom: 4px; }
|
||||
|
||||
.billet-badges { display: flex; gap: 6px; margin-top: 10px; }
|
||||
.exit-badge { padding: 4px 8px; font-size: 8px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px; border: 2px solid; }
|
||||
.exit-definitive { background: #fee2e2; color: #991b1b; border-color: #991b1b; }
|
||||
.exit-libre { background: #dcfce7; color: #166534; border-color: #166534; }
|
||||
.invitation-badge { padding: 4px 8px; font-size: 8px; font-weight: 900; text-transform: uppercase; letter-spacing: 2px; color: #fff; }
|
||||
|
||||
.qr-section { margin-top: auto; display: flex; align-items: flex-end; justify-content: space-between; padding-top: 10px; border-top: 1px solid #11111112; }
|
||||
.qr-box { width: 120px; height: 120px; display: flex; align-items: center; justify-content: center; background: #fff; }
|
||||
.qr-box img { width: 120px; height: 120px; }
|
||||
.ref-block { text-align: right; }
|
||||
.ref-label { font-size: 7px; font-weight: 900; text-transform: uppercase; letter-spacing: 1.5px; opacity: 0.35; }
|
||||
.ref-value { font-size: 9px; font-weight: 700; font-family: monospace; opacity: 0.55; }
|
||||
|
||||
.org-logo { width: 45px; height: 45px; object-fit: contain; flex-shrink: 0; }
|
||||
.org-name { font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1.5px; }
|
||||
.org-details { font-size: 8px; font-weight: 600; opacity: 0.7; margin-top: 2px; }
|
||||
.powered { font-size: 6px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; opacity: 0.4; margin-left: auto; text-align: right; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ticket">
|
||||
<div class="zone-top">
|
||||
<div class="zone-hg">
|
||||
<div class="event-title">{{ event.title }}</div>
|
||||
<div class="event-badge">Billet d'entree</div>
|
||||
|
||||
<div class="info-row">
|
||||
<div class="info-label">Date</div>
|
||||
<div class="info-value">{{ event.startAt|date('d/m/Y') }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">Horaires</div>
|
||||
<div class="info-value">{{ event.startAt|date('H:i') }} — {{ event.endAt|date('H:i') }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">Lieu</div>
|
||||
<div class="info-value">{{ event.address }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">Ville</div>
|
||||
<div class="info-value">{{ event.zipcode }} {{ event.city }}</div>
|
||||
</div>
|
||||
|
||||
<div class="separator"></div>
|
||||
|
||||
<div class="billet-name">{{ ticket.billetName }}</div>
|
||||
<div class="billet-price">{{ ticket.unitPriceHTDecimal|number_format(2, ',', ' ') }} € HT</div>
|
||||
|
||||
<div class="meta-grid">
|
||||
<div>
|
||||
<div class="meta-label">Categorie</div>
|
||||
<div class="meta-value">{{ ticket.billet.category.name }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="meta-label">Date d'achat</div>
|
||||
<div class="meta-value">{{ order.paidAt ? order.paidAt|date('d/m/Y H:i') : order.createdAt|date('d/m/Y H:i') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="meta-label">Acheteur</div>
|
||||
<div class="meta-value">{{ order.firstName }} {{ order.lastName }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="meta-label">E-mail</div>
|
||||
<div class="meta-value">{{ order.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="billet-badges">
|
||||
{% if ticket.billet.definedExit %}
|
||||
<div class="exit-badge exit-definitive">Sortie definitive</div>
|
||||
{% else %}
|
||||
<div class="exit-badge exit-libre">Sortie libre</div>
|
||||
{% endif %}
|
||||
{% if design %}
|
||||
<div class="invitation-badge" style="background: {{ inv_color }};">{{ inv_title }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="qr-section">
|
||||
<div class="qr-box">
|
||||
<img src="{{ qrBase64 }}" alt="QR Code">
|
||||
</div>
|
||||
<div class="ref-block">
|
||||
<div class="ref-label">Reference</div>
|
||||
<div class="ref-value">{{ ticket.reference }}</div>
|
||||
<div class="ref-label" style="margin-top: 6px;">Commande</div>
|
||||
<div class="ref-value">{{ order.reference }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zone-hd">
|
||||
{% if posterBase64 %}
|
||||
<img src="{{ posterBase64 }}" alt="{{ event.title }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zone-bottom">
|
||||
{% if logoBase64 %}
|
||||
<img src="{{ logoBase64 }}" alt="Logo" class="org-logo">
|
||||
{% endif %}
|
||||
<div>
|
||||
<div class="org-name">{{ organizer.companyName ?? (organizer.firstName ~ ' ' ~ organizer.lastName) }}</div>
|
||||
{% if organizer.address %}
|
||||
<div class="org-details">{{ organizer.address }}{% if organizer.postalCode %}, {{ organizer.postalCode }}{% endif %}{% if organizer.city %} {{ organizer.city }}{% endif %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="powered">E-Ticket<br>by E-Cosplay</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
93
tests/Entity/BilletOrderTest.php
Normal file
93
tests/Entity/BilletOrderTest.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\Billet;
|
||||
use App\Entity\BilletBuyer;
|
||||
use App\Entity\BilletOrder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BilletOrderTest extends TestCase
|
||||
{
|
||||
public function testDefaults(): void
|
||||
{
|
||||
$ticket = new BilletOrder();
|
||||
|
||||
self::assertNull($ticket->getId());
|
||||
self::assertNull($ticket->getBilletBuyer());
|
||||
self::assertNull($ticket->getBillet());
|
||||
self::assertNull($ticket->getBilletName());
|
||||
self::assertSame(0, $ticket->getUnitPriceHT());
|
||||
self::assertSame(0.0, $ticket->getUnitPriceHTDecimal());
|
||||
self::assertFalse($ticket->isScanned());
|
||||
self::assertNull($ticket->getScannedAt());
|
||||
self::assertMatchesRegularExpression('/^ETICKET-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/', $ticket->getReference());
|
||||
self::assertInstanceOf(\DateTimeImmutable::class, $ticket->getCreatedAt());
|
||||
}
|
||||
|
||||
public function testSetAndGetBilletBuyer(): void
|
||||
{
|
||||
$ticket = new BilletOrder();
|
||||
$buyer = new BilletBuyer();
|
||||
$result = $ticket->setBilletBuyer($buyer);
|
||||
|
||||
self::assertSame($buyer, $ticket->getBilletBuyer());
|
||||
self::assertSame($ticket, $result);
|
||||
}
|
||||
|
||||
public function testSetAndGetBillet(): void
|
||||
{
|
||||
$ticket = new BilletOrder();
|
||||
$billet = new Billet();
|
||||
$result = $ticket->setBillet($billet);
|
||||
|
||||
self::assertSame($billet, $ticket->getBillet());
|
||||
self::assertSame($ticket, $result);
|
||||
}
|
||||
|
||||
public function testSetAndGetBilletName(): void
|
||||
{
|
||||
$ticket = new BilletOrder();
|
||||
$result = $ticket->setBilletName('Entree VIP');
|
||||
|
||||
self::assertSame('Entree VIP', $ticket->getBilletName());
|
||||
self::assertSame($ticket, $result);
|
||||
}
|
||||
|
||||
public function testSetAndGetUnitPriceHT(): void
|
||||
{
|
||||
$ticket = new BilletOrder();
|
||||
$result = $ticket->setUnitPriceHT(1500);
|
||||
|
||||
self::assertSame(1500, $ticket->getUnitPriceHT());
|
||||
self::assertSame(15.0, $ticket->getUnitPriceHTDecimal());
|
||||
self::assertSame($ticket, $result);
|
||||
}
|
||||
|
||||
public function testSetAndGetIsScanned(): void
|
||||
{
|
||||
$ticket = new BilletOrder();
|
||||
$result = $ticket->setIsScanned(true);
|
||||
|
||||
self::assertTrue($ticket->isScanned());
|
||||
self::assertSame($ticket, $result);
|
||||
}
|
||||
|
||||
public function testSetAndGetScannedAt(): void
|
||||
{
|
||||
$ticket = new BilletOrder();
|
||||
$date = new \DateTimeImmutable();
|
||||
$result = $ticket->setScannedAt($date);
|
||||
|
||||
self::assertSame($date, $ticket->getScannedAt());
|
||||
self::assertSame($ticket, $result);
|
||||
}
|
||||
|
||||
public function testUniqueReferences(): void
|
||||
{
|
||||
$t1 = new BilletOrder();
|
||||
$t2 = new BilletOrder();
|
||||
|
||||
self::assertNotSame($t1->getReference(), $t2->getReference());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user