diff --git a/frontend/components/reception/update-bovin.vue b/frontend/components/reception/update-bovin.vue index 5001f12..2e21df2 100644 --- a/frontend/components/reception/update-bovin.vue +++ b/frontend/components/reception/update-bovin.vue @@ -48,8 +48,38 @@ const emit = defineEmits<{ const bovineTypes = ref([]) const localQuantities = reactive>({}) const localOtherQuantity = ref(props.otherQuantity ?? 0) +// Verrou pour éviter les boucles props -> local -> emit -> props. const isSyncing = ref(false) +function entriesEqualByTypeAndQuantity( + left: ReceptionBovineTypeData[], + right: ReceptionBovineTypeData[] +): boolean { + const toMap = (entries: ReceptionBovineTypeData[]) => { + const map = new Map() + for (const entry of entries) { + const typeId = entry.bovineType?.id ?? 0 + map.set(typeId, entry.quantity ?? 0) + } + + return map + } + + const a = toMap(left) + const b = toMap(right) + if (a.size !== b.size) { + return false + } + + for (const [typeId, quantity] of a.entries()) { + if ((b.get(typeId) ?? 0) !== quantity) { + return false + } + } + + return true +} + function buildEntriesFromLocal(): ReceptionBovineTypeData[] { return bovineTypes.value.map((type) => { const existing = props.modelValue.find((entry) => entry.bovineType.id === type.id) @@ -80,20 +110,33 @@ function syncLocalFromProps() { watch( () => props.otherQuantity, (value) => { - localOtherQuantity.value = value ?? 0 + if (isSyncing.value) { + return + } + + const next = value ?? 0 + isSyncing.value = true + localOtherQuantity.value = next + isSyncing.value = false } ) watch(localOtherQuantity, (value) => { - emit('update:otherQuantity', value ?? 0) + if (isSyncing.value) { + return + } + + const next = value ?? 0 + emit('update:otherQuantity', next) }) watch( () => props.modelValue, () => { + // Hydratation locale uniquement quand le parent change. syncLocalFromProps() }, - { deep: true } + { immediate: true } ) watch( @@ -102,7 +145,11 @@ watch( if (isSyncing.value) { return } - emit('update:modelValue', buildEntriesFromLocal()) + // N'émet que si les quantités diffèrent réellement du parent. + const nextEntries = buildEntriesFromLocal() + if (!entriesEqualByTypeAndQuantity(nextEntries, props.modelValue)) { + emit('update:modelValue', nextEntries) + } }, { deep: true } ) @@ -110,6 +157,5 @@ watch( onMounted(async () => { bovineTypes.value = await getBovineTypeList() syncLocalFromProps() - emit('update:modelValue', buildEntriesFromLocal()) }) diff --git a/frontend/components/reception/update-merchandise.vue b/frontend/components/reception/update-merchandise.vue index e866463..a5bb79c 100644 --- a/frontend/components/reception/update-merchandise.vue +++ b/frontend/components/reception/update-merchandise.vue @@ -109,7 +109,8 @@ const selectedMerchandiseTypeId = ref('') const selectedBuildingIds = ref([]) const selectedPelletBuildingIds = ref>({}) const merchandiseDetail = ref('') -const isHydrating = ref(false) +// Verrou de synchro pour empêcher les aller-retours infinis entre parent et composant. +const isSyncing = ref(false) const isReady = ref(false) const selectedMerchandiseType = computed(() => @@ -130,6 +131,39 @@ function clonePelletSelections(value: Record) { return clone } +function sorted(values: string[]): string[] { + return [...values].sort() +} + +function normalizeModel(value: MerchandiseEntryData): MerchandiseEntryData { + // Normalisation stable pour comparer deux modèles sans faux positifs (ordre des tableaux). + const pellet: Record = {} + const pelletKeys = Object.keys(value.selectedPelletBuildingIds ?? {}).sort() + for (const key of pelletKeys) { + pellet[key] = sorted(value.selectedPelletBuildingIds[key] ?? []) + } + + return { + merchandiseTypeId: value.merchandiseTypeId ?? '', + merchandiseDetail: value.merchandiseDetail ?? '', + selectedBuildingIds: sorted(value.selectedBuildingIds ?? []), + selectedPelletBuildingIds: pellet + } +} + +function buildCurrentModel(): MerchandiseEntryData { + return { + merchandiseTypeId: selectedMerchandiseTypeId.value, + merchandiseDetail: merchandiseDetail.value, + selectedBuildingIds: [...selectedBuildingIds.value], + selectedPelletBuildingIds: clonePelletSelections(selectedPelletBuildingIds.value) + } +} + +function isSameModel(left: MerchandiseEntryData, right: MerchandiseEntryData): boolean { + return JSON.stringify(normalizeModel(left)) === JSON.stringify(normalizeModel(right)) +} + function ensurePelletKeys() { for (const pelletType of pelletTypes.value) { const key = String(pelletType.id) @@ -140,7 +174,7 @@ function ensurePelletKeys() { } function hydrateFromModelValue(value: MerchandiseEntryData) { - isHydrating.value = true + isSyncing.value = true try { selectedMerchandiseTypeId.value = value.merchandiseTypeId ?? '' merchandiseDetail.value = value.merchandiseDetail ?? '' @@ -150,51 +184,71 @@ function hydrateFromModelValue(value: MerchandiseEntryData) { ) ensurePelletKeys() } finally { - isHydrating.value = false + isSyncing.value = false } } -function emitModelValue() { - emit('update:modelValue', { - merchandiseTypeId: selectedMerchandiseTypeId.value, - merchandiseDetail: merchandiseDetail.value, - selectedBuildingIds: [...selectedBuildingIds.value], - selectedPelletBuildingIds: clonePelletSelections(selectedPelletBuildingIds.value) - }) +function sanitizeLocalState() { + if (isGranule.value) { + if (selectedBuildingIds.value.length > 0) { + selectedBuildingIds.value = [] + } + } else { + for (const key of Object.keys(selectedPelletBuildingIds.value)) { + if (selectedPelletBuildingIds.value[key].length > 0) { + selectedPelletBuildingIds.value[key] = [] + } + } + } + + if (!isAutres.value && merchandiseDetail.value !== '') { + merchandiseDetail.value = '' + } +} + +function emitCurrentModel() { + const currentModel = buildCurrentModel() + // Ne pas réémettre si rien n'a changé côté métier. + if (isSameModel(currentModel, props.modelValue)) { + return + } + + emit('update:modelValue', currentModel) } watch( () => props.modelValue, (value) => { + const currentModel = buildCurrentModel() + // Si local == parent, on ignore pour éviter la boucle de réhydratation. + if (isSameModel(currentModel, value)) { + return + } hydrateFromModelValue(value) }, - { deep: true } + { immediate: true } ) watch( [selectedMerchandiseTypeId, selectedBuildingIds, selectedPelletBuildingIds, merchandiseDetail], () => { - if (isHydrating.value || !isReady.value) { + if (isSyncing.value || !isReady.value) { return } - if (isGranule.value) { - if (selectedBuildingIds.value.length > 0) { - selectedBuildingIds.value = [] - } - } else { - for (const key of Object.keys(selectedPelletBuildingIds.value)) { - if (selectedPelletBuildingIds.value[key].length > 0) { - selectedPelletBuildingIds.value[key] = [] - } - } + const beforeSanitize = buildCurrentModel() + isSyncing.value = true + // Applique les règles métier (granulé / autres) avant émission. + sanitizeLocalState() + isSyncing.value = false + + const afterSanitize = buildCurrentModel() + // Si la sanitation a modifié l'état, on laisse le watcher repasser proprement. + if (!isSameModel(beforeSanitize, afterSanitize)) { + return } - if (!isAutres.value && merchandiseDetail.value !== '') { - merchandiseDetail.value = '' - } - - emitModelValue() + emitCurrentModel() }, { deep: true } ) @@ -211,6 +265,5 @@ onMounted(async () => { hydrateFromModelValue(props.modelValue) isReady.value = true - emitModelValue() }) diff --git a/frontend/composables/useWeighing.ts b/frontend/composables/useWeighing.ts index 83b434d..c8f4357 100644 --- a/frontend/composables/useWeighing.ts +++ b/frontend/composables/useWeighing.ts @@ -62,7 +62,7 @@ export const useWeighing = ({ }) } else { await createWeight({ - reception: `api/receptions/${reception.value.id}`, + reception: `/api/receptions/${reception.value.id}`, type: mode, dsd: baseDsd, weight: baseWeight, @@ -146,7 +146,7 @@ export const useWeighingShipment = ({ }) } else { await createWeight({ - shipment: `api/shipments/${shipment.value.id}`, + shipment: `/api/shipments/${shipment.value.id}`, type: modeShipment, dsd: baseDsd, weight: baseWeight, diff --git a/frontend/pages/reception/update/[[id]].vue b/frontend/pages/reception/update/[[id]].vue index e675a31..9ec8536 100644 --- a/frontend/pages/reception/update/[[id]].vue +++ b/frontend/pages/reception/update/[[id]].vue @@ -172,13 +172,13 @@ /> void) { + if (vehicleSyncLock.value) { + return + } + + vehicleSyncLock.value = true + try { + mutator() + } finally { + queueMicrotask(() => { + vehicleSyncLock.value = false + }) + } +} + const form = reactive({ identificationNumber: null, licensePlate: '', @@ -581,8 +597,16 @@ async function saveWeightEntry(entry: WeightEntryData) { return } + // Fallback: if id is missing in local state, reuse existing weight by type. + const reception = await getReception(idReception) + const existingEntry = reception?.weights?.find((weight) => weight.type === entry.type) ?? null + if (existingEntry?.id) { + await updateWeight(existingEntry.id, payload) + return + } + await createWeight({ - reception: `api/receptions/${idReception}`, + reception: `/api/receptions/${idReception}`, ...payload }) } @@ -785,8 +809,11 @@ async function validate() { }) } - const refreshedReception = await getReception(idReception) - hydrateFromReception(refreshedReception) + // Évite une réhydratation complète après save (source de cascades de watchers). + // On recharge uniquement les bovins quand on est en mode bovins. + if (!isMerchandise.value) { + await loadBovineEntries(idReception) + } return } @@ -809,12 +836,20 @@ onMounted(async () => { watch( () => [form.supplierId, form.addressId, suppliers.value], () => { + if (isHydrating.value) { + return + } if (!form.supplierId) { - form.addressId = '' + if (form.addressId !== '') { + form.addressId = '' + } return } if (!form.addressId && supplierAddresses.value.length === 1) { - form.addressId = String(supplierAddresses.value[0].id) + const nextAddressId = String(supplierAddresses.value[0].id) + if (form.addressId !== nextAddressId) { + form.addressId = nextAddressId + } return } if (!form.addressId) { @@ -825,9 +860,14 @@ watch( ) if (!matches) { if (supplierAddresses.value.length === 1) { - form.addressId = String(supplierAddresses.value[0].id) + const nextAddressId = String(supplierAddresses.value[0].id) + if (form.addressId !== nextAddressId) { + form.addressId = nextAddressId + } } else { - form.addressId = '' + if (form.addressId !== '') { + form.addressId = '' + } } } }, @@ -837,24 +877,36 @@ watch( watch( () => form.carrierId, () => { - if (isHydrating.value) { + if (isHydrating.value || vehicleSyncLock.value) { return } if (!form.carrierId && idReception == null) { - form.driverId = '' - form.vehicleId = '' + runWithVehicleSyncLock(() => { + form.driverId = '' + form.vehicleId = '' + }) return } if (!isLiotCarrier.value && idReception == null) { - form.driverId = '' - form.vehicleId = '' + runWithVehicleSyncLock(() => { + form.driverId = '' + form.vehicleId = '' + }) return } if (filteredDrivers.value.length === 1) { - form.driverId = String(filteredDrivers.value[0].id) + const nextDriverId = String(filteredDrivers.value[0].id) + if (form.driverId !== nextDriverId) { + form.driverId = nextDriverId + } } if (filteredVehicles.value.length === 1) { - form.vehicleId = String(filteredVehicles.value[0].id) + const nextVehicleId = String(filteredVehicles.value[0].id) + if (form.vehicleId !== nextVehicleId) { + runWithVehicleSyncLock(() => { + form.vehicleId = nextVehicleId + }) + } } }, { immediate: true } @@ -863,11 +915,19 @@ watch( watch( () => [form.truckId, form.carrierId, vehicles.value], () => { + if (isHydrating.value || vehicleSyncLock.value) { + return + } if (!isLiotCarrier.value) { return } if (filteredVehicles.value.length === 1) { - form.vehicleId = String(filteredVehicles.value[0].id) + const nextVehicleId = String(filteredVehicles.value[0].id) + if (form.vehicleId !== nextVehicleId) { + runWithVehicleSyncLock(() => { + form.vehicleId = nextVehicleId + }) + } return } if (!form.vehicleId) { @@ -877,7 +937,11 @@ watch( (vehicle) => String(vehicle.id) === form.vehicleId ) if (!matches) { - form.vehicleId = '' + if (form.vehicleId !== '') { + runWithVehicleSyncLock(() => { + form.vehicleId = '' + }) + } } }, { immediate: true } @@ -886,6 +950,9 @@ watch( watch( () => [form.vehicleId, form.carrierId, vehicles.value], () => { + if (vehicleSyncLock.value) { + return + } if (!isLiotCarrier.value) { return } @@ -895,9 +962,11 @@ watch( const selected = filteredVehicles.value.find( (vehicle) => String(vehicle.id) === form.vehicleId ) - if (selected) { - form.licensePlate = selected.plate - allowAnyLicensePlate.value = false + if (selected && form.licensePlate !== selected.plate) { + runWithVehicleSyncLock(() => { + form.licensePlate = selected.plate + allowAnyLicensePlate.value = false + }) } } ) @@ -905,6 +974,12 @@ watch( watch( () => [form.licensePlate, form.carrierId, vehicles.value], () => { + if (vehicleSyncLock.value) { + return + } + if (isHydrating.value) { + return + } if (!isLiotCarrier.value || form.vehicleId) { return } @@ -912,7 +987,12 @@ watch( (vehicle) => vehicle.plate === form.licensePlate ) if (match) { - form.vehicleId = String(match.id) + const nextVehicleId = String(match.id) + if (form.vehicleId !== nextVehicleId) { + runWithVehicleSyncLock(() => { + form.vehicleId = nextVehicleId + }) + } } } )