feat(logistique) : pesée pont bascule stub + allocateur DSD + endpoint (ERP-184)

- 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)
This commit is contained in:
Matthieu
2026-06-17 18:09:54 +02:00
parent 312c119c06
commit e88bb059e6
13 changed files with 812 additions and 0 deletions
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Infrastructure\Service;
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
use App\Shared\Domain\Contract\SiteInterface;
use Doctrine\DBAL\Connection;
use LogicException;
/**
* Implementation DBAL de l'allocateur DSD (RG-5.04, § 2.7).
*
* Le compteur vit dans la table `weighbridge_dsd_counter (site_id PK,
* last_value)` — jamais mappee en ORM (DBAL brut, exclue du schema_filter).
* L'increment est realise dans une transaction avec verrou ligne
* `SELECT ... FOR UPDATE` : deux postes pesant en parallele sur le meme site
* sont serialises, ce qui garantit des DSD distincts (pas de collision).
*
* AUTO comme MANUAL passent par le meme increment (« dernier DSD du site + 1 ») :
* la seule difference fonctionnelle est l'origine du poids (lu par le pont en
* AUTO, saisi en MANUAL), pas la sequence DSD.
*
* La ligne compteur n'est pas seedee a la creation du site : on la cree a la
* volee (INSERT ... ON CONFLICT DO NOTHING) avant de prendre le verrou.
*/
final class DsdAllocator implements DsdAllocatorInterface
{
public function __construct(private readonly Connection $connection) {}
public function next(SiteInterface $site): int
{
$siteId = $site->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;
});
}
}