fix(logistique) : bon de pesée — cartouche tiers + filtrage des listes contrepartie par site (ERP-208)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 2m1s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m38s

- 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.
This commit is contained in:
2026-06-25 14:09:33 +02:00
parent fdd4394e99
commit 527e47d822
11 changed files with 712 additions and 19 deletions
@@ -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
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Domain;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use PHPUnit\Framework\TestCase;
final class WeighingTicketCounterpartyNameTest extends TestCase
{
public function testReturnsClientCompanyNameForClientCounterparty(): void
{
$client = (new Client())->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 `<style>`, ajouter :
```css
.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: center; font-weight: bold; font-size: 12px; }
```
- [ ] **Step 2 : remplacer le bloc logo + identité par une table 2 colonnes**
Remplacer (logo + 3 lignes company) par :
```twig
<table class="header">
<tr>
<td>
{% if logoSrc %}
<div class="logo"><img src="{{ logoSrc }}" alt="LPC LIOT"></div>
{% endif %}
<div class="company-name">SA LIOT Châtellerault</div>
<div class="company-line">Email : lpc.contacts@lpc-liot.fr</div>
<div class="company-line">RCS Châtellerault B 339 505 612</div>
</td>
<td class="h-right">
{% if ticket.counterpartyName %}
<div class="party-box">{{ ticket.counterpartyName }}</div>
{% endif %}
</td>
</tr>
</table>
```
(Le `.title` « Ticket de pesée » et la suite restent inchangés, sous la table.)
- [ ] **Step 3 : vérifier le rendu PDF**
Le test existant `WeighingTicketPrintApiTest` doit rester vert :
`docker exec php-starseed-fpm php bin/phpunit tests/Module/Logistique/Api/WeighingTicketPrintApiTest.php` → PASS (`%PDF`, content-type, disposition inchangés).
---
### Task 3 : `useWeighingTicketReferentials.load(siteId?)` (front)
**Files:**
- Modify: `frontend/modules/logistique/composables/useWeighingTicketReferentials.ts`
- Test: `frontend/modules/logistique/composables/__tests__/useWeighingTicketReferentials.spec.ts` (create)
**Interfaces:**
- Produces: `load(siteId?: number | null): Promise<void>` — passe `siteId[]=<siteId>` aux fetch `/clients` et `/suppliers` quand `siteId` est fourni ; sinon comportement actuel (liste complète).
- [ ] **Step 1 : test qui échoue**
```ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
const getMock = vi.fn()
vi.stubGlobal('useApi', () => ({ get: getMock }))
import { useWeighingTicketReferentials } from '~/modules/logistique/composables/useWeighingTicketReferentials'
describe('useWeighingTicketReferentials', () => {
beforeEach(() => {
getMock.mockReset()
getMock.mockResolvedValue({ member: [] })
})
it('passe siteId[] aux deux endpoints quand un site est fourni', async () => {
const { load } = useWeighingTicketReferentials()
await load(7)
const clientsCall = getMock.mock.calls.find(c => c[0] === '/clients')
const suppliersCall = getMock.mock.calls.find(c => c[0] === '/suppliers')
expect(clientsCall?.[1]).toMatchObject({ pagination: 'false', 'siteId[]': [7] })
expect(suppliersCall?.[1]).toMatchObject({ pagination: 'false', 'siteId[]': [7] })
})
it('ne passe pas siteId[] quand aucun site (liste complète)', async () => {
const { load } = useWeighingTicketReferentials()
await load(null)
const clientsCall = getMock.mock.calls.find(c => c[0] === '/clients')
expect(clientsCall?.[1]).not.toHaveProperty('siteId[]')
})
})
```
- [ ] **Step 2 : lancer → échec**
`make nuxt-test` (ou ciblé) → FAIL (`load` n'accepte pas d'argument / `siteId[]` absent).
- [ ] **Step 3 : implémentation**
Modifier `fetchAll` et `load` :
```ts
/** Récupère une collection complète (pagination désactivée) en Hydra, filtrée site si fourni. */
async function fetchAll(url: string, siteId?: number | null): Promise<PartyMember[]> {
const query: Record<string, unknown> = { pagination: 'false' }
// Filtre par site courant (ERP-208) : un tiers est rattaché à un site via
// les sites de ses adresses. Param `siteId[]` déjà géré par les providers M1/M2.
if (siteId !== null && siteId !== undefined) {
query['siteId[]'] = [siteId]
}
const res = await api.get<{ member?: PartyMember[] }>(
url,
query,
{ headers: LD_JSON_HEADERS, toast: false },
)
return res.member ?? []
}
async function load(siteId?: number | null): Promise<void> {
await Promise.allSettled([
fetchAll('/clients', siteId).then((list) => {
clients.value = list.map(c => ({ value: c['@id'], label: c.companyName }))
}),
fetchAll('/suppliers', siteId).then((list) => {
suppliers.value = list.map(s => ({ value: s['@id'], label: s.companyName }))
}),
])
}
```
- [ ] **Step 4 : lancer → succès**
`make nuxt-test` ciblé sur le spec → PASS.
---
### Task 4 : Brancher site courant + recharge dans new.vue et edit.vue (front)
**Files:**
- Modify: `frontend/modules/logistique/pages/weighing-tickets/new.vue`
- Modify: `frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue`
- Test: `frontend/modules/logistique/pages/__tests__/weighingTicketNew.spec.ts` (étendre)
**Interfaces:**
- Consumes: `useCurrentSite().currentSite` (ref `Site | null`), `useWeighingTicketReferentials().load(siteId?)`, `form.clientIri` / `form.supplierIri` / `referentials.clients` / `referentials.suppliers`.
- [ ] **Step 1 : helper de reset partagé**
Logique commune aux deux pages : après recharge, vider le tiers sélectionné s'il n'est plus dans les options. Implémenté inline dans chaque page (2 lignes) — pas de nouveau composable pour si peu.
- [ ] **Step 2 : new.vue — brancher currentSite + watch**
Remplacer le bloc `onMounted` final :
```ts
const { currentSite } = useCurrentSite()
/** Recharge les référentiels pour le site donné puis purge le tiers devenu hors-site (ERP-208). */
async function reloadReferentials(siteId: number | null): Promise<void> {
await referentials.load(siteId)
if (form.clientIri.value && !referentials.clients.value.some(o => o.value === form.clientIri.value)) {
form.clientIri.value = null
}
if (form.supplierIri.value && !referentials.suppliers.value.some(o => o.value === form.supplierIri.value)) {
form.supplierIri.value = null
}
}
onMounted(() => {
reloadReferentials(currentSite.value?.id ?? null).catch(() => {})
})
// Changement de site pendant la saisie → recharge les listes du nouveau site (ERP-208).
watch(() => currentSite.value?.id, (siteId) => {
reloadReferentials(siteId ?? null).catch(() => {})
})
```
Ajouter `watch` à l'import `vue` et `useCurrentSite` (auto-importé Nuxt — sinon import explicite `~/modules/sites/composables/useCurrentSite`).
- [ ] **Step 3 : edit.vue — même branchement**
Adapter le `onMounted` async existant (qui fait aussi `fetchTicket`/`hydrate`) :
```ts
const { currentSite } = useCurrentSite()
async function reloadReferentials(siteId: number | null): Promise<void> {
await referentials.load(siteId)
if (form.clientIri.value && !referentials.clients.value.some(o => o.value === form.clientIri.value)) {
form.clientIri.value = null
}
if (form.supplierIri.value && !referentials.suppliers.value.some(o => o.value === form.supplierIri.value)) {
form.supplierIri.value = null
}
}
onMounted(async () => {
reloadReferentials(currentSite.value?.id ?? null).catch(() => {})
try {
const detail = await fetchTicket(ticketId)
ticketNumber.value = detail.number ?? ''
form.hydrate(detail)
}
catch {
error.value = true
}
finally {
loading.value = false
}
})
watch(() => currentSite.value?.id, (siteId) => {
reloadReferentials(siteId ?? null).catch(() => {})
})
```
- [ ] **Step 4 : étendre le spec front**
Dans `weighingTicketNew.spec.ts`, ajouter un cas vérifiant que `load` est appelé avec l'id du site courant au montage (mock `useCurrentSite` retournant un `currentSite` avec `id`). Adapter au style de mock déjà en place dans le fichier.
- [ ] **Step 5 : lancer les tests front**
`make nuxt-test` → PASS (specs new/edit + referentials).
---
## Vérification finale
- [ ] `make test` (back) — vert.
- [ ] `make nuxt-test` (front) — vert.
- [ ] `make php-cs-fixer-allow-risky` — pas de diff non voulu.
- [ ] **STOP** : remettre la main à Tristan pour les tests manuels (impression PDF + switch de site). Commits différés jusqu'à sa demande.
## Self-review (couverture spec)
- Point 1 (cartouche PDF nom seul) → Task 1 + Task 2. ✓
- Point 2 (filtre site + recharge au switch + reset-si-absent) → Task 3 + Task 4. ✓
- Définition « lié au site » via adresses → param `siteId[]` (back déjà OK). ✓
- Portée ticket-seulement (pas de modif répertoires) → on n'édite que le composable du ticket + ses pages. ✓
- Pas de migration / RBAC / E2E. ✓