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