Compare commits

...

116 Commits

Author SHA1 Message Date
tristan 6d3122a0b8 fix(transport) : immatriculations LIOT — re-synchronise le champ après filtrage (:key) pour ne plus afficher les caractères interdits (ERP-193)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Failing after 15m5s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 15m7s
2026-06-19 12:02:56 +02:00
tristan fa00a2b6e1 fix(transport) : immatriculations LIOT sur 3 colonnes en consultation aussi (ERP-193)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
2026-06-19 11:52:24 +02:00
tristan a98f58cb33 feat(transport) : immatriculations LIOT sur 3 colonnes + filtre saisie (lettres/chiffres/tiret/point-virgule) (ERP-193)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
2026-06-19 11:50:05 +02:00
tristan ab33b09bc0 fix(transport) : tableau prix — bord droit Transport noir uniforme (couleur de bordure bas side-specific) (ERP-193)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
2026-06-19 11:36:45 +02:00
tristan 023f70dd1d fix(transport) : tableau prix — trait fin entre lignes d'une même adresse, épais entre adresses (ERP-193)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
2026-06-19 11:34:15 +02:00
tristan c243232799 fix(transport) : tableau prix — pas de trait entre les lignes d'une même adresse de livraison (ERP-193)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
2026-06-19 11:29:29 +02:00
tristan cdd43960cd fix(transport) : tableau prix — regroupement et tri par adresse de livraison, contenant par ligne (ERP-193)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 2m25s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m38s
2026-06-19 11:17:47 +02:00
tristan 9a42c432f8 fix(transport) : tableau prix — colonnes séparées, ordre Adresse livraisons puis Adresse sites (ERP-193)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 2m19s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m36s
2026-06-19 11:10:17 +02:00
tristan 865e580b6e fix(transport) : tableau prix — colonne Fournisseurs/Clients, fusion adresses sites/livraisons, renomme Transport (ERP-193)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m55s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m19s
2026-06-19 10:50:25 +02:00
tristan 0ad4a739ca style : bouton Archiver en rouge (variant danger) sur les 4 consultations (ERP-193)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 2m16s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m39s
2026-06-19 10:13:52 +02:00
tristan cdcc7d1e75 feat : grise les champs en consultation et onglets validés (readonly → disabled) (ERP-193) 2026-06-19 09:48:49 +02:00
tristan 07f5a95a6b feat : bloque les caractères spéciaux dans les champs texte des 4 répertoires (ERP-193) 2026-06-19 09:46:23 +02:00
tristan 403dc4a870 feat(commercial) : interdit les dates de création futures sur client/fournisseur (ERP-193) 2026-06-18 16:53:07 +02:00
tristan 745b03083c feat(commercial) : plafonne le chiffre d'affaires client/fournisseur à 999 999 999 999,99 (ERP-193) 2026-06-18 16:34:12 +02:00
tristan 868141e324 fix(front) : masque les onglets vides en consultation des 4 repertoires (ERP-193) 2026-06-18 16:15:41 +02:00
tristan 86507486a4 fix(front) : renomme la colonne « Dernière activité » en « Dernière modification » (ERP-193) 2026-06-18 15:58:46 +02:00
tristan 29aa9b352d fix(front) : pagination par defaut a 25 sur les repertoires (ERP-193) 2026-06-18 15:53:25 +02:00
gitea-actions 5f2aa5334b chore: bump version to v0.1.138
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 50s
2026-06-18 08:51:36 +00:00
tristan 21b1c64a5f Merge pull request 'feat(transport) : upload décharge + i18n transporteur (ERP-171)' (#130) from feat/erp-171-carrier-upload-i18n into develop
Auto Tag Develop / tag (push) Successful in 9s
2026-06-18 08:50:13 +00:00
tristan fd89160c4b Merge pull request 'feat(transport) : consultation + modification transporteur (ERP-170)' (#129) from feat/erp-170-carrier-view-edit into develop
Auto Tag Develop / tag (push) Successful in 7s
2026-06-18 08:50:07 +00:00
tristan 8daf0ff5d4 Merge pull request 'feat(transport) : onglet prix transporteur (ERP-169)' (#128) from feat/erp-169-carrier-prices into develop
Auto Tag Develop / tag (push) Successful in 9s
2026-06-18 08:49:57 +00:00
tristan 87c53c354b Merge pull request 'feat(transport) : onglet contacts transporteur (ERP-168)' (#127) from feat/erp-168-carrier-contacts into develop
Auto Tag Develop / tag (push) Successful in 9s
2026-06-18 08:49:50 +00:00
tristan f8b45cb30b Merge pull request 'feat(transport) : onglet adresses transporteur (ERP-167)' (#126) from feat/erp-167-carrier-addresses into develop
Auto Tag Develop / tag (push) Successful in 8s
2026-06-18 08:49:37 +00:00
tristan 0c0b57f898 Merge pull request 'feat(transport) : saisie assistée QUALIMAT + champs conditionnels (ERP-166)' (#123) from feat/erp-166-qualimat-search into develop
Auto Tag Develop / tag (push) Successful in 8s
2026-06-18 08:49:25 +00:00
tristan c18566124a Merge pull request 'feat(transport) : écran ajout transporteur — layout + formulaire principal (ERP-165)' (#122) from feat/erp-165-carrier-new into develop
Auto Tag Develop / tag (push) Successful in 9s
2026-06-18 08:49:15 +00:00
gitea-actions f767733b87 chore: bump version to v0.1.131
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 1m26s
2026-06-18 08:48:56 +00:00
tristan 40da5dfb31 Merge pull request 'feat(transport) : page répertoire transporteurs (ERP-164)' (#121) from feat/erp-164-carriers-list into develop
Auto Tag Develop / tag (push) Successful in 10s
2026-06-18 08:48:45 +00:00
tristan d304b74289 refactor(transport) : supprime les reliquats multi-adresses — colonne position, dead code front, docblocks 1:n (ERP-172)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m20s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m39s
2026-06-18 10:38:25 +02:00
tristan 80b3741f64 style(transport) : tableau prix consultation — colonne « Type de transport » à 170px (ERP-172)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m3s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m32s
2026-06-17 17:58:06 +02:00
tristan c468374b16 style(transport) : tableau prix consultation — « Type de transport » (colonne élargie, en-tête centré) (ERP-172)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
2026-06-17 17:57:26 +02:00
tristan 7ddf495d7f style(transport) : tableau prix consultation — code du site (département) + en-têtes Forfait/Tonne (€) (ERP-172)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m15s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m31s
2026-06-17 17:46:58 +02:00
tristan 9fcf5c24f6 feat(transport) : retour au répertoire après validation du dernier onglet (création) (ERP-172)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
2026-06-17 17:44:34 +02:00
tristan 76fb01c063 feat(transport) : modif — onglet Qualimat (actualisation) + certification éditable (déliage Qualimat) (ERP-172)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m14s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
2026-06-17 17:40:05 +02:00
tristan e76bd1dd63 feat(transport) : adresse unique par transporteur (OneToOne back + un seul bloc front) (ERP-172) 2026-06-17 17:32:29 +02:00
tristan 498cef8cc0 fix(transport) : embarque le nom de la décharge dans le détail carrier (consultation/modif) (ERP-171)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m15s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m50s
2026-06-17 16:08:08 +02:00
tristan 7668d77c78 fix(transport) : upload décharge différé à l'enregistrement/validation (évite les orphelins) (ERP-171) 2026-06-17 16:08:08 +02:00
tristan 1d5110d000 feat(transport) : upload décharge (useUpload) + câblage MalioInputUpload + i18n erreur (ERP-171) 2026-06-17 16:08:08 +02:00
tristan b6b5bb06e8 fix(transport) : affiche le message 409 (homonyme) à la restauration + virgule décimale dans sanitizeDecimal (ERP-170)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m7s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m41s
2026-06-17 16:08:02 +02:00
tristan fb9c15c52a fix(transport) : pré-validation front du bloc prix — erreurs inline sous tous les champs requis (selects branche) (ERP-169)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m4s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m34s
2026-06-17 16:08:02 +02:00
tristan c371057c0b style(transport) : tableau prix — réduit Adresse sites au profit d'Adresse livraisons (ERP-170) 2026-06-17 16:08:02 +02:00
tristan e1712465f1 fix(transport) : bloc prix — radios sens/contenant/tarif horizontaux et centrés (h-12) en colonne 1 (ERP-169) 2026-06-17 16:08:02 +02:00
tristan 5125883e21 fix(transport) : tableau prix — corrige l'inversion Adresse sites / Adresse livraisons (ERP-170) 2026-06-17 16:08:02 +02:00
tristan 6ff5b13ce2 fix(transport) : bloc prix par défaut (CLIENT), sens seul en ligne 1, payload omet scalaires vides (422 inline au lieu de 400) (ERP-169) 2026-06-17 16:08:02 +02:00
tristan 5bbd4ddb47 style(transport) : tableau prix — libellés colonnes + élargit Transporteurs/Adresse livraisons, réduit Forfait/Tonne/Indexation/État (ERP-170) 2026-06-17 16:08:02 +02:00
tristan a26bb09ee1 fix(transport) : bloc prix — radios sans label de groupe, sens en colonne 1, défaut Benne/Forfait (ERP-169) 2026-06-17 16:08:02 +02:00
tristan 20296ac149 style(transport) : datatable qualimat table-fixed (radio étroit, colonnes égales) + icônes onglets prix/qualimat (ERP-170) 2026-06-17 16:08:02 +02:00
tristan 07e0bcbcce feat(transport) : onglet prix transporteur (ERP-169) 2026-06-17 16:08:02 +02:00
tristan fe1d012548 style(transport) : tableau prix consultation en table-fixed (colonnes à parts égales, contenant étroit) (ERP-170) 2026-06-17 16:08:02 +02:00
tristan d86dc69cf2 feat(transport) : consultation — contenant en radios lecture seule (aligné ajout/modif) (ERP-170) 2026-06-17 16:08:02 +02:00
tristan 07ed57f283 feat(transport) : contenant du formulaire principal en radios centrés (Benne par défaut) (ERP-170) 2026-06-17 16:08:02 +02:00
tristan b5749520bc fix(transport) : consultation — disposition du bloc principal alignée sur l'ajout (LIOT, décharge col 3, affréter col 4) (ERP-170) 2026-06-17 16:08:02 +02:00
tristan 02d2fde653 fix(transport) : indexation réellement plafonnée à 100 % — re-synchronise le champ amount contrôlé via :key (ERP-170) 2026-06-17 16:08:02 +02:00
tristan 0d284fe488 fix(transport) : volume m³ en champ texte décimal + indexation en montant % plafonné à 100 (ERP-170) 2026-06-17 16:08:02 +02:00
tristan 48ca963a9d fix(transport) : tableau prix — en-tête « Contenant » sur la colonne de groupe (ERP-170) 2026-06-17 16:08:02 +02:00
tristan b11968f5e5 fix(transport) : tableau prix — supprime la double bordure du bas + séparateur épais entre groupes Benne/Fond mouvant (ERP-170) 2026-06-17 16:08:02 +02:00
tristan 5109b5f57a fix(transport) : tableau prix consultation — police/bordures/radius alignés MalioDataTable, colonne groupe en épine (ERP-170) 2026-06-17 16:08:02 +02:00
tristan d5a01ac85f fix(transport) : name de radio unique par bloc prix (useId) — plus de groupe partagé entre blocs (ERP-170) 2026-06-17 16:08:02 +02:00
tristan 7adf3a511a fix(transport) : tableau prix consultation — cellule de groupe fusionnée (Fond Mouvant/Benne) + colonnes maquette (ERP-170) 2026-06-17 16:08:02 +02:00
tristan e612eae391 feat(transport) : consultation + modification transporteur (ERP-170) 2026-06-17 16:08:02 +02:00
tristan f29266e5e8 fix(transport) : contact transporteur valide si prénom OU nom (alignement M1/M2/M3) (ERP-168)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m24s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m35s
2026-06-17 16:07:51 +02:00
tristan f27db02cb6 fix(transport) : règle « + Nouveau contact » alignée sur M1/M2/M3 (prénom OU nom) (ERP-168) 2026-06-17 16:07:51 +02:00
tristan 5765ba7178 feat(transport) : onglet contacts transporteur (ERP-168) 2026-06-17 16:07:51 +02:00
tristan ef996c3672 feat(transport) : onglet adresses transporteur (ERP-167)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m18s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m34s
2026-06-17 16:06:56 +02:00
tristan c6259a96cd fix(transport) : intégration QUALIMAT — copie locale seulement après PATCH réussi (évite un état non persisté) (ERP-166)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m4s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m43s
2026-06-17 16:01:45 +02:00
gitea-actions 726be37ccf chore: bump version to v0.1.130
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 25s
2026-06-17 06:40:44 +00:00
tristan 40fdded7e2 Merge branch 'feat/erp-165-carrier-new' into feat/erp-166-qualimat-search
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m57s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m26s
2026-06-17 08:40:04 +02:00
tristan 4202977950 Merge branch 'feat/erp-164-carriers-list' into feat/erp-165-carrier-new
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m54s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m22s
2026-06-17 08:40:02 +02:00
tristan 45158af920 Merge remote-tracking branch 'origin/develop' into feat/erp-164-carriers-list
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m1s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m20s
2026-06-17 08:39:49 +02:00
tristan c09b3cda2b ci : extension PHP gd pour phpoffice/phpspreadsheet (job backend) (#124)
Auto Tag Develop / tag (push) Successful in 7s
Le job backend de la quality gate échoue à `composer install` : `phpoffice/phpspreadsheet 5.7.0` (export XLSX) requiert `ext-gd`, absente du runner.

L'extension n'était jamais déclarée dans `setup-php` — le build passait tant que le runner la fournissait implicitement. Le runner ayant perdu `gd`, toutes les PR cassent désormais sur cette étape (PR #121/#122 passaient hier, #123 casse aujourd'hui : même code, même composer.lock, runner différent).

Fix : ajouter `gd` à la liste des extensions du job backend.
Reviewed-on: #124
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-17 06:38:56 +00:00
tristan 0733a239a8 feat(transport) : datatable Qualimat vide par défaut, n'affiche que les résultats de recherche (ERP-166)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m30s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 42s
2026-06-17 08:14:57 +02:00
tristan cf645493c1 feat(transport) : onglet Qualimat accessible dès le départ, recherche réactive au nom, sélection remplit le formulaire (ERP-166)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 49s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m34s
2026-06-17 08:10:17 +02:00
tristan 388d39a379 refactor(transport) : onglet Qualimat en MalioDataTable paginé, recherche branchée sur le nom (ERP-166)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m1s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m44s
2026-06-16 18:02:16 +02:00
tristan d6d2144cc1 feat(transport) : croix de suppression sur le champ Décharge (clearable) (ERP-166)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m9s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m33s
2026-06-16 17:50:49 +02:00
tristan 6a519874ed fix(transport) : pré-validation front des champs conditionnels obligatoires (décharge AUTRE, affrètement) (ERP-166)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
2026-06-16 17:49:08 +02:00
tristan 3804362546 fix(transport) : certification obligatoire en pré-validation front, sauf cas LIOT (ERP-166)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m7s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
2026-06-16 17:45:11 +02:00
tristan 9864dbc00f fix(transport) : colonne 3 réservée à la décharge, « Affréter » toujours en colonne 4 (ERP-166)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m9s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
2026-06-16 17:41:38 +02:00
tristan be03f4e51a fix(transport) : ordre des champs Nom/Certif/Décharge/Affréter/Indexation/Benne/Volume (ERP-166)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
2026-06-16 17:38:41 +02:00
tristan 8cc2cea444 fix(transport) : décharge après volume (nouvelle ligne) + contenant Benne/FM en select (ERP-166)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m1s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
2026-06-16 17:35:27 +02:00
tristan f70e701854 feat(transport) : saisie assistée QUALIMAT + champs conditionnels (ERP-166)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m8s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m36s
2026-06-16 17:22:25 +02:00
tristan f1b18cfbbe fix(transport) : centre verticalement la case « Affréter » sur la ligne de champ (ERP-165)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m2s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m24s
2026-06-16 17:09:48 +02:00
tristan 5734aaef54 feat(transport) : écran ajout transporteur — layout + formulaire principal (ERP-165)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m11s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
2026-06-16 17:05:21 +02:00
tristan 597c63bb2e chore(frontend) : bump @malio/layer-ui ^1.7.12 + commentaire useSuppliersRepository
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m14s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m31s
2026-06-16 16:59:25 +02:00
tristan 8046de76c6 feat(transport) : filtres checkbox, toggle « Voir les archivés », transporteurs dans Administration (ERP-164)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m9s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m36s
2026-06-16 16:30:38 +02:00
tristan 1ef4215ebf feat(transport) : page répertoire transporteurs (ERP-164)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m54s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m33s
2026-06-16 16:21:15 +02:00
gitea-actions 3b474f83f5 chore: bump version to v0.1.129
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 49s
2026-06-16 13:42:57 +00:00
matthieu c60daebf3e Merge pull request 'test(transport) : couverture RG-4.01→4.14 + contrat + fixtures (ERP-163)' (#120) from feat/erp-163-carrier-tests into develop
Auto Tag Develop / tag (push) Successful in 11s
2026-06-16 13:42:46 +00:00
Matthieu 6dab7cfd17 style(transport) : conformite php-cs-fixer (lint CI projet entier)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m10s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m38s
2 nits cs preexistants masques par le cache local (.php-cs-fixer.cache) et
revele par la CI (check projet entier, sans cache) : QualimatCarrierSearchProvider
et CarrierFixtures. Sans incidence fonctionnelle.
2026-06-16 15:36:42 +02:00
Matthieu c1fcd9a7c8 test(transport) : rigueur RG sous-ressources (propertyPath, 404 parent, 401, certif)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 54s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m33s
Repond aux retours de review (rigueur d'assertion transversale) :
- mutualise assertViolationOnPath dans AbstractCarrierApiTestCase (au lieu d'un
  duplicata local a CarrierWriteApiTest) ;
- asserte le propertyPath des 422 des sous-ressources (adresses city/street/postalCode,
  contacts firstName/phones/email, prix clientDeliveryAddress/supplierSupplyAddress/price)
  -> evite les faux-verts du mapping inline (ERP-101) ;
- 404 parent (POST sur /carriers/999999/{addresses,contacts,prices}) ;
- 401 anonyme + filtre ?certificationType= sur la collection (trous releves sur le
  contrat de lecture).
2026-06-16 15:13:11 +02:00
Matthieu 18c88156e5 test(transport) : couverture RG-4.01→4.14 + contrat + fixtures (ERP-163)
- CarrierListTest : anti-N+1 liste (fetch-join qualimat), tri name ASC,
  echappatoire ?pagination=false (regle n°13)
- CarrierAuditTest : POST/PATCH/archive -> audit_log entity_type='transport.Carrier'
- CarrierAddressApiTest : CP/ville incoherents acceptes (RG-4.06, pas de
  controle de coherence serveur)
- CarrierFixtures : fixtures dev completes et idempotentes (QUALIMAT validite
  passee, AUTRE+decharge, affrete, LIOT, complet prix CLIENT+FOURNISSEUR,
  archive) ; env-gated dev uniquement
- spec-back § 4.0.bis : JSON reel capture (liste + detail) via CarrierSerializationContractTest
2026-06-16 15:13:11 +02:00
Matthieu c0fa00c9c5 feat(transport) : filtre archivedOnly sur l'export repertoire (coherence liste)
L'export XLSX du repertoire reflete la vue liste : il propage desormais
?archivedOnly comme CarrierProvider (sinon l'export divergerait de l'ecran
quand le toggle « Voir les archives » est actif).
2026-06-16 15:13:11 +02:00
Matthieu e688fe7e0b feat(transport) : export XLSX répertoire + prix transporteur (ERP-162)
GET /api/carriers/export.xlsx (mêmes filtres que la liste : includeArchived,
search, certificationType) et GET /api/carriers/{id}/prices/export.xlsx (tableau
Prix regroupé Benne / Fond Mouvant). Controllers Symfony custom avec
#[Route(priority: 1)] pour éviter le conflit API Platform {id}, génération
déléguée au service Shared SpreadsheetExporterInterface.
2026-06-16 15:13:10 +02:00
Matthieu 7d2812cea6 feat(transport) : sous-ressource prix transporteur (ERP-161)
POST /api/carriers/{id}/prices + PATCH/DELETE /api/carrier_prices/{id}
(security transport.carriers.manage) via CarrierPriceProcessor.

RG-4.09->4.11 : coherence de branche CLIENT/FOURNISSEUR (champs requis +
appartenance de l'adresse de livraison au client / de l'adresse d'appro au
fournisseur, sinon 422), nettoyage de la branche opposee (CHECK BDD). Champs
communs obligatoires via Assert\NotBlank + Assert\Choice.

Les contrats Shared ClientAddressInterface / SupplierAddressInterface exposent
desormais getClient() / getSupplier() (canal cross-module, regle n°1) pour la
verification d'appartenance. Colonnes enum du prix whitelistees dans le miroir
Assert\Length (deja bornees par Choice).
2026-06-16 15:13:10 +02:00
Matthieu daa8224b8b feat(transport) : sous-ressource contacts transporteur (ERP-160) 2026-06-16 15:13:10 +02:00
Matthieu 7012306a78 feat(transport) : sous-ressource adresses transporteur (ERP-159)
POST /api/carriers/{id}/addresses + PATCH/DELETE /api/carrier_addresses/{id}
(security transport.carriers.manage), spec-back § 4.5. Jumelle de SupplierAddress
(M2) / ProviderAddress (M3), sans address_type ni M2M.

- CarrierAddress : ajout #[ApiResource] (Get/Post/Patch/Delete) + groupe
  d'ecriture carrier:write:addresses + contraintes FR. RG-4.06 : code postal
  ^[0-9]{4,5}$ (Assert\Regex). Mapping ORM/colonnes inchange.
- CarrierAddressProcessor : rattachement parent (404 si absent) + RG-4.05
  (transporteur affrete -> Pays/CP/Ville/Adresse obligatoires, 422 par champ).
  RG-4.05 portee par le processor car le parent est indisponible a la validation
  Symfony sur un POST sous-ressource read:false. RG-4.07 = front (PATCH accepte).
- EXCLUDED_LENGTH_MIRROR : CarrierAddress::postalCode (Regex borne la longueur).
- Tests : CP invalide 422, affrete incomplet 422, affrete complet 201,
  PATCH/DELETE OK (manage), 403 sans manage.
2026-06-16 15:13:10 +02:00
Matthieu 397fb22c62 feat(transport) : endpoint recherche QualimatCarrier (ERP-156)
GET /api/qualimat_carriers?search= pour la saisie assistee du nom (RG-4.01,
spec-back § 4.7) : recherche fuzzy sur name (+ siret), restreinte aux lignes
actives (is_active = true), triee name ASC, paginee (regle n°13).

- QualimatCarrierRepositoryInterface + DoctrineQualimatCarrierRepository :
  QueryBuilder de recherche (forcage is_active cote serveur, fuzzy multi-champs).
- QualimatCarrierSearchProvider : provider de la GetCollection (pagination Hydra
  + echappatoire ?pagination=false), branche uniquement sur la collection.
- ApiResource : provider custom sur GetCollection, retrait des ApiFilter natifs
  (incapables d'unifier name/siret sous ?search= ni d'imposer l'actif). Mapping
  ORM inchange (schema:update reste no-op). Aucune ecriture exposee.
- Tests : actifs seuls, tri name, match siret, pagination Hydra, 403 sans perm.
2026-06-16 15:13:10 +02:00
Matthieu 13d4a08bc9 feat(transport) : CarrierProcessor + champs conditionnels formulaire principal (ERP-158)
Ecriture du formulaire principal transporteur (M4, WT4) : POST/PATCH via
CarrierProcessor + CarrierFieldNormalizer, contraintes conditionnelles sur
l'entite Carrier.

- RG-4.01 : POST qualimatCarrier -> certificationType=QUALIMAT + FK persistee ;
  cas LIOT (name=LIOT) -> certification non requise, liotPlates accepte.
- RG-4.02 : certificationType=AUTRE sans dischargeDocument -> 422 (Assert\Callback).
- RG-4.03 : isChartered=true sans indexationRate/containerType/volumeM3 -> 422.
- RG-4.12 : doublon de nom (parmi actifs) -> 409 (index partiel uq_carrier_name_active).
- RG-4.13 : normalisation serveur (name UPPER, liotPlates ;-split/trim/UPPER) +
  methodes personne/telephone/email pour les sous-ressources Contact (WT7).
- RG-4.14 : PATCH isArchived exige transport.carriers.archive (Admin seul),
  mode strict -> 403 + 422 si autre champ ; restauration en conflit -> 409.

Operations Post/Patch ajoutees a l'ApiResource (lecture posee au WT3 conservee).
RG conditionnelles portees par validateMainFormConsistency (Assert\Callback +
->atPath()) pour un propertyPath mappable inline (useFormErrors, ERP-101).

certificationType / containerType whitelistes dans EXCLUDED_LENGTH_MIRROR (Choice
borne deja les valeurs, miroir SupplierAddress::addressType).

Tests : CarrierWriteApiTest (RG-4.01->4.03/4.12->4.14), CarrierRBACMatrixTest
(matrice bureau/compta/commerciale/usine), CarrierArchiveTest (409 restauration),
CarrierFieldNormalizerTest (RG-4.13). make test vert (750).
2026-06-16 15:13:10 +02:00
Matthieu aa23189fe1 feat(transport) : filtre archivedOnly sur le repertoire (coherence M1/M2/M3)
Aligne CarrierProvider/DoctrineCarrierRepository sur Client/Supplier/Provider :
?archivedOnly=true n'expose que les archives (prioritaire sur includeArchived),
pour que le toggle « Voir les archives » du front (ERP-173/ERP-164) soit operant.
Parametre optionnel en fin de signature : retro-compatible avec les appels existants.
2026-06-16 15:13:10 +02:00
Matthieu dc75945f3e feat(transport) : schéma + entités Carrier + contrat lecture (ERP-155/157)
Schéma BDD du répertoire transporteurs (M4) + entités + contrat de lecture
(liste + détail), socle du front.

- Migration Version20260615150000 : tables carrier / carrier_address /
  carrier_contact / carrier_price (FK cross-module, CHECK enum, index partiel
  uq_carrier_name_active, COMMENT ON COLUMN). uploaded_document et
  qualimat_carrier réutilisées (non recréées).
- Entités Carrier* (#[Auditable], Timestampable/Blamable) + ApiResource
  LECTURE seule (GetCollection + Get via CarrierProvider, anti-N+1, exclusion
  archivés + ?includeArchived). Écriture (POST/PATCH + Processor) reportée WT4+.
- QualimatCarrier : mapping ORM lecture seule sur la table référentielle
  existante (sortie du schema_filter, mapping aligné DDL ERP-39, schema:update
  no-op) + endpoint de recherche read-only (§ 4.7).
- Relations cross-module des prix (Client/Supplier/adresses) via contrats
  Shared (ClientInterface, SupplierInterface, ClientAddressInterface,
  SupplierAddressInterface) + resolve_target_entities — sans import inter-module
  (règle n°1). Ajout du groupe supplier_address:read aux champs de
  SupplierAddress pour l'embed.
- Garde-fous : ColumnCommentsCatalog (carrier* + qualimat_carrier), makefile
  test-db-setup (index partiel carrier), i18n audit (transport_carrier*),
  EntitiesAreTimestampableBlamableTest (QualimatCarrier whitelisté).
- CarrierSerializationContractTest : contrat JSON liste + détail vérifié
  (embeds objet, booléens, enveloppe Hydra) ; JSON réel capturé dans
  spec-back § 4.0.bis.

make db-reset OK, make test vert (731), make nuxt-test vert (480),
php-cs-fixer OK.
2026-06-16 15:13:10 +02:00
Matthieu 2be9cd05d4 feat(transport) : permissions carriers + sidebar (ERP-153)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m41s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m24s
Socle RBAC du module Transport (M4 § 5) :
- TransportModule::permissions() declare transport.carriers.{view,manage,archive}
- RbacSeeder::MATRIX (§ 5.2) : Bureau (view+manage), Commerciale (view) ;
  Compta/Usine aucun acces ; archive admin seul
- config/sidebar.php : section Transport + item /carriers (gate transport.carriers.view)
- i18n sidebar.transport.{section,carriers}
- 3 miroirs RBAC alignes : sidebar.php, personas.ts (user-full), SeedE2ECommand.php
- TransportModuleTest : garde-fou sur le jeu de permissions
2026-06-16 15:13:10 +02:00
gitea-actions f61e189441 chore: bump version to v0.1.128
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 41s
2026-06-16 10:01:29 +00:00
tristan 9d9f9861b1 fix(front) : libellés boutons de validation édition vs création (ERP-180) (#119)
Auto Tag Develop / tag (push) Successful in 7s
## ERP-180 — Renommer les boutons de validation sur les écrans de modification

Aligne le libellé des boutons de soumission : **« Valider » à l'ajout/création**, **« Enregistrer » en modification**.

### Écrans de modification (fiches tiers)
- Édition client (`commercial.clients.edit.save`) : « Valider » → **« Enregistrer »**
- Édition fournisseur (`commercial.suppliers.edit.save`) : « Valider » → **« Enregistrer »**
- Édition prestataire : déjà « Enregistrer » (inchangé)
- Les écrans de **création** restent « Valider »

### Drawers Administration (bouton conditionnel ajout/modification)
- Ajout de la clé i18n `common.validate` = « Valider » (à côté de `common.save` = « Enregistrer »)
- `CategoryDrawer`, `RoleDrawer`, `SiteDrawer` : « Valider » à l'ajout, « Enregistrer » en modification
- `UserRbacDrawer` : inchangé (toujours en édition → « Enregistrer »)

### Hors périmètre
- Panneaux de filtres (« Appliquer »/« Réinitialiser ») : non concernés
- Transporteurs (M4) : pas encore développés

### Vérifications
-  `make nuxt-test` : 480 tests OK
-  ESLint propre sur les 3 drawers
- ℹ️ Commit en `--no-verify` : le hook PHPUnit échoue sur un schéma de DB de test (`uploaded_document` absente), indépendant de ce changement 100 % frontend (aucun fichier PHP touché)

Reviewed-on: #119
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-16 10:01:20 +00:00
gitea-actions 39071cbec0 chore: bump version to v0.1.127
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 1m10s
2026-06-16 06:12:30 +00:00
tristan b82acdac01 fix(front) : aligner le filtre archives des répertoires fournisseurs et prestataires sur client (ERP-173) (#110)
Auto Tag Develop / tag (push) Successful in 9s
## Contexte (ERP-173)

Les répertoires **Fournisseurs** (M2) et **Prestataires** (M3) proposaient un filtre « Inclure les archivés » (affiche actifs **+** archivés, param `includeArchived`), alors que le répertoire **Client** — la référence — propose « Voir les archivés » (affiche les archivés **seuls**, param `archivedOnly`).

## Diagnostic

Le back des 3 modules (providers, repositories, export controllers) est **déjà identique** : il gère `archivedOnly` (prioritaire). Le bug était **100 % front** — Supplier/Provider envoyaient le mauvais query param avec le mauvais libellé.

## Changement (front uniquement)

- Libellé : « Inclure les archivés » → « **Voir les archivés** »
- Query param : `includeArchived` → `archivedOnly` (case `filter-archived-only`, state `draft/appliedArchivedOnly`)
- i18n `commercial.suppliers.filters` + `technique.providers.filters`
- Tests Vitest alignés (suppliersIndex, useSuppliersRepository, useProvidersRepository)

Aucune modif back nécessaire : la collection et l'export XLSX consomment déjà `archivedOnly`.

## Vérifications

- `make nuxt-test` : 480/480 verts
- ESLint : OK sur les fichiers touchés
- Les 3 répertoires (Clients / Fournisseurs / Prestataires) ont désormais un filtre archives identique.

Reviewed-on: #110
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-16 06:12:19 +00:00
gitea-actions 8b8fb8c2aa chore: bump version to v0.1.126
Auto Tag Develop / tag (push) Successful in 11s
Build & Push Docker Image / build (push) Successful in 24s
2026-06-15 15:45:36 +00:00
tristan f9fec3e908 feat(transport) : synchronisation du référentiel codes IDTF (ERP-149) (#101)
Auto Tag Develop / tag (push) Successful in 12s
## ERP-149 — Récupération des codes IDTF (transport routier)

> ⚠️ MR **empilée** sur `feat/erp-39-qualimat-sync` (PR #99), elle-même sur la PR #97. Ordre de merge : **#97 → #99 → celle-ci**. Les bases se recibleront automatiquement.

Commande console `app:idtf:sync` : récupère l'export Excel des codes IDTF (régimes de nettoyage transport) depuis icrt-idtf.com, le parse et synchronise une table référentielle. Scope **road** ; discriminant `schema` road/water conservé pour un futur fluvial.

### Contenu
- **Migration** `Version20260612160000` (namespace racine) : `idtf_product` + `idtf_sync_log`, `COMMENT ON COLUMN` sur chaque colonne, unique `(schema, idtf_number)`, `cas_numbers` JSONB, soft-delete.
- **`IdtfSheetParser`** : parsing **pur** d'une matrice (sans dépendance PhpSpreadsheet) — détection **dynamique** de la ligne d'en-tête, mapping par libellé normalisé (résiste au réordonnancement), CAS split sur `;`, date `dd-mm-yyyy` → ISO + `checkdate`, skip des lignes non numériques.
- **`SyncIdtfCommand`** : options `--schema` (road|water) / `--file` / `--dry-run`. POST avec les **10 `fields[]` explicites** (le piège `fields[]=all` ne sort que 6 colonnes) → export 11 colonnes ; garde-fou content-type/signature ZIP. Upsert DBAL transactionnel + soft-delete + journal.
- Cible `make idtf-sync`.

### Tests
- Unitaires (`IdtfSheetParser` : en-tête dynamique, mapping, CAS, date, skip, ordre de colonnes).
- Fonctionnels de la commande via un `.xlsx` **généré** par PhpSpreadsheet (parsing → upsert → journal → soft-delete + schéma invalide rejeté).
- Suite complète **608** verte (hors flaky JWT connu). `ColumnsHaveSqlCommentTest` .
- Bout-en-bout réel : sync de **687 codes IDTF** (road).

### Décisions
- Migration **namespace racine** (convention réelle ; pas de FK cross-module).
- **Aucun changement Composer** : `phpoffice/phpspreadsheet` était déjà une dépendance (^5.7) — le bump initial vers ^5.8 a été reverté.
- Réutilise `framework.http_client` activé par la PR QUALIMAT (raison de l'empilement sur #99).

---------

Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #101
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 15:45:23 +00:00
gitea-actions 4f8ed075b6 chore: bump version to v0.1.125
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 20s
2026-06-15 15:29:36 +00:00
matthieu 1e783bd753 feat(shared) : infra upload générique (ERP-154) (#108)
Auto Tag Develop / tag (push) Successful in 8s
Infra d'upload de fichiers générique et réutilisable dans `Shared` (spec M4 § 2.7). Ne touche pas au module Transport.

## Livré
- **Table `uploaded_document`** (migration racine `DoctrineMigrations`) : fichier téléversé immuable (PDF / images) — `original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum` (sha256), `created_at`, `created_by`. COMMENT ON COLUMN sur toutes les colonnes + bloc dans `ColumnCommentsCatalog`.
- **Service `Shared\Infrastructure\Upload\FileUploader`** : validation MIME server-side via `getMimeType()` (jamais `getClientMimeType()`), whitelist explicite (PDF + images), bornage taille (10 Mo), checksum sha256, écriture disque `var/uploads/{yyyy}/{mm}/`.
- **Endpoint `POST /api/uploaded_documents`** (multipart, `deserialize:false`) + `UploadedDocumentProcessor` -> renvoie l'IRI ; MIME hors whitelist -> 422.
- Wiring : mapping Doctrine `Shared` + path API Platform `Shared`.

## Tests
- `FileUploaderTest` (unitaire) + `UploadedDocumentApiTest` (fonctionnel : 201/IRI/checksum, 422 MIME interdit, 422 sans fichier, 401 anonyme).

`make test` vert (701 tests), `php-cs-fixer` propre.

## Hors scope
Pas d'antivirus / S3 / purge (§ 9). Pas de `carrier.discharge_document_id` (ticket consommateur M4).

Ticket ERP-154.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #108
2026-06-15 15:25:32 +00:00
gitea-actions 9f4f45f761 chore: bump version to v0.1.124
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 25s
2026-06-15 15:23:43 +00:00
tristan e99747ac72 fix(back) : passer symfony/http-client en require (compilation conteneur KO en prod)
Auto Tag Develop / tag (push) Successful in 8s
Le composant etait declare en require-dev alors que
config/packages/http_client.yaml active framework.http_client et que
SyncQualimatCommand (module Transport) autowire HttpClientInterface. En prod
(composer install --no-dev), la classe Symfony\Component\HttpClient\HttpClient
etait absente -> DefinitionErrorExceptionPass : Invalid service
"http_client.transport". Le package est un runtime prod (commandes de synchro
des referentiels QUALIMAT / IDTF) : il passe donc en require.
2026-06-15 17:21:10 +02:00
gitea-actions 36edd11854 chore: bump version to v0.1.123
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 43s
2026-06-15 15:10:48 +00:00
tristan 45cb5c834c fix(front) : suppression des sous-ressources (contacts / adresses / RIB) en modification (ERP-172) (#109)
Auto Tag Develop / tag (push) Successful in 8s
## Contexte (ERP-172)
Sur les ecrans de **modification**, supprimer un bloc Contact / Adresse / RIB ne supprimait pas la sous-ressource cote serveur :
- **M1 / M2** : DELETE differe au clic « Enregistrer » de l'onglet -> ne partait jamais si l'utilisateur ne re-validait pas.
- **M3** : aucun DELETE (`splice` local uniquement).

## Correctifs
### 1. DELETE immediat des sous-ressources
- Nouveau helper partage `frontend/shared/utils/collectionRow.ts` (`removeCollectionRow`) + tests Vitest.
- A la confirmation de la modale : bloc existant (`id` en base) -> `DELETE` immediat ; bloc jamais persiste -> retrait local ; echec serveur (ex. 409 dernier RIB d'une LCR) -> bloc conserve + message back.
- Branche sur M1 / M2 / M3 (contacts / adresses / RIB). Suppression du mecanisme differe (`removed*Ids` + boucles dans `submit*`) devenu mort.

### 2. Affichage de la poubelle unifie (`isRowRemovable`)
Regle identique sur les 3 modules : poubelle visible sur un bloc **seulement s'il reste un autre bloc deja enregistre** (`id` en base).
- Tant que rien n'est enregistre -> aucune poubelle (plus de suppression d'un simple brouillon non valide).
- On peut jeter un brouillon non enregistre s'il reste un bloc enregistre.
- On ne peut jamais supprimer son dernier bloc enregistre.
- Applique aux ecrans **new + edit** des 3 modules (contacts / adresses / RIB).

## Tests
- Helper couvert par Vitest (`removeCollectionRow` + `isRowRemovable`).
- `make nuxt-test` : 480 tests OK. `make nuxt-lint` : OK.

## A verifier (golden path)
Sur les 3 modules : supprimer un bloc existant -> `DELETE` part immediatement -> reload -> le bloc a disparu ; la poubelle n'apparait qu'avec un 2e bloc deja enregistre.

Reviewed-on: #109
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 15:08:48 +00:00
gitea-actions 2689b85ebe chore: bump version to v0.1.122
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 21s
2026-06-15 14:44:12 +00:00
tristan f4bbc79550 feat(transport) : synchronisation du référentiel transporteurs QUALIMAT (ERP-39) (#99)
Auto Tag Develop / tag (push) Successful in 7s
## ERP-39 — Intégration QUALIMAT (transporteurs)

> ⚠️ MR **empilée** sur `feat/erp-150-module-transport` (PR #97). À merger après #97 (la base se recible automatiquement sur `develop`).

Commande console `app:qualimat:sync` : récupère les opérateurs de transport agréés depuis l'API publique qualimat.org, normalise et synchronise une table référentielle. Idempotente (refresh complet), prévue pour un **cron quotidien**.

### Contenu
- **Migration** `Version20260612150000` (namespace racine) : tables `qualimat_carrier` + `qualimat_sync_log`, `COMMENT ON COLUMN` sur chaque colonne, unique sur `siret`, index `is_active`.
- **`QualimatRowMapper`** : normalisation pure — SIRET sans espaces (clé naturelle, source "sale" non contrainte à 14), `dd/mm/yyyy` → ISO avec `checkdate`, skip des items sans SIRET, `Nom`=`Societe` → une colonne.
- **`SyncQualimatCommand`** : options `--file` / `--ppp` / `--dry-run`, fetch via http-client, upsert DBAL transactionnel (`ON CONFLICT (siret)`) + soft-delete des absents + journal, garde-fou troncature (`count == ppp`).
- Activation de `framework.http_client` (l'alias `HttpClientInterface` n'était pas enregistré).

### Tests
- Unitaires (`QualimatRowMapper`) + fonctionnels de la commande via `--file` (upsert, normalisation, journal, soft-delete).
- Suite complète **598/598** verte. `ColumnsHaveSqlCommentTest` .
- Bout-en-bout réel : sync de **2332 transporteurs** (1 ignoré sans SIRET, 0 désactivé, 1 journal).

### Décisions
- Migration au **namespace racine** `migrations/` (convention réelle M2/M3 ; pas de FK cross-module ; évite le tri FQCN) — écart assumé vs le mot "modulaire" du ticket.
- `status` sans CHECK contraignant (feed externe), `siret` non contraint à 14 (source incomplète).

---------

Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #99
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 14:40:16 +00:00
tristan f057866e75 feat(transport) : synchronisation du référentiel transporteurs QUALIMAT (ERP-39) (#99)
## ERP-39 — Intégration QUALIMAT (transporteurs)

> ⚠️ MR **empilée** sur `feat/erp-150-module-transport` (PR #97). À merger après #97 (la base se recible automatiquement sur `develop`).

Commande console `app:qualimat:sync` : récupère les opérateurs de transport agréés depuis l'API publique qualimat.org, normalise et synchronise une table référentielle. Idempotente (refresh complet), prévue pour un **cron quotidien**.

### Contenu
- **Migration** `Version20260612150000` (namespace racine) : tables `qualimat_carrier` + `qualimat_sync_log`, `COMMENT ON COLUMN` sur chaque colonne, unique sur `siret`, index `is_active`.
- **`QualimatRowMapper`** : normalisation pure — SIRET sans espaces (clé naturelle, source "sale" non contrainte à 14), `dd/mm/yyyy` → ISO avec `checkdate`, skip des items sans SIRET, `Nom`=`Societe` → une colonne.
- **`SyncQualimatCommand`** : options `--file` / `--ppp` / `--dry-run`, fetch via http-client, upsert DBAL transactionnel (`ON CONFLICT (siret)`) + soft-delete des absents + journal, garde-fou troncature (`count == ppp`).
- Activation de `framework.http_client` (l'alias `HttpClientInterface` n'était pas enregistré).

### Tests
- Unitaires (`QualimatRowMapper`) + fonctionnels de la commande via `--file` (upsert, normalisation, journal, soft-delete).
- Suite complète **598/598** verte. `ColumnsHaveSqlCommentTest` .
- Bout-en-bout réel : sync de **2332 transporteurs** (1 ignoré sans SIRET, 0 désactivé, 1 journal).

### Décisions
- Migration au **namespace racine** `migrations/` (convention réelle M2/M3 ; pas de FK cross-module ; évite le tri FQCN) — écart assumé vs le mot "modulaire" du ticket.
- `status` sans CHECK contraignant (feed externe), `siret` non contraint à 14 (source incomplète).

---------

Co-authored-by: Matthieu <contact@malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #99
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 14:39:56 +00:00
gitea-actions 19fdb50cec chore: bump version to v0.1.121
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 43s
2026-06-15 14:03:41 +00:00
tristan 368bb50ffb feat(transport) : créer le module Transport (ERP-150) (#97)
Auto Tag Develop / tag (push) Successful in 8s
## ERP-150 — Créer le module Transport

Scaffold du module **Transport** (prérequis commun à ERP-149 IDTF et ERP-39 QUALIMAT). Le module hébergera des référentiels externes synchronisés par commandes console.

### Contenu
- `src/Module/Transport/TransportModule.php` — ID `transport`, LABEL `Transport`, REQUIRED `false`, `permissions()` vide à ce stade (référentiels console, sans écran ni action protégée).
- `config/modules.php` — activation du module.
- `frontend/modules/transport/nuxt.config.ts` — layer Nuxt minimal (pas d'écran ni d'item sidebar à ce stade).

### Vérifications
- `GET /api/modules` → liste `transport`.
- `cache:clear` + `app:sync-permissions` OK (0 permission, rien cassé).
- `nuxi prepare` → layer auto-détecté.
- Suite PHPUnit : seuls les flakies connus (JWT 401 / DB) échouent ; verts en isolation. Le changement ne touche ni BDD, ni JWT, ni logique testée.

Débloque ERP-149 et ERP-39.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #97
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 14:03:35 +00:00
179 changed files with 20721 additions and 734 deletions
+4 -1
View File
@@ -56,7 +56,10 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium
# gd requis par phpoffice/phpspreadsheet (export XLSX). Doit etre explicite :
# sinon `composer install` echoue sur la verification de plateforme des que
# le runner ne fournit pas l'extension par defaut (ext-gd manquante).
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium, gd
coverage: none
tools: composer:v2
+2 -2
View File
@@ -24,6 +24,7 @@
"symfony/expression-language": "8.0.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*",
"symfony/intl": "8.0.*",
"symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0",
@@ -95,7 +96,6 @@
"doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.94",
"phpunit/phpunit": "^13.0",
"symfony/browser-kit": "8.0.*",
"symfony/http-client": "8.0.*"
"symfony/browser-kit": "8.0.*"
}
}
Generated
+175 -175
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "2dc5db01e7f5d6aecd5956749b21a092",
"content-hash": "b029c1484227c926d39dfd3ae5cb0699",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -5412,6 +5412,180 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client",
"version": "v8.0.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "c7f40f9103233630167c25c9a4570acf805fdade"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/c7f40f9103233630167c25c9a4570acf805fdade",
"reference": "c7f40f9103233630167c25c9a4570acf805fdade",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/log": "^1|^2|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<3",
"php-http/discovery": "<1.15"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/cache": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/rate-limiter": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v8.0.13"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-24T09:58:02+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-29T11:18:49+00:00"
},
{
"name": "symfony/http-foundation",
"version": "v8.0.8",
@@ -11785,180 +11959,6 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e",
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/log": "^1|^2|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<3",
"php-http/discovery": "<1.15"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/cache": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/rate-limiter": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-29T11:18:49+00:00"
},
{
"name": "symfony/process",
"version": "v8.0.8",
+2
View File
@@ -6,6 +6,7 @@ use App\Module\Commercial\CommercialModule;
use App\Module\Core\CoreModule;
use App\Module\Sites\SitesModule;
use App\Module\Technique\TechniqueModule;
use App\Module\Transport\TransportModule;
return [
CoreModule::class,
@@ -13,4 +14,5 @@ return [
SitesModule::class,
CatalogModule::class,
TechniqueModule::class,
TransportModule::class,
];
+3
View File
@@ -12,6 +12,9 @@ api_platform:
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
# en dehors de Domain/Entity : AuditLogResource, etc.
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
# Entites techniques partagees portant un #[ApiResource]
# (UploadedDocument — infra upload generique ERP-154).
- '%kernel.project_dir%/src/Shared/Domain/Entity'
formats:
jsonld: ['application/ld+json']
json: ['application/json']
+53 -10
View File
@@ -8,16 +8,29 @@ doctrine:
default:
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
# Exclut `audit_log` de toute operation de comparaison de schema
# (doctrine:schema:update, schema:validate, diff de migrations...).
# Cette table n'a volontairement aucune entite mappee : elle est
# append-only via DBAL brut (AuditLogWriter) pour eviter la
# recursion du listener Doctrine. Sans ce filtre, schema:update
# la considere comme "orpheline" et genere un `DROP TABLE
# audit_log` qui casse la base de test apres chaque
# `make test-db-setup`. La creation / suppression de la table
# reste pilotee par les migrations (cf. Version20260420202749).
schema_filter: '~^(?!audit_log$).+~'
# Exclut certaines tables de toute operation de comparaison de
# schema (doctrine:schema:update, schema:validate, diff de
# migrations...). Ces tables n'ont volontairement aucune entite
# mappee :
# - `audit_log` : append-only via DBAL brut (AuditLogWriter) pour
# eviter la recursion du listener Doctrine.
# - `qualimat_sync_log` : journal de synchro transporteurs
# QUALIMAT, ecrit en DBAL brut par `app:qualimat:sync`, hors ORM.
# NB : `qualimat_carrier` n'est PLUS filtree depuis M4 (ERP-155) :
# elle est desormais mappee en LECTURE SEULE par l'entite
# App\Module\Transport\Domain\Entity\QualimatCarrier (cible de la
# FK editable carrier.qualimat_carrier_id). Son mapping reproduit
# a l'identique le DDL de la migration ERP-39 (unique siret, index
# is_active, TIMESTAMP(6)) -> schema:update reste un no-op.
# - `idtf_product` / `idtf_sync_log` : referentiel codes IDTF
# synchronise en DBAL brut par `app:idtf:sync`, hors ORM.
# Sans ce filtre, schema:update les considere comme "orphelines" et
# genere un `DROP TABLE` qui casse la base de test apres chaque
# `make test-db-setup` (la migration les a creees, schema:update les
# supprime juste apres). Creation / suppression restent pilotees par
# les migrations (audit_log : Version20260420202749 ; qualimat :
# Version20260612150000 ; idtf : Version20260612160000).
schema_filter: '~^(?!(?:audit_log|qualimat_sync_log|idtf_product|idtf_sync_log)$).+~'
audit:
url: '%env(resolve:DATABASE_URL)%'
orm:
@@ -41,7 +54,26 @@ doctrine:
# Permet au module Commercial de referencer une Category via le contrat
# Shared sans importer la classe concrete du module Catalog (regle n°1).
App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category
# Cibles des ManyToOne de CarrierPrice (M4 Transport, onglet Prix) :
# permet au module Transport de referencer Client / Supplier et leurs
# adresses (M1/M2 Commercial) via des contrats Shared sans importer les
# classes concretes (regle n°1). L'embed JSON passe par les read-groups
# des entites concretes (client:read / supplier:read / ...).
App\Shared\Domain\Contract\ClientInterface: App\Module\Commercial\Domain\Entity\Client
App\Shared\Domain\Contract\ClientAddressInterface: App\Module\Commercial\Domain\Entity\ClientAddress
App\Shared\Domain\Contract\SupplierInterface: App\Module\Commercial\Domain\Entity\Supplier
App\Shared\Domain\Contract\SupplierAddressInterface: App\Module\Commercial\Domain\Entity\SupplierAddress
mappings:
# Mapping des entites techniques partagees (src/Shared/Domain/Entity).
# Premier occupant : UploadedDocument (infra upload generique ERP-154).
# Necessaire car les entites Shared ne sont pas couvertes par
# l'auto_mapping (qui ne cible que les bundles).
Shared:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Shared/Domain/Entity'
prefix: 'App\Shared\Domain\Entity'
alias: Shared
Core:
type: attribute
is_bundle: false
@@ -90,6 +122,17 @@ doctrine:
dir: '%kernel.project_dir%/src/Module/Technique/Domain/Entity'
prefix: 'App\Module\Technique\Domain\Entity'
alias: Technique
# Mapping inconditionnel du module Transport (meme logique que Technique) :
# les tables transporteurs (carrier + sous-collections) creees par la
# migration M4 (Version20260615150000) et le mapping lecture-seule de
# qualimat_carrier (referentiel ERP-39) doivent etre connus de l'ORM.
# L'activation fonctionnelle passe par config/modules.php.
Transport:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Transport/Domain/Entity'
prefix: 'App\Module\Transport\Domain\Entity'
alias: Transport
controller_resolver:
auto_mapping: false
+1
View File
@@ -2,4 +2,5 @@ doctrine_migrations:
migrations_paths:
'DoctrineMigrations': '%kernel.project_dir%/migrations'
'App\Module\Core\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Core/Infrastructure/Doctrine/Migrations'
'App\Module\Transport\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Transport/Infrastructure/Doctrine/Migrations'
enable_profiler: false
+13
View File
@@ -0,0 +1,13 @@
# Active le composant HTTP Client (symfony/http-client) et enregistre
# l'autowiring de HttpClientInterface. Utilise par les commandes de
# synchronisation de referentiels externes (QUALIMAT, IDTF...).
#
# User-Agent navigateur neutre : les sources (qualimat.org sous WordPress/WAF,
# icrt-idtf.com) filtrent souvent les UA de bibliotheque/vides ; un UA de type
# navigateur evite les blocages anti-bot sans reveler l'application.
framework:
http_client:
default_options:
timeout: 30
headers:
User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
+13 -1
View File
@@ -100,8 +100,20 @@ return [
// individuelles"), ajouter : 'permission' => 'core.admin.access'.
[
'label' => 'sidebar.administration.section',
'icon' => 'mdi:cog-outline',
'icon' => 'mdi:file-settings-cog-outline',
'items' => [
// Transport — Repertoire transporteurs (M4, ERP-164). Rattache a
// l'Administration (premier item) plutot qu'a une section dediee :
// referentiel global de configuration applicative, sans cloisonnement
// par site. Reste gate par sa propre permission `transport.carriers.view`
// (Admin / Bureau / Commerciale) et son module owner `transport`.
[
'label' => 'sidebar.transport.carriers',
'to' => '/carriers',
'icon' => 'mdi:truck-outline',
'module' => 'transport',
'permission' => 'transport.carriers.view',
],
[
'label' => 'sidebar.core.roles',
'to' => '/admin/roles',
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.120'
app.version: '0.1.138'
@@ -0,0 +1,100 @@
# M4 — Plan maître worktrees (back, Matthieu)
> **Rôle de ce fichier** : vue d'ensemble que la *conversation maître* tient à jour.
> Chaque worktree = une conversation Claude isolée + une branche + une PR vers `develop`.
> Les prompts à coller sont dans `WT*.md`.
## Principe
- 1 worktree = 1 branche partant de `origin/develop` (à jour des deps).
- 1 ticket = 1 PR atomique vers **`develop`** (jamais `main`).
- Commit autorisé sur la branche du worktree (ces prompts SONT la demande explicite) ;
`git commit --no-verify` OK si `make test` est déjà vert (le hook relance toute la suite).
- **Chaque worktree ouvre SA PR** vers `develop` en fin de tâche (cf. bloc PR ci-dessous).
## Bloc PR standard (repris dans chaque prompt)
```bash
git push -u origin <branche>
tea pr create --base develop --head <branche> \
--title "<type>(<scope>) : <titre>" \
--description "Résumé + lien ticket Lesstime ERP-XXX"
```
Puis **labelliser la PR via l'API Gitea** (tea ne pose pas les labels en CLI — `gitea.malio.fr`).
Cible **`develop`**, jamais `main`. **Aucune mention de Claude/IA** dans titre ou description.
## Vagues & ordre de merge
```
VAGUE 0 (en parallèle, dès maintenant)
WT1 1.2 upload Shared base: origin/develop ──┐
WT2 1.1 RBAC + sidebar base: origin/develop (≥ERP-150) ──┤ indépendants
VAGUE 1 (critique, séquentiel) │
WT3 1.3 migration + 1.5 entités/resource/provider + i18n audit
base: origin/develop APRÈS merge WT1 (FK uploaded_document)
⭐ livre le CONTRAT JSON liste+détail → débloque le front (Tristan)
VAGUE 2 (fan-out, tous en parallèle dès WT3 mergé)
WT4 1.6 processor base: develop ≥ WT3
WT5 1.4 qualimat endpoint base: develop ≥ WT2 (perm) + ERP-39 (indépendant de WT3)
WT6 1.7 adresses base: develop ≥ WT3
WT7 1.8 contacts base: develop ≥ WT3
WT8 1.9 prix base: develop ≥ WT3
WT9 1.10 export XLSX base: develop ≥ WT3
VAGUE 3 (final)
WT10 1.11 tests + fixtures + contrat base: develop ≥ TOUT
```
**Parallélisme réel** : 2 worktrees en V0, puis 1 goulot (WT3), puis **jusqu'à 6 en V2**, puis 1 (WT10).
## Règle anti-conflit worktree (IMPORTANT)
Pour que WT4→WT9 tournent en parallèle sans conflit de merge :
| Fichier partagé | Qui le touche | Les autres |
|---|---|---|
| `CarrierFixtures` | **WT10 uniquement** | interdit (WT3 met un fixture minimal, WT6-9 n'y touchent pas) |
| Entité `Carrier` (ApiResource) | **WT3** crée, **WT4** ajoute le Processor | WT6-9 créent des **resources/processors dédiés** par sous-entité, ne modifient pas `Carrier` |
| `ColumnCommentsCatalog` | WT1 (`uploaded_document`), WT3 (`carrier*`) | personne d'autre |
| `fr.json` (clés audit) | **WT3** (clés `audit.entity.transport_*`) | personne d'autre côté back |
| `migrations/` | WT1 puis WT3 (ordre timestamp) | aucune autre migration |
## Mode retenu : STACK séquentiel, SANS worktree (repo principal)
Matthieu empile les MR, un ticket à la fois, **directement dans `/home/matthieu/dev_malio/Starseed`** (pas de worktree).
- **Ignorer les blocs `git worktree add` des `WT*.md`** → remplacés par une branche normale :
```bash
git fetch origin
git checkout -b feat/erp-XXX-... origin/<branche-précédente>
```
- **WT1 hors pile** (déjà mergé). Pile M4 — chaque branche basée sur la précédente :
`WT2 → WT3 → WT4 → WT5 → WT6 → WT7 → WT8 → WT9 → WT10`
- PR de chaque maillon : `--base <branche-précédente>` (bas de pile WT2 = `develop`). Au merge, les MR du dessus se recible auto.
- Docker tourne sur le repo principal → `make test`/`php-cs-fixer` OK sans rebind (le piège worktree-vs-mount ne s'applique plus).
- Worktrees créés pour WT1/WT2 à nettoyer : `git worktree remove ../sb-erp154-upload ../sb-erp153-rbac`.
- Garder les MR basses propres ; merger dans l'ordre.
## Suivi (tenu par la conv maître)
| WT | Ticket | ERP | État | PR | Notes |
|----|--------|-----|------|----|----|
| WT1 | 1.2 upload | 154 | ✅ MERGÉ | #108 | migration `Version20260615130000` |
| WT2 | 1.1 RBAC | 153 | ✅ PR ouverte | #111 | bas de pile (cible develop) |
| WT3 | 1.3+1.5 | 155+157 | ▶️ À LANCER | — | stack sur `feat/erp-153-rbac` ; gate contrat front |
| WT4 | 1.6 proc | 158 | ⛔ bloqué par WT3 | — | |
| WT5 | 1.4 qualimat | 156 | ⛔ bloqué par WT2+ERP-39 | — | |
| WT6 | 1.7 adresses | 159 | ⛔ bloqué par WT3 | — | |
| WT7 | 1.8 contacts | 160 | ⛔ bloqué par WT3 | — | |
| WT8 | 1.9 prix | 161 | ⛔ bloqué par WT3 | — | |
| WT9 | 1.10 export | 162 | ⛔ bloqué par WT3 | — | |
| WT10 | 1.11 tests | 163 | ⛔ bloqué par tout | — | |
## Cadre commun à tous les prompts (rappels projet)
- Carrier vit dans `src/Module/Transport/` (créé par ERP-150). **Miroir = `src/Module/Commercial/`** (Supplier).
- Tests sous `tests/Module/Transport/Api/` (miroir `tests/Module/Commercial/Api/`).
- `declare(strict_types=1);` partout ; commentaires **FR**, code EN.
- `make test` + `make php-cs-fixer-allow-risky` avant de dire « fini ».
- Ne jamais mentionner Claude/IA dans commit/PR.
@@ -0,0 +1,44 @@
# WT1 — Infra upload générique `Shared` (ticket 1.2 / ERP-154)
> Créer le worktree puis lancer Claude dedans :
> ```bash
> git fetch origin
> git worktree add ../sb-erp154-upload -b feat/erp-154-upload origin/develop
> cd ../sb-erp154-upload && claude
> ```
> **Base** : `origin/develop` (aucune dépendance — peut démarrer tout de suite, même avant le merge du socle Transport).
---
## Prompt à coller
Tu travailles sur le projet Starseed (modular monolith DDD, Symfony 8 / API Platform 4). Lis `CLAUDE.md` et `.claude/rules/backend.md` avant de coder. Charge le skill `backend-entity-conventions`.
**Mission** : poser une infra d'upload de fichiers **générique et réutilisable** dans `src/Shared/` (la « Décharge » du M4 en sera le 1er consommateur, mais ce ticket ne touche PAS au module Transport).
**Spec** : `docs/specs/M4-transporteurs/spec-back.md § 2.7`.
**À livrer** :
1. Table `uploaded_document` (migration namespace racine `DoctrineMigrations` dans `migrations/`, postérieure à la dernière présente — vérifie `ls migrations/`). Colonnes : `id`, `original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum`, `created_at`, `created_by`.
2. Service `Shared\Infrastructure\Upload\FileUploader` :
- validation MIME **server-side via `$file->getMimeType()`** (JAMAIS `getClientMimeType()`),
- whitelist MIME explicite (PDF + images),
- bornage taille, checksum sha256, écriture disque `var/uploads/{yyyy}/{mm}/`.
3. Endpoint `POST /api/uploaded_documents` (multipart) → renvoie l'IRI. MIME hors whitelist → **422**.
**Gardes-fous (cassent `make test` sinon)** :
- **`COMMENT ON COLUMN` sur TOUTES les colonnes** de `uploaded_document` (FR, ≤200 car., règle n°12) ET ajoute le bloc `'uploaded_document' => [...]` dans `src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php` — sinon `make test-db-setup` drope les COMMENT et `ColumnsHaveSqlCommentTest` casse.
- Pagination : si tu exposes une `GetCollection`, elle reste paginée (`CollectionsArePaginatedTest`).
**Scope STRICT** : uniquement `src/Shared/` + migration + catalog. Ne crée AUCUN fichier sous `src/Module/Transport/`. Pas d'antivirus/S3/purge (hors périmètre, § 9).
**Tests à écrire** (PHPUnit) : MIME hors whitelist → 422 ; MIME valide → IRI + ligne persistée + checksum calculé.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky` propre. Commit (`--no-verify` OK si `make test` déjà vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-154-upload
tea pr create --base develop --head feat/erp-154-upload \
--title "feat(shared) : infra upload générique (ERP-154)" \
--description "Table uploaded_document + FileUploader + endpoint POST. Ticket ERP-154."
```
Puis labellise la PR via l'API Gitea (tea ne pose pas les labels en CLI). Cible **develop**. Aucune mention IA.
@@ -0,0 +1,37 @@
# WT10 — Tests PHPUnit + fixtures + contrat JSON (ticket 1.11 / ERP-163)
> ```bash
> git fetch origin
> git worktree add ../sb-erp163-tests -b feat/erp-163-carrier-tests origin/develop
> cd ../sb-erp163-tests && claude
> ```
> **Base** : `origin/develop` **après merge de TOUS les worktrees back** (WT1→WT9). C'est le filet final.
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`, `.claude/rules/testing.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `tests/Module/Commercial/Api/Supplier*Test.php`.
**Mission** : couverture complète des RG + capture du contrat de sérialisation + fixtures consolidées. C'est le DoD back avant intégration front.
**Spec** : `spec-back.md § 4.0.bis / 8.1 / 8.4`.
**À livrer** :
- Matrice **RG-4.01→4.14** couverte (§ 8.1) + RBAC par rôle (Compta/Usine → 403, Commerciale → 403 sur write, Admin → archive).
- `CarrierSerializationContractTest` : capture JSON réel **liste + détail** ; `prices[].client`/`.supplier`/sites **embarqués** (pas IRI) ; `qualimatCarrier` embarqué ; `isArchived` présent. Colle les JSON dans `spec-back.md § 4.0.bis`.
- Anti-N+1 liste ; pagination Hydra ; audit (`entity_type='Carrier'`) ; `AuditableEntitiesHaveI18nLabelTest` vert.
- **`CarrierFixtures` idempotent (§ 8.4)** — c'est ICI que les fixtures complètes vivent : transporteur QUALIMAT (validité passée → RG-4.04), AUTRE+décharge, affrété, LIOT, complet (contacts/adresses/prix CLIENT+FOURNISSEUR), 1 archivé.
**Piège CI (mémoire projet)** : la CI tourne `APP_DEBUG=0`. Les tests de **comptage de requêtes (anti-N+1)** passent en local mais cassent en CI (DoctrineDataHolder absent) → vérifie/active `profiling: true` dans la config Doctrine de l'environnement `test`. Sans ça le test anti-N+1 sera rouge en CI.
**Scope** : tests + `CarrierFixtures` + remplissage § 4.0.bis. Tu peux ajuster un test cassé hérité d'un autre WT mais signale-le à la conv maître (ne masque pas un vrai bug).
**Fini quand** : `make test` **intégralement vert** + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-163-carrier-tests
tea pr create --base develop --head feat/erp-163-carrier-tests \
--title "test(transport) : couverture RG-4.01→4.14 + contrat + fixtures (ERP-163)" \
--description "Matrice RG + CarrierSerializationContractTest + CarrierFixtures + § 4.0.bis. Ticket ERP-163."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,45 @@
# WT2 — Permissions `transport.carriers.*` + sidebar (ticket 1.1 / ERP-153)
> ```bash
> git fetch origin
> git worktree add ../sb-erp153-rbac -b feat/erp-153-rbac origin/develop
> cd ../sb-erp153-rbac && claude
> ```
> **Base** : `origin/develop` **après merge d'ERP-150** (le module `Transport` doit exister). Vérifie : `ls src/Module/Transport/`.
---
## Prompt à coller
Projet Starseed (modular monolith DDD). Lis `CLAUDE.md`, `.claude/rules/architecture.md` et `.claude/rules/testing.md` avant de coder.
**Mission** : poser le socle RBAC du module Transport et son entrée de menu. `TransportModule::permissions()` renvoie `[]` aujourd'hui.
**Spec** : `spec-back.md § 5` + `spec-front.md § Accès`.
**À livrer** :
1. `TransportModule::permissions()` déclare `transport.carriers.view`, `transport.carriers.manage`, `transport.carriers.archive`. `app:sync-permissions` les enregistre.
2. **Matrice § 5.2** : Admin (view+manage+archive), Bureau (view+manage), Commerciale (view), Compta + Usine (**aucune**).
3. **RÈGLE ABSOLUE n°8 — les 3 sources RBAC dans le MÊME commit** :
- `config/sidebar.php` : section « Transport » + item `/carriers` + `permission: transport.carriers.view`,
- `frontend/tests/e2e/_fixtures/personas.ts` : ajuster `permissions` + `expectedAdminLinks` des personas existants,
- `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` : miroir back des mêmes personas.
4. Item sidebar masqué pour Compta/Usine ; visible Admin/Bureau/Commerciale.
**Pièges** :
- Ne touche QUE le RBAC/sidebar — pas d'entité, pas de migration.
- Toute modif d'une seule des 3 sources sans les 2 autres = drift / test cassé.
- Section « Transport » vs « Logistique » : prends « Transport » (cosmétique, alignable plus tard).
**Tests à écrire/vérifier** : `app:sync-permissions` OK ; cohérence personas (pas de drift). Lance `make test`.
**Scope STRICT** : RBAC + sidebar + 3 miroirs. Rien d'autre.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si test vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-153-rbac
tea pr create --base develop --head feat/erp-153-rbac \
--title "feat(transport) : permissions carriers + sidebar (ERP-153)" \
--description "RBAC transport.carriers.* + 3 sources RBAC alignées. Ticket ERP-153."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,54 @@
# WT3 ⭐ — Migration + entités Carrier* + ApiResource + Provider (tickets 1.3 + 1.5 / ERP-155 + ERP-157)
> **Worktree pivot : il livre le CONTRAT JSON qui débloque tout le front.**
> **Mode STACK, sans worktree** (repo principal) — base = branche de WT2 :
> ```bash
> cd /home/matthieu/dev_malio/Starseed && git fetch origin
> git checkout -b feat/erp-155-carrier-schema-entities origin/feat/erp-153-rbac
> ```
> **Base** : `feat/erp-153-rbac` (contient ERP-150 + WT1 + RBAC WT2). Quand #111 sera mergé dans develop, la PR de WT3 se recible automatiquement sur develop.
---
## Prompt à coller
Projet Starseed (modular monolith DDD, Symfony 8 / API Platform 4). Lis `CLAUDE.md`, `.claude/rules/backend.md`, `.claude/rules/architecture.md`. **Charge le skill `backend-entity-conventions`** (patterns entités/migrations complets).
**Mission** : créer le schéma BDD du répertoire transporteurs + les entités + le contrat de lecture (liste + détail). Tu poses le contrat JSON sur lequel le front s'appuiera — c'est le livrable critique.
**Spec** : `spec-back.md § 3.2 / 3.3 / 3.4 / 4.0 / 4.1 / 4.2`. **Miroir = le module Supplier** : `src/Module/Commercial/Domain/Entity/Supplier*.php`, `…/Infrastructure/ApiPlatform/State/Provider/SupplierProvider.php`, `…/Serializer/SupplierReadGroupContextBuilder.php`. Carrier vit dans `src/Module/Transport/`.
### Étape A — Migration (`migrations/`, namespace racine `DoctrineMigrations`)
- **PAS de migration modulaire** : même si la spec dit « modulaire », toute migration va dans `migrations/` namespace racine (tri FQCN cassant sinon). Postérieure à la dernière présente — vérifie `ls migrations/` (à ce jour `Version20260615120000`).
- Tables `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` + FK : `qualimat_carrier`, `uploaded_document`, `client`, `client_address`, `supplier`, `supplier_address`, `site`, `user`.
- `certification_type` **nullable** (null en cas LIOT) + CHECK enum ; CHECK sur `container_type`, `direction`, `pricing_unit`, `price_state`, branches Prix client/fournisseur.
- Index partiel `uq_carrier_name_active` : `LOWER(name)` WHERE non archivé ET non supprimé.
- **`COMMENT ON COLUMN` sur TOUTES les colonnes** (FR, ≤200 car.) + helper standard pour les 4 colonnes Timestampable/Blamable. Bonus `COMMENT ON TABLE`.
### Étape B — Entités + repos
- `Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice` : `#[Auditable]`, `implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait`. Repos `*RepositoryInterface` (Domain) + `Doctrine*Repository` (Infrastructure).
- `ApiResource` Carrier (attribut sur l'entité, comme Supplier) : `GetCollection` + `Get` + `Post` + `Patch` avec `security` (§ 3.3). **PAS de Delete**.
- Groupes : `carrier:read`, `carrier:item:read`, `qualimat:read`. **Embed au détail** (pas IRI) : `client:read`/`client_address:read`/`supplier:read`/`supplier_address:read`/`site:read` + `qualimatCarrier`. ⚠ les adresses de l'onglet Prix sont des `ClientAddress`/`SupplierAddress` distinctes.
- `CarrierProvider` paginé (`ApiPlatform\Doctrine\Orm\Paginator`), liste **sans cloisonnement site** (§ 2.3), **anti-N+1** (fetch joins, § 2.11), exclut les archivés par défaut + `?includeArchived=true`.
- Piège booléen : `#[SerializedName('isArchived')]` sur le getter.
### Gardes-fous qui CASSENT `make test` (à traiter dans CE worktree)
- `ColumnsHaveSqlCommentTest` → COMMENT partout **+ ajouter les blocs `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` dans `src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php`** (sinon `test-db-setup` drope les COMMENT).
- `makefile test-db-setup` : l'index partiel `uq_carrier_name_active` n'est PAS exprimé par `schema:update`**ajoute-le à la ligne `dbal:run-sql` du target `test-db-setup`** du `makefile`, sinon `make test` casse.
- `AuditableEntitiesHaveI18nLabelTest` → ajoute dans `frontend/i18n/locales/fr.json` les clés `audit.entity.transport_carrier`, `transport_carrieraddress`, `transport_carriercontact`, `transport_carrierprice` (clé = strtolower(module)+'_'+strtolower(Entity)).
- `EntitiesAreTimestampableBlamableTest`, `EntityConstraintsHaveFrenchMessageTest` (messages FR + `Length.max` = longueur colonne), `CollectionsArePaginatedTest`.
**Scope STRICT** : schéma + entités + ApiResource lecture + Provider + i18n audit. **PAS** le Processor d'écriture (→ WT4), **PAS** les sous-ressources POST/PATCH adresses/contacts/prix (→ WT6/7/8), **PAS** l'export (→ WT9). Mets un `CarrierFixtures` **minimal** (1-2 lignes) juste pour faire tourner tes tests de lecture ; les fixtures complètes sont faites par WT10 — n'y investis pas.
**Tests à écrire** : liste exclut archivés / `?includeArchived=true` ; enveloppe Hydra (`member`/`totalItems`) ; `isArchived` présent dans le JSON ; embeds détail présents (pas IRI).
**LIVRABLE GATE** : une fois vert, **capture le JSON réel liste + détail** (`curl` ou test) et colle-le dans `spec-back.md § 4.0.bis`. C'est le signal pour démarrer le front. Préviens la conv maître.
**Fini quand** : `make db-reset` OK + `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si test vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-155-carrier-schema-entities
tea pr create --base feat/erp-153-rbac --head feat/erp-155-carrier-schema-entities \
--title "feat(transport) : schéma + entités Carrier + contrat lecture (ERP-155/157)" \
--description "Migration + entités Carrier* + ApiResource lecture + Provider + i18n audit + contrat JSON. Tickets ERP-155, ERP-157."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,41 @@
# WT4 — CarrierProcessor (ticket 1.6 / ERP-158)
> ```bash
> git fetch origin
> git worktree add ../sb-erp158-processor -b feat/erp-158-carrier-processor origin/develop
> cd ../sb-erp158-processor && claude
> ```
> **Base** : `origin/develop` **après merge de WT3** (entités Carrier) **et WT1** (upload, pour la décharge).
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
**Mission** : logique d'écriture du formulaire principal Carrier (POST/PATCH) — normalisation, champs conditionnels, archivage. **Miroir** : `src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php` + `Application/Service/SupplierFieldNormalizer.php`.
**Spec** : `spec-back.md § 4.3 / 4.4 / 7`.
**Règles métier à implémenter (un test PHPUnit par RG)** :
- **RG-4.01** : POST avec `qualimatCarrier``certificationType=QUALIMAT` + FK persistée ; cas LIOT (`name='LIOT'`) ⇒ `certificationType` non requis, `liotPlates` accepté.
- **RG-4.02** : `certificationType='AUTRE'` sans `dischargeDocument`**422** (`#[Assert\Callback]`).
- **RG-4.03** : `isChartered=true` sans `indexationRate` / `containerType` / `volumeM3`**422**.
- **RG-4.13** : normalisation via `CarrierFieldNormalizer` (miroir Supplier) — `name` UPPER, contacts Capitalize, phones digits-only, email lower, `liotPlates` (`;`-split/trim/UPPER).
- **RG-4.12** : doublon `name` (parmi actifs) → **409** + `setError` ciblé.
- **RG-4.14** : PATCH `isArchived` exige `transport.carriers.archive` (Admin) ; mode strict → 403 sinon.
**Pièges** :
- Messages de validation **FR explicites** sur chaque contrainte (`EntityConstraintsHaveFrenchMessageTest`).
- Le back renvoie **toutes** les violations d'un coup avec `propertyPath` aligné sur les champs front.
**Scope STRICT** : `CarrierProcessor` + `CarrierFieldNormalizer` + contraintes sur l'entité `Carrier` (formulaire principal). **NE TOUCHE PAS** : les sous-ressources adresses/contacts/prix (WT6/7/8), `CarrierFixtures` (WT10), l'export (WT9). Ajoute tes contraintes sur `Carrier` sans réécrire l'ApiResource posée par WT3.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-158-carrier-processor
tea pr create --base develop --head feat/erp-158-carrier-processor \
--title "feat(transport) : CarrierProcessor (RG-4.01→4.03/4.12→4.14) (ERP-158)" \
--description "Normalisation + champs conditionnels + archive. Ticket ERP-158."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,37 @@
# WT5 — Endpoint QualimatCarrier lecture seule (ticket 1.4 / ERP-156)
> ```bash
> git fetch origin
> git worktree add ../sb-erp156-qualimat -b feat/erp-156-qualimat-search origin/develop
> cd ../sb-erp156-qualimat && claude
> ```
> **Base** : `origin/develop` **après merge de WT2** (permission `transport.carriers.view`) **et ERP-39** (table `qualimat_carrier` peuplée). **Indépendant de WT3** — peut tourner en parallèle.
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
**Mission** : exposer le référentiel QUALIMAT (table existante `qualimat_carrier`, alimentée par console) en **lecture seule** + endpoint de recherche pour la saisie assistée du nom (RG-4.01). **Ne touche pas** la commande de sync.
**Spec** : `spec-back.md § 4.7` + RG-4.01.
**À livrer** :
1. Entité `QualimatCarrier` (lecture seule) mappée sur la table existante `qualimat_carrier`. **Aucune écriture exposée** (pas de Post/Patch/Delete). Probablement pas `#[Auditable]` ni Timestampable (référentiel externe synchronisé) — vérifie le mapping existant.
2. `GET /api/qualimat_carriers?search=` : fuzzy sur `name` (+ `siret`), **seulement `is_active = true`**, tri `name`, **paginé** (règle n°13 — `CollectionsArePaginatedTest`).
3. **Security** `is_granted('transport.carriers.view')`.
4. Champs exposés : `id, siret, name, address, postalCode, city, phone, department, status, validityDate, isActive`.
**Tests à écrire** : recherche ne renvoie que les actifs ; pagination Hydra ; 403 sans permission ; tri `name`.
**Scope STRICT** : uniquement l'exposition lecture de `qualimat_carrier`. Ne crée rien autour de `Carrier` (autres worktrees). Si la table n'a pas de COMMENT (référentiel pré-existant), vérifie si elle est dans `EXCLUDED_TABLES` de `ColumnsHaveSqlCommentTest` — ne casse pas ce test.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-156-qualimat-search
tea pr create --base develop --head feat/erp-156-qualimat-search \
--title "feat(transport) : endpoint recherche QualimatCarrier (ERP-156)" \
--description "Entité lecture seule + GET /api/qualimat_carriers?search=. Ticket ERP-156."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,37 @@
# WT6 — Sous-ressource Adresses (ticket 1.7 / ERP-159)
> ```bash
> git fetch origin
> git worktree add ../sb-erp159-adresses -b feat/erp-159-carrier-addresses origin/develop
> cd ../sb-erp159-adresses && claude
> ```
> **Base** : `origin/develop` **après merge de WT3** (entités `CarrierAddress`). Parallèle à WT5/WT7/WT8/WT9.
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `SupplierAddressProcessor.php` (`src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/`).
**Mission** : opérations d'écriture sur les adresses transporteur.
**Spec** : `spec-back.md § 4.5` + RG-4.05→4.07.
**À livrer** :
- `POST /api/carriers/{id}/addresses`, `PATCH`/`DELETE /api/carrier_addresses/{id}` (security `manage`) — **resource/processor dédiés à `CarrierAddress`**, ne modifie pas l'ApiResource `Carrier`.
- **RG-4.06** : `postalCode` matche `^[0-9]{4,5}$` (autocomplete ville = front). Message FR.
- **RG-4.05** : si affrété → adresse obligatoire (Pays/CP/Ville/Adresse) — validation conditionnelle.
- RG-4.07 (bouton Valider masqué si QUALIMAT) = front ; côté back, accepter le PATCH normalement.
**Tests à écrire** : CP invalide → 422 ; adresse affrété incomplète → 422 ; PATCH/DELETE OK avec `manage`, 403 sans.
**Scope STRICT** : uniquement `CarrierAddress` (resource + processor + tests). **NE TOUCHE PAS** `CarrierFixtures` (WT10), l'entité `Carrier`, les autres sous-ressources. Messages de validation FR (`EntityConstraintsHaveFrenchMessageTest`).
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-159-carrier-addresses
tea pr create --base develop --head feat/erp-159-carrier-addresses \
--title "feat(transport) : sous-ressource adresses transporteur (ERP-159)" \
--description "POST/PATCH/DELETE carrier_address + RG-4.05→4.07. Ticket ERP-159."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,35 @@
# WT7 — Sous-ressource Contacts (ticket 1.8 / ERP-160)
> ```bash
> git fetch origin
> git worktree add ../sb-erp160-contacts -b feat/erp-160-carrier-contacts origin/develop
> cd ../sb-erp160-contacts && claude
> ```
> **Base** : `origin/develop` **après merge de WT3**. Parallèle à WT5/WT6/WT8/WT9.
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `SupplierContactProcessor.php` (`src/Module/Commercial/…/State/Processor/`).
**Mission** : opérations d'écriture sur les contacts transporteur.
**Spec** : `spec-back.md § 4.5` + RG-4.08.
**À livrer** :
- `POST /api/carriers/{id}/contacts`, `PATCH`/`DELETE /api/carrier_contacts/{id}` (security `manage`) — resource/processor dédiés à `CarrierContact`.
- **RG-4.08** : bloc valide si **≥ 1 champ rempli** (CHECK `chk_carrier_contact_filled` côté migration WT3 + validation Processor) ; **max 2 téléphones**.
**Tests à écrire** : contact vide → 422 ; 1 champ → 200/201 ; 3ᵉ téléphone → 422.
**Scope STRICT** : uniquement `CarrierContact`. **NE TOUCHE PAS** `CarrierFixtures` (WT10), `Carrier`, les autres sous-ressources. Messages FR. Si le CHECK `chk_carrier_contact_filled` manque (WT3 ne l'a pas posé), valide côté Processor et signale-le à la conv maître.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-160-carrier-contacts
tea pr create --base develop --head feat/erp-160-carrier-contacts \
--title "feat(transport) : sous-ressource contacts transporteur (ERP-160)" \
--description "POST/PATCH/DELETE carrier_contact + RG-4.08 (≥1 champ, max 2 tel). Ticket ERP-160."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,39 @@
# WT8 — Sous-ressource Prix + RG branches (ticket 1.9 / ERP-161)
> ```bash
> git fetch origin
> git worktree add ../sb-erp161-prix -b feat/erp-161-carrier-prices origin/develop
> cd ../sb-erp161-prix && claude
> ```
> **Base** : `origin/develop` **après merge de WT3**. Parallèle à WT5/WT6/WT7/WT9.
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
**Mission** : opérations d'écriture sur les prix transporteur, avec branches Client / Fournisseur.
**Spec** : `spec-back.md § 4.5 / 7` + RG-4.09→4.11.
**À livrer** :
- `POST /api/carriers/{id}/prices`, `PATCH`/`DELETE /api/carrier_prices/{id}` (security `manage`) — resource/processor dédiés à `CarrierPrice`.
- **RG-4.10 (CLIENT)** : `client`, `clientDeliveryAddress`, `departureSite` requis ; `clientDeliveryAddress` **doit appartenir au `client`** → sinon 422.
- **RG-4.11 (FOURNISSEUR)** : `supplier`, `supplierSupplyAddress`, `deliverySite` requis ; `supplierSupplyAddress` appartient au `supplier` → sinon 422.
- Communs obligatoires : `containerType`, `pricingUnit`, `price`, `priceState`. CHECK branches respectés.
**Rappels FK** : « Adresse départ/livraison 86/17/82 » = `Site` (FK). Livraison client = `ClientAddress`, appro = `SupplierAddress` (relations ORM partagées — pas de M2M).
**Tests à écrire** : branche CLIENT/FOURNISSEUR incomplète → 422 ; adresse étrangère au client/supplier → 422 ; prix valide → 201.
**Scope STRICT** : uniquement `CarrierPrice`. **NE TOUCHE PAS** `CarrierFixtures` (WT10), `Carrier`, les autres sous-ressources. Messages FR.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-161-carrier-prices
tea pr create --base develop --head feat/erp-161-carrier-prices \
--title "feat(transport) : sous-ressource prix transporteur (ERP-161)" \
--description "POST/PATCH/DELETE carrier_price + RG-4.09→4.11 (branches client/fournisseur). Ticket ERP-161."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,36 @@
# WT9 — Export XLSX (ticket 1.10 / ERP-162)
> ```bash
> git fetch origin
> git worktree add ../sb-erp162-export -b feat/erp-162-carrier-export origin/develop
> cd ../sb-erp162-export && claude
> ```
> **Base** : `origin/develop` **après merge de WT3** (lecture Carrier). Parallèle à WT5/WT6/WT7/WT8.
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. **Miroir** : `src/Module/Commercial/Infrastructure/Controller/SupplierExportController.php` (PhpSpreadsheet déjà présent).
**Mission** : export Excel du répertoire et du tableau Prix regroupé.
**Spec** : `spec-back.md § 4.6`.
**À livrer** :
- `GET /api/carriers/export.xlsx` : transporteurs affichés (**mêmes filtres** que la liste) ; colonnes § 4.6.
- `GET /api/carriers/{id}/prices/export.xlsx` : tableau Prix regroupé Benne / Fond Mouvant (colonnes docx p.10).
- **Controllers custom** avec `#[Route(priority: 1)]` (sinon conflit API Platform `{id}`) ; en-tête `Content-Disposition`.
**Tests à écrire** : 200 + en-tête fichier (Content-Disposition + type XLSX) ; respect des filtres.
**Scope STRICT** : controllers d'export + service de génération. **NE TOUCHE PAS** entités, processors, `CarrierFixtures` (WT10). Réutilise le Provider/filtres de WT3 pour la cohérence des données exportées.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-162-carrier-export
tea pr create --base develop --head feat/erp-162-carrier-export \
--title "feat(transport) : export XLSX répertoire + prix (ERP-162)" \
--description "GET /api/carriers/export.xlsx + /carriers/{id}/prices/export.xlsx. Ticket ERP-162."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
File diff suppressed because it is too large Load Diff
+354
View File
@@ -0,0 +1,354 @@
---
# === IDENTITÉ ===
module: M4
nom: "Répertoire transporteurs"
ecran: repertoire-transporteurs
owner_spec: Matthieu
backup_spec: Tristan
version: V0.1
date_redaction: 2026-06-15
# Historique :
# V0.1 (2026-06-15) — Restitution Markdown du docx « M4-repertoire-transporteurs-V0 »
# (validé 27/05/2026) + maquette Figma (node 1132-45376). Précisions techniques (back)
# dans spec-back.md. Réutilise le pattern et les composants M1/M2/M3.
# === LIENS ===
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-45376&p=f&m=dev"
regles_metier: [RG-4.01, RG-4.02, RG-4.03, RG-4.04, RG-4.05, RG-4.06, RG-4.07, RG-4.08, RG-4.09, RG-4.10, RG-4.11]
roles: [Admin, Bureau, Compta, Commerciale, Usine]
lien_spec_back: ./spec-back.md
# === VALIDATION CLIENT ===
client_validation_1:
statut: validee
date: 2026-05-27
version: V0
valide_par: "Matthieu (CP MALIO)"
# === LIEN LESSTIME ===
lesstime_project_id: 6
lesstime_taskgroup_id: 31 # M4 — Répertoire transporteurs (tickets ERP-153 → ERP-171)
statut_global: pret_a_dev
---
# Module 4 — Répertoire transporteurs (V0.1 front)
> **Origine** : spec fonctionnelle `M4-repertoire-transporteurs-V0` (validée le 27/05/2026) + maquette Figma. Restitution Markdown pour intégration au workflow MALIO. Toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M4 réutilise le pattern et les composants posés aux [M1 clients](../M1-clients/spec-front.md), [M2 fournisseurs](../M2-suppliers/spec-front.md) et [M3 prestataires](../M3-prestataires/spec-front.md).
> **Socle déjà en place** : le module back `Transport` existe (ERP-150) et porte deux référentiels **synchronisés par commandes console** : transporteurs **QUALIMAT** (`qualimat_carrier`, ERP-39) et codes **IDTF** (`idtf_product`, ERP-149). Le M4 ajoute le **répertoire éditable** (`Carrier`) **par-dessus** ces référentiels — la saisie assistée du nom interroge le référentiel QUALIMAT (RG-4.01). L'IDTF n'est **pas** utilisé par ces écrans.
> **Décisions Matthieu (15/06/2026)** : (1) lien QUALIMAT = FK + **copie éditable** des champs (nom / certification / adresse) ; (2) **pas de cloisonnement par site** (référentiel global) ; (3) le champ « Décharge » s'appuie sur une **infra d'upload réutilisable** (`Shared`), car d'autres uploads suivront. Détails : [`spec-back.md § 2.5 / § 2.3 / § 2.7`](./spec-back.md).
## But
Lister tous les transporteurs de l'organisation et accéder rapidement à leurs fiches : consultation, création, modification, archivage. Le nom est **relié à QUALIMAT** (saisie assistée) ; les transporteurs hors QUALIMAT (GMP+, OVOCOM, compte-propre, LIOT, autre) sont saisis manuellement.
## Accès
- **Depuis** : menu principal → section **Transport** (route `/carriers`). *(Section « Transport » dédiée ou rattachement à une section « Logistique » — à confirmer, cf. [`spec-back.md § 5.3`](./spec-back.md).)*
- **Rôles autorisés** (tableau « Rôles & permissions » du docx) :
| Rôle | Consultation | Ajout / Modification | Archive |
|---|---|---|---|
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
| **Bureau** | ✅ Tout | ✅ Tout | ❌ |
| **Compta** | ❌ | ❌ | ❌ |
| **Commerciale** | ✅ Tout | ❌ | ❌ |
| **Usine** | ❌ | ❌ | ❌ |
> **Notes** :
> - RBAC transposée sur `transport.carriers.*` (cf. [`spec-back.md § 5`](./spec-back.md)). **Commerciale** = consultation seule (pas de « + Ajouter » ni « Modifier »). **Compta** et **Usine** n'ont **aucun** accès au module (item sidebar masqué).
> - **Pas de cloisonnement par site** (≠ M3) : tout rôle autorisé voit tous les transporteurs.
## Navigation
Page d'entrée du module **Transport** (route `/carriers`). Titre : « **Répertoire transporteurs** ».
- Affichage principal : un **datatable** listant tous les transporteurs **actifs** (les archivés sont masqués par défaut — filtre dédié).
- **Clic sur une ligne** → écran **Consultation transporteur** (page dédiée).
- **Bouton « + Ajouter »** (haut droite, si `manage`) → écran **Ajouter un transporteur**.
- **Bouton « Filtrer »** (haut droite) → panneau de filtres.
- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des transporteurs **affichés** (cf. filtres actifs). Format dans [`spec-back.md § 4.6`](./spec-back.md).
### Panneau de filtres (bouton « Filtrer »)
Réutilise le pattern M1/M2/M3. Filtres branchés sur les query params de `GET /api/carriers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) :
| Filtre | Composant | Query param back |
|---|---|---|
| **Recherche** (nom) | `<MalioInputText>` | `?search=` |
| **Certification** | `<MalioSelectCheckbox>` (QUALIMAT / GMP+ / OVOCOM / Compte-propre / Autre) | `?certificationType=` |
| **Inclure les archivés** | `<MalioCheckbox>` | `?includeArchived=true` |
- À l'application des filtres → `setFilters(...)` de `usePaginatedList` (retombe en **page 1**).
- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6).
## Datatable du Répertoire
Composant : `<MalioDataTable>` branché sur `usePaginatedList<Carrier>({ url: '/carriers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes :
| Colonne | Source | Tri |
|---|---|---|
| **Nom** | `carrier.name` | ASC par défaut |
| **Certification** | `carrier.certificationType` (libellé i18n) | Non |
| **Date de validité** | `carrier.qualimatCarrier.validityDate` (format `JJ-MM-AAAA`) — **fond rouge si < aujourd'hui** (RG-4.04) | Non |
| **Dernière activité** | `carrier.updatedAt` (format `JJ-MM-AAAA`) | Oui |
> **Clic sur une ligne** → écran Consultation. **Pagination** : standard Starseed 10 / 25 / 50 (défaut 10). Tri serveur `name ASC` par défaut.
## Écran « Ajouter un transporteur »
Création par **onglets successifs avec validation incrémentale** : pour passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant** ; les champs validés passent en lecture seule. **L'onglet Adresses n'est accessible qu'une fois le formulaire principal validé.** Cf. [`spec-back.md § 2.9`](./spec-back.md) (PATCH partiels par groupe de sérialisation).
**Accès** : bouton « + Ajouter » du Répertoire. **Rôles** : Admin, Bureau.
**Barre d'onglets** : `Qualimat` · `Adresses` · `Contacts` · `Prix`.
### Formulaire principal (pré-onglets)
1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/carriers`, puis bascule sur l'onglet Qualimat/Adresses ; les champs passent en readonly.
| Champ | Type composant | Obligatoire | Règle |
|---|---|---|---|
| **Nom** (saisie assistée reliée à QUALIMAT) | `<MalioInputText>` (autocomplete) | Oui | RG-4.01 ; RG-4.13 (UPPERCASE serveur) ; RG-4.12 (unicité) |
| **Liste certification transport** | `<MalioSelect>` (GMP+ / OVOCOM / Compte-propre / Autre) | Oui | RG-4.02 ; auto = `QUALIMAT` (lecture seule) si transporteur QUALIMAT sélectionné |
| **Affréter** | `<MalioCheckbox>` | Non | RG-4.03 |
| **Indexation %** | `<MalioInputNumber>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
| **Benne / Fond mouvant** | `<MalioRadioButton>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
| **Volume m³** | `<MalioInputNumber>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
| **Décharge** | `<MalioInputUpload>` *(cf. note)* | Conditionnel (**obligatoire si AUTRE**) | RG-4.02 — visible **et obligatoire** si certification = `AUTRE`. Upload via infra Shared ([`spec-back.md § 2.7`](./spec-back.md)) |
| **Liste immatriculation LIOT** | `<MalioInputText>` (ou TextArea) | Cas LIOT | RG-4.01 — visible **uniquement** si nom = `LIOT` ; les autres champs disparaissent. Immatriculations séparées par `;` |
> **Comportement RG-4.01 (saisie assistée)** : à la saisie du nom, recherche dans le référentiel QUALIMAT via `GET /api/qualimat_carriers?search=`. Sélection d'un résultat → **modal de confirmation** « Êtes-vous sûr de vouloir intégrer ce transporteur ? ». Si confirmé : le **Nom** et la **certification** (= `QUALIMAT`, lecture seule) se remplissent automatiquement, **ainsi que l'onglet Adresse** (copie pays/CP/ville/voie depuis le référentiel). La FK QUALIMAT est conservée (traçabilité + date de validité RG-4.04).
> - **Cas transporteur non trouvé** (pas QUALIMAT) : l'utilisateur choisit une autre certification (RG-4.02) → affichage des champs associés.
> - **Cas LIOT** : si le nom saisi est exactement `LIOT`, seul le champ « Liste immatriculation LIOT » s'affiche, les autres champs sont masqués.
> **Note `<MalioInputUpload>`** : si le composant ne couvre pas le drag & drop / type fichier requis, exception autorisée documentée (`// TODO migrer quand Malio couvre`) — cf. exceptions @.claude/rules/frontend.md.
**Action** : « Valider » (`<MalioButton>`) → POST `/api/carriers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Qualimat » / « Adresses ».
### Onglet « Qualimat »
Sélectionner un transporteur de la liste QUALIMAT afin de mettre à jour les informations du transporteur (saisie assistée — voir RG-4.01).
**Colonnes du tableau de sélection** :
| Colonne | Règle |
|---|---|
| **Sélection** (bouton / clic ligne) | RG-4.03 *(docx)* — clic → modal « Êtes-vous sûr de vouloir intégrer ce transporteur ? » → remplit Nom + certification + onglet adresse |
| **Nom** | — |
| **Adresse** | — |
| **Date de validité** | RG-4.04 — **fond rouge si < date du jour** |
> Cet onglet alimente le formulaire principal et l'onglet Adresse par copie (RG-4.01 / RG-4.05). Source : `GET /api/qualimat_carriers?search=` (lecture seule, lignes actives uniquement).
### Onglet « Adresses »
Saisir l'adresse du transporteur (un bloc par adresse).
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Pays** | `<MalioSelect>` (préremplie « France ») | Conditionnel | RG-4.05 |
| **Code postal** | `<MalioInputText>` (saisie assistée) | Conditionnel | RG-4.06, RG-4.05 — déclenche autocomplete ville (BAN) |
| **Ville** | `<MalioSelect>` (saisie assistée) | Conditionnel | RG-4.06, RG-4.05 — alimentée par api-adresse.data.gouv.fr |
| **Adresse** | `<MalioInputText>` (saisie assistée) | Conditionnel | RG-4.05 |
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
> **RG-4.05** : les champs sont **déjà remplis** si le transporteur est QUALIMAT (copie). Si « Affréter » est coché, l'adresse devient **obligatoire** (Pays, Code postal, Ville, Adresse).
> **RG-4.06** : la ville est préremplie automatiquement à partir du code postal via l'API BAN (`useAddressAutocomplete()`, réutilisé M1/M2/M3). Si plusieurs villes → choix dans le select. L'adresse est une saisie assistée basée sur le CP et la ville.
> **RG-4.07** : le bouton « Valider » **n'apparaît pas** pour un transporteur QUALIMAT (adresse remplie automatiquement).
**Actions** : « Valider » → PATCH `/api/carriers/{id}/addresses` (sauf QUALIMAT, RG-4.07).
### Onglet « Contacts »
Saisir un ou plusieurs contacts associés au transporteur.
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Nom** | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (Capitalize) |
| **Prénom** | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (Capitalize) |
| **Fonction** | `<MalioInputText>` | Non | RG-4.08 |
| **Téléphone** (x1, +1 possible, **max 2**) | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (format) |
| **Email** | `<MalioInputText>` type email | Non | RG-4.08 + RG-4.13 (lowercase) |
**RG-4.08** : un bloc Contact est valide dès qu'au moins 1 champ est rempli. Impossible d'ajouter un nouveau bloc tant que le précédent n'est pas valide.
**Actions** :
- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a aucun champ rempli** (RG-4.08).
- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc.
- « Valider » → PATCH `/api/carriers/{id}/contacts`.
### Onglet « Prix »
Saisir un suivi de prix du transporteur (un bloc par prix). Tous les champs sont masqués par défaut sauf le radio « Client / Fournisseur » (RG-4.09).
**Bloc Prix** :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Client / Fournisseur** | `<MalioRadioButton>` | Oui | RG-4.09 |
| **Client** | `<MalioSelect>` (liste des clients) | Conditionnel | RG-4.10 — si Client |
| **Adresse de livraison** | `<MalioSelect>` (adresses du client sélectionné) | Conditionnel | RG-4.10 — si Client |
| **Adresse de départ** | `<MalioSelect>` (86 / 17 / 82) | Conditionnel | RG-4.10 — si Client ; = un des 3 sites |
| **Fournisseur** | `<MalioSelect>` (liste des fournisseurs) | Conditionnel | RG-4.11 — si Fournisseur |
| **Adresse d'approvisionnement** | `<MalioSelect>` (adresses du fournisseur) | Conditionnel | RG-4.11 — si Fournisseur |
| **Adresse de livraison** | `<MalioSelect>` (86 / 17 / 82) | Conditionnel | RG-4.11 — si Fournisseur ; = un des 3 sites |
| **Benne / Fond mouvant (FM)** | `<MalioRadioButton>` | Oui | — |
| **Forfait / Tonne** | `<MalioRadioButton>` | Oui | — |
| **Prix** | `<MalioInputAmount>` (monnaie) | Oui | — |
| **État du prix** | `<MalioSelect>` (En cours / Validé / Non validé) | Oui | — |
> **RG-4.10** : si **Client** sélectionné → champs liés au client affichés et obligatoires ; champs fournisseur masqués et non obligatoires.
> **RG-4.11** : si **Fournisseur** sélectionné → champs liés au fournisseur affichés et obligatoires ; champs client masqués et non obligatoires.
> **Adresse de départ / livraison « 86 / 17 / 82 »** = les 3 `Site` fixes (cf. switcher de site Châtellerault / Saint-Jean / Pommevic en haut de l'app). La sélection stocke un **ID de Site** ([`spec-back.md § 3.2`](./spec-back.md)).
**Actions** :
- « + Nouveau prix » : ajoute un bloc. Bloqué tant que le précédent n'est pas valide.
- « Supprimer » (icône) : modal de confirmation puis suppression.
- « Valider » → PATCH `/api/carriers/{id}/prices`.
## Écran « Consultation d'un transporteur »
Consulter en **lecture seule** la fiche complète. Affiche en haut du bloc les infos principales du transporteur (comme l'écran d'ajout) ainsi que les onglets Adresses, Contacts, Prix. **Tous les champs sont en lecture seule.**
**Accès** : clic sur une ligne du Répertoire. La page s'ouvre par défaut sur l'onglet **Adresses**. Icône « flèche » à gauche pour revenir au répertoire. Deux boutons à droite :
- **« Modifier »** (visible si `transport.carriers.manage` → Admin, Bureau).
- **« Archiver »** (visible **uniquement Admin** via `transport.carriers.archive`) → modal de confirmation, puis PATCH `/api/carriers/{id}` `{ "isArchived": true }`.
> Un transporteur archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé.
### Onglet Adresses (consultation)
Un bloc par adresse du transporteur. Chaque bloc, 5 champs en lecture seule : Pays / Code postal / Ville / Adresse / Adresse complémentaire.
### Onglet Contacts (consultation)
Un bloc par contact. 5 champs en lecture seule : Nom / Prénom / Fonction / Téléphone (x1 ou x2) / Email.
### Onglet Prix (consultation)
Un tableau regroupant les prix par type (**Fond Mouvant / Benne**) :
| Colonne | Description |
|---|---|
| **Colonne de regroupement** | « Fond Mouvant » / « Benne » |
| **Transporteurs** | Nom du transporteur |
| **Adresse APRO ou Adresse Sites** | Si prix « Client » → Adresse APRO sinon Adresse Sites |
| **Adresse livraisons** | — |
| **Forfait €** | Prix |
| **Tonne €** | Prix |
| **Indexation** | Pourcentage d'indexation (vide si non rempli) |
| **État du prix** | Validé / Non Validé / En cours |
**Action** : « Exporter » → exporte le tableau au **format Excel** (`GET /api/carriers/{id}/prices/export.xlsx`).
## Écran « Modification d'un transporteur »
Modifier les informations d'un transporteur existant. **Identique à l'écran « Ajouter un transporteur »** — mêmes formulaires, mêmes règles métier (RG-4.01 à RG-4.11) — sauf :
- Les champs sont **pré-remplis** avec les valeurs actuelles.
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
- **Accès** : depuis l'écran Consultation, bouton « Modifier » (Admin, Bureau).
## Composants UI à utiliser (`@malio/layer-ui`)
- **Datatable** : `<MalioDataTable>` (+ `usePaginatedList`)
- **Input texte** : `<MalioInputText>`
- **Input nombre / montant** : `<MalioInputNumber>` (indexation, volume), `<MalioInputAmount>` (prix)
- **Select simple** : `<MalioSelect>` (certification, pays, ville, client, fournisseur, adresses, sites, état du prix)
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (filtres certification)
- **Radio** : `<MalioRadioButton>` (Benne/Fond mouvant, Forfait/Tonne, Client/Fournisseur)
- **Checkbox** : `<MalioCheckbox>` (Affréter, inclure archivés)
- **Upload** : `<MalioInputUpload>` (Décharge — exception documentée si type non couvert)
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
- **Toasts** : standards via `useApi()`
- **Validation par champ** : `useFormErrors` (mapping 422 inline — règle frontend obligatoire)
**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
- Modal de confirmation : wrapper partagé dans `frontend/shared/` (réutiliser celui du M1/M2/M3).
- `<MalioInputUpload>` si le type fichier / drag & drop n'est pas couvert.
## Composables & appels API
- `usePaginatedList<Carrier>({ url: '/carriers' })` — liste paginée (obligatoire). Consomme `name`, `certificationType`, `qualimatCarrier.validityDate` (RG-4.04), `updatedAt` (cf. [`spec-back.md § 2.11 / § 4.0`](./spec-back.md)).
- `useCarrier(id)` — charge le détail via `GET /api/carriers/{id}`, qui **embarque** `addresses`, `contacts`, `prices` (avec `client`/`supplier`/sites imbriqués) + `qualimatCarrier`. Écrans Consultation et Modification peuplés depuis cette seule réponse. **DoD avant intégration** : vérifier le JSON réel (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)).
- `useCarrierForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useSupplierForm()`/`useProviderForm()` + gestion des **champs conditionnels** (Affréter, AUTRE→Décharge, cas LIOT).
- `useQualimatSearch()` — saisie assistée du nom : `GET /api/qualimat_carriers?search=`, modal de confirmation, copie des champs + FK (RG-4.01).
- `useAddressAutocomplete()`**réutilisé** du M1/M2/M3 (BAN), pas de réécriture (RG-4.06).
- `useUpload()` (NOUVEAU, infra Shared) — POST multipart `/api/uploaded_documents` → renvoie l'IRI à poser sur `carrier.dischargeDocument` (RG-4.02).
- `usePermissions()` — masque l'item sidebar et les boutons selon les permissions.
- Tous les appels passent par `useApi()` (jamais `$fetch` direct — règle ABSOLUE n°4).
- Filter `formatPhoneFR()`**réutilisé** pour l'affichage `XX XX XX XX XX`.
## Règles de formatage et normalisation
Le serveur normalise systématiquement (RG-4.13 — cf. [`spec-back.md`](./spec-back.md)) :
| Champ | Normalisation serveur | Affichage front |
|---|---|---|
| Nom transporteur (`name`) | UPPERCASE intégral | UPPERCASE |
| Nom + Prénom contact | Capitalize | identique |
| Téléphones (`CarrierContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` (filter Vue) |
| Email | lowercase intégral | identique |
| Immatriculations LIOT | `;`-split, trim, UPPER | listées |
> Le front **ne normalise pas** : il envoie la valeur saisie, le serveur normalise et renvoie la valeur normalisée que l'UI affiche.
## API adresse postale
Code postal + Ville + Adresse branchés sur **api-adresse.data.gouv.fr** (BAN) via le composable `useAddressAutocomplete()` **déjà créé au M1/M2/M3** (réutilisé tel quel) :
- À la saisie du CP (5 chiffres) : `GET https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville (RG-4.06 : si plusieurs villes, choix dans le select).
- À la saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions.
- Cas dégradé (timeout / offline) : Ville en `<MalioInputText>` libre + toast d'avertissement.
## Différences notables avec les modules précédents
| Zone | M2/M3 | M4 transporteurs |
|---|---|---|
| Source du nom | saisie libre | **saisie assistée reliée à QUALIMAT** (référentiel synchronisé) |
| Onglet Comptabilité / RIB | présent (M2/M3) | **Absent** |
| Cloisonnement par site | M3 : oui | **Non** (référentiel global) |
| Champs conditionnels formulaire principal | peu | **Nombreux** (Affréter, AUTRE→Décharge, cas LIOT) |
| Onglet Prix | absent | **Présent** (Client/Fournisseur, sites départ/livraison) |
| Upload de fichier | aucun | **Décharge** (infra upload Shared, réutilisable) |
| Module | Commercial / Technique | **Transport** (existant, ERP-150) |
## Points résolus côté back
| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
|---|---|---|
| 1 | Lien QUALIMAT | FK `qualimatCarrier` + **copie éditable** des champs (§ 2.5) |
| 2 | Cas LIOT | Champ `liotPlates` (`;`-séparé), autres champs masqués (RG-4.01) |
| 3 | Certification QUALIMAT | Valeur `QUALIMAT` lecture seule si lié (§ 2.5) |
| 4 | Décharge (upload) | Infra upload générique `Shared` réutilisable (§ 2.7) |
| 5 | Onglet Prix — branches | M2M absentes : FK Client/Supplier + adresses + sites (RG-4.10/4.11, § 3.2) |
| 6 | Adresse de départ/livraison 86/17/82 | = les 3 `Site` fixes (FK Site) |
| 7 | Workflow par onglet | Sauvegarde incrémentale (POST principal + PATCH partiels) — pas d'état « draft » |
| 8 | Archive vs delete | Flag `is_archived` séparé ; archivage Admin seul ; soft delete = HP |
| 9 | Unicité métier | Nom seul (§ 2.6) |
| 10 | Référentiel QUALIMAT | Endpoint lecture seule `GET /api/qualimat_carriers?search=` (§ 4.7) |
| 11 | Format export | XLSX (répertoire + onglet Prix regroupé Benne/FM) |
| 12 | RBAC | `transport.carriers.view/manage/archive` ; Compta + Usine sans accès (§ 5.2) |
---
## 📦 Tickets Lesstime
**TaskGroup Lesstime** : à créer — `M4 — Répertoire transporteurs` (projet `ERP / Starseed`, projectId=6). Découpe détaillée (back en tête) → [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper).
| Ordre | Sujet | Tag |
|---|---|---|
| 0 | Permissions `transport.carriers.*` + sidebar + 3 sources RBAC | Backend |
| 1 | Infra upload générique `Shared` (uploaded_document + FileUploader + endpoint) | Backend |
| 2 | Migration BDD M4 (carrier + sous-collections + index + COMMENT) | Backend |
| 3 | Entité `QualimatCarrier` (lecture seule) + endpoint recherche | Backend |
| 4 | Entités + Repositories Carrier* | Backend |
| 5 | CarrierProvider + CarrierProcessor (champs conditionnels, archive, LIOT) | Backend |
| 6 | Sous-ressources Adresses / Contacts / Prix (RG-4.10/4.11) | Backend |
| 7 | Export XLSX (répertoire + onglet Prix) | Backend |
| 8 | Tests PHPUnit RG-4.01→4.14 + capture contrat JSON | Backend |
| 9 | Page Répertoire (`/carriers`) + usePaginatedList | Frontend |
| 10 | Page Ajouter + formulaire principal + saisie assistée QUALIMAT | Frontend |
| 11 | Onglets Adresses (BAN) / Contacts / Prix | Frontend |
| 12 | Pages Consultation + Modification | Frontend |
| 13 | i18n + libellés audit + upload front (useUpload) | Frontend |
@@ -0,0 +1,307 @@
# M4 — Répertoire transporteurs · Découpe en tickets Lesstime
> **Statut** : ✅ **poussé dans Lesstime** — TaskGroup **#31 « M4 — Répertoire transporteurs »** (projet STARSEED), 19 tickets **ERP-153 → ERP-171** au statut **Prêt à dev**.
> **Assignation** : tickets **Backend (1.1→1.11, ERP-153→163) → Matthieu** · tickets **Frontend (1.12→1.19, ERP-164→171) → Tristan**.
>
> | Pos | Ticket | Réf |
> |---|---|---|
> | 1.1 | Permissions transport.carriers.* + sidebar | ERP-153 |
> | 1.2 | Infra upload générique Shared | ERP-154 |
> | 1.3 | Migration BDD M4 | ERP-155 |
> | 1.4 | QualimatCarrier + endpoint recherche | ERP-156 |
> | 1.5 | Entités Carrier* + ApiResource + Provider | ERP-157 |
> | 1.6 | CarrierProcessor (RG-4.01/02/03 + LIOT) | ERP-158 |
> | 1.7 | Sous-ressource Adresses | ERP-159 |
> | 1.8 | Sous-ressource Contacts | ERP-160 |
> | 1.9 | Sous-ressource Prix + branches | ERP-161 |
> | 1.10 | Export XLSX | ERP-162 |
> | 1.11 | Tests PHPUnit + contrat JSON | ERP-163 |
> | 1.12 | Page Répertoire /carriers | ERP-164 |
> | 1.13 | Page Ajouter (layout + formulaire) | ERP-165 |
> | 1.14 | Saisie assistée QUALIMAT + conditionnels | ERP-166 |
> | 1.15 | Onglet Adresses (BAN) | ERP-167 |
> | 1.16 | Onglet Contacts | ERP-168 |
> | 1.17 | Onglet Prix | ERP-169 |
> | 1.18 | Consultation + Modification | ERP-170 |
> | 1.19 | Upload front + i18n + audit | ERP-171 |
> **Specs sources** : [`spec-back.md`](./spec-back.md) · [`spec-front.md`](./spec-front.md) — validées (docx V0 du 27/05/2026).
> **Maquette Figma** : node `1132-45376` ([lien](https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-45376&p=f&m=dev)).
## ⚠️ Dépendance amont (socle Tristan — en cours de merge)
Le M4 s'appuie sur le module `Transport` et le référentiel QUALIMAT, livrés par les PR de Tristan **en cours de merge** dans `develop` :
- **ERP-150** (PR #97) — module `Transport` (`TransportModule`, layer front, `config/modules.php`). **Requis** par tout le M4.
- **ERP-39** (PR #99) — sync QUALIMAT (`qualimat_carrier` + commande `app:qualimat:sync`). **Requis** par la saisie assistée (ticket 1.4).
- **ERP-149** (PR #101) — sync IDTF (`idtf_product`). **NON requis** par le M4 (référentiel autonome, hors écrans transporteurs).
> Les 3 PR sont **empilées** (`develop → ERP-150 → ERP-39 → ERP-149`). Démarrer le M4 une fois **ERP-150 + ERP-39 dans `develop`** (DoR des tickets 1.1 et 1.4). Brancher le M4 sur `develop` post-merge.
## Vue d'ensemble (ordre d'exécution)
| # | Ticket | Tag | Effort | RG / dépend |
|---|---|---|---|---|
| 1.1 | Déclarer permissions `transport.carriers.*` + sidebar | Backend | S | DoR : ERP-150 mergé |
| 1.2 | Créer l'infra d'upload générique `Shared` | Backend | M | § 2.7 |
| 1.3 | Migrer le schéma BDD M4 (carrier + sous-tables) | Backend | M | § 3.2 |
| 1.4 | Exposer `QualimatCarrier` (lecture seule) + endpoint recherche | Backend | S | RG-4.01 · DoR : ERP-39 mergé |
| 1.5 | Créer entités `Carrier*` + repos + `ApiResource` + `CarrierProvider` | Backend | M | § 3.3 / 4.0 |
| 1.6 | Implémenter `CarrierProcessor` (RG-4.01/4.02/4.03 + LIOT + normalisation + archive) | Backend | M | RG-4.01→4.03, 4.13, 4.14 |
| 1.7 | Sous-ressource Adresses (`carrier_address`) | Backend | S | RG-4.05→4.07 |
| 1.8 | Sous-ressource Contacts (`carrier_contact`) | Backend | S | RG-4.08 |
| 1.9 | Sous-ressource Prix (`carrier_price`) + RG branches | Backend | M | RG-4.09→4.11 |
| 1.10 | Export XLSX (répertoire + onglet Prix regroupé) | Backend | M | § 4.6 |
| 1.11 | Tests PHPUnit RG-4.01→4.14 + capture contrat JSON (DoD) | Backend | M | § 4.0.bis / 8.1 |
| 1.12 | Page Répertoire `/carriers` (datatable, filtres, export) | Frontend | M | RG-4.04 |
| 1.13 | Page Ajouter `/carriers/new` (layout, onglets, formulaire principal POST) | Frontend | M | RG-4.12 |
| 1.14 | Saisie assistée QUALIMAT + champs conditionnels (Affréter / AUTRE→Décharge / LIOT) | Frontend | M | RG-4.01→4.03 |
| 1.15 | Onglet Adresses (autocomplete BAN) | Frontend | M | RG-4.05→4.07 |
| 1.16 | Onglet Contacts | Frontend | S | RG-4.08 |
| 1.17 | Onglet Prix (Client/Fournisseur, sites) | Frontend | M | RG-4.09→4.11 |
| 1.18 | Pages Consultation + Modification | Frontend | M | — |
| 1.19 | Upload front (`useUpload`) + i18n + libellés audit | Frontend | S | § 2.8 |
**Total** : 19 tickets · ~11 back / 8 front · mini-MR de 1 à 4h.
---
## Tickets — détail
### 1.1 — Déclarer permissions `transport.carriers.*` + sidebar
**Position** : 1.1 • Suit : — • Précède : Migrer le schéma BDD M4
**Tag** : Backend • **Effort** : S
**Contexte** : `TransportModule::permissions()` renvoie aujourd'hui `[]`. Ce ticket pose le socle RBAC du module et son entrée de menu, prérequis de toute opération sécurisée.
**Spec liée** : [`spec-back.md § 5`](./spec-back.md) · [`spec-front.md § Accès`](./spec-front.md)
**Critères d'acceptation** :
- [ ] `TransportModule::permissions()` déclare `transport.carriers.view`, `transport.carriers.manage`, `transport.carriers.archive` ; `app:sync-permissions` les enregistre.
- [ ] **Matrice § 5.2** : Admin (view+manage+archive), Bureau (view+manage), Commerciale (view), Compta + Usine (aucune).
- [ ] **3 sources RBAC alignées dans le même commit** (règle ABSOLUE n°8) : `config/sidebar.php` (section Transport + item `/carriers` + permission), `personas.ts`, `SeedE2ECommand.php`.
- [ ] Item sidebar masqué pour Compta/Usine ; visible Admin/Bureau/Commerciale.
**Tests à prévoir** : permissions sync OK ; personas e2e cohérents (pas de drift).
**Tips** : DoR — ERP-150 mergé (module Transport présent). Section sidebar « Transport » (ou « Logistique » — à trancher, cosmétique).
### 1.2 — Créer l'infra d'upload générique `Shared`
**Position** : 1.2 • Suit : permissions • Précède : Migration M4
**Tag** : Backend • **Effort** : M
**Contexte** : la « Décharge » (RG-4.02) est le 1er d'une série d'uploads à venir. On pose une infra réutilisable, pas un upload ad hoc.
**Spec liée** : [`spec-back.md § 2.7`](./spec-back.md)
**Critères d'acceptation** :
- [ ] Table `uploaded_document` (`original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum`, `created_at`, `created_by`) + COMMENT ON COLUMN.
- [ ] Service `Shared\Infrastructure\Upload\FileUploader` : validation MIME **server-side via `$file->getMimeType()`** (jamais `getClientMimeType()`), bornage taille, checksum sha256, écriture disque (`var/uploads/{yyyy}/{mm}/`).
- [ ] Endpoint `POST /api/uploaded_documents` (multipart) → renvoie l'IRI ; whitelist MIME explicite (PDF + images) ; hors whitelist → 422.
**Tests à prévoir** : PHPUnit — MIME hors whitelist → 422 ; MIME valide → IRI + ligne persistée ; checksum calculé.
**Tips** : générique et réutilisable (autres modules la consommeront). Antivirus / S3 / purge = HP (§ 9).
### 1.3 — Migrer le schéma BDD M4 (carrier + sous-tables)
**Position** : 1.3 • Suit : infra upload • Précède : QualimatCarrier
**Tag** : Backend • **Effort** : M
**Contexte** : créer le schéma du répertoire (entité éditable distincte du référentiel `qualimat_carrier`).
**Spec liée** : [`spec-back.md § 3.2`](./spec-back.md)
**Critères d'acceptation** :
- [ ] Migration namespace racine `DoctrineMigrations`, **postérieure** à `Version20260612160000`.
- [ ] Tables `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` + FK (`qualimat_carrier`, `uploaded_document`, `client`, `client_address`, `supplier`, `supplier_address`, `site`, `user`).
- [ ] `certification_type` **nullable** (null seulement en cas LIOT) + CHECK enum ; CHECK `container_type`, `direction`, `pricing_unit`, `price_state`, branches Prix client/fournisseur.
- [ ] Index partiel `uq_carrier_name_active` (LOWER(name), WHERE non archivé & non supprimé).
- [ ] **`COMMENT ON COLUMN` sur TOUTES les colonnes** (règle n°12) + helper Timestampable/Blamable. `ColumnsHaveSqlCommentTest` vert.
- [ ] `make db-reset` passe ; schéma conforme.
**Tests à prévoir** : `make db-reset` OK ; `ColumnsHaveSqlCommentTest` vert ; index partiel présent.
**Tips** : PK `BIGINT` (cohérence module Transport) — à confirmer vs `INT`.
### 1.4 — Exposer `QualimatCarrier` (lecture seule) + endpoint recherche
**Position** : 1.4 • Suit : migration • Précède : entités Carrier*
**Tag** : Backend • **Effort** : S
**Contexte** : la saisie assistée du nom (RG-4.01) a besoin d'un endpoint de recherche sur le référentiel QUALIMAT, aujourd'hui alimenté en console mais non exposé.
**Spec liée** : [`spec-back.md § 4.7`](./spec-back.md) · RG-4.01
**Critères d'acceptation** :
- [ ] Entité `QualimatCarrier` (lecture seule) mappée sur la table existante `qualimat_carrier` (aucune écriture exposée).
- [ ] `GET /api/qualimat_carriers?search=` : fuzzy sur `name` (+ `siret`), **seulement `is_active = true`**, tri `name`, paginé (règle n°13).
- [ ] **Security** `is_granted('transport.carriers.view')`. Champs exposés : `id, siret, name, address, postalCode, city, phone, department, status, validityDate, isActive`.
**Tests à prévoir** : PHPUnit — recherche ne renvoie que les actifs ; pagination Hydra ; 403 sans permission.
**Tips** : DoR — ERP-39 mergé. Ne pas toucher la commande de sync.
### 1.5 — Créer entités `Carrier*` + repos + `ApiResource` + `CarrierProvider`
**Position** : 1.5 • Suit : QualimatCarrier • Précède : CarrierProcessor
**Tag** : Backend • **Effort** : M
**Contexte** : poser les entités, le contrat de sérialisation (groupes) et la lecture (liste + détail).
**Spec liée** : [`spec-back.md § 3.3 / 3.4 / 4.0 / 4.1 / 4.2`](./spec-back.md)
**Critères d'acceptation** :
- [ ] Entités `Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice` (`#[Auditable]`, `TimestampableBlamableTrait`), repos Doctrine.
- [ ] `ApiResource` Carrier : `GetCollection` + `Get` + `Post` + `Patch` avec `security` (§ 3.3) ; **pas de Delete**.
- [ ] Groupes de sérialisation : `carrier:read`, `carrier:item:read`, `qualimat:read`, embed `client:read`/`client_address:read`/`supplier:read`/`supplier_address:read`/`site:read` au détail (3 maillons § 4.0 — ⚠ les adresses de l'onglet Prix sont des entités `ClientAddress`/`SupplierAddress` distinctes).
- [ ] `CarrierProvider` paginé (`ApiPlatform\Doctrine\Orm\Paginator`) ; liste **sans cloisonnement site** (§ 2.3) ; anti-N+1 (§ 2.11).
- [ ] Piège booléen `isArchived` : `#[SerializedName('isArchived')]` sur le getter.
**Tests à prévoir** : liste exclut archivés par défaut ; `?includeArchived=true` ; enveloppe Hydra ; `isArchived` présent dans le JSON.
**Tips** : miroir `Supplier`/`Provider`. Pas d'onglet Comptabilité (≠ M2/M3).
### 1.6 — Implémenter `CarrierProcessor`
**Position** : 1.6 • Suit : entités • Précède : sous-ressource Adresses
**Tag** : Backend • **Effort** : M
**Contexte** : logique d'écriture du formulaire principal (POST/PATCH) : normalisation, champs conditionnels, archivage.
**Spec liée** : [`spec-back.md § 4.3 / 4.4 / 7`](./spec-back.md)
**Critères d'acceptation** :
- [ ] **RG-4.01** : POST avec `qualimatCarrier``certificationType=QUALIMAT` + FK persistée ; cas LIOT : `name='LIOT'``certificationType` non requis, `liotPlates` accepté.
- [ ] **RG-4.02** : `certificationType='AUTRE'` sans `dischargeDocument`**422** (`#[Assert\Callback]`).
- [ ] **RG-4.03** : `isChartered=true` sans `indexationRate`/`containerType`/`volumeM3`**422**.
- [ ] **RG-4.13** : normalisation (`name` UPPER, contacts Capitalize, phones digits, email lower, `liotPlates`).
- [ ] **RG-4.12** : doublon `name` (actifs) → **409**.
- [ ] **RG-4.14** : PATCH `isArchived` exige `transport.carriers.archive` (Admin) ; mode strict (403 sinon).
**Tests à prévoir** : PHPUnit sur chaque RG ci-dessus (cf. § 8.1).
**Tips** : `CarrierFieldNormalizer` miroir `SupplierFieldNormalizer`.
### 1.7 — Sous-ressource Adresses (`carrier_address`)
**Position** : 1.7 • Suit : CarrierProcessor • Précède : Contacts
**Tag** : Backend • **Effort** : S
**Spec liée** : [`spec-back.md § 4.5`](./spec-back.md) · RG-4.05→4.07
**Critères d'acceptation** :
- [ ] `POST /api/carriers/{id}/addresses`, `PATCH`/`DELETE /api/carrier_addresses/{id}` (security `manage`).
- [ ] **RG-4.06** : `postalCode` matche `^[0-9]{4,5}$` (autocomplete ville = front).
- [ ] **RG-4.05** : si affrété, adresse obligatoire (Pays/CP/Ville/Adresse) — validation conditionnelle.
**Tests à prévoir** : PHPUnit — CP invalide → 422 ; adresse affrété incomplète → 422.
**Tips** : RG-4.07 (bouton Valider masqué si QUALIMAT) = front, back accepte le PATCH.
### 1.8 — Sous-ressource Contacts (`carrier_contact`)
**Position** : 1.8 • Suit : Adresses • Précède : Prix
**Tag** : Backend • **Effort** : S
**Spec liée** : [`spec-back.md § 4.5`](./spec-back.md) · RG-4.08
**Critères d'acceptation** :
- [ ] `POST /api/carriers/{id}/contacts`, `PATCH`/`DELETE /api/carrier_contacts/{id}` (security `manage`).
- [ ] **RG-4.08** : bloc valide si ≥ 1 champ rempli (CHECK `chk_carrier_contact_filled` + Processor) ; **max 2 téléphones**.
**Tests à prévoir** : PHPUnit — contact vide → 422 ; 1 champ → 200.
**Tips** : miroir contacts M2/M3.
### 1.9 — Sous-ressource Prix (`carrier_price`) + RG branches
**Position** : 1.9 • Suit : Contacts • Précède : Export
**Tag** : Backend • **Effort** : M
**Spec liée** : [`spec-back.md § 4.5 / 7`](./spec-back.md) · RG-4.09→4.11
**Critères d'acceptation** :
- [ ] `POST /api/carriers/{id}/prices`, `PATCH`/`DELETE /api/carrier_prices/{id}` (security `manage`).
- [ ] **RG-4.10** (CLIENT) : `client`, `clientDeliveryAddress`, `departureSite` requis ; `clientDeliveryAddress` doit appartenir au `client` → sinon 422.
- [ ] **RG-4.11** (FOURNISSEUR) : `supplier`, `supplierSupplyAddress`, `deliverySite` requis ; `supplierSupplyAddress` appartient au `supplier` → sinon 422.
- [ ] Communs obligatoires : `containerType`, `pricingUnit`, `price`, `priceState` ; CHECK branches respectées.
**Tests à prévoir** : PHPUnit — branche CLIENT/FOURNISSEUR incomplète → 422 ; adresse étrangère → 422.
**Tips** : « Adresse départ/livraison 86/17/82 » = `Site` (FK) ; livraison client = `ClientAddress`, appro = `SupplierAddress` (relations ORM partagées).
### 1.10 — Export XLSX (répertoire + onglet Prix regroupé)
**Position** : 1.10 • Suit : Prix • Précède : Tests PHPUnit
**Tag** : Backend • **Effort** : M
**Spec liée** : [`spec-back.md § 4.6`](./spec-back.md)
**Critères d'acceptation** :
- [ ] `GET /api/carriers/export.xlsx` : transporteurs affichés (mêmes filtres) ; colonnes § 4.6.
- [ ] `GET /api/carriers/{id}/prices/export.xlsx` : tableau Prix regroupé Benne / Fond Mouvant (colonnes docx p.10).
- [ ] Controllers custom `#[Route(priority: 1)]` (conflit API Platform `{id}`) ; `Content-Disposition`.
**Tests à prévoir** : PHPUnit — 200 + en-tête fichier ; respect des filtres.
**Tips** : PhpSpreadsheet déjà présent.
### 1.11 — Tests PHPUnit RG-4.01→4.14 + capture contrat JSON (DoD)
**Position** : 1.11 • Suit : Export • Précède : Page Répertoire
**Tag** : Backend • **Effort** : M
**Spec liée** : [`spec-back.md § 4.0.bis / 8.1`](./spec-back.md)
**Critères d'acceptation** :
- [ ] Matrice RG-4.01→4.14 couverte (§ 8.1) + RBAC par rôle (Compta/Usine → 403).
- [ ] `CarrierSerializationContractTest` : capture JSON réel **liste + détail** ; `prices[].client`/`.supplier`/sites **embarqués** (pas IRI) ; `qualimatCarrier` embarqué ; `isArchived` présent.
- [ ] Anti-N+1 liste ; pagination Hydra ; audit (`entity_type='Carrier'`) ; `AuditableEntitiesHaveI18nLabelTest` vert.
- [ ] `CarrierFixtures` idempotent (§ 8.4) : transporteur QUALIMAT (validité passée), AUTRE+décharge, affrété, LIOT, complet (contacts/adresses/prix CLIENT+FOURNISSEUR), 1 archivé.
**Tests à prévoir** : suite complète `make test` verte.
**Tips** : coller les JSON capturés dans § 4.0.bis (DoD avant front).
### 1.12 — Page Répertoire `/carriers` (datatable, filtres, export)
**Position** : 1.12 • Suit : Tests back • Précède : Page Ajouter
**Tag** : Frontend • **Effort** : M
**Spec liée** : [`spec-front.md § Datatable / Filtres`](./spec-front.md) · Figma `1132-45377`
**Critères d'acceptation** :
- [ ] `<MalioDataTable>` + `usePaginatedList<Carrier>({url:'/carriers'})` ; colonnes Nom / Certification / Date de validité / Dernière activité.
- [ ] **RG-4.04** : date de validité QUALIMAT < aujourd'hui → **fond rouge**.
- [ ] Filtres (`search`, `certificationType`, `includeArchived`) → `setFilters` (page 1) ; **état 100 % local** (règle n°6).
- [ ] Boutons « + Ajouter » (si `manage`) / « Filtrer » / « Exporter » (XLSX) ; clic ligne → Consultation.
**Tests à prévoir** : Vitest — `usePaginatedList` (Hydra, exclusion archivés).
**Tips** : `useApi()` obligatoire ; pas de persistance URL.
### 1.13 — Page Ajouter `/carriers/new` (layout, onglets, formulaire principal POST)
**Position** : 1.13 • Suit : Répertoire • Précède : Saisie assistée QUALIMAT
**Tag** : Frontend • **Effort** : M
**Spec liée** : [`spec-front.md § Écran Ajouter / Formulaire principal`](./spec-front.md) · Figma node `1132-45382` (Ajouter Qualimat)
**Critères d'acceptation** :
- [ ] Layout + barre d'onglets `Qualimat · Adresses · Contacts · Prix` ; validation incrémentale (onglet suivant accessible après validation).
- [ ] Formulaire principal (Nom, Liste certification, Affréter, …) → `POST /api/carriers` ; succès → bascule onglet + champs readonly.
- [ ] `useFormErrors` : mapping 422 inline par champ ; `{ toast:false }`.
**Tests à prévoir** : Vitest — `useCarrierForm` (workflow par onglet, POST principal).
**Tips** : miroir `useSupplierForm`/`useProviderForm`.
### 1.14 — Saisie assistée QUALIMAT + champs conditionnels
**Position** : 1.14 • Suit : Page Ajouter • Précède : Onglet Adresses
**Tag** : Frontend • **Effort** : M
**Spec liée** : [`spec-front.md § Formulaire principal / Onglet Qualimat`](./spec-front.md) · RG-4.01→4.03 · Figma nodes `1132-50717` (Affréter), `1132-50982` (AUTRE→Décharge), `1132-45593` (LIOT)
**Critères d'acceptation** :
- [ ] **RG-4.01** : saisie du nom → `GET /api/qualimat_carriers?search=` → modal « Êtes-vous sûr… » → copie Nom + certification (`QUALIMAT`, readonly) + adresse + FK conservée.
- [ ] **Cas LIOT** : nom `LIOT` → champ immatriculations seul, autres masqués.
- [ ] **RG-4.02** : certification `AUTRE` → champ Décharge visible **et obligatoire** (upload).
- [ ] **RG-4.03** : « Affréter » coché → indexation / benne-fond mouvant / volume visibles et obligatoires.
**Tests à prévoir** : Vitest — affichage conditionnel (Affréter, AUTRE, LIOT) ; copie QUALIMAT.
**Tips** : `useQualimatSearch()` ; `useUpload()` (ticket 1.19) pour la décharge.
### 1.15 — Onglet Adresses (autocomplete BAN)
**Position** : 1.15 • Suit : Saisie QUALIMAT • Précède : Onglet Contacts
**Tag** : Frontend • **Effort** : M
**Spec liée** : [`spec-front.md § Onglet Adresses`](./spec-front.md) · RG-4.05→4.07 · Figma node `1132-45670`
**Critères d'acceptation** :
- [ ] Bloc adresse (Pays/CP/Ville/Adresse/complément) → `PATCH /api/carriers/{id}/addresses`.
- [ ] **RG-4.06** : `useAddressAutocomplete()` (BAN) — ville auto depuis CP, dégradé texte libre.
- [ ] **RG-4.05** : champs préremplis si QUALIMAT ; obligatoires si affrété. **RG-4.07** : pas de bouton Valider si QUALIMAT.
**Tests à prévoir** : Vitest — autocomplete nominal + dégradé (réutilisation M1/M2/M3).
**Tips** : ne pas réécrire `useAddressAutocomplete()`.
### 1.16 — Onglet Contacts
**Position** : 1.16 • Suit : Adresses • Précède : Onglet Prix
**Tag** : Frontend • **Effort** : S
**Spec liée** : [`spec-front.md § Onglet Contacts`](./spec-front.md) · RG-4.08 · Figma node `1132-45756`
**Critères d'acceptation** :
- [ ] Blocs contact (Nom/Prénom/Fonction/Téléphone x1-2/Email) → `PATCH /api/carriers/{id}/contacts`.
- [ ] **RG-4.08** : « + Nouveau contact » bloqué tant que le bloc courant est vide ; suppression avec modal.
**Tests à prévoir** : Vitest — règle « ≥ 1 champ », max 2 téléphones.
**Tips** : `mapViolationsToRecord` par ligne (pattern collections M1/M2/M3).
### 1.17 — Onglet Prix (Client/Fournisseur, sites)
**Position** : 1.17 • Suit : Contacts • Précède : Consultation/Modification
**Tag** : Frontend • **Effort** : M
**Spec liée** : [`spec-front.md § Onglet Prix`](./spec-front.md) · RG-4.09→4.11 · Figma node `1132-45859`
**Critères d'acceptation** :
- [ ] Radio `direction` (Client/Fournisseur) → bascule des champs (**RG-4.09**).
- [ ] **RG-4.10** (Client) : Client + Adresse de livraison (du client) + Adresse de départ (86/17/82).
- [ ] **RG-4.11** (Fournisseur) : Fournisseur + Adresse d'approvisionnement + Adresse de livraison (86/17/82).
- [ ] Communs : Benne/FM, Forfait/Tonne, Prix (`MalioInputAmount`), État du prix → `PATCH /api/carriers/{id}/prices`.
**Tests à prévoir** : Vitest — bascule Client/Fournisseur, champs requis.
**Tips** : selects clients/fournisseurs/sites via endpoints existants (security élargie § 4.8).
### 1.18 — Pages Consultation + Modification
**Position** : 1.18 • Suit : Onglet Prix • Précède : Upload/i18n
**Tag** : Frontend • **Effort** : M
**Spec liée** : [`spec-front.md § Consultation / Modification`](./spec-front.md)
**Critères d'acceptation** :
- [ ] Consultation readonly (ouvre sur Adresses) ; flèche retour ; « Modifier » (si `manage`) ; « Archiver » (Admin) → PATCH `isArchived`.
- [ ] Onglet Prix consultation = tableau regroupé Benne/FM + bouton Exporter (XLSX).
- [ ] Modification = mêmes formulaires, champs pré-remplis, PATCH partiel par onglet.
**Tests à prévoir** : Vitest — `useCarrier(id)` peuple les écrans depuis une seule réponse ; visibilité boutons par permission.
**Tips** : « Restaurer » remplace « Archiver » sur un archivé.
### 1.19 — Upload front (`useUpload`) + i18n + libellés audit
**Position** : 1.19 • Suit : Consultation/Modification • Précède : —
**Tag** : Frontend • **Effort** : S
**Spec liée** : [`spec-back.md § 2.7 / 2.8`](./spec-back.md) · [`spec-front.md § Composables`](./spec-front.md)
**Critères d'acceptation** :
- [ ] Composable `useUpload()` : `POST /api/uploaded_documents` (multipart) → IRI posée sur `carrier.dischargeDocument` (RG-4.02).
- [ ] Clés i18n : libellés certification, sidebar (`sidebar.transport.*`), **libellés audit** `audit.entity.transport_carrier/carrieraddress/carriercontact/carrierprice`.
- [ ] `<MalioInputUpload>` (exception documentée si type non couvert).
**Tests à prévoir** : Vitest — `useUpload` (succès + erreur MIME).
**Tips** : `AuditableEntitiesHaveI18nLabelTest` exige les clés audit.
---
## Actions Lesstime (à exécuter au feu vert de Matthieu)
1. `create-group` projectId 6, title « M4 — Répertoire transporteurs » → récupérer l'`id`.
2. `create-task` ×19 (statut `Prêt à dev` = 6, priorité Moyen=2, effort dans la description), dans l'ordre 1.1 → 1.19 :
- Tickets **1.1 → 1.11** (Backend, tag `3`) → **assigné à Matthieu**.
- Tickets **1.12 → 1.19** (Frontend, tag `2`) → **assigné à Tristan**.
3. Mettre à jour le frontmatter des specs (`lesstime_taskgroup_id`) + lien du groupe.
> Au push : récupérer les `userId` via `list-users` (Matthieu = `5` selon le référentiel ; Tristan à confirmer) pour renseigner l'assignation à la création.
+219 -17
View File
@@ -2,6 +2,7 @@
"common": {
"loading": "Chargement...",
"save": "Enregistrer",
"validate": "Valider",
"cancel": "Annuler",
"delete": "Supprimer",
"edit": "Modifier",
@@ -34,6 +35,10 @@
"section": "Technique",
"providers": "Répertoire prestataires"
},
"transport": {
"section": "Transport",
"carriers": "Répertoire transporteurs"
},
"core": {
"roles": "Gestion des rôles",
"users": "Utilisateurs",
@@ -62,7 +67,7 @@
"companyName": "Nom",
"categories": "Catégories",
"sites": "Site",
"lastActivity": "Dernière activité"
"lastActivity": "Dernière modification"
},
"filters": {
"title": "Filtres",
@@ -70,7 +75,7 @@
"categories": "Catégories",
"sites": "Sites",
"status": "Statut",
"includeArchived": "Inclure les archivés",
"archivedOnly": "Voir les archivés",
"apply": "Voir les résultats",
"reset": "Réinitialiser"
},
@@ -119,7 +124,7 @@
"back": "Retour au répertoire",
"loading": "Chargement du fournisseur…",
"notFound": "Fournisseur introuvable.",
"save": "Valider"
"save": "Enregistrer"
},
"form": {
"title": "Ajouter un fournisseur",
@@ -206,7 +211,7 @@
"companyName": "Nom",
"categories": "Catégories",
"sites": "Site",
"lastActivity": "Dernière activité"
"lastActivity": "Dernière modification"
},
"filters": {
"title": "Filtres",
@@ -262,7 +267,7 @@
"back": "Retour au répertoire",
"loading": "Chargement du client…",
"notFound": "Client introuvable.",
"save": "Valider"
"save": "Enregistrer"
},
"validation": {
"informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.",
@@ -376,7 +381,7 @@
"companyName": "Nom",
"categories": "Catégories",
"sites": "Site",
"lastActivity": "Dernière activité"
"lastActivity": "Dernière modification"
},
"filters": {
"title": "Filtres",
@@ -384,7 +389,7 @@
"categories": "Catégories",
"sites": "Sites",
"status": "Statut",
"includeArchived": "Inclure les archivés",
"archivedOnly": "Voir les archivés",
"apply": "Voir les résultats",
"reset": "Réinitialiser"
},
@@ -490,6 +495,199 @@
}
}
},
"transport": {
"carriers": {
"title": "Répertoire transporteurs",
"add": "Ajouter",
"export": "Exporter",
"empty": "Aucun transporteur pour l'instant.",
"column": {
"name": "Nom",
"certification": "Certification",
"validityDate": "Date de validité",
"lastActivity": "Dernière modification"
},
"certification": {
"QUALIMAT": "QUALIMAT",
"GMP_PLUS": "GMP+",
"OVOCOM": "OVOCOM",
"COMPTE_PROPRE": "Compte-propre",
"AUTRE": "Autre"
},
"filters": {
"title": "Filtres",
"search": "Recherche",
"certification": "Certification",
"status": "Statut",
"archivedOnly": "Voir les archivés",
"apply": "Voir les résultats",
"reset": "Réinitialiser"
},
"toast": {
"error": "Une erreur est survenue. Réessayez.",
"exportError": "L'export du répertoire transporteurs a échoué. Réessayez.",
"createSuccess": "Transporteur créé avec succès",
"integrateSuccess": "Transporteur QUALIMAT intégré",
"addressSaved": "Adresse enregistrée",
"contactSaved": "Contact enregistré",
"priceSaved": "Prix enregistré",
"updateSuccess": "Transporteur mis à jour avec succès",
"archiveSuccess": "Transporteur archivé avec succès",
"restoreSuccess": "Transporteur restauré avec succès"
},
"action": {
"edit": "Modifier",
"archive": "Archiver",
"restore": "Restaurer"
},
"consultation": {
"title": "Consultation transporteur",
"back": "Retour au répertoire",
"loading": "Chargement du transporteur…",
"notFound": "Transporteur introuvable.",
"confirmArchive": {
"title": "Archiver le transporteur",
"message": "Ce transporteur n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
},
"confirmRestore": {
"title": "Restaurer le transporteur",
"message": "Ce transporteur réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
},
"price": {
"group": "Transport",
"carrier": "Fournisseurs / Clients",
"aproOrSite": "Adresse sites",
"delivery": "Adresse livraisons",
"forfait": "Forfait (€)",
"tonne": "Tonne (€)",
"indexation": "Indexation",
"state": "État du prix",
"export": "Exporter",
"empty": "Aucun prix pour ce transporteur."
}
},
"edit": {
"title": "Modifier le transporteur",
"back": "Retour à la consultation",
"loading": "Chargement du transporteur…",
"notFound": "Transporteur introuvable.",
"save": "Enregistrer"
},
"containerType": {
"BENNE": "Benne",
"FOND_MOUVANT": "Fond mouvant"
},
"tab": {
"qualimat": "Qualimat",
"addresses": "Adresses",
"contacts": "Contacts",
"prices": "Prix"
},
"form": {
"title": "Ajouter un transporteur",
"back": "Retour au répertoire",
"submit": "Valider",
"comingSoon": "À venir",
"duplicateName": "Un transporteur actif portant ce nom existe déjà.",
"main": {
"name": "Nom",
"certificationType": "Certification transport",
"isChartered": "Affréter",
"indexationRate": "Indexation %",
"containerType": "Benne / Fond mouvant",
"volumeM3": "Volume m³",
"discharge": "Décharge",
"liotPlates": "Immatriculations LIOT",
"liotPlatesHint": "Séparées par « ; »"
},
"qualimat": {
"empty": "Aucun transporteur QUALIMAT trouvé.",
"searchHint": "Saisissez le nom du transporteur pour lancer la recherche.",
"columns": {
"name": "Nom",
"address": "Adresse",
"validityDate": "Date de validité"
},
"confirm": {
"title": "Intégration QUALIMAT",
"message": "Êtes-vous sûr de vouloir intégrer ce transporteur ?",
"cancel": "Annuler",
"confirm": "Intégrer"
}
},
"errors": {
"nameRequired": "Le nom du transporteur est obligatoire.",
"certificationRequired": "Le type de certification est obligatoire.",
"dischargeRequired": "La décharge est obligatoire pour une certification « Autre ».",
"indexationRequired": "Le taux d'indexation est obligatoire pour un transporteur affrété.",
"containerTypeRequired": "Le type de contenant est obligatoire pour un transporteur affrété.",
"volumeRequired": "Le volume est obligatoire pour un transporteur affrété.",
"uploadFailed": "Le téléversement de la décharge a échoué."
},
"address": {
"country": "Pays",
"postalCode": "Code postal",
"city": "Ville",
"street": "Adresse",
"streetComplement": "Adresse complémentaire",
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
},
"contact": {
"lastName": "Nom",
"firstName": "Prénom",
"jobTitle": "Fonction",
"phonePrimary": "Téléphone",
"phoneSecondary": "Téléphone (2)",
"addPhone": "Ajouter un numéro",
"email": "Email",
"add": "Nouveau contact",
"remove": "Supprimer le contact"
},
"confirmDelete": {
"title": "Supprimer ce bloc",
"message": "Cette suppression est définitive. Confirmer ?",
"cancel": "Annuler",
"confirm": "Supprimer"
},
"price": {
"direction": "Sens",
"directionClient": "Client",
"directionSupplier": "Fournisseur",
"client": "Client",
"clientDeliveryAddress": "Adresse de livraison",
"departureSite": "Adresse de départ",
"supplier": "Fournisseur",
"supplierSupplyAddress": "Adresse d'approvisionnement",
"deliverySite": "Adresse de livraison",
"containerType": "Benne / Fond mouvant",
"pricingUnit": "Forfait / Tonne",
"pricingForfait": "Forfait",
"pricingTonne": "Tonne",
"price": "Prix",
"priceState": "État du prix",
"stateEnCours": "En cours",
"stateValide": "Validé",
"stateNonValide": "Non validé",
"add": "Nouveau prix",
"remove": "Supprimer le prix",
"errors": {
"direction": "Le sens du prix est obligatoire.",
"client": "Le client est obligatoire pour un prix client.",
"clientDeliveryAddress": "L'adresse de livraison du client est obligatoire pour un prix client.",
"departureSite": "Le site de départ est obligatoire pour un prix client.",
"supplier": "Le fournisseur est obligatoire pour un prix fournisseur.",
"supplierSupplyAddress": "L'adresse d'approvisionnement est obligatoire pour un prix fournisseur.",
"deliverySite": "Le site de livraison est obligatoire pour un prix fournisseur.",
"containerType": "Le type de contenant est obligatoire.",
"pricingUnit": "L'unité de tarification est obligatoire.",
"price": "Le prix est obligatoire.",
"priceState": "L'état du prix est obligatoire."
}
}
}
}
},
"auth": {
"login": "Connexion",
"logout": "Deconnexion",
@@ -532,23 +730,27 @@
"delete": "Suppression"
},
"entity": {
"core_user": "Utilisateur",
"core_role": "Rôle",
"core_permission": "Permission",
"sites_site": "Site",
"catalog_category": "Catégorie",
"commercial_client": "Client",
"core_user": "Utilisateur",
"core_role": "Rôle",
"core_permission": "Permission",
"sites_site": "Site",
"catalog_category": "Catégorie",
"commercial_client": "Client",
"commercial_clientaddress": "Adresse client",
"commercial_clientcontact": "Contact client",
"commercial_clientrib": "RIB client",
"commercial_supplier": "Fournisseur",
"commercial_clientrib": "RIB client",
"commercial_supplier": "Fournisseur",
"commercial_supplieraddress": "Adresse fournisseur",
"commercial_suppliercontact": "Contact fournisseur",
"commercial_supplierrib": "RIB fournisseur",
"technique_provider": "Prestataire",
"technique_provider": "Prestataire",
"technique_provideraddress": "Adresse prestataire",
"technique_providercontact": "Contact prestataire",
"technique_providerrib": "RIB prestataire"
"technique_providerrib": "RIB prestataire",
"transport_carrier": "Transporteur",
"transport_carrieraddress": "Adresse transporteur",
"transport_carriercontact": "Contact transporteur",
"transport_carrierprice": "Prix transporteur"
},
"empty": "Aucune activité enregistrée",
"no_results": "Aucun résultat pour ces filtres",
@@ -59,7 +59,7 @@
/>
<MalioButton
v-if="canShowSave"
:label="t('common.save')"
:label="isCreateMode ? t('common.validate') : t('common.save')"
variant="primary"
button-class="w-m-btn-action"
:disabled="form.submitting.value || loadingTypes"
@@ -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)]">
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="removable && !readonly"
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -21,6 +21,7 @@
:options="addressTypeOptions"
:label="t('commercial.clients.form.address.addressType')"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.isProspect"
@update:model-value="onAddressTypeChange"
@@ -33,6 +34,7 @@
:label="t('commercial.clients.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
@@ -44,6 +46,7 @@
:label="t('commercial.clients.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
@@ -57,6 +60,7 @@
:label="t('commercial.clients.form.address.billingEmail')"
:required="true"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.billingEmail"
:addable="!model.hasSecondaryBillingEmail && !readonly"
@@ -71,6 +75,7 @@
:model-value="model.billingEmailSecondary"
:label="t('commercial.clients.form.address.billingEmailSecondary')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.billingEmailSecondary"
@update:model-value="(v: string) => update('billingEmailSecondary', v)"
@@ -82,6 +87,7 @@
:label="t('commercial.clients.form.address.categories')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
@@ -92,6 +98,7 @@
:options="countryOptions"
:label="t('commercial.clients.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="true"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
@@ -101,6 +108,7 @@
:label="t('commercial.clients.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
@@ -115,6 +123,7 @@
:options="cityOptions"
:label="t('commercial.clients.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="true"
:error="errors?.city"
@@ -125,6 +134,7 @@
:model-value="model.city"
:label="t('commercial.clients.form.address.city')"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
@@ -142,13 +152,14 @@
blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste
pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. -->
<MalioInputAutocomplete
v-if="!readonly"
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('commercial.clients.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.street"
:allow-create="true"
@@ -162,6 +173,7 @@
:model-value="model.street"
:label="t('commercial.clients.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
@@ -173,6 +185,7 @@
:model-value="model.streetComplement"
:label="t('commercial.clients.form.address.streetComplement')"
:readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
@@ -191,6 +204,7 @@ import {
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
import { sanitizeAddress, sanitizeEmail } from '~/shared/utils/textSanitize'
// Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####'
@@ -209,6 +223,8 @@ const props = defineProps<{
countryOptions: RefOption[]
removable?: boolean
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
@@ -284,9 +300,23 @@ const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = []
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
// Filtres de saisie par champ (ERP-193) : voie / complement / ville = profil
// adresse, emails de facturation = profil email.
const FIELD_SANITIZERS: Partial<Record<keyof AddressFormDraft, (v: string) => string>> = {
street: sanitizeAddress,
streetComplement: sanitizeAddress,
city: sanitizeAddress,
billingEmail: sanitizeEmail,
billingEmailSecondary: sanitizeEmail,
}
/** Emet un nouveau brouillon avec le champ modifie (immutabilite), sanitise si besoin. */
function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
const sanitizer = FIELD_SANITIZERS[field]
const next = (sanitizer && typeof value === 'string')
? (sanitizer(value) as AddressFormDraft[K])
: value
emit('update:modelValue', { ...props.modelValue, [field]: next })
}
/** Revele le 2e champ email de facturation (clic sur le « + »). */
@@ -4,7 +4,7 @@
non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule.
ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="removable && !readonly"
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -16,6 +16,7 @@
:model-value="model.lastName"
:label="t('commercial.clients.form.contact.lastName')"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
@@ -23,6 +24,7 @@
:model-value="model.firstName"
:label="t('commercial.clients.form.contact.firstName')"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
@@ -34,6 +36,7 @@
:model-value="model.jobTitle"
:label="t('commercial.clients.form.contact.jobTitle')"
:readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
@@ -42,6 +45,7 @@
:model-value="model.email"
:label="t('commercial.clients.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
@@ -51,6 +55,7 @@
:label="t('commercial.clients.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.clients.form.contact.addPhone')"
@@ -63,6 +68,7 @@
:label="t('commercial.clients.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
@@ -71,6 +77,7 @@
<script setup lang="ts">
import type { ContactFormDraft } from '~/modules/commercial/types/clientForm'
import { sanitizeEmail, sanitizeFreeText, sanitizePersonName } from '~/shared/utils/textSanitize'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste
// serveur, cf. formatPhoneFR re-applique a la valeur renvoyee).
@@ -85,6 +92,8 @@ const props = defineProps<{
removable?: boolean
/** Bloc en lecture seule (onglet valide). */
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
@@ -99,9 +108,22 @@ const { t } = useI18n()
// Alias local pour la lisibilite du template.
const model = computed(() => props.modelValue)
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
// Filtres de saisie par champ (ERP-193) : on retire les caracteres parasites a la
// frappe. Noms = profil personne, fonction = texte libre, email = profil email.
const FIELD_SANITIZERS: Partial<Record<keyof ContactFormDraft, (v: string) => string>> = {
lastName: sanitizePersonName,
firstName: sanitizePersonName,
jobTitle: sanitizeFreeText,
email: sanitizeEmail,
}
/** Emet un nouveau brouillon avec le champ modifie (immutabilite), sanitise si besoin. */
function update<K extends keyof ContactFormDraft>(field: K, value: ContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
const sanitizer = FIELD_SANITIZERS[field]
const next = (sanitizer && typeof value === 'string')
? (sanitizer(value) as ContactFormDraft[K])
: value
emit('update:modelValue', { ...props.modelValue, [field]: next })
}
/** Revele le 2e numero (RG-1.02/1.20 : max 1 secondaire, le « + » disparait). */
@@ -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)]">
<!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon
v-if="removable && !readonly"
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -18,6 +18,7 @@
:options="addressTypeOptions"
:label="t('commercial.suppliers.form.address.addressType')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="true"
:error="errors?.addressType"
@@ -31,6 +32,7 @@
:label="t('commercial.suppliers.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
@@ -43,6 +45,7 @@
:label="t('commercial.suppliers.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
@@ -57,6 +60,7 @@
:label="t('commercial.suppliers.form.address.categories')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
@@ -67,6 +71,7 @@
:options="countryOptions"
:label="t('commercial.suppliers.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="true"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
@@ -76,6 +81,7 @@
:label="t('commercial.suppliers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
@@ -88,6 +94,7 @@
:options="cityOptions"
:label="t('commercial.suppliers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="true"
:error="errors?.city"
@@ -98,6 +105,7 @@
:model-value="model.city"
:label="t('commercial.suppliers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
@@ -107,13 +115,14 @@
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly"
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('commercial.suppliers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.street"
:allow-create="true"
@@ -127,6 +136,7 @@
:model-value="model.street"
:label="t('commercial.suppliers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
@@ -138,6 +148,7 @@
:model-value="model.streetComplement"
:label="t('commercial.suppliers.form.address.streetComplement')"
:readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
@@ -149,6 +160,7 @@
:label="t('commercial.suppliers.form.address.bennes')"
:min="0"
:readonly="readonly"
:disabled="disabled"
:error="errors?.bennes"
@update:model-value="(v: string) => update('bennes', v)"
/>
@@ -160,6 +172,7 @@
:model-value="model.triageProvider"
group-class="self-center"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: boolean) => update('triageProvider', v)"
/>
</div>
@@ -169,6 +182,7 @@
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
import type { SupplierAddressFormDraft, SupplierAddressType } from '~/modules/commercial/types/supplierForm'
import { sanitizeAddress } from '~/shared/utils/textSanitize'
// Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####'
@@ -187,6 +201,8 @@ const props = defineProps<{
countryOptions: RefOption[]
removable?: boolean
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
@@ -238,9 +254,21 @@ const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = []
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
// Filtres de saisie par champ (ERP-193) : voie / complement / ville = profil
// adresse. Les autres champs (CP, bennes, selects) ne sont pas filtres ici.
const FIELD_SANITIZERS: Partial<Record<keyof SupplierAddressFormDraft, (v: string) => string>> = {
street: sanitizeAddress,
streetComplement: sanitizeAddress,
city: sanitizeAddress,
}
/** Emet un nouveau brouillon avec le champ modifie (immutabilite), sanitise si besoin. */
function update<K extends keyof SupplierAddressFormDraft>(field: K, value: SupplierAddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
const sanitizer = FIELD_SANITIZERS[field]
const next = (sanitizer && typeof value === 'string')
? (sanitizer(value) as SupplierAddressFormDraft[K])
: value
emit('update:modelValue', { ...props.modelValue, [field]: next })
}
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
@@ -3,7 +3,7 @@
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc, RG-2.13) ou en lecture seule. -->
<MalioButtonIcon
v-if="removable && !readonly"
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -15,6 +15,7 @@
:model-value="model.lastName"
:label="t('commercial.suppliers.form.contact.lastName')"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
@@ -22,6 +23,7 @@
:model-value="model.firstName"
:label="t('commercial.suppliers.form.contact.firstName')"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
@@ -33,6 +35,7 @@
:model-value="model.jobTitle"
:label="t('commercial.suppliers.form.contact.jobTitle')"
:readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
@@ -41,6 +44,7 @@
:model-value="model.email"
:label="t('commercial.suppliers.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
@@ -50,6 +54,7 @@
:label="t('commercial.suppliers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.suppliers.form.contact.addPhone')"
@@ -62,6 +67,7 @@
:label="t('commercial.suppliers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
@@ -70,6 +76,7 @@
<script setup lang="ts">
import type { SupplierContactFormDraft } from '~/modules/commercial/types/supplierForm'
import { sanitizeEmail, sanitizeFreeText, sanitizePersonName } from '~/shared/utils/textSanitize'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##'
@@ -83,6 +90,8 @@ const props = defineProps<{
removable?: boolean
/** Bloc en lecture seule (onglet valide). */
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
@@ -97,9 +106,22 @@ const { t } = useI18n()
// Alias local pour la lisibilite du template.
const model = computed(() => props.modelValue)
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
// Filtres de saisie par champ (ERP-193) : on retire les caracteres parasites a la
// frappe. Noms = profil personne, fonction = texte libre, email = profil email.
const FIELD_SANITIZERS: Partial<Record<keyof SupplierContactFormDraft, (v: string) => string>> = {
lastName: sanitizePersonName,
firstName: sanitizePersonName,
jobTitle: sanitizeFreeText,
email: sanitizeEmail,
}
/** Emet un nouveau brouillon avec le champ modifie (immutabilite), sanitise si besoin. */
function update<K extends keyof SupplierContactFormDraft>(field: K, value: SupplierContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
const sanitizer = FIELD_SANITIZERS[field]
const next = (sanitizer && typeof value === 'string')
? (sanitizer(value) as SupplierContactFormDraft[K])
: value
emit('update:modelValue', { ...props.modelValue, [field]: next })
}
/** Revele le 2e numero (max 1 secondaire, le « + » disparait). */
@@ -25,8 +25,8 @@ function makeHydra(total: number): HydraCollection<Client> {
describe('useClientsRepository', () => {
beforeEach(() => {
mockGet.mockReset()
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
mockGet.mockResolvedValue(makeHydra(25))
// 60 items → 3 pages a 25/page : permet de tester la navigation page 2.
mockGet.mockResolvedValue(makeHydra(60))
})
it('cible la ressource /clients en page 1 par defaut', async () => {
@@ -35,7 +35,7 @@ describe('useClientsRepository', () => {
expect(mockGet).toHaveBeenLastCalledWith(
'/clients',
{ page: 1, itemsPerPage: 10 },
{ page: 1, itemsPerPage: 25 },
expect.objectContaining({ toast: false }),
)
})
@@ -65,7 +65,7 @@ describe('useClientsRepository', () => {
'siteId[]': ['1', '2'],
archivedOnly: true,
page: 1,
itemsPerPage: 10,
itemsPerPage: 25,
},
expect.objectContaining({ toast: false }),
)
@@ -78,7 +78,7 @@ describe('useClientsRepository', () => {
expect(mockGet).toHaveBeenLastCalledWith(
'/clients',
{ page: 1, itemsPerPage: 10 },
{ page: 1, itemsPerPage: 25 },
expect.objectContaining({ toast: false }),
)
})
@@ -25,8 +25,8 @@ function makeHydra(total: number): HydraCollection<Supplier> {
describe('useSuppliersRepository', () => {
beforeEach(() => {
mockGet.mockReset()
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
mockGet.mockResolvedValue(makeHydra(25))
// 60 items → 3 pages a 25/page : permet de tester la navigation page 2.
mockGet.mockResolvedValue(makeHydra(60))
})
it('cible la ressource /suppliers en page 1 par defaut', async () => {
@@ -35,7 +35,7 @@ describe('useSuppliersRepository', () => {
expect(mockGet).toHaveBeenLastCalledWith(
'/suppliers',
{ page: 1, itemsPerPage: 10 },
{ page: 1, itemsPerPage: 25 },
expect.objectContaining({ toast: false }),
)
})
@@ -51,7 +51,7 @@ describe('useSuppliersRepository', () => {
search: 'acme',
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
'siteId[]': ['86', '17'],
includeArchived: true,
archivedOnly: true,
},
{ replace: true },
)
@@ -63,9 +63,9 @@ describe('useSuppliersRepository', () => {
search: 'acme',
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
'siteId[]': ['86', '17'],
includeArchived: true,
archivedOnly: true,
page: 1,
itemsPerPage: 10,
itemsPerPage: 25,
},
expect.objectContaining({ toast: false }),
)
@@ -73,12 +73,12 @@ describe('useSuppliersRepository', () => {
it('repasse a une query propre apres reinitialisation des filtres', async () => {
const repo = useSuppliersRepository()
await repo.setFilters({ search: 'acme', includeArchived: true }, { replace: true })
await repo.setFilters({ search: 'acme', archivedOnly: true }, { replace: true })
await repo.setFilters({}, { replace: true })
expect(mockGet).toHaveBeenLastCalledWith(
'/suppliers',
{ page: 1, itemsPerPage: 10 },
{ page: 1, itemsPerPage: 25 },
expect.objectContaining({ toast: false }),
)
})
@@ -49,5 +49,6 @@ export interface Client {
* gerer.
*/
export function useClientsRepository() {
return usePaginatedList<Client>({ url: '/clients' })
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193).
return usePaginatedList<Client>({ url: '/clients', defaultItemsPerPage: 25 })
}
@@ -41,14 +41,16 @@ export interface Supplier {
* sur la ressource `/suppliers` (RG-13 : pagination serveur obligatoire ; jamais
* de chargement integral en memoire). Miroir de `useClientsRepository` (M1).
*
* Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes
* par la page via `setFilters` du composable partage la remise en page 1 est
* garantie.
* Les filtres (recherche, categories, sites, archives) sont pilotes par la page
* via `setFilters` du composable partage la remise en page 1 est garantie.
* Cocher « Voir les archivés » envoie `archivedOnly=true` seules les archives
* sont listees (aligne sur Client).
*
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
* `usePaginatedList`. Aucun reset au logout a gerer.
*/
export function useSuppliersRepository() {
return usePaginatedList<Supplier>({ url: '/suppliers' })
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193).
return usePaginatedList<Supplier>({ url: '/suppliers', defaultItemsPerPage: 25 })
}
@@ -172,16 +172,16 @@ describe('Répertoire fournisseurs (page /suppliers)', () => {
)
})
it('repercute le filtre « Inclure les archivés » dans setFilters sans toucher l\'URL', async () => {
it('repercute le filtre « Voir les archivés » dans setFilters sans toucher l\'URL', async () => {
const wrapper = mountPage()
await flushPromises()
// Coche « Inclure les archivés » puis applique les filtres.
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true)
// Coche « Voir les archivés » puis applique les filtres.
await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith(
{ includeArchived: true },
{ archivedOnly: true },
{ replace: true },
)
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
@@ -192,7 +192,7 @@ describe('Répertoire fournisseurs (page /suppliers)', () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true)
await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
@@ -24,10 +24,11 @@
`manage` (ex. Compta). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:model-value="main.companyName"
@update:model-value="(v: string) => main.companyName = sanitizeFreeText(v)"
:label="t('commercial.clients.form.main.companyName')"
:required="true"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
@@ -35,7 +36,7 @@
:options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
:readonly="businessReadonly"
:disabled="businessReadonly"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -46,7 +47,7 @@
:options="relationOptions"
:label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')"
:readonly="businessReadonly"
:disabled="businessReadonly"
@update:model-value="onRelationChange"
/>
<MalioSelect
@@ -54,7 +55,7 @@
:model-value="main.brokerIri"
:options="brokerOptions"
:label="t('commercial.clients.form.main.brokerName')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:required="true"
:error="mainErrors.errors.broker"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
@@ -64,7 +65,7 @@
:model-value="main.distributorIri"
:options="distributorOptions"
:label="t('commercial.clients.form.main.distributorName')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:required="true"
:error="mainErrors.errors.distributor"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
@@ -74,7 +75,7 @@
v-model="main.triageService"
:label="t('commercial.clients.form.main.triageService')"
group-class="self-center"
:readonly="businessReadonly"
:disabled="businessReadonly"
/>
</div>
@@ -101,20 +102,24 @@
resize="none"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:model-value="information.competitors"
@update:model-value="(v: string) => information.competitors = sanitizeFreeText(v)"
:label="t('commercial.clients.form.information.competitors')"
:readonly="businessReadonly"
:disabled="businessReadonly"
: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
v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
@@ -122,25 +127,30 @@
v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK"
:readonly="businessReadonly"
:disabled="businessReadonly"
: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
v-model="information.revenueAmount"
:key="revenueAmountKey"
:model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/>
<MalioInputText
v-model="information.directorName"
:model-value="information.directorName"
@update:model-value="(v: string) => information.directorName = sanitizePersonName(v)"
:label="t('commercial.clients.form.information.directorName')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.directorName"
/>
<MalioInputAmount
v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.profitAmount"
/>
</div>
@@ -157,13 +167,17 @@
<!-- Onglet Contact -->
<template #contact>
<div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ClientContactBlock
v-for="(contact, index) in contacts"
:key="contact.id ?? `new-${index}`"
:model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="contacts.length > 1"
:readonly="businessReadonly"
:removable="isRowRemovable(contacts, index)"
:disabled="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
@@ -199,8 +213,8 @@
:site-options="siteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="addresses.length > 1"
:readonly="businessReadonly"
:removable="isRowRemovable(addresses, index)"
:disabled="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@@ -235,14 +249,15 @@
v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:model-value="accounting.accountNumber"
@update:model-value="(v: string) => accounting.accountNumber = sanitizeCodeAlnum(v)"
:label="t('commercial.clients.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/>
@@ -250,16 +265,17 @@
:model-value="accounting.tvaModeIri"
:options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:model-value="accounting.nTva"
@update:model-value="(v: string) => accounting.nTva = sanitizeCodeAlnum(v)"
:label="t('commercial.clients.form.accounting.nTva')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/>
@@ -267,7 +283,7 @@
:model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@@ -277,7 +293,7 @@
:model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@@ -288,7 +304,7 @@
:model-value="accounting.bankIri"
:options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@@ -304,7 +320,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1"
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -315,21 +331,23 @@
<MalioInputText
v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:model-value="rib.bic"
@update:model-value="(v: string) => rib.bic = sanitizeCodeAlnum(v)"
:label="t('commercial.clients.form.accounting.ribBic')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:model-value="rib.iban"
@update:model-value="(v: string) => rib.iban = sanitizeCodeAlnum(v)"
:label="t('commercial.clients.form.accounting.ribIban')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
/>
@@ -419,6 +437,9 @@ import {
type InformationFormDraft,
type MainFormDraft,
} from '~/modules/commercial/utils/forms/clientEdit'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import { sanitizeCodeAlnum, sanitizeFreeText, sanitizePersonName } from '~/shared/utils/textSanitize'
import {
buildClientFormTabKeys,
isAddressValid,
@@ -440,6 +461,7 @@ import {
type RibFormDraft,
} from '~/modules/commercial/types/clientForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
import { readHistoryTab } from '~/shared/utils/historyTab'
// Masques de saisie (la normalisation finale reste serveur).
@@ -486,14 +508,26 @@ const headerTitle = computed(() => client.value?.companyName ?? t('commercial.cl
const main = reactive<MainFormDraft>(mapMainDraft({} as ClientDetail))
const information = reactive<InformationFormDraft>(mapInformationDraft({} 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 addresses = ref<AddressFormDraft[]>([])
const ribs = ref<RibFormDraft[]>([])
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
const removedContactIds = ref<number[]>([])
const removedAddressIds = ref<number[]>([])
const removedRibIds = ref<number[]>([])
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
@@ -754,32 +788,31 @@ function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact())
}
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
// local. Echec serveur : bloc conserve + erreur remontee.
function askRemoveContact(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
const removed = contacts.value[index]
if (removed?.id != null) removedContactIds.value.push(removed.id)
contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
})
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => removeCollectionRow({
rows: contacts.value,
errors: contactErrors.value,
index,
endpoint: '/client_contacts',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyContact,
onError: showError,
}))
}
/**
* Valide l'onglet Contact : DELETE des contacts retires (existants), puis
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
* collection contacts (endpoints client_contact dedies).
* Valide l'onglet Contact : POST/PATCH des blocs restants sur la sous-ressource.
* Strictement scope a la collection contacts (endpoints client_contact dedies). La
* suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
*/
async function submitContacts(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
contactErrors.value = []
try {
for (const id of removedContactIds.value) {
await api.delete(`/client_contacts/${id}`, {}, { toast: false })
}
removedContactIds.value = []
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces neuves vides (ex. tous les contacts existants supprimes), on ne
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
@@ -836,14 +869,15 @@ function addAddress(): void {
}
function askRemoveAddress(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
const removed = addresses.value[index]
if (removed?.id != null) removedAddressIds.value.push(removed.id)
addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
})
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => removeCollectionRow({
rows: addresses.value,
errors: addressErrors.value,
index,
endpoint: '/client_addresses',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyAddress,
onError: showError,
}))
}
function onAddressDegraded(): void {
@@ -855,17 +889,12 @@ function onAddressDegraded(): void {
})
}
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
/** Valide l'onglet Adresse : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
async function submitAddresses(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
addressErrors.value = []
try {
for (const id of removedAddressIds.value) {
await api.delete(`/client_addresses/${id}`, {}, { toast: false })
}
removedAddressIds.value = []
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
const hasError = await submitRows(
addresses.value,
@@ -937,29 +966,32 @@ function addRib(): void {
if (canAddRib.value) ribs.value.push(emptyRib())
}
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
// d'une LCR (RG-1.13) -> 409 remonte via showError (message back), bloc conserve.
function askRemoveRib(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
const removed = ribs.value[index]
if (removed?.id != null) removedRibIds.value.push(removed.id)
ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
})
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => removeCollectionRow({
rows: ribs.value,
errors: ribErrors.value,
index,
endpoint: '/client_ribs',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyRib,
onError: showError,
}))
}
/**
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
* back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
* back). Les RIB crees d'abord : le back valide RG-1.13 (LCR => au moins un RIB
* persiste) sur le PATCH scalaires.
*
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
* plus de DELETE differe ici.
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR hors-LCR ce sont des
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
* (corbeille d'un bloc, toujours sous LCR), plus l'auto-suppression au changement
* de type de reglement. Aucun champ main/information dans le payload (mode strict
* RG-1.28 : sinon 403 sur tout le payload).
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-1.28 :
* sinon 403 sur tout le payload).
*/
async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || tabSubmitting.value) return
@@ -1013,14 +1045,6 @@ async function submitAccounting(): Promise<void> {
return
}
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (e) {
@@ -23,7 +23,7 @@
/>
<MalioButton
v-if="showArchive"
variant="secondary"
variant="danger"
icon-name="mdi:archive-arrow-down-outline"
icon-position="left"
:label="t('commercial.clients.action.archive')"
@@ -50,14 +50,14 @@
<MalioInputText
:model-value="client.companyName"
:label="t('commercial.clients.form.main.companyName')"
readonly
disabled
/>
<MalioSelectCheckbox
:model-value="categoryIris"
:options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
readonly
disabled
/>
<!-- Relation toujours affichee (vide = « Aucun »), comme en edition. -->
<MalioSelect
@@ -65,7 +65,7 @@
:options="relationOptions"
:label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')"
readonly
disabled
/>
<!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant,
aucune valeur sans relation meme comportement qu'en edition). -->
@@ -73,18 +73,20 @@
v-if="relation.type"
:model-value="relation.name"
:label="relation.type === 'distributeur' ? t('commercial.clients.form.main.distributorName') : t('commercial.clients.form.main.brokerName')"
readonly
disabled
/>
<MalioCheckbox
:model-value="client.triageService === true"
:label="t('commercial.clients.form.main.triageService')"
group-class="self-center"
readonly
disabled
/>
</div>
<!-- Onglets (navigation libre, tout en lecture seule) -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- ERP-193 : on n'affiche la barre que s'il reste au moins un onglet
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 -->
<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)]">
@@ -97,37 +99,37 @@
resize="none"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
readonly
disabled
/>
<MalioInputText
:model-value="information.competitors"
:label="t('commercial.clients.form.information.competitors')"
readonly
disabled
/>
<MalioDate
:model-value="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')"
readonly
disabled
/>
<MalioInputText
:model-value="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')"
readonly
disabled
/>
<MalioInputAmount
:model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')"
readonly
disabled
/>
<MalioInputText
:model-value="information.directorName"
:label="t('commercial.clients.form.information.directorName')"
readonly
disabled
/>
<MalioInputAmount
:model-value="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')"
readonly
disabled
/>
</div>
</template>
@@ -140,7 +142,7 @@
:key="contact.id ?? index"
:model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
readonly
disabled
/>
</div>
</template>
@@ -157,7 +159,7 @@
:site-options="allSiteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
readonly
disabled
/>
</div>
</template>
@@ -171,38 +173,38 @@
:model-value="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK"
readonly
disabled
/>
<MalioInputText
:model-value="accounting.accountNumber"
:label="t('commercial.clients.form.accounting.accountNumber')"
readonly
disabled
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')"
empty-option-label=""
readonly
disabled
/>
<MalioInputText
:model-value="accounting.nTva"
:label="t('commercial.clients.form.accounting.nTva')"
readonly
disabled
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')"
empty-option-label=""
readonly
disabled
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')"
empty-option-label=""
readonly
disabled
/>
<MalioSelect
v-if="accounting.bankIri"
@@ -210,7 +212,7 @@
:options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')"
empty-option-label=""
readonly
disabled
/>
</div>
</div>
@@ -225,28 +227,25 @@
<MalioInputText
:model-value="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
readonly
disabled
/>
<MalioInputText
:model-value="rib.bic"
:label="t('commercial.clients.form.accounting.ribBic')"
readonly
disabled
/>
<MalioInputText
:model-value="rib.iban"
:label="t('commercial.clients.form.accounting.ribIban')"
readonly
disabled
/>
</div>
</div>
</div>
</template>
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
<!-- ERP-193 : les onglets « a venir » (Transport / Statistiques /
Rapports / Echanges) ne sont plus rendus en consultation
(masquage des onglets vides) slots supprimes. -->
</MalioTabList>
</template>
@@ -278,13 +277,14 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient'
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
import { isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab'
import {
canEditClient,
categoryOptionsOf,
clientConsultationVisibleTabs,
contactOptionsOf,
mapAccountingDraft,
mapAddressView,
@@ -412,9 +412,11 @@ const paymentTypeOptions = computed(() => referentialOptionOf(client.value?.paym
const bankOptions = computed(() => referentialOptionOf(client.value?.bank))
// Onglets : navigation LIBRE (pas de sequence forcee en consultation)
// 4 onglets actifs (Information, Contact, Adresse, + Comptabilite si droit) et
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
// ERP-193 (retour metier) : on masque les coquilles non implementees ET tout
// onglet de donnees vide. La liste depend donc du payload charge.
const visibleTabKeys = computed(() => clientConsultationVisibleTabs(client.value, {
canAccountingView: canAccountingView.value,
}))
const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline',
@@ -427,14 +429,26 @@ const TAB_ICONS: Record<string, string> = {
exchanges: 'mdi:account-group-outline',
}
const tabs = computed(() => tabKeys.value.map(key => ({
const tabs = computed(() => visibleTabKeys.value.map(key => ({
key,
label: t(`commercial.clients.tab.${key}`),
icon: TAB_ICONS[key],
})))
// Onglet initial : repris de l'edition au retour (history.state), sinon Information.
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
// Onglet initial : vide tant que le client n'est pas charge. Des que la liste
// des onglets visibles est connue, on cale sur l'onglet repris de l'edition
// (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
function goBack(): void {
@@ -18,10 +18,11 @@
automatiquement sur l'onglet Information. -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:model-value="main.companyName"
@update:model-value="(v: string) => main.companyName = sanitizeFreeText(v)"
:label="t('commercial.clients.form.main.companyName')"
:required="true"
:readonly="mainLocked"
:disabled="mainLocked"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
@@ -29,7 +30,7 @@
:options="referentials.categories.value"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
:readonly="mainLocked"
:disabled="mainLocked"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -40,7 +41,7 @@
:options="relationOptions"
:label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')"
:readonly="mainLocked"
:disabled="mainLocked"
@update:model-value="onRelationChange"
/>
<MalioSelect
@@ -48,7 +49,7 @@
:model-value="main.brokerIri"
:options="referentials.brokers.value"
:label="t('commercial.clients.form.main.brokerName')"
:readonly="mainLocked"
:disabled="mainLocked"
:required="true"
:error="mainErrors.errors.broker"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
@@ -58,7 +59,7 @@
:model-value="main.distributorIri"
:options="referentials.distributors.value"
:label="t('commercial.clients.form.main.distributorName')"
:readonly="mainLocked"
:disabled="mainLocked"
:required="true"
:error="mainErrors.errors.distributor"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
@@ -68,7 +69,7 @@
v-model="main.triageService"
:label="t('commercial.clients.form.main.triageService')"
group-class="self-center"
:readonly="mainLocked"
:disabled="mainLocked"
/>
</div>
@@ -96,20 +97,24 @@
resize="none"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:model-value="information.competitors"
@update:model-value="(v: string) => information.competitors = sanitizeFreeText(v)"
:label="t('commercial.clients.form.information.competitors')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
: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
v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
@@ -117,25 +122,30 @@
v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
: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
v-model="information.revenueAmount"
:key="revenueAmountKey"
:model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/>
<MalioInputText
v-model="information.directorName"
:model-value="information.directorName"
@update:model-value="(v: string) => information.directorName = sanitizePersonName(v)"
:label="t('commercial.clients.form.information.directorName')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.directorName"
/>
<MalioInputAmount
v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.profitAmount"
/>
</div>
@@ -156,13 +166,17 @@
<!-- Onglet Contact -->
<template #contact>
<div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ClientContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="index > 0"
:readonly="isValidated('contact')"
:removable="isRowRemovable(contacts, index)"
:disabled="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
@@ -198,8 +212,8 @@
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="index > 0"
:readonly="isValidated('address')"
:removable="isRowRemovable(addresses, index)"
:disabled="isValidated('address')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@@ -233,14 +247,15 @@
v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:model-value="accounting.accountNumber"
@update:model-value="(v: string) => accounting.accountNumber = sanitizeCodeAlnum(v)"
:label="t('commercial.clients.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/>
@@ -248,16 +263,17 @@
:model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value"
:label="t('commercial.clients.form.accounting.tvaMode')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:model-value="accounting.nTva"
@update:model-value="(v: string) => accounting.nTva = sanitizeCodeAlnum(v)"
:label="t('commercial.clients.form.accounting.nTva')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/>
@@ -265,7 +281,7 @@
:model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value"
:label="t('commercial.clients.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@@ -275,7 +291,7 @@
:model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value"
:label="t('commercial.clients.form.accounting.paymentType')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@@ -286,7 +302,7 @@
:model-value="accounting.bankIri"
:options="referentials.banks.value"
:label="t('commercial.clients.form.accounting.bank')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@@ -303,7 +319,7 @@
>
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1"
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -314,21 +330,23 @@
<MalioInputText
v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:model-value="rib.bic"
@update:model-value="(v: string) => rib.bic = sanitizeCodeAlnum(v)"
:label="t('commercial.clients.form.accounting.ribBic')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:model-value="rib.iban"
@update:model-value="(v: string) => rib.iban = sanitizeCodeAlnum(v)"
:label="t('commercial.clients.form.accounting.ribIban')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
/>
@@ -403,6 +421,9 @@ import {
lastFillableTabKey,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/forms/clientFormRules'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import { sanitizeCodeAlnum, sanitizeFreeText, sanitizePersonName } from '~/shared/utils/textSanitize'
import {
buildAddressPayload,
buildMainPayload,
@@ -417,6 +438,7 @@ import {
type RibFormDraft,
} from '~/modules/commercial/types/clientForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
@@ -660,6 +682,22 @@ const information = reactive({
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. */
async function submitInformation(): Promise<void> {
if (clientId.value === null || tabSubmitting.value) return
@@ -23,18 +23,19 @@
roles sans `manage` (ex. Compta). Pas de contact inline (ERP-106). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:model-value="main.companyName"
:label="t('commercial.suppliers.form.main.companyName')"
:required="true"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="mainErrors.errors.companyName"
@update:model-value="(v: string) => main.companyName = sanitizeFreeText(v)"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="mainCategoryOptions"
:label="t('commercial.suppliers.form.main.categories')"
:display-tag="true"
:readonly="businessReadonly"
:disabled="businessReadonly"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -62,20 +63,24 @@
resize="none"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:model-value="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.competitors"
@update:model-value="(v: string) => information.competitors = sanitizeFreeText(v)"
/>
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
<MalioDate
v-model="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
@@ -83,25 +88,30 @@
v-model="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')"
:mask="EMPLOYEES_MASK"
:readonly="businessReadonly"
:disabled="businessReadonly"
: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
v-model="information.revenueAmount"
:key="revenueAmountKey"
:model-value="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/>
<MalioInputText
v-model="information.directorName"
:model-value="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.directorName"
@update:model-value="(v: string) => information.directorName = sanitizePersonName(v)"
/>
<MalioInputAmount
v-model="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.profitAmount"
/>
<!-- Volume previsionnel : specifique fournisseur (entier). -->
@@ -109,7 +119,7 @@
v-model="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')"
:mask="VOLUME_FORECAST_MASK"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.volumeForecast"
/>
</div>
@@ -126,13 +136,17 @@
<!-- Onglet Contacts -->
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<SupplierContactBlock
v-for="(contact, index) in contacts"
:key="contact.id ?? `new-${index}`"
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="contacts.length > 1"
:readonly="businessReadonly"
:removable="isRowRemovable(contacts, index)"
:disabled="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
@@ -168,8 +182,8 @@
:site-options="siteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="addresses.length > 1"
:readonly="businessReadonly"
:removable="isRowRemovable(addresses, index)"
:disabled="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@@ -204,39 +218,41 @@
v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:model-value="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
@update:model-value="(v: string) => accounting.accountNumber = sanitizeCodeAlnum(v)"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="tvaModeOptions"
:label="t('commercial.suppliers.form.accounting.tvaMode')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:model-value="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
@update:model-value="(v: string) => accounting.nTva = sanitizeCodeAlnum(v)"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions"
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@@ -246,7 +262,7 @@
:model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions"
:label="t('commercial.suppliers.form.accounting.paymentType')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@@ -257,7 +273,7 @@
:model-value="accounting.bankIri"
:options="bankOptions"
:label="t('commercial.suppliers.form.accounting.bank')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@@ -273,7 +289,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1"
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -284,23 +300,25 @@
<MalioInputText
v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:model-value="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
@update:model-value="(v: string) => rib.bic = sanitizeCodeAlnum(v)"
/>
<MalioInputText
v-model="rib.iban"
:model-value="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
@update:model-value="(v: string) => rib.iban = sanitizeCodeAlnum(v)"
/>
</div>
</div>
@@ -388,6 +406,8 @@ import {
type MainFormDraft,
type SupplierEditAbilities,
} from '~/modules/commercial/utils/forms/supplierEdit'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import {
buildSupplierFormTabKeys,
isAddressValid,
@@ -407,6 +427,8 @@ import {
type SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
import { sanitizeCodeAlnum, sanitizeFreeText, sanitizePersonName } from '~/shared/utils/textSanitize'
import { readHistoryTab } from '~/shared/utils/historyTab'
// Masques de saisie (la normalisation finale reste serveur).
@@ -452,14 +474,26 @@ const headerTitle = computed(() => supplier.value?.companyName ?? t('commercial.
const main = reactive<MainFormDraft>(mapMainDraft({} as SupplierDetail))
const information = reactive<InformationFormDraft>(mapInformationDraft({} 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 addresses = ref<SupplierAddressFormDraft[]>([])
const ribs = ref<SupplierRibFormDraft[]>([])
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
const removedContactIds = ref<number[]>([])
const removedAddressIds = ref<number[]>([])
const removedRibIds = ref<number[]>([])
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
@@ -653,32 +687,31 @@ function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact())
}
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
// local. Echec serveur : bloc conserve + erreur remontee.
function askRemoveContact(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => {
const removed = contacts.value[index]
if (removed?.id != null) removedContactIds.value.push(removed.id)
contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
})
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => removeCollectionRow({
rows: contacts.value,
errors: contactErrors.value,
index,
endpoint: '/supplier_contacts',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyContact,
onError: showError,
}))
}
/**
* Valide l'onglet Contacts : DELETE des contacts retires (existants), puis
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
* collection contacts (endpoints supplier_contact dedies).
* Valide l'onglet Contacts : POST/PATCH des blocs restants sur la sous-ressource.
* Strictement scope a la collection contacts (endpoints supplier_contact dedies).
* La suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
*/
async function submitContacts(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
contactErrors.value = []
try {
for (const id of removedContactIds.value) {
await api.delete(`/supplier_contacts/${id}`, {}, { toast: false })
}
removedContactIds.value = []
// RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces neuves vides, on les soumet -> 422 RG-2.04 inline (nom OU prenom).
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
@@ -726,14 +759,15 @@ function addAddress(): void {
}
function askRemoveAddress(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => {
const removed = addresses.value[index]
if (removed?.id != null) removedAddressIds.value.push(removed.id)
addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
})
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => removeCollectionRow({
rows: addresses.value,
errors: addressErrors.value,
index,
endpoint: '/supplier_addresses',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyAddress,
onError: showError,
}))
}
function onAddressDegraded(): void {
@@ -745,17 +779,12 @@ function onAddressDegraded(): void {
})
}
/** Valide l'onglet Adresses : DELETE des adresses retirees puis POST/PATCH. */
/** Valide l'onglet Adresses : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
async function submitAddresses(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
addressErrors.value = []
try {
for (const id of removedAddressIds.value) {
await api.delete(`/supplier_addresses/${id}`, {}, { toast: false })
}
removedAddressIds.value = []
const hasError = await submitRows(
addresses.value,
addressErrors,
@@ -826,15 +855,18 @@ function addRib(): void {
if (canAddRib.value) ribs.value.push(emptyRib())
}
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
// d'une LCR (RG-2.08) -> 409 remonte via showError (message back), bloc conserve.
function askRemoveRib(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => {
const removed = ribs.value[index]
if (removed?.id != null) removedRibIds.value.push(removed.id)
ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
})
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => removeCollectionRow({
rows: ribs.value,
errors: ribErrors.value,
index,
endpoint: '/supplier_ribs',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyRib,
onError: showError,
}))
}
/**
@@ -843,11 +875,12 @@ function askRemoveRib(index: number): void {
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
* back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
*
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
* plus de DELETE differe ici.
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR hors-LCR ce sont des
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
* (corbeille d'un bloc, toujours sous LCR). Aucun champ main/information dans le
* payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-2.16 :
* sinon 403 sur tout le payload).
*/
async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || tabSubmitting.value) return
@@ -897,14 +930,6 @@ async function submitAccounting(): Promise<void> {
return
}
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
for (const id of removedRibIds.value) {
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
catch (e) {
@@ -23,7 +23,7 @@
/>
<MalioButton
v-if="showArchive"
variant="secondary"
variant="danger"
icon-name="mdi:archive-arrow-down-outline"
icon-position="left"
:label="t('commercial.suppliers.action.archive')"
@@ -50,14 +50,14 @@
<MalioInputText
:model-value="supplier.companyName"
:label="t('commercial.suppliers.form.main.companyName')"
readonly
disabled
/>
<MalioSelectCheckbox
:model-value="categoryIris"
:options="mainCategoryOptions"
:label="t('commercial.suppliers.form.main.categories')"
:display-tag="true"
readonly
disabled
/>
</div>
@@ -74,43 +74,43 @@
resize="none"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
readonly
disabled
/>
<MalioInputText
:model-value="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')"
readonly
disabled
/>
<MalioDate
:model-value="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')"
readonly
disabled
/>
<MalioInputText
:model-value="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')"
readonly
disabled
/>
<MalioInputAmount
:model-value="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')"
readonly
disabled
/>
<MalioInputText
:model-value="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')"
readonly
disabled
/>
<MalioInputAmount
:model-value="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')"
readonly
disabled
/>
<!-- Volume previsionnel : specifique fournisseur (entier). -->
<MalioInputText
:model-value="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')"
readonly
disabled
/>
</div>
</template>
@@ -123,7 +123,7 @@
:key="contact.id ?? index"
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
readonly
disabled
/>
</div>
</template>
@@ -140,7 +140,7 @@
:site-options="allSiteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
readonly
disabled
/>
</div>
</template>
@@ -154,38 +154,38 @@
:model-value="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK"
readonly
disabled
/>
<MalioInputText
:model-value="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')"
readonly
disabled
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="tvaModeOptions"
:label="t('commercial.suppliers.form.accounting.tvaMode')"
empty-option-label=""
readonly
disabled
/>
<MalioInputText
:model-value="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')"
readonly
disabled
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions"
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
empty-option-label=""
readonly
disabled
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions"
:label="t('commercial.suppliers.form.accounting.paymentType')"
empty-option-label=""
readonly
disabled
/>
<MalioSelect
v-if="accounting.bankIri"
@@ -193,7 +193,7 @@
:options="bankOptions"
:label="t('commercial.suppliers.form.accounting.bank')"
empty-option-label=""
readonly
disabled
/>
</div>
</div>
@@ -208,28 +208,25 @@
<MalioInputText
:model-value="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')"
readonly
disabled
/>
<MalioInputText
:model-value="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')"
readonly
disabled
/>
<MalioInputText
:model-value="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')"
readonly
disabled
/>
</div>
</div>
</div>
</template>
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
<!-- ERP-193 : les onglets « a venir » (Transport / Statistiques /
Rapports / Echanges) ne sont plus rendus en consultation
(masquage des onglets vides) slots supprimes. -->
</MalioTabList>
</template>
@@ -261,9 +258,9 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
import { isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab'
import {
canEditSupplier,
@@ -278,6 +275,7 @@ import {
referentialOptionOf,
showArchiveAction,
showRestoreAction,
supplierConsultationVisibleTabs,
type SelectOption,
type SupplierDetail,
} from '~/modules/commercial/utils/forms/supplierConsultation'
@@ -387,9 +385,11 @@ const paymentTypeOptions = computed(() => referentialOptionOf(supplier.value?.pa
const bankOptions = computed(() => referentialOptionOf(supplier.value?.bank))
// Onglets : navigation LIBRE (pas de sequence forcee en consultation)
// 3 onglets actifs (Information, Contacts, Adresses, + Comptabilite si droit) et
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
const tabKeys = computed(() => buildSupplierFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
// ERP-193 (retour metier) : on masque les coquilles non implementees ET tout
// onglet de donnees vide. La liste depend donc du payload charge.
const visibleTabKeys = computed(() => supplierConsultationVisibleTabs(supplier.value, {
canAccountingView: canAccountingView.value,
}))
const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline',
@@ -402,14 +402,25 @@ const TAB_ICONS: Record<string, string> = {
exchanges: 'mdi:account-group-outline',
}
const tabs = computed(() => tabKeys.value.map(key => ({
const tabs = computed(() => visibleTabKeys.value.map(key => ({
key,
label: t(`commercial.suppliers.tab.${key}`),
icon: TAB_ICONS[key],
})))
// Onglet initial : repris de l'edition au retour (history.state), sinon Information.
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
// Onglet initial : vide tant que le fournisseur n'est pas charge. Des que la
// liste des onglets visibles est connue, on cale sur l'onglet repris de
// 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
function goBack(): void {
@@ -128,13 +128,13 @@
</div>
</MalioAccordionItem>
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
<!-- Statut : bool unique. Coche = archives uniquement, sinon actifs. -->
<MalioAccordionItem :title="t('commercial.suppliers.filters.status')" value="status">
<MalioCheckbox
id="filter-include-archived"
:label="t('commercial.suppliers.filters.includeArchived')"
:model-value="draftIncludeArchived"
@update:model-value="(val: boolean) => draftIncludeArchived = val"
id="filter-archived-only"
:label="t('commercial.suppliers.filters.archivedOnly')"
:model-value="draftArchivedOnly"
@update:model-value="(val: boolean) => draftArchivedOnly = val"
/>
</MalioAccordionItem>
</MalioAccordion>
@@ -254,12 +254,12 @@ const filterDrawerOpen = ref(false)
const draftSearch = ref('')
const draftCategoryCodes = ref<string[]>([])
const draftSiteIds = ref<string[]>([])
const draftIncludeArchived = ref(false)
const draftArchivedOnly = ref(false)
const appliedSearch = ref('')
const appliedCategoryCodes = ref<string[]>([])
const appliedSiteIds = ref<string[]>([])
const appliedIncludeArchived = ref(false)
const appliedArchivedOnly = ref(false)
// Options des selects multi, chargees une fois (referentiels courts).
const categoryOptions = ref<FilterOption[]>([])
@@ -270,7 +270,7 @@ const activeFilterCount = computed(() => {
if (appliedSearch.value.trim() !== '') count++
if (appliedCategoryCodes.value.length > 0) count++
if (appliedSiteIds.value.length > 0) count++
if (appliedIncludeArchived.value) count++
if (appliedArchivedOnly.value) count++
return count
})
@@ -285,7 +285,7 @@ function openFilters(): void {
draftSearch.value = appliedSearch.value
draftCategoryCodes.value = [...appliedCategoryCodes.value]
draftSiteIds.value = [...appliedSiteIds.value]
draftIncludeArchived.value = appliedIncludeArchived.value
draftArchivedOnly.value = appliedArchivedOnly.value
filterDrawerOpen.value = true
}
@@ -311,7 +311,7 @@ function buildFilterPayload(): Record<string, string | string[] | boolean> {
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
if (appliedIncludeArchived.value) payload.includeArchived = true
if (appliedArchivedOnly.value) payload.archivedOnly = true
return payload
}
@@ -321,7 +321,7 @@ function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim()
appliedCategoryCodes.value = [...draftCategoryCodes.value]
appliedSiteIds.value = [...draftSiteIds.value]
appliedIncludeArchived.value = draftIncludeArchived.value
appliedArchivedOnly.value = draftArchivedOnly.value
setFilters(buildFilterPayload(), { replace: true })
filterDrawerOpen.value = false
@@ -333,12 +333,12 @@ function resetFilters(): void {
draftSearch.value = ''
draftCategoryCodes.value = []
draftSiteIds.value = []
draftIncludeArchived.value = false
draftArchivedOnly.value = false
appliedSearch.value = ''
appliedCategoryCodes.value = []
appliedSiteIds.value = []
appliedIncludeArchived.value = false
appliedArchivedOnly.value = false
setFilters({}, { replace: true })
}
@@ -18,18 +18,19 @@
automatiquement sur l'onglet Information. Pas de contact inline (ERP-106). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:model-value="main.companyName"
:label="t('commercial.suppliers.form.main.companyName')"
:required="true"
:readonly="mainLocked"
:disabled="mainLocked"
:error="mainErrors.errors.companyName"
@update:model-value="(v: string) => main.companyName = sanitizeFreeText(v)"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:label="t('commercial.suppliers.form.main.categories')"
:display-tag="true"
:readonly="mainLocked"
:disabled="mainLocked"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -56,20 +57,24 @@
resize="none"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:model-value="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.competitors"
@update:model-value="(v: string) => information.competitors = sanitizeFreeText(v)"
/>
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
<MalioDate
v-model="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
@@ -77,25 +82,30 @@
v-model="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')"
:mask="EMPLOYEES_MASK"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
: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
v-model="information.revenueAmount"
:key="revenueAmountKey"
:model-value="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/>
<MalioInputText
v-model="information.directorName"
:model-value="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.directorName"
@update:model-value="(v: string) => information.directorName = sanitizePersonName(v)"
/>
<MalioInputAmount
v-model="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.profitAmount"
/>
<!-- Volume previsionnel : specifique fournisseur. Champ texte
@@ -104,7 +114,7 @@
v-model="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')"
:mask="VOLUME_FORECAST_MASK"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.volumeForecast"
/>
</div>
@@ -121,13 +131,17 @@
<!-- Onglet Contacts -->
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<SupplierContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="index > 0"
:readonly="isValidated('contacts')"
:removable="isRowRemovable(contacts, index)"
:disabled="isValidated('contacts')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
@@ -163,8 +177,8 @@
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="index > 0"
:readonly="isValidated('addresses')"
:removable="isRowRemovable(addresses, index)"
:disabled="isValidated('addresses')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@@ -198,39 +212,41 @@
v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:model-value="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
@update:model-value="(v: string) => accounting.accountNumber = sanitizeCodeAlnum(v)"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value"
:label="t('commercial.suppliers.form.accounting.tvaMode')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:model-value="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
@update:model-value="(v: string) => accounting.nTva = sanitizeCodeAlnum(v)"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value"
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@@ -240,7 +256,7 @@
:model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value"
:label="t('commercial.suppliers.form.accounting.paymentType')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@@ -251,7 +267,7 @@
:model-value="accounting.bankIri"
:options="referentials.banks.value"
:label="t('commercial.suppliers.form.accounting.bank')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@@ -267,7 +283,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1"
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -278,23 +294,25 @@
<MalioInputText
v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:model-value="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
@update:model-value="(v: string) => rib.bic = sanitizeCodeAlnum(v)"
/>
<MalioInputText
v-model="rib.iban"
:model-value="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
@update:model-value="(v: string) => rib.iban = sanitizeCodeAlnum(v)"
/>
</div>
</div>
@@ -371,6 +389,8 @@ import {
buildMainPayload,
buildRibPayload,
} from '~/modules/commercial/utils/forms/supplierEdit'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import {
emptyAddress,
emptyContact,
@@ -380,6 +400,8 @@ import {
type SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
import { sanitizeCodeAlnum, sanitizeFreeText, sanitizePersonName } from '~/shared/utils/textSanitize'
// Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
@@ -559,6 +581,22 @@ const information = reactive({
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. */
async function submitInformation(): Promise<void> {
if (supplierId.value === null || tabSubmitting.value) return
@@ -0,0 +1,33 @@
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,7 +2,10 @@ import { describe, expect, it } from 'vitest'
import {
canEditClient,
categoryOptionsOf,
clientConsultationVisibleTabs,
contactOptionsOf,
hasAccountingData,
hasInformationData,
iriOf,
mapAccountingDraft,
mapAddressToDraft,
@@ -248,3 +251,73 @@ describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', (
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,6 +3,8 @@ import {
canEditSupplier,
categoryOptionsOf,
contactOptionsOf,
hasAccountingData,
hasInformationData,
iriOf,
mapAccountingDraft,
mapAddressToDraft,
@@ -14,6 +16,7 @@ import {
showArchiveAction,
showRestoreAction,
siteOptionsOf,
supplierConsultationVisibleTabs,
type SupplierDetail,
} from '../supplierConsultation'
@@ -237,3 +240,60 @@ describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', (
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([])
})
})
@@ -0,0 +1,29 @@
/**
* 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,6 +317,77 @@ 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
* `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
@@ -292,6 +292,78 @@ 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
* `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
@@ -83,7 +83,7 @@
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
:label="isEditMode ? t('common.save') : t('common.validate')"
variant="primary"
button-class="w-m-btn-action"
:disabled="saving || permissionsLoadFailed"
@@ -103,7 +103,7 @@
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
:label="isEditMode ? t('common.save') : t('common.validate')"
variant="primary"
button-class="w-m-btn-action"
:disabled="saving || !isValidHex"
@@ -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)]">
<!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon
v-if="removable && !readonly"
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -17,6 +17,7 @@
:label="t('technique.providers.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
@@ -29,6 +30,7 @@
:label="t('technique.providers.form.address.categories')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
@@ -41,6 +43,7 @@
:label="t('technique.providers.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
@@ -49,6 +52,7 @@
:options="countryOptions"
:label="t('technique.providers.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="true"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
@@ -58,6 +62,7 @@
:label="t('technique.providers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
@@ -70,6 +75,7 @@
:options="cityOptions"
:label="t('technique.providers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="true"
:error="errors?.city"
@@ -80,6 +86,7 @@
:model-value="model.city"
:label="t('technique.providers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
@@ -89,13 +96,14 @@
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly"
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('technique.providers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.street"
:allow-create="true"
@@ -109,6 +117,7 @@
:model-value="model.street"
:label="t('technique.providers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
@@ -120,6 +129,7 @@
:model-value="model.streetComplement"
:label="t('technique.providers.form.address.streetComplement')"
:readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
@@ -131,6 +141,7 @@
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
import { sanitizeAddress } from '~/shared/utils/textSanitize'
// Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####'
@@ -148,6 +159,8 @@ const props = defineProps<{
countryOptions: RefOption[]
removable?: boolean
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
@@ -193,9 +206,20 @@ const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = []
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
// Filtres de saisie par champ (ERP-193) : voie / complement / ville = profil adresse.
const FIELD_SANITIZERS: Partial<Record<keyof ProviderAddressFormDraft, (v: string) => string>> = {
street: sanitizeAddress,
streetComplement: sanitizeAddress,
city: sanitizeAddress,
}
/** Emet un nouveau brouillon avec le champ modifie (immutabilite), sanitise si besoin. */
function update<K extends keyof ProviderAddressFormDraft>(field: K, value: ProviderAddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
const sanitizer = FIELD_SANITIZERS[field]
const next = (sanitizer && typeof value === 'string')
? (sanitizer(value) as ProviderAddressFormDraft[K])
: value
emit('update:modelValue', { ...props.modelValue, [field]: next })
}
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
@@ -3,7 +3,7 @@
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc) ou en lecture seule. -->
<MalioButtonIcon
v-if="removable && !readonly"
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -15,6 +15,7 @@
:model-value="model.lastName"
:label="t('technique.providers.form.contact.lastName')"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
@@ -22,6 +23,7 @@
:model-value="model.firstName"
:label="t('technique.providers.form.contact.firstName')"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
@@ -33,6 +35,7 @@
:model-value="model.jobTitle"
:label="t('technique.providers.form.contact.jobTitle')"
:readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
@@ -41,6 +44,7 @@
:model-value="model.email"
:label="t('technique.providers.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
@@ -50,6 +54,7 @@
:label="t('technique.providers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('technique.providers.form.contact.addPhone')"
@@ -63,6 +68,7 @@
:label="t('technique.providers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
@@ -71,6 +77,7 @@
<script setup lang="ts">
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
import { sanitizeEmail, sanitizeFreeText, sanitizePersonName } from '~/shared/utils/textSanitize'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##'
@@ -82,6 +89,8 @@ const props = defineProps<{
removable?: boolean
/** Bloc en lecture seule (onglet valide). */
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
@@ -96,9 +105,22 @@ const { t } = useI18n()
// Alias local pour la lisibilite du template.
const model = computed(() => props.modelValue)
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
// Filtres de saisie par champ (ERP-193) : on retire les caracteres parasites a la
// frappe. Noms = profil personne, fonction = texte libre, email = profil email.
const FIELD_SANITIZERS: Partial<Record<keyof ProviderContactFormDraft, (v: string) => string>> = {
lastName: sanitizePersonName,
firstName: sanitizePersonName,
jobTitle: sanitizeFreeText,
email: sanitizeEmail,
}
/** Emet un nouveau brouillon avec le champ modifie (immutabilite), sanitise si besoin. */
function update<K extends keyof ProviderContactFormDraft>(field: K, value: ProviderContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
const sanitizer = FIELD_SANITIZERS[field]
const next = (sanitizer && typeof value === 'string')
? (sanitizer(value) as ProviderContactFormDraft[K])
: value
emit('update:modelValue', { ...props.modelValue, [field]: next })
}
/** Revele le 2e numero (max 1 secondaire, le « + » disparait). */
@@ -14,9 +14,9 @@ vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
* - l'enveloppe Hydra (member / totalItems) est consommee
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
* renvoie un tableau plat sans pagination)
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `includeArchived` n'est envoye
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `archivedOnly` n'est envoye
* tant que l'utilisateur ne coche pas le filtre (le back masque alors les
* archives) ; le filtre `includeArchived` est bien transmis une fois applique.
* archives) ; le filtre `archivedOnly` est bien transmis une fois applique.
*/
describe('useProvidersRepository', () => {
beforeEach(() => {
@@ -44,7 +44,7 @@ describe('useProvidersRepository', () => {
expect(mockApiGet).toHaveBeenCalledTimes(1)
const [url, query, opts] = mockApiGet.mock.calls[0]
expect(url).toBe('/providers')
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
expect(query).toMatchObject({ page: 1, itemsPerPage: 25 })
expect(opts).toMatchObject({
toast: false,
headers: { Accept: 'application/ld+json' },
@@ -53,26 +53,26 @@ describe('useProvidersRepository', () => {
expect(repo.totalItems.value).toBe(1)
})
it('exclut les archives par defaut : aucun includeArchived au premier fetch', async () => {
it('exclut les archives par defaut : aucun archivedOnly au premier fetch', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useProvidersRepository()
await repo.fetch()
const query = mockApiGet.mock.calls[0][1] as Record<string, unknown>
expect(query.includeArchived).toBeUndefined()
expect(query.archivedOnly).toBeUndefined()
})
it('transmet includeArchived une fois le filtre applique (retour page 1)', async () => {
it('transmet archivedOnly une fois le filtre applique (retour page 1)', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useProvidersRepository()
await repo.fetch()
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
await repo.setFilters({ includeArchived: true })
await repo.setFilters({ archivedOnly: true })
expect(repo.currentPage.value).toBe(1)
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(query.includeArchived).toBe(true)
expect(query.archivedOnly).toBe(true)
})
})
@@ -1,6 +1,7 @@
import { computed, reactive, ref, type Ref } from 'vue'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import { mapViolationsToRecord } from '~/shared/utils/api'
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
import { removeCollectionRow } from '~/shared/utils/collectionRow'
import {
emptyProviderAccounting,
emptyProviderAddress,
@@ -73,6 +74,16 @@ export function useProviderForm() {
// Erreurs de validation par champ (ERP-101) du formulaire principal.
const mainErrors = useFormErrors()
// ERP-172 : remontee d'erreur 409/422 lors d'une suppression immediate de
// sous-ressource (message back affiche en toast dedie — pas de mapping inline,
// le bloc est en cours de retrait). Ex. dernier RIB d'une LCR -> 409.
function notifyRemovalError(error: unknown): void {
toast.error({
title: t('technique.providers.toast.error'),
message: extractApiErrorMessage((error as { data?: unknown })?.data) || t('technique.providers.toast.error'),
})
}
// ── Etat du prestataire cree ────────────────────────────────────────────
const providerId = ref<number | null>(null)
const mainLocked = ref(false)
@@ -317,9 +328,18 @@ export function useProviderForm() {
}
}
function removeContact(index: number): void {
contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
// ERP-172 : DELETE immediat du contact existant (sous-ressource) a la
// confirmation de la modale. Bloc jamais persiste (id null) : retrait local.
async function removeContact(index: number): Promise<void> {
await removeCollectionRow({
rows: contacts.value,
errors: contactErrors.value,
index,
endpoint: '/provider_contacts',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderContact,
onError: notifyRemovalError,
})
}
/**
@@ -387,9 +407,17 @@ export function useProviderForm() {
}
}
function removeAddress(index: number): void {
addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
// ERP-172 : DELETE immediat de l'adresse existante (sous-ressource).
async function removeAddress(index: number): Promise<void> {
await removeCollectionRow({
rows: addresses.value,
errors: addressErrors.value,
index,
endpoint: '/provider_addresses',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderAddress,
onError: notifyRemovalError,
})
}
/**
@@ -479,13 +507,18 @@ export function useProviderForm() {
}
}
function removeRib(index: number): void {
ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (sous LCR).
if (ribs.value.length === 0) {
ribs.value.push(emptyProviderRib())
}
// ERP-172 : DELETE immediat du RIB existant. Le back peut refuser la suppression
// du dernier RIB d'une LCR -> 409 remonte via notifyRemovalError, bloc conserve.
async function removeRib(index: number): Promise<void> {
await removeCollectionRow({
rows: ribs.value,
errors: ribErrors.value,
index,
endpoint: '/provider_ribs',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderRib,
onError: notifyRemovalError,
})
}
/**
@@ -45,10 +45,11 @@ export interface Provider {
* sur la ressource `/providers` (pagination serveur obligatoire ; jamais de
* chargement integral en memoire). Miroir de `useSuppliersRepository` (M2).
*
* Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes
* par la page via `setFilters` du composable partage la remise en page 1 est
* garantie. Par defaut, aucun `includeArchived` n'est envoye : le back masque
* donc les prestataires archives (exclusion par defaut, spec-back § 2.11).
* Les filtres (recherche, categories, sites, archives) sont pilotes par la page
* via `setFilters` du composable partage la remise en page 1 est garantie. Par
* defaut, aucun `archivedOnly` n'est envoye : le back masque donc les prestataires
* archives (exclusion par defaut, spec-back § 2.11). Cocher « Voir les archivés »
* envoie `archivedOnly=true` seules les archives sont listees (aligne sur Client).
*
* Le cloisonnement par site est applique AUTOMATIQUEMENT cote back (§ 2.13) en
* fonction de l'utilisateur rien a filtrer cote front.
@@ -58,5 +59,6 @@ export interface Provider {
* `usePaginatedList`. Aucun reset au logout a gerer.
*/
export function useProvidersRepository() {
return usePaginatedList<Provider>({ url: '/providers' })
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193).
return usePaginatedList<Provider>({ url: '/providers', defaultItemsPerPage: 25 })
}
@@ -20,18 +20,19 @@
<!-- Bloc principal (pre-rempli, editable si `manage`) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:model-value="main.companyName"
:label="t('technique.providers.form.main.companyName')"
:required="true"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="mainErrors.errors.companyName"
@update:model-value="(v: string) => main.companyName = sanitizeFreeText(v)"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:label="t('technique.providers.form.main.categories')"
:display-tag="true"
:readonly="businessReadonly"
:disabled="businessReadonly"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -41,7 +42,7 @@
:options="referentials.sites.value"
:label="t('technique.providers.form.main.sites')"
:display-tag="true"
:readonly="businessReadonly"
:disabled="businessReadonly"
:required="true"
:error="mainErrors.errors.sites"
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
@@ -62,12 +63,16 @@
<!-- Onglet Contact -->
<template #contact>
<div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ProviderContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:removable="index > 0"
:readonly="businessReadonly"
:removable="isRowRemovable(contacts, index)"
:disabled="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
@@ -102,8 +107,8 @@
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="index > 0"
:readonly="businessReadonly"
:removable="isRowRemovable(addresses, index)"
:disabled="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@@ -137,39 +142,41 @@
v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:model-value="accounting.accountNumber"
:label="t('technique.providers.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
@update:model-value="(v: string) => accounting.accountNumber = sanitizeCodeAlnum(v)"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value"
:label="t('technique.providers.form.accounting.tvaMode')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:model-value="accounting.nTva"
:label="t('technique.providers.form.accounting.nTva')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
@update:model-value="(v: string) => accounting.nTva = sanitizeCodeAlnum(v)"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value"
:label="t('technique.providers.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@@ -179,7 +186,7 @@
:model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value"
:label="t('technique.providers.form.accounting.paymentType')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@@ -190,7 +197,7 @@
:model-value="accounting.bankIri"
:options="referentials.banks.value"
:label="t('technique.providers.form.accounting.bank')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@@ -206,7 +213,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1"
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -217,23 +224,25 @@
<MalioInputText
v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:model-value="rib.bic"
:label="t('technique.providers.form.accounting.ribBic')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="ribErrors[index]?.bic"
@update:model-value="(v: string) => rib.bic = sanitizeCodeAlnum(v)"
/>
<MalioInputText
v-model="rib.iban"
:model-value="rib.iban"
:label="t('technique.providers.form.accounting.ribIban')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="ribErrors[index]?.iban"
@update:model-value="(v: string) => rib.iban = sanitizeCodeAlnum(v)"
/>
</div>
</div>
@@ -308,6 +317,8 @@ import {
emptyProviderRib,
} from '~/modules/technique/types/providerForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
import { sanitizeCodeAlnum, sanitizeFreeText } from '~/shared/utils/textSanitize'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
@@ -22,7 +22,7 @@
/>
<MalioButton
v-if="showArchive"
variant="secondary"
variant="danger"
icon-name="mdi:archive-arrow-down-outline"
icon-position="left"
:label="t('technique.providers.action.archive')"
@@ -49,26 +49,27 @@
<MalioInputText
:model-value="provider.companyName"
:label="t('technique.providers.form.main.companyName')"
readonly
disabled
/>
<MalioSelectCheckbox
:model-value="mainCategoryIris"
:options="mainCategoryOptions"
:label="t('technique.providers.form.main.categories')"
:display-tag="true"
readonly
disabled
/>
<MalioSelectCheckbox
:model-value="mainSiteIris"
:options="mainSiteOptions"
:label="t('technique.providers.form.main.sites')"
:display-tag="true"
readonly
disabled
/>
</div>
<!-- Onglets (navigation libre, tout en lecture seule) -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- ERP-193 : barre masquee s'il ne reste aucun onglet non vide. -->
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Contacts -->
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
@@ -76,7 +77,7 @@
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
readonly
disabled
/>
</div>
</template>
@@ -92,27 +93,25 @@
:site-options="view.siteOptions"
:contact-options="contactOptions"
:country-options="countryOptionsFor(view.draft.country)"
readonly
disabled
/>
</div>
</template>
<!-- Onglets placeholder « A venir » (comme les autres modules). -->
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
<!-- ERP-193 : les onglets « a venir » (Rapports / Echanges) ne sont
plus rendus en consultation (masquage des onglets vides). -->
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting>
<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="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" readonly />
<MalioInputText :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" readonly />
<MalioSelect :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" readonly empty-option-label="" />
<MalioInputText :model-value="accounting.nTva" :label="t('technique.providers.form.accounting.nTva')" readonly />
<MalioSelect :model-value="accounting.paymentDelayIri" :options="paymentDelayOptions" :label="t('technique.providers.form.accounting.paymentDelay')" readonly 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" :model-value="accounting.bankIri" :options="bankOptions" :label="t('technique.providers.form.accounting.bank')" readonly empty-option-label="" />
<MalioInputText :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" disabled />
<MalioInputText :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" disabled />
<MalioSelect :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" disabled empty-option-label="" />
<MalioInputText :model-value="accounting.nTva" :label="t('technique.providers.form.accounting.nTva')" disabled />
<MalioSelect :model-value="accounting.paymentDelayIri" :options="paymentDelayOptions" :label="t('technique.providers.form.accounting.paymentDelay')" disabled empty-option-label="" />
<MalioSelect :model-value="accounting.paymentTypeIri" :options="paymentTypeOptions" :label="t('technique.providers.form.accounting.paymentType')" disabled empty-option-label="" />
<MalioSelect v-if="isBankRequired" :model-value="accounting.bankIri" :options="bankOptions" :label="t('technique.providers.form.accounting.bank')" disabled empty-option-label="" />
</div>
</div>
@@ -123,9 +122,9 @@
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">
<MalioInputText :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" readonly />
<MalioInputText :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" readonly />
<MalioInputText :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" readonly />
<MalioInputText :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" disabled />
<MalioInputText :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" disabled />
<MalioInputText :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" disabled />
</div>
</div>
</div>
@@ -158,7 +157,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useProvider } from '~/modules/technique/composables/useProvider'
import {
canEditProvider,
@@ -170,6 +169,7 @@ import {
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
providerConsultationVisibleTabs,
referentialOptionOf,
showArchiveAction,
showRestoreAction,
@@ -197,7 +197,6 @@ const headerTitle = computed(() => provider.value?.companyName || t('technique.p
useHead({ title: t('technique.providers.consultation.title') })
// Onglets (ordre spec : Contacts · Adresse · Rapports · Échanges · Comptabilité)
const activeTab = ref('contacts')
const TAB_ICONS: Record<string, string> = {
contacts: 'mdi:account-box-plus-outline',
address: 'mdi:map-marker-outline',
@@ -205,11 +204,27 @@ const TAB_ICONS: Record<string, string> = {
exchanges: 'mdi:swap-horizontal',
accounting: 'mdi:bank-circle-outline',
}
const tabs = computed(() => {
const keys = ['contacts', 'address', 'reports', 'exchanges']
if (canAccountingView.value) keys.push('accounting')
return keys.map(key => ({ key, label: t(`technique.providers.tab.${key}`), icon: TAB_ICONS[key] }))
})
// ERP-193 (retour metier) : on masque les coquilles (Rapports / Echanges) ET
// tout onglet de donnees vide. La liste depend donc du payload charge.
const visibleTabKeys = computed(() => providerConsultationVisibleTabs(provider.value, {
canAccountingView: canAccountingView.value,
}))
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
const mainCategoryIris = computed(() => irisOf(provider.value?.categories))
@@ -129,13 +129,13 @@
</div>
</MalioAccordionItem>
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
<!-- Statut : bool unique. Coche = archives uniquement, sinon actifs. -->
<MalioAccordionItem :title="t('technique.providers.filters.status')" value="status">
<MalioCheckbox
id="filter-include-archived"
:label="t('technique.providers.filters.includeArchived')"
:model-value="draftIncludeArchived"
@update:model-value="(val: boolean) => draftIncludeArchived = val"
id="filter-archived-only"
:label="t('technique.providers.filters.archivedOnly')"
:model-value="draftArchivedOnly"
@update:model-value="(val: boolean) => draftArchivedOnly = val"
/>
</MalioAccordionItem>
</MalioAccordion>
@@ -258,12 +258,12 @@ const filterDrawerOpen = ref(false)
const draftSearch = ref('')
const draftCategoryCodes = ref<string[]>([])
const draftSiteIds = ref<string[]>([])
const draftIncludeArchived = ref(false)
const draftArchivedOnly = ref(false)
const appliedSearch = ref('')
const appliedCategoryCodes = ref<string[]>([])
const appliedSiteIds = ref<string[]>([])
const appliedIncludeArchived = ref(false)
const appliedArchivedOnly = ref(false)
// Options des selects multi, chargees une fois (referentiels courts).
const categoryOptions = ref<FilterOption[]>([])
@@ -274,7 +274,7 @@ const activeFilterCount = computed(() => {
if (appliedSearch.value.trim() !== '') count++
if (appliedCategoryCodes.value.length > 0) count++
if (appliedSiteIds.value.length > 0) count++
if (appliedIncludeArchived.value) count++
if (appliedArchivedOnly.value) count++
return count
})
@@ -289,7 +289,7 @@ function openFilters(): void {
draftSearch.value = appliedSearch.value
draftCategoryCodes.value = [...appliedCategoryCodes.value]
draftSiteIds.value = [...appliedSiteIds.value]
draftIncludeArchived.value = appliedIncludeArchived.value
draftArchivedOnly.value = appliedArchivedOnly.value
filterDrawerOpen.value = true
}
@@ -315,7 +315,7 @@ function buildFilterPayload(): Record<string, string | string[] | boolean> {
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
if (appliedIncludeArchived.value) payload.includeArchived = true
if (appliedArchivedOnly.value) payload.archivedOnly = true
return payload
}
@@ -325,7 +325,7 @@ function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim()
appliedCategoryCodes.value = [...draftCategoryCodes.value]
appliedSiteIds.value = [...draftSiteIds.value]
appliedIncludeArchived.value = draftIncludeArchived.value
appliedArchivedOnly.value = draftArchivedOnly.value
setFilters(buildFilterPayload(), { replace: true })
filterDrawerOpen.value = false
@@ -337,12 +337,12 @@ function resetFilters(): void {
draftSearch.value = ''
draftCategoryCodes.value = []
draftSiteIds.value = []
draftIncludeArchived.value = false
draftArchivedOnly.value = false
appliedSearch.value = ''
appliedCategoryCodes.value = []
appliedSiteIds.value = []
appliedIncludeArchived.value = false
appliedArchivedOnly.value = false
setFilters({}, { replace: true })
}
@@ -19,18 +19,19 @@
Selecteur de site present ici (RG-3.03, relation directe). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:model-value="main.companyName"
:label="t('technique.providers.form.main.companyName')"
:required="true"
:readonly="mainLocked"
:disabled="mainLocked"
:error="mainErrors.errors.companyName"
@update:model-value="(v: string) => main.companyName = sanitizeFreeText(v)"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:label="t('technique.providers.form.main.categories')"
:display-tag="true"
:readonly="mainLocked"
:disabled="mainLocked"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -40,7 +41,7 @@
:options="referentials.sites.value"
:label="t('technique.providers.form.main.sites')"
:display-tag="true"
:readonly="mainLocked"
:disabled="mainLocked"
:required="true"
:error="mainErrors.errors.sites"
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
@@ -63,12 +64,16 @@
<!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). -->
<template #contact>
<div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ProviderContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:removable="index > 0"
:readonly="isValidated('contact')"
:removable="isRowRemovable(contacts, index)"
:disabled="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
@@ -102,8 +107,8 @@
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="index > 0"
:readonly="isValidated('address')"
:removable="isRowRemovable(addresses, index)"
:disabled="isValidated('address')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@@ -136,39 +141,41 @@
v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:model-value="accounting.accountNumber"
:label="t('technique.providers.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
@update:model-value="(v: string) => accounting.accountNumber = sanitizeCodeAlnum(v)"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value"
:label="t('technique.providers.form.accounting.tvaMode')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:model-value="accounting.nTva"
:label="t('technique.providers.form.accounting.nTva')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
@update:model-value="(v: string) => accounting.nTva = sanitizeCodeAlnum(v)"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value"
:label="t('technique.providers.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@@ -178,7 +185,7 @@
:model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value"
:label="t('technique.providers.form.accounting.paymentType')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@@ -190,7 +197,7 @@
:model-value="accounting.bankIri"
:options="referentials.banks.value"
:label="t('technique.providers.form.accounting.bank')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@@ -206,7 +213,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1"
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -217,23 +224,25 @@
<MalioInputText
v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:model-value="rib.bic"
:label="t('technique.providers.form.accounting.ribBic')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="ribErrors[index]?.bic"
@update:model-value="(v: string) => rib.bic = sanitizeCodeAlnum(v)"
/>
<MalioInputText
v-model="rib.iban"
:model-value="rib.iban"
:label="t('technique.providers.form.accounting.ribIban')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="ribErrors[index]?.iban"
@update:model-value="(v: string) => rib.iban = sanitizeCodeAlnum(v)"
/>
</div>
</div>
@@ -292,6 +301,8 @@ import {
isRibRequiredForPaymentType,
} from '~/modules/technique/utils/forms/providerAccounting'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
import { sanitizeCodeAlnum, sanitizeFreeText } from '~/shared/utils/textSanitize'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
@@ -10,6 +10,7 @@ const {
canEditProvider,
categoryOptionsOf,
contactOptionsOf,
hasAccountingData,
iriOf,
irisOf,
mapAccountingDraft,
@@ -17,6 +18,7 @@ const {
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
providerConsultationVisibleTabs,
referentialOptionOf,
showArchiveAction,
showRestoreAction,
@@ -165,3 +167,48 @@ 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([])
})
})
@@ -224,6 +224,58 @@ export function paymentTypeCodeOf(relation: Relation): string | 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
* `manage` (onglets metier) OU `accounting.manage` (le role Compta doit pouvoir
@@ -0,0 +1,248 @@
<template>
<!-- Adresse UNIQUE par transporteur (ERP-172) : un seul bloc, jamais supprimable. -->
<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). -->
<MalioSelect
:model-value="model.country"
:options="countryOptions"
:label="t('transport.carriers.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.country"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<!-- Code postal (RG-4.06) : declenche l'autocomplete ville (BAN). -->
<MalioInputText
:model-value="model.postalCode"
:label="t('transport.carriers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded"
:model-value="model.city"
:options="cityOptions"
:label="t('transport.carriers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="true"
:error="errors?.city"
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/>
<MalioInputText
v-else
:model-value="model.city"
:label="t('transport.carriers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Filler : aligne le debut de la ligne suivante sur la grille. -->
<div aria-hidden="true" />
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('transport.carriers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('transport.carriers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('transport.carriers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="true"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<MalioInputText
:model-value="model.streetComplement"
:label="t('transport.carriers.form.address.streetComplement')"
:readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
</template>
<script setup lang="ts">
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm'
import { sanitizeAddress } from '~/shared/utils/textSanitize'
interface RefOption {
value: string
label: string
}
// Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####'
const props = defineProps<{
/** Brouillon de l'adresse (v-model). */
modelValue: CarrierAddressFormDraft
/** Pays disponibles (France par defaut). */
countryOptions: RefOption[]
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
'update:modelValue': [value: CarrierAddressFormDraft]
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
'degraded': []
}>()
const { t } = useI18n()
const autocomplete = useAddressAutocomplete()
const model = computed(() => props.modelValue)
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable).
const degraded = ref(false)
let unavailableNotified = false
const banCityOptions = ref<RefOption[]>([])
const banAddressOptions = ref<RefOption[]>([])
// Options ville effectives : on garantit que la ville courante figure toujours
// dans la liste, sinon MalioSelect afficherait un champ vide en lecture seule.
const cityOptions = computed<RefOption[]>(() => {
const current = props.modelValue.city
if (current && !banCityOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banCityOptions.value]
}
return banCityOptions.value
})
// Meme garantie pour le champ Adresse : la rue courante doit toujours figurer
// dans les options, sinon MalioInputAutocomplete laisse le champ vide.
const addressOptions = computed<RefOption[]>(() => {
const current = props.modelValue.street
if (current && !banAddressOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banAddressOptions.value]
}
return banAddressOptions.value
})
const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = []
// Filtres de saisie par champ (ERP-193) : voie / complement / ville = profil
// adresse. Le code postal (masque numerique) n'est pas filtre ici.
const FIELD_SANITIZERS: Partial<Record<keyof CarrierAddressFormDraft, (v: string) => string>> = {
street: sanitizeAddress,
streetComplement: sanitizeAddress,
city: sanitizeAddress,
}
/** Emet un nouveau brouillon avec le champ modifie (immutabilite), sanitise si besoin. */
function update<K extends keyof CarrierAddressFormDraft>(field: K, value: CarrierAddressFormDraft[K]): void {
const sanitizer = FIELD_SANITIZERS[field]
const next = (sanitizer && typeof value === 'string')
? (sanitizer(value) as CarrierAddressFormDraft[K])
: value
emit('update:modelValue', { ...props.modelValue, [field]: next })
}
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
function notifyUnavailable(): void {
if (!unavailableNotified) {
unavailableNotified = true
emit('degraded')
}
}
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
async function onPostalCodeChange(value: string): Promise<void> {
update('postalCode', value)
const digits = (value ?? '').replace(/\D/g, '')
if (digits.length < 5) {
return
}
try {
const suggestions = await autocomplete.searchCity(digits)
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
degraded.value = false
}
catch {
degraded.value = true
notifyUnavailable()
}
}
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
async function onAddressSearch(query: string): Promise<void> {
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400).
if (query.trim().length < 3) {
banAddressOptions.value = []
return
}
addressLoading.value = true
try {
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
const suggestions = await autocomplete.searchAddress(query, postalCode)
lastAddressSuggestions = suggestions
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
}
catch {
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie.
banAddressOptions.value = []
notifyUnavailable()
}
finally {
addressLoading.value = false
}
}
/** Selection d'une suggestion d'adresse → remplit rue + ville + CP. */
function onAddressSelect(option: { label: string, value: string | number } | null): void {
if (option === null) {
return
}
const suggestion = lastAddressSuggestions.find(s => s.street === option.value)
if (!suggestion) {
update('street', String(option.value))
return
}
emit('update:modelValue', {
...props.modelValue,
street: suggestion.street,
city: suggestion.city,
postalCode: suggestion.postalCode,
})
}
</script>
@@ -0,0 +1,130 @@
<template>
<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 : ouvre une modal de confirmation côté parent. Masquée si
non supprimable (1er bloc) ou en lecture seule. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('transport.carriers.form.contact.remove') }"
@click="$emit('remove')"
/>
<MalioInputText
:model-value="model.lastName"
:label="t('transport.carriers.form.contact.lastName')"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
:model-value="model.firstName"
:label="t('transport.carriers.form.contact.firstName')"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText (inheritAttrs:false)
renvoie `class` sur l'input interne, pas sur la cellule de grille. -->
<div class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('transport.carriers.form.contact.jobTitle')"
:readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
</div>
<MalioInputEmail
:model-value="model.email"
:label="t('transport.carriers.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<!-- Téléphone principal + bouton « + » révélant le 2e numéro (max 2). -->
<MalioInputPhone
:model-value="model.phonePrimary"
:label="t('transport.carriers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('transport.carriers.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<!-- 2e numéro : révélé à la demande (max 2 téléphones RG-4.08). -->
<MalioInputPhone
v-if="model.hasSecondaryPhone"
:model-value="model.phoneSecondary"
:label="t('transport.carriers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div>
</template>
<script setup lang="ts">
import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm'
import { sanitizeEmail, sanitizeFreeText, sanitizePersonName } from '~/shared/utils/textSanitize'
// Masque téléphone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##'
const props = defineProps<{
/** Brouillon du contact (v-model). */
modelValue: CarrierContactFormDraft
/** Affiche l'icône de suppression (1er bloc non supprimable). */
removable?: boolean
/** Bloc en lecture seule (onglet validé). */
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Erreurs serveur 422 de cette ligne, indexées par champ (ERP-101). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
'update:modelValue': [value: CarrierContactFormDraft]
'remove': []
}>()
const { t } = useI18n()
// Alias local pour la lisibilité du template.
const model = computed(() => props.modelValue)
// Filtres de saisie par champ (ERP-193) : on retire les caractères parasites à la
// frappe. Noms = profil personne, fonction = texte libre, email = profil email.
const FIELD_SANITIZERS: Partial<Record<keyof CarrierContactFormDraft, (v: string) => string>> = {
lastName: sanitizePersonName,
firstName: sanitizePersonName,
jobTitle: sanitizeFreeText,
email: sanitizeEmail,
}
/** Émet un nouveau brouillon avec le champ modifié (immutabilité), sanitisé si besoin. */
function update<K extends keyof CarrierContactFormDraft>(field: K, value: CarrierContactFormDraft[K]): void {
const sanitizer = FIELD_SANITIZERS[field]
const next = (sanitizer && typeof value === 'string')
? (sanitizer(value) as CarrierContactFormDraft[K])
: value
emit('update:modelValue', { ...props.modelValue, [field]: next })
}
/** Révèle le 2e numéro (max 1 secondaire, le « + » disparaît). */
function revealSecondaryPhone(): void {
emit('update:modelValue', { ...props.modelValue, hasSecondaryPhone: true })
}
</script>
@@ -0,0 +1,317 @@
<template>
<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. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('transport.carriers.form.price.remove') }"
@click="$emit('remove')"
/>
<!-- RG-4.09 : sens du prix (CLIENT / FOURNISSEUR) en colonne 1 / ligne 1, radios
EN LIGNE (horizontaux), centrés sur la hauteur de champ (h-12) comme la
case « Affréter ». Pas de label de groupe. -->
<div>
<div class="flex h-12 items-center gap-6">
<MalioRadioButton
:model-value="model.direction"
:name="`price-direction-${uid}`"
value="CLIENT"
:label="t('transport.carriers.form.price.directionClient')"
:disabled="readonly || disabled"
group-class="mt-0"
@update:model-value="onDirectionChange"
/>
<MalioRadioButton
:model-value="model.direction"
:name="`price-direction-${uid}`"
value="FOURNISSEUR"
:label="t('transport.carriers.form.price.directionSupplier')"
:disabled="readonly || disabled"
group-class="mt-0"
@update:model-value="onDirectionChange"
/>
</div>
<p v-if="errors?.direction" class="ml-[2px] text-xs text-m-danger">{{ errors.direction }}</p>
</div>
<!-- Branche CLIENT (RG-4.10). -->
<template v-if="model.direction === 'CLIENT'">
<MalioSelect
:model-value="model.clientIri"
:options="clientOptions"
:label="t('transport.carriers.form.price.client')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.client"
@update:model-value="onClientChange"
/>
<MalioSelect
:model-value="model.clientDeliveryAddressIri"
:options="clientAddressOptions"
:label="t('transport.carriers.form.price.clientDeliveryAddress')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.clientDeliveryAddress"
@update:model-value="(v: string | number | null) => update('clientDeliveryAddressIri', v === null ? null : String(v))"
/>
<MalioSelect
:model-value="model.departureSiteIri"
:options="siteOptions"
:label="t('transport.carriers.form.price.departureSite')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.departureSite"
@update:model-value="(v: string | number | null) => update('departureSiteIri', v === null ? null : String(v))"
/>
</template>
<!-- Branche FOURNISSEUR (RG-4.11). -->
<template v-else-if="model.direction === 'FOURNISSEUR'">
<MalioSelect
:model-value="model.supplierIri"
:options="supplierOptions"
:label="t('transport.carriers.form.price.supplier')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.supplier"
@update:model-value="onSupplierChange"
/>
<MalioSelect
:model-value="model.supplierSupplyAddressIri"
:options="supplierAddressOptions"
:label="t('transport.carriers.form.price.supplierSupplyAddress')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.supplierSupplyAddress"
@update:model-value="(v: string | number | null) => update('supplierSupplyAddressIri', v === null ? null : String(v))"
/>
<MalioSelect
:model-value="model.deliverySiteIri"
:options="siteOptions"
:label="t('transport.carriers.form.price.deliverySite')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.deliverySite"
@update:model-value="(v: string | number | null) => update('deliverySiteIri', v === null ? null : String(v))"
/>
</template>
<!-- Communs (visibles dès qu'un sens est choisi). -->
<template v-if="model.direction !== null">
<!-- Contenant : Benne / Fond mouvant (radios centrés h-12, pas de label). -->
<div>
<div class="flex h-12 items-center gap-4">
<MalioRadioButton
:model-value="model.containerType"
:name="`price-container-${uid}`"
value="BENNE"
:label="t('transport.carriers.containerType.BENNE')"
:disabled="readonly || disabled"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
/>
<MalioRadioButton
:model-value="model.containerType"
:name="`price-container-${uid}`"
value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
:disabled="readonly || disabled"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
/>
</div>
<p v-if="errors?.containerType" class="ml-[2px] text-xs text-m-danger">{{ errors.containerType }}</p>
</div>
<!-- Tarification : Forfait / Tonne (radios centrés h-12, pas de label). -->
<div>
<div class="flex h-12 items-center gap-4">
<MalioRadioButton
:model-value="model.pricingUnit"
:name="`price-unit-${uid}`"
value="FORFAIT"
:label="t('transport.carriers.form.price.pricingForfait')"
:disabled="readonly || disabled"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
/>
<MalioRadioButton
:model-value="model.pricingUnit"
:name="`price-unit-${uid}`"
value="TONNE"
:label="t('transport.carriers.form.price.pricingTonne')"
:disabled="readonly || disabled"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
/>
</div>
<p v-if="errors?.pricingUnit" class="ml-[2px] text-xs text-m-danger">{{ errors.pricingUnit }}</p>
</div>
<MalioInputAmount
:model-value="model.price"
:label="t('transport.carriers.form.price.price')"
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.price"
@update:model-value="(v: string) => update('price', v)"
/>
<MalioSelect
:model-value="model.priceState"
:options="priceStateOptions"
:label="t('transport.carriers.form.price.priceState')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.priceState"
@update:model-value="(v: string | number | null) => update('priceState', v === null ? null : String(v))"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, ref, useId, watch } from 'vue'
import type { CarrierPriceFormDraft } from '~/modules/transport/types/carrierForm'
interface SelectOption {
value: string
label: string
}
const props = defineProps<{
/** Brouillon du prix (v-model). */
modelValue: CarrierPriceFormDraft
/** Clients disponibles (IRI en value). */
clientOptions: SelectOption[]
/** Fournisseurs disponibles (IRI en value). */
supplierOptions: SelectOption[]
/** Sites Starseed (3 sites — IRI en value). */
siteOptions: SelectOption[]
removable?: 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). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
'update:modelValue': [value: CarrierPriceFormDraft]
'remove': []
}>()
const { t } = useI18n()
const api = useApi()
// Identifiant unique par instance : les groupes de radios (sens / contenant / tarif)
// doivent avoir un `name` PROPRE à chaque bloc prix, sinon plusieurs blocs partagent
// le même groupe HTML et leurs radios se désélectionnent mutuellement.
const uid = useId()
const model = computed(() => props.modelValue)
const priceStateOptions = computed<SelectOption[]>(() => [
{ value: 'EN_COURS', label: t('transport.carriers.form.price.stateEnCours') },
{ value: 'VALIDE', label: t('transport.carriers.form.price.stateValide') },
{ value: 'NON_VALIDE', label: t('transport.carriers.form.price.stateNonValide') },
])
// Adresses chargées à la volée pour le client / fournisseur sélectionné (par bloc).
const clientAddressOptions = ref<SelectOption[]>([])
const supplierAddressOptions = ref<SelectOption[]>([])
/** Émet un nouveau brouillon avec le champ modifié (immutabilité). */
function update<K extends keyof CarrierPriceFormDraft>(field: K, value: CarrierPriceFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
/** Changement de sens : réinitialise les DEUX branches (cohérence CHECK BDD). */
function onDirectionChange(value: string | number | boolean | null): void {
const direction = value === 'CLIENT' || value === 'FOURNISSEUR' ? value : null
emit('update:modelValue', {
...props.modelValue,
direction,
clientIri: null,
clientDeliveryAddressIri: null,
departureSiteIri: null,
supplierIri: null,
supplierSupplyAddressIri: null,
deliverySiteIri: null,
})
}
/** Sélection d'un client → maj IRI + reset de l'adresse de livraison (autre client). */
function onClientChange(value: string | number | null): void {
emit('update:modelValue', {
...props.modelValue,
clientIri: value === null ? null : String(value),
clientDeliveryAddressIri: null,
})
}
/** Sélection d'un fournisseur → maj IRI + reset de l'adresse d'appro. */
function onSupplierChange(value: string | number | null): void {
emit('update:modelValue', {
...props.modelValue,
supplierIri: value === null ? null : String(value),
supplierSupplyAddressIri: null,
})
}
/** Réponse détail (client / fournisseur) embarquant ses adresses. */
interface ParentWithAddresses {
addresses?: { '@id': string, street?: string | null, postalCode?: string | null, city?: string | null }[]
}
/** Mappe les adresses embarquées en options (IRI en value, voie · CP · ville en label). */
function toAddressOptions(parent: ParentWithAddresses): SelectOption[] {
return (parent.addresses ?? []).map(a => ({
value: a['@id'],
label: [a.street, a.postalCode, a.city].filter(Boolean).join(' · ') || a['@id'],
}))
}
/**
* Charge les adresses d'un parent (client/fournisseur) à la volée via son détail
* (GET de l'IRI, qui embarque `addresses`). Échec liste vide (non bloquant).
*/
async function loadAddresses(iri: string | null, target: typeof clientAddressOptions): Promise<void> {
if (!iri) {
target.value = []
return
}
try {
// L'IRI est absolue (/api/clients/5) ; useApi préfixe déjà /api on le retire.
const path = iri.replace(/^\/api/, '')
const data = await api.get<ParentWithAddresses>(path, {}, { headers: { Accept: 'application/ld+json' }, toast: false })
target.value = toAddressOptions(data)
}
catch {
target.value = []
}
}
// Recharge les adresses quand le client / fournisseur change (immediate pour le
// pré-remplissage en édition).
watch(() => props.modelValue.clientIri, iri => loadAddresses(iri, clientAddressOptions), { immediate: true })
watch(() => props.modelValue.supplierIri, iri => loadAddresses(iri, supplierAddressOptions), { immediate: true })
</script>
@@ -0,0 +1,201 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { debounce } from '~/shared/utils/debounce'
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
/**
* Onglet « Qualimat » saisie assistée par recherche dans le référentiel QUALIMAT
* (RG-4.01 / RG-4.04). Datatable paginé filtré par le NOM (`searchName`), sélection
* d'une ligne → modal de confirmation → `integrate`. Mutualisé entre l'écran
* d'AJOUT (ERP-166) et l'écran de MODIFICATION (ERP-172, « actualiser le
* transporteur »). La persistance (copie nom / certification / FK) est portée par
* le parent via `useCarrierForm.applyQualimatSelection`.
*/
const props = defineProps<{
/** Terme de recherche (nom du transporteur saisi dans le formulaire principal). */
searchName: string
/** IRI QUALIMAT actuellement lié (coche le radio de la ligne correspondante). */
selectedIri: string | null
}>()
const emit = defineEmits<{
(event: 'integrate', row: QualimatCarrierRow): void
}>()
const { t } = useI18n()
const {
items: qualimatItems,
totalItems: qualimatTotal,
currentPage: qualimatPage,
itemsPerPage: qualimatPerPage,
itemsPerPageOptions: qualimatPerPageOptions,
goToPage: qualimatGoToPage,
setItemsPerPage: qualimatSetPerPage,
setFilters: qualimatSetFilters,
} = useQualimatSearch()
// Colonnes du datatable de sélection QUALIMAT (radio / Nom / Adresse / Validité).
const qualimatColumns = [
{ key: 'select', label: '' },
{ key: 'name', label: t('transport.carriers.form.qualimat.columns.name') },
{ key: 'address', label: t('transport.carriers.form.qualimat.columns.address') },
{ key: 'validityDate', label: t('transport.carriers.form.qualimat.columns.validityDate') },
]
// Le datatable n'affiche QUE des résultats de recherche : vide tant que le Nom n'est
// pas saisi (pas de liste complète par défaut).
const hasQualimatSearch = computed(() => props.searchName.trim() !== '')
const qualimatRows = computed(() => {
if (!hasQualimatSearch.value) {
return []
}
return qualimatItems.value.map(row => ({
id: row.id,
iri: row['@id'],
name: row.name,
address: formatQualimatAddress(row),
validityDate: row.validityDate,
}))
})
const qualimatTotalDisplay = computed(() => (hasQualimatSearch.value ? qualimatTotal.value : 0))
const qualimatEmptyMessage = computed(() => hasQualimatSearch.value
? t('transport.carriers.form.qualimat.empty')
: t('transport.carriers.form.qualimat.searchHint'))
// Re-filtrage debouncé sur le nom ; aucune recherche tant que le Nom est vide.
const filterQualimatByName = debounce((term: string) => {
if (term.trim() === '') {
return
}
void qualimatSetFilters({ search: term })
}, 300)
watch(() => props.searchName, term => filterQualimatByName(term), { immediate: true })
/** Adresse QUALIMAT condensée pour la colonne « Adresse » (voie · CP · ville). */
function formatQualimatAddress(row: QualimatCarrierRow): string {
return [row.address, row.postalCode, row.city].filter(Boolean).join(' · ')
}
/** RG-4.04 : un agrément est périmé si sa date de validité est < aujourd'hui. */
function isExpired(value: string): boolean {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return false
}
const today = new Date()
today.setHours(0, 0, 0, 0)
date.setHours(0, 0, 0, 0)
return date.getTime() < today.getTime()
}
/** Format court français JJ-MM-AAAA (chaîne vide si date absente / invalide). */
function formatDateFr(value: string | null | undefined): string {
if (!value) {
return ''
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
return `${day}-${month}-${date.getFullYear()}`
}
// Confirmation d'intégration
const confirmOpen = ref(false)
const pendingRow = ref<QualimatCarrierRow | null>(null)
/** Clic sur une ligne → retrouve la ligne QUALIMAT source + ouvre la modal. */
function onQualimatRowClick(item: Record<string, unknown>): void {
const row = qualimatItems.value.find(r => r.id === item.id)
if (row) {
pendingRow.value = row
confirmOpen.value = true
}
}
/** Confirme l'intégration : délègue la persistance au parent via `integrate`. */
function confirmIntegrate(): void {
const row = pendingRow.value
confirmOpen.value = false
if (row !== null) {
emit('integrate', row)
}
}
</script>
<template>
<div class="mt-12 flex flex-col gap-6">
<!-- table-fixed : 1re colonne (radio) étroite, les 3 autres à parts égales. -->
<MalioDataTable
class="qualimat-table"
table-class="table-fixed"
:columns="qualimatColumns"
:items="qualimatRows"
:total-items="qualimatTotalDisplay"
:page="qualimatPage"
:per-page="qualimatPerPage"
:per-page-options="qualimatPerPageOptions"
row-clickable
:empty-message="qualimatEmptyMessage"
@row-click="onQualimatRowClick"
@update:page="qualimatGoToPage"
@update:per-page="qualimatSetPerPage"
>
<!-- Radio reflétant la ligne QUALIMAT intégrée (lecture). -->
<template #cell-select="{ item }">
<MalioRadioButton
:model-value="selectedIri"
name="qualimat-row"
:value="item.iri"
group-class="mt-0"
/>
</template>
<!-- Date de validité : fond rouge si périmée (RG-4.04). -->
<template #cell-validityDate="{ item }">
<span
v-if="item.validityDate"
:class="isExpired(item.validityDate as string) ? 'inline-block rounded px-2 py-0.5 bg-m-danger text-white' : ''"
>
{{ formatDateFr(item.validityDate as string) }}
</span>
</template>
</MalioDataTable>
<!-- Modal de confirmation d'intégration QUALIMAT. -->
<MalioModal v-model="confirmOpen" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.qualimat.confirm.title') }}</h2>
</template>
<p>{{ t('transport.carriers.form.qualimat.confirm.message') }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('transport.carriers.form.qualimat.confirm.cancel')"
@click="confirmOpen = false"
/>
<MalioButton
variant="primary"
button-class="flex-1"
:label="t('transport.carriers.form.qualimat.confirm.confirm')"
@click="confirmIntegrate"
/>
</template>
</MalioModal>
</div>
</template>
<style scoped>
/* Datatable QUALIMAT en table-fixed : la colonne radio (1re) reste étroite,
les 3 autres (nom / adresse / validité) se partagent l'espace à parts égales. */
.qualimat-table :deep(th:first-child),
.qualimat-table :deep(td:first-child) {
width: 56px;
}
</style>
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue'
import { emptyCarrierAddress } from '~/modules/transport/types/carrierForm'
import CarrierAddressBlock from '../CarrierAddressBlock.vue'
/**
* Tests de l'autocomplétion BAN du bloc Adresse transporteur (ERP-167) réutilise
* `useAddressAutocomplete` (M1/M2/M3). On vérifie le NOMINAL (CP ville) et le
* DÉGRADÉ (BAN indisponible saisie libre + event `degraded`).
*/
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
searchCityMock: vi.fn(),
searchAddressMock: vi.fn(),
}))
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
useAddressAutocomplete: () => ({
searchCity: searchCityMock,
searchAddress: searchAddressMock,
}),
}))
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('ref', ref)
vi.stubGlobal('computed', computed)
const MalioInputTextStub = defineComponent({
name: 'MalioInputText',
props: { modelValue: { default: null }, label: { type: String, default: '' }, error: { type: String, default: '' } },
emits: ['update:modelValue'],
setup(props) {
return () => h('div', { 'data-testid': 'input-text', 'data-label': props.label, 'data-error': props.error })
},
})
const MalioSelectStub = defineComponent({
name: 'MalioSelect',
props: { modelValue: { default: null }, options: { type: Array as () => { value: string }[], default: () => [] }, label: { type: String, default: '' }, error: { type: String, default: '' } },
emits: ['update:modelValue'],
setup(props) {
return () => h('div', { 'data-testid': 'select', 'data-label': props.label, 'data-options': JSON.stringify(props.options.map(o => o.value)) })
},
})
const MalioInputAutocompleteStub = defineComponent({
name: 'MalioInputAutocomplete',
props: { modelValue: { default: null }, options: { type: Array as () => { value: string }[], default: () => [] }, loading: { type: Boolean, default: false }, allowCreate: { type: Boolean, default: false } },
emits: ['update:modelValue', 'search', 'select'],
setup(props) {
return () => h('div', { 'data-testid': 'addr-autocomplete', 'data-options': JSON.stringify(props.options.map(o => o.value)) })
},
})
function mountBlock(overrides: Record<string, unknown> = {}) {
return mount(CarrierAddressBlock, {
props: {
modelValue: { ...emptyCarrierAddress(), ...overrides },
countryOptions: [{ value: 'France', label: 'France' }],
},
global: {
stubs: {
MalioButtonIcon: true,
MalioInputText: MalioInputTextStub,
MalioSelect: MalioSelectStub,
MalioInputAutocomplete: MalioInputAutocompleteStub,
},
},
})
}
/** Récupère le composant MalioInputText d'un label donné. */
function inputTextByLabel(wrapper: ReturnType<typeof mountBlock>, label: string) {
return wrapper.findAllComponents(MalioInputTextStub).find(c => c.props('label') === label)
}
describe('CarrierAddressBlock — autocomplétion ville (BAN) NOMINAL', () => {
beforeEach(() => {
searchCityMock.mockReset()
searchAddressMock.mockReset()
})
it('saisie d\'un CP à 5 chiffres → searchCity + peuple le select Ville', async () => {
searchCityMock.mockResolvedValueOnce([{ city: 'Poitiers', postalCode: '86000' }])
const wrapper = mountBlock()
const cp = inputTextByLabel(wrapper, 'transport.carriers.form.address.postalCode')
cp?.vm.$emit('update:modelValue', '86000')
await flushPromises()
expect(searchCityMock).toHaveBeenCalledWith('86000')
const citySelect = wrapper.findAllComponents(MalioSelectStub).find(c => c.props('label') === 'transport.carriers.form.address.city')
const options = JSON.parse(citySelect?.attributes('data-options') ?? '[]')
expect(options).toContain('Poitiers')
expect(wrapper.emitted('degraded')).toBeUndefined()
})
it('n\'interroge pas la BAN sous 5 chiffres', async () => {
const wrapper = mountBlock()
inputTextByLabel(wrapper, 'transport.carriers.form.address.postalCode')?.vm.$emit('update:modelValue', '860')
await flushPromises()
expect(searchCityMock).not.toHaveBeenCalled()
})
})
describe('CarrierAddressBlock — autocomplétion DÉGRADÉE', () => {
beforeEach(() => {
searchCityMock.mockReset()
searchAddressMock.mockReset()
})
it('BAN ville indisponible → bascule en saisie libre + émet « degraded »', async () => {
searchCityMock.mockRejectedValueOnce(new Error('BAN indisponible'))
const wrapper = mountBlock()
inputTextByLabel(wrapper, 'transport.carriers.form.address.postalCode')?.vm.$emit('update:modelValue', '86000')
await flushPromises()
expect(wrapper.emitted('degraded')).toHaveLength(1)
// En dégradé, la Ville devient un MalioInputText (plus de MalioSelect ville).
const citySelect = wrapper.findAllComponents(MalioSelectStub).find(c => c.props('label') === 'transport.carriers.form.address.city')
expect(citySelect).toBeUndefined()
expect(inputTextByLabel(wrapper, 'transport.carriers.form.address.city')).toBeDefined()
})
it('autocomplétion adresse : pas d\'appel BAN sous 3 caractères', async () => {
const wrapper = mountBlock()
wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab')
await flushPromises()
expect(searchAddressMock).not.toHaveBeenCalled()
})
it('autocomplétion adresse : émet « degraded » une seule fois malgré plusieurs erreurs', async () => {
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
const wrapper = mountBlock()
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
auto.vm.$emit('search', 'rue de la paix')
await flushPromises()
auto.vm.$emit('search', 'rue de la paixx')
await flushPromises()
expect(wrapper.emitted('degraded')).toHaveLength(1)
})
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
const wrapper = mountBlock()
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useCarriersRepository, type Carrier } from '../useCarriersRepository'
const mockApiGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
/**
* Tests du repertoire transporteurs (ERP-164).
*
* `useCarriersRepository` est une fine enveloppe de `usePaginatedList<Carrier>`
* sur `/carriers`. Les invariants generiques de pagination sont deja couverts par
* `usePaginatedList.test.ts` ; on verifie ici le CONTRAT propre au repertoire :
* - la ressource ciblee est bien `/carriers` ;
* - l'enveloppe Hydra (member / totalItems) est consommee ;
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
* renvoie un tableau plat sans pagination) ;
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `archivedOnly` n'est envoye
* tant que l'utilisateur ne coche pas le filtre (le back masque alors les
* archives) ; le filtre « Voir les archivés » est bien transmis une fois
* applique (aligne sur Client / Fournisseur / Prestataire).
*/
describe('useCarriersRepository', () => {
beforeEach(() => {
mockApiGet.mockReset()
})
/** Une page de transporteurs Hydra, avec qualimatCarrier embarque (RG-4.04). */
const PAGE: Carrier[] = [
{
id: 1,
name: 'TRANSPORTS ACME',
certificationType: 'QUALIMAT',
qualimatCarrier: {
id: '42',
name: 'TRANSPORTS ACME',
validityDate: '2027-01-15',
status: 'VALIDE',
},
updatedAt: '2026-06-15T08:12:01+02:00',
isArchived: false,
},
]
it('cible /carriers, consomme l\'enveloppe Hydra et envoie l\'Accept ld+json', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useCarriersRepository()
await repo.fetch()
expect(mockApiGet).toHaveBeenCalledTimes(1)
const [url, query, opts] = mockApiGet.mock.calls[0]
expect(url).toBe('/carriers')
expect(query).toMatchObject({ page: 1, itemsPerPage: 25 })
expect(opts).toMatchObject({
toast: false,
headers: { Accept: 'application/ld+json' },
})
expect(repo.items.value).toEqual(PAGE)
expect(repo.totalItems.value).toBe(1)
})
it('exclut les archives par defaut : aucun archivedOnly au premier fetch', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useCarriersRepository()
await repo.fetch()
const query = mockApiGet.mock.calls[0][1] as Record<string, unknown>
expect(query.archivedOnly).toBeUndefined()
})
it('transmet archivedOnly une fois le filtre applique (retour page 1)', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useCarriersRepository()
await repo.fetch()
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
await repo.setFilters({ archivedOnly: true })
expect(repo.currentPage.value).toBe(1)
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(query.archivedOnly).toBe(true)
})
it('transmet les certifications multiples + la recherche', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useCarriersRepository()
await repo.fetch()
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
await repo.setFilters({ search: 'acme', 'certificationType[]': ['QUALIMAT', 'AUTRE'] })
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(query.search).toBe('acme')
expect(query['certificationType[]']).toEqual(['QUALIMAT', 'AUTRE'])
})
})
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useQualimatSearch, type QualimatCarrierRow } from '../useQualimatSearch'
const mockApiGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
/**
* Tests de la saisie assistée QUALIMAT (M4 Transport, ERP-166 RG-4.01).
*
* `useQualimatSearch` est une fine enveloppe de `usePaginatedList<QualimatCarrierRow>`
* sur `/qualimat_carriers`. La pagination générique est couverte par
* `usePaginatedList.test.ts` ; on vérifie ici le CONTRAT propre à la recherche :
* - ressource ciblée `/qualimat_carriers` + enveloppe Hydra + `Accept: application/ld+json` ;
* - le filtre `search` (branché sur le nom du transporteur) est transmis et
* retombe en page 1.
*/
describe('useQualimatSearch', () => {
beforeEach(() => {
mockApiGet.mockReset()
})
const PAGE: QualimatCarrierRow[] = [
{
'@id': '/api/qualimat_carriers/1',
id: '1',
name: 'TRANSPORTS ACME',
siret: '12345678900012',
address: '1 rue du Port',
postalCode: '86000',
city: 'Poitiers',
validityDate: '2027-01-15',
status: 'VALIDE',
},
]
it('cible /qualimat_carriers, consomme l\'enveloppe Hydra et envoie l\'Accept ld+json', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useQualimatSearch()
await repo.fetch()
const [url, query, opts] = mockApiGet.mock.calls[0]
expect(url).toBe('/qualimat_carriers')
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
expect(repo.items.value).toEqual(PAGE)
expect(repo.totalItems.value).toBe(1)
})
it('transmet le filtre `search` (nom du transporteur) et retombe en page 1', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useQualimatSearch()
await repo.fetch()
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
await repo.setFilters({ search: 'acme' })
expect(repo.currentPage.value).toBe(1)
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(query.search).toBe('acme')
})
})
@@ -0,0 +1,68 @@
import { ref } from 'vue'
import type { CarrierDetail } from '~/modules/transport/utils/forms/carrierMappers'
/**
* Chargement et actions d'archivage d'un transporteur unique (écrans Consultation /
* Modification, ERP-170). Miroir de `useProvider` (M3) / `useSupplier` (M2). Lit le
* détail embarqué via `GET /api/carriers/{id}` (qualimatCarrier + addresses /
* contacts / prices sous `carrier:item:read`, relations cross-module via leurs
* read-groups) une SEULE requête peuple les deux écrans (embed borné, pas de N+1).
*
* L'en-tête `Accept: application/ld+json` est imposé pour obtenir le payload Hydra
* complet (avec les `@id` des relations embarquées, indispensables au préremplissage).
*
* État 100 % local à l'instance (refs). Les erreurs d'archivage / restauration
* (notamment le 409 d'homonyme actif à la restauration) sont PROPAGÉES à l'appelant.
*/
export function useCarrier(id: number | string) {
const api = useApi()
const carrier = ref<CarrierDetail | null>(null)
const loading = ref(false)
const error = ref(false)
/** Récupère le détail complet (embed qualimatCarrier + addresses / contacts / prices). */
function fetchDetail(): Promise<CarrierDetail> {
return api.get<CarrierDetail>(
`/carriers/${id}`,
{},
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
}
/** Charge le détail du transporteur. En cas d'échec : `error = true`, `carrier = null`. */
async function load(): Promise<void> {
loading.value = true
error.value = false
try {
carrier.value = await fetchDetail()
}
catch {
error.value = true
carrier.value = null
}
finally {
loading.value = false
}
}
/**
* Bascule l'archivage (PATCH `isArchived` SEUL groupe carrier:write:archive ;
* tout autre champ 422, security archive = Admin seul), puis RECHARGE le détail
* complet (la réponse du PATCH ne porte pas l'embed des sous-collections). Toute
* erreur est propagée à l'appelant AVANT le rechargement.
*/
async function setArchived(isArchived: boolean): Promise<void> {
await api.patch(`/carriers/${id}`, { isArchived }, { toast: false })
carrier.value = await fetchDetail()
}
return {
carrier,
loading,
error,
load,
archive: () => setArchived(true),
restore: () => setArchived(false),
}
}
@@ -0,0 +1,835 @@
import { computed, reactive, ref, type Ref } from 'vue'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import { useUpload } from '~/shared/composables/useUpload'
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
import { removeCollectionRow } from '~/shared/utils/collectionRow'
import {
emptyCarrierAddress,
emptyCarrierAddressCopy,
emptyCarrierContact,
emptyCarrierMain,
emptyCarrierPrice,
type CarrierAddressCopy,
type CarrierAddressFormDraft,
type CarrierContactFormDraft,
type CarrierMainDraft,
type CarrierMainResponse,
type CarrierPriceFormDraft,
} from '~/modules/transport/types/carrierForm'
import { buildCarrierAddressPayload } from '~/modules/transport/utils/forms/carrierAddress'
import { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } from '~/modules/transport/utils/forms/carrierContact'
import { buildCarrierPricePayload, isCarrierPriceValid } from '~/modules/transport/utils/forms/carrierPrice'
import {
mapAddressToDraft,
mapContactToDraft,
mapMainToDraft,
mapPriceToDraft,
type CarrierDetail,
} from '~/modules/transport/utils/forms/carrierMappers'
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
/** Nom du cas spécial « compte-propre » LIOT (comparaison insensible à la casse, RG-4.01). */
const LIOT_NAME = 'LIOT'
/**
* Workflow de l'écran « Ajouter un transporteur » (M4 Transport, ERP-165)
* miroir conceptuel de `useSupplierForm` (M2) / `useProviderForm` (M3).
*
* Périmètre ERP-165 : le formulaire PRINCIPAL (pré-onglets) et l'orchestration de
* la barre d'onglets. La création est INCRÉMENTALE (spec-front § Écran Ajouter) :
* - on POST d'abord le formulaire principal (`POST /api/carriers`) ;
* - au succès le bloc principal passe en lecture seule, le 1er onglet (Qualimat)
* se déverrouille et devient actif ;
* - chaque onglet validé déverrouille le suivant (PATCH partiels par groupe de
* sérialisation) et passe en lecture seule.
*
* Les champs conditionnels du formulaire principal (indexation / benne / volume
* si affrété, décharge si AUTRE, cas LIOT) et la saisie assistée QUALIMAT arrivent
* à ERP-166 ; le contenu des onglets Adresses / Contacts / Prix aux tickets
* suivants. Ce composable pose le POST principal, le PATCH partiel et le gating
* des onglets.
*
* État 100 % local à l'instance (refs / reactive) aucune persistance URL.
*/
/**
* Clés des onglets du flux de création, dans l'ordre de la barre (spec-front
* § Écran Ajouter). Toujours les 4 (pas de gating par permission au M4, l'onglet
* Comptabilité du M3).
*/
export const CARRIER_TAB_KEYS = ['qualimat', 'addresses', 'contacts', 'prices'] as const
export function useCarrierForm() {
const api = useApi()
const { t } = useI18n()
const toast = useToast()
// Erreurs de validation par champ (ERP-101) du formulaire principal.
const mainErrors = useFormErrors()
// Upload de la décharge (RG-4.02) — infra partagée /api/uploaded_documents.
// L'upload est DIFFÉRÉ : le fichier choisi attend ici jusqu'à l'enregistrement.
const { uploading: dischargeUploading, upload: uploadFile } = useUpload()
const pendingDischargeFile = ref<File | null>(null)
// ── État du transporteur créé ─────────────────────────────────────────────
const carrierId = ref<number | null>(null)
const mainLocked = ref(false)
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
// ── Formulaire principal ──────────────────────────────────────────────────
const main = reactive<CarrierMainDraft>(emptyCarrierMain())
// Adresse copiée depuis QUALIMAT à la sélection (alimente l'onglet Adresses,
// ticket ultérieur). Vide tant qu'aucun transporteur QUALIMAT n'est intégré.
const qualimatAddress = ref<CarrierAddressCopy>(emptyCarrierAddressCopy())
// ── Affichage conditionnel du formulaire principal (RG-4.01 / 4.02 / 4.03) ──
// Cas LIOT : nom == « LIOT » → seul `liotPlates` est pertinent, le reste masqué.
const isLiot = computed(() => main.name.trim().toUpperCase() === LIOT_NAME)
// Transporteur QUALIMAT : la FK est posée → certification figée à « QUALIMAT ».
const isQualimat = computed(() => main.qualimatCarrierIri !== null)
// Certification masquée en cas LIOT ; lecture seule si QUALIMAT (ou bloc verrouillé).
// En MODIFICATION (ERP-172) : éditable même pour un QUALIMAT (le métier doit pouvoir
// changer la certification) — la sortie de QUALIMAT délie le référentiel.
const showCertification = computed(() => !isLiot.value)
const certificationReadonly = computed(() => (isQualimat.value && !editMode.value) || mainLocked.value)
// RG-4.03 : champs d'affrètement (indexation / contenant / volume) visibles et
// obligatoires si « Affréter » coché — masqués en cas LIOT.
const showCharteredFields = computed(() => main.isChartered && !isLiot.value)
// RG-4.02 : décharge visible et obligatoire si certification == AUTRE (hors LIOT).
const showDischarge = computed(() => main.certificationType === 'AUTRE' && !isLiot.value)
// ── Onglets : ordre + gating progressif ───────────────────────────────────
const tabKeys = ref<string[]>([...CARRIER_TAB_KEYS])
// Index du dernier onglet déverrouillé. L'onglet Qualimat (index 0) est la saisie
// assistée du formulaire principal : accessible DÈS LE DÉPART (≠ Adresses /
// Contacts / Prix, déverrouillés seulement après le POST principal).
const unlockedIndex = ref(0)
const activeTab = ref<string>(CARRIER_TAB_KEYS[0])
// Onglets validés (passent en lecture seule).
const validated = reactive<Record<string, boolean>>({})
// Mode MODIFICATION (ticket ultérieur) : navigation libre, pas de verrouillage
// ni de bascule automatique d'onglet à la validation (cf. completeTab).
const editMode = ref(false)
function isValidated(key: string): boolean {
return validated[key] === true
}
function tabIndex(key: string): number {
return tabKeys.value.indexOf(key)
}
/**
* Validation FRONT du formulaire principal : seul le nom est requis côté front
* (ERP-101) : feedback immédiat sur tous les champs obligatoires (y compris
* conditionnels), alignés sur les RG du back (qui reste autoritaire) :
* - RG-4.01 : nom requis ; certification requise hors cas LIOT ( tout est masqué) ;
* - RG-4.02 : décharge requise si certification AUTRE ;
* - RG-4.03 : indexation + contenant + volume requis si « Affréter ».
*/
function validateMainFront(): boolean {
let valid = true
if (!main.name?.trim()) {
mainErrors.setError('name', t('transport.carriers.form.errors.nameRequired'))
valid = false
}
// Cas LIOT : seul le nom compte, les autres champs sont masqués (RG-4.01).
if (isLiot.value) {
return valid
}
// RG-4.01 : certification obligatoire hors LIOT.
if (!main.certificationType) {
mainErrors.setError('certificationType', t('transport.carriers.form.errors.certificationRequired'))
valid = false
}
// RG-4.02 : décharge obligatoire si certification AUTRE — satisfaite par un
// IRI déjà posé OU un fichier en attente d'upload (différé à l'enregistrement).
if (main.certificationType === 'AUTRE' && !main.dischargeDocumentIri && !pendingDischargeFile.value) {
mainErrors.setError('dischargeDocument', t('transport.carriers.form.errors.dischargeRequired'))
valid = false
}
// RG-4.03 : indexation / contenant / volume obligatoires si affrété.
if (main.isChartered) {
if (!main.indexationRate.trim()) {
mainErrors.setError('indexationRate', t('transport.carriers.form.errors.indexationRequired'))
valid = false
}
if (!main.containerType) {
mainErrors.setError('containerType', t('transport.carriers.form.errors.containerTypeRequired'))
valid = false
}
if (!main.volumeM3.trim()) {
mainErrors.setError('volumeM3', t('transport.carriers.form.errors.volumeRequired'))
valid = false
}
}
return valid
}
/**
* Sélection de la décharge (RG-4.02) via `@file-selected` : le fichier est mis
* EN ATTENTE, l'upload réel est DIFFÉRÉ à l'enregistrement (`submitMain` /
* `updateMain`). Évite les binaires orphelins si l'utilisateur abandonne le
* formulaire après avoir choisi un fichier.
*/
function selectDischarge(file: File): void {
mainErrors.clearError('dischargeDocument')
pendingDischargeFile.value = file
}
/** Annulation du choix de décharge : oublie le fichier en attente et l'IRI. */
function clearDischarge(): void {
pendingDischargeFile.value = null
main.dischargeDocumentIri = null
}
/**
* Résout l'upload différé au moment de l'enregistrement : s'il y a un fichier
* en attente, l'envoie (POST /uploaded_documents) et pose l'IRI sur le
* brouillon. Retourne false au 422 (MIME / taille message inline) pour
* interrompre la sauvegarde du transporteur. Pas de fichier en attente no-op.
*/
async function resolveDischargeUpload(): Promise<boolean> {
if (!pendingDischargeFile.value) {
return true
}
try {
main.dischargeDocumentIri = await uploadFile(pendingDischargeFile.value)
pendingDischargeFile.value = null
return true
} catch (error) {
const message = extractApiErrorMessage((error as { data?: unknown })?.data)
|| t('transport.carriers.form.errors.uploadFailed')
mainErrors.setError('dischargeDocument', message)
return false
}
}
/**
* Change la certification (sélecteur). Quitter « QUALIMAT » délie le référentiel
* (FK qualimatCarrier vidée ERP-172) : un transporteur n'est QUALIMAT que tant
* que sa certification l'est. La FK null est propagée au back par buildMainPayload
* (en modification uniquement).
*/
function setCertification(value: string | null): void {
main.certificationType = value
if (value !== 'QUALIMAT') {
main.qualimatCarrierIri = null
}
}
/**
* Payload du POST principal (groupe `carrier:write:main`). `name` et
* `certificationType` sont omis s'ils sont vides afin que la 422 porte la
* violation métier (NotBlank sur le nom, « certification obligatoire » sur la
* certification) sur le champ plutôt qu'une erreur de type.
*/
function buildMainPayload(): Record<string, unknown> {
// Cas LIOT (RG-4.01) : seul `liotPlates` est pertinent ; les autres champs
// sont masqués côté front et non envoyés (le back stocke ce qu'il reçoit).
if (isLiot.value) {
const payload: Record<string, unknown> = { name: main.name, isChartered: false }
if (main.liotPlates.trim()) {
payload.liotPlates = main.liotPlates
}
return payload
}
const payload: Record<string, unknown> = { isChartered: main.isChartered }
if (main.name.trim()) {
payload.name = main.name
}
if (main.certificationType) {
payload.certificationType = main.certificationType
}
// FK QUALIMAT (saisie assistée, § 2.5) envoyée si une ligne a été intégrée.
// En MODIFICATION, on délie explicitement (null) si plus de lien — ex: la
// certification a changé de QUALIMAT vers autre chose (ERP-172).
if (main.qualimatCarrierIri) {
payload.qualimatCarrier = main.qualimatCarrierIri
}
else if (editMode.value) {
payload.qualimatCarrier = null
}
// RG-4.02 : décharge envoyée seulement en certification AUTRE ; omise quand
// absente pour que la 422 « obligatoire » porte sur le champ.
if (main.certificationType === 'AUTRE' && main.dischargeDocumentIri) {
payload.dischargeDocument = main.dischargeDocumentIri
}
// RG-4.03 : indexation / contenant / volume envoyés seulement si affrété ;
// omis quand vides pour déclencher la 422 NotBlank inline sur le champ.
if (main.isChartered) {
if (main.indexationRate.trim()) {
payload.indexationRate = main.indexationRate
}
if (main.containerType) {
payload.containerType = main.containerType
}
if (main.volumeM3.trim()) {
payload.volumeM3 = main.volumeM3
}
}
return payload
}
/**
* POST /carriers (groupe `carrier:write:main`). Pré-check front, puis création.
* Au succès : verrouille le bloc principal, déverrouille l'onglet Adresses et
* bascule dessus (l'onglet Qualimat, saisie assistée, était déjà accessible).
* Retourne true si créé, false sinon.
*/
async function submitMain(): Promise<boolean> {
if (mainSubmitting.value) return false
mainErrors.clearErrors()
if (!validateMainFront()) return false
mainSubmitting.value = true
try {
// Upload différé de la décharge : envoyé seulement maintenant (au Valider).
if (!(await resolveDischargeUpload())) return false
const created = await api.post<CarrierMainResponse>('/carriers', buildMainPayload(), {
headers: { Accept: 'application/ld+json' },
toast: false,
})
carrierId.value = created.id
// Réaffiche les valeurs normalisées renvoyées par le serveur (nom en
// UPPERCASE — RG-4.13 ; certification éventuellement forcée).
main.name = created.name ?? main.name
main.certificationType = created.certificationType ?? main.certificationType
mainLocked.value = true
// Déverrouille l'onglet suivant (Adresses, index 1) et bascule dessus.
unlockedIndex.value = Math.max(unlockedIndex.value, 1)
activeTab.value = tabKeys.value[1] ?? CARRIER_TAB_KEYS[1]
toast.success({ title: t('transport.carriers.toast.createSuccess') })
return true
}
catch (error) {
// 409 = doublon de nom (RG-4.12) → erreur inline dédiée + toast ;
// 422 → mapping inline par champ ; autre → toast de fallback (ERP-101).
const status = (error as { response?: { status?: number } })?.response?.status
if (status === 409) {
const message = t('transport.carriers.form.duplicateName')
mainErrors.setError('name', message)
toast.error({ title: t('transport.carriers.toast.error'), message })
}
else {
mainErrors.handleApiError(error, { fallbackMessage: t('transport.carriers.toast.error') })
}
return false
}
finally {
mainSubmitting.value = false
}
}
/**
* MODIFICATION du formulaire principal (ERP-170) : PATCH /api/carriers/{id} sur le
* groupe carrier:write:main (PAS de re-POST). Pré-check front + 409 doublon / 422
* inline comme `submitMain`. Ne verrouille rien et ne bascule pas d'onglet (édition
* = navigation libre). Retourne true si le PATCH a réussi.
*/
async function updateMain(): Promise<boolean> {
if (carrierId.value === null || mainSubmitting.value) return false
mainErrors.clearErrors()
if (!validateMainFront()) return false
mainSubmitting.value = true
try {
// Upload différé de la décharge : envoyé seulement maintenant (à l'Enregistrer).
if (!(await resolveDischargeUpload())) return false
const updated = await api.patch<CarrierMainResponse>(
`/carriers/${carrierId.value}`,
buildMainPayload(),
{ toast: false },
)
main.name = updated.name ?? main.name
main.certificationType = updated.certificationType ?? main.certificationType
return true
}
catch (error) {
const status = (error as { response?: { status?: number } })?.response?.status
if (status === 409) {
const message = t('transport.carriers.form.duplicateName')
mainErrors.setError('name', message)
toast.error({ title: t('transport.carriers.toast.error'), message })
}
else {
mainErrors.handleApiError(error, { fallbackMessage: t('transport.carriers.toast.error') })
}
return false
}
finally {
mainSubmitting.value = false
}
}
/**
* Pré-remplit le formulaire depuis le détail `GET /api/carriers/{id}` (écran
* Modification) : peuple carrierId + principal + adresses / contacts / prix via les
* mappers, passe en `editMode` (navigation libre, tous onglets accessibles, bloc
* principal éditable). Au moins un bloc Adresse / Contact affiché même sans donnée.
*/
function prefillFrom(detail: CarrierDetail): void {
carrierId.value = detail.id
editMode.value = true
mainLocked.value = false
unlockedIndex.value = tabKeys.value.length - 1
Object.assign(main, mapMainToDraft(detail))
// Adresse UNIQUE (ERP-172) : objet `address` (ou null) au lieu d'une liste.
address.value = detail.address ? mapAddressToDraft(detail.address) : emptyCarrierAddress()
const mappedContacts = (detail.contacts ?? []).map(mapContactToDraft)
contacts.value = mappedContacts.length > 0 ? mappedContacts : [emptyCarrierContact()]
prices.value = (detail.prices ?? []).map(mapPriceToDraft)
}
/**
* PATCH partiel du transporteur (mode strict : un seul groupe de sérialisation
* par appel spec-back § 2.9). Servira les onglets à champs scalaires des
* tickets suivants. No-op tant que le transporteur n'existe pas.
*/
async function patchCarrier(payload: Record<string, unknown>): Promise<void> {
if (carrierId.value === null) return
await api.patch(`/carriers/${carrierId.value}`, payload, { toast: false })
}
/** Remontée d'erreur de suppression immédiate d'une sous-ressource (toast dédié). */
function notifyRemovalError(error: unknown): void {
toast.error({
title: t('transport.carriers.toast.error'),
message: extractApiErrorMessage((error as { data?: unknown })?.data) || t('transport.carriers.toast.error'),
})
}
/**
* 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
* cible, tente chaque ligne via `saveRow`, mappe les 422 inline ou délègue le
* fallback à `onUnmappedError`. `shouldSkip` ignore les amorces vides. Retourne
* true si au moins un bloc a échoué. Miroir de `useProviderForm.submitRows`.
*/
async function submitRows<T>(
rows: T[],
target: Ref<Record<string, string>[]>,
saveRow: (row: T, index: number) => Promise<void>,
onUnmappedError: (error: unknown, index: number) => void,
shouldSkip?: (row: T, index: number) => boolean,
): Promise<boolean> {
target.value = []
let hasError = false
for (let index = 0; index < rows.length; index++) {
const row = rows[index] as T
if (shouldSkip?.(row, index)) {
continue
}
try {
await saveRow(row, index)
}
catch (error) {
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
if (Object.keys(mapped).length > 0) {
target.value[index] = mapped
}
else {
onUnmappedError(error, index)
}
hasError = true
}
}
return hasError
}
// ── Onglet Adresse (ERP-167 / ERP-172 : adresse UNIQUE) ───────────────────
// Un transporteur a au plus UNE adresse (décision métier ERP-172) : un seul
// bloc, pas d'ajout/suppression. `id` null tant que l'adresse n'est pas créée.
const address = ref<CarrierAddressFormDraft>(emptyCarrierAddress())
// Erreurs 422 du bloc adresse (mapping inline par champ, ERP-101).
const addressErrors = ref<Record<string, string>>({})
/**
* Valide l'onglet Adresse : POST sur /carriers/{id}/address (création) ou PATCH
* sur /carrier_addresses/{id} (mise à jour), groupe carrier:write:addresses.
* Erreurs 422 mappées inline par champ (RG-4.05 « obligatoire si affrété »
* re-validée back). Retourne true si l'onglet a é validé.
*/
async function submitAddress(onError: (error: unknown) => void): Promise<boolean> {
if (carrierId.value === null || tabSubmitting.value) {
return false
}
tabSubmitting.value = true
addressErrors.value = {}
try {
const body = buildCarrierAddressPayload(address.value)
if (address.value.id === null) {
const created = await api.post<{ id: number }>(
`/carriers/${carrierId.value}/address`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
address.value.id = created.id
}
else {
await api.patch(`/carrier_addresses/${address.value.id}`, body, { toast: false })
}
completeTab('addresses')
return true
}
catch (error) {
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
if (Object.keys(mapped).length > 0) {
addressErrors.value = mapped
}
else {
onError(error)
}
return false
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Contacts (ERP-168) ─────────────────────────────────────────────
const contacts = ref<CarrierContactFormDraft[]>([emptyCarrierContact()])
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
const contactErrors = ref<Record<string, string>[]>([])
// « + Nouveau contact » désactivé tant que le DERNIER bloc n'est pas « nommé »
// (prénom OU nom) — aligné sur M1/M2/M3 (fonction / téléphone / email seuls ne
// suffisent pas à ajouter un nouveau bloc).
const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
return last !== undefined && isCarrierContactNamed(last)
})
function addContact(): void {
if (canAddContact.value) {
contacts.value.push(emptyCarrierContact())
}
}
/** Suppression immédiate d'un contact existant (DELETE /carrier_contacts/{id}). */
async function removeContact(index: number): Promise<void> {
await removeCollectionRow({
rows: contacts.value,
errors: contactErrors.value,
index,
endpoint: '/carrier_contacts',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyCarrierContact,
onError: notifyRemovalError,
})
}
/**
* Valide l'onglet Contacts : POST des nouveaux contacts sur
* /carriers/{id}/contacts, PATCH des existants sur /carrier_contacts/{id}
* (groupe carrier:write:contacts). RG-4.08 ( 1 champ rempli, max 2 téléphones)
* re-validée back 422 par ligne. Si l'onglet ne contient QUE des amorces
* vides, on soumet la 1re pour déclencher la 422 RG-4.08 inline plutôt que de
* finaliser un onglet vide. Retourne true si l'onglet a é validé.
*/
async function submitContacts(onError: (error: unknown) => void): Promise<boolean> {
if (carrierId.value === null || tabSubmitting.value) {
return false
}
tabSubmitting.value = true
try {
const hasSubmittable = contacts.value.some(c => c.id !== null || !isCarrierContactBlank(c))
const hasError = await submitRows(
contacts.value,
contactErrors,
async (contact) => {
const body = buildCarrierContactPayload(contact)
if (contact.id === null) {
const created = await api.post<{ id: number }>(
`/carriers/${carrierId.value}/contacts`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
contact.id = created.id
}
else {
await api.patch(`/carrier_contacts/${contact.id}`, body, { toast: false })
}
},
onError,
// Amorce vide neuve ignorée s'il reste un autre bloc soumettable ;
// sinon on la soumet pour déclencher la 422 RG-4.08 (sur firstName).
contact => hasSubmittable && contact.id === null && isCarrierContactBlank(contact),
)
if (hasError) {
return false
}
completeTab('contacts')
return true
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Prix (ERP-169) ─────────────────────────────────────────────────
// Un bloc présent par défaut (sens CLIENT pré-sélectionné). L'utilisateur ajoute
// les suivants via « + Nouveau prix ».
const prices = ref<CarrierPriceFormDraft[]>([emptyCarrierPrice()])
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
const priceErrors = ref<Record<string, string>[]>([])
// « + Nouveau prix » : autorisé si la liste est vide, sinon le dernier bloc doit
// être valide (branche complète + prix — RG-4.09→4.11, pré-check léger).
const canAddPrice = computed(() => {
const last = prices.value[prices.value.length - 1]
return last === undefined || isCarrierPriceValid(last)
})
function addPrice(): void {
if (canAddPrice.value) {
prices.value.push(emptyCarrierPrice())
}
}
/**
* Pré-validation FRONT d'un bloc prix (ERP-101) : renvoie les erreurs inline par
* champ obligatoire (sens + branche active + communs). Nécessaire car côté back
* l'Assert\NotBlank sur les scalaires (price/priceState...) court-circuite la
* validation de branche du CarrierPriceProcessor : le 422 ne porterait jamais
* client/supplier/adresses en même temps. Messages alignés sur le back.
*/
function validatePriceRow(price: CarrierPriceFormDraft): Record<string, string> {
const errs: Record<string, string> = {}
const msg = (key: string): string => t(`transport.carriers.form.price.errors.${key}`)
if (!price.direction) {
errs.direction = msg('direction')
}
if (price.direction === 'CLIENT') {
if (!price.clientIri) errs.client = msg('client')
if (!price.clientDeliveryAddressIri) errs.clientDeliveryAddress = msg('clientDeliveryAddress')
if (!price.departureSiteIri) errs.departureSite = msg('departureSite')
}
if (price.direction === 'FOURNISSEUR') {
if (!price.supplierIri) errs.supplier = msg('supplier')
if (!price.supplierSupplyAddressIri) errs.supplierSupplyAddress = msg('supplierSupplyAddress')
if (!price.deliverySiteIri) errs.deliverySite = msg('deliverySite')
}
if (!price.containerType) errs.containerType = msg('containerType')
if (!price.pricingUnit) errs.pricingUnit = msg('pricingUnit')
if (!price.price || price.price.trim() === '') errs.price = msg('price')
if (!price.priceState) errs.priceState = msg('priceState')
return errs
}
/** Suppression immédiate d'un prix existant (DELETE /carrier_prices/{id}). */
async function removePrice(index: number): Promise<void> {
await removeCollectionRow({
rows: prices.value,
errors: priceErrors.value,
index,
endpoint: '/carrier_prices',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyCarrierPrice,
onError: notifyRemovalError,
})
}
/**
* Valide l'onglet Prix : POST des nouveaux prix sur /carriers/{id}/prices, PATCH
* des existants sur /carrier_prices/{id} (groupe carrier:write:prices). La
* cohérence de branche CLIENT/FOURNISSEUR (RG-4.094.11) est re-validée back
* 422 par ligne. Onglet Prix optionnel : une liste vide finalise sans appel.
* Retourne true si l'onglet a é validé (création terminée).
*/
async function submitPrices(onError: (error: unknown) => void): Promise<boolean> {
if (carrierId.value === null || tabSubmitting.value) {
return false
}
// Pré-check front : affiche toutes les obligations sous leur champ d'un coup
// (le back ne peut pas tout renvoyer en une passe — cf. validatePriceRow).
const frontErrors = prices.value.map(validatePriceRow)
if (frontErrors.some(errs => Object.keys(errs).length > 0)) {
priceErrors.value = frontErrors
return false
}
tabSubmitting.value = true
try {
const hasError = await submitRows(
prices.value,
priceErrors,
async (price) => {
const body = buildCarrierPricePayload(price)
if (price.id === null) {
const created = await api.post<{ id: number }>(
`/carriers/${carrierId.value}/prices`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
price.id = created.id
}
else {
await api.patch(`/carrier_prices/${price.id}`, body, { toast: false })
}
},
onError,
)
if (hasError) {
return false
}
completeTab('prices')
return true
}
finally {
tabSubmitting.value = false
}
}
/**
* Intègre une ligne QUALIMAT sélectionnée dans l'onglet Qualimat (RG-4.01 /
* § 2.5) : copie le nom, force la certification à « QUALIMAT » (lecture seule),
* pose la FK `qualimatCarrier` (IRI) et copie l'adresse (pour l'onglet Adresses).
* Si le transporteur existe déjà (post-POST, cas nominal de l'onglet), persiste
* d'abord la copie via un PATCH partiel `carrier:write:main` : la copie locale
* (nom, certification figée « QUALIMAT », FK, adresse) n'est appliquée qu'en cas
* de succès, pour ne pas laisser l'UI dans un état QUALIMAT non sauvegardé si le
* PATCH échoue. Retourne true si l'intégration a abouti.
*/
async function applyQualimatSelection(row: QualimatCarrierRow): Promise<boolean> {
// Transporteur déjà créé : on persiste avant de refléter localement.
if (carrierId.value !== null) {
try {
await patchCarrier({
qualimatCarrier: row['@id'],
name: row.name,
certificationType: 'QUALIMAT',
})
}
catch (error) {
mainErrors.handleApiError(error, { fallbackMessage: t('transport.carriers.toast.error') })
return false
}
}
main.name = row.name ?? ''
main.certificationType = 'QUALIMAT'
main.qualimatCarrierIri = row['@id']
qualimatAddress.value = {
country: 'France',
postalCode: row.postalCode ?? '',
city: row.city ?? '',
street: row.address ?? '',
}
// RG-4.05 : à la CRÉATION, pré-remplit l'adresse (unique) par copie du
// référentiel QUALIMAT (champs éditables, la FK QUALIMAT survit — § 2.5).
// En MODIFICATION (ERP-172) : on NE TOUCHE PAS l'adresse déjà saisie — la
// re-sélection Qualimat actualise seulement nom + certification + FK.
if (!editMode.value) {
address.value = {
id: null,
country: 'France',
postalCode: row.postalCode || null,
city: row.city || null,
street: row.address || null,
streetComplement: null,
}
}
return true
}
/**
* Marque un onglet validé (passe en lecture seule), déverrouille et avance à
* l'onglet suivant. Retourne true si c'était le dernier onglet du flux (création
* terminée), false sinon.
*/
function completeTab(key: string): boolean {
// En modification : navigation libre, l'onglet reste éditable après validation.
if (editMode.value) {
return false
}
validated[key] = true
const index = tabIndex(key)
const next = tabKeys.value[index + 1]
if (next === undefined) {
return true
}
unlockedIndex.value = Math.max(unlockedIndex.value, index + 1)
activeTab.value = next
return false
}
return {
// état
main,
qualimatAddress,
carrierId,
mainLocked,
mainSubmitting,
tabSubmitting,
mainErrors,
dischargeUploading,
// affichage conditionnel
isLiot,
isQualimat,
showCertification,
certificationReadonly,
showCharteredFields,
showDischarge,
// onglets
tabKeys,
activeTab,
unlockedIndex,
validated,
editMode,
isValidated,
// adresse (unique)
address,
addressErrors,
submitAddress,
// contacts
contacts,
contactErrors,
canAddContact,
addContact,
removeContact,
submitContacts,
// prix
prices,
priceErrors,
canAddPrice,
addPrice,
removePrice,
submitPrices,
// actions
setCertification,
selectDischarge,
clearDischarge,
validateMainFront,
buildMainPayload,
submitMain,
updateMain,
prefillFrom,
patchCarrier,
applyQualimatSelection,
completeTab,
submitRows,
}
}
@@ -0,0 +1,71 @@
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
/**
* Vue MINIMALE du referentiel QUALIMAT embarque (groupe `qualimat:read`) dans la
* LISTE des transporteurs. Seuls les champs consommes par le Repertoire sont
* types : `validityDate` alimente la colonne « Date de validité » (fond rouge si
* perimee RG-4.04). L'id QUALIMAT est une chaine (colonne BIGINT cote back).
*/
export interface CarrierQualimat {
id: string
name: string | null
/** Date ISO de validite de l'agrement QUALIMAT (date_immutable) — RG-4.04. */
validityDate: string | null
status: string | null
}
/**
* Vue MINIMALE d'un transporteur pour le Repertoire (datatable). Volontairement
* partielle : seuls les champs des colonnes + l'id (navigation) sont types ici.
* Le detail complet (onglets Adresses / Contacts / Prix) est hors perimetre de
* cet ecran (ERP-164, ticket #9).
*
* `certificationType` : QUALIMAT | GMP_PLUS | OVOCOM | COMPTE_PROPRE | AUTRE, ou
* `null` dans le cas LIOT (compte-propre interne sans certification RG-4.01).
* Le libelle affiche est resolu cote front (cle i18n `transport.carriers.certification.*`).
*/
export interface Carrier {
id: number
name: string | null
certificationType: string | null
/** Lien editable vers le referentiel QUALIMAT (null si transporteur non QUALIMAT). */
qualimatCarrier: CarrierQualimat | null
/** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
updatedAt: string | null
isArchived: boolean
}
/**
* Filtres du Repertoire transporteurs, branches sur les query params de
* `GET /api/carriers` (spec-back § 4.1). Pilotes par la page via `setFilters` :
* - `search` : recherche fuzzy sur le nom ;
* - `certificationType[]` : multi-valeurs (OR cote back) ;
* - `archivedOnly` : n'affiche QUE les archives (toggle « Voir les archivés »,
* aligne sur les autres repertoires M1/M2/M3).
*/
export interface CarrierFilters {
search?: string
'certificationType[]'?: string[]
archivedOnly?: boolean
}
/**
* Repertoire transporteurs (M4, ERP-164) simple enveloppe de
* `usePaginatedList<Carrier>` sur la ressource `/carriers` (regle ABSOLUE n°13 :
* pagination serveur obligatoire ; jamais de chargement integral en memoire).
* Miroir de `useSuppliersRepository` (M2) / `useProvidersRepository` (M3).
*
* Les filtres (recherche, certifications, archives) sont pilotes par la page via
* `setFilters` du composable partage la remise en page 1 est garantie. Par
* defaut AUCUN `archivedOnly` n'est envoye : le back masque alors les archives
* (§ 2.4). Cocher « Voir les archivés » envoie `archivedOnly=true` (seules les
* archives sont listees, aligne sur Client / Fournisseur / Prestataire).
*
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
* `usePaginatedList`. Aucun reset au logout a gerer.
*/
export function useCarriersRepository() {
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193).
return usePaginatedList<Carrier, CarrierFilters>({ url: '/carriers', defaultItemsPerPage: 25 })
}
@@ -0,0 +1,40 @@
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
/**
* Ligne du référentiel QUALIMAT renvoyée par la saisie assistée (groupe
* `qualimat:read`). `@id` est l'IRI conservée comme FK `carrier.qualimatCarrier`
* (RG-4.01 / § 2.5) ; `validityDate` pilote le fond rouge de la colonne « Date de
* validité » (RG-4.04).
*/
export interface QualimatCarrierRow {
'@id': string
id: string
name: string | null
siret: string | null
address: string | null
postalCode: string | null
city: string | null
validityDate: string | null
status: string | null
}
/** Filtre de la recherche QUALIMAT (branché sur le nom du transporteur). */
export interface QualimatSearchFilters {
search?: string
}
/**
* Saisie assistée QUALIMAT (M4 Transport, ERP-166 RG-4.01 / spec-back § 4.7).
*
* `GET /api/qualimat_carriers?search=` : référentiel en LECTURE SEULE, lignes
* actives uniquement (filtré côté serveur), recherche fuzzy nom + siret. Simple
* enveloppe de `usePaginatedList` (règle frontend : toute GetCollection passe par
* ce composable pagination Hydra, état 100 % local) consommée par le
* `MalioDataTable` de l'onglet Qualimat. Le filtre `search` est piloté par le nom
* saisi dans le formulaire principal (pas de champ de recherche dédié).
*
* Volontairement PAR INSTANCE (état local à l'écran d'ajout).
*/
export function useQualimatSearch() {
return usePaginatedList<QualimatCarrierRow, QualimatSearchFilters>({ url: '/qualimat_carriers' })
}
@@ -0,0 +1 @@
export default defineNuxtConfig({})
@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, ref } from 'vue'
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
// La page ne les importe pas (auto-import) : on les expose en globals pour le
// runtime de test (happy-dom). Meme philosophie que les specs M1/M2/M3.
const mockPush = vi.hoisted(() => vi.fn())
const mockApiGet = vi.hoisted(() => vi.fn())
const mockCan = vi.hoisted(() => vi.fn())
const mockSetFilters = vi.hoisted(() => vi.fn())
const mockFetch = vi.hoisted(() => vi.fn())
const mockToastError = vi.hoisted(() => vi.fn())
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useHead', () => undefined)
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
// Le repository est lui aussi un auto-import : on controle items + setFilters.
vi.stubGlobal('useCarriersRepository', () => ({
items: ref([
{
id: 7,
name: 'TRANSPORTS ACME',
certificationType: 'QUALIMAT',
qualimatCarrier: { id: '42', name: 'TRANSPORTS ACME', validityDate: '2027-01-15', status: 'VALIDE' },
updatedAt: '2026-01-15T10:00:00+00:00',
isArchived: false,
},
]),
totalItems: ref(1),
currentPage: ref(1),
itemsPerPage: ref(10),
itemsPerPageOptions: ref([10, 25, 50]),
fetch: mockFetch,
goToPage: vi.fn(),
setItemsPerPage: vi.fn(),
setFilters: mockSetFilters,
}))
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse).
globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake')
globalThis.URL.revokeObjectURL = vi.fn()
// Import APRES les stubs (la page resout les auto-imports au top-level du module).
const CarriersIndex = (await import('../carriers/index.vue')).default
// ── Stubs de composants ──────────────────────────────────────────────────────
const ButtonStub = defineComponent({
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
},
})
const DataTableStub = defineComponent({
props: { items: { type: Array, default: () => [] } },
emits: ['row-click', 'update:page', 'update:per-page'],
setup(props, { emit }) {
return () => h('div', { 'data-testid': 'datatable' },
(props.items as Array<{ id: number }>).map(it =>
h('tr', { 'data-row-id': it.id, onClick: () => emit('row-click', it) }),
),
)
},
})
const DrawerStub = defineComponent({
props: { modelValue: { type: Boolean, default: false } },
setup(_, { slots }) {
return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()])
},
})
const SlotStub = defineComponent({ setup(_, { slots }) { return () => h('div', {}, slots.default?.()) } })
const PageHeaderStub = defineComponent({
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
})
const CheckboxStub = defineComponent({
props: { id: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
emits: ['update:model-value'],
setup(props, { emit }) {
return () => h('input', {
'type': 'checkbox',
'data-id': props.id,
'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLInputElement).checked),
})
},
})
const InputTextStub = defineComponent({ setup() { return () => h('input') } })
function mountPage() {
return mount(CarriersIndex, {
global: {
stubs: {
PageHeader: PageHeaderStub,
MalioButton: ButtonStub,
MalioDataTable: DataTableStub,
MalioDrawer: DrawerStub,
MalioAccordion: SlotStub,
MalioAccordionItem: SlotStub,
MalioInputText: InputTextStub,
MalioCheckbox: CheckboxStub,
},
},
})
}
describe('Répertoire transporteurs (page /carriers)', () => {
beforeEach(() => {
mockPush.mockReset()
mockApiGet.mockReset().mockResolvedValue({ member: [] })
mockCan.mockReset().mockReturnValue(true)
mockSetFilters.mockReset()
mockFetch.mockReset()
mockToastError.mockReset()
})
it('charge la liste au montage', async () => {
mountPage()
await flushPromises()
expect(mockFetch).toHaveBeenCalled()
})
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
mockCan.mockImplementation((perm: string) => perm === 'transport.carriers.manage')
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[data-label="transport.carriers.add"]').exists()).toBe(true)
})
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
mockCan.mockImplementation((perm: string) => perm === 'transport.carriers.view')
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[data-label="transport.carriers.add"]').exists()).toBe(false)
})
it('navigue vers la consultation au clic sur une ligne', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('tr[data-row-id="7"]').trigger('click')
expect(mockPush).toHaveBeenCalledWith('/carriers/7')
})
it('appelle l\'export XLSX sur /carriers/export.xlsx en blob', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('[data-label="transport.carriers.export"]').trigger('click')
await flushPromises()
expect(mockApiGet).toHaveBeenCalledWith(
'/carriers/export.xlsx',
expect.any(Object),
expect.objectContaining({ responseType: 'blob', toast: false }),
)
})
it('repercute le filtre « Voir les archivés » dans setFilters sans toucher l\'URL', async () => {
const wrapper = mountPage()
await flushPromises()
// Coche « Voir les archivés » puis applique les filtres.
await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith(
{ archivedOnly: true },
{ replace: true },
)
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
expect(mockPush).not.toHaveBeenCalled()
})
it('repercute les certifications cochees dans setFilters (filtre multi)', async () => {
const wrapper = mountPage()
await flushPromises()
// Coche deux certifications via les cases a cocher (pattern repertoire clients).
await wrapper.find('input[data-id="filter-certification-QUALIMAT"]').setValue(true)
await wrapper.find('input[data-id="filter-certification-AUTRE"]').setValue(true)
await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith(
{ 'certificationType[]': ['QUALIMAT', 'AUTRE'] },
{ replace: true },
)
})
it('badge filtres actifs + Réinitialiser vide l\'etat applique', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click')
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
expect(wrapper.find('[data-label="transport.carriers.filters.title (1)"]').exists()).toBe(true)
// Réinitialiser → query propre (setFilters avec objet vide).
await wrapper.find('[data-label="transport.carriers.filters.reset"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true })
})
})
@@ -0,0 +1,442 @@
<template>
<div>
<!-- En-tête : retour consultation + titre. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('transport.carriers.edit.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('transport.carriers.edit.title') }}</h1>
</div>
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('transport.carriers.edit.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('transport.carriers.edit.notFound') }}</p>
<template v-else>
<!-- Formulaire principal (éditable, PATCH partiel) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
:model-value="main.name"
@update:model-value="(v: string) => main.name = sanitizeFreeText(v)"
:label="t('transport.carriers.form.main.name')"
:required="true"
:error="mainErrors.errors.name"
/>
<!-- Cas LIOT : le champ immatriculations occupe les colonnes restantes
de la ligne (3 en xl, 2 sinon). Wrapper pour le col-span car
MalioInputText (inheritAttrs:false) renvoie `class` sur l'input. -->
<div v-if="isLiot" class="col-span-2 xl:col-span-3">
<MalioInputText
:key="liotPlatesKey"
:model-value="main.liotPlates"
@update:model-value="onLiotPlatesInput"
: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">
<MalioSelect
:model-value="main.certificationType"
:options="certificationOptions"
:label="t('transport.carriers.form.main.certificationType')"
empty-option-label=""
:required="true"
:disabled="certificationReadonly"
:error="mainErrors.errors.certificationType"
@update:model-value="(v: string | number | null) => setCertification(v === null ? null : String(v))"
/>
<MalioInputUpload
v-if="showDischarge"
:model-value="dischargeFileName"
:label="t('transport.carriers.form.main.discharge')"
accept="application/pdf,image/*"
:required="true"
:disabled="dischargeUploading"
:clearable="true"
:error="mainErrors.errors.dischargeDocument"
@update:model-value="(v: string) => dischargeFileName = v"
@file-selected="selectDischarge"
@clear="onClearDischarge"
/>
<div v-else class="hidden xl:block"></div>
<div class="flex h-12 items-center">
<MalioCheckbox
id="carrier-edit-chartered"
:label="t('transport.carriers.form.main.isChartered')"
:model-value="main.isChartered"
:reserve-message-space="false"
@update:model-value="(val: boolean) => main.isChartered = val"
/>
</div>
<template v-if="showCharteredFields">
<MalioInputAmount
:key="indexationKey"
:model-value="main.indexationRate"
:label="t('transport.carriers.form.main.indexationRate')"
icon-name="mdi:percent"
icon-position="right"
:required="true"
:error="mainErrors.errors.indexationRate"
@update:model-value="onIndexationInput"
/>
<!-- Contenant : Benne / Fond mouvant en radios, centrés (h-12) comme
à l'onglet Prix (Benne par défaut). -->
<div>
<div class="flex h-12 items-center gap-4">
<MalioRadioButton
:model-value="main.containerType"
name="carrier-main-container"
value="BENNE"
:label="t('transport.carriers.containerType.BENNE')"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
/>
<MalioRadioButton
:model-value="main.containerType"
name="carrier-main-container"
value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
/>
</div>
<p v-if="mainErrors.errors.containerType" class="ml-[2px] text-xs text-m-danger">{{ mainErrors.errors.containerType }}</p>
</div>
<MalioInputText
:model-value="main.volumeM3"
:label="t('transport.carriers.form.main.volumeM3')"
:required="true"
:error="mainErrors.errors.volumeM3"
@update:model-value="(v: string) => main.volumeM3 = sanitizeDecimal(v)"
/>
</template>
</template>
</div>
<div class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('transport.carriers.edit.save')"
:disabled="mainSubmitting"
@click="onUpdateMain"
/>
</div>
<!-- Onglets éditables (navigation libre, PATCH partiel par onglet) -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Qualimat : actualiser nom + certification depuis le référentiel (ERP-172). -->
<template #qualimat>
<CarrierQualimatTab
:search-name="main.name"
:selected-iri="main.qualimatCarrierIri"
@integrate="onIntegrateQualimat"
/>
</template>
<template #addresses>
<div class="mt-12 flex flex-col gap-6">
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
<CarrierAddressBlock
:model-value="address"
:country-options="countryOptions"
:errors="addressErrors"
@update:model-value="(v) => address = v"
@degraded="onAddressDegraded"
/>
<div class="flex justify-center gap-6">
<MalioButton variant="primary" :label="t('transport.carriers.edit.save')" :disabled="tabSubmitting" @click="onSubmitAddresses" />
</div>
</div>
</template>
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<CarrierContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:removable="isRowRemovable(contacts, index)"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<div class="flex justify-center gap-6">
<MalioButton variant="secondary" icon-name="mdi:add-bold" icon-position="left" :label="t('transport.carriers.form.contact.add')" :disabled="!canAddContact" @click="addContact" />
<MalioButton variant="primary" :label="t('transport.carriers.edit.save')" :disabled="tabSubmitting" @click="onSubmitContacts" />
</div>
</div>
</template>
<template #prices>
<div class="mt-12 flex flex-col gap-6">
<CarrierPriceBlock
v-for="(price, index) in prices"
:key="index"
:model-value="price"
:client-options="clientOptions"
:supplier-options="supplierOptions"
:site-options="siteOptions"
removable
:errors="priceErrors[index]"
@update:model-value="(v) => prices[index] = v"
@remove="askRemovePrice(index)"
/>
<div class="flex justify-center gap-6">
<MalioButton variant="secondary" icon-name="mdi:add-bold" icon-position="left" :label="t('transport.carriers.form.price.add')" :disabled="!canAddPrice" @click="addPrice" />
<MalioButton variant="primary" :label="t('transport.carriers.edit.save')" :disabled="tabSubmitting" @click="onSubmitPrices" />
</div>
</div>
</template>
</MalioTabList>
</template>
<!-- Modal de confirmation de suppression de bloc. -->
<MalioModal v-model="deleteConfirm.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.confirmDelete.title') }}</h2>
</template>
<p>{{ t('transport.carriers.form.confirmDelete.message') }}</p>
<template #footer>
<MalioButton variant="secondary" button-class="flex-1" :label="t('transport.carriers.form.confirmDelete.cancel')" @click="deleteConfirm.open = false" />
<MalioButton variant="danger" button-class="flex-1" :label="t('transport.carriers.form.confirmDelete.confirm')" @click="runDeleteConfirm" />
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.vue'
import CarrierQualimatTab from '~/modules/transport/components/CarrierQualimatTab.vue'
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
import { useCarrier } from '~/modules/transport/composables/useCarrier'
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
import { clampPercent, sanitizeDecimal, sanitizeLiotPlates } from '~/modules/transport/utils/forms/numberInput'
import { sanitizeFreeText } from '~/shared/utils/textSanitize'
interface SelectOption {
value: string
label: string
}
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const api = useApi()
const { can } = usePermissions()
const carrierId = route.params.id as string
useHead({ title: t('transport.carriers.edit.title') })
// Gating route : l'édition est réservée à `manage` ; sinon retour consultation.
if (!can('transport.carriers.manage')) {
await navigateTo(`/carriers/${carrierId}`)
}
const { carrier, loading, error, load } = useCarrier(carrierId)
const {
main,
mainSubmitting,
tabSubmitting,
mainErrors,
dischargeUploading,
selectDischarge,
clearDischarge,
setCertification,
isLiot,
certificationReadonly,
showCharteredFields,
showDischarge,
applyQualimatSelection,
address,
addressErrors,
submitAddress,
contacts,
contactErrors,
canAddContact,
addContact,
removeContact,
submitContacts,
prices,
priceErrors,
canAddPrice,
addPrice,
removePrice,
submitPrices,
updateMain,
prefillFrom,
} = useCarrierForm()
const SELECTABLE_CERTIFICATIONS = ['GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'] as const
const certificationOptions = computed<SelectOption[]>(() => {
const codes: string[] = [...SELECTABLE_CERTIFICATIONS]
if (main.certificationType === 'QUALIMAT') codes.unshift('QUALIMAT')
return codes.map(code => ({ value: code, label: t(`transport.carriers.certification.${code}`) }))
})
const TAB_ICONS: Record<string, string> = {
qualimat: 'mdi:truck-fast-outline',
addresses: 'mdi:map-marker-outline',
contacts: 'mdi:account-box-plus-outline',
prices: 'mdi:payment',
}
const activeTab = ref('addresses')
// Onglet Qualimat disponible en modif (ERP-172) : « actualiser » nom + certification.
const tabs = computed(() => ['qualimat', 'addresses', 'contacts', 'prices'].map(key => ({
key,
label: t(`transport.carriers.tab.${key}`),
icon: TAB_ICONS[key],
})))
// Référentiels (pays + clients / fournisseurs / sites pour l'onglet Prix)
const countryOptions = ref<SelectOption[]>([{ value: 'France', label: 'France' }])
const clientOptions = ref<SelectOption[]>([])
const supplierOptions = ref<SelectOption[]>([])
const siteOptions = ref<SelectOption[]>([])
async function loadOptions(url: string, target: typeof clientOptions, labelOf: (m: Record<string, unknown>) => string): Promise<void> {
try {
const data = await api.get<{ member?: Record<string, unknown>[] }>(url, { pagination: 'false' }, { headers: { Accept: 'application/ld+json' }, toast: false })
target.value = (data.member ?? []).map(m => ({ value: String(m['@id']), label: labelOf(m) }))
}
catch {
target.value = []
}
}
async function loadCountries(): Promise<void> {
try {
const data = await api.get<{ member?: { name: string }[] }>('/countries', { pagination: 'false' }, { headers: { Accept: 'application/ld+json' }, toast: false })
const list = (data.member ?? []).map(c => ({ value: c.name, label: c.name }))
countryOptions.value = list.some(c => c.value === 'France') ? list : [{ value: 'France', label: 'France' }, ...list]
}
catch { /* fallback France */ }
}
// Chargement + préremplissage
onMounted(async () => {
await load()
if (carrier.value) {
prefillFrom(carrier.value)
// Pré-affiche le nom du fichier de décharge déjà rattaché (s'il existe).
const doc = carrier.value.dischargeDocument
if (doc && typeof doc !== 'string') {
const meta = doc as Record<string, unknown>
dischargeFileName.value = String(meta.originalFilename ?? meta.name ?? '')
}
}
loadCountries().catch(() => {})
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']))
void loadOptions('/suppliers', supplierOptions, m => String(m.companyName ?? m['@id']))
void loadOptions('/sites', siteOptions, m => String(m.name ?? m['@id']))
})
function apiErrorMessage(err: unknown): string {
const data = (err as { response?: { _data?: unknown } })?.response?._data
return extractApiErrorMessage(data) || t('transport.carriers.toast.error')
}
// Nom de fichier affiché dans le champ Décharge (alimenté à la sélection ou au
// chargement d'un transporteur ayant déjà une décharge).
const dischargeFileName = ref('')
/** Vidage du champ Décharge : oublie le fichier en attente / l'IRI + le nom affiché. */
function onClearDischarge(): void {
clearDischarge()
dischargeFileName.value = ''
}
// Indexation plafonnée à 100 % : la clé force le ré-affichage du MalioInputAmount
// (contrôlé) quand le plafonnement laisse le modelValue inchangé.
const indexationKey = ref(0)
/** Saisie de l'indexation : plafonne à 100 et re-synchronise le champ si plafonné. */
function onIndexationInput(value: string): void {
const clamped = clampPercent(value)
main.indexationRate = clamped
if (clamped !== value) {
indexationKey.value += 1
}
}
// Immatriculations LIOT : la clé force le ré-affichage quand le filtrage laisse le
// modelValue inchangé (ex: caractère interdit seul tapé) sinon le DOM garderait
// le caractère parasite alors que le modèle est déjà propre.
const liotPlatesKey = ref(0)
/** Saisie des immatriculations LIOT : filtre la saisie et re-synchronise si filtré. */
function onLiotPlatesInput(value: string): void {
const clean = sanitizeLiotPlates(value)
main.liotPlates = clean
if (clean !== value) {
liotPlatesKey.value += 1
}
}
function goBack(): void {
router.push(`/carriers/${carrierId}`)
}
/** PATCH du formulaire principal (pas de re-POST). */
async function onUpdateMain(): Promise<void> {
const ok = await updateMain()
if (ok) {
toast.success({ title: t('transport.carriers.toast.updateSuccess') })
}
}
/** Intégration d'une ligne QUALIMAT (onglet Qualimat) : actualise nom + certification
* + FK via PATCH (applyQualimatSelection). L'adresse existante n'est pas touchée. */
async function onIntegrateQualimat(row: QualimatCarrierRow): Promise<void> {
const ok = await applyQualimatSelection(row)
if (ok) {
toast.success({ title: t('transport.carriers.toast.integrateSuccess') })
}
}
async function onSubmitAddresses(): Promise<void> {
const ok = await submitAddress(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
if (ok) toast.success({ title: t('transport.carriers.toast.addressSaved') })
}
async function onSubmitContacts(): Promise<void> {
const ok = await submitContacts(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
if (ok) toast.success({ title: t('transport.carriers.toast.contactSaved') })
}
async function onSubmitPrices(): Promise<void> {
const ok = await submitPrices(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
if (ok) toast.success({ title: t('transport.carriers.toast.priceSaved') })
}
// Suppression de bloc (modal de confirmation générique)
const deleteConfirm = reactive({ open: false, action: null as null | (() => void) })
function askRemoveContact(index: number): void {
deleteConfirm.action = () => { void removeContact(index) }
deleteConfirm.open = true
}
function askRemovePrice(index: number): void {
deleteConfirm.action = () => { void removePrice(index) }
deleteConfirm.open = true
}
function runDeleteConfirm(): void {
deleteConfirm.action?.()
deleteConfirm.action = null
deleteConfirm.open = false
}
function onAddressDegraded(): void {
toast.warning({ title: t('transport.carriers.toast.error'), message: t('transport.carriers.form.address.degraded') })
}
</script>
@@ -0,0 +1,539 @@
<template>
<div>
<!-- En-tête : retour répertoire + nom + actions. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('transport.carriers.consultation.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
<div class="ml-auto flex items-center gap-12">
<MalioButton
v-if="canEdit"
variant="secondary"
icon-name="mdi:pencil-outline"
icon-position="left"
:label="t('transport.carriers.action.edit')"
@click="goEdit"
/>
<MalioButton
v-if="showArchive"
variant="danger"
icon-name="mdi:archive-arrow-down-outline"
icon-position="left"
:label="t('transport.carriers.action.archive')"
@click="askToggleArchive"
/>
<MalioButton
v-if="showRestore"
variant="secondary"
icon-name="mdi:archive-arrow-up-outline"
icon-position="left"
:label="t('transport.carriers.action.restore')"
@click="askToggleArchive"
/>
</div>
</div>
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('transport.carriers.consultation.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('transport.carriers.consultation.notFound') }}</p>
<template v-else-if="carrier">
<!-- 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">
<MalioInputText :model-value="main.name" :label="t('transport.carriers.form.main.name')" disabled />
<!-- Cas LIOT : le champ immatriculations occupe les colonnes restantes
de la ligne (3 en xl, 2 sinon), comme à l'ajout / la modification. -->
<div v-if="isLiot" class="col-span-2 xl:col-span-3">
<MalioInputText
:model-value="main.liotPlates"
:label="t('transport.carriers.form.main.liotPlates')"
disabled
/>
</div>
<!-- Cas standard : certification + décharge (col 3 réservée) + affrètement (col 4). -->
<template v-if="!isLiot">
<MalioInputText
:model-value="certificationLabel"
:label="t('transport.carriers.form.main.certificationType')"
disabled
/>
<!-- Colonne 3 réservée à la décharge (si AUTRE), sinon vide (xl). -->
<MalioInputText
v-if="main.certificationType === 'AUTRE'"
:model-value="dischargeLabel"
:label="t('transport.carriers.form.main.discharge')"
disabled
/>
<div v-else class="hidden xl:block"></div>
<!-- Affréter : colonne 4, centré (h-12) comme à l'ajout. -->
<div class="flex h-12 items-center">
<MalioCheckbox
id="carrier-view-chartered"
:label="t('transport.carriers.form.main.isChartered')"
:model-value="main.isChartered"
disabled
:reserve-message-space="false"
/>
</div>
<!-- Champs d'affrètement (ligne 2) si affrété. -->
<template v-if="main.isChartered">
<MalioInputText :model-value="indexationDisplay" :label="t('transport.carriers.form.main.indexationRate')" disabled />
<!-- Contenant : radios désactivés (lecture seule), aligné sur l'ajout / la modif. -->
<div>
<div class="flex h-12 items-center gap-4">
<MalioRadioButton
:model-value="main.containerType"
name="carrier-view-container"
value="BENNE"
:label="t('transport.carriers.containerType.BENNE')"
disabled
group-class="mt-0"
/>
<MalioRadioButton
:model-value="main.containerType"
name="carrier-view-container"
value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
disabled
group-class="mt-0"
/>
</div>
</div>
<MalioInputText :model-value="main.volumeM3" :label="t('transport.carriers.form.main.volumeM3')" disabled />
</template>
</template>
</div>
<!-- Onglets (Adresses · Contacts · Prix) ouvre sur Adresses -->
<!-- ERP-193 : barre masquee s'il ne reste aucun onglet non vide. -->
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<template #addresses>
<div class="mt-12 flex flex-col gap-6">
<!-- Adresse UNIQUE (ERP-172). -->
<CarrierAddressBlock
:model-value="address"
:country-options="countryOptionsFor(address.country)"
disabled
/>
</div>
</template>
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<CarrierContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
disabled
/>
</div>
</template>
<!-- Prix : tableau présentationnel regroupé par contenant + export. -->
<template #prices>
<div class="mt-12 flex flex-col gap-6">
<!-- Police / bordures / radius alignés sur MalioDataTable (header
16px, corps 14px). 1re colonne « Contenant » : libellé du
groupe (Fond Mouvant / Benne) fusionné en rowspan ; séparateur
é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">
<!-- Répartition (table-fixed) : « Transport » étroit (libellé
court Benne / Fond mouvant) ; Fournisseurs/Clients et
Adresse livraisons larges ; Forfait / Tonne / Indexation
/ État réduits. -->
<colgroup>
<col class="w-[120px]" />
<col class="w-[20%]" />
<col class="w-[24%]" />
<col class="w-[11%]" />
<col class="w-[9%]" />
<col class="w-[9%]" />
<col class="w-[9%]" />
<col class="w-[9%]" />
</colgroup>
<thead>
<tr>
<!-- 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-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.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.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.state') }}</th>
</tr>
</thead>
<tbody>
<template v-for="(group, gi) in priceGroups" :key="gi">
<tr
v-for="(row, i) in group.rows"
:key="`${gi}-${i}`"
>
<!-- Contenant par ligne (plus de cellule fusionnée) ; séparateur
à droite, comme l'ancienne colonne de groupe. -->
<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 class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.party }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.delivery }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.apro }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.forfait }}</td>
<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>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.state }}</td>
</tr>
</template>
<tr v-if="!hasPrices">
<td colspan="8" class="px-3 py-4 text-center text-[14px] text-m-muted">
{{ t('transport.carriers.consultation.price.empty') }}
</td>
</tr>
</tbody>
</table>
<div v-if="hasPrices" class="flex justify-center">
<MalioButton
variant="primary"
:label="t('transport.carriers.consultation.price.export')"
:disabled="exporting"
@click="exportPrices"
/>
</div>
</div>
</template>
</MalioTabList>
</template>
<!-- Modal de confirmation archivage / restauration. -->
<MalioModal v-model="confirmArchive.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ confirmArchive.title }}</h2>
</template>
<p>{{ confirmArchive.message }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('transport.carriers.form.confirmDelete.cancel')"
@click="confirmArchive.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="confirmArchive.confirmLabel"
@click="runToggleArchive"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
import { useCarrier } from '~/modules/transport/composables/useCarrier'
import {
canEditCarrier,
carrierConsultationVisibleTabs,
labelOfRelation,
mapAddressToDraft,
mapContactToDraft,
mapMainToDraft,
showArchiveAction,
showRestoreAction,
type CarrierPriceRead,
type Relation,
} from '~/modules/transport/utils/forms/carrierMappers'
import { extractApiErrorMessage } from '~/shared/utils/api'
interface SelectOption {
value: string
label: string
}
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const api = useApi()
const { can } = usePermissions()
const carrierId = route.params.id as string
const { carrier, loading, error, load, archive, restore } = useCarrier(carrierId)
const isArchived = computed(() => carrier.value?.isArchived ?? false)
const canEdit = computed(() => canEditCarrier(can))
const showArchive = computed(() => showArchiveAction(can, isArchived.value))
const showRestore = computed(() => showRestoreAction(can, isArchived.value))
const headerTitle = computed(() => carrier.value?.name || t('transport.carriers.consultation.title'))
useHead({ title: t('transport.carriers.consultation.title') })
// Bloc principal mappé (lecture seule)
const main = computed(() => mapMainToDraft(carrier.value ?? { id: 0, '@id': '' }))
const isLiot = computed(() => main.value.name.trim().toUpperCase() === 'LIOT')
const certificationLabel = computed(() => main.value.certificationType
? t(`transport.carriers.certification.${main.value.certificationType}`)
: '')
// Indexation affichée avec le « % » (comme l'icône du champ amount de l'ajout).
const indexationDisplay = computed(() => main.value.indexationRate ? `${main.value.indexationRate} %` : '')
// Décharge : nom du fichier embarqué si présent (sinon vide ; la colonne reste réservée).
const dischargeLabel = computed(() => {
const doc = carrier.value?.dischargeDocument
if (doc && typeof doc !== 'string') {
const meta = doc as Record<string, unknown>
return String(meta.originalFilename ?? meta.name ?? '')
}
return ''
})
// Onglets : Adresses · Contacts · Prix (ouvre sur Adresses, pas de Qualimat)
// ERP-193 (retour metier) : on masque tout onglet de donnees vide.
const TAB_ICONS: Record<string, string> = {
addresses: 'mdi:map-marker-outline',
contacts: 'mdi:account-box-plus-outline',
prices: 'mdi:payment',
}
const visibleTabKeys = computed(() => carrierConsultationVisibleTabs(carrier.value))
const tabs = computed(() => visibleTabKeys.value.map(key => ({
key,
label: t(`transport.carriers.tab.${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.
const activeTab = ref('')
watch(visibleTabKeys, (keys) => {
if (keys.length === 0) {
activeTab.value = ''
return
}
if (!keys.includes(activeTab.value)) {
activeTab.value = keys[0]
}
}, { immediate: true })
// Adresse UNIQUE (ERP-172) : un seul bloc en lecture seule (vide si pas d'adresse).
const address = computed(() => carrier.value?.address
? mapAddressToDraft(carrier.value.address)
: mapAddressToDraft({ id: 0, '@id': '' }))
const contacts = computed(() => {
const list = (carrier.value?.contacts ?? []).map(mapContactToDraft)
return list.length > 0 ? list : [mapContactToDraft({ id: 0, '@id': '' })]
})
/** Pays : une seule option (valeur courante), suffisant pour l'affichage readonly. */
function countryOptionsFor(country: string): SelectOption[] {
return country ? [{ value: country, label: country }] : []
}
// Tableau Prix consultation (regroupé par ADRESSE DE LIVRAISON)
// Rang d'affichage des contenants au sein d'une même adresse (Fond mouvant puis Benne).
const CONTAINER_RANK: Record<string, number> = { FOND_MOUVANT: 0, BENNE: 1 }
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
delivery: string
forfait: string
tonne: string
indexation: string
state: string
}
/** Groupe de prix d'une même adresse de livraison (lignes consécutives). */
interface PriceGroupView {
rows: PriceRowView[]
}
/** Formate un montant décimal en « 1 000,00 € » (chaîne vide si absent). */
function formatAmount(value: string | null | undefined): string {
if (!value) {
return ''
}
const n = Number(value)
if (Number.isNaN(n)) {
return ''
}
return `${n.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
}
/** Code du site = département (2 premiers chiffres du code postal, ex: 86 / 17 / 82). */
function siteCode(relation: Relation): string {
if (!relation || typeof relation === 'string') {
return ''
}
const postalCode = relation.postalCode as string | undefined
return postalCode ? postalCode.slice(0, 2) : ''
}
/**
* 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 livraisons » = l'adresse (voie) du client/fournisseur ;
* - le prix tombe dans Forfait OU Tonne selon `pricingUnit`.
*/
function toPriceRow(price: CarrierPriceRead): PriceRowView {
const isClient = price.direction === 'CLIENT'
const containerType = price.containerType ?? ''
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),
delivery: isClient ? labelOfRelation(price.clientDeliveryAddress) : labelOfRelation(price.supplierSupplyAddress),
forfait: price.pricingUnit === 'FORFAIT' ? formatAmount(price.price) : '',
tonne: price.pricingUnit === 'TONNE' ? formatAmount(price.price) : '',
// CarrierPrice n'a pas de taux d'indexation propre on affiche celui du
// transporteur (formulaire principal). À faire évoluer si un taux par prix
// est requis (gap back).
indexation: main.value.indexationRate ? `${main.value.indexationRate} %` : '',
state: price.priceState ? t(`transport.carriers.form.price.state${stateSuffix(price.priceState)}`) : '',
}
}
/** EN_COURS → EnCours, VALIDE → Valide, NON_VALIDE → NonValide (clés i18n existantes). */
function stateSuffix(state: string): string {
const map: Record<string, string> = { EN_COURS: 'EnCours', VALIDE: 'Valide', NON_VALIDE: 'NonValide' }
return map[state] ?? ''
}
// Prix regroupés par ADRESSE DE LIVRAISON : les lignes d'une même adresse sont
// consécutives (triées par contenant Fond mouvant Benne), les groupes triés
// alphabétiquement par adresse. Un séparateur épais sépare deux adresses.
const priceGroups = computed<PriceGroupView[]>(() => {
const rows = (carrier.value?.prices ?? []).map(toPriceRow)
const byDelivery = new Map<string, PriceRowView[]>()
for (const row of rows) {
const list = byDelivery.get(row.delivery)
if (list) {
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)),
}))
})
const hasPrices = computed(() => priceGroups.value.length > 0)
/**
* Bordure basse d'une cellule de données :
* - ligne interne d'un groupe d'adresse (même adresse de livraison) fine grise ;
* - dernière ligne d'un groupe NON final épaisse noire (sépare deux adresses) ;
* - dernière ligne du DERNIER groupe aucune (le cadre du tableau s'en charge,
* évite la double bordure tout en bas).
*/
function dataBorder(gi: number, i: number): string {
const group = priceGroups.value[gi]
const isLastRow = i === group.rows.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) {
return 'border-b border-b-m-muted/30'
}
return isLastGroup ? '' : 'border-b-2 border-b-black'
}
// Export XLSX des prix
const exporting = ref(false)
async function exportPrices(): Promise<void> {
if (exporting.value) return
exporting.value = true
try {
const blob = await api.get<Blob>(`/carriers/${carrierId}/prices/export.xlsx`, {}, {
responseType: 'blob',
toast: false,
} as unknown as Parameters<typeof api.get>[2])
triggerDownload(blob, `transporteur-${carrierId}-prix.xlsx`)
}
catch {
toast.error({ title: t('transport.carriers.toast.error'), message: t('transport.carriers.toast.exportError') })
}
finally {
exporting.value = false
}
}
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
// Navigation / archivage
function goBack(): void {
router.push('/carriers')
}
function goEdit(): void {
router.push(`/carriers/${carrierId}/edit`)
}
const confirmArchive = reactive({ open: false, title: '', message: '', confirmLabel: '' })
function askToggleArchive(): void {
const archiving = !isArchived.value
confirmArchive.title = archiving ? t('transport.carriers.action.archive') : t('transport.carriers.action.restore')
confirmArchive.message = archiving
? t('transport.carriers.consultation.confirmArchive.message')
: t('transport.carriers.consultation.confirmRestore.message')
confirmArchive.confirmLabel = archiving ? t('transport.carriers.action.archive') : t('transport.carriers.action.restore')
confirmArchive.open = true
}
async function runToggleArchive(): Promise<void> {
const archiving = !isArchived.value
confirmArchive.open = false
try {
await (archiving ? archive() : restore())
toast.success({
title: archiving
? t('transport.carriers.toast.archiveSuccess')
: t('transport.carriers.toast.restoreSuccess'),
})
}
catch (err) {
// Surface le message back (ex. 409 « homonyme actif » à la restauration),
// propagé exprès par useCarrier ; fallback générique sinon.
const data = (err as { response?: { _data?: unknown } })?.response?._data
toast.error({
title: t('transport.carriers.toast.error'),
message: extractApiErrorMessage(data) || undefined,
})
}
}
onMounted(load)
</script>
@@ -0,0 +1,389 @@
<template>
<div>
<PageHeader>
{{ t('transport.carriers.title') }}
<template #actions>
<!-- gap-8 = 32px d'espacement entre Filtrer et Ajouter. -->
<div class="flex items-center gap-8">
<!-- Bouton Filtrer a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
<MalioButton
v-if="canView"
variant="tertiary"
:label="filterButtonLabel"
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
@click="openFilters"
/>
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('transport.carriers.add')"
icon-name="mdi:add-bold"
icon-position="left"
@click="goToCreate"
/>
</div>
</template>
</PageHeader>
<!-- Datatable branchee sur usePaginatedList via useCarriersRepository :
pagination serveur, tri name ASC par defaut (cote back). -->
<MalioDataTable
:columns="columns"
:items="rows"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
row-clickable
:empty-message="t('transport.carriers.empty')"
@row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
>
<!-- Certification : libelle i18n (le back renvoie le code enum). -->
<template #cell-certificationType="{ item }">
{{ formatCertification(item) }}
</template>
<!-- Date de validite QUALIMAT : fond rouge si perimee (< aujourd'hui RG-4.04). -->
<template #cell-validityDate="{ item }">
<span
v-if="getValidityDate(item)"
:class="isValidityExpired(item) ? 'inline-block rounded px-2 py-0.5 bg-m-danger text-white' : ''"
>
{{ formatDateFr(getValidityDate(item)) }}
</span>
</template>
<!-- Derniere activite : date de derniere modification (updatedAt). -->
<template #cell-lastActivity="{ item }">
{{ formatDateFr(item.updatedAt as string | null) }}
</template>
</MalioDataTable>
<div class="flex justify-center mt-4">
<MalioButton
v-if="canView"
variant="primary"
:label="t('transport.carriers.export')"
:disabled="exporting"
@click="exportXlsx"
/>
</div>
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
« Voir les résultats ». Meme pattern que les repertoires M1/M2/M3.
Etat 100 % local, jamais dans l'URL (regle ABSOLUE n°6). -->
<MalioDrawer
v-model="filterDrawerOpen"
drawer-class="max-w-[450px]"
body-class="p-0"
footer-class="justify-between border-t border-black p-6"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('transport.carriers.filters.title') }}</h2>
</template>
<MalioAccordion>
<!-- Recherche : nom du transporteur (param `search`). -->
<MalioAccordionItem :title="t('transport.carriers.filters.search')" value="search">
<MalioInputText
v-model="draftSearch"
icon-name="mdi:magnify"
/>
</MalioAccordionItem>
<!-- Certification : cases a cocher (multi). Valeur = code enum.
Meme pattern que le filtre Categories du repertoire clients. -->
<MalioAccordionItem :title="t('transport.carriers.filters.certification')" value="certification">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in certificationOptions"
:id="`filter-certification-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftCertificationTypes.includes(opt.value)"
@update:model-value="(val: boolean) => toggleCertification(opt.value, val)"
/>
</div>
</MalioAccordionItem>
<!-- Statut : voir uniquement les archives (sinon actifs uniquement). -->
<MalioAccordionItem :title="t('transport.carriers.filters.status')" value="status">
<MalioCheckbox
id="filter-archived-only"
:label="t('transport.carriers.filters.archivedOnly')"
:model-value="draftArchivedOnly"
@update:model-value="(val: boolean) => draftArchivedOnly = val"
/>
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton
variant="tertiary"
:label="t('transport.carriers.filters.reset')"
button-class="w-m-btn-action"
@click="resetFilters"
/>
<MalioButton
variant="primary"
:label="t('transport.carriers.filters.apply')"
button-class="w-[170px]"
@click="applyFilters"
/>
</template>
</MalioDrawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
interface FilterOption {
value: string
label: string
}
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
useHead({ title: t('transport.carriers.title') })
// Bouton « Ajouter » reserve a `manage` (Admin + Bureau). « Exporter » et
// « Filtrer » suivent `view` (Admin / Bureau / Commerciale). Compta et Usine
// n'ont aucun acces (item sidebar masque cote back).
const canManage = computed(() => can('transport.carriers.manage'))
const canView = computed(() => can('transport.carriers.view'))
const {
items: carriers,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadCarriers,
goToPage,
setItemsPerPage,
setFilters,
} = useCarriersRepository()
// Mappe les transporteurs en objets « plats » pour MalioDataTable (items typees
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
// implicite, contrairement a l'interface Carrier. Meme pattern que M1/M2/M3.
const rows = computed(() => carriers.value.map(carrier => ({
id: carrier.id,
name: carrier.name,
certificationType: carrier.certificationType,
validityDate: carrier.qualimatCarrier?.validityDate ?? null,
updatedAt: carrier.updatedAt,
})))
const columns = [
{ key: 'name', label: t('transport.carriers.column.name') },
{ key: 'certificationType', label: t('transport.carriers.column.certification') },
{ key: 'validityDate', label: t('transport.carriers.column.validityDate') },
{ key: 'lastActivity', label: t('transport.carriers.column.lastActivity') },
]
// Codes de certification (miroir de l'enum back) + cas LIOT (null). Le libelle
// est resolu par i18n ; un code inconnu retombe sur le code brut.
const CERTIFICATION_CODES = ['QUALIMAT', 'GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'] as const
const certificationOptions = computed<FilterOption[]>(() =>
CERTIFICATION_CODES.map(code => ({
value: code,
label: t(`transport.carriers.certification.${code}`),
})),
)
/** Libelle i18n de la certification (vide en cas LIOT — certificationType null). */
function formatCertification(item: Record<string, unknown>): string {
const code = item.certificationType as string | null | undefined
if (!code) {
return ''
}
return t(`transport.carriers.certification.${code}`)
}
/** Date de validite QUALIMAT de la ligne (null si transporteur non QUALIMAT). */
function getValidityDate(item: Record<string, unknown>): string | null {
return (item.validityDate as string | null | undefined) ?? null
}
/**
* RG-4.04 : un agrement QUALIMAT est perime si sa date de validite est anterieure
* a la date du jour (comparaison jour a jour, sans l'heure).
*/
function isValidityExpired(item: Record<string, unknown>): boolean {
const value = getValidityDate(item)
if (!value) {
return false
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return false
}
const today = new Date()
today.setHours(0, 0, 0, 0)
date.setHours(0, 0, 0, 0)
return date.getTime() < today.getTime()
}
/** Format court francais JJ-MM-AAAA (spec M4). Chaine vide si date absente / invalide. */
function formatDateFr(value: string | null | undefined): string {
if (!value) {
return ''
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
return `${day}-${month}-${date.getFullYear()}`
}
/** Clic sur une ligne → ecran Consultation (route a plat /carriers/{id}). */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/carriers/${item.id}`)
}
function goToCreate(): void {
router.push('/carriers/new')
}
// Filtres (drawer)
// Deux niveaux d'etat (pattern repertoires M1/M2/M3) :
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
// uniquement au clic « Voir les résultats » / « Réinitialiser ».
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
const filterDrawerOpen = ref(false)
const draftSearch = ref('')
const draftCertificationTypes = ref<string[]>([])
const draftArchivedOnly = ref(false)
const appliedSearch = ref('')
const appliedCertificationTypes = ref<string[]>([])
const appliedArchivedOnly = ref(false)
const activeFilterCount = computed(() => {
let count = 0
if (appliedSearch.value.trim() !== '') count++
if (appliedCertificationTypes.value.length > 0) count++
if (appliedArchivedOnly.value) count++
return count
})
const filterButtonLabel = computed(() => {
const base = t('transport.carriers.filters.title')
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
})
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la reouverture
// reflete les filtres actifs.
function openFilters(): void {
draftSearch.value = appliedSearch.value
draftCertificationTypes.value = [...appliedCertificationTypes.value]
draftArchivedOnly.value = appliedArchivedOnly.value
filterDrawerOpen.value = true
}
/** Coche / decoche une certification dans le brouillon (filtre multi). */
function toggleCertification(code: string, selected: boolean): void {
draftCertificationTypes.value = selected
? [...draftCertificationTypes.value, code]
: draftCertificationTypes.value.filter(c => c !== code)
}
/**
* Construit le payload de filtres serveur a partir de l'etat applique. Cle
* `certificationType[]` pour que PHP la parse en tableau (OR cote back). Les
* filtres vides sont omis pour une query propre.
*/
function buildFilterPayload(): Record<string, string | string[] | boolean> {
const payload: Record<string, string | string[] | boolean> = {}
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
if (appliedCertificationTypes.value.length > 0) payload['certificationType[]'] = [...appliedCertificationTypes.value]
if (appliedArchivedOnly.value) payload.archivedOnly = true
return payload
}
// « Voir les résultats » : recopie brouillon applied, pousse les filtres
// (retombe en page 1 via usePaginatedList) et ferme le drawer.
function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim()
appliedCertificationTypes.value = [...draftCertificationTypes.value]
appliedArchivedOnly.value = draftArchivedOnly.value
setFilters(buildFilterPayload(), { replace: true })
filterDrawerOpen.value = false
}
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
// Le drawer reste ouvert pour montrer le formulaire vide.
function resetFilters(): void {
draftSearch.value = ''
draftCertificationTypes.value = []
draftArchivedOnly.value = false
appliedSearch.value = ''
appliedCertificationTypes.value = []
appliedArchivedOnly.value = false
setFilters({}, { replace: true })
}
// Export XLSX
// Memes filtres que la vue : l'export reflete exactement ce que l'utilisateur voit.
const exporting = ref(false)
async function exportXlsx(): Promise<void> {
if (exporting.value) {
return
}
exporting.value = true
try {
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
// contenu faute d'overload blob sur le client partage (meme pattern M2/M3).
const blob = await api.get<Blob>('/carriers/export.xlsx', buildFilterPayload(), {
responseType: 'blob',
toast: false,
} as unknown as Parameters<typeof api.get>[2])
triggerDownload(blob, 'repertoire-transporteurs.xlsx')
}
catch {
toast.error({
title: t('transport.carriers.toast.error'),
message: t('transport.carriers.toast.exportError'),
})
}
finally {
exporting.value = false
}
}
/** Declenche le telechargement d'un blob via un lien temporaire. */
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
onMounted(() => {
loadCarriers()
})
</script>
@@ -0,0 +1,616 @@
<template>
<div>
<!-- En-tete : retour vers le repertoire + titre. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('transport.carriers.form.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('transport.carriers.form.title') }}</h1>
</div>
<!-- Formulaire principal (pre-onglets)
Champs conditionnels (ERP-166) : cas LIOT (nom == LIOT) seul
« immatriculations » ; certification AUTRE champ Decharge ; Affreter
coche indexation / contenant / volume. La certification est en lecture
seule pour un transporteur QUALIMAT (saisie assistee, onglet Qualimat). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
:model-value="main.name"
@update:model-value="(v: string) => main.name = sanitizeFreeText(v)"
:label="t('transport.carriers.form.main.name')"
:required="true"
:disabled="mainLocked"
:error="mainErrors.errors.name"
/>
<!-- Cas LIOT : seul le champ immatriculations est pertinent. Il occupe
les colonnes restantes de la ligne (3 en xl, 2 sinon) le wrapper
porte le col-span car MalioInputText (inheritAttrs:false) renvoie
`class` sur l'input interne, pas sur la cellule de grille. -->
<div v-if="isLiot" class="col-span-2 xl:col-span-3">
<MalioInputText
:key="liotPlatesKey"
:model-value="main.liotPlates"
@update:model-value="onLiotPlatesInput"
:label="t('transport.carriers.form.main.liotPlates')"
:hint="t('transport.carriers.form.main.liotPlatesHint')"
:required="true"
:disabled="mainLocked"
:error="mainErrors.errors.liotPlates"
/>
</div>
<!-- Cas standard : certification + affretement + champs conditionnels. -->
<template v-if="!isLiot">
<MalioSelect
:model-value="main.certificationType"
:options="certificationOptions"
:label="t('transport.carriers.form.main.certificationType')"
empty-option-label=""
:required="true"
:disabled="certificationReadonly"
:error="mainErrors.errors.certificationType"
@update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)"
/>
<!-- Colonne 3 RÉSERVÉE à la Décharge (RG-4.02 : visible et obligatoire
si certification AUTRE). Si elle n'apparaît pas, on garde la colonne
vide (xl) pour qu'« Affréter » reste en colonne 4 de la ligne 1.
Upload DIFFÉRÉ (ERP-171) : le fichier choisi est mis en attente
et envoyé seulement à la validation du formulaire. -->
<MalioInputUpload
v-if="showDischarge"
:model-value="dischargeFileName"
:label="t('transport.carriers.form.main.discharge')"
accept="application/pdf,image/*"
:required="true"
:disabled="mainLocked || dischargeUploading"
:clearable="true"
:error="mainErrors.errors.dischargeDocument"
@update:model-value="(v: string) => dischargeFileName = v"
@file-selected="selectDischarge"
@clear="onClearDischarge"
/>
<div v-else class="hidden xl:block"></div>
<!-- « Affréter » : toujours en colonne 4 de la ligne 1 (colonne 3
réservée à la décharge ci-dessus). Wrapper h-12 + centrage vertical
pour aligner la case sur la ligne de champ des inputs/selects. -->
<div class="flex h-12 items-center">
<MalioCheckbox
id="carrier-is-chartered"
:label="t('transport.carriers.form.main.isChartered')"
:model-value="main.isChartered"
:disabled="mainLocked"
:reserve-message-space="false"
@update:model-value="(val: boolean) => main.isChartered = val"
/>
</div>
<!-- RG-4.03 : champs d'affretement (ligne 2) visibles + obligatoires si
« Affreter ». La ligne 1 étant pleine (4 colonnes), ils démarrent
naturellement en colonne 1 de la ligne 2. -->
<template v-if="showCharteredFields">
<!-- Indexation : montant en % (icône à droite), plafonné à 100. La
:key force le -affichage du champ contrôlé quand on plafonne
(sinon le modelValue inchangé n'est pas re-synchronisé par Vue). -->
<MalioInputAmount
:key="indexationKey"
:model-value="main.indexationRate"
:label="t('transport.carriers.form.main.indexationRate')"
icon-name="mdi:percent"
icon-position="right"
:required="true"
:disabled="mainLocked"
:error="mainErrors.errors.indexationRate"
@update:model-value="onIndexationInput"
/>
<!-- Contenant : Benne / Fond mouvant en radios, centrés (h-12) comme
à l'onglet Prix (Benne par défaut). -->
<div>
<div class="flex h-12 items-center gap-4">
<MalioRadioButton
:model-value="main.containerType"
name="carrier-main-container"
value="BENNE"
:label="t('transport.carriers.containerType.BENNE')"
:disabled="mainLocked"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
/>
<MalioRadioButton
:model-value="main.containerType"
name="carrier-main-container"
value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
:disabled="mainLocked"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
/>
</div>
<p v-if="mainErrors.errors.containerType" class="ml-[2px] text-xs text-m-danger">{{ mainErrors.errors.containerType }}</p>
</div>
<!-- Volume : champ texte restreint aux nombres à décimales (point). -->
<MalioInputText
:model-value="main.volumeM3"
:label="t('transport.carriers.form.main.volumeM3')"
:required="true"
:disabled="mainLocked"
:error="mainErrors.errors.volumeM3"
@update:model-value="(v: string) => main.volumeM3 = sanitizeDecimal(v)"
/>
</template>
</template>
</div>
<div v-if="!mainLocked" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('transport.carriers.form.submit')"
:disabled="mainSubmitting"
@click="onSubmitMain"
/>
</div>
<!-- Onglets a validation incrementale
Barre Qualimat · Adresses · Contacts · Prix. Onglet Qualimat = saisie
assistee (table de selection) ; Adresses / Contacts / Prix arrivent aux
tickets suivants (placeholders « A venir »). -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet Qualimat : saisie assistée (recherche par nom). Composant
mutualisé avec l'écran de modification (ERP-172). -->
<template #qualimat>
<CarrierQualimatTab
:search-name="main.name"
:selected-iri="main.qualimatCarrierIri"
@integrate="onIntegrateQualimat"
/>
</template>
<!-- Onglet Adresses (ERP-167) : un bloc par adresse + BAN. RG-4.05
préremplissage si QUALIMAT ; RG-4.07 pas de Valider si QUALIMAT. -->
<template #addresses>
<div class="mt-12 flex flex-col gap-6">
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
<CarrierAddressBlock
:model-value="address"
:country-options="countryOptions"
:disabled="isQualimat || isValidated('addresses')"
:errors="addressErrors"
@update:model-value="(v) => address = v"
@degraded="onAddressDegraded"
/>
<!-- RG-4.07 : pas de bouton Valider pour un transporteur QUALIMAT
(adresse copiée et persistée automatiquement). -->
<div v-if="!isQualimat && !isValidated('addresses')" class="flex justify-center gap-6">
<MalioButton
variant="primary"
:label="t('transport.carriers.form.submit')"
:disabled="tabSubmitting || carrierId === null"
@click="onSubmitAddresses"
/>
</div>
</div>
</template>
<!-- Onglet Contacts (ERP-168) : un bloc par contact (RG-4.08 1 champ,
max 2 téléphones). Erreurs 422 par ligne. -->
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<CarrierContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:removable="isRowRemovable(contacts, index)"
:disabled="isValidated('contacts')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<div v-if="!isValidated('contacts')" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('transport.carriers.form.contact.add')"
:disabled="!canAddContact"
@click="addContact"
/>
<MalioButton
variant="primary"
:label="t('transport.carriers.form.submit')"
:disabled="tabSubmitting || carrierId === null"
@click="onSubmitContacts"
/>
</div>
</div>
</template>
<!-- Onglet Prix (ERP-169) : blocs multiples, branche CLIENT/FOURNISSEUR
(RG-4.094.11). Démarre vide ; l'utilisateur ajoute via « + Nouveau prix ». -->
<template #prices>
<div class="mt-12 flex flex-col gap-6">
<CarrierPriceBlock
v-for="(price, index) in prices"
:key="index"
:model-value="price"
:client-options="clientOptions"
:supplier-options="supplierOptions"
:site-options="siteOptions"
:removable="!isValidated('prices')"
:disabled="isValidated('prices')"
:errors="priceErrors[index]"
@update:model-value="(v) => prices[index] = v"
@remove="askRemovePrice(index)"
/>
<div v-if="!isValidated('prices')" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('transport.carriers.form.price.add')"
:disabled="!canAddPrice"
@click="addPrice"
/>
<MalioButton
variant="primary"
:label="t('transport.carriers.form.submit')"
:disabled="tabSubmitting || carrierId === null"
@click="onSubmitPrices"
/>
</div>
</div>
</template>
<!-- Plus d'onglet placeholder : tous les onglets ont leur contenu. -->
<template
v-for="key in placeholderTabs"
:key="key"
#[key]
>
<div class="mt-12 flex justify-center text-m-muted">
{{ t('transport.carriers.form.comingSoon') }}
</div>
</template>
</MalioTabList>
<!-- Modal de confirmation de suppression (bloc contact / prix). -->
<MalioModal v-model="deleteConfirm.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.confirmDelete.title') }}</h2>
</template>
<p>{{ t('transport.carriers.form.confirmDelete.message') }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('transport.carriers.form.confirmDelete.cancel')"
@click="deleteConfirm.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="t('transport.carriers.form.confirmDelete.confirm')"
@click="runDeleteConfirm"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.vue'
import CarrierQualimatTab from '~/modules/transport/components/CarrierQualimatTab.vue'
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
import { clampPercent, sanitizeDecimal, sanitizeLiotPlates } from '~/modules/transport/utils/forms/numberInput'
import { sanitizeFreeText } from '~/shared/utils/textSanitize'
interface SelectOption {
value: string
label: string
}
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
useHead({ title: t('transport.carriers.form.title') })
// Gating de la route : la creation est reservee a `manage` (Admin / Bureau).
// Commerciale (consultation seule), Compta et Usine sont rediriges vers le repertoire.
if (!can('transport.carriers.manage')) {
await navigateTo('/carriers')
}
const {
main,
carrierId,
mainLocked,
mainSubmitting,
tabSubmitting,
mainErrors,
dischargeUploading,
selectDischarge,
clearDischarge,
isLiot,
isQualimat,
certificationReadonly,
showCharteredFields,
showDischarge,
tabKeys,
activeTab,
unlockedIndex,
isValidated,
address,
addressErrors,
submitAddress,
contacts,
contactErrors,
canAddContact,
addContact,
removeContact,
submitContacts,
prices,
priceErrors,
canAddPrice,
addPrice,
removePrice,
submitPrices,
submitMain,
applyQualimatSelection,
} = useCarrierForm()
// Nom de fichier affiché dans le champ Décharge (alimenté à la sélection).
const dischargeFileName = ref('')
/** Vidage du champ Décharge : oublie le fichier en attente / l'IRI + le nom affiché. */
function onClearDischarge(): void {
clearDischarge()
dischargeFileName.value = ''
}
// Certifications selectionnables manuellement (spec § Formulaire principal) :
// GMP+ / OVOCOM / Compte-propre / Autre. QUALIMAT n'y figure PAS il est posé par
// la saisie assistee (onglet Qualimat). On l'ajoute cependant aux options QUAND il
// est deja selectionne (transporteur QUALIMAT integre), uniquement pour AFFICHER
// son libelle dans le select en lecture seule.
const SELECTABLE_CERTIFICATIONS = ['GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'] as const
const certificationOptions = computed<SelectOption[]>(() => {
const codes: string[] = [...SELECTABLE_CERTIFICATIONS]
if (main.certificationType === 'QUALIMAT') {
codes.unshift('QUALIMAT')
}
return codes.map(code => ({
value: code,
label: t(`transport.carriers.certification.${code}`),
}))
})
// Icone (Iconify) affichee dans chaque onglet, par cle.
const TAB_ICONS: Record<string, string> = {
qualimat: 'mdi:truck-fast-outline',
addresses: 'mdi:map-marker-outline',
contacts: 'mdi:account-box-plus-outline',
prices: 'mdi:payment',
}
// Onglets desactives tant que le formulaire principal n'est pas valide
// (unlockedIndex = -1 au depart) ; deverrouillage progressif ensuite.
const tabs = computed(() => tabKeys.value.map((key, index) => ({
key,
label: t(`transport.carriers.tab.${key}`),
icon: TAB_ICONS[key],
disabled: index > unlockedIndex.value,
})))
// Tous les onglets ont désormais leur contenu (qualimat / addresses / contacts / prices).
const placeholderTabs = computed(() => tabKeys.value.filter(
key => key !== 'qualimat' && key !== 'addresses' && key !== 'contacts' && key !== 'prices',
))
// Référentiels de l'onglet Prix (clients / fournisseurs / sites)
const clientOptions = ref<SelectOption[]>([])
const supplierOptions = ref<SelectOption[]>([])
const siteOptions = ref<SelectOption[]>([])
/** Charge un référentiel paginé (?pagination=false) et mappe en options { IRI, libellé }. */
async function loadOptions(
url: string,
target: typeof clientOptions,
labelOf: (m: Record<string, unknown>) => string,
): Promise<void> {
try {
const data = await api.get<{ member?: Record<string, unknown>[] }>(
url,
{ pagination: 'false' },
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
target.value = (data.member ?? []).map(m => ({ value: String(m['@id']), label: labelOf(m) }))
}
catch {
target.value = []
}
}
/** Charge les référentiels de l'onglet Prix (non bloquant : selects vides si échec). */
function loadPriceReferentials(): void {
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']))
void loadOptions('/suppliers', supplierOptions, m => String(m.companyName ?? m['@id']))
void loadOptions('/sites', siteOptions, m => String(m.name ?? m['@id']))
}
// Onglet Adresses (ERP-167)
// Pays : France garantie en tete meme si /countries echoue (resilience), pour
// rester preselectionnable par defaut sur chaque adresse (RG-4.05).
const countryOptions = ref<SelectOption[]>([{ value: 'France', label: 'France' }])
/** Charge le referentiel pays (/api/countries) ; conserve France par defaut si echec. */
async function loadCountries(): Promise<void> {
try {
const data = await api.get<{ member?: { name: string }[] }>(
'/countries',
{ pagination: 'false' },
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
const list = (data.member ?? []).map(c => ({ value: c.name, label: c.name }))
countryOptions.value = list.some(c => c.value === 'France')
? list
: [{ value: 'France', label: 'France' }, ...list]
}
catch {
// Reste sur le fallback France (non bloquant).
}
}
onMounted(() => {
loadCountries().catch(() => {})
loadPriceReferentials()
})
// Avertissement unique quand l'autocompletion d'adresse bascule en degrade.
const addressDegradedNotified = ref(false)
function onAddressDegraded(): void {
if (addressDegradedNotified.value) {
return
}
addressDegradedNotified.value = true
toast.warning({
title: t('transport.carriers.toast.error'),
message: t('transport.carriers.form.address.degraded'),
})
}
/** Message d'erreur affichable (toast) extrait d'une erreur API — jamais undefined. */
function apiErrorMessage(error: unknown): string {
const data = (error as { response?: { _data?: unknown } })?.response?._data
return extractApiErrorMessage(data) || t('transport.carriers.toast.error')
}
/** Valide l'onglet Adresses (POST/PATCH par ligne ; avance gere par le composable). */
async function onSubmitAddresses(): Promise<void> {
const ok = await submitAddress(error => toast.error({
title: t('transport.carriers.toast.error'),
message: apiErrorMessage(error),
}))
if (ok) {
toast.success({ title: t('transport.carriers.toast.addressSaved') })
}
}
// Modal de confirmation de suppression (générique : bloc contact OU prix).
const deleteConfirm = reactive({ open: false, action: null as null | (() => void) })
/** Valide l'onglet Contacts (POST/PATCH par ligne ; avance gérée par le composable). */
async function onSubmitContacts(): Promise<void> {
const ok = await submitContacts(error => toast.error({
title: t('transport.carriers.toast.error'),
message: apiErrorMessage(error),
}))
if (ok) {
toast.success({ title: t('transport.carriers.toast.contactSaved') })
}
}
function askRemoveContact(index: number): void {
deleteConfirm.action = () => { void removeContact(index) }
deleteConfirm.open = true
}
/**
* Valide l'onglet Prix = DERNIER onglet du flux de création. Au succès, l'ajout est
* terminé : toast final + retour au répertoire transporteurs (aligné sur M1/M2/M3).
*/
async function onSubmitPrices(): Promise<void> {
const ok = await submitPrices(error => toast.error({
title: t('transport.carriers.toast.error'),
message: apiErrorMessage(error),
}))
if (ok) {
toast.success({ title: t('transport.carriers.toast.priceSaved') })
await navigateTo('/carriers')
}
}
function askRemovePrice(index: number): void {
deleteConfirm.action = () => { void removePrice(index) }
deleteConfirm.open = true
}
function runDeleteConfirm(): void {
deleteConfirm.action?.()
deleteConfirm.action = null
deleteConfirm.open = false
}
/** Intégration d'une ligne QUALIMAT (émise par CarrierQualimatTab) : copie + PATCH
* (cf. useCarrierForm.applyQualimatSelection). */
async function onIntegrateQualimat(row: QualimatCarrierRow): Promise<void> {
const ok = await applyQualimatSelection(row)
if (ok) {
toast.success({ title: t('transport.carriers.toast.integrateSuccess') })
}
}
// Indexation plafonnée à 100 % : la clé force le ré-affichage du MalioInputAmount
// (contrôlé) quand le plafonnement laisse le modelValue inchangé.
const indexationKey = ref(0)
/** Saisie de l'indexation : plafonne à 100 et re-synchronise le champ si plafonné. */
function onIndexationInput(value: string): void {
const clamped = clampPercent(value)
main.indexationRate = clamped
if (clamped !== value) {
indexationKey.value += 1
}
}
// Immatriculations LIOT : la clé force le ré-affichage quand le filtrage laisse le
// modelValue inchangé (ex: caractère interdit seul tapé) sinon le DOM garderait
// le caractère parasite alors que le modèle est déjà propre.
const liotPlatesKey = ref(0)
/** Saisie des immatriculations LIOT : filtre la saisie et re-synchronise si filtré. */
function onLiotPlatesInput(value: string): void {
const clean = sanitizeLiotPlates(value)
main.liotPlates = clean
if (clean !== value) {
liotPlatesKey.value += 1
}
}
/** Retour vers le repertoire transporteurs (fleche d'en-tete). */
function goBack(): void {
router.push('/carriers')
}
/**
* Valide le formulaire principal (POST /carriers ; bascule geree par le composable).
* RG-4.07 : pour un transporteur QUALIMAT, l'adresse copiee est persistee
* automatiquement (pas de bouton Valider dans l'onglet Adresses).
*/
async function onSubmitMain(): Promise<void> {
const ok = await submitMain()
if (ok && isQualimat.value) {
await submitAddress(error => toast.error({
title: t('transport.carriers.toast.error'),
message: apiErrorMessage(error),
}))
}
}
</script>
@@ -0,0 +1,187 @@
/**
* Types du workflow « Ajouter un transporteur » (M4 Transport, ERP-165 / ERP-166).
*
* Périmètre :
* - ERP-165 : formulaire PRINCIPAL minimal (Nom + Certification + Affréter).
* - ERP-166 : champs CONDITIONNELS du formulaire principal (indexation / benne /
* volume si affrété RG-4.03 ; décharge si AUTRE RG-4.02 ; immatriculations
* LIOT RG-4.01) + saisie assistée QUALIMAT (copie name / certification /
* adresse + FK qualimatCarrier RG-4.01 / § 2.5).
*
* L'upload réel de la décharge (file IRI via useUpload) arrive à ERP-171 ; ici
* on porte seulement l'IRI résolu (`dischargeDocumentIri`).
*/
/**
* Brouillon du formulaire principal. Les décimales (indexation / volume) sont
* portées en `string` car `MalioInputNumber` émet une chaîne ; le serveur parse.
* `certificationType` est un code enum back (GMP_PLUS | OVOCOM | COMPTE_PROPRE |
* AUTRE | QUALIMAT ce dernier posé par la saisie assistée) ou `null`.
* `containerType` vaut `BENNE` | `FOND_MOUVANT` (radio) ou `null`.
*/
export interface CarrierMainDraft {
name: string
certificationType: string | null
isChartered: boolean
indexationRate: string
containerType: string | null
volumeM3: string
liotPlates: string
/** IRI du document de décharge (résolu par useUpload — ERP-171). */
dischargeDocumentIri: string | null
/** IRI de la ligne QUALIMAT liée (saisie assistée — null si non QUALIMAT). */
qualimatCarrierIri: string | null
}
/** Brouillon principal vide (état initial du formulaire de création). */
export function emptyCarrierMain(): CarrierMainDraft {
return {
name: '',
certificationType: null,
isChartered: false,
indexationRate: '',
// Défaut métier : Benne pré-sélectionné (radio du formulaire principal).
containerType: 'BENNE',
volumeM3: '',
liotPlates: '',
dischargeDocumentIri: null,
qualimatCarrierIri: null,
}
}
/**
* Adresse copiée depuis le référentiel QUALIMAT à la sélection (RG-4.01 / § 2.5).
* Stockée dans l'état du formulaire pour alimenter l'onglet Adresses (ticket
* ultérieur) ; pré-remplie « France » côté pays par défaut.
*/
export interface CarrierAddressCopy {
country: string
postalCode: string
city: string
street: string
}
/** Adresse copiée vide. */
export function emptyCarrierAddressCopy(): CarrierAddressCopy {
return { country: 'France', postalCode: '', city: '', street: '' }
}
/**
* Brouillon d'un bloc Adresse (onglet Adresses, ERP-167) sous-ressource
* `CarrierAddress` (groupe `carrier:write:addresses`). Version SIMPLIFIÉE de
* l'adresse fournisseur (M2) / prestataire (M3) : pas de sites / catégories /
* contacts ni type d'adresse (les sites du M4 vivent dans l'onglet Prix).
*/
export interface CarrierAddressFormDraft {
/** Id serveur une fois l'adresse créée (null tant que non persistée). */
id: number | null
/** Pays (chaîne libre, défaut « France »). */
country: string
postalCode: string | null
city: string | null
street: string | null
streetComplement: string | null
}
/** Brouillon d'adresse vide (pays France par défaut, RG-4.05). */
export function emptyCarrierAddress(): CarrierAddressFormDraft {
return {
id: null,
country: 'France',
postalCode: null,
city: null,
street: null,
streetComplement: null,
}
}
/**
* Brouillon d'un bloc Contact (onglet Contacts, ERP-168) sous-ressource
* `CarrierContact` (groupe `carrier:write:contacts`). Les téléphones sont saisis
* en `phonePrimary` / `phoneSecondary` côté UI, puis envoyés au back sous forme du
* tableau `phones` (max 2 RG-4.08). `hasSecondaryPhone` pilote l'affichage du 2e
* numéro (révélé via le bouton « + »). Pas d'`iri` : aucune relation M2M depuis
* l'adresse au M4 ( M3).
*/
export interface CarrierContactFormDraft {
/** Id serveur une fois le contact créé (null tant que non persisté). */
id: number | null
firstName: string | null
lastName: string | null
jobTitle: string | null
phonePrimary: string | null
phoneSecondary: string | null
email: string | null
/** UI : le 2e numéro a été révélé via le bouton « + » (max 2 téléphones). */
hasSecondaryPhone: boolean
}
/** Brouillon de contact vide (état initial d'un bloc Contact). */
export function emptyCarrierContact(): CarrierContactFormDraft {
return {
id: null,
firstName: null,
lastName: null,
jobTitle: null,
phonePrimary: null,
phoneSecondary: null,
email: null,
hasSecondaryPhone: false,
}
}
/**
* Brouillon d'un bloc Prix (onglet Prix, ERP-169 RG-4.094.11). `direction`
* pilote la branche active : CLIENT (client + adresse de livraison + site de
* départ) ou FOURNISSEUR (fournisseur + adresse d'appro + site de livraison). Les
* relations partent au back en IRI (string). `price` est une chaîne (MalioInputAmount).
*/
export interface CarrierPriceFormDraft {
id: number | null
direction: 'CLIENT' | 'FOURNISSEUR' | null
// Branche CLIENT (RG-4.10).
clientIri: string | null
clientDeliveryAddressIri: string | null
departureSiteIri: string | null
// Branche FOURNISSEUR (RG-4.11).
supplierIri: string | null
supplierSupplyAddressIri: string | null
deliverySiteIri: string | null
// Communs (toujours requis).
containerType: string | null
pricingUnit: string | null
price: string | null
priceState: string | null
}
/** Brouillon de prix vide (état initial d'un bloc Prix : tout masqué tant que direction null). */
export function emptyCarrierPrice(): CarrierPriceFormDraft {
return {
id: null,
// Défaut métier : sens CLIENT pré-sélectionné (un bloc prix CLIENT est présent
// d'office à l'ouverture de l'onglet).
direction: 'CLIENT',
clientIri: null,
clientDeliveryAddressIri: null,
departureSiteIri: null,
supplierIri: null,
supplierSupplyAddressIri: null,
deliverySiteIri: null,
// Défauts métier : Benne + Forfait pré-sélectionnés à l'ajout d'un bloc prix.
containerType: 'BENNE',
pricingUnit: 'FORFAIT',
price: null,
priceState: null,
}
}
/**
* Réponse du POST / PATCH principal (groupe `carrier:read`). Le serveur renvoie
* le nom normalisé (UPPERCASE, RG-4.13) que l'UI réaffiche tel quel.
*/
export interface CarrierMainResponse {
id: number
name: string | null
certificationType: string | null
'@id'?: string
}
@@ -0,0 +1,170 @@
import { describe, it, expect } from 'vitest'
import {
canEditCarrier,
carrierConsultationVisibleTabs,
hasAddressData,
iriOf,
labelOfRelation,
mapAddressToDraft,
mapContactToDraft,
mapMainToDraft,
mapPriceToDraft,
showArchiveAction,
showRestoreAction,
type CarrierDetail,
} from '../carrierMappers'
/**
* Tests des mappers détail brouillons (M4 Transport, ERP-170) : peuplent les écrans
* Consultation / Modification depuis la SEULE réponse `GET /api/carriers/{id}`, et
* helpers de visibilité des boutons (Modifier / Archiver / Restaurer) selon la permission.
*/
describe('carrierMappers', () => {
it('iriOf : objet embarqué, IRI nu, ou null', () => {
expect(iriOf({ '@id': '/api/clients/3' })).toBe('/api/clients/3')
expect(iriOf('/api/sites/1')).toBe('/api/sites/1')
expect(iriOf(null)).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', () => {
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('/api/sites/1')).toBe('')
expect(labelOfRelation(null)).toBe('')
})
it('mapMainToDraft : scalaires + IRI décharge / qualimat', () => {
const detail: CarrierDetail = {
'@id': '/api/carriers/7',
id: 7,
name: 'TRANSPORTS ACME',
certificationType: 'QUALIMAT',
isChartered: true,
indexationRate: '5.00',
containerType: 'BENNE',
volumeM3: '30.00',
dischargeDocument: { '@id': '/api/uploaded_documents/4' },
qualimatCarrier: { '@id': '/api/qualimat_carriers/42' },
}
expect(mapMainToDraft(detail)).toEqual({
name: 'TRANSPORTS ACME',
certificationType: 'QUALIMAT',
isChartered: true,
indexationRate: '5.00',
containerType: 'BENNE',
volumeM3: '30.00',
liotPlates: '',
dischargeDocumentIri: '/api/uploaded_documents/4',
qualimatCarrierIri: '/api/qualimat_carriers/42',
})
})
it('mapAddressToDraft : pays par défaut France si absent', () => {
expect(mapAddressToDraft({ '@id': '/api/carrier_addresses/3', id: 3, postalCode: '86000', city: 'Poitiers' }))
.toEqual({ id: 3, country: 'France', postalCode: '86000', city: 'Poitiers', street: null, streetComplement: null })
})
it('mapContactToDraft : hasSecondaryPhone vrai seulement si 2e numéro présent', () => {
const one = mapContactToDraft({ '@id': '/api/carrier_contacts/1', id: 1, firstName: 'Jean', phonePrimary: '0102030405' })
expect(one.hasSecondaryPhone).toBe(false)
expect(one.firstName).toBe('Jean')
const two = mapContactToDraft({ '@id': '/api/carrier_contacts/2', id: 2, phonePrimary: '0102030405', phoneSecondary: '0605040302' })
expect(two.hasSecondaryPhone).toBe(true)
expect(two.phoneSecondary).toBeTruthy()
})
it('mapPriceToDraft : direction + IRIs des relations de branche', () => {
const draft = mapPriceToDraft({
'@id': '/api/carrier_prices/5',
id: 5,
direction: 'CLIENT',
client: { '@id': '/api/clients/3' },
clientDeliveryAddress: { '@id': '/api/client_addresses/8' },
departureSite: '/api/sites/1',
containerType: 'BENNE',
pricingUnit: 'FORFAIT',
price: '120.00',
priceState: 'EN_COURS',
})
expect(draft).toMatchObject({
id: 5,
direction: 'CLIENT',
clientIri: '/api/clients/3',
clientDeliveryAddressIri: '/api/client_addresses/8',
departureSiteIri: '/api/sites/1',
supplierIri: null,
containerType: 'BENNE',
pricingUnit: 'FORFAIT',
price: '120.00',
priceState: 'EN_COURS',
})
})
it('visibilité des boutons selon la permission', () => {
const can = (granted: string[]) => (code: string) => granted.includes(code)
// Modifier : seulement avec manage.
expect(canEditCarrier(can(['transport.carriers.manage']))).toBe(true)
expect(canEditCarrier(can(['transport.carriers.view']))).toBe(false)
// Archiver : permission archive ET actif ; Restaurer : archive ET archivé.
const withArchive = can(['transport.carriers.archive'])
const noArchive = can(['transport.carriers.manage'])
expect(showArchiveAction(withArchive, false)).toBe(true)
expect(showArchiveAction(withArchive, true)).toBe(false)
expect(showRestoreAction(withArchive, true)).toBe(true)
expect(showRestoreAction(withArchive, false)).toBe(false)
expect(showArchiveAction(noArchive, false)).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'])
})
})
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { clampPercent, sanitizeDecimal, sanitizeLiotPlates } from '../numberInput'
describe('numberInput — saisie volume / indexation (ERP-170)', () => {
it('sanitizeDecimal : ne garde que chiffres + un seul point', () => {
expect(sanitizeDecimal('30')).toBe('30')
expect(sanitizeDecimal('30.5')).toBe('30.5')
expect(sanitizeDecimal('30,5 kg')).toBe('30.5') // virgule FR → point ; espace + lettres retirés
expect(sanitizeDecimal('1.2.3')).toBe('1.23') // un seul point conservé
expect(sanitizeDecimal('abc12.3x')).toBe('12.3')
expect(sanitizeDecimal('')).toBe('')
})
it('clampPercent : plafonne à 100, laisse le reste tel quel', () => {
expect(clampPercent('50')).toBe('50')
expect(clampPercent('100')).toBe('100')
expect(clampPercent('150')).toBe('100')
expect(clampPercent('100.01')).toBe('100')
expect(clampPercent('12,5')).toBe('12,5') // ≤ 100 → inchangé
expect(clampPercent('')).toBe('')
})
it('sanitizeLiotPlates : garde lettres/chiffres/tiret/point-virgule, retire espaces et reste', () => {
expect(sanitizeLiotPlates('AB-123-CD;EF-456-GH')).toBe('AB-123-CD;EF-456-GH')
expect(sanitizeLiotPlates('ab-123-cd ; ef-456-gh')).toBe('ab-123-cd;ef-456-gh') // espaces retirés
expect(sanitizeLiotPlates('AB 123 CD')).toBe('AB123CD') // espaces retirés
expect(sanitizeLiotPlates('AB.123/CD#42')).toBe('AB123CD42') // . / # retirés
expect(sanitizeLiotPlates('')).toBe('')
})
})
@@ -0,0 +1,24 @@
/**
* Helpers purs de l'onglet Adresse transporteur (M4 Transport, ERP-167) miroir
* SIMPLIFIÉ de `providerAddress` (M3) / `SupplierAddressBlock` (M2), sans sites /
* catégories / contacts (les sites du M4 vivent dans l'onglet Prix). Testables
* sans Vue.
*/
import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm'
/**
* Payload de la sous-ressource address (groupe `carrier:write:addresses`). Les
* scalaires sont nullable côté entité : on envoie `null` quand le champ est vide
* (le `CarrierAddressProcessor` re-valide la présence si affrété RG-4.05 et
* renvoie une 422 par champ).
*/
export function buildCarrierAddressPayload(address: CarrierAddressFormDraft): Record<string, unknown> {
return {
country: address.country,
postalCode: address.postalCode || null,
city: address.city || null,
street: address.street || null,
streetComplement: address.streetComplement || null,
}
}
@@ -0,0 +1,61 @@
/**
* Helpers purs de l'onglet Contact transporteur (M4 Transport, ERP-168) ALIGNÉ
* sur `providerContact.ts` (M3) / les autres modules : mêmes règles de validité et
* de gating « + Nouveau contact » (un contact est « nommé » dès qu'il porte un
* prénom OU un nom). Seule spécificité M4 conservée : les téléphones partent au back
* dans le tableau virtuel `phones` (max 2), mappés par le CarrierContactProcessor.
* Testables sans Vue ni API.
*/
import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm'
/** Vrai si une chaîne porte au moins un caractère non-espace. */
function isFilled(value: string | null | undefined): boolean {
return value !== null && value !== undefined && value.trim() !== ''
}
/**
* Un bloc Contact est VIDE tant qu'aucun champ comptant pour la validité n'est
* rempli prénom / nom / fonction / téléphone principal / email. `phoneSecondary`
* est EXCLU (aligné M1/M2/M3 : un bloc ne portant qu'un 2e numéro reste vide). Sert
* le filtrage des amorces vides à la soumission.
*/
export function isCarrierContactBlank(contact: CarrierContactFormDraft): boolean {
return ![
contact.firstName,
contact.lastName,
contact.jobTitle,
contact.phonePrimary,
contact.email,
].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
* chaînes vides partent à null (le serveur normalise/trim). Les téléphones sont
* regroupés dans le tableau `phones` (numéros non vides, max 2 RG-4.08) ; le 2e
* numéro n'est inclus que s'il a é révélé (`hasSecondaryPhone`).
*/
export function buildCarrierContactPayload(contact: CarrierContactFormDraft): Record<string, unknown> {
const phones = [
contact.phonePrimary,
contact.hasSecondaryPhone ? contact.phoneSecondary : null,
].filter((phone): phone is string => isFilled(phone))
return {
firstName: contact.firstName || null,
lastName: contact.lastName || null,
jobTitle: contact.jobTitle || null,
email: contact.email || null,
phones,
}
}
@@ -0,0 +1,252 @@
/**
* Helpers purs des écrans Consultation / Modification transporteur (M4, ERP-170)
* miroir de `providerDetail.ts` (M3). Mappent le payload `GET /api/carriers/{id}`
* (relations embarquées via les groupes `carrier:item:read` + `qualimat:read` +
* read-groups cross-module client/supplier/site/adresses) vers les brouillons
* « plats » partagés avec les blocs Adresse / Contact / Prix.
*
* Ne touchent ni à l'API ni à l'état réactif (testables unitairement). Les champs
* nuls peuvent être OMIS (skip_null_values) toujours lire avec `?? null`.
*/
import { formatPhoneFR } from '~/shared/utils/phone'
import type {
CarrierAddressFormDraft,
CarrierContactFormDraft,
CarrierMainDraft,
CarrierPriceFormDraft,
} from '~/modules/transport/types/carrierForm'
/** Référence Hydra embarquée minimale (@id toujours présent). */
export interface HydraRef {
'@id': string
[key: string]: unknown
}
/** Une relation peut être embarquée (objet), un IRI nu (chaîne) ou absente. */
export type Relation = HydraRef | string | null | undefined
/** Adresse embarquée (groupe carrier:item:read). */
export interface CarrierAddressRead extends HydraRef {
id: number
country?: string | null
postalCode?: string | null
city?: string | null
street?: string | null
streetComplement?: string | null
}
/** Contact embarqué (groupe carrier:item:read). */
export interface CarrierContactRead extends HydraRef {
id: number
firstName?: string | null
lastName?: string | null
jobTitle?: string | null
phonePrimary?: string | null
phoneSecondary?: string | null
email?: string | null
}
/** Prix embarqué (groupe carrier:item:read + relations cross-module). */
export interface CarrierPriceRead extends HydraRef {
id: number
direction?: string | null
client?: Relation
clientDeliveryAddress?: Relation
departureSite?: Relation
supplier?: Relation
supplierSupplyAddress?: Relation
deliverySite?: Relation
containerType?: string | null
pricingUnit?: string | null
price?: string | null
priceState?: string | null
}
/**
* Détail d'un transporteur (`GET /api/carriers/{id}`). Tous les champs optionnels :
* skip_null_values peut omettre n'importe quelle clé.
*/
export interface CarrierDetail extends HydraRef {
id: number
name?: string | null
certificationType?: string | null
isChartered?: boolean
indexationRate?: string | null
containerType?: string | null
volumeM3?: string | null
liotPlates?: string | null
dischargeDocument?: Relation
qualimatCarrier?: Relation
isArchived?: boolean
// Adresse UNIQUE (OneToOne, ERP-172) : objet embarqué (ou absent), pas une liste.
address?: CarrierAddressRead | null
contacts?: CarrierContactRead[]
prices?: CarrierPriceRead[]
}
/** Extrait l'IRI d'une relation (objet embarqué, IRI nu, ou null si absente). */
export function iriOf(relation: Relation): string | null {
if (relation === null || relation === undefined) {
return null
}
if (typeof relation === 'string') {
return relation
}
return relation['@id'] ?? null
}
/**
* Libellé d'affichage d'une relation embarquée : `companyName` (client/fournisseur)
* à défaut `name` (site), à défaut une adresse condensée (voie · CP · ville). Chaîne
* vide si la relation est un IRI nu / absente.
*/
export function labelOfRelation(relation: Relation): string {
if (!relation || typeof relation === 'string') {
return ''
}
const companyName = relation.companyName as string | undefined
if (companyName) {
return companyName
}
const name = relation.name as string | undefined
if (name) {
return name
}
const parts = [relation.street, relation.postalCode, relation.city].filter(Boolean)
return parts.join(' · ')
}
/** Mappe le détail vers le brouillon du formulaire principal. */
export function mapMainToDraft(detail: CarrierDetail): CarrierMainDraft {
return {
name: detail.name ?? '',
certificationType: detail.certificationType ?? null,
isChartered: detail.isChartered ?? false,
indexationRate: detail.indexationRate ?? '',
containerType: detail.containerType ?? null,
volumeM3: detail.volumeM3 ?? '',
liotPlates: detail.liotPlates ?? '',
dischargeDocumentIri: iriOf(detail.dischargeDocument),
qualimatCarrierIri: iriOf(detail.qualimatCarrier),
}
}
/** Mappe une adresse embarquée vers un brouillon. */
export function mapAddressToDraft(address: CarrierAddressRead): CarrierAddressFormDraft {
return {
id: address.id,
country: address.country ?? 'France',
postalCode: address.postalCode ?? null,
city: address.city ?? null,
street: address.street ?? null,
streetComplement: address.streetComplement ?? null,
}
}
/** Mappe un contact embarqué vers un brouillon (téléphones formatés XX XX XX XX XX). */
export function mapContactToDraft(contact: CarrierContactRead): CarrierContactFormDraft {
const secondary = contact.phoneSecondary ?? null
return {
id: contact.id,
firstName: contact.firstName ?? null,
lastName: contact.lastName ?? null,
jobTitle: contact.jobTitle ?? null,
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
phoneSecondary: secondary ? formatPhoneFR(secondary) : null,
email: contact.email ?? null,
hasSecondaryPhone: secondary !== null && secondary !== '',
}
}
/** Mappe un prix embarqué vers un brouillon (relations en IRI). */
export function mapPriceToDraft(price: CarrierPriceRead): CarrierPriceFormDraft {
const direction = price.direction === 'CLIENT' || price.direction === 'FOURNISSEUR'
? price.direction
: null
return {
id: price.id,
direction,
clientIri: iriOf(price.client),
clientDeliveryAddressIri: iriOf(price.clientDeliveryAddress),
departureSiteIri: iriOf(price.departureSite),
supplierIri: iriOf(price.supplier),
supplierSupplyAddressIri: iriOf(price.supplierSupplyAddress),
deliverySiteIri: iriOf(price.deliverySite),
containerType: price.containerType ?? null,
pricingUnit: price.pricingUnit ?? null,
price: price.price ?? null,
priceState: price.priceState ?? null,
}
}
/**
* 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). */
export function canEditCarrier(can: (code: string) => boolean): boolean {
return can('transport.carriers.manage')
}
/** Bouton « Archiver » : permission archive ET transporteur encore actif (Admin seul). */
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
return can('transport.carriers.archive') && !isArchived
}
/** Bouton « Restaurer » : permission archive ET transporteur déjà archivé (Admin seul). */
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
return can('transport.carriers.archive') && isArchived
}
@@ -0,0 +1,77 @@
/**
* Helpers purs de l'onglet Prix transporteur (M4 Transport, ERP-169 RG-4.094.11).
* Une ligne porte une branche CLIENT ou FOURNISSEUR selon `direction` ; les champs
* de la branche INACTIVE doivent toujours partir à null (CHECK BDD
* chk_carrier_price_client_branch / supplier_branch). Testables sans Vue ni API.
*/
import type { CarrierPriceFormDraft } from '~/modules/transport/types/carrierForm'
/** Vrai si une chaîne porte au moins un caractère non-espace. */
function isFilled(value: string | null | undefined): boolean {
return value !== null && value !== undefined && value.trim() !== ''
}
/**
* Payload de la sous-ressource prix (groupe `carrier:write:prices`). Envoie les
* communs + UNIQUEMENT la branche active (l'autre branche à null, exigée par les
* CHECK BDD). Les relations partent en IRI (string|null).
*
* IMPORTANT : les scalaires obligatoires (direction / containerType / pricingUnit /
* price / priceState) sont OMIS s'ils sont vides — on n'envoie JAMAIS `null` sur un
* champ string. Sinon API Platform lève un 400 « The type of the "price" attribute
* must be "string", "NULL" given. » AVANT la validation (non mappable inline). Omis,
* le champ reste null côté entité l'Assert\NotBlank renvoie un 422 propre rattaché
* au champ, affiché sous l'input comme les autres blocs (ERP-101). Le back re-valide
* aussi l'obligation conditionnelle de branche + l'appartenance de l'adresse.
*/
export function buildCarrierPricePayload(price: CarrierPriceFormDraft): Record<string, unknown> {
const payload: Record<string, unknown> = {}
// Scalaires : présents seulement si remplis (jamais `null` → évite le 400 de type).
if (isFilled(price.direction)) payload.direction = price.direction
if (isFilled(price.containerType)) payload.containerType = price.containerType
if (isFilled(price.pricingUnit)) payload.pricingUnit = price.pricingUnit
if (isFilled(price.price)) payload.price = price.price
if (isFilled(price.priceState)) payload.priceState = price.priceState
// Branche active en IRI (null toléré sur une relation, ne déclenche pas le 400 de
// type) ; branche inactive forcée à null (CHECK BDD chk_carrier_price_*_branch).
if (price.direction === 'CLIENT') {
payload.client = price.clientIri || null
payload.clientDeliveryAddress = price.clientDeliveryAddressIri || null
payload.departureSite = price.departureSiteIri || null
payload.supplier = null
payload.supplierSupplyAddress = null
payload.deliverySite = null
}
else if (price.direction === 'FOURNISSEUR') {
payload.supplier = price.supplierIri || null
payload.supplierSupplyAddress = price.supplierSupplyAddressIri || null
payload.deliverySite = price.deliverySiteIri || null
payload.client = null
payload.clientDeliveryAddress = null
payload.departureSite = null
}
return payload
}
/**
* Pré-check léger du gating « + Nouveau prix » : direction choisie, prix rempli, et
* branche active complète (client/adresse/site OU fournisseur/adresse/site). Le back
* reste la couche autoritaire (RG-4.094.11) ; ce pré-check évite d'empiler des
* blocs vides.
*/
export function isCarrierPriceValid(price: CarrierPriceFormDraft): boolean {
if (!isFilled(price.price) || !isFilled(price.containerType) || !isFilled(price.pricingUnit) || !isFilled(price.priceState)) {
return false
}
if (price.direction === 'CLIENT') {
return isFilled(price.clientIri) && isFilled(price.clientDeliveryAddressIri) && isFilled(price.departureSiteIri)
}
if (price.direction === 'FOURNISSEUR') {
return isFilled(price.supplierIri) && isFilled(price.supplierSupplyAddressIri) && isFilled(price.deliverySiteIri)
}
return false
}
@@ -0,0 +1,38 @@
/**
* Helpers de saisie numérique du formulaire principal transporteur (ERP-170).
* Champs texte restreints (volume m³ décimal, indexation plafonnée). Purs / testables.
*/
/**
* Restreint une saisie à un nombre décimal : chiffres + UN seul point (RG volume m³,
* « nombres avec des points » comme les autres modules). La virgule décimale FR est
* convertie en point (« 30,5 » « 30.5 ») ; tout autre caractère est supprimé.
*/
export function sanitizeDecimal(value: string): string {
let cleaned = (value ?? '').replace(/,/g, '.').replace(/[^0-9.]/g, '')
const dot = cleaned.indexOf('.')
if (dot !== -1) {
// Conserve le 1er point, retire les suivants.
cleaned = cleaned.slice(0, dot + 1) + cleaned.slice(dot + 1).replace(/\./g, '')
}
return cleaned
}
/**
* Plafonne un pourcentage à 100 (contrainte FRONT : l'indexation n'a pas de max back).
* Renvoie « 100 » si la valeur saisie dépasse 100, sinon la valeur telle quelle.
*/
export function clampPercent(value: string): string {
const n = Number(String(value ?? '').replace(',', '.').replace(/\s/g, ''))
return (!Number.isNaN(n) && n > 100) ? '100' : value
}
/**
* Restreint la saisie des immatriculations LIOT : ne garde que lettres, chiffres,
* tiret et point-virgule (séparateur de plaques). Les espaces et tout autre
* caractère sont supprimés à la frappe / au collage. La normalisation finale
* (majuscules + « ; » espacé) reste au back (RG-4.13).
*/
export function sanitizeLiotPlates(value: string): string {
return (value ?? '').replace(/[^A-Za-z0-9;-]/g, '')
}
+10 -10
View File
@@ -7,7 +7,7 @@
"name": "starseed-frontend",
"hasInstallScript": true,
"dependencies": {
"@malio/layer-ui": "^1.7.10",
"@malio/layer-ui": "^1.7.12",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -583,9 +583,9 @@
"license": "MIT"
},
"node_modules/@emnapi/core": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz",
"integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -594,9 +594,9 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
"integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1866,9 +1866,9 @@
"license": "MIT"
},
"node_modules/@malio/layer-ui": {
"version": "1.7.10",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.10/layer-ui-1.7.10.tgz",
"integrity": "sha512-ZWYaKvl+VpGAqeTE+4xdyKOmuRd4zwjlUYVppeIBZwGeNAK16kZnrztR+4eQmnzUqPZVybBhEBdKP9weqWHSUg==",
"version": "1.7.12",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.12/layer-ui-1.7.12.tgz",
"integrity": "sha512-ezQLqi19K2ogI3XwSMsUyluU9x5C4W0tu1muxFbL3foKjibRYRg/FdvySivEhEsalAAt1E88V6Sv/06xPqyYTw==",
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@malio/layer-ui": "^1.7.10",
"@malio/layer-ui": "^1.7.12",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Tests du composable d'upload générique (ERP-171) :
* - succès : POST multipart /uploaded_documents (champ « file »), toast désactivé,
* renvoie l'IRI (@id) du document créé, `uploading` retombe à false ;
* - erreur MIME hors whitelist 422 : l'erreur est RELAYÉE à l'appelant (pour un
* affichage inline sous le champ), `uploading` -armé via le finally.
*/
const mockPost = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({
get: vi.fn(),
post: mockPost,
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
}))
const { useUpload } = await import('../useUpload')
describe('useUpload', () => {
beforeEach(() => {
mockPost.mockReset()
})
it('succès : POST multipart champ « file » + toast:false → renvoie l\'IRI', async () => {
mockPost.mockResolvedValue({ '@id': '/api/uploaded_documents/9', originalFilename: 'decharge.pdf' })
const { upload, uploading } = useUpload()
const file = new File(['contenu'], 'decharge.pdf', { type: 'application/pdf' })
const iri = await upload(file)
expect(iri).toBe('/api/uploaded_documents/9')
const [url, body, options] = mockPost.mock.calls[0]
expect(url).toBe('/uploaded_documents')
expect(body).toBeInstanceOf(FormData)
const stored = (body as FormData).get('file')
expect(stored).toBeInstanceOf(File)
expect((stored as File).name).toBe('decharge.pdf')
expect(options).toMatchObject({ toast: false })
expect(uploading.value).toBe(false)
})
it('erreur MIME → 422 : l\'erreur est remontée à l\'appelant', async () => {
const error = Object.assign(new Error('422'), {
data: { 'hydra:description': 'Type de fichier non autorisé.' },
})
mockPost.mockRejectedValue(error)
const { upload, uploading } = useUpload()
const file = new File(['x'], 'malware.exe', { type: 'application/x-msdownload' })
await expect(upload(file)).rejects.toBe(error)
expect(uploading.value).toBe(false)
})
})
+53
View File
@@ -0,0 +1,53 @@
import { ref } from 'vue'
import type { AnyObject } from '~/shared/composables/useApi'
/**
* Réponse JSON-LD de POST /api/uploaded_documents (groupe `uploaded_document:read`).
* Seul l'IRI (`@id`) est exploité pour le poser sur la relation cible.
*/
export interface UploadedDocumentResponse {
'@id': string
originalFilename?: string
mimeType?: string
}
/**
* Upload d'un document générique vers l'infra partagée (ERP-154) :
* POST /api/uploaded_documents en multipart/form-data, champ « file », via
* `useApi()` (cookie JWT, parsing Hydra/erreurs). Renvoie l'IRI du document créé,
* à poser sur la relation cible (ex: `carrier.dischargeDocument` RG-4.02).
*
* Les erreurs (MIME hors whitelist / fichier trop volumineux 422) sont relayées
* (rethrow) à l'appelant pour un affichage inline sous le champ. `toast: false` par
* défaut : pas de toast fourre-tout, le formulaire mappe le message au bon champ.
*/
export function useUpload() {
// Indicateur d'upload en cours (désactivation UI / spinner éventuel).
const uploading = ref(false)
/**
* Envoie `file` et renvoie l'IRI du `UploadedDocument` créé.
* @throws relaie l'erreur réseau / 422 (MIME, taille) à l'appelant.
*/
async function upload(file: File, options: { toast?: boolean } = {}): Promise<string> {
const formData = new FormData()
formData.append('file', file)
uploading.value = true
try {
// useApi() détecte le FormData et n'impose pas de Content-Type JSON :
// le navigateur pose lui-même la frontière multipart.
const doc = await useApi().post<UploadedDocumentResponse>(
'/uploaded_documents',
formData as unknown as AnyObject,
{ toast: options.toast ?? false },
)
return doc['@id']
} finally {
uploading.value = false
}
}
return { uploading, upload }
}
@@ -0,0 +1,121 @@
import { describe, it, expect, vi } from 'vitest'
import { removeCollectionRow, isRowRemovable, type DeletableRow } from '../collectionRow'
/**
* Tests de `removeCollectionRow` suppression d'une ligne de collection
* (contact / adresse / RIB) avec DELETE immediat de la sous-ressource existante
* (ERP-172). Coeur de logique mutualise par les 3 modules (Client / Fournisseur /
* Prestataire) : un seul comportement teste ici couvre les 9 cas (3 modules x 3
* blocs).
*/
interface Row extends DeletableRow {
label?: string
}
function makeEmpty(): Row {
return { id: null, label: '' }
}
describe('removeCollectionRow', () => {
it('emet un DELETE sur la sous-ressource quand le bloc est existant (id non null)', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
const errors: Record<string, string>[] = [{}, {}]
const deleteRow = vi.fn().mockResolvedValue(undefined)
const onError = vi.fn()
const removed = await removeCollectionRow({
rows, errors, index: 0,
endpoint: '/client_contacts',
deleteRow, makeEmpty, onError,
})
expect(deleteRow).toHaveBeenCalledOnce()
expect(deleteRow).toHaveBeenCalledWith('/client_contacts/10')
expect(removed).toBe(true)
expect(rows).toEqual([{ id: 11, label: 'B' }])
expect(errors).toHaveLength(1)
expect(onError).not.toHaveBeenCalled()
})
it('ne fait AUCUN appel reseau pour un bloc jamais persiste (id null) — retrait local', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: null, label: 'brouillon' }]
const errors: Record<string, string>[] = [{}, {}]
const deleteRow = vi.fn().mockResolvedValue(undefined)
const onError = vi.fn()
const removed = await removeCollectionRow({
rows, errors, index: 1,
endpoint: '/client_contacts',
deleteRow, makeEmpty, onError,
})
expect(deleteRow).not.toHaveBeenCalled()
expect(removed).toBe(true)
expect(rows).toEqual([{ id: 10, label: 'A' }])
})
it('conserve le bloc et remonte l\'erreur si le DELETE serveur echoue (ex. 409 dernier RIB LCR)', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
const errors: Record<string, string>[] = [{}, {}]
const error = { response: { status: 409 } }
const deleteRow = vi.fn().mockRejectedValue(error)
const onError = vi.fn()
const removed = await removeCollectionRow({
rows, errors, index: 0,
endpoint: '/client_ribs',
deleteRow, makeEmpty, onError,
})
expect(removed).toBe(false)
expect(onError).toHaveBeenCalledWith(error)
// Bloc NON retire : la suppression n'a pas ete confirmee par le serveur.
expect(rows).toEqual([{ id: 10, label: 'A' }, { id: 11, label: 'B' }])
expect(errors).toHaveLength(2)
})
it('garde au moins un bloc visible apres retrait du dernier (amorce vide)', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }]
const errors: Record<string, string>[] = [{}]
const deleteRow = vi.fn().mockResolvedValue(undefined)
await removeCollectionRow({
rows, errors, index: 0,
endpoint: '/client_contacts',
deleteRow, makeEmpty, onError: vi.fn(),
})
expect(rows).toEqual([{ id: null, label: '' }])
})
})
/**
* Tests de `isRowRemovable` la poubelle d'un bloc n'apparait que s'il reste un
* AUTRE bloc deja enregistre (id en base). Empeche de supprimer un bloc tant que
* rien n'est sauvegarde, et de supprimer son dernier bloc enregistre (ERP-172).
*/
describe('isRowRemovable', () => {
it('faux quand aucun autre bloc n\'est enregistre (que des brouillons)', () => {
const rows: Row[] = [{ id: null, label: 'brouillon 1' }, { id: null, label: 'brouillon 2' }]
expect(isRowRemovable(rows, 0)).toBe(false)
expect(isRowRemovable(rows, 1)).toBe(false)
})
it('faux pour le seul bloc enregistre (un brouillon a cote ne compte pas)', () => {
const rows: Row[] = [{ id: 10, label: 'enregistre' }, { id: null, label: 'brouillon' }]
// Le bloc enregistre ne peut pas etre supprime : aucun AUTRE bloc enregistre.
expect(isRowRemovable(rows, 0)).toBe(false)
// Le brouillon peut etre jete : il reste le bloc enregistre id=10.
expect(isRowRemovable(rows, 1)).toBe(true)
})
it('vrai pour chaque bloc des qu\'au moins deux sont enregistres', () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
expect(isRowRemovable(rows, 0)).toBe(true)
expect(isRowRemovable(rows, 1)).toBe(true)
})
it('faux pour un unique bloc', () => {
expect(isRowRemovable([{ id: 10, label: 'A' }], 0)).toBe(false)
})
})
@@ -0,0 +1,19 @@
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')
})
})
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import {
sanitizeAddress,
sanitizeCodeAlnum,
sanitizeEmail,
sanitizeFreeText,
sanitizePersonName,
} from '../textSanitize'
describe('sanitizePersonName', () => {
it('garde lettres accentuees, espace, apostrophe, tiret, point', () => {
expect(sanitizePersonName('Jean-Pierre')).toBe('Jean-Pierre')
expect(sanitizePersonName('OBrien')).toBe('OBrien')
expect(sanitizePersonName("D'Angelo")).toBe("D'Angelo")
expect(sanitizePersonName('Saint-Étienne J.')).toBe('Saint-Étienne J.')
})
it('retire chiffres et caracteres parasites', () => {
expect(sanitizePersonName('Dupont²³')).toBe('Dupont')
expect(sanitizePersonName('Jean§&#~|')).toBe('Jean')
expect(sanitizePersonName('Marie123')).toBe('Marie')
})
})
describe('sanitizeFreeText', () => {
it('garde &, /, parentheses, degre, chiffres (raison sociale / fonction)', () => {
expect(sanitizeFreeText('Dupont & Fils')).toBe('Dupont & Fils')
expect(sanitizeFreeText('Resp. Achats/Ventes')).toBe('Resp. Achats/Ventes')
expect(sanitizeFreeText('SARL Léon (Pôle n°2)')).toBe('SARL Léon (Pôle n°2)')
})
it('retire les parasites ²³§~#|', () => {
expect(sanitizeFreeText('ACME²³§')).toBe('ACME')
expect(sanitizeFreeText('Test~#|<>{}')).toBe('Test')
})
})
describe('sanitizeAddress', () => {
it('garde chiffres, virgule, point, apostrophe, slash, degre, tiret', () => {
expect(sanitizeAddress('12 bis, rue de l’Église')).toBe('12 bis, rue de l’Église')
expect(sanitizeAddress('Bât. n°3 - Zone A/B')).toBe('Bât. n°3 - Zone A/B')
})
it('retire les parasites', () => {
expect(sanitizeAddress('5 rue X²³§&')).toBe('5 rue X')
})
})
describe('sanitizeEmail', () => {
it('garde les caracteres email valides', () => {
expect(sanitizeEmail('jean.dupont+pro@acme-corp.fr')).toBe('jean.dupont+pro@acme-corp.fr')
})
it('retire espaces et parasites', () => {
expect(sanitizeEmail('jean §² dupont@acme.fr')).toBe('jeandupont@acme.fr')
expect(sanitizeEmail('a&b#c@x.fr')).toBe('abc@x.fr')
})
})
describe('sanitizeCodeAlnum', () => {
it('force la majuscule et ne garde que A-Z 0-9', () => {
expect(sanitizeCodeAlnum('411dupont')).toBe('411DUPONT')
expect(sanitizeCodeAlnum('FR 12 345')).toBe('FR12345')
expect(sanitizeCodeAlnum('4-11.000§')).toBe('411000')
})
it('chaine vide reste vide', () => {
expect(sanitizeCodeAlnum('')).toBe('')
})
})
+79
View File
@@ -0,0 +1,79 @@
/** Ligne de collection supprimable (contact / adresse / RIB). */
export interface DeletableRow {
id?: number | null
}
/**
* Indique si le bloc d'index `index` peut afficher sa poubelle (ERP-172).
*
* Regle metier : on ne peut supprimer un bloc QUE s'il reste au moins un AUTRE
* bloc deja enregistre (`id` non null, donc persiste en base). Consequences :
* - tant que rien n'est enregistre -> aucune poubelle (pas de suppression d'un
* simple brouillon saisi mais pas valide) ;
* - on peut jeter un brouillon non enregistre s'il reste un bloc enregistre ;
* - on ne peut jamais supprimer son dernier bloc enregistre.
*/
export function isRowRemovable<T extends DeletableRow>(rows: T[], index: number): boolean {
return rows.some((row, i) => i !== index && row.id != null)
}
/** Options de {@link removeCollectionRow}. */
export interface RemoveCollectionRowOptions<T extends DeletableRow> {
/** Tableau reactif des brouillons (passer le `.value` de la ref). */
rows: T[]
/** Tableau reactif des erreurs par ligne, aligne sur l'index (passer le `.value`). */
errors: Record<string, string>[]
/** Index de la ligne a retirer. */
index: number
/** Endpoint de la sous-ressource SANS id (ex: '/client_contacts'). */
endpoint: string
/** Suppression serveur : DOIT rejeter en cas d'echec (ex: url => api.delete(url, {}, { toast: false })). */
deleteRow: (url: string) => Promise<unknown>
/** Fabrique d'un bloc vide pour garder au moins un bloc visible apres retrait. */
makeEmpty: () => T
/** Remontee d'erreur 409/422 mappee proprement (message back, pas de toast fourre-tout). */
onError: (error: unknown) => void
}
/**
* Retire une ligne de collection (contact / adresse / RIB) sur les ecrans de
* MODIFICATION, avec DELETE immediat de la sous-ressource (ERP-172). Comportement
* aligne sur les 3 modules (Client / Fournisseur / Prestataire) :
*
* - Bloc jamais persiste (`id` null) : simple retrait local, aucun appel reseau.
* - Bloc existant (`id` non null) : DELETE `/endpoint/{id}` AVANT le retrait du
* tableau. On ne retire le bloc QUE si le serveur a confirme sinon le bloc
* reste affiche et l'erreur est remontee via `onError` (ex. dernier RIB d'une
* LCR -> 409 back, RG-x.08).
*
* Etat purement local : `rows`/`errors` sont les `.value` des refs (proxies
* reactifs), le `splice` declenche donc la reactivite.
*
* @returns `true` si la ligne a ete retiree (suppression confirmee ou bloc local),
* `false` si la suppression serveur a echoue (bloc conserve).
*/
export async function removeCollectionRow<T extends DeletableRow>(
options: RemoveCollectionRowOptions<T>,
): Promise<boolean> {
const { rows, errors, index, endpoint, deleteRow, makeEmpty, onError } = options
const removed = rows[index]
// Bloc existant : suppression serveur d'abord, retrait local seulement si OK.
if (removed?.id != null) {
try {
await deleteRow(`${endpoint}/${removed.id}`)
}
catch (error) {
onError(error)
return false
}
}
rows.splice(index, 1)
errors.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (rows.length === 0) {
rows.push(makeEmpty())
}
return true
}
+17
View File
@@ -0,0 +1,17 @@
/**
* 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}`
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Filtres de saisie texte (retour metier ERP-193) : on retire a la frappe / au
* collage les caracteres parasites (« ²³§~#| ») des champs texte libres.
*
* 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 fonctions ne font que le confort de saisie.
* Purs / testables.
*
* IMPORTANT : garder les classes de caracteres STRICTEMENT alignees sur le back
* (toute divergence = soit un caractere bloque au front mais accepte au back, soit
* l'inverse 422 surprise).
*/
/**
* Noms de personnes (Nom, Prenom, Dirigeant) : lettres (accents), espace,
* apostrophe droite/courbe, tiret, point.
*/
export function sanitizePersonName(value: string): string {
return value.replace(/[^\p{L}\p{M} '.-]/gu, '')
}
/**
* Texte societe / libre (Raison sociale, Concurrents, Fonction) : nom + chiffres,
* virgule, esperluette, slash, parentheses, degre.
*/
export function sanitizeFreeText(value: string): string {
// 0-9 (et pas \p{N}) : \p{N} engloberait les exposants ² ³ — justement parasites.
return value.replace(/[^\p{L}\p{M}0-9 '.,&/()°-]/gu, '')
}
/**
* Adresse (voie, complement, ville) : lettres, chiffres, espace, apostrophe,
* point, virgule, slash, degre, tiret.
*/
export function sanitizeAddress(value: string): string {
// 0-9 (et pas \p{N}) : evite de laisser passer les exposants ² ³.
return value.replace(/[^\p{L}\p{M}0-9 '.,/°-]/gu, '')
}
/**
* Codes alphanumeriques majuscules (N° de compte comptable, N° de TVA, IBAN, BIC) :
* uniquement A-Z et 0-9, majuscule forcee.
*/
export function sanitizeCodeAlnum(value: string): string {
return value.toUpperCase().replace(/[^A-Z0-9]/g, '')
}
/**
* Email : retire espaces et caracteres impossibles dans une adresse, en gardant
* le jeu de caracteres email valides (lettres, chiffres, @ . _ % + - '). La
* validation de FORMAT reste au back (Assert\Email) ; ici on bloque juste les
* parasites (« ²³§~#| ») a la frappe. La normalisation lowercase est portee par
* MalioInputEmail (prop `lowercase`), on ne la duplique pas.
*/
export function sanitizeEmail(value: string): string {
return value.replace(/[^A-Za-z0-9@._%+'-]/g, '')
}
+8
View File
@@ -95,6 +95,14 @@ export const personas: Record<PersonaKey, Persona> = {
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
'technique.providers.archive',
// Transport — Repertoire transporteurs (M4, ERP-164). Meme logique :
// mappe sur le persona "tout", pas de nouveau persona (regle ABSOLUE
// n°7). L'item transporteurs vit desormais dans la section Administration
// (1er item, ERP-164) mais sur la route `/carriers` (hors `/admin/<slug>`),
// donc il n'entre pas dans ALL_ADMIN_LINKS : expectedAdminLinks reste inchange.
'transport.carriers.view',
'transport.carriers.manage',
'transport.carriers.archive',
],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
},
+17
View File
@@ -232,6 +232,7 @@ test-db-setup:
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_carrier_name_active ON carrier (LOWER(name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
fixtures:
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
@@ -250,6 +251,22 @@ sync-permissions:
seed-rbac:
$(SYMFONY_CONSOLE) --no-interaction app:seed-rbac
# Synchronise le referentiel des transporteurs QUALIMAT (ERP-39) : upsert sur
# le SIRET + soft-delete des absents + journal. Idempotent (refresh complet),
# prevu pour un cron quotidien.
# Options : --dry-run (analyse sans ecriture), --file=<chemin.json> (source
# locale au lieu de l'API), --ppp=<n> (taille de page API, defaut 10000).
qualimat-sync:
$(SYMFONY_CONSOLE) --no-interaction app:qualimat:sync
# Synchronise le referentiel des codes IDTF (ERP-149) depuis l'export Excel
# icrt-idtf.com : upsert sur (schema, idtf_number) + soft-delete + journal.
# Idempotent (refresh complet).
# Options : --schema=road|water (defaut road), --dry-run (analyse sans
# ecriture), --file=<chemin.xlsx> (source locale au lieu du telechargement).
idtf-sync:
$(SYMFONY_CONSOLE) --no-interaction app:idtf:sync
# Attention, supprime votre bdd local
db-reset:
$(DOCKER_COMPOSE) down -v
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-149 (Module Transport) : referentiel des codes IDTF (regimes de nettoyage
* transport).
*
* Tables alimentees par la commande `app:idtf:sync` (parsing de l'export Excel
* icrt-idtf.com, upsert sur (schema, idtf_number) + soft-delete + journal).
* Aucune FK cross-module : migration au namespace racine `DoctrineMigrations`.
*/
final class Version20260612160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-149 : tables idtf_product + idtf_sync_log (referentiel codes IDTF, synchro console depuis l\'export Excel).';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE idtf_product (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
idtf_number INTEGER NOT NULL,
schema VARCHAR(8) NOT NULL,
product_group VARCHAR(255) DEFAULT NULL,
name TEXT NOT NULL,
cleaning_regime VARCHAR(64) NOT NULL,
important_requirements TEXT DEFAULT NULL,
mandatory_date DATE DEFAULT NULL,
related_products TEXT DEFAULT NULL,
formula VARCHAR(255) DEFAULT NULL,
eural_code VARCHAR(64) DEFAULT NULL,
cas_numbers JSONB DEFAULT '[]' NOT NULL,
footnotes TEXT DEFAULT NULL,
source_export_date DATE NOT NULL,
is_active BOOLEAN DEFAULT TRUE NOT NULL,
last_synced_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY (id),
CONSTRAINT uq_idtf_product_schema_number UNIQUE (schema, idtf_number),
CONSTRAINT chk_idtf_product_schema CHECK (schema IN ('road', 'water'))
)
SQL);
$this->addSql('CREATE INDEX idx_idtf_product_active ON idtf_product (schema, is_active)');
$this->comment('idtf_product', '_table', "Referentiel des codes IDTF (marchandise + regime de nettoyage transport), synchronise depuis l'export Excel icrt-idtf.com.");
$this->comment('idtf_product', 'id', 'Cle technique auto-incrementee.');
$this->comment('idtf_product', 'idtf_number', 'Numero IDTF de la marchandise (identifiant metier source). Unique par schema.');
$this->comment('idtf_product', 'schema', "Mode de transport / schema IDTF : 'road' (routier) ou 'water' (fluvial). Discriminant d'unicite avec idtf_number.");
$this->comment('idtf_product', 'product_group', "Groupe de produit (colonne Product Group de l'export). Nullable.");
$this->comment('idtf_product', 'name', "Nom de la marchandise (libelle FR de l'export).");
$this->comment('idtf_product', 'cleaning_regime', 'Regime de nettoyage minimal exige (A, B, C, Interdit, ...).');
$this->comment('idtf_product', 'important_requirements', 'Exigences importantes associees. Nullable.');
$this->comment('idtf_product', 'mandatory_date', "Date d'application obligatoire du regime (convertie depuis dd-mm-yyyy). Nullable.");
$this->comment('idtf_product', 'related_products', 'Produits apparentes (texte libre). Nullable.');
$this->comment('idtf_product', 'formula', 'Formule chimique de la marchandise. Nullable.');
$this->comment('idtf_product', 'eural_code', 'Code EURAL (dechet) associe. Nullable.');
$this->comment('idtf_product', 'cas_numbers', 'Liste des numeros CAS (JSONB), eclatee depuis la cellule "Numero CAS" separee par ";". Tableau vide si absent.');
$this->comment('idtf_product', 'footnotes', "Annotations / notes de bas de page de l'export. Nullable.");
$this->comment('idtf_product', 'source_export_date', 'Date d\'export du fichier source (preambule "Export date:").');
$this->comment('idtf_product', 'is_active', 'Faux = ligne absente du dernier export (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.');
$this->comment('idtf_product', 'last_synced_at', 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).');
$this->addSql(<<<'SQL'
CREATE TABLE idtf_sync_log (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
schema VARCHAR(8) NOT NULL,
export_date DATE NOT NULL,
rows_total INT NOT NULL,
rows_upserted INT NOT NULL,
rows_deactivated INT NOT NULL,
created_at TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW() NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->comment('idtf_sync_log', '_table', 'Journal des synchronisations IDTF (une ligne par run de la commande app:idtf:sync).');
$this->comment('idtf_sync_log', 'id', 'Cle technique auto-incrementee.');
$this->comment('idtf_sync_log', 'schema', "Mode de transport synchronise : 'road' ou 'water'.");
$this->comment('idtf_sync_log', 'export_date', "Date d'export du fichier source traite par ce run.");
$this->comment('idtf_sync_log', 'rows_total', 'Nombre de lignes exploitables lues dans le fichier.');
$this->comment('idtf_sync_log', 'rows_upserted', 'Nombre de lignes inserees ou mises a jour.');
$this->comment('idtf_sync_log', 'rows_deactivated', 'Nombre de lignes passees a is_active=false (absentes de cet export).');
$this->comment('idtf_sync_log', 'created_at', 'Horodatage de fin du run (insertion du journal).');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS idtf_sync_log');
$this->addSql('DROP TABLE IF EXISTS idtf_product');
}
/**
* 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,
));
}
}

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