fix(logistique) : bon de pesée — cartouche tiers + filtrage des listes contrepartie par site (ERP-208) (#155)
Auto Tag Develop / tag (push) Successful in 14s
Auto Tag Develop / tag (push) Successful in 14s
## ERP-208 — Fix ticket de pesée ### Bon de pesée (PDF) Ajout d'un **cartouche bordé en haut à droite** du bon de pesée, contenant le **type de contrepartie** (Client / Fournisseur / Autre, en gras au-dessus) et le **nom du tiers**. - `WeighingTicket::getCounterpartyName()` + `getCounterpartyTypeLabel()` (testés). - En-tête du template passé en table 2 colonnes (contrainte Dompdf CSS 2.1). ### Écran de saisie (Ajouter / Modifier) Les listes **Client / Fournisseur** sont **filtrées sur le site courant** (un tiers est rattaché à un site via les sites de ses adresses) et **rechargées au changement de site**. - Réutilise le filtre back existant `?siteId[]=` de /clients et /suppliers (aucun changement back sur le filtre). - Au switch de site : le tiers sélectionné est réinitialisé **uniquement** s'il sort du périmètre du nouveau site. - Portée limitée au ticket de pesée : les répertoires M1/M2 ne changent pas. ### Tests - Back : test unitaire `WeighingTicketCounterpartyNameTest` (nom + libellé) ; test PDF existant inchangé. - Front : specs référentiels + écrans Ajouter/Modifier (673/673). - Pas de migration, pas de RBAC, pas d'E2E. ### À vérifier en recette En **modification**, si le tiers d'un ticket n'a pas d'adresse sur le site courant, le select peut s'afficher vide (valeur conservée mais option filtrée). Reviewed-on: #155 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #155.
This commit is contained in:
@@ -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. ✓
|
||||
@@ -0,0 +1,124 @@
|
||||
# ERP-208 — Fix ticket de pesée
|
||||
|
||||
> Module : **Logistique (M5)** — écrans « Ajouter / Modifier un ticket de pesée » + bon de pesée imprimé (PDF).
|
||||
> Branche : `fix/erp-208-ticket-pesee`.
|
||||
> Date : 2026-06-25.
|
||||
|
||||
## 1. Contexte
|
||||
|
||||
Le ticket de pesée (M5) est implémenté (ERP-181 → ERP-193). Deux retours client sont
|
||||
regroupés dans ce fix :
|
||||
|
||||
1. **Bon de pesée PDF** : il manque un **cartouche bordé en haut à droite** de la
|
||||
page contenant le **nom du tiers** (client / fournisseur / champ « autre »). Le
|
||||
PDF actuel n'affiche que l'identité société (SA LIOT) en haut à gauche.
|
||||
2. **Écran de saisie** : quand l'utilisateur a **plusieurs sites autorisés**, les
|
||||
listes déroulantes Client / Fournisseur doivent être **filtrées sur le site
|
||||
courant** (le tiers est rattaché à un site via les sites de ses adresses), et
|
||||
**rechargées si l'utilisateur change de site** en restant sur la page.
|
||||
|
||||
## 2. État du code existant (constats de cadrage)
|
||||
|
||||
- **Le tiers n'a pas de site en propre.** Client (M1) et Supplier (M2) sont
|
||||
rattachés à un site **via les sites de leurs adresses** (`getSites()` agrège ;
|
||||
RG-2.06). « Lié au site » = a au moins une adresse rattachée à ce site.
|
||||
- **Le filtre back existe déjà.** `ClientProvider` / `SupplierProvider` lisent un
|
||||
filtre répétable `?siteId[]=<id>` (drawers des répertoires M1/M2) et le délèguent
|
||||
à `createListQueryBuilder(..., array $siteIds, ...)` → `applySiteIds()` qui joint
|
||||
`addresses → sites` (`site3.id IN (:siteIds)` / `site4.id IN (:siteIds)`).
|
||||
**Aucun travail back n'est nécessaire pour le filtre.**
|
||||
- **La donnée du PDF est déjà chargée.** `DoctrineWeighingTicketRepository::findById()`
|
||||
fetch-joine `client` et `supplier` ; le `WeighingTicketPrintProvider` charge le
|
||||
ticket par cette méthode. Le template a donc accès au nom du tiers.
|
||||
- **Le changement de site est global** (`SiteSelector` header → `useCurrentSite.switchSite`
|
||||
→ `PATCH /me/current-site` + `loadSidebar()` + `refreshNuxtData()`). `currentSite`
|
||||
est un ref singleton de module. Les référentiels du ticket sont chargés en
|
||||
`onMounted` **uniquement** (pas via `useAsyncData`) → ils ne se rechargent pas au
|
||||
switch : **c'est le bug du point 2.**
|
||||
- Le template PDF (`templates/logistique/weighing_ticket_print.html.twig`) est rendu
|
||||
par Dompdf → **CSS 2.1 uniquement (pas de flexbox/grid)**, mise en page par tableaux.
|
||||
|
||||
## 3. Décisions (validées avec Tristan)
|
||||
|
||||
| Sujet | Décision |
|
||||
|---|---|
|
||||
| Définition « lié au site » | Tiers ayant ≥ 1 adresse rattachée au site sélectionné (via les adresses). |
|
||||
| Portée du filtre | **Ticket de pesée seulement.** On ne modifie PAS le comportement des répertoires M1/M2 (déjà validés). On réutilise le param `?siteId[]=` existant côté front. |
|
||||
| Switch de site avec tiers sélectionné | **Reset si absent** : après rechargement, si le tiers sélectionné n'est plus dans la liste du nouveau site, on vide sa valeur (le type de contrepartie reste). S'il y est encore, on le garde. |
|
||||
| Contenu du cartouche PDF | **Nom seul** (pas de libellé « Client » / « Fournisseur » au-dessus). |
|
||||
|
||||
## 4. Conception
|
||||
|
||||
### 4.1 Point 1 — Cartouche tiers sur le bon de pesée (back + template)
|
||||
|
||||
**a. Résolution du nom — `WeighingTicket::getCounterpartyName(): ?string`**
|
||||
|
||||
Nouvelle méthode sur l'entité qui retourne, selon `counterpartyType` :
|
||||
- `CLIENT` → `client?->getCompanyName()`
|
||||
- `FOURNISSEUR` → `supplier?->getCompanyName()`
|
||||
- `AUTRE` → `otherLabel`
|
||||
- défaut → `null`
|
||||
|
||||
Rationale : garde le Twig « bête » (un seul `{{ ticket.counterpartyName }}`) et rend
|
||||
la logique testable unitairement, sans toucher le provider ni le renderer.
|
||||
|
||||
**b. Template `weighing_ticket_print.html.twig`**
|
||||
|
||||
Passer le bloc d'en-tête en **table 2 colonnes** (contrainte Dompdf CSS 2.1) :
|
||||
- colonne gauche (`width:auto`, `vertical-align:top`) : logo + identité société
|
||||
(contenu **inchangé**) ;
|
||||
- colonne droite (`text-align:right`, `vertical-align:top`) : un cartouche
|
||||
`border:1px solid #000; padding:8px;` (largeur fixe, ~200px) contenant
|
||||
`{{ ticket.counterpartyName }}` (nom seul, en gras).
|
||||
|
||||
Le reste du template (titre, table des pesées, poids net) est inchangé.
|
||||
|
||||
Cas `counterpartyName` null : en pratique l'impression a lieu après validation, où la
|
||||
contrepartie est requise (groupe `finalize`). Par robustesse, si null → ne pas rendre
|
||||
le cartouche (pas de cadre vide).
|
||||
|
||||
**c. Provider / renderer** : aucun changement (relations déjà fetch-jointes).
|
||||
|
||||
### 4.2 Point 2 — Listes filtrées par site + recharge au switch (front uniquement)
|
||||
|
||||
**a. `useWeighingTicketReferentials.ts`**
|
||||
|
||||
`load()` accepte un identifiant de site optionnel et l'injecte comme `siteId[]` dans
|
||||
les requêtes `/clients` et `/suppliers` (en plus de `pagination=false`) :
|
||||
- site fourni → `{ pagination: 'false', 'siteId[]': [siteId] }` ;
|
||||
- site absent (`null`) → comportement actuel (liste complète, dégradé gracieux).
|
||||
|
||||
**b. Pages `weighing-tickets/new.vue` et `weighing-tickets/[id]/edit.vue`**
|
||||
|
||||
- récupèrent `currentSite` via `useCurrentSite()` ;
|
||||
- `onMounted` → `referentials.load(currentSite.value?.id ?? null)` ;
|
||||
- `watch(currentSite)` (sur l'id) → `referentials.load(newId)` puis **reset-si-absent** :
|
||||
- si `form.clientIri` est défini et absent de `referentials.clients` → `form.clientIri = null` ;
|
||||
- si `form.supplierIri` est défini et absent de `referentials.suppliers` → `form.supplierIri = null` ;
|
||||
- `counterpartyType` et `otherLabel` ne sont pas touchés.
|
||||
|
||||
Note : le reset s'appuie sur les options (IRI `@id`) renvoyées par le référentiel ;
|
||||
la comparaison se fait sur `value` (l'IRI Hydra).
|
||||
|
||||
**c. Cohérence avec la liste des tickets** : la liste `/weighing_tickets` est déjà
|
||||
cloisonnée par site (provider M5). Filtrer les selects sur le site courant aligne la
|
||||
saisie sur la liste.
|
||||
|
||||
## 5. Tests & vérification
|
||||
|
||||
| Niveau | Test | Contenu |
|
||||
|---|---|---|
|
||||
| Back (PHPUnit) | unitaire `WeighingTicket::getCounterpartyName()` | 3 cas : CLIENT → companyName, FOURNISSEUR → companyName, AUTRE → otherLabel ; + null si type absent. |
|
||||
| Back | `WeighingTicketPrintApiTest` (existant) | reste vert (`%PDF`, content-type, disposition). |
|
||||
| Front (Vitest) | `weighingTicketNew.spec.ts` / `weighingTicketEdit.spec.ts` | `load` passe `siteId[]` quand un site courant existe ; au changement de `currentSite` → rechargement + reset-si-absent du tiers sélectionné. |
|
||||
|
||||
Commandes : `make test` + `make nuxt-test` + `make php-cs-fixer-allow-risky`.
|
||||
Pas de test E2E (règle d'or : Vitest privilégié).
|
||||
|
||||
## 6. Hors périmètre / non-objectifs
|
||||
|
||||
- Pas de modification du comportement des répertoires Clients / Fournisseurs (M1/M2).
|
||||
- Pas de nouvelle permission RBAC, pas de migration, pas de changement de schéma.
|
||||
- Pas de cloisonnement par site « global » sur `/clients` et `/suppliers` (rejeté :
|
||||
on garde le filtre opt-in via `?siteId[]`).
|
||||
- L'identité société du PDF reste fixe (décision ERP-192, ne change pas selon le site).
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useWeighingTicketReferentials } from '../useWeighingTicketReferentials'
|
||||
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
|
||||
/**
|
||||
* Tests des référentiels Client/Fournisseur de l'écran ticket de pesée (M5).
|
||||
* Contrat couvert (ERP-208) : `load(siteId)` filtre les deux endpoints par site
|
||||
* courant via `siteId[]` ; sans site → listes complètes (param absent).
|
||||
*/
|
||||
describe('useWeighingTicketReferentials', () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset()
|
||||
mockApiGet.mockResolvedValue({ member: [] })
|
||||
})
|
||||
|
||||
it('passe siteId[] aux deux endpoints quand un site courant est fourni', async () => {
|
||||
const { load } = useWeighingTicketReferentials()
|
||||
await load(7)
|
||||
|
||||
const clientsCall = mockApiGet.mock.calls.find(c => c[0] === '/clients')
|
||||
const suppliersCall = mockApiGet.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 = mockApiGet.mock.calls.find(c => c[0] === '/clients')
|
||||
expect(clientsCall?.[1]).not.toHaveProperty('siteId[]')
|
||||
expect(clientsCall?.[1]).toMatchObject({ pagination: 'false' })
|
||||
})
|
||||
|
||||
it('mappe les membres Hydra en options { value: @id, label: companyName }', async () => {
|
||||
mockApiGet.mockResolvedValue({ member: [{ '@id': '/api/clients/3', companyName: 'ACME' }] })
|
||||
const { load, clients } = useWeighingTicketReferentials()
|
||||
await load(7)
|
||||
|
||||
expect(clients.value).toEqual([{ value: '/api/clients/3', label: 'ACME' }])
|
||||
})
|
||||
})
|
||||
@@ -32,11 +32,19 @@ export function useWeighingTicketReferentials() {
|
||||
const clients = ref<RefOption[]>([])
|
||||
const suppliers = ref<RefOption[]>([])
|
||||
|
||||
/** Récupère une collection complète (pagination désactivée) en Hydra. */
|
||||
async function fetchAll(url: string): Promise<PartyMember[]> {
|
||||
/**
|
||||
* Récupère une collection complète (pagination désactivée) en Hydra. Filtre par
|
||||
* site courant si `siteId` est fourni (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.
|
||||
*/
|
||||
async function fetchAll(url: string, siteId?: number | null): Promise<PartyMember[]> {
|
||||
const query: Record<string, unknown> = { pagination: 'false' }
|
||||
if (siteId !== null && siteId !== undefined) {
|
||||
query['siteId[]'] = [siteId]
|
||||
}
|
||||
const res = await api.get<{ member?: PartyMember[] }>(
|
||||
url,
|
||||
{ pagination: 'false' },
|
||||
query,
|
||||
{ headers: LD_JSON_HEADERS, toast: false },
|
||||
)
|
||||
return res.member ?? []
|
||||
@@ -45,14 +53,15 @@ export function useWeighingTicketReferentials() {
|
||||
/**
|
||||
* Charge en parallèle clients + fournisseurs (résilient : un référentiel en
|
||||
* échec — ex. 403 selon le rôle — laisse simplement son select vide sans
|
||||
* faire échouer l'autre).
|
||||
* faire échouer l'autre). `siteId` (site courant) filtre les listes par site
|
||||
* (ERP-208) ; absent → listes complètes.
|
||||
*/
|
||||
async function load(): Promise<void> {
|
||||
async function load(siteId?: number | null): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
fetchAll('/clients').then((list) => {
|
||||
fetchAll('/clients', siteId).then((list) => {
|
||||
clients.value = list.map(c => ({ value: c['@id'], label: c.companyName }))
|
||||
}),
|
||||
fetchAll('/suppliers').then((list) => {
|
||||
fetchAll('/suppliers', siteId).then((list) => {
|
||||
suppliers.value = list.map(s => ({ value: s['@id'], label: s.companyName }))
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -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' }),
|
||||
@@ -100,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 () => {
|
||||
@@ -107,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 ».
|
||||
|
||||
@@ -7,9 +7,10 @@ const mockPost = 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/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' }),
|
||||
@@ -23,6 +24,8 @@ 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) : id 7 → les référentiels doivent être chargés filtrés sur ce site.
|
||||
vi.stubGlobal('useCurrentSite', () => ({ currentSite: ref({ id: 7, name: 'Site 7', color: '#000000' }) }))
|
||||
globalThis.open = mockOpen
|
||||
|
||||
const NewPage = (await import('../weighing-tickets/new.vue')).default
|
||||
@@ -70,6 +73,12 @@ describe('Écran Ajouter ticket de pesée (page /weighing-tickets/new)', () => {
|
||||
mockPatch.mockReset().mockResolvedValue({})
|
||||
mockPush.mockReset()
|
||||
mockOpen.mockReset()
|
||||
mockRefLoad.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('charge les référentiels filtrés sur le site courant au montage (ERP-208)', async () => {
|
||||
await mountPage()
|
||||
expect(mockRefLoad).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('un seul bouton « Valider » (pas de « Enregistrer » séparé)', async () => {
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
|
||||
import { useWeighingTicket } from '~/modules/logistique/composables/useWeighingTicket'
|
||||
import { useWeighingTicket, type WeighingTicketDetail } from '~/modules/logistique/composables/useWeighingTicket'
|
||||
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
||||
import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
|
||||
import { mapViolationsToRecord } from '~/shared/utils/api'
|
||||
@@ -404,12 +404,34 @@ function printTicket(): void {
|
||||
window.open(`/api/weighing_tickets/${ticketId}/print.pdf`, '_blank')
|
||||
}
|
||||
|
||||
/**
|
||||
* Garantit que la contrepartie DÉJÀ ENREGISTRÉE (hydratée depuis le ticket) reste
|
||||
* affichée même si la liste filtrée par site ne la contient pas (ticket antérieur
|
||||
* à ERP-208, droits restreints sur /clients, contrepartie hors site…) : on injecte
|
||||
* son option plutôt que de la purger. Évite toute perte silencieuse de la
|
||||
* contrepartie en édition (ERP-208, retour review).
|
||||
*/
|
||||
function ensureSelectedOptionPresent(detail: WeighingTicketDetail): void {
|
||||
const client = detail.client
|
||||
if (client && !referentials.clients.value.some(o => o.value === client['@id'])) {
|
||||
referentials.clients.value.push({ value: client['@id'], label: client.companyName })
|
||||
}
|
||||
const supplier = detail.supplier
|
||||
if (supplier && !referentials.suppliers.value.some(o => o.value === supplier['@id'])) {
|
||||
referentials.suppliers.value.push({ value: supplier['@id'], label: supplier.companyName })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
referentials.load().catch(() => {})
|
||||
try {
|
||||
const detail = await fetchTicket(ticketId)
|
||||
ticketNumber.value = detail.number ?? ''
|
||||
form.hydrate(detail)
|
||||
// Listes filtrées sur le SITE DU TICKET (immuable, RG-5.09) — pas le site
|
||||
// courant — et chargées APRÈS hydrate pour ne jamais purger la sélection
|
||||
// existante (pas de race load/hydrate, ERP-208).
|
||||
await referentials.load(detail.site?.id ?? null)
|
||||
ensureSelectedOptionPresent(detail)
|
||||
}
|
||||
catch {
|
||||
error.value = true
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
|
||||
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
||||
@@ -375,7 +375,28 @@ async function submitValidate(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const { currentSite } = useCurrentSite()
|
||||
|
||||
/**
|
||||
* Recharge les référentiels Client/Fournisseur pour le site donné, puis purge le
|
||||
* tiers sélectionné s'il n'appartient plus à la liste du nouveau 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(() => {
|
||||
referentials.load().catch(() => {})
|
||||
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(() => {})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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')
|
||||
@@ -458,6 +467,21 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nom du tiers à afficher (cartouche du 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) {
|
||||
self::COUNTERPARTY_CLIENT => $this->client?->getCompanyName(),
|
||||
self::COUNTERPARTY_FOURNISSEUR => $this->supplier?->getCompanyName(),
|
||||
self::COUNTERPARTY_AUTRE => $this->otherLabel,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function getImmatriculation(): ?string
|
||||
{
|
||||
return $this->immatriculation;
|
||||
|
||||
@@ -27,6 +27,19 @@
|
||||
.company-name { font-weight: bold; font-size: 12px; }
|
||||
.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). 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-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; }
|
||||
|
||||
/* Lignes des deux pesées : tableau sans bordure, colonnes alignées. */
|
||||
@@ -41,13 +54,34 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% if logoSrc %}
|
||||
<div class="logo"><img src="{{ logoSrc }}" alt="LPC LIOT"></div>
|
||||
{% endif %}
|
||||
{# Libellé FR du type de contrepartie (couche de rendu, pas le Domain — ERP-208). #}
|
||||
{% set counterpartyLabels = { 'CLIENT': 'Client', 'FOURNISSEUR': 'Fournisseur', 'AUTRE': 'Autre' } %}
|
||||
|
||||
<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>
|
||||
<table class="header">
|
||||
<tr>
|
||||
<td class="h-left">
|
||||
{% 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>
|
||||
{# 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é. #}
|
||||
<td class="h-right">
|
||||
{% if ticket.counterpartyType %}
|
||||
<div class="party-box">
|
||||
<div class="party-label">{{ counterpartyLabels[ticket.counterpartyType] ?? ticket.counterpartyType }} :</div>
|
||||
{% if ticket.counterpartyName %}
|
||||
<div class="party-name">{{ ticket.counterpartyName }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="title">Ticket de pesée</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Couvre WeighingTicket::getCounterpartyName() (ERP-208) : nom du tiers affiché
|
||||
* dans le cartouche du bon de pesée selon le type de contrepartie (RG-5.03).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user