Files
Starseed/src/Module/Logistique/Infrastructure/Doctrine/DoctrineWeighingTicketRepository.php
T
Matthieu 76e7a59ba7 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 14:37:16 +02:00

78 lines
2.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Module\Logistique\Infrastructure\Doctrine;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use App\Module\Logistique\Domain\Repository\WeighingTicketRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<WeighingTicket>
*/
class DoctrineWeighingTicketRepository extends ServiceEntityRepository implements WeighingTicketRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, WeighingTicket::class);
}
public function findById(int $id): ?WeighingTicket
{
return $this->find($id);
}
public function save(WeighingTicket $ticket): void
{
$this->getEntityManager()->persist($ticket);
$this->getEntityManager()->flush();
}
public function createListQueryBuilder(?string $search = null): QueryBuilder
{
// 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')->addSelect('c')
->leftJoin('wt.supplier', 's')->addSelect('s')
->leftJoin('wt.site', 'st')->addSelect('st')
->andWhere('wt.deletedAt IS NULL')
->orderBy('wt.number', 'DESC')
;
$this->applySearch($qb, $search);
return $qb;
}
/**
* Recherche fuzzy insensible a la casse sur le numero, le nom du client /
* fournisseur, le libelle « Autre » et l'immatriculation (§ 4.1).
* Metacaracteres LIKE (%, _, \) echappes pour rester litteraux.
*/
private function applySearch(QueryBuilder $qb, ?string $search): void
{
if (null === $search || '' === trim($search)) {
return;
}
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
$qb->andWhere(
'LOWER(wt.number) LIKE :search '
.'OR LOWER(c.companyName) LIKE :search '
.'OR LOWER(s.companyName) LIKE :search '
.'OR LOWER(wt.otherLabel) LIKE :search '
.'OR LOWER(wt.immatriculation) LIKE :search',
)->setParameter('search', $pattern);
}
}