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:
@@ -0,0 +1,137 @@
|
||||
<?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) ;
|
||||
* - 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);
|
||||
$search = $filters['search'] ?? null;
|
||||
$certificationTypes = $this->readStringList($filters['certificationType'] ?? []);
|
||||
|
||||
$qb = $this->repository->createListQueryBuilder(
|
||||
$includeArchived,
|
||||
is_string($search) ? $search : null,
|
||||
$certificationTypes,
|
||||
);
|
||||
|
||||
// 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,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Entity\CarrierContact;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
/**
|
||||
* Fixtures dev/test MINIMALES du repertoire transporteurs (M4, ERP-155/157) :
|
||||
* 2 transporteurs de demonstration suffisant a faire tourner les ecrans de
|
||||
* lecture (liste + detail). Les fixtures completes (cas QUALIMAT, affrete,
|
||||
* LIOT, prix CLIENT/FOURNISSEUR...) sont livrees par le worktree dedie (WT10) —
|
||||
* ne pas les developper ici (scope WT3 : contrat de lecture).
|
||||
*
|
||||
* Aucune dependance cross-module (pas de prix, pas de lien QUALIMAT) : la
|
||||
* fixture reste autonome et joue en fin de chaine sans contrainte d'ordre.
|
||||
*/
|
||||
final class CarrierFixtures extends Fixture
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// Transporteur certifie « classique ».
|
||||
$alpha = new Carrier();
|
||||
$alpha->setName('TRANSPORTS ALPHA');
|
||||
$alpha->setCertificationType('GMP_PLUS');
|
||||
$manager->persist($alpha);
|
||||
|
||||
$contact = new CarrierContact();
|
||||
$contact->setCarrier($alpha);
|
||||
$contact->setLastName('Durand');
|
||||
$contact->setPhonePrimary('0612345678');
|
||||
$alpha->addContact($contact);
|
||||
$manager->persist($contact);
|
||||
|
||||
// Transporteur affrete (RG-4.03).
|
||||
$beta = new Carrier();
|
||||
$beta->setName('TRANSPORTS BETA');
|
||||
$beta->setCertificationType('AUTRE');
|
||||
$beta->setIsChartered(true);
|
||||
$beta->setIndexationRate('5.00');
|
||||
$beta->setContainerType('BENNE');
|
||||
$beta->setVolumeM3('90.00');
|
||||
$manager->persist($beta);
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?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 = [],
|
||||
): 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.
|
||||
if (!$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)
|
||||
;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user