diff --git a/src/Module/Commercial/Application/Service/SupplierFieldNormalizer.php b/src/Module/Commercial/Application/Service/SupplierFieldNormalizer.php new file mode 100644 index 0000000..9b49405 --- /dev/null +++ b/src/Module/Commercial/Application/Service/SupplierFieldNormalizer.php @@ -0,0 +1,82 @@ + "0612345678" (RG-2.12). + * Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front. + * - email : lowercase integral (RG-2.12) + * + * 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 SupplierFieldNormalizer +{ + /** + * Nom de societe en majuscules (RG-2.12). 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 normalizeCompanyName(?string $value): ?string + { + if (null === $value) { + return null; + } + + return mb_strtoupper(trim($value), 'UTF-8'); + } + + /** + * Nom/prenom de personne en Title Case (RG-2.12) : "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-2.12). 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-2.12) : "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; + } +} diff --git a/src/Module/Commercial/Domain/Entity/Supplier.php b/src/Module/Commercial/Domain/Entity/Supplier.php index 3ac103c..128a77a 100644 --- a/src/Module/Commercial/Domain/Entity/Supplier.php +++ b/src/Module/Commercial/Domain/Entity/Supplier.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Commercial\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\Commercial\Infrastructure\ApiPlatform\State\Processor\SupplierProcessor; +use App\Module\Commercial\Infrastructure\ApiPlatform\State\Provider\SupplierProvider; use App\Module\Commercial\Infrastructure\Doctrine\DoctrineSupplierRepository; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; @@ -44,10 +51,73 @@ use Symfony\Component\Validator\Constraints as Assert; * CategoryInterface + resolve_target_entities (regle n°1, pas d'import direct). * * Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) : les read-groups - * sont poses ICI (source unique). L'#[ApiResource] et le SupplierProvider / - * SupplierProcessor (gating accounting, archivage, mode strict) sont branches au - * ticket suivant (ERP-87). + * sont poses ICI (source unique). L'#[ApiResource] (operations + contextes), le + * SupplierProvider (liste paginee, exclusion archives, item 404 soft-delete), le + * SupplierProcessor (normalisation, archivage, gating accounting/manage en mode + * strict, 409 doublon) et le SupplierReadGroupContextBuilder (ajout conditionnel + * du groupe supplier:read:accounting selon accounting.view) sont branches ICI + * (ERP-87). */ +#[ApiResource( + operations: [ + new GetCollection( + security: "is_granted('commercial.suppliers.view')", + // La liste embarque les categories (avec leur code/name, groupe + // category:read) et les sites agreges des adresses (groupe + // site:read) pour alimenter les colonnes « Catégories » et + // « Site(s) » du Repertoire (cohérence M1/ERP-62, § 2.12). Cf. + // getSites(). Fetch-joins/hydratation deleguee au repository (N+1). + normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']], + provider: SupplierProvider::class, + ), + new Get( + security: "is_granted('commercial.suppliers.view')", + // Detail : fournisseur + sous-collections embarquees (contacts / + // adresses + leurs sites/categories/contacts). + // - supplier:read:accounting est ajoute par SupplierReadGroupContextBuilder + // selon la permission (gate les scalaires comptables ET les RIB + // embarques), donc volontairement ABSENT ici (parade bug #4 M1). + // - category:read / site:read indispensables pour embarquer le + // code/name des categories et le name/postalCode des sites (sinon + // stub IRI nu — bugs #1/#2 M1). + normalizationContext: ['groups' => [ + 'supplier:read', + 'supplier:item:read', + 'category:read', + 'site:read', + 'default:read', + ]], + provider: SupplierProvider::class, + ), + new Post( + security: "is_granted('commercial.suppliers.manage')", + normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']], + denormalizationContext: ['groups' => ['supplier:write:main']], + processor: SupplierProcessor::class, + ), + new Patch( + // Security elargie : `manage` OU `accounting.manage`. Le role Compta + // n'a pas `manage` mais doit pouvoir editer l'onglet Comptabilite + // d'un fournisseur existant (§ 2.9). Le SupplierProcessor re-gate + // ensuite onglet par onglet (mode strict RG-2.16) : + // - champs accounting -> accounting.manage (guardAccounting) ; + // - champs main/information -> manage (guardManage : empeche Compta + // d'editer les autres onglets) ; + // - isArchived -> archive (guardArchive, RG-2.14). + security: "is_granted('commercial.suppliers.manage') or is_granted('commercial.suppliers.accounting.manage')", + normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']], + denormalizationContext: ['groups' => [ + 'supplier:write:main', + 'supplier:write:information', + 'supplier:write:accounting', + 'supplier:write:archive', + ]], + provider: SupplierProvider::class, + processor: SupplierProcessor::class, + ), + // Pas de Delete au M2 (HP-M3-1). Archivage via PATCH { isArchived: true }. + ], +)] #[ORM\Entity(repositoryClass: DoctrineSupplierRepository::class)] #[ORM\Table(name: 'supplier')] // Index nommes pour matcher la migration (Version20260605130000). L'index unique @@ -130,7 +200,8 @@ class Supplier implements TimestampableInterface, BlamableInterface // === Onglet Comptabilite === // Lecture conditionnee via le groupe `supplier:read:accounting` (ajoute au - // contexte par le SupplierProvider si l'user a accounting.view, ERP-87). + // contexte par le SupplierReadGroupContextBuilder si l'user a accounting.view, + // ERP-87 — un Provider ne peut pas influencer les groupes de serialisation). // Ecriture via `supplier:write:accounting` (le Processor exige accounting.manage). #[ORM\Column(length: 20, nullable: true)] #[Assert\Length(max: 20, maxMessage: 'Le SIREN ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] @@ -510,7 +581,7 @@ class Supplier implements TimestampableInterface, BlamableInterface // Embed gate sur le groupe COMPTABLE (et non supplier:item:read comme contacts/ // adresses) : supplier:read:accounting n'est ajoute au contexte que si l'user a - // accounting.view (SupplierProvider, ERP-87). Resultat : la cle `ribs` est + // accounting.view (SupplierReadGroupContextBuilder, ERP-87). Resultat : la cle `ribs` est // TOTALEMENT ABSENTE du detail pour un user sans accounting.view (ex. Commerciale), // au meme titre que les scalaires comptables — evite la fuite IBAN/BIC (piege n°4 M1). /** @return Collection */ diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/SupplierReadGroupContextBuilder.php b/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/SupplierReadGroupContextBuilder.php new file mode 100644 index 0000000..4cc8041 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/SupplierReadGroupContextBuilder.php @@ -0,0 +1,75 @@ +decorated->createFromRequest($request, $normalization, $extractedAttributes); + + // Uniquement en lecture, sur la ressource Supplier, avec la permission. + if (!$normalization) { + return $context; + } + + if (Supplier::class !== ($context['resource_class'] ?? null)) { + return $context; + } + + if (!$this->security->isGranted('commercial.suppliers.accounting.view')) { + return $context; + } + + $groups = $context['groups'] ?? []; + if (!in_array('supplier:read:accounting', $groups, true)) { + $groups[] = 'supplier:read:accounting'; + } + $context['groups'] = $groups; + + return $context; + } +} diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php new file mode 100644 index 0000000..2474623 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php @@ -0,0 +1,484 @@ + exige accounting.manage (403) ; + * - champ main/information modifie -> exige manage (guardManage, 403) : + * empeche Compta d'editer un autre onglet que la Comptabilite (§ 2.9) ; + * - champ isArchived dans le payload -> exige archive (RG-2.14, 403) et + * interdit toute autre modification dans la meme requete (RG-2.14, 422). + * 2. Normalisation serveur (RG-2.12) via SupplierFieldNormalizer. + * 3. Pose / retrait de archivedAt (RG-2.14 true=now, RG-2.15 false=null). + * 4. Persistance via le persist_processor Doctrine, avec traduction des + * collisions d'unicite en 409 (RG-2.11 doublon de nom ; RG-2.15 conflit de + * restauration). + * + * Hors perimetre ERP-87 (ticket #5 « Validators ») : RG-2.03 (completude + * Information pour la Commerciale), RG-2.07 (Virement -> banque), RG-2.08 (LCR -> + * RIB), RG-2.10 (categorie de type FOURNISSEUR). Ces regles metier seront + * branchees ici via des validators dedies au ticket suivant. + * + * Note : la validation Symfony (Assert\NotBlank, Assert\Count sur categories...) + * est jouee par API Platform AVANT ce processor ; on n'y traite donc que les + * regles non exprimables en simples contraintes d'attribut. + * + * @implements ProcessorInterface + */ +final class SupplierProcessor implements ProcessorInterface +{ + /** Champs de l'onglet principal (groupe supplier:write:main). */ + private const array MAIN_FIELDS = [ + 'companyName', 'categories', + ]; + + /** Champs de l'onglet Information (groupe supplier:write:information). */ + private const array INFORMATION_FIELDS = [ + 'description', 'competitors', 'foundedAt', 'employeesCount', + 'revenueAmount', 'directorName', 'profitAmount', 'volumeForecast', + ]; + + /** Champs de l'onglet Comptabilite (groupe supplier:write:accounting). */ + private const array ACCOUNTING_FIELDS = [ + 'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', + 'paymentType', 'bank', + ]; + + /** Champ d'archivage (groupe supplier:write:archive). */ + private const string ARCHIVE_FIELD = 'isArchived'; + + private const string PERM_MANAGE = 'commercial.suppliers.manage'; + private const string PERM_ACCOUNTING_MANAGE = 'commercial.suppliers.accounting.manage'; + private const string PERM_ARCHIVE = 'commercial.suppliers.archive'; + + /** + * Memoisation du dernier corps de requete decode, clos par le contenu brut. + * payloadKeys() est appele plusieurs fois par requete (writablePayloadKeys, + * categoriesChanged...) : on evite de rejouer json_decode a chaque appel. 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 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 SupplierFieldNormalizer $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 Supplier) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + $writableKeys = $this->writablePayloadKeys(); + + $isArchiveRequest = $this->guardArchive($data, $writableKeys); + $this->guardAccounting($data); + + $this->normalize($data); + + // guardManage apres normalize : la comparaison « change vs etat + // persiste » des champs texte (companyName...) se fait sur des valeurs + // normalisees des deux cotes (l'etat persiste l'a deja ete). + $this->guardManage($data); + + try { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } catch (UniqueConstraintViolationException $e) { + // Le seul index unique partiel est uq_supplier_company_name_active + // (LOWER(company_name) parmi non-archives/non-deletes — § 2.6). + if ($isArchiveRequest && false === $data->isArchived()) { + // RG-2.15 : restauration en conflit avec un homonyme actif. + throw new ConflictHttpException( + 'Restauration impossible : un autre fournisseur a pris le nom entre-temps.', + $e, + ); + } + + // RG-2.11 : doublon de nom de societe. + throw new ConflictHttpException( + sprintf('Un fournisseur nommé "%s" existe déjà.', (string) $data->getCompanyName()), + $e, + ); + } + } + + /** + * RG-2.14 / RG-2.15 : 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 fournisseur 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 $writableKeys cles ecrivables du payload (hors @* et champs inconnus) + */ + private function guardArchive(Supplier $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-2.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-2.14 (true -> now) / RG-2.15 (false -> null). + $data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null); + + return true; + } + + /** + * RG-2.16 : la modification effective d'un champ comptable exige + * accounting.manage, sinon 403 sur l'ensemble du payload (mode strict, pas + * de filtrage silencieux). On ne gate que si un champ change reellement par + * rapport a l'etat persiste : un POST/PATCH renvoyant des champs comptables + * inchanges (ou null en creation) ne declenche pas de 403 parasite. Le + * message precise le premier champ fautif. + */ + private function guardAccounting(Supplier $data): void + { + $changed = $this->changedAccountingFields($data); + + if ([] === $changed) { + return; + } + + if (!$this->security->isGranted(self::PERM_ACCOUNTING_MANAGE)) { + throw new AccessDeniedHttpException(sprintf( + 'Le champ "%s" requiert la permission "%s".', + $changed[0], + self::PERM_ACCOUNTING_MANAGE, + )); + } + } + + /** + * § 2.9 / RG-2.16 : la modification effective d'un champ « metier » (onglets + * principal ou Information) exige `commercial.suppliers.manage`. Sans cette + * permission -> 403 sur l'ensemble du payload (mode strict, miroir de + * guardAccounting). C'est ce qui empeche le role Compta — qui entre dans le + * PATCH via `accounting.manage` (security d'operation elargie) — d'editer + * autre chose que l'onglet Comptabilite. + * + * Ne s'applique qu'aux mises a jour (entite geree) : la creation (POST) est + * deja gardee par la security d'operation `manage`, donc inutile de la + * re-gater ici (et un POST par un porteur de `manage` passerait de toute + * facon). + */ + private function guardManage(Supplier $data): void + { + if (!$this->em->contains($data)) { + return; + } + + $changed = $this->changedBusinessFields($data); + + if ([] === $changed) { + return; + } + + if (!$this->security->isGranted(self::PERM_MANAGE)) { + throw new AccessDeniedHttpException(sprintf( + 'Le champ "%s" requiert la permission "%s".', + $changed[0], + self::PERM_MANAGE, + )); + } + } + + /** + * Champs « metier » (onglets principal + Information, hors comptabilite et + * archivage) dont la valeur courante differe de l'etat persiste. Memes + * regles de comparaison que changedAccountingFields (scalaires par valeur). + * + * Cas particulier `categories` (M2M) : non trace par getOriginalEntityData, + * compare par valeur via le snapshot de la PersistentCollection (cf. + * categoriesChanged) — la simple presence dans le payload ne suffit pas, sous + * peine de 403 parasite sur un PATCH representation complete reincluant des + * categories inchangees. + * + * @return list + */ + private function changedBusinessFields(Supplier $data): array + { + $newValues = [ + 'companyName' => $data->getCompanyName(), + 'description' => $data->getDescription(), + 'competitors' => $data->getCompetitors(), + 'foundedAt' => $data->getFoundedAt(), + 'employeesCount' => $data->getEmployeesCount(), + 'revenueAmount' => $data->getRevenueAmount(), + 'directorName' => $data->getDirectorName(), + 'profitAmount' => $data->getProfitAmount(), + 'volumeForecast' => $data->getVolumeForecast(), + ]; + + $changed = []; + foreach ($newValues as $field => $newValue) { + if ($this->fieldChanged($data, $field, $newValue)) { + $changed[] = $field; + } + } + + if ($this->categoriesChanged($data)) { + $changed[] = 'categories'; + } + + return $changed; + } + + /** + * Vrai si l'ensemble des categories (M2M) differe reellement de l'etat + * persiste. La collection n'etant pas tracee par getOriginalEntityData, on + * compare par identifiants (independamment de l'ordre) le snapshot de la + * PersistentCollection (etat charge depuis la base) a l'etat courant (apres + * application du payload). Symetrique de changedAccountingFields : seul un + * changement effectif compte, pas la simple presence dans le payload. + * + * - POST / entite non geree : fournir des categories est un acte metier + * (branche defensive, guardManage ne s'execute de toute facon que sur + * entite geree). + * - categories absent du payload (PATCH partiel) : aucun changement. + */ + private function categoriesChanged(Supplier $data): bool + { + if (!$this->em->contains($data)) { + return true; + } + + if (!in_array('categories', $this->payloadKeys(), true)) { + return false; + } + + $collection = $data->getCategories(); + + // Hors PersistentCollection (cas limite hors flux PATCH reel) : faute + // d'etat persiste comparable, on se rabat sur la presence payload. + if (!$collection instanceof PersistentCollection) { + return true; + } + + return $this->categoryIdSet($collection->toArray()) + !== $this->categoryIdSet($collection->getSnapshot()); + } + + /** + * Ensemble trie des identifiants d'une liste de categories — pour une + * comparaison par valeur independante de l'ordre. + * + * @param array $categories + * + * @return list + */ + private function categoryIdSet(array $categories): array + { + $ids = array_map( + static fn (object $category): mixed => method_exists($category, 'getId') + ? $category->getId() + : spl_object_id($category), + array_values($categories), + ); + sort($ids); + + return $ids; + } + + /** + * Champs comptables dont la valeur courante differe de l'etat persiste. Les + * relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par + * identite d'objet : l'identity map Doctrine renvoie la meme instance tant + * que la reference est inchangee. + * + * @return list + */ + private function changedAccountingFields(Supplier $data): array + { + $changed = []; + + foreach (self::ACCOUNTING_FIELDS as $field) { + $newValue = match ($field) { + 'siren' => $data->getSiren(), + 'accountNumber' => $data->getAccountNumber(), + 'tvaMode' => $data->getTvaMode(), + 'nTva' => $data->getNTva(), + 'paymentDelay' => $data->getPaymentDelay(), + 'paymentType' => $data->getPaymentType(), + 'bank' => $data->getBank(), + }; + + if ($this->fieldChanged($data, $field, $newValue)) { + $changed[] = $field; + } + } + + return $changed; + } + + /** + * 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(Supplier $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 + */ + private function originalData(Supplier $data): array + { + if (!$this->em->contains($data)) { + return []; + } + + return $this->em->getUnitOfWork()->getOriginalEntityData($data); + } + + /** + * Normalisation serveur du formulaire principal (RG-2.12). Seul companyName + * subsiste cote Supplier (le contact inline a ete retire en V1 — les champs + * de contact sont normalises par SupplierContactProcessor, ERP-88). Le setter + * non-nullable n'est touche que si une valeur est presente, pour ne jamais + * ecraser l'existant lors d'un PATCH partiel. + */ + private function normalize(Supplier $data): void + { + if (null !== $data->getCompanyName()) { + $data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName())); + } + } + + /** + * Cles ecrivables effectivement presentes dans le payload : on retire les + * cles JSON-LD (@id, @context, @var...) et tout champ non rattache a un + * groupe d'ecriture connu. C'est la base du 422 d'archivage (RG-2.14) — + * sans elles, un PATCH « representation complete » porteur de @id ferait + * croire a une modification multi-onglets. + * + * @return list + */ + private function writablePayloadKeys(): array + { + $writable = array_merge( + self::MAIN_FIELDS, + self::INFORMATION_FIELDS, + self::ACCOUNTING_FIELDS, + [self::ARCHIVE_FIELD], + ); + + return array_values(array_intersect($this->payloadKeys(), $writable)); + } + + /** + * 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 + */ + 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 + */ + 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')) : []; + } +} diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/SupplierProvider.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/SupplierProvider.php new file mode 100644 index 0000000..d61ed46 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/SupplierProvider.php @@ -0,0 +1,191 @@ + (fournisseurs + * ayant >= 1 categorie de ce code, repetable) et ?siteId= (fournisseurs + * ayant >= 1 adresse rattachee a ce site, repetable) ; + * - pagination obligatoire (regle ABSOLUE n°13) : Paginator ORM ; echappatoire + * ?pagination=false pour alimenter un cote front). + if (!$this->pagination->isEnabled($operation, $context)) { + /** @var list $suppliers */ + $suppliers = $qb->getQuery()->getResult(); + // Hydratation batchee des collections affichees (§ 2.12) : evite le + // N+1 si la serialisation touche categories/sites, sans cartesien. + $this->repository->hydrateListCollections($suppliers); + + return $suppliers; + } + + $limit = $this->pagination->getLimit($operation, $context); + $page = max(1, $this->pagination->getPage($context)); + $offset = ($page - 1) * $limit; + + $qb->setFirstResult($offset)->setMaxResults($limit); + + // Le QB de selection ne porte pas de fetch-join to-many (§ 2.12) : le + // COUNT est simple, fetchJoinCollection inutile. On materialise la page + // puis on hydrate ses collections en lot (memes entites managees). + $paginator = new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false)); + $this->repository->hydrateListCollections(iterator_to_array($paginator)); + + return $paginator; + } + + /** + * @param array $uriVariables + */ + private function provideItem(array $uriVariables): ?Supplier + { + $id = $uriVariables['id'] ?? null; + if (!is_int($id) && !(is_string($id) && ctype_digit($id))) { + return null; + } + + $supplier = $this->repository->findById((int) $id); + if (null === $supplier) { + return null; + } + + // Soft-delete : jamais expose au M2 (HP-M3-1) — 404 via retour null. + // Les archives restent visibles en detail (consultation + restauration). + if (null !== $supplier->getDeletedAt()) { + return null; + } + + return $supplier; + } + + /** + * Lit un flag booleen issu des query params. Accepte true / "true" / "1". + */ + private function readBool(mixed $raw): bool + { + if (is_bool($raw)) { + return $raw; + } + + return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true); + } + + /** + * Normalise un filtre en liste de chaines. Tolere un code unique (string) + * ou une liste (?key[]=a&key[]=b). Trim + retrait des vides. + * + * @return list + */ + private function readStringList(mixed $raw): array + { + $values = is_array($raw) ? $raw : [$raw]; + + $out = []; + foreach ($values as $value) { + if (is_string($value) && '' !== trim($value)) { + $out[] = trim($value); + } + } + + return $out; + } + + /** + * Normalise un filtre en liste d'identifiants entiers positifs. Tolere une + * valeur unique ou une liste (?key[]=1&key[]=2). + * + * @return list + */ + private function readIntList(mixed $raw): array + { + $values = is_array($raw) ? $raw : [$raw]; + + $out = []; + foreach ($values as $value) { + if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) { + $out[] = (int) $value; + } + } + + return $out; + } +}