Files
Starseed/src/Module/Logistique/Infrastructure/Service/WeighingTicketNumberAllocator.php
T
Matthieu 349d2cf202 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 11:01:23 +02:00

75 lines
3.1 KiB
PHP

<?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);
}
}