feat(transport) : sous-ressource contacts transporteur (ERP-160) #116

Merged
matthieu merged 4 commits from feat/erp-160-carrier-contacts into feat/erp-159-carrier-addresses 2026-06-16 13:49:05 +00:00
13 changed files with 1738 additions and 23 deletions
@@ -4,21 +4,80 @@ 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.
*
* WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read`
* (embarquees au detail). Les sous-ressources d'ecriture arrivent au WT7.
* 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'])]
@@ -39,18 +98,27 @@ class CarrierContact implements TimestampableInterface, BlamableInterface
#[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)]
#[Groups(['carrier:item:read'])]
#[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)]
#[Groups(['carrier:item:read'])]
#[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)]
#[Groups(['carrier:item:read'])]
#[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;
@@ -60,9 +128,22 @@ class CarrierContact implements TimestampableInterface, BlamableInterface
private ?string $phoneSecondary = null;
#[ORM\Column(length: 180, nullable: true)]
#[Groups(['carrier:item:read'])]
#[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'])]
Outdated
Review

🟢 Bon design pour la liste dynamique de téléphones (« x1, +1 possible, max 2 ») : un champ virtuel phones en write-only (groupe carrier:write:contacts, non persisté) que le processor mappe vers phonePrimary/phoneSecondary, les colonnes scalaires restant en lecture seule. Ça évite d'exposer 2 champs distincts en écriture et centralise le « max 2 » + normalisation côté serveur. Asymétrie lecture (2 scalaires) / écriture (1 tableau) assumée et documentée — le front la gère. RAS.

🟢 Bon design pour la liste dynamique de téléphones (« x1, +1 possible, max 2 ») : un champ virtuel `phones` en write-only (groupe `carrier:write:contacts`, non persisté) que le processor mappe vers `phonePrimary`/`phoneSecondary`, les colonnes scalaires restant en lecture seule. Ça évite d'exposer 2 champs distincts en écriture et centralise le « max 2 » + normalisation côté serveur. Asymétrie lecture (2 scalaires) / écriture (1 tableau) assumée et documentée — le front la gère. RAS.
private ?array $phones = null;
#[ORM\Column(options: ['default' => 0])]
private int $position = 0;
@@ -155,6 +236,24 @@ class CarrierContact implements TimestampableInterface, BlamableInterface
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;
@@ -4,6 +4,13 @@ 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;
@@ -15,6 +22,7 @@ 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
@@ -30,9 +38,73 @@ use Symfony\Component\Serializer\Attribute\Groups;
* (client:read / client_address:read / supplier:read / supplier_address:read /
* site:read), inclus dans le contexte du Get racine de Carrier (§ 4.0).
*
* WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read`. Les
* sous-ressources d'ecriture + validation des branches (Processor) : WT8.
* 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'])]
@@ -61,61 +133,74 @@ class CarrierPrice implements TimestampableInterface, BlamableInterface
/** CLIENT|FOURNISSEUR (RG-4.09) — pilote la branche active. */
#[ORM\Column(length: 12)]
#[Groups(['carrier:item:read'])]
#[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'])]
#[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'])]
#[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'])]
#[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'])]
#[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'])]
#[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'])]
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
private ?SiteInterface $deliverySite = null;
// === Commun ===
// === Commun (toujours obligatoires, RG-4.10/4.11) ===
/** BENNE|FOND_MOUVANT. */
#[ORM\Column(name: 'container_type', length: 12)]
#[Groups(['carrier:item:read'])]
#[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)]
#[Groups(['carrier:item:read'])]
#[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)]
#[Groups(['carrier:item:read'])]
#[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)]
#[Groups(['carrier:item:read'])]
#[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])]
@@ -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) {
Outdated
Review

🟡 Trou de test (mineur, identique à #115). Le 404 « Transporteur introuvable » (conséquence du read:false) n'est exercé par aucun test — un POST sur /api/carriers/999999/contacts devrait renvoyer 404. À ajouter pour figer le comportement.

🟡 Trou de test (mineur, identique à #115). Le 404 « Transporteur introuvable » (conséquence du `read:false`) n'est exercé par aucun test — un POST sur `/api/carriers/999999/contacts` devrait renvoyer 404. À ajouter pour figer le comportement.
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
Outdated
Review

🟢 RG-4.08 correcte : validateAtLeastOneField joue après normalisation + mapping téléphones (les chaînes vides sont déjà ramenées à null), 422 rattachée à firstName (mappable inline). Le périmètre exclut phoneSecondary à juste titre — applyPhones remplit toujours phonePrimary avant phoneSecondary (phones[0] puis phones[1]), donc un contact ne peut jamais avoir un secondaire sans primaire. Raisonnement sain, aligné sur le CHECK BDD.

🟢 RG-4.08 correcte : `validateAtLeastOneField` joue **après** normalisation + mapping téléphones (les chaînes vides sont déjà ramenées à null), 422 rattachée à `firstName` (mappable inline). Le périmètre exclut `phoneSecondary` à juste titre — `applyPhones` remplit toujours `phonePrimary` avant `phoneSecondary` (phones[0] puis phones[1]), donc un contact ne peut jamais avoir un secondaire sans primaire. Raisonnement sain, aligné sur le CHECK BDD.
{
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,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;
}
}
@@ -17,4 +17,12 @@ namespace App\Shared\Domain\Contract;
interface ClientAddressInterface
{
public function getId(): ?int;
/**
* Client parent de l'adresse. Expose le lien inverse sans coupler au module
* Commercial : permet a un autre module de verifier l'appartenance d'une
* adresse a un client (ex: CarrierPrice, RG-4.10 — l'adresse de livraison
* doit appartenir au client choisi). Retour covariant ?Client cote entite.
*/
public function getClient(): ?ClientInterface;
}
@@ -17,4 +17,12 @@ namespace App\Shared\Domain\Contract;
interface SupplierAddressInterface
{
public function getId(): ?int;
/**
* Fournisseur parent de l'adresse. Expose le lien inverse sans coupler au
* module Commercial : permet a un autre module de verifier l'appartenance
* d'une adresse a un fournisseur (ex: CarrierPrice, RG-4.11 — l'adresse
* d'appro doit appartenir au fournisseur choisi). Retour covariant ?Supplier.
*/
public function getSupplier(): ?SupplierInterface;
}
@@ -64,6 +64,11 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
'Carrier::certificationType' => 'Choice des 5 certifications borne deja les valeurs.',
// Le Choice {BENNE,FOND_MOUVANT} borne les valeurs (<= 12).
'Carrier::containerType' => 'Choice {BENNE,FOND_MOUVANT} borne deja les valeurs.',
// Colonnes enum du prix transporteur (M4) : le Choice borne deja les valeurs.
'CarrierPrice::direction' => 'Choice {CLIENT,FOURNISSEUR} borne deja les valeurs.',
'CarrierPrice::containerType' => 'Choice {BENNE,FOND_MOUVANT} borne deja les valeurs.',
'CarrierPrice::pricingUnit' => 'Choice {FORFAIT,TONNE} borne deja les valeurs.',
'CarrierPrice::priceState' => 'Choice {EN_COURS,VALIDE,NON_VALIDE} borne deja les valeurs.',
// Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres.
'Site::color' => 'Regex code hex #RRGGBB borne deja la longueur.',
];
@@ -109,7 +114,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
}
/** @var Constraint $constraint */
$constraint = $attribute->newInstance();
$constraint = $attribute->newInstance();
$messageProps = $this->messagePropertiesFor($constraint);
self::assertNotNull(
@@ -180,6 +185,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
foreach ($constraints as $c) {
if ($c instanceof Assert\Length) {
$length = $c->max;
break;
}
}
@@ -251,7 +257,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
* Liste des proprietes de message a verifier pour une contrainte donnee, ou
* null si la contrainte n'est pas geree (le test echoue alors explicitement).
*
* @return list<string>|null
* @return null|list<string>
*/
private function messagePropertiesFor(Constraint $constraint): ?array
{
@@ -325,7 +331,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
}
/**
* @param list<Constraint> $constraints
* @param list<Constraint> $constraints
* @param list<class-string<Constraint>> $classes
*/
private function hasAnyConstraint(array $constraints, array $classes): bool
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Entity\CarrierContact;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Sous-ressource Contact d'un transporteur (spec-back M4 § 4.5, ERP-160).
* POST /api/carriers/{id}/contacts, PATCH/DELETE /api/carrier_contacts/{id}.
*
* Contrat verifie :
* - RG-4.08 : contact totalement vide -> 422 (au moins 1 champ requis) ;
* - RG-4.08 : 1 seul champ rempli -> 201 ;
* - RG-4.08 : 3 telephones (tableau `phones`) -> 422 (max 2) ;
* - mapping `phones[]` -> phonePrimary / phoneSecondary + normalisation (RG-4.13) ;
* - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul).
*
* @internal
*/
final class CarrierContactApiTest extends AbstractCarrierApiTestCase
{
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
protected function setUp(): void
{
parent::setUp();
// Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin
// qu'en recette), requis pour les tests de permission (bureau/commerciale).
self::bootKernel();
$application = new Application(self::$kernel);
$application->setAutoExit(false);
$exit = $application->run(
new ArrayInput([
'command' => 'app:seed-rbac',
'--with-demo-users' => true,
'--password' => self::PWD,
]),
new NullOutput(),
);
self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).');
self::ensureKernelShutdown();
}
public function testEmptyContactReturns422(): void
Outdated
Review

