Merge branch 'develop' into fix/erp-193-retours-metier
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled

This commit is contained in:
2026-06-19 13:08:14 +00:00
58 changed files with 5451 additions and 15 deletions
@@ -80,6 +80,9 @@ final class RbacSeeder
// Transporteurs (M4 § 5.2, ERP-153) : view + manage (PAS archive -> admin seul).
'transport.carriers.view',
'transport.carriers.manage',
// Tickets de pesee (M5 § 5.2, ERP-181) : view + manage (« Tout »).
'logistique.weighing_tickets.view',
'logistique.weighing_tickets.manage',
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
@@ -137,9 +140,14 @@ final class RbacSeeder
'label' => 'Usine',
// Prestataires (M3 § 2.9 + § 2.13, ERP-138) : view en lecture seule,
// SANS `sites.bypass_scope` -> cloisonne aux prestataires de son site
// courant. Aucun autre acces metier.
// courant.
'permissions' => [
'technique.providers.view',
// Tickets de pesee (M5 § 5.2, ERP-181) : view + manage. L'Usine
// pese sur site -> reste cloisonnee a son site courant (pas de
// bypass_scope ; les tickets sont filtres par SiteScopedQueryExtension).
'logistique.weighing_tickets.view',
'logistique.weighing_tickets.manage',
],
],
];
@@ -217,6 +217,10 @@ final class SeedE2ECommand extends Command
'transport.carriers.view',
'transport.carriers.manage',
'transport.carriers.archive',
// Logistique — Tickets de pesee (M5, ERP-181). Meme logique :
// mappe sur le persona "tout". Miroir de personas.ts.
'logistique.weighing_tickets.view',
'logistique.weighing_tickets.manage',
],
],
[
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Application\Service;
use App\Shared\Domain\Contract\SiteInterface;
/**
* Allocateur du compteur DSD du pont bascule (RG-5.04, § 2.7).
*
* Le DSD est un index sequentiel de pesee, propre a CHAQUE site (un pont par
* site). Chaque pesee — bascule (AUTO) ou manuelle (MANUAL) — consomme une
* valeur : la suivante = dernier DSD du site + 1.
*
* Port (interface en couche Application) ; l'implementation (DsdAllocator,
* Infrastructure) incremente le compteur sous verrou ligne `SELECT ... FOR
* UPDATE` pour garantir l'unicite en concurrence.
*/
interface DsdAllocatorInterface
{
/**
* Attribue et renvoie la prochaine valeur DSD pour le site (dernier + 1),
* en persistant l'increment de maniere atomique (verrou ligne).
*/
public function next(SiteInterface $site): int;
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Application\Service;
use App\Module\Logistique\Domain\Exception\InvalidImmatriculationException;
/**
* Normalisation serveur des champs texte d'un WeighingTicket, appliquee par le
* WeighingTicketProcessor AVANT persistance. Cf. spec-back M5 § 6 + RG-5.01 /
* RG-5.10. Jumeau leger de CarrierFieldNormalizer (M4).
*
* - immatriculation (RG-5.01 / RG-5.10) : trim + UPPER. Si « Tout format » N'EST
* PAS coche (freeFormat = false), la saisie est ramenee au masque SIV
* canonique XX-000-XX (separateurs/espaces ignores a la saisie, re-poses) ; une
* plaque qui ne s'y conforme pas leve InvalidImmatriculationException (-> 422
* par le Processor). En « Tout format » (anciennes plaques, etranger, engins),
* seul le trim + UPPER s'applique.
* - otherLabel (RG-5.03) : trim ; une chaine vide apres trim devient null (evite
* de persister "" dans une colonne nullable).
*
* Methodes null-safe : une entree null ressort null (l'obligation eventuelle est
* portee par les Assert de l'entite / la coherence contrepartie, pas ici).
*/
final class WeighingTicketFieldNormalizer
{
/**
* Plaque SIV « nue » (sans separateurs) : 2 lettres, 3 chiffres, 2 lettres.
* Les lettres interdites du SIV (I, O, U + SS) ne sont pas filtrees ici : le
* masque de saisie reste volontairement simple (le metier accepte ces cas via
* « Tout format » si besoin).
*/
private const string SIV_BARE_PATTERN = '/^[A-Z]{2}[0-9]{3}[A-Z]{2}$/';
/**
* Normalise l'immatriculation (RG-5.01 / RG-5.10).
*
* @param bool $freeFormat « Tout format » coche -> masque SIV desactive
*
* @throws InvalidImmatriculationException si !freeFormat et la plaque ne
* respecte pas le masque XX-000-XX
*/
public function normalizeImmatriculation(?string $value, bool $freeFormat): ?string
{
if (null === $value) {
return null;
}
$value = mb_strtoupper(trim($value), 'UTF-8');
if ('' === $value) {
return null;
}
// « Tout format » : aucune contrainte de masque (RG-5.01).
if ($freeFormat) {
return $value;
}
// Masque SIV : on ignore tout ce qui n'est pas alphanumerique (l'operateur
// peut saisir « ab123cd », « AB 123 CD » ou « AB-123-CD ») puis on valide
// le squelette 2-3-2 et on repose les separateurs canoniques.
$bare = preg_replace('/[^A-Z0-9]/', '', $value) ?? '';
if (1 !== preg_match(self::SIV_BARE_PATTERN, $bare)) {
throw new InvalidImmatriculationException(
'Format d\'immatriculation invalide : attendu XX-000-XX (cochez « Tout format » pour une plaque libre).',
);
}
return sprintf('%s-%s-%s', substr($bare, 0, 2), substr($bare, 2, 3), substr($bare, 5, 2));
}
/**
* Trim du libelle « Autre » (RG-5.03). Une chaine vide apres trim devient null.
*/
public function normalizeOtherLabel(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : $value;
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Application\Service;
use App\Module\Sites\Domain\Entity\Site;
/**
* Allocateur du numero de ticket de pesee (RG-5.02, § 2.5).
*
* Le numero a le format {siteCode}-TP-{NNNN} (ex. 86-TP-0001), UNIQUE PAR SITE
* et immuable. Chaque site porte sa propre sequence : 86-TP-0001 et 17-TP-0001
* coexistent.
*
* Le code du site (prefixe) vit sur l'entite Site (site.code, ERP-183) — d'ou le
* type-hint sur Site concret (et non SiteInterface qui n'expose pas getCode()) ;
* c'est la meme reference ORM partagee que celle consommee par WeighingTicket
* (§ 2.1, pas de logique inter-module).
*
* Port (couche Application) ; l'implementation (WeighingTicketNumberAllocator,
* Infrastructure) incremente le compteur weighing_ticket_counter sous verrou ligne
* `SELECT ... FOR UPDATE` pour garantir l'unicite meme en concurrence.
*/
interface WeighingTicketNumberAllocatorInterface
{
/**
* Attribue et renvoie le prochain numero formate {siteCode}-TP-{NNNN} pour le
* site, en persistant l'increment de maniere atomique (verrou ligne).
*/
public function allocate(Site $site): string;
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Domain\Contract;
use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException;
use App\Module\Logistique\Domain\Weighbridge\WeighbridgeReading;
use App\Shared\Domain\Contract\SiteInterface;
/**
* Contrat de lecture du pont bascule (§ 2.6).
*
* Abstraction posee au M5 pour decoupler l'API du materiel : l'implementation
* livree est un stub (RandomWeighbridgeReader, poids aleatoire ∈ [10000,50000]
* kg). Le driver materiel reel (protocole serie/TCP de l'indicateur de pesage)
* est hors perimetre M5 (HP-M5-02) : le jour venu on substitue l'implementation
* derriere cette interface — zero impact sur les ecrans / l'API.
*/
interface WeighbridgeReaderInterface
{
/**
* Effectue une pesee « bascule » (AUTO) pour le site donne : renvoie le poids
* lu et le DSD (index de pesee du pont) attribue pour ce site (RG-5.04).
*
* @throws WeighbridgeUnavailableException si la bascule ne repond pas
* (le Processor traduit en HTTP 503 →
* bascule manuelle, RG-5.06)
*/
public function read(SiteInterface $site): WeighbridgeReading;
}
@@ -0,0 +1,557 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Commercial\Domain\Entity\Client; // relation ORM partagee (§ 2.1)
use App\Module\Commercial\Domain\Entity\Supplier; // relation ORM partagee (§ 2.1)
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighingTicketProcessor;
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketProvider;
use App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository;
use App\Module\Sites\Domain\Entity\Site; // relation ORM partagee (§ 2.1)
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Ticket de pesee (M5 Logistique) — entite racine du module, jumelle de
* Carrier (M4) / Supplier (M2) cote pattern (#[Auditable], TimestampableBlamable,
* contrat de serialisation 3 maillons). Porte EXACTEMENT deux pesees modelisees
* en colonnes plates (vide + plein, § 2.4), une contrepartie Client/Fournisseur/
* Autre (RG-5.03) et l'immatriculation partagee entre les deux formulaires
* (RG-5.01).
*
* Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) :
* - LISTE (weighing_ticket:read + client:read + supplier:read + site:read +
* default:read) : number, counterpartyType, client/supplier embarques,
* otherLabel, displayDate (= fullDate ?? emptyDate), netWeight,
* plateFreeFormat, createdAt/updatedAt (via default:read).
* - DETAIL (+ weighing_ticket:item:read) : ajoute site embarque, immatriculation
* et les deux pesees (empty* / full*).
*
* Champs renseignes SERVEUR (lecture seule cote API, sans groupe d'ecriture) :
* - number : numero {siteCode}-TP-{NNNN} attribue par le WeighingTicketProcessor
* (RG-5.02, immuable) ;
* - site : resolu depuis le site courant a la creation (CurrentSiteProvider,
* § 2.3), immuable (RG-5.09) ;
* - netWeight : poids net derive plein - vide, recalcule serveur (RG-5.05).
*
* Les RG inter-champs (RG-5.03 : champ associe a counterpartyType obligatoire)
* passent par une contrainte d'entite (Assert\Callback + ->atPath()) pour que
* chaque 422 porte un propertyPath exploitable par useFormErrors (mapping inline,
* pas un toast — ERP-101). L'exclusivite « les autres champs forces nuls » est
* garantie par les CHECK Postgres (chk_wt_*_branch) + la normalisation du
* Processor (ERP-185). Pas de Delete, pas d'archive au M5 (§ 2.13).
*
* @see WeighingTicketProvider Lecture (liste paginee filtree site courant + item) — ERP-185.
* @see WeighingTicketProcessor Ecriture (numerotation, normalisation, net) — ERP-185.
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('logistique.weighing_tickets.view')",
normalizationContext: ['groups' => [
'weighing_ticket:read',
'client:read',
'supplier:read',
'site:read',
'default:read',
]],
provider: WeighingTicketProvider::class,
),
new Get(
security: "is_granted('logistique.weighing_tickets.view')",
normalizationContext: ['groups' => [
'weighing_ticket:read',
'weighing_ticket:item:read',
'client:read',
'supplier:read',
'site:read',
'default:read',
]],
provider: WeighingTicketProvider::class,
),
new Post(
security: "is_granted('logistique.weighing_tickets.manage')",
normalizationContext: ['groups' => [
'weighing_ticket:read',
'weighing_ticket:item:read',
'client:read',
'supplier:read',
'site:read',
'default:read',
]],
denormalizationContext: ['groups' => ['weighing_ticket:write']],
processor: WeighingTicketProcessor::class,
),
new Patch(
security: "is_granted('logistique.weighing_tickets.manage')",
normalizationContext: ['groups' => [
'weighing_ticket:read',
'weighing_ticket:item:read',
'client:read',
'supplier:read',
'site:read',
'default:read',
]],
denormalizationContext: ['groups' => ['weighing_ticket:write']],
provider: WeighingTicketProvider::class,
processor: WeighingTicketProcessor::class,
),
// Pas de Delete au M5 (HP-M5-05). Pas d'archive (hors docx).
],
)]
#[ORM\Entity(repositoryClass: DoctrineWeighingTicketRepository::class)]
#[ORM\Table(name: 'weighing_ticket')]
#[ORM\Index(name: 'idx_wt_site', columns: ['site_id'])]
#[ORM\Index(name: 'idx_wt_client', columns: ['client_id'])]
#[ORM\Index(name: 'idx_wt_supplier', columns: ['supplier_id'])]
#[ORM\Index(name: 'idx_wt_deleted_at', columns: ['deleted_at'])]
#[ORM\Index(name: 'idx_wt_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_wt_updated_by', columns: ['updated_by'])]
#[ORM\UniqueConstraint(name: 'uq_weighing_ticket_number', columns: ['site_id', 'number'])]
#[Auditable]
class WeighingTicket implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['weighing_ticket:read'])]
private ?int $id = null;
/** Numero {siteCode}-TP-{NNNN} — attribue serveur, lecture seule, immuable (RG-5.02). */
#[ORM\Column(length: 20)]
#[Groups(['weighing_ticket:read'])]
private ?string $number = null;
/** Site du pont bascule — resolu serveur depuis le site courant, immuable (§ 2.3 / RG-5.09). */
#[ORM\ManyToOne(targetEntity: Site::class)]
#[ORM\JoinColumn(name: 'site_id', nullable: false, onDelete: 'RESTRICT')]
#[Groups(['weighing_ticket:item:read'])]
private ?Site $site = null;
/** CLIENT | FOURNISSEUR | AUTRE (RG-5.03) — pilote le champ associe obligatoire. */
#[ORM\Column(name: 'counterparty_type', length: 12)]
#[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.')]
#[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR', 'AUTRE'], message: 'Type de contrepartie invalide.')]
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
private ?string $counterpartyType = null;
/** Requis ssi counterpartyType = CLIENT (validateCounterpartyConsistency, RG-5.03). */
#[ORM\ManyToOne(targetEntity: Client::class)]
#[ORM\JoinColumn(name: 'client_id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
private ?Client $client = null;
/** Requis ssi counterpartyType = FOURNISSEUR (RG-5.03). */
#[ORM\ManyToOne(targetEntity: Supplier::class)]
#[ORM\JoinColumn(name: 'supplier_id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
private ?Supplier $supplier = null;
/** Libelle libre — requis ssi counterpartyType = AUTRE (RG-5.03). */
#[ORM\Column(name: 'other_label', length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le libellé ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
private ?string $otherLabel = null;
/** Plaque du vehicule, partagee entre les 2 formulaires (RG-5.01). Masque XX-000-XX sauf plateFreeFormat. */
#[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'L\'immatriculation est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 20, maxMessage: 'L\'immatriculation ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?string $immatriculation = null;
// « Tout format » : desactive le masque XX-000-XX (RG-5.01). Le groupe de
// LECTURE est porte par le getter isPlateFreeFormat() (+ SerializedName,
// piege booleen #3 M1) ; le groupe d'ECRITURE vit ici pour cibler le setter.
#[ORM\Column(name: 'plate_free_format', options: ['default' => false])]
#[Groups(['weighing_ticket:write'])]
private bool $plateFreeFormat = false;
// === Pesee a vide (§ 2.4) ===
#[ORM\Column(name: 'empty_date', type: 'datetime_immutable', nullable: true)]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?DateTimeImmutable $emptyDate = null;
/** Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07). */
#[ORM\Column(name: 'empty_weight', nullable: true)]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?int $emptyWeight = null;
#[ORM\Column(name: 'empty_dsd', nullable: true)]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?int $emptyDsd = null;
/** AUTO (pont bascule) | MANUAL (saisie) — chk_wt_empty_mode (RG-5.06). */
#[ORM\Column(name: 'empty_mode', length: 8, nullable: true)]
#[Assert\Choice(choices: ['AUTO', 'MANUAL'], message: 'Mode de pesée invalide.')]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?string $emptyMode = null;
/** Numero de pesee saisi en manuelle (distinct du DSD) — RG-5.04. */
#[ORM\Column(name: 'empty_manual_number', length: 50, nullable: true)]
#[Assert\Length(max: 50, maxMessage: 'Le numéro de pesée ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?string $emptyManualNumber = null;
// === Pesee a plein (§ 2.4) ===
#[ORM\Column(name: 'full_date', type: 'datetime_immutable', nullable: true)]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?DateTimeImmutable $fullDate = null;
/** Poids a plein (brut) en kg — readonly UI, rempli par la pesee (RG-5.07). */
#[ORM\Column(name: 'full_weight', nullable: true)]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?int $fullWeight = null;
#[ORM\Column(name: 'full_dsd', nullable: true)]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?int $fullDsd = null;
/** AUTO (pont bascule) | MANUAL (saisie) — chk_wt_full_mode (RG-5.06). */
#[ORM\Column(name: 'full_mode', length: 8, nullable: true)]
#[Assert\Choice(choices: ['AUTO', 'MANUAL'], message: 'Mode de pesée invalide.')]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?string $fullMode = null;
/** Numero de pesee saisi en manuelle (distinct du DSD) — RG-5.04. */
#[ORM\Column(name: 'full_manual_number', length: 50, nullable: true)]
#[Assert\Length(max: 50, maxMessage: 'Le numéro de pesée ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?string $fullManualNumber = null;
/** Poids net derive plein - vide (kg) — calcule serveur (RG-5.05). Colonne Poids de la liste. */
#[ORM\Column(name: 'net_weight', nullable: true)]
#[Groups(['weighing_ticket:read'])]
private ?int $netWeight = null;
/** Soft-delete technique prepare mais non expose au M5 (§ 2.13) — pas de groupe. */
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
private ?DateTimeImmutable $deletedAt = null;
/**
* Coherence de la contrepartie (RG-5.03). Decision figee (miroir M4
* validateMainFormConsistency) : porte par une contrainte d'entite
* (Assert\Callback + ->atPath()) pour que chaque 422 soit mappee inline sous
* le champ par useFormErrors (pas un toast — ERP-101). Jouee par API Platform
* AVANT le Processor, sur POST comme sur PATCH.
*
* Ne valide ICI que la PRESENCE du champ associe au type. L'exclusivite
* « les autres champs forces nuls » est garantie par les CHECK Postgres
* (chk_wt_*_branch) et la normalisation du Processor (qui null-ifie les
* champs hors-branche — ERP-185).
*/
#[Assert\Callback]
public function validateCounterpartyConsistency(ExecutionContextInterface $context): void
{
switch ($this->counterpartyType) {
case 'CLIENT':
if (null === $this->client) {
$context->buildViolation('Le client est obligatoire pour une contrepartie « Client ».')
->atPath('client')
->addViolation()
;
}
break;
case 'FOURNISSEUR':
if (null === $this->supplier) {
$context->buildViolation('Le fournisseur est obligatoire pour une contrepartie « Fournisseur ».')
->atPath('supplier')
->addViolation()
;
}
break;
case 'AUTRE':
if (null === $this->otherLabel || '' === trim($this->otherLabel)) {
$context->buildViolation('Le libellé est obligatoire pour une contrepartie « Autre ».')
->atPath('otherLabel')
->addViolation()
;
}
break;
}
}
/**
* Date du ticket affichee en LISTE (§ 4.0) : date de la pesee a plein si
* disponible, sinon date de la pesee a vide. Getter calcule (jamais
* persiste), expose en lecture seule.
*/
#[Groups(['weighing_ticket:read'])]
public function getDisplayDate(): ?DateTimeImmutable
{
return $this->fullDate ?? $this->emptyDate;
}
public function getId(): ?int
{
return $this->id;
}
public function getNumber(): ?string
{
return $this->number;
}
public function setNumber(?string $number): static
{
$this->number = $number;
return $this;
}
public function getSite(): ?Site
{
return $this->site;
}
public function setSite(?Site $site): static
{
$this->site = $site;
return $this;
}
public function getCounterpartyType(): ?string
{
return $this->counterpartyType;
}
public function setCounterpartyType(?string $counterpartyType): static
{
$this->counterpartyType = $counterpartyType;
return $this;
}
public function getClient(): ?Client
{
return $this->client;
}
public function setClient(?Client $client): static
{
$this->client = $client;
return $this;
}
public function getSupplier(): ?Supplier
{
return $this->supplier;
}
public function setSupplier(?Supplier $supplier): static
{
$this->supplier = $supplier;
return $this;
}
public function getOtherLabel(): ?string
{
return $this->otherLabel;
}
public function setOtherLabel(?string $otherLabel): static
{
$this->otherLabel = $otherLabel;
return $this;
}
public function getImmatriculation(): ?string
{
return $this->immatriculation;
}
public function setImmatriculation(?string $immatriculation): static
{
$this->immatriculation = $immatriculation;
return $this;
}
// Piege booleen (RETEX M1 #3) : #[Groups] + #[SerializedName] sur le getter,
// sinon Symfony strip le prefixe « is » et drope la cle plateFreeFormat du JSON.
#[Groups(['weighing_ticket:read'])]
#[SerializedName('plateFreeFormat')]
public function isPlateFreeFormat(): bool
{
return $this->plateFreeFormat;
}
public function setPlateFreeFormat(bool $plateFreeFormat): static
{
$this->plateFreeFormat = $plateFreeFormat;
return $this;
}
public function getEmptyDate(): ?DateTimeImmutable
{
return $this->emptyDate;
}
public function setEmptyDate(?DateTimeImmutable $emptyDate): static
{
$this->emptyDate = $emptyDate;
return $this;
}
public function getEmptyWeight(): ?int
{
return $this->emptyWeight;
}
public function setEmptyWeight(?int $emptyWeight): static
{
$this->emptyWeight = $emptyWeight;
return $this;
}
public function getEmptyDsd(): ?int
{
return $this->emptyDsd;
}
public function setEmptyDsd(?int $emptyDsd): static
{
$this->emptyDsd = $emptyDsd;
return $this;
}
public function getEmptyMode(): ?string
{
return $this->emptyMode;
}
public function setEmptyMode(?string $emptyMode): static
{
$this->emptyMode = $emptyMode;
return $this;
}
public function getEmptyManualNumber(): ?string
{
return $this->emptyManualNumber;
}
public function setEmptyManualNumber(?string $emptyManualNumber): static
{
$this->emptyManualNumber = $emptyManualNumber;
return $this;
}
public function getFullDate(): ?DateTimeImmutable
{
return $this->fullDate;
}
public function setFullDate(?DateTimeImmutable $fullDate): static
{
$this->fullDate = $fullDate;
return $this;
}
public function getFullWeight(): ?int
{
return $this->fullWeight;
}
public function setFullWeight(?int $fullWeight): static
{
$this->fullWeight = $fullWeight;
return $this;
}
public function getFullDsd(): ?int
{
return $this->fullDsd;
}
public function setFullDsd(?int $fullDsd): static
{
$this->fullDsd = $fullDsd;
return $this;
}
public function getFullMode(): ?string
{
return $this->fullMode;
}
public function setFullMode(?string $fullMode): static
{
$this->fullMode = $fullMode;
return $this;
}
public function getFullManualNumber(): ?string
{
return $this->fullManualNumber;
}
public function setFullManualNumber(?string $fullManualNumber): static
{
$this->fullManualNumber = $fullManualNumber;
return $this;
}
public function getNetWeight(): ?int
{
return $this->netWeight;
}
public function setNetWeight(?int $netWeight): static
{
$this->netWeight = $netWeight;
return $this;
}
public function getDeletedAt(): ?DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Domain\Exception;
use RuntimeException;
/**
* Levee quand une immatriculation ne respecte pas le masque SIV XX-000-XX alors
* que « Tout format » n'est PAS coche (plateFreeFormat = false, RG-5.01).
*
* Exception de DOMAINE (pure, sans dependance HTTP) levee par le
* WeighingTicketFieldNormalizer : c'est le WeighingTicketProcessor qui la traduit
* en 422 portant un propertyPath « immatriculation » (mapping inline useFormErrors,
* convention ERP-101) plutot qu'un toast.
*/
final class InvalidImmatriculationException extends RuntimeException {}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Domain\Exception;
use RuntimeException;
/**
* Levee lorsque le pont bascule ne repond pas / est indisponible (RG-5.06).
*
* Exception de DOMAINE (pure, sans dependance HTTP) : c'est le Processor de
* l'endpoint de pesee qui la traduit en reponse HTTP 503 « Pont bascule
* indisponible — passez en pesee manuelle » (cf. WeighbridgeReadingProcessor).
*
* Au M5, le stub (RandomWeighbridgeReader) ne la leve jamais, mais le chemin
* d'erreur est implemente et teste pour le jour ou un driver materiel reel
* (HP-M5-02) sera branche derriere WeighbridgeReaderInterface.
*/
final class WeighbridgeUnavailableException extends RuntimeException {}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Domain\Repository;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use Doctrine\ORM\QueryBuilder;
/**
* Contrat du repository des tickets de pesee (M5). Implementation Doctrine :
* App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository.
*/
interface WeighingTicketRepositoryInterface
{
public function findById(int $id): ?WeighingTicket;
public function save(WeighingTicket $ticket): void;
/**
* QueryBuilder de SELECTION (recherche + tri + fetch-join client/supplier/site)
* pour la liste, exploite par le WeighingTicketProvider (ERP-185) qui le wrappe
* dans un Paginator (règle ABSOLUE n°13). Exclut les soft-deletes (deleted_at
* IS NOT NULL). Tri par defaut number DESC (plus recents en tete, § 4.1).
*
* Le cloisonnement par site courant n'est PAS applique ici : un provider custom
* court-circuite le SiteScopedQueryExtension (qui n'agit que dans le provider
* ORM standard), donc le WeighingTicketProvider l'applique lui-meme (§ 2.3).
*
* @param null|string $search recherche fuzzy sur number, nom client/fournisseur,
* other_label et immatriculation (§ 4.1)
*/
public function createListQueryBuilder(?string $search = null): QueryBuilder;
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Domain\Weighbridge;
/**
* Resultat immuable d'une lecture du pont bascule (§ 2.6 / RG-5.06).
*
* Porte le couple {poids, DSD} renvoye par une pesee « bascule » (AUTO) :
* - weight : poids brut lu, en kilogrammes ;
* - dsd : index de pesee du pont (compteur par site, RG-5.04).
*
* Au M5 le pont est un stub (RandomWeighbridgeReader) ; un driver materiel reel
* (HP-M5-02) produira le meme objet derriere WeighbridgeReaderInterface, sans
* impact sur l'API.
*/
final readonly class WeighbridgeReading
{
public function __construct(
public int $weight,
public int $dsd,
) {}
}
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighbridgeReadingProcessor;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Ressource API Platform virtuelle (non mappee Doctrine) portant l'action de
* pesee au pont bascule : `POST /api/weighbridge_readings` (§ 4.2).
*
* Action AUTONOME : declenchee depuis le formulaire AVANT que le ticket existe.
* Le site est resolu serveur (site courant) — jamais envoye par le client.
*
* - AUTO (`{ "mode": "AUTO" }`) → `{ weight, dsd, mode }` (stub : poids
* aleatoire ∈ [10000,50000] kg + DSD du site, RG-5.04 / RG-5.06).
* - MANUAL (`{ "mode": "MANUAL", "weight": <int>, "manualNumber": "<str>" }`)
* → `{ weight, dsd, manualNumber, mode }` (DSD = dernier DSD du site + 1).
*
* `read: false` : pas de chargement d'entite existante — le payload est
* denormalise directement dans cette ressource, puis le Processor prend le relais.
*
* ⚠ Le `dsd` renvoye ici est PREVISIONNEL : l'attribution AUTORITAIRE du DSD
* (et du numero de ticket) est refaite/verrouillee a la creation du ticket
* (`POST /api/weighing_tickets`, ERP-185) pour eviter les collisions si deux
* postes pesent en parallele. Le front affiche cette valeur, mais c'est le
* ticket persiste qui fait foi.
*/
#[ApiResource(
shortName: 'WeighbridgeReading',
operations: [
new Post(
uriTemplate: '/weighbridge_readings',
// Action de lecture du pont (pas une creation de ressource) : 200, pas 201.
status: 200,
security: "is_granted('logistique.weighing_tickets.manage')",
normalizationContext: ['groups' => ['weighbridge_reading:read']],
denormalizationContext: ['groups' => ['weighbridge_reading:write']],
processor: WeighbridgeReadingProcessor::class,
read: false,
),
],
)]
final class WeighbridgeReadingResource
{
/** AUTO (pesee bascule) | MANUAL (pesee manuelle) — pilote le comportement (§ 4.2). */
#[Assert\NotBlank(message: 'Le mode de pesée est obligatoire.')]
#[Assert\Choice(choices: ['AUTO', 'MANUAL'], message: 'Mode de pesée invalide (AUTO ou MANUAL).')]
#[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])]
public ?string $mode = null;
/**
* Poids en kg. En entree : requis et saisi en MANUAL, ignore en AUTO (le pont
* fournit le poids). En sortie : poids effectif de la pesee.
*/
#[Assert\Positive(message: 'Le poids doit être un entier positif (kg).')]
#[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])]
public ?int $weight = null;
/** Numero de pesee papier saisi en MANUAL (distinct du DSD, RG-5.04). */
#[Assert\Length(max: 50, maxMessage: 'Le numéro de pesée ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])]
public ?string $manualNumber = null;
/** DSD attribue par le serveur (lecture seule) — previsionnel (cf. docbloc classe). */
#[Groups(['weighbridge_reading:read'])]
public ?int $dsd = null;
/**
* RG metier : en pesee MANUAL, le poids est saisi par l'operateur (le pont
* n'est pas lu) → il est obligatoire. Porte par un Callback pour que le 422
* cible le propertyPath `weight` (mapping inline front, ERP-101). En AUTO,
* le poids fourni par le client est ignore (renseigne par le pont).
*/
#[Assert\Callback]
public function validateManualWeight(ExecutionContextInterface $context): void
{
if ('MANUAL' === $this->mode && null === $this->weight) {
$context->buildViolation('Le poids est obligatoire en pesée manuelle.')
->atPath('weight')
->addViolation()
;
}
}
}
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface;
use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException;
use App\Module\Logistique\Infrastructure\ApiPlatform\Resource\WeighbridgeReadingResource;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use LogicException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
/**
* Processor de l'action `POST /api/weighbridge_readings` (§ 4.2).
*
* Resout le site courant (CurrentSiteProviderInterface — contrat Sites, seule
* logique cross-module autorisee, regle ABSOLUE n°1) puis :
* - AUTO : lit le pont (WeighbridgeReaderInterface) → poids + DSD. Si la
* bascule est indisponible (WeighbridgeUnavailableException) → HTTP 503
* « Pont bascule indisponible — passez en pesee manuelle » (RG-5.06).
* - MANUAL : conserve le poids saisi et alloue le DSD (dernier + 1, RG-5.04).
*
* @implements ProcessorInterface<WeighbridgeReadingResource, WeighbridgeReadingResource>
*/
final class WeighbridgeReadingProcessor implements ProcessorInterface
{
public function __construct(
private readonly CurrentSiteProviderInterface $currentSiteProvider,
private readonly WeighbridgeReaderInterface $weighbridgeReader,
private readonly DsdAllocatorInterface $dsdAllocator,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): WeighbridgeReadingResource
{
if (!$data instanceof WeighbridgeReadingResource) {
throw new LogicException(sprintf(
'WeighbridgeReadingProcessor attend une instance de %s, %s recu.',
WeighbridgeReadingResource::class,
get_debug_type($data),
));
}
// Site courant resolu serveur (jamais envoye par le client). Absent =
// aucun site selectionne dans le sélecteur → on ne peut pas peser.
$site = $this->currentSiteProvider->get();
if (null === $site) {
throw new BadRequestHttpException('Aucun site courant sélectionné — sélectionnez un site avant de peser.');
}
if ('AUTO' === $data->mode) {
try {
$reading = $this->weighbridgeReader->read($site);
} catch (WeighbridgeUnavailableException $e) {
// RG-5.06 : le pont ne repond pas → 503 explicite, le front bascule
// en pesee manuelle. (Le stub M5 ne leve jamais — chemin teste.)
throw new ServiceUnavailableHttpException(
null,
'Pont bascule indisponible — passez en pesée manuelle.',
$e,
);
}
$data->weight = $reading->weight;
$data->dsd = $reading->dsd;
$data->manualNumber = null; // pas de numero papier en mode bascule
return $data;
}
// MANUAL : le poids est saisi (validateManualWeight garantit sa presence),
// seul le DSD est attribue serveur (dernier DSD du site + 1, RG-5.04).
$data->dsd = $this->dsdAllocator->next($site);
return $data;
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Infrastructure\Controller;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use App\Module\Logistique\Domain\Repository\WeighingTicketRepositoryInterface;
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketProvider;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use DateTimeImmutable;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Export XLSX de la liste des tickets de pesee (M5, spec-back § 4.5 — bouton
* « Exporter » : « Exporte toute la liste des tickets de pesée »). Jumeau des
* controllers d'export SupplierExportController (M2) / ProviderExportController
* (M3) — references en prose volontairement (un {@see} inter-module violerait la
* regle ABSOLUE n°1).
*
* Controller Symfony custom (et non operation API Platform) car il produit un
* binaire de fichier, pas une representation Hydra. `priority: 1` est OBLIGATOIRE
* sur la route : sans cela API Platform capterait `/api/weighing_tickets/export.xlsx`
* comme l'item `GET /api/weighing_tickets/{id}.{_format}` (id="export",
* _format="xlsx") — cf. CLAUDE.md « controller custom sous /api ».
*
* Separation des responsabilites :
* - le COMMENT (generation du fichier) est delegue au service Shared
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
* - le QUOI vit ICI : selection des tickets (MEMES criteres que la liste
* `GET /api/weighing_tickets`, mais SANS pagination — export complet § 4.5) et
* mapping metier des colonnes.
*
* Filtrage : on rejoue EXACTEMENT la selection du {@see WeighingTicketProvider}
* pour que l'export reflete ce que l'utilisateur voit a l'ecran :
* - recherche fuzzy ?search (number, nom client/fournisseur, other_label, immat) ;
* - tri ?order[displayDate]=asc|desc (defaut number DESC) ;
* - cloisonnement par site courant (§ 2.3 / RG-5.09) : un user sans
* `sites.bypass_scope` possedant un site courant n'exporte que les tickets de
* ce site. La decision est prise ICI (l'user), le filtre DQL sur wt.site est
* pose sur le QueryBuilder. No-op pour bypass_scope ou site courant null.
*/
#[AsController]
final class WeighingTicketExportController
{
public function __construct(
#[Autowire(service: 'App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository')]
private readonly WeighingTicketRepositoryInterface $repository,
private readonly SpreadsheetExporterInterface $exporter,
private readonly Security $security,
private readonly CurrentSiteProviderInterface $currentSiteProvider,
) {}
#[Route('/api/weighing_tickets/export.xlsx', name: 'logistique_weighing_tickets_export_xlsx', methods: ['GET'], priority: 1)]
#[IsGranted('logistique.weighing_tickets.view')]
public function __invoke(Request $request): Response
{
$search = $request->query->getString('search') ?: null;
$qb = $this->repository->createListQueryBuilder($search);
$this->applyDisplayDateOrder($qb, $request->query->all());
$this->applySiteScope($qb);
// Export complet : pas de pagination (§ 4.5). On materialise toute la
// selection filtree (cloisonnee par site) AVANT le mapping des colonnes.
/** @var list<WeighingTicket> $tickets */
$tickets = $qb->getQuery()->getResult();
$binary = $this->exporter->export(
'Tickets de pesée',
$this->buildHeaders(),
$this->buildRows($tickets),
);
return $this->buildResponse($binary);
}
/**
* Tri par date du ticket (§ 4.1), miroir de WeighingTicketProvider :
* displayDate = COALESCE(full_date, empty_date) (getter calcule, pas une
* colonne). Absent du payload -> tri par defaut du repository (number DESC).
*
* @param array<string, mixed> $query
*/
private function applyDisplayDateOrder(QueryBuilder $qb, array $query): void
{
$order = $query['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);
}
/**
* Cloisonnement par site courant (§ 2.3 / RG-5.09), miroir de
* WeighingTicketProvider::applySiteScope() : restreint la selection au site
* courant si l'user n'a pas le bypass et qu'un site est resolu. No-op sinon.
*/
private function applySiteScope(QueryBuilder $qb): void
{
$scopeSite = $this->siteScopeOrNull();
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
* (user `sites.bypass_scope`, ou pas de site courant — module Sites off /
* user sans currentSite). Miroir de WeighingTicketProvider::currentScopeSite().
*/
private function siteScopeOrNull(): ?SiteInterface
{
if ($this->security->isGranted('sites.bypass_scope')) {
return null;
}
return $this->currentSiteProvider->get();
}
/**
* Colonnes de l'export (spec § 4.5).
*
* @return list<string>
*/
private function buildHeaders(): array
{
return [
'Numéro',
'Type contrepartie',
'Contrepartie',
'Date',
'Immatriculation',
'Poids vide (kg)',
'Poids plein (kg)',
'Poids net (kg)',
'DSD vide',
'DSD plein',
];
}
/**
* @param list<WeighingTicket> $tickets
*
* @return iterable<list<null|scalar>>
*/
private function buildRows(array $tickets): iterable
{
foreach ($tickets as $ticket) {
yield [
$ticket->getNumber(),
$this->counterpartyTypeLabel($ticket->getCounterpartyType()),
$this->counterpartyName($ticket),
$ticket->getDisplayDate()?->format('d/m/Y H:i') ?? '',
$ticket->getImmatriculation() ?? '',
$ticket->getEmptyWeight() ?? '',
$ticket->getFullWeight() ?? '',
$ticket->getNetWeight() ?? '',
$ticket->getEmptyDsd() ?? '',
$ticket->getFullDsd() ?? '',
];
}
}
/**
* Libelle FR du type de contrepartie (RG-5.03). Renvoie la valeur brute pour
* une valeur inattendue (garde-fou : ne masque pas une donnee corrompue).
*/
private function counterpartyTypeLabel(?string $type): string
{
return match ($type) {
'CLIENT' => 'Client',
'FOURNISSEUR' => 'Fournisseur',
'AUTRE' => 'Autre',
default => $type ?? '',
};
}
/**
* Nom de la contrepartie selon le type (RG-5.03) : raison sociale du client,
* du fournisseur, ou libelle libre « Autre ». Client / Supplier sont
* fetch-joines par le repository (anti N+1, § 4.0).
*/
private function counterpartyName(WeighingTicket $ticket): string
{
return match ($ticket->getCounterpartyType()) {
'CLIENT' => $ticket->getClient()?->getCompanyName() ?? '',
'FOURNISSEUR' => $ticket->getSupplier()?->getCompanyName() ?? '',
'AUTRE' => $ticket->getOtherLabel() ?? '',
default => '',
};
}
private function buildResponse(string $binary): Response
{
$filename = sprintf('tickets-pesee-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
$response = new Response($binary);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
return $response;
}
}
@@ -0,0 +1,77 @@
<?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);
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Infrastructure\Service;
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
use App\Shared\Domain\Contract\SiteInterface;
use Doctrine\DBAL\Connection;
use LogicException;
/**
* Implementation DBAL de l'allocateur DSD (RG-5.04, § 2.7).
*
* Le compteur vit dans la table `weighbridge_dsd_counter (site_id PK,
* last_value)` — jamais mappee en ORM (DBAL brut, exclue du schema_filter).
* L'increment est realise dans une transaction avec verrou ligne
* `SELECT ... FOR UPDATE` : deux postes pesant en parallele sur le meme site
* sont serialises, ce qui garantit des DSD distincts (pas de collision).
*
* AUTO comme MANUAL passent par le meme increment (« dernier DSD du site + 1 ») :
* la seule difference fonctionnelle est l'origine du poids (lu par le pont en
* AUTO, saisi en MANUAL), pas la sequence DSD.
*
* 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.
*/
final class DsdAllocator implements DsdAllocatorInterface
{
public function __construct(private readonly Connection $connection) {}
public function next(SiteInterface $site): int
{
$siteId = $site->getId();
if (null === $siteId) {
// Garde defensive : un site non persiste n'a pas de compteur (et la FK
// weighbridge_dsd_counter.site_id -> site(id) rejetterait l'INSERT).
throw new LogicException('Impossible d\'allouer un DSD pour un site non persiste (id null).');
}
return $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 weighbridge_dsd_counter (site_id, last_value) VALUES (:site, 0) ON CONFLICT (site_id) DO NOTHING',
['site' => $siteId],
);
// Verrou ligne : serialise les pesees concurrentes du meme site.
$current = (int) $conn->fetchOne(
'SELECT last_value FROM weighbridge_dsd_counter WHERE site_id = :site FOR UPDATE',
['site' => $siteId],
);
$next = $current + 1;
$conn->executeStatement(
'UPDATE weighbridge_dsd_counter SET last_value = :value WHERE site_id = :site',
['value' => $next, 'site' => $siteId],
);
return $next;
});
}
}
@@ -0,0 +1,74 @@
<?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);
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Infrastructure\Weighbridge;
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface;
use App\Module\Logistique\Domain\Weighbridge\WeighbridgeReading;
use App\Shared\Domain\Contract\SiteInterface;
/**
* Stub du pont bascule livre au M5 (DECISION Matthieu 17/06, § 2.6 / RG-5.06).
*
* Aucune liaison materielle : la pesee « bascule » est simulee par un poids
* aleatoire ∈ [10000, 50000] kg, et le DSD est attribue par l'allocateur de
* site (DsdAllocator, RG-5.04). Le driver materiel reel (HP-M5-02) remplacera
* cette classe derriere WeighbridgeReaderInterface sans impact sur l'API.
*
* Ce stub ne leve jamais WeighbridgeUnavailableException ; le chemin d'erreur
* (→ 503) reste implemente et teste cote Processor.
*/
final class RandomWeighbridgeReader implements WeighbridgeReaderInterface
{
public function __construct(private readonly DsdAllocatorInterface $dsdAllocator) {}
public function read(SiteInterface $site): WeighbridgeReading
{
return new WeighbridgeReading(
weight: random_int(10000, 50000),
dsd: $this->dsdAllocator->next($site),
);
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique;
final class LogistiqueModule
{
public const string ID = 'logistique';
public const string LABEL = 'Logistique';
public const bool REQUIRED = false;
/**
* Liste declarative des permissions RBAC exposees par le module Logistique.
*
* Socle des tickets de pesee (M5 § 5.1, ERP-181) :
* - `view` : consultation de la liste / fiche d'un ticket de pesee ;
* - `manage` : creation / modification d'un ticket de pesee.
*
* Consommee par `app:sync-permissions`. Matrice role -> permissions dans
* `RbacSeeder::MATRIX` (§ 5.2 : Bureau / Usine = view + manage ; Compta /
* Commerciale = aucun acces).
*
* @return array<int, array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'logistique.weighing_tickets.view', 'label' => 'Voir les tickets de pesée'],
['code' => 'logistique.weighing_tickets.manage', 'label' => 'Créer / modifier les tickets de pesée'],
];
}
}
+37
View File
@@ -72,6 +72,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'site')]
#[Auditable]
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]
#[ORM\UniqueConstraint(name: 'uq_site_code', columns: ['code'])]
#[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['name'], message: 'Un site avec ce nom existe deja.')]
class Site implements SiteInterface
@@ -88,6 +89,16 @@ class Site implements SiteInterface
#[Groups(['site:read', 'site:write', 'me:read'])]
private string $name;
// Code court du site (86/17/82) — prefixe de numerotation des tickets de
// pesee (RG-5.02, M5 Logistique). Auto-derive des 2 premiers chiffres du
// code postal (departement) a la creation s'il n'est pas fourni
// explicitement (onPrePersist, § 2.5), editable ensuite cote admin Sites.
// Unique en base (uq_site_code).
#[ORM\Column(length: 8)]
#[Assert\Length(max: 8, maxMessage: 'Le code du site ne peut pas depasser {{ limit }} caracteres.')]
#[Groups(['site:read', 'site:write', 'me:read'])]
private ?string $code = null;
// Premiere ligne d'adresse : numero + voie. Requise.
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'La rue est requise.')]
@@ -188,6 +199,20 @@ class Site implements SiteInterface
$this->updatedAt = new DateTimeImmutable();
}
/**
* A la creation, derive le code court du site des 2 premiers chiffres du
* code postal (departement) si aucun code n'a ete fourni explicitement
* (RG-5.02, § 2.5). Le code reste editable ensuite ; son unicite est
* garantie en base par uq_site_code.
*/
#[ORM\PrePersist]
public function onPrePersist(): void
{
if (null === $this->code || '' === trim($this->code)) {
$this->code = substr($this->postalCode, 0, 2);
}
}
public function getId(): ?int
{
return $this->id;
@@ -205,6 +230,18 @@ class Site implements SiteInterface
return $this;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(?string $code): static
{
$this->code = $code;
return $this;
}
public function getStreet(): string
{
return $this->street;
@@ -36,7 +36,7 @@ class SitesFixtures extends Fixture
public function load(ObjectManager $manager): void
{
// Chatellerault : bleu Starseed.
// Chatellerault : bleu Starseed. Code 86 (prefixe TP — RG-5.02).
$this->ensureSite(
$manager,
name: 'Chatellerault',
@@ -45,11 +45,12 @@ class SitesFixtures extends Fixture
postalCode: '86100',
city: 'Châtellerault',
color: '#056CF2',
code: '86',
);
// Saint-Jean : jaune vif. Le nom du site (identifier) ne reflete
// pas la ville reelle (Fontenet) — c'est une nomenclature interne
// client.
// client. Code 17 (prefixe TP — RG-5.02).
$this->ensureSite(
$manager,
name: 'Saint-Jean',
@@ -58,9 +59,10 @@ class SitesFixtures extends Fixture
postalCode: '17400',
city: 'Fontenet',
color: '#F3CB00',
code: '17',
);
// Pommevic : vert clair.
// Pommevic : vert clair. Code 82 (prefixe TP — RG-5.02).
$this->ensureSite(
$manager,
name: 'Pommevic',
@@ -69,6 +71,7 @@ class SitesFixtures extends Fixture
postalCode: '82400',
city: 'Pommevic',
color: '#74BF04',
code: '82',
);
$manager->flush();
@@ -91,11 +94,13 @@ class SitesFixtures extends Fixture
string $postalCode,
string $city,
string $color,
string $code,
): Site {
$site = $this->siteRepository->findByName($name);
if (null === $site) {
$site = new Site($name, $street, $complement, $postalCode, $city, $color);
$site->setCode($code);
$manager->persist($site);
return $site;
@@ -106,6 +111,7 @@ class SitesFixtures extends Fixture
$site->setPostalCode($postalCode);
$site->setCity($city);
$site->setColor($color);
$site->setCode($code);
return $site;
}
@@ -110,6 +110,7 @@ final class ColumnCommentsCatalog
'_table' => 'Sites geographiques — perimetre de scoping multi-site, attribues aux utilisateurs via user_site.',
'id' => 'Identifiant interne auto-incremente.',
'name' => 'Nom du site (≤ 100 caracteres).',
'code' => 'Code court du site (ex. 86/17/82) — prefixe de numerotation des tickets de pesee (RG-5.02). Auto-derive des 2 premiers chiffres du CP a la creation, editable ensuite. Unique (uq_site_code).',
'city' => 'Ville du site (≤ 100 caracteres).',
'postal_code' => 'Code postal (chaine ≤ 20 caracteres) — VARCHAR pour gerer les zeros initiaux et les formats internationaux.',
'color' => "Code couleur hexadecimal (#RRGGBB) — differenciation visuelle dans l'UI.",
@@ -537,6 +538,50 @@ final class ColumnCommentsCatalog
'price_state' => 'Etat du prix : EN_COURS, VALIDE ou NON_VALIDE (chk_carrier_price_state). Affiche dans le tableau Prix.',
'position' => 'Ordre d affichage du prix dans la liste du transporteur (croissant).',
] + self::timestampableBlamableComments(),
// M5 Logistique (ERP-182) — compteurs par site, hors ORM (DBAL brut
// FOR UPDATE) donc exclus du schema_filter ; catalogues ici pour que
// `app:apply-column-comments` rejoue leurs descriptions au besoin.
'weighing_ticket_counter' => [
'_table' => 'Sequence du numero de ticket de pesee par site (RG-5.02, M5 Logistique) — incrementee en DBAL brut sous verrou FOR UPDATE, hors ORM.',
'site_id' => 'Site proprietaire de la sequence (1 ligne par site). PK + FK -> site.id, ON DELETE CASCADE.',
'last_value' => 'Dernier numero de ticket attribue pour le site. Increment verrouille FOR UPDATE (RG-5.02).',
],
'weighbridge_dsd_counter' => [
'_table' => 'Compteur DSD du pont bascule par site (RG-5.04, M5 Logistique) — chaque pesee consomme une valeur. Incremente en DBAL brut sous verrou FOR UPDATE, hors ORM.',
'site_id' => 'Site proprietaire du compteur (1 pont par site). PK + FK -> site.id, ON DELETE CASCADE.',
'last_value' => 'Derniere valeur DSD attribuee pour le site (pont bascule). Increment verrouille FOR UPDATE (RG-5.04).',
],
// M5 Logistique (ERP-183) — table principale, desormais mappee par
// l'entite WeighingTicket : schema:update (test) la recree sans COMMENT
// -> app:apply-column-comments les rejoue depuis ce catalogue. Strings
// identiques aux COMMENT de la migration Version20260617150000.
'weighing_ticket' => [
'_table' => 'Tickets de pesee (M5 Logistique) — pesee a vide + a plein au pont bascule, contrepartie Client/Fournisseur/Autre. Cloisonne par site courant.',
'id' => 'Identifiant interne auto-incremente.',
'site_id' => 'Site du pont bascule (cloisonnement § 2.3). FK -> site.id, ON DELETE RESTRICT. Renseigne serveur depuis le site courant, immuable (RG-5.09).',
'number' => 'Numero {siteCode}-TP-{NNNN}, unique par site (uq_weighing_ticket_number), immuable. Sequence weighing_ticket_counter (RG-5.02).',
'counterparty_type' => 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (chk_wt_counterparty_type, RG-5.03). Pilote l obligation client_id / supplier_id / other_label.',
'client_id' => 'Branche CLIENT (RG-5.03) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi counterparty_type = CLIENT, nul sinon (chk_wt_client_branch).',
'supplier_id' => 'Branche FOURNISSEUR (RG-5.03) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi counterparty_type = FOURNISSEUR (chk_wt_supplier_branch).',
'other_label' => 'Branche AUTRE (RG-5.03) : libelle libre de la contrepartie. Requis ssi counterparty_type = AUTRE, nul sinon (chk_wt_other_branch).',
'immatriculation' => 'Plaque du vehicule, partagee entre pesee vide et plein. Masque XX-000-XX sauf si plate_free_format (RG-5.01). Normalisee serveur (trim/UPPER).',
'plate_free_format' => '« Tout format » : desactive le masque XX-000-XX de l immatriculation (RG-5.01). Partage entre les 2 formulaires. Faux par defaut.',
'empty_date' => 'Date/heure de la pesee a vide (tare). Defaut jour courant cote front (RG-5.07). Null tant que la pesee vide n est pas faite.',
'empty_weight' => 'Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).',
'empty_dsd' => 'Compteur DSD du pont a la pesee a vide. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).',
'empty_mode' => 'Mode de la pesee a vide : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_empty_mode (RG-5.06).',
'empty_manual_number' => 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a vide (RG-5.04).',
'full_date' => 'Date/heure de la pesee a plein (brut). Null tant que la pesee plein n est pas faite.',
'full_weight' => 'Poids a plein (brut) en kg — readonly UI, rempli par la pesee (RG-5.07).',
'full_dsd' => 'Compteur DSD du pont a la pesee a plein. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).',
'full_mode' => 'Mode de la pesee a plein : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_full_mode (RG-5.06).',
'full_manual_number' => 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a plein (RG-5.04).',
'net_weight' => 'Poids net = full_weight - empty_weight (kg), calcule serveur (RG-5.05). Null si une pesee manque. Colonne Poids de la liste.',
'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.',
] + self::timestampableBlamableComments(),
];
}