feat : cycle de vie brouillon/validé du ticket de pesée (ERP-193)

Une pesée (bascule ou manuelle) s'enregistre désormais dès la validation de sa
modale, sans exiger la contrepartie ni l'immatriculation : le ticket naît
« brouillon » (status DRAFT, sans numéro). Le bouton « Valider » finalise quand
les 3 champs du haut (contrepartie + champ associé + immatriculation) ET les 2
pesées sont renseignés : attribution du numéro {siteCode}-TP-{NNNN} et passage
en VALIDATED, puis ouverture du bon de pesée PDF.

Back : counterparty_type/immatriculation/number nullables + colonne status
(migration racine), contraintes strictes déplacées en groupe de validation
finalize, opération PATCH /weighing_tickets/{id}/validate, numéro attribué à la
validation. Front : 4 champs en haut hors blocs, persistance immédiate des
pesées, écrans Ajouter/Modifier refondus, colonne Statut dans la liste, form à
plat pleine largeur. Tests back (lifecycle brouillon/validate) + front à jour.
This commit is contained in:
2026-06-24 15:13:12 +02:00
parent d5d7d2e2aa
commit 819ac5e608
20 changed files with 794 additions and 389 deletions
@@ -129,6 +129,29 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
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,
),
// Pas de Delete au M5 (HP-M5-05). Pas d'archive (hors docx).
],
)]
@@ -146,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;
@@ -163,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;
@@ -188,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;
@@ -210,13 +239,11 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
/**
* Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).
* Obligatoire : un ticket est cree APRES la pesee a vide (POST). NotBlank ici
* (et non sur empty_dsd, alloue serveur) rend la 422 « poids obligatoire »
* coherente avec les autres champs requis (counterpartyType / immatriculation),
* toutes renvoyees d'un coup -> mapping inline front (ERP-101).
* 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)]
#[Assert\NotBlank(message: 'Le poids est obligatoire : effectuez une pesée.')]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?int $emptyWeight = null;
@@ -268,6 +295,16 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
#[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;
@@ -284,7 +321,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) {
@@ -320,6 +357,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
@@ -568,6 +630,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;
@@ -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);
@@ -89,6 +89,18 @@ final class WeighingTicketProcessor implements ProcessorInterface
$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);
}
@@ -556,12 +556,12 @@ final class ColumnCommentsCatalog
'_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.',
'number' => 'Numero {siteCode}-TP-{NNNN}, unique par site (uq_weighing_ticket_number), immuable. NULL tant que brouillon : attribue a la validation (RG-5.02, ERP-193).',
'counterparty_type' => 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (chk_wt_counterparty_type, RG-5.03). NULL tant que brouillon, requise a la validation. 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).',
'immatriculation' => 'Plaque du vehicule, partagee entre pesee vide et plein. NULL tant que brouillon, requise a la validation. 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).',
@@ -574,6 +574,7 @@ final class ColumnCommentsCatalog
'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.',
'status' => 'Cycle de vie (ERP-193) : DRAFT (« En attente », pesee enregistree sans contrepartie/immat) ou VALIDATED (« Terminée », valide avec numero). chk_wt_status. Defaut DRAFT.',
'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.',
] + self::timestampableBlamableComments(),
];