Files
crm_ecosplay/src/Service/Pdf/DevisPdf.php
Serreau Jovann 8b35e2b6d2 feat: comptabilite + prestataires + rapport financier + stats dynamiques
Comptabilite (Super Admin) :
- ComptabiliteController avec 7 exports CSV/JSON compatibles SAGE
  (journal ventes, grand livre, FEC, balance agee, reglements,
  commissions Stripe 1.5%+0.25E, couts services)
- Export PDF via ComptaPdf (FPDF) avec bloc legal pre-rempli,
  tableau pagine, champ signature DocuSeal
- Signature electronique DocuSeal + callback + envoi email signe
  avec template dedie (compta_export_signed.html.twig)
- Rapport financier public (RapportFinancierPdf) : recettes par
  service, depenses (Stripe, infra, prestataires), bilan excedent/deficit
- Codes comptables clients EC-XXXX (plus de 411xxx)

Prestataires (Super Admin) :
- Entite Prestataire (raisonSociale, siret, email, phone, adresse)
- Entite FacturePrestataire (numFacture, montantHt, montantTtc,
  year, month, isPaid, PDF via Vich)
- CRUD complet avec recherche SIRET via proxy API data.gouv.fr
- Commande cron app:reminder:factures-prestataire (5 du mois)
- Factures prestataires integrees dans export couts services
- Sidebar Super Admin : entree Prestataires + Comptabilite

Stats (/admin/stats) :
- Cout prestataire dynamique depuis FacturePrestataire
- Fusion Infra + Prestataire en "Cout de fonctionnement"
- Commission Stripe corrigee (1.5% + 0.25E par transaction)

Divers :
- DocuSealService::sendComptaForSignature() + getApi()
- Customer::generateCodeComptable() format EC-XXXX-XXXXX
- Protection double prefixe EC- a la creation client
- Bouton regenerer PDF cache quand advert state=accepted
- Modals sans script inline (data-modal-open/close dans app.js)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:39:31 +02:00

272 lines
8.7 KiB
PHP

<?php
namespace App\Service\Pdf;
use App\Entity\Devis;
use setasign\Fpdi\Fpdi;
use Symfony\Component\HttpKernel\KernelInterface;
if (!\defined('EURO')) {
\define('EURO', \chr(128));
}
/**
* Generation PDF d'un devis avec FPDF + fusion CGV via FPDI.
* Extend Fpdi (qui etend FPDF) pour pouvoir importer les pages des CGV.
*/
class DevisPdf extends Fpdi
{
/** @var array<int, array{title: string, content: string, priceHt: float}> */
private array $items = [];
private int $lastDevisPage = 0;
private ?\Twig\Environment $twig = null;
public function __construct(
private readonly KernelInterface $kernel,
private readonly Devis $devis,
?\Twig\Environment $twig = null,
) {
$this->twig = $twig;
parent::__construct();
$items = [];
foreach ($this->devis->getLines() as $line) {
$items[$line->getPos()] = [
'title' => $line->getTitle(),
'content' => $line->getDescription() ?? '',
'priceHt' => (float) $line->getPriceHt(),
];
}
ksort($items);
$this->items = $items;
$this->SetTitle($this->enc('Devis N° '.$this->devis->getOrderNumber()->getNumOrder()));
}
public function Header(): void
{
// Sur les pages CGV importees (au-dela de la derniere page devis), pas de header
if ($this->lastDevisPage > 0 && $this->PageNo() > $this->lastDevisPage) {
return;
}
$this->SetFont('Arial', '', 10);
$logo = $this->kernel->getProjectDir().'/public/logo.jpg';
if (file_exists($logo)) {
$this->Image($logo, 10, 8, 53);
}
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::FULL,
\IntlDateFormatter::NONE,
'Europe/Paris',
\IntlDateFormatter::GREGORIAN
);
$numDevisText = $this->enc('DEVIS N° '.$this->devis->getOrderNumber()->getNumOrder());
$dateText = $this->enc('Emis a Beautor, le '.$formatter->format($this->devis->getCreatedAt()));
$this->Text(15, 80, $numDevisText);
$this->Text(15, 85, $dateText);
$this->SetFont('Arial', 'B', 12);
$y = 60;
$customer = $this->devis->getCustomer();
if (null !== $customer) {
$name = $customer->getRaisonSociale() ?: $customer->getFullName();
$this->Text(110, $y, $this->enc($name));
if ($address = $customer->getAddress()) {
$y += 5;
$this->Text(110, $y, $this->enc($address));
}
if ($address2 = $customer->getAddress2()) {
$y += 5;
$this->Text(110, $y, $this->enc($address2));
}
$y += 5;
$cityLine = ($customer->getZipCode() ?? '').' '.($customer->getCity() ?? '');
$this->Text(110, $y, $this->enc(trim($cityLine)));
}
$this->body();
}
private function body(): void
{
$this->SetFont('Arial', 'B', 10);
$this->SetXY(145, 100);
$this->Cell(40, 5, $this->enc('PRIX HT'), 0, 0, 'C');
$this->Line(145, 110, 145, 220);
$this->Line(185, 110, 185, 220);
$this->Line(0, 100, 5, 100);
$this->Line(0, 200, 5, 200);
}
public function generate(): void
{
$this->AliasNbPages();
$this->AddPage();
$this->SetFont('Arial', '', 12);
$startY = 110;
$this->SetY($startY);
$contentBottomLimit = 220;
foreach ($this->items as $item) {
if ($this->GetY() + 30 > $contentBottomLimit) {
$this->AddPage();
$this->body();
$this->SetY($startY);
}
$currentY = $this->GetY();
// Titre
$this->SetX(20);
$this->SetFont('Arial', 'B', 11);
$this->Cell(95, 10, $this->enc($item['title']), 0, 0);
// Prix HT
$this->SetFont('Arial', 'B', 11);
$this->SetXY(142, $currentY);
$this->Cell(39, 8, number_format($item['priceHt'], 2, ',', ' ').' '.EURO, 0, 1, 'R');
// Description
$this->SetFont('Arial', '', 11);
$this->SetX(30);
if ('' !== $item['content']) {
$this->MultiCell(90, 5, $this->enc($item['content']), 0, 'L');
}
$this->Ln(5);
}
$this->displaySummary();
$this->displaySign();
$this->appendCgv();
}
/**
* Genere les CGV depuis le template Twig via Dompdf puis les importe via FPDI.
*/
private function displaySign(): void
{
$this->SetAutoPageBreak(false);
$this->SetXY(15, $this->GetPageHeight() - 45);
$this->SetFont('Arial', '', 10);
$this->SetTextColor(0, 0, 0);
$this->Cell(30, 10, '{{Sign;type=signature;role=First Party}}', 0, 0, 'L');
}
private function appendCgv(): void
{
if (null === $this->twig) {
return;
}
try {
$html = $this->twig->render('pdf/cgv.html.twig');
$dompdf = new \Dompdf\Dompdf();
$dompdf->loadHtml($html);
$dompdf->setPaper('A4');
$dompdf->render();
$tmpCgv = tempnam(sys_get_temp_dir(), 'cgv_').'.pdf';
file_put_contents($tmpCgv, $dompdf->output());
$this->lastDevisPage = $this->PageNo();
$pageCount = $this->setSourceFile($tmpCgv);
for ($i = 1; $i <= $pageCount; ++$i) {
$tpl = $this->importPage($i);
$size = $this->getTemplateSize($tpl);
$this->AddPage($size['orientation'] ?? 'P', [$size['width'], $size['height']]);
$this->useTemplate($tpl);
// Sur la DERNIERE page CGV : champ signature DocuSeal
if ($i === $pageCount) {
$this->SetAutoPageBreak(false);
$this->SetXY(15, $this->GetPageHeight() - 35);
$this->SetFont('Arial', '', 10);
$this->SetTextColor(0, 0, 0);
$this->Cell(60, 20, '{{SignCGV;type=signature;role=First Party}}', 0, 0, 'L');
}
}
@unlink($tmpCgv);
} catch (\Throwable) {
}
}
private function displaySummary(): void
{
$totalHt = (float) $this->devis->getTotalHt();
$totalTva = (float) $this->devis->getTotalTva();
$totalTtc = (float) $this->devis->getTotalTtc();
$tvaEnabled = $totalTva > 0.01;
$this->SetY($tvaEnabled ? -60 : -50);
$this->SetFont('Arial', 'B', 12);
$this->SetX(105);
$this->Cell(40, 10, $this->enc('Total HT :'), 0, 0, 'R');
$this->Cell(40, 10, number_format($totalHt, 2, ',', ' ').' '.EURO, 0, 1, 'R');
if ($tvaEnabled) {
$this->SetFont('Arial', '', 12);
$this->SetX(105);
$this->Cell(40, 10, $this->enc('TVA (20%) :'), 0, 0, 'R');
$this->Cell(40, 10, number_format($totalTva, 2, ',', ' ').' '.EURO, 0, 1, 'R');
$this->SetFont('Arial', 'B', 12);
$this->SetX(105);
$this->Cell(40, 10, $this->enc('Total TTC :'), 0, 0, 'R');
$this->Cell(40, 10, number_format($totalTtc, 2, ',', ' ').' '.EURO, 0, 1, 'R');
} else {
$this->SetFont('Arial', '', 8);
$this->SetX(105);
$this->Cell(80, 5, $this->enc('TVA non applicable - art. 293 B du CGI'), 0, 1, 'R');
}
}
public function Footer(): void
{
// Sur les pages CGV importees (au-dela de la derniere page devis), pas de footer
if ($this->lastDevisPage > 0 && $this->PageNo() > $this->lastDevisPage) {
return;
}
$this->SetY(-28);
$this->SetDrawColor(253, 140, 4);
$this->Line(15, $this->GetY(), 195, $this->GetY());
$this->Ln(4);
$this->SetFont('Arial', '', 8);
$this->SetTextColor(0, 0, 0);
$this->Cell(190, 4, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tél: 06 79 34 88 02'), 0, 1, 'C');
$this->Cell(190, 4, $this->enc('e-mail : contact@e-cosplay.fr - www.e-cosplay.fr'), 0, 1, 'C');
$this->Cell(190, 4, $this->enc('Association E-Cosplay - N°SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C');
// Numero de page avec alias {nb}
$this->SetFont('Arial', 'I', 7);
$this->SetTextColor(150, 150, 150);
$this->Cell(190, 4, $this->enc('Page ').$this->PageNo().' / {nb}', 0, 0, 'C');
}
/**
* Encode une chaine UTF-8 en Windows-1252 (requis par FPDF).
*/
private function enc(string $text): string
{
return mb_convert_encoding($text, 'Windows-1252', 'UTF-8');
}
}