feat : M5 — Tickets de pesée (ERP-188 → ERP-193) #144

Merged
tristan merged 20 commits from feat/erp-191-i18n-site-courant into develop 2026-06-24 14:38:02 +00:00
4 changed files with 134 additions and 8 deletions
Showing only changes of commit 391f383c4b - Show all commits
@@ -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()
@@ -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
* 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> {
return compact({
counterpartyType: counterpartyType.value,
...counterpartyPayload(),
...draftCounterpartyPayload(),
immatriculation: immatriculation.value || null,
plateFreeFormat: plateFreeFormat.value,
...blockPayload('empty', empty),
1
@@ -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
@@ -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'));