Compare commits

..

5 Commits

Author SHA1 Message Date
gitea-actions 7e53e331f3 chore: bump version to v0.1.143
Build & Push Docker Image / build (push) Successful in 21s
2026-06-18 12:51:17 +00:00
matthieu c0dadd79ff Merge pull request 'feat(logistique) : WeighingTicketProvider + Processor — numérotation, contrepartie, net, normalisation (ERP-185)' (#135) from feat/erp-185-provider-processor-weighingticket into develop
Auto Tag Develop / tag (push) Successful in 7s
2026-06-18 12:47:11 +00:00
matthieu 1ffa38282a Merge pull request 'feat(logistique) : pesée pont bascule stub + DSD + endpoint (ERP-184)' (#134) from feat/erp-184-pesee-pont-bascule into develop
Auto Tag Develop / tag (push) Successful in 6s
2026-06-18 12:47:06 +00:00
Matthieu 76e7a59ba7 feat(logistique) : WeighingTicketProvider + Processor — numérotation, contrepartie, net, normalisation (ERP-185)
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).
2026-06-18 14:37:16 +02:00
Matthieu e88bb059e6 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)
2026-06-18 14:37:16 +02:00
22 changed files with 1440 additions and 12 deletions
+11
View File
@@ -33,3 +33,14 @@ 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
# M5 Logistique — Provider/Processor ticket de pesee (ERP-185)
App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface:
alias: App\Module\Logistique\Infrastructure\Service\WeighingTicketNumberAllocator
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.141'
app.version: '0.1.143'
@@ -0,0 +1,27 @@
<?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;
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Application\Service;
use App\Module\Logistique\Domain\Exception\InvalidImmatriculationException;
/**
* Normalisation serveur des champs texte d'un WeighingTicket, appliquee par le
* WeighingTicketProcessor AVANT persistance. Cf. spec-back M5 § 6 + RG-5.01 /
* RG-5.10. Jumeau leger de CarrierFieldNormalizer (M4).
*
* - immatriculation (RG-5.01 / RG-5.10) : trim + UPPER. Si « Tout format » N'EST
* PAS coche (freeFormat = false), la saisie est ramenee au masque SIV
* canonique XX-000-XX (separateurs/espaces ignores a la saisie, re-poses) ; une
* plaque qui ne s'y conforme pas leve InvalidImmatriculationException (-> 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;
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Application\Service;
use App\Module\Sites\Domain\Entity\Site;
/**
* Allocateur du numero de ticket de pesee (RG-5.02, § 2.5).
*
* Le numero a le format {siteCode}-TP-{NNNN} (ex. 86-TP-0001), UNIQUE PAR SITE
* et immuable. Chaque site porte sa propre sequence : 86-TP-0001 et 17-TP-0001
* coexistent.
*
* Le code du site (prefixe) vit sur l'entite Site (site.code, ERP-183) — d'ou le
* type-hint sur Site concret (et non SiteInterface qui n'expose pas getCode()) ;
* c'est la meme reference ORM partagee que celle consommee par WeighingTicket
* (§ 2.1, pas de logique inter-module).
*
* Port (couche Application) ; l'implementation (WeighingTicketNumberAllocator,
* Infrastructure) incremente le compteur weighing_ticket_counter sous verrou ligne
* `SELECT ... FOR UPDATE` pour garantir l'unicite meme en concurrence.
*/
interface WeighingTicketNumberAllocatorInterface
{
/**
* Attribue et renvoie le prochain numero formate {siteCode}-TP-{NNNN} pour le
* site, en persistant l'increment de maniere atomique (verrou ligne).
*/
public function allocate(Site $site): string;
}
@@ -0,0 +1,31 @@
<?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;
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Domain\Exception;
use RuntimeException;
/**
* Levee quand une immatriculation ne respecte pas le masque SIV XX-000-XX alors
* que « Tout format » n'est PAS coche (plateFreeFormat = false, RG-5.01).
*
* Exception de DOMAINE (pure, sans dependance HTTP) levee par le
* WeighingTicketFieldNormalizer : c'est le WeighingTicketProcessor qui la traduit
* en 422 portant un propertyPath « immatriculation » (mapping inline useFormErrors,
* convention ERP-101) plutot qu'un toast.
*/
final class InvalidImmatriculationException extends RuntimeException {}
@@ -0,0 +1,20 @@
<?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 {}
@@ -18,13 +18,14 @@ interface WeighingTicketRepositoryInterface
public function save(WeighingTicket $ticket): void;
/**
* QueryBuilder de SELECTION (recherche + tri) pour la liste, exploite par le
* WeighingTicketProvider (ERP-185) qui le wrappe dans un Paginator (règle
* ABSOLUE n°13). Exclut les soft-deletes (deleted_at IS NOT NULL). Tri par
* defaut number DESC (plus recents en tete, § 4.1).
* QueryBuilder de SELECTION (recherche + tri + fetch-join client/supplier/site)
* pour la liste, exploite par le WeighingTicketProvider (ERP-185) qui le wrappe
* dans un Paginator (règle ABSOLUE n°13). Exclut les soft-deletes (deleted_at
* IS NOT NULL). Tri par defaut number DESC (plus recents en tete, § 4.1).
*
* Le cloisonnement par site courant n'est PAS applique ici : il l'est
* automatiquement par le SiteScopedQueryExtension (Sites, § 2.3).
* Le cloisonnement par site courant n'est PAS applique ici : un provider custom
* court-circuite le SiteScopedQueryExtension (qui n'agit que dans le provider
* ORM standard), donc le WeighingTicketProvider l'applique lui-meme (§ 2.3).
*
* @param null|string $search recherche fuzzy sur number, nom client/fournisseur,
* other_label et immatriculation (§ 4.1)
@@ -0,0 +1,24 @@
<?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,
) {}
}
@@ -0,0 +1,91 @@
<?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()
;
}
}
}
@@ -0,0 +1,81 @@
<?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;
}
}
@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
use App\Module\Logistique\Application\Service\WeighingTicketFieldNormalizer;
use App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use App\Module\Logistique\Domain\Exception\InvalidImmatriculationException;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture du ticket de pesee (M5). Cf. spec-back M5 § 4.3 / § 4.4 +
* RG-5.01 / RG-5.02 / RG-5.03 / RG-5.04 / RG-5.05 / RG-5.09 / RG-5.10. Jumeau des
* processors M2/M3/M4, recentre sur les regles specifiques du ticket de pesee.
*
* Sequence (POST / PATCH) — l'entite arrive deja VALIDEE (les Assert + le Callback
* RG-5.03 ont joue en amont) :
* 1. CREATION uniquement (RG-5.09, immuables) : resolution du site courant
* (CurrentSiteProviderInterface — seule logique cross-module autorisee, regle
* ABSOLUE n°1) puis attribution du numero {siteCode}-TP-{NNNN} (compteur
* verrouille, RG-5.02). Le PATCH ne retouche ni site ni numero.
* 2. Coherence contrepartie (RG-5.03) : null-ification des champs hors-branche
* selon counterpartyType (la PRESENCE du champ requis est deja validee par le
* Callback de l'entite ; ici on garantit l'EXCLUSIVITE — sinon les CHECK
* Postgres chk_wt_*_branch leveraient une 500 generique).
* 3. Normalisation immatriculation (RG-5.01 / RG-5.10) : trim + UPPER + masque
* XX-000-XX si !plateFreeFormat. Format invalide -> 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<WeighingTicket, WeighingTicket>
*/
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);
}
}
@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use App\Module\Logistique\Domain\Repository\WeighingTicketRepositoryInterface;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider de lecture des tickets de pesee (M5). Cf. spec-back M5 § 4.0 / § 4.1 +
* RG-5.09. Jumeau du SupplierProvider (M2), augmente du cloisonnement par site.
*
* Collection (GET /api/weighing_tickets) :
* - exclut les soft-deletes (deleted_at IS NOT NULL, prepares mais non exposes au
* M5 — § 2.13), via le repository ;
* - filtre ?search=... (fuzzy sur number, nom client/fournisseur, other_label,
* immatriculation — § 4.1) ;
* - tri ?order[displayDate]=asc|desc (date du ticket = COALESCE full/empty),
* defaut number DESC (plus recents en tete) ;
* - pagination obligatoire (regle ABSOLUE n°13) : Paginator ORM ; echappatoire
* ?pagination=false ;
* - fetch-join client / supplier / site (ManyToOne surs) pour eviter le N+1 a la
* serialisation (§ 4.0).
*
* Cloisonnement par site (§ 2.3 / RG-5.09) — applique ICI : un provider custom
* REMPLACE le provider Doctrine, donc le SiteScopedQueryExtension ne s'execute pas
* automatiquement (il n'agit que dans le provider ORM standard). On replique sa
* logique a l'identique :
* - user `sites.bypass_scope` (Admin auto, consolidation) -> 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<WeighingTicket>
*/
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<string, mixed> $context
*
* @return list<WeighingTicket>|Paginator<WeighingTicket>
*/
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 <select> cote front).
if (!$this->pagination->isEnabled($operation, $context)) {
/** @var list<WeighingTicket> $tickets */
return $qb->getQuery()->getResult();
}
$limit = $this->pagination->getLimit($operation, $context);
$page = max(1, $this->pagination->getPage($context));
$offset = ($page - 1) * $limit;
$qb->setFirstResult($offset)->setMaxResults($limit);
// Les fetch-joins du repository sont tous ManyToOne (client/supplier/site) :
// pas de demultiplication de lignes -> fetchJoinCollection: false (COUNT
// simple, page correcte).
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
}
/**
* @param array<string, mixed> $uriVariables
*/
private function provideItem(array $uriVariables): ?WeighingTicket
{
$id = $uriVariables['id'] ?? null;
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
$ticket = $this->repository->findById((int) $id);
if (null === $ticket) {
return null;
}
// Soft-delete : jamais expose au M5 (§ 2.13) -> 404 via retour null.
if (null !== $ticket->getDeletedAt()) {
return null;
}
// Cloisonnement par site : un ticket hors perimetre -> 404 (anti-enumeration).
$scopeSite = $this->currentScopeSite();
if (null !== $scopeSite && $ticket->getSite()?->getId() !== $scopeSite->getId()) {
return null;
}
return $ticket;
}
/**
* Tri par date du ticket (§ 4.1) : displayDate = full_date ?? empty_date, donc
* un getter calcule (pas une colonne) -> on trie sur l'expression DQL
* COALESCE(full_date, empty_date). Absent du payload -> on garde le tri par
* defaut du repository (number DESC).
*
* @param array<string, mixed> $filters
*/
private function applyDisplayDateOrder(QueryBuilder $qb, array $filters): void
{
$order = $filters['order'] ?? null;
if (!is_array($order) || !isset($order['displayDate'])) {
return;
}
$direction = 'asc' === strtolower((string) $order['displayDate']) ? 'ASC' : 'DESC';
$rootAlias = $qb->getRootAliases()[0];
$qb->orderBy(sprintf('COALESCE(%1$s.fullDate, %1$s.emptyDate)', $rootAlias), $direction);
}
/**
* Restreint la liste au site courant si l'user n'a pas le bypass et qu'un site
* est selectionne (cf. docblock de classe). No-op sinon.
*/
private function applySiteScope(QueryBuilder $qb): void
{
$scopeSite = $this->currentScopeSite();
if (null === $scopeSite) {
return;
}
$rootAlias = $qb->getRootAliases()[0];
$qb->andWhere(sprintf('%s.site = :scopeSite', $rootAlias))
->setParameter('scopeSite', $scopeSite)
;
}
/**
* Site servant a cloisonner, ou null si aucun cloisonnement ne s'applique
* (bypass_scope, ou pas de site courant). Replique les conditions de
* SiteScopedQueryExtension.
*/
private function currentScopeSite(): ?Site
{
if ($this->security->isGranted('sites.bypass_scope')) {
return null;
}
return $this->currentSiteProvider->get();
}
}
@@ -33,12 +33,16 @@ class DoctrineWeighingTicketRepository extends ServiceEntityRepository implement
public function createListQueryBuilder(?string $search = null): QueryBuilder
{
// Left-join des contreparties pour la recherche par nom (sans cartesien
// dangereux : ManyToOne). Le cloisonnement par site courant est ajoute
// par le SiteScopedQueryExtension (§ 2.3). Tri par defaut number DESC.
// Fetch-join (addSelect) des relations ManyToOne client / supplier / site :
// sert a la fois la recherche par nom et l'anti-N+1 a la serialisation
// (§ 4.0 / § 4.1) — aucune demultiplication de lignes (cardinalite to-one).
// Le cloisonnement par site courant n'est PAS pose ici : un provider custom
// court-circuite le SiteScopedQueryExtension, le WeighingTicketProvider
// l'applique donc lui-meme (§ 2.3). Tri par defaut number DESC.
$qb = $this->createQueryBuilder('wt')
->leftJoin('wt.client', 'c')
->leftJoin('wt.supplier', 's')
->leftJoin('wt.client', 'c')->addSelect('c')
->leftJoin('wt.supplier', 's')->addSelect('s')
->leftJoin('wt.site', 'st')->addSelect('st')
->andWhere('wt.deletedAt IS NULL')
->orderBy('wt.number', 'DESC')
;
@@ -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;
});
}
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Infrastructure\Service;
use App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\DBAL\Connection;
use LogicException;
/**
* Implementation DBAL de l'allocateur de numero de ticket (RG-5.02, § 2.5).
*
* Le compteur vit dans la table `weighing_ticket_counter (site_id PK,
* last_value)` — jamais mappee en ORM (DBAL brut, exclue du schema_filter), meme
* pattern que DsdAllocator. L'increment est realise dans une transaction avec
* verrou ligne `SELECT ... FOR UPDATE` : deux postes creant un ticket en parallele
* sur le meme site sont serialises -> numeros distincts, pas de collision sur
* l'index unique uq_weighing_ticket_number (site_id, number).
*
* 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.
*
* Le numero est formate `{siteCode}-TP-%04d` (zero-padding 4 chiffres, debordement
* naturel au-dela de 9999).
*/
final class WeighingTicketNumberAllocator implements WeighingTicketNumberAllocatorInterface
{
public function __construct(private readonly Connection $connection) {}
public function allocate(Site $site): string
{
$siteId = $site->getId();
if (null === $siteId) {
// Garde defensive : un site non persiste n'a pas de compteur (et la FK
// weighing_ticket_counter.site_id -> site(id) rejetterait l'INSERT).
throw new LogicException('Impossible d\'allouer un numero de ticket pour un site non persiste (id null).');
}
$code = $site->getCode();
if (null === $code || '' === trim($code)) {
// site.code est NOT NULL (ERP-183) ; garde defensive pour les contextes
// hors-flux (fixtures incompletes, site cree sans code).
throw new LogicException(sprintf('Le site #%d n\'a pas de code de numerotation (site.code).', $siteId));
}
$next = $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 weighing_ticket_counter (site_id, last_value) VALUES (:site, 0) ON CONFLICT (site_id) DO NOTHING',
['site' => $siteId],
);
// Verrou ligne : serialise les creations concurrentes du meme site.
$current = (int) $conn->fetchOne(
'SELECT last_value FROM weighing_ticket_counter WHERE site_id = :site FOR UPDATE',
['site' => $siteId],
);
$nextValue = $current + 1;
$conn->executeStatement(
'UPDATE weighing_ticket_counter SET last_value = :value WHERE site_id = :site',
['value' => $nextValue, 'site' => $siteId],
);
return $nextValue;
});
return sprintf('%s-TP-%04d', $code, $next);
}
}
@@ -0,0 +1,34 @@
<?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),
);
}
}
@@ -0,0 +1,137 @@
<?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']);
}
}
@@ -0,0 +1,133 @@
<?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());
}
}
@@ -0,0 +1,106 @@
<?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;
}
}
}
@@ -0,0 +1,56 @@
<?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);
}
}