From 391f383c4b453684aff079fd6e2097e8fa01ad4d Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 16:12:40 +0200 Subject: [PATCH] =?UTF-8?q?fix=20:=20brouillon=20=C3=A0=20contrepartie=20i?= =?UTF-8?q?ncompl=C3=A8te=20enregistrable=20sans=20erreur=20500=20(ERP-193?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un brouillon dont le type de contrepartie est choisi sans son champ associé (client/fournisseur null, ou libellé « Autre » vide) violait chk_wt_*_branch et levait une 500 : le callback de cohérence RG-5.03 ne joue qu'au groupe finalize, laissant passer l'incohérence à l'enregistrement du brouillon. - back : WeighingTicketProcessor retire la contrepartie entière quand le champ de branche est absent (clearCounterparty) au lieu de persister un état incohérent. N'affecte que le brouillon (à la validation, le callback finalize lève déjà une 422 avant le processor). - front : buildDraftPayload n'émet le type que si son champ associé est rempli ; la validation continue d'envoyer toujours le type pour la 422 métier. - tests : 2 cas back (CLIENT sans client, AUTRE libellé vide) + 2 cas front. --- .../__tests__/useWeighingTicketForm.spec.ts | 24 +++++++++ .../composables/useWeighingTicketForm.ts | 26 +++++++++- .../Processor/WeighingTicketProcessor.php | 49 ++++++++++++++++--- .../Api/WeighingTicketLifecycleTest.php | 43 ++++++++++++++++ 4 files changed, 134 insertions(+), 8 deletions(-) diff --git a/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts b/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts index f820710..f62bb3f 100644 --- a/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts +++ b/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts @@ -82,6 +82,30 @@ describe('useWeighingTicketForm', () => { expect(form.buildDraftPayload().otherLabel).toBe('Reprise interne') }) + it('buildDraftPayload : type choisi mais champ associé vide → contrepartie omise (pas de 500 chk_wt_*_branch)', () => { + const form = useWeighingTicketForm() + // L'opérateur ouvre le menu « Client » mais n'a pas encore choisi le client. + form.setCounterpartyType('CLIENT') + + const draft = form.buildDraftPayload() + // On n'émet ni le type ni la FK : un brouillon incohérent serait rejeté en 500 par le back. + expect(draft).not.toHaveProperty('counterpartyType') + expect(draft).not.toHaveProperty('client') + + // En revanche la validation envoie toujours le type, pour déclencher la 422 métier. + expect(form.buildValidatePayload().counterpartyType).toBe('CLIENT') + }) + + it('buildDraftPayload : AUTRE avec libellé vide → contrepartie omise', () => { + const form = useWeighingTicketForm() + form.setCounterpartyType('AUTRE') + form.otherLabel.value = ' ' + + const draft = form.buildDraftPayload() + expect(draft).not.toHaveProperty('counterpartyType') + expect(draft).not.toHaveProperty('otherLabel') + }) + // ── Immatriculation / « Tout format » partagés entre blocs (RG-5.01) ────── it('immatriculation et plateFreeFormat sont partagés (une seule valeur)', () => { const form = useWeighingTicketForm() diff --git a/frontend/modules/logistique/composables/useWeighingTicketForm.ts b/frontend/modules/logistique/composables/useWeighingTicketForm.ts index 7f4c63f..ba80747 100644 --- a/frontend/modules/logistique/composables/useWeighingTicketForm.ts +++ b/frontend/modules/logistique/composables/useWeighingTicketForm.ts @@ -185,6 +185,29 @@ export function useWeighingTicketForm() { } } + /** + * Contrepartie d'un BROUILLON : on n'envoie le type QUE si son champ associé est + * renseigné. Un type sans son champ (l'opérateur a ouvert le menu avant de + * choisir) est une contrepartie incohérente que le back devrait retirer (sinon + * les CHECK chk_wt_*_branch lèvent une 500). On évite donc de l'émettre côté + * front. La cohérence reste exigée à la validation : `buildValidatePayload()` + * envoie toujours le type, pour déclencher la 422 métier sur le champ manquant. + */ + function draftCounterpartyPayload(): Record { + switch (counterpartyType.value) { + case 'CLIENT': + return clientIri.value ? { counterpartyType: 'CLIENT', client: clientIri.value } : {} + case 'FOURNISSEUR': + return supplierIri.value ? { counterpartyType: 'FOURNISSEUR', supplier: supplierIri.value } : {} + case 'AUTRE': + return otherLabel.value && otherLabel.value.trim() !== '' + ? { counterpartyType: 'AUTRE', otherLabel: otherLabel.value } + : {} + default: + return {} + } + } + /** * Champs d'un bloc de pesée, UNIQUEMENT s'il a été pesé (poids renseigné) — on * n'envoie pas la date par défaut d'un bloc vierge (sinon le back stockerait une @@ -208,8 +231,7 @@ export function useWeighingTicketForm() { */ function buildDraftPayload(): Record { return compact({ - counterpartyType: counterpartyType.value, - ...counterpartyPayload(), + ...draftCounterpartyPayload(), immatriculation: immatriculation.value || null, plateFreeFormat: plateFreeFormat.value, ...blockPayload('empty', empty), diff --git a/src/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighingTicketProcessor.php b/src/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighingTicketProcessor.php index 1cbb344..81b6853 100644 --- a/src/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighingTicketProcessor.php +++ b/src/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighingTicketProcessor.php @@ -121,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 diff --git a/tests/Module/Logistique/Api/WeighingTicketLifecycleTest.php b/tests/Module/Logistique/Api/WeighingTicketLifecycleTest.php index 0101bfb..8439d66 100644 --- a/tests/Module/Logistique/Api/WeighingTicketLifecycleTest.php +++ b/tests/Module/Logistique/Api/WeighingTicketLifecycleTest.php @@ -36,6 +36,49 @@ final class WeighingTicketLifecycleTest extends AbstractWeighingTicketApiTestCas self::assertSame(7150, $body['emptyWeight']); } + public function testDraftWithIncompleteCounterpartyIsPersistedWithoutBranch(): void + { + $http = $this->authManageOnSite($this->siteByCode('86')); + + // Brouillon « contrepartie incomplete » : type CLIENT choisi mais client pas + // encore selectionne (cas reel : l'operateur ouvre le menu puis pese). Le + // Callback de coherence ne joue qu'a la validation (groupe finalize) -> + // SANS normalisation cote Processor, le persist violerait chk_wt_client_branch + // (counterparty_type='CLIENT' + client_id NULL) et leverait une 500. + $body = $this->postTicket($http, [ + 'counterpartyType' => 'CLIENT', + 'emptyDate' => '2026-06-17T09:00:00+02:00', + 'emptyWeight' => 7150, + 'emptyMode' => 'AUTO', + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame('DRAFT', $body['status']); + // La contrepartie incoherente est retiree (pas persistee a moitie) : le + // brouillon reste enregistrable, la coherence est exigee a la validation. + self::assertNull($body['counterpartyType'] ?? null); + self::assertSame(7150, $body['emptyWeight']); + } + + public function testDraftWithEmptyOtherLabelIsPersistedWithoutBranch(): void + { + $http = $this->authManageOnSite($this->siteByCode('86')); + + // Meme piege en branche AUTRE : type AUTRE mais libelle vide -> le normalizer + // ramene otherLabel a NULL, ce qui violait chk_wt_other_branch (500). + $body = $this->postTicket($http, [ + 'counterpartyType' => 'AUTRE', + 'otherLabel' => ' ', + 'emptyDate' => '2026-06-17T09:00:00+02:00', + 'emptyWeight' => 7150, + 'emptyMode' => 'AUTO', + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame('DRAFT', $body['status']); + self::assertNull($body['counterpartyType'] ?? null); + } + public function testValidateRequiresCounterparty(): void { $http = $this->authManageOnSite($this->siteByCode('86'));