From 819ac5e608ac285b2e865b4d84a01a2a2b0cb2a6 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 15:13:12 +0200 Subject: [PATCH] =?UTF-8?q?feat=20:=20cycle=20de=20vie=20brouillon/valid?= =?UTF-8?q?=C3=A9=20du=20ticket=20de=20pes=C3=A9e=20(ERP-193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/i18n/locales/fr.json | 7 +- .../logistique/components/WeighingBlock.vue | 134 +++++------- .../__tests__/useWeighingTicketForm.spec.ts | 62 +++--- .../composables/useWeighingTicket.ts | 10 +- .../composables/useWeighingTicketForm.ts | 93 +++++---- .../useWeighingTicketsRepository.ts | 7 +- .../__tests__/weighingTicketEdit.spec.ts | 21 +- .../pages/__tests__/weighingTicketNew.spec.ts | 102 ++++++++++ .../pages/weighing-tickets/[id]/edit.vue | 162 ++++++++------- .../pages/weighing-tickets/index.vue | 7 +- .../logistique/pages/weighing-tickets/new.vue | 192 ++++++++---------- migrations/Version20260624100000.php | 91 +++++++++ .../Domain/Entity/WeighingTicket.php | 107 ++++++++-- .../Processor/WeighingTicketProcessor.php | 22 +- .../Database/ColumnCommentsCatalog.php | 7 +- .../Api/AbstractWeighingTicketApiTestCase.php | 29 ++- .../Api/WeighingTicketLifecycleTest.php | 92 +++++++++ .../Api/WeighingTicketNumberingTest.php | 18 +- ...eighingTicketSerializationContractTest.php | 13 +- .../Processor/CounterpartyValidationTest.php | 7 +- 20 files changed, 794 insertions(+), 389 deletions(-) create mode 100644 frontend/modules/logistique/pages/__tests__/weighingTicketNew.spec.ts create mode 100644 migrations/Version20260624100000.php create mode 100644 tests/Module/Logistique/Api/WeighingTicketLifecycleTest.php diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 06584d0..95e6485 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -703,7 +703,12 @@ "supplier": "Fournisseur", "other": "Autre", "date": "Date", - "weight": "Poids" + "weight": "Poids", + "status": "Statut" + }, + "status": { + "draft": "En attente", + "validated": "Terminée" }, "form": { "back": "Retour à la liste", diff --git a/frontend/modules/logistique/components/WeighingBlock.vue b/frontend/modules/logistique/components/WeighingBlock.vue index d8db47e..0dafbc3 100644 --- a/frontend/modules/logistique/components/WeighingBlock.vue +++ b/frontend/modules/logistique/components/WeighingBlock.vue @@ -1,5 +1,6 @@ diff --git a/migrations/Version20260624100000.php b/migrations/Version20260624100000.php new file mode 100644 index 0000000..863454d --- /dev/null +++ b/migrations/Version20260624100000.php @@ -0,0 +1,91 @@ + valide. + * + * Le metier peut desormais enregistrer une pesee (bascule ou manuelle) SANS avoir + * rempli la contrepartie ni l'immatriculation : le ticket est cree « brouillon » + * des la 1ere pesee, puis « valide » (numero attribue, status VALIDATED) quand les + * 3 champs requis (type + champ contrepartie + immatriculation) ET les 2 pesees + * sont renseignes. + * + * Schema impacte : + * - `counterparty_type`, `immatriculation`, `number` passent NULLABLE (un brouillon + * n'a encore ni contrepartie, ni immat, ni numero — le numero n'est attribue + * qu'a la validation pour eviter les trous de sequence). Les CHECK de branche + * chk_wt_*_branch tolerent deja un counterparty_type NULL (NULL <> 'X' = NULL, + * donc CHECK non viole). + * - nouvelle colonne `status` (DRAFT|VALIDATED). Les tickets EXISTANTS (crees sous + * l'ancien flux, donc complets) sont retro-marques VALIDATED ; le defaut des + * nouvelles lignes est DRAFT. + * + * Namespace racine `DoctrineMigrations` (et non modulaire) : la migration ALTER une + * table creee par la migration racine Version20260617150000. Doctrine Migrations + * 3.x trie par FQCN alphabetique entre namespaces -> une migration modulaire + * `App\Module\...` passerait AVANT la racine sur base vide (make db-reset) et + * tenterait l'ALTER avant le CREATE. Le namespace racine garantit le tri par + * timestamp (regle ABSOLUE n°11, cf. Version20260617170000 pour site.code). + */ +final class Version20260624100000 extends AbstractMigration +{ + public function getDescription(): string + { + return 'ERP-193 : weighing_ticket brouillon/valide (counterparty_type/immatriculation/number nullable + colonne status).'; + } + + public function up(Schema $schema): void + { + // Brouillon : ni contrepartie, ni immat, ni numero tant que non valide. + $this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN counterparty_type DROP NOT NULL'); + $this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN immatriculation DROP NOT NULL'); + $this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN number DROP NOT NULL'); + + // Statut du cycle de vie. Colonne ajoutee nullable, retro-remplie a VALIDATED + // pour les tickets existants (complets), puis figee NOT NULL DEFAULT DRAFT. + $this->addSql('ALTER TABLE weighing_ticket ADD COLUMN status VARCHAR(12)'); + $this->addSql("UPDATE weighing_ticket SET status = 'VALIDATED'"); + $this->addSql("ALTER TABLE weighing_ticket ALTER COLUMN status SET DEFAULT 'DRAFT'"); + $this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN status SET NOT NULL'); + $this->addSql("ALTER TABLE weighing_ticket ADD CONSTRAINT chk_wt_status CHECK (status IN ('DRAFT','VALIDATED'))"); + + // Commentaires (regle ABSOLUE n°12). + $this->comment('weighing_ticket', 'status', "Cycle de vie : DRAFT (En attente, pesee enregistree sans contrepartie/immat) ou VALIDATED (Terminee, valide avec numero). Defaut DRAFT."); + $this->comment('weighing_ticket', 'number', "Numero {siteCode}-TP-{NNNN}, unique par site, immuable. NULL tant que le ticket est brouillon : attribue a la validation (RG-5.02, ERP-193)."); + $this->comment('weighing_ticket', 'counterparty_type', "Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (RG-5.03). NULL tant que brouillon ; requise a la validation. Pilote l'obligation client_id / supplier_id / other_label."); + $this->comment('weighing_ticket', 'immatriculation', "Plaque du vehicule, partagee entre pesee vide et plein (RG-5.01). NULL tant que brouillon ; requise a la validation. Masque XX-000-XX sauf plate_free_format."); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE weighing_ticket DROP CONSTRAINT IF EXISTS chk_wt_status'); + $this->addSql('ALTER TABLE weighing_ticket DROP COLUMN IF EXISTS status'); + + // Restauration NOT NULL : echoue s'il subsiste des brouillons (number / + // counterparty_type / immatriculation NULL) — irreversible en presence de + // donnees brouillon, ce qui est attendu (le down sert au dev sur base saine). + $this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN number SET NOT NULL'); + $this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN immatriculation SET NOT NULL'); + $this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN counterparty_type SET NOT NULL'); + } + + /** + * Pose un COMMENT ON COLUMN en dollar-quoting Postgres ($_$...$_$) pour eviter + * tout echappement d'apostrophes dans les descriptions. + */ + private function comment(string $table, string $column, string $description): void + { + $this->addSql(sprintf( + 'COMMENT ON COLUMN %s.%s IS $_$%s$_$', + '"'.str_replace('"', '""', $table).'"', + '"'.str_replace('"', '""', $column).'"', + $description, + )); + } +} diff --git a/src/Module/Logistique/Domain/Entity/WeighingTicket.php b/src/Module/Logistique/Domain/Entity/WeighingTicket.php index 7c27491..43d1625 100644 --- a/src/Module/Logistique/Domain/Entity/WeighingTicket.php +++ b/src/Module/Logistique/Domain/Entity/WeighingTicket.php @@ -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; diff --git a/src/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighingTicketProcessor.php b/src/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighingTicketProcessor.php index 984a298..1cbb344 100644 --- a/src/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighingTicketProcessor.php +++ b/src/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighingTicketProcessor.php @@ -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); } diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index eec268c..666496c 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -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(), ]; diff --git a/tests/Module/Logistique/Api/AbstractWeighingTicketApiTestCase.php b/tests/Module/Logistique/Api/AbstractWeighingTicketApiTestCase.php index 42c3039..519c6bf 100644 --- a/tests/Module/Logistique/Api/AbstractWeighingTicketApiTestCase.php +++ b/tests/Module/Logistique/Api/AbstractWeighingTicketApiTestCase.php @@ -220,7 +220,8 @@ abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase /** * POST un ticket et renvoie la reponse (assertions de statut a la charge de - * l'appelant). + * l'appelant). Cree un BROUILLON (status DRAFT, sans numero, ERP-193) — la + * validation est portee par validateTicket(). */ protected function postTicket(Client $http, array $payload): ResponseInterface { @@ -230,6 +231,32 @@ abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase ]); } + /** + * « Valider » un ticket : PATCH /weighing_tickets/{id}/validate (ERP-193). + * Declenche la validation stricte (groupe finalize) + attribution du numero + + * passage en VALIDATED. Body vide par defaut = on valide l'etat deja persiste. + */ + protected function validateTicket(Client $http, int $id, array $payload = []): ResponseInterface + { + return $http->request('PATCH', '/api/weighing_tickets/'.$id.'/validate', [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => [] === $payload ? new \stdClass() : $payload, + ]); + } + + /** + * POST un brouillon complet puis le valide ; renvoie le ticket VALIDE (numero + * attribue). Le payload doit porter contrepartie + immatriculation + 2 pesees. + * + * @return array + */ + protected function createValidatedTicket(Client $http, array $payload): array + { + $id = (int) $this->postTicket($http, $payload)->toArray()['id']; + + return $this->validateTicket($http, $id)->toArray(); + } + /** * Retrouve un membre d'une collection Hydra par son id. * diff --git a/tests/Module/Logistique/Api/WeighingTicketLifecycleTest.php b/tests/Module/Logistique/Api/WeighingTicketLifecycleTest.php new file mode 100644 index 0000000..0101bfb --- /dev/null +++ b/tests/Module/Logistique/Api/WeighingTicketLifecycleTest.php @@ -0,0 +1,92 @@ + valide du ticket de pesee (ERP-193, spec-back § 2.14). + * + * Couvre : + * - une pesee peut etre enregistree SANS contrepartie ni immatriculation : le POST + * cree un BROUILLON (status DRAFT, pas de numero) ; + * - la validation (PATCH /validate) exige les 3 champs du haut (type + champ + * contrepartie + immatriculation) ET les 2 pesees (groupe `finalize`) ; + * - une validation complete attribue le numero {siteCode}-TP-{NNNN} et passe le + * ticket en VALIDATED. + * + * @internal + */ +final class WeighingTicketLifecycleTest extends AbstractWeighingTicketApiTestCase +{ + public function testWeighingOnlyCreatesDraftWithoutNumber(): void + { + $http = $this->authManageOnSite($this->siteByCode('86')); + + // Pesee a vide seule : ni contrepartie, ni immatriculation. + $body = $this->postTicket($http, [ + 'emptyDate' => '2026-06-17T09:00:00+02:00', + 'emptyWeight' => 7150, + 'emptyMode' => 'AUTO', + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame('DRAFT', $body['status']); + self::assertArrayNotHasKey('number', $body, 'Un brouillon n\'a pas encore de numero (skip_null_values).'); + self::assertSame(7150, $body['emptyWeight']); + } + + public function testValidateRequiresCounterparty(): void + { + $http = $this->authManageOnSite($this->siteByCode('86')); + + // Brouillon complet cote pesees + immatriculation, mais SANS contrepartie. + $id = (int) $this->postTicket($http, [ + 'immatriculation' => 'AB-123-CD', + 'emptyDate' => '2026-06-17T09:00:00+02:00', + 'emptyWeight' => 7150, + 'emptyMode' => 'AUTO', + 'fullDate' => '2026-06-17T09:12:00+02:00', + 'fullWeight' => 14300, + 'fullMode' => 'AUTO', + ])->toArray()['id']; + + $response = $this->validateTicket($http, $id); + + self::assertResponseStatusCodeSame(422); + self::assertViolationOnPath($response, 'counterpartyType'); + } + + public function testValidateRequiresBothWeighings(): void + { + $http = $this->authManageOnSite($this->siteByCode('86')); + $client = $this->seedTestClient('Lifecycle'); + + // Brouillon avec contrepartie + immat + UNE seule pesee (a vide). + $id = (int) $this->postTicket($http, [ + 'counterpartyType' => 'CLIENT', + 'client' => $this->clientIri($client), + 'immatriculation' => 'AB-123-CD', + 'emptyDate' => '2026-06-17T09:00:00+02:00', + 'emptyWeight' => 7150, + 'emptyMode' => 'AUTO', + ])->toArray()['id']; + + $response = $this->validateTicket($http, $id); + + self::assertResponseStatusCodeSame(422); + self::assertViolationOnPath($response, 'fullWeight'); + } + + public function testValidateAssignsNumberAndStatus(): void + { + $http = $this->authManageOnSite($this->siteByCode('86')); + $client = $this->seedTestClient('LifecycleOk'); + + $validated = $this->createValidatedTicket($http, $this->validClientTicketPayload($client)); + + self::assertSame('VALIDATED', $validated['status']); + self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', (string) $validated['number']); + self::assertSame(7150, $validated['netWeight']); + } +} diff --git a/tests/Module/Logistique/Api/WeighingTicketNumberingTest.php b/tests/Module/Logistique/Api/WeighingTicketNumberingTest.php index 32e3bfd..33fe6a2 100644 --- a/tests/Module/Logistique/Api/WeighingTicketNumberingTest.php +++ b/tests/Module/Logistique/Api/WeighingTicketNumberingTest.php @@ -26,13 +26,12 @@ final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCas $http = $this->authManageOnSite($site); $client = $this->seedTestClient('Num'); - $first = $this->postTicket($http, $this->validClientTicketPayload($client)); - self::assertResponseStatusCodeSame(201); - $second = $this->postTicket($http, $this->validClientTicketPayload($client)); - self::assertResponseStatusCodeSame(201); + // Le numero est attribue a la VALIDATION (brouillon -> valide, ERP-193). + $first = $this->createValidatedTicket($http, $this->validClientTicketPayload($client)); + $second = $this->createValidatedTicket($http, $this->validClientTicketPayload($client)); - $n1 = (string) $first->toArray()['number']; - $n2 = (string) $second->toArray()['number']; + $n1 = (string) $first['number']; + $n2 = (string) $second['number']; self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n1); self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n2); @@ -49,8 +48,8 @@ final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCas $http86 = $this->authManageOnSite($this->siteByCode('86')); $http17 = $this->authManageOnSite($this->siteByCode('17')); - $n86 = (string) $this->postTicket($http86, $this->validClientTicketPayload($client))->toArray()['number']; - $n17 = (string) $this->postTicket($http17, $this->validClientTicketPayload($client))->toArray()['number']; + $n86 = (string) $this->createValidatedTicket($http86, $this->validClientTicketPayload($client))['number']; + $n17 = (string) $this->createValidatedTicket($http17, $this->validClientTicketPayload($client))['number']; // Chaque site encode son propre code dans le numero ; sequences disjointes. self::assertStringStartsWith('86-TP-', $n86); @@ -63,7 +62,8 @@ final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCas $http = $this->authManageOnSite($site); $client = $this->seedTestClient('Immutable'); - $created = $this->postTicket($http, $this->validClientTicketPayload($client))->toArray(); + // Ticket valide (numero attribue) puis tentative de re-ecriture. + $created = $this->createValidatedTicket($http, $this->validClientTicketPayload($client)); $id = (int) $created['id']; $number = (string) $created['number']; diff --git a/tests/Module/Logistique/Api/WeighingTicketSerializationContractTest.php b/tests/Module/Logistique/Api/WeighingTicketSerializationContractTest.php index c459d18..94e0b67 100644 --- a/tests/Module/Logistique/Api/WeighingTicketSerializationContractTest.php +++ b/tests/Module/Logistique/Api/WeighingTicketSerializationContractTest.php @@ -31,12 +31,12 @@ final class WeighingTicketSerializationContractTest extends AbstractWeighingTick $http = $this->authManageOnSite($site); $clientEntity = $this->seedTestClient('Negoce'); - $created = $this->postTicket($http, $this->validClientTicketPayload($clientEntity)); - self::assertResponseStatusCodeSame(201); - $createdBody = $created->toArray(); + // Brouillon cree puis valide (numero attribue a la validation, ERP-193). + $createdBody = $this->createValidatedTicket($http, $this->validClientTicketPayload($clientEntity)); $id = (int) $createdBody['id']; $number = (string) $createdBody['number']; + self::assertSame('VALIDATED', $createdBody['status']); $detail = $http->request('GET', '/api/weighing_tickets/'.$id, ['headers' => ['Accept' => self::LD]])->toArray(); $list = $http->request('GET', '/api/weighing_tickets?search='.$number, ['headers' => ['Accept' => self::LD]])->toArray(); @@ -69,6 +69,9 @@ final class WeighingTicketSerializationContractTest extends AbstractWeighingTick // displayDate (date du ticket = fullDate ?? emptyDate) expose en liste. self::assertArrayHasKey('displayDate', $row); + // Statut du cycle de vie expose en liste (colonne « En attente / Terminée »). + self::assertSame('VALIDATED', $row['status']); + // === DETAIL : site embarque (avec code), immatriculation, les 2 pesees === self::assertIsArray($detail['site']); self::assertSame('86', $detail['site']['code']); @@ -95,9 +98,7 @@ final class WeighingTicketSerializationContractTest extends AbstractWeighingTick $http = $this->authManageOnSite($site); $supplierEntity = $this->seedTestSupplier('Ferraille'); - $created = $this->postTicket($http, $this->validSupplierTicketPayload($supplierEntity)); - self::assertResponseStatusCodeSame(201); - $createdBody = $created->toArray(); + $createdBody = $this->createValidatedTicket($http, $this->validSupplierTicketPayload($supplierEntity)); $id = (int) $createdBody['id']; $number = (string) $createdBody['number']; diff --git a/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/CounterpartyValidationTest.php b/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/CounterpartyValidationTest.php index ee972f7..828354e 100644 --- a/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/CounterpartyValidationTest.php +++ b/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/CounterpartyValidationTest.php @@ -145,14 +145,17 @@ final class CounterpartyValidationTest extends TestCase } /** - * Liste des propertyPath des violations de l'entite. + * Liste des propertyPath des violations de l'entite, validee dans le groupe + * `finalize` (la coherence contrepartie ne joue qu'a la validation depuis + * ERP-193 ; un brouillon peut ne pas porter de contrepartie). Miroir du + * validationContext de l'operation `validate` (['Default', 'finalize']). * * @return list */ private function violationPaths(WeighingTicket $ticket): array { $paths = []; - foreach ($this->validator->validate($ticket) as $violation) { + foreach ($this->validator->validate($ticket, null, ['Default', 'finalize']) as $violation) { $paths[] = $violation->getPropertyPath(); }