Compare commits

..

1 Commits

Author SHA1 Message Date
gitea-actions 206057cf83 chore: bump version to v0.1.142
Build & Push Docker Image / build (push) Successful in 21s
2026-06-18 12:51:10 +00:00
115 changed files with 856 additions and 5111 deletions
-4
View File
@@ -40,7 +40,3 @@ services:
App\Module\Logistique\Application\Service\DsdAllocatorInterface: App\Module\Logistique\Application\Service\DsdAllocatorInterface:
alias: App\Module\Logistique\Infrastructure\Service\DsdAllocator 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
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.147' app.version: '0.1.142'
+36 -99
View File
@@ -172,16 +172,14 @@ Pattern Starseed standard (miroir M1→M4) :
- `WeighingTicket implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait` (4 colonnes standard). - `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)`). - **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 : 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). - **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).
- **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 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.
- **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 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.
- **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).
### 2.13 Pas d'archive ; soft delete préparé non exposé ### 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`). **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) : > **Pièges à re-tester** :
> 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`). > 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** (getter `isPlateFreeFormat()` + `SerializedName('plateFreeFormat')`). > 2. Booléen `plateFreeFormat` : clé présente (piège #3 M1 → getter + `SerializedName` si besoin).
> 3. `number` présent et formaté `{siteCode}-TP-{NNNN}` (ici `86-TP-0001`). > 3. `number` présent et formaté `{siteCode}-TP-{NNNN}`.
> 4. `netWeight` cohérent = `full - empty` = `14300 - 7150` = **`7150`** (RG-5.05). > 4. `netWeight` cohérent = `full - empty` (plein vide, 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`).
**`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 ```jsonc
{ {
@@ -528,94 +524,41 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
"totalItems": 1, "totalItems": 1,
"member": [ "member": [
{ {
"@id": "/api/weighing_tickets/9", "@id": "/api/weighing_tickets/1",
"@type": "WeighingTicket", "@type": "WeighingTicket",
"id": 9, "id": 1,
"number": "86-TP-0001", "number": "86-TP-0001",
"counterpartyType": "CLIENT", "counterpartyType": "CLIENT",
"client": { "client": { "@id": "/api/clients/117", "@type": "Client", "id": 117, "companyName": "NÉGOCE MÉTAUX ATLANTIQUE" },
"@id": "/api/clients/629", "supplier": null,
"@type": "Client", "otherLabel": null,
"id": 629, "displayDate": "2026-06-17T09:12:00+02:00",
"companyName": "NÉGOCE MÉTAUX ATLANTIQUE", "netWeight": 12340,
"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
},
"plateFreeFormat": false, "plateFreeFormat": false,
"netWeight": 7150, "createdAt": "2026-06-17T09:12:00+02:00",
"createdAt": "2026-06-18T11:50:48+02:00", "updatedAt": "2026-06-17T09:12:00+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)
} }
], ],
"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 ```jsonc
{ {
"@context": "/api/contexts/WeighingTicket", "@id": "/api/weighing_tickets/1",
"@id": "/api/weighing_tickets/9",
"@type": "WeighingTicket", "@type": "WeighingTicket",
"id": 9, "id": 1,
"number": "86-TP-0001", "number": "86-TP-0001",
"site": { "site": { "@id": "/api/sites/1", "@type": "Site", "id": 1, "name": "Châtellerault", "code": "86" },
"@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"
},
"counterpartyType": "CLIENT", "counterpartyType": "CLIENT",
"client": { "client": { "@id": "/api/clients/117", "@type": "Client", "id": 117, "companyName": "NÉGOCE MÉTAUX ATLANTIQUE" },
"@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
},
"immatriculation": "AB-123-CD", "immatriculation": "AB-123-CD",
"plateFreeFormat": false, "plateFreeFormat": false,
"emptyDate": "2026-06-17T09:00:00+02:00", "emptyDate": "2026-06-17T09:00:00+02:00", "emptyWeight": 14660, "emptyDsd": 41, "emptyMode": "AUTO", "emptyManualNumber": null,
"emptyWeight": 7150, "fullDate": "2026-06-17T09:12:00+02:00", "fullWeight": 27000, "fullDsd": 42, "fullMode": "AUTO", "fullManualNumber": null,
"emptyDsd": 1, "netWeight": 12340
"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)
} }
``` ```
@@ -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. - 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. - 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. RBAC, module & sidebar
### 5.1 `LogistiqueModule::permissions()` ### 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.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.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.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.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). | | **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-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-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-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). | | HP-M5-05 | Archivage fonctionnel des tickets (non prévu au docx — § 2.13). |
## 10. Tickets Lesstime (à découper — back en tête) ## 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 | | 4 | `WeighingTicketProvider` + `WeighingTicketProcessor` (numérotation, RG-5.03/5.05, normalisation) | Backend |
| 5 | Export XLSX | Backend | | 5 | Export XLSX | Backend |
| 6 | Tests PHPUnit RG-5.01→5.10 + capture contrat JSON | 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 | | 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 | | 8 | Écran Ajouter (formulaires vide + plein, pesée bascule/manuelle, masque immat) | Frontend |
| 9 | Écran Modification + bouton « Imprimer » (ouvre le PDF back) | Frontend | | 9 | Écran Modification (la **modal/bon d'impression** = **Tristan**, § 2.12) | Frontend |
| 10 | i18n + libellé audit + branchement site courant | Frontend | | 10 | i18n + libellé audit + branchement site courant | Frontend |
| — | **Bon d'impression du ticket de pesée** | **Tristan (hors découpe M5)** |
+11 -11
View File
@@ -71,7 +71,7 @@
"companyName": "Nom", "companyName": "Nom",
"categories": "Catégories", "categories": "Catégories",
"sites": "Site", "sites": "Site",
"lastActivity": "Dernière modification" "lastActivity": "Dernière activité"
}, },
"filters": { "filters": {
"title": "Filtres", "title": "Filtres",
@@ -125,7 +125,7 @@
}, },
"edit": { "edit": {
"title": "Modifier le fournisseur", "title": "Modifier le fournisseur",
"back": "Retour à la consultation", "back": "Retour au répertoire",
"loading": "Chargement du fournisseur…", "loading": "Chargement du fournisseur…",
"notFound": "Fournisseur introuvable.", "notFound": "Fournisseur introuvable.",
"save": "Enregistrer" "save": "Enregistrer"
@@ -215,7 +215,7 @@
"companyName": "Nom", "companyName": "Nom",
"categories": "Catégories", "categories": "Catégories",
"sites": "Site", "sites": "Site",
"lastActivity": "Dernière modification" "lastActivity": "Dernière activité"
}, },
"filters": { "filters": {
"title": "Filtres", "title": "Filtres",
@@ -268,7 +268,7 @@
}, },
"edit": { "edit": {
"title": "Modifier le client", "title": "Modifier le client",
"back": "Retour à la consultation", "back": "Retour au répertoire",
"loading": "Chargement du client…", "loading": "Chargement du client…",
"notFound": "Client introuvable.", "notFound": "Client introuvable.",
"save": "Enregistrer" "save": "Enregistrer"
@@ -385,7 +385,7 @@
"companyName": "Nom", "companyName": "Nom",
"categories": "Catégories", "categories": "Catégories",
"sites": "Site", "sites": "Site",
"lastActivity": "Dernière modification" "lastActivity": "Dernière activité"
}, },
"filters": { "filters": {
"title": "Filtres", "title": "Filtres",
@@ -420,7 +420,7 @@
}, },
"edit": { "edit": {
"title": "Modifier le prestataire", "title": "Modifier le prestataire",
"back": "Retour à la consultation", "back": "Retour à la fiche",
"loading": "Chargement…", "loading": "Chargement…",
"notFound": "Prestataire introuvable.", "notFound": "Prestataire introuvable.",
"save": "Enregistrer" "save": "Enregistrer"
@@ -453,6 +453,7 @@
}, },
"address": { "address": {
"sites": "Sites", "sites": "Sites",
"categories": "Catégorie",
"contacts": "Contact(s) rattaché(s)", "contacts": "Contact(s) rattaché(s)",
"country": "Pays", "country": "Pays",
"postalCode": "Code postal", "postalCode": "Code postal",
@@ -508,7 +509,7 @@
"name": "Nom", "name": "Nom",
"certification": "Certification", "certification": "Certification",
"validityDate": "Date de validité", "validityDate": "Date de validité",
"lastActivity": "Dernière modification" "lastActivity": "Dernière activité"
}, },
"certification": { "certification": {
"QUALIMAT": "QUALIMAT", "QUALIMAT": "QUALIMAT",
@@ -557,8 +558,8 @@
"message": "Ce transporteur réapparaîtra dans le répertoire actif. Confirmer la restauration ?" "message": "Ce transporteur réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
}, },
"price": { "price": {
"group": "Transport", "group": "Type de transport",
"carrier": "Fournisseurs / Clients", "carrier": "Transporteurs",
"aproOrSite": "Adresse sites", "aproOrSite": "Adresse sites",
"delivery": "Adresse livraisons", "delivery": "Adresse livraisons",
"forfait": "Forfait (€)", "forfait": "Forfait (€)",
@@ -789,8 +790,7 @@
"auth": { "auth": {
"logout": "Deconnexion reussie" "logout": "Deconnexion reussie"
}, },
"title": "Succès", "title": "Succès"
"deleted": "Suppression effectuée"
}, },
"admin": { "admin": {
"roles": { "roles": {
@@ -2,7 +2,7 @@
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). --> <!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly && !disabled" v-if="removable && !readonly"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -21,8 +21,7 @@
:options="addressTypeOptions" :options="addressTypeOptions"
:label="t('commercial.clients.form.address.addressType')" :label="t('commercial.clients.form.address.addressType')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.isProspect" :error="errors?.isProspect"
@update:model-value="onAddressTypeChange" @update:model-value="onAddressTypeChange"
/> />
@@ -34,21 +33,17 @@
:label="t('commercial.clients.form.address.sites')" :label="t('commercial.clients.form.address.sites')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.sites" :error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/> />
<!-- Contacts rattaches (M2M, facultatif). Consultation : masque si aucun (ERP-193). -->
<MalioSelectCheckbox <MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris" :model-value="model.contactIris"
:options="contactOptions" :options="contactOptions"
:label="t('commercial.clients.form.address.contacts')" :label="t('commercial.clients.form.address.contacts')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/> />
@@ -60,9 +55,8 @@
v-if="isBillingEmailRequired(model)" v-if="isBillingEmailRequired(model)"
:model-value="model.billingEmail" :model-value="model.billingEmail"
:label="t('commercial.clients.form.address.billingEmail')" :label="t('commercial.clients.form.address.billingEmail')"
:required="!readonly && !disabled" :required="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:lowercase="true" :lowercase="true"
:error="errors?.billingEmail" :error="errors?.billingEmail"
:addable="!model.hasSecondaryBillingEmail && !readonly" :addable="!model.hasSecondaryBillingEmail && !readonly"
@@ -70,17 +64,13 @@
@update:model-value="(v: string) => update('billingEmail', v)" @update:model-value="(v: string) => update('billingEmail', v)"
@add="revealSecondaryBillingEmail" @add="revealSecondaryBillingEmail"
/> />
<!-- Filler : aligne la suite de la grille (Categorie au debut de ligne). <div v-else aria-hidden="true" />
Inutile en consultation masquee (la grille se recompose sans les
champs vides, ERP-193). -->
<div v-else-if="!hideEmpty" aria-hidden="true" />
<MalioInputEmail <MalioInputEmail
v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail" v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail"
:model-value="model.billingEmailSecondary" :model-value="model.billingEmailSecondary"
:label="t('commercial.clients.form.address.billingEmailSecondary')" :label="t('commercial.clients.form.address.billingEmailSecondary')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:lowercase="true" :lowercase="true"
:error="errors?.billingEmailSecondary" :error="errors?.billingEmailSecondary"
@update:model-value="(v: string) => update('billingEmailSecondary', v)" @update:model-value="(v: string) => update('billingEmailSecondary', v)"
@@ -92,8 +82,7 @@
:label="t('commercial.clients.form.address.categories')" :label="t('commercial.clients.form.address.categories')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.categories" :error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/> />
@@ -103,8 +92,7 @@
:options="countryOptions" :options="countryOptions"
:label="t('commercial.clients.form.address.country')" :label="t('commercial.clients.form.address.country')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))" @update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/> />
@@ -113,8 +101,7 @@
:label="t('commercial.clients.form.address.postalCode')" :label="t('commercial.clients.form.address.postalCode')"
:mask="POSTAL_CODE_MASK" :mask="POSTAL_CODE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.postalCode" :error="errors?.postalCode"
@update:model-value="onPostalCodeChange" @update:model-value="onPostalCodeChange"
/> />
@@ -128,20 +115,17 @@
:options="cityOptions" :options="cityOptions"
:label="t('commercial.clients.form.address.city')" :label="t('commercial.clients.form.address.city')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
empty-option-label="" empty-option-label=""
:required="!readonly && !disabled" :required="true"
:error="errors?.city" :error="errors?.city"
@update:model-value="onCityChange" @update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/> />
<MalioInputText <MalioInputText
v-else v-else
:model-value="model.city" :model-value="model.city"
:label="t('commercial.clients.form.address.city')" :label="t('commercial.clients.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.city" :error="errors?.city"
@update:model-value="(v: string) => update('city', v)" @update:model-value="(v: string) => update('city', v)"
/> />
@@ -158,15 +142,14 @@
blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste
pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. --> pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. -->
<MalioInputAutocomplete <MalioInputAutocomplete
v-if="!readonly && !disabled" v-if="!readonly"
:model-value="model.street" :model-value="model.street"
:options="addressOptions" :options="addressOptions"
:loading="addressLoading" :loading="addressLoading"
:min-search-length="3" :min-search-length="3"
:label="t('commercial.clients.form.address.street')" :label="t('commercial.clients.form.address.street')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
:allow-create="true" :allow-create="true"
:no-results-text="t('commercial.clients.form.address.streetNotFound')" :no-results-text="t('commercial.clients.form.address.streetNotFound')"
@@ -179,20 +162,17 @@
:model-value="model.street" :model-value="model.street"
:label="t('commercial.clients.form.address.street')" :label="t('commercial.clients.form.address.street')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
@update:model-value="(v: string) => update('street', v)" @update:model-value="(v: string) => update('street', v)"
/> />
</div> </div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1"> <div class="col-span-1">
<MalioInputText <MalioInputText
:model-value="model.streetComplement" :model-value="model.streetComplement"
:label="t('commercial.clients.form.address.streetComplement')" :label="t('commercial.clients.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement" :error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)" @update:model-value="(v: string) => update('streetComplement', v)"
/> />
@@ -211,8 +191,6 @@ import {
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete' import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials' import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm' import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
import { isFilled } from '~/shared/utils/consultationDisplay'
// Masque code postal FR : 5 chiffres. // Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####' const POSTAL_CODE_MASK = '#####'
@@ -231,10 +209,6 @@ const props = defineProps<{
countryOptions: RefOption[] countryOptions: RefOption[]
removable?: boolean removable?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Consultation : masque les champs non remplis (ERP-193). */
hideEmpty?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -310,37 +284,11 @@ const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = [] let lastAddressSuggestions: AddressSuggestion[] = []
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur
// les champs texte editables (complement, ville en mode degrade). La voie en
// autocomplete (BAN) et la ville en select ne sont pas masquees (le back valide
// via Assert\Regex) ; les emails de facturation valident leur format (Assert\Email).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDraft[K]): void { function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
} }
/**
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
* a chaque frappe).
*/
function onCityChange(value: string | number | null): void {
const next = value === null ? null : String(value)
if (next === (props.modelValue.city ?? null)) {
return
}
banAddressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
city: next,
street: null,
streetComplement: null,
})
}
/** Revele le 2e champ email de facturation (clic sur le « + »). */ /** Revele le 2e champ email de facturation (clic sur le « + »). */
function revealSecondaryBillingEmail(): void { function revealSecondaryBillingEmail(): void {
emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: true }) emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: true })
@@ -356,27 +304,9 @@ function notifyUnavailable(): void {
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */ /** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
async function onPostalCodeChange(value: string): Promise<void> { async function onPostalCodeChange(value: string): Promise<void> {
update('postalCode', value)
const digits = (value ?? '').replace(/\D/g, '') const digits = (value ?? '').replace(/\D/g, '')
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
if (digits.length === 5 && digits !== previousDigits) {
banAddressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
postalCode: value,
city: null,
street: null,
streetComplement: null,
})
}
else {
update('postalCode', value)
}
if (digits.length < 5) { if (digits.length < 5) {
return return
} }
@@ -4,7 +4,7 @@
non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule. non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule.
ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). --> ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly && !disabled" v-if="removable && !readonly"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -13,56 +13,44 @@
/> />
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName" :model-value="model.lastName"
:label="t('commercial.clients.form.contact.lastName')" :label="t('commercial.clients.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.lastName" :error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)" @update:model-value="(v: string) => update('lastName', v)"
/> />
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName" :model-value="model.firstName"
:label="t('commercial.clients.form.contact.firstName')" :label="t('commercial.clients.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.firstName" :error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)" @update:model-value="(v: string) => update('firstName', v)"
/> />
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText <!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la (inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. --> cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2"> <div class="col-span-2">
<MalioInputText <MalioInputText
:model-value="model.jobTitle" :model-value="model.jobTitle"
:label="t('commercial.clients.form.contact.jobTitle')" :label="t('commercial.clients.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle" :error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)" @update:model-value="(v: string) => update('jobTitle', v)"
/> />
</div> </div>
<MalioInputEmail <MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email" :model-value="model.email"
:label="t('commercial.clients.form.contact.email')" :label="t('commercial.clients.form.contact.email')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:lowercase="true" :lowercase="true"
:error="errors?.email" :error="errors?.email"
@update:model-value="(v: string) => update('email', v)" @update:model-value="(v: string) => update('email', v)"
/> />
<MalioInputPhone <MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary" :model-value="model.phonePrimary"
:label="t('commercial.clients.form.contact.phonePrimary')" :label="t('commercial.clients.form.contact.phonePrimary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary" :error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly" :addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.clients.form.contact.addPhone')" :add-button-label="t('commercial.clients.form.contact.addPhone')"
@@ -70,12 +58,11 @@
@add="revealSecondaryPhone" @add="revealSecondaryPhone"
/> />
<MalioInputPhone <MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))" v-if="model.hasSecondaryPhone"
:model-value="model.phoneSecondary" :model-value="model.phoneSecondary"
:label="t('commercial.clients.form.contact.phoneSecondary')" :label="t('commercial.clients.form.contact.phoneSecondary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary" :error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)" @update:model-value="(v: string) => update('phoneSecondary', v)"
/> />
@@ -84,8 +71,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ContactFormDraft } from '~/modules/commercial/types/clientForm' import type { ContactFormDraft } from '~/modules/commercial/types/clientForm'
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
import { isFilled } from '~/shared/utils/consultationDisplay'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste // Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste
// serveur, cf. formatPhoneFR re-applique a la valeur renvoyee). // serveur, cf. formatPhoneFR re-applique a la valeur renvoyee).
@@ -100,10 +85,6 @@ const props = defineProps<{
removable?: boolean removable?: boolean
/** Bloc en lecture seule (onglet valide). */ /** Bloc en lecture seule (onglet valide). */
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Consultation : masque les champs non remplis (ERP-193). */
hideEmpty?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -118,10 +99,6 @@ const { t } = useI18n()
// Alias local pour la lisibilite du template. // Alias local pour la lisibilite du template.
const model = computed(() => props.modelValue) const model = computed(() => props.modelValue)
// Filtrage des caracteres parasites : porte par les masks maska sur les champs
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof ContactFormDraft>(field: K, value: ContactFormDraft[K]): void { function update<K extends keyof ContactFormDraft>(field: K, value: ContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
@@ -2,7 +2,7 @@
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : modal de confirmation cote parent. --> <!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly && !disabled" v-if="removable && !readonly"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -18,9 +18,8 @@
:options="addressTypeOptions" :options="addressTypeOptions"
:label="t('commercial.suppliers.form.address.addressType')" :label="t('commercial.suppliers.form.address.addressType')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
empty-option-label="" empty-option-label=""
:required="!readonly && !disabled" :required="true"
:error="errors?.addressType" :error="errors?.addressType"
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))" @update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))"
/> />
@@ -32,28 +31,24 @@
:label="t('commercial.suppliers.form.address.sites')" :label="t('commercial.suppliers.form.address.sites')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.sites" :error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/> />
<!-- Contacts rattaches (M2M, facultatif). --> <!-- Contacts rattaches (M2M, facultatif). -->
<MalioSelectCheckbox <MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris" :model-value="model.contactIris"
:options="contactOptions" :options="contactOptions"
:label="t('commercial.suppliers.form.address.contacts')" :label="t('commercial.suppliers.form.address.contacts')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/> />
<!-- Filler : aligne le debut de ligne suivant sur la grille (le bloc client <!-- Filler : aligne le debut de ligne suivant sur la grille (le bloc client
porte ici l'email de facturation, absent cote fournisseur). Inutile en porte ici l'email de facturation, absent cote fournisseur). -->
consultation masquee (la grille se recompose sans les champs vides). --> <div aria-hidden="true" />
<div v-if="!hideEmpty" aria-hidden="true" />
<!-- Categories de type FOURNISSEUR (>= 1 obligatoire, RG-2.10). --> <!-- Categories de type FOURNISSEUR (>= 1 obligatoire, RG-2.10). -->
<MalioSelectCheckbox <MalioSelectCheckbox
@@ -62,8 +57,7 @@
:label="t('commercial.suppliers.form.address.categories')" :label="t('commercial.suppliers.form.address.categories')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.categories" :error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/> />
@@ -73,8 +67,7 @@
:options="countryOptions" :options="countryOptions"
:label="t('commercial.suppliers.form.address.country')" :label="t('commercial.suppliers.form.address.country')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))" @update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/> />
@@ -83,8 +76,7 @@
:label="t('commercial.suppliers.form.address.postalCode')" :label="t('commercial.suppliers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK" :mask="POSTAL_CODE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.postalCode" :error="errors?.postalCode"
@update:model-value="onPostalCodeChange" @update:model-value="onPostalCodeChange"
/> />
@@ -96,20 +88,17 @@
:options="cityOptions" :options="cityOptions"
:label="t('commercial.suppliers.form.address.city')" :label="t('commercial.suppliers.form.address.city')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
empty-option-label="" empty-option-label=""
:required="!readonly && !disabled" :required="true"
:error="errors?.city" :error="errors?.city"
@update:model-value="onCityChange" @update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/> />
<MalioInputText <MalioInputText
v-else v-else
:model-value="model.city" :model-value="model.city"
:label="t('commercial.suppliers.form.address.city')" :label="t('commercial.suppliers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.city" :error="errors?.city"
@update:model-value="(v: string) => update('city', v)" @update:model-value="(v: string) => update('city', v)"
/> />
@@ -118,15 +107,14 @@
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). --> texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div class="col-span-2"> <div class="col-span-2">
<MalioInputAutocomplete <MalioInputAutocomplete
v-if="!readonly && !disabled" v-if="!readonly"
:model-value="model.street" :model-value="model.street"
:options="addressOptions" :options="addressOptions"
:loading="addressLoading" :loading="addressLoading"
:min-search-length="3" :min-search-length="3"
:label="t('commercial.suppliers.form.address.street')" :label="t('commercial.suppliers.form.address.street')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
:allow-create="true" :allow-create="true"
:no-results-text="t('commercial.suppliers.form.address.streetNotFound')" :no-results-text="t('commercial.suppliers.form.address.streetNotFound')"
@@ -138,50 +126,40 @@
v-else v-else
:model-value="model.street" :model-value="model.street"
:label="t('commercial.suppliers.form.address.street')" :label="t('commercial.suppliers.form.address.street')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
@update:model-value="(v: string) => update('street', v)" @update:model-value="(v: string) => update('street', v)"
/> />
</div> </div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1"> <div class="col-span-1">
<MalioInputText <MalioInputText
:model-value="model.streetComplement" :model-value="model.streetComplement"
:label="t('commercial.suppliers.form.address.streetComplement')" :label="t('commercial.suppliers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement" :error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)" @update:model-value="(v: string) => update('streetComplement', v)"
/> />
</div> </div>
<!-- Bennes : stepper (specifique fournisseur, defaut 0). En consultation, 0 <!-- Bennes : stepper (specifique fournisseur, defaut 0). -->
reste affiche (valeur saisie) ; seul un champ vide serait masque. -->
<MalioInputNumber <MalioInputNumber
v-if="!hideEmpty || isFilled(model.bennes)"
:model-value="model.bennes" :model-value="model.bennes"
:label="t('commercial.suppliers.form.address.bennes')" :label="t('commercial.suppliers.form.address.bennes')"
:min="0" :min="0"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.bennes" :error="errors?.bennes"
@update:model-value="(v: string) => update('bennes', v)" @update:model-value="(v: string) => update('bennes', v)"
/> />
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur). <!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur). -->
Consultation : masquee si non cochee (ERP-193). -->
<MalioCheckbox <MalioCheckbox
v-if="!hideEmpty || isFilled(model.triageProvider)"
id="address-triage-provider" id="address-triage-provider"
:label="t('commercial.suppliers.form.address.triageProvider')" :label="t('commercial.suppliers.form.address.triageProvider')"
:model-value="model.triageProvider" :model-value="model.triageProvider"
group-class="self-center" group-class="self-center"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
@update:model-value="(v: boolean) => update('triageProvider', v)" @update:model-value="(v: boolean) => update('triageProvider', v)"
/> />
</div> </div>
@@ -191,8 +169,6 @@
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete' import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useSupplierReferentials' import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
import type { SupplierAddressFormDraft, SupplierAddressType } from '~/modules/commercial/types/supplierForm' import type { SupplierAddressFormDraft, SupplierAddressType } from '~/modules/commercial/types/supplierForm'
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
import { isFilled } from '~/shared/utils/consultationDisplay'
// Masque code postal FR : 5 chiffres. // Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####' const POSTAL_CODE_MASK = '#####'
@@ -211,10 +187,6 @@ const props = defineProps<{
countryOptions: RefOption[] countryOptions: RefOption[]
removable?: boolean removable?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Consultation : masque les champs non remplis (ERP-193). */
hideEmpty?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -266,37 +238,11 @@ const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = [] let lastAddressSuggestions: AddressSuggestion[] = []
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur
// les champs texte editables (complement, ville en mode degrade, voie en repli). La
// voie en autocomplete (BAN) et la ville en select ne sont pas masquees (le back
// valide via Assert\Regex).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof SupplierAddressFormDraft>(field: K, value: SupplierAddressFormDraft[K]): void { function update<K extends keyof SupplierAddressFormDraft>(field: K, value: SupplierAddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
} }
/**
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
* a chaque frappe).
*/
function onCityChange(value: string | number | null): void {
const next = value === null ? null : String(value)
if (next === (props.modelValue.city ?? null)) {
return
}
banAddressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
city: next,
street: null,
streetComplement: null,
})
}
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */ /** Previent le parent (toast unique) que l'autocompletion est indisponible. */
function notifyUnavailable(): void { function notifyUnavailable(): void {
if (!unavailableNotified) { if (!unavailableNotified) {
@@ -307,27 +253,9 @@ function notifyUnavailable(): void {
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */ /** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
async function onPostalCodeChange(value: string): Promise<void> { async function onPostalCodeChange(value: string): Promise<void> {
update('postalCode', value)
const digits = (value ?? '').replace(/\D/g, '') const digits = (value ?? '').replace(/\D/g, '')
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
if (digits.length === 5 && digits !== previousDigits) {
banAddressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
postalCode: value,
city: null,
street: null,
streetComplement: null,
})
}
else {
update('postalCode', value)
}
if (digits.length < 5) { if (digits.length < 5) {
return return
} }
@@ -3,7 +3,7 @@
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si <!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc, RG-2.13) ou en lecture seule. --> non supprimable (1er bloc, RG-2.13) ou en lecture seule. -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly && !disabled" v-if="removable && !readonly"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -12,56 +12,44 @@
/> />
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName" :model-value="model.lastName"
:label="t('commercial.suppliers.form.contact.lastName')" :label="t('commercial.suppliers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.lastName" :error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)" @update:model-value="(v: string) => update('lastName', v)"
/> />
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName" :model-value="model.firstName"
:label="t('commercial.suppliers.form.contact.firstName')" :label="t('commercial.suppliers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.firstName" :error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)" @update:model-value="(v: string) => update('firstName', v)"
/> />
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText <!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la (inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. --> cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2"> <div class="col-span-2">
<MalioInputText <MalioInputText
:model-value="model.jobTitle" :model-value="model.jobTitle"
:label="t('commercial.suppliers.form.contact.jobTitle')" :label="t('commercial.suppliers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle" :error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)" @update:model-value="(v: string) => update('jobTitle', v)"
/> />
</div> </div>
<MalioInputEmail <MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email" :model-value="model.email"
:label="t('commercial.suppliers.form.contact.email')" :label="t('commercial.suppliers.form.contact.email')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:lowercase="true" :lowercase="true"
:error="errors?.email" :error="errors?.email"
@update:model-value="(v: string) => update('email', v)" @update:model-value="(v: string) => update('email', v)"
/> />
<MalioInputPhone <MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary" :model-value="model.phonePrimary"
:label="t('commercial.suppliers.form.contact.phonePrimary')" :label="t('commercial.suppliers.form.contact.phonePrimary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary" :error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly" :addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.suppliers.form.contact.addPhone')" :add-button-label="t('commercial.suppliers.form.contact.addPhone')"
@@ -69,12 +57,11 @@
@add="revealSecondaryPhone" @add="revealSecondaryPhone"
/> />
<MalioInputPhone <MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))" v-if="model.hasSecondaryPhone"
:model-value="model.phoneSecondary" :model-value="model.phoneSecondary"
:label="t('commercial.suppliers.form.contact.phoneSecondary')" :label="t('commercial.suppliers.form.contact.phoneSecondary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary" :error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)" @update:model-value="(v: string) => update('phoneSecondary', v)"
/> />
@@ -83,8 +70,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SupplierContactFormDraft } from '~/modules/commercial/types/supplierForm' import type { SupplierContactFormDraft } from '~/modules/commercial/types/supplierForm'
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
import { isFilled } from '~/shared/utils/consultationDisplay'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur). // Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##' const PHONE_MASK = '## ## ## ## ##'
@@ -98,10 +83,6 @@ const props = defineProps<{
removable?: boolean removable?: boolean
/** Bloc en lecture seule (onglet valide). */ /** Bloc en lecture seule (onglet valide). */
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Consultation : masque les champs non remplis (ERP-193). */
hideEmpty?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -116,10 +97,6 @@ const { t } = useI18n()
// Alias local pour la lisibilite du template. // Alias local pour la lisibilite du template.
const model = computed(() => props.modelValue) const model = computed(() => props.modelValue)
// Filtrage des caracteres parasites : porte par les masks maska sur les champs
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof SupplierContactFormDraft>(field: K, value: SupplierContactFormDraft[K]): void { function update<K extends keyof SupplierContactFormDraft>(field: K, value: SupplierContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
@@ -171,182 +171,6 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
}) })
}) })
/**
* Stub MalioInputText emetteur : re-expose `label` et relaie `update:model-value`,
* pour piloter le champ Code postal et observer le brouillon emis.
*/
const MalioInputTextEmitter = defineComponent({
name: 'MalioInputTextEmitter',
props: {
modelValue: { type: [String, Number, null], default: undefined },
label: { type: String, default: '' },
},
emits: ['update:modelValue'],
setup(props) {
return () => h('div', { 'data-testid': 'addr-input', 'data-label': props.label })
},
})
describe('ClientAddressBlock — changement de code postal vide les champs dependants (ERP-193)', () => {
beforeEach(() => {
searchCityMock.mockReset()
searchCityMock.mockResolvedValue([])
})
function mountFilled() {
return mount(ClientAddressBlock, {
props: {
modelValue: {
...emptyAddress(),
postalCode: '75001',
city: 'Paris',
street: '8 Boulevard du Port',
streetComplement: 'Bat A',
},
title: 'Adresse',
categoryOptions: [],
siteOptions: [],
contactOptions: [],
countryOptions: [],
},
global: {
stubs: {
MalioButtonIcon: true,
MalioCheckbox: true,
MalioSelect: true,
MalioSelectCheckbox: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
MalioInputText: MalioInputTextEmitter,
},
},
})
}
function postalCodeField(wrapper: ReturnType<typeof mountFilled>) {
return wrapper.findAllComponents(MalioInputTextEmitter).find(
c => c.props('label') === 'commercial.clients.form.address.postalCode',
)
}
it('vide ville, adresse et complement quand le CP complet change', async () => {
const wrapper = mountFilled()
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '33000')
await flushPromises()
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
expect(last.postalCode).toBe('33000')
expect(last.city).toBeNull()
expect(last.street).toBeNull()
expect(last.streetComplement).toBeNull()
})
it('ne vide pas les champs si le CP reste incomplet (< 5 chiffres)', async () => {
const wrapper = mountFilled()
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '7500')
await flushPromises()
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
expect(last.postalCode).toBe('7500')
expect(last.city).toBe('Paris')
expect(last.street).toBe('8 Boulevard du Port')
expect(last.streetComplement).toBe('Bat A')
})
it('ne vide pas les champs si le CP complet est identique', async () => {
const wrapper = mountFilled()
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '75001')
await flushPromises()
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
expect(last.city).toBe('Paris')
expect(last.street).toBe('8 Boulevard du Port')
expect(last.streetComplement).toBe('Bat A')
})
})
/**
* Stub MalioSelect emetteur : re-expose `label` et relaie `update:model-value`,
* pour piloter le select Ville et observer le brouillon emis.
*/
const MalioSelectEmitter = defineComponent({
name: 'MalioSelectEmitter',
props: {
modelValue: { type: [String, Number, null], default: undefined },
label: { type: String, default: '' },
},
emits: ['update:modelValue'],
setup(props) {
return () => h('div', { 'data-testid': 'addr-select', 'data-label': props.label })
},
})
describe('ClientAddressBlock — changement de ville vide adresse + complement (ERP-193)', () => {
beforeEach(() => {
searchCityMock.mockReset()
searchCityMock.mockResolvedValue([])
})
function mountFilled() {
return mount(ClientAddressBlock, {
props: {
modelValue: {
...emptyAddress(),
postalCode: '75001',
city: 'Paris',
street: '8 Boulevard du Port',
streetComplement: 'Bat A',
},
title: 'Adresse',
categoryOptions: [],
siteOptions: [],
contactOptions: [],
countryOptions: [],
},
global: {
stubs: {
MalioButtonIcon: true,
MalioCheckbox: true,
MalioSelectCheckbox: true,
MalioInputText: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
MalioSelect: MalioSelectEmitter,
},
},
})
}
function cityField(wrapper: ReturnType<typeof mountFilled>) {
return wrapper.findAllComponents(MalioSelectEmitter).find(
c => c.props('label') === 'commercial.clients.form.address.city',
)
}
it('vide adresse et complement quand la ville change', async () => {
const wrapper = mountFilled()
cityField(wrapper)!.vm.$emit('update:modelValue', 'Lyon')
await flushPromises()
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
expect(last.city).toBe('Lyon')
expect(last.street).toBeNull()
expect(last.streetComplement).toBeNull()
})
it('ne vide pas si la ville selectionnee est identique', async () => {
const wrapper = mountFilled()
cityField(wrapper)!.vm.$emit('update:modelValue', 'Paris')
await flushPromises()
// Aucun nouvel emit (valeur inchangee) → l'adresse reste intacte.
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
describe('ClientAddressBlock — recherche adresse robuste (erreur BAN)', () => { describe('ClientAddressBlock — recherche adresse robuste (erreur BAN)', () => {
beforeEach(() => { beforeEach(() => {
searchAddressMock.mockReset() searchAddressMock.mockReset()
@@ -25,8 +25,8 @@ function makeHydra(total: number): HydraCollection<Client> {
describe('useClientsRepository', () => { describe('useClientsRepository', () => {
beforeEach(() => { beforeEach(() => {
mockGet.mockReset() mockGet.mockReset()
// 60 items → 3 pages a 25/page : permet de tester la navigation page 2. // 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
mockGet.mockResolvedValue(makeHydra(60)) mockGet.mockResolvedValue(makeHydra(25))
}) })
it('cible la ressource /clients en page 1 par defaut', async () => { it('cible la ressource /clients en page 1 par defaut', async () => {
@@ -35,7 +35,7 @@ describe('useClientsRepository', () => {
expect(mockGet).toHaveBeenLastCalledWith( expect(mockGet).toHaveBeenLastCalledWith(
'/clients', '/clients',
{ page: 1, itemsPerPage: 25 }, { page: 1, itemsPerPage: 10 },
expect.objectContaining({ toast: false }), expect.objectContaining({ toast: false }),
) )
}) })
@@ -65,7 +65,7 @@ describe('useClientsRepository', () => {
'siteId[]': ['1', '2'], 'siteId[]': ['1', '2'],
archivedOnly: true, archivedOnly: true,
page: 1, page: 1,
itemsPerPage: 25, itemsPerPage: 10,
}, },
expect.objectContaining({ toast: false }), expect.objectContaining({ toast: false }),
) )
@@ -78,7 +78,7 @@ describe('useClientsRepository', () => {
expect(mockGet).toHaveBeenLastCalledWith( expect(mockGet).toHaveBeenLastCalledWith(
'/clients', '/clients',
{ page: 1, itemsPerPage: 25 }, { page: 1, itemsPerPage: 10 },
expect.objectContaining({ toast: false }), expect.objectContaining({ toast: false }),
) )
}) })
@@ -25,8 +25,8 @@ function makeHydra(total: number): HydraCollection<Supplier> {
describe('useSuppliersRepository', () => { describe('useSuppliersRepository', () => {
beforeEach(() => { beforeEach(() => {
mockGet.mockReset() mockGet.mockReset()
// 60 items → 3 pages a 25/page : permet de tester la navigation page 2. // 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
mockGet.mockResolvedValue(makeHydra(60)) mockGet.mockResolvedValue(makeHydra(25))
}) })
it('cible la ressource /suppliers en page 1 par defaut', async () => { it('cible la ressource /suppliers en page 1 par defaut', async () => {
@@ -35,7 +35,7 @@ describe('useSuppliersRepository', () => {
expect(mockGet).toHaveBeenLastCalledWith( expect(mockGet).toHaveBeenLastCalledWith(
'/suppliers', '/suppliers',
{ page: 1, itemsPerPage: 25 }, { page: 1, itemsPerPage: 10 },
expect.objectContaining({ toast: false }), expect.objectContaining({ toast: false }),
) )
}) })
@@ -65,7 +65,7 @@ describe('useSuppliersRepository', () => {
'siteId[]': ['86', '17'], 'siteId[]': ['86', '17'],
archivedOnly: true, archivedOnly: true,
page: 1, page: 1,
itemsPerPage: 25, itemsPerPage: 10,
}, },
expect.objectContaining({ toast: false }), expect.objectContaining({ toast: false }),
) )
@@ -78,7 +78,7 @@ describe('useSuppliersRepository', () => {
expect(mockGet).toHaveBeenLastCalledWith( expect(mockGet).toHaveBeenLastCalledWith(
'/suppliers', '/suppliers',
{ page: 1, itemsPerPage: 25 }, { page: 1, itemsPerPage: 10 },
expect.objectContaining({ toast: false }), expect.objectContaining({ toast: false }),
) )
}) })
@@ -49,6 +49,5 @@ export interface Client {
* gerer. * gerer.
*/ */
export function useClientsRepository() { export function useClientsRepository() {
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193). return usePaginatedList<Client>({ url: '/clients' })
return usePaginatedList<Client>({ url: '/clients', defaultItemsPerPage: 25 })
} }
@@ -51,6 +51,5 @@ export interface Supplier {
* `usePaginatedList`. Aucun reset au logout a gerer. * `usePaginatedList`. Aucun reset au logout a gerer.
*/ */
export function useSuppliersRepository() { export function useSuppliersRepository() {
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193). return usePaginatedList<Supplier>({ url: '/suppliers' })
return usePaginatedList<Supplier>({ url: '/suppliers', defaultItemsPerPage: 25 })
} }
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('commercial.clients.edit.back')"
v-bind="{ ariaLabel: t('commercial.clients.edit.back') }" v-bind="{ ariaLabel: t('commercial.clients.edit.back') }"
@click="goBack" @click="goBack"
/> />
@@ -26,10 +25,9 @@
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4"> <div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="main.companyName" v-model="main.companyName"
:mask="FREE_TEXT_MASK"
:label="t('commercial.clients.form.main.companyName')" :label="t('commercial.clients.form.main.companyName')"
:required="true" :required="true"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="mainErrors.errors.companyName" :error="mainErrors.errors.companyName"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
@@ -37,7 +35,7 @@
:options="mainCategoryOptions" :options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')" :label="t('commercial.clients.form.main.categories')"
:display-tag="true" :display-tag="true"
:disabled="businessReadonly" :readonly="businessReadonly"
:required="true" :required="true"
:error="mainErrors.errors.categories" :error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -48,7 +46,7 @@
:options="relationOptions" :options="relationOptions"
:label="t('commercial.clients.form.main.relation')" :label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')" :empty-option-label="t('commercial.clients.form.main.relationNone')"
:disabled="businessReadonly" :readonly="businessReadonly"
@update:model-value="onRelationChange" @update:model-value="onRelationChange"
/> />
<MalioSelect <MalioSelect
@@ -56,7 +54,7 @@
:model-value="main.brokerIri" :model-value="main.brokerIri"
:options="brokerOptions" :options="brokerOptions"
:label="t('commercial.clients.form.main.brokerName')" :label="t('commercial.clients.form.main.brokerName')"
:disabled="businessReadonly" :readonly="businessReadonly"
:required="true" :required="true"
:error="mainErrors.errors.broker" :error="mainErrors.errors.broker"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
@@ -66,7 +64,7 @@
:model-value="main.distributorIri" :model-value="main.distributorIri"
:options="distributorOptions" :options="distributorOptions"
:label="t('commercial.clients.form.main.distributorName')" :label="t('commercial.clients.form.main.distributorName')"
:disabled="businessReadonly" :readonly="businessReadonly"
:required="true" :required="true"
:error="mainErrors.errors.distributor" :error="mainErrors.errors.distributor"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
@@ -76,7 +74,7 @@
v-model="main.triageService" v-model="main.triageService"
:label="t('commercial.clients.form.main.triageService')" :label="t('commercial.clients.form.main.triageService')"
group-class="self-center" group-class="self-center"
:disabled="businessReadonly" :readonly="businessReadonly"
/> />
</div> </div>
@@ -103,24 +101,20 @@
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.description" :error="informationErrors.errors.description"
/> />
<MalioInputText <MalioInputText
v-model="information.competitors" v-model="information.competitors"
:mask="FREE_TEXT_MASK"
:label="t('commercial.clients.form.information.competitors')" :label="t('commercial.clients.form.information.competitors')"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.competitors" :error="informationErrors.errors.competitors"
/> />
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
<MalioDate <MalioDate
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')" :label="t('commercial.clients.form.information.foundedAt')"
:disabled="businessReadonly" :readonly="businessReadonly"
:editable="true" :editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v" @update:raw-value="(v: string) => information.foundedAtRaw = v"
/> />
@@ -128,30 +122,25 @@
v-model="information.employeesCount" v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')" :label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK" :mask="EMPLOYEES_MASK"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.employeesCount" :error="informationErrors.errors.employeesCount"
/> />
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
:key force le re-affichage quand on plafonne (modelValue inchange). -->
<MalioInputAmount <MalioInputAmount
:key="revenueAmountKey" v-model="information.revenueAmount"
:model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')" :label="t('commercial.clients.form.information.revenueAmount')"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.revenueAmount" :error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/> />
<MalioInputText <MalioInputText
v-model="information.directorName" v-model="information.directorName"
:mask="PERSON_NAME_MASK"
:label="t('commercial.clients.form.information.directorName')" :label="t('commercial.clients.form.information.directorName')"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.directorName" :error="informationErrors.errors.directorName"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.profitAmount" v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')" :label="t('commercial.clients.form.information.profitAmount')"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.profitAmount" :error="informationErrors.errors.profitAmount"
/> />
</div> </div>
@@ -178,7 +167,7 @@
:model-value="contact" :model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:disabled="businessReadonly" :readonly="businessReadonly"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
@@ -215,7 +204,7 @@
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="isRowRemovable(addresses, index)" :removable="isRowRemovable(addresses, index)"
:disabled="businessReadonly" :readonly="businessReadonly"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@@ -250,15 +239,14 @@
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.siren" :error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.accountNumber')" :label="t('commercial.clients.form.accounting.accountNumber')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.accountNumber" :error="accountingErrors.errors.accountNumber"
/> />
@@ -266,7 +254,7 @@
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="tvaModeOptions" :options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')" :label="t('commercial.clients.form.accounting.tvaMode')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.tvaMode" :error="accountingErrors.errors.tvaMode"
@@ -274,9 +262,8 @@
/> />
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.nTva')" :label="t('commercial.clients.form.accounting.nTva')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.nTva" :error="accountingErrors.errors.nTva"
/> />
@@ -284,7 +271,7 @@
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions" :options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')" :label="t('commercial.clients.form.accounting.paymentDelay')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentDelay" :error="accountingErrors.errors.paymentDelay"
@@ -294,7 +281,7 @@
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions" :options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')" :label="t('commercial.clients.form.accounting.paymentType')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentType" :error="accountingErrors.errors.paymentType"
@@ -305,7 +292,7 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="bankOptions" :options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')" :label="t('commercial.clients.form.accounting.bank')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.bank" :error="accountingErrors.errors.bank"
@@ -332,23 +319,21 @@
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.label" :error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.ribBic')" :label="t('commercial.clients.form.accounting.ribBic')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.bic" :error="ribErrors[index]?.bic"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.ribIban')" :label="t('commercial.clients.form.accounting.ribIban')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.iban" :error="ribErrors[index]?.iban"
/> />
@@ -438,9 +423,6 @@ import {
type InformationFormDraft, type InformationFormDraft,
type MainFormDraft, type MainFormDraft,
} from '~/modules/commercial/utils/forms/clientEdit' } from '~/modules/commercial/utils/forms/clientEdit'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
import { import {
buildClientFormTabKeys, buildClientFormTabKeys,
isAddressValid, isAddressValid,
@@ -509,22 +491,6 @@ const headerTitle = computed(() => client.value?.companyName ?? t('commercial.cl
const main = reactive<MainFormDraft>(mapMainDraft({} as ClientDetail)) const main = reactive<MainFormDraft>(mapMainDraft({} as ClientDetail))
const information = reactive<InformationFormDraft>(mapInformationDraft({} as ClientDetail)) const information = reactive<InformationFormDraft>(mapInformationDraft({} as ClientDetail))
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as ClientDetail)) const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as ClientDetail))
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
const maxFoundedAt = todayIso()
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
// champ controle quand le plafonnement laisse le modelValue inchange.
const revenueAmountKey = ref(0)
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
function onRevenueAmountInput(value: string | null): void {
const clamped = clampRevenueAmount(value)
information.revenueAmount = clamped ?? null
if (clamped !== value) {
revenueAmountKey.value += 1
}
}
const contacts = ref<ContactFormDraft[]>([]) const contacts = ref<ContactFormDraft[]>([])
const addresses = ref<AddressFormDraft[]>([]) const addresses = ref<AddressFormDraft[]>([])
const ribs = ref<RibFormDraft[]>([]) const ribs = ref<RibFormDraft[]>([])
@@ -702,11 +668,6 @@ function showError(e: unknown, opts: { duplicateCompany?: boolean } = {}): void
}) })
} }
/** Toast de succès après suppression serveur confirmée d'un bloc (contact / adresse / RIB). */
function notifyRemovalSuccess(): void {
toast.success({ title: t('success.title'), message: t('success.deleted') })
}
// ── Erreurs de validation par champ (ERP-101) ─────────────────────────────── // ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
// Etat d'erreurs factorise avec l'ecran de creation (cf. useClientFormErrors) : // Etat d'erreurs factorise avec l'ecran de creation (cf. useClientFormErrors) :
// un `useFormErrors` par groupe scalaire + un tableau d'erreurs par ligne pour // un `useFormErrors` par groupe scalaire + un tableau d'erreurs par ligne pour
@@ -806,7 +767,6 @@ function askRemoveContact(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyContact, makeEmpty: emptyContact,
onError: showError, onError: showError,
onSuccess: notifyRemovalSuccess,
})) }))
} }
@@ -884,7 +844,6 @@ function askRemoveAddress(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyAddress, makeEmpty: emptyAddress,
onError: showError, onError: showError,
onSuccess: notifyRemovalSuccess,
})) }))
} }
@@ -985,7 +944,6 @@ function askRemoveRib(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyRib, makeEmpty: emptyRib,
onError: showError, onError: showError,
onSuccess: notifyRemovalSuccess,
})) }))
} }
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('commercial.clients.consultation.back')"
v-bind="{ ariaLabel: t('commercial.clients.consultation.back') }" v-bind="{ ariaLabel: t('commercial.clients.consultation.back') }"
@click="goBack" @click="goBack"
/> />
@@ -24,7 +23,7 @@
/> />
<MalioButton <MalioButton
v-if="showArchive" v-if="showArchive"
variant="danger" variant="secondary"
icon-name="mdi:archive-arrow-down-outline" icon-name="mdi:archive-arrow-down-outline"
icon-position="left" icon-position="left"
:label="t('commercial.clients.action.archive')" :label="t('commercial.clients.action.archive')"
@@ -49,51 +48,43 @@
<!-- Formulaire principal (lecture seule) --> <!-- Formulaire principal (lecture seule) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4"> <div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="isFilled(client.companyName)"
:model-value="client.companyName" :model-value="client.companyName"
:label="t('commercial.clients.form.main.companyName')" :label="t('commercial.clients.form.main.companyName')"
disabled readonly
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
v-if="isFilled(categoryIris)"
:model-value="categoryIris" :model-value="categoryIris"
:options="mainCategoryOptions" :options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')" :label="t('commercial.clients.form.main.categories')"
:display-tag="true" :display-tag="true"
disabled readonly
/> />
<!-- Relation : masquee en consultation si aucune (ERP-193) ; en edition <!-- Relation toujours affichee (vide = « Aucun »), comme en edition. -->
elle reste toujours visible (vide = « Aucun »). -->
<MalioSelect <MalioSelect
v-if="isFilled(relation.type)"
:model-value="relation.type" :model-value="relation.type"
:options="relationOptions" :options="relationOptions"
:label="t('commercial.clients.form.main.relation')" :label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')" :empty-option-label="t('commercial.clients.form.main.relationNone')"
disabled readonly
/> />
<!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant, <!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant,
aucune valeur sans relation meme comportement qu'en edition). --> aucune valeur sans relation meme comportement qu'en edition). -->
<MalioInputText <MalioInputText
v-if="relation.type && isFilled(relation.name)" v-if="relation.type"
:model-value="relation.name" :model-value="relation.name"
:label="relation.type === 'distributeur' ? t('commercial.clients.form.main.distributorName') : t('commercial.clients.form.main.brokerName')" :label="relation.type === 'distributeur' ? t('commercial.clients.form.main.distributorName') : t('commercial.clients.form.main.brokerName')"
disabled readonly
/> />
<!-- Service de triage : case a cocher masquee si non cochee (ERP-193). -->
<MalioCheckbox <MalioCheckbox
v-if="isFilled(client.triageService === true)"
:model-value="client.triageService === true" :model-value="client.triageService === true"
:label="t('commercial.clients.form.main.triageService')" :label="t('commercial.clients.form.main.triageService')"
group-class="self-center" group-class="self-center"
disabled readonly
/> />
</div> </div>
<!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── --> <!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── -->
<!-- ERP-193 : on n'affiche la barre que s'il reste au moins un onglet <MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
non vide (sinon seul le bloc principal est visible). -->
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
@@ -101,49 +92,42 @@
sur les inputs (champ 40px centre dans un h-12 -> ~4px de sur les inputs (champ 40px centre dans un h-12 -> ~4px de
coussin de chaque cote). --> coussin de chaque cote). -->
<MalioInputTextArea <MalioInputTextArea
v-if="isFilled(information.description)"
:model-value="information.description" :model-value="information.description"
:label="t('commercial.clients.form.information.description')" :label="t('commercial.clients.form.information.description')"
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg" text-input="h-full text-lg"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(information.competitors)"
:model-value="information.competitors" :model-value="information.competitors"
:label="t('commercial.clients.form.information.competitors')" :label="t('commercial.clients.form.information.competitors')"
disabled readonly
/> />
<MalioDate <MalioDate
v-if="isFilled(information.foundedAt)"
:model-value="information.foundedAt" :model-value="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')" :label="t('commercial.clients.form.information.foundedAt')"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(information.employeesCount)"
:model-value="information.employeesCount" :model-value="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')" :label="t('commercial.clients.form.information.employeesCount')"
disabled readonly
/> />
<MalioInputAmount <MalioInputAmount
v-if="isFilled(information.revenueAmount)"
:model-value="information.revenueAmount" :model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')" :label="t('commercial.clients.form.information.revenueAmount')"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(information.directorName)"
:model-value="information.directorName" :model-value="information.directorName"
:label="t('commercial.clients.form.information.directorName')" :label="t('commercial.clients.form.information.directorName')"
disabled readonly
/> />
<MalioInputAmount <MalioInputAmount
v-if="isFilled(information.profitAmount)"
:model-value="information.profitAmount" :model-value="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')" :label="t('commercial.clients.form.information.profitAmount')"
disabled readonly
/> />
</div> </div>
</template> </template>
@@ -156,8 +140,7 @@
:key="contact.id ?? index" :key="contact.id ?? index"
:model-value="contact" :model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
disabled readonly
hide-empty
/> />
</div> </div>
</template> </template>
@@ -174,8 +157,7 @@
:site-options="allSiteOptions" :site-options="allSiteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
disabled readonly
hide-empty
/> />
</div> </div>
</template> </template>
@@ -186,47 +168,41 @@
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="isFilled(accounting.siren)"
:model-value="accounting.siren" :model-value="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(accounting.accountNumber)"
:model-value="accounting.accountNumber" :model-value="accounting.accountNumber"
:label="t('commercial.clients.form.accounting.accountNumber')" :label="t('commercial.clients.form.accounting.accountNumber')"
disabled readonly
/> />
<MalioSelect <MalioSelect
v-if="isFilled(accounting.tvaModeIri)"
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="tvaModeOptions" :options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')" :label="t('commercial.clients.form.accounting.tvaMode')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(accounting.nTva)"
:model-value="accounting.nTva" :model-value="accounting.nTva"
:label="t('commercial.clients.form.accounting.nTva')" :label="t('commercial.clients.form.accounting.nTva')"
disabled readonly
/> />
<MalioSelect <MalioSelect
v-if="isFilled(accounting.paymentDelayIri)"
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions" :options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')" :label="t('commercial.clients.form.accounting.paymentDelay')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
<MalioSelect <MalioSelect
v-if="isFilled(accounting.paymentTypeIri)"
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions" :options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')" :label="t('commercial.clients.form.accounting.paymentType')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
<MalioSelect <MalioSelect
v-if="accounting.bankIri" v-if="accounting.bankIri"
@@ -234,7 +210,7 @@
:options="bankOptions" :options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')" :label="t('commercial.clients.form.accounting.bank')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
</div> </div>
</div> </div>
@@ -247,30 +223,30 @@
> >
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="isFilled(rib.label)"
:model-value="rib.label" :model-value="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(rib.bic)"
:model-value="rib.bic" :model-value="rib.bic"
:label="t('commercial.clients.form.accounting.ribBic')" :label="t('commercial.clients.form.accounting.ribBic')"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(rib.iban)"
:model-value="rib.iban" :model-value="rib.iban"
:label="t('commercial.clients.form.accounting.ribIban')" :label="t('commercial.clients.form.accounting.ribIban')"
disabled readonly
/> />
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<!-- ERP-193 : les onglets « a venir » (Transport / Statistiques /
Rapports / Echanges) ne sont plus rendus en consultation <!-- Onglets non encore implementes : frame vide (navigation libre). -->
(masquage des onglets vides) — slots supprimes. --> <template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
</MalioTabList> </MalioTabList>
</template> </template>
@@ -302,14 +278,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient' import { useClient } from '~/modules/commercial/composables/useClient'
import { isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules' import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab' import { readHistoryTab } from '~/shared/utils/historyTab'
import { import {
canEditClient, canEditClient,
categoryOptionsOf, categoryOptionsOf,
clientConsultationVisibleTabs,
contactOptionsOf, contactOptionsOf,
mapAccountingDraft, mapAccountingDraft,
mapAddressView, mapAddressView,
@@ -324,7 +299,6 @@ import {
type SelectOption, type SelectOption,
} from '~/modules/commercial/utils/forms/clientConsultation' } from '~/modules/commercial/utils/forms/clientConsultation'
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm' import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
import { isFilled } from '~/shared/utils/consultationDisplay'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur). // Masque d'affichage (purement visuel, la donnee reste celle du serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -438,11 +412,9 @@ const paymentTypeOptions = computed(() => referentialOptionOf(client.value?.paym
const bankOptions = computed(() => referentialOptionOf(client.value?.bank)) const bankOptions = computed(() => referentialOptionOf(client.value?.bank))
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ──── // ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
// ERP-193 (retour metier) : on masque les coquilles non implementees ET tout // 4 onglets actifs (Information, Contact, Adresse, + Comptabilite si droit) et
// onglet de donnees vide. La liste depend donc du payload charge. // 4 coquilles (Transport, Statistiques, Rapports, Echanges).
const visibleTabKeys = computed(() => clientConsultationVisibleTabs(client.value, { const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
canAccountingView: canAccountingView.value,
}))
const TAB_ICONS: Record<string, string> = { const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline', information: 'mdi:account-outline',
@@ -455,26 +427,14 @@ const TAB_ICONS: Record<string, string> = {
exchanges: 'mdi:account-group-outline', exchanges: 'mdi:account-group-outline',
} }
const tabs = computed(() => visibleTabKeys.value.map(key => ({ const tabs = computed(() => tabKeys.value.map(key => ({
key, key,
label: t(`commercial.clients.tab.${key}`), label: t(`commercial.clients.tab.${key}`),
icon: TAB_ICONS[key], icon: TAB_ICONS[key],
}))) })))
// Onglet initial : vide tant que le client n'est pas charge. Des que la liste // Onglet initial : repris de l'edition au retour (history.state), sinon Information.
// des onglets visibles est connue, on cale sur l'onglet repris de l'edition const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
// (history.state) s'il est encore visible, sinon le premier onglet visible.
// Un watcher recale aussi si l'onglet courant disparait (ex: changement de droit).
const activeTab = ref('')
watch(visibleTabKeys, (keys) => {
if (keys.length === 0) {
activeTab.value = ''
return
}
if (!keys.includes(activeTab.value)) {
activeTab.value = readHistoryTab(keys) ?? keys[0]
}
}, { immediate: true })
// ── Navigation ───────────────────────────────────────────────────────────── // ── Navigation ─────────────────────────────────────────────────────────────
function goBack(): void { function goBack(): void {
@@ -43,7 +43,7 @@
@update:page="goToPage" @update:page="goToPage"
@update:per-page="setItemsPerPage" @update:per-page="setItemsPerPage"
> >
<!-- Categories : libelles (name) separes par une virgule (ERP-193). --> <!-- Categories : codes stables separes par une virgule (ERP-78). -->
<template #cell-categories="{ item }"> <template #cell-categories="{ item }">
{{ formatCategories(item) }} {{ formatCategories(item) }}
</template> </template>
@@ -209,10 +209,10 @@ const columns = [
{ key: 'lastActivity', label: t('commercial.clients.column.lastActivity') }, { key: 'lastActivity', label: t('commercial.clients.column.lastActivity') },
] ]
/** Libelles (name) des categories du client, separes par une virgule (ERP-193). */ /** Codes des categories du client, separes par une virgule (ERP-78). */
function formatCategories(item: Record<string, unknown>): string { function formatCategories(item: Record<string, unknown>): string {
const categories = (item.categories as Client['categories']) ?? [] const categories = (item.categories as Client['categories']) ?? []
return categories.map(c => c.name).join(', ') return categories.map(c => c.code).join(', ')
} }
/** /**
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('commercial.clients.form.back')"
v-bind="{ ariaLabel: t('commercial.clients.form.back') }" v-bind="{ ariaLabel: t('commercial.clients.form.back') }"
@click="goBack" @click="goBack"
/> />
@@ -20,10 +19,9 @@
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4"> <div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="main.companyName" v-model="main.companyName"
:mask="FREE_TEXT_MASK"
:label="t('commercial.clients.form.main.companyName')" :label="t('commercial.clients.form.main.companyName')"
:required="true" :required="true"
:disabled="mainLocked" :readonly="mainLocked"
:error="mainErrors.errors.companyName" :error="mainErrors.errors.companyName"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
@@ -31,7 +29,7 @@
:options="referentials.categories.value" :options="referentials.categories.value"
:label="t('commercial.clients.form.main.categories')" :label="t('commercial.clients.form.main.categories')"
:display-tag="true" :display-tag="true"
:disabled="mainLocked" :readonly="mainLocked"
:required="true" :required="true"
:error="mainErrors.errors.categories" :error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -42,7 +40,7 @@
:options="relationOptions" :options="relationOptions"
:label="t('commercial.clients.form.main.relation')" :label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')" :empty-option-label="t('commercial.clients.form.main.relationNone')"
:disabled="mainLocked" :readonly="mainLocked"
@update:model-value="onRelationChange" @update:model-value="onRelationChange"
/> />
<MalioSelect <MalioSelect
@@ -50,7 +48,7 @@
:model-value="main.brokerIri" :model-value="main.brokerIri"
:options="referentials.brokers.value" :options="referentials.brokers.value"
:label="t('commercial.clients.form.main.brokerName')" :label="t('commercial.clients.form.main.brokerName')"
:disabled="mainLocked" :readonly="mainLocked"
:required="true" :required="true"
:error="mainErrors.errors.broker" :error="mainErrors.errors.broker"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
@@ -60,7 +58,7 @@
:model-value="main.distributorIri" :model-value="main.distributorIri"
:options="referentials.distributors.value" :options="referentials.distributors.value"
:label="t('commercial.clients.form.main.distributorName')" :label="t('commercial.clients.form.main.distributorName')"
:disabled="mainLocked" :readonly="mainLocked"
:required="true" :required="true"
:error="mainErrors.errors.distributor" :error="mainErrors.errors.distributor"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
@@ -70,7 +68,7 @@
v-model="main.triageService" v-model="main.triageService"
:label="t('commercial.clients.form.main.triageService')" :label="t('commercial.clients.form.main.triageService')"
group-class="self-center" group-class="self-center"
:disabled="mainLocked" :readonly="mainLocked"
/> />
</div> </div>
@@ -98,24 +96,20 @@
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.description" :error="informationErrors.errors.description"
/> />
<MalioInputText <MalioInputText
v-model="information.competitors" v-model="information.competitors"
:mask="FREE_TEXT_MASK"
:label="t('commercial.clients.form.information.competitors')" :label="t('commercial.clients.form.information.competitors')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.competitors" :error="informationErrors.errors.competitors"
/> />
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
<MalioDate <MalioDate
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')" :label="t('commercial.clients.form.information.foundedAt')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:editable="true" :editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v" @update:raw-value="(v: string) => information.foundedAtRaw = v"
/> />
@@ -123,42 +117,37 @@
v-model="information.employeesCount" v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')" :label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK" :mask="EMPLOYEES_MASK"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.employeesCount" :error="informationErrors.errors.employeesCount"
/> />
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
:key force le re-affichage quand on plafonne (modelValue inchange). -->
<MalioInputAmount <MalioInputAmount
:key="revenueAmountKey" v-model="information.revenueAmount"
:model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')" :label="t('commercial.clients.form.information.revenueAmount')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.revenueAmount" :error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/> />
<MalioInputText <MalioInputText
v-model="information.directorName" v-model="information.directorName"
:mask="PERSON_NAME_MASK"
:label="t('commercial.clients.form.information.directorName')" :label="t('commercial.clients.form.information.directorName')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.directorName" :error="informationErrors.errors.directorName"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.profitAmount" v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')" :label="t('commercial.clients.form.information.profitAmount')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.profitAmount" :error="informationErrors.errors.profitAmount"
/> />
</div> </div>
<!-- Masque tant que le client n'est pas cree : Information etant <div v-if="!isValidated('information')" class="mt-12 flex justify-center">
l'onglet actif par defaut, son Valider ne doit pas apparaitre a <!-- Desactive tant que le client n'est pas cree (evite un PATCH
cote de celui du formulaire principal (ERP-193). Onglet facultatif : avant le POST si clic trop tot, Information etant l'onglet
un enregistrement a vide reste possible, c'est le back qui valide. --> actif par defaut). Onglet facultatif : un enregistrement a
<div v-if="!isValidated('information') && clientId !== null" class="mt-12 flex justify-center"> vide reste possible, c'est le back qui valide. -->
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.form.submit')" :label="t('commercial.clients.form.submit')"
:disabled="tabSubmitting" :disabled="tabSubmitting || clientId === null"
@click="submitInformation" @click="submitInformation"
/> />
</div> </div>
@@ -177,7 +166,7 @@
:model-value="contact" :model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:disabled="isValidated('contact')" :readonly="isValidated('contact')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
@@ -214,7 +203,7 @@
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="isRowRemovable(addresses, index)" :removable="isRowRemovable(addresses, index)"
:disabled="isValidated('address')" :readonly="isValidated('address')"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@@ -248,15 +237,14 @@
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.siren" :error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.accountNumber')" :label="t('commercial.clients.form.accounting.accountNumber')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.accountNumber" :error="accountingErrors.errors.accountNumber"
/> />
@@ -264,7 +252,7 @@
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value" :options="referentials.tvaModes.value"
:label="t('commercial.clients.form.accounting.tvaMode')" :label="t('commercial.clients.form.accounting.tvaMode')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.tvaMode" :error="accountingErrors.errors.tvaMode"
@@ -272,9 +260,8 @@
/> />
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.nTva')" :label="t('commercial.clients.form.accounting.nTva')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.nTva" :error="accountingErrors.errors.nTva"
/> />
@@ -282,7 +269,7 @@
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value" :options="referentials.paymentDelays.value"
:label="t('commercial.clients.form.accounting.paymentDelay')" :label="t('commercial.clients.form.accounting.paymentDelay')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentDelay" :error="accountingErrors.errors.paymentDelay"
@@ -292,7 +279,7 @@
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value" :options="referentials.paymentTypes.value"
:label="t('commercial.clients.form.accounting.paymentType')" :label="t('commercial.clients.form.accounting.paymentType')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentType" :error="accountingErrors.errors.paymentType"
@@ -303,7 +290,7 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="referentials.banks.value" :options="referentials.banks.value"
:label="t('commercial.clients.form.accounting.bank')" :label="t('commercial.clients.form.accounting.bank')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.bank" :error="accountingErrors.errors.bank"
@@ -331,23 +318,21 @@
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.label" :error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.ribBic')" :label="t('commercial.clients.form.accounting.ribBic')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.bic" :error="ribErrors[index]?.bic"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.ribIban')" :label="t('commercial.clients.form.accounting.ribIban')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.iban" :error="ribErrors[index]?.iban"
/> />
@@ -422,9 +407,6 @@ import {
lastFillableTabKey, lastFillableTabKey,
showsRelationAndTriageFields, showsRelationAndTriageFields,
} from '~/modules/commercial/utils/forms/clientFormRules' } from '~/modules/commercial/utils/forms/clientFormRules'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
import { import {
buildAddressPayload, buildAddressPayload,
buildMainPayload, buildMainPayload,
@@ -683,22 +665,6 @@ const information = reactive({
directorName: null as string | null, directorName: null as string | null,
}) })
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
const maxFoundedAt = todayIso()
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
// champ controle quand le plafonnement laisse le modelValue inchange.
const revenueAmountKey = ref(0)
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
function onRevenueAmountInput(value: string | null): void {
const clamped = clampRevenueAmount(value)
information.revenueAmount = clamped ?? null
if (clamped !== value) {
revenueAmountKey.value += 1
}
}
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */ /** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
async function submitInformation(): Promise<void> { async function submitInformation(): Promise<void> {
if (clientId.value === null || tabSubmitting.value) return if (clientId.value === null || tabSubmitting.value) return
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('commercial.suppliers.edit.back')"
v-bind="{ ariaLabel: t('commercial.suppliers.edit.back') }" v-bind="{ ariaLabel: t('commercial.suppliers.edit.back') }"
@click="goBack" @click="goBack"
/> />
@@ -27,16 +26,15 @@
v-model="main.companyName" v-model="main.companyName"
:label="t('commercial.suppliers.form.main.companyName')" :label="t('commercial.suppliers.form.main.companyName')"
:required="true" :required="true"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="mainErrors.errors.companyName" :error="mainErrors.errors.companyName"
:mask="FREE_TEXT_MASK"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="main.categoryIris" :model-value="main.categoryIris"
:options="mainCategoryOptions" :options="mainCategoryOptions"
:label="t('commercial.suppliers.form.main.categories')" :label="t('commercial.suppliers.form.main.categories')"
:display-tag="true" :display-tag="true"
:disabled="businessReadonly" :readonly="businessReadonly"
:required="true" :required="true"
:error="mainErrors.errors.categories" :error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -64,24 +62,20 @@
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.description" :error="informationErrors.errors.description"
/> />
<MalioInputText <MalioInputText
v-model="information.competitors" v-model="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')" :label="t('commercial.suppliers.form.information.competitors')"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.competitors" :error="informationErrors.errors.competitors"
:mask="FREE_TEXT_MASK"
/> />
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
<MalioDate <MalioDate
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')" :label="t('commercial.suppliers.form.information.foundedAt')"
:disabled="businessReadonly" :readonly="businessReadonly"
:editable="true" :editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v" @update:raw-value="(v: string) => information.foundedAtRaw = v"
/> />
@@ -89,30 +83,25 @@
v-model="information.employeesCount" v-model="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')" :label="t('commercial.suppliers.form.information.employeesCount')"
:mask="EMPLOYEES_MASK" :mask="EMPLOYEES_MASK"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.employeesCount" :error="informationErrors.errors.employeesCount"
/> />
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
:key force le re-affichage quand on plafonne (modelValue inchange). -->
<MalioInputAmount <MalioInputAmount
:key="revenueAmountKey" v-model="information.revenueAmount"
:model-value="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')" :label="t('commercial.suppliers.form.information.revenueAmount')"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.revenueAmount" :error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/> />
<MalioInputText <MalioInputText
v-model="information.directorName" v-model="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')" :label="t('commercial.suppliers.form.information.directorName')"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.directorName" :error="informationErrors.errors.directorName"
:mask="PERSON_NAME_MASK"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.profitAmount" v-model="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')" :label="t('commercial.suppliers.form.information.profitAmount')"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.profitAmount" :error="informationErrors.errors.profitAmount"
/> />
<!-- Volume previsionnel : specifique fournisseur (entier). --> <!-- Volume previsionnel : specifique fournisseur (entier). -->
@@ -120,7 +109,7 @@
v-model="information.volumeForecast" v-model="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')" :label="t('commercial.suppliers.form.information.volumeForecast')"
:mask="VOLUME_FORECAST_MASK" :mask="VOLUME_FORECAST_MASK"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.volumeForecast" :error="informationErrors.errors.volumeForecast"
/> />
</div> </div>
@@ -147,7 +136,7 @@
:model-value="contact" :model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })" :title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:disabled="businessReadonly" :readonly="businessReadonly"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
@@ -184,7 +173,7 @@
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="isRowRemovable(addresses, index)" :removable="isRowRemovable(addresses, index)"
:disabled="businessReadonly" :readonly="businessReadonly"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@@ -219,23 +208,22 @@
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')" :label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.siren" :error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')" :label="t('commercial.suppliers.form.accounting.accountNumber')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.accountNumber" :error="accountingErrors.errors.accountNumber"
:mask="CODE_ALNUM_MASK"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="tvaModeOptions" :options="tvaModeOptions"
:label="t('commercial.suppliers.form.accounting.tvaMode')" :label="t('commercial.suppliers.form.accounting.tvaMode')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.tvaMode" :error="accountingErrors.errors.tvaMode"
@@ -244,16 +232,15 @@
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')" :label="t('commercial.suppliers.form.accounting.nTva')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.nTva" :error="accountingErrors.errors.nTva"
:mask="CODE_ALNUM_MASK"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions" :options="paymentDelayOptions"
:label="t('commercial.suppliers.form.accounting.paymentDelay')" :label="t('commercial.suppliers.form.accounting.paymentDelay')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentDelay" :error="accountingErrors.errors.paymentDelay"
@@ -263,7 +250,7 @@
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions" :options="paymentTypeOptions"
:label="t('commercial.suppliers.form.accounting.paymentType')" :label="t('commercial.suppliers.form.accounting.paymentType')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentType" :error="accountingErrors.errors.paymentType"
@@ -274,7 +261,7 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="bankOptions" :options="bankOptions"
:label="t('commercial.suppliers.form.accounting.bank')" :label="t('commercial.suppliers.form.accounting.bank')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.bank" :error="accountingErrors.errors.bank"
@@ -301,25 +288,23 @@
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')" :label="t('commercial.suppliers.form.accounting.ribLabel')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.label" :error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')" :label="t('commercial.suppliers.form.accounting.ribBic')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.bic" :error="ribErrors[index]?.bic"
:mask="CODE_ALNUM_MASK"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')" :label="t('commercial.suppliers.form.accounting.ribIban')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.iban" :error="ribErrors[index]?.iban"
:mask="CODE_ALNUM_MASK"
/> />
</div> </div>
</div> </div>
@@ -407,8 +392,6 @@ import {
type MainFormDraft, type MainFormDraft,
type SupplierEditAbilities, type SupplierEditAbilities,
} from '~/modules/commercial/utils/forms/supplierEdit' } from '~/modules/commercial/utils/forms/supplierEdit'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import { import {
buildSupplierFormTabKeys, buildSupplierFormTabKeys,
isAddressValid, isAddressValid,
@@ -429,7 +412,6 @@ import {
} from '~/modules/commercial/types/supplierForm' } from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow' import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
import { readHistoryTab } from '~/shared/utils/historyTab' import { readHistoryTab } from '~/shared/utils/historyTab'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
@@ -475,22 +457,6 @@ const headerTitle = computed(() => supplier.value?.companyName ?? t('commercial.
const main = reactive<MainFormDraft>(mapMainDraft({} as SupplierDetail)) const main = reactive<MainFormDraft>(mapMainDraft({} as SupplierDetail))
const information = reactive<InformationFormDraft>(mapInformationDraft({} as SupplierDetail)) const information = reactive<InformationFormDraft>(mapInformationDraft({} as SupplierDetail))
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as SupplierDetail)) const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as SupplierDetail))
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
const maxFoundedAt = todayIso()
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
// champ controle quand le plafonnement laisse le modelValue inchange.
const revenueAmountKey = ref(0)
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
function onRevenueAmountInput(value: string | null): void {
const clamped = clampRevenueAmount(value)
information.revenueAmount = clamped ?? null
if (clamped !== value) {
revenueAmountKey.value += 1
}
}
const contacts = ref<SupplierContactFormDraft[]>([]) const contacts = ref<SupplierContactFormDraft[]>([])
const addresses = ref<SupplierAddressFormDraft[]>([]) const addresses = ref<SupplierAddressFormDraft[]>([])
const ribs = ref<SupplierRibFormDraft[]>([]) const ribs = ref<SupplierRibFormDraft[]>([])
@@ -617,11 +583,6 @@ function showError(e: unknown): void {
toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(e) }) toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(e) })
} }
/** Toast de succès après suppression serveur confirmée d'un bloc (contact / adresse / RIB). */
function notifyRemovalSuccess(): void {
toast.success({ title: t('success.title'), message: t('success.deleted') })
}
// ── Erreurs de validation par champ (ERP-101) ─────────────────────────────── // ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
const { const {
mainErrors, mainErrors,
@@ -705,7 +666,6 @@ function askRemoveContact(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyContact, makeEmpty: emptyContact,
onError: showError, onError: showError,
onSuccess: notifyRemovalSuccess,
})) }))
} }
@@ -774,7 +734,6 @@ function askRemoveAddress(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyAddress, makeEmpty: emptyAddress,
onError: showError, onError: showError,
onSuccess: notifyRemovalSuccess,
})) }))
} }
@@ -874,7 +833,6 @@ function askRemoveRib(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyRib, makeEmpty: emptyRib,
onError: showError, onError: showError,
onSuccess: notifyRemovalSuccess,
})) }))
} }
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('commercial.suppliers.consultation.back')"
v-bind="{ ariaLabel: t('commercial.suppliers.consultation.back') }" v-bind="{ ariaLabel: t('commercial.suppliers.consultation.back') }"
@click="goBack" @click="goBack"
/> />
@@ -24,7 +23,7 @@
/> />
<MalioButton <MalioButton
v-if="showArchive" v-if="showArchive"
variant="danger" variant="secondary"
icon-name="mdi:archive-arrow-down-outline" icon-name="mdi:archive-arrow-down-outline"
icon-position="left" icon-position="left"
:label="t('commercial.suppliers.action.archive')" :label="t('commercial.suppliers.action.archive')"
@@ -49,82 +48,69 @@
<!-- Formulaire principal (lecture seule) --> <!-- Formulaire principal (lecture seule) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4"> <div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="isFilled(supplier.companyName)"
:model-value="supplier.companyName" :model-value="supplier.companyName"
:label="t('commercial.suppliers.form.main.companyName')" :label="t('commercial.suppliers.form.main.companyName')"
disabled readonly
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
v-if="isFilled(categoryIris)"
:model-value="categoryIris" :model-value="categoryIris"
:options="mainCategoryOptions" :options="mainCategoryOptions"
:label="t('commercial.suppliers.form.main.categories')" :label="t('commercial.suppliers.form.main.categories')"
:display-tag="true" :display-tag="true"
disabled readonly
/> />
</div> </div>
<!-- Onglets (navigation libre, tout en lecture seule) --> <!-- Onglets (navigation libre, tout en lecture seule) -->
<!-- Masque la barre d'onglets (et sa bordure) quand aucun onglet n'est <MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
visible : seul le formulaire principal est rempli (aligné sur le
client). -->
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas <!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12). --> sur les inputs (champ 40px centre dans un h-12). -->
<MalioInputTextArea <MalioInputTextArea
v-if="isFilled(information.description)"
:model-value="information.description" :model-value="information.description"
:label="t('commercial.suppliers.form.information.description')" :label="t('commercial.suppliers.form.information.description')"
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg" text-input="h-full text-lg"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(information.competitors)"
:model-value="information.competitors" :model-value="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')" :label="t('commercial.suppliers.form.information.competitors')"
disabled readonly
/> />
<MalioDate <MalioDate
v-if="isFilled(information.foundedAt)"
:model-value="information.foundedAt" :model-value="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')" :label="t('commercial.suppliers.form.information.foundedAt')"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(information.employeesCount)"
:model-value="information.employeesCount" :model-value="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')" :label="t('commercial.suppliers.form.information.employeesCount')"
disabled readonly
/> />
<MalioInputAmount <MalioInputAmount
v-if="isFilled(information.revenueAmount)"
:model-value="information.revenueAmount" :model-value="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')" :label="t('commercial.suppliers.form.information.revenueAmount')"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(information.directorName)"
:model-value="information.directorName" :model-value="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')" :label="t('commercial.suppliers.form.information.directorName')"
disabled readonly
/> />
<MalioInputAmount <MalioInputAmount
v-if="isFilled(information.profitAmount)"
:model-value="information.profitAmount" :model-value="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')" :label="t('commercial.suppliers.form.information.profitAmount')"
disabled readonly
/> />
<!-- Volume previsionnel : specifique fournisseur (entier). --> <!-- Volume previsionnel : specifique fournisseur (entier). -->
<MalioInputText <MalioInputText
v-if="isFilled(information.volumeForecast)"
:model-value="information.volumeForecast" :model-value="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')" :label="t('commercial.suppliers.form.information.volumeForecast')"
disabled readonly
/> />
</div> </div>
</template> </template>
@@ -137,8 +123,7 @@
:key="contact.id ?? index" :key="contact.id ?? index"
:model-value="contact" :model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })" :title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
disabled readonly
hide-empty
/> />
</div> </div>
</template> </template>
@@ -155,8 +140,7 @@
:site-options="allSiteOptions" :site-options="allSiteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
disabled readonly
hide-empty
/> />
</div> </div>
</template> </template>
@@ -167,47 +151,41 @@
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="isFilled(accounting.siren)"
:model-value="accounting.siren" :model-value="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')" :label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(accounting.accountNumber)"
:model-value="accounting.accountNumber" :model-value="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')" :label="t('commercial.suppliers.form.accounting.accountNumber')"
disabled readonly
/> />
<MalioSelect <MalioSelect
v-if="isFilled(accounting.tvaModeIri)"
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="tvaModeOptions" :options="tvaModeOptions"
:label="t('commercial.suppliers.form.accounting.tvaMode')" :label="t('commercial.suppliers.form.accounting.tvaMode')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(accounting.nTva)"
:model-value="accounting.nTva" :model-value="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')" :label="t('commercial.suppliers.form.accounting.nTva')"
disabled readonly
/> />
<MalioSelect <MalioSelect
v-if="isFilled(accounting.paymentDelayIri)"
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions" :options="paymentDelayOptions"
:label="t('commercial.suppliers.form.accounting.paymentDelay')" :label="t('commercial.suppliers.form.accounting.paymentDelay')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
<MalioSelect <MalioSelect
v-if="isFilled(accounting.paymentTypeIri)"
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions" :options="paymentTypeOptions"
:label="t('commercial.suppliers.form.accounting.paymentType')" :label="t('commercial.suppliers.form.accounting.paymentType')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
<MalioSelect <MalioSelect
v-if="accounting.bankIri" v-if="accounting.bankIri"
@@ -215,7 +193,7 @@
:options="bankOptions" :options="bankOptions"
:label="t('commercial.suppliers.form.accounting.bank')" :label="t('commercial.suppliers.form.accounting.bank')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
</div> </div>
</div> </div>
@@ -228,30 +206,30 @@
> >
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="isFilled(rib.label)"
:model-value="rib.label" :model-value="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')" :label="t('commercial.suppliers.form.accounting.ribLabel')"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(rib.bic)"
:model-value="rib.bic" :model-value="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')" :label="t('commercial.suppliers.form.accounting.ribBic')"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(rib.iban)"
:model-value="rib.iban" :model-value="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')" :label="t('commercial.suppliers.form.accounting.ribIban')"
disabled readonly
/> />
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<!-- ERP-193 : les onglets « a venir » (Transport / Statistiques /
Rapports / Echanges) ne sont plus rendus en consultation <!-- Onglets non encore implementes : frame vide (navigation libre). -->
(masquage des onglets vides) slots supprimes. --> <template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
</MalioTabList> </MalioTabList>
</template> </template>
@@ -283,9 +261,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useSupplier } from '~/modules/commercial/composables/useSupplier' import { useSupplier } from '~/modules/commercial/composables/useSupplier'
import { isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules' import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab' import { readHistoryTab } from '~/shared/utils/historyTab'
import { import {
canEditSupplier, canEditSupplier,
@@ -300,12 +278,10 @@ import {
referentialOptionOf, referentialOptionOf,
showArchiveAction, showArchiveAction,
showRestoreAction, showRestoreAction,
supplierConsultationVisibleTabs,
type SelectOption, type SelectOption,
type SupplierDetail, type SupplierDetail,
} from '~/modules/commercial/utils/forms/supplierConsultation' } from '~/modules/commercial/utils/forms/supplierConsultation'
import { emptyContact } from '~/modules/commercial/types/supplierForm' import { emptyContact } from '~/modules/commercial/types/supplierForm'
import { isFilled } from '~/shared/utils/consultationDisplay'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur). // Masque d'affichage (purement visuel, la donnee reste celle du serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -411,11 +387,9 @@ const paymentTypeOptions = computed(() => referentialOptionOf(supplier.value?.pa
const bankOptions = computed(() => referentialOptionOf(supplier.value?.bank)) const bankOptions = computed(() => referentialOptionOf(supplier.value?.bank))
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ──── // ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
// ERP-193 (retour metier) : on masque les coquilles non implementees ET tout // 3 onglets actifs (Information, Contacts, Adresses, + Comptabilite si droit) et
// onglet de donnees vide. La liste depend donc du payload charge. // 4 coquilles (Transport, Statistiques, Rapports, Echanges).
const visibleTabKeys = computed(() => supplierConsultationVisibleTabs(supplier.value, { const tabKeys = computed(() => buildSupplierFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
canAccountingView: canAccountingView.value,
}))
const TAB_ICONS: Record<string, string> = { const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline', information: 'mdi:account-outline',
@@ -428,25 +402,14 @@ const TAB_ICONS: Record<string, string> = {
exchanges: 'mdi:account-group-outline', exchanges: 'mdi:account-group-outline',
} }
const tabs = computed(() => visibleTabKeys.value.map(key => ({ const tabs = computed(() => tabKeys.value.map(key => ({
key, key,
label: t(`commercial.suppliers.tab.${key}`), label: t(`commercial.suppliers.tab.${key}`),
icon: TAB_ICONS[key], icon: TAB_ICONS[key],
}))) })))
// Onglet initial : vide tant que le fournisseur n'est pas charge. Des que la // Onglet initial : repris de l'edition au retour (history.state), sinon Information.
// liste des onglets visibles est connue, on cale sur l'onglet repris de const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
// l'edition (history.state) s'il est encore visible, sinon le premier visible.
const activeTab = ref('')
watch(visibleTabKeys, (keys) => {
if (keys.length === 0) {
activeTab.value = ''
return
}
if (!keys.includes(activeTab.value)) {
activeTab.value = readHistoryTab(keys) ?? keys[0]
}
}, { immediate: true })
// ── Navigation ───────────────────────────────────────────────────────────── // ── Navigation ─────────────────────────────────────────────────────────────
function goBack(): void { function goBack(): void {
@@ -43,7 +43,7 @@
@update:page="goToPage" @update:page="goToPage"
@update:per-page="setItemsPerPage" @update:per-page="setItemsPerPage"
> >
<!-- Categories : libelles (name) separes par une virgule, aligne sur le client (ERP-193). --> <!-- Categories : libelles (name) separes par une virgule (spec M2). -->
<template #cell-categories="{ item }"> <template #cell-categories="{ item }">
{{ formatCategories(item) }} {{ formatCategories(item) }}
</template> </template>
@@ -209,7 +209,7 @@ const columns = [
{ key: 'lastActivity', label: t('commercial.suppliers.column.lastActivity') }, { key: 'lastActivity', label: t('commercial.suppliers.column.lastActivity') },
] ]
/** Libelles (name) des categories du fournisseur, separes par une virgule (aligne sur le client, ERP-193). */ /** Libelles des categories du fournisseur, separes par une virgule (spec M2 : name). */
function formatCategories(item: Record<string, unknown>): string { function formatCategories(item: Record<string, unknown>): string {
const categories = (item.categories as Supplier['categories']) ?? [] const categories = (item.categories as Supplier['categories']) ?? []
return categories.map(c => c.name).join(', ') return categories.map(c => c.name).join(', ')
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('commercial.suppliers.form.back')"
v-bind="{ ariaLabel: t('commercial.suppliers.form.back') }" v-bind="{ ariaLabel: t('commercial.suppliers.form.back') }"
@click="goBack" @click="goBack"
/> />
@@ -22,16 +21,15 @@
v-model="main.companyName" v-model="main.companyName"
:label="t('commercial.suppliers.form.main.companyName')" :label="t('commercial.suppliers.form.main.companyName')"
:required="true" :required="true"
:disabled="mainLocked" :readonly="mainLocked"
:error="mainErrors.errors.companyName" :error="mainErrors.errors.companyName"
:mask="FREE_TEXT_MASK"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="main.categoryIris" :model-value="main.categoryIris"
:options="referentials.categories.value" :options="referentials.categories.value"
:label="t('commercial.suppliers.form.main.categories')" :label="t('commercial.suppliers.form.main.categories')"
:display-tag="true" :display-tag="true"
:disabled="mainLocked" :readonly="mainLocked"
:required="true" :required="true"
:error="mainErrors.errors.categories" :error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -58,24 +56,20 @@
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.description" :error="informationErrors.errors.description"
/> />
<MalioInputText <MalioInputText
v-model="information.competitors" v-model="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')" :label="t('commercial.suppliers.form.information.competitors')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.competitors" :error="informationErrors.errors.competitors"
:mask="FREE_TEXT_MASK"
/> />
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
<MalioDate <MalioDate
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')" :label="t('commercial.suppliers.form.information.foundedAt')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:editable="true" :editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v" @update:raw-value="(v: string) => information.foundedAtRaw = v"
/> />
@@ -83,30 +77,25 @@
v-model="information.employeesCount" v-model="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')" :label="t('commercial.suppliers.form.information.employeesCount')"
:mask="EMPLOYEES_MASK" :mask="EMPLOYEES_MASK"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.employeesCount" :error="informationErrors.errors.employeesCount"
/> />
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
:key force le re-affichage quand on plafonne (modelValue inchange). -->
<MalioInputAmount <MalioInputAmount
:key="revenueAmountKey" v-model="information.revenueAmount"
:model-value="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')" :label="t('commercial.suppliers.form.information.revenueAmount')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.revenueAmount" :error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/> />
<MalioInputText <MalioInputText
v-model="information.directorName" v-model="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')" :label="t('commercial.suppliers.form.information.directorName')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.directorName" :error="informationErrors.errors.directorName"
:mask="PERSON_NAME_MASK"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.profitAmount" v-model="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')" :label="t('commercial.suppliers.form.information.profitAmount')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.profitAmount" :error="informationErrors.errors.profitAmount"
/> />
<!-- Volume previsionnel : specifique fournisseur. Champ texte <!-- Volume previsionnel : specifique fournisseur. Champ texte
@@ -115,18 +104,15 @@
v-model="information.volumeForecast" v-model="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')" :label="t('commercial.suppliers.form.information.volumeForecast')"
:mask="VOLUME_FORECAST_MASK" :mask="VOLUME_FORECAST_MASK"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.volumeForecast" :error="informationErrors.errors.volumeForecast"
/> />
</div> </div>
<!-- Masque tant que le fournisseur n'est pas cree : Information etant <div v-if="!isValidated('information')" class="mt-12 flex justify-center">
l'onglet actif par defaut, son Valider ne doit pas apparaitre a cote
de celui du formulaire principal (ERP-193). -->
<div v-if="!isValidated('information') && supplierId !== null" class="mt-12 flex justify-center">
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.suppliers.form.submit')" :label="t('commercial.suppliers.form.submit')"
:disabled="tabSubmitting" :disabled="tabSubmitting || supplierId === null"
@click="submitInformation" @click="submitInformation"
/> />
</div> </div>
@@ -145,7 +131,7 @@
:model-value="contact" :model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })" :title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:disabled="isValidated('contacts')" :readonly="isValidated('contacts')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
@@ -182,7 +168,7 @@
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="isRowRemovable(addresses, index)" :removable="isRowRemovable(addresses, index)"
:disabled="isValidated('addresses')" :readonly="isValidated('addresses')"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@@ -216,23 +202,22 @@
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')" :label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.siren" :error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')" :label="t('commercial.suppliers.form.accounting.accountNumber')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.accountNumber" :error="accountingErrors.errors.accountNumber"
:mask="CODE_ALNUM_MASK"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value" :options="referentials.tvaModes.value"
:label="t('commercial.suppliers.form.accounting.tvaMode')" :label="t('commercial.suppliers.form.accounting.tvaMode')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.tvaMode" :error="accountingErrors.errors.tvaMode"
@@ -241,16 +226,15 @@
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')" :label="t('commercial.suppliers.form.accounting.nTva')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.nTva" :error="accountingErrors.errors.nTva"
:mask="CODE_ALNUM_MASK"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value" :options="referentials.paymentDelays.value"
:label="t('commercial.suppliers.form.accounting.paymentDelay')" :label="t('commercial.suppliers.form.accounting.paymentDelay')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentDelay" :error="accountingErrors.errors.paymentDelay"
@@ -260,7 +244,7 @@
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value" :options="referentials.paymentTypes.value"
:label="t('commercial.suppliers.form.accounting.paymentType')" :label="t('commercial.suppliers.form.accounting.paymentType')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentType" :error="accountingErrors.errors.paymentType"
@@ -271,7 +255,7 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="referentials.banks.value" :options="referentials.banks.value"
:label="t('commercial.suppliers.form.accounting.bank')" :label="t('commercial.suppliers.form.accounting.bank')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.bank" :error="accountingErrors.errors.bank"
@@ -298,25 +282,23 @@
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')" :label="t('commercial.suppliers.form.accounting.ribLabel')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.label" :error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')" :label="t('commercial.suppliers.form.accounting.ribBic')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.bic" :error="ribErrors[index]?.bic"
:mask="CODE_ALNUM_MASK"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')" :label="t('commercial.suppliers.form.accounting.ribIban')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.iban" :error="ribErrors[index]?.iban"
:mask="CODE_ALNUM_MASK"
/> />
</div> </div>
</div> </div>
@@ -393,8 +375,6 @@ import {
buildMainPayload, buildMainPayload,
buildRibPayload, buildRibPayload,
} from '~/modules/commercial/utils/forms/supplierEdit' } from '~/modules/commercial/utils/forms/supplierEdit'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import { import {
emptyAddress, emptyAddress,
emptyContact, emptyContact,
@@ -405,7 +385,6 @@ import {
} from '~/modules/commercial/types/supplierForm' } from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow' import { isRowRemovable } from '~/shared/utils/collectionRow'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -585,22 +564,6 @@ const information = reactive({
volumeForecast: null as string | null, volumeForecast: null as string | null,
}) })
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
const maxFoundedAt = todayIso()
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
// champ controle quand le plafonnement laisse le modelValue inchange.
const revenueAmountKey = ref(0)
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
function onRevenueAmountInput(value: string | null): void {
const clamped = clampRevenueAmount(value)
information.revenueAmount = clamped ?? null
if (clamped !== value) {
revenueAmountKey.value += 1
}
}
/** PATCH /suppliers/{id} — mode strict : uniquement les champs du groupe information. */ /** PATCH /suppliers/{id} — mode strict : uniquement les champs du groupe information. */
async function submitInformation(): Promise<void> { async function submitInformation(): Promise<void> {
if (supplierId.value === null || tabSubmitting.value) return if (supplierId.value === null || tabSubmitting.value) return
@@ -1,33 +0,0 @@
import { describe, expect, it } from 'vitest'
import { clampRevenueAmount, REVENUE_AMOUNT_MAX } from '../amountInput'
describe('clampRevenueAmount', () => {
it('laisse les valeurs vides / nulles telles quelles', () => {
expect(clampRevenueAmount(null)).toBeNull()
expect(clampRevenueAmount(undefined)).toBeUndefined()
expect(clampRevenueAmount('')).toBe('')
})
it('laisse une valeur sous le plafond inchangee', () => {
expect(clampRevenueAmount('1000.50')).toBe('1000.50')
expect(clampRevenueAmount('999999999999.99')).toBe('999999999999.99')
})
it('plafonne une valeur au-dessus du maximum', () => {
expect(clampRevenueAmount('1000000000000')).toBe('999999999999.99')
expect(clampRevenueAmount('999999999999999.99')).toBe('999999999999.99')
})
it('tolere une saisie a virgule / avec espaces (securite)', () => {
expect(clampRevenueAmount('1 000 000 000 000,00')).toBe('999999999999.99')
expect(clampRevenueAmount('12,5')).toBe('12,5')
})
it('ne touche pas une saisie non numerique', () => {
expect(clampRevenueAmount('abc')).toBe('abc')
})
it('expose le plafond metier', () => {
expect(REVENUE_AMOUNT_MAX).toBe(999_999_999_999.99)
})
})
@@ -2,10 +2,7 @@ import { describe, expect, it } from 'vitest'
import { import {
canEditClient, canEditClient,
categoryOptionsOf, categoryOptionsOf,
clientConsultationVisibleTabs,
contactOptionsOf, contactOptionsOf,
hasAccountingData,
hasInformationData,
iriOf, iriOf,
mapAccountingDraft, mapAccountingDraft,
mapAddressToDraft, mapAddressToDraft,
@@ -251,73 +248,3 @@ describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', (
expect(paymentTypeCodeOf(undefined)).toBeNull() expect(paymentTypeCodeOf(undefined)).toBeNull()
}) })
}) })
describe('hasInformationData', () => {
it('faux si tous les champs Information sont vides/absents', () => {
expect(hasInformationData({ '@id': '/api/clients/1', id: 1 })).toBe(false)
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, description: ' ' })).toBe(false)
})
it('vrai des qu\'un champ Information porte une donnee', () => {
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, directorName: 'Dupont' })).toBe(true)
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, employeesCount: 0 })).toBe(true)
})
})
describe('hasAccountingData', () => {
it('faux sans champ comptable ni RIB', () => {
expect(hasAccountingData({ '@id': '/api/clients/1', id: 1 })).toBe(false)
})
it('vrai avec un champ comptable scalaire', () => {
expect(hasAccountingData({ '@id': '/api/clients/1', id: 1, siren: '123456789' })).toBe(true)
})
it('vrai avec une relation comptable embarquee (paymentType)', () => {
expect(hasAccountingData({
'@id': '/api/clients/1', id: 1,
paymentType: { '@id': '/api/payment_types/1', code: 'LCR' },
})).toBe(true)
})
it('vrai avec au moins un RIB', () => {
expect(hasAccountingData({
'@id': '/api/clients/1', id: 1,
ribs: [{ '@id': '/api/ribs/1', id: 1, iban: 'FR76...' }],
})).toBe(true)
})
})
describe('clientConsultationVisibleTabs', () => {
it('retourne [] tant que le client n\'est pas charge', () => {
expect(clientConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
expect(clientConsultationVisibleTabs(undefined, { canAccountingView: true })).toEqual([])
})
it('masque les coquilles et les onglets vides (client minimal)', () => {
const client: ClientDetail = { '@id': '/api/clients/1', id: 1, companyName: 'ACME' }
expect(clientConsultationVisibleTabs(client, { canAccountingView: true })).toEqual([])
})
it('affiche les onglets non vides dans l\'ordre information/contact/address/accounting', () => {
const client: ClientDetail = {
'@id': '/api/clients/1', id: 1,
directorName: 'Dupont',
contacts: [{ '@id': '/api/client_contacts/1', id: 1 }],
addresses: [{ '@id': '/api/client_addresses/1', id: 1 }],
siren: '123456789',
}
expect(clientConsultationVisibleTabs(client, { canAccountingView: true }))
.toEqual(['information', 'contact', 'address', 'accounting'])
})
it('masque Comptabilite sans le droit accounting.view meme si des donnees existent', () => {
const client: ClientDetail = {
'@id': '/api/clients/1', id: 1,
contacts: [{ '@id': '/api/client_contacts/1', id: 1 }],
siren: '123456789',
}
expect(clientConsultationVisibleTabs(client, { canAccountingView: false }))
.toEqual(['contact'])
})
})
@@ -3,8 +3,6 @@ import {
canEditSupplier, canEditSupplier,
categoryOptionsOf, categoryOptionsOf,
contactOptionsOf, contactOptionsOf,
hasAccountingData,
hasInformationData,
iriOf, iriOf,
mapAccountingDraft, mapAccountingDraft,
mapAddressToDraft, mapAddressToDraft,
@@ -16,7 +14,6 @@ import {
showArchiveAction, showArchiveAction,
showRestoreAction, showRestoreAction,
siteOptionsOf, siteOptionsOf,
supplierConsultationVisibleTabs,
type SupplierDetail, type SupplierDetail,
} from '../supplierConsultation' } from '../supplierConsultation'
@@ -240,60 +237,3 @@ describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', (
expect(paymentTypeCodeOf(undefined)).toBeNull() expect(paymentTypeCodeOf(undefined)).toBeNull()
}) })
}) })
describe('hasInformationData (fournisseur)', () => {
it('faux si tous les champs Information (volume previsionnel inclus) sont vides', () => {
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1 })).toBe(false)
})
it('vrai des qu\'un champ Information porte une donnee', () => {
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1, directorName: 'Martin' })).toBe(true)
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1, volumeForecast: 1200 })).toBe(true)
})
})
describe('hasAccountingData (fournisseur)', () => {
it('faux sans champ comptable ni RIB', () => {
expect(hasAccountingData({ '@id': '/api/suppliers/1', id: 1 })).toBe(false)
})
it('vrai avec un champ comptable ou un RIB', () => {
expect(hasAccountingData({ '@id': '/api/suppliers/1', id: 1, siren: '123456789' })).toBe(true)
expect(hasAccountingData({
'@id': '/api/suppliers/1', id: 1,
ribs: [{ '@id': '/api/supplier_ribs/1', id: 1, iban: 'FR76...' }],
})).toBe(true)
})
})
describe('supplierConsultationVisibleTabs', () => {
it('retourne [] tant que le fournisseur n\'est pas charge', () => {
expect(supplierConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
})
it('masque les coquilles et les onglets vides (fournisseur minimal)', () => {
expect(supplierConsultationVisibleTabs(
{ '@id': '/api/suppliers/1', id: 1, companyName: 'ACME' },
{ canAccountingView: true },
)).toEqual([])
})
it('affiche information/contacts/addresses/accounting (cles plurielles) dans l\'ordre', () => {
const supplier: SupplierDetail = {
'@id': '/api/suppliers/1', id: 1,
volumeForecast: 1000,
contacts: [{ '@id': '/api/supplier_contacts/1', id: 1 }],
addresses: [{ '@id': '/api/supplier_addresses/1', id: 1 }],
siren: '123456789',
}
expect(supplierConsultationVisibleTabs(supplier, { canAccountingView: true }))
.toEqual(['information', 'contacts', 'addresses', 'accounting'])
})
it('masque Comptabilite sans le droit accounting.view', () => {
expect(supplierConsultationVisibleTabs(
{ '@id': '/api/suppliers/1', id: 1, siren: '123456789' },
{ canAccountingView: false },
)).toEqual([])
})
})
@@ -1,29 +0,0 @@
/**
* Helpers de saisie des montants des formulaires Client / Fournisseur (commercial).
* Purs / testables. Pendant FRONT de la contrainte back `LessThanOrEqual` posee sur
* `revenueAmount` (Client/Supplier) — retour metier ERP-193 : le chiffre d'affaires
* est plafonne a 999 999 999 999,99.
*/
/** Plafond metier du chiffre d'affaires (CA) : 999 999 999 999,99. */
export const REVENUE_AMOUNT_MAX = 999_999_999_999.99
/** Valeur « modele » (decimale a point, sans separateur) renvoyee quand on plafonne. */
const REVENUE_AMOUNT_MAX_MODEL = '999999999999.99'
/**
* Plafonne le CA au maximum metier. Recoit le modele emis par `MalioInputAmount`
* (chaine propre a decimale `.`, sans espaces) ; tolere malgre tout une virgule /
* des espaces par securite. Renvoie la valeur telle quelle si elle est vide, non
* numerique ou sous le plafond ; sinon la valeur plafonnee.
*/
export function clampRevenueAmount(value: string | null | undefined): string | null | undefined {
if (value === null || value === undefined || value === '') {
return value
}
const n = Number(String(value).replace(/\s/g, '').replace(',', '.'))
if (Number.isNaN(n)) {
return value
}
return n > REVENUE_AMOUNT_MAX ? REVENUE_AMOUNT_MAX_MODEL : value
}
@@ -317,77 +317,6 @@ export function mapAddressView(address: AddressRead): AddressView {
} }
} }
/**
* Vrai si une valeur scalaire porte une donnee affichable (non null/undefined,
* et non chaine vide apres trim). Sert aux predicats « onglet vide » ci-dessous.
*/
function hasValue(value: unknown): boolean {
if (value === null || value === undefined) {
return false
}
if (typeof value === 'string') {
return value.trim() !== ''
}
return true
}
/**
* Vrai si l'onglet Information porte au moins une donnee. ERP-193 : en
* consultation on masque les onglets vides ; Information n'echappe pas a la
* regle malgre son statut d'onglet d'atterrissage par defaut.
*/
export function hasInformationData(client: ClientDetail): boolean {
return [
client.description,
client.competitors,
client.foundedAt,
client.employeesCount,
client.revenueAmount,
client.profitAmount,
client.directorName,
].some(hasValue)
}
/**
* Vrai si l'onglet Comptabilite porte au moins un champ comptable OU un RIB.
* (Le gating permission `accounting.view` reste applique en amont par l'appelant.)
*/
export function hasAccountingData(client: ClientDetail): boolean {
const draft = mapAccountingDraft(client)
const hasField = Object.values(draft).some(hasValue)
const hasRib = (client.ribs ?? []).length > 0
return hasField || hasRib
}
/**
* Onglets visibles en consultation (ERP-193, retour metier) : on masque les
* coquilles non implementees (Transport / Statistiques / Rapports / Echanges)
* ET tout onglet de donnees vide. L'ordre reproduit `buildClientFormTabKeys`.
* Retourne `[]` tant que le client n'est pas charge.
*/
export function clientConsultationVisibleTabs(
client: ClientDetail | null | undefined,
options: { canAccountingView: boolean },
): string[] {
if (!client) {
return []
}
const visible: string[] = []
if (hasInformationData(client)) {
visible.push('information')
}
if ((client.contacts ?? []).length > 0) {
visible.push('contact')
}
if ((client.addresses ?? []).length > 0) {
visible.push('address')
}
if (options.canAccountingView && hasAccountingData(client)) {
visible.push('accounting')
}
return visible
}
/** /**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet * Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta * — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
@@ -292,78 +292,6 @@ export function mapAddressView(address: AddressRead): AddressView {
} }
} }
/**
* Vrai si une valeur scalaire porte une donnee affichable (non null/undefined,
* et non chaine vide apres trim). Sert aux predicats « onglet vide » ci-dessous.
*/
function hasValue(value: unknown): boolean {
if (value === null || value === undefined) {
return false
}
if (typeof value === 'string') {
return value.trim() !== ''
}
return true
}
/**
* Vrai si l'onglet Information porte au moins une donnee (volume previsionnel
* inclus, specifique fournisseur). ERP-193 : en consultation on masque les
* onglets vides, Information comprise.
*/
export function hasInformationData(supplier: SupplierDetail): boolean {
return [
supplier.description,
supplier.competitors,
supplier.foundedAt,
supplier.employeesCount,
supplier.revenueAmount,
supplier.profitAmount,
supplier.directorName,
supplier.volumeForecast,
].some(hasValue)
}
/**
* Vrai si l'onglet Comptabilite porte au moins un champ comptable OU un RIB.
* (Le gating permission `accounting.view` reste applique en amont par l'appelant.)
*/
export function hasAccountingData(supplier: SupplierDetail): boolean {
const draft = mapAccountingDraft(supplier)
const hasField = Object.values(draft).some(hasValue)
const hasRib = (supplier.ribs ?? []).length > 0
return hasField || hasRib
}
/**
* Onglets visibles en consultation (ERP-193, retour metier) : on masque les
* coquilles non implementees (Transport / Statistiques / Rapports / Echanges)
* ET tout onglet de donnees vide. L'ordre reproduit `buildSupplierFormTabKeys`.
* Retourne `[]` tant que le fournisseur n'est pas charge.
*/
export function supplierConsultationVisibleTabs(
supplier: SupplierDetail | null | undefined,
options: { canAccountingView: boolean },
): string[] {
if (!supplier) {
return []
}
const visible: string[] = []
if (hasInformationData(supplier)) {
visible.push('information')
}
if ((supplier.contacts ?? []).length > 0) {
visible.push('contacts')
}
if ((supplier.addresses ?? []).length > 0) {
visible.push('addresses')
}
if (options.canAccountingView && hasAccountingData(supplier)) {
visible.push('accounting')
}
return visible
}
/** /**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet * Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta * — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
@@ -2,7 +2,7 @@
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : modal de confirmation cote parent. --> <!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly && !disabled" v-if="removable && !readonly"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -12,91 +12,91 @@
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). --> <!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
<MalioSelectCheckbox <MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.siteIris)"
:model-value="model.siteIris" :model-value="model.siteIris"
:options="siteOptions" :options="siteOptions"
:label="t('technique.providers.form.address.sites')" :label="t('technique.providers.form.address.sites')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.sites" :error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/> />
<!-- Categories de type PRESTATAIRE (>= 1 obligatoire, RG-3.09). -->
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
:label="t('technique.providers.form.address.categories')"
:display-tag="true"
:readonly="readonly"
:required="true"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. --> <!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
<MalioSelectCheckbox <MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris" :model-value="model.contactIris"
:options="contactOptions" :options="contactOptions"
:label="t('technique.providers.form.address.contacts')" :label="t('technique.providers.form.address.contacts')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/> />
<MalioSelect <MalioSelect
v-if="!hideEmpty || isFilled(model.country)"
:model-value="model.country" :model-value="model.country"
:options="countryOptions" :options="countryOptions"
:label="t('technique.providers.form.address.country')" :label="t('technique.providers.form.address.country')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))" @update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/> />
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.postalCode)"
:model-value="model.postalCode" :model-value="model.postalCode"
:label="t('technique.providers.form.address.postalCode')" :label="t('technique.providers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK" :mask="POSTAL_CODE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.postalCode" :error="errors?.postalCode"
@update:model-value="onPostalCodeChange" @update:model-value="onPostalCodeChange"
/> />
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. --> <!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect <MalioSelect
v-if="!degraded && (!hideEmpty || isFilled(model.city))" v-if="!degraded"
:model-value="model.city" :model-value="model.city"
:options="cityOptions" :options="cityOptions"
:label="t('technique.providers.form.address.city')" :label="t('technique.providers.form.address.city')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
empty-option-label="" empty-option-label=""
:required="!readonly && !disabled" :required="true"
:error="errors?.city" :error="errors?.city"
@update:model-value="onCityChange" @update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/> />
<MalioInputText <MalioInputText
v-else-if="degraded && (!hideEmpty || isFilled(model.city))" v-else
:model-value="model.city" :model-value="model.city"
:label="t('technique.providers.form.address.city')" :label="t('technique.providers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.city" :error="errors?.city"
@update:model-value="(v: string) => update('city', v)" @update:model-value="(v: string) => update('city', v)"
/> />
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le <!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). --> texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2"> <div class="col-span-2">
<MalioInputAutocomplete <MalioInputAutocomplete
v-if="!readonly && !disabled" v-if="!readonly"
:model-value="model.street" :model-value="model.street"
:options="addressOptions" :options="addressOptions"
:loading="addressLoading" :loading="addressLoading"
:min-search-length="3" :min-search-length="3"
:label="t('technique.providers.form.address.street')" :label="t('technique.providers.form.address.street')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
:allow-create="true" :allow-create="true"
:no-results-text="t('technique.providers.form.address.streetNotFound')" :no-results-text="t('technique.providers.form.address.streetNotFound')"
@@ -109,20 +109,17 @@
:model-value="model.street" :model-value="model.street"
:label="t('technique.providers.form.address.street')" :label="t('technique.providers.form.address.street')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
@update:model-value="(v: string) => update('street', v)" @update:model-value="(v: string) => update('street', v)"
/> />
</div> </div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1"> <div class="col-span-1">
<MalioInputText <MalioInputText
:model-value="model.streetComplement" :model-value="model.streetComplement"
:label="t('technique.providers.form.address.streetComplement')" :label="t('technique.providers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement" :error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)" @update:model-value="(v: string) => update('streetComplement', v)"
/> />
@@ -134,8 +131,6 @@
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete' import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials' import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm' import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
import { isFilled } from '~/shared/utils/consultationDisplay'
// Masque code postal FR : 5 chiffres. // Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####' const POSTAL_CODE_MASK = '#####'
@@ -143,6 +138,8 @@ const POSTAL_CODE_MASK = '#####'
const props = defineProps<{ const props = defineProps<{
/** Brouillon de l'adresse (v-model). */ /** Brouillon de l'adresse (v-model). */
modelValue: ProviderAddressFormDraft modelValue: ProviderAddressFormDraft
/** Categories autorisees sur une adresse (type PRESTATAIRE). */
categoryOptions: RefOption[]
/** Sites Starseed disponibles. */ /** Sites Starseed disponibles. */
siteOptions: RefOption[] siteOptions: RefOption[]
/** Contacts deja saisis, rattachables a l'adresse. */ /** Contacts deja saisis, rattachables a l'adresse. */
@@ -151,10 +148,6 @@ const props = defineProps<{
countryOptions: RefOption[] countryOptions: RefOption[]
removable?: boolean removable?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Consultation : masque les champs non remplis (ERP-193). */
hideEmpty?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -200,37 +193,11 @@ const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = [] let lastAddressSuggestions: AddressSuggestion[] = []
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur
// les champs texte editables (complement, ville en mode degrade). La voie en
// autocomplete (BAN) et la ville en select ne sont pas masquees (le back valide
// via Assert\Regex).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof ProviderAddressFormDraft>(field: K, value: ProviderAddressFormDraft[K]): void { function update<K extends keyof ProviderAddressFormDraft>(field: K, value: ProviderAddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
} }
/**
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
* a chaque frappe).
*/
function onCityChange(value: string | number | null): void {
const next = value === null ? null : String(value)
if (next === (props.modelValue.city ?? null)) {
return
}
banAddressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
city: next,
street: null,
streetComplement: null,
})
}
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */ /** Previent le parent (toast unique) que l'autocompletion est indisponible. */
function notifyUnavailable(): void { function notifyUnavailable(): void {
if (!unavailableNotified) { if (!unavailableNotified) {
@@ -241,27 +208,9 @@ function notifyUnavailable(): void {
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville (RG-3.06). */ /** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville (RG-3.06). */
async function onPostalCodeChange(value: string): Promise<void> { async function onPostalCodeChange(value: string): Promise<void> {
update('postalCode', value)
const digits = (value ?? '').replace(/\D/g, '') const digits = (value ?? '').replace(/\D/g, '')
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
if (digits.length === 5 && digits !== previousDigits) {
banAddressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
postalCode: value,
city: null,
street: null,
streetComplement: null,
})
}
else {
update('postalCode', value)
}
if (digits.length < 5) { if (digits.length < 5) {
return return
} }
@@ -3,7 +3,7 @@
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si <!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc) ou en lecture seule. --> non supprimable (1er bloc) ou en lecture seule. -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly && !disabled" v-if="removable && !readonly"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -12,56 +12,44 @@
/> />
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName" :model-value="model.lastName"
:label="t('technique.providers.form.contact.lastName')" :label="t('technique.providers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.lastName" :error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)" @update:model-value="(v: string) => update('lastName', v)"
/> />
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName" :model-value="model.firstName"
:label="t('technique.providers.form.contact.firstName')" :label="t('technique.providers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.firstName" :error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)" @update:model-value="(v: string) => update('firstName', v)"
/> />
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText <!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la (inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. --> cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2"> <div class="col-span-2">
<MalioInputText <MalioInputText
:model-value="model.jobTitle" :model-value="model.jobTitle"
:label="t('technique.providers.form.contact.jobTitle')" :label="t('technique.providers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle" :error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)" @update:model-value="(v: string) => update('jobTitle', v)"
/> />
</div> </div>
<MalioInputEmail <MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email" :model-value="model.email"
:label="t('technique.providers.form.contact.email')" :label="t('technique.providers.form.contact.email')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:lowercase="true" :lowercase="true"
:error="errors?.email" :error="errors?.email"
@update:model-value="(v: string) => update('email', v)" @update:model-value="(v: string) => update('email', v)"
/> />
<MalioInputPhone <MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary" :model-value="model.phonePrimary"
:label="t('technique.providers.form.contact.phonePrimary')" :label="t('technique.providers.form.contact.phonePrimary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary" :error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly" :addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('technique.providers.form.contact.addPhone')" :add-button-label="t('technique.providers.form.contact.addPhone')"
@@ -70,12 +58,11 @@
/> />
<!-- 2e numero : revele a la demande (max 2 telephones par contact). --> <!-- 2e numero : revele a la demande (max 2 telephones par contact). -->
<MalioInputPhone <MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))" v-if="model.hasSecondaryPhone"
:model-value="model.phoneSecondary" :model-value="model.phoneSecondary"
:label="t('technique.providers.form.contact.phoneSecondary')" :label="t('technique.providers.form.contact.phoneSecondary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary" :error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)" @update:model-value="(v: string) => update('phoneSecondary', v)"
/> />
@@ -84,8 +71,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm' import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
import { isFilled } from '~/shared/utils/consultationDisplay'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur). // Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##' const PHONE_MASK = '## ## ## ## ##'
@@ -97,10 +82,6 @@ const props = defineProps<{
removable?: boolean removable?: boolean
/** Bloc en lecture seule (onglet valide). */ /** Bloc en lecture seule (onglet valide). */
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Consultation : masque les champs non remplis (ERP-193). */
hideEmpty?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -115,10 +96,6 @@ const { t } = useI18n()
// Alias local pour la lisibilite du template. // Alias local pour la lisibilite du template.
const model = computed(() => props.modelValue) const model = computed(() => props.modelValue)
// Filtrage des caracteres parasites : porte par les masks maska sur les champs
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof ProviderContactFormDraft>(field: K, value: ProviderContactFormDraft[K]): void { function update<K extends keyof ProviderContactFormDraft>(field: K, value: ProviderContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
@@ -46,6 +46,7 @@ function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<str
return mount(ProviderAddressBlock, { return mount(ProviderAddressBlock, {
props: { props: {
modelValue: { ...emptyProviderAddress(), ...overrides }, modelValue: { ...emptyProviderAddress(), ...overrides },
categoryOptions: [],
siteOptions: [], siteOptions: [],
contactOptions: [], contactOptions: [],
countryOptions: [], countryOptions: [],
@@ -78,14 +79,17 @@ describe('ProviderAddressBlock — version simplifiee M3 (pas de type/bennes/tri
}) })
describe('ProviderAddressBlock — mapping erreur par champ (ERP-101)', () => { describe('ProviderAddressBlock — mapping erreur par champ (ERP-101)', () => {
it('affiche l\'erreur serveur sur sites (RG-3.05)', () => { it('affiche les erreurs serveur sur sites et categories (RG-3.05 / RG-3.09)', () => {
const wrapper = mountBlock({}, { const wrapper = mountBlock({}, {
sites: 'Au moins un site est obligatoire.', sites: 'Au moins un site est obligatoire.',
categories: 'Au moins une catégorie est obligatoire.',
}) })
const checkboxes = wrapper.findAll('malio-select-checkbox-stub') const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
const sitesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.sites') const sitesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.sites')
const categoriesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.categories')
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.') expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
}) })
it('affiche l\'erreur serveur sur le code postal', () => { it('affiche l\'erreur serveur sur le code postal', () => {
@@ -330,16 +330,17 @@ describe('useProviderForm — onglet Adresse (ERP-143)', () => {
return form return form
} }
/** Remplit un bloc adresse valide (site + scalaires requis). */ /** Remplit un bloc adresse valide (site + categorie + scalaires requis). */
function fillValidAddress(form: ProviderForm, index = 0): void { function fillValidAddress(form: ProviderForm, index = 0): void {
const a = addressAt(form, index) const a = addressAt(form, index)
a.siteIris = [SITE_86] a.siteIris = [SITE_86]
a.categoryIris = [CAT_MAINT]
a.postalCode = '86100' a.postalCode = '86100'
a.city = 'Châtellerault' a.city = 'Châtellerault'
a.street = '1 rue du Test' a.street = '1 rue du Test'
} }
it('RG-3.05 : « + Nouvelle adresse » desactive tant que le site manque', () => { it('RG-3.05 : « + Nouvelle adresse » desactive tant que site + categorie manquent', () => {
const form = createdForm() const form = createdForm()
expect(form.canAddAddress.value).toBe(false) expect(form.canAddAddress.value).toBe(false)
@@ -348,6 +349,8 @@ describe('useProviderForm — onglet Adresse (ERP-143)', () => {
expect(form.addresses.value).toHaveLength(1) expect(form.addresses.value).toHaveLength(1)
addressAt(form).siteIris = [SITE_86] addressAt(form).siteIris = [SITE_86]
expect(form.canAddAddress.value).toBe(false) // categorie manquante
addressAt(form).categoryIris = [CAT_MAINT]
expect(form.canAddAddress.value).toBe(true) expect(form.canAddAddress.value).toBe(true)
form.addAddress() form.addAddress()
expect(form.addresses.value).toHaveLength(2) expect(form.addresses.value).toHaveLength(2)
@@ -374,7 +377,7 @@ describe('useProviderForm — onglet Adresse (ERP-143)', () => {
expect(ok).toBe(true) expect(ok).toBe(true)
const [url, body, opts] = mockPost.mock.calls[0] ?? [] const [url, body, opts] = mockPost.mock.calls[0] ?? []
expect(url).toBe('/providers/7/addresses') expect(url).toBe('/providers/7/addresses')
expect(body).toMatchObject({ sites: [SITE_86], city: 'Châtellerault' }) expect(body).toMatchObject({ sites: [SITE_86], categories: [CAT_MAINT], city: 'Châtellerault' })
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
expect(addressAt(form).id).toBe(88) expect(addressAt(form).id).toBe(88)
expect(form.isValidated('address')).toBe(true) expect(form.isValidated('address')).toBe(true)
@@ -44,7 +44,7 @@ describe('useProvidersRepository', () => {
expect(mockApiGet).toHaveBeenCalledTimes(1) expect(mockApiGet).toHaveBeenCalledTimes(1)
const [url, query, opts] = mockApiGet.mock.calls[0] const [url, query, opts] = mockApiGet.mock.calls[0]
expect(url).toBe('/providers') expect(url).toBe('/providers')
expect(query).toMatchObject({ page: 1, itemsPerPage: 25 }) expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
expect(opts).toMatchObject({ expect(opts).toMatchObject({
toast: false, toast: false,
headers: { Accept: 'application/ld+json' }, headers: { Accept: 'application/ld+json' },
@@ -84,11 +84,6 @@ export function useProviderForm() {
}) })
} }
/** Toast de succès après suppression serveur confirmée d'une sous-ressource. */
function notifyRemovalSuccess(): void {
toast.success({ title: t('success.title'), message: t('success.deleted') })
}
// ── Etat du prestataire cree ──────────────────────────────────────────── // ── Etat du prestataire cree ────────────────────────────────────────────
const providerId = ref<number | null>(null) const providerId = ref<number | null>(null)
const mainLocked = ref(false) const mainLocked = ref(false)
@@ -344,7 +339,6 @@ export function useProviderForm() {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderContact, makeEmpty: emptyProviderContact,
onError: notifyRemovalError, onError: notifyRemovalError,
onSuccess: notifyRemovalSuccess,
}) })
} }
@@ -423,7 +417,6 @@ export function useProviderForm() {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderAddress, makeEmpty: emptyProviderAddress,
onError: notifyRemovalError, onError: notifyRemovalError,
onSuccess: notifyRemovalSuccess,
}) })
} }
@@ -525,7 +518,6 @@ export function useProviderForm() {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderRib, makeEmpty: emptyProviderRib,
onError: notifyRemovalError, onError: notifyRemovalError,
onSuccess: notifyRemovalSuccess,
}) })
} }
@@ -59,6 +59,5 @@ export interface Provider {
* `usePaginatedList`. Aucun reset au logout a gerer. * `usePaginatedList`. Aucun reset au logout a gerer.
*/ */
export function useProvidersRepository() { export function useProvidersRepository() {
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193). return usePaginatedList<Provider>({ url: '/providers' })
return usePaginatedList<Provider>({ url: '/providers', defaultItemsPerPage: 25 })
} }
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('technique.providers.edit.back')"
v-bind="{ ariaLabel: t('technique.providers.edit.back') }" v-bind="{ ariaLabel: t('technique.providers.edit.back') }"
@click="goBack" @click="goBack"
/> />
@@ -23,9 +22,8 @@
<MalioInputText <MalioInputText
v-model="main.companyName" v-model="main.companyName"
:label="t('technique.providers.form.main.companyName')" :label="t('technique.providers.form.main.companyName')"
:mask="FREE_TEXT_MASK"
:required="true" :required="true"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="mainErrors.errors.companyName" :error="mainErrors.errors.companyName"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
@@ -33,7 +31,7 @@
:options="referentials.categories.value" :options="referentials.categories.value"
:label="t('technique.providers.form.main.categories')" :label="t('technique.providers.form.main.categories')"
:display-tag="true" :display-tag="true"
:disabled="businessReadonly" :readonly="businessReadonly"
:required="true" :required="true"
:error="mainErrors.errors.categories" :error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -43,7 +41,7 @@
:options="referentials.sites.value" :options="referentials.sites.value"
:label="t('technique.providers.form.main.sites')" :label="t('technique.providers.form.main.sites')"
:display-tag="true" :display-tag="true"
:disabled="businessReadonly" :readonly="businessReadonly"
:required="true" :required="true"
:error="mainErrors.errors.sites" :error="mainErrors.errors.sites"
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
@@ -73,7 +71,7 @@
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:disabled="businessReadonly" :readonly="businessReadonly"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
@@ -104,11 +102,12 @@
v-for="(address, index) in addresses" v-for="(address, index) in addresses"
:key="index" :key="index"
:model-value="address" :model-value="address"
:category-options="referentials.categories.value"
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="isRowRemovable(addresses, index)" :removable="isRowRemovable(addresses, index)"
:disabled="businessReadonly" :readonly="businessReadonly"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@@ -142,15 +141,14 @@
v-model="accounting.siren" v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')" :label="t('technique.providers.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.siren" :error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:label="t('technique.providers.form.accounting.accountNumber')" :label="t('technique.providers.form.accounting.accountNumber')"
:mask="CODE_ALNUM_MASK" :readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.accountNumber" :error="accountingErrors.errors.accountNumber"
/> />
@@ -158,7 +156,7 @@
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value" :options="referentials.tvaModes.value"
:label="t('technique.providers.form.accounting.tvaMode')" :label="t('technique.providers.form.accounting.tvaMode')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.tvaMode" :error="accountingErrors.errors.tvaMode"
@@ -167,8 +165,7 @@
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:label="t('technique.providers.form.accounting.nTva')" :label="t('technique.providers.form.accounting.nTva')"
:mask="CODE_ALNUM_MASK" :readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.nTva" :error="accountingErrors.errors.nTva"
/> />
@@ -176,7 +173,7 @@
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value" :options="referentials.paymentDelays.value"
:label="t('technique.providers.form.accounting.paymentDelay')" :label="t('technique.providers.form.accounting.paymentDelay')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentDelay" :error="accountingErrors.errors.paymentDelay"
@@ -186,7 +183,7 @@
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value" :options="referentials.paymentTypes.value"
:label="t('technique.providers.form.accounting.paymentType')" :label="t('technique.providers.form.accounting.paymentType')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentType" :error="accountingErrors.errors.paymentType"
@@ -197,7 +194,7 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="referentials.banks.value" :options="referentials.banks.value"
:label="t('technique.providers.form.accounting.bank')" :label="t('technique.providers.form.accounting.bank')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.bank" :error="accountingErrors.errors.bank"
@@ -224,23 +221,21 @@
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')" :label="t('technique.providers.form.accounting.ribLabel')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="ribErrors[index]?.label" :error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:label="t('technique.providers.form.accounting.ribBic')" :label="t('technique.providers.form.accounting.ribBic')"
:mask="CODE_ALNUM_MASK" :readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="ribErrors[index]?.bic" :error="ribErrors[index]?.bic"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:label="t('technique.providers.form.accounting.ribIban')" :label="t('technique.providers.form.accounting.ribIban')"
:mask="CODE_ALNUM_MASK" :readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="ribErrors[index]?.iban" :error="ribErrors[index]?.iban"
/> />
@@ -318,7 +313,6 @@ import {
} from '~/modules/technique/types/providerForm' } from '~/modules/technique/types/providerForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow' import { isRowRemovable } from '~/shared/utils/collectionRow'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur). // Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('technique.providers.consultation.back')"
v-bind="{ ariaLabel: t('technique.providers.consultation.back') }" v-bind="{ ariaLabel: t('technique.providers.consultation.back') }"
@click="goBack" @click="goBack"
/> />
@@ -23,7 +22,7 @@
/> />
<MalioButton <MalioButton
v-if="showArchive" v-if="showArchive"
variant="danger" variant="secondary"
icon-name="mdi:archive-arrow-down-outline" icon-name="mdi:archive-arrow-down-outline"
icon-position="left" icon-position="left"
:label="t('technique.providers.action.archive')" :label="t('technique.providers.action.archive')"
@@ -48,32 +47,28 @@
<!-- Bloc principal (lecture seule) --> <!-- Bloc principal (lecture seule) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4"> <div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="isFilled(provider.companyName)"
:model-value="provider.companyName" :model-value="provider.companyName"
:label="t('technique.providers.form.main.companyName')" :label="t('technique.providers.form.main.companyName')"
disabled readonly
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
v-if="isFilled(mainCategoryIris)"
:model-value="mainCategoryIris" :model-value="mainCategoryIris"
:options="mainCategoryOptions" :options="mainCategoryOptions"
:label="t('technique.providers.form.main.categories')" :label="t('technique.providers.form.main.categories')"
:display-tag="true" :display-tag="true"
disabled readonly
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
v-if="isFilled(mainSiteIris)"
:model-value="mainSiteIris" :model-value="mainSiteIris"
:options="mainSiteOptions" :options="mainSiteOptions"
:label="t('technique.providers.form.main.sites')" :label="t('technique.providers.form.main.sites')"
:display-tag="true" :display-tag="true"
disabled readonly
/> />
</div> </div>
<!-- Onglets (navigation libre, tout en lecture seule) --> <!-- Onglets (navigation libre, tout en lecture seule) -->
<!-- ERP-193 : barre masquee s'il ne reste aucun onglet non vide. --> <MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Contacts --> <!-- Onglet Contacts -->
<template #contacts> <template #contacts>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
@@ -81,8 +76,7 @@
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
disabled readonly
hide-empty
/> />
</div> </div>
</template> </template>
@@ -94,29 +88,31 @@
v-for="(view, index) in addressViews" v-for="(view, index) in addressViews"
:key="index" :key="index"
:model-value="view.draft" :model-value="view.draft"
:category-options="view.categoryOptions"
:site-options="view.siteOptions" :site-options="view.siteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptionsFor(view.draft.country)" :country-options="countryOptionsFor(view.draft.country)"
disabled readonly
hide-empty
/> />
</div> </div>
</template> </template>
<!-- ERP-193 : les onglets « a venir » (Rapports / Echanges) ne sont
plus rendus en consultation (masquage des onglets vides). --> <!-- Onglets placeholder « A venir » (comme les autres modules). -->
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
<!-- Onglet Comptabilite (present uniquement si accounting.view). --> <!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText v-if="isFilled(accounting.siren)" :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" disabled /> <MalioInputText :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" readonly />
<MalioInputText v-if="isFilled(accounting.accountNumber)" :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" disabled /> <MalioInputText :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" readonly />
<MalioSelect v-if="isFilled(accounting.tvaModeIri)" :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" disabled empty-option-label="" /> <MalioSelect :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" readonly empty-option-label="" />
<MalioInputText v-if="isFilled(accounting.nTva)" :model-value="accounting.nTva" :label="t('technique.providers.form.accounting.nTva')" disabled /> <MalioInputText :model-value="accounting.nTva" :label="t('technique.providers.form.accounting.nTva')" readonly />
<MalioSelect v-if="isFilled(accounting.paymentDelayIri)" :model-value="accounting.paymentDelayIri" :options="paymentDelayOptions" :label="t('technique.providers.form.accounting.paymentDelay')" disabled empty-option-label="" /> <MalioSelect :model-value="accounting.paymentDelayIri" :options="paymentDelayOptions" :label="t('technique.providers.form.accounting.paymentDelay')" readonly empty-option-label="" />
<MalioSelect v-if="isFilled(accounting.paymentTypeIri)" :model-value="accounting.paymentTypeIri" :options="paymentTypeOptions" :label="t('technique.providers.form.accounting.paymentType')" disabled empty-option-label="" /> <MalioSelect :model-value="accounting.paymentTypeIri" :options="paymentTypeOptions" :label="t('technique.providers.form.accounting.paymentType')" readonly empty-option-label="" />
<MalioSelect v-if="isBankRequired && isFilled(accounting.bankIri)" :model-value="accounting.bankIri" :options="bankOptions" :label="t('technique.providers.form.accounting.bank')" disabled empty-option-label="" /> <MalioSelect v-if="isBankRequired" :model-value="accounting.bankIri" :options="bankOptions" :label="t('technique.providers.form.accounting.bank')" readonly empty-option-label="" />
</div> </div>
</div> </div>
@@ -127,9 +123,9 @@
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText v-if="isFilled(rib.label)" :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" disabled /> <MalioInputText :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" readonly />
<MalioInputText v-if="isFilled(rib.bic)" :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" disabled /> <MalioInputText :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" readonly />
<MalioInputText v-if="isFilled(rib.iban)" :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" disabled /> <MalioInputText :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" readonly />
</div> </div>
</div> </div>
</div> </div>
@@ -162,7 +158,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useProvider } from '~/modules/technique/composables/useProvider' import { useProvider } from '~/modules/technique/composables/useProvider'
import { import {
canEditProvider, canEditProvider,
@@ -174,7 +170,6 @@ import {
mapContactToDraft, mapContactToDraft,
mapRibToDraft, mapRibToDraft,
paymentTypeCodeOf, paymentTypeCodeOf,
providerConsultationVisibleTabs,
referentialOptionOf, referentialOptionOf,
showArchiveAction, showArchiveAction,
showRestoreAction, showRestoreAction,
@@ -182,7 +177,6 @@ import {
} from '~/modules/technique/utils/forms/providerDetail' } from '~/modules/technique/utils/forms/providerDetail'
import { isBankRequiredForPaymentType, isRibRequiredForPaymentType } from '~/modules/technique/utils/forms/providerAccounting' import { isBankRequiredForPaymentType, isRibRequiredForPaymentType } from '~/modules/technique/utils/forms/providerAccounting'
import { emptyProviderAddress, emptyProviderContact } from '~/modules/technique/types/providerForm' import { emptyProviderAddress, emptyProviderContact } from '~/modules/technique/types/providerForm'
import { isFilled } from '~/shared/utils/consultationDisplay'
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
@@ -203,6 +197,7 @@ const headerTitle = computed(() => provider.value?.companyName || t('technique.p
useHead({ title: t('technique.providers.consultation.title') }) useHead({ title: t('technique.providers.consultation.title') })
// ── Onglets (ordre spec : Contacts · Adresse · Rapports · Échanges · Comptabilité) ── // ── Onglets (ordre spec : Contacts · Adresse · Rapports · Échanges · Comptabilité) ──
const activeTab = ref('contacts')
const TAB_ICONS: Record<string, string> = { const TAB_ICONS: Record<string, string> = {
contacts: 'mdi:account-box-plus-outline', contacts: 'mdi:account-box-plus-outline',
address: 'mdi:map-marker-outline', address: 'mdi:map-marker-outline',
@@ -210,27 +205,11 @@ const TAB_ICONS: Record<string, string> = {
exchanges: 'mdi:swap-horizontal', exchanges: 'mdi:swap-horizontal',
accounting: 'mdi:bank-circle-outline', accounting: 'mdi:bank-circle-outline',
} }
// ERP-193 (retour metier) : on masque les coquilles (Rapports / Echanges) ET const tabs = computed(() => {
// tout onglet de donnees vide. La liste depend donc du payload charge. const keys = ['contacts', 'address', 'reports', 'exchanges']
const visibleTabKeys = computed(() => providerConsultationVisibleTabs(provider.value, { if (canAccountingView.value) keys.push('accounting')
canAccountingView: canAccountingView.value, return keys.map(key => ({ key, label: t(`technique.providers.tab.${key}`), icon: TAB_ICONS[key] }))
})) })
const tabs = computed(() => visibleTabKeys.value.map(
key => ({ key, label: t(`technique.providers.tab.${key}`), icon: TAB_ICONS[key] }),
))
// Onglet initial : vide tant que le prestataire n'est pas charge, puis premier
// onglet visible. Un watcher recale si l'onglet courant disparait.
const activeTab = ref('')
watch(visibleTabKeys, (keys) => {
if (keys.length === 0) {
activeTab.value = ''
return
}
if (!keys.includes(activeTab.value)) {
activeTab.value = keys[0]
}
}, { immediate: true })
// ── Donnees mappees depuis la SEULE reponse detail ───────────────────────────── // ── Donnees mappees depuis la SEULE reponse detail ─────────────────────────────
const mainCategoryIris = computed(() => irisOf(provider.value?.categories)) const mainCategoryIris = computed(() => irisOf(provider.value?.categories))
@@ -247,15 +226,16 @@ const contacts = computed(() => {
// Contacts rattachables (pour resoudre les libelles des contacts lies aux adresses). // Contacts rattachables (pour resoudre les libelles des contacts lies aux adresses).
const contactOptions = computed(() => contactOptionsOf(provider.value?.contacts)) const contactOptions = computed(() => contactOptionsOf(provider.value?.contacts))
// Vue par adresse : brouillon + options propres a l'adresse (sites embarques). // Vue par adresse : brouillon + options propres a l'adresse (sites/categories embarques).
const addressViews = computed(() => { const addressViews = computed(() => {
const views = (provider.value?.addresses ?? []).map(address => ({ const views = (provider.value?.addresses ?? []).map(address => ({
draft: mapAddressToDraft(address), draft: mapAddressToDraft(address),
siteOptions: siteOptionsOf(address.sites), siteOptions: siteOptionsOf(address.sites),
categoryOptions: categoryOptionsOf(address.categories),
})) }))
return views.length > 0 return views.length > 0
? views ? views
: [{ draft: emptyProviderAddress(), siteOptions: [] }] : [{ draft: emptyProviderAddress(), siteOptions: [], categoryOptions: [] }]
}) })
/** Pays : une seule option (la valeur courante), suffisant pour l'affichage readonly. */ /** Pays : une seule option (la valeur courante), suffisant pour l'affichage readonly. */
@@ -44,7 +44,7 @@
@update:page="goToPage" @update:page="goToPage"
@update:per-page="setItemsPerPage" @update:per-page="setItemsPerPage"
> >
<!-- Categories : libelles (name) separes par une virgule, aligne sur le client (ERP-193). --> <!-- Categories : libelles (name) separes par une virgule. -->
<template #cell-categories="{ item }"> <template #cell-categories="{ item }">
{{ formatCategories(item) }} {{ formatCategories(item) }}
</template> </template>
@@ -210,7 +210,7 @@ const columns = [
{ key: 'lastActivity', label: t('technique.providers.column.lastActivity') }, { key: 'lastActivity', label: t('technique.providers.column.lastActivity') },
] ]
/** Libelles (name) des categories du prestataire, separes par une virgule (aligne sur le client, ERP-193). */ /** Libelles des categories du prestataire, separes par une virgule (name). */
function formatCategories(item: Record<string, unknown>): string { function formatCategories(item: Record<string, unknown>): string {
const categories = (item.categories as Provider['categories']) ?? [] const categories = (item.categories as Provider['categories']) ?? []
return categories.map(c => c.name).join(', ') return categories.map(c => c.name).join(', ')
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('technique.providers.form.back')"
v-bind="{ ariaLabel: t('technique.providers.form.back') }" v-bind="{ ariaLabel: t('technique.providers.form.back') }"
@click="goBack" @click="goBack"
/> />
@@ -22,9 +21,8 @@
<MalioInputText <MalioInputText
v-model="main.companyName" v-model="main.companyName"
:label="t('technique.providers.form.main.companyName')" :label="t('technique.providers.form.main.companyName')"
:mask="FREE_TEXT_MASK"
:required="true" :required="true"
:disabled="mainLocked" :readonly="mainLocked"
:error="mainErrors.errors.companyName" :error="mainErrors.errors.companyName"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
@@ -32,7 +30,7 @@
:options="referentials.categories.value" :options="referentials.categories.value"
:label="t('technique.providers.form.main.categories')" :label="t('technique.providers.form.main.categories')"
:display-tag="true" :display-tag="true"
:disabled="mainLocked" :readonly="mainLocked"
:required="true" :required="true"
:error="mainErrors.errors.categories" :error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -42,7 +40,7 @@
:options="referentials.sites.value" :options="referentials.sites.value"
:label="t('technique.providers.form.main.sites')" :label="t('technique.providers.form.main.sites')"
:display-tag="true" :display-tag="true"
:disabled="mainLocked" :readonly="mainLocked"
:required="true" :required="true"
:error="mainErrors.errors.sites" :error="mainErrors.errors.sites"
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
@@ -74,16 +72,12 @@
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:disabled="isValidated('contact')" :readonly="isValidated('contact')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
/> />
<!-- Masque tant que le prestataire n'est pas cree : Contact etant <div v-if="!isValidated('contact')" class="flex justify-center gap-6">
l'onglet actif par defaut, ses actions (Ajouter / Valider) ne
doivent pas apparaitre a cote du Valider du formulaire principal
(ERP-193). -->
<div v-if="!isValidated('contact') && providerId !== null" class="flex justify-center gap-6">
<MalioButton <MalioButton
variant="secondary" variant="secondary"
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
@@ -95,7 +89,7 @@
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('technique.providers.form.submit')" :label="t('technique.providers.form.submit')"
:disabled="tabSubmitting" :disabled="tabSubmitting || providerId === null"
@click="onSubmitContacts" @click="onSubmitContacts"
/> />
</div> </div>
@@ -108,11 +102,12 @@
v-for="(address, index) in addresses" v-for="(address, index) in addresses"
:key="index" :key="index"
:model-value="address" :model-value="address"
:category-options="referentials.categories.value"
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="isRowRemovable(addresses, index)" :removable="isRowRemovable(addresses, index)"
:disabled="isValidated('address')" :readonly="isValidated('address')"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@@ -145,15 +140,14 @@
v-model="accounting.siren" v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')" :label="t('technique.providers.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.siren" :error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:label="t('technique.providers.form.accounting.accountNumber')" :label="t('technique.providers.form.accounting.accountNumber')"
:mask="CODE_ALNUM_MASK" :readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.accountNumber" :error="accountingErrors.errors.accountNumber"
/> />
@@ -161,7 +155,7 @@
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value" :options="referentials.tvaModes.value"
:label="t('technique.providers.form.accounting.tvaMode')" :label="t('technique.providers.form.accounting.tvaMode')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.tvaMode" :error="accountingErrors.errors.tvaMode"
@@ -170,8 +164,7 @@
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:label="t('technique.providers.form.accounting.nTva')" :label="t('technique.providers.form.accounting.nTva')"
:mask="CODE_ALNUM_MASK" :readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.nTva" :error="accountingErrors.errors.nTva"
/> />
@@ -179,7 +172,7 @@
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value" :options="referentials.paymentDelays.value"
:label="t('technique.providers.form.accounting.paymentDelay')" :label="t('technique.providers.form.accounting.paymentDelay')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentDelay" :error="accountingErrors.errors.paymentDelay"
@@ -189,7 +182,7 @@
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value" :options="referentials.paymentTypes.value"
:label="t('technique.providers.form.accounting.paymentType')" :label="t('technique.providers.form.accounting.paymentType')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentType" :error="accountingErrors.errors.paymentType"
@@ -201,7 +194,7 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="referentials.banks.value" :options="referentials.banks.value"
:label="t('technique.providers.form.accounting.bank')" :label="t('technique.providers.form.accounting.bank')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.bank" :error="accountingErrors.errors.bank"
@@ -228,23 +221,21 @@
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')" :label="t('technique.providers.form.accounting.ribLabel')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="ribErrors[index]?.label" :error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:label="t('technique.providers.form.accounting.ribBic')" :label="t('technique.providers.form.accounting.ribBic')"
:mask="CODE_ALNUM_MASK" :readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="ribErrors[index]?.bic" :error="ribErrors[index]?.bic"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:label="t('technique.providers.form.accounting.ribIban')" :label="t('technique.providers.form.accounting.ribIban')"
:mask="CODE_ALNUM_MASK" :readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="ribErrors[index]?.iban" :error="ribErrors[index]?.iban"
/> />
@@ -306,7 +297,6 @@ import {
} from '~/modules/technique/utils/forms/providerAccounting' } from '~/modules/technique/utils/forms/providerAccounting'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow' import { isRowRemovable } from '~/shared/utils/collectionRow'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur). // Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -97,6 +97,8 @@ export interface ProviderAddressFormDraft {
city: string | null city: string | null
street: string | null street: string | null
streetComplement: string | null streetComplement: string | null
/** IRI des categories rattachees (type PRESTATAIRE, RG-3.09 ; >= 1). */
categoryIris: string[]
/** IRI des sites rattaches a l'adresse (M2M `provider_address_site`, RG-3.05 ; >= 1). */ /** IRI des sites rattaches a l'adresse (M2M `provider_address_site`, RG-3.05 ; >= 1). */
siteIris: string[] siteIris: string[]
/** IRI des contacts rattaches (= blocs Contact deja persistes de l'onglet Contact). */ /** IRI des contacts rattaches (= blocs Contact deja persistes de l'onglet Contact). */
@@ -112,6 +114,7 @@ export function emptyProviderAddress(): ProviderAddressFormDraft {
city: null, city: null,
street: null, street: null,
streetComplement: null, streetComplement: null,
categoryIris: [],
siteIris: [], siteIris: [],
contactIris: [], contactIris: [],
} }
@@ -12,15 +12,21 @@ import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
*/ */
describe('providerAddress helpers', () => { describe('providerAddress helpers', () => {
const SITE = '/api/sites/1' const SITE = '/api/sites/1'
const CAT = '/api/categories/7'
describe('isProviderAddressValid (RG-3.05)', () => { describe('isProviderAddressValid (RG-3.05 / RG-3.09)', () => {
it('false sans site', () => { it('false sans site', () => {
const address = { ...emptyProviderAddress() } const address = { ...emptyProviderAddress(), categoryIris: [CAT] }
expect(isProviderAddressValid(address)).toBe(false) expect(isProviderAddressValid(address)).toBe(false)
}) })
it('true avec au moins un site', () => { it('false sans categorie', () => {
const address = { ...emptyProviderAddress(), siteIris: [SITE] } const address = { ...emptyProviderAddress(), siteIris: [SITE] }
expect(isProviderAddressValid(address)).toBe(false)
})
it('true avec au moins un site ET une categorie', () => {
const address = { ...emptyProviderAddress(), siteIris: [SITE], categoryIris: [CAT] }
expect(isProviderAddressValid(address)).toBe(true) expect(isProviderAddressValid(address)).toBe(true)
}) })
}) })
@@ -33,6 +39,7 @@ describe('providerAddress helpers', () => {
city: 'Châtellerault', city: 'Châtellerault',
street: '1 rue du Test', street: '1 rue du Test',
siteIris: [SITE], siteIris: [SITE],
categoryIris: [CAT],
contactIris: ['/api/provider_contacts/9'], contactIris: ['/api/provider_contacts/9'],
}) })
expect(payload).toEqual({ expect(payload).toEqual({
@@ -41,6 +48,7 @@ describe('providerAddress helpers', () => {
city: 'Châtellerault', city: 'Châtellerault',
street: '1 rue du Test', street: '1 rue du Test',
streetComplement: null, streetComplement: null,
categories: [CAT],
sites: [SITE], sites: [SITE],
contacts: ['/api/provider_contacts/9'], contacts: ['/api/provider_contacts/9'],
}) })
@@ -53,6 +61,7 @@ describe('providerAddress helpers', () => {
const payload = buildProviderAddressPayload({ const payload = buildProviderAddressPayload({
...emptyProviderAddress(), ...emptyProviderAddress(),
siteIris: [SITE], siteIris: [SITE],
categoryIris: [CAT],
}) })
expect(payload).not.toHaveProperty('postalCode') expect(payload).not.toHaveProperty('postalCode')
expect(payload).not.toHaveProperty('city') expect(payload).not.toHaveProperty('city')
@@ -10,7 +10,6 @@ const {
canEditProvider, canEditProvider,
categoryOptionsOf, categoryOptionsOf,
contactOptionsOf, contactOptionsOf,
hasAccountingData,
iriOf, iriOf,
irisOf, irisOf,
mapAccountingDraft, mapAccountingDraft,
@@ -18,7 +17,6 @@ const {
mapContactToDraft, mapContactToDraft,
mapRibToDraft, mapRibToDraft,
paymentTypeCodeOf, paymentTypeCodeOf,
providerConsultationVisibleTabs,
referentialOptionOf, referentialOptionOf,
showArchiveAction, showArchiveAction,
showRestoreAction, showRestoreAction,
@@ -76,7 +74,7 @@ describe('providerDetail helpers', () => {
}) })
describe('mapAddressToDraft', () => { describe('mapAddressToDraft', () => {
it('extrait les IRI des sites / contacts embarques', () => { it('extrait les IRI des sites / categories / contacts embarques', () => {
const draft = mapAddressToDraft({ const draft = mapAddressToDraft({
'@id': '/api/provider_addresses/3', '@id': '/api/provider_addresses/3',
id: 3, id: 3,
@@ -85,9 +83,11 @@ describe('providerDetail helpers', () => {
city: 'Châtellerault', city: 'Châtellerault',
street: '1 rue du Test', street: '1 rue du Test',
sites: [{ '@id': '/api/sites/1' }], sites: [{ '@id': '/api/sites/1' }],
categories: [{ '@id': '/api/categories/7' }],
contacts: [{ '@id': '/api/provider_contacts/5' }, '/api/provider_contacts/6'], contacts: [{ '@id': '/api/provider_contacts/5' }, '/api/provider_contacts/6'],
}) })
expect(draft.siteIris).toEqual(['/api/sites/1']) expect(draft.siteIris).toEqual(['/api/sites/1'])
expect(draft.categoryIris).toEqual(['/api/categories/7'])
expect(draft.contactIris).toEqual(['/api/provider_contacts/5', '/api/provider_contacts/6']) expect(draft.contactIris).toEqual(['/api/provider_contacts/5', '/api/provider_contacts/6'])
expect(draft.id).toBe(3) expect(draft.id).toBe(3)
}) })
@@ -165,48 +165,3 @@ describe('providerDetail helpers', () => {
}) })
}) })
}) })
describe('hasAccountingData (prestataire)', () => {
it('faux sans champ comptable ni RIB', () => {
expect(hasAccountingData({ '@id': '/api/providers/1', id: 1 })).toBe(false)
})
it('vrai avec un champ comptable ou un RIB', () => {
expect(hasAccountingData({ '@id': '/api/providers/1', id: 1, siren: '123456789' })).toBe(true)
expect(hasAccountingData({
'@id': '/api/providers/1', id: 1,
ribs: [{ '@id': '/api/provider_ribs/1', id: 1, iban: 'FR76...' }],
})).toBe(true)
})
})
describe('providerConsultationVisibleTabs', () => {
it('retourne [] tant que le prestataire n\'est pas charge', () => {
expect(providerConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
})
it('masque les coquilles (reports/exchanges) et les onglets vides', () => {
expect(providerConsultationVisibleTabs(
{ '@id': '/api/providers/1', id: 1, companyName: 'ACME' },
{ canAccountingView: true },
)).toEqual([])
})
it('affiche contacts/address/accounting dans l\'ordre (pas d\'onglet information)', () => {
const provider = {
'@id': '/api/providers/1', id: 1,
contacts: [{ '@id': '/api/provider_contacts/1', id: 1 }],
addresses: [{ '@id': '/api/provider_addresses/1', id: 1 }],
siren: '123456789',
}
expect(providerConsultationVisibleTabs(provider, { canAccountingView: true }))
.toEqual(['contacts', 'address', 'accounting'])
})
it('masque Comptabilite sans le droit accounting.view', () => {
expect(providerConsultationVisibleTabs(
{ '@id': '/api/providers/1', id: 1, siren: '123456789' },
{ canAccountingView: false },
)).toEqual([])
})
})
@@ -14,12 +14,12 @@ import type { ProviderAddressFormDraft } from '~/modules/technique/types/provide
const REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const const REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const
/** /**
* RG-3.05 : une adresse est « valide » pour autoriser l'ajout d'un nouveau bloc * RG-3.05 (+ RG-3.09) : une adresse est « valide » pour autoriser l'ajout d'un
* des qu'elle porte au moins un site. Les scalaires (CP/ville/rue) restent valides * nouveau bloc des qu'elle porte au moins un site ET au moins une categorie. Les
* par le back (422 inline). * scalaires (CP/ville/rue) restent valides par le back (422 inline).
*/ */
export function isProviderAddressValid(address: ProviderAddressFormDraft): boolean { export function isProviderAddressValid(address: ProviderAddressFormDraft): boolean {
return address.siteIris.length >= 1 return address.siteIris.length >= 1 && address.categoryIris.length >= 1
} }
/** /**
@@ -34,6 +34,7 @@ export function buildProviderAddressPayload(address: ProviderAddressFormDraft):
city: address.city || null, city: address.city || null,
street: address.street || null, street: address.street || null,
streetComplement: address.streetComplement || null, streetComplement: address.streetComplement || null,
categories: [...address.categoryIris],
sites: [...address.siteIris], sites: [...address.siteIris],
contacts: [...address.contactIris], contacts: [...address.contactIris],
} }
@@ -68,6 +68,7 @@ export interface AddressRead extends HydraRef {
street?: string | null street?: string | null
streetComplement?: string | null streetComplement?: string | null
sites?: SiteRead[] sites?: SiteRead[]
categories?: CategoryRead[]
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu. // L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
contacts?: Array<HydraRef | string> contacts?: Array<HydraRef | string>
} }
@@ -145,6 +146,7 @@ export function mapAddressToDraft(address: AddressRead): ProviderAddressFormDraf
city: address.city ?? null, city: address.city ?? null,
street: address.street ?? null, street: address.street ?? null,
streetComplement: address.streetComplement ?? null, streetComplement: address.streetComplement ?? null,
categoryIris: (address.categories ?? []).map(c => c['@id']),
siteIris: (address.sites ?? []).map(s => s['@id']), siteIris: (address.sites ?? []).map(s => s['@id']),
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])), contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
} }
@@ -222,58 +224,6 @@ export function paymentTypeCodeOf(relation: Relation): string | null {
return (relation.code as string | undefined) ?? null return (relation.code as string | undefined) ?? null
} }
/**
* Vrai si une valeur scalaire porte une donnee affichable (non null/undefined,
* et non chaine vide apres trim). Sert au predicat « onglet vide » ci-dessous.
*/
function hasValue(value: unknown): boolean {
if (value === null || value === undefined) {
return false
}
if (typeof value === 'string') {
return value.trim() !== ''
}
return true
}
/**
* Vrai si l'onglet Comptabilite porte au moins un champ comptable OU un RIB.
* (Le gating permission `accounting.view` reste applique en amont par l'appelant.)
*/
export function hasAccountingData(provider: ProviderDetail): boolean {
const draft = mapAccountingDraft(provider)
const hasField = Object.values(draft).some(hasValue)
const hasRib = (provider.ribs ?? []).length > 0
return hasField || hasRib
}
/**
* Onglets visibles en consultation (ERP-193, retour metier) : on masque les
* coquilles non implementees (Rapports / Echanges) ET tout onglet de donnees
* vide. Le prestataire n'a pas d'onglet Information (bloc principal au-dessus
* des onglets). Ordre : Contacts · Adresse · Comptabilite. Retourne `[]` tant
* que le prestataire n'est pas charge.
*/
export function providerConsultationVisibleTabs(
provider: ProviderDetail | null | undefined,
options: { canAccountingView: boolean },
): string[] {
if (!provider) {
return []
}
const visible: string[] = []
if ((provider.contacts ?? []).length > 0) {
visible.push('contacts')
}
if ((provider.addresses ?? []).length > 0) {
visible.push('address')
}
if (options.canAccountingView && hasAccountingData(provider)) {
visible.push('accounting')
}
return visible
}
/** /**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet — * Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet —
* `manage` (onglets metier) OU `accounting.manage` (le role Compta doit pouvoir * `manage` (onglets metier) OU `accounting.manage` (le role Compta doit pouvoir
@@ -3,72 +3,63 @@
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Pays : prerempli « France » (RG-4.05). --> <!-- Pays : prerempli « France » (RG-4.05). -->
<MalioSelect <MalioSelect
v-if="!hideEmpty || isFilled(model.country)"
:model-value="model.country" :model-value="model.country"
:options="countryOptions" :options="countryOptions"
:label="t('transport.carriers.form.address.country')" :label="t('transport.carriers.form.address.country')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.country" :error="errors?.country"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))" @update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/> />
<!-- Code postal (RG-4.06) : declenche l'autocomplete ville (BAN). --> <!-- Code postal (RG-4.06) : declenche l'autocomplete ville (BAN). -->
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.postalCode)"
:model-value="model.postalCode" :model-value="model.postalCode"
:label="t('transport.carriers.form.address.postalCode')" :label="t('transport.carriers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK" :mask="POSTAL_CODE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.postalCode" :error="errors?.postalCode"
@update:model-value="onPostalCodeChange" @update:model-value="onPostalCodeChange"
/> />
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. --> <!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect <MalioSelect
v-if="!degraded && (!hideEmpty || isFilled(model.city))" v-if="!degraded"
:model-value="model.city" :model-value="model.city"
:options="cityOptions" :options="cityOptions"
:label="t('transport.carriers.form.address.city')" :label="t('transport.carriers.form.address.city')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
empty-option-label="" empty-option-label=""
:required="!readonly && !disabled" :required="true"
:error="errors?.city" :error="errors?.city"
@update:model-value="onCityChange" @update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/> />
<MalioInputText <MalioInputText
v-else-if="degraded && (!hideEmpty || isFilled(model.city))" v-else
:model-value="model.city" :model-value="model.city"
:label="t('transport.carriers.form.address.city')" :label="t('transport.carriers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.city" :error="errors?.city"
@update:model-value="(v: string) => update('city', v)" @update:model-value="(v: string) => update('city', v)"
/> />
<!-- Filler : aligne le debut de la ligne suivante sur la grille. Inutile en <!-- Filler : aligne le debut de la ligne suivante sur la grille. -->
consultation masquee (la grille se recompose sans les champs vides). --> <div aria-hidden="true" />
<div v-if="!hideEmpty" aria-hidden="true" />
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le <!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). --> texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2"> <div class="col-span-2">
<MalioInputAutocomplete <MalioInputAutocomplete
v-if="!readonly && !disabled" v-if="!readonly"
:model-value="model.street" :model-value="model.street"
:options="addressOptions" :options="addressOptions"
:loading="addressLoading" :loading="addressLoading"
:min-search-length="3" :min-search-length="3"
:label="t('transport.carriers.form.address.street')" :label="t('transport.carriers.form.address.street')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
:allow-create="true" :allow-create="true"
:no-results-text="t('transport.carriers.form.address.streetNotFound')" :no-results-text="t('transport.carriers.form.address.streetNotFound')"
@@ -81,20 +72,16 @@
:model-value="model.street" :model-value="model.street"
:label="t('transport.carriers.form.address.street')" :label="t('transport.carriers.form.address.street')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
@update:model-value="(v: string) => update('street', v)" @update:model-value="(v: string) => update('street', v)"
/> />
</div> </div>
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.streetComplement)"
:model-value="model.streetComplement" :model-value="model.streetComplement"
:label="t('transport.carriers.form.address.streetComplement')" :label="t('transport.carriers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement" :error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)" @update:model-value="(v: string) => update('streetComplement', v)"
/> />
@@ -104,8 +91,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete' import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm' import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm'
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
import { isFilled } from '~/shared/utils/consultationDisplay'
interface RefOption { interface RefOption {
value: string value: string
@@ -121,10 +106,6 @@ const props = defineProps<{
/** Pays disponibles (France par defaut). */ /** Pays disponibles (France par defaut). */
countryOptions: RefOption[] countryOptions: RefOption[]
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Consultation : masque les champs non remplis (ERP-193). */
hideEmpty?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -169,36 +150,11 @@ const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = [] let lastAddressSuggestions: AddressSuggestion[] = []
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur les
// champs texte editables (complement, ville en mode degrade). La voie en autocomplete
// (BAN) et la ville en select ne sont pas masquees (le back valide via Assert\Regex).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof CarrierAddressFormDraft>(field: K, value: CarrierAddressFormDraft[K]): void { function update<K extends keyof CarrierAddressFormDraft>(field: K, value: CarrierAddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
} }
/**
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
* a chaque frappe).
*/
function onCityChange(value: string | number | null): void {
const next = value === null ? null : String(value)
if (next === (props.modelValue.city ?? null)) {
return
}
banAddressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
city: next,
street: null,
streetComplement: null,
})
}
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */ /** Previent le parent (toast unique) que l'autocompletion est indisponible. */
function notifyUnavailable(): void { function notifyUnavailable(): void {
if (!unavailableNotified) { if (!unavailableNotified) {
@@ -209,27 +165,9 @@ function notifyUnavailable(): void {
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */ /** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
async function onPostalCodeChange(value: string): Promise<void> { async function onPostalCodeChange(value: string): Promise<void> {
update('postalCode', value)
const digits = (value ?? '').replace(/\D/g, '') const digits = (value ?? '').replace(/\D/g, '')
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
if (digits.length === 5 && digits !== previousDigits) {
banAddressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
postalCode: value,
city: null,
street: null,
streetComplement: null,
})
}
else {
update('postalCode', value)
}
if (digits.length < 5) { if (digits.length < 5) {
return return
} }
@@ -3,7 +3,7 @@
<!-- Suppression : ouvre une modal de confirmation côté parent. Masquée si <!-- Suppression : ouvre une modal de confirmation côté parent. Masquée si
non supprimable (1er bloc) ou en lecture seule. --> non supprimable (1er bloc) ou en lecture seule. -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly && !disabled" v-if="removable && !readonly"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -12,56 +12,44 @@
/> />
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName" :model-value="model.lastName"
:label="t('transport.carriers.form.contact.lastName')" :label="t('transport.carriers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.lastName" :error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)" @update:model-value="(v: string) => update('lastName', v)"
/> />
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName" :model-value="model.firstName"
:label="t('transport.carriers.form.contact.firstName')" :label="t('transport.carriers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.firstName" :error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)" @update:model-value="(v: string) => update('firstName', v)"
/> />
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText (inheritAttrs:false) <!-- Fonction sur 2 colonnes : on wrappe car MalioInputText (inheritAttrs:false)
renvoie `class` sur l'input interne, pas sur la cellule de grille. --> renvoie `class` sur l'input interne, pas sur la cellule de grille. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2"> <div class="col-span-2">
<MalioInputText <MalioInputText
:model-value="model.jobTitle" :model-value="model.jobTitle"
:label="t('transport.carriers.form.contact.jobTitle')" :label="t('transport.carriers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle" :error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)" @update:model-value="(v: string) => update('jobTitle', v)"
/> />
</div> </div>
<MalioInputEmail <MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email" :model-value="model.email"
:label="t('transport.carriers.form.contact.email')" :label="t('transport.carriers.form.contact.email')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:lowercase="true" :lowercase="true"
:error="errors?.email" :error="errors?.email"
@update:model-value="(v: string) => update('email', v)" @update:model-value="(v: string) => update('email', v)"
/> />
<!-- Téléphone principal + bouton « + » révélant le 2e numéro (max 2). --> <!-- Téléphone principal + bouton « + » révélant le 2e numéro (max 2). -->
<MalioInputPhone <MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary" :model-value="model.phonePrimary"
:label="t('transport.carriers.form.contact.phonePrimary')" :label="t('transport.carriers.form.contact.phonePrimary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary" :error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly" :addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('transport.carriers.form.contact.addPhone')" :add-button-label="t('transport.carriers.form.contact.addPhone')"
@@ -70,12 +58,11 @@
/> />
<!-- 2e numéro : révélé à la demande (max 2 téléphones — RG-4.08). --> <!-- 2e numéro : révélé à la demande (max 2 téléphones — RG-4.08). -->
<MalioInputPhone <MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))" v-if="model.hasSecondaryPhone"
:model-value="model.phoneSecondary" :model-value="model.phoneSecondary"
:label="t('transport.carriers.form.contact.phoneSecondary')" :label="t('transport.carriers.form.contact.phoneSecondary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary" :error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)" @update:model-value="(v: string) => update('phoneSecondary', v)"
/> />
@@ -84,8 +71,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm' import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm'
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
import { isFilled } from '~/shared/utils/consultationDisplay'
// Masque téléphone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur). // Masque téléphone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##' const PHONE_MASK = '## ## ## ## ##'
@@ -97,10 +82,6 @@ const props = defineProps<{
removable?: boolean removable?: boolean
/** Bloc en lecture seule (onglet validé). */ /** Bloc en lecture seule (onglet validé). */
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Consultation : masque les champs non remplis (ERP-193). */
hideEmpty?: boolean
/** Erreurs serveur 422 de cette ligne, indexées par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexées par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -115,10 +96,6 @@ const { t } = useI18n()
// Alias local pour la lisibilité du template. // Alias local pour la lisibilité du template.
const model = computed(() => props.modelValue) const model = computed(() => props.modelValue)
// Filtrage des caractères parasites : porté par les masks maska sur les champs
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
/** Émet un nouveau brouillon avec le champ modifié (immutabilité). */ /** Émet un nouveau brouillon avec le champ modifié (immutabilité). */
function update<K extends keyof CarrierContactFormDraft>(field: K, value: CarrierContactFormDraft[K]): void { function update<K extends keyof CarrierContactFormDraft>(field: K, value: CarrierContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
@@ -2,7 +2,7 @@
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : modal de confirmation côté parent. --> <!-- Suppression : modal de confirmation côté parent. -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly && !disabled" v-if="removable && !readonly"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -20,7 +20,7 @@
:name="`price-direction-${uid}`" :name="`price-direction-${uid}`"
value="CLIENT" value="CLIENT"
:label="t('transport.carriers.form.price.directionClient')" :label="t('transport.carriers.form.price.directionClient')"
:disabled="readonly || disabled" :disabled="readonly"
group-class="mt-0" group-class="mt-0"
@update:model-value="onDirectionChange" @update:model-value="onDirectionChange"
/> />
@@ -29,7 +29,7 @@
:name="`price-direction-${uid}`" :name="`price-direction-${uid}`"
value="FOURNISSEUR" value="FOURNISSEUR"
:label="t('transport.carriers.form.price.directionSupplier')" :label="t('transport.carriers.form.price.directionSupplier')"
:disabled="readonly || disabled" :disabled="readonly"
group-class="mt-0" group-class="mt-0"
@update:model-value="onDirectionChange" @update:model-value="onDirectionChange"
/> />
@@ -46,7 +46,6 @@
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.client" :error="errors?.client"
@update:model-value="onClientChange" @update:model-value="onClientChange"
/> />
@@ -57,7 +56,6 @@
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.clientDeliveryAddress" :error="errors?.clientDeliveryAddress"
@update:model-value="(v: string | number | null) => update('clientDeliveryAddressIri', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('clientDeliveryAddressIri', v === null ? null : String(v))"
/> />
@@ -68,7 +66,6 @@
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.departureSite" :error="errors?.departureSite"
@update:model-value="(v: string | number | null) => update('departureSiteIri', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('departureSiteIri', v === null ? null : String(v))"
/> />
@@ -83,7 +80,6 @@
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.supplier" :error="errors?.supplier"
@update:model-value="onSupplierChange" @update:model-value="onSupplierChange"
/> />
@@ -94,7 +90,6 @@
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.supplierSupplyAddress" :error="errors?.supplierSupplyAddress"
@update:model-value="(v: string | number | null) => update('supplierSupplyAddressIri', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('supplierSupplyAddressIri', v === null ? null : String(v))"
/> />
@@ -105,7 +100,6 @@
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.deliverySite" :error="errors?.deliverySite"
@update:model-value="(v: string | number | null) => update('deliverySiteIri', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('deliverySiteIri', v === null ? null : String(v))"
/> />
@@ -121,7 +115,7 @@
:name="`price-container-${uid}`" :name="`price-container-${uid}`"
value="BENNE" value="BENNE"
:label="t('transport.carriers.containerType.BENNE')" :label="t('transport.carriers.containerType.BENNE')"
:disabled="readonly || disabled" :disabled="readonly"
group-class="mt-0" group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))" @update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
/> />
@@ -130,7 +124,7 @@
:name="`price-container-${uid}`" :name="`price-container-${uid}`"
value="FOND_MOUVANT" value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')" :label="t('transport.carriers.containerType.FOND_MOUVANT')"
:disabled="readonly || disabled" :disabled="readonly"
group-class="mt-0" group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))" @update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
/> />
@@ -146,7 +140,7 @@
:name="`price-unit-${uid}`" :name="`price-unit-${uid}`"
value="FORFAIT" value="FORFAIT"
:label="t('transport.carriers.form.price.pricingForfait')" :label="t('transport.carriers.form.price.pricingForfait')"
:disabled="readonly || disabled" :disabled="readonly"
group-class="mt-0" group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))" @update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
/> />
@@ -155,7 +149,7 @@
:name="`price-unit-${uid}`" :name="`price-unit-${uid}`"
value="TONNE" value="TONNE"
:label="t('transport.carriers.form.price.pricingTonne')" :label="t('transport.carriers.form.price.pricingTonne')"
:disabled="readonly || disabled" :disabled="readonly"
group-class="mt-0" group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))" @update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
/> />
@@ -168,7 +162,6 @@
:label="t('transport.carriers.form.price.price')" :label="t('transport.carriers.form.price.price')"
:required="true" :required="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.price" :error="errors?.price"
@update:model-value="(v: string) => update('price', v)" @update:model-value="(v: string) => update('price', v)"
/> />
@@ -180,7 +173,6 @@
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.priceState" :error="errors?.priceState"
@update:model-value="(v: string | number | null) => update('priceState', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('priceState', v === null ? null : String(v))"
/> />
@@ -208,8 +200,6 @@ const props = defineProps<{
siteOptions: SelectOption[] siteOptions: SelectOption[]
removable?: boolean removable?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Erreurs serveur 422 de cette ligne, indexées par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexées par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -37,7 +37,7 @@ vi.stubGlobal('useToast', () => ({
})) }))
const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm') const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm')
const { buildCarrierContactPayload, isCarrierContactBlank } = await import('../../utils/forms/carrierContact') const { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } = await import('../../utils/forms/carrierContact')
const { buildCarrierPricePayload, isCarrierPriceValid } = await import('../../utils/forms/carrierPrice') const { buildCarrierPricePayload, isCarrierPriceValid } = await import('../../utils/forms/carrierPrice')
const { emptyCarrierContact, emptyCarrierPrice } = await import('../../types/carrierForm') const { emptyCarrierContact, emptyCarrierPrice } = await import('../../types/carrierForm')
@@ -545,9 +545,6 @@ describe('useCarrierForm — onglet Adresse (ERP-167 / ERP-172 : adresse unique)
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
expect(form.address.value.id).toBe(88) expect(form.address.value.id).toBe(88)
expect(form.isValidated('addresses')).toBe(true) expect(form.isValidated('addresses')).toBe(true)
// ERP-193 : Contact optionnel → valider Adresses déverrouille jusqu'à Prix
// (dernier onglet), sans étape bloquante par Contacts.
expect(form.unlockedIndex.value).toBe(CARRIER_TAB_KEYS.length - 1)
}) })
it('submitAddress : PATCH de l\'adresse existante sur /carrier_addresses/{id}', async () => { it('submitAddress : PATCH de l\'adresse existante sur /carrier_addresses/{id}', async () => {
@@ -580,7 +577,7 @@ describe('useCarrierForm — onglet Adresse (ERP-167 / ERP-172 : adresse unique)
}) })
}) })
describe('carrierContact (util) — bloc optionnel (ERP-193) + max 2 téléphones', () => { describe('carrierContact (util) — validité alignée M1/M2/M3 + max 2 téléphones', () => {
it('isCarrierContactBlank : vrai si vide, faux dès un champ comptant rempli (phoneSecondary exclu)', () => { it('isCarrierContactBlank : vrai si vide, faux dès un champ comptant rempli (phoneSecondary exclu)', () => {
expect(isCarrierContactBlank(emptyCarrierContact())).toBe(true) expect(isCarrierContactBlank(emptyCarrierContact())).toBe(true)
expect(isCarrierContactBlank({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false) expect(isCarrierContactBlank({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false)
@@ -589,6 +586,15 @@ describe('carrierContact (util) — bloc optionnel (ERP-193) + max 2 téléphone
expect(isCarrierContactBlank({ ...emptyCarrierContact(), phoneSecondary: '0605040302', hasSecondaryPhone: true })).toBe(true) expect(isCarrierContactBlank({ ...emptyCarrierContact(), phoneSecondary: '0605040302', hasSecondaryPhone: true })).toBe(true)
}) })
it('isCarrierContactNamed : nommé seulement avec un prénom OU un nom', () => {
expect(isCarrierContactNamed(emptyCarrierContact())).toBe(false)
expect(isCarrierContactNamed({ ...emptyCarrierContact(), firstName: 'Jean' })).toBe(true)
expect(isCarrierContactNamed({ ...emptyCarrierContact(), lastName: 'Doe' })).toBe(true)
// Fonction / téléphone / email seuls ne « nomment » pas (≠ RG-4.08 large).
expect(isCarrierContactNamed({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false)
expect(isCarrierContactNamed({ ...emptyCarrierContact(), email: 'a@b.fr' })).toBe(false)
})
it('buildCarrierContactPayload : phones = 1 numéro sans secondaire', () => { it('buildCarrierContactPayload : phones = 1 numéro sans secondaire', () => {
const body = buildCarrierContactPayload({ ...emptyCarrierContact(), phonePrimary: '0102030405' }) const body = buildCarrierContactPayload({ ...emptyCarrierContact(), phonePrimary: '0102030405' })
expect(body.phones).toEqual(['0102030405']) expect(body.phones).toEqual(['0102030405'])
@@ -629,18 +635,23 @@ describe('useCarrierForm — onglet Contacts (ERP-168)', () => {
return form return form
} }
it('ERP-193 : « + Nouveau contact » désactivé tant que le bloc est VIDE (plus de règle prénom/nom)', () => { it('« + Nouveau contact » désactivé tant que le bloc n\'est pas nommé (prénom OU nom, aligné M1/M2/M3)', () => {
const form = createdForm() const form = createdForm()
expect(form.canAddContact.value).toBe(false) expect(form.canAddContact.value).toBe(false)
// addContact est un no-op tant que le bloc est totalement vide. // addContact est un no-op tant que le bloc n'est pas nommé.
form.addContact() form.addContact()
expect(form.contacts.value).toHaveLength(1) expect(form.contacts.value).toHaveLength(1)
// ERP-193 : un seul champ rempli (ici la fonction, sans prénom ni nom) suffit // Fonction seule ne suffit PAS à ajouter un nouveau bloc (≠ RG-4.08 large).
// désormais à débloquer l'ajout — la règle « prénom OU nom » est retirée.
const first = form.contacts.value[0] const first = form.contacts.value[0]
if (first) first.jobTitle = 'Acheteur' if (first) first.jobTitle = 'Acheteur'
expect(form.canAddContact.value).toBe(false)
form.addContact()
expect(form.contacts.value).toHaveLength(1)
// Un nom (ou prénom) débloque l'ajout.
if (first) first.lastName = 'Doe'
expect(form.canAddContact.value).toBe(true) expect(form.canAddContact.value).toBe(true)
form.addContact() form.addContact()
expect(form.contacts.value).toHaveLength(2) expect(form.contacts.value).toHaveLength(2)
@@ -675,15 +686,21 @@ describe('useCarrierForm — onglet Contacts (ERP-168)', () => {
expect(mockPatch).toHaveBeenCalledWith('/carrier_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false }) expect(mockPatch).toHaveBeenCalledWith('/carrier_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false })
}) })
it('ERP-193 : onglet Contact vide → aucun POST, onglet finalisé (bloc optionnel)', async () => { it('RG-4.08 : onglet vide → soumet l\'amorce pour déclencher la 422 inline', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'firstName', message: 'Au moins un champ du contact est obligatoire.' }] },
},
})
const form = createdForm() const form = createdForm()
// Bloc vide → rien n'est soumis, l'onglet se finalise et déverrouille Prix.
const ok = await form.submitContacts(vi.fn()) const ok = await form.submitContacts(vi.fn())
expect(ok).toBe(true) expect(ok).toBe(false)
expect(mockPost).not.toHaveBeenCalled() expect(mockPost).toHaveBeenCalledTimes(1)
expect(form.isValidated('contacts')).toBe(true) expect(form.contactErrors.value[0]?.firstName).toBe('Au moins un champ du contact est obligatoire.')
expect(form.isValidated('contacts')).toBe(false)
}) })
it('removeContact : DELETE /carrier_contacts/{id} puis retrait du bloc', async () => { it('removeContact : DELETE /carrier_contacts/{id} puis retrait du bloc', async () => {
@@ -50,7 +50,7 @@ describe('useCarriersRepository', () => {
expect(mockApiGet).toHaveBeenCalledTimes(1) expect(mockApiGet).toHaveBeenCalledTimes(1)
const [url, query, opts] = mockApiGet.mock.calls[0] const [url, query, opts] = mockApiGet.mock.calls[0]
expect(url).toBe('/carriers') expect(url).toBe('/carriers')
expect(query).toMatchObject({ page: 1, itemsPerPage: 25 }) expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
expect(opts).toMatchObject({ expect(opts).toMatchObject({
toast: false, toast: false,
headers: { Accept: 'application/ld+json' }, headers: { Accept: 'application/ld+json' },
@@ -17,7 +17,7 @@ import {
type CarrierPriceFormDraft, type CarrierPriceFormDraft,
} from '~/modules/transport/types/carrierForm' } from '~/modules/transport/types/carrierForm'
import { buildCarrierAddressPayload } from '~/modules/transport/utils/forms/carrierAddress' import { buildCarrierAddressPayload } from '~/modules/transport/utils/forms/carrierAddress'
import { buildCarrierContactPayload, isCarrierContactBlank } from '~/modules/transport/utils/forms/carrierContact' import { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } from '~/modules/transport/utils/forms/carrierContact'
import { buildCarrierPricePayload, isCarrierPriceValid } from '~/modules/transport/utils/forms/carrierPrice' import { buildCarrierPricePayload, isCarrierPriceValid } from '~/modules/transport/utils/forms/carrierPrice'
import { import {
mapAddressToDraft, mapAddressToDraft,
@@ -416,11 +416,6 @@ export function useCarrierForm() {
}) })
} }
/** Toast de succès après suppression serveur confirmée d'une sous-ressource. */
function notifyRemovalSuccess(): void {
toast.success({ title: t('success.title'), message: t('success.deleted') })
}
/** /**
* Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX : * Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX :
* on n'arrête pas au premier bloc en échec (décision ERP-101). Réinitialise la * on n'arrête pas au premier bloc en échec (décision ERP-101). Réinitialise la
@@ -493,10 +488,6 @@ export function useCarrierForm() {
await api.patch(`/carrier_addresses/${address.value.id}`, body, { toast: false }) await api.patch(`/carrier_addresses/${address.value.id}`, body, { toast: false })
} }
completeTab('addresses') completeTab('addresses')
// ERP-193 : l'onglet Contact est OPTIONNEL — il ne doit pas verrouiller
// l'accès à Prix. Dès les Adresses validées, on déverrouille jusqu'à Prix
// (Contacts reste accessible mais n'est plus une étape bloquante).
unlockedIndex.value = tabKeys.value.length - 1
return true return true
} }
catch (error) { catch (error) {
@@ -520,13 +511,12 @@ export function useCarrierForm() {
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows. // Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
const contactErrors = ref<Record<string, string>[]>([]) const contactErrors = ref<Record<string, string>[]>([])
// « + Nouveau contact » désactivé tant que le DERNIER bloc est VIDE. ERP-193 : // « + Nouveau contact » désactivé tant que le DERNIER bloc n'est pas « nommé »
// l'onglet Contact n'est plus obligatoire — on ne réclame plus prénom OU nom, // (prénom OU nom) — aligné sur M1/M2/M3 (fonction / téléphone / email seuls ne
// un seul champ rempli (fonction / téléphone / email) suffit pour empiler un // suffisent pas à ajouter un nouveau bloc).
// bloc suivant (et évite d'accumuler des blocs totalement vides).
const canAddContact = computed(() => { const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1] const last = contacts.value[contacts.value.length - 1]
return last !== undefined && !isCarrierContactBlank(last) return last !== undefined && isCarrierContactNamed(last)
}) })
function addContact(): void { function addContact(): void {
@@ -545,18 +535,16 @@ export function useCarrierForm() {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyCarrierContact, makeEmpty: emptyCarrierContact,
onError: notifyRemovalError, onError: notifyRemovalError,
onSuccess: notifyRemovalSuccess,
}) })
} }
/** /**
* Valide l'onglet Contacts : POST des nouveaux contacts sur * Valide l'onglet Contacts : POST des nouveaux contacts sur
* /carriers/{id}/contacts, PATCH des existants sur /carrier_contacts/{id} * /carriers/{id}/contacts, PATCH des existants sur /carrier_contacts/{id}
* (groupe carrier:write:contacts). Max 2 téléphones re-validé back → 422 par * (groupe carrier:write:contacts). RG-4.08 (≥ 1 champ rempli, max 2 téléphones)
* ligne. ERP-193 : l'onglet Contact est OPTIONNEL — les amorces vides neuves * re-validée back → 422 par ligne. Si l'onglet ne contient QUE des amorces
* sont systématiquement ignorées (pas de contact vide créé) et un onglet sans * vides, on soumet la 1re pour déclencher la 422 RG-4.08 inline plutôt que de
* aucun bloc rempli est simplement finalisé, déverrouillant l'onglet Prix. * finaliser un onglet vide. Retourne true si l'onglet a été validé.
* Retourne true si l'onglet a été validé.
*/ */
async function submitContacts(onError: (error: unknown) => void): Promise<boolean> { async function submitContacts(onError: (error: unknown) => void): Promise<boolean> {
if (carrierId.value === null || tabSubmitting.value) { if (carrierId.value === null || tabSubmitting.value) {
@@ -564,6 +552,7 @@ export function useCarrierForm() {
} }
tabSubmitting.value = true tabSubmitting.value = true
try { try {
const hasSubmittable = contacts.value.some(c => c.id !== null || !isCarrierContactBlank(c))
const hasError = await submitRows( const hasError = await submitRows(
contacts.value, contacts.value,
contactErrors, contactErrors,
@@ -582,9 +571,9 @@ export function useCarrierForm() {
} }
}, },
onError, onError,
// Amorce vide neuve toujours ignorée (bloc Contact optionnel, ERP-193) : // Amorce vide neuve ignorée s'il reste un autre bloc soumettable ;
// un onglet sans aucun bloc rempli se finalise sans rien créer. // sinon on la soumet pour déclencher la 422 RG-4.08 (sur firstName).
contact => contact.id === null && isCarrierContactBlank(contact), contact => hasSubmittable && contact.id === null && isCarrierContactBlank(contact),
) )
if (hasError) { if (hasError) {
return false return false
@@ -659,7 +648,6 @@ export function useCarrierForm() {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyCarrierPrice, makeEmpty: emptyCarrierPrice,
onError: notifyRemovalError, onError: notifyRemovalError,
onSuccess: notifyRemovalSuccess,
}) })
} }
@@ -66,6 +66,5 @@ export interface CarrierFilters {
* `usePaginatedList`. Aucun reset au logout a gerer. * `usePaginatedList`. Aucun reset au logout a gerer.
*/ */
export function useCarriersRepository() { export function useCarriersRepository() {
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193). return usePaginatedList<Carrier, CarrierFilters>({ url: '/carriers' })
return usePaginatedList<Carrier, CarrierFilters>({ url: '/carriers', defaultItemsPerPage: 25 })
} }
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('transport.carriers.edit.back')"
v-bind="{ ariaLabel: t('transport.carriers.edit.back') }" v-bind="{ ariaLabel: t('transport.carriers.edit.back') }"
@click="goBack" @click="goBack"
/> />
@@ -21,24 +20,18 @@
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4"> <div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="main.name" v-model="main.name"
:mask="FREE_TEXT_MASK"
:label="t('transport.carriers.form.main.name')" :label="t('transport.carriers.form.main.name')"
:required="true" :required="true"
:error="mainErrors.errors.name" :error="mainErrors.errors.name"
/> />
<!-- Cas LIOT : le champ immatriculations occupe les colonnes restantes <MalioInputText
de la ligne (3 en xl, 2 sinon). Wrapper pour le col-span car v-if="isLiot"
MalioInputText (inheritAttrs:false) renvoie `class` sur l'input. --> v-model="main.liotPlates"
<div v-if="isLiot" class="col-span-2 xl:col-span-3"> :label="t('transport.carriers.form.main.liotPlates')"
<MalioInputText :hint="t('transport.carriers.form.main.liotPlatesHint')"
v-model="main.liotPlates" :required="true"
:mask="LIOT_PLATES_MASK" :error="mainErrors.errors.liotPlates"
:label="t('transport.carriers.form.main.liotPlates')" />
:hint="t('transport.carriers.form.main.liotPlatesHint')"
:required="true"
:error="mainErrors.errors.liotPlates"
/>
</div>
<template v-if="!isLiot"> <template v-if="!isLiot">
<MalioSelect <MalioSelect
:model-value="main.certificationType" :model-value="main.certificationType"
@@ -46,7 +39,7 @@
:label="t('transport.carriers.form.main.certificationType')" :label="t('transport.carriers.form.main.certificationType')"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:disabled="certificationReadonly" :readonly="certificationReadonly"
:error="mainErrors.errors.certificationType" :error="mainErrors.errors.certificationType"
@update:model-value="(v: string | number | null) => setCertification(v === null ? null : String(v))" @update:model-value="(v: string | number | null) => setCertification(v === null ? null : String(v))"
/> />
@@ -56,7 +49,7 @@
:label="t('transport.carriers.form.main.discharge')" :label="t('transport.carriers.form.main.discharge')"
accept="application/pdf,image/*" accept="application/pdf,image/*"
:required="true" :required="true"
:disabled="dischargeUploading" :readonly="dischargeUploading"
:clearable="true" :clearable="true"
:error="mainErrors.errors.dischargeDocument" :error="mainErrors.errors.dischargeDocument"
@update:model-value="(v: string) => dischargeFileName = v" @update:model-value="(v: string) => dischargeFileName = v"
@@ -220,8 +213,7 @@ import CarrierQualimatTab from '~/modules/transport/components/CarrierQualimatTa
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm' import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
import { useCarrier } from '~/modules/transport/composables/useCarrier' import { useCarrier } from '~/modules/transport/composables/useCarrier'
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch' import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
import { clampPercent, LIOT_PLATES_MASK, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput' import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
import { FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
interface SelectOption { interface SelectOption {
value: string value: string
@@ -292,13 +284,9 @@ const TAB_ICONS: Record<string, string> = {
contacts: 'mdi:account-box-plus-outline', contacts: 'mdi:account-box-plus-outline',
prices: 'mdi:payment', prices: 'mdi:payment',
} }
const activeTab = ref('addresses')
// Onglet Qualimat disponible en modif (ERP-172) : « actualiser » nom + certification. // Onglet Qualimat disponible en modif (ERP-172) : « actualiser » nom + certification.
const TAB_KEYS = ['qualimat', 'addresses', 'contacts', 'prices'] const tabs = computed(() => ['qualimat', 'addresses', 'contacts', 'prices'].map(key => ({
// ERP-193 : on honore l'onglet demande via `?tab=` (navigation depuis la
// consultation) pour retomber sur le meme onglet ; defaut « addresses ».
const requestedTab = typeof route.query.tab === 'string' ? route.query.tab : ''
const activeTab = ref(TAB_KEYS.includes(requestedTab) ? requestedTab : 'addresses')
const tabs = computed(() => TAB_KEYS.map(key => ({
key, key,
label: t(`transport.carriers.tab.${key}`), label: t(`transport.carriers.tab.${key}`),
icon: TAB_ICONS[key], icon: TAB_ICONS[key],
@@ -376,8 +364,7 @@ function onIndexationInput(value: string): void {
} }
function goBack(): void { function goBack(): void {
// ERP-193 : on transmet l'onglet courant pour retomber dessus en consultation. router.push(`/carriers/${carrierId}`)
router.push({ path: `/carriers/${carrierId}`, query: { tab: activeTab.value } })
} }
/** PATCH du formulaire principal (pas de re-POST). */ /** PATCH du formulaire principal (pas de re-POST). */
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('transport.carriers.consultation.back')"
v-bind="{ ariaLabel: t('transport.carriers.consultation.back') }" v-bind="{ ariaLabel: t('transport.carriers.consultation.back') }"
@click="goBack" @click="goBack"
/> />
@@ -23,7 +22,7 @@
/> />
<MalioButton <MalioButton
v-if="showArchive" v-if="showArchive"
variant="danger" variant="secondary"
icon-name="mdi:archive-arrow-down-outline" icon-name="mdi:archive-arrow-down-outline"
icon-position="left" icon-position="left"
:label="t('transport.carriers.action.archive')" :label="t('transport.carriers.action.archive')"
@@ -46,58 +45,56 @@
<template v-else-if="carrier"> <template v-else-if="carrier">
<!-- Bloc principal (lecture seule) même disposition que l'ajout --> <!-- Bloc principal (lecture seule) même disposition que l'ajout -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4"> <div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText v-if="isFilled(main.name)" :model-value="main.name" :label="t('transport.carriers.form.main.name')" disabled /> <MalioInputText :model-value="main.name" :label="t('transport.carriers.form.main.name')" readonly />
<!-- Cas LIOT : le champ immatriculations occupe les colonnes restantes <!-- Cas LIOT : seul le champ immatriculations. -->
de la ligne (3 en xl, 2 sinon), comme à l'ajout / la modification. --> <MalioInputText
<div v-if="isLiot && isFilled(main.liotPlates)" class="col-span-2 xl:col-span-3"> v-if="isLiot"
<MalioInputText :model-value="main.liotPlates"
:model-value="main.liotPlates" :label="t('transport.carriers.form.main.liotPlates')"
:label="t('transport.carriers.form.main.liotPlates')" readonly
disabled />
/>
</div>
<!-- Cas standard : certification + décharge (col 3 réservée) + affrètement (col 4). --> <!-- Cas standard : certification + décharge (col 3 réservée) + affrètement (col 4). -->
<template v-if="!isLiot"> <template v-if="!isLiot">
<MalioInputText <MalioInputText
v-if="isFilled(certificationLabel)"
:model-value="certificationLabel" :model-value="certificationLabel"
:label="t('transport.carriers.form.main.certificationType')" :label="t('transport.carriers.form.main.certificationType')"
disabled readonly
/> />
<!-- Décharge (si AUTRE) : affichee uniquement si un document est attache. --> <!-- Colonne 3 réservée à la décharge (si AUTRE), sinon vide (xl). -->
<MalioInputText <MalioInputText
v-if="main.certificationType === 'AUTRE' && isFilled(dischargeLabel)" v-if="main.certificationType === 'AUTRE'"
:model-value="dischargeLabel" :model-value="dischargeLabel"
:label="t('transport.carriers.form.main.discharge')" :label="t('transport.carriers.form.main.discharge')"
disabled readonly
/> />
<div v-else class="hidden xl:block"></div>
<!-- Affréter : masquee si non cochee (ERP-193). --> <!-- Affréter : colonne 4, centré (h-12) comme à l'ajout. -->
<div v-if="isFilled(main.isChartered)" class="flex h-12 items-center"> <div class="flex h-12 items-center">
<MalioCheckbox <MalioCheckbox
id="carrier-view-chartered" id="carrier-view-chartered"
:label="t('transport.carriers.form.main.isChartered')" :label="t('transport.carriers.form.main.isChartered')"
:model-value="main.isChartered" :model-value="main.isChartered"
disabled readonly
:reserve-message-space="false" :reserve-message-space="false"
/> />
</div> </div>
<!-- Champs d'affrètement (ligne 2) si affrété. --> <!-- Champs d'affrètement (ligne 2) si affrété. -->
<template v-if="main.isChartered"> <template v-if="main.isChartered">
<MalioInputText v-if="isFilled(indexationDisplay)" :model-value="indexationDisplay" :label="t('transport.carriers.form.main.indexationRate')" disabled /> <MalioInputText :model-value="indexationDisplay" :label="t('transport.carriers.form.main.indexationRate')" readonly />
<!-- Contenant : radios désactivés (lecture seule), aligné sur l'ajout / la modif. --> <!-- Contenant : radios désactivés (lecture seule), aligné sur l'ajout / la modif. -->
<div v-if="isFilled(main.containerType)"> <div>
<div class="flex h-12 items-center gap-4"> <div class="flex h-12 items-center gap-4">
<MalioRadioButton <MalioRadioButton
:model-value="main.containerType" :model-value="main.containerType"
name="carrier-view-container" name="carrier-view-container"
value="BENNE" value="BENNE"
:label="t('transport.carriers.containerType.BENNE')" :label="t('transport.carriers.containerType.BENNE')"
disabled readonly
group-class="mt-0" group-class="mt-0"
/> />
<MalioRadioButton <MalioRadioButton
@@ -105,27 +102,25 @@
name="carrier-view-container" name="carrier-view-container"
value="FOND_MOUVANT" value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')" :label="t('transport.carriers.containerType.FOND_MOUVANT')"
disabled readonly
group-class="mt-0" group-class="mt-0"
/> />
</div> </div>
</div> </div>
<MalioInputText v-if="isFilled(main.volumeM3)" :model-value="main.volumeM3" :label="t('transport.carriers.form.main.volumeM3')" disabled /> <MalioInputText :model-value="main.volumeM3" :label="t('transport.carriers.form.main.volumeM3')" readonly />
</template> </template>
</template> </template>
</div> </div>
<!-- ── Onglets (Adresses · Contacts · Prix) — ouvre sur Adresses ──── --> <!-- ── Onglets (Adresses · Contacts · Prix) — ouvre sur Adresses ──── -->
<!-- ERP-193 : barre masquee s'il ne reste aucun onglet non vide. --> <MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<template #addresses> <template #addresses>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- Adresse UNIQUE (ERP-172). --> <!-- Adresse UNIQUE (ERP-172). -->
<CarrierAddressBlock <CarrierAddressBlock
:model-value="address" :model-value="address"
:country-options="countryOptionsFor(address.country)" :country-options="countryOptionsFor(address.country)"
disabled readonly
hide-empty
/> />
</div> </div>
</template> </template>
@@ -136,8 +131,7 @@
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
disabled readonly
hide-empty
/> />
</div> </div>
</template> </template>
@@ -150,15 +144,14 @@
groupe (Fond Mouvant / Benne) fusionné en rowspan ; séparateur groupe (Fond Mouvant / Benne) fusionné en rowspan ; séparateur
épais entre les deux groupes. --> épais entre les deux groupes. -->
<table class="w-full table-fixed border-separate border-spacing-0 overflow-hidden rounded-malio border border-black text-left text-black"> <table class="w-full table-fixed border-separate border-spacing-0 overflow-hidden rounded-malio border border-black text-left text-black">
<!-- Répartition (table-fixed) : « Transport » étroit (libellé <!-- Répartition (table-fixed) : « Type de transport » un peu plus
court Benne / Fond mouvant) ; Fournisseurs/Clients et large ; Transporteurs et Adresse livraisons larges ; Forfait /
Adresse livraisons larges ; Forfait / Tonne / Indexation Tonne / Indexation / État réduits. -->
/ État réduits. -->
<colgroup> <colgroup>
<col class="w-[120px]" /> <col class="w-[170px]" />
<col class="w-[20%]" /> <col class="w-[20%]" />
<col class="w-[24%]" />
<col class="w-[11%]" /> <col class="w-[11%]" />
<col class="w-[24%]" />
<col class="w-[9%]" /> <col class="w-[9%]" />
<col class="w-[9%]" /> <col class="w-[9%]" />
<col class="w-[9%]" /> <col class="w-[9%]" />
@@ -169,8 +162,8 @@
<!-- En-tête centré pour matcher les cellules fusionnées Benne / Fond mouvant. --> <!-- En-tête centré pour matcher les cellules fusionnées Benne / Fond mouvant. -->
<th class="border-b border-r border-black bg-m-surface px-3 py-3 text-center align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.group') }}</th> <th class="border-b border-r border-black bg-m-surface px-3 py-3 text-center align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.group') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.carrier') }}</th> <th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.carrier') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.delivery') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.aproOrSite') }}</th> <th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.aproOrSite') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.delivery') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.forfait') }}</th> <th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.forfait') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.tonne') }}</th> <th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.tonne') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.indexation') }}</th> <th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.indexation') }}</th>
@@ -178,21 +171,28 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<template v-for="(group, gi) in priceGroups" :key="gi"> <template v-for="(group, gi) in priceGroups" :key="group.label">
<tr <tr
v-for="(row, i) in group.rows" v-for="(row, i) in group.rows"
:key="`${gi}-${i}`" :key="`${gi}-${i}`"
> >
<!-- Contenant par ligne (plus de cellule fusionnée) ; séparateur <!-- Cellule de groupe fusionnée (rowspan), centrée verticalement ;
à droite, comme l'ancienne colonne de groupe. --> séparateur épais en bas entre les groupes (sauf dernier). -->
<td class="border-r border-black px-3 py-4 text-center align-middle text-[14px] font-medium" :class="dataBorder(gi, i)">{{ row.transport }}</td> <td
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.party }}</td> v-if="i === 0"
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.delivery }}</td> :rowspan="group.rows.length"
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.apro }}</td> class="border-r border-black px-3 text-center align-middle text-[14px] font-medium"
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.forfait }}</td> :class="groupBorder(gi)"
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.tonne }}</td> >
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.indexation }}</td> {{ group.label }}
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.state }}</td> </td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ headerTitle }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.apro }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.delivery }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.forfait }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.tonne }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.indexation }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.state }}</td>
</tr> </tr>
</template> </template>
<tr v-if="!hasPrices"> <tr v-if="!hasPrices">
@@ -241,13 +241,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue' import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue' import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
import { useCarrier } from '~/modules/transport/composables/useCarrier' import { useCarrier } from '~/modules/transport/composables/useCarrier'
import { import {
canEditCarrier, canEditCarrier,
carrierConsultationVisibleTabs,
labelOfRelation, labelOfRelation,
mapAddressToDraft, mapAddressToDraft,
mapContactToDraft, mapContactToDraft,
@@ -258,7 +257,6 @@ import {
type Relation, type Relation,
} from '~/modules/transport/utils/forms/carrierMappers' } from '~/modules/transport/utils/forms/carrierMappers'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isFilled } from '~/shared/utils/consultationDisplay'
interface SelectOption { interface SelectOption {
value: string value: string
@@ -302,36 +300,18 @@ const dischargeLabel = computed(() => {
}) })
// ── Onglets : Adresses · Contacts · Prix (ouvre sur Adresses, pas de Qualimat) ── // ── Onglets : Adresses · Contacts · Prix (ouvre sur Adresses, pas de Qualimat) ──
// ERP-193 (retour metier) : on masque tout onglet de donnees vide. const activeTab = ref('addresses')
const TAB_ICONS: Record<string, string> = { const TAB_ICONS: Record<string, string> = {
addresses: 'mdi:map-marker-outline', addresses: 'mdi:map-marker-outline',
contacts: 'mdi:account-box-plus-outline', contacts: 'mdi:account-box-plus-outline',
prices: 'mdi:payment', prices: 'mdi:payment',
} }
const visibleTabKeys = computed(() => carrierConsultationVisibleTabs(carrier.value)) const tabs = computed(() => ['addresses', 'contacts', 'prices'].map(key => ({
const tabs = computed(() => visibleTabKeys.value.map(key => ({
key, key,
label: t(`transport.carriers.tab.${key}`), label: t(`transport.carriers.tab.${key}`),
icon: TAB_ICONS[key], icon: TAB_ICONS[key],
}))) })))
// Onglet initial : vide tant que le transporteur n'est pas charge, puis premier
// onglet visible. Un watcher recale si l'onglet courant disparait. ERP-193 : on
// honore l'onglet demande via `?tab=` (navigation depuis l'edition) s'il est
// visible, pour retomber sur le meme onglet en passant edition <-> consultation.
const activeTab = ref('')
let requestedTab = typeof route.query.tab === 'string' ? route.query.tab : ''
watch(visibleTabKeys, (keys) => {
if (keys.length === 0) {
activeTab.value = ''
return
}
if (!keys.includes(activeTab.value)) {
activeTab.value = requestedTab && keys.includes(requestedTab) ? requestedTab : keys[0]
requestedTab = ''
}
}, { immediate: true })
// Adresse UNIQUE (ERP-172) : un seul bloc en lecture seule (vide si pas d'adresse). // Adresse UNIQUE (ERP-172) : un seul bloc en lecture seule (vide si pas d'adresse).
const address = computed(() => carrier.value?.address const address = computed(() => carrier.value?.address
? mapAddressToDraft(carrier.value.address) ? mapAddressToDraft(carrier.value.address)
@@ -346,17 +326,10 @@ function countryOptionsFor(country: string): SelectOption[] {
return country ? [{ value: country, label: country }] : [] return country ? [{ value: country, label: country }] : []
} }
// ── Tableau Prix consultation (regroupé par ADRESSE DE LIVRAISON) ───────────── // ── Tableau Prix consultation (regroupé par contenant Fond Mouvant / Benne) ───
// Rang d'affichage des contenants au sein d'une même adresse (Fond mouvant puis Benne). const PRICE_GROUP_ORDER = ['FOND_MOUVANT', 'BENNE'] as const
const CONTAINER_RANK: Record<string, number> = { FOND_MOUVANT: 0, BENNE: 1 }
interface PriceRowView { interface PriceRowView {
/** Contenant (libellé affiché : Fond mouvant / Benne). */
transport: string
/** Contenant brut (FOND_MOUVANT / BENNE) — tri interne du groupe. */
transportType: string
/** Fournisseur ou client lié au prix (raison sociale). */
party: string
apro: string apro: string
delivery: string delivery: string
forfait: string forfait: string
@@ -365,8 +338,9 @@ interface PriceRowView {
state: string state: string
} }
/** Groupe de prix d'une même adresse de livraison (lignes consécutives). */ /** Groupe de prix d'un même contenant (Fond Mouvant / Benne) — cellule fusionnée. */
interface PriceGroupView { interface PriceGroupView {
label: string
rows: PriceRowView[] rows: PriceRowView[]
} }
@@ -393,19 +367,13 @@ function siteCode(relation: Relation): string {
/** /**
* Construit une ligne d'affichage depuis un prix embarqué (maquette Prix) : * Construit une ligne d'affichage depuis un prix embarqué (maquette Prix) :
* - « Transport » = le contenant (Fond mouvant / Benne) ;
* - « Fournisseurs / Clients » = le fournisseur OU le client lié (raison sociale) ;
* - « Adresse sites » = le CODE du site (département, ex: 86 / 17 / 82) ; * - « Adresse sites » = le CODE du site (département, ex: 86 / 17 / 82) ;
* - « Adresse livraisons » = l'adresse (voie) du client/fournisseur ; * - « Adresse livraisons » = l'adresse (voie) du client/fournisseur ;
* - le prix tombe dans Forfait € OU Tonne € selon `pricingUnit`. * - le prix tombe dans Forfait € OU Tonne € selon `pricingUnit`.
*/ */
function toPriceRow(price: CarrierPriceRead): PriceRowView { function toPriceRow(price: CarrierPriceRead): PriceRowView {
const isClient = price.direction === 'CLIENT' const isClient = price.direction === 'CLIENT'
const containerType = price.containerType ?? ''
return { return {
transportType: containerType,
transport: containerType ? t(`transport.carriers.containerType.${containerType}`) : '',
party: labelOfRelation(isClient ? price.client : price.supplier),
apro: isClient ? siteCode(price.departureSite) : siteCode(price.deliverySite), apro: isClient ? siteCode(price.departureSite) : siteCode(price.deliverySite),
delivery: isClient ? labelOfRelation(price.clientDeliveryAddress) : labelOfRelation(price.supplierSupplyAddress), delivery: isClient ? labelOfRelation(price.clientDeliveryAddress) : labelOfRelation(price.supplierSupplyAddress),
forfait: price.pricingUnit === 'FORFAIT' ? formatAmount(price.price) : '', forfait: price.pricingUnit === 'FORFAIT' ? formatAmount(price.price) : '',
@@ -424,48 +392,39 @@ function stateSuffix(state: string): string {
return map[state] ?? '' return map[state] ?? ''
} }
// Prix regroupés par ADRESSE DE LIVRAISON : les lignes d'une même adresse sont // Prix regroupés par contenant (Fond Mouvant puis Benne) — une cellule fusionnée
// consécutives (triées par contenant Fond mouvant → Benne), les groupes triés // par groupe (rowspan) à gauche, conformément à la maquette.
// alphabétiquement par adresse. Un séparateur épais sépare deux adresses.
const priceGroups = computed<PriceGroupView[]>(() => { const priceGroups = computed<PriceGroupView[]>(() => {
const rows = (carrier.value?.prices ?? []).map(toPriceRow) const list = carrier.value?.prices ?? []
const byDelivery = new Map<string, PriceRowView[]>() return PRICE_GROUP_ORDER
for (const row of rows) { .map(container => ({
const list = byDelivery.get(row.delivery) label: t(`transport.carriers.containerType.${container}`),
if (list) { rows: list.filter(p => p.containerType === container).map(toPriceRow),
list.push(row)
} else {
byDelivery.set(row.delivery, [row])
}
}
return [...byDelivery.entries()]
.sort(([a], [b]) => a.localeCompare(b, 'fr'))
.map(([, groupRows]) => ({
rows: groupRows
.slice()
.sort((x, y) => (CONTAINER_RANK[x.transportType] ?? 99) - (CONTAINER_RANK[y.transportType] ?? 99)),
})) }))
.filter(group => group.rows.length > 0)
}) })
const hasPrices = computed(() => priceGroups.value.length > 0) const hasPrices = computed(() => priceGroups.value.length > 0)
/** /**
* Bordure basse d'une cellule de données : * Bordure basse d'une cellule de données :
* - ligne interne d'un groupe d'adresse (même adresse de livraison) → fine grise ; * - ligne interne d'un groupe → fine grise ;
* - dernière ligne d'un groupe NON final → épaisse noire (sépare deux adresses) ; * - dernière ligne d'un groupe NON final → épaisse noire (séparateur de groupe) ;
* - dernière ligne du DERNIER groupe → aucune (le cadre du tableau s'en charge, * - dernière ligne du DERNIER groupe → aucune (le cadre du tableau s'en charge,
* évite la double bordure tout en bas). * évite la double bordure tout en bas).
*/ */
function dataBorder(gi: number, i: number): string { function dataBorder(group: PriceGroupView, i: number, gi: number): string {
const group = priceGroups.value[gi]
const isLastRow = i === group.rows.length - 1 const isLastRow = i === group.rows.length - 1
const isLastGroup = gi === priceGroups.value.length - 1 const isLastGroup = gi === priceGroups.value.length - 1
// Couleur de bordure SIDE-SPECIFIC (border-b-*) : un `border-{color}` global
// ecraserait la couleur du bord droit noir de la colonne Transport.
if (!isLastRow) { if (!isLastRow) {
return 'border-b border-b-m-muted/30' return 'border-b border-m-muted/30'
} }
return isLastGroup ? '' : 'border-b-2 border-b-black' return isLastGroup ? '' : 'border-b-2 border-black'
}
/** Bordure basse de la cellule de groupe fusionnée (séparateur épais sauf dernier groupe). */
function groupBorder(gi: number): string {
return gi === priceGroups.value.length - 1 ? '' : 'border-b-2 border-black'
} }
// ── Export XLSX des prix ───────────────────────────────────────────────────── // ── Export XLSX des prix ─────────────────────────────────────────────────────
@@ -506,8 +465,7 @@ function goBack(): void {
} }
function goEdit(): void { function goEdit(): void {
// ERP-193 : on transmet l'onglet courant pour retomber dessus en edition. router.push(`/carriers/${carrierId}/edit`)
router.push({ path: `/carriers/${carrierId}/edit`, query: activeTab.value ? { tab: activeTab.value } : {} })
} }
const confirmArchive = reactive({ open: false, title: '', message: '', confirmLabel: '' }) const confirmArchive = reactive({ open: false, title: '', message: '', confirmLabel: '' })
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('transport.carriers.form.back')"
v-bind="{ ariaLabel: t('transport.carriers.form.back') }" v-bind="{ ariaLabel: t('transport.carriers.form.back') }"
@click="goBack" @click="goBack"
/> />
@@ -21,28 +20,22 @@
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4"> <div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="main.name" v-model="main.name"
:mask="FREE_TEXT_MASK"
:label="t('transport.carriers.form.main.name')" :label="t('transport.carriers.form.main.name')"
:required="true" :required="true"
:disabled="mainLocked" :readonly="mainLocked"
:error="mainErrors.errors.name" :error="mainErrors.errors.name"
/> />
<!-- Cas LIOT : seul le champ immatriculations est pertinent. Il occupe <!-- Cas LIOT : seul le champ immatriculations est pertinent. -->
les colonnes restantes de la ligne (3 en xl, 2 sinon) le wrapper <MalioInputText
porte le col-span car MalioInputText (inheritAttrs:false) renvoie v-if="isLiot"
`class` sur l'input interne, pas sur la cellule de grille. --> v-model="main.liotPlates"
<div v-if="isLiot" class="col-span-2 xl:col-span-3"> :label="t('transport.carriers.form.main.liotPlates')"
<MalioInputText :hint="t('transport.carriers.form.main.liotPlatesHint')"
v-model="main.liotPlates" :required="true"
:mask="LIOT_PLATES_MASK" :readonly="mainLocked"
:label="t('transport.carriers.form.main.liotPlates')" :error="mainErrors.errors.liotPlates"
:hint="t('transport.carriers.form.main.liotPlatesHint')" />
:required="true"
:disabled="mainLocked"
:error="mainErrors.errors.liotPlates"
/>
</div>
<!-- Cas standard : certification + affretement + champs conditionnels. --> <!-- Cas standard : certification + affretement + champs conditionnels. -->
<template v-if="!isLiot"> <template v-if="!isLiot">
@@ -52,7 +45,7 @@
:label="t('transport.carriers.form.main.certificationType')" :label="t('transport.carriers.form.main.certificationType')"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:disabled="certificationReadonly" :readonly="certificationReadonly"
:error="mainErrors.errors.certificationType" :error="mainErrors.errors.certificationType"
@update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)"
/> />
@@ -68,7 +61,7 @@
:label="t('transport.carriers.form.main.discharge')" :label="t('transport.carriers.form.main.discharge')"
accept="application/pdf,image/*" accept="application/pdf,image/*"
:required="true" :required="true"
:disabled="mainLocked || dischargeUploading" :readonly="mainLocked || dischargeUploading"
:clearable="true" :clearable="true"
:error="mainErrors.errors.dischargeDocument" :error="mainErrors.errors.dischargeDocument"
@update:model-value="(v: string) => dischargeFileName = v" @update:model-value="(v: string) => dischargeFileName = v"
@@ -85,7 +78,7 @@
id="carrier-is-chartered" id="carrier-is-chartered"
:label="t('transport.carriers.form.main.isChartered')" :label="t('transport.carriers.form.main.isChartered')"
:model-value="main.isChartered" :model-value="main.isChartered"
:disabled="mainLocked" :readonly="mainLocked"
:reserve-message-space="false" :reserve-message-space="false"
@update:model-value="(val: boolean) => main.isChartered = val" @update:model-value="(val: boolean) => main.isChartered = val"
/> />
@@ -105,7 +98,7 @@
icon-name="mdi:percent" icon-name="mdi:percent"
icon-position="right" icon-position="right"
:required="true" :required="true"
:disabled="mainLocked" :readonly="mainLocked"
:error="mainErrors.errors.indexationRate" :error="mainErrors.errors.indexationRate"
@update:model-value="onIndexationInput" @update:model-value="onIndexationInput"
/> />
@@ -141,7 +134,7 @@
:model-value="main.volumeM3" :model-value="main.volumeM3"
:label="t('transport.carriers.form.main.volumeM3')" :label="t('transport.carriers.form.main.volumeM3')"
:required="true" :required="true"
:disabled="mainLocked" :readonly="mainLocked"
:error="mainErrors.errors.volumeM3" :error="mainErrors.errors.volumeM3"
@update:model-value="(v: string) => main.volumeM3 = sanitizeDecimal(v)" @update:model-value="(v: string) => main.volumeM3 = sanitizeDecimal(v)"
/> />
@@ -181,7 +174,7 @@
<CarrierAddressBlock <CarrierAddressBlock
:model-value="address" :model-value="address"
:country-options="countryOptions" :country-options="countryOptions"
:disabled="isQualimat || isValidated('addresses')" :readonly="isQualimat || isValidated('addresses')"
:errors="addressErrors" :errors="addressErrors"
@update:model-value="(v) => address = v" @update:model-value="(v) => address = v"
@degraded="onAddressDegraded" @degraded="onAddressDegraded"
@@ -199,8 +192,8 @@
</div> </div>
</template> </template>
<!-- Onglet Contacts (ERP-168) : un bloc par contact (bloc optionnel, <!-- Onglet Contacts (ERP-168) : un bloc par contact (RG-4.08 ≥ 1 champ,
ERP-193 ; max 2 téléphones). Erreurs 422 par ligne. --> max 2 téléphones). Erreurs 422 par ligne. -->
<template #contacts> <template #contacts>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<CarrierContactBlock <CarrierContactBlock
@@ -208,7 +201,7 @@
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:disabled="isValidated('contacts')" :readonly="isValidated('contacts')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
@@ -244,7 +237,7 @@
:supplier-options="supplierOptions" :supplier-options="supplierOptions"
:site-options="siteOptions" :site-options="siteOptions"
:removable="!isValidated('prices')" :removable="!isValidated('prices')"
:disabled="isValidated('prices')" :readonly="isValidated('prices')"
:errors="priceErrors[index]" :errors="priceErrors[index]"
@update:model-value="(v) => prices[index] = v" @update:model-value="(v) => prices[index] = v"
@remove="askRemovePrice(index)" @remove="askRemovePrice(index)"
@@ -314,8 +307,7 @@ import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.
import CarrierQualimatTab from '~/modules/transport/components/CarrierQualimatTab.vue' import CarrierQualimatTab from '~/modules/transport/components/CarrierQualimatTab.vue'
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm' import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch' import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
import { clampPercent, LIOT_PLATES_MASK, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput' import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
import { FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
interface SelectOption { interface SelectOption {
value: string value: string
@@ -1,8 +1,6 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { import {
canEditCarrier, canEditCarrier,
carrierConsultationVisibleTabs,
hasAddressData,
iriOf, iriOf,
labelOfRelation, labelOfRelation,
mapAddressToDraft, mapAddressToDraft,
@@ -27,10 +25,6 @@ describe('carrierMappers', () => {
expect(iriOf(undefined)).toBeNull() expect(iriOf(undefined)).toBeNull()
}) })
it('labelOfRelation : companyName (client/fournisseur) prioritaire sur name/adresse', () => {
expect(labelOfRelation({ '@id': '/api/suppliers/8', companyName: 'AAAAAAA', name: 'X' })).toBe('AAAAAAA')
})
it('labelOfRelation : name (site) à défaut adresse condensée', () => { it('labelOfRelation : name (site) à défaut adresse condensée', () => {
expect(labelOfRelation({ '@id': '/api/sites/1', name: 'Châtellerault' })).toBe('Châtellerault') expect(labelOfRelation({ '@id': '/api/sites/1', name: 'Châtellerault' })).toBe('Châtellerault')
expect(labelOfRelation({ '@id': '/api/client_addresses/8', street: '1 rue X', postalCode: '86000', city: 'Poitiers' })).toBe('1 rue X · 86000 · Poitiers') expect(labelOfRelation({ '@id': '/api/client_addresses/8', street: '1 rue X', postalCode: '86000', city: 'Poitiers' })).toBe('1 rue X · 86000 · Poitiers')
@@ -124,47 +118,3 @@ describe('carrierMappers', () => {
expect(showRestoreAction(noArchive, true)).toBe(false) expect(showRestoreAction(noArchive, true)).toBe(false)
}) })
}) })
describe('hasAddressData', () => {
it('faux pour une adresse absente ou entièrement vide', () => {
expect(hasAddressData(null)).toBe(false)
expect(hasAddressData(undefined)).toBe(false)
expect(hasAddressData({ '@id': '/api/carrier_addresses/1', id: 1 })).toBe(false)
})
it('vrai dès qu\'un champ adresse est rempli', () => {
expect(hasAddressData({ '@id': '/api/carrier_addresses/1', id: 1, city: 'Poitiers' })).toBe(true)
expect(hasAddressData({ '@id': '/api/carrier_addresses/1', id: 1, country: 'France' })).toBe(true)
})
})
describe('carrierConsultationVisibleTabs', () => {
it('retourne [] tant que le transporteur n\'est pas chargé', () => {
expect(carrierConsultationVisibleTabs(null)).toEqual([])
expect(carrierConsultationVisibleTabs(undefined)).toEqual([])
})
it('masque les onglets vides (transporteur minimal)', () => {
expect(carrierConsultationVisibleTabs({ '@id': '/api/carriers/1', id: 1, name: 'LIOT' })).toEqual([])
})
it('affiche addresses/contacts/prices dans l\'ordre quand renseignés', () => {
const carrier: CarrierDetail = {
'@id': '/api/carriers/1', id: 1,
address: { '@id': '/api/carrier_addresses/1', id: 1, city: 'Poitiers' },
contacts: [{ '@id': '/api/carrier_contacts/1', id: 1 }],
prices: [{ '@id': '/api/carrier_prices/1', id: 1 }],
}
expect(carrierConsultationVisibleTabs(carrier)).toEqual(['addresses', 'contacts', 'prices'])
})
it('ne garde que les onglets non vides (contacts seulement)', () => {
const carrier: CarrierDetail = {
'@id': '/api/carriers/1', id: 1,
address: { '@id': '/api/carrier_addresses/1', id: 1 },
contacts: [{ '@id': '/api/carrier_contacts/1', id: 1 }],
prices: [],
}
expect(carrierConsultationVisibleTabs(carrier)).toEqual(['contacts'])
})
})
@@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { Mask } from 'maska' import { clampPercent, sanitizeDecimal } from '../numberInput'
import { clampPercent, LIOT_PLATES_MASK, sanitizeDecimal } from '../numberInput'
describe('numberInput — saisie volume / indexation (ERP-170)', () => { describe('numberInput — saisie volume / indexation (ERP-170)', () => {
it('sanitizeDecimal : ne garde que chiffres + un seul point', () => { it('sanitizeDecimal : ne garde que chiffres + un seul point', () => {
@@ -20,14 +19,4 @@ describe('numberInput — saisie volume / indexation (ERP-170)', () => {
expect(clampPercent('12,5')).toBe('12,5') // ≤ 100 → inchangé expect(clampPercent('12,5')).toBe('12,5') // ≤ 100 → inchangé
expect(clampPercent('')).toBe('') expect(clampPercent('')).toBe('')
}) })
it('LIOT_PLATES_MASK : garde lettres/chiffres/tiret/point-virgule, bloque espaces et reste', () => {
// Reproduit ce que fait maska au runtime (MaskInput) : preProcess puis masked.
const masked = (v: string) => new Mask(LIOT_PLATES_MASK).masked(LIOT_PLATES_MASK.preProcess!(v))
expect(masked('AB-123-CD;EF-456-GH')).toBe('AB-123-CD;EF-456-GH')
expect(masked('ab-123-cd ; ef-456-gh')).toBe('ab-123-cd;ef-456-gh') // espaces retirés
expect(masked('AB 123 CD')).toBe('AB123CD') // espaces retirés
expect(masked('AB.123/CD#42&²²')).toBe('AB123CD42') // . / # & ² retirés
expect(masked('')).toBe('')
})
}) })
@@ -1,10 +1,10 @@
/** /**
* Helpers purs de l'onglet Contact transporteur (M4 Transport, ERP-168). ERP-193 * Helpers purs de l'onglet Contact transporteur (M4 Transport, ERP-168) ALIGNÉ
* (retour métier) : l'onglet Contact n'est plus obligatoire la règle « prénom OU * sur `providerContact.ts` (M3) / les autres modules : mêmes règles de validité et
* nom » est retirée. Le gating « + Nouveau contact » repose désormais sur « le * de gating « + Nouveau contact » (un contact est « nommé » dès qu'il porte un
* dernier bloc n'est pas vide » (et non plus « nommé »). Spécificité M4 conservée : * prénom OU un nom). Seule spécificité M4 conservée : les téléphones partent au back
* les téléphones partent au back dans le tableau virtuel `phones` (max 2), mappés * dans le tableau virtuel `phones` (max 2), mappés par le CarrierContactProcessor.
* par le CarrierContactProcessor. Testables sans Vue ni API. * Testables sans Vue ni API.
*/ */
import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm' import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm'
@@ -30,6 +30,15 @@ export function isCarrierContactBlank(contact: CarrierContactFormDraft): boolean
].some(isFilled) ].some(isFilled)
} }
/**
* Un contact est « nommé » (valide) dès qu'il porte un prénom OU un nom aligné
* sur M1/M2/M3. Pilote le gating « + Nouveau contact » : la fonction / le téléphone
* / l'email seuls ne suffisent pas pour ajouter un nouveau bloc.
*/
export function isCarrierContactNamed(contact: CarrierContactFormDraft): boolean {
return isFilled(contact.firstName) || isFilled(contact.lastName)
}
/** /**
* Payload de la sous-ressource contacts (groupe `carrier:write:contacts`). Les * Payload de la sous-ressource contacts (groupe `carrier:write:contacts`). Les
* chaînes vides partent à null (le serveur normalise/trim). Les téléphones sont * chaînes vides partent à null (le serveur normalise/trim). Les téléphones sont
@@ -97,18 +97,13 @@ export function iriOf(relation: Relation): string | null {
} }
/** /**
* Libellé d'affichage d'une relation embarquée : `companyName` (client/fournisseur) * Libellé d'affichage d'une relation embarquée : `name` (site) à défaut une adresse
* à défaut `name` (site), à défaut une adresse condensée (voie · CP · ville). Chaîne * condensée (voie · CP · ville). Chaîne vide si la relation est un IRI nu / absente.
* vide si la relation est un IRI nu / absente.
*/ */
export function labelOfRelation(relation: Relation): string { export function labelOfRelation(relation: Relation): string {
if (!relation || typeof relation === 'string') { if (!relation || typeof relation === 'string') {
return '' return ''
} }
const companyName = relation.companyName as string | undefined
if (companyName) {
return companyName
}
const name = relation.name as string | undefined const name = relation.name as string | undefined
if (name) { if (name) {
return name return name
@@ -180,62 +175,6 @@ export function mapPriceToDraft(price: CarrierPriceRead): CarrierPriceFormDraft
} }
} }
/**
* Vrai si une valeur scalaire porte une donnée affichable (non null/undefined,
* et non chaîne vide après trim). Sert aux prédicats « onglet vide » ci-dessous.
*/
function hasValue(value: unknown): boolean {
if (value === null || value === undefined) {
return false
}
if (typeof value === 'string') {
return value.trim() !== ''
}
return true
}
/**
* Vrai si l'adresse unique (OneToOne, ERP-172) porte au moins un champ rempli.
* Un objet adresse vide (toutes clés nulles) est considéré comme « pas d'adresse ».
*/
export function hasAddressData(address: CarrierAddressRead | null | undefined): boolean {
if (!address) {
return false
}
return [
address.postalCode,
address.city,
address.street,
address.streetComplement,
address.country,
].some(hasValue)
}
/**
* Onglets visibles en consultation (ERP-193, retour métier) : on masque tout
* onglet de données vide. Le transporteur n'a pas de coquille « à venir ».
* Ordre : Adresses · Contacts · Prix. Retourne `[]` tant que le transporteur
* n'est pas chargé.
*/
export function carrierConsultationVisibleTabs(
carrier: CarrierDetail | null | undefined,
): string[] {
if (!carrier) {
return []
}
const visible: string[] = []
if (hasAddressData(carrier.address)) {
visible.push('addresses')
}
if ((carrier.contacts ?? []).length > 0) {
visible.push('contacts')
}
if ((carrier.prices ?? []).length > 0) {
visible.push('prices')
}
return visible
}
/** Bouton « Modifier » : visible avec la permission `manage` (Admin / Bureau). */ /** Bouton « Modifier » : visible avec la permission `manage` (Admin / Bureau). */
export function canEditCarrier(can: (code: string) => boolean): boolean { export function canEditCarrier(can: (code: string) => boolean): boolean {
return can('transport.carriers.manage') return can('transport.carriers.manage')
@@ -1,9 +1,7 @@
/** /**
* Helpers de saisie du formulaire principal transporteur (ERP-170). * Helpers de saisie numérique du formulaire principal transporteur (ERP-170).
* Champs texte restreints (volume m³ décimal, indexation plafonnée, immatriculations * Champs texte restreints (volume m³ décimal, indexation plafonnée). Purs / testables.
* LIOT via mask maska). Purs / testables.
*/ */
import type { MaskInputOptions } from 'maska'
/** /**
* Restreint une saisie à un nombre décimal : chiffres + UN seul point (RG volume m³, * Restreint une saisie à un nombre décimal : chiffres + UN seul point (RG volume m³,
@@ -28,20 +26,3 @@ export function clampPercent(value: string): string {
const n = Number(String(value ?? '').replace(',', '.').replace(/\s/g, '')) const n = Number(String(value ?? '').replace(',', '.').replace(/\s/g, ''))
return (!Number.isNaN(n) && n > 100) ? '100' : value return (!Number.isNaN(n) && n > 100) ? '100' : value
} }
/**
* Mask maska des immatriculations LIOT : n'autorise que lettres, chiffres, tiret et
* point-virgule (séparateur de plaques), longueur libre. Filtrage NATIF (maska gère
* le focus et le curseur, contrairement à un nettoyage manuel). Espaces et tout autre
* caractère sont bloqués à la frappe / au collage. La normalisation finale (majuscules
* + « ; » espacé) reste au back (RG-4.13).
*
* `preProcess` retire d'abord tout caractère interdit (espaces, &, ², …) OÙ QU'IL
* SOIT (le masque positionnel seul s'arrêterait au 1er caractère invalide) ; le
* token `P` en `multiple: true` laisse ensuite passer le reste (longueur libre).
*/
export const LIOT_PLATES_MASK: MaskInputOptions = {
mask: 'P',
tokens: { P: { pattern: /[A-Za-z0-9;-]/, multiple: true } },
preProcess: (value: string) => value.replace(/[^A-Za-z0-9;-]/g, ''),
}
+4 -4
View File
@@ -7,7 +7,7 @@
"name": "starseed-frontend", "name": "starseed-frontend",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.7.15", "@malio/layer-ui": "^1.7.12",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -1866,9 +1866,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@malio/layer-ui": { "node_modules/@malio/layer-ui": {
"version": "1.7.15", "version": "1.7.12",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.15/layer-ui-1.7.15.tgz", "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.12/layer-ui-1.7.12.tgz",
"integrity": "sha512-CgEC0l2pkR6rlzpi1zZqswHs+/yGTSd861tdT678/wSKtQPQ6JxUIf63ugFDItyvyLW+nbcNWuHTFC2Bimp1EQ==", "integrity": "sha512-ezQLqi19K2ogI3XwSMsUyluU9x5C4W0tu1muxFbL3foKjibRYRg/FdvySivEhEsalAAt1E88V6Sv/06xPqyYTw==",
"dependencies": { "dependencies": {
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui" "test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.7.15", "@malio/layer-ui": "^1.7.12",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -22,12 +22,11 @@ describe('removeCollectionRow', () => {
const errors: Record<string, string>[] = [{}, {}] const errors: Record<string, string>[] = [{}, {}]
const deleteRow = vi.fn().mockResolvedValue(undefined) const deleteRow = vi.fn().mockResolvedValue(undefined)
const onError = vi.fn() const onError = vi.fn()
const onSuccess = vi.fn()
const removed = await removeCollectionRow({ const removed = await removeCollectionRow({
rows, errors, index: 0, rows, errors, index: 0,
endpoint: '/client_contacts', endpoint: '/client_contacts',
deleteRow, makeEmpty, onError, onSuccess, deleteRow, makeEmpty, onError,
}) })
expect(deleteRow).toHaveBeenCalledOnce() expect(deleteRow).toHaveBeenCalledOnce()
@@ -36,8 +35,6 @@ describe('removeCollectionRow', () => {
expect(rows).toEqual([{ id: 11, label: 'B' }]) expect(rows).toEqual([{ id: 11, label: 'B' }])
expect(errors).toHaveLength(1) expect(errors).toHaveLength(1)
expect(onError).not.toHaveBeenCalled() expect(onError).not.toHaveBeenCalled()
// Toast de succes uniquement sur suppression serveur confirmee.
expect(onSuccess).toHaveBeenCalledOnce()
}) })
it('ne fait AUCUN appel reseau pour un bloc jamais persiste (id null) — retrait local', async () => { it('ne fait AUCUN appel reseau pour un bloc jamais persiste (id null) — retrait local', async () => {
@@ -45,19 +42,16 @@ describe('removeCollectionRow', () => {
const errors: Record<string, string>[] = [{}, {}] const errors: Record<string, string>[] = [{}, {}]
const deleteRow = vi.fn().mockResolvedValue(undefined) const deleteRow = vi.fn().mockResolvedValue(undefined)
const onError = vi.fn() const onError = vi.fn()
const onSuccess = vi.fn()
const removed = await removeCollectionRow({ const removed = await removeCollectionRow({
rows, errors, index: 1, rows, errors, index: 1,
endpoint: '/client_contacts', endpoint: '/client_contacts',
deleteRow, makeEmpty, onError, onSuccess, deleteRow, makeEmpty, onError,
}) })
expect(deleteRow).not.toHaveBeenCalled() expect(deleteRow).not.toHaveBeenCalled()
expect(removed).toBe(true) expect(removed).toBe(true)
expect(rows).toEqual([{ id: 10, label: 'A' }]) expect(rows).toEqual([{ id: 10, label: 'A' }])
// Retrait d'un simple brouillon local : pas de toast « supprime ».
expect(onSuccess).not.toHaveBeenCalled()
}) })
it('conserve le bloc et remonte l\'erreur si le DELETE serveur echoue (ex. 409 dernier RIB LCR)', async () => { it('conserve le bloc et remonte l\'erreur si le DELETE serveur echoue (ex. 409 dernier RIB LCR)', async () => {
@@ -1,23 +0,0 @@
import { describe, it, expect } from 'vitest'
import { isFilled } from '../consultationDisplay'
describe('isFilled (masquage des champs vides en consultation, ERP-193)', () => {
it('considere VIDE : null / undefined / chaine vide ou espaces / tableau vide / false', () => {
expect(isFilled(null)).toBe(false)
expect(isFilled(undefined)).toBe(false)
expect(isFilled('')).toBe(false)
expect(isFilled(' ')).toBe(false)
expect(isFilled([])).toBe(false)
// Case a cocher non cochee => masquee.
expect(isFilled(false)).toBe(false)
})
it('considere REMPLI : chaine non vide / tableau non vide / nombre (y compris 0) / true / objet', () => {
expect(isFilled('Dupont')).toBe(true)
expect(isFilled(['/api/sites/1'])).toBe(true)
expect(isFilled(0)).toBe(true)
expect(isFilled(42)).toBe(true)
expect(isFilled(true)).toBe(true)
expect(isFilled({ '@id': '/api/x/1' })).toBe(true)
})
})
@@ -1,19 +0,0 @@
import { describe, expect, it } from 'vitest'
import { todayIso } from '../date'
describe('todayIso', () => {
it('formate la date locale en YYYY-MM-DD (zero-pad mois/jour)', () => {
// 7 mars 2026 (heure locale) -> '2026-03-07'.
expect(todayIso(new Date(2026, 2, 7, 10, 30))).toBe('2026-03-07')
})
it('utilise les composantes LOCALES, pas UTC (pas de decalage de minuit)', () => {
// 18 juin 2026 23:30 heure locale : la date locale reste le 18 meme si
// toISOString() (UTC) basculerait au 19 selon le fuseau.
expect(todayIso(new Date(2026, 5, 18, 23, 30))).toBe('2026-06-18')
})
it('gere le dernier jour de l\'annee', () => {
expect(todayIso(new Date(2026, 11, 31, 12, 0))).toBe('2026-12-31')
})
})
@@ -1,65 +0,0 @@
import { describe, expect, it } from 'vitest'
import { Mask, type MaskInputOptions } from 'maska'
import {
ADDRESS_MASK,
CODE_ALNUM_MASK,
FREE_TEXT_MASK,
PERSON_NAME_MASK,
} from '../textSanitize'
/** Reproduit le traitement maska au runtime (MaskInput) : preProcess puis masked. */
function apply(mask: MaskInputOptions, value: string): string {
const pre = mask.preProcess ? mask.preProcess(value) : value
return new Mask(mask).masked(pre)
}
describe('PERSON_NAME_MASK', () => {
it('garde lettres accentuees, espace, apostrophe, tiret, point', () => {
expect(apply(PERSON_NAME_MASK, 'Jean-Pierre')).toBe('Jean-Pierre')
expect(apply(PERSON_NAME_MASK, 'OBrien')).toBe('OBrien')
expect(apply(PERSON_NAME_MASK, "D'Angelo")).toBe("D'Angelo")
expect(apply(PERSON_NAME_MASK, 'Saint-Étienne J.')).toBe('Saint-Étienne J.')
})
it('retire chiffres et caracteres parasites (ou qu\'ils soient)', () => {
expect(apply(PERSON_NAME_MASK, 'Dupont²³')).toBe('Dupont')
expect(apply(PERSON_NAME_MASK, 'Jean§&#~|')).toBe('Jean')
expect(apply(PERSON_NAME_MASK, 'Ma§rie123')).toBe('Marie') // parasite AU MILIEU
})
})
describe('FREE_TEXT_MASK', () => {
it('garde &, /, parentheses, degre, chiffres', () => {
expect(apply(FREE_TEXT_MASK, 'Dupont & Fils')).toBe('Dupont & Fils')
expect(apply(FREE_TEXT_MASK, 'Resp. Achats/Ventes')).toBe('Resp. Achats/Ventes')
expect(apply(FREE_TEXT_MASK, 'SARL Léon (Pôle n°2)')).toBe('SARL Léon (Pôle n°2)')
})
it('retire les parasites ²³§~#|', () => {
expect(apply(FREE_TEXT_MASK, 'ACME²³§')).toBe('ACME')
expect(apply(FREE_TEXT_MASK, 'Te~#|st<>{}')).toBe('Test')
})
})
describe('ADDRESS_MASK', () => {
it('garde chiffres, virgule, point, apostrophe, slash, degre, tiret', () => {
expect(apply(ADDRESS_MASK, '12 bis, rue de l’Église')).toBe('12 bis, rue de l’Église')
expect(apply(ADDRESS_MASK, 'Bât. n°3 - Zone A/B')).toBe('Bât. n°3 - Zone A/B')
})
it('retire les parasites', () => {
expect(apply(ADDRESS_MASK, '5 rue X²³§&')).toBe('5 rue X')
})
})
describe('CODE_ALNUM_MASK', () => {
it('force la majuscule et ne garde que A-Z 0-9', () => {
expect(apply(CODE_ALNUM_MASK, '411dupont')).toBe('411DUPONT')
expect(apply(CODE_ALNUM_MASK, 'FR 12 345')).toBe('FR12345')
expect(apply(CODE_ALNUM_MASK, '4-11.000§')).toBe('411000')
})
it('chaine vide reste vide', () => {
expect(apply(CODE_ALNUM_MASK, '')).toBe('')
})
})
+1 -13
View File
@@ -33,12 +33,6 @@ export interface RemoveCollectionRowOptions<T extends DeletableRow> {
makeEmpty: () => T makeEmpty: () => T
/** Remontee d'erreur 409/422 mappee proprement (message back, pas de toast fourre-tout). */ /** Remontee d'erreur 409/422 mappee proprement (message back, pas de toast fourre-tout). */
onError: (error: unknown) => void onError: (error: unknown) => void
/**
* Callback de succes (toast) appele UNIQUEMENT apres une suppression serveur
* confirmee d'un bloc persiste (`id` non null). Pas appele sur le simple retrait
* d'un brouillon local non enregistre (aucune suppression reelle).
*/
onSuccess?: () => void
} }
/** /**
@@ -61,9 +55,8 @@ export interface RemoveCollectionRowOptions<T extends DeletableRow> {
export async function removeCollectionRow<T extends DeletableRow>( export async function removeCollectionRow<T extends DeletableRow>(
options: RemoveCollectionRowOptions<T>, options: RemoveCollectionRowOptions<T>,
): Promise<boolean> { ): Promise<boolean> {
const { rows, errors, index, endpoint, deleteRow, makeEmpty, onError, onSuccess } = options const { rows, errors, index, endpoint, deleteRow, makeEmpty, onError } = options
const removed = rows[index] const removed = rows[index]
let serverDeleted = false
// Bloc existant : suppression serveur d'abord, retrait local seulement si OK. // Bloc existant : suppression serveur d'abord, retrait local seulement si OK.
if (removed?.id != null) { if (removed?.id != null) {
@@ -74,7 +67,6 @@ export async function removeCollectionRow<T extends DeletableRow>(
onError(error) onError(error)
return false return false
} }
serverDeleted = true
} }
rows.splice(index, 1) rows.splice(index, 1)
@@ -83,9 +75,5 @@ export async function removeCollectionRow<T extends DeletableRow>(
if (rows.length === 0) { if (rows.length === 0) {
rows.push(makeEmpty()) rows.push(makeEmpty())
} }
// Toast de succes uniquement quand le serveur a confirme une vraie suppression.
if (serverDeleted) {
onSuccess?.()
}
return true return true
} }
@@ -1,37 +0,0 @@
/**
* Helpers d'affichage en CONSULTATION (lecture seule).
*
* Decision metier (ERP-193) : en consultation, on masque les champs non remplis
* (et les cases a cocher non cochees) pour ne montrer que l'information saisie.
* Mutualise entre modules (fournisseur, prestataire, client, transporteur) : la
* meme regle « vide » s'applique partout.
*/
/**
* Indique si une valeur est « remplie » (donc a afficher en consultation).
*
* Sont consideres VIDES (donc masques) :
* - null / undefined
* - chaine vide ou composee uniquement d'espaces
* - tableau vide (multiselect / cases a cocher sans selection)
* - booleen `false` (case a cocher non cochee)
*
* Restent AFFICHES : tout nombre (y compris 0, qui est une valeur saisie), les
* objets non nuls, et toute chaine non vide.
*/
export function isFilled(value: unknown): boolean {
if (value === null || value === undefined) {
return false
}
if (typeof value === 'string') {
return value.trim() !== ''
}
if (Array.isArray(value)) {
return value.length > 0
}
if (typeof value === 'boolean') {
return value
}
return true
}
-17
View File
@@ -1,17 +0,0 @@
/**
* Helpers de date purs / testables (partages inter-modules).
*/
/**
* Date du jour au format ISO `YYYY-MM-DD` en heure LOCALE.
*
* On NE passe PAS par `toISOString()` (UTC) : pres de minuit, le decalage de
* fuseau (FR = UTC+1/+2) renverrait la veille ou le lendemain. On lit donc les
* composantes locales. Parametre `now` injectable pour les tests.
*/
export function todayIso(now: Date = new Date()): string {
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
-47
View File
@@ -1,47 +0,0 @@
/**
* Masks de saisie texte (retour metier ERP-193) : filtrage NATIF (maska) des
* caracteres parasites (« ²³§~#| ») dans les champs texte libres. maska gere le
* focus et le curseur (contrairement a un nettoyage manuel sur @update qui laissait
* le caractere affiche jusqu'a la frappe suivante).
*
* Miroir FRONT des patterns back `App\Shared\Domain\Validation\TextInputPattern`
* (allow-list par famille de champ). Le back reste l'autorite (Assert\Regex
* 422 inline via useFormErrors) ; ces masks ne font que le confort de saisie.
*
* IMPORTANT : garder les classes de caracteres STRICTEMENT alignees sur le back.
*
* L'EMAIL n'a PAS de mask (decision ERP-101 : un email n'a pas de structure fixe,
* on valide le FORMAT via Assert\Email + erreur inline, jamais via un masque).
*/
import type { MaskInputOptions } from 'maska'
/**
* Construit un mask maska « jeu de caracteres autorise, longueur libre » :
* - `preProcess` retire d'abord TOUT caractere hors charset, OU QU'IL SOIT (un
* masque positionnel seul s'arreterait au 1er caractere invalide car le token
* `multiple` est glouton) ;
* - le token `P` (`multiple`) laisse ensuite passer le reste, sans limite de longueur.
*
* @param pattern classe des caracteres AUTORISES (1 caractere, sans flag global)
* @param strip negation de `pattern`, flag global (retire les interdits)
* @param upper force la majuscule (codes : n° compte / TVA / IBAN / BIC)
*/
function charsetMask(pattern: RegExp, strip: RegExp, upper = false): MaskInputOptions {
return {
mask: 'P',
tokens: { P: { pattern, multiple: true } },
preProcess: (v: string) => (upper ? v.toUpperCase() : v).replace(strip, ''),
}
}
/** Noms de personnes (Nom, Prenom, Dirigeant) : lettres (accents), espace, apostrophe, tiret, point. */
export const PERSON_NAME_MASK = charsetMask(/[\p{L}\p{M} '.-]/u, /[^\p{L}\p{M} '.-]/gu)
/** Texte societe / libre (Raison sociale, Concurrents, Fonction) : + chiffres, virgule, &, /, parentheses, degre. */
export const FREE_TEXT_MASK = charsetMask(/[\p{L}\p{M}0-9 '.,&/()°-]/u, /[^\p{L}\p{M}0-9 '.,&/()°-]/gu)
/** Adresse (voie, complement, ville) : lettres, chiffres, espace, apostrophe, point, virgule, slash, degre, tiret. */
export const ADDRESS_MASK = charsetMask(/[\p{L}\p{M}0-9 '.,/°-]/u, /[^\p{L}\p{M}0-9 '.,/°-]/gu)
/** Codes alphanumeriques majuscules (N° de compte, N° de TVA, IBAN, BIC) : A-Z et 0-9, majuscule forcee. */
export const CODE_ALNUM_MASK = charsetMask(/[A-Z0-9]/, /[^A-Z0-9]/g, true)
+1 -1
View File
@@ -75,7 +75,7 @@ COPY infra/prod/maintenance.html /var/www/html/public/maintenance.html
RUN echo "APP_ENV=prod" > /var/www/html/.env RUN echo "APP_ENV=prod" > /var/www/html/.env
# Permissions # Permissions
RUN mkdir -p /var/www/html/var /var/www/html/var/log /var/www/html/config/jwt \ RUN mkdir -p /var/www/html/var /var/www/html/config/jwt \
&& chown -R www-data:www-data /var/www/html/var && chown -R www-data:www-data /var/www/html/var
WORKDIR /var/www/html WORKDIR /var/www/html
-47
View File
@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-193 (retour metier) l'onglet Contact d'un transporteur n'est plus
* obligatoire : suppression de la garde « prenom OU nom » (ex RG-4.08). Drop du
* CHECK chk_carrier_contact_name et mise a jour des commentaires de colonnes. La
* garde applicative (CarrierContactProcessor::validateName) est retiree dans le
* meme commit ; le catalogue ColumnCommentsCatalog aussi.
*
* Placee au namespace racine DoctrineMigrations (et non en modulaire Transport) :
* elle ALTERE une table creee par une migration racine (Version20260615150000),
* comme la migration qui avait introduit le CHECK (Version20260617120000) ; le tri
* par version au sein du meme namespace garantit qu'elle joue APRES (cf. CLAUDE.md
* regle 11 le tri cross-namespace casserait l'ordre sur base vide).
*/
final class Version20260619120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-193 : onglet Contact transporteur non obligatoire — drop du CHECK chk_carrier_contact_name.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE carrier_contact DROP CONSTRAINT chk_carrier_contact_name');
$this->addSql('COMMENT ON TABLE carrier_contact IS $_$Contacts d un transporteur (1:n) — onglet Contact (M4). Bloc optionnel (ERP-193) ; max 2 telephones.$_$');
$this->addSql('COMMENT ON COLUMN carrier_contact.first_name IS $_$Prenom du contact (capitalise serveur). Optionnel (ERP-193).$_$');
$this->addSql('COMMENT ON COLUMN carrier_contact.last_name IS $_$Nom du contact (capitalise serveur). Optionnel (ERP-193).$_$');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE carrier_contact ADD CONSTRAINT chk_carrier_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)');
$this->addSql('COMMENT ON TABLE carrier_contact IS $_$Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins le prenom OU le nom rempli (RG-4.08, chk_carrier_contact_name), max 2 telephones.$_$');
$this->addSql('COMMENT ON COLUMN carrier_contact.first_name IS $_$Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN carrier_contact.last_name IS $_$Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).$_$');
}
}
-74
View File
@@ -1,74 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-193 (retours metier M3) : suppression du champ « Categorie » du BLOC ADRESSE
* prestataire (ProviderAddress) fonctionnalite jugee inutile cote metier. Seule
* la categorie du PRESTATAIRE lui-meme (table provider_category) est conservee.
*
* Drop de la table de jointure M2M provider_address_category (creee par
* Version20260612100000). Migration au namespace racine DoctrineMigrations (et non
* modulaire Technique) : elle DEPEND d une table creee au namespace racine et doit
* donc s executer APRES sur base vide. Le tri cross-namespace de Doctrine Migrations
* est alphabetique par FQCN (cf. regle ABSOLUE n°11) : une migration modulaire
* « App\... » trierait AVANT « DoctrineMigrations\... » et passerait le DROP avant
* le CREATE (table recreee a la fin). Rester en racine garantit l ordre par version.
*/
final class Version20260622100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-193 : suppression de la categorie du bloc adresse prestataire (drop provider_address_category).';
}
public function up(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS provider_address_category');
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE provider_address_category (
provider_address_id INT NOT NULL,
category_id INT NOT NULL,
PRIMARY KEY (provider_address_id, category_id),
CONSTRAINT fk_provider_address_category_address
FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_address_category_category
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
)
SQL);
$this->comment('provider_address_category', '_table', 'Jointure M2M provider_address <-> category — categories d adresse de type PRESTATAIRE (RG-3.09).');
$this->comment('provider_address_category', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.');
$this->comment('provider_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type PRESTATAIRE (RG-3.09).');
}
/**
* Pose un COMMENT ON TABLE/COLUMN en dollar-quoting Postgres ($_$...$_$)
* pour eviter tout echappement d'apostrophes dans les descriptions.
*/
private function comment(string $table, string $column, string $description): void
{
$quotedTable = '"'.str_replace('"', '""', $table).'"';
if ('_table' === $column) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
return;
}
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
$quotedTable,
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
@@ -19,7 +19,6 @@ use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -163,7 +162,6 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
#[ORM\Column(length: 180)] #[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 180, minMessage: 'Le nom de l\'entreprise doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom de l\'entreprise ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(min: 2, max: 180, minMessage: 'Le nom de l\'entreprise doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom de l\'entreprise ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['client:read', 'client:write:main'])] #[Groups(['client:read', 'client:write:main'])]
private ?string $companyName = null; private ?string $companyName = null;
@@ -216,14 +214,11 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['client:read', 'client:write:information'])] #[Groups(['client:read', 'client:write:information'])]
private ?string $competitors = null; private ?string $competitors = null;
#[ORM\Column(type: 'date_immutable', nullable: true)] #[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['client:read', 'client:write:information'])] #[Groups(['client:read', 'client:write:information'])]
// Date de creation jamais dans le futur (retour metier ERP-193) : <= aujourd'hui.
#[Assert\LessThanOrEqual(value: 'today', message: 'La date de création ne peut pas être dans le futur.')]
// Format d'ENTREE strict ISO `Y-m-d` (le `!` remet l'heure a 00:00:00). Sans // Format d'ENTREE strict ISO `Y-m-d` (le `!` remet l'heure a 00:00:00). Sans
// ce format, PHP DateTime accepte des formes ambigues : « 12/25/2026 » (que // ce format, PHP DateTime accepte des formes ambigues : « 12/25/2026 » (que
// le front MalioDate juge invalide en JJ/MM/AAAA) serait sinon interprete en // le front MalioDate juge invalide en JJ/MM/AAAA) serait sinon interprete en
@@ -238,16 +233,12 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
#[Groups(['client:read', 'client:write:information'])] #[Groups(['client:read', 'client:write:information'])]
private ?int $employeesCount = null; private ?int $employeesCount = null;
// Chiffre d'affaires plafonne a 999 999 999 999,99 (12 chiffres, retour metier
// ERP-193). La colonne (decimal 15,2) tolere plus, mais le metier borne la saisie.
#[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
#[Assert\LessThanOrEqual(value: 999_999_999_999.99, message: 'Le chiffre d\'affaires ne peut pas dépasser 999 999 999 999,99.')]
#[Groups(['client:read', 'client:write:information'])] #[Groups(['client:read', 'client:write:information'])]
private ?string $revenueAmount = null; private ?string $revenueAmount = null;
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['client:read', 'client:write:information'])] #[Groups(['client:read', 'client:write:information'])]
private ?string $directorName = null; private ?string $directorName = null;
@@ -266,7 +257,6 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
#[ORM\Column(length: 40, nullable: true)] #[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
#[Groups(['client:read:accounting', 'client:write:accounting'])] #[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?string $accountNumber = null; private ?string $accountNumber = null;
@@ -277,7 +267,6 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
#[ORM\Column(length: 40, nullable: true)] #[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
#[Groups(['client:read:accounting', 'client:write:accounting'])] #[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?string $nTva = null; private ?string $nTva = null;
@@ -19,7 +19,6 @@ use App\Shared\Domain\Contract\ClientAddressInterface;
use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
@@ -159,20 +158,17 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
#[ORM\Column(length: 120)] #[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private ?string $city = null; private ?string $city = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private ?string $street = null; private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private ?string $streetComplement = null; private ?string $streetComplement = null;
@@ -16,7 +16,6 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
@@ -95,19 +94,16 @@ class ClientContact implements TimestampableInterface, BlamableInterface
// deux restent nullable au niveau ORM. // deux restent nullable au niveau ORM.
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['client_contact:read', 'client_contact:write'])] #[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $firstName = null; private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['client_contact:read', 'client_contact:write'])] #[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $lastName = null; private ?string $lastName = null;
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['client_contact:read', 'client_contact:write'])] #[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $jobTitle = null; private ?string $jobTitle = null;
@@ -19,7 +19,6 @@ use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SupplierInterface; use App\Shared\Domain\Contract\SupplierInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -172,7 +171,6 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
#[ORM\Column(length: 180)] #[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'Le nom du fournisseur est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'Le nom du fournisseur est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 180, minMessage: 'Le nom du fournisseur doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom du fournisseur ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(min: 2, max: 180, minMessage: 'Le nom du fournisseur doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom du fournisseur ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['supplier:read', 'supplier:write:main'])] #[Groups(['supplier:read', 'supplier:write:main'])]
private ?string $companyName = null; private ?string $companyName = null;
@@ -197,14 +195,11 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['supplier:read', 'supplier:write:information'])] #[Groups(['supplier:read', 'supplier:write:information'])]
private ?string $competitors = null; private ?string $competitors = null;
#[ORM\Column(type: 'date_immutable', nullable: true)] #[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['supplier:read', 'supplier:write:information'])] #[Groups(['supplier:read', 'supplier:write:information'])]
// Date de creation jamais dans le futur (retour metier ERP-193) : <= aujourd'hui.
#[Assert\LessThanOrEqual(value: 'today', message: 'La date de création ne peut pas être dans le futur.')]
// Format d'ENTREE strict ISO `Y-m-d` : sans lui, PHP DateTime accepte des // Format d'ENTREE strict ISO `Y-m-d` : sans lui, PHP DateTime accepte des
// formes ambigues (« 12/25/2026 », jugee invalide par MalioDate en JJ/MM/AAAA, // formes ambigues (« 12/25/2026 », jugee invalide par MalioDate en JJ/MM/AAAA,
// serait lue en M/J -> 25 decembre et acceptee a tort). Avec le format, toute // serait lue en M/J -> 25 decembre et acceptee a tort). Avec le format, toute
@@ -217,16 +212,12 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
#[Groups(['supplier:read', 'supplier:write:information'])] #[Groups(['supplier:read', 'supplier:write:information'])]
private ?int $employeesCount = null; private ?int $employeesCount = null;
// Chiffre d'affaires plafonne a 999 999 999 999,99 (12 chiffres, retour metier
// ERP-193). La colonne (decimal 15,2) tolere plus, mais le metier borne la saisie.
#[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
#[Assert\LessThanOrEqual(value: 999_999_999_999.99, message: 'Le chiffre d\'affaires ne peut pas dépasser 999 999 999 999,99.')]
#[Groups(['supplier:read', 'supplier:write:information'])] #[Groups(['supplier:read', 'supplier:write:information'])]
private ?string $revenueAmount = null; private ?string $revenueAmount = null;
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['supplier:read', 'supplier:write:information'])] #[Groups(['supplier:read', 'supplier:write:information'])]
private ?string $directorName = null; private ?string $directorName = null;
@@ -252,7 +243,6 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
#[ORM\Column(length: 40, nullable: true)] #[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
#[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
private ?string $accountNumber = null; private ?string $accountNumber = null;
@@ -263,7 +253,6 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
#[ORM\Column(length: 40, nullable: true)] #[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
#[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
private ?string $nTva = null; private ?string $nTva = null;
@@ -19,7 +19,6 @@ use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SupplierAddressInterface; use App\Shared\Domain\Contract\SupplierAddressInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
@@ -155,20 +154,17 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
#[ORM\Column(length: 120)] #[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])] #[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private ?string $city = null; private ?string $city = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])] #[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private ?string $street = null; private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])] #[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private ?string $streetComplement = null; private ?string $streetComplement = null;
@@ -16,7 +16,6 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
@@ -100,19 +99,16 @@ class SupplierContact implements TimestampableInterface, BlamableInterface
// deux restent nullable au niveau ORM. // deux restent nullable au niveau ORM.
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['supplier:item:read', 'supplier:write:contacts'])] #[Groups(['supplier:item:read', 'supplier:write:contacts'])]
private ?string $firstName = null; private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['supplier:item:read', 'supplier:write:contacts'])] #[Groups(['supplier:item:read', 'supplier:write:contacts'])]
private ?string $lastName = null; private ?string $lastName = null;
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['supplier:item:read', 'supplier:write:contacts'])] #[Groups(['supplier:item:read', 'supplier:write:contacts'])]
private ?string $jobTitle = null; private ?string $jobTitle = null;
@@ -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; public function save(WeighingTicket $ticket): void;
/** /**
* QueryBuilder de SELECTION (recherche + tri + fetch-join client/supplier/site) * QueryBuilder de SELECTION (recherche + tri) pour la liste, exploite par le
* pour la liste, exploite par le WeighingTicketProvider (ERP-185) qui le wrappe * WeighingTicketProvider (ERP-185) qui le wrappe dans un Paginator (règle
* dans un Paginator (règle ABSOLUE n°13). Exclut les soft-deletes (deleted_at * ABSOLUE n°13). Exclut les soft-deletes (deleted_at IS NOT NULL). Tri par
* IS NOT NULL). Tri par defaut number DESC (plus recents en tete, § 4.1). * defaut number DESC (plus recents en tete, § 4.1).
* *
* Le cloisonnement par site courant n'est PAS applique ici : un provider custom * Le cloisonnement par site courant n'est PAS applique ici : il l'est
* court-circuite le SiteScopedQueryExtension (qui n'agit que dans le provider * automatiquement par le SiteScopedQueryExtension (Sites, § 2.3).
* ORM standard), donc le WeighingTicketProvider l'applique lui-meme (§ 2.3).
* *
* @param null|string $search recherche fuzzy sur number, nom client/fournisseur, * @param null|string $search recherche fuzzy sur number, nom client/fournisseur,
* other_label et immatriculation (§ 4.1) * other_label et immatriculation (§ 4.1)
@@ -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);
}
}
@@ -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 public function createListQueryBuilder(?string $search = null): QueryBuilder
{ {
// Fetch-join (addSelect) des relations ManyToOne client / supplier / site : // Left-join des contreparties pour la recherche par nom (sans cartesien
// sert a la fois la recherche par nom et l'anti-N+1 a la serialisation // dangereux : ManyToOne). Le cloisonnement par site courant est ajoute
// (§ 4.0 / § 4.1) — aucune demultiplication de lignes (cardinalite to-one). // par le SiteScopedQueryExtension (§ 2.3). Tri par defaut number DESC.
// 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.
$qb = $this->createQueryBuilder('wt') $qb = $this->createQueryBuilder('wt')
->leftJoin('wt.client', 'c')->addSelect('c') ->leftJoin('wt.client', 'c')
->leftJoin('wt.supplier', 's')->addSelect('s') ->leftJoin('wt.supplier', 's')
->leftJoin('wt.site', 'st')->addSelect('st')
->andWhere('wt.deletedAt IS NULL') ->andWhere('wt.deletedAt IS NULL')
->orderBy('wt.number', 'DESC') ->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);
}
}
@@ -22,7 +22,6 @@ use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -157,7 +156,6 @@ class Provider implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 180)] #[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'Le nom du prestataire est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'Le nom du prestataire est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 180, minMessage: 'Le nom du prestataire doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom du prestataire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(min: 2, max: 180, minMessage: 'Le nom du prestataire doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom du prestataire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['provider:read', 'provider:write:main'])] #[Groups(['provider:read', 'provider:write:main'])]
private ?string $companyName = null; private ?string $companyName = null;
@@ -202,7 +200,6 @@ class Provider implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 40, nullable: true)] #[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])] #[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $accountNumber = null; private ?string $accountNumber = null;
@@ -213,7 +210,6 @@ class Provider implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 40, nullable: true)] #[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])] #[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $nTva = null; private ?string $nTva = null;
@@ -278,7 +274,8 @@ class Provider implements TimestampableInterface, BlamableInterface
/** /**
* RG-3.09 : toute categorie posee sur le prestataire doit etre de type * RG-3.09 : toute categorie posee sur le prestataire doit etre de type
* PRESTATAIRE -> sinon 422 avec violation sur le champ `categories` * PRESTATAIRE -> sinon 422 avec violation sur le champ `categories`
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur * (propertyPath aligne ERP-101, message FR ERP-107). Miroir de
* ProviderAddress::validateCategoryType. S'appuie sur
* CategoryInterface::getCategoryTypeCodes() (multi-type la categorie est * CategoryInterface::getCategoryTypeCodes() (multi-type la categorie est
* acceptee des qu'elle PORTE le type PRESTATAIRE ; pas d'import du module * acceptee des qu'elle PORTE le type PRESTATAIRE ; pas d'import du module
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API * Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API
@@ -14,26 +14,30 @@ use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderAddr
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider; use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/** /**
* Adresse d'un prestataire (1:n) onglet Adresse. Version SIMPLIFIEE de * Adresse d'un prestataire (1:n) onglet Adresse. Version SIMPLIFIEE de
* SupplierAddress : PAS de address_type (PROSPECT/DEPART/RENDU), PAS de bennes, * SupplierAddress : PAS de address_type (PROSPECT/DEPART/RENDU), PAS de bennes,
* PAS de triage_provider (champs specifiques fournisseur). Champs : country / * PAS de triage_provider (champs specifiques fournisseur). Champs : country /
* postal_code / city / street / street_complement + M2M sites / contacts. * postal_code / city / street / street_complement + M2M sites / contacts /
* categories.
* *
* Relations M2M : * Relations M2M :
* - sites : SiteInterface (module Sites) via resolve_target_entities au moins * - sites : SiteInterface (module Sites) via resolve_target_entities au moins
* un site obligatoire (RG-3.05, Assert\Count). Site n'a pas de `code`. * un site obligatoire (RG-3.05, Assert\Count). Site n'a pas de `code`.
* - contacts : ProviderContact (meme module). * - contacts : ProviderContact (meme module).
* - categories : CategoryInterface (module Catalog) via resolve_target_entities
* type PRESTATAIRE attendu (RG-3.09, Assert\Callback validateCategoryType).
* *
* Embarquee sous `provider.addresses` au detail (groupe provider:item:read, * Embarquee sous `provider.addresses` au detail (groupe provider:item:read,
* maillon (a)). * maillon (a)).
@@ -45,7 +49,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* - GET /api/provider_addresses/{id} : lecture unitaire (security view) la lecture * - GET /api/provider_addresses/{id} : lecture unitaire (security view) la lecture
* courante reste via le parent. Pas de GET collection autonome. * courante reste via le parent. Pas de GET collection autonome.
* Tout passe par le ProviderAddressProcessor (rattachement parent + cloisonnement * Tout passe par le ProviderAddressProcessor (rattachement parent + cloisonnement
* d'ecriture des sites, § 2.13). Les regles RG-3.05/3.06 sont portees par les * d'ecriture des sites, § 2.13). Les regles RG-3.05/3.06/3.09 sont portees par les
* contraintes de l'entite (jouees avant le processor). * contraintes de l'entite (jouees avant le processor).
* *
* Audite (#[Auditable]) + Timestampable / Blamable. * Audite (#[Auditable]) + Timestampable / Blamable.
@@ -54,9 +58,9 @@ use Symfony\Component\Validator\Constraints as Assert;
operations: [ operations: [
new Get( new Get(
security: "is_granted('technique.providers.view')", security: "is_granted('technique.providers.view')",
// site:read : embarque les Site lies (maillon (c)) plutot que des IRI // site:read + category:read : embarquent les Site / Category lies
// nus dans le retour. // (maillon (c)) plutot que des IRI nus dans le retour.
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'default:read']], normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre. // Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
provider: ProviderSubResourceItemProvider::class, provider: ProviderSubResourceItemProvider::class,
), ),
@@ -71,13 +75,13 @@ use Symfony\Component\Validator\Constraints as Assert;
// manuellement par ProviderAddressProcessor::linkParent (404 si absent). // manuellement par ProviderAddressProcessor::linkParent (404 si absent).
read: false, read: false,
security: "is_granted('technique.providers.manage')", security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'default:read']], normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
denormalizationContext: ['groups' => ['provider:write:addresses']], denormalizationContext: ['groups' => ['provider:write:addresses']],
processor: ProviderAddressProcessor::class, processor: ProviderAddressProcessor::class,
), ),
new Patch( new Patch(
security: "is_granted('technique.providers.manage')", security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'default:read']], normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
denormalizationContext: ['groups' => ['provider:write:addresses']], denormalizationContext: ['groups' => ['provider:write:addresses']],
provider: ProviderSubResourceItemProvider::class, provider: ProviderSubResourceItemProvider::class,
processor: ProviderAddressProcessor::class, processor: ProviderAddressProcessor::class,
@@ -97,6 +101,13 @@ class ProviderAddress implements TimestampableInterface, BlamableInterface, Prov
{ {
use TimestampableBlamableTrait; use TimestampableBlamableTrait;
/**
* RG-3.09 : seules les categories PORTANT ce type sont autorisees sur une
* adresse prestataire. S'appuie sur CategoryInterface::getCategoryTypeCodes()
* (pas d'import du module Catalog regle ABSOLUE n°1).
*/
private const string REQUIRED_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
@@ -124,20 +135,17 @@ class ProviderAddress implements TimestampableInterface, BlamableInterface, Prov
#[ORM\Column(length: 120)] #[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['provider:item:read', 'provider:write:addresses'])] #[Groups(['provider:item:read', 'provider:write:addresses'])]
private ?string $city = null; private ?string $city = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['provider:item:read', 'provider:write:addresses'])] #[Groups(['provider:item:read', 'provider:write:addresses'])]
private ?string $street = null; private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['provider:item:read', 'provider:write:addresses'])] #[Groups(['provider:item:read', 'provider:write:addresses'])]
private ?string $streetComplement = null; private ?string $streetComplement = null;
@@ -163,10 +171,46 @@ class ProviderAddress implements TimestampableInterface, BlamableInterface, Prov
#[Groups(['provider:item:read', 'provider:write:addresses'])] #[Groups(['provider:item:read', 'provider:write:addresses'])]
private Collection $contacts; private Collection $contacts;
// RG-3.09 : au moins une categorie de type PRESTATAIRE par adresse (le type est
// controle par validateCategoryType ; le minimum par Assert\Count, miroir sites).
/** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'provider_address_category')]
#[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
#[Groups(['provider:item:read', 'provider:write:addresses'])]
private Collection $categories;
public function __construct() public function __construct()
{ {
$this->sites = new ArrayCollection(); $this->sites = new ArrayCollection();
$this->contacts = new ArrayCollection(); $this->contacts = new ArrayCollection();
$this->categories = new ArrayCollection();
}
/**
* RG-3.09 : toute categorie posee sur une adresse prestataire doit etre de
* type PRESTATAIRE -> sinon 422 avec violation sur le champ `categories`
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
* CategoryInterface::getCategoryTypeCodes() (multi-type la categorie est
* acceptee des qu'elle PORTE le type PRESTATAIRE ; pas d'import du module
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
*/
#[Assert\Callback]
public function validateCategoryType(ExecutionContextInterface $context): void
{
foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
$context->buildViolation('Type de catégorie non autorisé (PRESTATAIRE attendu).')
->atPath('categories')
->addViolation()
;
return;
}
}
} }
public function getId(): ?int public function getId(): ?int
@@ -301,4 +345,26 @@ class ProviderAddress implements TimestampableInterface, BlamableInterface, Prov
return $this; return $this;
} }
/** @return Collection<int, CategoryInterface> */
public function getCategories(): Collection
{
return $this->categories;
}
public function addCategory(CategoryInterface $category): static
{
if (!$this->categories->contains($category)) {
$this->categories->add($category);
}
return $this;
}
public function removeCategory(CategoryInterface $category): static
{
$this->categories->removeElement($category);
return $this;
}
} }
@@ -16,7 +16,6 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
@@ -103,19 +102,16 @@ class ProviderContact implements TimestampableInterface, BlamableInterface, Prov
// champs restent nullable au niveau ORM. // champs restent nullable au niveau ORM.
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['provider:item:read', 'provider:write:contacts'])] #[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $firstName = null; private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['provider:item:read', 'provider:write:contacts'])] #[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $lastName = null; private ?string $lastName = null;
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['provider:item:read', 'provider:write:contacts'])] #[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $jobTitle = null; private ?string $jobTitle = null;
@@ -33,7 +33,8 @@ use Symfony\Component\Validator\ConstraintViolationList;
* sites de l'adresse (RG-3.05 / § 2.13). Les regles de l'onglet Adresse sont * sites de l'adresse (RG-3.05 / § 2.13). Les regles de l'onglet Adresse sont
* garanties en amont par des contraintes sur l'entite, jouees par API Platform * garanties en amont par des contraintes sur l'entite, jouees par API Platform
* avant ce processor : RG-3.06 (code postal, Assert\Regex), RG-3.05 (>= 1 site, * avant ce processor : RG-3.06 (code postal, Assert\Regex), RG-3.05 (>= 1 site,
* Assert\Count). * Assert\Count), RG-3.09 (categorie de type PRESTATAIRE, Assert\Callback
* ProviderAddress::validateCategoryType).
* - DELETE : aucune regle metier specifique (suppression physique directe). * - DELETE : aucune regle metier specifique (suppression physique directe).
* *
* La security de l'operation (technique.providers.manage) est appliquee par API * La security de l'operation (technique.providers.manage) est appliquee par API
@@ -63,7 +63,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
class ProviderFixtures extends Fixture implements DependentFixtureInterface class ProviderFixtures extends Fixture implements DependentFixtureInterface
{ {
/** /**
* Type de categorie exige pour un prestataire (RG-3.09). * Type de categorie exige pour un prestataire et ses adresses (RG-3.09).
* Miroir de Provider::REQUIRED_CATEGORY_TYPE_CODE (non importable regle n°1). * Miroir de Provider::REQUIRED_CATEGORY_TYPE_CODE (non importable regle n°1).
*/ */
private const string PROVIDER_CATEGORY_TYPE_CODE = 'PRESTATAIRE'; private const string PROVIDER_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
@@ -117,7 +117,7 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
$maintenance->setPaymentType($this->paymentType($manager, 'VIREMENT')); $maintenance->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$maintenance->setBank($this->bank($manager, 'SG')); $maintenance->setBank($this->bank($manager, 'SG'));
$this->addContact($maintenance, 'Marie', 'Martin', 'Responsable', '05 49 00 00 01', null, 'marie.martin@maintenance-pro.fr'); $this->addContact($maintenance, 'Marie', 'Martin', 'Responsable', '05 49 00 00 01', null, 'marie.martin@maintenance-pro.fr');
$this->addAddress($maintenance, ['Chatellerault', 'Saint-Jean'], '86000', 'Poitiers', '12 rue des Acacias'); $this->addAddress($maintenance, ['Chatellerault', 'Saint-Jean'], '86000', 'Poitiers', '12 rue des Acacias', categoryNames: ['Maintenance industrielle']);
$this->addRib($maintenance, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0); $this->addRib($maintenance, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0);
} }
@@ -140,7 +140,7 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
$transport->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION')); $transport->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
$transport->setPaymentType($this->paymentType($manager, 'CHEQUE')); $transport->setPaymentType($this->paymentType($manager, 'CHEQUE'));
$this->addContact($transport, 'Thomas', 'Petit', 'Responsable logistique', '05 56 31 32 33', null, 'thomas.petit@transport-express.fr'); $this->addContact($transport, 'Thomas', 'Petit', 'Responsable logistique', '05 56 31 32 33', null, 'thomas.petit@transport-express.fr');
$this->addAddress($transport, ['Saint-Jean'], '17400', 'Fontenet', '4 zone des Transporteurs'); $this->addAddress($transport, ['Saint-Jean'], '17400', 'Fontenet', '4 zone des Transporteurs', categoryNames: ['Transport']);
} }
// === Prestataire minimal — contact par le seul nom (RG-3.04), site 86 === // === Prestataire minimal — contact par le seul nom (RG-3.04), site 86 ===
@@ -237,7 +237,8 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
* moins un site est rattache (RG-3.05) ; categories d'adresse de type * moins un site est rattache (RG-3.05) ; categories d'adresse de type
* PRESTATAIRE (RG-3.09). * PRESTATAIRE (RG-3.09).
* *
* @param list<string> $siteNames au moins un site (RG-3.05) * @param list<string> $siteNames au moins un site (RG-3.05)
* @param list<string> $categoryNames categories de type PRESTATAIRE (RG-3.09)
*/ */
private function addAddress( private function addAddress(
Provider $provider, Provider $provider,
@@ -246,6 +247,7 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
string $city, string $city,
string $street, string $street,
?string $streetComplement = null, ?string $streetComplement = null,
array $categoryNames = [],
int $position = 0, int $position = 0,
): void { ): void {
$address = new ProviderAddress(); $address = new ProviderAddress();
@@ -260,6 +262,9 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
foreach ($siteNames as $siteName) { foreach ($siteNames as $siteName) {
$address->addSite($this->site($siteName)); $address->addSite($this->site($siteName));
} }
foreach ($categoryNames as $categoryName) {
$address->addCategory($this->category($this->manager, $categoryName));
}
$provider->addAddress($address); $provider->addAddress($address);
} }
@@ -17,7 +17,6 @@ use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Entity\UploadedDocument; use App\Shared\Domain\Entity\UploadedDocument;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -146,7 +145,6 @@ class Carrier implements TimestampableInterface, BlamableInterface
maxMessage: 'Le nom du transporteur ne peut dépasser {{ limit }} caractères.', maxMessage: 'Le nom du transporteur ne peut dépasser {{ limit }} caractères.',
normalizer: 'trim', normalizer: 'trim',
)] )]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['carrier:read', 'carrier:write:main'])] #[Groups(['carrier:read', 'carrier:write:main'])]
private ?string $name = null; private ?string $name = null;
@@ -15,7 +15,6 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
@@ -124,19 +123,16 @@ class CarrierAddress implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['carrier:item:read', 'carrier:write:addresses'])] #[Groups(['carrier:item:read', 'carrier:write:addresses'])]
private ?string $city = null; private ?string $city = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'L\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'L\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['carrier:item:read', 'carrier:write:addresses'])] #[Groups(['carrier:item:read', 'carrier:write:addresses'])]
private ?string $street = null; private ?string $street = null;
#[ORM\Column(name: 'street_complement', length: 255, nullable: true)] #[ORM\Column(name: 'street_complement', length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['carrier:item:read', 'carrier:write:addresses'])] #[Groups(['carrier:item:read', 'carrier:write:addresses'])]
private ?string $streetComplement = null; private ?string $streetComplement = null;
@@ -15,15 +15,14 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/** /**
* Contact d'un transporteur (1:n) onglet Contact (M4). ERP-193 (retour metier) : * Contact d'un transporteur (1:n) onglet Contact (M4). Jumeau de
* le bloc Contact est OPTIONNEL la garde « prenom OU nom » (ex RG-4.08) est * SupplierContact (M2) : au moins le prenom OU le nom (RG-4.08, garanti par le
* retiree. Reste applicable : max 2 telephones. * CHECK chk_carrier_contact_name + le Processor), max 2 telephones.
* *
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du * Lecture : proprietes en `carrier:item:read` (embarquees au detail du
* transporteur). Ecriture : groupe `carrier:write:contacts`. * transporteur). Ecriture : groupe `carrier:write:contacts`.
@@ -103,19 +102,16 @@ class CarrierContact implements TimestampableInterface, BlamableInterface
// Processor + CHECK BDD). Les colonnes restent nullable au niveau ORM. // Processor + CHECK BDD). Les colonnes restent nullable au niveau ORM.
#[ORM\Column(name: 'first_name', length: 120, nullable: true)] #[ORM\Column(name: 'first_name', length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['carrier:item:read', 'carrier:write:contacts'])] #[Groups(['carrier:item:read', 'carrier:write:contacts'])]
private ?string $firstName = null; private ?string $firstName = null;
#[ORM\Column(name: 'last_name', length: 120, nullable: true)] #[ORM\Column(name: 'last_name', length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['carrier:item:read', 'carrier:write:contacts'])] #[Groups(['carrier:item:read', 'carrier:write:contacts'])]
private ?string $lastName = null; private ?string $lastName = null;
#[ORM\Column(name: 'job_title', length: 120, nullable: true)] #[ORM\Column(name: 'job_title', length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['carrier:item:read', 'carrier:write:contacts'])] #[Groups(['carrier:item:read', 'carrier:write:contacts'])]
private ?string $jobTitle = null; private ?string $jobTitle = null;
@@ -23,19 +23,23 @@ use function is_string;
/** /**
* Processor d'ecriture de la sous-ressource Contact d'un transporteur (M4, * Processor d'ecriture de la sous-ressource Contact d'un transporteur (M4,
* spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le * spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
* perimetre ERP-160. ERP-193 (retour metier) : l'onglet Contact n'est plus * perimetre ERP-160. RG-4.08 (correctif, alignement M1/M2/M3) : un contact exige
* obligatoire la garde « prenom OU nom » (ex RG-4.08) est retiree, un contact * au moins le PRENOM OU le NOM (la fonction / le telephone / l'email seuls ne
* peut donc etre cree sans nom. Le « max 2 telephones » reste une specificite M4. * suffisent pas), porte a la fois par le CHECK BDD chk_carrier_contact_name et par
* ce Processor ; le « max 2 telephones » reste une specificite M4.
* *
* Sequence : * Sequence :
* - POST / PATCH : rattachement au transporteur parent (linkParent), * - POST / PATCH : rattachement au transporteur parent (linkParent),
* normalisation serveur RG-4.13 (prenom/nom Title Case, email lowercase), * normalisation serveur RG-4.13 (prenom/nom Title Case, email lowercase),
* mapping du tableau d'ecriture `phones` -> phonePrimary/phoneSecondary * mapping du tableau d'ecriture `phones` -> phonePrimary/phoneSecondary
* (max 2, chiffres uniquement) avant persistance. * (max 2, chiffres uniquement), puis garde « prenom OU nom » avant persistance.
* - DELETE : aucune regle metier specifique (suppression physique directe). * - DELETE : aucune regle metier specifique (suppression physique directe).
* *
* Le « max 2 telephones » est rattache au champ `phones` : seul point de saisie * La garde « prenom OU nom » vit ICI (double du CHECK BDD) pour transformer une
* des numeros (les colonnes phonePrimary/phoneSecondary sont en lecture seule). * violation SQL (500 generique) en 422 propre rattachee au champ `firstName`
* (mapping inline ERP-101). Le « max 2 telephones » est rattache au champ `phones` : seul
* point de saisie des numeros (les colonnes phonePrimary/phoneSecondary sont en
* lecture seule).
* *
* La security d'operation (transport.carriers.manage) est appliquee par API * La security d'operation (transport.carriers.manage) est appliquee par API
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut * Platform en amont, de meme que la validation Symfony des contraintes d'attribut
@@ -73,6 +77,7 @@ final class CarrierContactProcessor implements ProcessorInterface
$this->linkParent($data, $uriVariables); $this->linkParent($data, $uriVariables);
$this->normalize($data); $this->normalize($data);
$this->applyPhones($data); $this->applyPhones($data);
$this->validateName($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context); return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} }
@@ -110,8 +115,9 @@ final class CarrierContactProcessor implements ProcessorInterface
/** /**
* Normalisation serveur RG-4.13 des champs texte. Toutes les methodes du * Normalisation serveur RG-4.13 des champs texte. Toutes les methodes du
* normalizer sont null-safe : une chaine vide apres trim devient null. Les * normalizer sont null-safe : une chaine vide apres trim devient null (donc la
* telephones sont traites a part (applyPhones). * garde RG-4.08 detecte bien « champ non rempli »). Les telephones sont
* traites a part (applyPhones).
*/ */
private function normalize(CarrierContact $contact): void private function normalize(CarrierContact $contact): void
{ {
@@ -180,6 +186,30 @@ final class CarrierContactProcessor implements ProcessorInterface
$contact->setPhones(null); $contact->setPhones(null);
} }
/**
* RG-4.08 (alignement M1/M2/M3) : un bloc Contact exige au moins le PRENOM OU le
* NOM un contact se materialise par son nom ; fonction / telephone / email
* seuls ne suffisent pas. Double garde avec le CHECK BDD chk_carrier_contact_name
* leve une 422 propre rattachee a `firstName` plutot qu'une 500 SQL. Joue apres
* normalisation + mapping telephones, donc les chaines vides sont deja null.
*/
private function validateName(CarrierContact $contact): void
{
if (null === $contact->getFirstName() && null === $contact->getLastName()) {
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Le prénom ou le nom du contact est obligatoire.',
null,
[],
$contact,
'firstName',
null,
));
throw new ValidationException($violations);
}
}
/** /**
* Trim + chaine vide -> null (la fonction n'est pas normalisee en casse, * Trim + chaine vide -> null (la fonction n'est pas normalisee en casse,
* contrairement aux noms de personne). Evite de persister une chaine vide * contrairement aux noms de personne). Evite de persister une chaine vide
@@ -195,8 +195,7 @@ class CarrierFixtures extends Fixture implements DependentFixtureInterface
/** /**
* Ajoute un contact normalise au transporteur (cascade persist via * Ajoute un contact normalise au transporteur (cascade persist via
* Carrier.contacts). Le bloc Contact est optionnel (ERP-193) ; les fixtures * Carrier.contacts). Prenom OU nom toujours fourni (RG-4.08, chk_carrier_contact_name).
* fournissent neanmoins un nom pour des donnees de demonstration realistes.
*/ */
private function addContact( private function addContact(
Carrier $carrier, Carrier $carrier,
@@ -1,51 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Validation;
/**
* Profils de caracteres autorises pour les champs texte libres (retour metier
* ERP-193 : bloquer les caracteres parasites « ²³§~#| … » sans casser les saisies
* legitimes accents, apostrophe, tiret, &, etc.).
*
* Approche allow-list (pas blacklist) : on definit ce qui est AUTORISE par famille
* de champ, le reste est rejete. Couche AUTORITAIRE (back) : `#[Assert\Regex]` avec
* ces patterns et messages FR ; le front (shared/utils/textSanitize.ts) miroite ces
* memes ensembles en filtrant la saisie a la frappe.
*
* Note : `Assert\Regex` laisse passer null et la chaine vide (champs nullable OK) ;
* seules les valeurs non vides sont controlees.
*/
final class TextInputPattern
{
/**
* Noms de personnes (Nom, Prenom, Dirigeant) : lettres (accents inclus),
* espace, apostrophe droite/courbe, tiret, point. Ni chiffres ni symboles.
*/
public const string PERSON_NAME = '/^[\p{L}\p{M} \'.\-]+$/u';
public const string PERSON_NAME_MESSAGE = 'Ce champ ne peut contenir que des lettres, espaces, apostrophes, tirets et points.';
/**
* Texte societe / libre (Raison sociale, Concurrents, Fonction) : comme un nom
* + chiffres, virgule, esperluette, slash, parentheses, degre (). Couvre
* « Dupont & Fils », « Achats/Ventes », « Pole 2 ».
*/
// 0-9 (et pas \p{N}) : \p{N} engloberait les exposants ² ³ — justement parasites.
public const string FREE_TEXT = '/^[\p{L}\p{M}0-9 \'.,&\/()°\-]+$/u';
public const string FREE_TEXT_MESSAGE = 'Ce champ contient des caractères non autorisés.';
/**
* Adresse (voie, complement, ville) : lettres, chiffres, espace, apostrophe,
* point, virgule, slash, degre, tiret. Couvre « 12 bis, rue de l’Église ».
*/
public const string ADDRESS = '/^[\p{L}\p{M}0-9 \'.,\/°\-]+$/u';
public const string ADDRESS_MESSAGE = 'Cette adresse contient des caractères non autorisés.';
/**
* Codes alphanumeriques majuscules ( de compte comptable, de TVA) :
* uniquement A-Z et 0-9. Le front force la majuscule a la frappe.
*/
public const string CODE_ALNUM = '/^[A-Z0-9]+$/';
public const string CODE_ALNUM_MESSAGE = 'Ce champ ne doit contenir que des lettres majuscules et des chiffres.';
}
@@ -444,6 +444,12 @@ final class ColumnCommentsCatalog
'provider_contact_id' => 'FK -> provider_contact.id, ON DELETE CASCADE — contact associe a l adresse.', 'provider_contact_id' => 'FK -> provider_contact.id, ON DELETE CASCADE — contact associe a l adresse.',
], ],
'provider_address_category' => [
'_table' => 'Jointure M2M provider_address <-> category — categories d adresse de type PRESTATAIRE (RG-3.09).',
'provider_address_id' => 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.',
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type PRESTATAIRE (RG-3.09).',
],
'provider_rib' => [ 'provider_rib' => [
'_table' => 'Coordonnees bancaires d un prestataire (1:n) — >= 1 RIB attendu selon le type de reglement (RG-3.08). Tous les champs audites (pas d AuditIgnore).', '_table' => 'Coordonnees bancaires d un prestataire (1:n) — >= 1 RIB attendu selon le type de reglement (RG-3.08). Tous les champs audites (pas d AuditIgnore).',
'id' => 'Identifiant interne auto-incremente.', 'id' => 'Identifiant interne auto-incremente.',
@@ -503,11 +509,11 @@ final class ColumnCommentsCatalog
] + self::timestampableBlamableComments(), ] + self::timestampableBlamableComments(),
'carrier_contact' => [ 'carrier_contact' => [
'_table' => 'Contacts d un transporteur (1:n) — onglet Contact (M4). Bloc optionnel (ERP-193) ; max 2 telephones.', '_table' => 'Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins le prenom OU le nom rempli (RG-4.08, chk_carrier_contact_name), max 2 telephones.',
'id' => 'Identifiant interne auto-incremente.', 'id' => 'Identifiant interne auto-incremente.',
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du contact.', 'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du contact.',
'first_name' => 'Prenom du contact (capitalise serveur). Optionnel (ERP-193).', 'first_name' => 'Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).',
'last_name' => 'Nom du contact (capitalise serveur). Optionnel (ERP-193).', 'last_name' => 'Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).',
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).', 'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
'phone_primary' => 'Telephone principal — chiffres uniquement (normalisation serveur).', 'phone_primary' => 'Telephone principal — chiffres uniquement (normalisation serveur).',
'phone_secondary' => 'Telephone secondaire — chiffres uniquement (max 2 telephones, RG-4.08).', 'phone_secondary' => 'Telephone secondaire — chiffres uniquement (max 2 telephones, RG-4.08).',
@@ -99,7 +99,6 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
Assert\Positive::class, Assert\Positive::class,
Assert\NegativeOrZero::class, Assert\NegativeOrZero::class,
Assert\Negative::class, Assert\Negative::class,
Assert\LessThanOrEqual::class,
]; ];
public function testEveryConstraintHasAnExplicitFrenchMessage(): void public function testEveryConstraintHasAnExplicitFrenchMessage(): void
@@ -307,9 +306,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
Assert\Length::class => new Assert\Length(max: 1), Assert\Length::class => new Assert\Length(max: 1),
Assert\Count::class => new Assert\Count(min: 1), Assert\Count::class => new Assert\Count(min: 1),
Assert\Regex::class => new Assert\Regex(pattern: '/^x$/'), Assert\Regex::class => new Assert\Regex(pattern: '/^x$/'),
// AbstractComparison exige value|propertyPath des l'instanciation. default => new $class(),
Assert\LessThanOrEqual::class => new Assert\LessThanOrEqual(value: 0),
default => new $class(),
}; };
$value = $bare->{$prop} ?? null; $value = $bare->{$prop} ?? null;
@@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use DateTimeImmutable;
/**
* Validation back-autoritative de la date de creation (foundedAt) sur Client ET
* Fournisseur retour metier ERP-193 : une date dans le futur est refusee.
*
* Le front (MalioDate `:max`) plafonne deja le calendrier a aujourd'hui, mais le
* back reste la couche autoritaire : `Assert\LessThanOrEqual('today')` rejette une
* date future (ISO valide) avec une 422 portee sur `foundedAt` (mappable inline par
* useFormErrors). Une date passee ou egale a aujourd'hui reste acceptee.
*
* @internal
*/
final class FoundedAtFutureTest extends AbstractSupplierApiTestCase
{
/** Client : date de creation future -> 422 portee sur foundedAt. */
public function testClientFoundedAtFuturEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Founded Future SARL');
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => $this->futureDate()],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
}
/** Client : date de creation passee -> acceptee (200). */
public function testClientFoundedAtPasseEst200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Founded Past SARL');
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '2000-06-15'],
]);
self::assertResponseStatusCodeSame(200);
}
/** Fournisseur : date de creation future -> 422 portee sur foundedAt. */
public function testSupplierFoundedAtFuturEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Founded Future Fournisseur SARL');
$body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => $this->futureDate()],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
}
/** Fournisseur : date de creation passee -> acceptee (200). */
public function testSupplierFoundedAtPasseEst200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Founded Past Fournisseur SARL');
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '2000-06-15'],
]);
self::assertResponseStatusCodeSame(200);
}
/** Date ISO clairement dans le futur. */
private function futureDate(): string
{
return new DateTimeImmutable('+1 year')->format('Y-m-d');
}
}

Some files were not shown because too many files have changed in this diff Show More