From e455204bbde50358bd772fa2759d1f4ce5f95581 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 17 Jun 2026 18:09:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(logistique)=20:=20pes=C3=A9e=20pont=20basc?= =?UTF-8?q?ule=20stub=20+=20allocateur=20DSD=20+=20endpoint=20(ERP-184)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WeighbridgeReaderInterface (contrat) + RandomWeighbridgeReader (stub, poids aléatoire ∈ [10000,50000] kg, RG-5.06) + WeighbridgeUnavailableException - DsdAllocator : compteur DSD par site (weighbridge_dsd_counter) incrémenté sous verrou ligne SELECT ... FOR UPDATE (RG-5.04, § 2.7) - endpoint POST /api/weighbridge_readings : ressource virtuelle WeighbridgeReadingResource + WeighbridgeReadingProcessor (pas de controller) - AUTO -> {weight, dsd, mode} ; MANUAL -> {weight, dsd, manualNumber, mode} - WeighbridgeUnavailableException -> HTTP 503 explicite (RG-5.06) - site courant via CurrentSiteProviderInterface (contrat Sites) - is_granted('logistique.weighing_tickets.manage') - dsd renvoyé prévisionnel : attribution autoritaire refaite à la création du ticket (ERP-185) - tests : WeighbridgeReaderStubTest, DsdAllocatorTest, processor (503/400), WeighbridgeReadingApiTest (RBAC + AUTO/MANUAL + 422) --- config/services.yaml | 7 + .../Service/DsdAllocatorInterface.php | 27 ++++ .../Contract/WeighbridgeReaderInterface.php | 31 ++++ .../WeighbridgeUnavailableException.php | 20 +++ .../Domain/Weighbridge/WeighbridgeReading.php | 24 +++ .../Resource/WeighbridgeReadingResource.php | 91 ++++++++++++ .../Processor/WeighbridgeReadingProcessor.php | 81 +++++++++++ .../Infrastructure/Service/DsdAllocator.php | 65 +++++++++ .../Weighbridge/RandomWeighbridgeReader.php | 34 +++++ .../Api/WeighbridgeReadingApiTest.php | 137 ++++++++++++++++++ .../WeighbridgeReadingProcessorTest.php | 133 +++++++++++++++++ .../Service/DsdAllocatorTest.php | 106 ++++++++++++++ .../Weighbridge/WeighbridgeReaderStubTest.php | 56 +++++++ 13 files changed, 812 insertions(+) create mode 100644 src/Module/Logistique/Application/Service/DsdAllocatorInterface.php create mode 100644 src/Module/Logistique/Domain/Contract/WeighbridgeReaderInterface.php create mode 100644 src/Module/Logistique/Domain/Exception/WeighbridgeUnavailableException.php create mode 100644 src/Module/Logistique/Domain/Weighbridge/WeighbridgeReading.php create mode 100644 src/Module/Logistique/Infrastructure/ApiPlatform/Resource/WeighbridgeReadingResource.php create mode 100644 src/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighbridgeReadingProcessor.php create mode 100644 src/Module/Logistique/Infrastructure/Service/DsdAllocator.php create mode 100644 src/Module/Logistique/Infrastructure/Weighbridge/RandomWeighbridgeReader.php create mode 100644 tests/Module/Logistique/Api/WeighbridgeReadingApiTest.php create mode 100644 tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighbridgeReadingProcessorTest.php create mode 100644 tests/Module/Logistique/Infrastructure/Service/DsdAllocatorTest.php create mode 100644 tests/Module/Logistique/Infrastructure/Weighbridge/WeighbridgeReaderStubTest.php diff --git a/config/services.yaml b/config/services.yaml index fa6c942..8afb3df 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -33,3 +33,10 @@ services: App\Module\Sites\Application\Service\CurrentSiteProviderInterface: alias: App\Module\Sites\Application\Service\CurrentSiteProvider + + # M5 Logistique — pesee pont bascule (ERP-184) + App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface: + alias: App\Module\Logistique\Infrastructure\Weighbridge\RandomWeighbridgeReader + + App\Module\Logistique\Application\Service\DsdAllocatorInterface: + alias: App\Module\Logistique\Infrastructure\Service\DsdAllocator diff --git a/src/Module/Logistique/Application/Service/DsdAllocatorInterface.php b/src/Module/Logistique/Application/Service/DsdAllocatorInterface.php new file mode 100644 index 0000000..645cbde --- /dev/null +++ b/src/Module/Logistique/Application/Service/DsdAllocatorInterface.php @@ -0,0 +1,27 @@ +, "manualNumber": "" }`) + * → `{ weight, dsd, manualNumber, mode }` (DSD = dernier DSD du site + 1). + * + * `read: false` : pas de chargement d'entite existante — le payload est + * denormalise directement dans cette ressource, puis le Processor prend le relais. + * + * ⚠ Le `dsd` renvoye ici est PREVISIONNEL : l'attribution AUTORITAIRE du DSD + * (et du numero de ticket) est refaite/verrouillee a la creation du ticket + * (`POST /api/weighing_tickets`, ERP-185) pour eviter les collisions si deux + * postes pesent en parallele. Le front affiche cette valeur, mais c'est le + * ticket persiste qui fait foi. + */ +#[ApiResource( + shortName: 'WeighbridgeReading', + operations: [ + new Post( + uriTemplate: '/weighbridge_readings', + // Action de lecture du pont (pas une creation de ressource) : 200, pas 201. + status: 200, + security: "is_granted('logistique.weighing_tickets.manage')", + normalizationContext: ['groups' => ['weighbridge_reading:read']], + denormalizationContext: ['groups' => ['weighbridge_reading:write']], + processor: WeighbridgeReadingProcessor::class, + read: false, + ), + ], +)] +final class WeighbridgeReadingResource +{ + /** AUTO (pesee bascule) | MANUAL (pesee manuelle) — pilote le comportement (§ 4.2). */ + #[Assert\NotBlank(message: 'Le mode de pesée est obligatoire.')] + #[Assert\Choice(choices: ['AUTO', 'MANUAL'], message: 'Mode de pesée invalide (AUTO ou MANUAL).')] + #[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])] + public ?string $mode = null; + + /** + * Poids en kg. En entree : requis et saisi en MANUAL, ignore en AUTO (le pont + * fournit le poids). En sortie : poids effectif de la pesee. + */ + #[Assert\Positive(message: 'Le poids doit être un entier positif (kg).')] + #[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])] + public ?int $weight = null; + + /** Numero de pesee papier saisi en MANUAL (distinct du DSD, RG-5.04). */ + #[Assert\Length(max: 50, maxMessage: 'Le numéro de pesée ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])] + public ?string $manualNumber = null; + + /** DSD attribue par le serveur (lecture seule) — previsionnel (cf. docbloc classe). */ + #[Groups(['weighbridge_reading:read'])] + public ?int $dsd = null; + + /** + * RG metier : en pesee MANUAL, le poids est saisi par l'operateur (le pont + * n'est pas lu) → il est obligatoire. Porte par un Callback pour que le 422 + * cible le propertyPath `weight` (mapping inline front, ERP-101). En AUTO, + * le poids fourni par le client est ignore (renseigne par le pont). + */ + #[Assert\Callback] + public function validateManualWeight(ExecutionContextInterface $context): void + { + if ('MANUAL' === $this->mode && null === $this->weight) { + $context->buildViolation('Le poids est obligatoire en pesée manuelle.') + ->atPath('weight') + ->addViolation() + ; + } + } +} diff --git a/src/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighbridgeReadingProcessor.php b/src/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighbridgeReadingProcessor.php new file mode 100644 index 0000000..f099739 --- /dev/null +++ b/src/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighbridgeReadingProcessor.php @@ -0,0 +1,81 @@ + + */ +final class WeighbridgeReadingProcessor implements ProcessorInterface +{ + public function __construct( + private readonly CurrentSiteProviderInterface $currentSiteProvider, + private readonly WeighbridgeReaderInterface $weighbridgeReader, + private readonly DsdAllocatorInterface $dsdAllocator, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): WeighbridgeReadingResource + { + if (!$data instanceof WeighbridgeReadingResource) { + throw new LogicException(sprintf( + 'WeighbridgeReadingProcessor attend une instance de %s, %s recu.', + WeighbridgeReadingResource::class, + get_debug_type($data), + )); + } + + // Site courant resolu serveur (jamais envoye par le client). Absent = + // aucun site selectionne dans le sélecteur → on ne peut pas peser. + $site = $this->currentSiteProvider->get(); + if (null === $site) { + throw new BadRequestHttpException('Aucun site courant sélectionné — sélectionnez un site avant de peser.'); + } + + if ('AUTO' === $data->mode) { + try { + $reading = $this->weighbridgeReader->read($site); + } catch (WeighbridgeUnavailableException $e) { + // RG-5.06 : le pont ne repond pas → 503 explicite, le front bascule + // en pesee manuelle. (Le stub M5 ne leve jamais — chemin teste.) + throw new ServiceUnavailableHttpException( + null, + 'Pont bascule indisponible — passez en pesée manuelle.', + $e, + ); + } + + $data->weight = $reading->weight; + $data->dsd = $reading->dsd; + $data->manualNumber = null; // pas de numero papier en mode bascule + + return $data; + } + + // MANUAL : le poids est saisi (validateManualWeight garantit sa presence), + // seul le DSD est attribue serveur (dernier DSD du site + 1, RG-5.04). + $data->dsd = $this->dsdAllocator->next($site); + + return $data; + } +} diff --git a/src/Module/Logistique/Infrastructure/Service/DsdAllocator.php b/src/Module/Logistique/Infrastructure/Service/DsdAllocator.php new file mode 100644 index 0000000..4592c96 --- /dev/null +++ b/src/Module/Logistique/Infrastructure/Service/DsdAllocator.php @@ -0,0 +1,65 @@ +getId(); + if (null === $siteId) { + // Garde defensive : un site non persiste n'a pas de compteur (et la FK + // weighbridge_dsd_counter.site_id -> site(id) rejetterait l'INSERT). + throw new LogicException('Impossible d\'allouer un DSD pour un site non persiste (id null).'); + } + + return $this->connection->transactional(function (Connection $conn) use ($siteId): int { + // Garantit l'existence de la ligne compteur du site sans ecraser une + // valeur deja presente (idempotent, concurrence-safe). + $conn->executeStatement( + 'INSERT INTO weighbridge_dsd_counter (site_id, last_value) VALUES (:site, 0) ON CONFLICT (site_id) DO NOTHING', + ['site' => $siteId], + ); + + // Verrou ligne : serialise les pesees concurrentes du meme site. + $current = (int) $conn->fetchOne( + 'SELECT last_value FROM weighbridge_dsd_counter WHERE site_id = :site FOR UPDATE', + ['site' => $siteId], + ); + + $next = $current + 1; + + $conn->executeStatement( + 'UPDATE weighbridge_dsd_counter SET last_value = :value WHERE site_id = :site', + ['value' => $next, 'site' => $siteId], + ); + + return $next; + }); + } +} diff --git a/src/Module/Logistique/Infrastructure/Weighbridge/RandomWeighbridgeReader.php b/src/Module/Logistique/Infrastructure/Weighbridge/RandomWeighbridgeReader.php new file mode 100644 index 0000000..1ec3d54 --- /dev/null +++ b/src/Module/Logistique/Infrastructure/Weighbridge/RandomWeighbridgeReader.php @@ -0,0 +1,34 @@ +dsdAllocator->next($site), + ); + } +} diff --git a/tests/Module/Logistique/Api/WeighbridgeReadingApiTest.php b/tests/Module/Logistique/Api/WeighbridgeReadingApiTest.php new file mode 100644 index 0000000..06ab388 --- /dev/null +++ b/tests/Module/Logistique/Api/WeighbridgeReadingApiTest.php @@ -0,0 +1,137 @@ +getEm(); + $em->getConnection()->executeStatement('DELETE FROM weighbridge_dsd_counter'); + $em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :p') + ->setParameter('p', 'testuser_%')->execute(); + $em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :p') + ->setParameter('p', 'test_%')->execute(); + + parent::tearDown(); + } + + public function testAutoWeighingReturnsWeightInBoundsAndDsd(): void + { + $client = $this->manageClientWithCurrentSite(); + + $response = $client->request('POST', '/api/weighbridge_readings', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['mode' => 'AUTO'], + ]); + + self::assertResponseStatusCodeSame(200); + $data = $response->toArray(); + + self::assertSame('AUTO', $data['mode']); + self::assertIsInt($data['weight']); + self::assertGreaterThanOrEqual(10000, $data['weight']); + self::assertLessThanOrEqual(50000, $data['weight']); + self::assertIsInt($data['dsd']); + self::assertGreaterThanOrEqual(1, $data['dsd']); + // manualNumber est null en mode bascule (cle potentiellement omise si + // skip_null_values est actif — tolerant aux deux cas). + self::assertNull($data['manualNumber'] ?? null); + } + + public function testManualWeighingKeepsWeightAndAllocatesDsd(): void + { + $client = $this->manageClientWithCurrentSite(); + + $response = $client->request('POST', '/api/weighbridge_readings', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['mode' => 'MANUAL', 'weight' => 23187, 'manualNumber' => 'PAP-555'], + ]); + + self::assertResponseStatusCodeSame(200); + $data = $response->toArray(); + + self::assertSame('MANUAL', $data['mode']); + self::assertSame(23187, $data['weight']); + self::assertSame('PAP-555', $data['manualNumber']); + self::assertGreaterThanOrEqual(1, $data['dsd']); + } + + public function testManagePermissionIsRequired(): void + { + // Un user portant uniquement `view` ne peut pas declencher de pesee. + $credentials = $this->createUserWithPermission('logistique.weighing_tickets.view'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + + $client->request('POST', '/api/weighbridge_readings', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['mode' => 'AUTO'], + ]); + + self::assertResponseStatusCodeSame(403); + } + + public function testInvalidModeIsRejected(): void + { + $client = $this->manageClientWithCurrentSite(); + + $client->request('POST', '/api/weighbridge_readings', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['mode' => 'INVALID'], + ]); + + self::assertResponseStatusCodeSame(422); + } + + public function testManualWeighingRequiresWeight(): void + { + $client = $this->manageClientWithCurrentSite(); + + $client->request('POST', '/api/weighbridge_readings', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['mode' => 'MANUAL'], + ]); + + self::assertResponseStatusCodeSame(422); + } + + /** + * Cree un user non-admin portant `logistique.weighing_tickets.manage`, lui + * positionne un site courant (l'endpoint est cloisonne par site, § 2.3) et + * renvoie un client authentifie. + */ + private function manageClientWithCurrentSite(): Client + { + $credentials = $this->createUserWithPermission('logistique.weighing_tickets.manage'); + + $em = $this->getEm(); + $user = $em->getRepository(User::class)->findOneBy(['username' => $credentials['username']]); + self::assertInstanceOf(User::class, $user); + + $site = $em->getRepository(Site::class)->findAll()[0]; + $user->setCurrentSite($site); + $em->flush(); + + return $this->authenticatedClient($credentials['username'], $credentials['password']); + } +} diff --git a/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighbridgeReadingProcessorTest.php b/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighbridgeReadingProcessorTest.php new file mode 100644 index 0000000..a0ac304 --- /dev/null +++ b/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighbridgeReadingProcessorTest.php @@ -0,0 +1,133 @@ +createStub(CurrentSiteProviderInterface::class); + $siteProvider->method('get')->willReturn($this->site()); + + $reader = $this->createStub(WeighbridgeReaderInterface::class); + $reader->method('read')->willReturn(new WeighbridgeReading(23000, 42)); + + $processor = new WeighbridgeReadingProcessor( + $siteProvider, + $reader, + $this->createStub(DsdAllocatorInterface::class), + ); + + $resource = new WeighbridgeReadingResource(); + $resource->mode = 'AUTO'; + + $result = $processor->process($resource, new Post()); + + self::assertSame(23000, $result->weight); + self::assertSame(42, $result->dsd); + self::assertNull($result->manualNumber); + self::assertSame('AUTO', $result->mode); + } + + public function testManualModeKeepsWeightAndAllocatesDsd(): void + { + $siteProvider = $this->createStub(CurrentSiteProviderInterface::class); + $siteProvider->method('get')->willReturn($this->site()); + + $allocator = $this->createStub(DsdAllocatorInterface::class); + $allocator->method('next')->willReturn(43); + + $processor = new WeighbridgeReadingProcessor( + $siteProvider, + $this->createStub(WeighbridgeReaderInterface::class), + $allocator, + ); + + $resource = new WeighbridgeReadingResource(); + $resource->mode = 'MANUAL'; + $resource->weight = 23187; + $resource->manualNumber = 'PAP-555'; + + $result = $processor->process($resource, new Post()); + + self::assertSame(23187, $result->weight, 'Le poids saisi est conserve en manuel.'); + self::assertSame(43, $result->dsd); + self::assertSame('PAP-555', $result->manualNumber); + self::assertSame('MANUAL', $result->mode); + } + + public function testWeighbridgeUnavailableIsMappedTo503(): void + { + $siteProvider = $this->createStub(CurrentSiteProviderInterface::class); + $siteProvider->method('get')->willReturn($this->site()); + + $reader = $this->createStub(WeighbridgeReaderInterface::class); + $reader->method('read')->willThrowException(new WeighbridgeUnavailableException()); + + $processor = new WeighbridgeReadingProcessor( + $siteProvider, + $reader, + $this->createStub(DsdAllocatorInterface::class), + ); + + $resource = new WeighbridgeReadingResource(); + $resource->mode = 'AUTO'; + + try { + $processor->process($resource, new Post()); + self::fail('Une ServiceUnavailableHttpException (503) etait attendue.'); + } catch (ServiceUnavailableHttpException $e) { + self::assertSame(503, $e->getStatusCode()); + self::assertStringContainsString('pesée manuelle', $e->getMessage()); + } + } + + public function testMissingCurrentSiteIsRejected(): void + { + $siteProvider = $this->createStub(CurrentSiteProviderInterface::class); + $siteProvider->method('get')->willReturn(null); + + $processor = new WeighbridgeReadingProcessor( + $siteProvider, + $this->createStub(WeighbridgeReaderInterface::class), + $this->createStub(DsdAllocatorInterface::class), + ); + + $resource = new WeighbridgeReadingResource(); + $resource->mode = 'AUTO'; + + $this->expectException(BadRequestHttpException::class); + $processor->process($resource, new Post()); + } +} diff --git a/tests/Module/Logistique/Infrastructure/Service/DsdAllocatorTest.php b/tests/Module/Logistique/Infrastructure/Service/DsdAllocatorTest.php new file mode 100644 index 0000000..e03b0c6 --- /dev/null +++ b/tests/Module/Logistique/Infrastructure/Service/DsdAllocatorTest.php @@ -0,0 +1,106 @@ + */ + private array $touchedSiteIds = []; + + protected function setUp(): void + { + self::bootKernel(); + $container = self::getContainer(); + $this->em = $container->get('doctrine')->getManager(); + $this->connection = $this->em->getConnection(); + $this->allocator = $container->get(DsdAllocator::class); + } + + protected function tearDown(): void + { + if ([] !== $this->touchedSiteIds) { + $this->connection->executeStatement( + 'DELETE FROM weighbridge_dsd_counter WHERE site_id IN (?)', + [$this->touchedSiteIds], + [ArrayParameterType::INTEGER], + ); + } + + parent::tearDown(); + } + + public function testNextIncrementsSequentiallyAndIsIsolatedPerSite(): void + { + $sites = $this->em->getRepository(Site::class)->findAll(); + self::assertGreaterThanOrEqual(2, \count($sites), 'Au moins 2 sites doivent etre seedes (fixtures).'); + + $siteA = $sites[0]; + $siteB = $sites[1]; + $this->resetCounter($siteA); + $this->resetCounter($siteB); + + // AUTO/MANUAL partagent le meme increment : la sequence demarre a 1. + self::assertSame(1, $this->allocator->next($siteA)); + self::assertSame(2, $this->allocator->next($siteA)); + self::assertSame(3, $this->allocator->next($siteA)); + + // Isolation par site : le compteur de B est independant de celui de A. + self::assertSame(1, $this->allocator->next($siteB)); + self::assertSame(2, $this->allocator->next($siteB)); + + // La sequence de A reprend la ou elle en etait (4), non perturbee par B. + self::assertSame(4, $this->allocator->next($siteA)); + } + + public function testNextStartsAtOneWhenNoCounterRowExists(): void + { + $site = $this->em->getRepository(Site::class)->findAll()[0]; + $this->resetCounter($site); + + // Aucune ligne compteur pour ce site : le premier appel la cree (last=0) + // et renvoie 1 (dernier + 1). + self::assertSame(1, $this->allocator->next($site)); + } + + /** + * Supprime la ligne compteur du site pour repartir d'un etat connu, et + * enregistre l'id pour la purge de tearDown. + */ + private function resetCounter(Site $site): void + { + $siteId = $site->getId(); + self::assertNotNull($siteId); + + $this->connection->executeStatement( + 'DELETE FROM weighbridge_dsd_counter WHERE site_id = :site', + ['site' => $siteId], + ); + + if (!\in_array($siteId, $this->touchedSiteIds, true)) { + $this->touchedSiteIds[] = $siteId; + } + } +} diff --git a/tests/Module/Logistique/Infrastructure/Weighbridge/WeighbridgeReaderStubTest.php b/tests/Module/Logistique/Infrastructure/Weighbridge/WeighbridgeReaderStubTest.php new file mode 100644 index 0000000..08cd3f2 --- /dev/null +++ b/tests/Module/Logistique/Infrastructure/Weighbridge/WeighbridgeReaderStubTest.php @@ -0,0 +1,56 @@ +createStub(DsdAllocatorInterface::class); + $allocator->method('next')->willReturn(1); + + $reader = new RandomWeighbridgeReader($allocator); + $site = $this->createStub(SiteInterface::class); + + for ($i = 0; $i < 500; ++$i) { + $reading = $reader->read($site); + self::assertGreaterThanOrEqual(10000, $reading->weight); + self::assertLessThanOrEqual(50000, $reading->weight); + } + } + + /** + * RG-5.04 : le DSD renvoye par la lecture est celui fourni par l'allocateur + * de site (le reader ne calcule pas le DSD lui-meme). + */ + public function testReadDelegatesDsdToAllocator(): void + { + $allocator = $this->createStub(DsdAllocatorInterface::class); + $allocator->method('next')->willReturn(42); + + $reader = new RandomWeighbridgeReader($allocator); + $reading = $reader->read($this->createStub(SiteInterface::class)); + + self::assertSame(42, $reading->dsd); + } +}