From a003837e3207ce9927ce7ffd186fdae9fa3981ec Mon Sep 17 00:00:00 2001 From: Baptiste Leduc Date: Mon, 14 Sep 2026 20:23:40 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20tra=C3=A7abilit=C3=A9=20de=20l'envoi=20?= =?UTF-8?q?des=20factures=20dans=20la=20tr=C3=A9so?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/routing/admin_accounting.yml | 5 ++ .../20260914090000_facture_envoi.php | 18 +++++++ .../AppBundle/Accounting/InvoicingMailer.php | 2 + .../AppBundle/Accounting/Model/Invoicing.php | 28 ++++++++++ .../Model/Repository/InvoicingRepository.php | 19 ++++++- .../Invoice/MarkFactureAsSentAction.php | 51 +++++++++++++++++++ .../Invoice/SendInvoiceEmailAction.php | 9 ++++ .../admin/accounting/invoice/list.html.twig | 24 +++++++++ tests/behat/bootstrap/FeatureContext.php | 31 ++++++++--- .../Admin/Tresorerie/DevisFactures.feature | 15 +++++- .../Accounting/InvoicingMailerTest.php | 42 +++++++++++++++ 11 files changed, 234 insertions(+), 10 deletions(-) create mode 100644 db/migrations/20260914090000_facture_envoi.php create mode 100644 sources/AppBundle/Controller/Admin/Accounting/Invoice/MarkFactureAsSentAction.php create mode 100644 tests/unit/AppBundle/Accounting/InvoicingMailerTest.php diff --git a/config/routing/admin_accounting.yml b/config/routing/admin_accounting.yml index 166746cfa..1f16b2835 100644 --- a/config/routing/admin_accounting.yml +++ b/config/routing/admin_accounting.yml @@ -50,6 +50,11 @@ admin_accounting_invoices_send_email: path: /invoices/send defaults: {_controller: AppBundle\Controller\Admin\Accounting\Invoice\SendInvoiceEmailAction} +admin_accounting_invoices_mark_sent: + path: /invoices/mark-sent + methods: [POST] + defaults: {_controller: AppBundle\Controller\Admin\Accounting\Invoice\MarkFactureAsSentAction} + admin_accounting_search: path: /search defaults: {_controller: AppBundle\Controller\Admin\Accounting\SearchAction} diff --git a/db/migrations/20260914090000_facture_envoi.php b/db/migrations/20260914090000_facture_envoi.php new file mode 100644 index 000000000..898afab95 --- /dev/null +++ b/db/migrations/20260914090000_facture_envoi.php @@ -0,0 +1,18 @@ +execute($sql); + } +} diff --git a/sources/AppBundle/Accounting/InvoicingMailer.php b/sources/AppBundle/Accounting/InvoicingMailer.php index 59d0d04d1..6217cde53 100644 --- a/sources/AppBundle/Accounting/InvoicingMailer.php +++ b/sources/AppBundle/Accounting/InvoicingMailer.php @@ -9,6 +9,7 @@ use AppBundle\Email\Mailer\Attachment; use AppBundle\Email\Mailer\Mailer; use AppBundle\Email\Mailer\MailUser; +use AppBundle\Email\Mailer\MailUserFactory; use AppBundle\Email\Mailer\Message; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -43,6 +44,7 @@ public function sendInvoice(Invoicing $invoicing): bool new MailUser(MailUser::DEFAULT_SENDER_EMAIL, MailUser::DEFAULT_SENDER_NAME), new MailUser($invoicing->getEmail(), $invoicing->getLastname()), ); + $message->addCc(MailUserFactory::sponsors()); $message->addAttachment(new Attachment( $cheminFacture, 'facture-' . $invoiceNumber . '.pdf', diff --git a/sources/AppBundle/Accounting/Model/Invoicing.php b/sources/AppBundle/Accounting/Model/Invoicing.php index dd5c4fe8d..bd0863442 100644 --- a/sources/AppBundle/Accounting/Model/Invoicing.php +++ b/sources/AppBundle/Accounting/Model/Invoicing.php @@ -38,6 +38,8 @@ class Invoicing implements NotifyPropertyInterface private string $phone = ''; private InvoicingPaymentStatus $paymentStatus = InvoicingPaymentStatus::Waiting; private ?DateTime $paymentDate = null; + private ?DateTime $dateEnvoi = null; + private ?string $envoyePar = null; private ?InvoicingCurrency $currency = null; /** @var InvoicingDetail[] */ #[Assert\Valid] @@ -355,6 +357,32 @@ public function setPrice(?float $price): self return $this; } + public function getDateEnvoi(): ?DateTime + { + return $this->dateEnvoi; + } + + public function setDateEnvoi(?DateTime $dateEnvoi): self + { + $this->propertyChanged('dateEnvoi', $this->dateEnvoi, $dateEnvoi); + $this->dateEnvoi = $dateEnvoi; + + return $this; + } + + public function getEnvoyePar(): ?string + { + return $this->envoyePar; + } + + public function setEnvoyePar(?string $envoyePar): self + { + $this->propertyChanged('envoyePar', $this->envoyePar, $envoyePar); + $this->envoyePar = $envoyePar; + + return $this; + } + public function getPaymentUrlRef(): string { if (empty($this->getInvoiceNumber())) { diff --git a/sources/AppBundle/Accounting/Model/Repository/InvoicingRepository.php b/sources/AppBundle/Accounting/Model/Repository/InvoicingRepository.php index 8ffdcafc6..5da57a67e 100644 --- a/sources/AppBundle/Accounting/Model/Repository/InvoicingRepository.php +++ b/sources/AppBundle/Accounting/Model/Repository/InvoicingRepository.php @@ -51,7 +51,7 @@ public function getQuotationsByPeriodId(?int $periodId = null, string $sort = 'd ->where('acf.date_devis <= (select date_fin from compta_periode where id = :periodId)') ->bindValues(['periodId' => $periodId]); } - $builder->groupBy(['acf.id', 'date_devis', 'numero_devis', 'date_facture', 'numero_facture', 'societe', 'adresse', 'code_postal', 'ville', 'id_pays', 'email', 'observation', 'ref_clt1', 'ref_clt2', 'ref_clt3', 'nom', 'prenom', 'tel', 'etat_paiement', 'date_paiement', 'devise_facture']) + $builder->groupBy(['acf.id', 'date_devis', 'numero_devis', 'date_facture', 'numero_facture', 'societe', 'adresse', 'code_postal', 'ville', 'id_pays', 'email', 'observation', 'ref_clt1', 'ref_clt2', 'ref_clt3', 'nom', 'prenom', 'tel', 'etat_paiement', 'date_paiement', 'date_envoi', 'envoye_par', 'devise_facture']) ->orderBy(["$filter $direction"]); $hydrator = new HydratorSingleObject(); @@ -82,7 +82,7 @@ public function getInvoicesByPeriodId(?int $periodId = null, string $sort = 'dat ->where('acf.date_facture <= (select date_fin from compta_periode where id = :periodId)') ->bindValues(['periodId' => $periodId]); } - $builder->groupBy(['acf.id', 'date_devis', 'numero_devis', 'date_facture', 'numero_facture', 'societe', 'adresse', 'code_postal', 'ville', 'id_pays', 'email', 'observation', 'ref_clt1', 'ref_clt2', 'ref_clt3', 'nom', 'prenom', 'tel', 'etat_paiement', 'date_paiement', 'devise_facture']) + $builder->groupBy(['acf.id', 'date_devis', 'numero_devis', 'date_facture', 'numero_facture', 'societe', 'adresse', 'code_postal', 'ville', 'id_pays', 'email', 'observation', 'ref_clt1', 'ref_clt2', 'ref_clt3', 'nom', 'prenom', 'tel', 'etat_paiement', 'date_paiement', 'date_envoi', 'envoye_par', 'devise_facture']) ->orderBy(["$filter $direction"]); $hydrator = new HydratorSingleObject(); @@ -315,6 +315,21 @@ public static function initMetadata(SerializerFactoryInterface $serializerFactor 'unserialize' => ['enum' => InvoicingCurrency::class], ], ]) + ->addField([ + 'columnName' => 'date_envoi', + 'fieldName' => 'dateEnvoi', + 'type' => 'datetime', + 'serializer' => DateTime::class, + 'serializer_options' => [ + 'serialize' => ['format' => 'Y-m-d H:i:s'], + 'unserialize' => ['format' => 'Y-m-d H:i:s', 'unSerializeUseFormat' => true], + ], + ]) + ->addField([ + 'columnName' => 'envoye_par', + 'fieldName' => 'envoyePar', + 'type' => 'string', + ]) ; return $metadata; diff --git a/sources/AppBundle/Controller/Admin/Accounting/Invoice/MarkFactureAsSentAction.php b/sources/AppBundle/Controller/Admin/Accounting/Invoice/MarkFactureAsSentAction.php new file mode 100644 index 000000000..c51489f07 --- /dev/null +++ b/sources/AppBundle/Controller/Admin/Accounting/Invoice/MarkFactureAsSentAction.php @@ -0,0 +1,51 @@ +request->get('_token')); + if (!$this->csrfTokenManager->isTokenValid($token)) { + throw $this->createAccessDeniedException('Token CSRF invalide'); + } + + $invoiceRef = $request->request->get('ref'); + $invoice = $this->invoicingRepository->getOneByInvoiceNumber((string) $invoiceRef); + if ($invoice === null) { + throw new NotFoundHttpException("Cette facture n'existe pas"); + } + + $invoice->setDateEnvoi(new \DateTime()); + $user = $this->security->getUser(); + if ($user instanceof User) { + $invoice->setEnvoyePar($user->getUsername()); + } + $this->invoicingRepository->save($invoice); + $this->audit->log('Facture n°' . $invoiceRef . ' marquée comme envoyée manuellement'); + $this->addFlash('notice', 'La facture a été marquée comme envoyée'); + + return $this->redirectToRoute('admin_accounting_invoices_list'); + } +} diff --git a/sources/AppBundle/Controller/Admin/Accounting/Invoice/SendInvoiceEmailAction.php b/sources/AppBundle/Controller/Admin/Accounting/Invoice/SendInvoiceEmailAction.php index 1690914b6..6968291fd 100644 --- a/sources/AppBundle/Controller/Admin/Accounting/Invoice/SendInvoiceEmailAction.php +++ b/sources/AppBundle/Controller/Admin/Accounting/Invoice/SendInvoiceEmailAction.php @@ -6,10 +6,12 @@ use AppBundle\Accounting\InvoicingMailer; use AppBundle\Accounting\Model\Repository\InvoicingRepository; +use AppBundle\Association\Model\User; use AppBundle\AuditLog\Audit; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class SendInvoiceEmailAction extends AbstractController @@ -18,6 +20,7 @@ public function __construct( private readonly InvoicingMailer $invoicingMailer, private readonly InvoicingRepository $invoicingRepository, private readonly Audit $audit, + private readonly Security $security, ) {} public function __invoke(Request $request): Response @@ -29,6 +32,12 @@ public function __invoke(Request $request): Response } if ($this->invoicingMailer->sendInvoice($invoice)) { + $invoice->setDateEnvoi(new \DateTime()); + $user = $this->security->getUser(); + if ($user instanceof User) { + $invoice->setEnvoyePar($user->getUsername()); + } + $this->invoicingRepository->save($invoice); $this->audit->log('Envoi par email de la facture n°' . $invoiceRef); $this->addFlash('notice', 'La facture a été envoyée'); } else { diff --git a/templates/admin/accounting/invoice/list.html.twig b/templates/admin/accounting/invoice/list.html.twig index 2c831cedd..836830296 100644 --- a/templates/admin/accounting/invoice/list.html.twig +++ b/templates/admin/accounting/invoice/list.html.twig @@ -30,6 +30,7 @@ Ville Numéro facture Référence client + État envoi État paiement Montant{% if isSubjectedToVat %} HT{% endif %} @@ -45,6 +46,18 @@ {{ line.city }} {{ line.invoiceNumber }} {{ line.refClt1 }} + + {% if line.dateEnvoi %} + + Envoyé + + {% else %} + Non envoyé + {% endif %} + {% if line.paymentStatus == enum('AppBundle\\Accounting\\InvoicingPaymentStatus').Cancelled %} Annulé @@ -77,6 +90,17 @@ > +
+ + + +
click(); } + #[When('I press the button of tooltip :arg1')] + public function pressButtonOfTooltip(string $tooltip): void + { + $button = $this->minkContext->getSession()->getPage()->find('css', sprintf('button[data-tooltip="%s"]', $tooltip)); + + if (null === $button) { + throw new ExpectationException( + sprintf('Button of tooltip "%s" not found', $tooltip), + $this->minkContext->getSession()->getDriver(), + ); + } + + $button->press(); + } + #[Then('/^the rows of table "(?P[^"]+)" should be in the following order:$/')] public function assertTableRowsInOrder(string $selector, PyStringNode $expectedRows): void { @@ -209,14 +224,18 @@ public function assertDownloadedFile(string $mode, string $filename): void #[Then('/^(?:|I )should see a (?P(?:[\w])* )?label "(?P(?:[^"]|\\")*)"$/')] public function shouldSeeLabel(string $color, string $text): void { - $label = $this->minkContext->getSession()->getPage()->find('css', sprintf('.ui.label%s', $color != '' ? ('.' . $color) : '')); + $labels = $this->minkContext->getSession()->getPage()->findAll('css', sprintf('.ui.label%s', $color != '' ? ('.' . $color) : '')); - if (null === $label) { - throw new ExpectationException( - sprintf('label "%s" was not found', $text), - $this->minkContext->getSession()->getDriver(), - ); + foreach ($labels as $label) { + if (trim((string) $label->getText()) === $text) { + return; + } } + + throw new ExpectationException( + sprintf('label "%s" was not found', $text), + $this->minkContext->getSession()->getDriver(), + ); } /** diff --git a/tests/behat/features/Admin/Tresorerie/DevisFactures.feature b/tests/behat/features/Admin/Tresorerie/DevisFactures.feature index d63e6fba4..fabee2978 100644 --- a/tests/behat/features/Admin/Tresorerie/DevisFactures.feature +++ b/tests/behat/features/Admin/Tresorerie/DevisFactures.feature @@ -128,9 +128,10 @@ Feature: Administration - Trésorerie - Devis/Facture # Envoi de la facture par email Then I follow the button of tooltip "Envoyer la facture 2026-3 par mail" And I should only receive the following emails: - | from | to | subject | - | | | Facture AFUP | + | from | to | subject | + | | , | Facture AFUP | Then the ".content .message" element should contain "La facture a été envoyée" + And I should see a green label "Envoyé" # Lien de paiement Then I follow the button of tooltip "Récupérer le lien de paiement en ligne" Then I should see "Paiement en ligne de la facture" @@ -235,6 +236,16 @@ Feature: Administration - Trésorerie - Devis/Facture Then The page "1" of the PDF should contain "TVA non applicable - art. 293B du CGI" Then the checksum of the response content should be "c424b17649ae6bfc83bf6791dc1543ab" + @reloadDbWithTestData + Scenario: Marquer une facture comme envoyée manuellement + Given I am logged in as admin and on the Administration + When I go to "/admin/accounting/invoices/list?periodId=16" + Then I should see a label "Non envoyé" + When I press the button of tooltip "Marquer la facture 2025-02 comme envoyée manuellement" + Then I should see "La facture a été marquée comme envoyée" + And I go to "/admin/accounting/invoices/list?periodId=16" + And I should see a green label "Envoyé" + @reloadDbWithTestData @vat diff --git a/tests/unit/AppBundle/Accounting/InvoicingMailerTest.php b/tests/unit/AppBundle/Accounting/InvoicingMailerTest.php new file mode 100644 index 000000000..c77ca4b98 --- /dev/null +++ b/tests/unit/AppBundle/Accounting/InvoicingMailerTest.php @@ -0,0 +1,42 @@ +setInvoiceNumber('2026-001'); + $invoicing->setCompany('Société'); + $invoicing->setEmail('contact@exemple.fr'); + $invoicing->setLastname('Dupont'); + + /** @var Mailer&MockObject $mailer */ + $mailer = $this->createMock(Mailer::class); + $pdfGenerator = $this->createStub(InvoicingPdfGenerator::class); + + $mailer->expects(self::once())->method('send') + ->with(self::callback(static function (Message $message): bool { + self::assertSame(1, count($message->getRecipients() ?? [])); + $cc = $message->getCc(); + self::assertCount(1, $cc); + self::assertSame('sponsors@afup.org', array_key_first($cc)); + + return true; + })); + + $invoicingMailer = new InvoicingMailer($pdfGenerator, $mailer, sys_get_temp_dir() . '/'); + $invoicingMailer->sendInvoice($invoicing); + } +}