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'));