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:
Serreau Jovann
2026-03-21 14:04:45 +01:00
parent f0969972a2
commit 52cb19df8b
13 changed files with 874 additions and 1 deletions

View 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');
}
}

View File

@@ -18,3 +18,4 @@ parameters:
- src/Entity/BilletDesign.php - src/Entity/BilletDesign.php
- src/Entity/BilletBuyer.php - src/Entity/BilletBuyer.php
- src/Entity/BilletBuyerItem.php - src/Entity/BilletBuyerItem.php
- src/Entity/BilletOrder.php

View File

@@ -37,6 +37,7 @@
<file>src/Controller/StripeWebhookController.php</file> <file>src/Controller/StripeWebhookController.php</file>
<file>src/Service/StripeService.php</file> <file>src/Service/StripeService.php</file>
<file>src/Service/PayoutPdfService.php</file> <file>src/Service/PayoutPdfService.php</file>
<file>src/Service/BilletOrderService.php</file>
</exclude> </exclude>
<deprecationTrigger> <deprecationTrigger>

View File

@@ -1,7 +1,7 @@
sonar.projectKey=e-ticket sonar.projectKey=e-ticket
sonar.projectName=E-Ticket sonar.projectName=E-Ticket
sonar.sources=src,assets,templates,docker 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.php.version=8.4
sonar.sourceEncoding=UTF-8 sonar.sourceEncoding=UTF-8
sonar.php.coverage.reportPaths=coverage.xml sonar.php.coverage.reportPaths=coverage.xml

View File

@@ -5,8 +5,10 @@ namespace App\Controller;
use App\Entity\Billet; use App\Entity\Billet;
use App\Entity\BilletBuyer; use App\Entity\BilletBuyer;
use App\Entity\BilletBuyerItem; use App\Entity\BilletBuyerItem;
use App\Entity\BilletOrder;
use App\Entity\Event; use App\Entity\Event;
use App\Entity\User; use App\Entity\User;
use App\Service\BilletOrderService;
use App\Service\StripeService; use App\Service\StripeService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; 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
View 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;
}
}

View 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);
}
}

View 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,
);
}
}

View 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, ',', ' ') }} &euro;</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, ',', ' ') }} &euro;</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 %}

View 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, ',', ' ') }} &euro;</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 %}

View 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">&#10007;</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">&#10003;</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 %}

View 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, ',', ' ') }} &euro; 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>

View 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());
}
}