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:
+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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user