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).
This commit is contained in:
@@ -40,3 +40,7 @@ services:
|
||||
|
||||
App\Module\Logistique\Application\Service\DsdAllocatorInterface:
|
||||
alias: App\Module\Logistique\Infrastructure\Service\DsdAllocator
|
||||
|
||||
# M5 Logistique — Provider/Processor ticket de pesee (ERP-185)
|
||||
App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface:
|
||||
alias: App\Module\Logistique\Infrastructure\Service\WeighingTicketNumberAllocator
|
||||
|
||||
@@ -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,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 {}
|
||||
@@ -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)
|
||||
|
||||
+209
@@ -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);
|
||||
}
|
||||
}
|
||||
+187
@@ -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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user