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:
@@ -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;
|
||||
|
||||
+17
-5
@@ -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(),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user