From 7dccc4edf4de998f4284cc70525c08dd273d9632 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 5 Jun 2026 11:57:25 +0200 Subject: [PATCH] feat(commercial) : sous-ressources M2 fournisseurs (contacts/adresses/ribs) (ERP-88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute les opérations API Platform et les Processors d'écriture des sous-collections du fournisseur (POST/PATCH/DELETE + GET unitaire) : - SupplierContactProcessor : rattachement parent, normalisation serveur (RG-2.12), validation RG-2.04 (prénom OU nom). DELETE libre (RG-2.13). - SupplierAddressProcessor : rattachement parent. RG-2.05/2.06/2.09 portées par les contraintes d'entité ; RG-2.10 (catégorie type FOURNISSEUR) via Assert\Callback validateCategoryType. - SupplierRibProcessor : rattachement parent, RG-2.08 (refus DELETE du dernier RIB sous LCR -> 409). Security différenciée : contacts/adresses -> commercial.suppliers.manage ; ribs -> commercial.suppliers.accounting.manage (+ .view pour le GET). POST en read:false (parent rattaché manuellement, 404 si absent) — parade NonUniqueResult du M1. Messages FR (ERP-107) + propertyPath aligné (ERP-101). --- .../Domain/Entity/SupplierAddress.php | 90 +++++++++++- .../Domain/Entity/SupplierContact.php | 57 +++++++- .../Commercial/Domain/Entity/SupplierRib.php | 52 +++++++ .../Processor/SupplierAddressProcessor.php | 90 ++++++++++++ .../Processor/SupplierContactProcessor.php | 135 ++++++++++++++++++ .../State/Processor/SupplierRibProcessor.php | 114 +++++++++++++++ 6 files changed, 532 insertions(+), 6 deletions(-) create mode 100644 src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierAddressProcessor.php create mode 100644 src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierContactProcessor.php create mode 100644 src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierRibProcessor.php diff --git a/src/Module/Commercial/Domain/Entity/SupplierAddress.php b/src/Module/Commercial/Domain/Entity/SupplierAddress.php index 549a2cf..40c63ee 100644 --- a/src/Module/Commercial/Domain/Entity/SupplierAddress.php +++ b/src/Module/Commercial/Domain/Entity/SupplierAddress.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Commercial\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\Commercial\Infrastructure\ApiPlatform\State\Processor\SupplierAddressProcessor; use App\Module\Commercial\Infrastructure\Doctrine\DoctrineSupplierAddressRepository; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; @@ -17,6 +24,7 @@ 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; /** * Adresse d'un fournisseur (1:n) — onglet Adresse. Le type d'adresse est un enum @@ -30,14 +38,60 @@ use Symfony\Component\Validator\Constraints as Assert; * un site obligatoire (RG-2.06, Assert\Count). Site n'a pas de `code`. * - contacts : SupplierContact (meme module). * - categories : CategoryInterface (module Catalog) via resolve_target_entities — - * type FOURNISSEUR attendu (RG-2.10, controle au Processor/Validator ERP-89). + * type FOURNISSEUR attendu (RG-2.10, Assert\Callback validateCategoryType). * * Embarquee sous `supplier.addresses` au detail (groupe supplier:item:read, - * maillon (a)). Edition via la sous-ressource (POST /api/suppliers/{id}/addresses, - * PATCH/DELETE /api/supplier_addresses/{id}), branchee a ERP-88. + * maillon (a)). + * + * Sous-ressource API (ERP-88, spec § 4.5) : + * - POST /api/suppliers/{supplierId}/addresses : creation rattachee au + * fournisseur parent (Link toProperty 'supplier'), security + * commercial.suppliers.manage. + * - PATCH / DELETE /api/supplier_addresses/{id} : security + * commercial.suppliers.manage. + * - GET /api/supplier_addresses/{id} : lecture unitaire (security view) — la + * lecture courante reste via le parent. Pas de GET collection autonome. + * Tout passe par le SupplierAddressProcessor (rattachement parent). Les regles + * RG-2.05/2.06/2.09/2.10 sont portees par les contraintes de l'entite (jouees + * avant le processor). * * Audite (#[Auditable]) + Timestampable / Blamable. */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('commercial.suppliers.view')", + // site:read + category:read : embarquent les Site / Category lies + // (maillon (c)) plutot que des IRI nus dans le retour. + normalizationContext: ['groups' => ['supplier:item:read', 'site:read', 'category:read', 'default:read']], + ), + new Post( + uriTemplate: '/suppliers/{supplierId}/addresses', + uriVariables: [ + 'supplierId' => new Link(fromClass: Supplier::class, toProperty: 'supplier'), + ], + // read:false : pas de stade lecture du parent. Le Link toProperty + // resoudrait l'enfant (SELECT SupplierAddress ... WHERE supplier = :id) + // et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache + // manuellement par SupplierAddressProcessor::linkParent (404 si absent). + read: false, + security: "is_granted('commercial.suppliers.manage')", + normalizationContext: ['groups' => ['supplier:item:read', 'site:read', 'category:read', 'default:read']], + denormalizationContext: ['groups' => ['supplier:write:addresses']], + processor: SupplierAddressProcessor::class, + ), + new Patch( + security: "is_granted('commercial.suppliers.manage')", + normalizationContext: ['groups' => ['supplier:item:read', 'site:read', 'category:read', 'default:read']], + denormalizationContext: ['groups' => ['supplier:write:addresses']], + processor: SupplierAddressProcessor::class, + ), + new Delete( + security: "is_granted('commercial.suppliers.manage')", + processor: SupplierAddressProcessor::class, + ), + ], +)] #[ORM\Entity(repositoryClass: DoctrineSupplierAddressRepository::class)] #[ORM\Table(name: 'supplier_address')] #[ORM\Index(name: 'idx_supplier_address_supplier', columns: ['supplier_id'])] @@ -53,6 +107,13 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface */ public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU']; + /** + * RG-2.10 : seules les categories de ce type sont autorisees sur une adresse + * fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCode() (pas + * d'import du module Catalog — regle ABSOLUE n°1). + */ + private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR'; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] @@ -154,6 +215,29 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface $this->categories = new ArrayCollection(); } + /** + * RG-2.10 : toute categorie posee sur une adresse fournisseur doit etre de + * type FOURNISSEUR -> sinon 422 avec violation sur le champ `categories` + * (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur + * CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog — + * regle ABSOLUE n°1). Joue avant la base via la validation API Platform. + */ + #[Assert\Callback] + public function validateCategoryType(ExecutionContextInterface $context): void + { + foreach ($this->categories as $category) { + if ($category instanceof CategoryInterface + && self::REQUIRED_CATEGORY_TYPE_CODE !== $category->getCategoryTypeCode()) { + $context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).') + ->atPath('categories') + ->addViolation() + ; + + return; + } + } + } + public function getId(): ?int { return $this->id; diff --git a/src/Module/Commercial/Domain/Entity/SupplierContact.php b/src/Module/Commercial/Domain/Entity/SupplierContact.php index 37a51ce..de48188 100644 --- a/src/Module/Commercial/Domain/Entity/SupplierContact.php +++ b/src/Module/Commercial/Domain/Entity/SupplierContact.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Commercial\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\Commercial\Infrastructure\ApiPlatform\State\Processor\SupplierContactProcessor; use App\Module\Commercial\Infrastructure\Doctrine\DoctrineSupplierContactRepository; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; @@ -20,12 +27,56 @@ use Symfony\Component\Validator\Constraints as Assert; * permissive (les deux champs sont nullable). * * Embarque sous `supplier.contacts` au detail (groupe supplier:item:read, - * maillon (a) du contrat de serialisation). Edition via la sous-ressource - * (POST /api/suppliers/{id}/contacts, PATCH/DELETE /api/supplier_contacts/{id}), - * branchee a ERP-88 (l'#[ApiResource] sera ajoute alors). + * maillon (a) du contrat de serialisation). + * + * Sous-ressource API (ERP-88, spec § 4.5) : + * - POST /api/suppliers/{supplierId}/contacts : creation rattachee au + * fournisseur parent (Link toProperty 'supplier'), security + * commercial.suppliers.manage. + * - PATCH / DELETE /api/supplier_contacts/{id} : security + * commercial.suppliers.manage. Le DELETE est physique et libre (pas de garde + * « dernier contact » au M2 — RG-2.13 front-driven, la collection peut rester + * vide cote back). + * - GET /api/supplier_contacts/{id} : lecture unitaire (security view) — la + * lecture courante reste via le parent (le fournisseur embarque ses contacts). + * Pas de GET collection autonome. + * Tout passe par le SupplierContactProcessor (normalisation RG-2.12, RG-2.04). * * Audite (#[Auditable]) + Timestampable / Blamable (pattern Shared standard). */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('commercial.suppliers.view')", + normalizationContext: ['groups' => ['supplier:item:read']], + ), + new Post( + uriTemplate: '/suppliers/{supplierId}/contacts', + uriVariables: [ + 'supplierId' => new Link(fromClass: Supplier::class, toProperty: 'supplier'), + ], + // read:false : pas de stade lecture du parent. Le Link toProperty + // resoudrait l'enfant (SELECT SupplierContact ... WHERE supplier = :id) + // et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache + // manuellement par SupplierContactProcessor::linkParent (404 si absent). + read: false, + security: "is_granted('commercial.suppliers.manage')", + normalizationContext: ['groups' => ['supplier:item:read']], + denormalizationContext: ['groups' => ['supplier:write:contacts']], + processor: SupplierContactProcessor::class, + ), + new Patch( + security: "is_granted('commercial.suppliers.manage')", + normalizationContext: ['groups' => ['supplier:item:read']], + denormalizationContext: ['groups' => ['supplier:write:contacts']], + processor: SupplierContactProcessor::class, + ), + new Delete( + security: "is_granted('commercial.suppliers.manage')", + processor: SupplierContactProcessor::class, + ), + ], +)] #[ORM\Entity(repositoryClass: DoctrineSupplierContactRepository::class)] #[ORM\Table(name: 'supplier_contact')] #[ORM\Index(name: 'idx_supplier_contact_supplier', columns: ['supplier_id'])] diff --git a/src/Module/Commercial/Domain/Entity/SupplierRib.php b/src/Module/Commercial/Domain/Entity/SupplierRib.php index 60806d7..7ef1a2f 100644 --- a/src/Module/Commercial/Domain/Entity/SupplierRib.php +++ b/src/Module/Commercial/Domain/Entity/SupplierRib.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Commercial\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\Commercial\Infrastructure\ApiPlatform\State\Processor\SupplierRibProcessor; use App\Module\Commercial\Infrastructure\Doctrine\DoctrineSupplierRibRepository; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; @@ -24,9 +31,54 @@ use Symfony\Component\Validator\Constraints as Assert; * piege n°4 du M1). Aucun #[AuditIgnore] sur iban/bic : l'audit etant admin-only, * la tracabilite RIB est conservee (decision M1 reportee, § 2.7). * + * Sous-ressource API (ERP-88, spec § 4.5) — gating comptable renforce : + * - POST /api/suppliers/{supplierId}/ribs : creation rattachee au fournisseur + * parent (Link toProperty 'supplier'), security + * commercial.suppliers.accounting.manage. + * - PATCH / DELETE /api/supplier_ribs/{id} : security + * commercial.suppliers.accounting.manage. Le DELETE refuse la suppression du + * dernier RIB sous LCR (RG-2.08, 409). + * - GET /api/supplier_ribs/{id} : lecture unitaire, security + * commercial.suppliers.accounting.view (donnees bancaires sensibles). Pas de + * GET collection autonome. + * Tout passe par le SupplierRibProcessor (RG-2.08 sur DELETE). + * * Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony (pas de controle * banque reelle). Audite (#[Auditable]) + Timestampable / Blamable. */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('commercial.suppliers.accounting.view')", + normalizationContext: ['groups' => ['supplier:read:accounting']], + ), + new Post( + uriTemplate: '/suppliers/{supplierId}/ribs', + uriVariables: [ + 'supplierId' => new Link(fromClass: Supplier::class, toProperty: 'supplier'), + ], + // read:false : pas de stade lecture du parent. Le Link toProperty + // resoudrait l'enfant (SELECT SupplierRib ... WHERE supplier = :id) et + // casse en NonUniqueResult des >= 2 enfants. Le parent est rattache + // manuellement par SupplierRibProcessor::linkParent (404 si absent). + read: false, + security: "is_granted('commercial.suppliers.accounting.manage')", + normalizationContext: ['groups' => ['supplier:read:accounting']], + denormalizationContext: ['groups' => ['supplier:write:accounting']], + processor: SupplierRibProcessor::class, + ), + new Patch( + security: "is_granted('commercial.suppliers.accounting.manage')", + normalizationContext: ['groups' => ['supplier:read:accounting']], + denormalizationContext: ['groups' => ['supplier:write:accounting']], + processor: SupplierRibProcessor::class, + ), + new Delete( + security: "is_granted('commercial.suppliers.accounting.manage')", + processor: SupplierRibProcessor::class, + ), + ], +)] #[ORM\Entity(repositoryClass: DoctrineSupplierRibRepository::class)] #[ORM\Table(name: 'supplier_rib')] #[ORM\Index(name: 'idx_supplier_rib_supplier', columns: ['supplier_id'])] diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierAddressProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierAddressProcessor.php new file mode 100644 index 0000000..43f0d2b --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierAddressProcessor.php @@ -0,0 +1,90 @@ += 1 site, Assert\Count), RG-2.09 (type d'adresse, Assert\Choice + + * CHECK BDD), RG-2.10 (categorie de type FOURNISSEUR, Assert\Callback + * SupplierAddress::validateCategoryType). + * - DELETE : aucune regle metier specifique (suppression physique directe). + * + * La security de l'operation (commercial.suppliers.manage) est appliquee par API + * Platform en amont, de meme que la validation Symfony des contraintes d'attribut. + * + * @implements ProcessorInterface + */ +final class SupplierAddressProcessor 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 SupplierAddress) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + $this->linkParent($data, $uriVariables); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Rattache l'adresse au fournisseur parent de la sous-ressource POST + * (/suppliers/{supplierId}/addresses) : la relation n'est pas peuplee + * automatiquement par le Link sur une ecriture. Sur PATCH, no-op. + */ + private function linkParent(SupplierAddress $address, array $uriVariables): void + { + if (null !== $address->getSupplier()) { + return; + } + + $supplierId = $uriVariables['supplierId'] ?? null; + if (null === $supplierId) { + return; + } + + $supplier = $supplierId instanceof Supplier + ? $supplierId + : $this->em->getRepository(Supplier::class)->find($supplierId); + + // 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 supplier_id NOT NULL). + if (!$supplier instanceof Supplier) { + throw new NotFoundHttpException('Fournisseur introuvable.'); + } + + $address->setSupplier($supplier); + } +} diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierContactProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierContactProcessor.php new file mode 100644 index 0000000..3d5f3a8 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierContactProcessor.php @@ -0,0 +1,135 @@ + + */ +final class SupplierContactProcessor 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 SupplierFieldNormalizer $normalizer, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof SupplierContact) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + $this->linkParent($data, $uriVariables); + $this->normalize($data); + $this->validateName($data); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Rattache le contact au fournisseur parent de la sous-ressource POST + * (/suppliers/{supplierId}/contacts). La relation n'est pas peuplee + * automatiquement par le Link sur une operation d'ecriture : on resout le + * parent depuis l'uri variable. Sur PATCH (entite existante), le fournisseur + * est deja present -> no-op. + */ + private function linkParent(SupplierContact $contact, array $uriVariables): void + { + if (null !== $contact->getSupplier()) { + return; + } + + $supplierId = $uriVariables['supplierId'] ?? null; + if (null === $supplierId) { + return; + } + + $supplier = $supplierId instanceof Supplier + ? $supplierId + : $this->em->getRepository(Supplier::class)->find($supplierId); + + // 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 supplier_id NOT NULL). + if (!$supplier instanceof Supplier) { + throw new NotFoundHttpException('Fournisseur introuvable.'); + } + + $contact->setSupplier($supplier); + } + + /** + * Normalisation serveur (RG-2.12). Toutes les methodes du normalizer sont + * null-safe : une chaine vide apres trim devient null. + */ + private function normalize(SupplierContact $contact): void + { + $contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName())); + $contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName())); + $contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary())); + $contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary())); + $contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail())); + } + + /** + * RG-2.04 : au moins le prenom OU le nom est obligatoire (double garde avec le + * CHECK BDD chk_supplier_contact_name — leve une 422 propre rattachee au champ + * `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les + * chaines vides sont deja ramenees a null. + */ + private function validateName(SupplierContact $contact): void + { + if (null === $contact->getFirstName() && null === $contact->getLastName()) { + $violations = new ConstraintViolationList(); + $violations->add(new ConstraintViolation( + 'Le prénom ou le nom du contact est obligatoire.', + null, + [], + $contact, + 'firstName', + null, + )); + + throw new ValidationException($violations); + } + } +} diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierRibProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierRibProcessor.php new file mode 100644 index 0000000..7eeda22 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierRibProcessor.php @@ -0,0 +1,114 @@ + + */ +final class SupplierRibProcessor 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 SupplierRib) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + $this->guardLastRibDeletionUnderLcr($data); + + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + $this->linkParent($data, $uriVariables); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Rattache le RIB au fournisseur parent de la sous-ressource POST + * (/suppliers/{supplierId}/ribs) : la relation n'est pas peuplee + * automatiquement par le Link sur une ecriture. Sur PATCH, no-op. + */ + private function linkParent(SupplierRib $rib, array $uriVariables): void + { + if (null !== $rib->getSupplier()) { + return; + } + + $supplierId = $uriVariables['supplierId'] ?? null; + if (null === $supplierId) { + return; + } + + $supplier = $supplierId instanceof Supplier + ? $supplierId + : $this->em->getRepository(Supplier::class)->find($supplierId); + + // 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 supplier_id NOT NULL). + if (!$supplier instanceof Supplier) { + throw new NotFoundHttpException('Fournisseur introuvable.'); + } + + $rib->setSupplier($supplier); + } + + /** + * RG-2.08 : un fournisseur dont le type de reglement est LCR doit conserver au + * moins un RIB. La collection inclut le RIB en cours de suppression : un + * effectif <= 1 signifie qu'il ne resterait aucun RIB -> 409. Pour tout autre + * type de reglement, les RIBs sont optionnels (suppression libre). + */ + private function guardLastRibDeletionUnderLcr(SupplierRib $rib): void + { + $supplier = $rib->getSupplier(); + if (null === $supplier) { + return; + } + + if ('LCR' === $supplier->getPaymentType()?->getCode() && $supplier->getRibs()->count() <= 1) { + throw new ConflictHttpException( + 'Impossible de supprimer le dernier RIB : le type de règlement LCR exige au moins un RIB.', + ); + } + } +}