fix : brouillon à contrepartie incomplète enregistrable sans erreur 500 (ERP-193)

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.
This commit is contained in:
2026-06-24 16:12:40 +02:00
parent 9e2206a7d6
commit 391f383c4b
4 changed files with 134 additions and 8 deletions
@@ -82,6 +82,30 @@ describe('useWeighingTicketForm', () => {
expect(form.buildDraftPayload().otherLabel).toBe('Reprise interne') 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) ────── // ── Immatriculation / « Tout format » partagés entre blocs (RG-5.01) ──────
it('immatriculation et plateFreeFormat sont partagés (une seule valeur)', () => { it('immatriculation et plateFreeFormat sont partagés (une seule valeur)', () => {
const form = useWeighingTicketForm() const form = useWeighingTicketForm()
@@ -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<string, unknown> {
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 * 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 * 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<string, unknown> { function buildDraftPayload(): Record<string, unknown> {
return compact({ return compact({
counterpartyType: counterpartyType.value, ...draftCounterpartyPayload(),
...counterpartyPayload(),
immatriculation: immatriculation.value || null, immatriculation: immatriculation.value || null,
plateFreeFormat: plateFreeFormat.value, plateFreeFormat: plateFreeFormat.value,
...blockPayload('empty', empty), ...blockPayload('empty', empty),
@@ -121,36 +121,73 @@ final class WeighingTicketProcessor implements ProcessorInterface
/** /**
* RG-5.03 : garantit l'exclusivite de la contrepartie en forcant a null les * 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 * champs hors-branche selon counterpartyType. La PRESENCE du champ requis n'est
* deja validee en amont (Assert\Callback de l'entite) ; ici on evite qu'un * validee qu'a la VALIDATION (Assert\Callback groupe finalize, ERP-193) : un
* payload portant a la fois client_id ET supplier_id ne fasse echouer les CHECK * BROUILLON peut donc arriver ici avec un type choisi mais SANS son champ associe
* Postgres (500 generique au lieu d'une donnee coherente). otherLabel est * (l'operateur a ouvert le menu avant de selectionner). On retire alors la
* normalise (trim) dans la branche AUTRE. * 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 private function applyCounterpartyExclusivity(WeighingTicket $data): void
{ {
switch ($data->getCounterpartyType()) { switch ($data->getCounterpartyType()) {
case 'CLIENT': case 'CLIENT':
if (null === $data->getClient()) {
$this->clearCounterparty($data);
break;
}
$data->setSupplier(null); $data->setSupplier(null);
$data->setOtherLabel(null); $data->setOtherLabel(null);
break; break;
case 'FOURNISSEUR': case 'FOURNISSEUR':
if (null === $data->getSupplier()) {
$this->clearCounterparty($data);
break;
}
$data->setClient(null); $data->setClient(null);
$data->setOtherLabel(null); $data->setOtherLabel(null);
break; break;
case 'AUTRE': case 'AUTRE':
$label = $this->normalizer->normalizeOtherLabel($data->getOtherLabel());
if (null === $label) {
$this->clearCounterparty($data);
break;
}
$data->setClient(null); $data->setClient(null);
$data->setSupplier(null); $data->setSupplier(null);
$data->setOtherLabel($this->normalizer->normalizeOtherLabel($data->getOtherLabel())); $data->setOtherLabel($label);
break; 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 * 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 * + masque XX-000-XX hors « Tout format »). Un format invalide est traduit en
@@ -36,6 +36,49 @@ final class WeighingTicketLifecycleTest extends AbstractWeighingTicketApiTestCas
self::assertSame(7150, $body['emptyWeight']); 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 public function testValidateRequiresCounterparty(): void
{ {
$http = $this->authManageOnSite($this->siteByCode('86')); $http = $this->authManageOnSite($this->siteByCode('86'));