Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b1e2c2a80 | |||
| 8570bd09f0 | |||
| 54ac034c1b | |||
| 456c6682b0 |
@@ -4,22 +4,86 @@ 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\CarrierAddressProcessor;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Adresse d'un transporteur (1:n) — onglet Adresse (M4). Jumelle de
|
||||
* SupplierAddress (M2), version simplifiee (pas de type d'adresse, pas de M2M
|
||||
* sites/categories sur l'adresse : les sites du M4 vivent dans l'onglet Prix).
|
||||
*
|
||||
* WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read`
|
||||
* (embarquees au detail du transporteur). Les sous-ressources d'ecriture
|
||||
* (POST/PATCH/DELETE) + RG-4.05→4.07 arrivent au worktree dedie (WT6).
|
||||
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
|
||||
* transporteur). Ecriture : groupe `carrier:write:addresses`.
|
||||
*
|
||||
* Sous-ressource API (ERP-159, spec § 4.5) — jumelle de SupplierAddress (M2) /
|
||||
* ProviderAddress (M3), sans address_type ni M2M (les sites du M4 vivent dans
|
||||
* l'onglet Prix) :
|
||||
* - POST /api/carriers/{carrierId}/addresses : creation rattachee au
|
||||
* transporteur parent (Link toProperty 'carrier'), security
|
||||
* transport.carriers.manage.
|
||||
* - PATCH / DELETE /api/carrier_addresses/{id} : security
|
||||
* transport.carriers.manage.
|
||||
* - GET /api/carrier_addresses/{id} : lecture unitaire (security view) — la
|
||||
* lecture courante reste via le parent. Pas de GET collection autonome.
|
||||
* Tout passe par le CarrierAddressProcessor (rattachement parent + RG-4.05).
|
||||
*
|
||||
* Regles de l'onglet Adresse :
|
||||
* - RG-4.06 : code postal a 4 ou 5 chiffres (Assert\Regex ; pas de controle
|
||||
* CP/ville serveur, l'autocomplete BAN est front).
|
||||
* - RG-4.05 : si le transporteur est affrete (isChartered), l'adresse devient
|
||||
* obligatoire (Pays / CP / Ville / Adresse) — validation conditionnelle portee
|
||||
* par le CarrierAddressProcessor (le parent n'est pas disponible a la
|
||||
* validation Symfony sur un POST sous-ressource en read:false).
|
||||
* - RG-4.07 : masquage du bouton « Valider » si QUALIMAT = front ; le back
|
||||
* accepte le PATCH normalement (aucune garde back specifique).
|
||||
*
|
||||
* 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}/addresses',
|
||||
uriVariables: [
|
||||
'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'),
|
||||
],
|
||||
// read:false : pas de stade lecture du parent. Le Link toProperty
|
||||
// resoudrait l'enfant (SELECT CarrierAddress ... WHERE carrier = :id)
|
||||
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
|
||||
// manuellement par CarrierAddressProcessor::linkParent (404 si absent).
|
||||
read: false,
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['carrier:write:addresses']],
|
||||
processor: CarrierAddressProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['carrier:write:addresses']],
|
||||
processor: CarrierAddressProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
processor: CarrierAddressProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'carrier_address')]
|
||||
#[ORM\Index(name: 'idx_carrier_address_carrier', columns: ['carrier_id'])]
|
||||
@@ -41,23 +105,32 @@ class CarrierAddress implements TimestampableInterface, BlamableInterface
|
||||
private ?Carrier $carrier = null;
|
||||
|
||||
#[ORM\Column(length: 80, options: ['default' => 'France'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
#[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||
private string $country = 'France';
|
||||
|
||||
// RG-4.06 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur,
|
||||
// l'autocomplete BAN est front). Le Regex borne deja la longueur (<= 5) : pas
|
||||
// de Length redondant (whitelist EXCLUDED_LENGTH_MIRROR). Nullable : obligatoire
|
||||
// seulement si affrete (RG-4.05, garde CarrierAddressProcessor).
|
||||
#[ORM\Column(name: 'postal_code', length: 20, nullable: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
#[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||
private ?string $postalCode = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
#[Assert\Length(max: 255, maxMessage: 'L\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(name: 'street_complement', length: 255, nullable: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||
private ?string $streetComplement = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
|
||||
@@ -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'])]
|
||||
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])]
|
||||
|
||||
@@ -4,13 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\QualimatCarrierSearchProvider;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
@@ -26,8 +23,9 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
* - cible de la FK editable `carrier.qualimat_carrier_id` (§ 2.5) ;
|
||||
* - embarquee (groupe `qualimat:read`) dans la liste et le detail Carrier pour
|
||||
* afficher statut + date de validite QUALIMAT (RG-4.04) ;
|
||||
* - endpoint de recherche `GET /api/qualimat_carriers?...` pour la saisie
|
||||
* assistee du nom (§ 4.7) — filtres built-in name/siret (partiel), isActive.
|
||||
* - endpoint de recherche `GET /api/qualimat_carriers?search=` pour la saisie
|
||||
* assistee du nom (§ 4.7) — fuzzy name (+ siret), SEULEMENT les lignes actives,
|
||||
* tri name ASC, paginee ; logique portee par QualimatCarrierSearchProvider.
|
||||
*
|
||||
* La table reste hors `schema_filter` Doctrine (doctrine.yaml) : c'est la
|
||||
* migration modulaire Version20260612150000 qui possede son DDL et ses COMMENT
|
||||
@@ -36,8 +34,14 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
// Saisie assistee (§ 4.7 / RG-4.01) : ?search= fuzzy name (+ siret),
|
||||
// SEULEMENT les lignes actives, tri name ASC, paginee. La logique vit
|
||||
// dans le provider (forcage is_active + recherche multi-champs) car un
|
||||
// SearchFilter natif ne sait ni unifier name/siret sous un seul ?search=,
|
||||
// ni imposer cote serveur le filtre actif.
|
||||
new GetCollection(
|
||||
security: "is_granted('transport.carriers.view')",
|
||||
provider: QualimatCarrierSearchProvider::class,
|
||||
normalizationContext: ['groups' => ['qualimat:read', 'default:read']],
|
||||
),
|
||||
new Get(
|
||||
@@ -46,9 +50,6 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'siret' => 'partial'])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['isActive'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name'], arguments: ['orderParameterName' => 'order'])]
|
||||
#[ORM\Entity]
|
||||
// Mapping reproduisant a l'identique le DDL de la migration ERP-39
|
||||
// (Version20260612150000) pour que `schema:update --force` reste un no-op :
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Repository;
|
||||
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
/**
|
||||
* Contrat du repository du referentiel QUALIMAT (M4, lecture seule). Implementation
|
||||
* Doctrine : App\Module\Transport\Infrastructure\Doctrine\DoctrineQualimatCarrierRepository.
|
||||
*
|
||||
* La table `qualimat_carrier` est alimentee/soft-deletee EXCLUSIVEMENT par la
|
||||
* commande console `app:qualimat:sync` : ce contrat n'expose que de la lecture.
|
||||
*/
|
||||
interface QualimatCarrierRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* QueryBuilder de la saisie assistee (§ 4.7 / RG-4.01). Restreint aux lignes
|
||||
* actives (is_active = true), recherche fuzzy sur name (+ siret), tri name ASC.
|
||||
*
|
||||
* @param null|string $search texte de recherche libre (fuzzy name + siret)
|
||||
*/
|
||||
public function createSearchQueryBuilder(?string $search = null): QueryBuilder;
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
<?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\CarrierAddress;
|
||||
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 Adresse d'un transporteur (M4,
|
||||
* spec-back § 4.5). Jumeau du SupplierAddressProcessor (M2) / ProviderAddressProcessor
|
||||
* (M3), recentre sur le perimetre ERP-159, AVEC une garde propre au M4 : RG-4.05
|
||||
* (adresse obligatoire si le transporteur est affrete).
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : rattachement au transporteur parent (linkParent) puis garde
|
||||
* RG-4.05 (guardCharteredAddress). RG-4.06 (code postal, Assert\Regex) est portee
|
||||
* par l'entite et jouee par API Platform AVANT ce processor.
|
||||
* - DELETE : aucune regle metier specifique (suppression physique directe).
|
||||
*
|
||||
* RG-4.05 vit ICI (et non en Assert\Callback sur l'entite) car elle depend du
|
||||
* transporteur PARENT, indisponible a la validation Symfony sur un POST
|
||||
* sous-ressource en read:false (le parent n'est rattache qu'au stade processor).
|
||||
* La violation est construite a la main avec le meme rendu Hydra que les
|
||||
* contraintes Symfony, donc consommable inline par champ (convention ERP-101).
|
||||
*
|
||||
* RG-4.07 (masquage du bouton « Valider » si QUALIMAT) est purement front : le
|
||||
* back accepte le PATCH normalement, aucune garde ici.
|
||||
*
|
||||
* La security d'operation (transport.carriers.manage) est appliquee par API
|
||||
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut.
|
||||
*
|
||||
* @implements ProcessorInterface<CarrierAddress, null|CarrierAddress>
|
||||
*/
|
||||
final class CarrierAddressProcessor implements ProcessorInterface
|
||||
{
|
||||
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 CarrierAddress) {
|
||||
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->guardCharteredAddress($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache l'adresse au transporteur parent de la sous-ressource POST
|
||||
* (/carriers/{carrierId}/addresses) : la relation n'est pas peuplee
|
||||
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
|
||||
*/
|
||||
private function linkParent(CarrierAddress $address, array $uriVariables): void
|
||||
{
|
||||
if (null !== $address->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.');
|
||||
}
|
||||
|
||||
$address->setCarrier($carrier);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.05 : si le transporteur parent est affrete (isChartered), l'adresse doit
|
||||
* porter Pays / Code postal / Ville / Adresse. Chaque champ manquant -> une
|
||||
* violation 422 sur son propre propertyPath (mapping inline ERP-101). La
|
||||
* validation porte sur l'ETAT RESULTANT de l'adresse (apres application du
|
||||
* payload), donc identique sur POST et sur PATCH partiel. Sans affretement,
|
||||
* l'adresse reste partielle (champs nullable, RG-4.06 inchangee).
|
||||
*/
|
||||
private function guardCharteredAddress(CarrierAddress $address): void
|
||||
{
|
||||
$carrier = $address->getCarrier();
|
||||
if (!$carrier instanceof Carrier || !$carrier->isChartered()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$required = [
|
||||
'country' => [$address->getCountry(), 'Le pays est obligatoire pour un transporteur affrété.'],
|
||||
'postalCode' => [$address->getPostalCode(), 'Le code postal est obligatoire pour un transporteur affrété.'],
|
||||
'city' => [$address->getCity(), 'La ville est obligatoire pour un transporteur affrété.'],
|
||||
'street' => [$address->getStreet(), 'L\'adresse est obligatoire pour un transporteur affrété.'],
|
||||
];
|
||||
|
||||
$violations = new ConstraintViolationList();
|
||||
foreach ($required as $path => [$value, $message]) {
|
||||
if (null === $value || '' === trim($value)) {
|
||||
$violations->add(new ConstraintViolation($message, null, [], $address, $path, $value));
|
||||
}
|
||||
}
|
||||
|
||||
if (0 < $violations->count()) {
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
}
|
||||
+235
@@ -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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+170
@@ -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);
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Transport\Domain\Entity\QualimatCarrier;
|
||||
use App\Module\Transport\Domain\Repository\QualimatCarrierRepositoryInterface;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider de la saisie assistee QUALIMAT (spec-back § 4.7 / RG-4.01).
|
||||
*
|
||||
* GET /api/qualimat_carriers?search=<texte> :
|
||||
* - restreint aux lignes actives (is_active = true) — regle serveur, pas un
|
||||
* filtre client desactivable ;
|
||||
* - recherche fuzzy insensible a la casse sur name (+ siret) ;
|
||||
* - tri par name ASC ;
|
||||
* - pagination Hydra (regle n°13) + echappatoire ?pagination=false (selects).
|
||||
*
|
||||
* Branche uniquement sur la GetCollection ; le Get unitaire reste servi par le
|
||||
* provider ORM par defaut (lecture seule, aucune ecriture exposee).
|
||||
*
|
||||
* @implements ProviderInterface<QualimatCarrier>
|
||||
*/
|
||||
final class QualimatCarrierSearchProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineQualimatCarrierRepository')]
|
||||
private readonly QualimatCarrierRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<QualimatCarrier>|Paginator<QualimatCarrier>
|
||||
*/
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|Paginator
|
||||
{
|
||||
$filters = $context['filters'] ?? [];
|
||||
$search = $filters['search'] ?? null;
|
||||
|
||||
$qb = $this->repository->createSearchQueryBuilder(is_string($search) ? $search : null);
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete (selects front).
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
/** @var list<QualimatCarrier> $carriers */
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
// fetchJoinCollection: false — aucune jointure to-many (referentiel plat).
|
||||
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Transport\Domain\Entity\QualimatCarrier;
|
||||
use App\Module\Transport\Domain\Repository\QualimatCarrierRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<QualimatCarrier>
|
||||
*/
|
||||
class DoctrineQualimatCarrierRepository extends ServiceEntityRepository implements QualimatCarrierRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, QualimatCarrier::class);
|
||||
}
|
||||
|
||||
public function createSearchQueryBuilder(?string $search = null): QueryBuilder
|
||||
{
|
||||
// Saisie assistee (§ 4.7) : on ne propose QUE des transporteurs QUALIMAT
|
||||
// actifs (is_active = true), tries par nom. Le forcage de l'actif est une
|
||||
// regle serveur (pas un filtre client) — les lignes soft-deletees par la
|
||||
// synchro restent invisibles.
|
||||
$qb = $this->createQueryBuilder('q')
|
||||
->andWhere('q.isActive = true')
|
||||
->orderBy('q.name', 'ASC')
|
||||
;
|
||||
|
||||
$this->applySearch($qb, $search);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche fuzzy insensible a la casse sur le nom (+ siret) du transporteur
|
||||
* QUALIMAT (§ 4.7 / RG-4.01). Metacaracteres LIKE (%, _, \) echappes pour
|
||||
* rester litteraux.
|
||||
*/
|
||||
private function applySearch(QueryBuilder $qb, ?string $search): void
|
||||
{
|
||||
if (null === $search || '' === trim($search)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
||||
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
||||
|
||||
$qb->andWhere('LOWER(q.name) LIKE :search OR LOWER(q.siret) LIKE :search')
|
||||
->setParameter('search', $pattern)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -56,12 +56,19 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
'SupplierAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
|
||||
// Idem cote prestataire (meme Regex CP — M3 Technique).
|
||||
'ProviderAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
|
||||
// Idem cote transporteur (meme Regex CP — M4 Transport).
|
||||
'CarrierAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
|
||||
// Le Choice {PROSPECT,DEPART,RENDU} borne les valeurs (<= 8 < 20).
|
||||
'SupplierAddress::addressType' => 'Choice {PROSPECT,DEPART,RENDU} borne deja les valeurs.',
|
||||
// Le Choice {QUALIMAT,GMP_PLUS,OVOCOM,COMPTE_PROPRE,AUTRE} borne les valeurs (<= 13 < 20).
|
||||
'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.',
|
||||
];
|
||||
@@ -107,7 +114,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
}
|
||||
|
||||
/** @var Constraint $constraint */
|
||||
$constraint = $attribute->newInstance();
|
||||
$constraint = $attribute->newInstance();
|
||||
$messageProps = $this->messagePropertiesFor($constraint);
|
||||
|
||||
self::assertNotNull(
|
||||
@@ -178,6 +185,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
foreach ($constraints as $c) {
|
||||
if ($c instanceof Assert\Length) {
|
||||
$length = $c->max;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -249,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
|
||||
{
|
||||
@@ -323,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,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Entity\CarrierAddress;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
/**
|
||||
* Sous-ressource Adresse d'un transporteur (spec-back M4 § 4.5, ERP-159).
|
||||
* POST /api/carriers/{id}/addresses, PATCH/DELETE /api/carrier_addresses/{id}.
|
||||
*
|
||||
* Contrat verifie :
|
||||
* - RG-4.06 : code postal hors ^[0-9]{4,5}$ -> 422 ;
|
||||
* - RG-4.05 : transporteur affrete + adresse incomplete -> 422 (par champ) ;
|
||||
* - RG-4.05 : transporteur affrete + adresse complete -> 201 ;
|
||||
* - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierAddressApiTest 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 testInvalidPostalCodeReturns422(): void
|
||||
{
|
||||
// Transporteur NON affrete : RG-4.05 ne s'applique pas, seule RG-4.06 joue.
|
||||
$carrier = $this->seedCarrierWithChartered('Cp Invalide', false);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '123'], // 3 chiffres -> Regex KO
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testCharteredCarrierIncompleteAddressReturns422(): void
|
||||
{
|
||||
// Transporteur affrete : RG-4.05 exige Pays/CP/Ville/Adresse. CP valide mais
|
||||
// ville + rue manquantes -> 422 conditionnelle (CarrierAddressProcessor).
|
||||
$carrier = $this->seedCarrierWithChartered('Affrete Incomplet', true);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '86000'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testCharteredCarrierCompleteAddressIsCreated(): void
|
||||
{
|
||||
$carrier = $this->seedCarrierWithChartered('Affrete Complet', true);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'country' => 'France',
|
||||
'postalCode' => '86000',
|
||||
'city' => 'Poitiers',
|
||||
'street' => '12 rue des Acacias',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testPatchAndDeleteSucceedWithManage(): void
|
||||
{
|
||||
$address = $this->seedAddress('Patch Delete', false);
|
||||
$client = $this->authenticatedClient('bureau', self::PWD); // manage (matrice § 5.2)
|
||||
|
||||
// PATCH (manage) -> 200
|
||||
$client->request('PATCH', '/api/carrier_addresses/'.$address->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['city' => 'Lyon'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// DELETE (manage) -> 204
|
||||
$client->request('DELETE', '/api/carrier_addresses/'.$address->getId());
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
}
|
||||
|
||||
public function testWriteForbiddenWithoutManage(): void
|
||||
{
|
||||
$address = $this->seedAddress('Forbidden', false);
|
||||
$carrier = $address->getCarrier();
|
||||
self::assertNotNull($carrier);
|
||||
$client = $this->authenticatedClient('commerciale', self::PWD); // view seul
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '86000', 'city' => 'Poitiers', 'street' => '1 rue X'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('PATCH', '/api/carrier_addresses/'.$address->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['city' => 'Lyon'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('DELETE', '/api/carrier_addresses/'.$address->getId());
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un transporteur minimal en controlant le flag affrete (RG-4.05).
|
||||
*/
|
||||
private function seedCarrierWithChartered(string $name, bool $isChartered): Carrier
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$carrier = new Carrier();
|
||||
$carrier->setName(mb_strtoupper($name, 'UTF-8'));
|
||||
$carrier->setCertificationType('GMP_PLUS');
|
||||
$carrier->setIsChartered($isChartered);
|
||||
$em->persist($carrier);
|
||||
$em->flush();
|
||||
|
||||
return $carrier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un transporteur + une adresse rattachee (pour les tests PATCH/DELETE).
|
||||
*/
|
||||
private function seedAddress(string $name, bool $isChartered): CarrierAddress
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$carrier = $this->seedCarrierWithChartered($name, $isChartered);
|
||||
|
||||
$address = new CarrierAddress();
|
||||
$address->setCarrier($carrier);
|
||||
$address->setPostalCode('86000');
|
||||
$address->setCity('Poitiers');
|
||||
$address->setStreet('12 rue des Acacias');
|
||||
$carrier->addAddress($address);
|
||||
$em->persist($address);
|
||||
$em->flush();
|
||||
|
||||
return $address;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
// 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,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,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
/**
|
||||
* Endpoint de recherche du referentiel QUALIMAT (spec-back M4 § 4.7 / RG-4.01,
|
||||
* ERP-156). Saisie assistee du nom : GET /api/qualimat_carriers?search= .
|
||||
*
|
||||
* Contrat verifie :
|
||||
* - recherche fuzzy sur name (+ siret), SEULEMENT les lignes actives ;
|
||||
* - tri name ASC ;
|
||||
* - enveloppe Hydra paginee (member / totalItems / view — regle n°13) ;
|
||||
* - 403 sans la permission transport.carriers.view (compta/usine, matrice § 5.2).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class QualimatCarrierSearchTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
|
||||
|
||||
/** Prefixe SIRET dedie (purge par AbstractCarrierApiTestCase::tearDown). */
|
||||
private const string SIRET_PREFIX = 'TESTQ';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin
|
||||
// qu'en recette), requis pour le test de permission (usine sans acces).
|
||||
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 testSearchReturnsOnlyActiveOrderedByName(): void
|
||||
{
|
||||
// Marqueur unique partage par les 3 lignes : isole la recherche d'eventuelles
|
||||
// autres lignes du referentiel.
|
||||
$this->insertQualimat('QSEARCH GAMMA', true, 'A1');
|
||||
$this->insertQualimat('QSEARCH ALPHA', true, 'A2');
|
||||
$this->insertQualimat('QSEARCH BETA', false, 'A3'); // inactive -> exclue
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('GET', '/api/qualimat_carriers?search=qsearch', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$data = $client->getResponse()->toArray();
|
||||
$names = array_column($data['member'], 'name');
|
||||
|
||||
self::assertSame(2, $data['totalItems'], 'Seules les 2 lignes actives doivent remonter (BETA inactive exclue).');
|
||||
self::assertSame(['QSEARCH ALPHA', 'QSEARCH GAMMA'], $names, 'Tri name ASC, sans la ligne inactive.');
|
||||
}
|
||||
|
||||
public function testSearchMatchesSiret(): void
|
||||
{
|
||||
// Le nom ne porte pas le marqueur : la correspondance se fait via le siret.
|
||||
$this->insertQualimat('TRANSPORTEUR SANS MARQUEUR', true, 'SIRETHIT1');
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('GET', '/api/qualimat_carriers?search=testqsirethit1', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$data = $client->getResponse()->toArray();
|
||||
self::assertSame(1, $data['totalItems'], 'La recherche fuzzy doit aussi cibler le siret.');
|
||||
self::assertSame('TRANSPORTEUR SANS MARQUEUR', $data['member'][0]['name']);
|
||||
}
|
||||
|
||||
public function testCollectionExposesHydraPagination(): void
|
||||
{
|
||||
$this->insertQualimat('QPAGE UN', true, 'P1');
|
||||
$this->insertQualimat('QPAGE DEUX', true, 'P2');
|
||||
$this->insertQualimat('QPAGE TROIS', true, 'P3');
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('GET', '/api/qualimat_carriers?search=qpage&itemsPerPage=2', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$data = $client->getResponse()->toArray();
|
||||
self::assertArrayHasKey('totalItems', $data, 'La collection doit exposer totalItems.');
|
||||
self::assertArrayHasKey('view', $data, 'La collection doit exposer view quand totalItems > itemsPerPage.');
|
||||
self::assertIsArray($data['member']);
|
||||
self::assertSame(3, $data['totalItems']);
|
||||
self::assertCount(2, $data['member'], 'La page doit etre bornee a itemsPerPage=2.');
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutPermission(): void
|
||||
{
|
||||
// Usine : aucun acces transporteurs (matrice § 5.2) -> 403 sur la recherche.
|
||||
$client = $this->authenticatedClient('usine', self::PWD);
|
||||
$client->request('GET', '/api/qualimat_carriers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insere une ligne qualimat_carrier de test en DBAL brut (l'entite mappee est
|
||||
* en lecture seule). SIRET prefixe TESTQ pour la purge ciblee du tearDown.
|
||||
*/
|
||||
private function insertQualimat(string $name, bool $isActive, string $siretSuffix): void
|
||||
{
|
||||
$this->getEm()->getConnection()->insert('qualimat_carrier', [
|
||||
'siret' => self::SIRET_PREFIX.$siretSuffix,
|
||||
'name' => $name,
|
||||
'status' => 'Valide',
|
||||
'validity_date' => '2027-12-31',
|
||||
'is_active' => $isActive ? 'true' : 'false',
|
||||
'last_synced_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user