🟡 Rigueur d'assertion + 1 trou (mineurs, cohérence avec #113/#115) :

  • les tests 422 (testEmptyContactReturns422, testThirdPhoneReturns422) n'assertent que le code HTTP, pas le propertyPath — or RG-4.08 cible firstName et le max-2 cible phones ; sans l'assertion du path, une régression de mapping passerait au vert (le test phones mapping, lui, assert bien les valeurs — 👍) ;
  • pas de test du format email invalide → 422 (la contrainte Assert\Email est nouvelle sur ce champ).

Le reste du contrat est bien couvert, avec assertions de valeurs sur la normalisation (RG-4.13).

🟡 Rigueur d'assertion + 1 trou (mineurs, cohérence avec #113/#115) : - les tests 422 (`testEmptyContactReturns422`, `testThirdPhoneReturns422`) n'assertent que le **code HTTP**, pas le `propertyPath` — or RG-4.08 cible `firstName` et le max-2 cible `phones` ; sans l'assertion du path, une régression de mapping passerait au vert (le test `phones` mapping, lui, assert bien les valeurs — 👍) ; - pas de test du format email invalide → 422 (la contrainte `Assert\Email` est nouvelle sur ce champ). Le reste du contrat est bien couvert, avec assertions de valeurs sur la normalisation (RG-4.13).
{
// RG-4.08 : aucun champ rempli -> 422 (garde Processor, double du CHECK BDD).
$carrier = $this->seedCarrier('Contact Vide');
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => [],
]);
self::assertResponseStatusCodeSame(422);
}
public function testSingleFieldContactIsCreated(): void
{
// RG-4.08 : un seul champ suffit a valider le bloc.
$carrier = $this->seedCarrier('Contact Mono');
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => ['lastName' => 'martin'],
]);
self::assertResponseStatusCodeSame(201);
// RG-4.13 : nom capitalise serveur.
self::assertJsonContains(['lastName' => 'Martin']);
}
public function testThirdPhoneReturns422(): void
{
// RG-4.08 : max 2 telephones. Le contrat d'ecriture accepte un tableau
// `phones` (liste dynamique cote front « x1, +1 possible, max 2 ») ; un 3e
// numero -> 422 rattachee au champ `phones`.
$carrier = $this->seedCarrier('Contact Trois Tel');
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'firstName' => 'Jean',
'phones' => ['0611111111', '0622222222', '0633333333'],
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testPhonesAreMappedAndNormalized(): void
{
// Mapping `phones[0]` -> phonePrimary, `phones[1]` -> phoneSecondary +
// normalisation RG-4.13 (chiffres uniquement).
$carrier = $this->seedCarrier('Contact Deux Tel');
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'lastName' => 'Dupont',
'phones' => ['06.11.11.11.11', '06 22 22 22 22'],
],
]);
self::assertResponseStatusCodeSame(201);
self::assertJsonContains([
'phonePrimary' => '0611111111',
'phoneSecondary' => '0622222222',
]);
}
public function testPatchAndDeleteSucceedWithManage(): void
{
$contact = $this->seedContact('Patch Delete');
$client = $this->authenticatedClient('bureau', self::PWD); // manage (matrice § 5.2)
// PATCH (manage) -> 200
$client->request('PATCH', '/api/carrier_contacts/'.$contact->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['jobTitle' => 'Directeur'],
]);
self::assertResponseStatusCodeSame(200);
// DELETE (manage) -> 204
$client->request('DELETE', '/api/carrier_contacts/'.$contact->getId());
self::assertResponseStatusCodeSame(204);
}
public function testWriteForbiddenWithoutManage(): void
{
$contact = $this->seedContact('Forbidden');
$carrier = $contact->getCarrier();
self::assertNotNull($carrier);
$client = $this->authenticatedClient('commerciale', self::PWD); // view seul
$client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => ['lastName' => 'Bernard'],
]);
self::assertResponseStatusCodeSame(403);
$client->request('PATCH', '/api/carrier_contacts/'.$contact->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['jobTitle' => 'Chef'],
]);
self::assertResponseStatusCodeSame(403);
$client->request('DELETE', '/api/carrier_contacts/'.$contact->getId());
self::assertResponseStatusCodeSame(403);
}
/**
* Seede un transporteur + un contact rattache (pour les tests PATCH/DELETE).
*/
private function seedContact(string $name): CarrierContact
{
$em = $this->getEm();
$carrier = $this->seedCarrier($name);
$contact = new CarrierContact();
$contact->setCarrier($carrier);
$contact->setLastName('Martin');
$contact->setPhonePrimary('0612345678');
$carrier->addContact($contact);
$em->persist($contact);
$em->flush();
return $contact;
}
}
@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* Tests fonctionnels de l'export XLSX du repertoire transporteurs (M4, § 4.6).
* Jumeau du {@see \App\Tests\Module\Commercial\Api\SupplierExportControllerTest}.
*
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes), exclusion
* des archives par defaut, respect du filtre ?search, peuplement des colonnes
* QUALIMAT (statut + date de validite, RG-4.04), 403 sans transport.carriers.view,
* 401 anonyme.
*
* @internal
*/
final class CarrierExportControllerTest extends AbstractCarrierApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/carriers/export.xlsx';
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
{
$client = $this->createAdminClient();
$this->seedCarrier('Export Alpha');
$response = $client->request('GET', self::EXPORT_URL);
self::assertResponseIsSuccessful();
$headers = $response->getHeaders(false);
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
$disposition = $headers['content-disposition'][0] ?? '';
self::assertStringContainsString('attachment; filename="repertoire-transporteurs-', $disposition);
self::assertMatchesRegularExpression(
'/filename="repertoire-transporteurs-\d{8}\.xlsx"/',
$disposition,
);
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
$headers = $this->gridFromResponse($response->getContent())[0];
self::assertSame('Nom', $headers[0]);
self::assertContains('Certification', $headers);
self::assertContains('Statut QUALIMAT', $headers);
self::assertContains('Date de validité', $headers);
self::assertContains('Affrété', $headers);
self::assertContains('Volume m³', $headers);
self::assertContains('Date de création', $headers);
}
public function testExportExcludesArchivedByDefault(): void
{
$client = $this->createAdminClient();
$this->seedCarrier('Active One');
$this->seedCarrier('Archived One', true);
$names = $this->carrierNames($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('ACTIVE ONE', $names);
self::assertNotContains('ARCHIVED ONE', $names);
}
public function testExportRespectsSearchFilter(): void
{
$client = $this->createAdminClient();
$this->seedCarrier('Searchable Alpha');
$this->seedCarrier('Other Beta');
$names = $this->carrierNames(
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
);
self::assertContains('SEARCHABLE ALPHA', $names);
self::assertNotContains('OTHER BETA', $names);
}
/**
* Colonnes « Statut QUALIMAT » et « Date de validite » : alimentees par le
* referentiel QUALIMAT lie (RG-4.04). Un transporteur complet seede un lien
* QUALIMAT (statut « Valide », validite 31/12/2027).
*/
public function testExportPopulatesQualimatColumns(): void
{
$client = $this->createAdminClient();
$this->seedCompleteCarrier('Grelillier');
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
self::assertStringContainsString('QUALIMAT', $flat);
self::assertStringContainsString('Valide', $flat);
self::assertStringContainsString('31/12/2027', $flat);
}
public function testForbiddenWithoutCarriersViewPermission(): void
{
$creds = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(403);
}
public function testUnauthorizedWhenAnonymous(): void
{
$client = self::createClient();
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(401);
}
/**
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
*
* @return array<int, array<int, mixed>>
*/
private function gridFromResponse(string $binary): array
{
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_carrier_export_test_');
self::assertIsString($tmp);
file_put_contents($tmp, $binary);
try {
return IOFactory::load($tmp)->getActiveSheet()->toArray();
} finally {
@unlink($tmp);
}
}
/**
* Extrait la colonne « Nom » (1re colonne) des lignes de donnees.
*
* @return list<string>
*/
private function carrierNames(string $binary): array
{
$rows = array_slice($this->gridFromResponse($binary), 1); // saute l'en-tete
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
}
/**
* Aplatit toute la grille en une chaine, pour les assertions de presence.
*
* @param array<int, array<int, mixed>> $grid
*/
private function flatten(array $grid): string
{
return implode('|', array_map(
static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)),
$grid,
));
}
}
@@ -0,0 +1,276 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
use App\Module\Commercial\Domain\Entity\ClientAddress;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Transport\Domain\Entity\CarrierPrice;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Sous-ressource Prix d'un transporteur (spec-back M4 § 4.5, ERP-161).
* POST /api/carriers/{id}/prices, PATCH/DELETE /api/carrier_prices/{id}.
*
* Contrat verifie (RG-4.09→4.11) :
* - branche CLIENT incomplete -> 422 ;
* - branche FOURNISSEUR incomplete -> 422 ;
* - adresse de livraison etrangere au client -> 422 ;
* - adresse d'appro etrangere au fournisseur -> 422 ;
* - prix CLIENT / FOURNISSEUR complets -> 201 ;
* - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul).
*
* @internal
*/
final class CarrierPriceApiTest extends AbstractCarrierApiTestCase
{
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
protected function setUp(): void
{
parent::setUp();
// Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin
// qu'en recette), requis pour les tests de permission (bureau/commerciale).
self::bootKernel();
$application = new Application(self::$kernel);
$application->setAutoExit(false);
$exit = $application->run(
new ArrayInput([
'command' => 'app:seed-rbac',
'--with-demo-users' => true,
'--password' => self::PWD,
]),
new NullOutput(),
);
self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).');
self::ensureKernelShutdown();
}
public function testIncompleteClientBranchReturns422(): void
{
// RG-4.10 : direction CLIENT sans client / adresse / site de depart -> 422.
$carrier = $this->seedCarrier('Prix Client Incomplet');
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'direction' => 'CLIENT',
'containerType' => 'BENNE',
'pricingUnit' => 'TONNE',
'price' => '42.50',
'priceState' => 'VALIDE',
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testIncompleteSupplierBranchReturns422(): void
{
// RG-4.11 : direction FOURNISSEUR sans fournisseur / adresse / site -> 422.
$carrier = $this->seedCarrier('Prix Fournisseur Incomplet');
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'direction' => 'FOURNISSEUR',
'containerType' => 'FOND_MOUVANT',
'pricingUnit' => 'FORFAIT',
'price' => '320.00',
'priceState' => 'EN_COURS',
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testForeignClientAddressReturns422(): void
{
// RG-4.10 : l'adresse de livraison doit appartenir au client choisi.
$carrier = $this->seedCarrier('Prix Adresse Etrangere Client');
$addrA = $this->seedClientWithAddress('Client A');
$addrB = $this->seedClientWithAddress('Client B');
$this->getEm()->flush();
$siteId = $this->aSiteId();
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'direction' => 'CLIENT',
'client' => '/api/clients/'.$addrA->getClient()?->getId(),
'clientDeliveryAddress' => '/api/client_addresses/'.$addrB->getId(), // adresse du client B
'departureSite' => '/api/sites/'.$siteId,
'containerType' => 'BENNE',
'pricingUnit' => 'TONNE',
'price' => '42.50',
'priceState' => 'VALIDE',
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testForeignSupplierAddressReturns422(): void
{
// RG-4.11 : l'adresse d'appro doit appartenir au fournisseur choisi.
$carrier = $this->seedCarrier('Prix Adresse Etrangere Fournisseur');
$addrA = $this->seedSupplierWithAddress('Fournisseur A');
$addrB = $this->seedSupplierWithAddress('Fournisseur B');
$this->getEm()->flush();
$siteId = $this->aSiteId();
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'direction' => 'FOURNISSEUR',
'supplier' => '/api/suppliers/'.$addrA->getSupplier()?->getId(),
'supplierSupplyAddress' => '/api/supplier_addresses/'.$addrB->getId(), // adresse du fournisseur B
'deliverySite' => '/api/sites/'.$siteId,
'containerType' => 'FOND_MOUVANT',
'pricingUnit' => 'FORFAIT',
'price' => '320.00',
'priceState' => 'EN_COURS',
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testValidClientPriceIsCreated(): void
{
$carrier = $this->seedCarrier('Prix Client Valide');
$addr = $this->seedClientWithAddress('Client OK');
$this->getEm()->flush();
$siteId = $this->aSiteId();
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'direction' => 'CLIENT',
'client' => '/api/clients/'.$addr->getClient()?->getId(),
'clientDeliveryAddress' => '/api/client_addresses/'.$addr->getId(),
'departureSite' => '/api/sites/'.$siteId,
'containerType' => 'BENNE',
'pricingUnit' => 'TONNE',
'price' => '42.50',
'priceState' => 'VALIDE',
],
]);
self::assertResponseStatusCodeSame(201);
self::assertJsonContains(['direction' => 'CLIENT', 'priceState' => 'VALIDE']);
}
public function testValidSupplierPriceIsCreated(): void
{
$carrier = $this->seedCarrier('Prix Fournisseur Valide');
$addr = $this->seedSupplierWithAddress('Fournisseur OK');
$this->getEm()->flush();
$siteId = $this->aSiteId();
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'direction' => 'FOURNISSEUR',
'supplier' => '/api/suppliers/'.$addr->getSupplier()?->getId(),
'supplierSupplyAddress' => '/api/supplier_addresses/'.$addr->getId(),
'deliverySite' => '/api/sites/'.$siteId,
'containerType' => 'FOND_MOUVANT',
'pricingUnit' => 'FORFAIT',
'price' => '320.00',
'priceState' => 'EN_COURS',
],
]);
self::assertResponseStatusCodeSame(201);
self::assertJsonContains(['direction' => 'FOURNISSEUR', 'priceState' => 'EN_COURS']);
}
public function testPatchAndDeleteSucceedWithManage(): void
{
$price = $this->seedClientPrice('Patch Delete');
$client = $this->authenticatedClient('bureau', self::PWD); // manage (matrice § 5.2)
// PATCH (manage) -> 200
$client->request('PATCH', '/api/carrier_prices/'.$price->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['priceState' => 'NON_VALIDE'],
]);
self::assertResponseStatusCodeSame(200);
self::assertJsonContains(['priceState' => 'NON_VALIDE']);
// DELETE (manage) -> 204
$client->request('DELETE', '/api/carrier_prices/'.$price->getId());
self::assertResponseStatusCodeSame(204);
}
public function testWriteForbiddenWithoutManage(): void
{
$price = $this->seedClientPrice('Forbidden');
$carrier = $price->getCarrier();
self::assertNotNull($carrier);
$client = $this->authenticatedClient('commerciale', self::PWD); // view seul
$client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
'headers' => ['Content-Type' => self::LD],
'json' => ['direction' => 'CLIENT'],
]);
self::assertResponseStatusCodeSame(403);
$client->request('PATCH', '/api/carrier_prices/'.$price->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['priceState' => 'VALIDE'],
]);
self::assertResponseStatusCodeSame(403);
$client->request('DELETE', '/api/carrier_prices/'.$price->getId());
self::assertResponseStatusCodeSame(403);
}
/** Id d'un site fixture (adresse de depart / livraison des prix). */
private function aSiteId(): int
{
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
self::assertNotNull($site, 'Un site fixture est requis (SitesFixtures).');
$id = $site->getId();
self::assertNotNull($id);
return $id;
}
/**
* Seede un transporteur + un prix CLIENT complet rattache (pour les tests
* PATCH / DELETE). Passe par l'EM directement (le flux d'ecriture est teste
* via l'API ailleurs).
*/
private function seedClientPrice(string $name): CarrierPrice
{
$em = $this->getEm();
$carrier = $this->seedCarrier($name);
/** @var ClientAddress $addr */
$addr = $this->seedClientWithAddress($name);
$price = new CarrierPrice();
$price->setCarrier($carrier);
$price->setDirection('CLIENT');
$price->setClient($addr->getClient());
$price->setClientDeliveryAddress($addr);
$price->setDepartureSite($em->getRepository(Site::class)->findOneBy([]));
$price->setContainerType('BENNE');
$price->setPricingUnit('TONNE');
$price->setPrice('42.50');
$price->setPriceState('VALIDE');
$carrier->addPrice($price);
$em->persist($price);
$em->flush();
return $price;
}
}
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
use App\Module\Transport\Domain\Entity\Carrier;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* Tests fonctionnels de l'export XLSX du tableau Prix d'un transporteur (M4,
* § 4.6 / spec-front « Onglet Prix »).
*
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes), rendu des
* lignes regroupees par type de contenant (Benne / Fond Mouvant) avec ventilation
* Forfait/Tonne, libelles d'etat FR, points de depart/livraison cross-module,
* 404 sur transporteur inconnu, 403 sans transport.carriers.view, 401 anonyme.
*
* @internal
*/
final class CarrierPriceExportControllerTest extends AbstractCarrierApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
{
$client = $this->createAdminClient();
$carrier = $this->seedCompleteCarrier('Price Alpha');
$response = $client->request('GET', $this->exportUrl($carrier));
self::assertResponseIsSuccessful();
$headers = $response->getHeaders(false);
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
$disposition = $headers['content-disposition'][0] ?? '';
self::assertStringContainsString('attachment; filename="prix-transporteur-', $disposition);
self::assertMatchesRegularExpression(
'/filename="prix-transporteur-\d+-\d{8}\.xlsx"/',
$disposition,
);
$headerRow = $this->gridFromResponse($response->getContent())[0];
self::assertSame('Type de contenant', $headerRow[0]);
self::assertContains('Transporteurs', $headerRow);
self::assertContains('Adresse APRO ou Adresse Sites', $headerRow);
self::assertContains('Adresse livraisons', $headerRow);
self::assertContains('Forfait €', $headerRow);
self::assertContains('Tonne €', $headerRow);
self::assertContains('Indexation', $headerRow);
self::assertContains('État du prix', $headerRow);
}
/**
* Le transporteur complet seede 2 prix : une branche CLIENT (Benne / Tonne /
* 42.50 / Valide) et une branche FOURNISSEUR (Fond Mouvant / Forfait / 320.00 /
* En cours). On verifie le regroupement par contenant, la ventilation
* Forfait/Tonne, les libelles d'etat FR et les points de depart/livraison
* cross-module (le prix CLIENT livre chez le client, le prix FOURNISSEUR part
* de l'adresse du fournisseur).
*/
public function testExportRendersGroupedPriceRows(): void
{
$client = $this->createAdminClient();
$carrier = $this->seedCompleteCarrier('Price Grouping');
$grid = $this->gridFromResponse($client->request('GET', $this->exportUrl($carrier))->getContent());
$benne = $this->rowForContainer($grid, 'Benne');
self::assertNotNull($benne, 'Ligne « Benne » introuvable dans l\'export prix.');
self::assertSame($carrier->getName(), $benne[1]);
// Branche CLIENT : prix en Tonne (42.50 -> 42.5 apres typage numerique du
// classeur), colonne Forfait vide, etat « Valide », livraison chez le client.
self::assertEmpty($benne[4]);
self::assertEqualsWithDelta(42.5, (float) $benne[5], 0.001);
self::assertSame('Validé', $benne[7]);
self::assertStringContainsString('TESTCARRIERREF CLI', (string) $benne[3]);
$fondMouvant = $this->rowForContainer($grid, 'Fond Mouvant');
self::assertNotNull($fondMouvant, 'Ligne « Fond Mouvant » introuvable dans l\'export prix.');
// Branche FOURNISSEUR : prix au Forfait (320.00 -> 320), colonne Tonne vide,
// etat « En cours », depart depuis l'adresse du fournisseur (APRO).
self::assertEqualsWithDelta(320.0, (float) $fondMouvant[4], 0.001);
self::assertEmpty($fondMouvant[5]);
self::assertSame('En cours', $fondMouvant[7]);
self::assertStringContainsString('TESTCARRIERREF FRN', (string) $fondMouvant[2]);
}
public function testNotFoundForUnknownCarrier(): void
{
$client = $this->createAdminClient();
$client->request('GET', '/api/carriers/99999999/prices/export.xlsx');
self::assertResponseStatusCodeSame(404);
}
public function testForbiddenWithoutCarriersViewPermission(): void
{
$carrier = $this->seedCompleteCarrier('Price Forbidden');
$creds = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$client->request('GET', $this->exportUrl($carrier));
self::assertResponseStatusCodeSame(403);
}
public function testUnauthorizedWhenAnonymous(): void
{
$carrier = $this->seedCompleteCarrier('Price Anonymous');
$client = self::createClient();
$client->request('GET', $this->exportUrl($carrier));
self::assertResponseStatusCodeSame(401);
}
private function exportUrl(Carrier $carrier): string
{
return sprintf('/api/carriers/%d/prices/export.xlsx', (int) $carrier->getId());
}
/**
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
*
* @return array<int, array<int, mixed>>
*/
private function gridFromResponse(string $binary): array
{
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_carrier_price_export_test_');
self::assertIsString($tmp);
file_put_contents($tmp, $binary);
try {
return IOFactory::load($tmp)->getActiveSheet()->toArray();
} finally {
@unlink($tmp);
}
}
/**
* Renvoie la 1re ligne de donnees dont la colonne « Type de contenant »
* (1re colonne) vaut $container, ou null.
*
* @param array<int, array<int, mixed>> $grid
*
* @return null|array<int, mixed>
*/
private function rowForContainer(array $grid, string $container): ?array
{
foreach (array_slice($grid, 1) as $row) {
if ((string) ($row[0] ?? '') === $container) {
return $row;
}
}
return null;
}
}