From 527e47d822cdc26f4222e601e33b244552b92fd7 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 25 Jun 2026 14:09:33 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(logistique)=20:=20bon=20de=20pes=C3=A9e?= =?UTF-8?q?=20=E2=80=94=20cartouche=20tiers=20+=20filtrage=20des=20listes?= =?UTF-8?q?=20contrepartie=20par=20site=20(ERP-208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PDF : cartouche bordé en haut à droite avec le type (Client/Fournisseur/Autre) et le nom du tiers (getCounterpartyName + getCounterpartyTypeLabel). - Écran ticket : listes Client/Fournisseur filtrées sur le site courant (param siteId[]) et rechargées au changement de site ; reset du tiers sélectionné s'il sort du périmètre du nouveau site. --- .../2026-06-25-erp-208-fix-ticket-pesee.md | 353 ++++++++++++++++++ ...6-06-25-erp-208-fix-ticket-pesee-design.md | 124 ++++++ .../useWeighingTicketReferentials.spec.ts | 44 +++ .../useWeighingTicketReferentials.ts | 23 +- .../__tests__/weighingTicketEdit.spec.ts | 2 + .../pages/__tests__/weighingTicketNew.spec.ts | 11 +- .../pages/weighing-tickets/[id]/edit.vue | 25 +- .../logistique/pages/weighing-tickets/new.vue | 25 +- .../Domain/Entity/WeighingTicket.php | 29 ++ .../weighing_ticket_print.html.twig | 36 +- .../WeighingTicketCounterpartyNameTest.php | 59 +++ 11 files changed, 712 insertions(+), 19 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-25-erp-208-fix-ticket-pesee.md create mode 100644 docs/superpowers/specs/2026-06-25-erp-208-fix-ticket-pesee-design.md create mode 100644 frontend/modules/logistique/composables/__tests__/useWeighingTicketReferentials.spec.ts create mode 100644 tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php diff --git a/docs/superpowers/plans/2026-06-25-erp-208-fix-ticket-pesee.md b/docs/superpowers/plans/2026-06-25-erp-208-fix-ticket-pesee.md new file mode 100644 index 0000000..a612126 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-erp-208-fix-ticket-pesee.md @@ -0,0 +1,353 @@ +# ERP-208 — Fix ticket de pesée — Plan d'implémentation + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development ou superpowers:executing-plans. Étapes en cases à cocher (`- [ ]`). + +**Goal:** Ajouter le nom du tiers dans un cartouche bordé en haut à droite du bon de pesée PDF, et filtrer les listes Client/Fournisseur du formulaire de ticket sur le site courant (avec recharge au changement de site). + +**Architecture:** Le filtre back `?siteId[]=` existe déjà sur `/clients` et `/suppliers` (joint adresses→sites) → point 2 = front uniquement. Point 1 = une méthode entité `getCounterpartyName()` + refonte du header du template Twig en table 2 colonnes (Dompdf = CSS 2.1). + +**Tech Stack:** PHP 8.4 / Symfony / API Platform / Doctrine / Twig + Dompdf ; Nuxt 4 / Vue 3 / Vitest. + +## Global Constraints + +- `declare(strict_types=1);` en tête de tout fichier PHP. +- Commentaires en **français**, code (noms) en anglais. +- Front : `useApi()` uniquement, composants `Malio*`, 4 espaces, TS strict. +- Dompdf : **CSS 2.1 uniquement** (pas de flex/grid) → mise en page par tableaux. +- **Aucun commit sans demande explicite de Tristan** (les étapes « commit » sont différées en fin de chantier, sur demande). +- Vérif finale : `make test` + `make nuxt-test` + `make php-cs-fixer-allow-risky`. Pas d'E2E. + +--- + +### Task 1 : `WeighingTicket::getCounterpartyName()` (back) + +**Files:** +- Modify: `src/Module/Logistique/Domain/Entity/WeighingTicket.php` (ajout méthode près de `getOtherLabel`, ~ligne 449) +- Test: `tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php` (create) + +**Interfaces:** +- Produces: `WeighingTicket::getCounterpartyName(): ?string` — companyName du client/fournisseur ou otherLabel selon `counterpartyType`, null sinon. Consommé par le template Twig (Task 2). + +- [ ] **Step 1 : test qui échoue** + +```php +setCompanyName('Ferme du Pré'); + $ticket = (new WeighingTicket())->setCounterpartyType('CLIENT')->setClient($client); + + self::assertSame('Ferme du Pré', $ticket->getCounterpartyName()); + } + + public function testReturnsSupplierCompanyNameForSupplierCounterparty(): void + { + $supplier = (new Supplier())->setCompanyName('Coop Sud'); + $ticket = (new WeighingTicket())->setCounterpartyType('FOURNISSEUR')->setSupplier($supplier); + + self::assertSame('Coop Sud', $ticket->getCounterpartyName()); + } + + public function testReturnsOtherLabelForOtherCounterparty(): void + { + $ticket = (new WeighingTicket())->setCounterpartyType('AUTRE')->setOtherLabel('Particulier'); + + self::assertSame('Particulier', $ticket->getCounterpartyName()); + } + + public function testReturnsNullWhenNoCounterparty(): void + { + self::assertNull((new WeighingTicket())->getCounterpartyName()); + } +} +``` + +- [ ] **Step 2 : lancer le test → échec** + +`make test` filtré : `docker exec php-starseed-fpm php bin/phpunit tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php` +Attendu : FAIL (`getCounterpartyName` n'existe pas). Vérifier au passage que `Client`/`Supplier` ont bien un constructeur sans argument et `setCompanyName` (sinon adapter l'instanciation du test au pattern existant des entités). + +- [ ] **Step 3 : implémentation minimale** + +Dans `WeighingTicket.php`, après `getOtherLabel()`/`setOtherLabel()` : + +```php + /** + * Nom du tiers à afficher (bon de pesée PDF, ERP-208) : raison sociale du + * client/fournisseur ou libellé libre selon le type de contrepartie (RG-5.03). + * Null si aucune contrepartie cohérente (brouillon). + */ + public function getCounterpartyName(): ?string + { + return match ($this->counterpartyType) { + 'CLIENT' => $this->client?->getCompanyName(), + 'FOURNISSEUR' => $this->supplier?->getCompanyName(), + 'AUTRE' => $this->otherLabel, + default => null, + }; + } +``` + +- [ ] **Step 4 : lancer le test → succès** + +`docker exec php-starseed-fpm php bin/phpunit tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php` → PASS. + +--- + +### Task 2 : Cartouche tiers dans le template PDF + +**Files:** +- Modify: `templates/logistique/weighing_ticket_print.html.twig` + +**Interfaces:** +- Consumes: `ticket.counterpartyName` (Task 1). + +- [ ] **Step 1 : ajouter le style du cartouche + header 2 colonnes** + +Dans le ` - {% if logoSrc %} - - {% endif %} - -
SA LIOT Châtellerault
-
Email : lpc.contacts@lpc-liot.fr
-
RCS Châtellerault B 339 505 612
+ + + + {# Cartouche tiers (ERP-208) : nom du client / fournisseur / « autre ». #} + + +
+ {% if logoSrc %} + + {% endif %} +
SA LIOT Châtellerault
+
Email : lpc.contacts@lpc-liot.fr
+
RCS Châtellerault B 339 505 612
+
+ {% if ticket.counterpartyName %} +
+
{{ ticket.counterpartyTypeLabel }} :
+ {{ ticket.counterpartyName }} +
+ {% endif %} +
Ticket de pesée
diff --git a/tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php b/tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php new file mode 100644 index 0000000..5b1fa22 --- /dev/null +++ b/tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php @@ -0,0 +1,59 @@ +setCompanyName('Ferme du Pré'); + $ticket = new WeighingTicket()->setCounterpartyType('CLIENT')->setClient($client); + + self::assertSame('Ferme du Pré', $ticket->getCounterpartyName()); + } + + public function testReturnsSupplierCompanyNameForSupplierCounterparty(): void + { + $supplier = new Supplier()->setCompanyName('Coop Sud'); + $ticket = new WeighingTicket()->setCounterpartyType('FOURNISSEUR')->setSupplier($supplier); + + self::assertSame('Coop Sud', $ticket->getCounterpartyName()); + } + + public function testReturnsOtherLabelForOtherCounterparty(): void + { + $ticket = new WeighingTicket()->setCounterpartyType('AUTRE')->setOtherLabel('Particulier'); + + self::assertSame('Particulier', $ticket->getCounterpartyName()); + } + + public function testReturnsNullWhenNoCounterparty(): void + { + self::assertNull(new WeighingTicket()->getCounterpartyName()); + } + + public function testTypeLabelIsFrenchPerCounterpartyType(): void + { + self::assertSame('Client', new WeighingTicket()->setCounterpartyType('CLIENT')->getCounterpartyTypeLabel()); + self::assertSame('Fournisseur', new WeighingTicket()->setCounterpartyType('FOURNISSEUR')->getCounterpartyTypeLabel()); + self::assertSame('Autre', new WeighingTicket()->setCounterpartyType('AUTRE')->getCounterpartyTypeLabel()); + } + + public function testTypeLabelIsNullWhenNoCounterparty(): void + { + self::assertNull(new WeighingTicket()->getCounterpartyTypeLabel()); + } +} -- 2.39.5 From 2b03c4ae153af34ece263acc647d7a473a3d79fc Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 25 Jun 2026 14:55:35 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(logistique)=20:=20corrections=20review?= =?UTF-8?q?=20ticket=20de=20pes=C3=A9e=20(ERP-208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Édition : listes contrepartie filtrées sur le site DU TICKET (immuable), chargées après hydrate, sans purge de la contrepartie persistée (injection de l'option si absente) → corrige la perte silencieuse / race. - Entité : constantes COUNTERPARTY_* (Assert\Choice + validation + getCounterpartyName) ; libellé FR du type déplacé du Domain vers le template. - PDF : cartouche conditionné sur le type (nom à l'intérieur), layout Dompdf-safe (largeurs de cellules, cartouche en bloc, nom long renvoyé à la ligne). --- .../__tests__/weighingTicketEdit.spec.ts | 12 ++++-- .../pages/weighing-tickets/[id]/edit.vue | 37 +++++++++--------- .../Domain/Entity/WeighingTicket.php | 39 ++++++++----------- .../weighing_ticket_print.html.twig | 28 +++++++++---- .../WeighingTicketCounterpartyNameTest.php | 12 ------ 5 files changed, 65 insertions(+), 63 deletions(-) diff --git a/frontend/modules/logistique/pages/__tests__/weighingTicketEdit.spec.ts b/frontend/modules/logistique/pages/__tests__/weighingTicketEdit.spec.ts index c807609..c688d03 100644 --- a/frontend/modules/logistique/pages/__tests__/weighingTicketEdit.spec.ts +++ b/frontend/modules/logistique/pages/__tests__/weighingTicketEdit.spec.ts @@ -8,12 +8,13 @@ const mockFetchTicket = vi.hoisted(() => vi.fn()) const mockPatch = vi.hoisted(() => vi.fn()) const mockPush = vi.hoisted(() => vi.fn()) const mockOpen = vi.hoisted(() => vi.fn()) +const mockRefLoad = vi.hoisted(() => vi.fn()) vi.mock('~/modules/logistique/composables/useWeighingTicket', () => ({ useWeighingTicket: () => ({ fetchTicket: mockFetchTicket }), })) vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({ - useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: vi.fn().mockResolvedValue(undefined) }), + useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: mockRefLoad }), })) vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({ useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }), @@ -28,8 +29,6 @@ vi.stubGlobal('useRouter', () => ({ push: mockPush })) vi.stubGlobal('usePermissions', () => ({ can: () => true })) vi.stubGlobal('navigateTo', vi.fn()) vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() })) -// Site courant (ERP-208) : nécessaire depuis que l'écran filtre les référentiels par site. -vi.stubGlobal('useCurrentSite', () => ({ currentSite: ref({ id: 7, name: 'Site 7', color: '#000000' }) })) globalThis.open = mockOpen const EditPage = (await import('../weighing-tickets/[id]/edit.vue')).default @@ -102,6 +101,7 @@ describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit mockPatch.mockReset().mockResolvedValue({}) mockPush.mockReset() mockOpen.mockReset() + mockRefLoad.mockReset().mockResolvedValue(undefined) }) it('charge le ticket au montage (pré-remplissage via hydrate)', async () => { @@ -109,6 +109,12 @@ describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit expect(mockFetchTicket).toHaveBeenCalledWith('9') }) + it('filtre les référentiels sur le SITE DU TICKET, pas le site courant (ERP-208)', async () => { + await mountPage() + // DETAIL.site.id = 1 → les listes sont chargées pour le site du ticket (immuable). + expect(mockRefLoad).toHaveBeenCalledWith(1) + }) + it('ticket validé : action principale « Enregistrer » + « Imprimer » (pas « Valider »)', async () => { const wrapper = await mountPage() // DETAIL.status = VALIDATED → l'action principale s'intitule « Enregistrer ». diff --git a/frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue b/frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue index 9b1d233..93b7346 100644 --- a/frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue +++ b/frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue @@ -186,10 +186,10 @@ diff --git a/src/Module/Logistique/Domain/Entity/WeighingTicket.php b/src/Module/Logistique/Domain/Entity/WeighingTicket.php index e9a067d..df54d19 100644 --- a/src/Module/Logistique/Domain/Entity/WeighingTicket.php +++ b/src/Module/Logistique/Domain/Entity/WeighingTicket.php @@ -175,6 +175,15 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface /** Valide : contrepartie + immatriculation + 2 pesees OK, numero attribue (« Terminée »). */ public const string STATUS_VALIDATED = 'VALIDATED'; + /** Contrepartie « Client » (M1) — RG-5.03. */ + public const string COUNTERPARTY_CLIENT = 'CLIENT'; + + /** Contrepartie « Fournisseur » (M2) — RG-5.03. */ + public const string COUNTERPARTY_FOURNISSEUR = 'FOURNISSEUR'; + + /** Contrepartie « Autre » (libelle libre) — RG-5.03. */ + public const string COUNTERPARTY_AUTRE = 'AUTRE'; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] @@ -195,7 +204,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface /** CLIENT | FOURNISSEUR | AUTRE (RG-5.03) — null tant que brouillon, requis a la validation. Pilote le champ associe obligatoire. */ #[ORM\Column(name: 'counterparty_type', length: 12, nullable: true)] #[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.', groups: ['finalize'])] - #[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR', 'AUTRE'], message: 'Type de contrepartie invalide.')] + #[Assert\Choice(choices: [self::COUNTERPARTY_CLIENT, self::COUNTERPARTY_FOURNISSEUR, self::COUNTERPARTY_AUTRE], message: 'Type de contrepartie invalide.')] #[Groups(['weighing_ticket:read', 'weighing_ticket:write'])] private ?string $counterpartyType = null; @@ -313,7 +322,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface public function validateCounterpartyConsistency(ExecutionContextInterface $context): void { switch ($this->counterpartyType) { - case 'CLIENT': + case self::COUNTERPARTY_CLIENT: if (null === $this->client) { $context->buildViolation('Le client est obligatoire pour une contrepartie « Client ».') ->atPath('client') @@ -323,7 +332,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface break; - case 'FOURNISSEUR': + case self::COUNTERPARTY_FOURNISSEUR: if (null === $this->supplier) { $context->buildViolation('Le fournisseur est obligatoire pour une contrepartie « Fournisseur ».') ->atPath('supplier') @@ -333,7 +342,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface break; - case 'AUTRE': + case self::COUNTERPARTY_AUTRE: if (null === $this->otherLabel || '' === trim($this->otherLabel)) { $context->buildViolation('Le libellé est obligatoire pour une contrepartie « Autre ».') ->atPath('otherLabel') @@ -466,24 +475,10 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface public function getCounterpartyName(): ?string { return match ($this->counterpartyType) { - 'CLIENT' => $this->client?->getCompanyName(), - 'FOURNISSEUR' => $this->supplier?->getCompanyName(), - 'AUTRE' => $this->otherLabel, - default => null, - }; - } - - /** - * Libellé FR du type de contrepartie (cartouche du bon de pesée PDF, ERP-208), - * affiché au-dessus du nom. Null si aucun type défini (brouillon). - */ - public function getCounterpartyTypeLabel(): ?string - { - return match ($this->counterpartyType) { - 'CLIENT' => 'Client', - 'FOURNISSEUR' => 'Fournisseur', - 'AUTRE' => 'Autre', - default => null, + self::COUNTERPARTY_CLIENT => $this->client?->getCompanyName(), + self::COUNTERPARTY_FOURNISSEUR => $this->supplier?->getCompanyName(), + self::COUNTERPARTY_AUTRE => $this->otherLabel, + default => null, }; } diff --git a/templates/logistique/weighing_ticket_print.html.twig b/templates/logistique/weighing_ticket_print.html.twig index f40af05..8cd210e 100644 --- a/templates/logistique/weighing_ticket_print.html.twig +++ b/templates/logistique/weighing_ticket_print.html.twig @@ -28,12 +28,17 @@ .company-line { font-size: 12px; } /* En-tête 2 colonnes (Dompdf = CSS 2.1, pas de flex/grid) : identité - société à gauche, cartouche du tiers à droite (ERP-208). */ + société à gauche, cartouche du tiers à droite (ERP-208). Largeurs + fixes par cellule + cartouche en bloc (pas d'inline-block/min-width, + mal supportés par Dompdf) : le cartouche occupe la colonne de droite + et un nom long passe à la ligne au lieu de déborder. */ .header { width: 100%; border-collapse: collapse; } .header td { vertical-align: top; } - .header .h-right { text-align: right; } - .party-box { display: inline-block; border: 1px solid #000; padding: 8px 12px; min-width: 160px; text-align: left; font-weight: normal; font-size: 11px; } + .header .h-left { width: 62%; } + .header .h-right { width: 38%; } + .party-box { border: 1px solid #000; padding: 8px 12px; } .party-label { font-weight: bold; font-size: 14px; margin-bottom: 4px; } + .party-name { font-size: 11px; word-wrap: break-word; } .title { font-size: 22px; font-weight: bold; margin: 22px 0 18px; } @@ -49,9 +54,12 @@ + {# Libellé FR du type de contrepartie (couche de rendu, pas le Domain — ERP-208). #} + {% set counterpartyLabels = { 'CLIENT': 'Client', 'FOURNISSEUR': 'Fournisseur', 'AUTRE': 'Autre' } %} + - - {# Cartouche tiers (ERP-208) : nom du client / fournisseur / « autre ». #} + {# Cartouche tiers (ERP-208) : type (libellé) + nom du client / fournisseur / + « autre ». Conditionné sur le TYPE : un brouillon sans type n'affiche rien ; + un type sans nom (cas limite) affiche au moins le libellé. #} diff --git a/tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php b/tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php index 5b1fa22..92cbccf 100644 --- a/tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php +++ b/tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php @@ -44,16 +44,4 @@ final class WeighingTicketCounterpartyNameTest extends TestCase { self::assertNull(new WeighingTicket()->getCounterpartyName()); } - - public function testTypeLabelIsFrenchPerCounterpartyType(): void - { - self::assertSame('Client', new WeighingTicket()->setCounterpartyType('CLIENT')->getCounterpartyTypeLabel()); - self::assertSame('Fournisseur', new WeighingTicket()->setCounterpartyType('FOURNISSEUR')->getCounterpartyTypeLabel()); - self::assertSame('Autre', new WeighingTicket()->setCounterpartyType('AUTRE')->getCounterpartyTypeLabel()); - } - - public function testTypeLabelIsNullWhenNoCounterparty(): void - { - self::assertNull(new WeighingTicket()->getCounterpartyTypeLabel()); - } } -- 2.39.5
+ {% if logoSrc %} {% endif %} @@ -59,12 +67,16 @@
Email : lpc.contacts@lpc-liot.fr
RCS Châtellerault B 339 505 612
- {% if ticket.counterpartyName %} + {% if ticket.counterpartyType %}
-
{{ ticket.counterpartyTypeLabel }} :
- {{ ticket.counterpartyName }} +
{{ counterpartyLabels[ticket.counterpartyType] ?? ticket.counterpartyType }} :
+ {% if ticket.counterpartyName %} +
{{ ticket.counterpartyName }}
+ {% endif %}
{% endif %}