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>
272 lines
8.7 KiB
PHP
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');
|
|
}
|
|
}
|