Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ea9c0547e |
@@ -33,10 +33,3 @@ services:
|
|||||||
|
|
||||||
App\Module\Sites\Application\Service\CurrentSiteProviderInterface:
|
App\Module\Sites\Application\Service\CurrentSiteProviderInterface:
|
||||||
alias: App\Module\Sites\Application\Service\CurrentSiteProvider
|
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
|
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.142'
|
app.version: '0.1.141'
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Logistique\Application\Service;
|
|
||||||
|
|
||||||
use App\Shared\Domain\Contract\SiteInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Allocateur du compteur DSD du pont bascule (RG-5.04, § 2.7).
|
|
||||||
*
|
|
||||||
* Le DSD est un index sequentiel de pesee, propre a CHAQUE site (un pont par
|
|
||||||
* site). Chaque pesee — bascule (AUTO) ou manuelle (MANUAL) — consomme une
|
|
||||||
* valeur : la suivante = dernier DSD du site + 1.
|
|
||||||
*
|
|
||||||
* Port (interface en couche Application) ; l'implementation (DsdAllocator,
|
|
||||||
* Infrastructure) incremente le compteur sous verrou ligne `SELECT ... FOR
|
|
||||||
* UPDATE` pour garantir l'unicite en concurrence.
|
|
||||||
*/
|
|
||||||
interface DsdAllocatorInterface
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Attribue et renvoie la prochaine valeur DSD pour le site (dernier + 1),
|
|
||||||
* en persistant l'increment de maniere atomique (verrou ligne).
|
|
||||||
*/
|
|
||||||
public function next(SiteInterface $site): int;
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Logistique\Domain\Contract;
|
|
||||||
|
|
||||||
use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException;
|
|
||||||
use App\Module\Logistique\Domain\Weighbridge\WeighbridgeReading;
|
|
||||||
use App\Shared\Domain\Contract\SiteInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Contrat de lecture du pont bascule (§ 2.6).
|
|
||||||
*
|
|
||||||
* Abstraction posee au M5 pour decoupler l'API du materiel : l'implementation
|
|
||||||
* livree est un stub (RandomWeighbridgeReader, poids aleatoire ∈ [10000,50000]
|
|
||||||
* kg). Le driver materiel reel (protocole serie/TCP de l'indicateur de pesage)
|
|
||||||
* est hors perimetre M5 (HP-M5-02) : le jour venu on substitue l'implementation
|
|
||||||
* derriere cette interface — zero impact sur les ecrans / l'API.
|
|
||||||
*/
|
|
||||||
interface WeighbridgeReaderInterface
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Effectue une pesee « bascule » (AUTO) pour le site donne : renvoie le poids
|
|
||||||
* lu et le DSD (index de pesee du pont) attribue pour ce site (RG-5.04).
|
|
||||||
*
|
|
||||||
* @throws WeighbridgeUnavailableException si la bascule ne repond pas
|
|
||||||
* (le Processor traduit en HTTP 503 →
|
|
||||||
* bascule manuelle, RG-5.06)
|
|
||||||
*/
|
|
||||||
public function read(SiteInterface $site): WeighbridgeReading;
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Logistique\Domain\Exception;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Levee lorsque le pont bascule ne repond pas / est indisponible (RG-5.06).
|
|
||||||
*
|
|
||||||
* Exception de DOMAINE (pure, sans dependance HTTP) : c'est le Processor de
|
|
||||||
* l'endpoint de pesee qui la traduit en reponse HTTP 503 « Pont bascule
|
|
||||||
* indisponible — passez en pesee manuelle » (cf. WeighbridgeReadingProcessor).
|
|
||||||
*
|
|
||||||
* Au M5, le stub (RandomWeighbridgeReader) ne la leve jamais, mais le chemin
|
|
||||||
* d'erreur est implemente et teste pour le jour ou un driver materiel reel
|
|
||||||
* (HP-M5-02) sera branche derriere WeighbridgeReaderInterface.
|
|
||||||
*/
|
|
||||||
final class WeighbridgeUnavailableException extends RuntimeException {}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Logistique\Domain\Weighbridge;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resultat immuable d'une lecture du pont bascule (§ 2.6 / RG-5.06).
|
|
||||||
*
|
|
||||||
* Porte le couple {poids, DSD} renvoye par une pesee « bascule » (AUTO) :
|
|
||||||
* - weight : poids brut lu, en kilogrammes ;
|
|
||||||
* - dsd : index de pesee du pont (compteur par site, RG-5.04).
|
|
||||||
*
|
|
||||||
* Au M5 le pont est un stub (RandomWeighbridgeReader) ; un driver materiel reel
|
|
||||||
* (HP-M5-02) produira le meme objet derriere WeighbridgeReaderInterface, sans
|
|
||||||
* impact sur l'API.
|
|
||||||
*/
|
|
||||||
final readonly class WeighbridgeReading
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public int $weight,
|
|
||||||
public int $dsd,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
-91
@@ -1,91 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Logistique\Infrastructure\ApiPlatform\Resource;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
|
||||||
use ApiPlatform\Metadata\Post;
|
|
||||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighbridgeReadingProcessor;
|
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
|
||||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ressource API Platform virtuelle (non mappee Doctrine) portant l'action de
|
|
||||||
* pesee au pont bascule : `POST /api/weighbridge_readings` (§ 4.2).
|
|
||||||
*
|
|
||||||
* Action AUTONOME : declenchee depuis le formulaire AVANT que le ticket existe.
|
|
||||||
* Le site est resolu serveur (site courant) — jamais envoye par le client.
|
|
||||||
*
|
|
||||||
* - AUTO (`{ "mode": "AUTO" }`) → `{ weight, dsd, mode }` (stub : poids
|
|
||||||
* aleatoire ∈ [10000,50000] kg + DSD du site, RG-5.04 / RG-5.06).
|
|
||||||
* - MANUAL (`{ "mode": "MANUAL", "weight": <int>, "manualNumber": "<str>" }`)
|
|
||||||
* → `{ 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()
|
|
||||||
;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-81
@@ -1,81 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
|
||||||
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
|
|
||||||
use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface;
|
|
||||||
use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException;
|
|
||||||
use App\Module\Logistique\Infrastructure\ApiPlatform\Resource\WeighbridgeReadingResource;
|
|
||||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
|
||||||
use LogicException;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processor de l'action `POST /api/weighbridge_readings` (§ 4.2).
|
|
||||||
*
|
|
||||||
* Resout le site courant (CurrentSiteProviderInterface — contrat Sites, seule
|
|
||||||
* logique cross-module autorisee, regle ABSOLUE n°1) puis :
|
|
||||||
* - AUTO : lit le pont (WeighbridgeReaderInterface) → poids + DSD. Si la
|
|
||||||
* bascule est indisponible (WeighbridgeUnavailableException) → HTTP 503
|
|
||||||
* « Pont bascule indisponible — passez en pesee manuelle » (RG-5.06).
|
|
||||||
* - MANUAL : conserve le poids saisi et alloue le DSD (dernier + 1, RG-5.04).
|
|
||||||
*
|
|
||||||
* @implements ProcessorInterface<WeighbridgeReadingResource, WeighbridgeReadingResource>
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
<?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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Logistique\Infrastructure\Weighbridge;
|
|
||||||
|
|
||||||
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
|
|
||||||
use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface;
|
|
||||||
use App\Module\Logistique\Domain\Weighbridge\WeighbridgeReading;
|
|
||||||
use App\Shared\Domain\Contract\SiteInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stub du pont bascule livre au M5 (DECISION Matthieu 17/06, § 2.6 / RG-5.06).
|
|
||||||
*
|
|
||||||
* Aucune liaison materielle : la pesee « bascule » est simulee par un poids
|
|
||||||
* aleatoire ∈ [10000, 50000] kg, et le DSD est attribue par l'allocateur de
|
|
||||||
* site (DsdAllocator, RG-5.04). Le driver materiel reel (HP-M5-02) remplacera
|
|
||||||
* cette classe derriere WeighbridgeReaderInterface sans impact sur l'API.
|
|
||||||
*
|
|
||||||
* Ce stub ne leve jamais WeighbridgeUnavailableException ; le chemin d'erreur
|
|
||||||
* (→ 503) reste implemente et teste cote Processor.
|
|
||||||
*/
|
|
||||||
final class RandomWeighbridgeReader implements WeighbridgeReaderInterface
|
|
||||||
{
|
|
||||||
public function __construct(private readonly DsdAllocatorInterface $dsdAllocator) {}
|
|
||||||
|
|
||||||
public function read(SiteInterface $site): WeighbridgeReading
|
|
||||||
{
|
|
||||||
return new WeighbridgeReading(
|
|
||||||
weight: random_int(10000, 50000),
|
|
||||||
dsd: $this->dsdAllocator->next($site),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Module\Logistique\Api;
|
|
||||||
|
|
||||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
|
||||||
use App\Module\Core\Domain\Entity\Role;
|
|
||||||
use App\Module\Core\Domain\Entity\User;
|
|
||||||
use App\Module\Sites\Domain\Entity\Site;
|
|
||||||
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Endpoint `POST /api/weighbridge_readings` (§ 4.2) — tests fonctionnels.
|
|
||||||
*
|
|
||||||
* Couvre le wiring securite/routage (que les tests unitaires ne voient pas) :
|
|
||||||
* - happy path AUTO / MANUAL avec site courant et permission `manage` ;
|
|
||||||
* - 403 sans la permission `manage` (RBAC § 5.2) ;
|
|
||||||
* - 422 si le mode est absent / invalide (validation de la ressource).
|
|
||||||
*
|
|
||||||
* Nettoyage manuel (pas de DAMA) : users/roles `test*` + compteurs DSD.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class WeighbridgeReadingApiTest extends AbstractApiTestCase
|
|
||||||
{
|
|
||||||
protected function tearDown(): void
|
|
||||||
{
|
|
||||||
$em = $this->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']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-133
@@ -1,133 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Post;
|
|
||||||
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
|
|
||||||
use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface;
|
|
||||||
use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException;
|
|
||||||
use App\Module\Logistique\Domain\Weighbridge\WeighbridgeReading;
|
|
||||||
use App\Module\Logistique\Infrastructure\ApiPlatform\Resource\WeighbridgeReadingResource;
|
|
||||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighbridgeReadingProcessor;
|
|
||||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
|
||||||
use App\Module\Sites\Domain\Entity\Site;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processor de l'action `POST /api/weighbridge_readings` (§ 4.2).
|
|
||||||
*
|
|
||||||
* Couvre les 4 chemins sans BDD ni HTTP (stubs purs) : AUTO (lecture pont),
|
|
||||||
* MANUAL (allocation DSD seule), indisponibilite → 503 (RG-5.06) et absence de
|
|
||||||
* site courant → 400.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class WeighbridgeReadingProcessorTest extends TestCase
|
|
||||||
{
|
|
||||||
private function site(): Site
|
|
||||||
{
|
|
||||||
// getId() reste null (non persiste) — sans incidence : reader et allocator
|
|
||||||
// sont stubbes dans ces tests unitaires.
|
|
||||||
return new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testAutoModeFillsWeightAndDsdFromReader(): void
|
|
||||||
{
|
|
||||||
$siteProvider = $this->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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Module\Logistique\Infrastructure\Service;
|
|
||||||
|
|
||||||
use App\Module\Logistique\Infrastructure\Service\DsdAllocator;
|
|
||||||
use App\Module\Sites\Domain\Entity\Site;
|
|
||||||
use Doctrine\DBAL\ArrayParameterType;
|
|
||||||
use Doctrine\DBAL\Connection;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Allocateur DSD (RG-5.04 / § 2.7) — test d'integration sur la table
|
|
||||||
* `weighbridge_dsd_counter` (DBAL brut, verrou FOR UPDATE).
|
|
||||||
*
|
|
||||||
* Verifie l'increment sequentiel et l'isolation PAR SITE (un pont par site).
|
|
||||||
* Les compteurs des sites touches sont remis a zero en debut de test et purges
|
|
||||||
* en tearDown (pas de DAMA en local — nettoyage manuel obligatoire).
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class DsdAllocatorTest extends KernelTestCase
|
|
||||||
{
|
|
||||||
private Connection $connection;
|
|
||||||
private DsdAllocator $allocator;
|
|
||||||
private EntityManagerInterface $em;
|
|
||||||
|
|
||||||
/** @var list<int> */
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Module\Logistique\Infrastructure\Weighbridge;
|
|
||||||
|
|
||||||
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
|
|
||||||
use App\Module\Logistique\Infrastructure\Weighbridge\RandomWeighbridgeReader;
|
|
||||||
use App\Shared\Domain\Contract\SiteInterface;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stub du pont bascule (RG-5.06 / § 2.6).
|
|
||||||
*
|
|
||||||
* Verifie le contrat du stub livre au M5 : poids aleatoire borne a
|
|
||||||
* [10000, 50000] kg et DSD delegue a l'allocateur (le chemin d'erreur 503
|
|
||||||
* est couvert cote Processor — WeighbridgeReadingProcessorTest).
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class WeighbridgeReaderStubTest extends TestCase
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* RG-5.06 : sur un grand nombre de lectures, le poids reste toujours dans
|
|
||||||
* l'intervalle borne [10000, 50000] (random_int inclusif aux deux bornes).
|
|
||||||
*/
|
|
||||||
public function testReadReturnsWeightWithinBounds(): void
|
|
||||||
{
|
|
||||||
$allocator = $this->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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user