diff --git a/src/Module/Transport/Domain/Entity/CarrierContact.php b/src/Module/Transport/Domain/Entity/CarrierContact.php index 1f5e8fb..93a507a 100644 --- a/src/Module/Transport/Domain/Entity/CarrierContact.php +++ b/src/Module/Transport/Domain/Entity/CarrierContact.php @@ -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 + */ + #[Groups(['carrier:write:contacts'])] + 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 + */ + public function getPhones(): ?array + { + return $this->phones; + } + + /** + * @param null|list $phones + */ + public function setPhones(?array $phones): static + { + $this->phones = $phones; + + return $this; + } + public function getPosition(): int { return $this->position; diff --git a/src/Module/Transport/Domain/Entity/CarrierPrice.php b/src/Module/Transport/Domain/Entity/CarrierPrice.php index 90e4be0..ed41f01 100644 --- a/src/Module/Transport/Domain/Entity/CarrierPrice.php +++ b/src/Module/Transport/Domain/Entity/CarrierPrice.php @@ -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])] diff --git a/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierContactProcessor.php b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierContactProcessor.php new file mode 100644 index 0000000..6291feb --- /dev/null +++ b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierContactProcessor.php @@ -0,0 +1,235 @@ + 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 + */ +final class CarrierContactProcessor implements ProcessorInterface +{ + /** RG-4.08 : nombre maximal de telephones par contact. */ + private const int MAX_PHONES = 2; + + /** Longueur max d'un telephone normalise (colonne VARCHAR(20)). */ + private const int PHONE_MAX_LENGTH = 20; + + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private readonly ProcessorInterface $removeProcessor, + private readonly CarrierFieldNormalizer $normalizer, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof CarrierContact) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + $this->linkParent($data, $uriVariables); + $this->normalize($data); + $this->applyPhones($data); + $this->validateAtLeastOneField($data); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Rattache le contact au transporteur parent de la sous-ressource POST + * (/carriers/{carrierId}/contacts) : la relation n'est pas peuplee + * automatiquement par le Link sur une ecriture. Sur PATCH (entite existante), + * le transporteur est deja present -> no-op. + */ + private function linkParent(CarrierContact $contact, array $uriVariables): void + { + if (null !== $contact->getCarrier()) { + return; + } + + $carrierId = $uriVariables['carrierId'] ?? null; + if (null === $carrierId) { + return; + } + + $carrier = $carrierId instanceof Carrier + ? $carrierId + : $this->em->getRepository(Carrier::class)->find($carrierId); + + // read:false sur le POST : sans stade lecture, un parent introuvable n'est + // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la + // contrainte carrier_id NOT NULL). + if (!$carrier instanceof Carrier) { + throw new NotFoundHttpException('Transporteur introuvable.'); + } + + $contact->setCarrier($carrier); + } + + /** + * Normalisation serveur RG-4.13 des champs texte. Toutes les methodes du + * normalizer sont null-safe : une chaine vide apres trim devient null (donc la + * garde RG-4.08 detecte bien « champ non rempli »). Les telephones sont + * traites a part (applyPhones). + */ + private function normalize(CarrierContact $contact): void + { + $contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName())); + $contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName())); + $contact->setJobTitle($this->blankToNull($contact->getJobTitle())); + $contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail())); + } + + /** + * Mappe le tableau d'ecriture `phones` (max 2, RG-4.08) vers phonePrimary / + * phoneSecondary apres normalisation RG-4.13 (chiffres uniquement). Les + * numeros vides (sans chiffre) sont ecartes. null = champ non fourni (PATCH + * partiel) -> on ne touche pas aux telephones existants. Un 3e numero + * exploitable, ou un numero trop long (> colonne VARCHAR(20)), -> 422 sur + * `phones`. + */ + private function applyPhones(CarrierContact $contact): void + { + $phones = $contact->getPhones(); + if (null === $phones) { + return; + } + + $normalized = []; + foreach ($phones as $phone) { + $digits = $this->normalizer->normalizePhone(is_string($phone) ? $phone : null); + if (null !== $digits) { + $normalized[] = $digits; + } + } + + $violations = new ConstraintViolationList(); + if (self::MAX_PHONES < count($normalized)) { + $violations->add(new ConstraintViolation( + 'Un contact ne peut comporter plus de deux téléphones.', + null, + [], + $contact, + 'phones', + $phones, + )); + } + foreach ($normalized as $digits) { + if (self::PHONE_MAX_LENGTH < mb_strlen($digits)) { + $violations->add(new ConstraintViolation( + 'Un numéro de téléphone ne peut dépasser '.self::PHONE_MAX_LENGTH.' caractères.', + null, + [], + $contact, + 'phones', + $phones, + )); + + break; + } + } + + if (0 < $violations->count()) { + throw new ValidationException($violations); + } + + $contact->setPhonePrimary($normalized[0] ?? null); + $contact->setPhoneSecondary($normalized[1] ?? null); + // Nettoie le champ virtuel (non persiste, mais evite toute fuite ulterieure). + $contact->setPhones(null); + } + + /** + * RG-4.08 : un bloc Contact est valide des qu'au moins 1 champ est rempli + * (firstName, lastName, jobTitle, phonePrimary ou email — meme perimetre que + * le CHECK BDD chk_carrier_contact_filled, qui exclut phoneSecondary). Double + * garde : leve une 422 propre rattachee a `firstName` plutot qu'une 500 SQL. + * Joue apres normalisation + mapping telephones, donc les chaines vides sont + * deja ramenees a null. + */ + private function validateAtLeastOneField(CarrierContact $contact): void + { + if ( + null === $contact->getFirstName() + && null === $contact->getLastName() + && null === $contact->getJobTitle() + && null === $contact->getPhonePrimary() + && null === $contact->getEmail() + ) { + $violations = new ConstraintViolationList(); + $violations->add(new ConstraintViolation( + 'Renseignez au moins un champ pour le contact.', + null, + [], + $contact, + 'firstName', + null, + )); + + throw new ValidationException($violations); + } + } + + /** + * Trim + chaine vide -> null (la fonction n'est pas normalisee en casse, + * contrairement aux noms de personne). Garantit que RG-4.08 detecte un champ + * « non rempli » meme si le client envoie une chaine vide. + */ + private function blankToNull(?string $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim($value); + + return '' === $value ? null : $value; + } +} diff --git a/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierPriceProcessor.php b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierPriceProcessor.php new file mode 100644 index 0000000..adefa85 --- /dev/null +++ b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierPriceProcessor.php @@ -0,0 +1,170 @@ + + */ +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); + } +} diff --git a/src/Module/Transport/Infrastructure/Controller/CarrierExportController.php b/src/Module/Transport/Infrastructure/Controller/CarrierExportController.php new file mode 100644 index 0000000..3df86a6 --- /dev/null +++ b/src/Module/Transport/Infrastructure/Controller/CarrierExportController.php @@ -0,0 +1,161 @@ +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 $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 + */ + private function buildHeaders(): array + { + return [ + 'Nom', + 'Certification', + 'Statut QUALIMAT', + 'Date de validité', + 'Affrété', + 'Volume m³', + 'Date de création', + ]; + } + + /** + * @param list $carriers + * + * @return iterable> + */ + 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 + */ + 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; + } +} diff --git a/src/Module/Transport/Infrastructure/Controller/CarrierPriceExportController.php b/src/Module/Transport/Infrastructure/Controller/CarrierPriceExportController.php new file mode 100644 index 0000000..2abb99e --- /dev/null +++ b/src/Module/Transport/Infrastructure/Controller/CarrierPriceExportController.php @@ -0,0 +1,170 @@ + '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 + */ + 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> + */ + 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; + } +} diff --git a/src/Shared/Domain/Contract/ClientAddressInterface.php b/src/Shared/Domain/Contract/ClientAddressInterface.php index 4f5f6a4..0c52e84 100644 --- a/src/Shared/Domain/Contract/ClientAddressInterface.php +++ b/src/Shared/Domain/Contract/ClientAddressInterface.php @@ -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; } diff --git a/src/Shared/Domain/Contract/SupplierAddressInterface.php b/src/Shared/Domain/Contract/SupplierAddressInterface.php index 4e32c7f..2c5a141 100644 --- a/src/Shared/Domain/Contract/SupplierAddressInterface.php +++ b/src/Shared/Domain/Contract/SupplierAddressInterface.php @@ -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; } diff --git a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php index 799ff09..74374e7 100644 --- a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php +++ b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php @@ -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|null + * @return null|list */ private function messagePropertiesFor(Constraint $constraint): ?array { @@ -325,7 +331,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase } /** - * @param list $constraints + * @param list $constraints * @param list> $classes */ private function hasAnyConstraint(array $constraints, array $classes): bool diff --git a/tests/Module/Transport/Api/CarrierContactApiTest.php b/tests/Module/Transport/Api/CarrierContactApiTest.php new file mode 100644 index 0000000..189ef45 --- /dev/null +++ b/tests/Module/Transport/Api/CarrierContactApiTest.php @@ -0,0 +1,179 @@ + 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 + { + // 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; + } +} diff --git a/tests/Module/Transport/Api/CarrierExportControllerTest.php b/tests/Module/Transport/Api/CarrierExportControllerTest.php new file mode 100644 index 0000000..5e18abe --- /dev/null +++ b/tests/Module/Transport/Api/CarrierExportControllerTest.php @@ -0,0 +1,157 @@ +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> + */ + 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 + */ + 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> $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, + )); + } +} diff --git a/tests/Module/Transport/Api/CarrierPriceApiTest.php b/tests/Module/Transport/Api/CarrierPriceApiTest.php new file mode 100644 index 0000000..399430d --- /dev/null +++ b/tests/Module/Transport/Api/CarrierPriceApiTest.php @@ -0,0 +1,276 @@ + 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; + } +} diff --git a/tests/Module/Transport/Api/CarrierPriceExportControllerTest.php b/tests/Module/Transport/Api/CarrierPriceExportControllerTest.php new file mode 100644 index 0000000..92e7c48 --- /dev/null +++ b/tests/Module/Transport/Api/CarrierPriceExportControllerTest.php @@ -0,0 +1,161 @@ +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> + */ + 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> $grid + * + * @return null|array + */ + private function rowForContainer(array $grid, string $container): ?array + { + foreach (array_slice($grid, 1) as $row) { + if ((string) ($row[0] ?? '') === $container) { + return $row; + } + } + + return null; + } +}