Files
crm_ecosplay/src/Controller/Admin/EcheancierController.php
Serreau Jovann 3c5d9c0f94 fix: auto-generation PDF a la creation echeancier + bouton regenerer
- create() genere le PDF automatiquement apres creation
- Extraction generateEcheancierPdf() methode privee reutilisable
- Bouton "Regenerer PDF" (jaune) si PDF existe, "Generer PDF" sinon
- Bouton visible dans tous les etats sauf cancelled/completed
- Redirect vers show apres creation (au lieu de l'onglet client)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:04:24 +02:00

450 lines
17 KiB
PHP

<?php
namespace App\Controller\Admin;
use App\Entity\Customer;
use App\Entity\Echeancier;
use App\Entity\EcheancierLine;
use App\Service\DocuSealService;
use App\Service\MailerService;
use App\Service\Pdf\EcheancierPdf;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Twig\Environment;
#[Route('/admin/echeancier', name: 'app_admin_echeancier_')]
#[IsGranted('ROLE_EMPLOYE')]
class EcheancierController extends AbstractController
{
private const MSG_NOT_FOUND = 'Echeancier introuvable';
public function __construct(
private EntityManagerInterface $em,
) {
}
#[Route('/create/{customerId}', name: 'create', requirements: ['customerId' => '\d+'], methods: ['POST'])]
public function create(int $customerId, Request $request, KernelInterface $kernel): Response
{
$customer = $this->em->getRepository(Customer::class)->find($customerId);
if (null === $customer) {
throw $this->createNotFoundException('Client introuvable');
}
$description = trim($request->request->getString('description'));
$totalHt = $request->request->getString('totalHt');
$nbEcheances = $request->request->getInt('nbEcheances');
$startDate = $request->request->getString('startDate');
if ('' === $description || $nbEcheances < 2 || $nbEcheances > 36 || '' === $startDate) {
$this->addFlash('error', 'Donnees invalides. Minimum 2 echeances, maximum 36.');
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customerId, 'tab' => 'echeancier']);
}
$totalHtFloat = (float) str_replace(',', '.', $totalHt);
$monthlyAmount = round($totalHtFloat / $nbEcheances, 2);
$echeancier = new Echeancier($customer, $description, number_format($totalHtFloat, 2, '.', ''));
/** @var \App\Entity\User|null $currentUser */
$currentUser = $this->getUser();
$echeancier->setSubmitterCompanyId($currentUser?->getId());
$echeancier->setSubmitterCustomerId($customer->getId());
$start = new \DateTimeImmutable($startDate);
for ($i = 1; $i <= $nbEcheances; ++$i) {
$scheduledAt = $start->modify('+'.($i - 1).' months');
$amount = $i === $nbEcheances
? number_format($totalHtFloat - ($monthlyAmount * ($nbEcheances - 1)), 2, '.', '')
: number_format($monthlyAmount, 2, '.', '');
$line = new EcheancierLine($echeancier, $i, $amount, $scheduledAt);
$echeancier->addLine($line);
$this->em->persist($line);
}
$this->em->persist($echeancier);
$this->em->flush();
// Generer le PDF automatiquement
$this->generateEcheancierPdf($echeancier, $kernel);
$this->addFlash('success', 'Echeancier cree avec '.$nbEcheances.' echeances de '.$monthlyAmount.' EUR/mois. PDF genere.');
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $echeancier->getId()]);
}
#[Route('/{id}', name: 'show', requirements: ['id' => '\d+'])]
public function show(int $id): Response
{
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
return $this->render('admin/echeancier/show.html.twig', [
'echeancier' => $echeancier,
'customer' => $echeancier->getCustomer(),
]);
}
/**
* Envoie l'echeancier par email au client avec la proposition.
*/
#[Route('/{id}/send', name: 'send', requirements: ['id' => '\d+'], methods: ['POST'])]
public function send(int $id, MailerService $mailer, Environment $twig): Response
{
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
$customer = $echeancier->getCustomer();
if (null === $customer->getEmail()) {
$this->addFlash('error', 'Email client introuvable.');
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
$html = $twig->render('emails/echeancier_proposition.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
]);
$mailer->sendEmail(
$customer->getEmail(),
'Proposition d\'echeancier de paiement',
$html,
null,
null,
false,
);
$echeancier->setState(Echeancier::STATE_SEND);
$this->em->flush();
$this->addFlash('success', 'Proposition d\'echeancier envoyee a '.$customer->getEmail().'.');
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
/**
* Renvoie l'email de proposition au client.
*/
#[Route('/{id}/resend', name: 'resend', requirements: ['id' => '\d+'], methods: ['POST'])]
public function resend(int $id, MailerService $mailer, Environment $twig): Response
{
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
$customer = $echeancier->getCustomer();
if (null === $customer->getEmail()) {
$this->addFlash('error', 'Email client introuvable.');
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
$html = $twig->render('emails/echeancier_proposition.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
]);
$mailer->sendEmail(
$customer->getEmail(),
'Rappel - Proposition d\'echeancier de paiement',
$html,
null,
null,
false,
);
$this->addFlash('success', 'Rappel envoye a '.$customer->getEmail().'.');
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
/**
* Genere le PDF de l'echeancier.
*/
#[Route('/{id}/generate-pdf', name: 'generate_pdf', requirements: ['id' => '\d+'], methods: ['POST'])]
public function generatePdf(int $id, KernelInterface $kernel): Response
{
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
$this->generateEcheancierPdf($echeancier, $kernel);
$this->addFlash('success', 'PDF echeancier '.($echeancier->getPdfUnsigned() ? 'regenere' : 'genere').'.');
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
private function generateEcheancierPdf(Echeancier $echeancier, KernelInterface $kernel): void
{
$pdf = new EcheancierPdf($kernel, $echeancier);
$pdf->generate();
$tmpPath = tempnam(sys_get_temp_dir(), 'echeancier_').'.pdf';
$pdf->Output('F', $tmpPath);
$echeancier->setPdfUnsignedFile(new UploadedFile(
$tmpPath,
'echeancier-'.$echeancier->getId().'.pdf',
'application/pdf',
null,
true,
));
$echeancier->setUpdatedAt(new \DateTimeImmutable());
$this->em->flush();
@unlink($tmpPath);
}
/**
* Envoie le PDF pour signature via DocuSeal (2 parties : Company auto-signe + Client signe).
*/
#[Route('/{id}/send-signature', name: 'send_signature', requirements: ['id' => '\d+'], methods: ['POST'])]
public function sendSignature(
int $id,
DocuSealService $docuSeal,
MailerService $mailer,
Environment $twig,
UrlGeneratorInterface $urlGenerator,
#[Autowire(env: 'DOCUSEAL_URL')] string $docuSealUrl = '',
#[Autowire('%kernel.project_dir%')] string $projectDir = '',
): Response {
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
$customer = $echeancier->getCustomer();
if (null === $echeancier->getPdfUnsigned() || null === $customer->getEmail()) {
$this->addFlash('error', null === $echeancier->getPdfUnsigned() ? 'Le PDF doit etre genere avant l\'envoi.' : 'Email client introuvable.');
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
$pdfPath = $projectDir.'/public/uploads/echeanciers/'.$echeancier->getPdfUnsigned();
if (!file_exists($pdfPath)) {
$this->addFlash('error', 'Fichier PDF introuvable.');
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
$signedRedirectUrl = $urlGenerator->generate('app_echeancier_signed', [
'id' => $echeancier->getId(),
], UrlGeneratorInterface::ABSOLUTE_URL);
try {
$pdfBase64 = base64_encode(file_get_contents($pdfPath));
$result = $docuSeal->getApi()->createSubmissionFromPdf([
'name' => 'Echeancier - '.$customer->getFullName(),
'send_email' => false,
'flatten' => true,
'documents' => [
[
'name' => 'echeancier-'.$echeancier->getId().'.pdf',
'file' => 'data:application/pdf;base64,'.$pdfBase64,
],
],
'submitters' => [
[
'email' => 'contact@e-cosplay.fr',
'name' => 'Association E-Cosplay',
'role' => 'Company',
'completed' => true,
'send_email' => false,
'values' => ['Sign' => $docuSeal->getLogoBase64()],
'metadata' => ['doc_type' => 'echeancier', 'echeancier_id' => $echeancier->getId()],
],
[
'email' => $customer->getEmail(),
'name' => $customer->getFullName(),
'role' => 'First Party',
'send_email' => false,
'completed_redirect_url' => $signedRedirectUrl,
'metadata' => ['doc_type' => 'echeancier', 'echeancier_id' => $echeancier->getId()],
],
],
]);
$submitterId = $result['submitters'][1]['id'] ?? ($result[1]['id'] ?? null);
if (null !== $submitterId) {
$echeancier->setSubmissionId((string) $submitterId);
$echeancier->setState(Echeancier::STATE_SEND);
$this->em->flush();
// Envoyer email au client avec lien de signature
$slug = $docuSeal->getSubmitterSlug($submitterId);
$signUrl = null !== $slug ? rtrim($docuSealUrl, '/').'/s/'.$slug : null;
$html = $twig->render('emails/echeancier_signature.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
'signUrl' => $signUrl,
]);
$mailer->sendEmail(
$customer->getEmail(),
'Echeancier a signer - '.$customer->getFullName(),
$html,
null,
null,
false,
);
$this->addFlash('success', 'Echeancier envoye pour signature a '.$customer->getEmail().'.');
} else {
$this->addFlash('error', 'Erreur DocuSeal : aucun submitter retourne.');
}
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur DocuSeal : '.$e->getMessage());
}
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
/**
* Annule un echeancier (et la subscription Stripe si active).
*/
#[Route('/{id}/cancel', name: 'cancel', requirements: ['id' => '\d+'], methods: ['POST'])]
public function cancel(
int $id,
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
): Response {
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
// @codeCoverageIgnoreStart
if (null !== $echeancier->getStripeSubscriptionId() && '' !== $stripeSk) {
try {
\Stripe\Stripe::setApiKey($stripeSk);
\Stripe\Subscription::retrieve($echeancier->getStripeSubscriptionId())->cancel();
} catch (\Throwable) {
// Best effort
}
}
// @codeCoverageIgnoreEnd
$echeancier->setState(Echeancier::STATE_CANCELLED);
$this->em->flush();
$this->addFlash('success', 'Echeancier annule.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $echeancier->getCustomer()->getId(),
'tab' => 'echeancier',
]);
}
/**
* Active la subscription Stripe apres signature du client.
*/
#[Route('/{id}/activate', name: 'activate', requirements: ['id' => '\d+'], methods: ['POST'])]
public function activate(
int $id,
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
): Response {
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
if (Echeancier::STATE_SIGNED !== $echeancier->getState()) {
$this->addFlash('error', 'L\'echeancier doit etre signe avant activation.');
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
$customer = $echeancier->getCustomer();
if ('' === $stripeSk) {
$this->addFlash('error', 'Stripe non configure.');
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
// @codeCoverageIgnoreStart
try {
\Stripe\Stripe::setApiKey($stripeSk);
// Creer un prix Stripe pour le montant mensuel
$monthlyAmountCents = (int) round($echeancier->getMonthlyAmount() * 100);
$price = \Stripe\Price::create([
'unit_amount' => $monthlyAmountCents,
'currency' => 'eur',
'recurring' => ['interval' => 'month'],
'product_data' => [
'name' => 'Echeancier - '.$customer->getFullName(),
'metadata' => ['echeancier_id' => $echeancier->getId()],
],
]);
$echeancier->setStripePriceId($price->id);
// Utiliser le customer Stripe existant ou en creer un
$stripeCustomerId = $customer->getStripeCustomerId();
if (null === $stripeCustomerId) {
$stripeCustomer = \Stripe\Customer::create([
'email' => $customer->getEmail(),
'name' => $customer->getFullName(),
]);
$stripeCustomerId = $stripeCustomer->id;
$customer->setStripeCustomerId($stripeCustomerId);
}
$echeancier->setStripeCustomerId($stripeCustomerId);
// Creer la subscription avec nombre fixe d'echeances
$nbLines = $echeancier->getNbLines();
$firstLine = $echeancier->getLines()->first();
$billingAnchor = false !== $firstLine ? $firstLine->getScheduledAt()->getTimestamp() : time();
$subscription = \Stripe\Subscription::create([
'customer' => $stripeCustomerId,
'items' => [['price' => $price->id]],
'billing_cycle_anchor' => $billingAnchor,
'cancel_at' => (new \DateTimeImmutable())->modify('+'.$nbLines.' months')->getTimestamp(),
'metadata' => [
'echeancier_id' => (string) $echeancier->getId(),
'customer_email' => $customer->getEmail(),
'nb_echeances' => (string) $nbLines,
],
'payment_behavior' => 'default_incomplete',
'payment_settings' => [
'payment_method_types' => ['sepa_debit', 'card'],
],
]);
$echeancier->setStripeSubscriptionId($subscription->id);
$echeancier->setState(Echeancier::STATE_ACTIVE);
$this->em->flush();
$this->addFlash('success', 'Subscription Stripe activee. '.$nbLines.' echeances de '.number_format($echeancier->getMonthlyAmount(), 2, ',', ' ').' EUR/mois.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur Stripe : '.$e->getMessage());
}
// @codeCoverageIgnoreEnd
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
}