From 8b3966acae8bd9a9d6aba7f18bba37a53135417e Mon Sep 17 00:00:00 2001 From: Thomas Da Silva Date: Mon, 19 Oct 2026 15:12:15 +0200 Subject: [PATCH 01/12] feat: give each credit note a language A credit note gets the language of its order, of its customer without an order, or the default language of the shop when it is written, and the back-office modal lets the administrator change it, numbered or not. The document is printed in that language, still within document_locales. The update fills the existing credit notes with the language of their customer, the one they were printed in until now. --- CHANGELOG.md | 1 + Config/Update/sql/4.3.0.sql | 22 +++ Config/schema.xml | 10 ++ Config/thelia.sql | 9 +- Controller/CreditNoteController.php | 41 ++++-- EventListener/CreditNoteListener.php | 9 +- Form/CreditNoteCreateForm.php | 3 + I18n/backOffice/default/en_US.php | 2 + I18n/backOffice/default/fr_FR.php | 2 + Service/CreditNoteDocumentLocale.php | 19 +-- Service/CreditNoteLanguage.php | 54 +++++++ Service/CreditNoteModalPresenter.php | 17 +++ .../ajax/credit-note-modal.html.twig | 13 ++ .../CreditNoteDocumentLocaleTest.php | 70 ++++++--- .../CreditNoteLanguageRouteTest.php | 133 +++++++++++++++++ tests/Integration/CreditNoteLanguageTest.php | 136 ++++++++++++++++++ 16 files changed, 502 insertions(+), 39 deletions(-) create mode 100644 Config/Update/sql/4.3.0.sql create mode 100644 Service/CreditNoteLanguage.php create mode 100644 tests/Integration/CreditNoteLanguageRouteTest.php create mode 100644 tests/Integration/CreditNoteLanguageTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 72f4683..a4d4b72 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Unreleased +- A credit note has a language (`lang_id`), chosen in the back-office modal: the language of its order by default, of its customer without an order, the default language of the shop otherwise. It stays editable once the credit note is numbered, since it only changes the printed document. The document is printed in it (still within `document_locales`). The update fills the existing credit notes with the language they were printed in: the one of their customer, of their order when the customer has none. - Smarty is gone, as in Thelia 3: the Smarty plugin `credit_note_format_address`, the Smarty back-office and PDF templates, the Smarty fallback of the back-office hooks. The loops no Twig template calls are removed too: `credit-note-comment`, `credit-note-status`, `credit-note-type`, `order-credit-note` and `credit-note-version` (its table has not existed since 2.3.4). The loops `credit-note`, `credit-note-address` and `credit-note-detail` stay: the credit note document of the PDF template reads them. - The languages a credit note is printed in are a setting of the module (`document_locales`, locales separated by commas): a customer whose language is in the list gets the document in that language, the others the default language of the shop. Empty, as before: English and French. A shop whose PDF template is translated in German adds `de_DE`. The field refuses anything that is not a list of locales such as `fr_FR`. - Credit notes numbered like the invoices (`invoice_ref_with_thelia_order`) draw their number from `InvoiceRefSequence::next()` (InvoiceRef 3.1 or later): the counter is read under a lock shared with the orders, and a number an order already carries is skipped. The previous read and increment, without a lock, could hand the same number to an invoice and a credit note. diff --git a/Config/Update/sql/4.3.0.sql b/Config/Update/sql/4.3.0.sql new file mode 100644 index 0000000..7632076 --- /dev/null +++ b/Config/Update/sql/4.3.0.sql @@ -0,0 +1,22 @@ +# This is a fix for InnoDB in MySQL >= 4.1.x +# It "suspends judgement" for fkey relationships until are tables are set. +SET FOREIGN_KEY_CHECKS = 0; + +ALTER TABLE `credit_note` ADD `lang_id` INTEGER AFTER `allow_partial_use`; +ALTER TABLE `credit_note` ADD INDEX `idx_lang_id_fk` (`lang_id`); +ALTER TABLE `credit_note` ADD CONSTRAINT `credit_note_FK_8` + FOREIGN KEY (`lang_id`) + REFERENCES `lang` (`id`) + ON UPDATE RESTRICT + ON DELETE SET NULL; + +# The existing credit notes keep the language they were printed in until now: the one of their +# customer, or of their order when the customer has none. +UPDATE `credit_note` + LEFT JOIN `customer` ON `customer`.`id` = `credit_note`.`customer_id` + LEFT JOIN `order` ON `order`.`id` = `credit_note`.`order_id` +SET `credit_note`.`lang_id` = COALESCE(`customer`.`lang_id`, `order`.`lang_id`) +WHERE `credit_note`.`lang_id` IS NULL; + +# This restores the fkey checks, after having unset them earlier +SET FOREIGN_KEY_CHECKS = 1; diff --git a/Config/schema.xml b/Config/schema.xml index 26aaa27..dd9df59 100755 --- a/Config/schema.xml +++ b/Config/schema.xml @@ -28,6 +28,8 @@ + + @@ -56,6 +58,10 @@ + + + + @@ -84,6 +90,10 @@ + + + + diff --git a/Config/thelia.sql b/Config/thelia.sql index 85b9085..43553e4 100755 --- a/Config/thelia.sql +++ b/Config/thelia.sql @@ -28,6 +28,7 @@ CREATE TABLE `credit_note` `discount_without_tax` DECIMAL(16,6) DEFAULT 0.000000, `discount_with_tax` DECIMAL(16,6) DEFAULT 0.000000, `allow_partial_use` TINYINT(1) DEFAULT 1, + `lang_id` INTEGER, `created_at` DATETIME, `updated_at` DATETIME, PRIMARY KEY (`id`), @@ -38,6 +39,7 @@ CREATE TABLE `credit_note` INDEX `idx_parent_id_fk` (`parent_id`), INDEX `idx_type_id_fk` (`type_id`), INDEX `idx_status_id_fk` (`status_id`), + INDEX `idx_lang_id_fk` (`lang_id`), INDEX `credit_note_FI_6` (`currency_id`), INDEX `credit_note_FI_7` (`invoice_address_id`), CONSTRAINT `credit_note_FK_1` @@ -74,7 +76,12 @@ CREATE TABLE `credit_note` FOREIGN KEY (`invoice_address_id`) REFERENCES `credit_note_address` (`id`) ON UPDATE RESTRICT - ON DELETE RESTRICT + ON DELETE RESTRICT, + CONSTRAINT `credit_note_FK_8` + FOREIGN KEY (`lang_id`) + REFERENCES `lang` (`id`) + ON UPDATE RESTRICT + ON DELETE SET NULL ) ENGINE=InnoDB; -- --------------------------------------------------------------------- diff --git a/Controller/CreditNoteController.php b/Controller/CreditNoteController.php index cffec20..f822b39 100755 --- a/Controller/CreditNoteController.php +++ b/Controller/CreditNoteController.php @@ -13,6 +13,7 @@ use CreditNote\Helper\CreditNoteHelper; use CreditNote\Helper\CriteriaSearchHelper; use CreditNote\Service\CreditNoteHookPresenter; +use CreditNote\Service\CreditNoteLanguage; use CreditNote\Service\CreditNoteModalPresenter; use CreditNote\Service\CreditNotePdfRenderer; use CreditNote\Exception\CreditNoteExceedsOrderException; @@ -157,9 +158,9 @@ public function saveConfigAction(Request $request) } #[Route('/credit-note/create', name: '_create', methods: 'POST')] - public function createAction(Request $request, EventDispatcherInterface $eventDispatcher, ParserContext $parserContext, SecurityContext $securityContext) + public function createAction(Request $request, EventDispatcherInterface $eventDispatcher, ParserContext $parserContext, SecurityContext $securityContext, CreditNoteLanguage $language) { - $creditNote = $this->performCreditNote($eventDispatcher, $parserContext, $securityContext); + $creditNote = $this->performCreditNote($eventDispatcher, $parserContext, $securityContext, $language); $con = Propel::getServiceContainer()->getWriteConnection(CreditNoteTableMap::DATABASE_NAME); @@ -207,13 +208,13 @@ public function createAction(Request $request, EventDispatcherInterface $eventDi * @return \Thelia\Core\HttpFoundation\Response */ #[Route('/credit-note/{id}', name: '_view', methods: 'POST', requirements: ['id' => '\d+'])] - public function viewAction(Request $request, $id, EventDispatcherInterface $eventDispatcher, ParserContext $parserContext, SecurityContext $securityContext, CreditNoteModalPresenter $modalPresenter) + public function viewAction(Request $request, $id, EventDispatcherInterface $eventDispatcher, ParserContext $parserContext, SecurityContext $securityContext, CreditNoteModalPresenter $modalPresenter, CreditNoteLanguage $language) { $creditNote = CreditNoteQuery::create() ->filterById($id, Criteria::EQUAL) ->findOne(); - $creditNote = $this->performCreditNote($eventDispatcher, $parserContext, $securityContext, $creditNote); + $creditNote = $this->performCreditNote($eventDispatcher, $parserContext, $securityContext, $language, $creditNote); return $this->renderModal($request, $creditNote, $modalPresenter); } @@ -230,13 +231,13 @@ private function renderModal(Request $request, CreditNote $creditNote, CreditNot } #[Route('/credit-note/{id}/_update', name: '_update', methods: 'POST', requirements: ['id' => '\d+'])] - public function updateAction(Request $request, $id, EventDispatcherInterface $eventDispatcher, ParserContext $parserContext, SecurityContext $securityContext) + public function updateAction(Request $request, $id, EventDispatcherInterface $eventDispatcher, ParserContext $parserContext, SecurityContext $securityContext, CreditNoteLanguage $language) { $creditNote = CreditNoteQuery::create() ->filterById($id, Criteria::EQUAL) ->findOne(); - $creditNote = $this->performCreditNote($eventDispatcher, $parserContext, $securityContext, $creditNote); + $creditNote = $this->performCreditNote($eventDispatcher, $parserContext, $securityContext, $language, $creditNote); $con = Propel::getServiceContainer()->getWriteConnection(CreditNoteTableMap::DATABASE_NAME); @@ -322,9 +323,9 @@ public function deleteAction(Request $request, $id, Translator $translator) * @return \Thelia\Core\HttpFoundation\Response */ #[Route('/credit-note/ajax/modal/create', name: '_ajax_create', methods: 'POST')] - public function ajaxModalCreateAction(Request $request, EventDispatcherInterface $eventDispatcher, ParserContext $parserContext, SecurityContext $securityContext, CreditNoteModalPresenter $modalPresenter) + public function ajaxModalCreateAction(Request $request, EventDispatcherInterface $eventDispatcher, ParserContext $parserContext, SecurityContext $securityContext, CreditNoteModalPresenter $modalPresenter, CreditNoteLanguage $language) { - $creditNote = $this->performCreditNote($eventDispatcher, $parserContext, $securityContext); + $creditNote = $this->performCreditNote($eventDispatcher, $parserContext, $securityContext, $language); return $this->renderModal($request, $creditNote, $modalPresenter); } @@ -391,7 +392,7 @@ private function exceedsOrderMessage(CreditNoteExceedsOrderException $exception) /** * @return CreditNote */ - protected function performCreditNote(EventDispatcherInterface $eventDispatcher, ParserContext $parserContext, SecurityContext $securityContext, CreditNote $creditNote = null) + protected function performCreditNote(EventDispatcherInterface $eventDispatcher, ParserContext $parserContext, SecurityContext $securityContext, CreditNoteLanguage $language, ?CreditNote $creditNote = null) { if (null === $creditNote) { $creditNote = new CreditNote(); @@ -416,7 +417,11 @@ protected function performCreditNote(EventDispatcherInterface $eventDispatcher, ; } - $this->performComment($formValidate, $creditNote, $securityContext); + // The language only changes the printed document: it stays editable once the credit + // note is numbered, like the comment. + $this + ->performLang($formValidate, $creditNote, $language) + ->performComment($formValidate, $creditNote, $securityContext); $parserContext->addForm($form); @@ -561,6 +566,22 @@ protected function performAmount(Form $formValidate, CreditNote $creditNote) return $this; } + /** + * A language the credit note cannot have (unknown, or inactive and not already its own) is + * ignored: the credit note keeps its language. None chosen yet on a new credit note: the + * default one is set on insert (CreditNoteListener). + */ + protected function performLang(Form $formValidate, CreditNote $creditNote, CreditNoteLanguage $language) + { + $langId = (int) $formValidate->get('lang_id')->getData(); + + if ($langId > 0 && $language->isSelectable($creditNote, $langId)) { + $creditNote->setLangId($langId); + } + + return $this; + } + protected function performComment(Form $formValidate, CreditNote $creditNote, SecurityContext $securityContext) { /** @var string $orderId */ diff --git a/EventListener/CreditNoteListener.php b/EventListener/CreditNoteListener.php index 3f1353d..6fbd6d1 100755 --- a/EventListener/CreditNoteListener.php +++ b/EventListener/CreditNoteListener.php @@ -13,6 +13,7 @@ use CreditNote\Event\PropelEvent; use CreditNote\Model\CreditNote as CreditNoteModel; use CreditNote\Model\Map\CreditNoteTableMap; +use CreditNote\Service\CreditNoteLanguage; use CreditNote\Service\CreditNoteOrderCeiling; use CreditNote\Service\SharedInvoiceNumber; use Propel\Runtime\Propel; @@ -26,6 +27,7 @@ class CreditNoteListener implements EventSubscriberInterface public function __construct( private readonly CreditNoteOrderCeiling $orderCeiling, private readonly SharedInvoiceNumber $sharedInvoiceNumber, + private readonly CreditNoteLanguage $language, ) { } @@ -44,7 +46,8 @@ public function generateCreditNoteInvoiceRef(PropelEvent $event) /** * Before an insert: the ceiling of the order, then the reference, then the accounting - * number when the credit note is created accepted. + * number when the credit note is created accepted. A credit note written without a + * language gets the default one (see CreditNoteLanguage). */ public function generateCreditNoteRef(PropelEvent $event) { @@ -53,6 +56,10 @@ public function generateCreditNoteRef(PropelEvent $event) $this->enforceOrderCeiling($instance); + if ($instance->getLangId() === null) { + $instance->setLangId($this->language->defaultFor($instance)?->getId()); + } + if ($instance->getRef() === null) { $ref = CreditNote::getConfigValue(CreditNote::CONFIG_KEY_REF_PREFIX) . str_pad( (int) CreditNote::getConfigValue(CreditNote::CONFIG_KEY_REF_INCREMENT, 1) + 1, diff --git a/Form/CreditNoteCreateForm.php b/Form/CreditNoteCreateForm.php index 6f503c3..7a3da97 100755 --- a/Form/CreditNoteCreateForm.php +++ b/Form/CreditNoteCreateForm.php @@ -63,6 +63,9 @@ protected function buildForm() )) ->add('comment', TextType::class, array( 'required' => false + )) + ->add('lang_id', IntegerType::class, array( + 'required' => false )); $this->formBuilder diff --git a/I18n/backOffice/default/en_US.php b/I18n/backOffice/default/en_US.php index d1bb828..09c9147 100755 --- a/I18n/backOffice/default/en_US.php +++ b/I18n/backOffice/default/en_US.php @@ -41,6 +41,7 @@ 'Credit Notes' => 'Credit notes', 'Customer' => 'Customer', 'Customer :' => 'Customer:', + 'Default (%language)' => 'Default (%language)', 'Delete' => 'Delete', 'Delete credit note' => 'Delete the credit note', 'Discount' => 'Discount', @@ -57,6 +58,7 @@ 'Invoice date' => 'Invoice date', 'Invoice ref' => 'Invoice ref', 'Invoice ref :' => 'Invoice ref:', + 'Language :' => 'Language:', 'Lastname :' => 'Last name:', 'Message content : ' => 'Message:', 'Message sent to the customer when a credit note is created from an order.' => 'Message sent to the customer when a credit note is created from an order.', diff --git a/I18n/backOffice/default/fr_FR.php b/I18n/backOffice/default/fr_FR.php index fb15ac2..c9e3097 100755 --- a/I18n/backOffice/default/fr_FR.php +++ b/I18n/backOffice/default/fr_FR.php @@ -49,6 +49,7 @@ 'Customer' => 'Client', 'Customer :' => 'Client :', 'Customer : ' => 'Client :', + 'Default (%language)' => 'Par défaut (%language)', 'Delete' => 'Supprimer', 'Delete credit note' => 'Supprimer cet avoir', 'Discount' => 'Réduction', @@ -71,6 +72,7 @@ 'Invoice ref' => 'Référence facture', 'Invoice ref :' => 'Référence facture :', 'Invoice ref : %ref' => 'Référence facture : %ref', + 'Language :' => 'Langue :', 'Lastname :' => 'Nom :', 'Lastname : ' => 'Nom :', 'Message content : ' => 'Message :', diff --git a/Service/CreditNoteDocumentLocale.php b/Service/CreditNoteDocumentLocale.php index d639ae6..180bab4 100644 --- a/Service/CreditNoteDocumentLocale.php +++ b/Service/CreditNoteDocumentLocale.php @@ -10,10 +10,11 @@ use Thelia\Model\LangQuery; /** - * The language a credit note is printed in: the one its customer prefers when the shop prints - * its credit notes in that language (`document_locales` setting of the module, English and - * French by default), otherwise the default language of the shop, and English when the shop - * has none. + * The language a credit note is printed in: its own language (chosen in the back-office, see + * CreditNoteLanguage), or the one its customer prefers for a credit note without one, when the + * shop prints its credit notes in that language (`document_locales` setting of the module, + * English and French by default); otherwise the default language of the shop, and English + * when the shop has none. */ final readonly class CreditNoteDocumentLocale { @@ -24,10 +25,10 @@ public function forCreditNote(CreditNoteModel $creditNote): string { - $customerLocale = $this->customerLocale($creditNote); + $requestedLocale = $this->requestedLocale($creditNote); - if (null !== $customerLocale && \in_array($customerLocale, CreditNote::documentLocales(), true)) { - return $customerLocale; + if (null !== $requestedLocale && \in_array($requestedLocale, CreditNote::documentLocales(), true)) { + return $requestedLocale; } $defaultLocale = $this->defaultLocale(); @@ -35,9 +36,9 @@ public function forCreditNote(CreditNoteModel $creditNote): string return null !== $defaultLocale && '' !== $defaultLocale ? $defaultLocale : self::FALLBACK; } - private function customerLocale(CreditNoteModel $creditNote): ?string + private function requestedLocale(CreditNoteModel $creditNote): ?string { - $langId = $creditNote->getCustomer()?->getLangId(); + $langId = $creditNote->getLangId() ?? $creditNote->getCustomer()?->getLangId(); if (null === $langId) { return null; diff --git a/Service/CreditNoteLanguage.php b/Service/CreditNoteLanguage.php new file mode 100644 index 0000000..7e42dd6 --- /dev/null +++ b/Service/CreditNoteLanguage.php @@ -0,0 +1,54 @@ +getOrder()?->getLangId() ?? $creditNote->getCustomer()?->getLangId(); + + if (null !== $langId && null !== $lang = LangQuery::create()->findPk($langId)) { + return $lang; + } + + return LangQuery::create()->findOneByByDefault(true); + } + + /** + * @return list + */ + public function selectable(CreditNoteModel $creditNote): array + { + $query = LangQuery::create()->filterByActive(true); + + if (null !== $creditNote->getLangId()) { + $query->_or()->filterById($creditNote->getLangId()); + } + + return iterator_to_array($query->orderByPosition()->find(), false); + } + + public function isSelectable(CreditNoteModel $creditNote, int $langId): bool + { + foreach ($this->selectable($creditNote) as $lang) { + if ($lang->getId() === $langId) { + return true; + } + } + + return false; + } +} diff --git a/Service/CreditNoteModalPresenter.php b/Service/CreditNoteModalPresenter.php index 84c952e..f17624d 100644 --- a/Service/CreditNoteModalPresenter.php +++ b/Service/CreditNoteModalPresenter.php @@ -32,6 +32,7 @@ final class CreditNoteModalPresenter { public function __construct( private readonly RequestStack $requestStack, + private readonly CreditNoteLanguage $language, ) { } @@ -67,7 +68,10 @@ public function build(CreditNote $creditNote, string $locale): array 'total_price_with_tax' => $this->money($creditNote->getTotalPriceWithTax(), $currencyId), 'address' => $this->address($creditNote), 'comment' => '', + 'lang_id' => $creditNote->getLangId(), ], + 'languages' => $this->languages($creditNote), + 'default_language' => $creditNote->getLangId() === null ? $this->language->defaultFor($creditNote)?->getTitle() : null, 'currencies' => $this->currencies(), 'statuses' => $this->statuses($locale), 'types' => $this->types($locale), @@ -139,6 +143,19 @@ private function statuses(string $locale): array return $out; } + /** + * @return list + */ + private function languages(CreditNote $creditNote): array + { + $out = []; + foreach ($this->language->selectable($creditNote) as $lang) { + $out[] = ['id' => $lang->getId(), 'title' => (string) $lang->getTitle()]; + } + + return $out; + } + /** * @return array */ diff --git a/templates/backOffice/default-twig/ajax/credit-note-modal.html.twig b/templates/backOffice/default-twig/ajax/credit-note-modal.html.twig index 2b70623..06c41bc 100644 --- a/templates/backOffice/default-twig/ajax/credit-note-modal.html.twig +++ b/templates/backOffice/default-twig/ajax/credit-note-modal.html.twig @@ -53,6 +53,19 @@ + {# Editable even once the credit note is numbered: the language only changes the printed document. #} +
+ + +
+