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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
145
src/Service/ExportService.php
Normal file
145
src/Service/ExportService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
107
templates/pdf/export_recap.html.twig
Normal file
107
templates/pdf/export_recap.html.twig
Normal 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, ',', ' ') }} €</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="label">Commission E-Ticket</div>
|
||||
<div class="value" style="color: #dc2626;">{{ stats.commissionEticket|number_format(2, ',', ' ') }} €</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="label">Commission Stripe</div>
|
||||
<div class="value" style="color: #dc2626;">{{ stats.commissionStripe|number_format(2, ',', ' ') }} €</div>
|
||||
</td>
|
||||
{% if not isAdmin %}
|
||||
<td>
|
||||
<div class="label">Net percu</div>
|
||||
<div class="value" style="color: #16a34a;">{{ stats.netOrga|number_format(2, ',', ' ') }} €</div>
|
||||
</td>
|
||||
{% else %}
|
||||
<td>
|
||||
<div class="label">Net E-Ticket</div>
|
||||
<div class="value" style="color: #16a34a;">{{ stats.commissionEticket|number_format(2, ',', ' ') }} €</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, ',', ' ') }} €</td>
|
||||
<td style="text-align: right; color: #dc2626;">{{ eticketFee|number_format(2, ',', ' ') }} €</td>
|
||||
<td style="text-align: right; color: #dc2626;">{{ stripeFee|number_format(2, ',', ' ') }} €</td>
|
||||
{% if not isAdmin %}<td style="text-align: right; color: #16a34a; font-weight: bold;">{{ net|number_format(2, ',', ' ') }} €</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>
|
||||
Reference in New Issue
Block a user