Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 206057cf83 |
@@ -40,7 +40,3 @@ services:
|
||||
|
||||
App\Module\Logistique\Application\Service\DsdAllocatorInterface:
|
||||
alias: App\Module\Logistique\Infrastructure\Service\DsdAllocator
|
||||
|
||||
# M5 Logistique — Provider/Processor ticket de pesee (ERP-185)
|
||||
App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface:
|
||||
alias: App\Module\Logistique\Infrastructure\Service\WeighingTicketNumberAllocator
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.145'
|
||||
app.version: '0.1.142'
|
||||
|
||||
@@ -172,16 +172,14 @@ Pattern Starseed standard (miroir M1→M4) :
|
||||
- `WeighingTicket implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait` (4 colonnes standard).
|
||||
- **Libellé i18n** (règle ABSOLUE backend — `AuditableEntitiesHaveI18nLabelTest`) : ajouter `audit.entity.logistique_weighingticket` dans `frontend/i18n/locales/fr.json` (clé = `strtolower(module)` + `_` + `strtolower(Entity)`).
|
||||
|
||||
### 2.12 Bon de pesée — PDF généré côté serveur via template Twig (RG-5.08)
|
||||
### 2.12 Impression du ticket / bon de pesée (RG-5.08)
|
||||
|
||||
> **DÉCISION Matthieu (17/06)** : le **bon de pesée est généré côté back** par un **template Twig → PDF** (et non un gabarit imprimé par le navigateur). **OWNER : Tristan** (ticket back dédié, cf. § 10). Cette spec en pose le contrat (endpoint, contenu, données).
|
||||
> **OWNER : Tristan.** La **réalisation du bon d'impression** (gabarit du ticket de pesée, mise en page, déclenchement de l'impression) est **prise en charge par Tristan lui-même** — hors de la découpe back/front standard du M5. Cette spec en pose **le contrat attendu** (déclencheur, contenu, données disponibles) pour qu'il puisse s'y brancher sans rétro-spec.
|
||||
|
||||
Contrat attendu :
|
||||
- **Endpoint** : `GET /api/weighing_tickets/{id}/print.pdf` (opération API Platform dédiée, **pas de controller** — provider renvoyant un binaire). Sécurité `is_granted('logistique.weighing_tickets.view')`. Réponse `Content-Type: application/pdf` (inline).
|
||||
- **Rendu** : un template **Twig** (`templates/logistique/weighing_ticket_print.html.twig`) hydraté avec le ticket → converti en PDF via le générateur PDF du projet (ex. Dompdf / wkhtmltopdf / Gotenberg — s'aligner sur l'existant ; sinon proposer une lib et la cadrer avec Matthieu).
|
||||
- **Contenu du bon** : numéro (`{siteCode}-TP-{NNNN}`), site, contrepartie (Client / Fournisseur / Autre + libellé), immatriculation, **pesée à vide** (date/poids/DSD), **pesée à plein** (date/poids/DSD), **poids net** (= plein − vide), date d'édition. (En-tête / logo / mentions = à caler par Tristan.)
|
||||
- **Données** : toutes déjà disponibles sur le ticket (mêmes champs que `GET /api/weighing_tickets/{id}` § 4.0) — aucun champ API supplémentaire requis.
|
||||
- **Déclencheurs front** (RG-5.08) : à la **validation** (création), le front ouvre l'aperçu/PDF servi par cet endpoint ; en **modification**, le bouton **« Imprimer »** ouvre le même PDF (absent à l'ajout).
|
||||
- **Déclencheur** : à la **validation** (création), l'API renvoie le ticket complet ; le front ouvre une **modal d'impression**. En **modification**, un bouton **« Imprimer »** est disponible (absent à l'ajout — docx / RG-5.08).
|
||||
- **Contenu minimal du bon** : numéro (`{siteCode}-TP-{NNNN}`), site, contrepartie (Client / Fournisseur / Autre + libellé), immatriculation, **pesée à vide** (date/poids/DSD), **pesée à plein** (date/poids/DSD), **poids net** (= plein − vide), date d'édition.
|
||||
- **Données** : toutes disponibles dans la réponse `GET /api/weighing_tickets/{id}` (§ 4.0) — aucun champ supplémentaire requis côté API. Si Tristan opte pour un **PDF serveur**, prévoir l'endpoint `GET /api/weighing_tickets/{id}/print.pdf` (HP-M5-04) ; sinon impression navigateur d'un gabarit front.
|
||||
|
||||
### 2.13 Pas d'archive ; soft delete préparé non exposé
|
||||
|
||||
@@ -506,19 +504,17 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
||||
|
||||
**DÉTAIL — maillons** : scalaires + `emptyDate/emptyWeight/emptyDsd/...` + `full*` ∈ `weighing_ticket:item:read` ; `client`/`supplier`/`site` embarqués (`client:read`/`supplier:read`/`site:read`).
|
||||
|
||||
### 4.0.bis Réponse JSON de référence (DoD — CAPTURÉE sur l'API réelle ✅)
|
||||
### 4.0.bis Réponse JSON de référence (DoD — à CAPTURER sur l'API réelle)
|
||||
|
||||
> **Definition of Done** (miroir M2/M3/M4) : ✅ **FAIT (ERP-187)**. Le JSON ci-dessous est la réponse **RÉELLE** capturée par le test `WeighingTicketSerializationContractTest::testListAndDetailSerializationContract` (ticket créé via `POST /api/weighing_tickets` — numérotation serveur réelle — contrepartie Client, pesée vide + plein AUTO). Re-capturable : `WEIGHING_TICKET_DOD_DUMP=1` → `/tmp/weighing-ticket-dod-{list,detail}.json`. **Feu vert front.** Toute donnée affichée par le front DOIT apparaître dans ce JSON.
|
||||
> **Definition of Done** (miroir M2/M3/M4) : avant les écrans front, **capturer la réponse RÉELLE** via un test PHPUnit (`WeighingTicketSerializationContractTest`, ticket complet seedé : contrepartie Client, pesée vide + plein) et la coller ici. Toute donnée affichée par le front DOIT apparaître dans ce JSON.
|
||||
>
|
||||
> **Pièges re-testés — tous VERTS** (assertions dans le test) :
|
||||
> 1. ✅ `client` sort en **objet embarqué** (`client:read`), pas en IRI nu ; `supplier` **omis car null** (`skip_null_values` — jamais un IRI nu). Sur une contrepartie Fournisseur, `supplier` sortirait symétriquement en objet (`supplier:read`).
|
||||
> 2. ✅ Booléen `plateFreeFormat` : **clé présente** (getter `isPlateFreeFormat()` + `SerializedName('plateFreeFormat')`).
|
||||
> 3. ✅ `number` présent et formaté `{siteCode}-TP-{NNNN}` (ici `86-TP-0001`).
|
||||
> 4. ✅ `netWeight` cohérent = `full - empty` = `14300 - 7150` = **`7150`** (RG-5.05).
|
||||
>
|
||||
> **Note `skip_null_values`** : les champs null sont **omis** du JSON (ex. `supplier`, `otherLabel`, `emptyManualNumber`, `fullManualNumber` absents quand null). Le front ne doit pas présumer leur présence — lire avec un défaut (`?? null`).
|
||||
> **Pièges à re-tester** :
|
||||
> 1. `client` / `supplier` doivent sortir en **objet embarqué**, pas en IRI nu → read-groups `client:read`/`supplier:read`.
|
||||
> 2. Booléen `plateFreeFormat` : clé présente (piège #3 M1 → getter + `SerializedName` si besoin).
|
||||
> 3. `number` présent et formaté `{siteCode}-TP-{NNNN}`.
|
||||
> 4. `netWeight` cohérent = `full - empty` (plein − vide, RG-5.05).
|
||||
|
||||
**`GET /api/weighing_tickets?search=86-TP-0001` (LISTE)** — enveloppe Hydra AP4 (`member`/`totalItems`/`view`), filtrée site courant (§ 2.3). Capture réelle :
|
||||
**`GET /api/weighing_tickets` (LISTE)** — enveloppe Hydra AP4 (`member`/`totalItems`/`view`), filtrée site courant (§ 2.3) :
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -528,94 +524,41 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
||||
"totalItems": 1,
|
||||
"member": [
|
||||
{
|
||||
"@id": "/api/weighing_tickets/9",
|
||||
"@id": "/api/weighing_tickets/1",
|
||||
"@type": "WeighingTicket",
|
||||
"id": 9,
|
||||
"id": 1,
|
||||
"number": "86-TP-0001",
|
||||
"counterpartyType": "CLIENT",
|
||||
"client": {
|
||||
"@id": "/api/clients/629",
|
||||
"@type": "Client",
|
||||
"id": 629,
|
||||
"companyName": "NÉGOCE MÉTAUX ATLANTIQUE",
|
||||
"triageService": false,
|
||||
"categories": [],
|
||||
"createdAt": "2026-06-18T11:50:47+02:00",
|
||||
"updatedAt": "2026-06-18T11:50:47+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me",
|
||||
"sites": [],
|
||||
"isArchived": false
|
||||
},
|
||||
"client": { "@id": "/api/clients/117", "@type": "Client", "id": 117, "companyName": "NÉGOCE MÉTAUX ATLANTIQUE" },
|
||||
"supplier": null,
|
||||
"otherLabel": null,
|
||||
"displayDate": "2026-06-17T09:12:00+02:00",
|
||||
"netWeight": 12340,
|
||||
"plateFreeFormat": false,
|
||||
"netWeight": 7150,
|
||||
"createdAt": "2026-06-18T11:50:48+02:00",
|
||||
"updatedAt": "2026-06-18T11:50:48+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me",
|
||||
"displayDate": "2026-06-17T09:12:00+02:00"
|
||||
// supplier / otherLabel omis (null → skip_null_values)
|
||||
"createdAt": "2026-06-17T09:12:00+02:00",
|
||||
"updatedAt": "2026-06-17T09:12:00+02:00"
|
||||
}
|
||||
],
|
||||
"view": { "@id": "/api/weighing_tickets?search=86-TP-0001", "@type": "PartialCollectionView" }
|
||||
"view": { "@id": "/api/weighing_tickets", "@type": "PartialCollectionView" }
|
||||
}
|
||||
```
|
||||
|
||||
**`GET /api/weighing_tickets/9` (DÉTAIL)** — ajoute le site embarqué (avec `code`), l'immatriculation et les deux pesées. Capture réelle :
|
||||
**`GET /api/weighing_tickets/{id}` (DÉTAIL)** — ajoute les pesées :
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"@context": "/api/contexts/WeighingTicket",
|
||||
"@id": "/api/weighing_tickets/9",
|
||||
"@id": "/api/weighing_tickets/1",
|
||||
"@type": "WeighingTicket",
|
||||
"id": 9,
|
||||
"id": 1,
|
||||
"number": "86-TP-0001",
|
||||
"site": {
|
||||
"@id": "/api/sites/1",
|
||||
"@type": "Site",
|
||||
"id": 1,
|
||||
"name": "Chatellerault",
|
||||
"code": "86",
|
||||
"street": "14 All. d'Argenson",
|
||||
"postalCode": "86100",
|
||||
"city": "Châtellerault",
|
||||
"color": "#056CF2",
|
||||
"createdAt": "2026-06-17T17:07:47+02:00",
|
||||
"updatedAt": "2026-06-17T17:07:47+02:00",
|
||||
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
|
||||
},
|
||||
"site": { "@id": "/api/sites/1", "@type": "Site", "id": 1, "name": "Châtellerault", "code": "86" },
|
||||
"counterpartyType": "CLIENT",
|
||||
"client": {
|
||||
"@id": "/api/clients/629",
|
||||
"@type": "Client",
|
||||
"id": 629,
|
||||
"companyName": "NÉGOCE MÉTAUX ATLANTIQUE",
|
||||
"triageService": false,
|
||||
"categories": [],
|
||||
"createdAt": "2026-06-18T11:50:47+02:00",
|
||||
"updatedAt": "2026-06-18T11:50:47+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me",
|
||||
"sites": [],
|
||||
"isArchived": false
|
||||
},
|
||||
"client": { "@id": "/api/clients/117", "@type": "Client", "id": 117, "companyName": "NÉGOCE MÉTAUX ATLANTIQUE" },
|
||||
"immatriculation": "AB-123-CD",
|
||||
"plateFreeFormat": false,
|
||||
"emptyDate": "2026-06-17T09:00:00+02:00",
|
||||
"emptyWeight": 7150,
|
||||
"emptyDsd": 1,
|
||||
"emptyMode": "AUTO",
|
||||
"fullDate": "2026-06-17T09:12:00+02:00",
|
||||
"fullWeight": 14300,
|
||||
"fullDsd": 2,
|
||||
"fullMode": "AUTO",
|
||||
"netWeight": 7150,
|
||||
"createdAt": "2026-06-18T11:50:48+02:00",
|
||||
"updatedAt": "2026-06-18T11:50:48+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me",
|
||||
"displayDate": "2026-06-17T09:12:00+02:00"
|
||||
// emptyManualNumber / fullManualNumber omis (null → skip_null_values)
|
||||
"emptyDate": "2026-06-17T09:00:00+02:00", "emptyWeight": 14660, "emptyDsd": 41, "emptyMode": "AUTO", "emptyManualNumber": null,
|
||||
"fullDate": "2026-06-17T09:12:00+02:00", "fullWeight": 27000, "fullDsd": 42, "fullMode": "AUTO", "fullManualNumber": null,
|
||||
"netWeight": 12340
|
||||
}
|
||||
```
|
||||
|
||||
@@ -666,12 +609,6 @@ Action **autonome** (le ticket n'est pas encore créé quand on déclenche la pe
|
||||
- Colonnes : Numéro, Contrepartie (Client/Fournisseur/Autre + nom), Date, Immatriculation, Poids vide, Poids plein, **Poids net**, DSD vide/plein.
|
||||
- Génération via le helper XLSX standard projet (skill `xlsx`). Endpoint : provider dédié renvoyant un binaire (`Content-Type` xlsx) — whitelisté pagination (`EXCLUDED`) car export complet.
|
||||
|
||||
### 4.6 Impression — `GET /api/weighing_tickets/{id}/print.pdf` (bon de pesée, OWNER Tristan)
|
||||
|
||||
- Opération API Platform dédiée (provider renvoyant un binaire PDF, **pas de controller**). Sécurité `is_granted('logistique.weighing_tickets.view')`.
|
||||
- Rendu d'un **template Twig** (`templates/logistique/weighing_ticket_print.html.twig`) → PDF (cf. § 2.12). `Content-Type: application/pdf`, inline.
|
||||
- Contenu : cf. § 2.12. Données déjà portées par le ticket — aucun champ API supplémentaire.
|
||||
|
||||
## 5. RBAC, module & sidebar
|
||||
|
||||
### 5.1 `LogistiqueModule::permissions()`
|
||||
@@ -744,7 +681,7 @@ final class WeighingTicketFieldNormalizer
|
||||
| **RG-5.05** | back | Poids net = `poids plein − poids vide`, calculé serveur, exposé en liste/détail (§ 2.8 — confirmé Matthieu 17/06). |
|
||||
| **RG-5.06** | docx+back | Pesée bascule indisponible → erreur explicite + bascule en pesée manuelle. Au M5, le pont est un **stub** (poids aléatoire ∈ [10000,50000] kg, § 2.6). |
|
||||
| **RG-5.07** | docx | Formulaire à vide : `Date` = date du jour par défaut ; `Poids` et `DSD` **readonly** (remplis par la pesée, pas saisis). |
|
||||
| **RG-5.08** | docx | « Valider » (création) → enregistre + ouvre le **bon de pesée (PDF servi par le back)**. En modification : bouton « Valider » → « Enregistrer », bouton « Imprimer » disponible (absent à l'ajout) → ouvre le même PDF. Le bouton « Enregistré » du bloc pesée à vide disparaît en modification. **Bon de pesée = PDF généré back via template Twig, OWNER Tristan** (§ 2.12 / § 4.6). |
|
||||
| **RG-5.08** | docx | « Valider » (création) → enregistre + ouvre la modal d'impression. En modification : bouton « Valider » → « Enregistrer », bouton d'impression disponible (absent à l'ajout). Le bouton « Enregistré » du bloc pesée à vide disparaît en modification. **Le bon d'impression est réalisé par Tristan** (§ 2.12). |
|
||||
| **RG-5.09** | back | Site & numéro immuables après création ; liste cloisonnée par site courant (§ 2.3, à confirmer). |
|
||||
| **RG-5.10** | back | Normalisation immatriculation (trim/UPPER/format) côté serveur (§ 6). |
|
||||
|
||||
@@ -769,7 +706,7 @@ Cohérence inter-champs (RG-5.03, RG-5.01) implémentée via `#[Assert\Callback]
|
||||
| HP-M5-01 | Vue multi-sites des tickets (retirer le cloisonnement + filtre `?siteId=`) si demandé (§ 2.3). |
|
||||
| HP-M5-02 | Driver matériel réel du pont bascule (protocole série/TCP, parsing trame, reconnexion) derrière `WeighbridgeReaderInterface` (§ 2.6). |
|
||||
| HP-M5-03 | Sens réception-expédition explicite + contrôle de signe du net (le net reste `plein − vide`, § 2.8). |
|
||||
| ~~HP-M5-04~~ | **Passé en périmètre** : bon de pesée = PDF serveur via template Twig → ticket back dédié (OWNER Tristan, § 2.12 / § 4.6). |
|
||||
| HP-M5-04 | Génération PDF serveur du ticket (`/print.pdf`) si l'impression navigateur ne suffit pas (§ 2.12). |
|
||||
| HP-M5-05 | Archivage fonctionnel des tickets (non prévu au docx — § 2.13). |
|
||||
|
||||
## 10. Tickets Lesstime (à découper — back en tête)
|
||||
@@ -783,8 +720,8 @@ Cohérence inter-champs (RG-5.03, RG-5.01) implémentée via `#[Assert\Callback]
|
||||
| 4 | `WeighingTicketProvider` + `WeighingTicketProcessor` (numérotation, RG-5.03/5.05, normalisation) | Backend |
|
||||
| 5 | Export XLSX | Backend |
|
||||
| 6 | Tests PHPUnit RG-5.01→5.10 + capture contrat JSON | Backend |
|
||||
| 6.bis (ERP-192) | **Bon de pesée — PDF via template Twig** (`/print.pdf`, § 2.12 / § 4.6) | **Backend (OWNER Tristan)** |
|
||||
| 7 | Page liste `/weighing-tickets` (usePaginatedList) + export | Frontend |
|
||||
| 8 | Écran Ajouter (formulaires vide + plein, pesée bascule/manuelle, masque immat) + ouverture PDF à la validation | Frontend |
|
||||
| 9 | Écran Modification + bouton « Imprimer » (ouvre le PDF back) | Frontend |
|
||||
| 8 | Écran Ajouter (formulaires vide + plein, pesée bascule/manuelle, masque immat) | Frontend |
|
||||
| 9 | Écran Modification (la **modal/bon d'impression** = **Tristan**, § 2.12) | Frontend |
|
||||
| 10 | i18n + libellé audit + branchement site courant | Frontend |
|
||||
| — | **Bon d'impression du ticket de pesée** | **Tristan (hors découpe M5)** |
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Application\Service;
|
||||
|
||||
use App\Module\Logistique\Domain\Exception\InvalidImmatriculationException;
|
||||
|
||||
/**
|
||||
* Normalisation serveur des champs texte d'un WeighingTicket, appliquee par le
|
||||
* WeighingTicketProcessor AVANT persistance. Cf. spec-back M5 § 6 + RG-5.01 /
|
||||
* RG-5.10. Jumeau leger de CarrierFieldNormalizer (M4).
|
||||
*
|
||||
* - immatriculation (RG-5.01 / RG-5.10) : trim + UPPER. Si « Tout format » N'EST
|
||||
* PAS coche (freeFormat = false), la saisie est ramenee au masque SIV
|
||||
* canonique XX-000-XX (separateurs/espaces ignores a la saisie, re-poses) ; une
|
||||
* plaque qui ne s'y conforme pas leve InvalidImmatriculationException (-> 422
|
||||
* par le Processor). En « Tout format » (anciennes plaques, etranger, engins),
|
||||
* seul le trim + UPPER s'applique.
|
||||
* - otherLabel (RG-5.03) : trim ; une chaine vide apres trim devient null (evite
|
||||
* de persister "" dans une colonne nullable).
|
||||
*
|
||||
* Methodes null-safe : une entree null ressort null (l'obligation eventuelle est
|
||||
* portee par les Assert de l'entite / la coherence contrepartie, pas ici).
|
||||
*/
|
||||
final class WeighingTicketFieldNormalizer
|
||||
{
|
||||
/**
|
||||
* Plaque SIV « nue » (sans separateurs) : 2 lettres, 3 chiffres, 2 lettres.
|
||||
* Les lettres interdites du SIV (I, O, U + SS) ne sont pas filtrees ici : le
|
||||
* masque de saisie reste volontairement simple (le metier accepte ces cas via
|
||||
* « Tout format » si besoin).
|
||||
*/
|
||||
private const string SIV_BARE_PATTERN = '/^[A-Z]{2}[0-9]{3}[A-Z]{2}$/';
|
||||
|
||||
/**
|
||||
* Normalise l'immatriculation (RG-5.01 / RG-5.10).
|
||||
*
|
||||
* @param bool $freeFormat « Tout format » coche -> masque SIV desactive
|
||||
*
|
||||
* @throws InvalidImmatriculationException si !freeFormat et la plaque ne
|
||||
* respecte pas le masque XX-000-XX
|
||||
*/
|
||||
public function normalizeImmatriculation(?string $value, bool $freeFormat): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = mb_strtoupper(trim($value), 'UTF-8');
|
||||
if ('' === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// « Tout format » : aucune contrainte de masque (RG-5.01).
|
||||
if ($freeFormat) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Masque SIV : on ignore tout ce qui n'est pas alphanumerique (l'operateur
|
||||
// peut saisir « ab123cd », « AB 123 CD » ou « AB-123-CD ») puis on valide
|
||||
// le squelette 2-3-2 et on repose les separateurs canoniques.
|
||||
$bare = preg_replace('/[^A-Z0-9]/', '', $value) ?? '';
|
||||
|
||||
if (1 !== preg_match(self::SIV_BARE_PATTERN, $bare)) {
|
||||
throw new InvalidImmatriculationException(
|
||||
'Format d\'immatriculation invalide : attendu XX-000-XX (cochez « Tout format » pour une plaque libre).',
|
||||
);
|
||||
}
|
||||
|
||||
return sprintf('%s-%s-%s', substr($bare, 0, 2), substr($bare, 2, 3), substr($bare, 5, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim du libelle « Autre » (RG-5.03). Une chaine vide apres trim devient null.
|
||||
*/
|
||||
public function normalizeOtherLabel(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : $value;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Application\Service;
|
||||
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
|
||||
/**
|
||||
* Allocateur du numero de ticket de pesee (RG-5.02, § 2.5).
|
||||
*
|
||||
* Le numero a le format {siteCode}-TP-{NNNN} (ex. 86-TP-0001), UNIQUE PAR SITE
|
||||
* et immuable. Chaque site porte sa propre sequence : 86-TP-0001 et 17-TP-0001
|
||||
* coexistent.
|
||||
*
|
||||
* Le code du site (prefixe) vit sur l'entite Site (site.code, ERP-183) — d'ou le
|
||||
* type-hint sur Site concret (et non SiteInterface qui n'expose pas getCode()) ;
|
||||
* c'est la meme reference ORM partagee que celle consommee par WeighingTicket
|
||||
* (§ 2.1, pas de logique inter-module).
|
||||
*
|
||||
* Port (couche Application) ; l'implementation (WeighingTicketNumberAllocator,
|
||||
* Infrastructure) incremente le compteur weighing_ticket_counter sous verrou ligne
|
||||
* `SELECT ... FOR UPDATE` pour garantir l'unicite meme en concurrence.
|
||||
*/
|
||||
interface WeighingTicketNumberAllocatorInterface
|
||||
{
|
||||
/**
|
||||
* Attribue et renvoie le prochain numero formate {siteCode}-TP-{NNNN} pour le
|
||||
* site, en persistant l'increment de maniere atomique (verrou ligne).
|
||||
*/
|
||||
public function allocate(Site $site): string;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Levee quand une immatriculation ne respecte pas le masque SIV XX-000-XX alors
|
||||
* que « Tout format » n'est PAS coche (plateFreeFormat = false, RG-5.01).
|
||||
*
|
||||
* Exception de DOMAINE (pure, sans dependance HTTP) levee par le
|
||||
* WeighingTicketFieldNormalizer : c'est le WeighingTicketProcessor qui la traduit
|
||||
* en 422 portant un propertyPath « immatriculation » (mapping inline useFormErrors,
|
||||
* convention ERP-101) plutot qu'un toast.
|
||||
*/
|
||||
final class InvalidImmatriculationException extends RuntimeException {}
|
||||
@@ -18,14 +18,13 @@ interface WeighingTicketRepositoryInterface
|
||||
public function save(WeighingTicket $ticket): void;
|
||||
|
||||
/**
|
||||
* QueryBuilder de SELECTION (recherche + tri + fetch-join client/supplier/site)
|
||||
* pour la liste, exploite par le WeighingTicketProvider (ERP-185) qui le wrappe
|
||||
* dans un Paginator (règle ABSOLUE n°13). Exclut les soft-deletes (deleted_at
|
||||
* IS NOT NULL). Tri par defaut number DESC (plus recents en tete, § 4.1).
|
||||
* QueryBuilder de SELECTION (recherche + tri) pour la liste, exploite par le
|
||||
* WeighingTicketProvider (ERP-185) qui le wrappe dans un Paginator (règle
|
||||
* ABSOLUE n°13). Exclut les soft-deletes (deleted_at IS NOT NULL). Tri par
|
||||
* defaut number DESC (plus recents en tete, § 4.1).
|
||||
*
|
||||
* Le cloisonnement par site courant n'est PAS applique ici : un provider custom
|
||||
* court-circuite le SiteScopedQueryExtension (qui n'agit que dans le provider
|
||||
* ORM standard), donc le WeighingTicketProvider l'applique lui-meme (§ 2.3).
|
||||
* Le cloisonnement par site courant n'est PAS applique ici : il l'est
|
||||
* automatiquement par le SiteScopedQueryExtension (Sites, § 2.3).
|
||||
*
|
||||
* @param null|string $search recherche fuzzy sur number, nom client/fournisseur,
|
||||
* other_label et immatriculation (§ 4.1)
|
||||
|
||||
-209
@@ -1,209 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
|
||||
use App\Module\Logistique\Application\Service\WeighingTicketFieldNormalizer;
|
||||
use App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface;
|
||||
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||
use App\Module\Logistique\Domain\Exception\InvalidImmatriculationException;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture du ticket de pesee (M5). Cf. spec-back M5 § 4.3 / § 4.4 +
|
||||
* RG-5.01 / RG-5.02 / RG-5.03 / RG-5.04 / RG-5.05 / RG-5.09 / RG-5.10. Jumeau des
|
||||
* processors M2/M3/M4, recentre sur les regles specifiques du ticket de pesee.
|
||||
*
|
||||
* Sequence (POST / PATCH) — l'entite arrive deja VALIDEE (les Assert + le Callback
|
||||
* RG-5.03 ont joue en amont) :
|
||||
* 1. CREATION uniquement (RG-5.09, immuables) : resolution du site courant
|
||||
* (CurrentSiteProviderInterface — seule logique cross-module autorisee, regle
|
||||
* ABSOLUE n°1) puis attribution du numero {siteCode}-TP-{NNNN} (compteur
|
||||
* verrouille, RG-5.02). Le PATCH ne retouche ni site ni numero.
|
||||
* 2. Coherence contrepartie (RG-5.03) : null-ification des champs hors-branche
|
||||
* selon counterpartyType (la PRESENCE du champ requis est deja validee par le
|
||||
* Callback de l'entite ; ici on garantit l'EXCLUSIVITE — sinon les CHECK
|
||||
* Postgres chk_wt_*_branch leveraient une 500 generique).
|
||||
* 3. Normalisation immatriculation (RG-5.01 / RG-5.10) : trim + UPPER + masque
|
||||
* XX-000-XX si !plateFreeFormat. Format invalide -> 422 sur « immatriculation »
|
||||
* (mapping inline useFormErrors, ERP-101).
|
||||
* 4. DSD autoritaire (RG-5.04) : pour chaque pesee AUTO, (re)attribution du DSD
|
||||
* via DsdAllocator (verrou FOR UPDATE). Le DSD renvoye par
|
||||
* POST /api/weighbridge_readings est PREVISIONNEL ; l'attribution autoritaire
|
||||
* est faite ici. Une pesee MANUELLE conserve le DSD deja alloue par l'endpoint
|
||||
* de pesee (« dernier + 1 », round-trip par le client, deja consomme).
|
||||
* 5. Poids net (RG-5.05) : net_weight = full_weight - empty_weight si les deux
|
||||
* poids sont presents, sinon null.
|
||||
* 6. Persistance via le persist_processor Doctrine.
|
||||
*
|
||||
* @implements ProcessorInterface<WeighingTicket, WeighingTicket>
|
||||
*/
|
||||
final class WeighingTicketProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
private readonly WeighingTicketNumberAllocatorInterface $numberAllocator,
|
||||
private readonly DsdAllocatorInterface $dsdAllocator,
|
||||
private readonly WeighingTicketFieldNormalizer $normalizer,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof WeighingTicket) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
// Une entite non geree par l'ORM = creation (POST) : site + numero ne sont
|
||||
// attribues qu'a ce moment et restent immuables ensuite (RG-5.09).
|
||||
$isNew = !$this->em->contains($data);
|
||||
|
||||
if ($isNew) {
|
||||
$site = $this->resolveCurrentSite();
|
||||
$data->setSite($site);
|
||||
$data->setNumber($this->numberAllocator->allocate($site));
|
||||
}
|
||||
|
||||
$this->applyCounterpartyExclusivity($data);
|
||||
$this->normalizeImmatriculation($data);
|
||||
|
||||
// Le site est toujours present apres creation ; sur PATCH il est charge
|
||||
// depuis la base. Garde defensive si jamais il manque (ne devrait pas).
|
||||
$site = $data->getSite();
|
||||
if ($site instanceof Site) {
|
||||
$this->allocateAutoDsd($data, $site, $isNew);
|
||||
}
|
||||
|
||||
$this->computeNetWeight($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resout le site courant (sélecteur de site). Absent = aucun site selectionne
|
||||
* -> 400 : on ne peut pas numeroter ni rattacher un ticket sans site (site_id
|
||||
* NOT NULL, § 2.3).
|
||||
*/
|
||||
private function resolveCurrentSite(): Site
|
||||
{
|
||||
$site = $this->currentSiteProvider->get();
|
||||
if (!$site instanceof Site) {
|
||||
throw new BadRequestHttpException('Aucun site courant sélectionné — sélectionnez un site avant de créer un ticket de pesée.');
|
||||
}
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-5.03 : garantit l'exclusivite de la contrepartie en forcant a null les
|
||||
* champs hors-branche selon counterpartyType. La PRESENCE du champ requis est
|
||||
* deja validee en amont (Assert\Callback de l'entite) ; ici on evite qu'un
|
||||
* payload portant a la fois client_id ET supplier_id ne fasse echouer les CHECK
|
||||
* Postgres (500 generique au lieu d'une donnee coherente). otherLabel est
|
||||
* normalise (trim) dans la branche AUTRE.
|
||||
*/
|
||||
private function applyCounterpartyExclusivity(WeighingTicket $data): void
|
||||
{
|
||||
switch ($data->getCounterpartyType()) {
|
||||
case 'CLIENT':
|
||||
$data->setSupplier(null);
|
||||
$data->setOtherLabel(null);
|
||||
|
||||
break;
|
||||
|
||||
case 'FOURNISSEUR':
|
||||
$data->setClient(null);
|
||||
$data->setOtherLabel(null);
|
||||
|
||||
break;
|
||||
|
||||
case 'AUTRE':
|
||||
$data->setClient(null);
|
||||
$data->setSupplier(null);
|
||||
$data->setOtherLabel($this->normalizer->normalizeOtherLabel($data->getOtherLabel()));
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-5.01 / RG-5.10 : normalisation serveur de l'immatriculation (trim + UPPER
|
||||
* + masque XX-000-XX hors « Tout format »). Un format invalide est traduit en
|
||||
* 422 portant un propertyPath « immatriculation » consommable inline par
|
||||
* useFormErrors (ERP-101), plutot qu'un toast.
|
||||
*/
|
||||
private function normalizeImmatriculation(WeighingTicket $data): void
|
||||
{
|
||||
$current = $data->getImmatriculation();
|
||||
if (null === $current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$data->setImmatriculation(
|
||||
$this->normalizer->normalizeImmatriculation($current, $data->isPlateFreeFormat()),
|
||||
);
|
||||
} catch (InvalidImmatriculationException $e) {
|
||||
$this->throwFieldViolation($data, 'immatriculation', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-5.04 : (re)attribution AUTORITAIRE du DSD pour chaque pesee AUTO via
|
||||
* DsdAllocator (verrou FOR UPDATE). A la creation, le DSD prévisionnel envoye
|
||||
* par le client (issu de POST /api/weighbridge_readings) est ecrase. Sur PATCH,
|
||||
* on n'alloue que pour une pesee AUTO encore depourvue de DSD (ex. la pesee a
|
||||
* plein realisee apres coup) — sinon on churne le compteur a chaque edition.
|
||||
* Les pesees MANUELLES conservent leur DSD (deja alloue par l'endpoint de
|
||||
* pesee, « dernier + 1 »).
|
||||
*/
|
||||
private function allocateAutoDsd(WeighingTicket $data, Site $site, bool $isNew): void
|
||||
{
|
||||
if ('AUTO' === $data->getEmptyMode() && ($isNew || null === $data->getEmptyDsd())) {
|
||||
$data->setEmptyDsd($this->dsdAllocator->next($site));
|
||||
}
|
||||
|
||||
if ('AUTO' === $data->getFullMode() && ($isNew || null === $data->getFullDsd())) {
|
||||
$data->setFullDsd($this->dsdAllocator->next($site));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-5.05 : poids net = poids plein - poids vide (kg), recalcule a chaque
|
||||
* ecriture. Null tant que l'une des deux pesees manque.
|
||||
*/
|
||||
private function computeNetWeight(WeighingTicket $data): void
|
||||
{
|
||||
$empty = $data->getEmptyWeight();
|
||||
$full = $data->getFullWeight();
|
||||
|
||||
$data->setNetWeight(null !== $empty && null !== $full ? $full - $empty : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leve une 422 portant une violation unique sur un champ — meme rendu Hydra que
|
||||
* les contraintes Symfony, consommable inline par useFormErrors (ERP-101).
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
private function throwFieldViolation(WeighingTicket $root, string $propertyPath, string $message): void
|
||||
{
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation($message, null, [], $root, $propertyPath, null));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
-187
@@ -1,187 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||
use App\Module\Logistique\Domain\Repository\WeighingTicketRepositoryInterface;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider de lecture des tickets de pesee (M5). Cf. spec-back M5 § 4.0 / § 4.1 +
|
||||
* RG-5.09. Jumeau du SupplierProvider (M2), augmente du cloisonnement par site.
|
||||
*
|
||||
* Collection (GET /api/weighing_tickets) :
|
||||
* - exclut les soft-deletes (deleted_at IS NOT NULL, prepares mais non exposes au
|
||||
* M5 — § 2.13), via le repository ;
|
||||
* - filtre ?search=... (fuzzy sur number, nom client/fournisseur, other_label,
|
||||
* immatriculation — § 4.1) ;
|
||||
* - tri ?order[displayDate]=asc|desc (date du ticket = COALESCE full/empty),
|
||||
* defaut number DESC (plus recents en tete) ;
|
||||
* - pagination obligatoire (regle ABSOLUE n°13) : Paginator ORM ; echappatoire
|
||||
* ?pagination=false ;
|
||||
* - fetch-join client / supplier / site (ManyToOne surs) pour eviter le N+1 a la
|
||||
* serialisation (§ 4.0).
|
||||
*
|
||||
* Cloisonnement par site (§ 2.3 / RG-5.09) — applique ICI : un provider custom
|
||||
* REMPLACE le provider Doctrine, donc le SiteScopedQueryExtension ne s'execute pas
|
||||
* automatiquement (il n'agit que dans le provider ORM standard). On replique sa
|
||||
* logique a l'identique :
|
||||
* - user `sites.bypass_scope` (Admin auto, consolidation) -> aucun filtre ;
|
||||
* - site courant null (module Sites off / user sans site) -> no-op (l'user voit
|
||||
* tout, decision site-aware.md § 5) ;
|
||||
* - sinon -> liste restreinte aux tickets du site courant, AVANT pagination
|
||||
* (totalItems reflete le perimetre).
|
||||
*
|
||||
* Item (GET /api/weighing_tickets/{id} + provider de PATCH) :
|
||||
* - 404 si introuvable OU soft-delete (deleted_at non null) ;
|
||||
* - 404 si hors perimetre site (ne pas reveler l'existence d'une ligne d'un autre
|
||||
* site — anti-enumeration).
|
||||
*
|
||||
* @implements ProviderInterface<WeighingTicket>
|
||||
*/
|
||||
final class WeighingTicketProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository')]
|
||||
private readonly WeighingTicketRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|WeighingTicket|null
|
||||
{
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
return $this->provideCollection($operation, $context);
|
||||
}
|
||||
|
||||
return $this->provideItem($uriVariables);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*
|
||||
* @return list<WeighingTicket>|Paginator<WeighingTicket>
|
||||
*/
|
||||
private function provideCollection(Operation $operation, array $context): array|Paginator
|
||||
{
|
||||
$filters = $context['filters'] ?? [];
|
||||
$search = $filters['search'] ?? null;
|
||||
|
||||
$qb = $this->repository->createListQueryBuilder(is_string($search) ? $search : null);
|
||||
|
||||
$this->applyDisplayDateOrder($qb, $filters);
|
||||
$this->applySiteScope($qb);
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||
// (regle n°13 — utile pour alimenter un <select> cote front).
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
// @var list<WeighingTicket> $tickets
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
// Les fetch-joins du repository sont tous ManyToOne (client/supplier/site) :
|
||||
// pas de demultiplication de lignes -> fetchJoinCollection: false (COUNT
|
||||
// simple, page correcte).
|
||||
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $uriVariables
|
||||
*/
|
||||
private function provideItem(array $uriVariables): ?WeighingTicket
|
||||
{
|
||||
$id = $uriVariables['id'] ?? null;
|
||||
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ticket = $this->repository->findById((int) $id);
|
||||
if (null === $ticket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Soft-delete : jamais expose au M5 (§ 2.13) -> 404 via retour null.
|
||||
if (null !== $ticket->getDeletedAt()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cloisonnement par site : un ticket hors perimetre -> 404 (anti-enumeration).
|
||||
$scopeSite = $this->currentScopeSite();
|
||||
if (null !== $scopeSite && $ticket->getSite()?->getId() !== $scopeSite->getId()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tri par date du ticket (§ 4.1) : displayDate = full_date ?? empty_date, donc
|
||||
* un getter calcule (pas une colonne) -> on trie sur l'expression DQL
|
||||
* COALESCE(full_date, empty_date). Absent du payload -> on garde le tri par
|
||||
* defaut du repository (number DESC).
|
||||
*
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
private function applyDisplayDateOrder(QueryBuilder $qb, array $filters): void
|
||||
{
|
||||
$order = $filters['order'] ?? null;
|
||||
if (!is_array($order) || !isset($order['displayDate'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$direction = 'asc' === strtolower((string) $order['displayDate']) ? 'ASC' : 'DESC';
|
||||
$rootAlias = $qb->getRootAliases()[0];
|
||||
|
||||
$qb->orderBy(sprintf('COALESCE(%1$s.fullDate, %1$s.emptyDate)', $rootAlias), $direction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restreint la liste au site courant si l'user n'a pas le bypass et qu'un site
|
||||
* est selectionne (cf. docblock de classe). No-op sinon.
|
||||
*/
|
||||
private function applySiteScope(QueryBuilder $qb): void
|
||||
{
|
||||
$scopeSite = $this->currentScopeSite();
|
||||
if (null === $scopeSite) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rootAlias = $qb->getRootAliases()[0];
|
||||
$qb->andWhere(sprintf('%s.site = :scopeSite', $rootAlias))
|
||||
->setParameter('scopeSite', $scopeSite)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site servant a cloisonner, ou null si aucun cloisonnement ne s'applique
|
||||
* (bypass_scope, ou pas de site courant). Replique les conditions de
|
||||
* SiteScopedQueryExtension.
|
||||
*/
|
||||
private function currentScopeSite(): ?Site
|
||||
{
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->currentSiteProvider->get();
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||
use App\Module\Logistique\Domain\Repository\WeighingTicketRepositoryInterface;
|
||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketProvider;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Export XLSX de la liste des tickets de pesee (M5, spec-back § 4.5 — bouton
|
||||
* « Exporter » : « Exporte toute la liste des tickets de pesée »). Jumeau des
|
||||
* controllers d'export SupplierExportController (M2) / ProviderExportController
|
||||
* (M3) — references en prose volontairement (un {@see} inter-module violerait la
|
||||
* regle ABSOLUE n°1).
|
||||
*
|
||||
* Controller Symfony custom (et non operation API Platform) car il produit un
|
||||
* binaire de fichier, pas une representation Hydra. `priority: 1` est OBLIGATOIRE
|
||||
* sur la route : sans cela API Platform capterait `/api/weighing_tickets/export.xlsx`
|
||||
* comme l'item `GET /api/weighing_tickets/{id}.{_format}` (id="export",
|
||||
* _format="xlsx") — cf. CLAUDE.md « controller custom sous /api ».
|
||||
*
|
||||
* Separation des responsabilites :
|
||||
* - le COMMENT (generation du fichier) est delegue au service Shared
|
||||
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
|
||||
* - le QUOI vit ICI : selection des tickets (MEMES criteres que la liste
|
||||
* `GET /api/weighing_tickets`, mais SANS pagination — export complet § 4.5) et
|
||||
* mapping metier des colonnes.
|
||||
*
|
||||
* Filtrage : on rejoue EXACTEMENT la selection du {@see WeighingTicketProvider}
|
||||
* pour que l'export reflete ce que l'utilisateur voit a l'ecran :
|
||||
* - recherche fuzzy ?search (number, nom client/fournisseur, other_label, immat) ;
|
||||
* - tri ?order[displayDate]=asc|desc (defaut number DESC) ;
|
||||
* - cloisonnement par site courant (§ 2.3 / RG-5.09) : un user sans
|
||||
* `sites.bypass_scope` possedant un site courant n'exporte que les tickets de
|
||||
* ce site. La decision est prise ICI (l'user), le filtre DQL sur wt.site est
|
||||
* pose sur le QueryBuilder. No-op pour bypass_scope ou site courant null.
|
||||
*/
|
||||
#[AsController]
|
||||
final class WeighingTicketExportController
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository')]
|
||||
private readonly WeighingTicketRepositoryInterface $repository,
|
||||
private readonly SpreadsheetExporterInterface $exporter,
|
||||
private readonly Security $security,
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
) {}
|
||||
|
||||
#[Route('/api/weighing_tickets/export.xlsx', name: 'logistique_weighing_tickets_export_xlsx', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('logistique.weighing_tickets.view')]
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$search = $request->query->getString('search') ?: null;
|
||||
|
||||
$qb = $this->repository->createListQueryBuilder($search);
|
||||
|
||||
$this->applyDisplayDateOrder($qb, $request->query->all());
|
||||
$this->applySiteScope($qb);
|
||||
|
||||
// Export complet : pas de pagination (§ 4.5). On materialise toute la
|
||||
// selection filtree (cloisonnee par site) AVANT le mapping des colonnes.
|
||||
/** @var list<WeighingTicket> $tickets */
|
||||
$tickets = $qb->getQuery()->getResult();
|
||||
|
||||
$binary = $this->exporter->export(
|
||||
'Tickets de pesée',
|
||||
$this->buildHeaders(),
|
||||
$this->buildRows($tickets),
|
||||
);
|
||||
|
||||
return $this->buildResponse($binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tri par date du ticket (§ 4.1), miroir de WeighingTicketProvider :
|
||||
* displayDate = COALESCE(full_date, empty_date) (getter calcule, pas une
|
||||
* colonne). Absent du payload -> tri par defaut du repository (number DESC).
|
||||
*
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private function applyDisplayDateOrder(QueryBuilder $qb, array $query): void
|
||||
{
|
||||
$order = $query['order'] ?? null;
|
||||
if (!is_array($order) || !isset($order['displayDate'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$direction = 'asc' === strtolower((string) $order['displayDate']) ? 'ASC' : 'DESC';
|
||||
$rootAlias = $qb->getRootAliases()[0];
|
||||
|
||||
$qb->orderBy(sprintf('COALESCE(%1$s.fullDate, %1$s.emptyDate)', $rootAlias), $direction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloisonnement par site courant (§ 2.3 / RG-5.09), miroir de
|
||||
* WeighingTicketProvider::applySiteScope() : restreint la selection au site
|
||||
* courant si l'user n'a pas le bypass et qu'un site est resolu. No-op sinon.
|
||||
*/
|
||||
private function applySiteScope(QueryBuilder $qb): void
|
||||
{
|
||||
$scopeSite = $this->siteScopeOrNull();
|
||||
if (null === $scopeSite) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rootAlias = $qb->getRootAliases()[0];
|
||||
$qb->andWhere(sprintf('%s.site = :scopeSite', $rootAlias))
|
||||
->setParameter('scopeSite', $scopeSite)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site servant a cloisonner, ou null si aucun cloisonnement ne s'applique
|
||||
* (user `sites.bypass_scope`, ou pas de site courant — module Sites off /
|
||||
* user sans currentSite). Miroir de WeighingTicketProvider::currentScopeSite().
|
||||
*/
|
||||
private function siteScopeOrNull(): ?SiteInterface
|
||||
{
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->currentSiteProvider->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes de l'export (spec § 4.5).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Numéro',
|
||||
'Type contrepartie',
|
||||
'Contrepartie',
|
||||
'Date',
|
||||
'Immatriculation',
|
||||
'Poids vide (kg)',
|
||||
'Poids plein (kg)',
|
||||
'Poids net (kg)',
|
||||
'DSD vide',
|
||||
'DSD plein',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<WeighingTicket> $tickets
|
||||
*
|
||||
* @return iterable<list<null|scalar>>
|
||||
*/
|
||||
private function buildRows(array $tickets): iterable
|
||||
{
|
||||
foreach ($tickets as $ticket) {
|
||||
yield [
|
||||
$ticket->getNumber(),
|
||||
$this->counterpartyTypeLabel($ticket->getCounterpartyType()),
|
||||
$this->counterpartyName($ticket),
|
||||
$ticket->getDisplayDate()?->format('d/m/Y H:i') ?? '',
|
||||
$ticket->getImmatriculation() ?? '',
|
||||
$ticket->getEmptyWeight() ?? '',
|
||||
$ticket->getFullWeight() ?? '',
|
||||
$ticket->getNetWeight() ?? '',
|
||||
$ticket->getEmptyDsd() ?? '',
|
||||
$ticket->getFullDsd() ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelle FR du type de contrepartie (RG-5.03). Renvoie la valeur brute pour
|
||||
* une valeur inattendue (garde-fou : ne masque pas une donnee corrompue).
|
||||
*/
|
||||
private function counterpartyTypeLabel(?string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'CLIENT' => 'Client',
|
||||
'FOURNISSEUR' => 'Fournisseur',
|
||||
'AUTRE' => 'Autre',
|
||||
default => $type ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Nom de la contrepartie selon le type (RG-5.03) : raison sociale du client,
|
||||
* du fournisseur, ou libelle libre « Autre ». Client / Supplier sont
|
||||
* fetch-joines par le repository (anti N+1, § 4.0).
|
||||
*/
|
||||
private function counterpartyName(WeighingTicket $ticket): string
|
||||
{
|
||||
return match ($ticket->getCounterpartyType()) {
|
||||
'CLIENT' => $ticket->getClient()?->getCompanyName() ?? '',
|
||||
'FOURNISSEUR' => $ticket->getSupplier()?->getCompanyName() ?? '',
|
||||
'AUTRE' => $ticket->getOtherLabel() ?? '',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function buildResponse(string $binary): Response
|
||||
{
|
||||
$filename = sprintf('tickets-pesee-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
|
||||
|
||||
$response = new Response($binary);
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -33,16 +33,12 @@ class DoctrineWeighingTicketRepository extends ServiceEntityRepository implement
|
||||
|
||||
public function createListQueryBuilder(?string $search = null): QueryBuilder
|
||||
{
|
||||
// Fetch-join (addSelect) des relations ManyToOne client / supplier / site :
|
||||
// sert a la fois la recherche par nom et l'anti-N+1 a la serialisation
|
||||
// (§ 4.0 / § 4.1) — aucune demultiplication de lignes (cardinalite to-one).
|
||||
// Le cloisonnement par site courant n'est PAS pose ici : un provider custom
|
||||
// court-circuite le SiteScopedQueryExtension, le WeighingTicketProvider
|
||||
// l'applique donc lui-meme (§ 2.3). Tri par defaut number DESC.
|
||||
// Left-join des contreparties pour la recherche par nom (sans cartesien
|
||||
// dangereux : ManyToOne). Le cloisonnement par site courant est ajoute
|
||||
// par le SiteScopedQueryExtension (§ 2.3). Tri par defaut number DESC.
|
||||
$qb = $this->createQueryBuilder('wt')
|
||||
->leftJoin('wt.client', 'c')->addSelect('c')
|
||||
->leftJoin('wt.supplier', 's')->addSelect('s')
|
||||
->leftJoin('wt.site', 'st')->addSelect('st')
|
||||
->leftJoin('wt.client', 'c')
|
||||
->leftJoin('wt.supplier', 's')
|
||||
->andWhere('wt.deletedAt IS NULL')
|
||||
->orderBy('wt.number', 'DESC')
|
||||
;
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\Service;
|
||||
|
||||
use App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
* Implementation DBAL de l'allocateur de numero de ticket (RG-5.02, § 2.5).
|
||||
*
|
||||
* Le compteur vit dans la table `weighing_ticket_counter (site_id PK,
|
||||
* last_value)` — jamais mappee en ORM (DBAL brut, exclue du schema_filter), meme
|
||||
* pattern que DsdAllocator. L'increment est realise dans une transaction avec
|
||||
* verrou ligne `SELECT ... FOR UPDATE` : deux postes creant un ticket en parallele
|
||||
* sur le meme site sont serialises -> numeros distincts, pas de collision sur
|
||||
* l'index unique uq_weighing_ticket_number (site_id, number).
|
||||
*
|
||||
* La ligne compteur n'est pas seedee a la creation du site : on la cree a la
|
||||
* volee (INSERT ... ON CONFLICT DO NOTHING) avant de prendre le verrou.
|
||||
*
|
||||
* Le numero est formate `{siteCode}-TP-%04d` (zero-padding 4 chiffres, debordement
|
||||
* naturel au-dela de 9999).
|
||||
*/
|
||||
final class WeighingTicketNumberAllocator implements WeighingTicketNumberAllocatorInterface
|
||||
{
|
||||
public function __construct(private readonly Connection $connection) {}
|
||||
|
||||
public function allocate(Site $site): string
|
||||
{
|
||||
$siteId = $site->getId();
|
||||
if (null === $siteId) {
|
||||
// Garde defensive : un site non persiste n'a pas de compteur (et la FK
|
||||
// weighing_ticket_counter.site_id -> site(id) rejetterait l'INSERT).
|
||||
throw new LogicException('Impossible d\'allouer un numero de ticket pour un site non persiste (id null).');
|
||||
}
|
||||
|
||||
$code = $site->getCode();
|
||||
if (null === $code || '' === trim($code)) {
|
||||
// site.code est NOT NULL (ERP-183) ; garde defensive pour les contextes
|
||||
// hors-flux (fixtures incompletes, site cree sans code).
|
||||
throw new LogicException(sprintf('Le site #%d n\'a pas de code de numerotation (site.code).', $siteId));
|
||||
}
|
||||
|
||||
$next = $this->connection->transactional(function (Connection $conn) use ($siteId): int {
|
||||
// Garantit l'existence de la ligne compteur du site sans ecraser une
|
||||
// valeur deja presente (idempotent, concurrence-safe).
|
||||
$conn->executeStatement(
|
||||
'INSERT INTO weighing_ticket_counter (site_id, last_value) VALUES (:site, 0) ON CONFLICT (site_id) DO NOTHING',
|
||||
['site' => $siteId],
|
||||
);
|
||||
|
||||
// Verrou ligne : serialise les creations concurrentes du meme site.
|
||||
$current = (int) $conn->fetchOne(
|
||||
'SELECT last_value FROM weighing_ticket_counter WHERE site_id = :site FOR UPDATE',
|
||||
['site' => $siteId],
|
||||
);
|
||||
|
||||
$nextValue = $current + 1;
|
||||
|
||||
$conn->executeStatement(
|
||||
'UPDATE weighing_ticket_counter SET last_value = :value WHERE site_id = :site',
|
||||
['value' => $nextValue, 'site' => $siteId],
|
||||
);
|
||||
|
||||
return $nextValue;
|
||||
});
|
||||
|
||||
return sprintf('%s-TP-%04d', $code, $next);
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Logistique\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||
use App\Module\Commercial\Domain\Entity\Supplier as SupplierEntity;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Base des tests fonctionnels du ticket de pesee (M5). Mutualise le seeding des
|
||||
* dependances (Client cross-module, user manage/view rattache a un site courant),
|
||||
* le payload POST de reference et la purge ciblee (pas de DAMA en local).
|
||||
*
|
||||
* Cloisonnement (§ 2.3) : le POST resout le site depuis le site courant de l'user
|
||||
* (CurrentSiteProvider) ; on positionne donc toujours un site courant avant
|
||||
* d'ecrire. Les Client de test sont prefixes pour une purge sans collision.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase
|
||||
{
|
||||
protected const string LD = 'application/ld+json';
|
||||
protected const string MERGE = 'application/merge-patch+json';
|
||||
|
||||
/** Prefixe companyName des Client seedes par ces tests (purge ciblee). */
|
||||
protected const string TEST_CLIENT_PREFIX = 'ZTESTWTAPI';
|
||||
|
||||
/** Prefixe companyName des Supplier seedes par ces tests (purge ciblee). */
|
||||
protected const string TEST_SUPPLIER_PREFIX = 'ZTESTWTAPISUP';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
// Tickets referencant un Client OU un Supplier de test d'abord (FK
|
||||
// client_id / supplier_id RESTRICT) : purge DBAL brute pour liberer la
|
||||
// contrepartie avant de la supprimer. Un ticket FOURNISSEUR a client_id
|
||||
// NULL -> il faut bien purger aussi par supplier_id (sinon ticket orphelin).
|
||||
$em->getConnection()->executeStatement(
|
||||
'DELETE FROM weighing_ticket WHERE client_id IN (SELECT id FROM client WHERE company_name LIKE :p)',
|
||||
['p' => self::TEST_CLIENT_PREFIX.'%'],
|
||||
);
|
||||
$em->getConnection()->executeStatement(
|
||||
'DELETE FROM weighing_ticket WHERE supplier_id IN (SELECT id FROM supplier WHERE company_name LIKE :p)',
|
||||
['p' => self::TEST_SUPPLIER_PREFIX.'%'],
|
||||
);
|
||||
$em->createQuery('DELETE FROM '.SupplierEntity::class.' s WHERE s.companyName LIKE :p')
|
||||
->setParameter('p', self::TEST_SUPPLIER_PREFIX.'%')->execute()
|
||||
;
|
||||
$em->createQuery('DELETE FROM '.ClientEntity::class.' c WHERE c.companyName LIKE :p')
|
||||
->setParameter('p', self::TEST_CLIENT_PREFIX.'%')->execute()
|
||||
;
|
||||
$em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :p')
|
||||
->setParameter('p', 'testuser_%')->execute()
|
||||
;
|
||||
$em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :p')
|
||||
->setParameter('p', 'test_%')->execute()
|
||||
;
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Garde-fou ERP-101 (miroir M4) : une 422 doit porter une violation sur le
|
||||
* `propertyPath` attendu, et pas seulement le bon code HTTP.
|
||||
*/
|
||||
protected static function assertViolationOnPath(object $response, string $path): void
|
||||
{
|
||||
/** @var ResponseInterface $response */
|
||||
$paths = array_column($response->toArray(false)['violations'] ?? [], 'propertyPath');
|
||||
|
||||
self::assertContains(
|
||||
$path,
|
||||
$paths,
|
||||
sprintf('Aucune violation sur "%s" (paths: %s).', $path, implode(', ', $paths)),
|
||||
);
|
||||
}
|
||||
|
||||
protected function firstSite(): Site
|
||||
{
|
||||
$site = $this->getEm()->getRepository(Site::class)->findAll()[0] ?? null;
|
||||
self::assertInstanceOf(Site::class, $site, 'Un site fixture est requis (SitesFixtures).');
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
protected function siteByCode(string $code): Site
|
||||
{
|
||||
$site = $this->getEm()->getRepository(Site::class)->findOneBy(['code' => $code]);
|
||||
self::assertInstanceOf(Site::class, $site, sprintf('Le site de code "%s" doit etre seede.', $code));
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree un user non-admin portant view + manage, lui positionne $site comme site
|
||||
* courant (cloisonnement + numerotation) et renvoie un client authentifie.
|
||||
*/
|
||||
protected function authManageOnSite(Site $site): Client
|
||||
{
|
||||
$creds = $this->createUserWithPermissions([
|
||||
'logistique.weighing_tickets.view',
|
||||
'logistique.weighing_tickets.manage',
|
||||
]);
|
||||
|
||||
$this->setCurrentSite($creds['username'], $site);
|
||||
|
||||
return $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Positionne le site courant d'un user (par username) — persiste en base, donc
|
||||
* survit au reboot du kernel a l'authentification.
|
||||
*/
|
||||
protected function setCurrentSite(string $username, Site $site): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$user = $em->getRepository(User::class)->findOneBy(['username' => $username]);
|
||||
self::assertInstanceOf(User::class, $user);
|
||||
|
||||
$user->setCurrentSite($em->getReference(Site::class, $site->getId()));
|
||||
$em->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un Client minimal (companyName prefixe pour la purge). Sert de
|
||||
* contrepartie aux tickets de test.
|
||||
*/
|
||||
protected function seedTestClient(string $label): ClientEntity
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
|
||||
$client = new ClientEntity();
|
||||
$client->setCompanyName(mb_strtoupper(self::TEST_CLIENT_PREFIX.' '.$label.' '.$suffix, 'UTF-8'));
|
||||
$em->persist($client);
|
||||
$em->flush();
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
protected function clientIri(ClientEntity $client): string
|
||||
{
|
||||
return '/api/clients/'.$client->getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un Supplier minimal (companyName prefixe pour la purge). Sert de
|
||||
* contrepartie aux tickets de test en branche FOURNISSEUR (RG-5.03).
|
||||
*/
|
||||
protected function seedTestSupplier(string $label): SupplierEntity
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
|
||||
$supplier = new SupplierEntity();
|
||||
$supplier->setCompanyName(mb_strtoupper(self::TEST_SUPPLIER_PREFIX.' '.$label.' '.$suffix, 'UTF-8'));
|
||||
$em->persist($supplier);
|
||||
$em->flush();
|
||||
|
||||
return $supplier;
|
||||
}
|
||||
|
||||
protected function supplierIri(SupplierEntity $supplier): string
|
||||
{
|
||||
return '/api/suppliers/'.$supplier->getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload POST de reference : contrepartie Client, pesee a vide + a plein en
|
||||
* mode AUTO (le Processor (re)alloue les DSD et calcule le net = 14300 - 7150).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function validClientTicketPayload(ClientEntity $client): array
|
||||
{
|
||||
return [
|
||||
'counterpartyType' => 'CLIENT',
|
||||
'client' => $this->clientIri($client),
|
||||
'immatriculation' => 'AB-123-CD',
|
||||
'plateFreeFormat' => false,
|
||||
'emptyDate' => '2026-06-17T09:00:00+02:00',
|
||||
'emptyWeight' => 7150,
|
||||
'emptyMode' => 'AUTO',
|
||||
'fullDate' => '2026-06-17T09:12:00+02:00',
|
||||
'fullWeight' => 14300,
|
||||
'fullMode' => 'AUTO',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload POST de reference en branche FOURNISSEUR (RG-5.03) — miroir de
|
||||
* validClientTicketPayload, contrepartie Supplier. Sert a prouver l'embed
|
||||
* symetrique de `supplier` (spec § 4.0.bis piege #1).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function validSupplierTicketPayload(SupplierEntity $supplier): array
|
||||
{
|
||||
return [
|
||||
'counterpartyType' => 'FOURNISSEUR',
|
||||
'supplier' => $this->supplierIri($supplier),
|
||||
'immatriculation' => 'AB-123-CD',
|
||||
'plateFreeFormat' => false,
|
||||
'emptyDate' => '2026-06-17T09:00:00+02:00',
|
||||
'emptyWeight' => 7150,
|
||||
'emptyMode' => 'AUTO',
|
||||
'fullDate' => '2026-06-17T09:12:00+02:00',
|
||||
'fullWeight' => 14300,
|
||||
'fullMode' => 'AUTO',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* POST un ticket et renvoie la reponse (assertions de statut a la charge de
|
||||
* l'appelant).
|
||||
*/
|
||||
protected function postTicket(Client $http, array $payload): ResponseInterface
|
||||
{
|
||||
return $http->request('POST', '/api/weighing_tickets', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $payload,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrouve un membre d'une collection Hydra par son id.
|
||||
*
|
||||
* @param array<string, mixed> $collection
|
||||
*
|
||||
* @return null|array<string, mixed>
|
||||
*/
|
||||
protected function memberById(array $collection, int $id): ?array
|
||||
{
|
||||
foreach ($collection['member'] ?? [] as $member) {
|
||||
if (($member['id'] ?? null) === $id) {
|
||||
return $member;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -29,11 +29,9 @@ final class WeighbridgeReadingApiTest extends AbstractApiTestCase
|
||||
$em = $this->getEm();
|
||||
$em->getConnection()->executeStatement('DELETE FROM weighbridge_dsd_counter');
|
||||
$em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :p')
|
||||
->setParameter('p', 'testuser_%')->execute()
|
||||
;
|
||||
->setParameter('p', 'testuser_%')->execute();
|
||||
$em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :p')
|
||||
->setParameter('p', 'test_%')->execute()
|
||||
;
|
||||
->setParameter('p', 'test_%')->execute();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
@@ -97,45 +95,24 @@ final class WeighbridgeReadingApiTest extends AbstractApiTestCase
|
||||
{
|
||||
$client = $this->manageClientWithCurrentSite();
|
||||
|
||||
$response = $client->request('POST', '/api/weighbridge_readings', [
|
||||
$client->request('POST', '/api/weighbridge_readings', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => ['mode' => 'INVALID'],
|
||||
]);
|
||||
|
||||
// Garde-fou ERP-101 : la 422 doit cibler `mode` (Assert\Choice), pas juste
|
||||
// un bon code HTTP — sinon une violation sur le mauvais champ passerait.
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertViolationOnPath($response, 'mode');
|
||||
}
|
||||
|
||||
public function testManualWeighingRequiresWeight(): void
|
||||
{
|
||||
$client = $this->manageClientWithCurrentSite();
|
||||
|
||||
$response = $client->request('POST', '/api/weighbridge_readings', [
|
||||
$client->request('POST', '/api/weighbridge_readings', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => ['mode' => 'MANUAL'],
|
||||
]);
|
||||
|
||||
// Garde-fou ERP-101 : la 422 doit cibler `weight` (Callback validateManualWeight).
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertViolationOnPath($response, 'weight');
|
||||
}
|
||||
|
||||
/**
|
||||
* Garde-fou ERP-101 (miroir AbstractWeighingTicketApiTestCase) : une 422 doit
|
||||
* porter une violation sur le `propertyPath` attendu, consommable inline par
|
||||
* useFormErrors cote front, pas seulement le bon statut HTTP.
|
||||
*/
|
||||
private static function assertViolationOnPath(object $response, string $path): void
|
||||
{
|
||||
$paths = array_column($response->toArray(false)['violations'] ?? [], 'propertyPath');
|
||||
|
||||
self::assertContains(
|
||||
$path,
|
||||
$paths,
|
||||
sprintf('Aucune violation sur "%s" (paths: %s).', $path, implode(', ', $paths)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Logistique\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
||||
use DateTimeImmutable;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'export XLSX des tickets de pesee (M5, § 4.5).
|
||||
* Jumeau du {@see \App\Tests\Module\Transport\Api\CarrierExportControllerTest}.
|
||||
*
|
||||
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes), mapping
|
||||
* des colonnes (numero, contrepartie, poids vide/plein/net, DSD vide/plein) avec
|
||||
* net = plein - vide, cloisonnement par site courant (un non-admin n'exporte que
|
||||
* les tickets de son site), 403 sans `logistique.weighing_tickets.view`, 401
|
||||
* anonyme.
|
||||
*
|
||||
* Nettoyage manuel (pas de DAMA) : tickets/clients de test (prefixes dedies) +
|
||||
* users/roles `test*`.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class WeighingTicketExportControllerTest extends AbstractApiTestCase
|
||||
{
|
||||
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
private const string EXPORT_URL = '/api/weighing_tickets/export.xlsx';
|
||||
private const string NUMBER_PREFIX = 'ZTEST-';
|
||||
private const string CLIENT_PREFIX = 'ZTESTWT';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$em->createQuery('DELETE FROM '.WeighingTicket::class.' wt WHERE wt.number LIKE :p')
|
||||
->setParameter('p', self::NUMBER_PREFIX.'%')->execute()
|
||||
;
|
||||
$em->createQuery('DELETE FROM '.ClientEntity::class.' c WHERE c.companyName LIKE :p')
|
||||
->setParameter('p', self::CLIENT_PREFIX.'%')->execute()
|
||||
;
|
||||
$em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :p')
|
||||
->setParameter('p', 'testuser_%')->execute()
|
||||
;
|
||||
$em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :p')
|
||||
->setParameter('p', 'test_%')->execute()
|
||||
;
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testExportReturnsXlsxResponseWithHeaders(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$this->seedTicketWithClient($this->firstSite(), 'Acme');
|
||||
|
||||
$response = $client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$headers = $response->getHeaders(false);
|
||||
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
|
||||
|
||||
$disposition = $headers['content-disposition'][0] ?? '';
|
||||
self::assertMatchesRegularExpression('/filename="tickets-pesee-\d{8}\.xlsx"/', $disposition);
|
||||
|
||||
// 1re ligne = en-tetes attendus (ordre des colonnes § 4.5).
|
||||
$header = $this->gridFromResponse($response->getContent())[0];
|
||||
self::assertSame('Numéro', $header[0]);
|
||||
self::assertContains('Type contrepartie', $header);
|
||||
self::assertContains('Contrepartie', $header);
|
||||
self::assertContains('Date', $header);
|
||||
self::assertContains('Immatriculation', $header);
|
||||
self::assertContains('Poids vide (kg)', $header);
|
||||
self::assertContains('Poids plein (kg)', $header);
|
||||
self::assertContains('Poids net (kg)', $header);
|
||||
self::assertContains('DSD vide', $header);
|
||||
self::assertContains('DSD plein', $header);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping des colonnes : la ligne exportee porte les bonnes valeurs aux bons
|
||||
* index, et le poids net = poids plein - poids vide (RG-5.05).
|
||||
*/
|
||||
public function testExportMapsColumnsAndComputesNetWeight(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$ticket = $this->seedTicketWithClient($this->firstSite(), 'Béton SA');
|
||||
|
||||
$grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
$header = $grid[0];
|
||||
$row = $this->rowByNumber($grid, (string) $ticket->getNumber());
|
||||
self::assertNotNull($row, 'La ligne du ticket seede doit etre presente dans l\'export.');
|
||||
|
||||
$cell = static fn (string $label) => $row[array_search($label, $header, true)] ?? null;
|
||||
|
||||
self::assertSame('Client', $cell('Type contrepartie'));
|
||||
self::assertStringContainsString('BÉTON SA', (string) $cell('Contrepartie'));
|
||||
self::assertSame('AB-123-CD', $cell('Immatriculation'));
|
||||
self::assertSame(7150, (int) $cell('Poids vide (kg)'));
|
||||
self::assertSame(14300, (int) $cell('Poids plein (kg)'));
|
||||
self::assertSame(7150, (int) $cell('Poids net (kg)'));
|
||||
self::assertSame(7150, (int) $cell('Poids plein (kg)') - (int) $cell('Poids vide (kg)'));
|
||||
self::assertSame(41, (int) $cell('DSD vide'));
|
||||
self::assertSame(42, (int) $cell('DSD plein'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloisonnement par site (§ 2.3 / RG-5.09) : un non-admin (sans bypass)
|
||||
* possedant un site courant n'exporte QUE les tickets de ce site.
|
||||
*/
|
||||
public function testExportIsScopedToCurrentSiteForNonAdmin(): void
|
||||
{
|
||||
$sites = $this->getEm()->getRepository(Site::class)->findAll();
|
||||
self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites attendus (fixtures).');
|
||||
|
||||
$ticketHere = $this->seedTicketWithClient($sites[0], 'Ici');
|
||||
$ticketOther = $this->seedTicketWithClient($sites[1], 'Ailleurs');
|
||||
|
||||
$client = $this->viewClientWithCurrentSite($sites[0]);
|
||||
|
||||
$numbers = $this->numbersFromResponse($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains($ticketHere->getNumber(), $numbers);
|
||||
self::assertNotContains($ticketOther->getNumber(), $numbers);
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutViewPermission(): void
|
||||
{
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testUnauthorizedWhenAnonymous(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
private function firstSite(): Site
|
||||
{
|
||||
$site = $this->getEm()->getRepository(Site::class)->findAll()[0] ?? null;
|
||||
self::assertInstanceOf(Site::class, $site, 'Un site fixture est requis.');
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un ticket complet (contrepartie Client, pesee vide + plein) rattache au
|
||||
* site donne. Numero unique prefixe pour la purge. Le net est pose
|
||||
* explicitement (pas de Processor sur un persist direct) = plein - vide.
|
||||
*/
|
||||
private function seedTicketWithClient(Site $site, string $label): WeighingTicket
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
$clientEntity = new ClientEntity();
|
||||
$clientEntity->setCompanyName(mb_strtoupper(self::CLIENT_PREFIX.' '.$label, 'UTF-8'));
|
||||
$em->persist($clientEntity);
|
||||
|
||||
$ticket = new WeighingTicket();
|
||||
$ticket->setSite($em->getReference(Site::class, $site->getId()));
|
||||
$ticket->setNumber(self::NUMBER_PREFIX.substr(bin2hex(random_bytes(5)), 0, 10));
|
||||
$ticket->setCounterpartyType('CLIENT');
|
||||
$ticket->setClient($clientEntity);
|
||||
$ticket->setImmatriculation('AB-123-CD');
|
||||
$ticket->setEmptyDate(new DateTimeImmutable('2026-06-17 09:00:00'));
|
||||
$ticket->setEmptyWeight(7150);
|
||||
$ticket->setEmptyDsd(41);
|
||||
$ticket->setEmptyMode('AUTO');
|
||||
$ticket->setFullDate(new DateTimeImmutable('2026-06-17 09:12:00'));
|
||||
$ticket->setFullWeight(14300);
|
||||
$ticket->setFullDsd(42);
|
||||
$ticket->setFullMode('AUTO');
|
||||
$ticket->setNetWeight(7150);
|
||||
|
||||
$em->persist($ticket);
|
||||
$em->flush();
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree un non-admin portant `logistique.weighing_tickets.view`, lui positionne
|
||||
* un site courant (cloisonnement § 2.3) et renvoie un client authentifie.
|
||||
*/
|
||||
private function viewClientWithCurrentSite(Site $site): Client
|
||||
{
|
||||
$creds = $this->createUserWithPermission('logistique.weighing_tickets.view');
|
||||
|
||||
$em = $this->getEm();
|
||||
$user = $em->getRepository(User::class)->findOneBy(['username' => $creds['username']]);
|
||||
self::assertInstanceOf(User::class, $user);
|
||||
$user->setCurrentSite($em->getReference(Site::class, $site->getId()));
|
||||
$em->flush();
|
||||
|
||||
return $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
|
||||
*
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
private function gridFromResponse(string $binary): array
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_wt_export_test_');
|
||||
self::assertIsString($tmp);
|
||||
file_put_contents($tmp, $binary);
|
||||
|
||||
try {
|
||||
return IOFactory::load($tmp)->getActiveSheet()->toArray();
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Premiere ligne de donnees dont la colonne « Numéro » vaut $number, ou null.
|
||||
*
|
||||
* @param array<int, array<int, mixed>> $grid
|
||||
*
|
||||
* @return null|array<int, mixed>
|
||||
*/
|
||||
private function rowByNumber(array $grid, string $number): ?array
|
||||
{
|
||||
foreach (array_slice($grid, 1) as $row) {
|
||||
if ((string) ($row[0] ?? '') === $number) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonne « Numéro » (1re colonne) des lignes de donnees.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function numbersFromResponse(string $binary): array
|
||||
{
|
||||
$rows = array_slice($this->gridFromResponse($binary), 1); // saute l'en-tete
|
||||
|
||||
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Logistique\Api;
|
||||
|
||||
/**
|
||||
* Numerotation des tickets de pesee (RG-5.02 / § 2.5) — tests fonctionnels sur
|
||||
* l'API reelle (compteur DBAL `weighing_ticket_counter`, verrou FOR UPDATE).
|
||||
*
|
||||
* Couvre : format {siteCode}-TP-{NNNN}, sequence incrementale et unique PAR site,
|
||||
* independance des sequences entre sites, immuabilite du numero et du site au PATCH
|
||||
* (RG-5.09 : aucun groupe d'ecriture sur ces champs).
|
||||
*
|
||||
* La serialisation concurrente (FOR UPDATE) est exercee a l'identique par le
|
||||
* DsdAllocator (cf. DsdAllocatorTest) ; un vrai parallelisme n'est pas reproductible
|
||||
* en PHPUnit mono-processus — on valide ici la sequence deterministe.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCase
|
||||
{
|
||||
public function testNumberFormatAndSequentialPerSite(): void
|
||||
{
|
||||
$site = $this->siteByCode('86');
|
||||
$http = $this->authManageOnSite($site);
|
||||
$client = $this->seedTestClient('Num');
|
||||
|
||||
$first = $this->postTicket($http, $this->validClientTicketPayload($client));
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
$second = $this->postTicket($http, $this->validClientTicketPayload($client));
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
$n1 = (string) $first->toArray()['number'];
|
||||
$n2 = (string) $second->toArray()['number'];
|
||||
|
||||
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n1);
|
||||
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n2);
|
||||
self::assertNotSame($n1, $n2, 'Deux tickets du meme site portent des numeros distincts (unicite).');
|
||||
|
||||
// Sequence : le second numero = premier + 1 (compteur par site).
|
||||
self::assertSame($this->suffix($n1) + 1, $this->suffix($n2));
|
||||
}
|
||||
|
||||
public function testNumberingIsIsolatedPerSite(): void
|
||||
{
|
||||
$client = $this->seedTestClient('IsoSite');
|
||||
|
||||
$http86 = $this->authManageOnSite($this->siteByCode('86'));
|
||||
$http17 = $this->authManageOnSite($this->siteByCode('17'));
|
||||
|
||||
$n86 = (string) $this->postTicket($http86, $this->validClientTicketPayload($client))->toArray()['number'];
|
||||
$n17 = (string) $this->postTicket($http17, $this->validClientTicketPayload($client))->toArray()['number'];
|
||||
|
||||
// Chaque site encode son propre code dans le numero ; sequences disjointes.
|
||||
self::assertStringStartsWith('86-TP-', $n86);
|
||||
self::assertStringStartsWith('17-TP-', $n17);
|
||||
}
|
||||
|
||||
public function testNumberAndSiteAreImmutableOnPatch(): void
|
||||
{
|
||||
$site = $this->siteByCode('86');
|
||||
$http = $this->authManageOnSite($site);
|
||||
$client = $this->seedTestClient('Immutable');
|
||||
|
||||
$created = $this->postTicket($http, $this->validClientTicketPayload($client))->toArray();
|
||||
$id = (int) $created['id'];
|
||||
$number = (string) $created['number'];
|
||||
|
||||
// Tentative de re-ecriture du numero et du site (aucun groupe d'ecriture) +
|
||||
// changement legitime de la pesee a plein -> net recalcule.
|
||||
$patched = $http->request('PATCH', '/api/weighing_tickets/'.$id, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => [
|
||||
'number' => 'HACK-TP-9999',
|
||||
'site' => '/api/sites/'.$this->siteByCode('17')->getId(),
|
||||
'fullWeight' => 20000,
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertSame($number, $patched['number'], 'Le numero est immuable (RG-5.02 / RG-5.09).');
|
||||
self::assertSame('86', $patched['site']['code'], 'Le site est immuable (RG-5.09).');
|
||||
// Net recalcule : 20000 - 7150 = 12850 (RG-5.05).
|
||||
self::assertSame(12850, $patched['netWeight']);
|
||||
}
|
||||
|
||||
/** Suffixe numerique {NNNN} d'un numero {siteCode}-TP-{NNNN}. */
|
||||
private function suffix(string $number): int
|
||||
{
|
||||
return (int) substr($number, strrpos($number, '-') + 1);
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Logistique\Api;
|
||||
|
||||
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
/**
|
||||
* Matrice RBAC du ticket de pesee par role metier (spec-back M5 § 5.2). Jumeau de
|
||||
* {@see \App\Tests\Module\Transport\Api\CarrierRBACMatrixTest}.
|
||||
*
|
||||
* Matrice § 5.2 (V0.2) :
|
||||
* - admin / bureau / usine : view + manage (200 lecture, 201 creation)
|
||||
* - compta / commerciale : AUCUN acces (403 sur view ET manage)
|
||||
* - anonyme : 401
|
||||
*
|
||||
* La creation (POST -> 201) suppose un site courant (numerotation + cloisonnement,
|
||||
* § 2.3) : on le positionne pour chaque role autorise a ecrire.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class WeighingTicketRBACMatrixTest extends AbstractWeighingTicketApiTestCase
|
||||
{
|
||||
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Seed idempotent des roles metier + matrice § 5.2 + comptes demo (meme
|
||||
// chemin qu'en recette).
|
||||
self::bootKernel();
|
||||
$application = new Application(self::$kernel);
|
||||
$application->setAutoExit(false);
|
||||
$exit = $application->run(
|
||||
new ArrayInput([
|
||||
'command' => 'app:seed-rbac',
|
||||
'--with-demo-users' => true,
|
||||
'--password' => self::PWD,
|
||||
]),
|
||||
new NullOutput(),
|
||||
);
|
||||
self::assertSame(
|
||||
0,
|
||||
$exit,
|
||||
'app:seed-rbac a echoue : les permissions logistique.weighing_tickets.* sont-elles synchronisees (app:sync-permissions) ?',
|
||||
);
|
||||
|
||||
self::ensureKernelShutdown();
|
||||
}
|
||||
|
||||
public function testAdminCanViewAndManage(): void
|
||||
{
|
||||
$this->assertCanViewAndManage('admin', 'admin');
|
||||
}
|
||||
|
||||
public function testBureauCanViewAndManage(): void
|
||||
{
|
||||
$this->assertCanViewAndManage('bureau', self::PWD);
|
||||
}
|
||||
|
||||
public function testUsineCanViewAndManage(): void
|
||||
{
|
||||
$this->assertCanViewAndManage('usine', self::PWD);
|
||||
}
|
||||
|
||||
public function testComptaHasNoAccess(): void
|
||||
{
|
||||
$this->assertHasNoAccess('compta');
|
||||
}
|
||||
|
||||
public function testCommercialeHasNoAccess(): void
|
||||
{
|
||||
$this->assertHasNoAccess('commerciale');
|
||||
}
|
||||
|
||||
public function testAnonymousIsUnauthorized(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', '/api/weighing_tickets', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Role autorise : GET 200 (view) + POST 201 (manage). Le site courant est
|
||||
* positionne avant le POST pour permettre la numerotation.
|
||||
*/
|
||||
private function assertCanViewAndManage(string $username, string $password): void
|
||||
{
|
||||
$site = $this->firstSite();
|
||||
$this->setCurrentSite($username, $site);
|
||||
|
||||
$clientEntity = $this->seedTestClient('Rbac '.$username);
|
||||
$http = $this->authenticatedClient($username, $password);
|
||||
|
||||
$http->request('GET', '/api/weighing_tickets', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
$this->postTicket($http, $this->validClientTicketPayload($clientEntity));
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Role sans acces : 403 en lecture (view absent) ET en ecriture (manage absent).
|
||||
*/
|
||||
private function assertHasNoAccess(string $username): void
|
||||
{
|
||||
$clientEntity = $this->seedTestClient('Rbac '.$username);
|
||||
$http = $this->authenticatedClient($username, self::PWD);
|
||||
|
||||
$http->request('GET', '/api/weighing_tickets', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$this->postTicket($http, $this->validClientTicketPayload($clientEntity));
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Logistique\Api;
|
||||
|
||||
/**
|
||||
* Contrat de serialisation du ticket de pesee (M5, spec-back § 4.0 / § 4.0.bis).
|
||||
* Jumeau du test de contrat M4 CarrierSerializationContractTest (module Transport,
|
||||
* reference en prose pour ne pas materialiser d'import inter-module).
|
||||
*
|
||||
* Capture le JSON REEL (liste + detail) via un ticket cree par l'API (numerotation
|
||||
* serveur reelle) et reverifie les 4 pieges du RETEX M1 transposes au M5 :
|
||||
* #1 : `client` (et `supplier`) sortent en OBJET embarque, pas en IRI nu
|
||||
* (read-groups client:read / supplier:read).
|
||||
* #2 : booleen `plateFreeFormat` present dans le JSON (getter + SerializedName).
|
||||
* #3 : `number` present, formate {siteCode}-TP-{NNNN}.
|
||||
* #4 : `netWeight` coherent = full - empty (plein - vide, RG-5.05).
|
||||
*
|
||||
* REGLE D'OR : on asserte sur le CORPS JSON reel, jamais sur les annotations.
|
||||
* DoD (§ 4.0.bis) : avec WEIGHING_TICKET_DOD_DUMP positionnee, ecrit les corps
|
||||
* liste + detail sous /tmp pour les coller dans la spec avant les ecrans front.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class WeighingTicketSerializationContractTest extends AbstractWeighingTicketApiTestCase
|
||||
{
|
||||
public function testListAndDetailSerializationContract(): void
|
||||
{
|
||||
$site = $this->siteByCode('86');
|
||||
$http = $this->authManageOnSite($site);
|
||||
$clientEntity = $this->seedTestClient('Negoce');
|
||||
|
||||
$created = $this->postTicket($http, $this->validClientTicketPayload($clientEntity));
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
$createdBody = $created->toArray();
|
||||
|
||||
$id = (int) $createdBody['id'];
|
||||
$number = (string) $createdBody['number'];
|
||||
|
||||
$detail = $http->request('GET', '/api/weighing_tickets/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
$list = $http->request('GET', '/api/weighing_tickets?search='.$number, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// Enveloppe Hydra AP4 (member/totalItems sans prefixe hydra:).
|
||||
self::assertArrayHasKey('member', $list);
|
||||
self::assertArrayNotHasKey('hydra:member', $list);
|
||||
|
||||
$row = $this->memberById($list, $id);
|
||||
self::assertNotNull($row, 'Le ticket cree doit apparaitre dans la liste filtree.');
|
||||
|
||||
// === Piege #1 : relations embarquees en OBJET (pas IRI nu) ===
|
||||
self::assertIsArray($row['client'], 'client doit etre un objet embarque (client:read), pas un IRI nu.');
|
||||
self::assertArrayHasKey('companyName', $row['client']);
|
||||
// supplier null sur une contrepartie Client (cle potentiellement omise par
|
||||
// skip_null_values — tolerant aux deux cas, jamais un IRI nu).
|
||||
self::assertNull($row['supplier'] ?? null);
|
||||
|
||||
// === Piege #2 : booleen plateFreeFormat present ===
|
||||
self::assertArrayHasKey('plateFreeFormat', $row);
|
||||
self::assertFalse($row['plateFreeFormat']);
|
||||
|
||||
// === Piege #3 : number formate {siteCode}-TP-{NNNN} ===
|
||||
self::assertArrayHasKey('number', $row);
|
||||
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $row['number']);
|
||||
|
||||
// === Piege #4 : netWeight = full - empty (14300 - 7150) ===
|
||||
self::assertSame(7150, $row['netWeight']);
|
||||
|
||||
// displayDate (date du ticket = fullDate ?? emptyDate) expose en liste.
|
||||
self::assertArrayHasKey('displayDate', $row);
|
||||
|
||||
// === DETAIL : site embarque (avec code), immatriculation, les 2 pesees ===
|
||||
self::assertIsArray($detail['site']);
|
||||
self::assertSame('86', $detail['site']['code']);
|
||||
self::assertSame('AB-123-CD', $detail['immatriculation']);
|
||||
self::assertSame(7150, $detail['emptyWeight']);
|
||||
self::assertSame(14300, $detail['fullWeight']);
|
||||
self::assertSame(7150, $detail['netWeight']);
|
||||
self::assertIsArray($detail['client']);
|
||||
self::assertArrayHasKey('companyName', $detail['client']);
|
||||
|
||||
$this->dumpDodIfRequested($list, $detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Piege #1 symetrique (spec § 4.0.bis) : sur une contrepartie FOURNISSEUR,
|
||||
* `supplier` doit sortir en OBJET embarque (supplier:read) et `client` etre
|
||||
* null (jamais un IRI nu). Le cas Client est couvert ci-dessus ; ce test
|
||||
* verrouille l'autre branche pour qu'un drift de read-group cote Supplier ne
|
||||
* passe pas inapercu.
|
||||
*/
|
||||
public function testSupplierCounterpartyEmbedsSupplier(): void
|
||||
{
|
||||
$site = $this->siteByCode('86');
|
||||
$http = $this->authManageOnSite($site);
|
||||
$supplierEntity = $this->seedTestSupplier('Ferraille');
|
||||
|
||||
$created = $this->postTicket($http, $this->validSupplierTicketPayload($supplierEntity));
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
$createdBody = $created->toArray();
|
||||
|
||||
$id = (int) $createdBody['id'];
|
||||
$number = (string) $createdBody['number'];
|
||||
|
||||
$detail = $http->request('GET', '/api/weighing_tickets/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
$list = $http->request('GET', '/api/weighing_tickets?search='.$number, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
$row = $this->memberById($list, $id);
|
||||
self::assertNotNull($row, 'Le ticket fournisseur cree doit apparaitre dans la liste filtree.');
|
||||
|
||||
// Liste : supplier embarque en objet, client omis/null (skip_null_values).
|
||||
self::assertIsArray($row['supplier'], 'supplier doit etre un objet embarque (supplier:read), pas un IRI nu.');
|
||||
self::assertArrayHasKey('companyName', $row['supplier']);
|
||||
self::assertNull($row['client'] ?? null);
|
||||
self::assertSame('FOURNISSEUR', $row['counterpartyType']);
|
||||
|
||||
// Detail : meme contrat cote item.
|
||||
self::assertIsArray($detail['supplier']);
|
||||
self::assertArrayHasKey('companyName', $detail['supplier']);
|
||||
self::assertNull($detail['client'] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* DoD (§ 4.0.bis) : ecrit les corps JSON reels sous /tmp si WEIGHING_TICKET_DOD_DUMP
|
||||
* est positionnee (sinon no-op). A coller dans spec-back.md § 4.0.bis.
|
||||
*
|
||||
* @param array<string, mixed> $list
|
||||
* @param array<string, mixed> $detail
|
||||
*/
|
||||
private function dumpDodIfRequested(array $list, array $detail): void
|
||||
{
|
||||
if (false === getenv('WEIGHING_TICKET_DOD_DUMP')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
|
||||
file_put_contents('/tmp/weighing-ticket-dod-list.json', json_encode($list, $flags));
|
||||
file_put_contents('/tmp/weighing-ticket-dod-detail.json', json_encode($detail, $flags));
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Logistique\Application\Service;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
|
||||
use App\Module\Logistique\Application\Service\WeighingTicketFieldNormalizer;
|
||||
use App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface;
|
||||
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||
use App\Module\Logistique\Domain\Exception\InvalidImmatriculationException;
|
||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighingTicketProcessor;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Normalisation de l'immatriculation (RG-5.01 / RG-5.10 / § 6 / § 2.10) — test
|
||||
* unitaire en deux volets, sans BDD ni HTTP :
|
||||
*
|
||||
* 1. Le WeighingTicketFieldNormalizer applique trim + UPPER ; hors « Tout format »
|
||||
* il ramene la saisie au masque SIV canonique XX-000-XX (separateurs/espaces
|
||||
* ignores puis re-poses) et leve InvalidImmatriculationException si le squelette
|
||||
* 2-3-2 n'est pas respecte. En « Tout format », seul trim + UPPER s'applique.
|
||||
* 2. Le WeighingTicketProcessor traduit cette exception de domaine en 422 portant
|
||||
* un propertyPath « immatriculation » (mapping inline useFormErrors, ERP-101).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ImmatriculationNormalizationTest extends TestCase
|
||||
{
|
||||
private WeighingTicketFieldNormalizer $normalizer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->normalizer = new WeighingTicketFieldNormalizer();
|
||||
}
|
||||
|
||||
// === Volet 1 : normalisation pure (masque + « Tout format ») ===
|
||||
|
||||
#[DataProvider('provideMaskedPlates')]
|
||||
public function testMaskedPlateIsReformattedToCanonicalSiv(string $input): void
|
||||
{
|
||||
self::assertSame('AB-123-CD', $this->normalizer->normalizeImmatriculation($input, false));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function provideMaskedPlates(): iterable
|
||||
{
|
||||
yield 'deja canonique' => ['AB-123-CD'];
|
||||
yield 'minuscules nues' => ['ab123cd'];
|
||||
yield 'espaces' => ['AB 123 CD'];
|
||||
yield 'minuscules tirets'=> ['ab-123-cd'];
|
||||
yield 'espaces de garde' => [' ab-123-cd '];
|
||||
}
|
||||
|
||||
public function testInvalidPlateWithoutFreeFormatThrows(): void
|
||||
{
|
||||
$this->expectException(InvalidImmatriculationException::class);
|
||||
$this->normalizer->normalizeImmatriculation('ABC-12-D', false);
|
||||
}
|
||||
|
||||
public function testFreeFormatBypassesTheMask(): void
|
||||
{
|
||||
// Ancienne plaque / engin : aucune contrainte de masque, juste trim + UPPER.
|
||||
self::assertSame('1234 WW 75', $this->normalizer->normalizeImmatriculation(' 1234 ww 75 ', true));
|
||||
self::assertSame('ENGIN-XYZ', $this->normalizer->normalizeImmatriculation('engin-xyz', true));
|
||||
}
|
||||
|
||||
public function testNullAndBlankAreNormalizedToNull(): void
|
||||
{
|
||||
self::assertNull($this->normalizer->normalizeImmatriculation(null, false));
|
||||
self::assertNull($this->normalizer->normalizeImmatriculation(' ', false));
|
||||
self::assertNull($this->normalizer->normalizeImmatriculation(' ', true));
|
||||
}
|
||||
|
||||
public function testOtherLabelIsTrimmedAndBlankBecomesNull(): void
|
||||
{
|
||||
self::assertSame('Reprise interne', $this->normalizer->normalizeOtherLabel(' Reprise interne '));
|
||||
self::assertNull($this->normalizer->normalizeOtherLabel(' '));
|
||||
self::assertNull($this->normalizer->normalizeOtherLabel(null));
|
||||
}
|
||||
|
||||
// === Volet 2 : mapping 422 par le Processor (RG-5.01, ERP-101) ===
|
||||
|
||||
public function testProcessorMapsInvalidPlateTo422OnImmatriculationPath(): void
|
||||
{
|
||||
$ticket = (new WeighingTicket())
|
||||
->setCounterpartyType('AUTRE')
|
||||
->setOtherLabel('Reprise')
|
||||
->setImmatriculation('PLAQUE INVALIDE')
|
||||
->setPlateFreeFormat(false)
|
||||
;
|
||||
|
||||
try {
|
||||
$this->makeProcessor()->process($ticket, new Post());
|
||||
self::fail('Une ValidationException (422) etait attendue sur une immatriculation invalide.');
|
||||
} catch (ValidationException $e) {
|
||||
$paths = [];
|
||||
foreach ($e->getConstraintViolationList() as $violation) {
|
||||
$paths[] = $violation->getPropertyPath();
|
||||
}
|
||||
self::assertContains('immatriculation', $paths);
|
||||
}
|
||||
}
|
||||
|
||||
public function testProcessorReformatsValidPlateAndHonorsFreeFormat(): void
|
||||
{
|
||||
// Masque applique a la persistance (saisie nue -> canonique).
|
||||
$masked = (new WeighingTicket())
|
||||
->setCounterpartyType('AUTRE')
|
||||
->setOtherLabel('Reprise')
|
||||
->setImmatriculation('ab123cd')
|
||||
->setPlateFreeFormat(false)
|
||||
;
|
||||
$this->makeProcessor()->process($masked, new Post());
|
||||
self::assertSame('AB-123-CD', $masked->getImmatriculation());
|
||||
|
||||
// « Tout format » : la plaque libre passe (UPPER seulement), aucune 422.
|
||||
$free = (new WeighingTicket())
|
||||
->setCounterpartyType('AUTRE')
|
||||
->setOtherLabel('Reprise')
|
||||
->setImmatriculation('vieux 4321 zz')
|
||||
->setPlateFreeFormat(true)
|
||||
;
|
||||
$this->makeProcessor()->process($free, new Post());
|
||||
self::assertSame('VIEUX 4321 ZZ', $free->getImmatriculation());
|
||||
}
|
||||
|
||||
private function makeProcessor(): WeighingTicketProcessor
|
||||
{
|
||||
$persist = $this->createStub(ProcessorInterface::class);
|
||||
$persist->method('process')->willReturnArgument(0);
|
||||
|
||||
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
|
||||
$siteProvider->method('get')->willReturn(
|
||||
(new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233'))->setCode('86'),
|
||||
);
|
||||
|
||||
$numberAllocator = $this->createStub(WeighingTicketNumberAllocatorInterface::class);
|
||||
$numberAllocator->method('allocate')->willReturn('86-TP-0001');
|
||||
|
||||
$em = $this->createStub(EntityManagerInterface::class);
|
||||
$em->method('contains')->willReturn(false);
|
||||
|
||||
return new WeighingTicketProcessor(
|
||||
$persist,
|
||||
$siteProvider,
|
||||
$numberAllocator,
|
||||
$this->createStub(DsdAllocatorInterface::class),
|
||||
new WeighingTicketFieldNormalizer(),
|
||||
$em,
|
||||
);
|
||||
}
|
||||
}
|
||||
-187
@@ -1,187 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
|
||||
use App\Module\Logistique\Application\Service\WeighingTicketFieldNormalizer;
|
||||
use App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface;
|
||||
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighingTicketProcessor;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Validator\Validation;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
/**
|
||||
* Coherence de la contrepartie (RG-5.03 / § 2.9) — test unitaire en deux volets,
|
||||
* sans BDD ni HTTP :
|
||||
*
|
||||
* 1. PRESENCE (Assert\Callback de l'entite) : selon counterpartyType, le champ
|
||||
* associe est obligatoire ; la violation porte le bon propertyPath
|
||||
* (client / supplier / otherLabel) pour le mapping inline useFormErrors
|
||||
* (ERP-101). Valide via le validateur Symfony sur l'entite (les attributs
|
||||
* #[Assert\*] sont lus directement).
|
||||
* 2. EXCLUSIVITE (WeighingTicketProcessor) : les champs hors-branche sont forces
|
||||
* a null avant persistance (garde-fou des CHECK Postgres chk_wt_*_branch).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CounterpartyValidationTest extends TestCase
|
||||
{
|
||||
private ValidatorInterface $validator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->validator = Validation::createValidatorBuilder()
|
||||
->enableAttributeMapping()
|
||||
->getValidator()
|
||||
;
|
||||
}
|
||||
|
||||
// === Volet 1 : presence du champ requis (Assert\Callback) ===
|
||||
|
||||
public function testClientBranchRequiresClient(): void
|
||||
{
|
||||
$ticket = $this->baseTicket('CLIENT');
|
||||
|
||||
// Sans client : violation attendue sur le path « client ».
|
||||
self::assertContains('client', $this->violationPaths($ticket));
|
||||
|
||||
// Avec client : plus de violation sur « client ».
|
||||
$ticket->setClient(new Client());
|
||||
self::assertNotContains('client', $this->violationPaths($ticket));
|
||||
}
|
||||
|
||||
public function testSupplierBranchRequiresSupplier(): void
|
||||
{
|
||||
$ticket = $this->baseTicket('FOURNISSEUR');
|
||||
|
||||
self::assertContains('supplier', $this->violationPaths($ticket));
|
||||
|
||||
$ticket->setSupplier(new Supplier());
|
||||
self::assertNotContains('supplier', $this->violationPaths($ticket));
|
||||
}
|
||||
|
||||
public function testOtherBranchRequiresOtherLabel(): void
|
||||
{
|
||||
$ticket = $this->baseTicket('AUTRE');
|
||||
|
||||
// Ni null ni chaine vide apres trim ne suffisent (RG-5.03).
|
||||
self::assertContains('otherLabel', $this->violationPaths($ticket));
|
||||
|
||||
$ticket->setOtherLabel(' ');
|
||||
self::assertContains('otherLabel', $this->violationPaths($ticket));
|
||||
|
||||
$ticket->setOtherLabel('Reprise interne');
|
||||
self::assertNotContains('otherLabel', $this->violationPaths($ticket));
|
||||
}
|
||||
|
||||
// === Volet 2 : exclusivite (le Processor null-ifie les champs hors-branche) ===
|
||||
|
||||
public function testClientBranchNullifiesSupplierAndOtherLabel(): void
|
||||
{
|
||||
$ticket = $this->baseTicket('CLIENT')
|
||||
->setClient(new Client())
|
||||
->setSupplier(new Supplier())
|
||||
->setOtherLabel('parasite')
|
||||
;
|
||||
|
||||
$this->makeProcessor()->process($ticket, new Post());
|
||||
|
||||
self::assertInstanceOf(Client::class, $ticket->getClient());
|
||||
self::assertNull($ticket->getSupplier());
|
||||
self::assertNull($ticket->getOtherLabel());
|
||||
}
|
||||
|
||||
public function testSupplierBranchNullifiesClientAndOtherLabel(): void
|
||||
{
|
||||
$ticket = $this->baseTicket('FOURNISSEUR')
|
||||
->setClient(new Client())
|
||||
->setSupplier(new Supplier())
|
||||
->setOtherLabel('parasite')
|
||||
;
|
||||
|
||||
$this->makeProcessor()->process($ticket, new Post());
|
||||
|
||||
self::assertInstanceOf(Supplier::class, $ticket->getSupplier());
|
||||
self::assertNull($ticket->getClient());
|
||||
self::assertNull($ticket->getOtherLabel());
|
||||
}
|
||||
|
||||
public function testOtherBranchNullifiesClientAndSupplierAndTrimsLabel(): void
|
||||
{
|
||||
$ticket = $this->baseTicket('AUTRE')
|
||||
->setClient(new Client())
|
||||
->setSupplier(new Supplier())
|
||||
->setOtherLabel(' Reprise interne ')
|
||||
;
|
||||
|
||||
$this->makeProcessor()->process($ticket, new Post());
|
||||
|
||||
self::assertNull($ticket->getClient());
|
||||
self::assertNull($ticket->getSupplier());
|
||||
self::assertSame('Reprise interne', $ticket->getOtherLabel());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticket minimal VALIDE hors contrepartie : counterpartyType + immatriculation
|
||||
* renseignes, afin d'isoler la violation de contrepartie (et pas un NotBlank
|
||||
* collateral) dans le volet 1.
|
||||
*/
|
||||
private function baseTicket(string $type): WeighingTicket
|
||||
{
|
||||
return (new WeighingTicket())
|
||||
->setCounterpartyType($type)
|
||||
->setImmatriculation('AB-123-CD')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des propertyPath des violations de l'entite.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function violationPaths(WeighingTicket $ticket): array
|
||||
{
|
||||
$paths = [];
|
||||
foreach ($this->validator->validate($ticket) as $violation) {
|
||||
$paths[] = $violation->getPropertyPath();
|
||||
}
|
||||
|
||||
return $paths;
|
||||
}
|
||||
|
||||
private function makeProcessor(): WeighingTicketProcessor
|
||||
{
|
||||
$persist = $this->createStub(ProcessorInterface::class);
|
||||
$persist->method('process')->willReturnArgument(0);
|
||||
|
||||
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
|
||||
$siteProvider->method('get')->willReturn(
|
||||
(new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233'))->setCode('86'),
|
||||
);
|
||||
|
||||
$numberAllocator = $this->createStub(WeighingTicketNumberAllocatorInterface::class);
|
||||
$numberAllocator->method('allocate')->willReturn('86-TP-0001');
|
||||
|
||||
$em = $this->createStub(EntityManagerInterface::class);
|
||||
$em->method('contains')->willReturn(false);
|
||||
|
||||
return new WeighingTicketProcessor(
|
||||
$persist,
|
||||
$siteProvider,
|
||||
$numberAllocator,
|
||||
$this->createStub(DsdAllocatorInterface::class),
|
||||
new WeighingTicketFieldNormalizer(),
|
||||
$em,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
|
||||
use App\Module\Logistique\Application\Service\WeighingTicketFieldNormalizer;
|
||||
use App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface;
|
||||
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighingTicketProcessor;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Poids net (RG-5.05 / § 2.8) — test unitaire du WeighingTicketProcessor, sans BDD
|
||||
* ni HTTP (stubs purs, meme approche que WeighbridgeReadingProcessorTest).
|
||||
*
|
||||
* Verifie la regle metier seule : net_weight = full_weight - empty_weight des que
|
||||
* les DEUX poids sont presents, null tant que l'une des deux pesees manque, et
|
||||
* recalcul a l'edition (PATCH).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class NetWeightTest extends TestCase
|
||||
{
|
||||
public function testNetIsFullMinusEmptyWhenBothPresent(): void
|
||||
{
|
||||
$ticket = new WeighingTicket()
|
||||
->setEmptyWeight(7150)
|
||||
->setFullWeight(14300)
|
||||
;
|
||||
|
||||
$this->makeProcessor(isNew: true)->process($ticket, new Post());
|
||||
|
||||
// 14300 - 7150 = 7150 (exemple maquette § 2.8).
|
||||
self::assertSame(7150, $ticket->getNetWeight());
|
||||
}
|
||||
|
||||
public function testNetIsNullWhenFullWeightMissing(): void
|
||||
{
|
||||
$ticket = new WeighingTicket()->setEmptyWeight(7150);
|
||||
|
||||
$this->makeProcessor(isNew: true)->process($ticket, new Post());
|
||||
|
||||
self::assertNull($ticket->getNetWeight());
|
||||
}
|
||||
|
||||
public function testNetIsNullWhenEmptyWeightMissing(): void
|
||||
{
|
||||
$ticket = new WeighingTicket()->setFullWeight(14300);
|
||||
|
||||
$this->makeProcessor(isNew: true)->process($ticket, new Post());
|
||||
|
||||
self::assertNull($ticket->getNetWeight());
|
||||
}
|
||||
|
||||
public function testNetIsNullWhenNoWeighing(): void
|
||||
{
|
||||
$ticket = new WeighingTicket();
|
||||
|
||||
$this->makeProcessor(isNew: true)->process($ticket, new Post());
|
||||
|
||||
self::assertNull($ticket->getNetWeight());
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-5.05 : a la modification (PATCH = entite deja geree par l'ORM), le net est
|
||||
* recalcule a partir des poids courants — ici la pesee a plein renseignee apres
|
||||
* coup complete le ticket.
|
||||
*/
|
||||
public function testNetIsRecomputedOnPatch(): void
|
||||
{
|
||||
$ticket = new WeighingTicket()
|
||||
->setSite($this->site())
|
||||
->setEmptyWeight(7150)
|
||||
->setFullWeight(20000)
|
||||
;
|
||||
|
||||
$this->makeProcessor(isNew: false)->process($ticket, new Patch());
|
||||
|
||||
self::assertSame(12850, $ticket->getNetWeight());
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le Processor avec des dependances stubbees. `isNew` porte le sens
|
||||
* metier : true => creation (POST, attribution site/numero), false => entite
|
||||
* geree (PATCH, ni site ni numero retouches). Il est INVERSE pour stubber
|
||||
* EntityManager::contains() (qui renvoie true pour une entite deja persistee),
|
||||
* d'ou `willReturn(!$isNew)` plus bas.
|
||||
*/
|
||||
private function makeProcessor(bool $isNew): WeighingTicketProcessor
|
||||
{
|
||||
$persist = $this->createStub(ProcessorInterface::class);
|
||||
$persist->method('process')->willReturnArgument(0);
|
||||
|
||||
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
|
||||
$siteProvider->method('get')->willReturn($this->site());
|
||||
|
||||
$numberAllocator = $this->createStub(WeighingTicketNumberAllocatorInterface::class);
|
||||
$numberAllocator->method('allocate')->willReturn('86-TP-0001');
|
||||
|
||||
$dsdAllocator = $this->createStub(DsdAllocatorInterface::class);
|
||||
$dsdAllocator->method('next')->willReturn(99);
|
||||
|
||||
$em = $this->createStub(EntityManagerInterface::class);
|
||||
$em->method('contains')->willReturn(!$isNew);
|
||||
|
||||
return new WeighingTicketProcessor(
|
||||
$persist,
|
||||
$siteProvider,
|
||||
$numberAllocator,
|
||||
$dsdAllocator,
|
||||
new WeighingTicketFieldNormalizer(),
|
||||
$em,
|
||||
);
|
||||
}
|
||||
|
||||
private function site(): Site
|
||||
{
|
||||
// getId() reste null : numberAllocator et dsdAllocator sont stubbes, donc
|
||||
// aucune requete reelle ne depend de l'id du site.
|
||||
return new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233')
|
||||
->setCode('86')
|
||||
;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user