From 349d2cf2022f139088bc8dad6b832d586b8dca6f Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 18 Jun 2026 11:01:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(logistique)=20:=20WeighingTicketProvider?= =?UTF-8?q?=20+=20Processor=20=E2=80=94=20num=C3=A9rotation,=20contreparti?= =?UTF-8?q?e,=20net,=20normalisation=20(ERP-185)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logique métier d'écriture et de lecture du ticket de pesée (M5). Processor (POST/PATCH) : - résolution du site courant (CurrentSiteProvider) + attribution du numéro {siteCode}-TP-{NNNN} à la création, immuables ensuite (RG-5.02 / RG-5.09) ; - exclusivité de la contrepartie CLIENT/FOURNISSEUR/AUTRE — null-ification des champs hors-branche (RG-5.03, garde-fou CHECK Postgres) ; - normalisation immatriculation trim/UPPER + masque XX-000-XX hors « Tout format », 422 inline sur le champ si invalide (RG-5.01 / RG-5.10) ; - DSD autoritaire pour les pesées AUTO via DsdAllocator (verrou), MANUEL conservé (RG-5.04) ; - poids net = plein − vide recalculé (RG-5.05). Provider (GET) : liste paginée (Paginator ORM, règle n°13), recherche ?search=, tri ?order[displayDate], cloisonnement par site courant appliqué dans le provider (le SiteScopedQueryExtension ne traverse pas un provider custom), fetch-join client/supplier/site anti-N+1, 404 hors périmètre / soft-delete. Ajouts : WeighingTicketNumberAllocator (compteur weighing_ticket_counter, SELECT FOR UPDATE), WeighingTicketFieldNormalizer, InvalidImmatriculationException + alias DI. make test vert (811), Architecture vert (CollectionsArePaginatedTest). --- config/services.yaml | 4 + .../Service/WeighingTicketFieldNormalizer.php | 87 ++++++++ ...WeighingTicketNumberAllocatorInterface.php | 32 +++ .../InvalidImmatriculationException.php | 18 ++ .../WeighingTicketRepositoryInterface.php | 13 +- .../Processor/WeighingTicketProcessor.php | 209 ++++++++++++++++++ .../State/Provider/WeighingTicketProvider.php | 187 ++++++++++++++++ .../DoctrineWeighingTicketRepository.php | 14 +- .../Service/WeighingTicketNumberAllocator.php | 74 +++++++ 9 files changed, 627 insertions(+), 11 deletions(-) create mode 100644 src/Module/Logistique/Application/Service/WeighingTicketFieldNormalizer.php create mode 100644 src/Module/Logistique/Application/Service/WeighingTicketNumberAllocatorInterface.php create mode 100644 src/Module/Logistique/Domain/Exception/InvalidImmatriculationException.php create mode 100644 src/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighingTicketProcessor.php create mode 100644 src/Module/Logistique/Infrastructure/ApiPlatform/State/Provider/WeighingTicketProvider.php create mode 100644 src/Module/Logistique/Infrastructure/Service/WeighingTicketNumberAllocator.php diff --git a/config/services.yaml b/config/services.yaml index 8afb3df..e42868d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -40,3 +40,7 @@ services: App\Module\Logistique\Application\Service\DsdAllocatorInterface: alias: App\Module\Logistique\Infrastructure\Service\DsdAllocator + + # M5 Logistique — Provider/Processor ticket de pesee (ERP-185) + App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface: + alias: App\Module\Logistique\Infrastructure\Service\WeighingTicketNumberAllocator diff --git a/src/Module/Logistique/Application/Service/WeighingTicketFieldNormalizer.php b/src/Module/Logistique/Application/Service/WeighingTicketFieldNormalizer.php new file mode 100644 index 0000000..83a1163 --- /dev/null +++ b/src/Module/Logistique/Application/Service/WeighingTicketFieldNormalizer.php @@ -0,0 +1,87 @@ + 422 + * par le Processor). En « Tout format » (anciennes plaques, etranger, engins), + * seul le trim + UPPER s'applique. + * - otherLabel (RG-5.03) : trim ; une chaine vide apres trim devient null (evite + * de persister "" dans une colonne nullable). + * + * Methodes null-safe : une entree null ressort null (l'obligation eventuelle est + * portee par les Assert de l'entite / la coherence contrepartie, pas ici). + */ +final class WeighingTicketFieldNormalizer +{ + /** + * Plaque SIV « nue » (sans separateurs) : 2 lettres, 3 chiffres, 2 lettres. + * Les lettres interdites du SIV (I, O, U + SS) ne sont pas filtrees ici : le + * masque de saisie reste volontairement simple (le metier accepte ces cas via + * « Tout format » si besoin). + */ + private const string SIV_BARE_PATTERN = '/^[A-Z]{2}[0-9]{3}[A-Z]{2}$/'; + + /** + * Normalise l'immatriculation (RG-5.01 / RG-5.10). + * + * @param bool $freeFormat « Tout format » coche -> masque SIV desactive + * + * @throws InvalidImmatriculationException si !freeFormat et la plaque ne + * respecte pas le masque XX-000-XX + */ + public function normalizeImmatriculation(?string $value, bool $freeFormat): ?string + { + if (null === $value) { + return null; + } + + $value = mb_strtoupper(trim($value), 'UTF-8'); + if ('' === $value) { + return null; + } + + // « Tout format » : aucune contrainte de masque (RG-5.01). + if ($freeFormat) { + return $value; + } + + // Masque SIV : on ignore tout ce qui n'est pas alphanumerique (l'operateur + // peut saisir « ab123cd », « AB 123 CD » ou « AB-123-CD ») puis on valide + // le squelette 2-3-2 et on repose les separateurs canoniques. + $bare = preg_replace('/[^A-Z0-9]/', '', $value) ?? ''; + + if (1 !== preg_match(self::SIV_BARE_PATTERN, $bare)) { + throw new InvalidImmatriculationException( + 'Format d\'immatriculation invalide : attendu XX-000-XX (cochez « Tout format » pour une plaque libre).', + ); + } + + return sprintf('%s-%s-%s', substr($bare, 0, 2), substr($bare, 2, 3), substr($bare, 5, 2)); + } + + /** + * Trim du libelle « Autre » (RG-5.03). Une chaine vide apres trim devient null. + */ + public function normalizeOtherLabel(?string $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim($value); + + return '' === $value ? null : $value; + } +} diff --git a/src/Module/Logistique/Application/Service/WeighingTicketNumberAllocatorInterface.php b/src/Module/Logistique/Application/Service/WeighingTicketNumberAllocatorInterface.php new file mode 100644 index 0000000..77bfa3a --- /dev/null +++ b/src/Module/Logistique/Application/Service/WeighingTicketNumberAllocatorInterface.php @@ -0,0 +1,32 @@ + 422 sur « immatriculation » + * (mapping inline useFormErrors, ERP-101). + * 4. DSD autoritaire (RG-5.04) : pour chaque pesee AUTO, (re)attribution du DSD + * via DsdAllocator (verrou FOR UPDATE). Le DSD renvoye par + * POST /api/weighbridge_readings est PREVISIONNEL ; l'attribution autoritaire + * est faite ici. Une pesee MANUELLE conserve le DSD deja alloue par l'endpoint + * de pesee (« dernier + 1 », round-trip par le client, deja consomme). + * 5. Poids net (RG-5.05) : net_weight = full_weight - empty_weight si les deux + * poids sont presents, sinon null. + * 6. Persistance via le persist_processor Doctrine. + * + * @implements ProcessorInterface + */ +final class WeighingTicketProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + private readonly CurrentSiteProviderInterface $currentSiteProvider, + private readonly WeighingTicketNumberAllocatorInterface $numberAllocator, + private readonly DsdAllocatorInterface $dsdAllocator, + private readonly WeighingTicketFieldNormalizer $normalizer, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof WeighingTicket) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + // Une entite non geree par l'ORM = creation (POST) : site + numero ne sont + // attribues qu'a ce moment et restent immuables ensuite (RG-5.09). + $isNew = !$this->em->contains($data); + + if ($isNew) { + $site = $this->resolveCurrentSite(); + $data->setSite($site); + $data->setNumber($this->numberAllocator->allocate($site)); + } + + $this->applyCounterpartyExclusivity($data); + $this->normalizeImmatriculation($data); + + // Le site est toujours present apres creation ; sur PATCH il est charge + // depuis la base. Garde defensive si jamais il manque (ne devrait pas). + $site = $data->getSite(); + if ($site instanceof Site) { + $this->allocateAutoDsd($data, $site, $isNew); + } + + $this->computeNetWeight($data); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Resout le site courant (sélecteur de site). Absent = aucun site selectionne + * -> 400 : on ne peut pas numeroter ni rattacher un ticket sans site (site_id + * NOT NULL, § 2.3). + */ + private function resolveCurrentSite(): Site + { + $site = $this->currentSiteProvider->get(); + if (!$site instanceof Site) { + throw new BadRequestHttpException('Aucun site courant sélectionné — sélectionnez un site avant de créer un ticket de pesée.'); + } + + return $site; + } + + /** + * RG-5.03 : garantit l'exclusivite de la contrepartie en forcant a null les + * champs hors-branche selon counterpartyType. La PRESENCE du champ requis est + * deja validee en amont (Assert\Callback de l'entite) ; ici on evite qu'un + * payload portant a la fois client_id ET supplier_id ne fasse echouer les CHECK + * Postgres (500 generique au lieu d'une donnee coherente). otherLabel est + * normalise (trim) dans la branche AUTRE. + */ + private function applyCounterpartyExclusivity(WeighingTicket $data): void + { + switch ($data->getCounterpartyType()) { + case 'CLIENT': + $data->setSupplier(null); + $data->setOtherLabel(null); + + break; + + case 'FOURNISSEUR': + $data->setClient(null); + $data->setOtherLabel(null); + + break; + + case 'AUTRE': + $data->setClient(null); + $data->setSupplier(null); + $data->setOtherLabel($this->normalizer->normalizeOtherLabel($data->getOtherLabel())); + + break; + } + } + + /** + * RG-5.01 / RG-5.10 : normalisation serveur de l'immatriculation (trim + UPPER + * + masque XX-000-XX hors « Tout format »). Un format invalide est traduit en + * 422 portant un propertyPath « immatriculation » consommable inline par + * useFormErrors (ERP-101), plutot qu'un toast. + */ + private function normalizeImmatriculation(WeighingTicket $data): void + { + $current = $data->getImmatriculation(); + if (null === $current) { + return; + } + + try { + $data->setImmatriculation( + $this->normalizer->normalizeImmatriculation($current, $data->isPlateFreeFormat()), + ); + } catch (InvalidImmatriculationException $e) { + $this->throwFieldViolation($data, 'immatriculation', $e->getMessage()); + } + } + + /** + * RG-5.04 : (re)attribution AUTORITAIRE du DSD pour chaque pesee AUTO via + * DsdAllocator (verrou FOR UPDATE). A la creation, le DSD prévisionnel envoye + * par le client (issu de POST /api/weighbridge_readings) est ecrase. Sur PATCH, + * on n'alloue que pour une pesee AUTO encore depourvue de DSD (ex. la pesee a + * plein realisee apres coup) — sinon on churne le compteur a chaque edition. + * Les pesees MANUELLES conservent leur DSD (deja alloue par l'endpoint de + * pesee, « dernier + 1 »). + */ + private function allocateAutoDsd(WeighingTicket $data, Site $site, bool $isNew): void + { + if ('AUTO' === $data->getEmptyMode() && ($isNew || null === $data->getEmptyDsd())) { + $data->setEmptyDsd($this->dsdAllocator->next($site)); + } + + if ('AUTO' === $data->getFullMode() && ($isNew || null === $data->getFullDsd())) { + $data->setFullDsd($this->dsdAllocator->next($site)); + } + } + + /** + * RG-5.05 : poids net = poids plein - poids vide (kg), recalcule a chaque + * ecriture. Null tant que l'une des deux pesees manque. + */ + private function computeNetWeight(WeighingTicket $data): void + { + $empty = $data->getEmptyWeight(); + $full = $data->getFullWeight(); + + $data->setNetWeight(null !== $empty && null !== $full ? $full - $empty : null); + } + + /** + * Leve une 422 portant une violation unique sur un champ — meme rendu Hydra que + * les contraintes Symfony, consommable inline par useFormErrors (ERP-101). + * + * @return never + */ + private function throwFieldViolation(WeighingTicket $root, string $propertyPath, string $message): void + { + $violations = new ConstraintViolationList(); + $violations->add(new ConstraintViolation($message, null, [], $root, $propertyPath, null)); + + throw new ValidationException($violations); + } +} diff --git a/src/Module/Logistique/Infrastructure/ApiPlatform/State/Provider/WeighingTicketProvider.php b/src/Module/Logistique/Infrastructure/ApiPlatform/State/Provider/WeighingTicketProvider.php new file mode 100644 index 0000000..b2b58ed --- /dev/null +++ b/src/Module/Logistique/Infrastructure/ApiPlatform/State/Provider/WeighingTicketProvider.php @@ -0,0 +1,187 @@ + aucun filtre ; + * - site courant null (module Sites off / user sans site) -> no-op (l'user voit + * tout, decision site-aware.md § 5) ; + * - sinon -> liste restreinte aux tickets du site courant, AVANT pagination + * (totalItems reflete le perimetre). + * + * Item (GET /api/weighing_tickets/{id} + provider de PATCH) : + * - 404 si introuvable OU soft-delete (deleted_at non null) ; + * - 404 si hors perimetre site (ne pas reveler l'existence d'une ligne d'un autre + * site — anti-enumeration). + * + * @implements ProviderInterface + */ +final class WeighingTicketProvider implements ProviderInterface +{ + public function __construct( + #[Autowire(service: 'App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository')] + private readonly WeighingTicketRepositoryInterface $repository, + private readonly Pagination $pagination, + private readonly CurrentSiteProviderInterface $currentSiteProvider, + private readonly Security $security, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|WeighingTicket|null + { + if ($operation instanceof CollectionOperationInterface) { + return $this->provideCollection($operation, $context); + } + + return $this->provideItem($uriVariables); + } + + /** + * @param array $context + * + * @return list|Paginator + */ + private function provideCollection(Operation $operation, array $context): array|Paginator + { + $filters = $context['filters'] ?? []; + $search = $filters['search'] ?? null; + + $qb = $this->repository->createListQueryBuilder(is_string($search) ? $search : null); + + $this->applyDisplayDateOrder($qb, $filters); + $this->applySiteScope($qb); + + // Echappatoire ?pagination=false : collection complete sans Paginator + // (regle n°13 — utile pour alimenter un