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.
This commit is contained in:
Matthieu
2026-06-15 19:15:12 +02:00
parent 2be9cd05d4
commit dc75945f3e
39 changed files with 4696 additions and 16 deletions
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat minimal d'une adresse de Client (M1 Commercial) exposable a un autre
* module sans couplage direct (regle ABSOLUE n°1). Mappe vers
* App\Module\Commercial\Domain\Entity\ClientAddress via `resolve_target_entities`.
*
* Implemente par App\Module\Commercial\Domain\Entity\ClientAddress. Utilise
* comme type-hint des relations ORM cross-module (ex: CarrierPrice.clientDeliveryAddress,
* M4). La serialisation passe par le read-group de l'entite concrete
* (client_address:read), pas par cette interface.
*/
interface ClientAddressInterface
{
public function getId(): ?int;
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat minimal exposant ce qu'un autre module doit connaitre d'un Client
* (M1 Commercial) sans creer de couplage direct vers le module Commercial
* (regle ABSOLUE n°1). Mappe vers App\Module\Commercial\Domain\Entity\Client
* via `resolve_target_entities` (doctrine.yaml).
*
* Implemente par App\Module\Commercial\Domain\Entity\Client. Utilise comme
* type-hint dans les relations ORM cross-module (ex: CarrierPrice.client, M4).
* La serialisation passe par les read-groups de l'entite concrete (client:read),
* pas par cette interface.
*/
interface ClientInterface
{
public function getId(): ?int;
public function getCompanyName(): ?string;
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat minimal d'une adresse de Supplier (M2 Commercial) exposable a un autre
* module sans couplage direct (regle ABSOLUE n°1). Mappe vers
* App\Module\Commercial\Domain\Entity\SupplierAddress via `resolve_target_entities`.
*
* Implemente par App\Module\Commercial\Domain\Entity\SupplierAddress. Utilise
* comme type-hint des relations ORM cross-module (ex: CarrierPrice.supplierSupplyAddress,
* M4). La serialisation passe par le read-group de l'entite concrete
* (supplier_address:read), pas par cette interface.
*/
interface SupplierAddressInterface
{
public function getId(): ?int;
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat minimal exposant ce qu'un autre module doit connaitre d'un Supplier
* (M2 Commercial) sans creer de couplage direct vers le module Commercial
* (regle ABSOLUE n°1). Mappe vers App\Module\Commercial\Domain\Entity\Supplier
* via `resolve_target_entities` (doctrine.yaml).
*
* Implemente par App\Module\Commercial\Domain\Entity\Supplier. Utilise comme
* type-hint dans les relations ORM cross-module (ex: CarrierPrice.supplier, M4).
* La serialisation passe par les read-groups de l'entite concrete (supplier:read),
* pas par cette interface.
*/
interface SupplierInterface
{
public function getId(): ?int;
public function getCompanyName(): ?string;
}
@@ -458,6 +458,86 @@ final class ColumnCommentsCatalog
'iban' => 'IBAN du compte (≤ 34 caracteres).',
'position' => 'Ordre d affichage du RIB dans la liste du prestataire (croissant).',
] + self::timestampableBlamableComments(),
// === M4 Transport — referentiel QUALIMAT (ERP-39, mappe lecture seule des ERP-155) ===
// Mappe par l'entite QualimatCarrier depuis M4 -> retire du schema_filter,
// donc ses COMMENT sont rejoues par app:apply-column-comments apres schema:update.
'qualimat_carrier' => [
'_table' => "Referentiel des transporteurs agrees QUALIMAT, synchronise quotidiennement depuis l'API qualimat.org (type=operateur_transport).",
'id' => 'Cle technique auto-incrementee.',
'siret' => 'SIRET normalise (chiffres sans espaces). Cle naturelle de synchro (unique). Source parfois incomplete (longueur variable), non contrainte a 14.',
'name' => 'Raison sociale du transporteur (champs Nom = Societe de la source, identiques).',
'address' => 'Adresse postale (voie). Nullable.',
'postal_code' => 'Code postal. Nullable.',
'city' => 'Ville. Nullable.',
'phone' => 'Telephone au format source "indicatif|numero" (ex: +33|0608890316). Nullable.',
'department' => 'Departement au format source "code - libelle" (ex: 65 - Hautes-Pyrenees). Nullable.',
'status' => "Statut d'agrement QUALIMAT (valeurs connues : Audite, Valide, Suspendu). Valeur brute de la source, non contrainte.",
'validity_date' => 'Date de fin de validite de la certification (convertie depuis dd/mm/yyyy). Nullable.',
'is_active' => 'Faux = transporteur absent du dernier import (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.',
'last_synced_at' => 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).',
],
// === M4 Transport — repertoire transporteurs (ERP-155/157) ===
'carrier' => [
'_table' => 'Repertoire transporteurs (M4 Transport) — entites editables, archivables (is_archived) et soft-deletables (deleted_at). Distinct du referentiel qualimat_carrier.',
'id' => 'Identifiant interne auto-incremente.',
'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.',
'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).',
'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).',
'is_chartered' => '« Affreter » coche : declenche indexation/benne-fond mouvant/volume, obligatoires (RG-4.03). Faux par defaut.',
'indexation_rate' => 'Taux d indexation en pourcentage (NUMERIC 5,2) — renseigne si affrete (RG-4.03).',
'container_type' => 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_container_type) — renseigne si affrete (RG-4.03).',
'volume_m3' => 'Volume en m3 (NUMERIC 10,2) — renseigne si affrete (RG-4.03).',
'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.',
'liot_plates' => 'Immatriculations LIOT separees par « ; » (cas special nom=LIOT, RG-4.01). Les autres champs sont masques dans ce cas.',
'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission transport.carriers.archive (Admin seul).',
'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.',
'deleted_at' => 'Horodatage du soft-delete technique — non expose par l API au M4. Null = ligne active.',
] + self::timestampableBlamableComments(),
'carrier_address' => [
'_table' => 'Adresses d un transporteur (1:n) — onglet Adresse (M4). Pre-remplie depuis QUALIMAT si applicable (RG-4.05).',
'id' => 'Identifiant interne auto-incremente.',
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire de l adresse.',
'country' => 'Pays de l adresse — defaut France.',
'postal_code' => 'Code postal (saisie assistee BAN cote front, RG-4.06).',
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front.',
'street' => 'Numero et voie de l adresse.',
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
'position' => 'Ordre d affichage de l adresse dans la liste du transporteur (croissant).',
] + self::timestampableBlamableComments(),
'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.',
'id' => 'Identifiant interne auto-incremente.',
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du contact.',
'first_name' => 'Prenom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).',
'last_name' => 'Nom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).',
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
'phone_primary' => 'Telephone principal — chiffres uniquement (normalisation serveur).',
'phone_secondary' => 'Telephone secondaire — chiffres uniquement (max 2 telephones, RG-4.08).',
'email' => 'Email du contact (lowercase serveur).',
'position' => 'Ordre d affichage du contact dans la liste du transporteur (croissant).',
] + self::timestampableBlamableComments(),
'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_*).',
'id' => 'Identifiant interne auto-incremente.',
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du prix.',
'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).',
'client_id' => 'Branche CLIENT (RG-4.10) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi direction = CLIENT.',
'client_delivery_address_id' => 'Branche CLIENT : adresse de livraison du client. FK -> client_address.id, ON DELETE RESTRICT.',
'departure_site_id' => 'Branche CLIENT : adresse de depart = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.',
'supplier_id' => 'Branche FOURNISSEUR (RG-4.11) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi direction = FOURNISSEUR.',
'supplier_supply_address_id' => 'Branche FOURNISSEUR : adresse d approvisionnement du fournisseur. FK -> supplier_address.id, ON DELETE RESTRICT.',
'delivery_site_id' => 'Branche FOURNISSEUR : adresse de livraison = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.',
'container_type' => 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_price_container).',
'pricing_unit' => 'Unite de tarification FORFAIT|TONNE (chk_carrier_price_unit).',
'price' => 'Montant du prix (NUMERIC 12,2).',
'price_state' => 'Etat du prix : EN_COURS, VALIDE ou NON_VALIDE (chk_carrier_price_state). Affiche dans le tableau Prix.',
'position' => 'Ordre d affichage du prix dans la liste du transporteur (croissant).',
] + self::timestampableBlamableComments(),
];
}