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,174 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
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?...` pour la saisie
* assistee du nom (§ 4.7) — filtres built-in name/siret (partiel), isActive.
*
* 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: [
new GetCollection(
security: "is_granted('transport.carriers.view')",
normalizationContext: ['groups' => ['qualimat:read', 'default:read']],
),
new Get(
security: "is_granted('transport.carriers.view')",
normalizationContext: ['groups' => ['qualimat:read', 'default:read']],
),
],
)]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'siret' => 'partial'])]
#[ApiFilter(BooleanFilter::class, properties: ['isActive'])]
#[ApiFilter(OrderFilter::class, properties: ['name'], arguments: ['orderParameterName' => 'order'])]
#[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;
}
}