Add monthly export CSV + PDF for admin and organizers

- ExportService: monthly stats query, CSV generation, PDF generation via dompdf
- Admin: /admin/export/{year}/{month} (CSV) + /admin/export/{year}/{month}/pdf
- Orga: /mon-compte/export/{year}/{month} (CSV) + /mon-compte/export/{year}/{month}/pdf
- Admin CSV: commande, date, événement, orga, acheteur, billets, total HT, com E-Ticket, com Stripe
- Orga CSV: + net perçu column
- PDF A4 landscape: KPIs + orders table with commissions breakdown
- Buttons in admin dashboard and orga payouts tab (current + previous month)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-22 22:16:55 +01:00
parent 47916f5f30
commit 608b746989
7 changed files with 336 additions and 1 deletions

View File

@@ -21,7 +21,7 @@
### Paiements & Finances
- [ ] Ajouter le dashboard financier pour l'orga (total encaissé, en attente, remboursé)
- [ ] Ajouter les virements Stripe (payouts) dans l'onglet de l'orga (il exite déja c'est "Virement")
- [ ] Générer un récapitulatif mensuel des ventes (export CSV/PDF)
- [x] Générer un récapitulatif mensuel des ventes (export CSV + PDF, admin et orga)
### Admin
- [x] Dashboard admin : stats globales (CA global, commission E-Ticket, commission Stripe, nb commandes, nb billets, nb orgas, revenus net)

View File

@@ -14,6 +14,7 @@ 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\OrderIndexService;
use App\Service\PayoutPdfService;
@@ -1130,6 +1131,42 @@ class AccountController extends AbstractController
*
* @return array{totalHT: int, totalSold: int, billetStats: array<int, array{name: string, sold: int, revenue: int}>}
*/
#[Route('/mon-compte/export/{year}/{month}', name: 'app_account_export', requirements: ['year' => '\d{4}', 'month' => '\d{1,2}'], methods: ['GET'])]
public function export(int $year, int $month, ExportService $exportService): Response
{
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
/** @var User $user */
$user = $this->getUser();
$stats = $exportService->getMonthlyStats($year, $month, $user);
$csv = $exportService->generateCsv($stats['orders']);
$filename = sprintf('export_%04d_%02d.csv', $year, $month);
return new Response($csv, 200, [
'Content-Type' => 'text/csv; charset=utf-8',
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
]);
}
#[Route('/mon-compte/export/{year}/{month}/pdf', name: 'app_account_export_pdf', requirements: ['year' => '\d{4}', 'month' => '\d{1,2}'], methods: ['GET'])]
public function exportPdf(int $year, int $month, ExportService $exportService): Response
{
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
/** @var User $user */
$user = $this->getUser();
$stats = $exportService->getMonthlyStats($year, $month, $user);
$pdf = $exportService->generatePdf($stats, $year, $month, $user);
$filename = sprintf('recap_%04d_%02d.pdf', $year, $month);
return new Response($pdf, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
private function computeEventStats(array $paidOrders): array
{
$totalHT = 0;

View File

@@ -7,6 +7,7 @@ use App\Entity\BilletBuyer;
use App\Entity\OrganizerInvitation;
use App\Entity\User;
use App\Service\AuditService;
use App\Service\ExportService;
use App\Service\MailerService;
use App\Service\MeilisearchService;
use App\Service\SiretService;
@@ -553,6 +554,32 @@ class AdminController extends AbstractController
]);
}
#[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
{

View File

@@ -0,0 +1,145 @@
<?php
namespace App\Service;
use App\Entity\BilletBuyer;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Dompdf\Dompdf;
use Twig\Environment;
class ExportService
{
public function __construct(
private EntityManagerInterface $em,
private Environment $twig,
) {
}
/**
* @return array{orders: list<BilletBuyer>, totalHT: float, commissionEticket: float, commissionStripe: float, netOrga: float}
*/
public function getMonthlyStats(int $year, int $month, ?User $organizer = null): array
{
$start = new \DateTimeImmutable("$year-$month-01 00:00:00");
$end = $start->modify('last day of this month')->setTime(23, 59, 59);
$qb = $this->em->createQueryBuilder()
->select('o', 'i')
->from(BilletBuyer::class, 'o')
->leftJoin('o.items', 'i')
->where('o.status = :paid')
->andWhere('o.paidAt >= :start')
->andWhere('o.paidAt <= :end')
->setParameter('paid', BilletBuyer::STATUS_PAID)
->setParameter('start', $start)
->setParameter('end', $end)
->orderBy('o.paidAt', 'ASC');
if ($organizer) {
$qb->join('o.event', 'e')
->andWhere('e.account = :orga')
->setParameter('orga', $organizer);
}
$orders = $qb->getQuery()->getResult();
$totalHT = 0;
$commissionEticket = 0;
$commissionStripe = 0;
foreach ($orders as $order) {
$ht = $order->getTotalHT() / 100;
$rate = $order->getEvent()->getAccount()->getCommissionRate() ?? 3;
$totalHT += $ht;
$commissionEticket += $ht * ($rate / 100);
$commissionStripe += $ht * 0.015 + 0.25;
}
return [
'orders' => $orders,
'totalHT' => $totalHT,
'commissionEticket' => $commissionEticket,
'commissionStripe' => $commissionStripe,
'netOrga' => $totalHT - $commissionEticket - $commissionStripe,
];
}
/**
* @param list<BilletBuyer> $orders
*/
public function generateCsv(array $orders, bool $isAdmin = false): string
{
$lines = [];
if ($isAdmin) {
$lines[] = implode(';', ['Commande', 'Date', 'Evenement', 'Organisateur', 'Acheteur', 'Email', 'Billets', 'Total HT', 'Com E-Ticket', 'Com Stripe']);
} else {
$lines[] = implode(';', ['Commande', 'Date', 'Evenement', 'Acheteur', 'Email', 'Billets', 'Total HT', 'Com E-Ticket', 'Com Stripe', 'Net percu']);
}
foreach ($orders as $order) {
$ht = $order->getTotalHT() / 100;
$rate = $order->getEvent()->getAccount()->getCommissionRate() ?? 3;
$eticketFee = $ht * ($rate / 100);
$stripeFee = $ht * 0.015 + 0.25;
$net = $ht - $eticketFee - $stripeFee;
$items = [];
foreach ($order->getItems() as $item) {
$items[] = $item->getBilletName().' x'.$item->getQuantity();
}
if ($isAdmin) {
$lines[] = implode(';', [
$order->getOrderNumber(),
$order->getPaidAt()?->format('d/m/Y H:i'),
$order->getEvent()->getTitle(),
$order->getEvent()->getAccount()->getCompanyName() ?? $order->getEvent()->getAccount()->getFirstName(),
$order->getFirstName().' '.$order->getLastName(),
$order->getEmail(),
implode(', ', $items),
number_format($ht, 2, ',', ''),
number_format($eticketFee, 2, ',', ''),
number_format($stripeFee, 2, ',', ''),
]);
} else {
$lines[] = implode(';', [
$order->getOrderNumber(),
$order->getPaidAt()?->format('d/m/Y H:i'),
$order->getEvent()->getTitle(),
$order->getFirstName().' '.$order->getLastName(),
$order->getEmail(),
implode(', ', $items),
number_format($ht, 2, ',', ''),
number_format($eticketFee, 2, ',', ''),
number_format($stripeFee, 2, ',', ''),
number_format(max(0, $net), 2, ',', ''),
]);
}
}
return implode("\n", $lines);
}
/**
* @param array{orders: list<BilletBuyer>, totalHT: float, commissionEticket: float, commissionStripe: float, netOrga: float} $stats
*/
public function generatePdf(array $stats, int $year, int $month, ?User $organizer = null): string
{
$html = $this->twig->render('pdf/export_recap.html.twig', [
'stats' => $stats,
'year' => $year,
'month' => $month,
'organizer' => $organizer,
'isAdmin' => null === $organizer,
]);
$dompdf = new Dompdf();
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'landscape');
$dompdf->render();
return $dompdf->output();
}
}

View File

@@ -415,6 +415,18 @@
</div>
{% elseif tab == 'payouts' %}
<div class="card-brutal overflow-hidden mb-6">
<div class="section-header">
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Export mensuel</h2>
</div>
<div class="p-6 flex flex-wrap gap-3">
<a href="{{ path('app_account_export', {year: "now"|date('Y'), month: "now"|date('n')}) }}" class="px-3 py-2 border-2 border-gray-900 bg-[#fabf04] text-xs font-black uppercase hover:bg-yellow-500 transition-all">CSV {{ "now"|date('m/Y') }}</a>
<a href="{{ path('app_account_export', {year: "now"|date_modify('-1 month')|date('Y'), month: "now"|date_modify('-1 month')|date('n')}) }}" class="px-3 py-2 border-2 border-gray-900 bg-white text-xs font-black uppercase hover:bg-gray-100 transition-all">CSV {{ "now"|date_modify('-1 month')|date('m/Y') }}</a>
<a href="{{ path('app_account_export_pdf', {year: "now"|date('Y'), month: "now"|date('n')}) }}" class="px-3 py-2 border-2 border-gray-900 bg-indigo-600 text-white text-xs font-black uppercase hover:bg-indigo-800 transition-all">PDF {{ "now"|date('m/Y') }}</a>
<a href="{{ path('app_account_export_pdf', {year: "now"|date_modify('-1 month')|date('Y'), month: "now"|date_modify('-1 month')|date('n')}) }}" class="px-3 py-2 border-2 border-gray-900 bg-white text-xs font-black uppercase hover:bg-gray-100 transition-all">PDF {{ "now"|date_modify('-1 month')|date('m/Y') }}</a>
</div>
</div>
<div class="card-brutal">
<div class="section-header">
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Mes virements</h2>

View File

@@ -62,6 +62,13 @@
</div>
</div>
<div class="admin-card">
<p class="admin-stat-label font-black uppercase text-gray-400 mb-2">Export mensuel</p>
<div class="flex flex-wrap gap-2 mb-4">
<a href="{{ path('app_admin_export', {year: "now"|date('Y'), month: "now"|date('n')}) }}" class="admin-btn-sm-yellow text-xs font-black uppercase tracking-widest hover:bg-indigo-600 hover:text-black transition-all py-2 px-3">CSV {{ "now"|date('m/Y') }}</a>
<a href="{{ path('app_admin_export', {year: "now"|date_modify('-1 month')|date('Y'), month: "now"|date_modify('-1 month')|date('n')}) }}" class="admin-btn-sm-white text-xs font-black uppercase tracking-widest hover:bg-indigo-600 hover:text-black transition-all py-2 px-3">CSV {{ "now"|date_modify('-1 month')|date('m/Y') }}</a>
<a href="{{ path('app_admin_export_pdf', {year: "now"|date('Y'), month: "now"|date('n')}) }}" class="admin-btn-sm-white text-xs font-black uppercase tracking-widest hover:bg-indigo-600 hover:text-black transition-all py-2 px-3">PDF {{ "now"|date('m/Y') }}</a>
<a href="{{ path('app_admin_export_pdf', {year: "now"|date_modify('-1 month')|date('Y'), month: "now"|date_modify('-1 month')|date('n')}) }}" class="admin-btn-sm-white text-xs font-black uppercase tracking-widest hover:bg-indigo-600 hover:text-black transition-all py-2 px-3">PDF {{ "now"|date_modify('-1 month')|date('m/Y') }}</a>
</div>
<p class="admin-stat-label font-black uppercase text-gray-400 mb-4">Actions rapides</p>
<div class="space-y-3">
<a href="{{ path('app_admin_orders') }}" class="block admin-btn-sm-white text-xs font-black uppercase tracking-widest hover:bg-indigo-600 hover:text-black transition-all text-center py-2">Voir toutes les commandes</a>

View File

@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Recapitulatif {{ '%02d'|format(month) }}/{{ year }}</title>
<style>
@page { size: A4 landscape; margin: 20px; }
body { font-family: Helvetica, Arial, sans-serif; font-size: 10px; color: #111; margin: 0; }
.header { background: #111827; color: #fff; padding: 16px 24px; margin-bottom: 16px; }
.header h1 { font-size: 18px; margin: 0 0 4px; }
.header p { font-size: 10px; margin: 0; opacity: 0.7; }
.stats { margin-bottom: 16px; }
.stats td { padding: 8px 16px; border: 2px solid #111827; text-align: center; font-weight: bold; }
.stats .label { font-size: 8px; text-transform: uppercase; letter-spacing: 1px; color: #666; }
.stats .value { font-size: 16px; }
table.orders { width: 100%; border-collapse: collapse; }
table.orders th { background: #111827; color: #fff; padding: 6px 8px; font-size: 8px; text-transform: uppercase; letter-spacing: 1px; text-align: left; }
table.orders td { padding: 5px 8px; border-bottom: 1px solid #eee; font-size: 9px; }
table.orders tr:nth-child(even) { background: #f9fafb; }
.footer { margin-top: 16px; font-size: 8px; color: #999; text-align: center; }
</style>
</head>
<body>
<div class="header">
<h1>Recapitulatif mensuel — {{ '%02d'|format(month) }}/{{ year }}</h1>
<p>{% if organizer %}{{ organizer.companyName ?? (organizer.firstName ~ ' ' ~ organizer.lastName) }}{% endif %}E-Ticket by E-Cosplay</p>
</div>
<table class="stats">
<tr>
<td>
<div class="label">Commandes</div>
<div class="value">{{ stats.orders|length }}</div>
</td>
<td>
<div class="label">Total HT</div>
<div class="value" style="color: #4f46e5;">{{ stats.totalHT|number_format(2, ',', ' ') }} &euro;</div>
</td>
<td>
<div class="label">Commission E-Ticket</div>
<div class="value" style="color: #dc2626;">{{ stats.commissionEticket|number_format(2, ',', ' ') }} &euro;</div>
</td>
<td>
<div class="label">Commission Stripe</div>
<div class="value" style="color: #dc2626;">{{ stats.commissionStripe|number_format(2, ',', ' ') }} &euro;</div>
</td>
{% if not isAdmin %}
<td>
<div class="label">Net percu</div>
<div class="value" style="color: #16a34a;">{{ stats.netOrga|number_format(2, ',', ' ') }} &euro;</div>
</td>
{% else %}
<td>
<div class="label">Net E-Ticket</div>
<div class="value" style="color: #16a34a;">{{ stats.commissionEticket|number_format(2, ',', ' ') }} &euro;</div>
</td>
{% endif %}
</tr>
</table>
<table class="orders">
<thead>
<tr>
<th>Commande</th>
<th>Date</th>
{% if isAdmin %}<th>Organisateur</th>{% endif %}
<th>Evenement</th>
<th>Acheteur</th>
<th>Email</th>
<th>Billets</th>
<th style="text-align: right;">Total HT</th>
<th style="text-align: right;">Com E-Ticket</th>
<th style="text-align: right;">Com Stripe</th>
{% if not isAdmin %}<th style="text-align: right;">Net percu</th>{% endif %}
</tr>
</thead>
<tbody>
{% for order in stats.orders %}
{% set ht = order.totalHTDecimal %}
{% set rate = order.event.account.commissionRate ?? 3 %}
{% set eticketFee = ht * rate / 100 %}
{% set stripeFee = ht * 0.015 + 0.25 %}
{% set net = ht - eticketFee - stripeFee %}
<tr>
<td style="font-weight: bold;">{{ order.orderNumber }}</td>
<td>{{ order.paidAt ? order.paidAt|date('d/m/Y') : '—' }}</td>
{% if isAdmin %}<td>{{ order.event.account.companyName ?? order.event.account.firstName }}</td>{% endif %}
<td>{{ order.event.title }}</td>
<td>{{ order.firstName }} {{ order.lastName }}</td>
<td>{{ order.email }}</td>
<td>{% for item in order.items %}{{ item.billetName }} x{{ item.quantity }}{% if not loop.last %}, {% endif %}{% endfor %}</td>
<td style="text-align: right; font-weight: bold;">{{ ht|number_format(2, ',', ' ') }} &euro;</td>
<td style="text-align: right; color: #dc2626;">{{ eticketFee|number_format(2, ',', ' ') }} &euro;</td>
<td style="text-align: right; color: #dc2626;">{{ stripeFee|number_format(2, ',', ' ') }} &euro;</td>
{% if not isAdmin %}<td style="text-align: right; color: #16a34a; font-weight: bold;">{{ net|number_format(2, ',', ' ') }} &euro;</td>{% endif %}
</tr>
{% else %}
<tr><td colspan="{{ isAdmin ? 10 : 10 }}" style="text-align: center; padding: 20px; color: #999;">Aucune commande ce mois-ci.</td></tr>
{% endfor %}
</tbody>
</table>
<div class="footer">
Genere le {{ "now"|date('d/m/Y H:i') }} — E-Ticket by E-Cosplay — contact@e-cosplay.fr
</div>
</body>
</html>