feat : M5 — Tickets de pesée (ERP-188 → ERP-193) (#144)
Auto Tag Develop / tag (push) Successful in 8s

MR unique regroupant tout le module M5 « Tickets de pesée » (remplace les MR empilées #140/#141/#142/#143).

## Périmètre
- **ERP-188** — Page liste des tickets de pesée + export XLSX (colonnes Fournisseur/Client/Autre + Statut).
- **ERP-189** — Écran « Ajouter » (4 champs en haut, 2 blocs de pesée, pesée bascule/manuelle, date+heure horodatée à la validation).
- **ERP-190** — Écran « Modifier » + bouton Imprimer.
- **ERP-191** — i18n + libellés + branchement site courant.
- **ERP-192** — Bon de pesée PDF généré côté back (template Twig → Dompdf), endpoint `GET /api/weighing_tickets/{id}/print.pdf`.
- **ERP-193** — Cycle de vie brouillon/validé (status DRAFT/VALIDATED, numéro attribué à la validation), DSD saisi conservé en pesée manuelle, retours métier design.

## Vérifications
- Back : tests Logistique + architecture verts, php-cs-fixer propre, migrations appliquées (dev + test).
- Front : suite Vitest complète verte, ESLint propre.

Base : `develop` — contient les 16 commits du M5 (rien d'autre).
Reviewed-on: #144
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #144.
This commit is contained in:
2026-06-24 14:38:01 +00:00
committed by Autin
parent a4158d4e37
commit faafd99ef8
47 changed files with 4121 additions and 254 deletions
@@ -12,6 +12,7 @@ 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\WeighingTicketPrintProvider;
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)
@@ -84,6 +85,18 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
]],
provider: WeighingTicketProvider::class,
),
// Bon de pesee PDF (RG-5.08, spec § 2.12 / § 4.6) : operation dediee qui
// sert un binaire (pas une representation Hydra). Le provider retourne une
// Response -> la serialisation est court-circuitee. Pas de controller
// (decision spec § 4.6). Pas de format API Platform negocie : `.pdf` est
// litteral dans l'URI.
new Get(
uriTemplate: '/weighing_tickets/{id}/print.pdf',
security: "is_granted('logistique.weighing_tickets.view')",
provider: WeighingTicketPrintProvider::class,
output: false,
read: true,
),
new Post(
security: "is_granted('logistique.weighing_tickets.manage')",
normalizationContext: ['groups' => [
@@ -95,6 +108,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
'default:read',
]],
denormalizationContext: ['groups' => ['weighing_ticket:write']],
// Erreurs de denormalisation (date non parsable, type/IRI invalide)
// remontees en 422 avec propertyPath (et non 400 opaque) -> mapping
// inline par champ cote front via useFormErrors (miroir M1 Client).
collectDenormalizationErrors: true,
processor: WeighingTicketProcessor::class,
),
new Patch(
@@ -108,6 +125,30 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
'default:read',
]],
denormalizationContext: ['groups' => ['weighing_ticket:write']],
collectDenormalizationErrors: true,
provider: WeighingTicketProvider::class,
processor: WeighingTicketProcessor::class,
),
// Validation (« Valider », ERP-193) : transition brouillon -> valide. Seule
// operation qui exige le groupe `finalize` (contrepartie + immatriculation +
// les 2 pesees, § 2.14) ; le Processor y attribue le numero et passe status
// a VALIDATED. Le POST/PATCH standard restent « brouillon » (validation
// Default relachee, on enregistre une pesee sans contrepartie/immat).
new Patch(
uriTemplate: '/weighing_tickets/{id}/validate',
name: 'weighing_ticket_validate',
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']],
validationContext: ['groups' => ['Default', 'finalize']],
collectDenormalizationErrors: true,
provider: WeighingTicketProvider::class,
processor: WeighingTicketProcessor::class,
),
@@ -128,14 +169,20 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
/** Brouillon : pesee(s) enregistree(s), pas encore valide (« En attente »). */
public const string STATUS_DRAFT = 'DRAFT';
/** Valide : contrepartie + immatriculation + 2 pesees OK, numero attribue (« Terminée »). */
public const string STATUS_VALIDATED = 'VALIDATED';
#[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)]
/** Numero {siteCode}-TP-{NNNN} — attribue serveur a la VALIDATION, null tant que brouillon, immuable ensuite (RG-5.02, ERP-193). */
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['weighing_ticket:read'])]
private ?string $number = null;
@@ -145,9 +192,9 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
#[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.')]
/** CLIENT | FOURNISSEUR | AUTRE (RG-5.03) — null tant que brouillon, requis a la validation. Pilote le champ associe obligatoire. */
#[ORM\Column(name: 'counterparty_type', length: 12, nullable: true)]
#[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.', groups: ['finalize'])]
#[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR', 'AUTRE'], message: 'Type de contrepartie invalide.')]
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
private ?string $counterpartyType = null;
@@ -170,9 +217,9 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
#[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')]
/** Plaque du vehicule, partagee entre les 2 formulaires (RG-5.01). Null tant que brouillon, requise a la validation. Masque XX-000-XX sauf plateFreeFormat. */
#[ORM\Column(length: 20, nullable: true)]
#[Assert\NotBlank(message: 'L\'immatriculation est obligatoire.', normalizer: 'trim', groups: ['finalize'])]
#[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;
@@ -190,7 +237,12 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
#[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). */
/**
* Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).
* Nullable au brouillon (on peut enregistrer la seule pesee a plein d'abord,
* ERP-193). L'obligation des DEUX pesees est portee par validateFinalization
* (groupe `finalize`), jouee uniquement a la validation.
*/
#[ORM\Column(name: 'empty_weight', nullable: true)]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?int $emptyWeight = null;
@@ -205,12 +257,6 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
#[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)]
@@ -232,17 +278,21 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
#[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;
/**
* Cycle de vie (ERP-193) : DRAFT (« En attente » — pesee enregistree sans
* contrepartie/immat) -> VALIDATED (« Terminée » — valide avec numero). Pose
* serveur (DRAFT a la creation, VALIDATED par l'operation `validate`) ; pas de
* groupe d'ecriture (jamais pilote par le client).
*/
#[ORM\Column(length: 12, options: ['default' => self::STATUS_DRAFT])]
#[Groups(['weighing_ticket:read'])]
private string $status = self::STATUS_DRAFT;
/** 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;
@@ -259,7 +309,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
* (chk_wt_*_branch) et la normalisation du Processor (qui null-ifie les
* champs hors-branche — ERP-185).
*/
#[Assert\Callback]
#[Assert\Callback(groups: ['finalize'])]
public function validateCounterpartyConsistency(ExecutionContextInterface $context): void
{
switch ($this->counterpartyType) {
@@ -295,6 +345,31 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
}
}
/**
* Validation finale (ERP-193, § 2.14) : un ticket ne peut etre VALIDE qu'avec
* ses DEUX pesees renseignees (le poids net plein - vide n'a de sens que
* complet). Jouee uniquement dans le groupe `finalize` (operation `validate`) ;
* un brouillon peut ne porter qu'une seule pesee. Violations posees sur les
* champs poids -> mapping inline front (useFormErrors, ERP-101).
*/
#[Assert\Callback(groups: ['finalize'])]
public function validateFinalization(ExecutionContextInterface $context): void
{
if (null === $this->emptyWeight) {
$context->buildViolation('La pesée à vide est obligatoire pour valider le ticket.')
->atPath('emptyWeight')
->addViolation()
;
}
if (null === $this->fullWeight) {
$context->buildViolation('La pesée à plein est obligatoire pour valider le ticket.')
->atPath('fullWeight')
->addViolation()
;
}
}
/**
* 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
@@ -459,18 +534,6 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
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;
@@ -519,18 +582,6 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
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;
@@ -543,6 +594,23 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function isValidated(): bool
{
return self::STATUS_VALIDATED === $this->status;
}
public function getDeletedAt(): ?DateTimeImmutable
{
return $this->deletedAt;
@@ -20,17 +20,16 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
*
* - 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).
* - MANUAL (`{ "mode": "MANUAL", "weight": <int>, "dsd": <int> }`)
* → `{ weight, dsd, mode }`. Le DSD est SAISI par l'operateur (numero du pont
* qu'il a reellement utilise) et conserve tel quel — plus d'auto-increment
* (ERP-193). Pas d'unicite : un DSD peut se repeter.
*
* `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.
* ⚠ En AUTO, le `dsd` renvoye est fourni par le pont. En MANUAL, c'est la valeur
* saisie. Le ticket persiste fait foi.
*/
#[ApiResource(
shortName: 'WeighbridgeReading',
@@ -63,29 +62,40 @@ final class WeighbridgeReadingResource
#[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')]
/**
* DSD de la pesee. En AUTO : fourni par le pont (lecture seule). En MANUAL :
* SAISI par l'operateur et conserve tel quel (ERP-193). Positif s'il est present
* (l'obligation en MANUAL est portee par le Callback ci-dessous).
*/
#[Assert\Positive(message: 'Le DSD doit être un entier positif.')]
#[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).
* RG metier MANUAL : le pont n'est pas lu, l'operateur saisit le poids ET le DSD
* → les deux sont obligatoires. Porte par un Callback pour que chaque 422 cible
* son propertyPath (`weight` / `dsd`) et soit mappee inline (ERP-101). En AUTO,
* poids et DSD sont fournis par le pont (saisie client ignoree).
*/
#[Assert\Callback]
public function validateManualWeight(ExecutionContextInterface $context): void
public function validateManualFields(ExecutionContextInterface $context): void
{
if ('MANUAL' === $this->mode && null === $this->weight) {
if ('MANUAL' !== $this->mode) {
return;
}
if (null === $this->weight) {
$context->buildViolation('Le poids est obligatoire en pesée manuelle.')
->atPath('weight')
->addViolation()
;
}
if (null === $this->dsd) {
$context->buildViolation('Le DSD est obligatoire en pesée manuelle.')
->atPath('dsd')
->addViolation()
;
}
}
}
@@ -6,7 +6,6 @@ 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;
@@ -23,7 +22,9 @@ use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
* - 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).
* - MANUAL : conserve le poids ET le DSD saisis par l'operateur tels quels — plus
* d'auto-increment (ERP-193 : le DSD saisi est la valeur du pont reellement
* utilisee, on ne la remplace pas).
*
* @implements ProcessorInterface<WeighbridgeReadingResource, WeighbridgeReadingResource>
*/
@@ -32,7 +33,6 @@ 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
@@ -65,17 +65,15 @@ final class WeighbridgeReadingProcessor implements ProcessorInterface
);
}
$data->weight = $reading->weight;
$data->dsd = $reading->dsd;
$data->manualNumber = null; // pas de numero papier en mode bascule
$data->weight = $reading->weight;
$data->dsd = $reading->dsd;
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);
// MANUAL : poids ET DSD sont saisis par l'operateur (validateManualFields
// garantit leur presence) et conserves tels quels — aucun auto-increment
// (ERP-193). Rien a recalculer cote serveur.
return $data;
}
}
@@ -67,14 +67,14 @@ final class WeighingTicketProcessor implements ProcessorInterface
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).
// Une entite non geree par l'ORM = creation (POST). On rattache le site
// courant (cloisonnement + base de la numerotation), immuable ensuite
// (RG-5.09). Le NUMERO n'est PLUS attribue ici : un ticket nait « brouillon »
// (status DRAFT par defaut) et n'est numerote qu'a la validation (ERP-193).
$isNew = !$this->em->contains($data);
if ($isNew) {
$site = $this->resolveCurrentSite();
$data->setSite($site);
$data->setNumber($this->numberAllocator->allocate($site));
$data->setSite($this->resolveCurrentSite());
}
$this->applyCounterpartyExclusivity($data);
@@ -84,11 +84,23 @@ final class WeighingTicketProcessor implements ProcessorInterface
// 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->allocateAutoDsd($data, $site);
}
$this->computeNetWeight($data);
// Operation `validate` (« Valider », ERP-193) : transition brouillon -> valide.
// La validation stricte (groupe finalize : contrepartie + immat + 2 pesees) a
// deja joue en amont. On attribue le numero {siteCode}-TP-{NNNN} (compteur
// verrouille, RG-5.02 ; uniquement s'il n'existe pas encore, immuable) puis on
// passe le statut a VALIDATED.
if ('weighing_ticket_validate' === $operation->getName()) {
if (null === $data->getNumber() && $site instanceof Site) {
$data->setNumber($this->numberAllocator->allocate($site));
}
$data->setStatus(WeighingTicket::STATUS_VALIDATED);
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
@@ -109,36 +121,73 @@ final class WeighingTicketProcessor implements ProcessorInterface
/**
* 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.
* champs hors-branche selon counterpartyType. La PRESENCE du champ requis n'est
* validee qu'a la VALIDATION (Assert\Callback groupe finalize, ERP-193) : un
* BROUILLON peut donc arriver ici avec un type choisi mais SANS son champ associe
* (l'operateur a ouvert le menu avant de selectionner). On retire alors la
* contrepartie entiere (clearCounterparty) au lieu de persister un etat
* incoherent qui violerait les CHECK Postgres chk_wt_*_branch (500 generique).
* Ne concerne que le brouillon : a la validation, le Callback finalize a deja
* leve une 422 AVANT ce Processor. otherLabel est normalise (trim) en branche
* AUTRE ; un libelle vide vaut « champ associe absent » -> contrepartie retiree.
*/
private function applyCounterpartyExclusivity(WeighingTicket $data): void
{
switch ($data->getCounterpartyType()) {
case 'CLIENT':
if (null === $data->getClient()) {
$this->clearCounterparty($data);
break;
}
$data->setSupplier(null);
$data->setOtherLabel(null);
break;
case 'FOURNISSEUR':
if (null === $data->getSupplier()) {
$this->clearCounterparty($data);
break;
}
$data->setClient(null);
$data->setOtherLabel(null);
break;
case 'AUTRE':
$label = $this->normalizer->normalizeOtherLabel($data->getOtherLabel());
if (null === $label) {
$this->clearCounterparty($data);
break;
}
$data->setClient(null);
$data->setSupplier(null);
$data->setOtherLabel($this->normalizer->normalizeOtherLabel($data->getOtherLabel()));
$data->setOtherLabel($label);
break;
}
}
/**
* Retire toute la contrepartie d'un brouillon a la selection incomplete (type
* sans champ associe) : on ne persiste pas une contrepartie a moitie (qui
* violerait chk_wt_*_branch). Le brouillon reste enregistrable sans contrepartie
* (ERP-193) ; la coherence est exigee a la validation.
*/
private function clearCounterparty(WeighingTicket $data): void
{
$data->setCounterpartyType(null);
$data->setClient(null);
$data->setSupplier(null);
$data->setOtherLabel(null);
}
/**
* 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
@@ -162,21 +211,25 @@ final class WeighingTicketProcessor implements ProcessorInterface
}
/**
* 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 »).
* RG-5.04 : le DSD d'une pesee est attribue A LA PESEE (POST /api/weighbridge_readings)
* et CONSERVE tel quel sur le ticket — on ne le reattribue PAS au save. Raison :
* le DSD est l'index de pesee du pont, deja verrouille (FOR UPDATE) a l'emission ;
* demain il proviendra directement du materiel (driver reel derriere
* WeighbridgeReaderInterface) et devra etre persiste a l'identique. Reallouer ici
* ecraserait cet index (double comptage aujourd'hui, perte de l'index reel demain)
* et ferait diverger le DSD previsionnel affiche du DSD enregistre.
*
* On n'alloue donc qu'en FILET DE SECURITE : pesee AUTO sans DSD (ex. ticket cree
* sans passer par l'endpoint de pesee). Les pesees MANUELLES conservent egalement
* leur DSD (alloue « dernier + 1 » par l'endpoint de pesee).
*/
private function allocateAutoDsd(WeighingTicket $data, Site $site, bool $isNew): void
private function allocateAutoDsd(WeighingTicket $data, Site $site): void
{
if ('AUTO' === $data->getEmptyMode() && ($isNew || null === $data->getEmptyDsd())) {
if ('AUTO' === $data->getEmptyMode() && null === $data->getEmptyDsd()) {
$data->setEmptyDsd($this->dsdAllocator->next($site));
}
if ('AUTO' === $data->getFullMode() && ($isNew || null === $data->getFullDsd())) {
if ('AUTO' === $data->getFullMode() && null === $data->getFullDsd()) {
$data->setFullDsd($this->dsdAllocator->next($site));
}
}
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use App\Module\Logistique\Domain\Repository\WeighingTicketRepositoryInterface;
use App\Module\Logistique\Infrastructure\Pdf\WeighingTicketPdfRenderer;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Sites\Domain\Entity\Site;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Provider de l'operation `GET /api/weighing_tickets/{id}/print.pdf` : sert le bon
* de pesee en PDF (M5, spec-back § 2.12 / § 4.6 — RG-5.08). Operation API Platform
* dediee (pas de controller, decision spec § 4.6) ; le binaire est genere par
* {@see WeighingTicketPdfRenderer} (template Twig -> Dompdf).
*
* Le provider retourne directement une {@see Response} : API Platform court-circuite
* alors la serialisation Hydra (le SerializeListener/RespondListener detectent une
* Response et la renvoient telle quelle). `Content-Type: application/pdf`,
* disposition `inline` (le front ouvre l'apercu — RG-5.08).
*
* Securite & visibilite — miroir de {@see WeighingTicketProvider::provideItem()} :
* - permission `logistique.weighing_tickets.view` portee par l'operation (403) ;
* - 404 si ticket introuvable, soft-delete (non expose au M5 — § 2.13), ou hors
* perimetre du site courant (anti-enumeration, § 2.3 / RG-5.09).
*
* @implements ProviderInterface<WeighingTicket>
*/
final class WeighingTicketPrintProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository')]
private readonly WeighingTicketRepositoryInterface $repository,
private readonly WeighingTicketPdfRenderer $renderer,
private readonly CurrentSiteProviderInterface $currentSiteProvider,
private readonly Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
{
$ticket = $this->findVisibleTicket($uriVariables['id'] ?? null);
if (null === $ticket) {
throw new NotFoundHttpException('Ticket de pesée introuvable.');
}
$pdf = $this->renderer->render($ticket);
$response = new Response($pdf);
$response->headers->set('Content-Type', 'application/pdf');
$response->headers->set(
'Content-Disposition',
sprintf('inline; filename="bon-pesee-%s.pdf"', $ticket->getNumber() ?? (string) $ticket->getId()),
);
return $response;
}
/**
* Charge le ticket visible par l'utilisateur courant, ou null (-> 404) :
* introuvable, soft-delete, ou hors perimetre du site courant. Logique
* identique a WeighingTicketProvider::provideItem() (cloisonnement § 2.3).
*/
private function findVisibleTicket(mixed $id): ?WeighingTicket
{
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
$ticket = $this->repository->findById((int) $id);
if (null === $ticket || null !== $ticket->getDeletedAt()) {
return null;
}
$scopeSite = $this->currentScopeSite();
if (null !== $scopeSite && $ticket->getSite()?->getId() !== $scopeSite->getId()) {
return null;
}
return $ticket;
}
/**
* Site servant a cloisonner, ou null si aucun cloisonnement ne s'applique
* (user `sites.bypass_scope`, ou pas de site courant). Miroir de
* WeighingTicketProvider::currentScopeSite().
*/
private function currentScopeSite(): ?Site
{
if ($this->security->isGranted('sites.bypass_scope')) {
return null;
}
return $this->currentSiteProvider->get();
}
}
@@ -146,8 +146,11 @@ final class WeighingTicketExportController
{
return [
'Numéro',
'Type contrepartie',
'Contrepartie',
// Contrepartie eclatee en 3 colonnes mutuellement exclusives (miroir de
// la liste / repertoire, ERP-193) plutot que « type + nom ».
'Fournisseur',
'Client',
'Autre',
'Date',
'Immatriculation',
'Poids vide (kg)',
@@ -155,6 +158,7 @@ final class WeighingTicketExportController
'Poids net (kg)',
'DSD vide',
'DSD plein',
'Statut',
];
}
@@ -166,10 +170,14 @@ final class WeighingTicketExportController
private function buildRows(array $tickets): iterable
{
foreach ($tickets as $ticket) {
$type = $ticket->getCounterpartyType();
yield [
$ticket->getNumber(),
$this->counterpartyTypeLabel($ticket->getCounterpartyType()),
$this->counterpartyName($ticket),
$ticket->getNumber() ?? '',
// Une seule des 3 colonnes est renseignee selon le type (RG-5.03).
'FOURNISSEUR' === $type ? ($ticket->getSupplier()?->getCompanyName() ?? '') : '',
'CLIENT' === $type ? ($ticket->getClient()?->getCompanyName() ?? '') : '',
'AUTRE' === $type ? ($ticket->getOtherLabel() ?? '') : '',
$ticket->getDisplayDate()?->format('d/m/Y H:i') ?? '',
$ticket->getImmatriculation() ?? '',
$ticket->getEmptyWeight() ?? '',
@@ -177,36 +185,22 @@ final class WeighingTicketExportController
$ticket->getNetWeight() ?? '',
$ticket->getEmptyDsd() ?? '',
$ticket->getFullDsd() ?? '',
$this->statusLabel($ticket->getStatus()),
];
}
}
/**
* 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).
* Libelle FR du statut du cycle de vie (ERP-193) : « En attente » (DRAFT) ou
* « Terminée » (VALIDATED). Renvoie la valeur brute pour une valeur inattendue
* (garde-fou : ne masque pas une donnee corrompue).
*/
private function counterpartyTypeLabel(?string $type): string
private function statusLabel(string $status): 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 => '',
return match ($status) {
WeighingTicket::STATUS_DRAFT => 'En attente',
WeighingTicket::STATUS_VALIDATED => 'Terminée',
default => $status,
};
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Infrastructure\Pdf;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use Dompdf\Dompdf;
use Dompdf\Options;
use Twig\Environment;
/**
* Rend le ticket de pesee (M5, spec-back § 2.12 / § 4.6 — RG-5.08) : hydrate le
* template Twig `logistique/weighing_ticket_print.html.twig` avec le ticket, puis
* convertit le HTML en PDF via Dompdf (pur PHP, aucune dependance systeme — choix
* valide avec Matthieu, ERP-192).
*
* Le gabarit reproduit le modele fourni (ticket_pesee.pdf) : en-tete FIXE (logo +
* identite societe), titre, les deux pesees (poids / N° pesee / DSD + date) et le
* poids net. Le rendu ne depend PAS du site (decision Tristan, ERP-192) : le logo
* et l'identite societe sont constants.
*
* Service technique d'infrastructure (pas de logique metier) : le contenu/affiche
* est decide par le template ; ICI on ne fait que charger le logo et generer le
* binaire.
*/
final class WeighingTicketPdfRenderer
{
/** Logo societe embarque dans l'en-tete (fixe, hors versioning par site). */
private const string LOGO_PATH = __DIR__.'/assets/logo-lpc-liot.png';
public function __construct(
private readonly Environment $twig,
) {}
/**
* Genere le binaire PDF du ticket de pesee pour un ticket donne.
*
* Dompdf : remote desactive (aucune ressource externe chargee — securite ; le
* logo passe en data-URI), A4 portrait, police par defaut DejaVu Sans (UTF-8
* -> accents FR et « ° » corrects).
*/
public function render(WeighingTicket $ticket): string
{
$html = $this->twig->render('logistique/weighing_ticket_print.html.twig', [
'ticket' => $ticket,
'logoSrc' => $this->logoDataUri(),
]);
$options = new Options();
$options->set('isRemoteEnabled', false);
$options->set('defaultFont', 'DejaVu Sans');
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html, 'UTF-8');
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
return (string) $dompdf->output();
}
/**
* Logo societe encode en data-URI base64, ou null s'il est introuvable (le
* template degrade alors sans bloquer la generation du PDF).
*/
private function logoDataUri(): ?string
{
$binary = @file_get_contents(self::LOGO_PATH);
if (false === $binary) {
return null;
}
return 'data:image/png;base64,'.base64_encode($binary);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB