349d2cf202
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).
75 lines
3.1 KiB
PHP
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);
|
|
}
|
|
}
|