Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f619a6969d | |||
| 64c3b9b6ec | |||
| ce0e274743 | |||
| f12a378126 | |||
| 04008f97a9 | |||
| 086be7b4f0 |
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.153'
|
||||
app.version: '0.1.154'
|
||||
|
||||
@@ -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).
|
||||
@@ -53,7 +53,7 @@
|
||||
},
|
||||
"catalog": {
|
||||
"categories": "Gestion des catégories",
|
||||
"products": "Catalogue produit"
|
||||
"products": "Produits"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -1020,6 +1020,65 @@
|
||||
"duplicate": "Une catégorie nommée « {name} » existe déjà.",
|
||||
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
|
||||
}
|
||||
},
|
||||
"products": {
|
||||
"title": "Catalogue produit",
|
||||
"add": "Ajouter",
|
||||
"export": "Exporter",
|
||||
"empty": "Aucun produit pour l'instant.",
|
||||
"column": {
|
||||
"name": "Nom",
|
||||
"code": "Numéro",
|
||||
"category": "Catégorie"
|
||||
},
|
||||
"state": {
|
||||
"PURCHASE": "Achat",
|
||||
"SALE": "Vendu",
|
||||
"OTHER": "Autre"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"search": "Recherche",
|
||||
"category": "Catégorie",
|
||||
"categoryAll": "Toutes les catégories",
|
||||
"state": "État",
|
||||
"stateAll": "Tous les états",
|
||||
"site": "Sites",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
"form": {
|
||||
"title": "Ajouter un produit",
|
||||
"back": "Retour au catalogue",
|
||||
"submit": "Valider",
|
||||
"states": "État du produit",
|
||||
"sites": "Site",
|
||||
"name": "Nom du produit",
|
||||
"code": "Code produit",
|
||||
"category": "Catégorie produit",
|
||||
"storageTypes": "Type de stockage",
|
||||
"manufactured": "Fabriqué",
|
||||
"containsMolasses": "Contient de la mélasse",
|
||||
"duplicateCode": "Un produit portant ce code existe déjà."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier le produit",
|
||||
"back": "Retour au catalogue",
|
||||
"save": "Enregistrer",
|
||||
"loading": "Chargement du produit…",
|
||||
"notFound": "Produit introuvable."
|
||||
},
|
||||
"tab": {
|
||||
"suppliers": "Fournisseurs",
|
||||
"clients": "Clients",
|
||||
"placeholder": "Cet onglet est en cours de développement"
|
||||
},
|
||||
"toast": {
|
||||
"error": "Une erreur est survenue. Réessayez.",
|
||||
"exportError": "L'export du catalogue produit a échoué. Réessayez.",
|
||||
"createSuccess": "Produit créé avec succès",
|
||||
"updateSuccess": "Produit mis à jour avec succès"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<!--
|
||||
Onglets « Fournisseurs » / « Clients » de la fiche produit — HORS PERIMETRE
|
||||
V0 (HP-M6-01, RG-6.10) : ils dependent d'un module Contrat inexistant.
|
||||
Rendu en placeholder « en cours de développement » (meme composant que les
|
||||
onglets non-dev des fiches M1→M4). AUCUN appel API, AUCUN champ saisissable.
|
||||
Affiches sans condition d'etat (a raffiner avec le module Contrat).
|
||||
-->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||
<template #suppliers><ComingSoonPlaceholder :title="t('admin.products.tab.placeholder')" /></template>
|
||||
<template #clients><ComingSoonPlaceholder :title="t('admin.products.tab.placeholder')" /></template>
|
||||
</MalioTabList>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const activeTab = ref('suppliers')
|
||||
|
||||
// Icone (Iconify) par onglet, alignee sur la convention des fiches existantes.
|
||||
const tabs = computed(() => [
|
||||
{ key: 'suppliers', label: t('admin.products.tab.suppliers'), icon: 'mdi:truck-outline' },
|
||||
{ key: 'clients', label: t('admin.products.tab.clients'), icon: 'mdi:account-group-outline' },
|
||||
])
|
||||
</script>
|
||||
@@ -0,0 +1,287 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||
import { useProductForm } from '../useProductForm'
|
||||
|
||||
// Stubs des auto-imports Nuxt consommes par le composable + ses dependances.
|
||||
const mockGet = vi.hoisted(() => vi.fn())
|
||||
const mockPost = vi.hoisted(() => vi.fn())
|
||||
const mockPatch = vi.hoisted(() => vi.fn())
|
||||
const mockToastSuccess = vi.hoisted(() => vi.fn())
|
||||
const mockToastError = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useApi', () => ({
|
||||
get: mockGet,
|
||||
post: mockPost,
|
||||
put: vi.fn(),
|
||||
patch: mockPatch,
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
vi.stubGlobal('useToast', () => ({
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
}))
|
||||
// useFormErrors est un auto-import Nuxt : on expose l'implementation reelle
|
||||
// (elle consomme useToast/useI18n deja stubbes) pour tester l'integration 422/409.
|
||||
vi.stubGlobal('useFormErrors', useFormErrors)
|
||||
vi.stubGlobal('useI18n', () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}::${JSON.stringify(params)}` : key,
|
||||
}))
|
||||
|
||||
/** Reponse Hydra des types de stockage selon les sites demandes. */
|
||||
function storageMembersForSites(siteIds: string[]): { member: Array<{ '@id': string, label: string }> } {
|
||||
// Site 1 → types 9 et 5 ; site 2 → type 7. Permet de tester la cascade.
|
||||
const byId: Record<string, Array<{ '@id': string, label: string }>> = {
|
||||
'1': [
|
||||
{ '@id': '/api/storage_types/9', label: 'Tas' },
|
||||
{ '@id': '/api/storage_types/5', label: 'Cellule' },
|
||||
],
|
||||
'2': [{ '@id': '/api/storage_types/7', label: 'Cuve mélasse' }],
|
||||
}
|
||||
const seen = new Map<string, { '@id': string, label: string }>()
|
||||
for (const id of siteIds) {
|
||||
for (const m of byId[id] ?? []) {
|
||||
seen.set(m['@id'], m)
|
||||
}
|
||||
}
|
||||
return { member: [...seen.values()] }
|
||||
}
|
||||
|
||||
describe('useProductForm', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
mockToastSuccess.mockReset()
|
||||
mockToastError.mockReset()
|
||||
|
||||
// Routage des GET par url (referentiels + cascade stockage).
|
||||
mockGet.mockImplementation((url: string, query: Record<string, unknown> = {}) => {
|
||||
if (url === '/sites') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
|
||||
}
|
||||
if (url === '/categories') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/categories/12', name: 'Céréales' }] })
|
||||
}
|
||||
if (url === '/storage_types') {
|
||||
const raw = (query['siteId[]'] ?? []) as string[]
|
||||
return Promise.resolve(storageMembersForSites(raw))
|
||||
}
|
||||
return Promise.resolve({ member: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('RG-6.03 — champs conditionnels « Vendu »', () => {
|
||||
it('isSale est vrai uniquement si states contient SALE', () => {
|
||||
const { form, isSale } = useProductForm()
|
||||
expect(isSale.value).toBe(false)
|
||||
form.states = ['PURCHASE']
|
||||
expect(isSale.value).toBe(false)
|
||||
form.states = ['PURCHASE', 'SALE']
|
||||
expect(isSale.value).toBe(true)
|
||||
})
|
||||
|
||||
it('remet manufactured / containsMolasses a false quand SALE est retire', async () => {
|
||||
const { form, isSale } = useProductForm()
|
||||
form.states = ['SALE']
|
||||
form.manufactured = true
|
||||
form.containsMolasses = true
|
||||
await nextTick()
|
||||
expect(isSale.value).toBe(true)
|
||||
|
||||
form.states = ['PURCHASE']
|
||||
await nextTick()
|
||||
expect(form.manufactured).toBe(false)
|
||||
expect(form.containsMolasses).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RG-6.06 — cascade Site → Type de stockage', () => {
|
||||
it('charge les types de stockage filtres par les sites selectionnes', async () => {
|
||||
const { storageTypeOptions, setSites } = useProductForm()
|
||||
await setSites(['/api/sites/1'])
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/storage_types',
|
||||
expect.objectContaining({ 'siteId[]': ['1'], pagination: 'false' }),
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(storageTypeOptions.value.map(o => o.value)).toEqual([
|
||||
'/api/storage_types/9',
|
||||
'/api/storage_types/5',
|
||||
])
|
||||
})
|
||||
|
||||
it('retire de la selection les types devenus indisponibles', async () => {
|
||||
const { form, setStorageTypes, setSites } = useProductForm()
|
||||
|
||||
// Selection initiale sur le site 1 (types 9 et 5).
|
||||
await setSites(['/api/sites/1'])
|
||||
setStorageTypes(['/api/storage_types/9', '/api/storage_types/5'])
|
||||
|
||||
// Bascule vers le site 2 (type 7 seul) : 9 et 5 ne sont plus dispo.
|
||||
await setSites(['/api/sites/2'])
|
||||
expect(form.storageTypeIris).toEqual([])
|
||||
})
|
||||
|
||||
it('vide options + selection quand plus aucun site n\'est selectionne', async () => {
|
||||
const { form, storageTypeOptions, setStorageTypes, setSites } = useProductForm()
|
||||
await setSites(['/api/sites/1'])
|
||||
setStorageTypes(['/api/storage_types/9'])
|
||||
|
||||
await setSites([])
|
||||
expect(storageTypeOptions.value).toEqual([])
|
||||
expect(form.storageTypeIris).toEqual([])
|
||||
// Pas d'appel /storage_types inutile sans site.
|
||||
expect(mockGet).not.toHaveBeenCalledWith('/storage_types', expect.objectContaining({ 'siteId[]': [] }), expect.any(Object))
|
||||
})
|
||||
})
|
||||
|
||||
describe('submit — POST /products', () => {
|
||||
function fillValidForm(form: ReturnType<typeof useProductForm>['form']): void {
|
||||
form.code = 'ble-01'
|
||||
form.name = 'Blé tendre'
|
||||
form.states = ['PURCHASE', 'SALE']
|
||||
form.siteIris = ['/api/sites/1']
|
||||
form.categoryIri = '/api/categories/12'
|
||||
form.storageTypeIris = ['/api/storage_types/9']
|
||||
form.manufactured = true
|
||||
form.containsMolasses = false
|
||||
}
|
||||
|
||||
it('poste le payload (relations en IRI) et retourne true au succes', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 34 })
|
||||
const { form, submit } = useProductForm()
|
||||
fillValidForm(form)
|
||||
|
||||
const ok = await submit()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/products',
|
||||
{
|
||||
code: 'ble-01',
|
||||
name: 'Blé tendre',
|
||||
states: ['PURCHASE', 'SALE'],
|
||||
manufactured: true,
|
||||
containsMolasses: false,
|
||||
category: '/api/categories/12',
|
||||
sites: ['/api/sites/1'],
|
||||
storageTypes: ['/api/storage_types/9'],
|
||||
},
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
expect(mockToastSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('force manufactured / containsMolasses a false hors « Vendu » (RG-6.03)', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 35 })
|
||||
const { form, submit } = useProductForm()
|
||||
fillValidForm(form)
|
||||
// L'utilisateur retire « Vendu » apres avoir coche les booleens.
|
||||
form.states = ['PURCHASE']
|
||||
|
||||
await submit()
|
||||
|
||||
const payload = mockPost.mock.calls[0][1]
|
||||
expect(payload.manufactured).toBe(false)
|
||||
expect(payload.containsMolasses).toBe(false)
|
||||
})
|
||||
|
||||
it('mappe un 409 doublon de code sur errors.code + toast explicite', async () => {
|
||||
mockPost.mockRejectedValueOnce({ response: { status: 409, _data: {} } })
|
||||
const { form, errors, submit } = useProductForm()
|
||||
fillValidForm(form)
|
||||
|
||||
const ok = await submit()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(errors.code).toBe('admin.products.form.duplicateCode')
|
||||
expect(mockToastError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mappe une 422 inline par champ (errors.code) sans toast', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'code', message: 'Le code produit est obligatoire.' }] },
|
||||
},
|
||||
})
|
||||
const { form, errors, submit } = useProductForm()
|
||||
fillValidForm(form)
|
||||
form.code = null
|
||||
|
||||
const ok = await submit()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(errors.code).toBe('Le code produit est obligatoire.')
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RG-6.08 — mode edition (prefill + PATCH)', () => {
|
||||
// Produit charge (memes cles que la reponse reelle § 4.0.bis : @id sur les relations).
|
||||
const PRODUCT = {
|
||||
id: 34,
|
||||
code: 'BLE-01',
|
||||
name: 'Blé tendre',
|
||||
states: ['PURCHASE', 'SALE'],
|
||||
manufactured: true,
|
||||
containsMolasses: false,
|
||||
category: { '@id': '/api/categories/12', id: 12, name: 'Céréales', code: 'CEREALES' },
|
||||
sites: [{ '@id': '/api/sites/1', id: 1, name: 'Chatellerault', code: '86', postalCode: '86100', city: 'C', color: '#000', fullAddress: 'x' }],
|
||||
storageTypes: [{ '@id': '/api/storage_types/9', id: 9, code: 'TAS', label: 'Tas' }],
|
||||
createdAt: '', updatedAt: '',
|
||||
}
|
||||
|
||||
it('pre-remplit le formulaire depuis le produit (relations en IRI) + charge le stockage', async () => {
|
||||
const { form, prefill, storageTypeOptions } = useProductForm()
|
||||
await prefill(PRODUCT)
|
||||
|
||||
expect(form.code).toBe('BLE-01')
|
||||
expect(form.name).toBe('Blé tendre')
|
||||
expect(form.states).toEqual(['PURCHASE', 'SALE'])
|
||||
expect(form.categoryIri).toBe('/api/categories/12')
|
||||
expect(form.siteIris).toEqual(['/api/sites/1'])
|
||||
expect(form.storageTypeIris).toEqual(['/api/storage_types/9'])
|
||||
expect(form.manufactured).toBe(true)
|
||||
// Cascade : options de stockage chargees pour le site du produit.
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/storage_types',
|
||||
expect.objectContaining({ 'siteId[]': ['1'] }),
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(storageTypeOptions.value.map(o => o.value)).toContain('/api/storage_types/9')
|
||||
})
|
||||
|
||||
it('soumet un PATCH /products/{id} apres prefill (RG-6.08)', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ id: 34 })
|
||||
const { prefill, submit } = useProductForm()
|
||||
await prefill(PRODUCT)
|
||||
|
||||
const ok = await submit()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/products/34',
|
||||
expect.objectContaining({ code: 'BLE-01', name: 'Blé tendre' }),
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
expect(mockToastSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mappe un 409 doublon de code aussi en edition', async () => {
|
||||
mockPatch.mockRejectedValueOnce({ response: { status: 409, _data: {} } })
|
||||
const { errors, prefill, submit } = useProductForm()
|
||||
await prefill(PRODUCT)
|
||||
|
||||
const ok = await submit()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(errors.code).toBe('admin.products.form.duplicateCode')
|
||||
expect(mockToastError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Product } from '~/modules/catalog/types/product'
|
||||
|
||||
/**
|
||||
* Chargement d'un produit unique (ecran « Modification produit », M6 — ERP-206).
|
||||
* Lit le detail via `GET /api/products/{id}` — meme structure que la ligne de
|
||||
* liste (category / sites / storageTypes embarques, § 4.0.bis).
|
||||
*
|
||||
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload
|
||||
* Hydra complet (IRI `@id` des relations, necessaires au pre-remplissage des
|
||||
* selects). Etat 100 % local a l'instance (refs) — aucune persistance URL.
|
||||
*/
|
||||
export function useProduct(id: number | string) {
|
||||
const api = useApi()
|
||||
|
||||
const product = ref<Product | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
|
||||
/** Charge le detail du produit. En cas d'echec : `error = true`, `product = null`. */
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
product.value = await api.get<Product>(
|
||||
`/products/${id}`,
|
||||
{},
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
}
|
||||
catch {
|
||||
error.value = true
|
||||
product.value = null
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { product, loading, error, load }
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Composable du formulaire de creation produit (M6 — ERP-205).
|
||||
*
|
||||
* Porte l'etat du formulaire principal, les referentiels des selects, les regles
|
||||
* de gestion front (champs conditionnels RG-6.03, cascade site→stockage RG-6.06)
|
||||
* et la soumission `POST /api/products` avec mapping des erreurs 422/409 inline
|
||||
* (useFormErrors). Reference : ecran « Ajouter un client » / « Ajouter un
|
||||
* prestataire » (formulaire principal).
|
||||
*
|
||||
* Etat 100 % local a l'instance.
|
||||
*/
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import {
|
||||
useSiteOptions,
|
||||
useCategoryOptions,
|
||||
useStorageTypeOptions,
|
||||
} from '~/modules/catalog/composables/useProductOptions'
|
||||
import type { Product } from '~/modules/catalog/types/product'
|
||||
|
||||
/** Etats produit (miroir de l'enum back Product::STATE_*). */
|
||||
export const PRODUCT_STATES = ['PURCHASE', 'SALE', 'OTHER'] as const
|
||||
|
||||
/** Extrait l'id numerique d'un IRI Hydra (`/api/sites/1` → 1), sinon null. */
|
||||
function iriToId(iri: string): number | null {
|
||||
const tail = iri.split('/').pop()
|
||||
return tail !== undefined && /^\d+$/.test(tail) ? Number(tail) : null
|
||||
}
|
||||
|
||||
export function useProductForm() {
|
||||
const api = useApi()
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const formErrors = useFormErrors()
|
||||
|
||||
const sites = useSiteOptions()
|
||||
const categories = useCategoryOptions({ typeCode: 'PRODUIT' })
|
||||
const storage = useStorageTypeOptions()
|
||||
|
||||
// ── Etat du formulaire ───────────────────────────────────────────────────
|
||||
// Les relations sont stockees en IRI (envoyees telles quelles au POST) ;
|
||||
// `states` porte les codes enum ; les booleens conditionnels RG-6.03 a part.
|
||||
const form = reactive({
|
||||
code: null as string | null,
|
||||
name: null as string | null,
|
||||
states: [] as string[],
|
||||
siteIris: [] as string[],
|
||||
categoryIri: null as string | null,
|
||||
storageTypeIris: [] as string[],
|
||||
manufactured: false,
|
||||
containsMolasses: false,
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
// Id du produit edite (null = creation). Pilote l'URL/methode du submit (RG-6.08 :
|
||||
// « Modification » = meme formulaire/regles que « Ajouter », bouton « Enregistrer »).
|
||||
const productId = ref<number | null>(null)
|
||||
|
||||
// RG-6.03 : « Fabriqué » / « Contient de la mélasse » saisissables uniquement
|
||||
// si l'etat contient « Vendu » (SALE).
|
||||
const isSale = computed(() => form.states.includes('SALE'))
|
||||
|
||||
// Quand l'etat ne contient plus SALE, on remet les booleens a false : le back
|
||||
// les forcerait de toute facon (RG-6.03), on evite de soumettre une valeur
|
||||
// fantome saisie avant de retirer « Vendu ».
|
||||
watch(isSale, (sale) => {
|
||||
if (!sale) {
|
||||
form.manufactured = false
|
||||
form.containsMolasses = false
|
||||
}
|
||||
})
|
||||
|
||||
/** Met a jour les etats (multi-select). */
|
||||
function setStates(states: string[]): void {
|
||||
form.states = states
|
||||
}
|
||||
|
||||
/** Met a jour la categorie (select simple). */
|
||||
function setCategory(iri: string | null): void {
|
||||
form.categoryIri = iri
|
||||
}
|
||||
|
||||
/** Met a jour les types de stockage (multi-select). */
|
||||
function setStorageTypes(iris: string[]): void {
|
||||
form.storageTypeIris = iris
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-6.06 (cascade) : a chaque changement de Site, recharge les options de Type
|
||||
* de stockage filtrees par les sites choisis et retire de la selection les
|
||||
* types devenus indisponibles.
|
||||
*/
|
||||
async function setSites(iris: string[]): Promise<void> {
|
||||
form.siteIris = iris
|
||||
const siteIds = iris
|
||||
.map(iriToId)
|
||||
.filter((id): id is number => id !== null)
|
||||
|
||||
await storage.load(siteIds)
|
||||
|
||||
const available = new Set(storage.options.value.map(o => o.value))
|
||||
form.storageTypeIris = form.storageTypeIris.filter(iri => available.has(iri))
|
||||
}
|
||||
|
||||
/** Charge les referentiels initiaux (sites + categories). Resilient. */
|
||||
async function loadReferentials(): Promise<void> {
|
||||
await Promise.allSettled([sites.load(), categories.load()])
|
||||
// Les types de stockage se chargent a la 1re selection de sites (cascade).
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-remplit le formulaire depuis un produit charge (mode edition, RG-6.08).
|
||||
* Les relations sont reprises via leur IRI `@id` (= valeur d'option des selects).
|
||||
* Charge au passage les options de Type de stockage pour les sites du produit,
|
||||
* afin que le multi-select affiche les libelles et conserve la selection.
|
||||
*/
|
||||
async function prefill(product: Product): Promise<void> {
|
||||
productId.value = product.id
|
||||
form.code = product.code
|
||||
form.name = product.name
|
||||
form.states = [...product.states]
|
||||
form.categoryIri = product.category?.['@id'] ?? null
|
||||
form.siteIris = product.sites.map(s => s['@id'])
|
||||
form.manufactured = product.manufactured
|
||||
form.containsMolasses = product.containsMolasses
|
||||
|
||||
const siteIds = form.siteIris
|
||||
.map(iriToId)
|
||||
.filter((id): id is number => id !== null)
|
||||
await storage.load(siteIds)
|
||||
form.storageTypeIris = product.storageTypes.map(st => st['@id'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Soumet le formulaire. Retourne true au succes (la page redirige), false sinon.
|
||||
* Creation → `POST /products` ; edition (productId non nul, RG-6.08) →
|
||||
* `PATCH /products/{id}` (mode merge-patch gere par useApi). 422 → mapping
|
||||
* inline par champ (useFormErrors) ; 409 doublon de code → erreur inline sur
|
||||
* `code` + toast explicite (RG-6.01, unicite re-validee aussi en edition).
|
||||
*/
|
||||
async function submit(): Promise<boolean> {
|
||||
if (submitting.value) {
|
||||
return false
|
||||
}
|
||||
submitting.value = true
|
||||
formErrors.clearErrors()
|
||||
const editing = productId.value !== null
|
||||
try {
|
||||
const payload = {
|
||||
code: form.code || null,
|
||||
name: form.name || null,
|
||||
states: form.states,
|
||||
// RG-6.03 : booleens forces a false hors « Vendu » (le back les
|
||||
// re-force, on garde le payload coherent).
|
||||
manufactured: isSale.value ? form.manufactured : false,
|
||||
containsMolasses: isSale.value ? form.containsMolasses : false,
|
||||
category: form.categoryIri,
|
||||
sites: form.siteIris,
|
||||
storageTypes: form.storageTypeIris,
|
||||
}
|
||||
const options = { headers: { Accept: 'application/ld+json' }, toast: false }
|
||||
if (editing) {
|
||||
await api.patch(`/products/${productId.value}`, payload, options)
|
||||
toast.success({ title: t('admin.products.toast.updateSuccess') })
|
||||
}
|
||||
else {
|
||||
await api.post('/products', payload, options)
|
||||
toast.success({ title: t('admin.products.toast.createSuccess') })
|
||||
}
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) {
|
||||
// Doublon de code (RG-6.01) : inline sur le champ + toast explicite.
|
||||
const message = t('admin.products.form.duplicateCode')
|
||||
formErrors.setError('code', message)
|
||||
toast.error({ title: t('admin.products.toast.error'), message })
|
||||
}
|
||||
else {
|
||||
formErrors.handleApiError(error, { fallbackMessage: t('admin.products.toast.error') })
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
productId,
|
||||
errors: formErrors.errors,
|
||||
submitting,
|
||||
isSale,
|
||||
siteOptions: sites.options,
|
||||
categoryOptions: categories.options,
|
||||
storageTypeOptions: storage.options,
|
||||
setStates,
|
||||
setCategory,
|
||||
setStorageTypes,
|
||||
setSites,
|
||||
loadReferentials,
|
||||
prefill,
|
||||
submit,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Composables d'options des selects du formulaire produit (M6 — ERP-205).
|
||||
*
|
||||
* Chaque referentiel est borne (quelques dizaines d'entrees) : on le recupere en
|
||||
* entier via l'echappatoire `?pagination=false`, avec l'en-tete
|
||||
* `Accept: application/ld+json` impose par API Platform 4 pour obtenir l'enveloppe
|
||||
* Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`), renvoyee telle
|
||||
* quelle dans le payload POST (relations ManyToOne / ManyToMany).
|
||||
*
|
||||
* Etat 100 % local a l'instance (refs) — aucune persistance URL. Chaque appel cree
|
||||
* sa propre instance ; le formulaire en consomme une via `useProductForm`.
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
|
||||
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
|
||||
export interface RefOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Membre Hydra minimal commun aux referentiels consommes ici. */
|
||||
interface HydraMember {
|
||||
'@id': string
|
||||
name?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||
|
||||
/**
|
||||
* Recupere une collection complete (pagination desactivee) et la projette en
|
||||
* options `{ value: IRI, label }`. Resilient : l'appelant gere l'echec (liste vide).
|
||||
*/
|
||||
async function fetchOptions(
|
||||
url: string,
|
||||
query: Record<string, string | string[]>,
|
||||
toLabel: (member: HydraMember) => string,
|
||||
): Promise<RefOption[]> {
|
||||
const res = await useApi().get<{ member?: HydraMember[] }>(
|
||||
url,
|
||||
{ pagination: 'false', ...query },
|
||||
{ headers: LD_JSON_HEADERS, toast: false },
|
||||
)
|
||||
return (res.member ?? []).map(m => ({ value: m['@id'], label: toLabel(m) }))
|
||||
}
|
||||
|
||||
/** Sites de disponibilite (libelle = nom du site). */
|
||||
export function useSiteOptions() {
|
||||
const options = ref<RefOption[]>([])
|
||||
|
||||
async function load(): Promise<void> {
|
||||
options.value = await fetchOptions('/sites', {}, s => s.name ?? '')
|
||||
}
|
||||
|
||||
return { options, load }
|
||||
}
|
||||
|
||||
/**
|
||||
* Categories produit. Filtrees au type voulu (`?typeCode=PRODUIT` pour le produit,
|
||||
* RG-6.05) cote serveur — le provider Category supporte deja `typeCode`.
|
||||
*/
|
||||
export function useCategoryOptions(params: { typeCode: string }) {
|
||||
const options = ref<RefOption[]>([])
|
||||
|
||||
async function load(): Promise<void> {
|
||||
options.value = await fetchOptions('/categories', { typeCode: params.typeCode }, c => c.name ?? '')
|
||||
}
|
||||
|
||||
return { options, load }
|
||||
}
|
||||
|
||||
/**
|
||||
* Types de stockage (libelle = `label`). Filtres par les sites selectionnes
|
||||
* (`?siteId[]=…`, RG-6.06) : on ne charge que les types disponibles sur AU MOINS
|
||||
* UN des sites passes. Sans site, la liste est videe (le multi-select depend des
|
||||
* sites).
|
||||
*/
|
||||
export function useStorageTypeOptions() {
|
||||
const options = ref<RefOption[]>([])
|
||||
|
||||
async function load(siteIds: number[]): Promise<void> {
|
||||
if (siteIds.length === 0) {
|
||||
options.value = []
|
||||
return
|
||||
}
|
||||
options.value = await fetchOptions(
|
||||
'/storage_types',
|
||||
{ 'siteId[]': siteIds.map(String) },
|
||||
s => s.label ?? '',
|
||||
)
|
||||
}
|
||||
|
||||
return { options, load }
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h, Suspense } from 'vue'
|
||||
|
||||
// Produit charge simule (cles de la reponse reelle § 4.0.bis).
|
||||
const PRODUCT = {
|
||||
id: 34,
|
||||
code: 'BLE-01',
|
||||
name: 'Blé tendre',
|
||||
states: ['PURCHASE'],
|
||||
manufactured: false,
|
||||
containsMolasses: false,
|
||||
category: { '@id': '/api/categories/12', id: 12, name: 'Céréales', code: 'CEREALES' },
|
||||
sites: [{ '@id': '/api/sites/1', id: 1, name: 'Chatellerault' }],
|
||||
storageTypes: [{ '@id': '/api/storage_types/9', id: 9, code: 'TAS', label: 'Tas' }],
|
||||
createdAt: '', updatedAt: '',
|
||||
}
|
||||
|
||||
// Holders crees dans les factories (vue initialise au moment de l'import page).
|
||||
const fx = vi.hoisted(() => ({
|
||||
isSale: null as unknown as { value: boolean },
|
||||
submit: vi.fn(),
|
||||
prefill: vi.fn(),
|
||||
loadReferentials: vi.fn(),
|
||||
load: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('~/modules/catalog/composables/useProductForm', async () => {
|
||||
const { ref, reactive } = await import('vue')
|
||||
fx.isSale = ref(false)
|
||||
return {
|
||||
PRODUCT_STATES: ['PURCHASE', 'SALE', 'OTHER'],
|
||||
useProductForm: () => ({
|
||||
form: reactive({
|
||||
code: null, name: null, states: [], siteIris: [],
|
||||
categoryIri: null, storageTypeIris: [], manufactured: false, containsMolasses: false,
|
||||
}),
|
||||
errors: reactive({}),
|
||||
submitting: ref(false),
|
||||
isSale: fx.isSale,
|
||||
siteOptions: ref([]),
|
||||
categoryOptions: ref([]),
|
||||
storageTypeOptions: ref([]),
|
||||
setStates: vi.fn(),
|
||||
setCategory: vi.fn(),
|
||||
setStorageTypes: vi.fn(),
|
||||
setSites: vi.fn(),
|
||||
loadReferentials: fx.loadReferentials,
|
||||
prefill: fx.prefill,
|
||||
submit: fx.submit,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('~/modules/catalog/composables/useProduct', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useProduct: () => ({
|
||||
product: ref(PRODUCT),
|
||||
loading: ref(false),
|
||||
error: ref(false),
|
||||
load: fx.load,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||
const mockPush = vi.hoisted(() => vi.fn())
|
||||
const mockNavigateTo = vi.hoisted(() => vi.fn())
|
||||
const mockCan = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('useHead', () => undefined)
|
||||
vi.stubGlobal('useRoute', () => ({ params: { id: '34' } }))
|
||||
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||
vi.stubGlobal('navigateTo', mockNavigateTo)
|
||||
|
||||
const EditPage = (await import('../admin/products/[id]/edit.vue')).default
|
||||
|
||||
const ButtonStub = defineComponent({
|
||||
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||
emits: ['click'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||
},
|
||||
})
|
||||
const InputStub = defineComponent({
|
||||
props: { label: { type: String, default: '' }, modelValue: { default: null } },
|
||||
setup(props) { return () => h('input', { 'data-label': props.label }) },
|
||||
})
|
||||
const CheckboxStub = defineComponent({
|
||||
props: { label: { type: String, default: '' } },
|
||||
setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) },
|
||||
})
|
||||
// Placeholder : rendu sans aucun appel API (juste un marqueur).
|
||||
const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } })
|
||||
|
||||
const stubs = {
|
||||
MalioButtonIcon: ButtonStub,
|
||||
MalioButton: ButtonStub,
|
||||
MalioInputText: InputStub,
|
||||
MalioSelect: InputStub,
|
||||
MalioSelectCheckbox: InputStub,
|
||||
MalioCheckbox: CheckboxStub,
|
||||
ProductPlaceholderTabs: TabsStub,
|
||||
}
|
||||
|
||||
async function mountPage() {
|
||||
const wrapper = mount(defineComponent({
|
||||
components: { EditPage },
|
||||
setup: () => () => h(Suspense, null, { default: () => h(EditPage) }),
|
||||
}), { global: { stubs } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('Écran Modifier un produit (page /admin/products/{id}/edit)', () => {
|
||||
beforeEach(() => {
|
||||
fx.submit.mockReset().mockResolvedValue(true)
|
||||
fx.prefill.mockReset().mockResolvedValue(undefined)
|
||||
fx.loadReferentials.mockReset().mockResolvedValue(undefined)
|
||||
fx.load.mockReset().mockResolvedValue(undefined)
|
||||
mockPush.mockReset()
|
||||
mockNavigateTo.mockReset()
|
||||
mockCan.mockReset().mockReturnValue(true)
|
||||
fx.isSale.value = false
|
||||
})
|
||||
|
||||
it('charge le produit et pre-remplit le formulaire au montage', async () => {
|
||||
await mountPage()
|
||||
expect(fx.load).toHaveBeenCalled()
|
||||
expect(fx.prefill).toHaveBeenCalledWith(PRODUCT)
|
||||
})
|
||||
|
||||
it('redirige vers la liste sans la permission manage', async () => {
|
||||
mockCan.mockReturnValue(false)
|
||||
await mountPage()
|
||||
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products')
|
||||
})
|
||||
|
||||
it('bouton « Enregistrer » : submit (PATCH) puis retour a la liste', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.find('[data-label="admin.products.edit.save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(fx.submit).toHaveBeenCalled()
|
||||
expect(mockPush).toHaveBeenCalledWith('/admin/products')
|
||||
})
|
||||
|
||||
it('affiche les onglets placeholder (rendu sans appel API)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
expect(wrapper.find('[data-testid="placeholder-tabs"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h, Suspense } from 'vue'
|
||||
|
||||
// ── Mock du composable form (sa logique est testee a part : useProductForm.spec).
|
||||
// Ici on teste le WIRING de la page : rendu conditionnel RG-6.03 + submit→redirect.
|
||||
// Les refs partagees sont creees DANS la factory (vue est initialise au moment ou
|
||||
// la page est importee) et exposees via un holder hoiste pour pilotage par test.
|
||||
const fx = vi.hoisted(() => ({
|
||||
isSale: null as unknown as { value: boolean },
|
||||
submit: vi.fn(),
|
||||
loadReferentials: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('~/modules/catalog/composables/useProductForm', async () => {
|
||||
const { ref, reactive } = await import('vue')
|
||||
fx.isSale = ref(false)
|
||||
return {
|
||||
PRODUCT_STATES: ['PURCHASE', 'SALE', 'OTHER'],
|
||||
useProductForm: () => ({
|
||||
form: reactive({
|
||||
code: null, name: null, states: [], siteIris: [],
|
||||
categoryIri: null, storageTypeIris: [], manufactured: false, containsMolasses: false,
|
||||
}),
|
||||
errors: reactive({}),
|
||||
submitting: ref(false),
|
||||
isSale: fx.isSale,
|
||||
siteOptions: ref([]),
|
||||
categoryOptions: ref([]),
|
||||
storageTypeOptions: ref([]),
|
||||
setStates: vi.fn(),
|
||||
setCategory: vi.fn(),
|
||||
setStorageTypes: vi.fn(),
|
||||
setSites: vi.fn(),
|
||||
loadReferentials: fx.loadReferentials,
|
||||
submit: fx.submit,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||
const mockPush = vi.hoisted(() => vi.fn())
|
||||
const mockNavigateTo = vi.hoisted(() => vi.fn())
|
||||
const mockCan = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('useHead', () => undefined)
|
||||
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||
vi.stubGlobal('navigateTo', mockNavigateTo)
|
||||
|
||||
const NewPage = (await import('../admin/products/new.vue')).default
|
||||
|
||||
const ButtonStub = defineComponent({
|
||||
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||
emits: ['click'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||
},
|
||||
})
|
||||
const InputStub = defineComponent({
|
||||
props: { label: { type: String, default: '' }, modelValue: { default: null } },
|
||||
setup(props) { return () => h('input', { 'data-label': props.label }) },
|
||||
})
|
||||
const CheckboxStub = defineComponent({
|
||||
props: { label: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
|
||||
setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) },
|
||||
})
|
||||
|
||||
// Placeholder (onglets Fournisseurs/Clients) : marqueur sans appel API.
|
||||
const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } })
|
||||
|
||||
const stubs = {
|
||||
MalioButtonIcon: ButtonStub,
|
||||
MalioButton: ButtonStub,
|
||||
MalioInputText: InputStub,
|
||||
MalioSelect: InputStub,
|
||||
MalioSelectCheckbox: InputStub,
|
||||
MalioCheckbox: CheckboxStub,
|
||||
ProductPlaceholderTabs: TabsStub,
|
||||
}
|
||||
|
||||
async function mountPage() {
|
||||
const wrapper = mount(defineComponent({
|
||||
components: { NewPage },
|
||||
setup: () => () => h(Suspense, null, { default: () => h(NewPage) }),
|
||||
}), { global: { stubs } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('Écran Ajouter un produit (page /admin/products/new)', () => {
|
||||
beforeEach(() => {
|
||||
fx.submit.mockReset().mockResolvedValue(true)
|
||||
fx.loadReferentials.mockReset().mockResolvedValue(undefined)
|
||||
mockPush.mockReset()
|
||||
mockNavigateTo.mockReset()
|
||||
mockCan.mockReset().mockReturnValue(true)
|
||||
fx.isSale.value = false
|
||||
})
|
||||
|
||||
it('redirige vers la liste sans la permission manage', async () => {
|
||||
mockCan.mockReturnValue(false)
|
||||
await mountPage()
|
||||
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products')
|
||||
})
|
||||
|
||||
it('charge les referentiels au montage', async () => {
|
||||
await mountPage()
|
||||
expect(fx.loadReferentials).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('RG-6.03 : masque Fabriqué / mélasse tant que l\'etat ne contient pas « Vendu »', async () => {
|
||||
fx.isSale.value = false
|
||||
const wrapper = await mountPage()
|
||||
expect(wrapper.find('[data-label="admin.products.form.manufactured"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-label="admin.products.form.containsMolasses"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('RG-6.03 : affiche Fabriqué / mélasse quand l\'etat contient « Vendu »', async () => {
|
||||
fx.isSale.value = true
|
||||
const wrapper = await mountPage()
|
||||
expect(wrapper.find('[data-label="admin.products.form.manufactured"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-label="admin.products.form.containsMolasses"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('« Valider » : submit puis retour a la liste au succes', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.find('[data-label="admin.products.form.submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(fx.submit).toHaveBeenCalled()
|
||||
expect(mockPush).toHaveBeenCalledWith('/admin/products')
|
||||
})
|
||||
|
||||
it('ne redirige pas si submit echoue (erreurs inline)', async () => {
|
||||
fx.submit.mockResolvedValueOnce(false)
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.find('[data-label="admin.products.form.submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
|
||||
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||
// La page ne les importe pas (auto-import) : on les expose en globals pour le
|
||||
// runtime de test (happy-dom). Meme philosophie que les specs M1→M5.
|
||||
const mockPush = vi.hoisted(() => vi.fn())
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
const mockCan = vi.hoisted(() => vi.fn())
|
||||
const mockSetFilters = vi.hoisted(() => vi.fn())
|
||||
const mockFetch = vi.hoisted(() => vi.fn())
|
||||
const mockToastError = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('useHead', () => undefined)
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
|
||||
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||
// usePaginatedList est l'auto-import pilotant la liste : on controle items +
|
||||
// setFilters + fetch. La ligne reproduit le contrat JSON reel (§ 4.0.bis).
|
||||
vi.stubGlobal('usePaginatedList', () => ({
|
||||
items: ref<Array<Record<string, unknown>>>([
|
||||
{
|
||||
id: 34,
|
||||
code: 'BLE-TENDRE-01',
|
||||
name: 'Blé tendre',
|
||||
states: ['PURCHASE', 'SALE'],
|
||||
manufactured: true,
|
||||
containsMolasses: true,
|
||||
category: { id: 12, name: 'Céréales', code: 'CEREALES' },
|
||||
sites: [{ id: 1, name: 'Chatellerault', code: '86' }],
|
||||
storageTypes: [{ id: 9, code: 'TAS', label: 'Tas' }],
|
||||
},
|
||||
]),
|
||||
totalItems: ref(1),
|
||||
currentPage: ref(1),
|
||||
itemsPerPage: ref(10),
|
||||
itemsPerPageOptions: ref([10, 25, 50]),
|
||||
fetch: mockFetch,
|
||||
goToPage: vi.fn(),
|
||||
setItemsPerPage: vi.fn(),
|
||||
setFilters: mockSetFilters,
|
||||
}))
|
||||
|
||||
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
|
||||
// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse).
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake')
|
||||
globalThis.URL.revokeObjectURL = vi.fn()
|
||||
|
||||
// Import APRES les stubs (la page resout les auto-imports au top-level du module).
|
||||
const ProductsIndex = (await import('../admin/products.vue')).default
|
||||
|
||||
// ── Stubs de composants ──────────────────────────────────────────────────────
|
||||
const ButtonStub = defineComponent({
|
||||
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||
emits: ['click'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||
},
|
||||
})
|
||||
|
||||
const DataTableStub = defineComponent({
|
||||
props: { items: { type: Array, default: () => [] } },
|
||||
emits: ['row-click', 'update:page', 'update:per-page'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('div', { 'data-testid': 'datatable' },
|
||||
(props.items as Array<Record<string, unknown>>).map(it =>
|
||||
h('tr', {
|
||||
'data-row-id': it.id,
|
||||
'data-name': it.name,
|
||||
'data-code': it.code,
|
||||
'data-category': it.categoryName,
|
||||
'onClick': () => emit('row-click', it),
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const DrawerStub = defineComponent({
|
||||
props: { modelValue: { type: Boolean, default: false } },
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()])
|
||||
},
|
||||
})
|
||||
|
||||
const SlotStub = defineComponent({ setup(_, { slots }) { return () => h('div', {}, slots.default?.()) } })
|
||||
|
||||
const PageHeaderStub = defineComponent({
|
||||
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
|
||||
})
|
||||
|
||||
const CheckboxStub = defineComponent({
|
||||
props: { id: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
|
||||
emits: ['update:model-value'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('input', {
|
||||
'type': 'checkbox',
|
||||
'data-id': props.id,
|
||||
'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLInputElement).checked),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const SelectStub = defineComponent({
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null] as unknown as () => string | number | null, default: null },
|
||||
options: { type: Array, default: () => [] },
|
||||
emptyOptionLabel: { type: String, default: '' },
|
||||
},
|
||||
emits: ['update:model-value'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('select', {
|
||||
'data-empty-label': props.emptyOptionLabel,
|
||||
'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLSelectElement).value),
|
||||
}, (props.options as Array<{ value: string | number, label: string }>).map(o =>
|
||||
h('option', { value: o.value }, o.label),
|
||||
))
|
||||
},
|
||||
})
|
||||
|
||||
const InputTextStub = defineComponent({ setup() { return () => h('input') } })
|
||||
|
||||
function mountPage() {
|
||||
return mount(ProductsIndex, {
|
||||
global: {
|
||||
stubs: {
|
||||
PageHeader: PageHeaderStub,
|
||||
MalioButton: ButtonStub,
|
||||
MalioDataTable: DataTableStub,
|
||||
MalioDrawer: DrawerStub,
|
||||
MalioAccordion: SlotStub,
|
||||
MalioAccordionItem: SlotStub,
|
||||
MalioInputText: InputTextStub,
|
||||
MalioSelect: SelectStub,
|
||||
MalioCheckbox: CheckboxStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('Catalogue produit (page /admin/products)', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockReset()
|
||||
mockApiGet.mockReset().mockImplementation((url: string) => {
|
||||
if (url === '/categories') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/categories/12', id: 12, name: 'Céréales' }] })
|
||||
}
|
||||
if (url === '/sites') {
|
||||
return Promise.resolve({ member: [{ id: 1, name: 'Chatellerault' }] })
|
||||
}
|
||||
return Promise.resolve({ member: [] })
|
||||
})
|
||||
mockCan.mockReset().mockReturnValue(true)
|
||||
mockSetFilters.mockReset()
|
||||
mockFetch.mockReset()
|
||||
mockToastError.mockReset()
|
||||
})
|
||||
|
||||
it('charge la liste au montage', async () => {
|
||||
mountPage()
|
||||
await flushPromises()
|
||||
expect(mockFetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mappe les colonnes Nom / Numéro / Catégorie sur le JSON réel (§ 4.0.bis)', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
const row = wrapper.find('tr[data-row-id="34"]')
|
||||
expect(row.attributes('data-name')).toBe('Blé tendre')
|
||||
expect(row.attributes('data-code')).toBe('BLE-TENDRE-01')
|
||||
expect(row.attributes('data-category')).toBe('Céréales')
|
||||
})
|
||||
|
||||
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
|
||||
mockCan.mockImplementation((perm: string) => perm === 'catalog.products.manage')
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-label="admin.products.add"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
|
||||
mockCan.mockImplementation((perm: string) => perm === 'catalog.products.view')
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-label="admin.products.add"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('navigue vers l\'édition au clic sur une ligne', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
await wrapper.find('tr[data-row-id="34"]').trigger('click')
|
||||
expect(mockPush).toHaveBeenCalledWith('/admin/products/34/edit')
|
||||
})
|
||||
|
||||
it('navigue vers la création au clic sur « + Ajouter »', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-label="admin.products.add"]').trigger('click')
|
||||
expect(mockPush).toHaveBeenCalledWith('/admin/products/new')
|
||||
})
|
||||
|
||||
it('appelle l\'export XLSX sur /products/export.xlsx en blob', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-label="admin.products.export"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
'/products/export.xlsx',
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ responseType: 'blob', toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('répercute les sites cochés dans setFilters (filtre multi, clé siteId[])', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('input[data-id="filter-site-1"]').setValue(true)
|
||||
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
|
||||
|
||||
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||
{ 'siteId[]': ['1'] },
|
||||
{ replace: true },
|
||||
)
|
||||
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('répercute l\'état sélectionné dans setFilters (param state)', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('select[data-empty-label="admin.products.filters.stateAll"]').setValue('SALE')
|
||||
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
|
||||
|
||||
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||
{ state: 'SALE' },
|
||||
{ replace: true },
|
||||
)
|
||||
})
|
||||
|
||||
it('répercute la catégorie sélectionnée dans setFilters (param categoryId)', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('select[data-empty-label="admin.products.filters.categoryAll"]').setValue('12')
|
||||
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
|
||||
|
||||
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||
{ categoryId: '12' },
|
||||
{ replace: true },
|
||||
)
|
||||
})
|
||||
|
||||
it('badge filtres actifs + Réinitialiser vide l\'état appliqué', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('input[data-id="filter-site-1"]').setValue(true)
|
||||
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
|
||||
|
||||
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
|
||||
expect(wrapper.find('[data-label="admin.products.filters.title (1)"]').exists()).toBe(true)
|
||||
|
||||
// Réinitialiser → query propre (setFilters avec objet vide).
|
||||
await wrapper.find('[data-label="admin.products.filters.reset"]').trigger('click')
|
||||
expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ t('admin.products.title') }}
|
||||
<template #actions>
|
||||
<!-- gap-8 = 32px d'espacement entre Filtrer et Ajouter (meme
|
||||
design que le Repertoire transporteurs / la Gestion des categories). -->
|
||||
<div class="flex items-center gap-8">
|
||||
<!-- Bouton Filtrer a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
|
||||
<MalioButton
|
||||
v-if="canView"
|
||||
variant="tertiary"
|
||||
:label="filterButtonLabel"
|
||||
icon-name="mdi:tune"
|
||||
icon-position="left"
|
||||
icon-size="24"
|
||||
@click="openFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
variant="secondary"
|
||||
:label="t('admin.products.add')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="goToCreate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Datatable branchee sur usePaginatedList : pagination serveur, tri
|
||||
name ASC par defaut (cote back, § 4.1). Colonnes Nom / Numero /
|
||||
Categorie (docx p.3). -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
row-clickable
|
||||
:empty-message="t('admin.products.empty')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
/>
|
||||
|
||||
<div class="flex justify-center mt-4">
|
||||
<MalioButton
|
||||
v-if="canView"
|
||||
variant="primary"
|
||||
:label="t('admin.products.export')"
|
||||
:disabled="exporting"
|
||||
@click="exportXlsx"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
|
||||
« Voir les résultats ». Meme pattern que les repertoires M1→M5.
|
||||
Etat 100 % local, jamais dans l'URL (regle ABSOLUE n°6). -->
|
||||
<MalioDrawer
|
||||
v-model="filterDrawerOpen"
|
||||
drawer-class="max-w-[450px]"
|
||||
body-class="p-0"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold uppercase">{{ t('admin.products.filters.title') }}</h2>
|
||||
</template>
|
||||
|
||||
<MalioAccordion>
|
||||
<!-- Recherche : code + nom (param `search`, partiel insensible a la casse). -->
|
||||
<MalioAccordionItem :title="t('admin.products.filters.search')" value="search">
|
||||
<MalioInputText
|
||||
v-model="draftSearch"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Categorie : select simple (param `categoryId`). Referentiel borne
|
||||
aux categories de type PRODUIT (RG-6.05). -->
|
||||
<MalioAccordionItem :title="t('admin.products.filters.category')" value="category">
|
||||
<MalioSelect
|
||||
:model-value="draftCategoryId"
|
||||
:options="categoryOptions"
|
||||
:empty-option-label="t('admin.products.filters.categoryAll')"
|
||||
@update:model-value="(v: string | number | null) => draftCategoryId = v === null || v === '' ? null : Number(v)"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Etat : select simple (param `state`, enum PURCHASE / SALE / OTHER). -->
|
||||
<MalioAccordionItem :title="t('admin.products.filters.state')" value="state">
|
||||
<MalioSelect
|
||||
:model-value="draftState"
|
||||
:options="stateOptions"
|
||||
:empty-option-label="t('admin.products.filters.stateAll')"
|
||||
@update:model-value="(v: string | number | null) => draftState = v === null || v === '' ? null : String(v)"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Site(s) : cases a cocher (multi, param `siteId[]`). Un produit
|
||||
remonte s'il est disponible sur AU MOINS UN des sites coches. -->
|
||||
<MalioAccordionItem :title="t('admin.products.filters.site')" value="site">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in siteOptions"
|
||||
:id="`filter-site-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="draftSiteIds.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => toggleSite(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('admin.products.filters.reset')"
|
||||
button-class="w-m-btn-action"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('admin.products.filters.apply')"
|
||||
button-class="w-[170px]"
|
||||
@click="applyFilters"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { Product } from '~/modules/catalog/types/product'
|
||||
|
||||
interface FilterOption {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('admin.products.title') })
|
||||
|
||||
// Catalogue produit admin-only (docx p.3) : « + Ajouter » reserve a `manage`.
|
||||
// « Filtrer » / « Exporter » suivent `view` (gate page-level). L'item sidebar
|
||||
// est deja masque cote back pour les roles sans `view` (RBAC § 5.2).
|
||||
const canManage = computed(() => can('catalog.products.manage'))
|
||||
const canView = computed(() => can('catalog.products.view'))
|
||||
|
||||
// Pagination serveur via le composable partage. Le ProductProvider applique
|
||||
// deja name ASC (§ 4.1) — pas de defaultSort cote front tant qu'aucun
|
||||
// OrderFilter n'est expose.
|
||||
const {
|
||||
items: products,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadProducts,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
setFilters,
|
||||
} = usePaginatedList<Product>({ url: '/products' })
|
||||
|
||||
// Mappe les produits en objets « plats » pour MalioDataTable (items typees
|
||||
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
|
||||
// implicite, contrairement a l'interface Product. Meme pattern que M1→M5.
|
||||
const rows = computed(() => products.value.map(product => ({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
code: product.code,
|
||||
categoryName: product.category?.name ?? '',
|
||||
})))
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: t('admin.products.column.name') },
|
||||
{ key: 'code', label: t('admin.products.column.code') },
|
||||
{ key: 'categoryName', label: t('admin.products.column.category') },
|
||||
]
|
||||
|
||||
/** Clic sur une ligne → ecran d'edition (route imbriquee /admin/products/{id}/edit). */
|
||||
function onRowClick(item: Record<string, unknown>): void {
|
||||
router.push(`/admin/products/${item.id}/edit`)
|
||||
}
|
||||
|
||||
function goToCreate(): void {
|
||||
router.push('/admin/products/new')
|
||||
}
|
||||
|
||||
// ── Referentiels des filtres ─────────────────────────────────────────────────
|
||||
// Charges une fois (pagination desactivee, referentiels bornes). Categories
|
||||
// filtrees au type PRODUIT (RG-6.05) ; sites = tous les sites actifs.
|
||||
const categoryOptions = ref<FilterOption[]>([])
|
||||
const siteOptions = ref<FilterOption[]>([])
|
||||
|
||||
// Etats produit (miroir de l'enum back Product::STATE_*). Le libelle est resolu
|
||||
// par i18n. Select simple cote filtre (`?state=` n'accepte qu'une valeur).
|
||||
const PRODUCT_STATES = ['PURCHASE', 'SALE', 'OTHER'] as const
|
||||
|
||||
const stateOptions = computed(() =>
|
||||
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
|
||||
)
|
||||
|
||||
interface HydraMember { '@id': string, id: number, name?: string, postalCode?: string }
|
||||
|
||||
/** Recupere une collection complete (pagination desactivee) en Hydra. */
|
||||
async function fetchAll<T extends HydraMember>(
|
||||
url: string,
|
||||
query: Record<string, string> = {},
|
||||
): Promise<T[]> {
|
||||
const res = await api.get<{ member?: T[] }>(
|
||||
url,
|
||||
{ pagination: 'false', ...query },
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
return res.member ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge les referentiels des filtres en parallele et de maniere resiliente :
|
||||
* un referentiel en echec (403/500) reste vide sans casser l'autre.
|
||||
*/
|
||||
async function loadFilterReferentials(): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
fetchAll('/categories', { typeCode: 'PRODUIT' })
|
||||
.then((cats) => { categoryOptions.value = cats.map(c => ({ value: c.id, label: c.name ?? '' })) }),
|
||||
fetchAll('/sites')
|
||||
.then((sitesList) => { siteOptions.value = sitesList.map(s => ({ value: s.id, label: s.name ?? '' })) }),
|
||||
])
|
||||
}
|
||||
|
||||
// ── Filtres (drawer) ─────────────────────────────────────────────────────────
|
||||
// Deux niveaux d'etat (pattern repertoires M1→M5) :
|
||||
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
|
||||
// uniquement au clic « Voir les résultats » / « Réinitialiser ».
|
||||
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
|
||||
const filterDrawerOpen = ref(false)
|
||||
|
||||
const draftSearch = ref('')
|
||||
const draftCategoryId = ref<number | null>(null)
|
||||
const draftState = ref<string | null>(null)
|
||||
const draftSiteIds = ref<number[]>([])
|
||||
|
||||
const appliedSearch = ref('')
|
||||
const appliedCategoryId = ref<number | null>(null)
|
||||
const appliedState = ref<string | null>(null)
|
||||
const appliedSiteIds = ref<number[]>([])
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
let count = 0
|
||||
if (appliedSearch.value.trim() !== '') count++
|
||||
if (appliedCategoryId.value !== null) count++
|
||||
if (appliedState.value !== null) count++
|
||||
if (appliedSiteIds.value.length > 0) count++
|
||||
return count
|
||||
})
|
||||
|
||||
const filterButtonLabel = computed(() => {
|
||||
const base = t('admin.products.filters.title')
|
||||
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
|
||||
})
|
||||
|
||||
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la reouverture
|
||||
// reflete les filtres actifs.
|
||||
function openFilters(): void {
|
||||
draftSearch.value = appliedSearch.value
|
||||
draftCategoryId.value = appliedCategoryId.value
|
||||
draftState.value = appliedState.value
|
||||
draftSiteIds.value = [...appliedSiteIds.value]
|
||||
filterDrawerOpen.value = true
|
||||
}
|
||||
|
||||
/** Coche / decoche un site dans le brouillon (filtre multi). */
|
||||
function toggleSite(id: number, selected: boolean): void {
|
||||
draftSiteIds.value = selected
|
||||
? [...draftSiteIds.value, id]
|
||||
: draftSiteIds.value.filter(s => s !== id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le payload de filtres serveur a partir de l'etat applique. Cle
|
||||
* `siteId[]` pour que PHP la parse en tableau (OR cote back). Les filtres vides
|
||||
* sont omis pour une query propre.
|
||||
*/
|
||||
function buildFilterPayload(): Record<string, string | string[]> {
|
||||
const payload: Record<string, string | string[]> = {}
|
||||
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
||||
if (appliedCategoryId.value !== null) payload.categoryId = String(appliedCategoryId.value)
|
||||
if (appliedState.value !== null) payload.state = appliedState.value
|
||||
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = appliedSiteIds.value.map(String)
|
||||
return payload
|
||||
}
|
||||
|
||||
// « Voir les résultats » : recopie brouillon → applied, pousse les filtres
|
||||
// (retombe en page 1 via usePaginatedList) et ferme le drawer.
|
||||
function applyFilters(): void {
|
||||
appliedSearch.value = draftSearch.value.trim()
|
||||
appliedCategoryId.value = draftCategoryId.value
|
||||
appliedState.value = draftState.value
|
||||
appliedSiteIds.value = [...draftSiteIds.value]
|
||||
|
||||
setFilters(buildFilterPayload(), { replace: true })
|
||||
filterDrawerOpen.value = false
|
||||
}
|
||||
|
||||
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
|
||||
// Le drawer reste ouvert pour montrer le formulaire vide.
|
||||
function resetFilters(): void {
|
||||
draftSearch.value = ''
|
||||
draftCategoryId.value = null
|
||||
draftState.value = null
|
||||
draftSiteIds.value = []
|
||||
|
||||
appliedSearch.value = ''
|
||||
appliedCategoryId.value = null
|
||||
appliedState.value = null
|
||||
appliedSiteIds.value = []
|
||||
|
||||
setFilters({}, { replace: true })
|
||||
}
|
||||
|
||||
// ── Export XLSX ──────────────────────────────────────────────────────────────
|
||||
// Memes filtres que la vue : l'export reflete exactement ce que l'utilisateur voit.
|
||||
const exporting = ref(false)
|
||||
|
||||
async function exportXlsx(): Promise<void> {
|
||||
if (exporting.value) {
|
||||
return
|
||||
}
|
||||
exporting.value = true
|
||||
try {
|
||||
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
|
||||
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
|
||||
// contenu faute d'overload blob sur le client partage (meme pattern M2→M5).
|
||||
const blob = await api.get<Blob>('/products/export.xlsx', buildFilterPayload(), {
|
||||
responseType: 'blob',
|
||||
toast: false,
|
||||
} as unknown as Parameters<typeof api.get>[2])
|
||||
|
||||
triggerDownload(blob, 'catalogue-produits.xlsx')
|
||||
}
|
||||
catch {
|
||||
toast.error({
|
||||
title: t('admin.products.toast.error'),
|
||||
message: t('admin.products.toast.exportError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Declenche le telechargement d'un blob via un lien temporaire. */
|
||||
function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProducts()
|
||||
loadFilterReferentials()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete : retour vers le catalogue + nom du produit. -->
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('admin.products.edit.back')"
|
||||
v-bind="{ ariaLabel: t('admin.products.edit.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Etats de chargement / introuvable. -->
|
||||
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('admin.products.edit.loading') }}</p>
|
||||
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('admin.products.edit.notFound') }}</p>
|
||||
|
||||
<template v-else-if="product">
|
||||
<!-- ── Formulaire principal pre-rempli (mêmes champs/regles que l'ajout,
|
||||
RG-6.01→6.07). Bouton « Enregistrer » → PATCH (RG-6.08). -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Etat du produit : multi-select obligatoire (>= 1, RG-6.02). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="form.states"
|
||||
:options="stateOptions"
|
||||
:label="t('admin.products.form.states')"
|
||||
:display-tag="true"
|
||||
:required="true"
|
||||
:error="errors.states"
|
||||
@update:model-value="(v: (string | number)[]) => setStates(v.map(String))"
|
||||
/>
|
||||
<!-- Sites de disponibilite : multi-select obligatoire (>= 1, RG-6.04).
|
||||
Pilote la cascade Type de stockage (RG-6.06). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="form.siteIris"
|
||||
:options="siteOptions"
|
||||
:label="t('admin.products.form.sites')"
|
||||
:display-tag="true"
|
||||
:required="true"
|
||||
:error="errors.sites"
|
||||
@update:model-value="(v: (string | number)[]) => setSites(v.map(String))"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:label="t('admin.products.form.name')"
|
||||
:required="true"
|
||||
:error="errors.name"
|
||||
/>
|
||||
<!-- Code modifiable techniquement ; l'unicite reste re-validee serveur (RG-6.01). -->
|
||||
<MalioInputText
|
||||
v-model="form.code"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('admin.products.form.code')"
|
||||
:required="true"
|
||||
:error="errors.code"
|
||||
/>
|
||||
<!-- Categorie produit : select simple obligatoire, filtre type PRODUIT (RG-6.05). -->
|
||||
<MalioSelect
|
||||
:model-value="form.categoryIri"
|
||||
:options="categoryOptions"
|
||||
:label="t('admin.products.form.category')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="errors.category"
|
||||
@update:model-value="(v: string | number | null) => setCategory(v === null || v === '' ? null : String(v))"
|
||||
/>
|
||||
<!-- Type de stockage : multi-select obligatoire (>= 1), options filtrees
|
||||
par les sites selectionnes (RG-6.06). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="form.storageTypeIris"
|
||||
:options="storageTypeOptions"
|
||||
:label="t('admin.products.form.storageTypes')"
|
||||
:display-tag="true"
|
||||
:required="true"
|
||||
:error="errors.storageTypes"
|
||||
@update:model-value="(v: (string | number)[]) => setStorageTypes(v.map(String))"
|
||||
/>
|
||||
<!-- RG-6.03 : « Fabriqué » + « Contient de la mélasse » visibles
|
||||
uniquement si l'Etat contient « Vendu ». -->
|
||||
<MalioCheckbox
|
||||
v-if="isSale"
|
||||
v-model="form.manufactured"
|
||||
:label="t('admin.products.form.manufactured')"
|
||||
group-class="self-center"
|
||||
/>
|
||||
<MalioCheckbox
|
||||
v-if="isSale"
|
||||
v-model="form.containsMolasses"
|
||||
:label="t('admin.products.form.containsMolasses')"
|
||||
group-class="self-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('admin.products.edit.save')"
|
||||
:disabled="submitting"
|
||||
@click="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Onglets Fournisseurs / Clients en placeholder (HP-M6-01). -->
|
||||
<ProductPlaceholderTabs />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useProductForm, PRODUCT_STATES } from '~/modules/catalog/composables/useProductForm'
|
||||
import { useProduct } from '~/modules/catalog/composables/useProduct'
|
||||
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { can } = usePermissions()
|
||||
|
||||
// Gating de la route : la modification est reservee a `manage` (catalogue admin-only).
|
||||
if (!can('catalog.products.manage')) {
|
||||
await navigateTo('/admin/products')
|
||||
}
|
||||
|
||||
const productId = route.params.id as string
|
||||
|
||||
const { product, loading, error, load } = useProduct(productId)
|
||||
|
||||
const {
|
||||
form,
|
||||
errors,
|
||||
submitting,
|
||||
isSale,
|
||||
siteOptions,
|
||||
categoryOptions,
|
||||
storageTypeOptions,
|
||||
setStates,
|
||||
setCategory,
|
||||
setStorageTypes,
|
||||
setSites,
|
||||
loadReferentials,
|
||||
prefill,
|
||||
submit,
|
||||
} = useProductForm()
|
||||
|
||||
const headerTitle = computed(() => product.value?.name ?? t('admin.products.edit.title'))
|
||||
|
||||
useHead({ title: headerTitle })
|
||||
|
||||
// Options de l'etat : libelles i18n (la valeur d'option = code enum).
|
||||
const stateOptions = computed(() =>
|
||||
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
|
||||
)
|
||||
|
||||
/** Retour vers le catalogue produit (fleche d'en-tete). */
|
||||
function goBack(): void {
|
||||
router.push('/admin/products')
|
||||
}
|
||||
|
||||
/** Soumet la modification (PATCH) ; au succes, retour a la liste. */
|
||||
async function onSubmit(): Promise<void> {
|
||||
const ok = await submit()
|
||||
if (ok) {
|
||||
router.push('/admin/products')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Referentiels (selects) + detail du produit charges en parallele.
|
||||
await Promise.all([
|
||||
loadReferentials().catch(() => {}),
|
||||
load(),
|
||||
])
|
||||
// Pre-remplissage une fois le produit charge (echec de chargement => message).
|
||||
if (product.value) {
|
||||
await prefill(product.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete : retour vers le catalogue + titre. -->
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('admin.products.form.back')"
|
||||
v-bind="{ ariaLabel: t('admin.products.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('admin.products.form.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- ── Formulaire principal de creation ───────────────────────────────
|
||||
Bouton « Valider » TOUJOURS actif (ERP-101) : la validation
|
||||
autoritaire est serveur, les erreurs 422 reviennent inline. -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Etat du produit : multi-select obligatoire (>= 1, RG-6.02). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="form.states"
|
||||
:options="stateOptions"
|
||||
:label="t('admin.products.form.states')"
|
||||
:display-tag="true"
|
||||
:required="true"
|
||||
:error="errors.states"
|
||||
@update:model-value="(v: (string | number)[]) => setStates(v.map(String))"
|
||||
/>
|
||||
<!-- Sites de disponibilite : multi-select obligatoire (>= 1, RG-6.04).
|
||||
Pilote la cascade Type de stockage (RG-6.06). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="form.siteIris"
|
||||
:options="siteOptions"
|
||||
:label="t('admin.products.form.sites')"
|
||||
:display-tag="true"
|
||||
:required="true"
|
||||
:error="errors.sites"
|
||||
@update:model-value="(v: (string | number)[]) => setSites(v.map(String))"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:label="t('admin.products.form.name')"
|
||||
:required="true"
|
||||
:error="errors.name"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.code"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('admin.products.form.code')"
|
||||
:required="true"
|
||||
:error="errors.code"
|
||||
/>
|
||||
<!-- Categorie produit : select simple obligatoire, filtre type PRODUIT (RG-6.05). -->
|
||||
<MalioSelect
|
||||
:model-value="form.categoryIri"
|
||||
:options="categoryOptions"
|
||||
:label="t('admin.products.form.category')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="errors.category"
|
||||
@update:model-value="(v: string | number | null) => setCategory(v === null || v === '' ? null : String(v))"
|
||||
/>
|
||||
<!-- Type de stockage : multi-select obligatoire (>= 1), options filtrees
|
||||
par les sites selectionnes (RG-6.06). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="form.storageTypeIris"
|
||||
:options="storageTypeOptions"
|
||||
:label="t('admin.products.form.storageTypes')"
|
||||
:display-tag="true"
|
||||
:required="true"
|
||||
:error="errors.storageTypes"
|
||||
@update:model-value="(v: (string | number)[]) => setStorageTypes(v.map(String))"
|
||||
/>
|
||||
<!-- RG-6.03 : « Fabriqué » + « Contient de la mélasse » visibles
|
||||
uniquement si l'Etat contient « Vendu ». -->
|
||||
<MalioCheckbox
|
||||
v-if="isSale"
|
||||
v-model="form.manufactured"
|
||||
:label="t('admin.products.form.manufactured')"
|
||||
group-class="self-center"
|
||||
/>
|
||||
<MalioCheckbox
|
||||
v-if="isSale"
|
||||
v-model="form.containsMolasses"
|
||||
:label="t('admin.products.form.containsMolasses')"
|
||||
group-class="self-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('admin.products.form.submit')"
|
||||
:disabled="submitting"
|
||||
@click="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Onglets Fournisseurs / Clients en placeholder (HP-M6-01) : presents
|
||||
des l'ajout pour coherence avec l'ecran de modification. -->
|
||||
<ProductPlaceholderTabs />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useProductForm, PRODUCT_STATES } from '~/modules/catalog/composables/useProductForm'
|
||||
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('admin.products.form.title') })
|
||||
|
||||
// Gating de la route : la creation est reservee a `manage` (catalogue admin-only).
|
||||
if (!can('catalog.products.manage')) {
|
||||
await navigateTo('/admin/products')
|
||||
}
|
||||
|
||||
const {
|
||||
form,
|
||||
errors,
|
||||
submitting,
|
||||
isSale,
|
||||
siteOptions,
|
||||
categoryOptions,
|
||||
storageTypeOptions,
|
||||
setStates,
|
||||
setCategory,
|
||||
setStorageTypes,
|
||||
setSites,
|
||||
loadReferentials,
|
||||
submit,
|
||||
} = useProductForm()
|
||||
|
||||
// Options de l'etat : libelles i18n (la valeur d'option = code enum).
|
||||
const stateOptions = computed(() =>
|
||||
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
|
||||
)
|
||||
|
||||
/** Retour vers le catalogue produit (fleche d'en-tete). */
|
||||
function goBack(): void {
|
||||
router.push('/admin/products')
|
||||
}
|
||||
|
||||
/** Soumet la creation ; au succes, retour a la liste. */
|
||||
async function onSubmit(): Promise<void> {
|
||||
const ok = await submit()
|
||||
if (ok) {
|
||||
router.push('/admin/products')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Echec du chargement des referentiels non bloquant : les selects restent vides.
|
||||
loadReferentials().catch(() => {})
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Types front du module Catalog (M6 — Catalogue produit).
|
||||
*
|
||||
* Contrats API consommes :
|
||||
* - GET /api/products → HydraCollection<Product>
|
||||
* - GET /api/products/{id} → Product
|
||||
* - GET /api/products/export.xlsx → binaire XLSX (export complet, filtres actifs)
|
||||
*
|
||||
* Notes (cf. spec-back § 4.0.bis, contrat JSON capture en ERP-203) :
|
||||
* - `category` est embarque (objet, pas IRI) ; idem `sites` / `storageTypes`
|
||||
* (tableaux d'objets bornes). On n'a besoin que de `category.name` en liste.
|
||||
* - `states` est un tableau de chaines (PURCHASE / SALE / OTHER).
|
||||
* - `skip_null_values` actif cote back : ne pas presumer la presence des nulls.
|
||||
*/
|
||||
|
||||
/** Type de categorie embarque dans `category.categoryTypes` (RG-6.05). */
|
||||
export interface ProductCategoryType {
|
||||
id: number
|
||||
code: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Categorie embarquee dans un produit (lecture seule, sous-ensemble utile au front). */
|
||||
export interface ProductCategory {
|
||||
/** IRI Hydra, ex. `/api/categories/12` — utilise pour pre-selectionner le select en edition. */
|
||||
'@id': string
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
categoryTypes?: ProductCategoryType[]
|
||||
}
|
||||
|
||||
/** Site de disponibilite embarque dans un produit (groupe `site:read`). */
|
||||
export interface ProductSite {
|
||||
/** IRI Hydra, ex. `/api/sites/1` — utilise pour pre-selectionner le multi-select en edition. */
|
||||
'@id': string
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
postalCode: string
|
||||
city: string
|
||||
color: string
|
||||
fullAddress: string
|
||||
}
|
||||
|
||||
/** Type de stockage embarque dans un produit (referentiel borne, § 2.4). */
|
||||
export interface ProductStorageType {
|
||||
/** IRI Hydra, ex. `/api/storage_types/9` — utilise pour pre-selectionner le multi-select en edition. */
|
||||
'@id': string
|
||||
id: number
|
||||
code: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Produit metier — tel qu'il est lu depuis l'API. L'entite porte le pattern
|
||||
* Timestampable+Blamable (cf. spec-back § 2.8).
|
||||
*/
|
||||
export interface Product {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
/** Etats : sous-ensemble de PURCHASE / SALE / OTHER (RG-6.02). */
|
||||
states: string[]
|
||||
manufactured: boolean
|
||||
containsMolasses: boolean
|
||||
category: ProductCategory | null
|
||||
sites: ProductSite[]
|
||||
storageTypes: ProductStorageType[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
+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