fix(logistique) : corrections review ticket de pesée (ERP-208)
- Édition : listes contrepartie filtrées sur le site DU TICKET (immuable), chargées après hydrate, sans purge de la contrepartie persistée (injection de l'option si absente) → corrige la perte silencieuse / race. - Entité : constantes COUNTERPARTY_* (Assert\Choice + validation + getCounterpartyName) ; libellé FR du type déplacé du Domain vers le template. - PDF : cartouche conditionné sur le type (nom à l'intérieur), layout Dompdf-safe (largeurs de cellules, cartouche en bloc, nom long renvoyé à la ligne).
This commit is contained in:
@@ -8,12 +8,13 @@ const mockFetchTicket = vi.hoisted(() => vi.fn())
|
|||||||
const mockPatch = vi.hoisted(() => vi.fn())
|
const mockPatch = vi.hoisted(() => vi.fn())
|
||||||
const mockPush = vi.hoisted(() => vi.fn())
|
const mockPush = vi.hoisted(() => vi.fn())
|
||||||
const mockOpen = vi.hoisted(() => vi.fn())
|
const mockOpen = vi.hoisted(() => vi.fn())
|
||||||
|
const mockRefLoad = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
vi.mock('~/modules/logistique/composables/useWeighingTicket', () => ({
|
vi.mock('~/modules/logistique/composables/useWeighingTicket', () => ({
|
||||||
useWeighingTicket: () => ({ fetchTicket: mockFetchTicket }),
|
useWeighingTicket: () => ({ fetchTicket: mockFetchTicket }),
|
||||||
}))
|
}))
|
||||||
vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({
|
vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({
|
||||||
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: vi.fn().mockResolvedValue(undefined) }),
|
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: mockRefLoad }),
|
||||||
}))
|
}))
|
||||||
vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({
|
vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({
|
||||||
useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }),
|
useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }),
|
||||||
@@ -28,8 +29,6 @@ vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
|||||||
vi.stubGlobal('usePermissions', () => ({ can: () => true }))
|
vi.stubGlobal('usePermissions', () => ({ can: () => true }))
|
||||||
vi.stubGlobal('navigateTo', vi.fn())
|
vi.stubGlobal('navigateTo', vi.fn())
|
||||||
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() }))
|
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() }))
|
||||||
// Site courant (ERP-208) : nécessaire depuis que l'écran filtre les référentiels par site.
|
|
||||||
vi.stubGlobal('useCurrentSite', () => ({ currentSite: ref({ id: 7, name: 'Site 7', color: '#000000' }) }))
|
|
||||||
globalThis.open = mockOpen
|
globalThis.open = mockOpen
|
||||||
|
|
||||||
const EditPage = (await import('../weighing-tickets/[id]/edit.vue')).default
|
const EditPage = (await import('../weighing-tickets/[id]/edit.vue')).default
|
||||||
@@ -102,6 +101,7 @@ describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit
|
|||||||
mockPatch.mockReset().mockResolvedValue({})
|
mockPatch.mockReset().mockResolvedValue({})
|
||||||
mockPush.mockReset()
|
mockPush.mockReset()
|
||||||
mockOpen.mockReset()
|
mockOpen.mockReset()
|
||||||
|
mockRefLoad.mockReset().mockResolvedValue(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('charge le ticket au montage (pré-remplissage via hydrate)', async () => {
|
it('charge le ticket au montage (pré-remplissage via hydrate)', async () => {
|
||||||
@@ -109,6 +109,12 @@ describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit
|
|||||||
expect(mockFetchTicket).toHaveBeenCalledWith('9')
|
expect(mockFetchTicket).toHaveBeenCalledWith('9')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('filtre les référentiels sur le SITE DU TICKET, pas le site courant (ERP-208)', async () => {
|
||||||
|
await mountPage()
|
||||||
|
// DETAIL.site.id = 1 → les listes sont chargées pour le site du ticket (immuable).
|
||||||
|
expect(mockRefLoad).toHaveBeenCalledWith(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('ticket validé : action principale « Enregistrer » + « Imprimer » (pas « Valider »)', async () => {
|
it('ticket validé : action principale « Enregistrer » + « Imprimer » (pas « Valider »)', async () => {
|
||||||
const wrapper = await mountPage()
|
const wrapper = await mountPage()
|
||||||
// DETAIL.status = VALIDATED → l'action principale s'intitule « Enregistrer ».
|
// DETAIL.status = VALIDATED → l'action principale s'intitule « Enregistrer ».
|
||||||
|
|||||||
@@ -186,10 +186,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||||
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
|
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
|
||||||
import { useWeighingTicket } from '~/modules/logistique/composables/useWeighingTicket'
|
import { useWeighingTicket, type WeighingTicketDetail } from '~/modules/logistique/composables/useWeighingTicket'
|
||||||
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
||||||
import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
|
import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
|
||||||
import { mapViolationsToRecord } from '~/shared/utils/api'
|
import { mapViolationsToRecord } from '~/shared/utils/api'
|
||||||
@@ -404,28 +404,34 @@ function printTicket(): void {
|
|||||||
window.open(`/api/weighing_tickets/${ticketId}/print.pdf`, '_blank')
|
window.open(`/api/weighing_tickets/${ticketId}/print.pdf`, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
const { currentSite } = useCurrentSite()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recharge les référentiels Client/Fournisseur pour le site donné, puis purge le
|
* Garantit que la contrepartie DÉJÀ ENREGISTRÉE (hydratée depuis le ticket) reste
|
||||||
* tiers sélectionné s'il n'appartient plus à la liste du nouveau site (ERP-208).
|
* affichée même si la liste filtrée par site ne la contient pas (ticket antérieur
|
||||||
|
* à ERP-208, droits restreints sur /clients, contrepartie hors site…) : on injecte
|
||||||
|
* son option plutôt que de la purger. Évite toute perte silencieuse de la
|
||||||
|
* contrepartie en édition (ERP-208, retour review).
|
||||||
*/
|
*/
|
||||||
async function reloadReferentials(siteId: number | null): Promise<void> {
|
function ensureSelectedOptionPresent(detail: WeighingTicketDetail): void {
|
||||||
await referentials.load(siteId)
|
const client = detail.client
|
||||||
if (form.clientIri.value && !referentials.clients.value.some(o => o.value === form.clientIri.value)) {
|
if (client && !referentials.clients.value.some(o => o.value === client['@id'])) {
|
||||||
form.clientIri.value = null
|
referentials.clients.value.push({ value: client['@id'], label: client.companyName })
|
||||||
}
|
}
|
||||||
if (form.supplierIri.value && !referentials.suppliers.value.some(o => o.value === form.supplierIri.value)) {
|
const supplier = detail.supplier
|
||||||
form.supplierIri.value = null
|
if (supplier && !referentials.suppliers.value.some(o => o.value === supplier['@id'])) {
|
||||||
|
referentials.suppliers.value.push({ value: supplier['@id'], label: supplier.companyName })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
reloadReferentials(currentSite.value?.id ?? null).catch(() => {})
|
|
||||||
try {
|
try {
|
||||||
const detail = await fetchTicket(ticketId)
|
const detail = await fetchTicket(ticketId)
|
||||||
ticketNumber.value = detail.number ?? ''
|
ticketNumber.value = detail.number ?? ''
|
||||||
form.hydrate(detail)
|
form.hydrate(detail)
|
||||||
|
// Listes filtrées sur le SITE DU TICKET (immuable, RG-5.09) — pas le site
|
||||||
|
// courant — et chargées APRÈS hydrate pour ne jamais purger la sélection
|
||||||
|
// existante (pas de race load/hydrate, ERP-208).
|
||||||
|
await referentials.load(detail.site?.id ?? null)
|
||||||
|
ensureSelectedOptionPresent(detail)
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
error.value = true
|
error.value = true
|
||||||
@@ -434,9 +440,4 @@ onMounted(async () => {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Changement de site pendant l'édition → recharge les listes du nouveau site (ERP-208).
|
|
||||||
watch(() => currentSite.value?.id, (siteId) => {
|
|
||||||
reloadReferentials(siteId ?? null).catch(() => {})
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -175,6 +175,15 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
/** Valide : contrepartie + immatriculation + 2 pesees OK, numero attribue (« Terminée »). */
|
/** Valide : contrepartie + immatriculation + 2 pesees OK, numero attribue (« Terminée »). */
|
||||||
public const string STATUS_VALIDATED = 'VALIDATED';
|
public const string STATUS_VALIDATED = 'VALIDATED';
|
||||||
|
|
||||||
|
/** Contrepartie « Client » (M1) — RG-5.03. */
|
||||||
|
public const string COUNTERPARTY_CLIENT = 'CLIENT';
|
||||||
|
|
||||||
|
/** Contrepartie « Fournisseur » (M2) — RG-5.03. */
|
||||||
|
public const string COUNTERPARTY_FOURNISSEUR = 'FOURNISSEUR';
|
||||||
|
|
||||||
|
/** Contrepartie « Autre » (libelle libre) — RG-5.03. */
|
||||||
|
public const string COUNTERPARTY_AUTRE = 'AUTRE';
|
||||||
|
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
@@ -195,7 +204,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
/** CLIENT | FOURNISSEUR | AUTRE (RG-5.03) — null tant que brouillon, requis a la validation. Pilote le champ associe 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)]
|
#[ORM\Column(name: 'counterparty_type', length: 12, nullable: true)]
|
||||||
#[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.', groups: ['finalize'])]
|
#[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.', groups: ['finalize'])]
|
||||||
#[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR', 'AUTRE'], message: 'Type de contrepartie invalide.')]
|
#[Assert\Choice(choices: [self::COUNTERPARTY_CLIENT, self::COUNTERPARTY_FOURNISSEUR, self::COUNTERPARTY_AUTRE], message: 'Type de contrepartie invalide.')]
|
||||||
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||||
private ?string $counterpartyType = null;
|
private ?string $counterpartyType = null;
|
||||||
|
|
||||||
@@ -313,7 +322,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
public function validateCounterpartyConsistency(ExecutionContextInterface $context): void
|
public function validateCounterpartyConsistency(ExecutionContextInterface $context): void
|
||||||
{
|
{
|
||||||
switch ($this->counterpartyType) {
|
switch ($this->counterpartyType) {
|
||||||
case 'CLIENT':
|
case self::COUNTERPARTY_CLIENT:
|
||||||
if (null === $this->client) {
|
if (null === $this->client) {
|
||||||
$context->buildViolation('Le client est obligatoire pour une contrepartie « Client ».')
|
$context->buildViolation('Le client est obligatoire pour une contrepartie « Client ».')
|
||||||
->atPath('client')
|
->atPath('client')
|
||||||
@@ -323,7 +332,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'FOURNISSEUR':
|
case self::COUNTERPARTY_FOURNISSEUR:
|
||||||
if (null === $this->supplier) {
|
if (null === $this->supplier) {
|
||||||
$context->buildViolation('Le fournisseur est obligatoire pour une contrepartie « Fournisseur ».')
|
$context->buildViolation('Le fournisseur est obligatoire pour une contrepartie « Fournisseur ».')
|
||||||
->atPath('supplier')
|
->atPath('supplier')
|
||||||
@@ -333,7 +342,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'AUTRE':
|
case self::COUNTERPARTY_AUTRE:
|
||||||
if (null === $this->otherLabel || '' === trim($this->otherLabel)) {
|
if (null === $this->otherLabel || '' === trim($this->otherLabel)) {
|
||||||
$context->buildViolation('Le libellé est obligatoire pour une contrepartie « Autre ».')
|
$context->buildViolation('Le libellé est obligatoire pour une contrepartie « Autre ».')
|
||||||
->atPath('otherLabel')
|
->atPath('otherLabel')
|
||||||
@@ -466,24 +475,10 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
public function getCounterpartyName(): ?string
|
public function getCounterpartyName(): ?string
|
||||||
{
|
{
|
||||||
return match ($this->counterpartyType) {
|
return match ($this->counterpartyType) {
|
||||||
'CLIENT' => $this->client?->getCompanyName(),
|
self::COUNTERPARTY_CLIENT => $this->client?->getCompanyName(),
|
||||||
'FOURNISSEUR' => $this->supplier?->getCompanyName(),
|
self::COUNTERPARTY_FOURNISSEUR => $this->supplier?->getCompanyName(),
|
||||||
'AUTRE' => $this->otherLabel,
|
self::COUNTERPARTY_AUTRE => $this->otherLabel,
|
||||||
default => null,
|
default => null,
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Libellé FR du type de contrepartie (cartouche du bon de pesée PDF, ERP-208),
|
|
||||||
* affiché au-dessus du nom. Null si aucun type défini (brouillon).
|
|
||||||
*/
|
|
||||||
public function getCounterpartyTypeLabel(): ?string
|
|
||||||
{
|
|
||||||
return match ($this->counterpartyType) {
|
|
||||||
'CLIENT' => 'Client',
|
|
||||||
'FOURNISSEUR' => 'Fournisseur',
|
|
||||||
'AUTRE' => 'Autre',
|
|
||||||
default => null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,17 @@
|
|||||||
.company-line { font-size: 12px; }
|
.company-line { font-size: 12px; }
|
||||||
|
|
||||||
/* En-tête 2 colonnes (Dompdf = CSS 2.1, pas de flex/grid) : identité
|
/* En-tête 2 colonnes (Dompdf = CSS 2.1, pas de flex/grid) : identité
|
||||||
société à gauche, cartouche du tiers à droite (ERP-208). */
|
société à gauche, cartouche du tiers à droite (ERP-208). Largeurs
|
||||||
|
fixes par cellule + cartouche en bloc (pas d'inline-block/min-width,
|
||||||
|
mal supportés par Dompdf) : le cartouche occupe la colonne de droite
|
||||||
|
et un nom long passe à la ligne au lieu de déborder. */
|
||||||
.header { width: 100%; border-collapse: collapse; }
|
.header { width: 100%; border-collapse: collapse; }
|
||||||
.header td { vertical-align: top; }
|
.header td { vertical-align: top; }
|
||||||
.header .h-right { text-align: right; }
|
.header .h-left { width: 62%; }
|
||||||
.party-box { display: inline-block; border: 1px solid #000; padding: 8px 12px; min-width: 160px; text-align: left; font-weight: normal; font-size: 11px; }
|
.header .h-right { width: 38%; }
|
||||||
|
.party-box { border: 1px solid #000; padding: 8px 12px; }
|
||||||
.party-label { font-weight: bold; font-size: 14px; margin-bottom: 4px; }
|
.party-label { font-weight: bold; font-size: 14px; margin-bottom: 4px; }
|
||||||
|
.party-name { font-size: 11px; word-wrap: break-word; }
|
||||||
|
|
||||||
.title { font-size: 22px; font-weight: bold; margin: 22px 0 18px; }
|
.title { font-size: 22px; font-weight: bold; margin: 22px 0 18px; }
|
||||||
|
|
||||||
@@ -49,9 +54,12 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
{# Libellé FR du type de contrepartie (couche de rendu, pas le Domain — ERP-208). #}
|
||||||
|
{% set counterpartyLabels = { 'CLIENT': 'Client', 'FOURNISSEUR': 'Fournisseur', 'AUTRE': 'Autre' } %}
|
||||||
|
|
||||||
<table class="header">
|
<table class="header">
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td class="h-left">
|
||||||
{% if logoSrc %}
|
{% if logoSrc %}
|
||||||
<div class="logo"><img src="{{ logoSrc }}" alt="LPC LIOT"></div>
|
<div class="logo"><img src="{{ logoSrc }}" alt="LPC LIOT"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -59,12 +67,16 @@
|
|||||||
<div class="company-line">Email : lpc.contacts@lpc-liot.fr</div>
|
<div class="company-line">Email : lpc.contacts@lpc-liot.fr</div>
|
||||||
<div class="company-line">RCS Châtellerault B 339 505 612</div>
|
<div class="company-line">RCS Châtellerault B 339 505 612</div>
|
||||||
</td>
|
</td>
|
||||||
{# Cartouche tiers (ERP-208) : nom du client / fournisseur / « autre ». #}
|
{# Cartouche tiers (ERP-208) : type (libellé) + nom du client / fournisseur /
|
||||||
|
« autre ». Conditionné sur le TYPE : un brouillon sans type n'affiche rien ;
|
||||||
|
un type sans nom (cas limite) affiche au moins le libellé. #}
|
||||||
<td class="h-right">
|
<td class="h-right">
|
||||||
{% if ticket.counterpartyName %}
|
{% if ticket.counterpartyType %}
|
||||||
<div class="party-box">
|
<div class="party-box">
|
||||||
<div class="party-label">{{ ticket.counterpartyTypeLabel }} :</div>
|
<div class="party-label">{{ counterpartyLabels[ticket.counterpartyType] ?? ticket.counterpartyType }} :</div>
|
||||||
{{ ticket.counterpartyName }}
|
{% if ticket.counterpartyName %}
|
||||||
|
<div class="party-name">{{ ticket.counterpartyName }}</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -44,16 +44,4 @@ final class WeighingTicketCounterpartyNameTest extends TestCase
|
|||||||
{
|
{
|
||||||
self::assertNull(new WeighingTicket()->getCounterpartyName());
|
self::assertNull(new WeighingTicket()->getCounterpartyName());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testTypeLabelIsFrenchPerCounterpartyType(): void
|
|
||||||
{
|
|
||||||
self::assertSame('Client', new WeighingTicket()->setCounterpartyType('CLIENT')->getCounterpartyTypeLabel());
|
|
||||||
self::assertSame('Fournisseur', new WeighingTicket()->setCounterpartyType('FOURNISSEUR')->getCounterpartyTypeLabel());
|
|
||||||
self::assertSame('Autre', new WeighingTicket()->setCounterpartyType('AUTRE')->getCounterpartyTypeLabel());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testTypeLabelIsNullWhenNoCounterparty(): void
|
|
||||||
{
|
|
||||||
self::assertNull(new WeighingTicket()->getCounterpartyTypeLabel());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user