feat(transport) : CarrierProcessor + champs conditionnels formulaire principal (ERP-158)
Ecriture du formulaire principal transporteur (M4, WT4) : POST/PATCH via CarrierProcessor + CarrierFieldNormalizer, contraintes conditionnelles sur l'entite Carrier. - RG-4.01 : POST qualimatCarrier -> certificationType=QUALIMAT + FK persistee ; cas LIOT (name=LIOT) -> certification non requise, liotPlates accepte. - RG-4.02 : certificationType=AUTRE sans dischargeDocument -> 422 (Assert\Callback). - RG-4.03 : isChartered=true sans indexationRate/containerType/volumeM3 -> 422. - RG-4.12 : doublon de nom (parmi actifs) -> 409 (index partiel uq_carrier_name_active). - RG-4.13 : normalisation serveur (name UPPER, liotPlates ;-split/trim/UPPER) + methodes personne/telephone/email pour les sous-ressources Contact (WT7). - RG-4.14 : PATCH isArchived exige transport.carriers.archive (Admin seul), mode strict -> 403 + 422 si autre champ ; restauration en conflit -> 409. Operations Post/Patch ajoutees a l'ApiResource (lecture posee au WT3 conservee). RG conditionnelles portees par validateMainFormConsistency (Assert\Callback + ->atPath()) pour un propertyPath mappable inline (useFormErrors, ERP-101). certificationType / containerType whitelistes dans EXCLUDED_LENGTH_MIRROR (Choice borne deja les valeurs, miroir SupplierAddress::addressType). Tests : CarrierWriteApiTest (RG-4.01->4.03/4.12->4.14), CarrierRBACMatrixTest (matrice bureau/compta/commerciale/usine), CarrierArchiveTest (409 restauration), CarrierFieldNormalizerTest (RG-4.13). make test vert (750).
This commit is contained in:
@@ -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')) : [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user