Compare commits

...

39 Commits

Author SHA1 Message Date
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
gitea-actions 6a83adc00a chore: bump version to v0.1.120
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 42s
2026-06-15 09:29:53 +00:00
tristan c76c447aa2 feat(front) : consultation + modification prestataire (ERP-145) (#107)
Auto Tag Develop / tag (push) Successful in 8s
Empilée sur ERP-144 (#106).

## Périmètre ERP-145
Écrans **Consultation** (lecture seule) et **Modification** (édition par onglet), peuplés depuis la **seule** réponse `GET /api/providers/{id}` (embed contacts/adresses/ribs + refs comptables — pas de N+1).

### Consultation — `pages/providers/[id]/index.vue` (`/providers/{id}`)
- Ouverture par défaut sur **Contacts** ; tous champs readonly ; onglets **Contacts · Adresse · Rapports · Échanges · Comptabilité** (navigation libre). Rapports/Échanges = placeholders « À venir ».
- Flèche retour → répertoire. Bouton **Modifier** (si `manage` OU `accounting.manage`). Bouton **Archiver** (Admin seul, `archive`) → modal → PATCH `{isArchived:true}` ; **Restaurer** si archivé.
- Comptabilité visible seulement si `accounting.view` ; banque/RIB affichés selon le type de règlement (VIREMENT/LCR).

### Modification — `pages/providers/[id]/edit.vue` (`/providers/{id}/edit`)
- Pré-rempli ; **bloc principal éditable** (Nom/Catégories/Sites, PATCH `provider:write:main` via `updateMain`) ; onglets Contact/Adresse/Comptabilité en **navigation libre**, PATCH partiel par onglet (réutilise `useProviderForm` en `editMode`).
- Onglets sans permission `manage` / `accounting.manage` restent **readonly** (pas de bouton Valider / suppression). Accès réservé à `manage` OU `accounting.manage`.

### Composables / helpers
- **`useProvider(id)`** : charge le détail (ld+json) + archive/restore (PATCH isArchived seul, puis rechargement).
- **`useProviderForm`** étendu : `updateMain()` (PATCH principal en édition) + `editMode` (completeTab ne verrouille/avance plus).
- **`providerDetail.ts`** : mapping embed → brouillons + options role-indépendantes (libellés depuis l'embed) + règles d'actions (Modifier/Archiver/Restaurer).

## Conformité
- `useApi()` only ; `Malio*` only ; `usePermissions()` pour boutons/onglets ; aucun texte FR en dur ; pas d'import inter-module (règle ABSOLUE n°1).

## Vérifications
- Vitest : 470/470 (16 nouveaux : mapping détail, actions par permission, updateMain + editMode).
- ESLint : OK · `nuxi typecheck` : 0 erreur sur les fichiers source du ticket.
- Golden path navigateur : **Consultation** (ACME) — bloc principal readonly + libellés catégories/sites résolus depuis l'embed, 5 onglets, Modifier+Archiver visibles (admin), Comptabilité readonly. **Modification** — bloc principal éditable pré-rempli (Site « 86 17 »), 3 onglets navigation libre, onglet Contact pré-rempli.

Reviewed-on: #107
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 09:29:44 +00:00
137 changed files with 15507 additions and 482 deletions
+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.119'
app.version: '0.1.129'
@@ -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.
+98 -6
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",
@@ -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",
@@ -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.",
@@ -384,15 +389,38 @@
"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"
},
"tab": {
"contact": "Contact",
"contacts": "Contacts",
"address": "Adresse",
"reports": "Rapports",
"exchanges": "Échanges",
"accounting": "Comptabilité"
},
"action": {
"edit": "Modifier",
"archive": "Archiver",
"restore": "Restaurer"
},
"consultation": {
"title": "Fiche prestataire",
"back": "Retour au répertoire",
"loading": "Chargement…",
"notFound": "Prestataire introuvable.",
"confirmArchive": "Archiver ce prestataire ? Il n'apparaîtra plus dans le répertoire actif.",
"confirmRestore": "Restaurer ce prestataire ? Il réapparaîtra dans le répertoire actif."
},
"edit": {
"title": "Modifier le prestataire",
"back": "Retour à la fiche",
"loading": "Chargement…",
"notFound": "Prestataire introuvable.",
"save": "Enregistrer"
},
"form": {
"title": "Ajouter un prestataire",
"back": "Précédent",
@@ -404,6 +432,7 @@
"sites": "Site"
},
"errors": {
"nameRequired": "Le nom du prestataire est obligatoire.",
"siteRequired": "Sélectionnez au moins un site.",
"categoryRequired": "Sélectionnez au moins une catégorie."
},
@@ -459,7 +488,66 @@
"error": "Une erreur est survenue. Réessayez.",
"exportError": "L'export du répertoire prestataires a échoué. Réessayez.",
"createSuccess": "Prestataire créé avec succès",
"updateSuccess": "Prestataire mis à jour avec succès"
"updateSuccess": "Prestataire mis à jour avec succès",
"addComplete": "Prestataire ajouté",
"archiveSuccess": "Prestataire archivé avec succès",
"restoreSuccess": "Prestataire restauré avec succès"
}
}
},
"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 activité"
},
"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"
},
"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"
},
"errors": {
"nameRequired": "Le nom du transporteur est obligatoire."
}
}
}
},
@@ -521,7 +609,11 @@
"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"
@@ -51,7 +51,7 @@ describe('useSuppliersRepository', () => {
search: 'acme',
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
'siteId[]': ['86', '17'],
includeArchived: true,
archivedOnly: true,
},
{ replace: true },
)
@@ -63,7 +63,7 @@ describe('useSuppliersRepository', () => {
search: 'acme',
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
'siteId[]': ['86', '17'],
includeArchived: true,
archivedOnly: true,
page: 1,
itemsPerPage: 10,
},
@@ -73,7 +73,7 @@ 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(
@@ -41,9 +41,10 @@ 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
@@ -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).
@@ -157,12 +157,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. -->
<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"
:removable="isRowRemovable(contacts, index)"
:readonly="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -199,7 +203,7 @@
:site-options="siteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="addresses.length > 1"
:removable="isRowRemovable(addresses, index)"
:readonly="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@@ -304,7 +308,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"
@@ -440,6 +444,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).
@@ -490,10 +495,6 @@ 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 +755,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 +836,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 +856,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 +933,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 +1012,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) {
@@ -156,12 +156,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. -->
<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"
:removable="isRowRemovable(contacts, index)"
:readonly="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -198,7 +202,7 @@
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="index > 0"
:removable="isRowRemovable(addresses, index)"
:readonly="isValidated('address')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@@ -303,7 +307,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"
@@ -417,6 +421,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 = '#########'
@@ -126,12 +126,16 @@
<!-- 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"
:removable="isRowRemovable(contacts, index)"
:readonly="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -168,7 +172,7 @@
:site-options="siteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="addresses.length > 1"
:removable="isRowRemovable(addresses, index)"
:readonly="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@@ -273,7 +277,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"
@@ -407,6 +411,7 @@ import {
type SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm'
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).
@@ -456,10 +461,6 @@ 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 +654,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 +726,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 +746,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 +822,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 +842,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 +897,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) {
@@ -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 })
}
@@ -121,12 +121,16 @@
<!-- 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"
:removable="isRowRemovable(contacts, index)"
:readonly="isValidated('contacts')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -163,7 +167,7 @@
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="index > 0"
:removable="isRowRemovable(addresses, index)"
:readonly="isValidated('addresses')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@@ -267,7 +271,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"
@@ -380,6 +384,7 @@ import {
type SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
@@ -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"
@@ -69,14 +69,14 @@ describe('useProviderForm', () => {
permState.accountingManage = false
})
it('RG-3.03/RG-3.09 (front) : bloque le POST si aucun site / aucune categorie', async () => {
it('front : formulaire principal vide -> erreurs sur nom + site + categorie, pas de POST', async () => {
const form = useProviderForm()
form.main.companyName = 'Maintenance Pro'
const created = await form.submitMain()
expect(created).toBe(false)
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired')
expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
expect(form.mainLocked.value).toBe(false)
@@ -122,18 +122,17 @@ describe('useProviderForm', () => {
expect(form.unlockedIndex.value).toBe(0)
})
it('omet companyName vide du payload (laisse la 422 NotBlank back mordre)', async () => {
mockPost.mockResolvedValueOnce({ id: 1, companyName: null })
it('front : nom vide/espaces -> erreur inline sur companyName, pas de POST', async () => {
const form = useProviderForm()
form.main.companyName = ' '
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
await form.submitMain()
const created = await form.submitMain()
const body = (mockPost.mock.calls[0] ?? [])[1] as Record<string, unknown>
expect(body).not.toHaveProperty('companyName')
expect(body).toEqual({ categories: [CAT_MAINT], sites: [SITE_86] })
expect(created).toBe(false)
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired')
})
it('409 doublon (RG-3.10) : erreur inline dediee sur companyName, pas de verrouillage', async () => {
@@ -585,3 +584,70 @@ describe('useProviderForm — onglet Comptabilite (ERP-144)', () => {
expect(mockPatch).not.toHaveBeenCalled()
})
})
describe('useProviderForm — modification (ERP-145)', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
permState.accountingView = false
permState.accountingManage = false
})
it('editMode : completeTab ne verrouille pas et ne bascule pas d\'onglet', () => {
const form = useProviderForm()
form.editMode.value = true
form.activeTab.value = 'contact'
expect(form.completeTab('contact')).toBe(false)
expect(form.isValidated('contact')).toBe(false)
expect(form.activeTab.value).toBe('contact')
})
it('updateMain : PATCH /providers/{id} sur le groupe principal (pas de POST)', async () => {
mockPatch.mockResolvedValueOnce({ id: 7, companyName: 'MAINTENANCE PRO' })
const form = useProviderForm()
form.providerId.value = 7
form.main.companyName = 'Maintenance Pro'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const ok = await form.updateMain()
expect(ok).toBe(true)
expect(mockPost).not.toHaveBeenCalled()
expect(mockPatch).toHaveBeenCalledWith(
'/providers/7',
{ companyName: 'Maintenance Pro', categories: [CAT_MAINT], sites: [SITE_86] },
{ toast: false },
)
// Reaffiche le nom normalise renvoye par le serveur.
expect(form.main.companyName).toBe('MAINTENANCE PRO')
})
it('updateMain : RG-3.03 front -> bloque le PATCH sans site', async () => {
const form = useProviderForm()
form.providerId.value = 7
form.main.companyName = 'X'
form.main.categoryIris = [CAT_MAINT]
const ok = await form.updateMain()
expect(ok).toBe(false)
expect(mockPatch).not.toHaveBeenCalled()
expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
})
it('updateMain : 409 doublon -> erreur inline sur companyName', async () => {
mockPatch.mockRejectedValueOnce({ response: { status: 409 } })
const form = useProviderForm()
form.providerId.value = 7
form.main.companyName = 'Doublon'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const ok = await form.updateMain()
expect(ok).toBe(false)
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany')
})
})
@@ -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(() => {
@@ -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)
})
})
@@ -0,0 +1,70 @@
import { ref } from 'vue'
import type { ProviderDetail } from '~/modules/technique/utils/forms/providerDetail'
/**
* Chargement et actions d'archivage d'un prestataire unique (ecrans Consultation /
* Modification, ERP-145). Miroir de `useSupplier` (M2). Lit le detail embarque via
* `GET /api/providers/{id}` (contacts / adresses + leurs sous-collections / ribs
* sous `provider:item:read` / `provider:read:accounting`) une SEULE requete
* peuple les deux ecrans (embed borne, pas de N+1).
*
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload Hydra
* complet (avec les `@id` des relations embarquees, indispensables au pre-remplissage).
*
* Etat 100 % local a l'instance (refs). Les erreurs d'archivage / restauration
* (notamment le 409 d'homonyme actif a la restauration) sont PROPAGEES a l'appelant,
* qui decide du toast a afficher.
*/
export function useProvider(id: number | string) {
const api = useApi()
const provider = ref<ProviderDetail | null>(null)
const loading = ref(false)
const error = ref(false)
/** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */
function fetchDetail(): Promise<ProviderDetail> {
return api.get<ProviderDetail>(
`/providers/${id}`,
{},
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
}
/** Charge le detail du prestataire. En cas d'echec : `error = true`, `provider = null`. */
async function load(): Promise<void> {
loading.value = true
error.value = false
try {
provider.value = await fetchDetail()
}
catch {
error.value = true
provider.value = null
}
finally {
loading.value = false
}
}
/**
* Bascule l'archivage (PATCH `isArchived` SEUL groupe provider:write:archive ;
* tout autre champ => 422), puis RECHARGE le detail complet : la reponse du PATCH
* ne porte que `provider:read` (ni l'embed des sous-collections ni les libelles
* comptables), un simple merge laisserait l'affichage incoherent. Toute erreur est
* propagee a l'appelant AVANT le rechargement.
*/
async function setArchived(isArchived: boolean): Promise<void> {
await api.patch(`/providers/${id}`, { isArchived }, { toast: false })
provider.value = await fetchDetail()
}
return {
provider,
loading,
error,
load,
archive: () => setArchived(true),
restore: () => setArchived(false),
}
}
@@ -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,
@@ -20,6 +21,7 @@ import {
import {
buildProviderContactPayload,
isProviderContactBlank,
isProviderContactNamed,
} from '~/modules/technique/utils/forms/providerContact'
import {
buildProviderAddressPayload,
@@ -72,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)
@@ -91,6 +103,9 @@ export function useProviderForm() {
const activeTab = ref<string>('contact')
// Onglets valides (passent en lecture seule).
const validated = reactive<Record<string, boolean>>({})
// Mode MODIFICATION (ERP-145) : navigation libre, pas de verrouillage ni de
// bascule automatique d'onglet a la validation (cf. completeTab).
const editMode = ref(false)
function isValidated(key: string): boolean {
return validated[key] === true
@@ -108,6 +123,10 @@ export function useProviderForm() {
*/
function validateMainFront(): boolean {
let valid = true
if (!main.companyName?.trim()) {
mainErrors.setError('companyName', t('technique.providers.form.errors.nameRequired'))
valid = false
}
if (main.siteIris.length === 0) {
mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired'))
valid = false
@@ -192,12 +211,55 @@ export function useProviderForm() {
await api.patch(`/providers/${providerId.value}`, payload, { toast: false })
}
/**
* MODIFICATION du bloc principal (ERP-145) : PATCH /providers/{id} sur le groupe
* provider:write:main (nom + categories + sites). Pre-check front RG-3.03/3.09,
* 409 doublon de nom (RG-3.10) et 422 mappes inline comme a la creation. A la
* difference de `submitMain`, ne verrouille rien et ne bascule pas d'onglet (la
* navigation est libre en modification). Retourne true si le PATCH a reussi.
*/
async function updateMain(): Promise<boolean> {
if (providerId.value === null || mainSubmitting.value) return false
mainErrors.clearErrors()
if (!validateMainFront()) return false
mainSubmitting.value = true
try {
const updated = await api.patch<ProviderMainResponse>(
`/providers/${providerId.value}`,
buildMainPayload(),
{ toast: false },
)
main.companyName = updated.companyName ?? main.companyName
return true
}
catch (error) {
const status = (error as { response?: { status?: number } })?.response?.status
if (status === 409) {
const message = t('technique.providers.form.duplicateCompany')
mainErrors.setError('companyName', message)
toast.error({ title: t('technique.providers.toast.error'), message })
}
else {
mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
}
return false
}
finally {
mainSubmitting.value = false
}
}
/**
* Marque un onglet valide (passe en lecture seule), deverrouille et avance a
* l'onglet suivant. Retourne true si c'etait le dernier onglet du flux
* (creation terminee), false sinon.
*/
function completeTab(key: string): boolean {
// En modification : navigation libre, l'onglet reste editable apres validation.
if (editMode.value) {
return false
}
validated[key] = true
const index = tabIndex(key)
const next = tabKeys.value[index + 1]
@@ -253,10 +315,11 @@ export function useProviderForm() {
// Erreurs 422 par ligne (alignees sur l'index du v-for), peuplees par submitRows.
const contactErrors = ref<Record<string, string>[]>([])
// « + Nouveau contact » desactive tant que le dernier bloc est vide (RG-3.04).
// « + Nouveau contact » desactive tant que le dernier bloc n'a pas de nom OU
// prenom (RG-3.04, aligne M1/M2 — fonction/tel/email seuls ne suffisent pas).
const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
return last !== undefined && !isProviderContactBlank(last)
return last !== undefined && isProviderContactNamed(last)
})
function addContact(): void {
@@ -265,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,
})
}
/**
@@ -335,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,
})
}
/**
@@ -427,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,
})
}
/**
@@ -521,6 +606,7 @@ export function useProviderForm() {
activeTab,
unlockedIndex,
validated,
editMode,
isValidated,
// contacts
contacts,
@@ -551,6 +637,7 @@ export function useProviderForm() {
validateMainFront,
buildMainPayload,
submitMain,
updateMain,
patchProvider,
completeTab,
submitRows,
@@ -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.
@@ -0,0 +1,543 @@
<template>
<div>
<!-- En-tete : retour consultation + nom du prestataire. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('technique.providers.edit.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('technique.providers.edit.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('technique.providers.edit.notFound') }}</p>
<template v-else-if="provider">
<!-- 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"
:label="t('technique.providers.form.main.companyName')"
:required="true"
:readonly="businessReadonly"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:label="t('technique.providers.form.main.categories')"
:display-tag="true"
:readonly="businessReadonly"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/>
<MalioSelectCheckbox
:model-value="main.siteIris"
:options="referentials.sites.value"
:label="t('technique.providers.form.main.sites')"
:display-tag="true"
:readonly="businessReadonly"
:required="true"
:error="mainErrors.errors.sites"
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
/>
</div>
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('technique.providers.edit.save')"
:disabled="mainSubmitting"
@click="onUpdateMain"
/>
</div>
<!-- Onglets : navigation LIBRE, edition independante par onglet -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- 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="isRowRemovable(contacts, index)"
:readonly="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('technique.providers.form.contact.add')"
:disabled="!canAddContact"
@click="addContact"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.edit.save')"
:disabled="tabSubmitting"
@click="onSubmitContacts"
/>
</div>
</div>
</template>
<!-- Onglet Adresse -->
<template #address>
<div class="mt-12 flex flex-col gap-6">
<ProviderAddressBlock
v-for="(address, index) in addresses"
:key="index"
:model-value="address"
:category-options="referentials.categories.value"
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="isRowRemovable(addresses, index)"
:readonly="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
/>
<div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('technique.providers.form.address.add')"
:disabled="!canAddAddress"
@click="addAddress"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.edit.save')"
:disabled="tabSubmitting"
@click="onSubmitAddresses"
/>
</div>
</div>
</template>
<!-- Onglet Comptabilite (present si accounting.view ; editable si manage). -->
<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
v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:label="t('technique.providers.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value"
:label="t('technique.providers.form.accounting.tvaMode')"
:readonly="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"
:label="t('technique.providers.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value"
:label="t('technique.providers.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value"
:label="t('technique.providers.form.accounting.paymentType')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange"
/>
<MalioSelect
v-if="isBankRequired"
:model-value="accounting.bankIri"
:options="referentials.banks.value"
:label="t('technique.providers.form.accounting.bank')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
/>
</div>
</div>
<!-- Blocs RIB affiches uniquement si type de reglement = LCR (RG-3.08). -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
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 && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:required="true"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:label="t('technique.providers.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="true"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:label="t('technique.providers.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="true"
:error="ribErrors[index]?.iban"
/>
</div>
</div>
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
<MalioButton
v-if="isRibRequired"
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('technique.providers.form.accounting.addRib')"
:disabled="!canAddRib"
@click="addRib"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.edit.save')"
:disabled="tabSubmitting"
@click="onSubmitAccounting"
/>
</div>
</div>
</template>
</MalioTabList>
</template>
<!-- Modal de confirmation generique (suppression contact / adresse / RIB). -->
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('technique.providers.form.confirmDelete.title') }}</h2>
</template>
<p>{{ confirmModal.message }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('technique.providers.form.confirmDelete.cancel')"
@click="confirmModal.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="t('technique.providers.form.confirmDelete.confirm')"
@click="runConfirm"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useProvider } from '~/modules/technique/composables/useProvider'
import { useProviderReferentials, type RefOption } from '~/modules/technique/composables/useProviderReferentials'
import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
import {
canEditProvider,
irisOf,
mapAccountingDraft,
mapAddressToDraft,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
} from '~/modules/technique/utils/forms/providerDetail'
import {
isBankRequiredForPaymentType,
isRibRequiredForPaymentType,
} from '~/modules/technique/utils/forms/providerAccounting'
import {
emptyProviderAddress,
emptyProviderContact,
emptyProviderRib,
} from '~/modules/technique/types/providerForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { can, canAny } = usePermissions()
const providerId = route.params.id as string
// Acces : l'edition exige `manage` OU `accounting.manage` (le role Compta edite
// son onglet). Sinon retour consultation.
if (!canEditProvider(canAny)) {
await navigateTo(`/providers/${providerId}`)
}
const businessReadonly = computed(() => !can('technique.providers.manage'))
const referentials = useProviderReferentials()
const { provider, loading, error, load } = useProvider(providerId)
const {
main,
providerId: formProviderId,
mainErrors,
mainSubmitting,
tabSubmitting,
editMode,
canAccountingView,
tabKeys,
activeTab,
contacts,
contactErrors,
canAddContact,
addContact,
removeContact,
submitContacts,
addresses,
addressErrors,
canAddAddress,
addAddress,
removeAddress,
submitAddresses,
accounting,
ribs,
accountingErrors,
ribErrors,
accountingReadonly,
setPaymentType,
canAddRib,
addRib,
removeRib,
submitAccounting,
updateMain,
} = useProviderForm()
// Modification : navigation libre + pas de verrouillage a la validation.
editMode.value = true
activeTab.value = 'contact'
const headerTitle = computed(() => provider.value?.companyName || t('technique.providers.edit.title'))
useHead({ title: t('technique.providers.edit.title') })
// Onglets (navigation libre ; Comptabilite si accounting.view)
const TAB_ICONS: Record<string, string> = {
contact: 'mdi:account-box-plus-outline',
address: 'mdi:map-marker-outline',
accounting: 'mdi:bank-circle-outline',
}
const tabs = computed(() => tabKeys.value.map(key => ({
key,
label: t(`technique.providers.tab.${key}`),
icon: TAB_ICONS[key],
})))
/** Pre-remplit les brouillons depuis la SEULE reponse detail. */
function prefill(): void {
const d = provider.value
if (!d) return
// Indispensable : pilote les URLs des PATCH/POST par onglet (sinon les submits no-op).
formProviderId.value = d.id
main.companyName = d.companyName ?? null
main.categoryIris = irisOf(d.categories)
main.siteIris = irisOf(d.sites)
const mappedContacts = (d.contacts ?? []).map(mapContactToDraft)
contacts.value = mappedContacts.length > 0 ? mappedContacts : [emptyProviderContact()]
const mappedAddresses = (d.addresses ?? []).map(mapAddressToDraft)
addresses.value = mappedAddresses.length > 0 ? mappedAddresses : [emptyProviderAddress()]
if (canAccountingView.value) {
Object.assign(accounting, mapAccountingDraft(d))
ribs.value = (d.ribs ?? []).map(mapRibToDraft)
// Garantit un bloc RIB visible si le type de reglement est LCR.
if (isRibRequiredForPaymentType(paymentTypeCodeOf(d.paymentType)) && ribs.value.length === 0) {
ribs.value.push(emptyProviderRib())
}
}
}
// Comptabilite : RG-3.07 / RG-3.08 pilotees par le code du type de reglement
const selectedPaymentTypeCode = computed(() =>
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
)
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
function onPaymentTypeChange(value: string | number | null): void {
const iri = value === null ? null : String(value)
const code = referentials.paymentTypes.value.find(p => p.value === iri)?.code ?? null
setPaymentType(iri, isBankRequiredForPaymentType(code), isRibRequiredForPaymentType(code))
}
// Options adresses
const contactOptions = computed<RefOption[]>(() =>
contacts.value
.filter(c => c.iri !== null)
.map(c => ({
value: c.iri as string,
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
})),
)
const countryOptions = computed<RefOption[]>(() => {
const list = referentials.countries.value
return list.some(c => c.value === 'France')
? list
: [{ value: 'France', label: 'France' }, ...list]
})
const addressDegradedNotified = ref(false)
function onAddressDegraded(): void {
if (addressDegradedNotified.value) return
addressDegradedNotified.value = true
toast.warning({
title: t('technique.providers.toast.error'),
message: t('technique.providers.form.address.degraded'),
})
}
// Navigation + helpers
function goBack(): void {
router.push(`/providers/${providerId}`)
}
function apiErrorMessage(err: unknown): string {
const data = (err as { response?: { _data?: unknown } })?.response?._data
return extractApiErrorMessage(data) || t('technique.providers.toast.error')
}
/** PATCH du bloc principal (groupe provider:write:main). */
async function onUpdateMain(): Promise<void> {
if (await updateMain()) {
toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
}
async function onSubmitContacts(): Promise<void> {
const ok = await submitContacts(err => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(err),
}))
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
async function onSubmitAddresses(): Promise<void> {
const ok = await submitAddresses(err => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(err),
}))
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
async function onSubmitAccounting(): Promise<void> {
const ok = await submitAccounting(
isBankRequired.value,
isRibRequired.value,
err => toast.error({ title: t('technique.providers.toast.error'), message: apiErrorMessage(err) }),
)
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
// Modal de confirmation generique
const confirmModal = reactive({
open: false,
message: '',
action: null as null | (() => void),
})
function askConfirm(message: string, action: () => void): void {
confirmModal.message = message
confirmModal.action = action
confirmModal.open = true
}
function runConfirm(): void {
confirmModal.action?.()
confirmModal.action = null
confirmModal.open = false
}
function askRemoveContact(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.contact'), () => removeContact(index))
}
function askRemoveAddress(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.address'), () => removeAddress(index))
}
function askRemoveRib(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index))
}
onMounted(async () => {
referentials.loadMain().catch(() => {})
if (canAccountingView.value) {
referentials.loadAccounting().catch(() => {})
}
await load()
prefill()
})
</script>
@@ -0,0 +1,308 @@
<template>
<div>
<!-- En-tete : retour repertoire + nom du prestataire + 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('technique.providers.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('technique.providers.action.edit')"
@click="goEdit"
/>
<MalioButton
v-if="showArchive"
variant="secondary"
icon-name="mdi:archive-arrow-down-outline"
icon-position="left"
:label="t('technique.providers.action.archive')"
@click="askToggleArchive"
/>
<MalioButton
v-if="showRestore"
variant="secondary"
icon-name="mdi:archive-arrow-up-outline"
icon-position="left"
:label="t('technique.providers.action.restore')"
@click="askToggleArchive"
/>
</div>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('technique.providers.consultation.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('technique.providers.consultation.notFound') }}</p>
<template v-else-if="provider">
<!-- Bloc principal (lecture seule) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
:model-value="provider.companyName"
:label="t('technique.providers.form.main.companyName')"
readonly
/>
<MalioSelectCheckbox
:model-value="mainCategoryIris"
:options="mainCategoryOptions"
:label="t('technique.providers.form.main.categories')"
:display-tag="true"
readonly
/>
<MalioSelectCheckbox
:model-value="mainSiteIris"
:options="mainSiteOptions"
:label="t('technique.providers.form.main.sites')"
:display-tag="true"
readonly
/>
</div>
<!-- Onglets (navigation libre, tout en lecture seule) -->
<MalioTabList 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">
<ProviderContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
readonly
/>
</div>
</template>
<!-- Onglet Adresse -->
<template #address>
<div class="mt-12 flex flex-col gap-6">
<ProviderAddressBlock
v-for="(view, index) in addressViews"
:key="index"
:model-value="view.draft"
:category-options="view.categoryOptions"
:site-options="view.siteOptions"
:contact-options="contactOptions"
:country-options="countryOptionsFor(view.draft.country)"
readonly
/>
</div>
</template>
<!-- Onglets placeholder « A venir » (comme les autres modules). -->
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
<!-- 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="" />
</div>
</div>
<!-- Blocs RIB (uniquement si type de reglement = LCR). -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
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 />
</div>
</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('technique.providers.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 } from 'vue'
import { useProvider } from '~/modules/technique/composables/useProvider'
import {
canEditProvider,
categoryOptionsOf,
contactOptionsOf,
irisOf,
mapAccountingDraft,
mapAddressToDraft,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf,
showArchiveAction,
showRestoreAction,
siteOptionsOf,
} from '~/modules/technique/utils/forms/providerDetail'
import { isBankRequiredForPaymentType, isRibRequiredForPaymentType } from '~/modules/technique/utils/forms/providerAccounting'
import { emptyProviderAddress, emptyProviderContact } from '~/modules/technique/types/providerForm'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { can, canAny } = usePermissions()
const providerId = route.params.id as string
const { provider, loading, error, load, archive, restore } = useProvider(providerId)
const canAccountingView = computed(() => can('technique.providers.accounting.view'))
const canEdit = computed(() => canEditProvider(canAny))
const isArchived = computed(() => provider.value?.isArchived ?? false)
const showArchive = computed(() => showArchiveAction(can, isArchived.value))
const showRestore = computed(() => showRestoreAction(can, isArchived.value))
const headerTitle = computed(() => provider.value?.companyName || t('technique.providers.consultation.title'))
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',
reports: 'mdi:file-chart-outline',
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] }))
})
// Donnees mappees depuis la SEULE reponse detail
const mainCategoryIris = computed(() => irisOf(provider.value?.categories))
const mainSiteIris = computed(() => irisOf(provider.value?.sites))
const mainCategoryOptions = computed(() => categoryOptionsOf(provider.value?.categories))
const mainSiteOptions = computed(() => siteOptionsOf(provider.value?.sites))
// Au moins un bloc affiche meme sans donnee (bloc vide en lecture seule, comme
// l'onglet Comptabilite et les autres modules pas de message « Aucun »).
const contacts = computed(() => {
const list = (provider.value?.contacts ?? []).map(mapContactToDraft)
return list.length > 0 ? list : [emptyProviderContact()]
})
// Contacts rattachables (pour resoudre les libelles des contacts lies aux adresses).
const contactOptions = computed(() => contactOptionsOf(provider.value?.contacts))
// Vue par adresse : brouillon + options propres a l'adresse (sites/categories embarques).
const addressViews = computed(() => {
const views = (provider.value?.addresses ?? []).map(address => ({
draft: mapAddressToDraft(address),
siteOptions: siteOptionsOf(address.sites),
categoryOptions: categoryOptionsOf(address.categories),
}))
return views.length > 0
? views
: [{ draft: emptyProviderAddress(), siteOptions: [], categoryOptions: [] }]
})
/** Pays : une seule option (la valeur courante), suffisant pour l'affichage readonly. */
function countryOptionsFor(country: string): { value: string, label: string }[] {
return country ? [{ value: country, label: country }] : []
}
// Comptabilite (presente uniquement si accounting.view)
const accounting = computed(() => mapAccountingDraft(provider.value ?? { id: 0, '@id': '' }))
const paymentTypeCode = computed(() => paymentTypeCodeOf(provider.value?.paymentType))
const isBankRequired = computed(() => isBankRequiredForPaymentType(paymentTypeCode.value))
const isRibRequired = computed(() => isRibRequiredForPaymentType(paymentTypeCode.value))
const visibleRibs = computed(() => isRibRequired.value ? (provider.value?.ribs ?? []).map(mapRibToDraft) : [])
// Options « une entree » construites depuis l'embed (libelles role-independants).
const tvaModeOptions = computed(() => referentialOptionOf(provider.value?.tvaMode))
const paymentDelayOptions = computed(() => referentialOptionOf(provider.value?.paymentDelay))
const paymentTypeOptions = computed(() => referentialOptionOf(provider.value?.paymentType))
const bankOptions = computed(() => referentialOptionOf(provider.value?.bank))
// Navigation / actions
function goBack(): void {
router.push('/providers')
}
function goEdit(): void {
router.push(`/providers/${providerId}/edit`)
}
// Archivage / restauration
const confirmArchive = reactive({
open: false,
title: '',
message: '',
confirmLabel: '',
})
function askToggleArchive(): void {
const archiving = !isArchived.value
confirmArchive.title = archiving
? t('technique.providers.action.archive')
: t('technique.providers.action.restore')
confirmArchive.message = archiving
? t('technique.providers.consultation.confirmArchive')
: t('technique.providers.consultation.confirmRestore')
confirmArchive.confirmLabel = archiving
? t('technique.providers.action.archive')
: t('technique.providers.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('technique.providers.toast.archiveSuccess')
: t('technique.providers.toast.restoreSuccess'),
})
}
catch {
// 409 a la restauration (homonyme actif) ou autre : toast generique.
toast.error({ title: t('technique.providers.toast.error') })
}
}
onMounted(load)
</script>
@@ -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 })
}
@@ -63,11 +63,15 @@
<!-- 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"
:removable="isRowRemovable(contacts, index)"
:readonly="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -85,7 +89,7 @@
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting"
:disabled="tabSubmitting || providerId === null"
@click="onSubmitContacts"
/>
</div>
@@ -102,7 +106,7 @@
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="index > 0"
:removable="isRowRemovable(addresses, index)"
:readonly="isValidated('address')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@@ -121,7 +125,7 @@
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting"
:disabled="tabSubmitting || providerId === null"
@click="onSubmitAddresses"
/>
</div>
@@ -206,7 +210,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"
@@ -251,7 +255,7 @@
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting"
:disabled="tabSubmitting || providerId === null"
@click="onSubmitAccounting"
/>
</div>
@@ -292,6 +296,7 @@ import {
isRibRequiredForPaymentType,
} from '~/modules/technique/utils/forms/providerAccounting'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
@@ -314,6 +319,7 @@ const referentials = useProviderReferentials()
const {
main,
providerId,
mainLocked,
mainSubmitting,
mainErrors,
@@ -362,15 +368,33 @@ function apiErrorMessage(error: unknown): string {
return extractApiErrorMessage(data) || t('technique.providers.toast.error')
}
// Dernier onglet REMPLISSABLE par le role : tabKeys exclut deja la Comptabilite
// si l'user n'a pas accounting.view. Sa validation cloture l'ajout (redirection).
const lastFillableTab = computed(() => tabKeys.value[tabKeys.value.length - 1])
/**
* Apres validation d'un onglet (creation) : si c'est le dernier onglet du role,
* l'ajout est termine -> toast final + retour au repertoire (miroir M1/M2) ; sinon
* toast de mise a jour (l'onglet suivant a deja ete deverrouille par completeTab).
*/
function onTabSaved(key: string): void {
if (key === lastFillableTab.value) {
toast.success({ title: t('technique.providers.toast.addComplete') })
router.push('/providers')
return
}
toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
// Onglet Contact
/** Valide l'onglet Contact ; toast de succes si l'onglet a ete finalise. */
/** Valide l'onglet Contact ; redirige si c'est le dernier onglet du role. */
async function onSubmitContacts(): Promise<void> {
const ok = await submitContacts(error => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(error),
}))
if (ok) {
toast.success({ title: t('technique.providers.toast.updateSuccess') })
onTabSaved('contact')
}
}
@@ -413,14 +437,14 @@ function onAddressDegraded(): void {
})
}
/** Valide l'onglet Adresse ; toast de succes si l'onglet a ete finalise. */
/** Valide l'onglet Adresse ; redirige si c'est le dernier onglet du role. */
async function onSubmitAddresses(): Promise<void> {
const ok = await submitAddresses(error => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(error),
}))
if (ok) {
toast.success({ title: t('technique.providers.toast.updateSuccess') })
onTabSaved('address')
}
}
@@ -450,7 +474,7 @@ function askRemoveRib(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index))
}
/** Valide l'onglet Comptabilite ; toast de succes si l'onglet a ete finalise. */
/** Valide l'onglet Comptabilite ; redirige si c'est le dernier onglet du role. */
async function onSubmitAccounting(): Promise<void> {
const ok = await submitAccounting(
isBankRequired.value,
@@ -461,7 +485,7 @@ async function onSubmitAccounting(): Promise<void> {
}),
)
if (ok) {
toast.success({ title: t('technique.providers.toast.updateSuccess') })
onTabSaved('accounting')
}
}
@@ -3,6 +3,7 @@ import {
buildProviderContactPayload,
hasAtLeastOneFilledContact,
isProviderContactBlank,
isProviderContactNamed,
} from '../providerContact'
import { emptyProviderContact } from '~/modules/technique/types/providerForm'
@@ -34,15 +35,28 @@ describe('providerContact helpers', () => {
})
})
describe('hasAtLeastOneFilledContact (RG-3.12)', () => {
it('false si tous les blocs sont vides', () => {
expect(hasAtLeastOneFilledContact([emptyProviderContact(), emptyProviderContact()])).toBe(false)
describe('isProviderContactNamed (RG-3.04 — prenom OU nom)', () => {
it('vrai avec un prenom seul ou un nom seul', () => {
expect(isProviderContactNamed({ ...emptyProviderContact(), firstName: 'Jean' })).toBe(true)
expect(isProviderContactNamed({ ...emptyProviderContact(), lastName: 'Dupont' })).toBe(true)
})
it('true des qu\'un bloc porte une donnee', () => {
it('faux si seuls fonction / telephone / email sont remplis (ne suffit pas)', () => {
expect(isProviderContactNamed({ ...emptyProviderContact(), jobTitle: 'Directeur' })).toBe(false)
expect(isProviderContactNamed({ ...emptyProviderContact(), email: 'a@b.fr' })).toBe(false)
expect(isProviderContactNamed({ ...emptyProviderContact(), phonePrimary: '0102030405' })).toBe(false)
})
})
describe('hasAtLeastOneFilledContact (RG-3.12 — au moins un contact nomme)', () => {
it('false si aucun bloc n\'est nomme', () => {
expect(hasAtLeastOneFilledContact([emptyProviderContact(), { ...emptyProviderContact(), email: 'a@b.fr' }])).toBe(false)
})
it('true des qu\'un bloc porte un nom ou prenom', () => {
expect(hasAtLeastOneFilledContact([
emptyProviderContact(),
{ ...emptyProviderContact(), email: 'a@b.fr' },
{ ...emptyProviderContact(), lastName: 'Dupont' },
])).toBe(true)
})
})
@@ -0,0 +1,167 @@
import { describe, it, expect, vi } from 'vitest'
// formatPhoneFR est auto-importe dans le helper via le chemin partage ; on le mocke
// pour un rendu deterministe (la mise en forme exacte est testee ailleurs).
vi.mock('~/shared/utils/phone', () => ({
formatPhoneFR: (v: string) => `fmt(${v})`,
}))
const {
canEditProvider,
categoryOptionsOf,
contactOptionsOf,
iriOf,
irisOf,
mapAccountingDraft,
mapAddressToDraft,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf,
showArchiveAction,
showRestoreAction,
siteOptionsOf,
} = await import('../providerDetail')
/**
* Helpers purs des ecrans Consultation / Modification (ERP-145) : mapping du
* detail embarque vers les brouillons + regles d'affichage des actions (Modifier /
* Archiver / Restaurer).
*/
describe('providerDetail helpers', () => {
describe('iriOf / irisOf', () => {
it('extrait l\'IRI d\'un objet embarque, d\'un IRI nu, ou null', () => {
expect(iriOf({ '@id': '/api/banks/2' })).toBe('/api/banks/2')
expect(iriOf('/api/banks/2')).toBe('/api/banks/2')
expect(iriOf(null)).toBeNull()
expect(iriOf(undefined)).toBeNull()
})
it('extrait les IRI d\'une collection embarquee', () => {
expect(irisOf([{ '@id': '/api/sites/1' }, { '@id': '/api/sites/2' }])).toEqual(['/api/sites/1', '/api/sites/2'])
expect(irisOf(undefined)).toEqual([])
})
})
describe('mapContactToDraft', () => {
it('mappe les champs, formate les telephones et derive hasSecondaryPhone', () => {
const draft = mapContactToDraft({
'@id': '/api/provider_contacts/5',
id: 5,
firstName: 'Jean',
lastName: 'Dupont',
phonePrimary: '0102030405',
phoneSecondary: '0607080910',
email: 'jean@x.fr',
})
expect(draft).toMatchObject({
id: 5,
iri: '/api/provider_contacts/5',
firstName: 'Jean',
lastName: 'Dupont',
phonePrimary: 'fmt(0102030405)',
phoneSecondary: 'fmt(0607080910)',
email: 'jean@x.fr',
hasSecondaryPhone: true,
})
})
it('hasSecondaryPhone faux sans 2e numero', () => {
const draft = mapContactToDraft({ '@id': '/api/provider_contacts/6', id: 6, lastName: 'Doe' })
expect(draft.hasSecondaryPhone).toBe(false)
expect(draft.phoneSecondary).toBeNull()
})
})
describe('mapAddressToDraft', () => {
it('extrait les IRI des sites / categories / contacts embarques', () => {
const draft = mapAddressToDraft({
'@id': '/api/provider_addresses/3',
id: 3,
country: 'France',
postalCode: '86100',
city: 'Châtellerault',
street: '1 rue du Test',
sites: [{ '@id': '/api/sites/1' }],
categories: [{ '@id': '/api/categories/7' }],
contacts: [{ '@id': '/api/provider_contacts/5' }, '/api/provider_contacts/6'],
})
expect(draft.siteIris).toEqual(['/api/sites/1'])
expect(draft.categoryIris).toEqual(['/api/categories/7'])
expect(draft.contactIris).toEqual(['/api/provider_contacts/5', '/api/provider_contacts/6'])
expect(draft.id).toBe(3)
})
})
describe('mapAccountingDraft / mapRibToDraft', () => {
it('mappe les scalaires et les IRI des referentiels embarques', () => {
const draft = mapAccountingDraft({
'@id': '/api/providers/9',
id: 9,
siren: '123456789',
accountNumber: '4010',
nTva: 'FR123',
tvaMode: { '@id': '/api/tva_modes/1', label: 'TVA' },
paymentType: { '@id': '/api/payment_types/3', code: 'VIREMENT' },
bank: { '@id': '/api/banks/2' },
})
expect(draft.tvaModeIri).toBe('/api/tva_modes/1')
expect(draft.paymentTypeIri).toBe('/api/payment_types/3')
expect(draft.bankIri).toBe('/api/banks/2')
expect(draft.paymentDelayIri).toBeNull()
expect(draft.siren).toBe('123456789')
})
it('mappe un RIB embarque', () => {
expect(mapRibToDraft({ '@id': '/api/provider_ribs/1', id: 1, label: 'Compte', bic: 'BIC', iban: 'IBAN' }))
.toEqual({ id: 1, label: 'Compte', bic: 'BIC', iban: 'IBAN' })
})
})
describe('options builders (libelles role-independants depuis l\'embed)', () => {
it('categoryOptionsOf / siteOptionsOf / contactOptionsOf', () => {
expect(categoryOptionsOf([{ '@id': '/api/categories/7', name: 'Maintenance', code: 'MAINT' }]))
.toEqual([{ value: '/api/categories/7', label: 'Maintenance' }])
expect(siteOptionsOf([{ '@id': '/api/sites/1', name: 'Châtellerault' }]))
.toEqual([{ value: '/api/sites/1', label: 'Châtellerault' }])
expect(contactOptionsOf([{ '@id': '/api/provider_contacts/5', id: 5, firstName: 'Jean', lastName: 'Dupont' }]))
.toEqual([{ value: '/api/provider_contacts/5', label: 'Jean Dupont' }])
})
it('referentialOptionOf / paymentTypeCodeOf', () => {
expect(referentialOptionOf({ '@id': '/api/banks/2', label: 'SG' }))
.toEqual([{ value: '/api/banks/2', label: 'SG' }])
expect(referentialOptionOf(null)).toEqual([])
expect(referentialOptionOf('/api/banks/2')).toEqual([])
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/3', code: 'LCR' })).toBe('LCR')
expect(paymentTypeCodeOf(null)).toBeNull()
})
})
describe('actions selon permissions', () => {
/** Fabrique un `can` qui n'autorise que les codes fournis. */
const canFor = (granted: string[]) => (code: string) => granted.includes(code)
const canAnyFor = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c))
it('« Modifier » visible avec manage OU accounting.manage (Compta inclus)', () => {
expect(canEditProvider(canAnyFor(['technique.providers.manage']))).toBe(true)
expect(canEditProvider(canAnyFor(['technique.providers.accounting.manage']))).toBe(true)
expect(canEditProvider(canAnyFor(['technique.providers.view']))).toBe(false)
})
it('« Archiver » visible seulement avec archive ET prestataire actif (Admin seul)', () => {
const admin = canFor(['technique.providers.archive'])
const bureau = canFor(['technique.providers.manage'])
expect(showArchiveAction(admin, false)).toBe(true)
expect(showArchiveAction(admin, true)).toBe(false) // deja archive -> Restaurer
expect(showArchiveAction(bureau, false)).toBe(false) // pas la permission archive
})
it('« Restaurer » visible seulement avec archive ET prestataire archive', () => {
const admin = canFor(['technique.providers.archive'])
expect(showRestoreAction(admin, true)).toBe(true)
expect(showRestoreAction(admin, false)).toBe(false)
expect(showRestoreAction(canFor([]), true)).toBe(false)
})
})
})
@@ -32,12 +32,21 @@ export function isProviderContactBlank(contact: ProviderContactFormDraft): boole
].some(isFilled)
}
/**
* RG-3.04 : un contact est « nomme » (valide) des qu'il porte un prenom OU un nom
* aligne sur le M1/M2. Sert le gating « + Nouveau contact » et la notion de
* contact valide (la fonction / le telephone / l'email seuls ne suffisent pas).
*/
export function isProviderContactNamed(contact: ProviderContactFormDraft): boolean {
return isFilled(contact.firstName) || isFilled(contact.lastName)
}
/**
* RG-3.12 : l'onglet Contact ne peut etre finalise que s'il reste au moins un
* bloc non vide (au moins un contact valide).
* contact nomme (prenom ou nom).
*/
export function hasAtLeastOneFilledContact(contacts: ProviderContactFormDraft[]): boolean {
return contacts.some(contact => !isProviderContactBlank(contact))
return contacts.some(isProviderContactNamed)
}
/**
@@ -0,0 +1,245 @@
/**
* Helpers purs des ecrans Consultation / Modification prestataire (M3 Technique,
* ERP-145) miroir SIMPLIFIE de `supplierConsultation.ts` (M2). Mappent le payload
* `GET /api/providers/{id}` (relations embarquees, cf. groupes `provider:item:read`
* + `provider:read:accounting`) vers les brouillons « plats » partages avec
* `ProviderContactBlock` / `ProviderAddressBlock` et l'onglet Comptabilite.
*
* Ne touchent ni a l'API ni a l'etat reactif (testables unitairement).
*
* Rappels de contrat back (JSON reel fige ERP-139, spec-back § 4.0.bis) :
* - categories / sites du prestataire et des adresses : OBJETS embarques (avec @id) ;
* - refs comptables (tvaMode/paymentDelay/paymentType/bank) : OBJETS embarques
* `{@id, id, label, (code pour paymentType)}` ;
* - champs nuls OMIS (skip_null_values) toujours lire avec `?? null` ;
* - champs comptables + `ribs` TOTALEMENT ABSENTS sans permission accounting.view.
*
* Differences M2 : pas de type d'adresse / bennes / triage, pas d'onglet Information.
*/
import { formatPhoneFR } from '~/shared/utils/phone'
import type {
ProviderAccountingDraft,
ProviderAddressFormDraft,
ProviderContactFormDraft,
ProviderRibFormDraft,
} from '~/modules/technique/types/providerForm'
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
/** Reference Hydra embarquee minimale (@id toujours present). */
export interface HydraRef {
'@id': string
[key: string]: unknown
}
/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */
export type Relation = HydraRef | string | null | undefined
/** Site embarque (groupe site:read). */
export interface SiteRead extends HydraRef {
name?: string
postalCode?: string
color?: string
}
/** Categorie embarquee (groupe category:read). */
export interface CategoryRead extends HydraRef {
code?: string
name?: string
}
/** Contact embarque (groupe provider:item:read). */
export interface ContactRead extends HydraRef {
id: number
firstName?: string | null
lastName?: string | null
jobTitle?: string | null
phonePrimary?: string | null
phoneSecondary?: string | null
email?: string | null
}
/** Adresse embarquee (groupe provider:item:read) — version simplifiee M3. */
export interface AddressRead extends HydraRef {
id: number
country?: string | null
postalCode?: string | null
city?: string | null
street?: string | null
streetComplement?: string | null
sites?: SiteRead[]
categories?: CategoryRead[]
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
contacts?: Array<HydraRef | string>
}
/** RIB embarque (groupe provider:read:accounting, present ssi accounting.view). */
export interface RibRead extends HydraRef {
id: number
label?: string | null
bic?: string | null
iban?: string | null
}
/**
* Detail d'un prestataire (`GET /api/providers/{id}`). Tous les champs sont
* optionnels : skip_null_values + gating accounting peuvent omettre n'importe
* quelle cle.
*/
export interface ProviderDetail extends HydraRef {
id: number
companyName?: string | null
isArchived?: boolean
categories?: CategoryRead[]
sites?: SiteRead[]
contacts?: ContactRead[]
addresses?: AddressRead[]
ribs?: RibRead[]
// Onglet Comptabilite (present ssi accounting.view)
siren?: string | null
accountNumber?: string | null
nTva?: string | null
tvaMode?: Relation
paymentDelay?: Relation
paymentType?: Relation
bank?: Relation
}
/** Extrait l'IRI d'une relation (objet embarque, 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
}
/** IRI des elements d'une collection embarquee (categories / sites du prestataire). */
export function irisOf(items: HydraRef[] | undefined): string[] {
return (items ?? []).map(i => i['@id'])
}
/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */
export function mapContactToDraft(contact: ContactRead): ProviderContactFormDraft {
const phoneSecondary = contact.phoneSecondary ?? null
return {
id: contact.id,
iri: contact['@id'] ?? null,
firstName: contact.firstName ?? null,
lastName: contact.lastName ?? null,
jobTitle: contact.jobTitle ?? null,
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
email: contact.email ?? null,
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
}
}
/** Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections). */
export function mapAddressToDraft(address: AddressRead): ProviderAddressFormDraft {
return {
id: address.id,
country: address.country ?? 'France',
postalCode: address.postalCode ?? null,
city: address.city ?? null,
street: address.street ?? null,
streetComplement: address.streetComplement ?? null,
categoryIris: (address.categories ?? []).map(c => c['@id']),
siteIris: (address.sites ?? []).map(s => s['@id']),
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
}
}
/** Mappe un RIB embarque vers un brouillon. */
export function mapRibToDraft(rib: RibRead): ProviderRibFormDraft {
return {
id: rib.id,
label: rib.label ?? null,
bic: rib.bic ?? null,
iban: rib.iban ?? null,
}
}
/** Mappe les champs comptables (scalaires + IRI des referentiels embarques). */
export function mapAccountingDraft(provider: ProviderDetail): ProviderAccountingDraft {
return {
siren: provider.siren ?? null,
accountNumber: provider.accountNumber ?? null,
nTva: provider.nTva ?? null,
tvaModeIri: iriOf(provider.tvaMode),
paymentDelayIri: iriOf(provider.paymentDelay),
paymentTypeIri: iriOf(provider.paymentType),
bankIri: iriOf(provider.bank),
}
}
/**
* Options de categories (value=IRI, label=nom) construites depuis l'embed.
* Source role-independante : evite de dependre de `GET /categories` (403 possible
* pour un role metier), qui laisserait les libelles vides en consultation.
*/
export function categoryOptionsOf(categories: CategoryRead[] | undefined): RefOption[] {
return (categories ?? []).map(c => ({
value: c['@id'],
label: c.name ?? c.code ?? c['@id'],
}))
}
/** Options de sites (value=IRI, label=nom) construites depuis un embed. */
export function siteOptionsOf(sites: SiteRead[] | undefined): RefOption[] {
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
}
/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed prestataire. */
export function contactOptionsOf(contacts: ContactRead[] | undefined): RefOption[] {
return (contacts ?? []).map(c => ({
value: c['@id'],
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']),
}))
}
/**
* Liste a une seule option (ou vide) construite depuis un referentiel embarque
* (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en
* lecture seule. Le libelle vient de l'embed, jamais d'un GET de referentiel
* l'affichage reste correct quel que soit le role.
*/
export function referentialOptionOf(relation: Relation): RefOption[] {
if (!relation || typeof relation === 'string') {
return []
}
const label = (relation.label as string | undefined)
?? (relation.name as string | undefined)
?? relation['@id']
return [{ value: relation['@id'], label }]
}
/** Code metier d'un referentiel embarque (PaymentType.code = 'LCR' / 'VIREMENT'), ou null. */
export function paymentTypeCodeOf(relation: Relation): string | null {
if (!relation || typeof relation === 'string') {
return null
}
return (relation.code as string | undefined) ?? null
}
/**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
* `manage` (onglets metier) OU `accounting.manage` (le role Compta doit pouvoir
* ouvrir l'edition pour son onglet Comptabilite). Le readonly fin par onglet est
* gere sur l'ecran d'edition.
*/
export function canEditProvider(canAny: (codes: string[]) => boolean): boolean {
return canAny(['technique.providers.manage', 'technique.providers.accounting.manage'])
}
/** Bouton « Archiver » : permission archive ET prestataire encore actif (Admin seul). */
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
return can('technique.providers.archive') && !isArchived
}
/** Bouton « Restaurer » : permission archive ET prestataire deja archive (Admin seul). */
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
return can('technique.providers.archive') && isArchived
}
@@ -0,0 +1,191 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Tests du workflow « Ajouter un transporteur » (M4 Transport, ERP-165).
*
* `useCarrierForm` porte le formulaire principal (Nom + Certification + Affréter)
* et l'orchestration des onglets de création. On vérifie ici le CONTRAT propre à
* la création :
* - pré-check front : nom requis POST bloqué, erreur inline, aucun appel réseau ;
* - POST /carriers (groupe carrier:write:main) : payload + Accept ld+json +
* toast:false ; au succès, verrouillage + bascule sur l'onglet Qualimat +
* réaffichage du nom normalisé ;
* - 409 doublon (RG-4.12) erreur inline dédiée sur `name` ;
* - 422 mapping inline par champ (propertyPath) ;
* - onglets : 4 onglets (Qualimat/Adresses/Contacts/Prix), completeTab
* déverrouille/avance et signale le dernier onglet ;
* - patchCarrier : PATCH partiel, no-op avant création.
*/
const mockPost = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({
get: vi.fn(),
post: mockPost,
put: vi.fn(),
patch: mockPatch,
delete: vi.fn(),
}))
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useToast', () => ({
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}))
const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm')
describe('useCarrierForm', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
})
it('front : nom vide → erreur inline sur name, pas de POST', async () => {
const form = useCarrierForm()
const created = await form.submitMain()
expect(created).toBe(false)
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.name).toBe('transport.carriers.form.errors.nameRequired')
expect(form.mainLocked.value).toBe(false)
})
it('front : nom en espaces uniquement → erreur inline, pas de POST', async () => {
const form = useCarrierForm()
form.main.name = ' '
const created = await form.submitMain()
expect(created).toBe(false)
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.name).toBe('transport.carriers.form.errors.nameRequired')
})
it('POST /carriers avec Accept ld+json, verrouille et bascule sur Qualimat', async () => {
mockPost.mockResolvedValueOnce({ id: 42, name: 'TRANSPORTS ACME', certificationType: 'GMP_PLUS' })
const form = useCarrierForm()
form.main.name = 'Transports Acme'
form.main.certificationType = 'GMP_PLUS'
form.main.isChartered = true
const created = await form.submitMain()
expect(created).toBe(true)
expect(mockPost).toHaveBeenCalledTimes(1)
const [url, body, opts] = mockPost.mock.calls[0] ?? []
expect(url).toBe('/carriers')
expect(body).toEqual({
name: 'Transports Acme',
certificationType: 'GMP_PLUS',
isChartered: true,
})
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
expect(form.carrierId.value).toBe(42)
// RG-4.13 : réaffiche le nom normalisé (UPPERCASE) renvoyé par le serveur.
expect(form.main.name).toBe('TRANSPORTS ACME')
expect(form.mainLocked.value).toBe(true)
expect(form.activeTab.value).toBe('qualimat')
expect(form.unlockedIndex.value).toBe(0)
})
it('payload : omet name et certificationType vides, garde isChartered', async () => {
mockPost.mockRejectedValueOnce({ response: { status: 422, _data: { violations: [] } } })
const form = useCarrierForm()
form.main.name = 'X' // nom présent pour passer le pré-check front
// certificationType laissé null → omis pour que la 422 « obligatoire » porte.
await form.submitMain()
const body = mockPost.mock.calls[0]?.[1] as Record<string, unknown>
expect(body).toEqual({ name: 'X', isChartered: false })
expect('certificationType' in body).toBe(false)
})
it('409 doublon (RG-4.12) : erreur inline dédiée sur name, pas de verrouillage', async () => {
mockPost.mockRejectedValueOnce({ response: { status: 409 } })
const form = useCarrierForm()
form.main.name = 'Doublon'
form.main.certificationType = 'AUTRE'
const created = await form.submitMain()
expect(created).toBe(false)
expect(form.mainErrors.errors.name).toBe('transport.carriers.form.duplicateName')
expect(form.mainLocked.value).toBe(false)
})
it('422 : mappe les violations serveur inline par champ', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'certificationType', message: 'Le type de certification est obligatoire.' }] },
},
})
const form = useCarrierForm()
form.main.name = 'Sans Certif'
const created = await form.submitMain()
expect(created).toBe(false)
expect(form.mainErrors.errors.certificationType).toBe('Le type de certification est obligatoire.')
expect(form.mainLocked.value).toBe(false)
})
it('onglets : 4 clés Qualimat/Adresses/Contacts/Prix', () => {
expect(CARRIER_TAB_KEYS).toEqual(['qualimat', 'addresses', 'contacts', 'prices'])
const form = useCarrierForm()
expect(form.tabKeys.value).toEqual(['qualimat', 'addresses', 'contacts', 'prices'])
// Tous verrouillés tant que le formulaire principal n'est pas validé.
expect(form.unlockedIndex.value).toBe(-1)
})
it('completeTab : déverrouille/avance, et signale le dernier onglet du flux', () => {
const form = useCarrierForm()
// Qualimat → Adresses (pas le dernier).
expect(form.completeTab('qualimat')).toBe(false)
expect(form.isValidated('qualimat')).toBe(true)
expect(form.activeTab.value).toBe('addresses')
expect(form.unlockedIndex.value).toBe(1)
expect(form.completeTab('addresses')).toBe(false)
expect(form.activeTab.value).toBe('contacts')
expect(form.completeTab('contacts')).toBe(false)
expect(form.activeTab.value).toBe('prices')
// Prix = dernier onglet → true (création terminée).
expect(form.completeTab('prices')).toBe(true)
expect(form.isValidated('prices')).toBe(true)
})
it('editMode : completeTab ne verrouille pas et ne bascule pas d\'onglet', () => {
const form = useCarrierForm()
form.editMode.value = true
form.activeTab.value = 'qualimat'
expect(form.completeTab('qualimat')).toBe(false)
expect(form.isValidated('qualimat')).toBe(false)
expect(form.activeTab.value).toBe('qualimat')
})
it('patchCarrier : PATCH /carriers/{id} en mode strict, no-op avant création', async () => {
const form = useCarrierForm()
await form.patchCarrier({ liotPlates: 'AA-123-BB' })
expect(mockPatch).not.toHaveBeenCalled()
mockPost.mockResolvedValueOnce({ id: 9, name: 'ACME', certificationType: 'OVOCOM' })
form.main.name = 'Acme'
form.main.certificationType = 'OVOCOM'
await form.submitMain()
await form.patchCarrier({ liotPlates: 'AA-123-BB' })
expect(mockPatch).toHaveBeenCalledWith('/carriers/9', { liotPlates: 'AA-123-BB' }, { toast: false })
})
})
@@ -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: 10 })
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,207 @@
import { reactive, ref } from 'vue'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import {
emptyCarrierMain,
type CarrierMainDraft,
type CarrierMainResponse,
} from '~/modules/transport/types/carrierForm'
/**
* 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()
// ── État du transporteur créé ─────────────────────────────────────────────
const carrierId = ref<number | null>(null)
const mainLocked = ref(false)
const mainSubmitting = ref(false)
// ── Formulaire principal ──────────────────────────────────────────────────
const main = reactive<CarrierMainDraft>(emptyCarrierMain())
// ── Onglets : ordre + gating progressif ───────────────────────────────────
const tabKeys = ref<string[]>([...CARRIER_TAB_KEYS])
// Index du dernier onglet déverrouillé (-1 tant que le transporteur n'est pas créé).
const unlockedIndex = ref(-1)
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
* (RG-4.01). Le back reste la couche autoritaire (ERP-101) la certification
* obligatoire (sauf LIOT) et les RG conditionnelles sont re-validées serveur et
* remontées en 422 inline, sans pré-check front (qui devrait connaître le cas
* LIOT, hors périmètre ERP-165).
*/
function validateMainFront(): boolean {
let valid = true
if (!main.name?.trim()) {
mainErrors.setError('name', t('transport.carriers.form.errors.nameRequired'))
valid = false
}
return valid
}
/**
* 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> {
const payload: Record<string, unknown> = {
isChartered: main.isChartered,
}
if (main.name?.trim()) {
payload.name = main.name
}
if (main.certificationType) {
payload.certificationType = main.certificationType
}
return payload
}
/**
* POST /carriers (groupe `carrier:write:main`). Pré-check front (nom), puis
* création. Au succès : verrouille le bloc principal, déverrouille le 1er onglet
* et bascule sur « Qualimat ». 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 {
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
unlockedIndex.value = 0
activeTab.value = tabKeys.value[0] ?? CARRIER_TAB_KEYS[0]
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
}
}
/**
* 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 })
}
/**
* 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,
carrierId,
mainLocked,
mainSubmitting,
mainErrors,
// onglets
tabKeys,
activeTab,
unlockedIndex,
validated,
editMode,
isValidated,
// actions
validateMainFront,
buildMainPayload,
submitMain,
patchCarrier,
completeTab,
}
}
@@ -0,0 +1,70 @@
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() {
return usePaginatedList<Carrier, CarrierFilters>({ url: '/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,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,152 @@
<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)
Sans validation de ce bloc, les onglets restent inaccessibles. Au
succes du POST, les champs passent en lecture seule et on bascule
automatiquement sur l'onglet Qualimat. Les champs conditionnels
(indexation / benne / volume si affrete, decharge si AUTRE, cas LIOT)
et la saisie assistee QUALIMAT arrivent a ERP-166. -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.name"
:label="t('transport.carriers.form.main.name')"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.name"
/>
<MalioSelect
:model-value="main.certificationType"
:options="certificationOptions"
:label="t('transport.carriers.form.main.certificationType')"
empty-option-label=""
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.certificationType"
@update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)"
/>
<!-- Wrapper h-12 + centrage vertical : aligne la case a cocher sur la
ligne de champ des inputs/selects (qui posent un h-12 items-center
en interne). reserve-message-space=false pour un centrage exact. -->
<div class="flex h-12 items-center">
<MalioCheckbox
id="carrier-is-chartered"
:label="t('transport.carriers.form.main.isChartered')"
:model-value="main.isChartered"
:readonly="mainLocked"
:reserve-message-space="false"
@update:model-value="(val: boolean) => main.isChartered = val"
/>
</div>
</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. Onglets verrouilles tant
que le formulaire principal n'est pas valide (unlockedIndex = -1) puis
deverrouilles progressivement. Le contenu de chaque onglet arrive aux
tickets suivants (ERP-166+) : placeholders « A venir » pour l'instant. -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<template
v-for="key in tabKeys"
:key="key"
#[key]
>
<div class="mt-12 flex justify-center text-m-muted">
{{ t('transport.carriers.form.comingSoon') }}
</div>
</template>
</MalioTabList>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
interface SelectOption {
value: string
label: string
}
const { t } = useI18n()
const router = useRouter()
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,
mainLocked,
mainSubmitting,
mainErrors,
tabKeys,
activeTab,
unlockedIndex,
submitMain,
} = useCarrierForm()
// Certifications selectionnables manuellement (spec § Formulaire principal).
// QUALIMAT n'est PAS dans cette liste : il est pose par la saisie assistee QUALIMAT
// (ERP-166), pas choisi a la main.
const SELECTABLE_CERTIFICATIONS = ['GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'] as const
const certificationOptions = computed<SelectOption[]>(() =>
SELECTABLE_CERTIFICATIONS.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-check-outline',
addresses: 'mdi:map-marker-outline',
contacts: 'mdi:account-box-plus-outline',
prices: 'mdi:currency-eur',
}
// 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,
})))
/** Retour vers le repertoire transporteurs (fleche d'en-tete). */
function goBack(): void {
router.push('/carriers')
}
/** Valide le formulaire principal (POST /carriers ; bascule gerée par le composable). */
async function onSubmitMain(): Promise<void> {
await submitMain()
}
</script>
@@ -0,0 +1,40 @@
/**
* Types du workflow « Ajouter un transporteur » (M4 Transport, ERP-165).
*
* Périmètre ERP-165 = formulaire PRINCIPAL (pré-onglets) uniquement : Nom +
* Certification + Affréter. Les champs conditionnels (indexation / benne / volume
* si affrété, décharge si AUTRE, immatriculations LIOT) et la saisie assistée
* QUALIMAT arrivent à ERP-166 ; les onglets Adresses / Contacts / Prix aux tickets
* suivants. On garde donc volontairement ce draft minimal il s'étendra.
*/
/**
* Brouillon du formulaire principal. `certificationType` est un code enum back
* (GMP_PLUS | OVOCOM | COMPTE_PROPRE | AUTRE ; QUALIMAT sera posé par la saisie
* assistée à ERP-166) ou `null` tant que rien n'est choisi.
*/
export interface CarrierMainDraft {
name: string
certificationType: string | null
isChartered: boolean
}
/** Brouillon principal vide (état initial du formulaire de création). */
export function emptyCarrierMain(): CarrierMainDraft {
return {
name: '',
certificationType: null,
isChartered: false,
}
}
/**
* 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
}
+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,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)
})
})
+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
}
+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,
));
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* RG-3.04 (correctif) aligne la regle de validite d'un contact prestataire sur
* le M1/M2 : au moins le PRENOM OU le NOM (et non plus « un champ quelconque parmi
* prenom/nom/fonction/telephone/email »). Remplace le CHECK chk_provider_contact_name
* et met a jour les commentaires de colonnes. La garde applicative
* (ProviderContactProcessor::validateName) est alignee dans le meme commit.
*
* Placee au namespace racine DoctrineMigrations (et non en modulaire Technique) :
* elle ALTERE une table creee par une migration racine (Version20260612100000) ;
* le tri par version au sein du meme namespace garantit qu'elle joue APRES l'init
* (cf. CLAUDE.md regle 11 le tri cross-namespace casserait l'ordre sur base vide).
*/
final class Version20260615120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'RG-3.04 : contact prestataire valide si prenom OU nom (alignement M1/M2) — CHECK chk_provider_contact_name.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name');
$this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)');
$this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Facultatif — ne suffit plus a valider le contact (RG-3.04).$_$');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name');
$this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL)');
$this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/fonction/telephone/email (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-154 Infra d'upload de fichiers generique et reutilisable (src/Shared).
*
* Cree la table `uploaded_document` : reference technique d'un fichier televerse
* (PDF / image), gere par le service Shared\Infrastructure\Upload\FileUploader.
* La « Decharge » du M4 transporteurs en sera le premier consommateur, mais ce
* ticket ne touche AUCUN module : la table vit cote Shared.
*
* Caracteristiques :
* - Document IMMUABLE : pas d'onglet edition, pas de updated_at / updated_by.
* Seules les colonnes created_at (UTC, remplie par le FileUploader via
* l'horloge injectee) et created_by (auteur HTTP, null hors HTTP) tracent
* l'origine. C'est pourquoi l'entite Shared n'implemente PAS
* Timestampable/Blamable (qui imposeraient les 4 colonnes).
* - checksum sha256 (64 caracteres hex) : controle d'integrite + future
* deduplication eventuelle (hors scope ici).
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et non
* modulaire : la table porte une FK cross-module vers "user" (created_by). Le
* tri par version au sein du namespace racine garantit qu'elle joue APRES la
* creation de "user" sur base vide.
*
* Style DDL aligne sur le M1/M2/M3 : `INT GENERATED BY DEFAULT AS IDENTITY` et
* `TIMESTAMP(0) WITHOUT TIME ZONE` (mapping ORM `datetime_immutable`), pour que
* `schema:update --force` reste un no-op une fois l'entite mappee.
*
* COMMENT ON COLUMN inline (regle ABSOLUE n°12) : chaque colonne porte sa
* description ici. La table est aussi ajoutee a `ColumnCommentsCatalog` car
* l'entite UploadedDocument existe des ce ticket `app:apply-column-comments`
* du `test-db-setup` rejoue donc ces COMMENT apres le `schema:update --force`.
*/
final class Version20260615130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-154 : table uploaded_document (infra upload generique Shared) — fichier televerse immuable, checksum sha256.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE uploaded_document (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
original_filename VARCHAR(255) NOT NULL,
stored_path VARCHAR(512) NOT NULL,
mime_type VARCHAR(100) NOT NULL,
size_bytes INT NOT NULL,
checksum VARCHAR(64) NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_uploaded_document_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
// Postgres n'indexe pas automatiquement les colonnes de FK.
$this->addSql('CREATE INDEX idx_uploaded_document_created_by ON uploaded_document (created_by)');
// Recherche d'integrite / future deduplication par empreinte sha256.
$this->addSql('CREATE INDEX idx_uploaded_document_checksum ON uploaded_document (checksum)');
$this->addSql('COMMENT ON TABLE uploaded_document IS $_$Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.id IS $_$Identifiant interne auto-incremente.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.original_filename IS $_$Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.stored_path IS $_$Chemin relatif du fichier sous var/uploads (ex: 2026/06/<hash>.pdf) — nom genere aleatoirement, jamais le nom client.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.mime_type IS $_$Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.size_bytes IS $_$Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.checksum IS $_$Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.created_at IS $_$Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.created_by IS $_$ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.$_$');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS uploaded_document');
}
}
+356
View File
@@ -0,0 +1,356 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M4 Repertoire transporteurs (ERP-155/157) : creation du schema BDD du
* repertoire transporteurs sous le module Transport (jumeau des M2/M3).
*
* Tables creees :
* - carrier : table principale (formulaire + lien QUALIMAT + archive + soft-delete
* + Timestampable/Blamable) ;
* - carrier_address / carrier_contact / carrier_price : sous-collections 1:n.
*
* Tables NON recrees (reutilisees) :
* - qualimat_carrier (ERP-39, Version20260612150000) : cible de la FK editable
* carrier.qualimat_carrier_id (§ 2.5) ;
* - uploaded_document (ERP-154, Version20260615130000) : cible de la FK
* carrier.discharge_document_id (Decharge, § 2.7) ;
* - client / client_address / supplier / supplier_address (M1/M2) et site (Sites) :
* cibles des FK de carrier_price (onglet Prix, RG-4.10/4.11).
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire :
* FK cross-module (user, client, client_address, supplier, supplier_address, site,
* qualimat_carrier, uploaded_document). Le tri par timestamp au sein du namespace
* racine garantit l'ordre apres la creation de ces tables sur base vide.
*
* Decision IDs (spec § 2.2, tranchee a ce ticket) : carrier et ses sous-tables
* utilisent `INT GENERATED BY DEFAULT AS IDENTITY` (homogeneite globale Starseed
* M1/M2/M3, evite la friction bigint->string de l'ORM). Seule
* carrier.qualimat_carrier_id est BIGINT pour matcher qualimat_carrier.id (existant).
* Horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le TimestampableBlamableTrait mappe
* `datetime_immutable`), pour que `schema:update --force` reste un no-op.
*
* Chaque colonne porte son `COMMENT ON COLUMN` (regle ABSOLUE n°12). Les 4
* tables carrier* etant mappees par l'ORM des ce ticket, elles sont aussi ajoutees
* a ColumnCommentsCatalog : `app:apply-column-comments` (test-db-setup) rejoue ces
* COMMENT apres le `schema:update --force` qui les droperait sinon.
*/
final class Version20260615150000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-155/157 (M4) : tables carrier + carrier_address + carrier_contact + carrier_price (repertoire transporteurs).';
}
public function up(Schema $schema): void
{
$this->createCarrierTable();
$this->createCarrierAddress();
$this->createCarrierContact();
$this->createCarrierPrice();
}
public function down(Schema $schema): void
{
// Ordre inverse des dependances FK : sous-collections d'abord, puis carrier.
$this->addSql('DROP TABLE IF EXISTS carrier_price');
$this->addSql('DROP TABLE IF EXISTS carrier_contact');
$this->addSql('DROP TABLE IF EXISTS carrier_address');
$this->addSql('DROP TABLE IF EXISTS carrier');
}
// =================================================================
// Table principale `carrier`
// =================================================================
private function createCarrierTable(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE carrier (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
qualimat_carrier_id BIGINT DEFAULT NULL,
name VARCHAR(255) NOT NULL,
certification_type VARCHAR(20) DEFAULT NULL,
is_chartered BOOLEAN DEFAULT FALSE NOT NULL,
indexation_rate NUMERIC(5, 2) DEFAULT NULL,
container_type VARCHAR(12) DEFAULT NULL,
volume_m3 NUMERIC(10, 2) DEFAULT NULL,
discharge_document_id INT DEFAULT NULL,
liot_plates TEXT DEFAULT NULL,
is_archived BOOLEAN DEFAULT FALSE NOT NULL,
archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT chk_carrier_certification_type
CHECK (certification_type IS NULL OR certification_type IN ('QUALIMAT', 'GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE')),
CONSTRAINT chk_carrier_container_type
CHECK (container_type IS NULL OR container_type IN ('BENNE', 'FOND_MOUVANT')),
CONSTRAINT fk_carrier_qualimat
FOREIGN KEY (qualimat_carrier_id) REFERENCES qualimat_carrier (id) ON DELETE SET NULL,
CONSTRAINT fk_carrier_discharge_document
FOREIGN KEY (discharge_document_id) REFERENCES uploaded_document (id) ON DELETE SET NULL,
CONSTRAINT fk_carrier_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_carrier_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
$this->addSql('CREATE INDEX idx_carrier_is_archived ON carrier (is_archived)');
$this->addSql('CREATE INDEX idx_carrier_deleted_at ON carrier (deleted_at)');
$this->addSql('CREATE INDEX idx_carrier_qualimat ON carrier (qualimat_carrier_id)');
$this->addSql('CREATE INDEX idx_carrier_discharge_document ON carrier (discharge_document_id)');
$this->addSql('CREATE INDEX idx_carrier_created_by ON carrier (created_by)');
$this->addSql('CREATE INDEX idx_carrier_updated_by ON carrier (updated_by)');
// Unicite metier partielle : nom insensible a la casse, parmi les
// non-archives ET non soft-deletes uniquement (§ 2.6). Inexprimable en ORM.
$this->addSql(<<<'SQL'
CREATE UNIQUE INDEX uq_carrier_name_active
ON carrier (LOWER(name))
WHERE is_archived = FALSE AND deleted_at IS NULL
SQL);
$this->comment('carrier', '_table', 'Repertoire transporteurs (M4 Transport) — entites editables, archivables (is_archived) et soft-deletables (deleted_at). Distinct du referentiel qualimat_carrier.');
$this->comment('carrier', 'id', 'Identifiant interne auto-incremente.');
$this->comment('carrier', 'qualimat_carrier_id', 'Lien editable vers le referentiel QUALIMAT (saisie assistee RG-4.01). FK -> qualimat_carrier.id, ON DELETE SET NULL : transporteur conserve si la ligne QUALIMAT disparait.');
$this->comment('carrier', 'name', 'Raison sociale du transporteur (stockee en MAJUSCULES). Unique case-insensitive parmi les non-archives/non-supprimes (uq_carrier_name_active, RG-4.12 / § 2.6).');
$this->comment('carrier', 'certification_type', 'Type de certification : QUALIMAT (si lie, lecture seule) ou GMP_PLUS/OVOCOM/COMPTE_PROPRE/AUTRE. AUTRE declenche le champ Decharge (RG-4.02). Null en cas LIOT (RG-4.01).');
$this->comment('carrier', 'is_chartered', '« Affreter » coche : declenche indexation/benne-fond mouvant/volume, obligatoires (RG-4.03). Faux par defaut.');
$this->comment('carrier', 'indexation_rate', 'Taux d indexation en pourcentage (NUMERIC 5,2) — renseigne si affrete (RG-4.03).');
$this->comment('carrier', 'container_type', 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_container_type) — renseigne si affrete (RG-4.03).');
$this->comment('carrier', 'volume_m3', 'Volume en m3 (NUMERIC 10,2) — renseigne si affrete (RG-4.03).');
$this->comment('carrier', 'discharge_document_id', 'Document de Decharge (visible si certification_type = AUTRE, RG-4.02). FK -> uploaded_document.id (infra Shared § 2.7), ON DELETE SET NULL.');
$this->comment('carrier', 'liot_plates', 'Immatriculations LIOT separees par « ; » (cas special nom=LIOT, RG-4.01). Les autres champs sont masques dans ce cas.');
$this->comment('carrier', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission transport.carriers.archive (Admin seul).');
$this->comment('carrier', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.');
$this->comment('carrier', 'deleted_at', 'Horodatage du soft-delete technique — non expose par l API au M4. Null = ligne active.');
$this->addTimestampableBlamableComments('carrier');
}
// =================================================================
// Sous-collection : adresses (1:n)
// =================================================================
private function createCarrierAddress(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE carrier_address (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
carrier_id INT NOT NULL,
country VARCHAR(80) DEFAULT 'France' NOT NULL,
postal_code VARCHAR(20) DEFAULT NULL,
city VARCHAR(120) DEFAULT NULL,
street VARCHAR(255) DEFAULT NULL,
street_complement VARCHAR(255) DEFAULT NULL,
position INT DEFAULT 0 NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_carrier_address_carrier
FOREIGN KEY (carrier_id) REFERENCES carrier (id) ON DELETE CASCADE,
CONSTRAINT fk_carrier_address_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_carrier_address_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
$this->addSql('CREATE INDEX idx_carrier_address_carrier ON carrier_address (carrier_id)');
$this->addSql('CREATE INDEX idx_carrier_address_created_by ON carrier_address (created_by)');
$this->addSql('CREATE INDEX idx_carrier_address_updated_by ON carrier_address (updated_by)');
$this->comment('carrier_address', '_table', 'Adresses d un transporteur (1:n) — onglet Adresse (M4). Pre-remplie depuis QUALIMAT si applicable (RG-4.05).');
$this->comment('carrier_address', 'id', 'Identifiant interne auto-incremente.');
$this->comment('carrier_address', 'carrier_id', 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire de l adresse.');
$this->comment('carrier_address', 'country', 'Pays de l adresse — defaut France.');
$this->comment('carrier_address', 'postal_code', 'Code postal (saisie assistee BAN cote front, RG-4.06).');
$this->comment('carrier_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front.');
$this->comment('carrier_address', 'street', 'Numero et voie de l adresse.');
$this->comment('carrier_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.');
$this->comment('carrier_address', 'position', 'Ordre d affichage de l adresse dans la liste du transporteur (croissant).');
$this->addTimestampableBlamableComments('carrier_address');
}
// =================================================================
// Sous-collection : contacts (1:n)
// =================================================================
private function createCarrierContact(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE carrier_contact (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
carrier_id INT NOT NULL,
first_name VARCHAR(120) DEFAULT NULL,
last_name VARCHAR(120) DEFAULT NULL,
job_title VARCHAR(120) DEFAULT NULL,
phone_primary VARCHAR(20) DEFAULT NULL,
phone_secondary VARCHAR(20) DEFAULT NULL,
email VARCHAR(180) DEFAULT NULL,
position INT DEFAULT 0 NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT chk_carrier_contact_filled
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL
OR phone_primary IS NOT NULL OR email IS NOT NULL),
CONSTRAINT fk_carrier_contact_carrier
FOREIGN KEY (carrier_id) REFERENCES carrier (id) ON DELETE CASCADE,
CONSTRAINT fk_carrier_contact_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_carrier_contact_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
$this->addSql('CREATE INDEX idx_carrier_contact_carrier ON carrier_contact (carrier_id)');
$this->addSql('CREATE INDEX idx_carrier_contact_created_by ON carrier_contact (created_by)');
$this->addSql('CREATE INDEX idx_carrier_contact_updated_by ON carrier_contact (updated_by)');
$this->comment('carrier_contact', '_table', 'Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins un champ rempli (RG-4.08, chk_carrier_contact_filled), max 2 telephones.');
$this->comment('carrier_contact', 'id', 'Identifiant interne auto-incremente.');
$this->comment('carrier_contact', 'carrier_id', 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du contact.');
$this->comment('carrier_contact', 'first_name', 'Prenom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).');
$this->comment('carrier_contact', 'last_name', 'Nom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).');
$this->comment('carrier_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).');
$this->comment('carrier_contact', 'phone_primary', 'Telephone principal — chiffres uniquement (normalisation serveur).');
$this->comment('carrier_contact', 'phone_secondary', 'Telephone secondaire — chiffres uniquement (max 2 telephones, RG-4.08).');
$this->comment('carrier_contact', 'email', 'Email du contact (lowercase serveur).');
$this->comment('carrier_contact', 'position', 'Ordre d affichage du contact dans la liste du transporteur (croissant).');
$this->addTimestampableBlamableComments('carrier_contact');
}
// =================================================================
// Sous-collection : prix (1:n) — onglet Prix (RG-4.09 -> RG-4.11)
// =================================================================
private function createCarrierPrice(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE carrier_price (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
carrier_id INT NOT NULL,
direction VARCHAR(12) NOT NULL,
client_id INT DEFAULT NULL,
client_delivery_address_id INT DEFAULT NULL,
departure_site_id INT DEFAULT NULL,
supplier_id INT DEFAULT NULL,
supplier_supply_address_id INT DEFAULT NULL,
delivery_site_id INT DEFAULT NULL,
container_type VARCHAR(12) NOT NULL,
pricing_unit VARCHAR(8) NOT NULL,
price NUMERIC(12, 2) NOT NULL,
price_state VARCHAR(12) NOT NULL,
position INT DEFAULT 0 NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT chk_carrier_price_direction CHECK (direction IN ('CLIENT', 'FOURNISSEUR')),
CONSTRAINT chk_carrier_price_container CHECK (container_type IN ('BENNE', 'FOND_MOUVANT')),
CONSTRAINT chk_carrier_price_unit CHECK (pricing_unit IN ('FORFAIT', 'TONNE')),
CONSTRAINT chk_carrier_price_state CHECK (price_state IN ('EN_COURS', 'VALIDE', 'NON_VALIDE')),
CONSTRAINT chk_carrier_price_client_branch
CHECK (direction <> 'CLIENT' OR (client_id IS NOT NULL AND supplier_id IS NULL)),
CONSTRAINT chk_carrier_price_supplier_branch
CHECK (direction <> 'FOURNISSEUR' OR (supplier_id IS NOT NULL AND client_id IS NULL)),
CONSTRAINT fk_carrier_price_carrier
FOREIGN KEY (carrier_id) REFERENCES carrier (id) ON DELETE CASCADE,
CONSTRAINT fk_carrier_price_client
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE RESTRICT,
CONSTRAINT fk_carrier_price_client_address
FOREIGN KEY (client_delivery_address_id) REFERENCES client_address (id) ON DELETE RESTRICT,
CONSTRAINT fk_carrier_price_departure_site
FOREIGN KEY (departure_site_id) REFERENCES site (id) ON DELETE RESTRICT,
CONSTRAINT fk_carrier_price_supplier
FOREIGN KEY (supplier_id) REFERENCES supplier (id) ON DELETE RESTRICT,
CONSTRAINT fk_carrier_price_supplier_address
FOREIGN KEY (supplier_supply_address_id) REFERENCES supplier_address (id) ON DELETE RESTRICT,
CONSTRAINT fk_carrier_price_delivery_site
FOREIGN KEY (delivery_site_id) REFERENCES site (id) ON DELETE RESTRICT,
CONSTRAINT fk_carrier_price_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_carrier_price_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
$this->addSql('CREATE INDEX idx_carrier_price_carrier ON carrier_price (carrier_id)');
$this->addSql('CREATE INDEX idx_carrier_price_client ON carrier_price (client_id)');
$this->addSql('CREATE INDEX idx_carrier_price_client_address ON carrier_price (client_delivery_address_id)');
$this->addSql('CREATE INDEX idx_carrier_price_departure_site ON carrier_price (departure_site_id)');
$this->addSql('CREATE INDEX idx_carrier_price_supplier ON carrier_price (supplier_id)');
$this->addSql('CREATE INDEX idx_carrier_price_supplier_address ON carrier_price (supplier_supply_address_id)');
$this->addSql('CREATE INDEX idx_carrier_price_delivery_site ON carrier_price (delivery_site_id)');
$this->addSql('CREATE INDEX idx_carrier_price_created_by ON carrier_price (created_by)');
$this->addSql('CREATE INDEX idx_carrier_price_updated_by ON carrier_price (updated_by)');
$this->comment('carrier_price', '_table', 'Prix d un transporteur (1:n) — onglet Prix (M4). Branche CLIENT ou FOURNISSEUR selon direction (RG-4.09→4.11, CHECK chk_carrier_price_*).');
$this->comment('carrier_price', 'id', 'Identifiant interne auto-incremente.');
$this->comment('carrier_price', 'carrier_id', 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du prix.');
$this->comment('carrier_price', 'direction', 'Sens du prix : CLIENT ou FOURNISSEUR (RG-4.09). Pilote l affichage et l obligation des colonnes client_*/supplier_* (RG-4.10/4.11).');
$this->comment('carrier_price', 'client_id', 'Branche CLIENT (RG-4.10) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi direction = CLIENT.');
$this->comment('carrier_price', 'client_delivery_address_id', 'Branche CLIENT : adresse de livraison du client. FK -> client_address.id, ON DELETE RESTRICT.');
$this->comment('carrier_price', 'departure_site_id', 'Branche CLIENT : adresse de depart = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.');
$this->comment('carrier_price', 'supplier_id', 'Branche FOURNISSEUR (RG-4.11) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi direction = FOURNISSEUR.');
$this->comment('carrier_price', 'supplier_supply_address_id', 'Branche FOURNISSEUR : adresse d approvisionnement du fournisseur. FK -> supplier_address.id, ON DELETE RESTRICT.');
$this->comment('carrier_price', 'delivery_site_id', 'Branche FOURNISSEUR : adresse de livraison = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.');
$this->comment('carrier_price', 'container_type', 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_price_container).');
$this->comment('carrier_price', 'pricing_unit', 'Unite de tarification FORFAIT|TONNE (chk_carrier_price_unit).');
$this->comment('carrier_price', 'price', 'Montant du prix (NUMERIC 12,2).');
$this->comment('carrier_price', 'price_state', 'Etat du prix : EN_COURS, VALIDE ou NON_VALIDE (chk_carrier_price_state). Affiche dans le tableau Prix.');
$this->comment('carrier_price', 'position', 'Ordre d affichage du prix dans la liste du transporteur (croissant).');
$this->addTimestampableBlamableComments('carrier_price');
}
// =================================================================
// Helpers (identiques au M2 Version20260605130000)
// =================================================================
/**
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
* en reutilisant le catalogue partage (source unique, ERP-67).
*/
private function addTimestampableBlamableComments(string $table): void
{
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
$this->comment($table, $column, $description);
}
}
/**
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN`
* en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement d apostrophe.
*/
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,
));
}
}
@@ -15,6 +15,7 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
@@ -147,7 +148,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Index(name: 'idx_client_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_client_updated_by', columns: ['updated_by'])]
#[Auditable]
class Client implements TimestampableInterface, BlamableInterface
class Client implements TimestampableInterface, BlamableInterface, ClientInterface
{
use TimestampableBlamableTrait;
@@ -15,6 +15,7 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepositor
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\ClientAddressInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
@@ -89,7 +90,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Table(name: 'client_address')]
#[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])]
#[Auditable]
class ClientAddress implements TimestampableInterface, BlamableInterface
class ClientAddress implements TimestampableInterface, BlamableInterface, ClientAddressInterface
{
use TimestampableBlamableTrait;
@@ -16,6 +16,7 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SupplierInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
@@ -142,7 +143,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Index(name: 'idx_supplier_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_supplier_updated_by', columns: ['updated_by'])]
#[Auditable]
class Supplier implements TimestampableInterface, BlamableInterface
class Supplier implements TimestampableInterface, BlamableInterface, SupplierInterface
{
use TimestampableBlamableTrait;
@@ -16,6 +16,7 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SupplierAddressInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\Common\Collections\ArrayCollection;
@@ -96,7 +97,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Table(name: 'supplier_address')]
#[ORM\Index(name: 'idx_supplier_address_supplier', columns: ['supplier_id'])]
#[Auditable]
class SupplierAddress implements TimestampableInterface, BlamableInterface
class SupplierAddress implements TimestampableInterface, BlamableInterface, SupplierAddressInterface
{
use TimestampableBlamableTrait;
@@ -117,7 +118,11 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['supplier:item:read'])]
// supplier_address:read : groupe additif consomme par l'embed cross-module
// (CarrierPrice.supplierSupplyAddress, M4 § 3.4). Inerte pour M2 (ses contextes
// ne l'incluent pas) — expose le libelle d'adresse quand un autre module embarque
// une SupplierAddress.
#[Groups(['supplier:item:read', 'supplier_address:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Supplier::class, inversedBy: 'addresses')]
@@ -130,12 +135,12 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'Le type d\'adresse est obligatoire.', normalizer: 'trim')]
#[Assert\Choice(choices: self::ADDRESS_TYPES, message: 'Le type d\'adresse doit être Prospect, Départ ou Rendu.')]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private ?string $addressType = null;
#[ORM\Column(length: 80, options: ['default' => 'France'])]
#[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private string $country = 'France';
// RG-2.05 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur).
@@ -143,24 +148,24 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'Le code postal est obligatoire.', normalizer: 'trim')]
#[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private ?string $postalCode = null;
#[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private ?string $city = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private ?string $streetComplement = null;
// Specifique fournisseur : nombre de bennes sur le site.
@@ -51,9 +51,9 @@ final class RbacSeeder
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
* attacher (admin n'apparait pas car il bypass tout via isAdmin ;
* `commercial.clients.archive`, `commercial.suppliers.archive` et
* `technique.providers.archive` ne sont attaches a aucun role metier
* admin seul).
* `commercial.clients.archive`, `commercial.suppliers.archive`,
* `technique.providers.archive` et `transport.carriers.archive` ne sont
* attaches a aucun role metier admin seul).
*
* Cloisonnement par site des prestataires (M3 § 2.13) : la permission
* `sites.bypass_scope` est attribuee par defaut a Bureau / Compta /
@@ -77,6 +77,9 @@ final class RbacSeeder
// Prestataires (M3 § 2.9, ERP-138) : view + manage (hors Comptabilite).
'technique.providers.view',
'technique.providers.manage',
// Transporteurs (M4 § 5.2, ERP-153) : view + manage (PAS archive -> admin seul).
'transport.carriers.view',
'transport.carriers.manage',
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
@@ -120,6 +123,9 @@ final class RbacSeeder
// (onglet Comptabilite masque/filtre pour la Commerciale).
'technique.providers.view',
'technique.providers.manage',
// Transporteurs (M4 § 5.2, ERP-153) : view seul (consultation « Tout »,
// ni manage ni archive pour la Commerciale).
'transport.carriers.view',
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
@@ -212,6 +212,11 @@ final class SeedE2ECommand extends Command
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
'technique.providers.archive',
// Transport — Repertoire transporteurs (M4, ERP-153). Meme
// logique : mappe sur le persona "tout". Miroir de personas.ts.
'transport.carriers.view',
'transport.carriers.manage',
'transport.carriers.archive',
],
],
[
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Application\Validator;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Technique\Domain\Entity\Provider;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier (spec-front M3 § Onglet Comptabilite jumeau de
* SupplierAccountingCompletenessValidator M2) : a la soumission complete de
* l'onglet Comptabilite, les six champs scalaires obligatoires doivent etre
* renseignes (SIREN, Numero de compte, Mode de TVA, N de TVA, Delai de reglement,
* Type de reglement). La banque reste conditionnelle (RG-3.07) et les RIB aussi
* (RG-3.08) : ils ne sont pas couverts ici (Assert\Callback sur l'entite Provider
* validatePaymentTypeConsistency).
*
* Parti pris (miroir M1/M2) : colonnes nullable en base + validateur contextuel,
* plutot qu'un Assert\NotBlank sur l'entite (qui casserait le POST de l'onglet
* principal, lequel n'envoie aucun champ comptable).
*
* Invoque par le ProviderProcessor uniquement quand le payload porte les six
* champs (= une validation d'onglet), jamais sur un PATCH ciblant un seul champ.
*
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
* coherence avec les violations Symfony rendues par API Platform (mapping inline
* front via useFormErrors, ERP-101).
*/
final class ProviderAccountingCompletenessValidator
{
public function validate(Provider $provider): void
{
// Map champ -> valeur courante des champs obligatoires de l'onglet.
$fields = [
'siren' => $provider->getSiren(),
'accountNumber' => $provider->getAccountNumber(),
'tvaMode' => $provider->getTvaMode(),
'nTva' => $provider->getNTva(),
'paymentDelay' => $provider->getPaymentDelay(),
'paymentType' => $provider->getPaymentType(),
];
$violations = new ConstraintViolationList();
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
'Ce champ est obligatoire.',
null,
[],
$provider,
$property,
$value,
));
}
}
if (count($violations) > 0) {
throw new ValidationException($violations);
}
}
/**
* Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les
* references (TvaMode / PaymentDelay / PaymentType) ne sont manquantes que
* lorsqu'elles valent null.
*/
private function isMissing(mixed $value): bool
{
if (null === $value) {
return true;
}
return is_string($value) && '' === trim($value);
}
}
@@ -119,23 +119,18 @@ final class ProviderContactProcessor implements ProcessorInterface
}
/**
* RG-3.04 : un bloc Contact est valide des qu'au moins un champ parmi prenom /
* nom / fonction / telephone principal / email est renseigne (double garde avec
* le CHECK BDD chk_provider_contact_name leve une 422 propre rattachee au
* champ `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
* chaines vides (y compris une fonction ou un phone_secondary vides) sont deja
* ramenees a null et ne suffisent pas a valider le bloc.
* RG-3.04 : un bloc Contact exige au moins le prenom OU le nom (aligne sur le
* M1/M2 un contact se materialise par son nom ; fonction / telephone / email
* seuls ne suffisent pas). Double garde avec le CHECK BDD chk_provider_contact_name
* leve une 422 propre rattachee au champ `firstName` plutot qu'une 500 SQL.
* Joue apres normalisation (les chaines vides sont deja ramenees a null).
*/
private function validateName(ProviderContact $contact): void
{
if (null === $contact->getFirstName()
&& null === $contact->getLastName()
&& null === $contact->getJobTitle()
&& null === $contact->getPhonePrimary()
&& null === $contact->getEmail()) {
if (null === $contact->getFirstName() && null === $contact->getLastName()) {
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Au moins un champ du contact est obligatoire (nom, prénom, fonction, téléphone ou email).',
'Le prénom ou le nom du contact est obligatoire.',
null,
[],
$contact,
@@ -9,6 +9,7 @@ use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Core\Domain\Entity\User;
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
use App\Module\Technique\Application\Validator\ProviderAccountingCompletenessValidator;
use App\Module\Technique\Domain\Entity\Provider;
use App\Shared\Domain\Contract\SiteInterface;
use DateTimeImmutable;
@@ -75,6 +76,15 @@ final class ProviderProcessor implements ProcessorInterface
'paymentType', 'bank',
];
/**
* Champs comptables obligatoires a la validation complete de l'onglet
* (spec-front M3 § Onglet Comptabilite miroir M1/M2). bank est exclu :
* conditionnel (RG-3.07).
*/
private const array ACCOUNTING_REQUIRED_FIELDS = [
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType',
];
/** Champ d'archivage (groupe provider:write:archive). */
private const string ARCHIVE_FIELD = 'isArchived';
@@ -102,6 +112,7 @@ final class ProviderProcessor implements ProcessorInterface
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
private readonly ProviderAccountingCompletenessValidator $accountingValidator,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -128,6 +139,10 @@ final class ProviderProcessor implements ProcessorInterface
// deux cotes (l'etat persiste l'a deja ete).
$this->guardManage($data);
// Completude de l'onglet Comptabilite (apres normalize : les chaines vides
// sont deja ramenees a null). Joue uniquement sur une soumission d'onglet.
$this->validateAccountingCompleteness($data);
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
@@ -496,6 +511,21 @@ final class ProviderProcessor implements ProcessorInterface
*
* @return list<string>
*/
/**
* Completude de l'onglet Comptabilite (miroir SupplierProcessor) : ne se
* declenche que si TOUS les champs requis sont presents dans le payload
* (= soumission d'onglet, pas un PATCH partiel cible). Delegue au validateur
* qui leve une 422 listant chaque champ manquant (mapping inline ERP-101).
*/
private function validateAccountingCompleteness(Provider $data): void
{
if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) {
return;
}
$this->accountingValidator->validate($data);
}
private function payloadKeys(): array
{
$request = $this->requestStack->getCurrentRequest();
@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Application\Idtf;
use RuntimeException;
use function array_slice;
/**
* Parsing pur d'une matrice (lignes/colonnes 0-indexees, telle que retournee
* par PhpSpreadsheet::toArray) de l'export Excel IDTF vers des lignes
* normalisees pretes a l'upsert. Sans dependance a PhpSpreadsheet : la matrice
* est un simple tableau, ce qui rend le parsing testable en isolation.
*
* Robuste au reordonnancement des colonnes (mapping par libelle normalise) et
* aux lignes de preambule (detection dynamique de la ligne d'en-tete). Voir
* ERP-149 § 2.
*/
final class IdtfSheetParser
{
/**
* @param array<int, array<int, mixed>> $matrix
*
* @return array{exportDate: null|string, rows: list<array<string, mixed>>}
*/
public static function parse(array $matrix): array
{
$exportDate = self::extractExportDate($matrix);
$headerIndex = self::findHeaderIndex($matrix);
if (null === $headerIndex) {
throw new RuntimeException("Ligne d'en-tete introuvable (colonne 'Numero IDTF').");
}
$map = self::buildColumnMap($matrix[$headerIndex]);
if (!isset($map['idtf_number'])) {
throw new RuntimeException("Colonne 'Numero IDTF' introuvable dans l'en-tete.");
}
$rows = [];
foreach (array_slice($matrix, $headerIndex + 1) as $row) {
$idtf = trim((string) ($row[$map['idtf_number']] ?? ''));
// Ligne vide / non exploitable : pas d'identifiant numerique.
if ('' === $idtf || !ctype_digit($idtf)) {
continue;
}
$rows[] = [
'idtf_number' => (int) $idtf,
'product_group' => self::val($row, $map['product_group'] ?? null),
'name' => self::val($row, $map['name'] ?? null) ?? '',
'cleaning_regime' => self::val($row, $map['cleaning_regime'] ?? null) ?? '',
'important_requirements' => self::val($row, $map['important_requirements'] ?? null),
'mandatory_date' => self::parseDate(self::val($row, $map['mandatory_date'] ?? null)),
'related_products' => self::val($row, $map['related_products'] ?? null),
'formula' => self::val($row, $map['formula'] ?? null),
'eural_code' => self::val($row, $map['eural_code'] ?? null),
'cas_numbers' => self::splitCas(self::val($row, $map['cas'] ?? null)),
'footnotes' => self::val($row, $map['footnotes'] ?? null),
];
}
return ['exportDate' => $exportDate, 'rows' => $rows];
}
/**
* Cherche une date "d-m-Y" dans les premieres lignes (preambule
* "Export date: 12-6-2026") et la convertit en "Y-m-d". Null si absente.
*
* @param array<int, array<int, mixed>> $matrix
*/
public static function extractExportDate(array $matrix): ?string
{
foreach (array_slice($matrix, 0, 5) as $row) {
$line = implode(' ', array_map(static fn (mixed $c): string => (string) $c, $row));
if (preg_match('/(\d{1,2})-(\d{1,2})-(\d{4})/', $line, $m)) {
$day = (int) $m[1];
$month = (int) $m[2];
$year = (int) $m[3];
if (checkdate($month, $day, $year)) {
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
}
}
return null;
}
/**
* Index de la ligne d'en-tete : premiere ligne contenant une cellule dont
* le libelle normalise contient "numero idtf".
*
* @param array<int, array<int, mixed>> $matrix
*/
private static function findHeaderIndex(array $matrix): ?int
{
foreach ($matrix as $i => $row) {
foreach ($row as $cell) {
if (str_contains(self::normalize((string) $cell), 'numero idtf')) {
return $i;
}
}
}
return null;
}
/**
* Construit le mapping logique -> index de colonne a partir de la ligne
* d'en-tete (resiste au reordonnancement via fields[]).
*
* @param array<int, mixed> $header
*
* @return array<string, int>
*/
private static function buildColumnMap(array $header): array
{
$map = [];
foreach ($header as $col => $label) {
$n = self::normalize((string) $label);
$key = match (true) {
str_contains($n, 'numero idtf') => 'idtf_number',
str_contains($n, 'product group'),
str_contains($n, 'groupe') => 'product_group',
str_contains($n, 'nom de la marchandise') => 'name',
str_contains($n, 'regime de nettoyage') => 'cleaning_regime',
str_contains($n, 'exigences importantes') => 'important_requirements',
str_contains($n, 'date d application') => 'mandatory_date',
str_contains($n, 'produits apparentes') => 'related_products',
str_contains($n, 'formule') => 'formula',
str_contains($n, 'code eural') => 'eural_code',
str_contains($n, 'numero cas') => 'cas',
str_contains($n, 'annotations') => 'footnotes',
default => null,
};
if (null !== $key && !isset($map[$key])) {
$map[$key] = (int) $col;
}
}
return $map;
}
/**
* Convertit une date "dd-mm-yyyy" en "yyyy-mm-dd". Null si format invalide
* ou date calendaire impossible.
*/
private static function parseDate(?string $raw): ?string
{
if (null === $raw || !preg_match('/^(\d{1,2})-(\d{1,2})-(\d{4})$/', $raw, $m)) {
return null;
}
$day = (int) $m[1];
$month = (int) $m[2];
$year = (int) $m[3];
if (!checkdate($month, $day, $year)) {
return null;
}
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
/**
* Eclate une cellule "Numero CAS" sur ';' en liste de chaines non vides.
*
* @return list<string>
*/
private static function splitCas(?string $raw): array
{
if (null === $raw) {
return [];
}
$parts = array_map('trim', explode(';', $raw));
return array_values(array_filter($parts, static fn (string $v): bool => '' !== $v));
}
/**
* Valeur d'une cellule par index : trim, null si absente/vide.
*
* @param array<int, mixed> $row
*/
private static function val(array $row, ?int $col): ?string
{
if (null === $col) {
return null;
}
$v = trim((string) ($row[$col] ?? ''));
return '' === $v ? null : $v;
}
/**
* Normalise un libelle d'en-tete : minuscules, sans accents ni apostrophes,
* espaces compresses (pour un matching robuste).
*/
private static function normalize(string $s): string
{
$s = str_replace(['', "'"], ' ', $s);
$s = (string) iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
$s = mb_strtolower($s);
return trim((string) preg_replace('/\s+/', ' ', $s));
}
}
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Application\Qualimat;
/**
* Mapping pur d'un item brut de l'API QUALIMAT vers une ligne normalisee
* prete a l'upsert dans `qualimat_carrier`. Sans dependance (testable en
* isolation). Voir ERP-39 § 2 pour les pieges qualite de la source.
*/
final class QualimatRowMapper
{
/**
* Mappe un lot d'items. Les items sans SIRET exploitable sont ignores et
* comptes a part (cf. `rows_skipped` du journal). Les doublons de SIRET
* (source "sale" : memes chiffres a separateurs pres) sont fusionnes,
* derniere occurrence gagnante l'upsert ne verrait qu'une ligne de toute
* facon, et le compte `rows_upserted` reflete ainsi les transporteurs
* distincts.
*
* @param array<int, array<string, mixed>> $items
*
* @return array{rows: list<array<string, mixed>>, skipped: int}
*/
public static function mapMany(array $items): array
{
$bySiret = [];
$skipped = 0;
foreach ($items as $item) {
$row = self::mapOne($item);
if (null === $row) {
++$skipped;
continue;
}
// Cle = SIRET normalise : une occurrence ulterieure ecrase la
// precedente (derniere gagnante).
$bySiret[$row['siret']] = $row;
}
return ['rows' => array_values($bySiret), 'skipped' => $skipped];
}
/**
* Mappe un item unique. Retourne null si le SIRET est absent ou vide
* (ligne inexploitable : pas de cle naturelle pour l'upsert).
*
* @param array<string, mixed> $item
*
* @return null|array<string, mixed>
*/
public static function mapOne(array $item): ?array
{
$siret = self::normalizeSiret(self::str($item['Siret'] ?? null));
if (null === $siret) {
return null;
}
return [
'siret' => $siret,
// Nom et Societe sont identiques a la source : une seule colonne.
'name' => self::str($item['Nom'] ?? null) ?? '',
'address' => self::str($item['Adresse'] ?? null),
'postal_code' => self::str($item['CodePostal'] ?? null),
'city' => self::str($item['Ville'] ?? null),
'phone' => self::str($item['Telephone_1'] ?? null),
'department' => self::str($item['Departement'] ?? null),
// Statut conserve brut (feed externe, valeurs non contraintes).
'status' => self::str($item['Statut'] ?? null) ?? '',
'validity_date' => self::parseDate(self::str($item['Validite'] ?? null)),
];
}
/**
* Normalise un SIRET : ne conserve que les chiffres. Null si vide.
* La source est "sale" (longueurs variables 7 a 14) : aucune contrainte
* de longueur, on stocke les chiffres tels quels.
*/
public static function normalizeSiret(?string $raw): ?string
{
if (null === $raw) {
return null;
}
$digits = preg_replace('/\D+/', '', $raw) ?? '';
return '' === $digits ? null : $digits;
}
/**
* Convertit une date "dd/mm/yyyy" en "yyyy-mm-dd". Null si le format ne
* correspond pas ou si la date n'est pas un jour calendaire valide
* (garde-fou : evite un INSERT en erreur sur une date impossible).
*/
public static function parseDate(?string $raw): ?string
{
if (null === $raw || !preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $raw, $m)) {
return null;
}
$day = (int) $m[1];
$month = (int) $m[2];
$year = (int) $m[3];
if (!checkdate($month, $day, $year)) {
return null;
}
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
/**
* Trim d'une valeur scalaire ; null si la chaine resultante est vide.
*/
private static function str(mixed $value): ?string
{
if (null === $value) {
return null;
}
$trimmed = trim((string) $value);
return '' === $trimmed ? null : $trimmed;
}
}
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Application\Service;
/**
* Normalisation serveur des champs texte d'un Carrier / CarrierContact, appliquee
* par le CarrierProcessor (et les futurs processors de sous-ressources, WT6/7/8)
* AVANT persistance. Cf. spec-back M4 § 2.10 + RG-4.13. Jumeau de
* SupplierFieldNormalizer (M2), enrichi du cas LIOT (immatriculations).
*
* - name : UPPERCASE integral (RG-4.13)
* - firstName / lastName (personnes, sur CarrierContact) : Title Case (RG-4.13)
* - phone* : chiffres uniquement, ex "06.12.34.56.78" -> "0612345678" (RG-4.13).
* Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front.
* - email : lowercase integral (RG-4.13)
* - liotPlates : liste « ; » -> split, trim, UPPER, rejoin "; " (cas LIOT RG-4.01).
*
* Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide apres
* trim devient null (evite de persister "" dans des colonnes nullable).
*/
final class CarrierFieldNormalizer
{
/**
* Raison sociale en majuscules (RG-4.13). Conserve null tel quel ; une chaine
* non vide est trim + upper. Une chaine vide reste "" (champ obligatoire :
* c'est l'Assert\NotBlank qui rejette, pas le normalizer).
*/
public function normalizeName(?string $value): ?string
{
if (null === $value) {
return null;
}
return mb_strtoupper(trim($value), 'UTF-8');
}
/**
* Nom/prenom de personne en Title Case (RG-4.13) : "JEAN dupont" ->
* "Jean Dupont". Une chaine vide apres trim devient null.
*/
public function normalizePersonName(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8');
}
/**
* Email en minuscules (RG-4.13). Une chaine vide apres trim devient null.
*/
public function normalizeEmail(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : mb_strtolower($value, 'UTF-8');
}
/**
* Telephone reduit aux chiffres (RG-4.13) : "06.12.34.56.78" -> "0612345678".
* Une valeur sans aucun chiffre devient null.
*/
public function normalizePhone(?string $value): ?string
{
if (null === $value) {
return null;
}
$digits = preg_replace('/\D+/', '', $value) ?? '';
return '' === $digits ? null : $digits;
}
/**
* Immatriculations LIOT (RG-4.01 / RG-4.13) : la saisie « ; »-separee est
* decoupee, chaque plaque trim + UPPER, les segments vides ecartes, puis
* recomposee avec le separateur canonique "; ". Une saisie sans aucune plaque
* exploitable devient null.
*/
public function normalizeLiotPlates(?string $value): ?string
{
if (null === $value) {
return null;
}
$plates = [];
foreach (explode(';', $value) as $plate) {
$plate = trim($plate);
if ('' !== $plate) {
$plates[] = mb_strtoupper($plate, 'UTF-8');
}
}
return [] === $plates ? null : implode('; ', $plates);
}
}
@@ -0,0 +1,525 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierProcessor;
use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\CarrierProvider;
use App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Entity\UploadedDocument;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Transporteur (M4 Transport) entite racine du repertoire transporteurs,
* jumelle de Supplier (M2) / Provider (M3). Porte le formulaire principal, le
* lien editable vers le referentiel QUALIMAT (§ 2.5), l'archivage
* (is_archived / archived_at) et le soft-delete technique prepare mais non
* expose au M4 (deleted_at).
*
* Perimetre WT4 (ERP-158) = formulaire principal en ecriture. L'#[ApiResource]
* expose desormais Post + Patch (via CarrierProcessor : normalisation RG-4.13,
* gating archive mode strict RG-4.14, 409 doublon de nom RG-4.12) en plus du
* contrat de lecture pose au WT3. Les proprietes du formulaire principal portent
* leur groupe d'ecriture (carrier:write:main / carrier:write:archive) et leurs
* contraintes Assert ; les RG conditionnelles (RG-4.01 certification obligatoire
* sauf LIOT, RG-4.02 AUTRE -> decharge, RG-4.03 affrete -> indexation/benne/volume)
* sont portees par validateMainFormConsistency (Assert\Callback + ->atPath()).
* Les sous-ressources d'ecriture (adresses/contacts/prix) arrivent aux worktrees
* suivants (WT6/7/8). Les invariants BDD (NOT NULL, CHECK enum, FK, unicite
* partielle) restent garantis par la migration Version20260615150000.
*
* Contrat de serialisation (RETEX M1, 3 maillons spec § 4.0) :
* - LISTE (carrier:read + qualimat:read + default:read) : name, certificationType,
* qualimatCarrier (statut/validite RG-4.04), updatedAt.
* - DETAIL (+ carrier:item:read + embeds client/supplier/site...) : sous-collections
* addresses / contacts / prices embarquees, avec les entites cross-module
* (Client/Supplier/Site/adresses) serialisees via leurs read-groups.
*
* Pas de #[ORM\UniqueConstraint] : l'unicite du nom (RG-4.12) est portee par
* l'index partiel fonctionnel uq_carrier_name_active (LOWER(name) WHERE
* is_archived = FALSE AND deleted_at IS NULL), inexprimable en attribut ORM.
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('transport.carriers.view')",
// Liste : embarque qualimatCarrier (ManyToOne, fetch-join sur cette
// seule relation cote repository — § 2.11) pour le statut/date de
// validite QUALIMAT (RG-4.04). Aucune sous-collection en liste.
normalizationContext: ['groups' => ['carrier:read', 'qualimat:read', 'default:read']],
provider: CarrierProvider::class,
),
new Get(
security: "is_granted('transport.carriers.view')",
// Detail : transporteur + qualimatCarrier + sous-collections embarquees
// (addresses / contacts / prices). Les relations cross-module des prix
// (client / supplier / sites / adresses) sont embarquees via leurs
// read-groups (client:read / supplier:read / ... — bugs #1/#2 M1).
normalizationContext: ['groups' => [
'carrier:read',
'carrier:item:read',
'qualimat:read',
'client:read',
'client_address:read',
'supplier:read',
'supplier_address:read',
'site:read',
'default:read',
]],
provider: CarrierProvider::class,
),
new Post(
// Creation du formulaire principal (RG-4.01 -> RG-4.03 / RG-4.12 /
// RG-4.13). La reponse 201 ne renvoie que les scalaires principaux +
// id : le front enchaine ensuite les sous-ressources par onglet.
security: "is_granted('transport.carriers.manage')",
normalizationContext: ['groups' => ['carrier:read', 'default:read']],
denormalizationContext: ['groups' => ['carrier:write:main']],
processor: CarrierProcessor::class,
),
new Patch(
// Security elargie au seul `manage` (Admin + Bureau). Le CarrierProcessor
// re-gate ensuite l'archivage : un payload basculant isArchived exige
// `transport.carriers.archive` (Admin seul -> Bureau recoit 403, mode
// strict RG-4.14).
security: "is_granted('transport.carriers.manage')",
normalizationContext: ['groups' => ['carrier:read', 'default:read']],
denormalizationContext: ['groups' => ['carrier:write:main', 'carrier:write:archive']],
provider: CarrierProvider::class,
processor: CarrierProcessor::class,
),
// Pas de Delete au M4 (HP-M4-C). Archivage via PATCH { isArchived: true }.
],
)]
#[ORM\Entity(repositoryClass: DoctrineCarrierRepository::class)]
#[ORM\Table(name: 'carrier')]
#[ORM\Index(name: 'idx_carrier_is_archived', columns: ['is_archived'])]
#[ORM\Index(name: 'idx_carrier_deleted_at', columns: ['deleted_at'])]
#[ORM\Index(name: 'idx_carrier_qualimat', columns: ['qualimat_carrier_id'])]
#[ORM\Index(name: 'idx_carrier_discharge_document', columns: ['discharge_document_id'])]
#[ORM\Index(name: 'idx_carrier_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_carrier_updated_by', columns: ['updated_by'])]
#[Auditable]
class Carrier implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
/** RG-4.01 : nom du cas special « compte-propre » LIOT (comparaison insensible a la casse). */
private const string LIOT_NAME = 'LIOT';
/** RG-4.02 : valeur de certification imposant le champ Decharge. */
private const string CERTIFICATION_AUTRE = 'AUTRE';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['carrier:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'Le nom du transporteur est obligatoire.', normalizer: 'trim')]
#[Assert\Length(
min: 2,
max: 255,
minMessage: 'Le nom du transporteur doit contenir au moins {{ limit }} caractères.',
maxMessage: 'Le nom du transporteur ne peut dépasser {{ limit }} caractères.',
normalizer: 'trim',
)]
#[Groups(['carrier:read', 'carrier:write:main'])]
private ?string $name = null;
/** Lien editable vers le referentiel QUALIMAT (saisie assistee RG-4.01, § 2.5). */
#[ORM\ManyToOne(targetEntity: QualimatCarrier::class)]
#[ORM\JoinColumn(name: 'qualimat_carrier_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['carrier:read', 'carrier:write:main'])]
private ?QualimatCarrier $qualimatCarrier = null;
/** QUALIMAT|GMP_PLUS|OVOCOM|COMPTE_PROPRE|AUTRE ; null en cas LIOT (RG-4.01). */
#[ORM\Column(length: 20, nullable: true)]
#[Assert\Choice(
choices: ['QUALIMAT', 'GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'],
message: 'Type de certification invalide.',
)]
// Obligatoire SAUF en cas LIOT (champ masque) : controle conditionnel dans
// validateMainFormConsistency (RG-4.01). La longueur est bornee par le Choice
// (whitelist EntityConstraintsHaveFrenchMessageTest::EXCLUDED_LENGTH_MIRROR).
#[Groups(['carrier:read', 'carrier:write:main'])]
private ?string $certificationType = null;
#[ORM\Column(name: 'is_chartered', options: ['default' => false])]
#[Groups(['carrier:write:main'])]
private bool $isChartered = false;
/** % d'indexation — obligatoire si affrete (RG-4.03, validateMainFormConsistency). */
#[ORM\Column(name: 'indexation_rate', type: 'decimal', precision: 5, scale: 2, nullable: true)]
#[Groups(['carrier:read', 'carrier:write:main'])]
private ?string $indexationRate = null;
/** BENNE|FOND_MOUVANT — obligatoire si affrete (RG-4.03). */
#[ORM\Column(name: 'container_type', length: 12, nullable: true)]
#[Assert\Choice(choices: ['BENNE', 'FOND_MOUVANT'], message: 'Type de contenant invalide.')]
// Longueur bornee par le Choice (whitelist EXCLUDED_LENGTH_MIRROR).
#[Groups(['carrier:read', 'carrier:write:main'])]
private ?string $containerType = null;
/** Volume m3 — obligatoire si affrete (RG-4.03). */
#[ORM\Column(name: 'volume_m3', type: 'decimal', precision: 10, scale: 2, nullable: true)]
#[Groups(['carrier:read', 'carrier:write:main'])]
private ?string $volumeM3 = null;
/** Decharge (upload, visible si certificationType = AUTRE — RG-4.02). Infra Shared (§ 2.7). */
#[ORM\ManyToOne(targetEntity: UploadedDocument::class)]
#[ORM\JoinColumn(name: 'discharge_document_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['carrier:read', 'carrier:write:main'])]
private ?UploadedDocument $dischargeDocument = null;
/** Immatriculations LIOT separees par « ; » (cas LIOT — RG-4.01). */
#[ORM\Column(name: 'liot_plates', type: 'text', nullable: true)]
#[Groups(['carrier:read', 'carrier:write:main'])]
private ?string $liotPlates = null;
// === Sous-collections — EMBARQUEES dans le DETAIL (read-group sur le getter) ===
/** @var Collection<int, CarrierAddress> */
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $addresses;
/** @var Collection<int, CarrierContact> */
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contacts;
/** @var Collection<int, CarrierPrice> */
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierPrice::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $prices;
// === Archive / Soft delete ===
// Le groupe de LECTURE est declare sur le getter isArchived() avec
// SerializedName('isArchived') (piege booleen #3 M1) ; le groupe d'ECRITURE
// vit sur la propriete pour que la denormalisation cible setIsArchived.
#[ORM\Column(name: 'is_archived', options: ['default' => false])]
#[Groups(['carrier:write:archive'])]
private bool $isArchived = false;
#[ORM\Column(name: 'archived_at', type: 'datetime_immutable', nullable: true)]
#[Groups(['carrier:read'])]
private ?DateTimeImmutable $archivedAt = null;
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
private ?DateTimeImmutable $deletedAt = null;
public function __construct()
{
$this->addresses = new ArrayCollection();
$this->contacts = new ArrayCollection();
$this->prices = new ArrayCollection();
}
/**
* Coherence conditionnelle du formulaire principal (RG-4.01 / RG-4.02 /
* RG-4.03). Decision figee (miroir M2 RG-2.07/2.08) : ces RG inter-champs
* passent par une contrainte d'entite (Assert\Callback + ->atPath()) et NON par
* le CarrierProcessor, afin que chaque 422 porte un propertyPath exploitable
* par useFormErrors (mapping inline sous le champ, pas un toast ERP-101).
* Jouee par API Platform AVANT le processor, sur POST comme sur PATCH.
*
* Cas LIOT (RG-4.01) : seul liotPlates est pertinent ; les autres champs sont
* masques cote front et le back ne les valide pas (« stocke ce qu'il recoit,
* pas de 422 sur la presence residuelle »). Le nom est compare en majuscules
* car la normalisation UPPER n'intervient qu'au processor (apres validation).
*/
#[Assert\Callback]
public function validateMainFormConsistency(ExecutionContextInterface $context): void
{
if (self::LIOT_NAME === mb_strtoupper(trim((string) $this->name), 'UTF-8')) {
return;
}
// RG-4.01 : certification obligatoire hors cas LIOT.
if (null === $this->certificationType || '' === $this->certificationType) {
$context->buildViolation('Le type de certification est obligatoire.')
->atPath('certificationType')
->addViolation()
;
}
// RG-4.02 : certification AUTRE -> decharge obligatoire.
if (self::CERTIFICATION_AUTRE === $this->certificationType && null === $this->dischargeDocument) {
$context->buildViolation('La décharge est obligatoire pour une certification « Autre ».')
->atPath('dischargeDocument')
->addViolation()
;
}
// RG-4.03 : affrete -> indexation + benne/fond mouvant + volume obligatoires.
if ($this->isChartered) {
if (null === $this->indexationRate) {
$context->buildViolation('Le taux d\'indexation est obligatoire pour un transporteur affrété.')
->atPath('indexationRate')
->addViolation()
;
}
if (null === $this->containerType) {
$context->buildViolation('Le type de contenant est obligatoire pour un transporteur affrété.')
->atPath('containerType')
->addViolation()
;
}
if (null === $this->volumeM3) {
$context->buildViolation('Le volume est obligatoire pour un transporteur affrété.')
->atPath('volumeM3')
->addViolation()
;
}
}
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getQualimatCarrier(): ?QualimatCarrier
{
return $this->qualimatCarrier;
}
public function setQualimatCarrier(?QualimatCarrier $qualimatCarrier): static
{
$this->qualimatCarrier = $qualimatCarrier;
return $this;
}
public function getCertificationType(): ?string
{
return $this->certificationType;
}
public function setCertificationType(?string $certificationType): static
{
$this->certificationType = $certificationType;
return $this;
}
// Boolean trap (RETEX M1 bug #3) : #[Groups] + #[SerializedName] sur le getter,
// sinon Symfony strip le prefixe "is" et drope la cle du JSON.
#[Groups(['carrier:read'])]
#[SerializedName('isChartered')]
public function isChartered(): bool
{
return $this->isChartered;
}
public function setIsChartered(bool $isChartered): static
{
$this->isChartered = $isChartered;
return $this;
}
public function getIndexationRate(): ?string
{
return $this->indexationRate;
}
public function setIndexationRate(?string $indexationRate): static
{
$this->indexationRate = $indexationRate;
return $this;
}
public function getContainerType(): ?string
{
return $this->containerType;
}
public function setContainerType(?string $containerType): static
{
$this->containerType = $containerType;
return $this;
}
public function getVolumeM3(): ?string
{
return $this->volumeM3;
}
public function setVolumeM3(?string $volumeM3): static
{
$this->volumeM3 = $volumeM3;
return $this;
}
public function getDischargeDocument(): ?UploadedDocument
{
return $this->dischargeDocument;
}
public function setDischargeDocument(?UploadedDocument $dischargeDocument): static
{
$this->dischargeDocument = $dischargeDocument;
return $this;
}
public function getLiotPlates(): ?string
{
return $this->liotPlates;
}
public function setLiotPlates(?string $liotPlates): static
{
$this->liotPlates = $liotPlates;
return $this;
}
/** @return Collection<int, CarrierAddress> */
#[Groups(['carrier:item:read'])]
public function getAddresses(): Collection
{
return $this->addresses;
}
public function addAddress(CarrierAddress $address): static
{
if (!$this->addresses->contains($address)) {
$this->addresses->add($address);
$address->setCarrier($this);
}
return $this;
}
public function removeAddress(CarrierAddress $address): static
{
if ($this->addresses->removeElement($address) && $address->getCarrier() === $this) {
$address->setCarrier(null);
}
return $this;
}
/** @return Collection<int, CarrierContact> */
#[Groups(['carrier:item:read'])]
public function getContacts(): Collection
{
return $this->contacts;
}
public function addContact(CarrierContact $contact): static
{
if (!$this->contacts->contains($contact)) {
$this->contacts->add($contact);
$contact->setCarrier($this);
}
return $this;
}
public function removeContact(CarrierContact $contact): static
{
if ($this->contacts->removeElement($contact) && $contact->getCarrier() === $this) {
$contact->setCarrier(null);
}
return $this;
}
/** @return Collection<int, CarrierPrice> */
#[Groups(['carrier:item:read'])]
public function getPrices(): Collection
{
return $this->prices;
}
public function addPrice(CarrierPrice $price): static
{
if (!$this->prices->contains($price)) {
$this->prices->add($price);
$price->setCarrier($this);
}
return $this;
}
public function removePrice(CarrierPrice $price): static
{
if ($this->prices->removeElement($price) && $price->getCarrier() === $this) {
$price->setCarrier(null);
}
return $this;
}
// Boolean trap (cf. isChartered) : groupe de lecture + SerializedName sur le getter.
#[Groups(['carrier:read'])]
#[SerializedName('isArchived')]
public function isArchived(): bool
{
return $this->isArchived;
}
public function setIsArchived(bool $isArchived): static
{
$this->isArchived = $isArchived;
return $this;
}
public function getArchivedAt(): ?DateTimeImmutable
{
return $this->archivedAt;
}
public function setArchivedAt(?DateTimeImmutable $archivedAt): static
{
$this->archivedAt = $archivedAt;
return $this;
}
public function getDeletedAt(): ?DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
}
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierAddressProcessor;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Adresse d'un transporteur (1:n) onglet Adresse (M4). Jumelle de
* SupplierAddress (M2), version simplifiee (pas de type d'adresse, pas de M2M
* sites/categories sur l'adresse : les sites du M4 vivent dans l'onglet Prix).
*
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
* transporteur). Ecriture : groupe `carrier:write:addresses`.
*
* Sous-ressource API (ERP-159, spec § 4.5) jumelle de SupplierAddress (M2) /
* ProviderAddress (M3), sans address_type ni M2M (les sites du M4 vivent dans
* l'onglet Prix) :
* - POST /api/carriers/{carrierId}/addresses : creation rattachee au
* transporteur parent (Link toProperty 'carrier'), security
* transport.carriers.manage.
* - PATCH / DELETE /api/carrier_addresses/{id} : security
* transport.carriers.manage.
* - GET /api/carrier_addresses/{id} : lecture unitaire (security view) la
* lecture courante reste via le parent. Pas de GET collection autonome.
* Tout passe par le CarrierAddressProcessor (rattachement parent + RG-4.05).
*
* Regles de l'onglet Adresse :
* - RG-4.06 : code postal a 4 ou 5 chiffres (Assert\Regex ; pas de controle
* CP/ville serveur, l'autocomplete BAN est front).
* - RG-4.05 : si le transporteur est affrete (isChartered), l'adresse devient
* obligatoire (Pays / CP / Ville / Adresse) validation conditionnelle portee
* par le CarrierAddressProcessor (le parent n'est pas disponible a la
* validation Symfony sur un POST sous-ressource en read:false).
* - RG-4.07 : masquage du bouton « Valider » si QUALIMAT = front ; le back
* accepte le PATCH normalement (aucune garde back specifique).
*
* Audite (#[Auditable]) + Timestampable / Blamable.
*/
#[ApiResource(
operations: [
new Get(
security: "is_granted('transport.carriers.view')",
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
),
new Post(
uriTemplate: '/carriers/{carrierId}/addresses',
uriVariables: [
'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'),
],
// read:false : pas de stade lecture du parent. Le Link toProperty
// resoudrait l'enfant (SELECT CarrierAddress ... WHERE carrier = :id)
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
// manuellement par CarrierAddressProcessor::linkParent (404 si absent).
read: false,
security: "is_granted('transport.carriers.manage')",
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
denormalizationContext: ['groups' => ['carrier:write:addresses']],
processor: CarrierAddressProcessor::class,
),
new Patch(
security: "is_granted('transport.carriers.manage')",
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
denormalizationContext: ['groups' => ['carrier:write:addresses']],
processor: CarrierAddressProcessor::class,
),
new Delete(
security: "is_granted('transport.carriers.manage')",
processor: CarrierAddressProcessor::class,
),
],
)]
#[ORM\Entity]
#[ORM\Table(name: 'carrier_address')]
#[ORM\Index(name: 'idx_carrier_address_carrier', columns: ['carrier_id'])]
#[ORM\Index(name: 'idx_carrier_address_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_carrier_address_updated_by', columns: ['updated_by'])]
#[Auditable]
class CarrierAddress implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['carrier:item:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Carrier::class, inversedBy: 'addresses')]
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Carrier $carrier = null;
#[ORM\Column(length: 80, options: ['default' => 'France'])]
#[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
private string $country = 'France';
// RG-4.06 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur,
// l'autocomplete BAN est front). Le Regex borne deja la longueur (<= 5) : pas
// de Length redondant (whitelist EXCLUDED_LENGTH_MIRROR). Nullable : obligatoire
// seulement si affrete (RG-4.05, garde CarrierAddressProcessor).
#[ORM\Column(name: 'postal_code', length: 20, nullable: true)]
#[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
private ?string $postalCode = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
private ?string $city = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'L\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
private ?string $street = null;
#[ORM\Column(name: 'street_complement', length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
private ?string $streetComplement = null;
#[ORM\Column(options: ['default' => 0])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getCarrier(): ?Carrier
{
return $this->carrier;
}
public function setCarrier(?Carrier $carrier): static
{
$this->carrier = $carrier;
return $this;
}
public function getCountry(): string
{
return $this->country;
}
public function setCountry(string $country): static
{
$this->country = $country;
return $this;
}
public function getPostalCode(): ?string
{
return $this->postalCode;
}
public function setPostalCode(?string $postalCode): static
{
$this->postalCode = $postalCode;
return $this;
}
public function getCity(): ?string
{
return $this->city;
}
public function setCity(?string $city): static
{
$this->city = $city;
return $this;
}
public function getStreet(): ?string
{
return $this->street;
}
public function setStreet(?string $street): static
{
$this->street = $street;
return $this;
}
public function getStreetComplement(): ?string
{
return $this->streetComplement;
}
public function setStreetComplement(?string $streetComplement): static
{
$this->streetComplement = $streetComplement;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierContactProcessor;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Contact d'un transporteur (1:n) onglet Contact (M4). Jumeau de
* SupplierContact (M2) : au moins un champ rempli (RG-4.08, garanti par le
* CHECK chk_carrier_contact_filled + le Processor), max 2 telephones.
*
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
* transporteur). Ecriture : groupe `carrier:write:contacts`.
*
* Sous-ressource API (ERP-160, spec § 4.5) jumelle de SupplierContact (M2) :
* - POST /api/carriers/{carrierId}/contacts : creation rattachee au
* transporteur parent (Link toProperty 'carrier'), security
* transport.carriers.manage.
* - PATCH / DELETE /api/carrier_contacts/{id} : security
* transport.carriers.manage.
* - GET /api/carrier_contacts/{id} : lecture unitaire (security view) la
* lecture courante reste via le parent. Pas de GET collection autonome.
* Tout passe par le CarrierContactProcessor (rattachement parent + RG-4.08 +
* RG-4.13).
*
* Telephones (RG-4.08, max 2) : le contrat d'ecriture expose un tableau virtuel
* `phones` (liste dynamique cote front « x1, +1 possible, max 2 ») mappe par le
* Processor vers `phonePrimary` / `phoneSecondary` (un 3e numero -> 422). Les
* deux colonnes scalaires restent en lecture seule (embarquees au detail).
*
* Audite (#[Auditable]) + Timestampable / Blamable.
*/
#[ApiResource(
operations: [
new Get(
security: "is_granted('transport.carriers.view')",
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
),
new Post(
uriTemplate: '/carriers/{carrierId}/contacts',
uriVariables: [
'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'),
],
// read:false : pas de stade lecture du parent. Le Link toProperty
// resoudrait l'enfant (SELECT CarrierContact ... WHERE carrier = :id)
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
// manuellement par CarrierContactProcessor::linkParent (404 si absent).
read: false,
security: "is_granted('transport.carriers.manage')",
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
denormalizationContext: ['groups' => ['carrier:write:contacts']],
processor: CarrierContactProcessor::class,
),
new Patch(
security: "is_granted('transport.carriers.manage')",
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
denormalizationContext: ['groups' => ['carrier:write:contacts']],
processor: CarrierContactProcessor::class,
),
new Delete(
security: "is_granted('transport.carriers.manage')",
processor: CarrierContactProcessor::class,
),
],
)]
#[ORM\Entity]
#[ORM\Table(name: 'carrier_contact')]
#[ORM\Index(name: 'idx_carrier_contact_carrier', columns: ['carrier_id'])]
#[ORM\Index(name: 'idx_carrier_contact_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_carrier_contact_updated_by', columns: ['updated_by'])]
#[Auditable]
class CarrierContact implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['carrier:item:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Carrier::class, inversedBy: 'contacts')]
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Carrier $carrier = null;
// RG-4.08 : aucun champ obligatoire isolement (≥ 1 champ rempli, garde
// Processor + CHECK BDD). Les colonnes restent nullable au niveau ORM.
#[ORM\Column(name: 'first_name', length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
private ?string $firstName = null;
#[ORM\Column(name: 'last_name', length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
private ?string $lastName = null;
#[ORM\Column(name: 'job_title', length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
private ?string $jobTitle = null;
// Telephones en LECTURE seule : alimentes en ecriture via le tableau virtuel
// `phones` (mappe par le CarrierContactProcessor). Pas de groupe write -> pas
// de saisie directe (et donc exemptes du miroir Assert\Length, le Processor
// borne deja la longueur).
#[ORM\Column(name: 'phone_primary', length: 20, nullable: true)]
#[Groups(['carrier:item:read'])]
private ?string $phonePrimary = null;
#[ORM\Column(name: 'phone_secondary', length: 20, nullable: true)]
#[Groups(['carrier:item:read'])]
private ?string $phoneSecondary = null;
#[ORM\Column(length: 180, nullable: true)]
#[Assert\Email(message: 'L\'adresse email n\'est pas valide.')]
#[Assert\Length(max: 180, maxMessage: 'L\'email ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
private ?string $email = null;
/**
* Telephones en ecriture (RG-4.08, max 2), NON persiste : le
* CarrierContactProcessor normalise chaque numero (RG-4.13) puis le mappe vers
* phonePrimary / phoneSecondary. null = non fourni (PATCH partiel : on ne
* touche pas aux telephones existants). Un 3e numero -> 422 sur `phones`.
*
* @var null|list<string>
*/
#[Groups(['carrier:write:contacts'])]
private ?array $phones = null;
#[ORM\Column(options: ['default' => 0])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getCarrier(): ?Carrier
{
return $this->carrier;
}
public function setCarrier(?Carrier $carrier): static
{
$this->carrier = $carrier;
return $this;
}
public function getFirstName(): ?string
{
return $this->firstName;
}
public function setFirstName(?string $firstName): static
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): ?string
{
return $this->lastName;
}
public function setLastName(?string $lastName): static
{
$this->lastName = $lastName;
return $this;
}
public function getJobTitle(): ?string
{
return $this->jobTitle;
}
public function setJobTitle(?string $jobTitle): static
{
$this->jobTitle = $jobTitle;
return $this;
}
public function getPhonePrimary(): ?string
{
return $this->phonePrimary;
}
public function setPhonePrimary(?string $phonePrimary): static
{
$this->phonePrimary = $phonePrimary;
return $this;
}
public function getPhoneSecondary(): ?string
{
return $this->phoneSecondary;
}
public function setPhoneSecondary(?string $phoneSecondary): static
{
$this->phoneSecondary = $phoneSecondary;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): static
{
$this->email = $email;
return $this;
}
/**
* @return null|list<string>
*/
public function getPhones(): ?array
{
return $this->phones;
}
/**
* @param null|list<string> $phones
*/
public function setPhones(?array $phones): static
{
$this->phones = $phones;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,369 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierPriceProcessor;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\ClientAddressInterface;
use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SupplierAddressInterface;
use App\Shared\Domain\Contract\SupplierInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Prix d'un transporteur (1:n) onglet Prix (M4, RG-4.09→4.11). Une ligne porte
* soit une branche CLIENT (client + adresse de livraison + site de depart), soit
* une branche FOURNISSEUR (supplier + adresse d'appro + site de livraison),
* selon `direction`. La coherence des branches est garantie en BDD par les CHECK
* chk_carrier_price_client_branch / chk_carrier_price_supplier_branch.
*
* Relations cross-module (Client/Supplier/adresses M1-M2, Site Sites) referencees
* via des contrats Shared (ClientInterface, SupplierInterface, ...) + resolve_target_entities
* JAMAIS d'import direct d'une entite d'un autre module (regle ABSOLUE n°1).
* L'embed JSON au detail passe par les read-groups des entites concretes
* (client:read / client_address:read / supplier:read / supplier_address:read /
* site:read), inclus dans le contexte du Get racine de Carrier (§ 4.0).
*
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
* transporteur). Ecriture : groupe `carrier:write:prices`.
*
* Sous-ressource API (ERP-161, spec § 4.5) jumelle de CarrierAddress /
* CarrierContact :
* - POST /api/carriers/{carrierId}/prices : creation rattachee au transporteur
* parent (Link toProperty 'carrier'), security transport.carriers.manage.
* - PATCH / DELETE /api/carrier_prices/{id} : security transport.carriers.manage.
* - GET /api/carrier_prices/{id} : lecture unitaire (security view).
* Tout passe par le CarrierPriceProcessor (rattachement parent + RG-4.09→4.11 :
* coherence de branche CLIENT/FOURNISSEUR + appartenance de l'adresse).
*
* Les champs communs (direction, containerType, pricingUnit, price, priceState)
* sont obligatoires (Assert\NotBlank + Assert\Choice). L'obligation conditionnelle
* des champs de branche (client/supplier + adresses + sites) et l'appartenance de
* l'adresse au client/fournisseur sont portees par le Processor (violations Hydra
* a la main) : ces RG dependent de relations resolues a la denormalisation et non
* exprimables par une simple contrainte d'attribut.
*/
#[ApiResource(
operations: [
new Get(
security: "is_granted('transport.carriers.view')",
normalizationContext: ['groups' => [
'carrier:item:read',
'client:read', 'client_address:read',
'supplier:read', 'supplier_address:read',
'site:read', 'default:read',
]],
),
new Post(
uriTemplate: '/carriers/{carrierId}/prices',
uriVariables: [
'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'),
],
// read:false : pas de stade lecture du parent. Le Link toProperty
// resoudrait l'enfant (SELECT CarrierPrice ... WHERE carrier = :id) et
// casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
// manuellement par CarrierPriceProcessor::linkParent (404 si absent).
read: false,
security: "is_granted('transport.carriers.manage')",
normalizationContext: ['groups' => [
'carrier:item:read',
'client:read', 'client_address:read',
'supplier:read', 'supplier_address:read',
'site:read', 'default:read',
]],
denormalizationContext: ['groups' => ['carrier:write:prices']],
processor: CarrierPriceProcessor::class,
),
new Patch(
security: "is_granted('transport.carriers.manage')",
normalizationContext: ['groups' => [
'carrier:item:read',
'client:read', 'client_address:read',
'supplier:read', 'supplier_address:read',
'site:read', 'default:read',
]],
denormalizationContext: ['groups' => ['carrier:write:prices']],
processor: CarrierPriceProcessor::class,
),
new Delete(
security: "is_granted('transport.carriers.manage')",
processor: CarrierPriceProcessor::class,
),
],
)]
#[ORM\Entity]
#[ORM\Table(name: 'carrier_price')]
#[ORM\Index(name: 'idx_carrier_price_carrier', columns: ['carrier_id'])]
#[ORM\Index(name: 'idx_carrier_price_client', columns: ['client_id'])]
#[ORM\Index(name: 'idx_carrier_price_client_address', columns: ['client_delivery_address_id'])]
#[ORM\Index(name: 'idx_carrier_price_departure_site', columns: ['departure_site_id'])]
#[ORM\Index(name: 'idx_carrier_price_supplier', columns: ['supplier_id'])]
#[ORM\Index(name: 'idx_carrier_price_supplier_address', columns: ['supplier_supply_address_id'])]
#[ORM\Index(name: 'idx_carrier_price_delivery_site', columns: ['delivery_site_id'])]
#[ORM\Index(name: 'idx_carrier_price_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_carrier_price_updated_by', columns: ['updated_by'])]
#[Auditable]
class CarrierPrice implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['carrier:item:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Carrier::class, inversedBy: 'prices')]
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Carrier $carrier = null;
/** CLIENT|FOURNISSEUR (RG-4.09) — pilote la branche active. */
#[ORM\Column(length: 12)]
#[Assert\NotBlank(message: 'Le sens du prix est obligatoire.')]
#[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR'], message: 'Le sens du prix est invalide.')]
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
private ?string $direction = null;
// === Branche CLIENT (RG-4.10) ===
// Obligation conditionnelle (direction=CLIENT) + appartenance de l'adresse au
// client : portees par le CarrierPriceProcessor (relations resolues a la
// denormalisation, hors portee d'une contrainte d'attribut).
#[ORM\ManyToOne(targetEntity: ClientInterface::class)]
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
private ?ClientInterface $client = null;
#[ORM\ManyToOne(targetEntity: ClientAddressInterface::class)]
#[ORM\JoinColumn(name: 'client_delivery_address_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
private ?ClientAddressInterface $clientDeliveryAddress = null;
/** Adresse de depart = un des 3 sites (86/17/82). */
#[ORM\ManyToOne(targetEntity: SiteInterface::class)]
#[ORM\JoinColumn(name: 'departure_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
private ?SiteInterface $departureSite = null;
// === Branche FOURNISSEUR (RG-4.11) ===
#[ORM\ManyToOne(targetEntity: SupplierInterface::class)]
#[ORM\JoinColumn(name: 'supplier_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
private ?SupplierInterface $supplier = null;
#[ORM\ManyToOne(targetEntity: SupplierAddressInterface::class)]
#[ORM\JoinColumn(name: 'supplier_supply_address_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
private ?SupplierAddressInterface $supplierSupplyAddress = null;
/** Adresse de livraison = un des 3 sites (86/17/82). */
#[ORM\ManyToOne(targetEntity: SiteInterface::class)]
#[ORM\JoinColumn(name: 'delivery_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
private ?SiteInterface $deliverySite = null;
// === Commun (toujours obligatoires, RG-4.10/4.11) ===
/** BENNE|FOND_MOUVANT. */
#[ORM\Column(name: 'container_type', length: 12)]
#[Assert\NotBlank(message: 'Le type de contenant est obligatoire.')]
#[Assert\Choice(choices: ['BENNE', 'FOND_MOUVANT'], message: 'Le type de contenant est invalide.')]
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
private ?string $containerType = null;
/** FORFAIT|TONNE. */
#[ORM\Column(name: 'pricing_unit', length: 8)]
#[Assert\NotBlank(message: 'L\'unite de tarification est obligatoire.')]
#[Assert\Choice(choices: ['FORFAIT', 'TONNE'], message: 'L\'unite de tarification est invalide.')]
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
private ?string $pricingUnit = null;
#[ORM\Column(type: 'decimal', precision: 12, scale: 2)]
#[Assert\NotBlank(message: 'Le prix est obligatoire.')]
#[Assert\PositiveOrZero(message: 'Le prix ne peut pas etre negatif.')]
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
private ?string $price = null;
/** EN_COURS|VALIDE|NON_VALIDE. */
#[ORM\Column(name: 'price_state', length: 12)]
#[Assert\NotBlank(message: 'L\'etat du prix est obligatoire.')]
#[Assert\Choice(choices: ['EN_COURS', 'VALIDE', 'NON_VALIDE'], message: 'L\'etat du prix est invalide.')]
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
private ?string $priceState = null;
#[ORM\Column(options: ['default' => 0])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getCarrier(): ?Carrier
{
return $this->carrier;
}
public function setCarrier(?Carrier $carrier): static
{
$this->carrier = $carrier;
return $this;
}
public function getDirection(): ?string
{
return $this->direction;
}
public function setDirection(?string $direction): static
{
$this->direction = $direction;
return $this;
}
public function getClient(): ?ClientInterface
{
return $this->client;
}
public function setClient(?ClientInterface $client): static
{
$this->client = $client;
return $this;
}
public function getClientDeliveryAddress(): ?ClientAddressInterface
{
return $this->clientDeliveryAddress;
}
public function setClientDeliveryAddress(?ClientAddressInterface $clientDeliveryAddress): static
{
$this->clientDeliveryAddress = $clientDeliveryAddress;
return $this;
}
public function getDepartureSite(): ?SiteInterface
{
return $this->departureSite;
}
public function setDepartureSite(?SiteInterface $departureSite): static
{
$this->departureSite = $departureSite;
return $this;
}
public function getSupplier(): ?SupplierInterface
{
return $this->supplier;
}
public function setSupplier(?SupplierInterface $supplier): static
{
$this->supplier = $supplier;
return $this;
}
public function getSupplierSupplyAddress(): ?SupplierAddressInterface
{
return $this->supplierSupplyAddress;
}
public function setSupplierSupplyAddress(?SupplierAddressInterface $supplierSupplyAddress): static
{
$this->supplierSupplyAddress = $supplierSupplyAddress;
return $this;
}
public function getDeliverySite(): ?SiteInterface
{
return $this->deliverySite;
}
public function setDeliverySite(?SiteInterface $deliverySite): static
{
$this->deliverySite = $deliverySite;
return $this;
}
public function getContainerType(): ?string
{
return $this->containerType;
}
public function setContainerType(?string $containerType): static
{
$this->containerType = $containerType;
return $this;
}
public function getPricingUnit(): ?string
{
return $this->pricingUnit;
}
public function setPricingUnit(?string $pricingUnit): static
{
$this->pricingUnit = $pricingUnit;
return $this;
}
public function getPrice(): ?string
{
return $this->price;
}
public function setPrice(?string $price): static
{
$this->price = $price;
return $this;
}
public function getPriceState(): ?string
{
return $this->priceState;
}
public function setPriceState(?string $priceState): static
{
$this->priceState = $priceState;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\QualimatCarrierSearchProvider;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
/**
* Mapping ORM LECTURE SEULE sur la table existante `qualimat_carrier`
* (referentiel des transporteurs agrees QUALIMAT, ERP-39). La table est
* alimentee/soft-deletee EXCLUSIVEMENT par la commande console `app:qualimat:sync` ;
* cette entite n'expose donc AUCUNE ecriture (ni Post/Patch/Delete).
*
* Role M4 (ERP-155/157) :
* - cible de la FK editable `carrier.qualimat_carrier_id` (§ 2.5) ;
* - embarquee (groupe `qualimat:read`) dans la liste et le detail Carrier pour
* afficher statut + date de validite QUALIMAT (RG-4.04) ;
* - endpoint de recherche `GET /api/qualimat_carriers?search=` pour la saisie
* assistee du nom (§ 4.7) fuzzy name (+ siret), SEULEMENT les lignes actives,
* tri name ASC, paginee ; logique portee par QualimatCarrierSearchProvider.
*
* La table reste hors `schema_filter` Doctrine (doctrine.yaml) : c'est la
* migration modulaire Version20260612150000 qui possede son DDL et ses COMMENT
* (pas l'ORM). Lecture seule + referentiel synchronise => exclue de
* EntitiesAreTimestampableBlamableTest et non #[Auditable].
*/
#[ApiResource(
operations: [
// Saisie assistee (§ 4.7 / RG-4.01) : ?search= fuzzy name (+ siret),
// SEULEMENT les lignes actives, tri name ASC, paginee. La logique vit
// dans le provider (forcage is_active + recherche multi-champs) car un
// SearchFilter natif ne sait ni unifier name/siret sous un seul ?search=,
// ni imposer cote serveur le filtre actif.
new GetCollection(
security: "is_granted('transport.carriers.view')",
provider: QualimatCarrierSearchProvider::class,
normalizationContext: ['groups' => ['qualimat:read', 'default:read']],
),
new Get(
security: "is_granted('transport.carriers.view')",
normalizationContext: ['groups' => ['qualimat:read', 'default:read']],
),
],
)]
#[ORM\Entity]
// Mapping reproduisant a l'identique le DDL de la migration ERP-39
// (Version20260612150000) pour que `schema:update --force` reste un no-op :
// contrainte d'unicite siret + index is_active.
#[ORM\Table(name: 'qualimat_carrier')]
#[ORM\UniqueConstraint(name: 'uq_qualimat_carrier_siret', columns: ['siret'])]
#[ORM\Index(name: 'idx_qualimat_carrier_active', columns: ['is_active'])]
class QualimatCarrier
{
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
#[ORM\Column(type: 'bigint')]
#[Groups(['qualimat:read'])]
private ?string $id = null;
#[ORM\Column(length: 20)]
#[Groups(['qualimat:read'])]
private ?string $siret = null;
#[ORM\Column(length: 255)]
#[Groups(['qualimat:read'])]
private ?string $name = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['qualimat:read'])]
private ?string $address = null;
#[ORM\Column(name: 'postal_code', length: 10, nullable: true)]
#[Groups(['qualimat:read'])]
private ?string $postalCode = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['qualimat:read'])]
private ?string $city = null;
#[ORM\Column(length: 32, nullable: true)]
#[Groups(['qualimat:read'])]
private ?string $phone = null;
#[ORM\Column(length: 64, nullable: true)]
#[Groups(['qualimat:read'])]
private ?string $department = null;
#[ORM\Column(length: 32)]
#[Groups(['qualimat:read'])]
private ?string $status = null;
#[ORM\Column(name: 'validity_date', type: 'date_immutable', nullable: true)]
#[Groups(['qualimat:read'])]
private ?DateTimeImmutable $validityDate = null;
#[ORM\Column(name: 'is_active', options: ['default' => true])]
#[Groups(['qualimat:read'])]
#[SerializedName('isActive')]
private bool $isActive = true;
// Colonne technique de synchro (soft-delete) — mappee pour completude, non
// serialisee. Alimentee par app:qualimat:sync. columnDefinition pin la
// precision TIMESTAMP(6) du DDL ERP-39 pour eviter un ALTER de schema:update
// (le datetime_immutable par defaut mapperait sur TIMESTAMP(0)).
#[ORM\Column(name: 'last_synced_at', type: 'datetime_immutable', columnDefinition: 'TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL')]
private ?DateTimeImmutable $lastSyncedAt = null;
public function getId(): ?string
{
return $this->id;
}
public function getSiret(): ?string
{
return $this->siret;
}
public function getName(): ?string
{
return $this->name;
}
public function getAddress(): ?string
{
return $this->address;
}
public function getPostalCode(): ?string
{
return $this->postalCode;
}
public function getCity(): ?string
{
return $this->city;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function getDepartment(): ?string
{
return $this->department;
}
public function getStatus(): ?string
{
return $this->status;
}
public function getValidityDate(): ?DateTimeImmutable
{
return $this->validityDate;
}
public function isActive(): bool
{
return $this->isActive;
}
public function getLastSyncedAt(): ?DateTimeImmutable
{
return $this->lastSyncedAt;
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Domain\Repository;
use App\Module\Transport\Domain\Entity\Carrier;
use Doctrine\ORM\QueryBuilder;
/**
* Contrat du repository transporteurs (M4). Implementation Doctrine :
* App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository.
*/
interface CarrierRepositoryInterface
{
public function findById(int $id): ?Carrier;
public function save(Carrier $carrier): void;
/**
* QueryBuilder de SELECTION (filtres + tri) pour la liste. Exclut les
* soft-deletes (deleted_at IS NOT NULL) et, par defaut, les archives.
* Fetch-join uniquement qualimatCarrier (ManyToOne, sur § 2.11) : la liste
* n'embarque aucune sous-collection. Tri par defaut name ASC.
*
* Perimetre d'archivage (aligne sur ClientProvider/SupplierProvider/
* ProviderProvider toggle « Voir les archives » d'ERP-173) :
* - $archivedOnly = true -> uniquement les archives (is_archived = true) ;
* - sinon $includeArchived = true -> actifs + archives (echappatoire) ;
* - par defaut -> actifs seuls (is_archived = false).
* $archivedOnly a la priorite sur $includeArchived.
*
* @param list<string> $certificationTypes filtre repetable (OR) sur certificationType
*/
public function createListQueryBuilder(
bool $includeArchived = false,
?string $search = null,
array $certificationTypes = [],
bool $archivedOnly = false,
): QueryBuilder;
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Domain\Repository;
use Doctrine\ORM\QueryBuilder;
/**
* Contrat du repository du referentiel QUALIMAT (M4, lecture seule). Implementation
* Doctrine : App\Module\Transport\Infrastructure\Doctrine\DoctrineQualimatCarrierRepository.
*
* La table `qualimat_carrier` est alimentee/soft-deletee EXCLUSIVEMENT par la
* commande console `app:qualimat:sync` : ce contrat n'expose que de la lecture.
*/
interface QualimatCarrierRepositoryInterface
{
/**
* QueryBuilder de la saisie assistee (§ 4.7 / RG-4.01). Restreint aux lignes
* actives (is_active = true), recherche fuzzy sur name (+ siret), tri name ASC.
*
* @param null|string $search texte de recherche libre (fuzzy name + siret)
*/
public function createSearchQueryBuilder(?string $search = null): QueryBuilder;
}
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Entity\CarrierAddress;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture de la sous-ressource Adresse d'un transporteur (M4,
* spec-back § 4.5). Jumeau du SupplierAddressProcessor (M2) / ProviderAddressProcessor
* (M3), recentre sur le perimetre ERP-159, AVEC une garde propre au M4 : RG-4.05
* (adresse obligatoire si le transporteur est affrete).
*
* Sequence :
* - POST / PATCH : rattachement au transporteur parent (linkParent) puis garde
* RG-4.05 (guardCharteredAddress). RG-4.06 (code postal, Assert\Regex) est portee
* par l'entite et jouee par API Platform AVANT ce processor.
* - DELETE : aucune regle metier specifique (suppression physique directe).
*
* RG-4.05 vit ICI (et non en Assert\Callback sur l'entite) car elle depend du
* transporteur PARENT, indisponible a la validation Symfony sur un POST
* sous-ressource en read:false (le parent n'est rattache qu'au stade processor).
* La violation est construite a la main avec le meme rendu Hydra que les
* contraintes Symfony, donc consommable inline par champ (convention ERP-101).
*
* RG-4.07 (masquage du bouton « Valider » si QUALIMAT) est purement front : le
* back accepte le PATCH normalement, aucune garde ici.
*
* La security d'operation (transport.carriers.manage) est appliquee par API
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut.
*
* @implements ProcessorInterface<CarrierAddress, null|CarrierAddress>
*/
final class CarrierAddressProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof CarrierAddress) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
$this->guardCharteredAddress($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache l'adresse au transporteur parent de la sous-ressource POST
* (/carriers/{carrierId}/addresses) : la relation n'est pas peuplee
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
*/
private function linkParent(CarrierAddress $address, array $uriVariables): void
{
if (null !== $address->getCarrier()) {
return;
}
$carrierId = $uriVariables['carrierId'] ?? null;
if (null === $carrierId) {
return;
}
$carrier = $carrierId instanceof Carrier
? $carrierId
: $this->em->getRepository(Carrier::class)->find($carrierId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
// contrainte carrier_id NOT NULL).
if (!$carrier instanceof Carrier) {
throw new NotFoundHttpException('Transporteur introuvable.');
}
$address->setCarrier($carrier);
}
/**
* RG-4.05 : si le transporteur parent est affrete (isChartered), l'adresse doit
* porter Pays / Code postal / Ville / Adresse. Chaque champ manquant -> une
* violation 422 sur son propre propertyPath (mapping inline ERP-101). La
* validation porte sur l'ETAT RESULTANT de l'adresse (apres application du
* payload), donc identique sur POST et sur PATCH partiel. Sans affretement,
* l'adresse reste partielle (champs nullable, RG-4.06 inchangee).
*/
private function guardCharteredAddress(CarrierAddress $address): void
{
$carrier = $address->getCarrier();
if (!$carrier instanceof Carrier || !$carrier->isChartered()) {
return;
}
$required = [
'country' => [$address->getCountry(), 'Le pays est obligatoire pour un transporteur affrété.'],
'postalCode' => [$address->getPostalCode(), 'Le code postal est obligatoire pour un transporteur affrété.'],
'city' => [$address->getCity(), 'La ville est obligatoire pour un transporteur affrété.'],
'street' => [$address->getStreet(), 'L\'adresse est obligatoire pour un transporteur affrété.'],
];
$violations = new ConstraintViolationList();
foreach ($required as $path => [$value, $message]) {
if (null === $value || '' === trim($value)) {
$violations->add(new ConstraintViolation($message, null, [], $address, $path, $value));
}
}
if (0 < $violations->count()) {
throw new ValidationException($violations);
}
}
}
@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Entity\CarrierContact;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use function count;
use function is_string;
/**
* Processor d'ecriture de la sous-ressource Contact d'un transporteur (M4,
* spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
* perimetre ERP-160, AVEC deux specificites M4 : RG-4.08 ( 1 champ rempli, max
* 2 telephones) portee a la fois par le CHECK BDD chk_carrier_contact_filled et
* par ce Processor.
*
* Sequence :
* - POST / PATCH : rattachement au transporteur parent (linkParent),
* normalisation serveur RG-4.13 (prenom/nom Title Case, email lowercase),
* mapping du tableau d'ecriture `phones` -> phonePrimary/phoneSecondary
* (max 2, chiffres uniquement), puis garde RG-4.08 ( 1 champ) avant
* persistance.
* - DELETE : aucune regle metier specifique (suppression physique directe).
*
* RG-4.08 vit ICI (double du CHECK BDD) pour transformer une violation SQL (500
* generique) en 422 propre rattachee au champ `firstName` (mapping inline
* ERP-101). Le « max 2 telephones » est rattache au champ `phones` : seul
* point de saisie des numeros (les colonnes phonePrimary/phoneSecondary sont en
* lecture seule).
*
* La security d'operation (transport.carriers.manage) est appliquee par API
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut
* (Assert\Email, Assert\Length...).
*
* @implements ProcessorInterface<CarrierContact, null|CarrierContact>
*/
final class CarrierContactProcessor implements ProcessorInterface
{
/** RG-4.08 : nombre maximal de telephones par contact. */
private const int MAX_PHONES = 2;
/** Longueur max d'un telephone normalise (colonne VARCHAR(20)). */
private const int PHONE_MAX_LENGTH = 20;
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly CarrierFieldNormalizer $normalizer,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof CarrierContact) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
$this->normalize($data);
$this->applyPhones($data);
$this->validateAtLeastOneField($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache le contact au transporteur parent de la sous-ressource POST
* (/carriers/{carrierId}/contacts) : la relation n'est pas peuplee
* automatiquement par le Link sur une ecriture. Sur PATCH (entite existante),
* le transporteur est deja present -> no-op.
*/
private function linkParent(CarrierContact $contact, array $uriVariables): void
{
if (null !== $contact->getCarrier()) {
return;
}
$carrierId = $uriVariables['carrierId'] ?? null;
if (null === $carrierId) {
return;
}
$carrier = $carrierId instanceof Carrier
? $carrierId
: $this->em->getRepository(Carrier::class)->find($carrierId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
// contrainte carrier_id NOT NULL).
if (!$carrier instanceof Carrier) {
throw new NotFoundHttpException('Transporteur introuvable.');
}
$contact->setCarrier($carrier);
}
/**
* Normalisation serveur RG-4.13 des champs texte. Toutes les methodes du
* normalizer sont null-safe : une chaine vide apres trim devient null (donc la
* garde RG-4.08 detecte bien « champ non rempli »). Les telephones sont
* traites a part (applyPhones).
*/
private function normalize(CarrierContact $contact): void
{
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
$contact->setJobTitle($this->blankToNull($contact->getJobTitle()));
$contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
}
/**
* Mappe le tableau d'ecriture `phones` (max 2, RG-4.08) vers phonePrimary /
* phoneSecondary apres normalisation RG-4.13 (chiffres uniquement). Les
* numeros vides (sans chiffre) sont ecartes. null = champ non fourni (PATCH
* partiel) -> on ne touche pas aux telephones existants. Un 3e numero
* exploitable, ou un numero trop long (> colonne VARCHAR(20)), -> 422 sur
* `phones`.
*/
private function applyPhones(CarrierContact $contact): void
{
$phones = $contact->getPhones();
if (null === $phones) {
return;
}
$normalized = [];
foreach ($phones as $phone) {
$digits = $this->normalizer->normalizePhone(is_string($phone) ? $phone : null);
if (null !== $digits) {
$normalized[] = $digits;
}
}
$violations = new ConstraintViolationList();
if (self::MAX_PHONES < count($normalized)) {
$violations->add(new ConstraintViolation(
'Un contact ne peut comporter plus de deux téléphones.',
null,
[],
$contact,
'phones',
$phones,
));
}
foreach ($normalized as $digits) {
if (self::PHONE_MAX_LENGTH < mb_strlen($digits)) {
$violations->add(new ConstraintViolation(
'Un numéro de téléphone ne peut dépasser '.self::PHONE_MAX_LENGTH.' caractères.',
null,
[],
$contact,
'phones',
$phones,
));
break;
}
}
if (0 < $violations->count()) {
throw new ValidationException($violations);
}
$contact->setPhonePrimary($normalized[0] ?? null);
$contact->setPhoneSecondary($normalized[1] ?? null);
// Nettoie le champ virtuel (non persiste, mais evite toute fuite ulterieure).
$contact->setPhones(null);
}
/**
* RG-4.08 : un bloc Contact est valide des qu'au moins 1 champ est rempli
* (firstName, lastName, jobTitle, phonePrimary ou email meme perimetre que
* le CHECK BDD chk_carrier_contact_filled, qui exclut phoneSecondary). Double
* garde : leve une 422 propre rattachee a `firstName` plutot qu'une 500 SQL.
* Joue apres normalisation + mapping telephones, donc les chaines vides sont
* deja ramenees a null.
*/
private function validateAtLeastOneField(CarrierContact $contact): void
{
if (
null === $contact->getFirstName()
&& null === $contact->getLastName()
&& null === $contact->getJobTitle()
&& null === $contact->getPhonePrimary()
&& null === $contact->getEmail()
) {
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Renseignez au moins un champ pour le contact.',
null,
[],
$contact,
'firstName',
null,
));
throw new ValidationException($violations);
}
}
/**
* Trim + chaine vide -> null (la fonction n'est pas normalisee en casse,
* contrairement aux noms de personne). Garantit que RG-4.08 detecte un champ
* « non rempli » meme si le client envoie une chaine vide.
*/
private function blankToNull(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : $value;
}
}
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Entity\CarrierPrice;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture de la sous-ressource Prix d'un transporteur (M4,
* spec-back § 4.5, ERP-161). Jumeau des CarrierAddressProcessor / CarrierContactProcessor.
*
* Sequence :
* - POST / PATCH : rattachement au transporteur parent (linkParent) puis
* validation de la coherence de branche CLIENT/FOURNISSEUR (RG-4.09→4.11).
* - DELETE : suppression physique directe (aucune regle metier specifique).
*
* RG-4.10 (branche CLIENT) : `client`, `clientDeliveryAddress`, `departureSite`
* obligatoires ; l'adresse de livraison doit appartenir au client choisi.
* RG-4.11 (branche FOURNISSEUR) : `supplier`, `supplierSupplyAddress`,
* `deliverySite` obligatoires ; l'adresse d'appro doit appartenir au fournisseur.
* Ces RG vivent ICI (et non en contrainte d'attribut) car elles dependent de
* relations resolues a la denormalisation (et le parent carrier est indisponible
* en validation Symfony sur un POST sous-ressource read:false). On nettoie aussi
* la branche opposee (les CHECK BDD imposent ses colonnes nulles) transforme une
* violation SQL (500) en 422 propre rattachee au champ (mapping inline ERP-101).
*
* Les champs communs obligatoires (direction, containerType, pricingUnit, price,
* priceState) sont valides en amont par les contraintes d'attribut (Assert\NotBlank
* + Assert\Choice), de meme que la security d'operation (transport.carriers.manage).
*
* @implements ProcessorInterface<CarrierPrice, null|CarrierPrice>
*/
final class CarrierPriceProcessor implements ProcessorInterface
{
private const string DIRECTION_CLIENT = 'CLIENT';
private const string DIRECTION_SUPPLIER = 'FOURNISSEUR';
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof CarrierPrice) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
$this->validateBranch($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache le prix au transporteur parent de la sous-ressource POST
* (/carriers/{carrierId}/prices) : la relation n'est pas peuplee
* automatiquement par le Link sur une ecriture. Sur PATCH (entite existante),
* le transporteur est deja present -> no-op.
*/
private function linkParent(CarrierPrice $price, array $uriVariables): void
{
if (null !== $price->getCarrier()) {
return;
}
$carrierId = $uriVariables['carrierId'] ?? null;
if (null === $carrierId) {
return;
}
$carrier = $carrierId instanceof Carrier
? $carrierId
: $this->em->getRepository(Carrier::class)->find($carrierId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
// contrainte carrier_id NOT NULL).
if (!$carrier instanceof Carrier) {
throw new NotFoundHttpException('Transporteur introuvable.');
}
$price->setCarrier($carrier);
}
/**
* RG-4.09→4.11 : valide la coherence de la branche active (CLIENT vs
* FOURNISSEUR) et nettoie la branche opposee (les CHECK BDD imposent ses
* colonnes nulles). Toutes les violations sont collectees puis renvoyees d'un
* coup (un seul aller-retour, mapping inline par champ ERP-101). La direction
* elle-meme est deja garantie CLIENT|FOURNISSEUR par Assert\NotBlank + Choice.
*/
private function validateBranch(CarrierPrice $price): void
{
$violations = new ConstraintViolationList();
if (self::DIRECTION_CLIENT === $price->getDirection()) {
$this->requireField($violations, $price, 'client', $price->getClient(), 'Le client est obligatoire pour un prix client.');
$this->requireField($violations, $price, 'clientDeliveryAddress', $price->getClientDeliveryAddress(), 'L\'adresse de livraison du client est obligatoire pour un prix client.');
$this->requireField($violations, $price, 'departureSite', $price->getDepartureSite(), 'Le site de depart est obligatoire pour un prix client.');
// RG-4.10 : l'adresse de livraison doit appartenir au client choisi.
$client = $price->getClient();
$address = $price->getClientDeliveryAddress();
if (null !== $client && null !== $address && $address->getClient()?->getId() !== $client->getId()) {
$violations->add($this->violation($price, 'clientDeliveryAddress', 'L\'adresse de livraison doit appartenir au client selectionne.'));
}
// Coherence CHECK chk_carrier_price_client_branch : branche fournisseur nulle.
$price->setSupplier(null);
$price->setSupplierSupplyAddress(null);
$price->setDeliverySite(null);
} elseif (self::DIRECTION_SUPPLIER === $price->getDirection()) {
$this->requireField($violations, $price, 'supplier', $price->getSupplier(), 'Le fournisseur est obligatoire pour un prix fournisseur.');
$this->requireField($violations, $price, 'supplierSupplyAddress', $price->getSupplierSupplyAddress(), 'L\'adresse d\'approvisionnement est obligatoire pour un prix fournisseur.');
$this->requireField($violations, $price, 'deliverySite', $price->getDeliverySite(), 'Le site de livraison est obligatoire pour un prix fournisseur.');
// RG-4.11 : l'adresse d'appro doit appartenir au fournisseur choisi.
$supplier = $price->getSupplier();
$address = $price->getSupplierSupplyAddress();
if (null !== $supplier && null !== $address && $address->getSupplier()?->getId() !== $supplier->getId()) {
$violations->add($this->violation($price, 'supplierSupplyAddress', 'L\'adresse d\'approvisionnement doit appartenir au fournisseur selectionne.'));
}
// Coherence CHECK chk_carrier_price_supplier_branch : branche client nulle.
$price->setClient(null);
$price->setClientDeliveryAddress(null);
$price->setDepartureSite(null);
}
if (0 < $violations->count()) {
throw new ValidationException($violations);
}
}
/**
* Ajoute une violation « champ obligatoire » sur `$path` si la relation est
* absente (branche active, RG-4.10/4.11).
*/
private function requireField(ConstraintViolationList $violations, CarrierPrice $price, string $path, ?object $value, string $message): void
{
if (null === $value) {
$violations->add($this->violation($price, $path, $message));
}
}
private function violation(CarrierPrice $price, string $path, string $message): ConstraintViolation
{
return new ConstraintViolation($message, null, [], $price, $path, null);
}
}
@@ -0,0 +1,275 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
use App\Module\Transport\Domain\Entity\Carrier;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use JsonException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* Processor d'ecriture du repertoire transporteurs (M4, formulaire principal). Cf.
* spec-back M4 § 4.3 / § 4.4 + RG-4.12 / RG-4.13 / RG-4.14. Jumeau du
* SupplierProcessor (M2), recentre sur le perimetre WT4 (formulaire principal :
* normalisation, gating archive, 409 doublon de nom).
*
* Sequence (POST / PATCH) :
* 1. Gating archive (mode strict RG-4.14). La security d'operation laisse entrer
* `transport.carriers.manage` (Admin + Bureau) ; ce processor re-gate
* finement : un payload basculant `isArchived` exige `transport.carriers.archive`
* (Admin seul) -> 403, et une requete d'archivage ne peut modifier aucun autre
* champ -> 422. C'est ce qui empeche Bureau d'archiver (manage sans archive).
* 2. Normalisation serveur (RG-4.13) via CarrierFieldNormalizer (name UPPER,
* liotPlates « ; »-normalise). Les champs personne / telephone / email sont
* portes par les sous-ressources Contact (WT7), pas par le formulaire principal.
* 3. Pose / retrait de archivedAt (RG-4.14 true=now, false=null).
* 4. Persistance via le persist_processor Doctrine, avec traduction des
* collisions d'unicite partielle (uq_carrier_name_active) en 409 (RG-4.12
* doublon de nom ; conflit de restauration).
*
* Les RG conditionnelles du formulaire principal (RG-4.01 certification obligatoire
* sauf LIOT, RG-4.02 AUTRE -> decharge, RG-4.03 affrete -> indexation/benne/volume)
* sont portees par un Assert\Callback + ->atPath() sur l'entite Carrier (joue par
* API Platform AVANT ce processor), pour que chaque 422 porte un propertyPath
* consommable par useFormErrors (mapping inline, pas un toast convention ERP-101).
*
* @implements ProcessorInterface<Carrier, Carrier>
*/
final class CarrierProcessor implements ProcessorInterface
{
/** Champs ecrivables du formulaire principal (groupe carrier:write:main). */
private const array MAIN_FIELDS = [
'name', 'qualimatCarrier', 'certificationType', 'isChartered',
'indexationRate', 'containerType', 'volumeM3', 'dischargeDocument', 'liotPlates',
];
/** Champ d'archivage (groupe carrier:write:archive). */
private const string ARCHIVE_FIELD = 'isArchived';
private const string PERM_ARCHIVE = 'transport.carriers.archive';
/**
* Memoisation du dernier corps de requete decode, clos par le contenu brut.
* payloadKeys() est appele plusieurs fois par requete : on evite de rejouer
* json_decode. La cle etant le contenu lui-meme et le calcul une fonction pure
* de ce contenu, aucune fuite n'est possible entre requetes sur ce service
* partage (un meme corps redonne les memes cles).
*/
private ?string $decodedContent = null;
/** @var list<string> Cles de premier niveau correspondant au corps memoise. */
private array $decodedPayloadKeys = [];
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly CarrierFieldNormalizer $normalizer,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Carrier) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// Reinitialisation de la memoisation du payload en debut de traitement :
// le service est partage (stateful), on repart du corps de LA requete
// courante et on n'herite jamais des cles decodees d'une requete passee.
$this->decodedContent = null;
$this->decodedPayloadKeys = [];
$isArchiveRequest = $this->guardArchive($data, $this->writablePayloadKeys());
$this->normalize($data);
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
// Le seul index unique partiel est uq_carrier_name_active
// (LOWER(name) parmi non-archives/non-deletes — § 2.6).
if ($isArchiveRequest && false === $data->isArchived()) {
// RG-4.14 : restauration en conflit avec un homonyme actif.
throw new ConflictHttpException(
'Restauration impossible : un autre transporteur a pris le nom entre-temps.',
$e,
);
}
// RG-4.12 : doublon de nom de transporteur.
throw new ConflictHttpException(
sprintf('Un transporteur nommé "%s" existe déjà.', (string) $data->getName()),
$e,
);
}
}
/**
* RG-4.14 : si le payload bascule reellement isArchived, exige la permission
* archive (403), interdit toute autre modification (422) et pose/retire
* archivedAt. Retourne true si la requete est une requete d'archivage.
*
* Le gating est restreint a la mise a jour d'un transporteur existant ET au
* seul cas ou isArchived change vraiment : un POST (entite non encore geree
* par l'ORM) ou un PATCH « representation complete » renvoyant isArchived
* inchange ne doit declencher ni 403 ni 422 parasite.
*
* @param list<string> $writableKeys cles ecrivables du payload (hors @* et champs inconnus)
*/
private function guardArchive(Carrier $data, array $writableKeys): bool
{
// POST / entite non geree : l'archivage est une action de mise a jour.
if (!$this->em->contains($data)) {
return false;
}
// isArchived inchange par rapport a l'etat persiste : pas une requete
// d'archivage (cas du PATCH representation complete).
if (!$this->fieldChanged($data, 'isArchived', $data->isArchived())) {
return false;
}
if (!$this->security->isGranted(self::PERM_ARCHIVE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
self::ARCHIVE_FIELD,
self::PERM_ARCHIVE,
));
}
// RG-4.14 : une requete d'archivage ne modifie aucun autre champ ecrivable.
if ([] !== array_diff($writableKeys, [self::ARCHIVE_FIELD])) {
throw new UnprocessableEntityHttpException(
'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".',
);
}
// RG-4.14 (true -> now) / (false -> null).
$data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null);
return true;
}
/**
* Normalisation serveur du formulaire principal (RG-4.13). name (non-nullable)
* et liotPlates (cas LIOT) sont les seuls champs texte du formulaire principal ;
* le contact (personne / telephone / email) est normalise par son propre
* processor (sous-ressource, WT7). Les setters ne sont touches que si une valeur
* est presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel.
*/
private function normalize(Carrier $data): void
{
if (null !== $data->getName()) {
$data->setName((string) $this->normalizer->normalizeName($data->getName()));
}
if (null !== $data->getLiotPlates()) {
$data->setLiotPlates($this->normalizer->normalizeLiotPlates($data->getLiotPlates()));
}
}
/**
* Cles ecrivables effectivement presentes dans le payload : on retire les cles
* JSON-LD (@id, @context...) et tout champ non rattache a un groupe d'ecriture
* connu. C'est la base du 422 d'archivage (RG-4.14) sans elles, un PATCH
* « representation complete » porteur de @id ferait croire a une modification
* multi-champs.
*
* @return list<string>
*/
private function writablePayloadKeys(): array
{
$writable = array_merge(self::MAIN_FIELDS, [self::ARCHIVE_FIELD]);
return array_values(array_intersect($this->payloadKeys(), $writable));
}
/**
* Vrai si la valeur courante d'un champ differe de l'etat persiste. Pour une
* entite non geree (creation/POST), l'etat persiste est vide : toute valeur
* non-null est alors un changement.
*/
private function fieldChanged(Carrier $data, string $field, mixed $newValue): bool
{
$original = $this->originalData($data);
return $newValue !== ($original[$field] ?? null);
}
/**
* Snapshot des valeurs persistees de l'entite (telles que chargees, avant
* application du payload). Vide pour une entite non geree (POST).
*
* @return array<string, mixed>
*/
private function originalData(Carrier $data): array
{
if (!$this->em->contains($data)) {
return [];
}
return $this->em->getUnitOfWork()->getOriginalEntityData($data);
}
/**
* Cles de premier niveau effectivement envoyees par le client (payload JSON
* brut), filtrage compris. Pour un PATCH merge-patch+json, ce sont les seuls
* champs modifies.
*
* @return list<string>
*/
private function payloadKeys(): array
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return [];
}
$content = $request->getContent();
// Cache hit : meme corps brut que le dernier decodage -> memes cles.
if ($content === $this->decodedContent) {
return $this->decodedPayloadKeys;
}
$this->decodedContent = $content;
$this->decodedPayloadKeys = $this->extractPayloadKeys($content);
return $this->decodedPayloadKeys;
}
/**
* Decode le corps brut et en extrait les cles de premier niveau (chaines).
* Corps vide ou JSON invalide -> aucune cle.
*
* @return list<string>
*/
private function extractPayloadKeys(string $content): array
{
if ('' === $content) {
return [];
}
try {
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return [];
}
return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
}
}
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Repository\CarrierRepositoryInterface;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider du repertoire transporteurs (M4, spec-back § 4.1 / § 4.2). Jumeau du
* SupplierProvider (M2), simplifie : pas de cloisonnement par site (§ 2.3) et
* aucune sous-collection a hydrater en liste (le contrat liste n'embarque que
* qualimatCarrier, deja fetch-joine par le repository § 2.11).
*
* Collection (GET /api/carriers) :
* - exclut par defaut les archives (is_archived = true) ET les soft-deletes ;
* - ?includeArchived=true reintegre les archives (soft-deletes toujours exclus) ;
* - ?archivedOnly=true n'expose QUE les archives (prioritaire sur includeArchived,
* aligne sur Client/Supplier/Provider toggle « Voir les archives » ERP-173) ;
* - filtres ?search= (fuzzy name) et ?certificationType= (repetable) ;
* - tri par defaut name ASC ; pagination Hydra (regle n°13) + echappatoire
* ?pagination=false.
*
* Item (GET /api/carriers/{id}) : 404 si introuvable OU soft-delete. Les archives
* restent consultables en detail.
*
* @implements ProviderInterface<Carrier>
*/
final class CarrierProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository')]
private readonly CarrierRepositoryInterface $repository,
private readonly Pagination $pagination,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Carrier|iterable|Paginator|null
{
if ($operation instanceof CollectionOperationInterface) {
return $this->provideCollection($operation, $context);
}
return $this->provideItem($uriVariables);
}
/**
* @param array<string, mixed> $context
*
* @return list<Carrier>|Paginator<Carrier>
*/
private function provideCollection(Operation $operation, array $context): array|Paginator
{
$filters = $context['filters'] ?? [];
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
$archivedOnly = $this->readBool($filters['archivedOnly'] ?? false);
$search = $filters['search'] ?? null;
$certificationTypes = $this->readStringList($filters['certificationType'] ?? []);
$qb = $this->repository->createListQueryBuilder(
$includeArchived,
is_string($search) ? $search : null,
$certificationTypes,
$archivedOnly,
);
// Echappatoire ?pagination=false : collection complete (selects front).
if (!$this->pagination->isEnabled($operation, $context)) {
// @var list<Carrier> $carriers
return $qb->getQuery()->getResult();
}
$limit = $this->pagination->getLimit($operation, $context);
$page = max(1, $this->pagination->getPage($context));
$offset = ($page - 1) * $limit;
$qb->setFirstResult($offset)->setMaxResults($limit);
// fetchJoinCollection: false — la seule jointure est un ManyToOne (sur),
// pas une to-many : pas de besoin du mode collection du Paginator.
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
}
/**
* @param array<string, mixed> $uriVariables
*/
private function provideItem(array $uriVariables): ?Carrier
{
$id = $uriVariables['id'] ?? null;
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
$carrier = $this->repository->findById((int) $id);
if (null === $carrier) {
return null;
}
// Soft-delete : jamais expose (404). Les archives restent consultables.
if (null !== $carrier->getDeletedAt()) {
return null;
}
return $carrier;
}
private function readBool(mixed $raw): bool
{
if (is_bool($raw)) {
return $raw;
}
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
}
/**
* Normalise un filtre en liste de chaines (valeur unique ou ?key[]=a&key[]=b).
*
* @return list<string>
*/
private function readStringList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if (is_string($value) && '' !== trim($value)) {
$out[] = trim($value);
}
}
return $out;
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Transport\Domain\Entity\QualimatCarrier;
use App\Module\Transport\Domain\Repository\QualimatCarrierRepositoryInterface;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider de la saisie assistee QUALIMAT (spec-back § 4.7 / RG-4.01).
*
* GET /api/qualimat_carriers?search=<texte> :
* - restreint aux lignes actives (is_active = true) regle serveur, pas un
* filtre client desactivable ;
* - recherche fuzzy insensible a la casse sur name (+ siret) ;
* - tri par name ASC ;
* - pagination Hydra (regle n°13) + echappatoire ?pagination=false (selects).
*
* Branche uniquement sur la GetCollection ; le Get unitaire reste servi par le
* provider ORM par defaut (lecture seule, aucune ecriture exposee).
*
* @implements ProviderInterface<QualimatCarrier>
*/
final class QualimatCarrierSearchProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineQualimatCarrierRepository')]
private readonly QualimatCarrierRepositoryInterface $repository,
private readonly Pagination $pagination,
) {}
/**
* @return list<QualimatCarrier>|Paginator<QualimatCarrier>
*/
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|Paginator
{
$filters = $context['filters'] ?? [];
$search = $filters['search'] ?? null;
$qb = $this->repository->createSearchQueryBuilder(is_string($search) ? $search : null);
// Echappatoire ?pagination=false : collection complete (selects front).
if (!$this->pagination->isEnabled($operation, $context)) {
// @var list<QualimatCarrier> $carriers
return $qb->getQuery()->getResult();
}
$limit = $this->pagination->getLimit($operation, $context);
$page = max(1, $this->pagination->getPage($context));
$offset = ($page - 1) * $limit;
$qb->setFirstResult($offset)->setMaxResults($limit);
// fetchJoinCollection: false — aucune jointure to-many (referentiel plat).
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
}
}
@@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\Console;
use App\Module\Transport\Application\Idtf\IdtfSheetParser;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use PhpOffice\PhpSpreadsheet\IOFactory;
use RuntimeException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
use function array_slice;
use function count;
use function in_array;
use const JSON_THROW_ON_ERROR;
use const JSON_UNESCAPED_UNICODE;
/**
* ERP-149 : synchronise le referentiel des codes IDTF (regimes de nettoyage
* transport).
*
* Recupere l'export Excel depuis le generateur icrt-idtf.com (ou un fichier
* local), le parse et synchronise `idtf_product` de facon transactionnelle :
* upsert sur (schema, idtf_number), soft-delete des absents, journal dans
* `idtf_sync_log`. Idempotente (refresh complet).
*/
#[AsCommand(
name: 'app:idtf:sync',
description: 'Synchronise le referentiel des codes IDTF depuis l\'export Excel icrt-idtf.com (upsert + soft-delete + journal).',
)]
final class SyncIdtfCommand extends Command
{
private const string GENERATOR_URL = 'https://www.icrt-idtf.com/fr/excel-generator/';
/**
* Champs a cocher explicitement : `fields[]=all` ne deplie PAS les colonnes
* cote serveur (6 colonnes seulement). Cette liste donne l'export complet
* (11 colonnes). Cf. ERP-149 § 1.
*
* @var list<string>
*/
private const array EXPORT_FIELDS = [
'product_number_idtf',
'product_name',
'minimum_cleaning_regime',
'important_requirements',
'date_mandatory',
'related_products',
'formula',
'product_number_eural',
'product_number_cas',
'footnotes',
];
public function __construct(
private readonly Connection $connection,
private readonly HttpClientInterface $httpClient,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('schema', null, InputOption::VALUE_REQUIRED, "Module IDTF : 'road' (routier) ou 'water' (fluvial).", 'road')
->addOption('file', null, InputOption::VALUE_REQUIRED, "Chemin d'un .xlsx local (court-circuite le telechargement, utile pour tests/rejeu).")
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Analyse sans ecriture en base.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$schema = (string) $input->getOption('schema');
$dryRun = (bool) $input->getOption('dry-run');
$file = $input->getOption('file');
if (!in_array($schema, ['road', 'water'], true)) {
$io->error("--schema doit valoir 'road' ou 'water'.");
return Command::INVALID;
}
// 1. Recuperation du binaire xlsx (local ou via POST).
try {
$xlsx = null !== $file ? $this->readLocal((string) $file) : $this->downloadExport($schema);
} catch (Throwable $e) {
$io->error('Telechargement/lecture impossible : '.$e->getMessage());
return Command::FAILURE;
}
// 2. Parsing (xlsx -> matrice -> lignes normalisees).
try {
$parsed = IdtfSheetParser::parse($this->toMatrix($xlsx));
} catch (Throwable $e) {
$io->error('Parsing impossible : '.$e->getMessage());
return Command::FAILURE;
}
$rows = $parsed['rows'];
$exportDate = $parsed['exportDate'] ?? new DateTimeImmutable()->format('Y-m-d');
$io->section(sprintf('IDTF %s — export du %s', mb_strtoupper($schema), $exportDate));
$io->writeln(sprintf('%d lignes exploitables lues.', count($rows)));
if ($dryRun) {
$this->renderPreview($io, $rows);
$io->note(sprintf('Dry-run : aucune ecriture. (%d lignes au total)', count($rows)));
return Command::SUCCESS;
}
// 3. Sync transactionnelle : upsert -> soft-delete -> journal.
$run = new DateTimeImmutable()->format('Y-m-d H:i:s.u');
$this->connection->beginTransaction();
try {
$upserted = $this->upsertAll($schema, $exportDate, $rows, $run);
$deactivated = $this->deactivateMissing($schema, $run);
$this->log($schema, $exportDate, count($rows), $upserted, $deactivated);
$this->connection->commit();
} catch (Throwable $e) {
$this->connection->rollBack();
$io->error('Sync annulee (rollback) : '.$e->getMessage());
return Command::FAILURE;
}
$io->success(sprintf('%d upsert, %d desactive(s).', $upserted, $deactivated));
return Command::SUCCESS;
}
/**
* Rejoue le POST du generateur pour recuperer le binaire xlsx complet.
* Le formulaire poste sur lui-meme ; pas besoin de GET/cookies prealables.
*/
private function downloadExport(string $schema): string
{
// Corps construit a la main : http-client encoderait fields[] en
// indices numerotes, on veut bien des "fields[]=..." repetes.
$pairs = [
'schema='.$schema,
'type%5B%5D='.$schema,
'roadRegime%5B%5D=all',
'waterRegime%5B%5D=all',
'groups%5B%5D=all',
'products%5B%5D=all',
];
foreach (self::EXPORT_FIELDS as $field) {
$pairs[] = 'fields%5B%5D='.$field;
}
$pairs[] = 'generateExcel=';
$response = $this->httpClient->request('POST', self::GENERATOR_URL, [
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
'body' => implode('&', $pairs),
'timeout' => 90,
]);
$content = $response->getContent();
$contentType = $response->getHeaders(false)['content-type'][0] ?? '';
// Garde-fou : un HTML signifie un POST rejete (filtres/payload).
if (!str_contains($contentType, 'spreadsheet') && !str_starts_with($content, "PK\x03\x04")) {
throw new RuntimeException(sprintf('Reponse non-xlsx (content-type: %s). Verifie le payload.', $contentType));
}
return $content;
}
private function readLocal(string $path): string
{
$raw = @file_get_contents($path);
if (false === $raw) {
throw new RuntimeException(sprintf('Fichier illisible : %s', $path));
}
return $raw;
}
/**
* Charge le binaire xlsx via PhpSpreadsheet et retourne la feuille active
* sous forme de matrice 0-indexee (lignes/colonnes).
*
* @return array<int, array<int, mixed>>
*/
private function toMatrix(string $xlsx): array
{
$tmp = tempnam(sys_get_temp_dir(), 'idtf_').'.xlsx';
file_put_contents($tmp, $xlsx);
try {
// toArray(null, true, true, false) : colonnes 0-indexees.
return IOFactory::load($tmp)->getActiveSheet()->toArray(null, true, true, false);
} finally {
@unlink($tmp);
}
}
/**
* Upsert de toutes les lignes (cle naturelle = schema + idtf_number).
*
* @param list<array<string, mixed>> $rows
*/
private function upsertAll(string $schema, string $exportDate, array $rows, string $run): int
{
$sql = <<<'SQL'
INSERT INTO idtf_product
(idtf_number, schema, product_group, name, cleaning_regime, important_requirements,
mandatory_date, related_products, formula, eural_code, cas_numbers, footnotes,
source_export_date, is_active, last_synced_at)
VALUES
(:idtf, :schema, :grp, :name, :regime, :req, :mdate, :related, :formula, :eural,
CAST(:cas AS JSONB), :foot, :export, TRUE, :run)
ON CONFLICT (schema, idtf_number) DO UPDATE SET
product_group = EXCLUDED.product_group,
name = EXCLUDED.name,
cleaning_regime = EXCLUDED.cleaning_regime,
important_requirements = EXCLUDED.important_requirements,
mandatory_date = EXCLUDED.mandatory_date,
related_products = EXCLUDED.related_products,
formula = EXCLUDED.formula,
eural_code = EXCLUDED.eural_code,
cas_numbers = EXCLUDED.cas_numbers,
footnotes = EXCLUDED.footnotes,
source_export_date = EXCLUDED.source_export_date,
is_active = TRUE,
last_synced_at = EXCLUDED.last_synced_at
SQL;
$count = 0;
foreach ($rows as $r) {
$this->connection->executeStatement($sql, [
'idtf' => $r['idtf_number'],
'schema' => $schema,
'grp' => $r['product_group'],
'name' => $r['name'],
'regime' => $r['cleaning_regime'],
'req' => $r['important_requirements'],
'mdate' => $r['mandatory_date'],
'related' => $r['related_products'],
'formula' => $r['formula'],
'eural' => $r['eural_code'],
'cas' => json_encode($r['cas_numbers'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'foot' => $r['footnotes'],
'export' => $exportDate,
'run' => $run,
]);
++$count;
}
return $count;
}
/**
* Soft-delete : toute ligne du schema active non revue par ce run passe a
* is_active=false.
*/
private function deactivateMissing(string $schema, string $run): int
{
return (int) $this->connection->executeStatement(
'UPDATE idtf_product SET is_active = FALSE WHERE schema = :schema AND is_active = TRUE AND last_synced_at < :run',
['schema' => $schema, 'run' => $run],
);
}
private function log(string $schema, string $exportDate, int $total, int $upserted, int $deactivated): void
{
$this->connection->executeStatement(
<<<'SQL'
INSERT INTO idtf_sync_log (schema, export_date, rows_total, rows_upserted, rows_deactivated)
VALUES (:schema, :export, :total, :upserted, :deactivated)
SQL,
[
'schema' => $schema,
'export' => $exportDate,
'total' => $total,
'upserted' => $upserted,
'deactivated' => $deactivated,
],
);
}
/**
* @param list<array<string, mixed>> $rows
*/
private function renderPreview(SymfonyStyle $io, array $rows): void
{
$io->table(
['IDTF', 'Nom', 'Regime', 'CAS'],
array_map(static fn (array $r): array => [
(string) $r['idtf_number'],
mb_strimwidth((string) $r['name'], 0, 50, '…'),
(string) $r['cleaning_regime'],
implode(', ', $r['cas_numbers']),
], array_slice($rows, 0, 15)),
);
}
}
@@ -0,0 +1,327 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\Console;
use App\Module\Transport\Application\Qualimat\QualimatRowMapper;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use RuntimeException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
use function array_slice;
use function count;
use function is_array;
use const JSON_THROW_ON_ERROR;
/**
* ERP-39 : synchronise le referentiel des transporteurs QUALIMAT.
*
* Recupere la liste des operateurs de transport depuis l'API publique (ou un
* fichier local), normalise chaque ligne et synchronise `qualimat_carrier` de
* facon transactionnelle : upsert sur le SIRET, soft-delete des absents,
* journal dans `qualimat_sync_log`. Idempotente (refresh complet) : prevue
* pour un cron quotidien.
*/
#[AsCommand(
name: 'app:qualimat:sync',
description: 'Synchronise le referentiel des transporteurs QUALIMAT (upsert + soft-delete + journal).',
)]
final class SyncQualimatCommand extends Command
{
private const string API_URL = 'https://www.qualimat.org/wp-json/qualimat/v1/getOperateurs';
private const int DEFAULT_PPP = 10000;
// Cle arbitraire (mais stable) du verrou consultatif Postgres serialisant
// les runs de `app:qualimat:sync` entre eux. Propre a cette commande.
private const int ADVISORY_LOCK_KEY = 3_900_000_039;
// Nombre de lignes par INSERT groupe. 10 parametres/ligne, large marge sous
// la limite Postgres de 65535 parametres par requete.
private const int UPSERT_CHUNK = 1000;
public function __construct(
private readonly Connection $connection,
private readonly HttpClientInterface $httpClient,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('file', null, InputOption::VALUE_REQUIRED, "Chemin d'un JSON local (court-circuite l'appel HTTP, utile pour tests/rejeu).")
->addOption('ppp', null, InputOption::VALUE_REQUIRED, "Taille de page demandee a l'API.", (string) self::DEFAULT_PPP)
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Analyse sans ecriture en base.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$ppp = max(1, (int) $input->getOption('ppp'));
$dryRun = (bool) $input->getOption('dry-run');
$file = $input->getOption('file');
// Verrou consultatif (session) : empeche deux runs de se chevaucher
// (cron qui deborde, invocation manuelle parallele). Sans lui, le run le
// plus tardif desactiverait les lignes que l'autre vient d'inserer.
if (!$this->acquireLock()) {
$io->error('Une synchronisation QUALIMAT est deja en cours (verrou non disponible).');
return Command::FAILURE;
}
try {
return $this->doSync($io, $ppp, $dryRun, $file);
} finally {
$this->releaseLock();
}
}
/**
* Coeur de la synchronisation, execute sous verrou consultatif.
*/
private function doSync(SymfonyStyle $io, int $ppp, bool $dryRun, ?string $file): int
{
// 1. Recuperation des items (fichier local ou API).
try {
$items = null !== $file ? $this->readLocal($file) : $this->fetchRemote($ppp);
} catch (Throwable $e) {
$io->error('Recuperation impossible : '.$e->getMessage());
return Command::FAILURE;
}
$total = count($items);
$io->section(sprintf('QUALIMAT — %d items recus', $total));
// Garde-fou troncature : un retour egal a ppp signale un dataset coupe.
if (null === $file && $total === $ppp) {
$io->warning(sprintf("Le nombre d'items recus (%d) egale --ppp : resultat potentiellement tronque, augmente --ppp.", $ppp));
}
// 2. Mapping / normalisation (les items sans SIRET sont ignores, les
// doublons de SIRET sont fusionnes : derniere occurrence gagnante).
['rows' => $rows, 'skipped' => $skipped] = QualimatRowMapper::mapMany($items);
$io->writeln(sprintf('%d lignes exploitables, %d ignorees (sans SIRET).', count($rows), $skipped));
if ($dryRun) {
$this->renderPreview($io, $rows);
$io->note(sprintf('Dry-run : aucune ecriture. (%d lignes au total)', count($rows)));
return Command::SUCCESS;
}
// Garde-fou « zero ligne » : une source vide (incident amont, liste []
// legitime) ne doit JAMAIS atteindre le soft-delete, qui desactiverait
// tout le referentiel. On abandonne sans rien ecrire.
if ([] === $rows) {
$io->error('Aucune ligne exploitable : synchronisation abandonnee (desactivation de masse evitee).');
return Command::FAILURE;
}
// 3. Sync transactionnelle : upsert -> soft-delete -> journal.
$run = new DateTimeImmutable()->format('Y-m-d H:i:s.u');
$this->connection->beginTransaction();
try {
$upserted = $this->upsertAll($rows, $run);
$deactivated = $this->deactivateMissing($run);
$this->log($run, $total, $upserted, $skipped, $deactivated);
$this->connection->commit();
} catch (Throwable $e) {
$this->connection->rollBack();
$io->error('Sync annulee (rollback) : '.$e->getMessage());
return Command::FAILURE;
}
$io->success(sprintf('%d upsert, %d ignore(s), %d desactive(s).', $upserted, $skipped, $deactivated));
return Command::SUCCESS;
}
/**
* Tente de prendre le verrou consultatif de session. Retourne false si un
* autre run le detient deja (Postgres `pg_try_advisory_lock`, non bloquant).
*/
private function acquireLock(): bool
{
return (bool) $this->connection->fetchOne('SELECT pg_try_advisory_lock(:key)', ['key' => self::ADVISORY_LOCK_KEY]);
}
/**
* Relache le verrou consultatif pris par acquireLock().
*/
private function releaseLock(): void
{
$this->connection->executeStatement('SELECT pg_advisory_unlock(:key)', ['key' => self::ADVISORY_LOCK_KEY]);
}
/**
* Rejoue l'appel GET de l'API QUALIMAT et retourne le tableau d'items.
*
* @return array<int, array<string, mixed>>
*/
private function fetchRemote(int $ppp): array
{
$response = $this->httpClient->request('GET', self::API_URL, [
'query' => ['type' => 'operateur_transport', 'ppp' => $ppp],
'timeout' => 60,
]);
// toArray() leve une exception sur un statut non-2xx ou un corps non-JSON.
$data = $response->toArray();
// Un 2xx au corps inattendu (objet d'erreur, enveloppe {"data":[...]}, etc.)
// ne doit PAS etre interprete comme « 0 transporteur » : ce serait masquer
// un changement de contrat de l'API et declencher la desactivation de masse
// (cf. garde-fou « zero ligne » dans execute()). On echoue franchement.
if (!array_is_list($data)) {
throw new RuntimeException("Reponse inattendue de l'API QUALIMAT : un tableau d'items etait attendu.");
}
return $data;
}
/**
* Lit un export JSON local (tableau d'objets).
*
* @return array<int, array<string, mixed>>
*/
private function readLocal(string $path): array
{
$raw = @file_get_contents($path);
if (false === $raw) {
throw new RuntimeException(sprintf('Fichier illisible : %s', $path));
}
$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($data) || !array_is_list($data)) {
throw new RuntimeException("Le JSON doit etre un tableau d'objets.");
}
return $data;
}
/**
* Upsert de toutes les lignes valides (cle naturelle = siret) par paquets
* (INSERT groupe), au lieu d'un aller-retour par ligne. Marque is_active=TRUE
* et tamponne last_synced_at avec le run courant. Les lignes etant deja
* dedoublonnees par SIRET en amont, le compte retourne = transporteurs
* distincts effectivement synchronises.
*
* @param list<array<string, mixed>> $rows
*/
private function upsertAll(array $rows, string $run): int
{
$count = 0;
foreach (array_chunk($rows, self::UPSERT_CHUNK) as $chunk) {
$placeholders = [];
$params = [];
foreach ($chunk as $r) {
// 10 valeurs liees + is_active force a TRUE (litteral).
$placeholders[] = '(?, ?, ?, ?, ?, ?, ?, ?, ?, TRUE, ?)';
$params[] = $r['siret'];
$params[] = $r['name'];
$params[] = $r['address'];
$params[] = $r['postal_code'];
$params[] = $r['city'];
$params[] = $r['phone'];
$params[] = $r['department'];
$params[] = $r['status'];
$params[] = $r['validity_date'];
$params[] = $run;
}
$sql = sprintf(
<<<'SQL'
INSERT INTO qualimat_carrier
(siret, name, address, postal_code, city, phone, department, status, validity_date, is_active, last_synced_at)
VALUES
%s
ON CONFLICT (siret) DO UPDATE SET
name = EXCLUDED.name,
address = EXCLUDED.address,
postal_code = EXCLUDED.postal_code,
city = EXCLUDED.city,
phone = EXCLUDED.phone,
department = EXCLUDED.department,
status = EXCLUDED.status,
validity_date = EXCLUDED.validity_date,
is_active = TRUE,
last_synced_at = EXCLUDED.last_synced_at
SQL,
implode(",\n ", $placeholders),
);
$this->connection->executeStatement($sql, $params);
$count += count($chunk);
}
return $count;
}
/**
* Soft-delete : toute ligne active non revue par ce run (tampon anterieur)
* passe a is_active=false.
*/
private function deactivateMissing(string $run): int
{
return (int) $this->connection->executeStatement(
'UPDATE qualimat_carrier SET is_active = FALSE WHERE is_active = TRUE AND last_synced_at < :run',
['run' => $run],
);
}
private function log(string $run, int $total, int $upserted, int $skipped, int $deactivated): void
{
$this->connection->executeStatement(
<<<'SQL'
INSERT INTO qualimat_sync_log (fetched_at, rows_total, rows_upserted, rows_skipped, rows_deactivated)
VALUES (:run, :total, :upserted, :skipped, :deactivated)
SQL,
[
'run' => $run,
'total' => $total,
'upserted' => $upserted,
'skipped' => $skipped,
'deactivated' => $deactivated,
],
);
}
/**
* @param list<array<string, mixed>> $rows
*/
private function renderPreview(SymfonyStyle $io, array $rows): void
{
$io->table(
['SIRET', 'Nom', 'CP', 'Ville', 'Statut', 'Validite'],
array_map(static fn (array $r): array => [
(string) $r['siret'],
mb_strimwidth((string) $r['name'], 0, 40, '…'),
(string) ($r['postal_code'] ?? ''),
mb_strimwidth((string) ($r['city'] ?? ''), 0, 25, '…'),
(string) $r['status'],
(string) ($r['validity_date'] ?? ''),
], array_slice($rows, 0, 15)),
);
}
}
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\Controller;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Repository\CarrierRepositoryInterface;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use DateTimeImmutable;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Export XLSX du repertoire transporteurs (M4, spec-back § 4.6). Jumeau des
* controllers d'export SupplierExportController (M2) / ProviderExportController
* (M3) references en prose volontairement (pas de {@see} : un import
* inter-module violerait la regle ABSOLUE n°1). Simplifie : pas de cloisonnement
* par site (§ 2.3) ni de colonne gatee par une permission comptable.
*
* Controller Symfony custom (et non operation API Platform) car il produit un
* binaire de fichier, pas une representation Hydra. `priority: 1` est OBLIGATOIRE
* sur la route : sans cela API Platform capterait `/api/carriers/export.xlsx`
* comme l'item `GET /api/carriers/{id}.{_format}` (id="export", _format="xlsx")
* cf. CLAUDE.md « controller custom sous /api ».
*
* Separation des responsabilites :
* - le COMMENT (generation du fichier) est delegue au service Shared
* {@see SpreadsheetExporterInterface} generique, reutilisable, sans metier ;
* - le QUOI vit ICI : selection des transporteurs (MEMES filtres que
* `GET /api/carriers`, via {@see CarrierRepositoryInterface::createListQueryBuilder()}
* l'export reflete exactement ce que l'utilisateur voit a l'ecran) et mapping
* metier des colonnes.
*/
#[AsController]
final class CarrierExportController
{
public function __construct(
#[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository')]
private readonly CarrierRepositoryInterface $repository,
private readonly SpreadsheetExporterInterface $exporter,
) {}
#[Route('/api/carriers/export.xlsx', name: 'transport_carriers_export_xlsx', methods: ['GET'], priority: 1)]
#[IsGranted('transport.carriers.view')]
public function __invoke(Request $request): Response
{
// Memes filtres que la vue liste (CarrierProvider) pour que l'export
// reflete exactement ce que l'utilisateur voit a l'ecran :
// - includeArchived : reintegre les archives en plus des actifs ;
// - archivedOnly : n'exporte QUE les archives (prioritaire sur
// includeArchived, aligne sur le provider — toggle « Voir les archives ») ;
// - search : recherche fuzzy sur le nom ;
// - certificationType : filtre repetable (?certificationType[]=A&...).
$includeArchived = $this->readBool($request->query->get('includeArchived'));
$archivedOnly = $this->readBool($request->query->get('archivedOnly'));
$search = $request->query->getString('search') ?: null;
$certificationTypes = $this->readStringList($request->query->all()['certificationType'] ?? []);
/** @var list<Carrier> $carriers */
$carriers = $this->repository
->createListQueryBuilder($includeArchived, $search, $certificationTypes, $archivedOnly)
->getQuery()
->getResult()
;
$binary = $this->exporter->export(
'Répertoire transporteurs',
$this->buildHeaders(),
$this->buildRows($carriers),
);
return $this->buildResponse($binary);
}
/**
* Colonnes de l'export (spec § 4.6).
*
* @return list<string>
*/
private function buildHeaders(): array
{
return [
'Nom',
'Certification',
'Statut QUALIMAT',
'Date de validité',
'Affrété',
'Volume m³',
'Date de création',
];
}
/**
* @param list<Carrier> $carriers
*
* @return iterable<list<null|scalar>>
*/
private function buildRows(array $carriers): iterable
{
foreach ($carriers as $carrier) {
// Statut / date de validite proviennent du referentiel QUALIMAT lie
// (RG-4.04), deja fetch-joine par le repository (anti N+1, § 2.11).
$qualimat = $carrier->getQualimatCarrier();
yield [
$carrier->getName(),
$carrier->getCertificationType() ?? '',
$qualimat?->getStatus() ?? '',
$qualimat?->getValidityDate()?->format('d/m/Y') ?? '',
$carrier->isChartered() ? 'Oui' : 'Non',
$carrier->getVolumeM3() ?? '',
$carrier->getCreatedAt()?->format('d/m/Y'),
];
}
}
private function buildResponse(string $binary): Response
{
$filename = sprintf('repertoire-transporteurs-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
$response = new Response($binary);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
return $response;
}
/**
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
* Aligne sur CarrierProvider pour un comportement identique a la liste.
*/
private function readBool(mixed $raw): bool
{
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
}
/**
* Normalise un filtre en liste de chaines (valeur unique ou liste).
* Aligne sur CarrierProvider pour un comportement identique a la liste.
*
* @return list<string>
*/
private function readStringList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if (is_string($value) && '' !== trim($value)) {
$out[] = trim($value);
}
}
return $out;
}
}
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\Controller;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Entity\CarrierPrice;
use App\Module\Transport\Domain\Repository\CarrierRepositoryInterface;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use DateTimeImmutable;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Export XLSX du tableau Prix d'un transporteur (M4, spec-back § 4.6 / spec-front
* § « Onglet Prix »). Reproduit le tableau de consultation regroupe par type de
* contenant (Fond Mouvant / Benne colonnes du docx p.10).
*
* Controller Symfony custom (binaire de fichier, pas une representation Hydra).
* `priority: 1` est OBLIGATOIRE : sans cela API Platform capterait
* `/api/carriers/{id}/prices/export.xlsx` via ses routes generiques.
*
* Separation des responsabilites : le COMMENT (generation) est delegue au service
* Shared {@see SpreadsheetExporterInterface} ; le QUOI (chargement du transporteur,
* regroupement par contenant, mapping metier des colonnes) vit ICI.
*
* Adresses cross-module : les contrats Shared (ClientInterface / SupplierInterface
* / SiteInterface) exposent volontairement le minimum (regle ABSOLUE n°1). Faute
* d'acceder au detail postal d'une adresse Client/Fournisseur sans coupler au
* module Commercial, les colonnes d'adresse identifient le point par le libelle
* disponible : nom du site pour un Site, raison sociale du client/fournisseur pour
* une adresse de livraison/approvisionnement.
*/
#[AsController]
final class CarrierPriceExportController
{
/** Libelles d'affichage des enums (spec-front « Onglet Prix »). */
private const array CONTAINER_LABELS = ['BENNE' => 'Benne', 'FOND_MOUVANT' => 'Fond Mouvant'];
private const array PRICE_STATE_LABELS = [
'EN_COURS' => 'En cours',
'VALIDE' => 'Validé',
'NON_VALIDE' => 'Non validé',
];
public function __construct(
#[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository')]
private readonly CarrierRepositoryInterface $repository,
private readonly SpreadsheetExporterInterface $exporter,
) {}
#[Route('/api/carriers/{id}/prices/export.xlsx', name: 'transport_carrier_prices_export_xlsx', requirements: ['id' => '\d+'], methods: ['GET'], priority: 1)]
#[IsGranted('transport.carriers.view')]
public function __invoke(int $id): Response
{
$carrier = $this->repository->findById($id);
// Soft-delete jamais expose (comme CarrierProvider::provideItem) : 404.
if (null === $carrier || null !== $carrier->getDeletedAt()) {
throw new NotFoundHttpException('Transporteur introuvable.');
}
$binary = $this->exporter->export(
'Prix transporteur',
$this->buildHeaders(),
$this->buildRows($carrier),
);
return $this->buildResponse($carrier, $binary);
}
/**
* Colonnes du tableau Prix regroupe (spec-front « Onglet Prix » / docx p.10).
*
* @return list<string>
*/
private function buildHeaders(): array
{
return [
'Type de contenant',
'Transporteurs',
'Adresse APRO ou Adresse Sites',
'Adresse livraisons',
'Forfait €',
'Tonne €',
'Indexation',
'État du prix',
];
}
/**
* Lignes regroupees par type de contenant (Fond Mouvant / Benne). On trie les
* prix par contenant puis position pour materialiser le regroupement.
*
* @return iterable<list<null|scalar>>
*/
private function buildRows(Carrier $carrier): iterable
{
$prices = $carrier->getPrices()->toArray();
usort(
$prices,
static fn (CarrierPrice $a, CarrierPrice $b): int => [$a->getContainerType(), $a->getPosition()]
<=> [$b->getContainerType(), $b->getPosition()],
);
// Indexation : portee par le transporteur (RG-4.03), identique pour toutes
// ses lignes de prix. Vide si non renseigne (spec-front).
$indexation = $carrier->getIndexationRate() ?? '';
foreach ($prices as $price) {
$isForfait = 'FORFAIT' === $price->getPricingUnit();
yield [
self::CONTAINER_LABELS[$price->getContainerType()] ?? $price->getContainerType(),
$carrier->getName(),
$this->formatDeparture($price),
$this->formatDelivery($price),
$isForfait ? $price->getPrice() : '',
$isForfait ? '' : $price->getPrice(),
$indexation,
self::PRICE_STATE_LABELS[$price->getPriceState()] ?? $price->getPriceState(),
];
}
}
/**
* Point de depart du prix (colonne « Adresse APRO ou Adresse Sites ») :
* - branche CLIENT : le site de depart (un des 3 sites 86/17/82) ;
* - branche FOURNISSEUR : l'adresse d'approvisionnement, identifiee par la
* raison sociale du fournisseur (cf. note de classe sur les contrats Shared).
*/
private function formatDeparture(CarrierPrice $price): string
{
if ('CLIENT' === $price->getDirection()) {
return $price->getDepartureSite()?->getName() ?? '';
}
return $price->getSupplierSupplyAddress()?->getSupplier()?->getCompanyName() ?? '';
}
/**
* Point de livraison du prix (colonne « Adresse livraisons ») :
* - branche CLIENT : l'adresse de livraison, identifiee par la raison sociale
* du client ;
* - branche FOURNISSEUR : le site de livraison (un des 3 sites 86/17/82).
*/
private function formatDelivery(CarrierPrice $price): string
{
if ('CLIENT' === $price->getDirection()) {
return $price->getClientDeliveryAddress()?->getClient()?->getCompanyName() ?? '';
}
return $price->getDeliverySite()?->getName() ?? '';
}
private function buildResponse(Carrier $carrier, string $binary): Response
{
$filename = sprintf('prix-transporteur-%d-%s.xlsx', (int) $carrier->getId(), new DateTimeImmutable()->format('Ymd'));
$response = new Response($binary);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
return $response;
}
}
@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\DataFixtures;
use App\Module\Commercial\Infrastructure\DataFixtures\ClientFixtures;
use App\Module\Commercial\Infrastructure\DataFixtures\SupplierFixtures;
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Entity\CarrierAddress;
use App\Module\Transport\Domain\Entity\CarrierContact;
use App\Module\Transport\Domain\Entity\CarrierPrice;
use App\Module\Transport\Domain\Entity\QualimatCarrier;
use App\Shared\Domain\Contract\ClientAddressInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SiteProviderInterface;
use App\Shared\Domain\Contract\SupplierAddressInterface;
use App\Shared\Domain\Entity\UploadedDocument;
use DateTimeImmutable;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Fixtures dev/demo du repertoire transporteurs (M4) couvrant l'ensemble des cas
* metier RG-4.xx, jumelles des fixtures fournisseurs (M2). C'est ICI que vivent
* les fixtures COMPLETES (les maillons WT precedents s'etaient limites a un stub
* de lecture). Cas pivots seedes (§ 8.4) :
* - 1 transporteur QUALIMAT (lien `qualimat_carrier` + adresse copiee +
* validityDate PASSEE pour exercer le fond rouge RG-4.04) ;
* - 1 transporteur AUTRE + Decharge (UploadedDocument, RG-4.02) ;
* - 1 transporteur affrete (indexation + benne + volume obligatoires, RG-4.03) ;
* - 1 transporteur LIOT (immatriculations, certification non requise, RG-4.01) ;
* - 1 transporteur COMPLET : contacts + adresses + prix CLIENT et FOURNISSEUR ;
* - 1 transporteur archive (exclusion liste + restauration, RG-4.14).
*
* Resolution inter-modules conforme a la regle n°1 (pas d'import de logique) :
* - sites resolus via le contrat Shared SiteProviderInterface ;
* - client/adresse et fournisseur/adresse des prix resolus via les contrats
* Shared ClientAddressInterface / SupplierAddressInterface (relations ORM
* partagees, RG-4.10/4.11). Si la demo Commercial/Sites n'est pas chargee, les
* prix sont simplement omis (le reste de la fiche reste seede).
*
* Normalisation : valeurs fournies BRUTES puis normalisees par
* CarrierFieldNormalizer avant persist, comme le ferait le CarrierProcessor via
* l'API (name UPPERCASE, first/last Capitalize, telephones chiffres seuls, email
* lowercase, liotPlates « ; »-normalise).
*
* Idempotence : lookup par `name` normalise (coherent avec l'index unique partiel
* uq_carrier_name_active). Un transporteur deja present n'est pas reconstruit (ses
* sous-collections ne sont pas redupliquees). Rejouable sans doublon.
*
* Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by
* restent null (« Systeme » cote front), c'est attendu.
*
* Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`, la
* fixture ne charge rien : les tests seedent et nettoient leurs propres
* transporteurs et comptent sur une table `carrier` vierge y injecter des
* transporteurs de demo casserait les comptages de liste et les cleanups. Meme
* garde-fou que ClientFixtures / SupplierFixtures.
*/
class CarrierFixtures extends Fixture implements DependentFixtureInterface
{
/** SIRET de la ligne qualimat_carrier de demo (cle naturelle, insert idempotent). */
private const string QUALIMAT_DEMO_SIRET = '90000000000017';
public function __construct(
private readonly CarrierFieldNormalizer $normalizer,
private readonly SiteProviderInterface $siteProvider,
#[Autowire('%kernel.environment%')]
private readonly string $environment,
) {}
/**
* @return array<int, class-string>
*/
public function getDependencies(): array
{
// Les prix referencent des Client/Supplier/Site de demo (relations ORM
// partagees) : ces fixtures doivent tourner avant.
return [
SitesFixtures::class,
ClientFixtures::class,
SupplierFixtures::class,
];
}
public function load(ObjectManager $manager): void
{
// Donnees de demo : dev uniquement. En test, on laisse la table vierge.
if ('test' === $this->environment) {
return;
}
// === Transporteur QUALIMAT (RG-4.01) — adresse copiee + validite PASSEE (RG-4.04) ===
[$grelillier, $isNew] = $this->ensureCarrier($manager, 'Transports Grelillier');
if ($isNew) {
$grelillier->setQualimatCarrier($this->ensureQualimatDemoLine($manager));
$grelillier->setCertificationType('QUALIMAT');
// Adresse pre-remplie depuis la copie QUALIMAT (RG-4.05).
$this->addAddress($grelillier, '86000', 'Poitiers', '12 rue des Acacias');
$this->addContact($grelillier, 'Marie', 'Martin', 'Exploitation', '06 12 34 56 78', null, 'marie.martin@grelillier.fr');
}
// === Transporteur AUTRE + Decharge (RG-4.02) ===
[$pandele, $isNew] = $this->ensureCarrier($manager, 'Transports Pandele');
if ($isNew) {
$pandele->setCertificationType('AUTRE');
$pandele->setDischargeDocument($this->buildDischargeDocument($manager));
$this->addContact($pandele, 'Luc', 'Pandele', 'Gerant', '05 49 11 22 33', null, 'luc.pandele@pandele.fr');
}
// === Transporteur affrete (RG-4.03) — indexation + benne + volume ===
[$affrete, $isNew] = $this->ensureCarrier($manager, 'Affreteurs Reunis');
if ($isNew) {
$affrete->setCertificationType('GMP_PLUS');
$affrete->setIsChartered(true);
$affrete->setIndexationRate('5.00');
$affrete->setContainerType('BENNE');
$affrete->setVolumeM3('90.00');
$this->addAddress($affrete, '17000', 'La Rochelle', '4 quai des Affreteurs');
}
// === Cas LIOT (RG-4.01) — immatriculations, certification non requise ===
[$liot, $isNew] = $this->ensureCarrier($manager, 'LIOT');
if ($isNew) {
$liot->setLiotPlates($this->normalizer->normalizeLiotPlates('ab-123-cd ; ef-456-gh ; gh-789-ij'));
}
// === Transporteur COMPLET — contacts + adresses + prix CLIENT et FOURNISSEUR ===
[$complet, $isNew] = $this->ensureCarrier($manager, 'Transports Logistique Globale');
if ($isNew) {
$complet->setCertificationType('OVOCOM');
$this->addAddress($complet, '86100', 'Châtellerault', '20 zone des Transporteurs');
$this->addContact($complet, 'Sophie', 'Bernard', 'Directrice', '05 49 44 55 66', '06 99 88 77 66', 'sophie.bernard@logistique-globale.fr', 0);
$this->addContact($complet, 'Marc', 'Lopez', 'Affretement', '05 49 44 55 67', null, 'marc.lopez@logistique-globale.fr', 1);
$this->addPrices($manager, $complet);
}
// === Transporteur archive (RG-4.14) ===
[$archive, $isNew] = $this->ensureCarrier($manager, 'Transports Anciens', isArchived: true);
if ($isNew) {
$archive->setCertificationType('COMPTE_PROPRE');
$this->addContact($archive, 'Paul', 'Ancien', 'Ex-gerant', '05 49 00 00 00', null, 'paul.ancien@anciens.fr');
}
$manager->flush();
}
/**
* Cree un transporteur (nom normalise UPPERCASE) s'il n'existe pas encore,
* sinon retourne l'existant. Retourne [Carrier, isNew] : isNew=false bloque la
* reconstruction des sous-collections (idempotence sans doublon).
*
* @return array{0: Carrier, 1: bool}
*/
private function ensureCarrier(ObjectManager $manager, string $name, bool $isArchived = false): array
{
$normalizedName = (string) $this->normalizer->normalizeName($name);
$existing = $manager->getRepository(Carrier::class)->findOneBy(['name' => $normalizedName]);
if ($existing instanceof Carrier) {
return [$existing, false];
}
$carrier = new Carrier();
$carrier->setName($normalizedName);
if ($isArchived) {
$carrier->setIsArchived(true);
$carrier->setArchivedAt(new DateTimeImmutable());
}
$manager->persist($carrier);
return [$carrier, true];
}
/**
* Ajoute une adresse au transporteur (cascade persist via Carrier.addresses).
*/
private function addAddress(Carrier $carrier, string $postalCode, string $city, string $street): void
{
$address = new CarrierAddress();
$address->setPostalCode($postalCode);
$address->setCity($city);
$address->setStreet($street);
$carrier->addAddress($address);
}
/**
* Ajoute un contact normalise au transporteur (cascade persist via
* Carrier.contacts). Au moins un champ est toujours fourni (RG-4.08).
*/
private function addContact(
Carrier $carrier,
?string $firstName,
?string $lastName,
?string $jobTitle,
?string $phonePrimary,
?string $phoneSecondary,
?string $email,
int $position = 0,
): void {
$contact = new CarrierContact();
$contact->setFirstName($this->normalizer->normalizePersonName($firstName));
$contact->setLastName($this->normalizer->normalizePersonName($lastName));
$contact->setJobTitle($jobTitle);
$contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary));
$contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary));
$contact->setEmail($this->normalizer->normalizeEmail($email));
$contact->setPosition($position);
$carrier->addContact($contact);
}
/**
* Ajoute un prix CLIENT et un prix FOURNISSEUR au transporteur (RG-4.10/4.11),
* en resolvant les relations cross-module (client/adresse de livraison + site
* de depart ; fournisseur/adresse d'appro + site de livraison) via les contrats
* Shared. Si la demo Commercial/Sites n'est pas disponible, les prix sont omis.
*/
private function addPrices(ObjectManager $manager, Carrier $carrier): void
{
$site = $this->siteProvider->findByName('Chatellerault');
// Branche CLIENT (RG-4.10) : 1ere adresse de livraison de la demo M1.
$clientAddress = $manager->getRepository(ClientAddressInterface::class)->findOneBy(['isDelivery' => true]);
if ($site instanceof SiteInterface && $clientAddress instanceof ClientAddressInterface && null !== $clientAddress->getClient()) {
$clientPrice = new CarrierPrice();
$clientPrice->setDirection('CLIENT');
$clientPrice->setClient($clientAddress->getClient());
$clientPrice->setClientDeliveryAddress($clientAddress);
$clientPrice->setDepartureSite($site);
$clientPrice->setContainerType('BENNE');
$clientPrice->setPricingUnit('TONNE');
$clientPrice->setPrice('42.50');
$clientPrice->setPriceState('VALIDE');
$carrier->addPrice($clientPrice);
}
// Branche FOURNISSEUR (RG-4.11) : 1ere adresse de DEPART de la demo M2.
$supplierAddress = $manager->getRepository(SupplierAddressInterface::class)->findOneBy(['addressType' => 'DEPART']);
if ($site instanceof SiteInterface && $supplierAddress instanceof SupplierAddressInterface && null !== $supplierAddress->getSupplier()) {
$supplierPrice = new CarrierPrice();
$supplierPrice->setDirection('FOURNISSEUR');
$supplierPrice->setSupplier($supplierAddress->getSupplier());
$supplierPrice->setSupplierSupplyAddress($supplierAddress);
$supplierPrice->setDeliverySite($site);
$supplierPrice->setContainerType('FOND_MOUVANT');
$supplierPrice->setPricingUnit('FORFAIT');
$supplierPrice->setPrice('320.00');
$supplierPrice->setPriceState('EN_COURS');
$carrier->addPrice($supplierPrice);
}
}
/**
* Construit (non persiste explicitement cascade via la FK Carrier) un
* UploadedDocument de demo pour la Decharge (RG-4.02). Pas de fichier reel sur
* disque : metadonnees factices suffisantes pour la demo.
*/
private function buildDischargeDocument(ObjectManager $manager): UploadedDocument
{
$document = new UploadedDocument(
'decharge-demo.pdf',
'demo/decharge-demo.pdf',
'application/pdf',
12_345,
str_repeat('0', 64),
new DateTimeImmutable(),
);
$manager->persist($document);
return $document;
}
/**
* Insere (idempotent, par SIRET) une ligne `qualimat_carrier` de demo a
* validite PASSEE (RG-4.04) puis retourne l'entite (lecture seule) rechargee.
* La table est normalement alimentee par `app:qualimat:sync` ; en demo on pose
* une ligne directe en DBAL (l'entite mappee n'expose aucune ecriture API).
*/
private function ensureQualimatDemoLine(ObjectManager $manager): QualimatCarrier
{
$repository = $manager->getRepository(QualimatCarrier::class);
$existing = $repository->findOneBy(['siret' => self::QUALIMAT_DEMO_SIRET]);
if ($existing instanceof QualimatCarrier) {
return $existing;
}
if ($manager instanceof EntityManagerInterface) {
$manager->getConnection()->insert('qualimat_carrier', [
'siret' => self::QUALIMAT_DEMO_SIRET,
'name' => 'TRANSPORTS GRELILLIER',
'address' => '12 rue des Acacias',
'postal_code' => '86000',
'city' => 'Poitiers',
'status' => 'Valide',
// Validite PASSEE : exerce le fond rouge RG-4.04 cote front.
'validity_date' => '2024-12-31',
'is_active' => 'true',
'last_synced_at' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
]);
}
// @var QualimatCarrier $line
return $repository->findOneBy(['siret' => self::QUALIMAT_DEMO_SIRET]);
}
}
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\Doctrine;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Repository\CarrierRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Carrier>
*/
class DoctrineCarrierRepository extends ServiceEntityRepository implements CarrierRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Carrier::class);
}
public function findById(int $id): ?Carrier
{
return $this->find($id);
}
public function save(Carrier $carrier): void
{
$this->getEntityManager()->persist($carrier);
$this->getEntityManager()->flush();
}
public function createListQueryBuilder(
bool $includeArchived = false,
?string $search = null,
array $certificationTypes = [],
bool $archivedOnly = false,
): QueryBuilder {
// Fetch-join de la SEULE relation ManyToOne qualimatCarrier (sur, pas de
// cartesien) pour exposer statut/date de validite QUALIMAT en liste sans
// N+1 (§ 2.11). Aucune sous-collection (addresses/contacts/prices) jointe
// en liste : elles ne sont embarquees qu'au detail (carrier:item:read).
$qb = $this->createQueryBuilder('c')
->leftJoin('c.qualimatCarrier', 'q')->addSelect('q')
->andWhere('c.deletedAt IS NULL')
->orderBy('c.name', 'ASC')
;
// Pas de cloisonnement par site (§ 2.3) : referentiel global.
// Perimetre d'archivage : archivedOnly prioritaire sur includeArchived
// (jumeau de DoctrineProviderRepository — toggle « Voir les archives »).
if ($archivedOnly) {
$qb->andWhere('c.isArchived = true');
} elseif (!$includeArchived) {
$qb->andWhere('c.isArchived = false');
}
$this->applySearch($qb, $search);
$this->applyCertificationTypes($qb, $certificationTypes);
return $qb;
}
/**
* Recherche fuzzy insensible a la casse sur le nom du transporteur (§ 4.1).
* Metacaracteres LIKE (%, _, \) echappes pour rester litteraux.
*/
private function applySearch(QueryBuilder $qb, ?string $search): void
{
if (null === $search || '' === trim($search)) {
return;
}
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
$qb->andWhere('LOWER(c.name) LIKE :search')
->setParameter('search', $pattern)
;
}
/**
* Restreint aux transporteurs dont la certification figure dans la liste (OR).
* Alimente le filtre « Certification » de la liste (§ 4.1).
*
* @param list<string> $certificationTypes
*/
private function applyCertificationTypes(QueryBuilder $qb, array $certificationTypes): void
{
$codes = [];
foreach ($certificationTypes as $code) {
if (is_string($code) && '' !== trim($code)) {
$codes[] = trim($code);
}
}
if ([] === $codes) {
return;
}
$qb->andWhere('c.certificationType IN (:certificationTypes)')
->setParameter('certificationTypes', $codes)
;
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\Doctrine;
use App\Module\Transport\Domain\Entity\QualimatCarrier;
use App\Module\Transport\Domain\Repository\QualimatCarrierRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<QualimatCarrier>
*/
class DoctrineQualimatCarrierRepository extends ServiceEntityRepository implements QualimatCarrierRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, QualimatCarrier::class);
}
public function createSearchQueryBuilder(?string $search = null): QueryBuilder
{
// Saisie assistee (§ 4.7) : on ne propose QUE des transporteurs QUALIMAT
// actifs (is_active = true), tries par nom. Le forcage de l'actif est une
// regle serveur (pas un filtre client) — les lignes soft-deletees par la
// synchro restent invisibles.
$qb = $this->createQueryBuilder('q')
->andWhere('q.isActive = true')
->orderBy('q.name', 'ASC')
;
$this->applySearch($qb, $search);
return $qb;
}
/**
* Recherche fuzzy insensible a la casse sur le nom (+ siret) du transporteur
* QUALIMAT (§ 4.7 / RG-4.01). Metacaracteres LIKE (%, _, \) echappes pour
* rester litteraux.
*/
private function applySearch(QueryBuilder $qb, ?string $search): void
{
if (null === $search || '' === trim($search)) {
return;
}
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
$qb->andWhere('LOWER(q.name) LIKE :search OR LOWER(q.siret) LIKE :search')
->setParameter('search', $pattern)
;
}
}
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\Doctrine\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-39 (Module Transport) : referentiel des transporteurs agrees QUALIMAT.
*
* Tables alimentees par la commande de synchronisation `app:qualimat:sync`
* (upsert sur le SIRET + soft-delete des absents + journal). Aucune FK
* cross-module (referentiel autonome) : migration au namespace modulaire
* Transport. Tables autonomes, sans dependance d'ordre vis-a-vis des autres
* migrations, donc insensible au tri cross-namespace de Doctrine Migrations.
*/
final class Version20260612150000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-39 : tables qualimat_carrier + qualimat_sync_log (referentiel transporteurs QUALIMAT, synchro console).';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE qualimat_carrier (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
siret VARCHAR(20) NOT NULL,
name VARCHAR(255) NOT NULL,
address VARCHAR(255) DEFAULT NULL,
postal_code VARCHAR(10) DEFAULT NULL,
city VARCHAR(255) DEFAULT NULL,
phone VARCHAR(32) DEFAULT NULL,
department VARCHAR(64) DEFAULT NULL,
status VARCHAR(32) NOT NULL,
validity_date DATE DEFAULT NULL,
is_active BOOLEAN DEFAULT TRUE NOT NULL,
last_synced_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY (id),
CONSTRAINT uq_qualimat_carrier_siret UNIQUE (siret)
)
SQL);
$this->addSql('CREATE INDEX idx_qualimat_carrier_active ON qualimat_carrier (is_active)');
$this->comment('qualimat_carrier', '_table', "Referentiel des transporteurs agrees QUALIMAT, synchronise quotidiennement depuis l'API qualimat.org (type=operateur_transport).");
$this->comment('qualimat_carrier', 'id', 'Cle technique auto-incrementee.');
$this->comment('qualimat_carrier', 'siret', 'SIRET normalise (chiffres sans espaces). Cle naturelle de synchro (unique). Source parfois incomplete (longueur variable), non contrainte a 14.');
$this->comment('qualimat_carrier', 'name', 'Raison sociale du transporteur (champs Nom = Societe de la source, identiques).');
$this->comment('qualimat_carrier', 'address', 'Adresse postale (voie). Nullable.');
$this->comment('qualimat_carrier', 'postal_code', 'Code postal. Nullable.');
$this->comment('qualimat_carrier', 'city', 'Ville. Nullable.');
$this->comment('qualimat_carrier', 'phone', 'Telephone au format source "indicatif|numero" (ex: +33|0608890316). Nullable.');
$this->comment('qualimat_carrier', 'department', 'Departement au format source "code - libelle" (ex: 65 - Hautes-Pyrenees). Nullable.');
$this->comment('qualimat_carrier', 'status', "Statut d'agrement QUALIMAT (valeurs connues : Audite, Valide, Suspendu). Valeur brute de la source, non contrainte.");
$this->comment('qualimat_carrier', 'validity_date', 'Date de fin de validite de la certification (convertie depuis dd/mm/yyyy). Nullable.');
$this->comment('qualimat_carrier', 'is_active', 'Faux = transporteur absent du dernier import (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.');
$this->comment('qualimat_carrier', '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 qualimat_sync_log (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
fetched_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
rows_total INT NOT NULL,
rows_upserted INT NOT NULL,
rows_skipped 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('qualimat_sync_log', '_table', 'Journal des synchronisations QUALIMAT (une ligne par run de la commande app:qualimat:sync).');
$this->comment('qualimat_sync_log', 'id', 'Cle technique auto-incrementee.');
$this->comment('qualimat_sync_log', 'fetched_at', "Horodatage de l'appel a l'API source (= run de synchro).");
$this->comment('qualimat_sync_log', 'rows_total', "Nombre d'items renvoyes par l'API.");
$this->comment('qualimat_sync_log', 'rows_upserted', 'Nombre de transporteurs inseres ou mis a jour.');
$this->comment('qualimat_sync_log', 'rows_skipped', "Nombre d'items ignores (sans SIRET exploitable).");
$this->comment('qualimat_sync_log', 'rows_deactivated', 'Nombre de transporteurs passes a is_active=false (absents de cet import).');
$this->comment('qualimat_sync_log', 'created_at', 'Horodatage de fin du run (insertion du journal).');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS qualimat_sync_log');
$this->addSql('DROP TABLE IF EXISTS qualimat_carrier');
}
/**
* 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