diff --git a/CHANGELOG.md b/CHANGELOG.md index 72f4683..472b997 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 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. +- The colors of the status and type badges are written into `style` attributes only when they are hexadecimal colors, grey otherwise. +- A credit note created or saved without an invoice address (no customer picked, or "Other" with the name left empty) is refused with a message, and nothing is written. The controller used to save the empty address, which a database in strict mode refuses with an error 500 (`Field 'firstname' doesn't have a default value`). The module's back-office messages use the `danger` type, the one the Twig back office styles. +- `update()` runs the scripts of `Config/Update/sql/` from the version installed up to the target version only, compared by version (the file name without `.sql`). It used to run every script above the installed version, which replayed `4.3.0.sql` on a shop already at 4.3.0. +- After creating, updating or deleting a credit note, the administrator is back on the page the modal was opened from, on the tab that held it ("Credit note" of an order): the modal script sends that page as `success-url`, which the modal kept only from the query string. Only a path of the shop in printable ASCII is followed: an absolute or protocol-relative `success-url`, or one carrying a tab or a line break that the browser would drop, sends back to the order. +- The "Credit Notes" section of the admin side menu is back, right below "Orders": its script looked for the `#orders_menu` entry of the Smarty back-office, absent from default-twig. It now follows the entry `[data-testid="bo-nav-order"]` of the default-twig side menu, which has no hook between sections. - 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..72afeae --- /dev/null +++ b/Config/Update/sql/4.3.0.sql @@ -0,0 +1,25 @@ +# 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. Only a language that exists: the foreign +# key checks are off in this script. +UPDATE `credit_note` + LEFT JOIN `customer` ON `customer`.`id` = `credit_note`.`customer_id` + LEFT JOIN `lang` `customer_lang` ON `customer_lang`.`id` = `customer`.`lang_id` + LEFT JOIN `order` ON `order`.`id` = `credit_note`.`order_id` + LEFT JOIN `lang` `order_lang` ON `order_lang`.`id` = `order`.`lang_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/module.xml b/Config/module.xml index e7cfc59..37f3621 100755 --- a/Config/module.xml +++ b/Config/module.xml @@ -17,7 +17,7 @@ en_US fr_FR - 4.2.0 + 4.3.0 Gilles Bourgeat gbourgeat@openstudio.fr 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..081e1de 100755 --- a/Controller/CreditNoteController.php +++ b/Controller/CreditNoteController.php @@ -13,9 +13,11 @@ 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; +use CreditNote\Exception\CreditNoteInvoiceAddressMissingException; use Thelia\Model\ConfigQuery; use CreditNote\Model\Base\CreditNoteStatusQuery; use CreditNote\Model\CreditNote; @@ -32,7 +34,6 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\Form; -use Symfony\Component\Form\FormError; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -61,6 +62,7 @@ use Thelia\Model\TaxRuleQuery; use CreditNote\CreditNote as CreditNoteModule; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Thelia\Tools\URL; /** @@ -149,7 +151,7 @@ public function saveConfigAction(Request $request) CreditNoteModule::setConfigValue(CreditNoteModule::CONFIG_KEY_DOCUMENT_LOCALES, implode(',', array_filter(array_map('trim', explode(',', (string) ($data[CreditNoteModule::CONFIG_KEY_DOCUMENT_LOCALES] ?? '')))))); } catch (FormValidationException $e) { if ($request->hasSession()) { - $request->getSession()->getFlashBag()->add('error', $e->getMessage()); + $request->getSession()->getFlashBag()->add('danger', $e->getMessage()); } } @@ -157,9 +159,13 @@ 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); + try { + $creditNote = $this->performCreditNote($eventDispatcher, $parserContext, $securityContext, $language); + } catch (CreditNoteInvoiceAddressMissingException) { + return $this->refuseWithoutInvoiceAddress($request, null); + } $con = Propel::getServiceContainer()->getWriteConnection(CreditNoteTableMap::DATABASE_NAME); @@ -172,33 +178,13 @@ public function createAction(Request $request, EventDispatcherInterface $eventDi $con->commit(); } catch (CreditNoteExceedsOrderException $e) { $con->rollBack(); - $request->getSession()->getFlashBag()->add('error', $this->exceedsOrderMessage($e)); + $request->getSession()->getFlashBag()->add('danger', $this->exceedsOrderMessage($e)); } catch (\Exception $e) { $con->rollBack(); throw $e; } - if (null !== $successUrl = ($request->request->get('success-url') ?? $request->query->get('success-url'))) { - return new RedirectResponse($successUrl); - } - - if (null !== $creditNote->getOrder()) { - return $this->generateRedirectFromRoute( - 'admin.order.update.view', - [ - 'tab' => 'credit-note' - ], - [ - 'order_id' => $creditNote->getOrder()->getId() - ] - ); - } - - if (null !== $creditNote->getCustomer()) { - return $this->generateRedirectFromRoute('admin.customer.update.view', [], [ - 'customer_id' => $creditNote->getCustomer()->getId() - ]); - } + return $this->redirectAfterSave($request, $creditNote); } /** @@ -207,13 +193,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 +216,17 @@ 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); + try { + $creditNote = $this->performCreditNote($eventDispatcher, $parserContext, $securityContext, $language, $creditNote); + } catch (CreditNoteInvoiceAddressMissingException) { + return $this->refuseWithoutInvoiceAddress($request, $creditNote); + } $con = Propel::getServiceContainer()->getWriteConnection(CreditNoteTableMap::DATABASE_NAME); @@ -249,33 +239,53 @@ public function updateAction(Request $request, $id, EventDispatcherInterface $ev $con->commit(); } catch (CreditNoteExceedsOrderException $e) { $con->rollBack(); - $request->getSession()->getFlashBag()->add('error', $this->exceedsOrderMessage($e)); + $request->getSession()->getFlashBag()->add('danger', $this->exceedsOrderMessage($e)); } catch (\Exception $e) { $con->rollBack(); throw $e; } - if (null !== $successUrl = ($request->request->get('success-url') ?? $request->query->get('success-url'))) { + return $this->redirectAfterSave($request, $creditNote); + } + + /** + * Back to the page the modal was opened from (`success-url`, sent by the modal script with + * the tab to reopen as fragment), otherwise to the credit notes of the order, or of the + * customer. Only a path of this shop is followed, in printable ASCII and without backslash: + * the browser drops tabs and line breaks from an address, so "/\t/host" would become the + * protocol-relative "//host" of another site. + */ + private function redirectAfterSave(Request $request, ?CreditNote $creditNote): RedirectResponse + { + $successUrl = (string) ($request->request->get('success-url') ?? $request->query->get('success-url') ?? ''); + + if (1 === preg_match('#^/(?!/)[\x21-\x5B\x5D-\x7E]*$#D', $successUrl)) { return new RedirectResponse($successUrl); } - if (null !== $creditNote->getOrder()) { - return $this->generateRedirectFromRoute( - 'admin.order.update.view', - [ - 'tab' => 'credit-note' - ], - [ - 'order_id' => $creditNote->getOrder()->getId() - ] - ); + if (null !== $creditNote?->getOrderId()) { + return new RedirectResponse($this->getRoute('admin.order.update.view', ['order_id' => $creditNote->getOrderId()], UrlGeneratorInterface::ABSOLUTE_PATH) . '#credit-note'); } - if (null !== $creditNote->getCustomer()) { - return $this->generateRedirectFromRoute('admin.customer.update.view', [], [ - 'customer_id' => $creditNote->getCustomer()->getId() - ]); + if (null !== $creditNote?->getCustomerId()) { + return new RedirectResponse($this->getRoute('admin.customer.update.view', ['customer_id' => $creditNote->getCustomerId()], UrlGeneratorInterface::ABSOLUTE_PATH)); } + + return new RedirectResponse($this->getRoute('credit_note_list', [], UrlGeneratorInterface::ABSOLUTE_PATH)); + } + + /** + * Nothing is written, not even the empty address: the administrator goes back to the page + * the modal was opened from with the reason. + */ + private function refuseWithoutInvoiceAddress(Request $request, ?CreditNote $creditNote): RedirectResponse + { + $request->getSession()->getFlashBag()->add( + 'danger', + $this->getTranslator()->trans('Please select an invoice address', [], CreditNoteModule::DOMAIN_MESSAGE) + ); + + return $this->redirectAfterSave($request, $creditNote); } #[Route('/credit-note/{id}/_delete', name: '_delete', methods: 'POST', requirements: ['id' => '\d+'])] @@ -286,7 +296,7 @@ public function deleteAction(Request $request, $id, Translator $translator) if (!empty($creditNote->getInvoiceRef())) { if ($request->hasSession()) { $request->getSession()->getFlashBag()->set( - 'error', + 'danger', $translator->trans("You can not delete this credit note", [], CreditNoteModule::DOMAIN_MESSAGE) ); } @@ -294,27 +304,7 @@ public function deleteAction(Request $request, $id, Translator $translator) CreditNoteQuery::create()->filterById($id)->delete(); } - if (null !== $successUrl = ($request->request->get('success-url') ?? $request->query->get('success-url'))) { - return new RedirectResponse($successUrl); - } - - if (null !== $creditNote->getOrder()) { - return $this->generateRedirectFromRoute( - 'admin.order.update.view', - [ - 'tab' => 'credit-note' - ], - [ - 'order_id' => $creditNote->getOrder()->getId() - ] - ); - } - - if (null !== $creditNote->getCustomer()) { - return $this->generateRedirectFromRoute('admin.customer.update.view', [], [ - 'customer_id' => $creditNote->getCustomer()->getId() - ]); - } + return $this->redirectAfterSave($request, $creditNote); } /** @@ -322,9 +312,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 +381,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 +406,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); @@ -505,11 +499,7 @@ protected function performInvoiceAddress(Form $formValidate, CreditNote $creditN } if (empty($creditNoteAddress->getLastname()) && ('create' === $action || 'update' === $action)) { - $formValidate->addError( - new FormError('Please select a invoice address') - ); - - $creditNoteAddress->save(); + throw new CreditNoteInvoiceAddressMissingException(); } $creditNote->setCreditNoteAddress($creditNoteAddress); @@ -561,6 +551,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/CreditNote.php b/CreditNote.php index 38ba416..325eee9 100755 --- a/CreditNote.php +++ b/CreditNote.php @@ -78,8 +78,13 @@ public function update($currentVersion, $newVersion, ?ConnectionInterface $con = ->in(__DIR__ ."/Config/Update/") ->sort($sort); + // The scripts of the versions this update crosses only: above the version installed, up + // to the one installed now. A script above the target describes a schema the shop does + // not have yet, or already has when it was installed at that version. foreach ($files as $file) { - if (version_compare($file->getFilename(), $currentVersion, ">")) { + $version = substr($file->getFilename(), 0, -4); + + if (version_compare($version, (string) $currentVersion, '>') && version_compare($version, (string) $newVersion, '<=')) { $sqlToExecute[$file->getFilename()] = $file->getRealPath(); } } 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/Exception/CreditNoteInvoiceAddressMissingException.php b/Exception/CreditNoteInvoiceAddressMissingException.php new file mode 100644 index 0000000..f516681 --- /dev/null +++ b/Exception/CreditNoteInvoiceAddressMissingException.php @@ -0,0 +1,17 @@ +add('comment', TextType::class, array( 'required' => false + )) + ->add('lang_id', IntegerType::class, array( + 'required' => false )); $this->formBuilder diff --git a/Helper/BadgeColor.php b/Helper/BadgeColor.php new file mode 100644 index 0000000..d31f133 --- /dev/null +++ b/Helper/BadgeColor.php @@ -0,0 +1,19 @@ + '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..321c65c 100755 --- a/I18n/backOffice/default/fr_FR.php +++ b/I18n/backOffice/default/fr_FR.php @@ -71,6 +71,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/I18n/en_US.php b/I18n/en_US.php index a79a2be..eca0087 100755 --- a/I18n/en_US.php +++ b/I18n/en_US.php @@ -21,6 +21,7 @@ 'Invoices' => 'Invoices', 'Next credit note reference number' => 'Next credit note reference number', 'Next invoice reference number' => 'Next invoice reference number', + 'Please select an invoice address' => 'Please select an invoice address', 'Restrict coupon codes from order credit notes to the original customer' => 'Restrict coupon codes from order credit notes to the original customer', 'Save' => 'Save', 'Searching credit notes' => 'Searching credit notes', diff --git a/I18n/fr_FR.php b/I18n/fr_FR.php index 0bdd33a..a1b5aff 100755 --- a/I18n/fr_FR.php +++ b/I18n/fr_FR.php @@ -17,6 +17,7 @@ 'Invoices' => 'Factures', 'Next credit note reference number' => 'Prochain numéro d\'avoir', 'Next invoice reference number' => 'Prochain numéro de facture', + 'Please select an invoice address' => 'Veuillez sélectionner une adresse de facturation', 'Save' => 'Enregistrer', 'Searching credit notes' => 'Recherche d\'avoirs', 'To' => 'Jusqu\'au', diff --git a/Service/CreditNoteDocumentLocale.php b/Service/CreditNoteDocumentLocale.php index d639ae6..fb72e66 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,14 @@ public function forCreditNote(CreditNoteModel $creditNote): string return null !== $defaultLocale && '' !== $defaultLocale ? $defaultLocale : self::FALLBACK; } - private function customerLocale(CreditNoteModel $creditNote): ?string + /** + * The customer answers for a credit note without a language only: one written before credit + * notes had a language, printed in the language of its customer until then. A new credit + * note always gets one on insert (CreditNoteLanguage::defaultFor()). + */ + 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/CreditNoteHookPresenter.php b/Service/CreditNoteHookPresenter.php index e44bb09..e9b777a 100644 --- a/Service/CreditNoteHookPresenter.php +++ b/Service/CreditNoteHookPresenter.php @@ -4,6 +4,7 @@ namespace CreditNote\Service; +use CreditNote\Helper\BadgeColor; use CreditNote\Model\CreditNoteQuery; use CreditNote\Model\CreditNoteStatusQuery; use CreditNote\Model\CreditNoteDetailQuery; @@ -134,9 +135,9 @@ private function row(\CreditNote\Model\CreditNote $creditNote, string $locale): 'order_ref' => $order?->getRef(), 'currency_id' => $creditNote->getCurrencyId(), 'status_title' => $status?->setLocale($locale)->getTitle() ?? '', - 'status_color' => $status?->getColor() ?? '#777', + 'status_color' => BadgeColor::css($status?->getColor()), 'type_title' => $type?->setLocale($locale)->getTitle() ?? '', - 'type_color' => $type?->getColor() ?? '#777', + 'type_color' => BadgeColor::css($type?->getColor()), 'total_price' => $this->formatMoney($creditNote->getTotalPrice(), $creditNote->getCurrencyId()), 'total_price_with_tax' => $this->formatMoney($creditNote->getTotalPriceWithTax(), $creditNote->getCurrencyId()), ]; @@ -201,7 +202,7 @@ public function detailRowsForOrderProduct(int $orderProductId, string $locale): 'credit_note_id' => $creditNote?->getId(), 'ref' => $creditNote?->getRef(), 'status_title' => $status?->setLocale($locale)->getTitle() ?? '', - 'status_color' => $status?->getColor() ?? '#777', + 'status_color' => BadgeColor::css($status?->getColor()), 'quantity' => $detail->getQuantity(), ]; } @@ -228,7 +229,7 @@ public function menuStatuses(string $locale): array $entries[] = [ 'id' => $status->getId(), 'title' => $status->setLocale($locale)->getTitle(), - 'color' => $status->getColor(), + 'color' => BadgeColor::css($status->getColor()), 'count' => $count, ]; } diff --git a/Service/CreditNoteLanguage.php b/Service/CreditNoteLanguage.php new file mode 100644 index 0000000..fe0d5db --- /dev/null +++ b/Service/CreditNoteLanguage.php @@ -0,0 +1,49 @@ +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 + { + return $langId === $creditNote->getLangId() + || LangQuery::create()->filterById($langId)->filterByActive(true)->exists(); + } +} diff --git a/Service/CreditNoteModalPresenter.php b/Service/CreditNoteModalPresenter.php index 84c952e..18ea9bf 100644 --- a/Service/CreditNoteModalPresenter.php +++ b/Service/CreditNoteModalPresenter.php @@ -4,6 +4,7 @@ namespace CreditNote\Service; +use CreditNote\Helper\BadgeColor; use CreditNote\Helper\CreditNoteHelper; use CreditNote\Model\CreditNote; use CreditNote\Model\CreditNoteCommentQuery; @@ -32,6 +33,7 @@ final class CreditNoteModalPresenter { public function __construct( private readonly RequestStack $requestStack, + private readonly CreditNoteLanguage $language, ) { } @@ -40,6 +42,9 @@ public function __construct( */ public function build(CreditNote $creditNote, string $locale): array { + // A credit note without a language yet (a new one) gets its default on insert: the select + // offers it first, without value, so a change of order or customer in the modal moves it. + $defaultLanguage = null === $creditNote->getLangId() ? $this->language->defaultFor($creditNote) : null; $currencyId = $creditNote->getCurrencyId(); $customer = $creditNote->getCustomer(); $order = $creditNote->getOrder(); @@ -67,7 +72,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(), ], + 'default_language' => null !== $defaultLanguage ? (string) $defaultLanguage->getTitle() : null, + 'languages' => $this->languages($creditNote, $defaultLanguage?->getId()), 'currencies' => $this->currencies(), 'statuses' => $this->statuses($locale), 'types' => $this->types($locale), @@ -132,13 +140,32 @@ private function statuses(string $locale): array $out[] = [ 'id' => $status->getId(), 'title' => $title !== null && $title !== '' ? $title : $status->getCode(), - 'color' => $status->getColor() ?? '#777', + 'color' => BadgeColor::css($status->getColor()), ]; } return $out; } + /** + * The languages the credit note can take, but the default one: the first option already + * names it. + * + * @return list + */ + private function languages(CreditNote $creditNote, ?int $defaultLanguageId): array + { + $out = []; + foreach ($this->language->selectable($creditNote) as $lang) { + if ($lang->getId() === $defaultLanguageId) { + continue; + } + $out[] = ['id' => $lang->getId(), 'title' => (string) $lang->getTitle()]; + } + + return $out; + } + /** * @return array */ @@ -150,7 +177,7 @@ private function types(string $locale): array $out[] = [ 'id' => $type->getId(), 'title' => $title !== null && $title !== '' ? $title : $type->getCode(), - 'color' => $type->getColor() ?? '#777', + 'color' => BadgeColor::css($type->getColor()), ]; } diff --git a/templates/backOffice/default-twig/CreditNote/hook/main.footer-js.html.twig b/templates/backOffice/default-twig/CreditNote/hook/main.footer-js.html.twig index 1bb263e..bb77fde 100644 --- a/templates/backOffice/default-twig/CreditNote/hook/main.footer-js.html.twig +++ b/templates/backOffice/default-twig/CreditNote/hook/main.footer-js.html.twig @@ -1,24 +1,27 @@ -{# main.footer-js hook — injects a "Credit Notes" entry in the admin side menu. Data: menu_statuses, total_count. #} +{# main.footer-js hook — injects a "Credit Notes" section right below the "Orders" one of the admin side menu + (_side_nav.html.twig of default-twig, which has no hook between sections), with the markup of its sections. + Data: menu_statuses, total_count. #} {% if is_granted('VIEW', 'admin.order') %} + {% set active = admin_current_location == 'credit-note' %}