Compare commits
4 Commits
54ac034c1b
...
91dc02ab05
| Author | SHA1 | Date | |
|---|---|---|---|
| 91dc02ab05 | |||
| df3b924b17 | |||
| cbabe8f9ac | |||
| 0f62deb80f |
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Application\Service;
|
||||
|
||||
/**
|
||||
* Normalisation serveur des champs texte d'un Carrier / CarrierContact, appliquee
|
||||
* par le CarrierProcessor (et les futurs processors de sous-ressources, WT6/7/8)
|
||||
* AVANT persistance. Cf. spec-back M4 § 2.10 + RG-4.13. Jumeau de
|
||||
* SupplierFieldNormalizer (M2), enrichi du cas LIOT (immatriculations).
|
||||
*
|
||||
* - name : UPPERCASE integral (RG-4.13)
|
||||
* - firstName / lastName (personnes, sur CarrierContact) : Title Case (RG-4.13)
|
||||
* - phone* : chiffres uniquement, ex "06.12.34.56.78" -> "0612345678" (RG-4.13).
|
||||
* Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front.
|
||||
* - email : lowercase integral (RG-4.13)
|
||||
* - liotPlates : liste « ; » -> split, trim, UPPER, rejoin "; " (cas LIOT RG-4.01).
|
||||
*
|
||||
* Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide apres
|
||||
* trim devient null (evite de persister "" dans des colonnes nullable).
|
||||
*/
|
||||
final class CarrierFieldNormalizer
|
||||
{
|
||||
/**
|
||||
* Raison sociale en majuscules (RG-4.13). Conserve null tel quel ; une chaine
|
||||
* non vide est trim + upper. Une chaine vide reste "" (champ obligatoire :
|
||||
* c'est l'Assert\NotBlank qui rejette, pas le normalizer).
|
||||
*/
|
||||
public function normalizeName(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mb_strtoupper(trim($value), 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nom/prenom de personne en Title Case (RG-4.13) : "JEAN dupont" ->
|
||||
* "Jean Dupont". Une chaine vide apres trim devient null.
|
||||
*/
|
||||
public function normalizePersonName(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Email en minuscules (RG-4.13). Une chaine vide apres trim devient null.
|
||||
*/
|
||||
public function normalizeEmail(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : mb_strtolower($value, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Telephone reduit aux chiffres (RG-4.13) : "06.12.34.56.78" -> "0612345678".
|
||||
* Une valeur sans aucun chiffre devient null.
|
||||
*/
|
||||
public function normalizePhone(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = preg_replace('/\D+/', '', $value) ?? '';
|
||||
|
||||
return '' === $digits ? null : $digits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immatriculations LIOT (RG-4.01 / RG-4.13) : la saisie « ; »-separee est
|
||||
* decoupee, chaque plaque trim + UPPER, les segments vides ecartes, puis
|
||||
* recomposee avec le separateur canonique "; ". Une saisie sans aucune plaque
|
||||
* exploitable devient null.
|
||||
*/
|
||||
public function normalizeLiotPlates(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$plates = [];
|
||||
foreach (explode(';', $value) as $plate) {
|
||||
$plate = trim($plate);
|
||||
if ('' !== $plate) {
|
||||
$plates[] = mb_strtoupper($plate, 'UTF-8');
|
||||
}
|
||||
}
|
||||
|
||||
return [] === $plates ? null : implode('; ', $plates);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ namespace App\Module\Transport\Domain\Entity;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierProcessor;
|
||||
use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\CarrierProvider;
|
||||
use App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
@@ -20,6 +23,8 @@ use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* Transporteur (M4 Transport) — entite racine du repertoire transporteurs,
|
||||
@@ -28,16 +33,17 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
* (is_archived / archived_at) et le soft-delete technique prepare mais non
|
||||
* expose au M4 (deleted_at).
|
||||
*
|
||||
* Perimetre WT3 (ERP-155/157) = CONTRAT DE LECTURE uniquement : l'#[ApiResource]
|
||||
* n'expose que GetCollection + Get (via CarrierProvider). La creation /
|
||||
* modification (POST/PATCH + CarrierProcessor : normalisation, RG-4.01→4.14,
|
||||
* 409 doublon, gating archive) et les sous-ressources d'ecriture
|
||||
* (adresses/contacts/prix) arrivent aux worktrees suivants (WT4+). C'est
|
||||
* pourquoi les proprietes ne portent ICI que des read-groups (carrier:read /
|
||||
* carrier:item:read / qualimat:read), sans groupe d'ecriture ni contrainte
|
||||
* Assert de validation (qui appartiennent au flux d'ecriture). Les invariants
|
||||
* BDD (NOT NULL, CHECK enum, FK, unicite partielle) restent garantis par la
|
||||
* migration Version20260615150000.
|
||||
* Perimetre WT4 (ERP-158) = formulaire principal en ecriture. L'#[ApiResource]
|
||||
* expose desormais Post + Patch (via CarrierProcessor : normalisation RG-4.13,
|
||||
* gating archive mode strict RG-4.14, 409 doublon de nom RG-4.12) en plus du
|
||||
* contrat de lecture pose au WT3. Les proprietes du formulaire principal portent
|
||||
* leur groupe d'ecriture (carrier:write:main / carrier:write:archive) et leurs
|
||||
* contraintes Assert ; les RG conditionnelles (RG-4.01 certification obligatoire
|
||||
* sauf LIOT, RG-4.02 AUTRE -> decharge, RG-4.03 affrete -> indexation/benne/volume)
|
||||
* sont portees par validateMainFormConsistency (Assert\Callback + ->atPath()).
|
||||
* Les sous-ressources d'ecriture (adresses/contacts/prix) arrivent aux worktrees
|
||||
* suivants (WT6/7/8). Les invariants BDD (NOT NULL, CHECK enum, FK, unicite
|
||||
* partielle) restent garantis par la migration Version20260615150000.
|
||||
*
|
||||
* Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) :
|
||||
* - LISTE (carrier:read + qualimat:read + default:read) : name, certificationType,
|
||||
@@ -79,7 +85,27 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
]],
|
||||
provider: CarrierProvider::class,
|
||||
),
|
||||
// Pas de Post/Patch/Delete au WT3 (lecture seule). Ecriture + archivage : WT4.
|
||||
new Post(
|
||||
// Creation du formulaire principal (RG-4.01 -> RG-4.03 / RG-4.12 /
|
||||
// RG-4.13). La reponse 201 ne renvoie que les scalaires principaux +
|
||||
// id : le front enchaine ensuite les sous-ressources par onglet.
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => ['carrier:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['carrier:write:main']],
|
||||
processor: CarrierProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
// Security elargie au seul `manage` (Admin + Bureau). Le CarrierProcessor
|
||||
// re-gate ensuite l'archivage : un payload basculant isArchived exige
|
||||
// `transport.carriers.archive` (Admin seul -> Bureau recoit 403, mode
|
||||
// strict RG-4.14).
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => ['carrier:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['carrier:write:main', 'carrier:write:archive']],
|
||||
provider: CarrierProvider::class,
|
||||
processor: CarrierProcessor::class,
|
||||
),
|
||||
// Pas de Delete au M4 (HP-M4-C). Archivage via PATCH { isArchived: true }.
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineCarrierRepository::class)]
|
||||
@@ -95,6 +121,12 @@ class Carrier implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
/** RG-4.01 : nom du cas special « compte-propre » LIOT (comparaison insensible a la casse). */
|
||||
private const string LIOT_NAME = 'LIOT';
|
||||
|
||||
/** RG-4.02 : valeur de certification imposant le champ Decharge. */
|
||||
private const string CERTIFICATION_AUTRE = 'AUTRE';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
@@ -102,47 +134,65 @@ class Carrier implements TimestampableInterface, BlamableInterface
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['carrier:read'])]
|
||||
#[Assert\NotBlank(message: 'Le nom du transporteur est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(
|
||||
min: 2,
|
||||
max: 255,
|
||||
minMessage: 'Le nom du transporteur doit contenir au moins {{ limit }} caractères.',
|
||||
maxMessage: 'Le nom du transporteur ne peut dépasser {{ limit }} caractères.',
|
||||
normalizer: 'trim',
|
||||
)]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $name = null;
|
||||
|
||||
/** Lien editable vers le referentiel QUALIMAT (saisie assistee RG-4.01, § 2.5). */
|
||||
#[ORM\ManyToOne(targetEntity: QualimatCarrier::class)]
|
||||
#[ORM\JoinColumn(name: 'qualimat_carrier_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['carrier:read'])]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?QualimatCarrier $qualimatCarrier = null;
|
||||
|
||||
/** QUALIMAT|GMP_PLUS|OVOCOM|COMPTE_PROPRE|AUTRE ; null en cas LIOT (RG-4.01). */
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
#[Groups(['carrier:read'])]
|
||||
#[Assert\Choice(
|
||||
choices: ['QUALIMAT', 'GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'],
|
||||
message: 'Type de certification invalide.',
|
||||
)]
|
||||
// Obligatoire SAUF en cas LIOT (champ masque) : controle conditionnel dans
|
||||
// validateMainFormConsistency (RG-4.01). La longueur est bornee par le Choice
|
||||
// (whitelist EntityConstraintsHaveFrenchMessageTest::EXCLUDED_LENGTH_MIRROR).
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $certificationType = null;
|
||||
|
||||
#[ORM\Column(name: 'is_chartered', options: ['default' => false])]
|
||||
#[Groups(['carrier:write:main'])]
|
||||
private bool $isChartered = false;
|
||||
|
||||
/** % d'indexation — renseigne si affrete (RG-4.03). */
|
||||
/** % d'indexation — obligatoire si affrete (RG-4.03, validateMainFormConsistency). */
|
||||
#[ORM\Column(name: 'indexation_rate', type: 'decimal', precision: 5, scale: 2, nullable: true)]
|
||||
#[Groups(['carrier:read'])]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $indexationRate = null;
|
||||
|
||||
/** BENNE|FOND_MOUVANT — renseigne si affrete (RG-4.03). */
|
||||
/** BENNE|FOND_MOUVANT — obligatoire si affrete (RG-4.03). */
|
||||
#[ORM\Column(name: 'container_type', length: 12, nullable: true)]
|
||||
#[Groups(['carrier:read'])]
|
||||
#[Assert\Choice(choices: ['BENNE', 'FOND_MOUVANT'], message: 'Type de contenant invalide.')]
|
||||
// Longueur bornee par le Choice (whitelist EXCLUDED_LENGTH_MIRROR).
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $containerType = null;
|
||||
|
||||
/** Volume m3 — renseigne si affrete (RG-4.03). */
|
||||
/** Volume m3 — obligatoire si affrete (RG-4.03). */
|
||||
#[ORM\Column(name: 'volume_m3', type: 'decimal', precision: 10, scale: 2, nullable: true)]
|
||||
#[Groups(['carrier:read'])]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $volumeM3 = null;
|
||||
|
||||
/** Decharge (upload, visible si certificationType = AUTRE — RG-4.02). Infra Shared (§ 2.7). */
|
||||
#[ORM\ManyToOne(targetEntity: UploadedDocument::class)]
|
||||
#[ORM\JoinColumn(name: 'discharge_document_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['carrier:read'])]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?UploadedDocument $dischargeDocument = null;
|
||||
|
||||
/** Immatriculations LIOT separees par « ; » (cas LIOT — RG-4.01). */
|
||||
#[ORM\Column(name: 'liot_plates', type: 'text', nullable: true)]
|
||||
#[Groups(['carrier:read'])]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $liotPlates = null;
|
||||
|
||||
// === Sous-collections — EMBARQUEES dans le DETAIL (read-group sur le getter) ===
|
||||
@@ -159,7 +209,11 @@ class Carrier implements TimestampableInterface, BlamableInterface
|
||||
private Collection $prices;
|
||||
|
||||
// === Archive / Soft delete ===
|
||||
// Le groupe de LECTURE est declare sur le getter isArchived() avec
|
||||
// SerializedName('isArchived') (piege booleen #3 M1) ; le groupe d'ECRITURE
|
||||
// vit sur la propriete pour que la denormalisation cible setIsArchived.
|
||||
#[ORM\Column(name: 'is_archived', options: ['default' => false])]
|
||||
#[Groups(['carrier:write:archive'])]
|
||||
private bool $isArchived = false;
|
||||
|
||||
#[ORM\Column(name: 'archived_at', type: 'datetime_immutable', nullable: true)]
|
||||
@@ -176,6 +230,65 @@ class Carrier implements TimestampableInterface, BlamableInterface
|
||||
$this->prices = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Coherence conditionnelle du formulaire principal (RG-4.01 / RG-4.02 /
|
||||
* RG-4.03). Decision figee (miroir M2 RG-2.07/2.08) : ces RG inter-champs
|
||||
* passent par une contrainte d'entite (Assert\Callback + ->atPath()) et NON par
|
||||
* le CarrierProcessor, afin que chaque 422 porte un propertyPath exploitable
|
||||
* par useFormErrors (mapping inline sous le champ, pas un toast — ERP-101).
|
||||
* Jouee par API Platform AVANT le processor, sur POST comme sur PATCH.
|
||||
*
|
||||
* Cas LIOT (RG-4.01) : seul liotPlates est pertinent ; les autres champs sont
|
||||
* masques cote front et le back ne les valide pas (« stocke ce qu'il recoit,
|
||||
* pas de 422 sur la presence residuelle »). Le nom est compare en majuscules
|
||||
* car la normalisation UPPER n'intervient qu'au processor (apres validation).
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateMainFormConsistency(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (self::LIOT_NAME === mb_strtoupper(trim((string) $this->name), 'UTF-8')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// RG-4.01 : certification obligatoire hors cas LIOT.
|
||||
if (null === $this->certificationType || '' === $this->certificationType) {
|
||||
$context->buildViolation('Le type de certification est obligatoire.')
|
||||
->atPath('certificationType')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
// RG-4.02 : certification AUTRE -> decharge obligatoire.
|
||||
if (self::CERTIFICATION_AUTRE === $this->certificationType && null === $this->dischargeDocument) {
|
||||
$context->buildViolation('La décharge est obligatoire pour une certification « Autre ».')
|
||||
->atPath('dischargeDocument')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
// RG-4.03 : affrete -> indexation + benne/fond mouvant + volume obligatoires.
|
||||
if ($this->isChartered) {
|
||||
if (null === $this->indexationRate) {
|
||||
$context->buildViolation('Le taux d\'indexation est obligatoire pour un transporteur affrété.')
|
||||
->atPath('indexationRate')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
if (null === $this->containerType) {
|
||||
$context->buildViolation('Le type de contenant est obligatoire pour un transporteur affrété.')
|
||||
->atPath('containerType')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
if (null === $this->volumeM3) {
|
||||
$context->buildViolation('Le volume est obligatoire pour un transporteur affrété.')
|
||||
->atPath('volumeM3')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
|
||||
@@ -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,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 :
|
||||
|
||||
@@ -23,11 +23,19 @@ interface CarrierRepositoryInterface
|
||||
* Fetch-join uniquement qualimatCarrier (ManyToOne, sur — § 2.11) : la liste
|
||||
* n'embarque aucune sous-collection. Tri par defaut name ASC.
|
||||
*
|
||||
* Perimetre d'archivage (aligne sur ClientProvider/SupplierProvider/
|
||||
* ProviderProvider — toggle « Voir les archives » d'ERP-173) :
|
||||
* - $archivedOnly = true -> uniquement les archives (is_archived = true) ;
|
||||
* - sinon $includeArchived = true -> actifs + archives (echappatoire) ;
|
||||
* - par defaut -> actifs seuls (is_archived = false).
|
||||
* $archivedOnly a la priorite sur $includeArchived.
|
||||
*
|
||||
* @param list<string> $certificationTypes filtre repetable (OR) sur certificationType
|
||||
*/
|
||||
public function createListQueryBuilder(
|
||||
bool $includeArchived = false,
|
||||
?string $search = null,
|
||||
array $certificationTypes = [],
|
||||
bool $archivedOnly = false,
|
||||
): QueryBuilder;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use JsonException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture du repertoire transporteurs (M4, formulaire principal). Cf.
|
||||
* spec-back M4 § 4.3 / § 4.4 + RG-4.12 / RG-4.13 / RG-4.14. Jumeau du
|
||||
* SupplierProcessor (M2), recentre sur le perimetre WT4 (formulaire principal :
|
||||
* normalisation, gating archive, 409 doublon de nom).
|
||||
*
|
||||
* Sequence (POST / PATCH) :
|
||||
* 1. Gating archive (mode strict RG-4.14). La security d'operation laisse entrer
|
||||
* `transport.carriers.manage` (Admin + Bureau) ; ce processor re-gate
|
||||
* finement : un payload basculant `isArchived` exige `transport.carriers.archive`
|
||||
* (Admin seul) -> 403, et une requete d'archivage ne peut modifier aucun autre
|
||||
* champ -> 422. C'est ce qui empeche Bureau d'archiver (manage sans archive).
|
||||
* 2. Normalisation serveur (RG-4.13) via CarrierFieldNormalizer (name UPPER,
|
||||
* liotPlates « ; »-normalise). Les champs personne / telephone / email sont
|
||||
* portes par les sous-ressources Contact (WT7), pas par le formulaire principal.
|
||||
* 3. Pose / retrait de archivedAt (RG-4.14 true=now, false=null).
|
||||
* 4. Persistance via le persist_processor Doctrine, avec traduction des
|
||||
* collisions d'unicite partielle (uq_carrier_name_active) en 409 (RG-4.12
|
||||
* doublon de nom ; conflit de restauration).
|
||||
*
|
||||
* Les RG conditionnelles du formulaire principal (RG-4.01 certification obligatoire
|
||||
* sauf LIOT, RG-4.02 AUTRE -> decharge, RG-4.03 affrete -> indexation/benne/volume)
|
||||
* sont portees par un Assert\Callback + ->atPath() sur l'entite Carrier (joue par
|
||||
* API Platform AVANT ce processor), pour que chaque 422 porte un propertyPath
|
||||
* consommable par useFormErrors (mapping inline, pas un toast — convention ERP-101).
|
||||
*
|
||||
* @implements ProcessorInterface<Carrier, Carrier>
|
||||
*/
|
||||
final class CarrierProcessor implements ProcessorInterface
|
||||
{
|
||||
/** Champs ecrivables du formulaire principal (groupe carrier:write:main). */
|
||||
private const array MAIN_FIELDS = [
|
||||
'name', 'qualimatCarrier', 'certificationType', 'isChartered',
|
||||
'indexationRate', 'containerType', 'volumeM3', 'dischargeDocument', 'liotPlates',
|
||||
];
|
||||
|
||||
/** Champ d'archivage (groupe carrier:write:archive). */
|
||||
private const string ARCHIVE_FIELD = 'isArchived';
|
||||
|
||||
private const string PERM_ARCHIVE = 'transport.carriers.archive';
|
||||
|
||||
/**
|
||||
* Memoisation du dernier corps de requete decode, clos par le contenu brut.
|
||||
* payloadKeys() est appele plusieurs fois par requete : on evite de rejouer
|
||||
* json_decode. La cle etant le contenu lui-meme et le calcul une fonction pure
|
||||
* de ce contenu, aucune fuite n'est possible entre requetes sur ce service
|
||||
* partage (un meme corps redonne les memes cles).
|
||||
*/
|
||||
private ?string $decodedContent = null;
|
||||
|
||||
/** @var list<string> Cles de premier niveau correspondant au corps memoise. */
|
||||
private array $decodedPayloadKeys = [];
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly CarrierFieldNormalizer $normalizer,
|
||||
private readonly Security $security,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof Carrier) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
// Reinitialisation de la memoisation du payload en debut de traitement :
|
||||
// le service est partage (stateful), on repart du corps de LA requete
|
||||
// courante et on n'herite jamais des cles decodees d'une requete passee.
|
||||
$this->decodedContent = null;
|
||||
$this->decodedPayloadKeys = [];
|
||||
|
||||
$isArchiveRequest = $this->guardArchive($data, $this->writablePayloadKeys());
|
||||
|
||||
$this->normalize($data);
|
||||
|
||||
try {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
// Le seul index unique partiel est uq_carrier_name_active
|
||||
// (LOWER(name) parmi non-archives/non-deletes — § 2.6).
|
||||
if ($isArchiveRequest && false === $data->isArchived()) {
|
||||
// RG-4.14 : restauration en conflit avec un homonyme actif.
|
||||
throw new ConflictHttpException(
|
||||
'Restauration impossible : un autre transporteur a pris le nom entre-temps.',
|
||||
$e,
|
||||
);
|
||||
}
|
||||
|
||||
// RG-4.12 : doublon de nom de transporteur.
|
||||
throw new ConflictHttpException(
|
||||
sprintf('Un transporteur nommé "%s" existe déjà.', (string) $data->getName()),
|
||||
$e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.14 : si le payload bascule reellement isArchived, exige la permission
|
||||
* archive (403), interdit toute autre modification (422) et pose/retire
|
||||
* archivedAt. Retourne true si la requete est une requete d'archivage.
|
||||
*
|
||||
* Le gating est restreint a la mise a jour d'un transporteur existant ET au
|
||||
* seul cas ou isArchived change vraiment : un POST (entite non encore geree
|
||||
* par l'ORM) ou un PATCH « representation complete » renvoyant isArchived
|
||||
* inchange ne doit declencher ni 403 ni 422 parasite.
|
||||
*
|
||||
* @param list<string> $writableKeys cles ecrivables du payload (hors @* et champs inconnus)
|
||||
*/
|
||||
private function guardArchive(Carrier $data, array $writableKeys): bool
|
||||
{
|
||||
// POST / entite non geree : l'archivage est une action de mise a jour.
|
||||
if (!$this->em->contains($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// isArchived inchange par rapport a l'etat persiste : pas une requete
|
||||
// d'archivage (cas du PATCH representation complete).
|
||||
if (!$this->fieldChanged($data, 'isArchived', $data->isArchived())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted(self::PERM_ARCHIVE)) {
|
||||
throw new AccessDeniedHttpException(sprintf(
|
||||
'Le champ "%s" requiert la permission "%s".',
|
||||
self::ARCHIVE_FIELD,
|
||||
self::PERM_ARCHIVE,
|
||||
));
|
||||
}
|
||||
|
||||
// RG-4.14 : une requete d'archivage ne modifie aucun autre champ ecrivable.
|
||||
if ([] !== array_diff($writableKeys, [self::ARCHIVE_FIELD])) {
|
||||
throw new UnprocessableEntityHttpException(
|
||||
'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".',
|
||||
);
|
||||
}
|
||||
|
||||
// RG-4.14 (true -> now) / (false -> null).
|
||||
$data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisation serveur du formulaire principal (RG-4.13). name (non-nullable)
|
||||
* et liotPlates (cas LIOT) sont les seuls champs texte du formulaire principal ;
|
||||
* le contact (personne / telephone / email) est normalise par son propre
|
||||
* processor (sous-ressource, WT7). Les setters ne sont touches que si une valeur
|
||||
* est presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel.
|
||||
*/
|
||||
private function normalize(Carrier $data): void
|
||||
{
|
||||
if (null !== $data->getName()) {
|
||||
$data->setName((string) $this->normalizer->normalizeName($data->getName()));
|
||||
}
|
||||
|
||||
if (null !== $data->getLiotPlates()) {
|
||||
$data->setLiotPlates($this->normalizer->normalizeLiotPlates($data->getLiotPlates()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cles ecrivables effectivement presentes dans le payload : on retire les cles
|
||||
* JSON-LD (@id, @context...) et tout champ non rattache a un groupe d'ecriture
|
||||
* connu. C'est la base du 422 d'archivage (RG-4.14) — sans elles, un PATCH
|
||||
* « representation complete » porteur de @id ferait croire a une modification
|
||||
* multi-champs.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function writablePayloadKeys(): array
|
||||
{
|
||||
$writable = array_merge(self::MAIN_FIELDS, [self::ARCHIVE_FIELD]);
|
||||
|
||||
return array_values(array_intersect($this->payloadKeys(), $writable));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si la valeur courante d'un champ differe de l'etat persiste. Pour une
|
||||
* entite non geree (creation/POST), l'etat persiste est vide : toute valeur
|
||||
* non-null est alors un changement.
|
||||
*/
|
||||
private function fieldChanged(Carrier $data, string $field, mixed $newValue): bool
|
||||
{
|
||||
$original = $this->originalData($data);
|
||||
|
||||
return $newValue !== ($original[$field] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot des valeurs persistees de l'entite (telles que chargees, avant
|
||||
* application du payload). Vide pour une entite non geree (POST).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function originalData(Carrier $data): array
|
||||
{
|
||||
if (!$this->em->contains($data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->em->getUnitOfWork()->getOriginalEntityData($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cles de premier niveau effectivement envoyees par le client (payload JSON
|
||||
* brut), filtrage compris. Pour un PATCH merge-patch+json, ce sont les seuls
|
||||
* champs modifies.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function payloadKeys(): array
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
if (null === $request) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = $request->getContent();
|
||||
|
||||
// Cache hit : meme corps brut que le dernier decodage -> memes cles.
|
||||
if ($content === $this->decodedContent) {
|
||||
return $this->decodedPayloadKeys;
|
||||
}
|
||||
|
||||
$this->decodedContent = $content;
|
||||
$this->decodedPayloadKeys = $this->extractPayloadKeys($content);
|
||||
|
||||
return $this->decodedPayloadKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode le corps brut et en extrait les cles de premier niveau (chaines).
|
||||
* Corps vide ou JSON invalide -> aucune cle.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractPayloadKeys(string $content): array
|
||||
{
|
||||
if ('' === $content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
* Collection (GET /api/carriers) :
|
||||
* - exclut par defaut les archives (is_archived = true) ET les soft-deletes ;
|
||||
* - ?includeArchived=true reintegre les archives (soft-deletes toujours exclus) ;
|
||||
* - ?archivedOnly=true n'expose QUE les archives (prioritaire sur includeArchived,
|
||||
* aligne sur Client/Supplier/Provider — toggle « Voir les archives » ERP-173) ;
|
||||
* - filtres ?search= (fuzzy name) et ?certificationType= (repetable) ;
|
||||
* - tri par defaut name ASC ; pagination Hydra (regle n°13) + echappatoire
|
||||
* ?pagination=false.
|
||||
@@ -58,6 +60,7 @@ final class CarrierProvider implements ProviderInterface
|
||||
{
|
||||
$filters = $context['filters'] ?? [];
|
||||
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
|
||||
$archivedOnly = $this->readBool($filters['archivedOnly'] ?? false);
|
||||
$search = $filters['search'] ?? null;
|
||||
$certificationTypes = $this->readStringList($filters['certificationType'] ?? []);
|
||||
|
||||
@@ -65,11 +68,12 @@ final class CarrierProvider implements ProviderInterface
|
||||
$includeArchived,
|
||||
is_string($search) ? $search : null,
|
||||
$certificationTypes,
|
||||
$archivedOnly,
|
||||
);
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete (selects front).
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
/** @var list<Carrier> $carriers */
|
||||
// @var list<Carrier> $carriers
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
|
||||
+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));
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ class DoctrineCarrierRepository extends ServiceEntityRepository implements Carri
|
||||
bool $includeArchived = false,
|
||||
?string $search = null,
|
||||
array $certificationTypes = [],
|
||||
bool $archivedOnly = false,
|
||||
): QueryBuilder {
|
||||
// Fetch-join de la SEULE relation ManyToOne qualimatCarrier (sur, pas de
|
||||
// cartesien) pour exposer statut/date de validite QUALIMAT en liste sans
|
||||
@@ -47,7 +48,11 @@ class DoctrineCarrierRepository extends ServiceEntityRepository implements Carri
|
||||
;
|
||||
|
||||
// Pas de cloisonnement par site (§ 2.3) : referentiel global.
|
||||
if (!$includeArchived) {
|
||||
// Perimetre d'archivage : archivedOnly prioritaire sur includeArchived
|
||||
// (jumeau de DoctrineProviderRepository — toggle « Voir les archives »).
|
||||
if ($archivedOnly) {
|
||||
$qb->andWhere('c.isArchived = true');
|
||||
} elseif (!$includeArchived) {
|
||||
$qb->andWhere('c.isArchived = 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)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -56,8 +56,14 @@ 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.',
|
||||
// Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres.
|
||||
'Site::color' => 'Regex code hex #RRGGBB borne deja la longueur.',
|
||||
];
|
||||
|
||||
@@ -31,7 +31,8 @@ use DateTimeImmutable;
|
||||
*/
|
||||
abstract class AbstractCarrierApiTestCase extends AbstractApiTestCase
|
||||
{
|
||||
protected const string LD = 'application/ld+json';
|
||||
protected const string LD = 'application/ld+json';
|
||||
protected const string MERGE = 'application/merge-patch+json';
|
||||
|
||||
/** Prefixe SIRET des lignes qualimat_carrier seedees par les tests (purge ciblee). */
|
||||
private const string TEST_SIRET_PREFIX = 'TESTQ';
|
||||
@@ -63,6 +64,22 @@ abstract class AbstractCarrierApiTestCase extends AbstractApiTestCase
|
||||
return $this->authenticatedClient('admin', 'admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload minimal valide du formulaire principal (transporteur non-QUALIMAT,
|
||||
* non affrete) : nom + certification GMP_PLUS. Sert de base aux tests
|
||||
* d'ecriture / RBAC.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function validMainPayload(string $name): array
|
||||
{
|
||||
return [
|
||||
'name' => $name,
|
||||
'certificationType' => 'GMP_PLUS',
|
||||
'isChartered' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un transporteur minimal (nom en MAJUSCULES, comme le ferait le
|
||||
* futur Processor). Sert aux tests de liste / archivage.
|
||||
|
||||
@@ -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,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
/**
|
||||
* Archivage / restauration transporteur — trou 409 de restauration en conflit
|
||||
* d'unicite (M4, RG-4.14). Le nominal (archive pose archivedAt) et le 422
|
||||
* « archive + autre champ » sont couverts par CarrierWriteApiTest. Jumeau de
|
||||
* SupplierArchiveTest (M2).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierArchiveTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
/**
|
||||
* RG-4.14 : restaurer un transporteur archive dont le nom a ete repris par un
|
||||
* transporteur actif entre-temps doit echouer en 409 (index partiel
|
||||
* uq_carrier_name_active : un seul actif portant ce nom).
|
||||
*/
|
||||
public function testRestoreConflictReturns409(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$archived = $this->seedCarrier('Acme Conflict', true);
|
||||
$this->seedCarrier('Acme Conflict', false);
|
||||
|
||||
$client->request('PATCH', '/api/carriers/'.$archived->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => false],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?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 Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
/**
|
||||
* Matrice RBAC du repertoire transporteurs par role metier (spec-back M4 § 5.2 +
|
||||
* ERP-153/158). Valide 200/403 par verbe pour bureau / compta / commerciale /
|
||||
* usine ; l'archivage reste admin seul (gating CarrierProcessor, RG-4.14). Jumeau
|
||||
* de SupplierRBACMatrixTest (M2).
|
||||
*
|
||||
* Matrice § 5.2 — rappel :
|
||||
* - bureau : view + manage (PAS archive)
|
||||
* - commerciale : view seul (ni manage ni archive)
|
||||
* - compta : aucun acces (403 sur view ET manage)
|
||||
* - usine : aucun acces (403 partout)
|
||||
* - archive : admin seul
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierRBACMatrixTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Seed idempotent via la commande applicative (roles + matrice § 5.2 +
|
||||
// comptes demo) — meme chemin qu'en recette.
|
||||
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 : les permissions transport.carriers.* sont-elles synchronisees (app:sync-permissions) ?',
|
||||
);
|
||||
|
||||
self::ensureKernelShutdown();
|
||||
}
|
||||
|
||||
public function testUsineIsForbiddenEverywhere(): void
|
||||
{
|
||||
$seed = $this->seedCarrier('Usine Target');
|
||||
$client = $this->authAs('usine');
|
||||
|
||||
$client->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('GET', '/api/carriers/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Usine Post'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testComptaHasNoAccess(): void
|
||||
{
|
||||
$seed = $this->seedCarrier('Compta Target');
|
||||
$client = $this->authAs('compta');
|
||||
|
||||
// PAS view (matrice § 5.2 : Compta sans acces transporteurs).
|
||||
$client->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS manage : creation refusee.
|
||||
$client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Compta Post'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('PATCH', '/api/carriers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['name' => 'Renamed By Compta'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testBureauHasViewAndManageButNoArchive(): void
|
||||
{
|
||||
$seed = $this->seedCarrier('Bureau Target');
|
||||
$client = $this->authAs('bureau');
|
||||
|
||||
// view
|
||||
$client->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// manage : creation OK
|
||||
$client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Bureau Created'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// manage : edition formulaire principal OK
|
||||
$client->request('PATCH', '/api/carriers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['name' => 'Bureau Renamed'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// PAS archive : archivage refuse (RG-4.14, gating CarrierProcessor).
|
||||
$client->request('PATCH', '/api/carriers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testCommercialeHasViewOnly(): void
|
||||
{
|
||||
$seed = $this->seedCarrier('Commerciale Target');
|
||||
$client = $this->authAs('commerciale');
|
||||
|
||||
// view (consultation « Tout »)
|
||||
$client->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// PAS manage : creation refusee
|
||||
$client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Commerciale Post'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS manage : edition refusee
|
||||
$client->request('PATCH', '/api/carriers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['name' => 'Renamed By Commerciale'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
private function authAs(string $role): Client
|
||||
{
|
||||
return $this->authenticatedClient($role, self::PWD);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
/**
|
||||
* Ecriture du formulaire principal transporteur (M4, WT4 — ERP-158). Couvre les
|
||||
* RG du CarrierProcessor + contraintes conditionnelles : RG-4.01 (QUALIMAT + cas
|
||||
* LIOT), RG-4.02 (AUTRE -> decharge), RG-4.03 (affrete -> indexation/benne/volume),
|
||||
* RG-4.12 (doublon de nom -> 409), RG-4.13 (normalisation), RG-4.14 (archivage +
|
||||
* mode strict). Jumeau des SupplierApiTest / SupplierPatchStrictTest (M2).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierWriteApiTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
/**
|
||||
* RG-4.01 : POST avec qualimatCarrier -> certificationType=QUALIMAT accepte et
|
||||
* FK persistee (verifiee au detail, qualimatCarrier embarque).
|
||||
*/
|
||||
public function testPostQualimatPersistsCertificationAndForeignKey(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$qualimat = $this->seedQualimatCarrier('Transports Grelillier');
|
||||
|
||||
$created = $client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'name' => 'Transports Grelillier',
|
||||
'qualimatCarrier' => '/api/qualimat_carriers/'.$qualimat->getId(),
|
||||
'certificationType' => 'QUALIMAT',
|
||||
'isChartered' => false,
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame('QUALIMAT', $created['certificationType']);
|
||||
|
||||
$detail = $client->request('GET', $created['@id'], ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertIsArray($detail['qualimatCarrier']);
|
||||
self::assertSame((int) $qualimat->getId(), (int) $detail['qualimatCarrier']['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.01 (cas LIOT) : nom = LIOT -> certificationType non requis (champ masque)
|
||||
* et liotPlates accepte (et normalise, RG-4.13).
|
||||
*/
|
||||
public function testPostLiotAcceptsPlatesWithoutCertification(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$created = $client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'name' => 'LIOT',
|
||||
'liotPlates' => 'ab-123-cd ; ef-456-gh',
|
||||
'isChartered' => false,
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertNull($created['certificationType']);
|
||||
self::assertSame('AB-123-CD; EF-456-GH', $created['liotPlates']);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.01 : hors cas LIOT, l'absence de certification est rejetee (422 cible
|
||||
* sur certificationType).
|
||||
*/
|
||||
public function testPostWithoutCertificationOutsideLiotIsRejected(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['name' => 'Sans Certif', 'isChartered' => false],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertViolationOnPath($response, 'certificationType');
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.02 : certificationType=AUTRE sans dischargeDocument -> 422 cible ; une
|
||||
* certification != AUTRE sans decharge passe (201).
|
||||
*/
|
||||
public function testAutreCertificationRequiresDischarge(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['name' => 'Sans Decharge', 'certificationType' => 'AUTRE', 'isChartered' => false],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertViolationOnPath($response, 'dischargeDocument');
|
||||
|
||||
// Certification != AUTRE : pas de decharge requise.
|
||||
$client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['name' => 'Avec GmpPlus', 'certificationType' => 'GMP_PLUS', 'isChartered' => false],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.03 : isChartered=true sans indexationRate / containerType / volumeM3 ->
|
||||
* 422 (violations ciblees) ; complet -> 201.
|
||||
*/
|
||||
public function testCharteredRequiresIndexationContainerAndVolume(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['name' => 'Affrete Incomplet', 'certificationType' => 'GMP_PLUS', 'isChartered' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertViolationOnPath($response, 'indexationRate');
|
||||
self::assertViolationOnPath($response, 'containerType');
|
||||
self::assertViolationOnPath($response, 'volumeM3');
|
||||
|
||||
$client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'name' => 'Affrete Complet',
|
||||
'certificationType' => 'GMP_PLUS',
|
||||
'isChartered' => true,
|
||||
'indexationRate' => '5.00',
|
||||
'containerType' => 'BENNE',
|
||||
'volumeM3' => '90.00',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.12 : nom deja pris (parmi actifs) -> 409. Le meme nom redevient
|
||||
* disponible apres archivage de l'ancien -> 201.
|
||||
*/
|
||||
public function testDuplicateNameReturns409AndIsFreedAfterArchive(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$existing = $this->seedCarrier('Doublon Co');
|
||||
|
||||
$client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Doublon Co'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
|
||||
// Archivage de l'ancien -> le nom se libere (index partiel sur actifs).
|
||||
$client->request('PATCH', '/api/carriers/'.$existing->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
$client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Doublon Co'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.13 : le nom est persiste en MAJUSCULES (normalisation serveur).
|
||||
*/
|
||||
public function testNameIsUpperCasedOnPersist(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$created = $client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('transports x'),
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame('TRANSPORTS X', $created['name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.14 : PATCH isArchived=true par Admin -> 200 + archivedAt rempli ;
|
||||
* restauration -> archivedAt remis a null.
|
||||
*/
|
||||
public function testAdminArchiveSetsArchivedAtAndRestoreClearsIt(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$carrier = $this->seedCarrier('A Archiver');
|
||||
|
||||
$archived = $client->request('PATCH', '/api/carriers/'.$carrier->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
])->toArray();
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
self::assertTrue($archived['isArchived']);
|
||||
self::assertNotNull($archived['archivedAt']);
|
||||
|
||||
$restored = $client->request('PATCH', '/api/carriers/'.$carrier->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => false],
|
||||
])->toArray();
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
self::assertFalse($restored['isArchived']);
|
||||
self::assertNull($restored['archivedAt']);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.14 (mode strict) : une requete d'archivage ne peut modifier aucun autre
|
||||
* champ ecrivable -> 422.
|
||||
*/
|
||||
public function testArchiveRequestMixingOtherFieldIsRejected(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$carrier = $this->seedCarrier('Strict Co');
|
||||
|
||||
$client->request('PATCH', '/api/carriers/'.$carrier->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true, 'name' => 'Renamed While Archiving'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifie qu'une violation 422 cible bien la propriete attendue (propertyPath),
|
||||
* gage du mapping inline front (useFormErrors, ERP-101).
|
||||
*/
|
||||
private function assertViolationOnPath(object $response, string $path): void
|
||||
{
|
||||
/** @var \Symfony\Contracts\HttpClient\ResponseInterface $response */
|
||||
$paths = array_column($response->toArray(false)['violations'] ?? [], 'propertyPath');
|
||||
|
||||
self::assertContains(
|
||||
$path,
|
||||
$paths,
|
||||
sprintf('Aucune violation sur "%s" (paths: %s).', $path, implode(', ', $paths)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Application;
|
||||
|
||||
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Normalisation serveur des champs texte du repertoire transporteurs (RG-4.13 +
|
||||
* cas LIOT RG-4.01). Jumeau de SupplierFieldNormalizerTest (M2), enrichi de
|
||||
* normalizeLiotPlates.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierFieldNormalizerTest extends TestCase
|
||||
{
|
||||
private CarrierFieldNormalizer $normalizer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->normalizer = new CarrierFieldNormalizer();
|
||||
}
|
||||
|
||||
public function testNameIsUpperCasedAndTrimmed(): void
|
||||
{
|
||||
self::assertSame('TRANSPORTS X', $this->normalizer->normalizeName(' transports x '));
|
||||
self::assertNull($this->normalizer->normalizeName(null));
|
||||
}
|
||||
|
||||
public function testPersonNameIsTitleCased(): void
|
||||
{
|
||||
self::assertSame('Jean Dupont', $this->normalizer->normalizePersonName('JEAN dupont'));
|
||||
self::assertNull($this->normalizer->normalizePersonName(' '));
|
||||
self::assertNull($this->normalizer->normalizePersonName(null));
|
||||
}
|
||||
|
||||
public function testEmailIsLowerCased(): void
|
||||
{
|
||||
self::assertSame('marie.martin@seed.test', $this->normalizer->normalizeEmail(' Marie.MARTIN@Seed.Test '));
|
||||
self::assertNull($this->normalizer->normalizeEmail(' '));
|
||||
self::assertNull($this->normalizer->normalizeEmail(null));
|
||||
}
|
||||
|
||||
public function testPhoneKeepsDigitsOnly(): void
|
||||
{
|
||||
self::assertSame('0612345678', $this->normalizer->normalizePhone('06.12.34.56.78'));
|
||||
self::assertSame('33612345678', $this->normalizer->normalizePhone('+33 6 12 34 56 78'));
|
||||
self::assertNull($this->normalizer->normalizePhone('sans chiffre'));
|
||||
self::assertNull($this->normalizer->normalizePhone(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.01 / RG-4.13 : la saisie « ; »-separee est decoupee, chaque plaque trim
|
||||
* + UPPER, segments vides ecartes, recomposee avec "; ".
|
||||
*/
|
||||
public function testLiotPlatesAreSplitTrimmedUpperedAndRejoined(): void
|
||||
{
|
||||
self::assertSame(
|
||||
'AB-123-CD; EF-456-GH',
|
||||
$this->normalizer->normalizeLiotPlates('ab-123-cd ; ef-456-gh'),
|
||||
);
|
||||
// Segments vides (« ;; » / fin de chaine) ecartes.
|
||||
self::assertSame('AB-123-CD', $this->normalizer->normalizeLiotPlates(' ab-123-cd ; ; '));
|
||||
self::assertNull($this->normalizer->normalizeLiotPlates(' ; ; '));
|
||||
self::assertNull($this->normalizer->normalizeLiotPlates(null));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user