fix : page de modification reception qui crash en prod

This commit is contained in:
tristan
2026-02-26 10:28:00 +01:00
parent c48cc477da
commit 59d76c5f14
4 changed files with 236 additions and 57 deletions
+51 -5
View File
@@ -48,8 +48,38 @@ const emit = defineEmits<{
const bovineTypes = ref<BovineTypeData[]>([]) const bovineTypes = ref<BovineTypeData[]>([])
const localQuantities = reactive<Record<string, number | null>>({}) const localQuantities = reactive<Record<string, number | null>>({})
const localOtherQuantity = ref<number | null>(props.otherQuantity ?? 0) const localOtherQuantity = ref<number | null>(props.otherQuantity ?? 0)
// Verrou pour éviter les boucles props -> local -> emit -> props.
const isSyncing = ref(false) const isSyncing = ref(false)
function entriesEqualByTypeAndQuantity(
left: ReceptionBovineTypeData[],
right: ReceptionBovineTypeData[]
): boolean {
const toMap = (entries: ReceptionBovineTypeData[]) => {
const map = new Map<number, number>()
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[] { function buildEntriesFromLocal(): ReceptionBovineTypeData[] {
return bovineTypes.value.map((type) => { return bovineTypes.value.map((type) => {
const existing = props.modelValue.find((entry) => entry.bovineType.id === type.id) const existing = props.modelValue.find((entry) => entry.bovineType.id === type.id)
@@ -80,20 +110,33 @@ function syncLocalFromProps() {
watch( watch(
() => props.otherQuantity, () => props.otherQuantity,
(value) => { (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) => { watch(localOtherQuantity, (value) => {
emit('update:otherQuantity', value ?? 0) if (isSyncing.value) {
return
}
const next = value ?? 0
emit('update:otherQuantity', next)
}) })
watch( watch(
() => props.modelValue, () => props.modelValue,
() => { () => {
// Hydratation locale uniquement quand le parent change.
syncLocalFromProps() syncLocalFromProps()
}, },
{ deep: true } { immediate: true }
) )
watch( watch(
@@ -102,7 +145,11 @@ watch(
if (isSyncing.value) { if (isSyncing.value) {
return 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 } { deep: true }
) )
@@ -110,6 +157,5 @@ watch(
onMounted(async () => { onMounted(async () => {
bovineTypes.value = await getBovineTypeList() bovineTypes.value = await getBovineTypeList()
syncLocalFromProps() syncLocalFromProps()
emit('update:modelValue', buildEntriesFromLocal())
}) })
</script> </script>
@@ -109,7 +109,8 @@ const selectedMerchandiseTypeId = ref('')
const selectedBuildingIds = ref<string[]>([]) const selectedBuildingIds = ref<string[]>([])
const selectedPelletBuildingIds = ref<Record<string, string[]>>({}) const selectedPelletBuildingIds = ref<Record<string, string[]>>({})
const merchandiseDetail = 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 isReady = ref(false)
const selectedMerchandiseType = computed(() => const selectedMerchandiseType = computed(() =>
@@ -130,6 +131,39 @@ function clonePelletSelections(value: Record<string, string[]>) {
return clone 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<string, string[]> = {}
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() { function ensurePelletKeys() {
for (const pelletType of pelletTypes.value) { for (const pelletType of pelletTypes.value) {
const key = String(pelletType.id) const key = String(pelletType.id)
@@ -140,7 +174,7 @@ function ensurePelletKeys() {
} }
function hydrateFromModelValue(value: MerchandiseEntryData) { function hydrateFromModelValue(value: MerchandiseEntryData) {
isHydrating.value = true isSyncing.value = true
try { try {
selectedMerchandiseTypeId.value = value.merchandiseTypeId ?? '' selectedMerchandiseTypeId.value = value.merchandiseTypeId ?? ''
merchandiseDetail.value = value.merchandiseDetail ?? '' merchandiseDetail.value = value.merchandiseDetail ?? ''
@@ -150,51 +184,71 @@ function hydrateFromModelValue(value: MerchandiseEntryData) {
) )
ensurePelletKeys() ensurePelletKeys()
} finally { } finally {
isHydrating.value = false isSyncing.value = false
} }
} }
function emitModelValue() { function sanitizeLocalState() {
emit('update:modelValue', { if (isGranule.value) {
merchandiseTypeId: selectedMerchandiseTypeId.value, if (selectedBuildingIds.value.length > 0) {
merchandiseDetail: merchandiseDetail.value, selectedBuildingIds.value = []
selectedBuildingIds: [...selectedBuildingIds.value], }
selectedPelletBuildingIds: clonePelletSelections(selectedPelletBuildingIds.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( watch(
() => props.modelValue, () => props.modelValue,
(value) => { (value) => {
const currentModel = buildCurrentModel()
// Si local == parent, on ignore pour éviter la boucle de réhydratation.
if (isSameModel(currentModel, value)) {
return
}
hydrateFromModelValue(value) hydrateFromModelValue(value)
}, },
{ deep: true } { immediate: true }
) )
watch( watch(
[selectedMerchandiseTypeId, selectedBuildingIds, selectedPelletBuildingIds, merchandiseDetail], [selectedMerchandiseTypeId, selectedBuildingIds, selectedPelletBuildingIds, merchandiseDetail],
() => { () => {
if (isHydrating.value || !isReady.value) { if (isSyncing.value || !isReady.value) {
return return
} }
if (isGranule.value) { const beforeSanitize = buildCurrentModel()
if (selectedBuildingIds.value.length > 0) { isSyncing.value = true
selectedBuildingIds.value = [] // Applique les règles métier (granulé / autres) avant émission.
} sanitizeLocalState()
} else { isSyncing.value = false
for (const key of Object.keys(selectedPelletBuildingIds.value)) {
if (selectedPelletBuildingIds.value[key].length > 0) { const afterSanitize = buildCurrentModel()
selectedPelletBuildingIds.value[key] = [] // Si la sanitation a modifié l'état, on laisse le watcher repasser proprement.
} if (!isSameModel(beforeSanitize, afterSanitize)) {
} return
} }
if (!isAutres.value && merchandiseDetail.value !== '') { emitCurrentModel()
merchandiseDetail.value = ''
}
emitModelValue()
}, },
{ deep: true } { deep: true }
) )
@@ -211,6 +265,5 @@ onMounted(async () => {
hydrateFromModelValue(props.modelValue) hydrateFromModelValue(props.modelValue)
isReady.value = true isReady.value = true
emitModelValue()
}) })
</script> </script>
+2 -2
View File
@@ -62,7 +62,7 @@ export const useWeighing = ({
}) })
} else { } else {
await createWeight({ await createWeight({
reception: `api/receptions/${reception.value.id}`, reception: `/api/receptions/${reception.value.id}`,
type: mode, type: mode,
dsd: baseDsd, dsd: baseDsd,
weight: baseWeight, weight: baseWeight,
@@ -146,7 +146,7 @@ export const useWeighingShipment = ({
}) })
} else { } else {
await createWeight({ await createWeight({
shipment: `api/shipments/${shipment.value.id}`, shipment: `/api/shipments/${shipment.value.id}`,
type: modeShipment, type: modeShipment,
dsd: baseDsd, dsd: baseDsd,
weight: baseWeight, weight: baseWeight,
+102 -22
View File
@@ -172,13 +172,13 @@
/> />
<update-merchandise <update-merchandise
v-show="activeTab === 'merchandise' && isMerchandise" v-if="activeTab === 'merchandise' && isMerchandise"
v-model="merchandiseForm" v-model="merchandiseForm"
:isAdmin="auth.isAdmin" :isAdmin="auth.isAdmin"
/> />
<update-bovin <update-bovin
v-show="activeTab === 'merchandise' && !isMerchandise" v-if="activeTab === 'merchandise' && !isMerchandise"
v-model="bovineEntries" v-model="bovineEntries"
v-model:otherQuantity="bovineOtherQuantity" v-model:otherQuantity="bovineOtherQuantity"
:isAdmin="auth.isAdmin" :isAdmin="auth.isAdmin"
@@ -279,9 +279,25 @@ const isLoadingVehicles = ref(false)
const formIsLoading = ref(false) const formIsLoading = ref(false)
const isMerchandise = ref(false) const isMerchandise = ref(false)
const isHydrating = ref(false) const isHydrating = ref(false)
const vehicleSyncLock = ref(false)
const idReception = Number(route.params.id) const idReception = Number(route.params.id)
function runWithVehicleSyncLock(mutator: () => void) {
if (vehicleSyncLock.value) {
return
}
vehicleSyncLock.value = true
try {
mutator()
} finally {
queueMicrotask(() => {
vehicleSyncLock.value = false
})
}
}
const form = reactive<ReceptionFormData>({ const form = reactive<ReceptionFormData>({
identificationNumber: null, identificationNumber: null,
licensePlate: '', licensePlate: '',
@@ -581,8 +597,16 @@ async function saveWeightEntry(entry: WeightEntryData) {
return 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({ await createWeight({
reception: `api/receptions/${idReception}`, reception: `/api/receptions/${idReception}`,
...payload ...payload
}) })
} }
@@ -785,8 +809,11 @@ async function validate() {
}) })
} }
const refreshedReception = await getReception(idReception) // Évite une réhydratation complète après save (source de cascades de watchers).
hydrateFromReception(refreshedReception) // On recharge uniquement les bovins quand on est en mode bovins.
if (!isMerchandise.value) {
await loadBovineEntries(idReception)
}
return return
} }
@@ -809,12 +836,20 @@ onMounted(async () => {
watch( watch(
() => [form.supplierId, form.addressId, suppliers.value], () => [form.supplierId, form.addressId, suppliers.value],
() => { () => {
if (isHydrating.value) {
return
}
if (!form.supplierId) { if (!form.supplierId) {
form.addressId = '' if (form.addressId !== '') {
form.addressId = ''
}
return return
} }
if (!form.addressId && supplierAddresses.value.length === 1) { 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 return
} }
if (!form.addressId) { if (!form.addressId) {
@@ -825,9 +860,14 @@ watch(
) )
if (!matches) { if (!matches) {
if (supplierAddresses.value.length === 1) { 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 { } else {
form.addressId = '' if (form.addressId !== '') {
form.addressId = ''
}
} }
} }
}, },
@@ -837,24 +877,36 @@ watch(
watch( watch(
() => form.carrierId, () => form.carrierId,
() => { () => {
if (isHydrating.value) { if (isHydrating.value || vehicleSyncLock.value) {
return return
} }
if (!form.carrierId && idReception == null) { if (!form.carrierId && idReception == null) {
form.driverId = '' runWithVehicleSyncLock(() => {
form.vehicleId = '' form.driverId = ''
form.vehicleId = ''
})
return return
} }
if (!isLiotCarrier.value && idReception == null) { if (!isLiotCarrier.value && idReception == null) {
form.driverId = '' runWithVehicleSyncLock(() => {
form.vehicleId = '' form.driverId = ''
form.vehicleId = ''
})
return return
} }
if (filteredDrivers.value.length === 1) { 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) { 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 } { immediate: true }
@@ -863,11 +915,19 @@ watch(
watch( watch(
() => [form.truckId, form.carrierId, vehicles.value], () => [form.truckId, form.carrierId, vehicles.value],
() => { () => {
if (isHydrating.value || vehicleSyncLock.value) {
return
}
if (!isLiotCarrier.value) { if (!isLiotCarrier.value) {
return return
} }
if (filteredVehicles.value.length === 1) { 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 return
} }
if (!form.vehicleId) { if (!form.vehicleId) {
@@ -877,7 +937,11 @@ watch(
(vehicle) => String(vehicle.id) === form.vehicleId (vehicle) => String(vehicle.id) === form.vehicleId
) )
if (!matches) { if (!matches) {
form.vehicleId = '' if (form.vehicleId !== '') {
runWithVehicleSyncLock(() => {
form.vehicleId = ''
})
}
} }
}, },
{ immediate: true } { immediate: true }
@@ -886,6 +950,9 @@ watch(
watch( watch(
() => [form.vehicleId, form.carrierId, vehicles.value], () => [form.vehicleId, form.carrierId, vehicles.value],
() => { () => {
if (vehicleSyncLock.value) {
return
}
if (!isLiotCarrier.value) { if (!isLiotCarrier.value) {
return return
} }
@@ -895,9 +962,11 @@ watch(
const selected = filteredVehicles.value.find( const selected = filteredVehicles.value.find(
(vehicle) => String(vehicle.id) === form.vehicleId (vehicle) => String(vehicle.id) === form.vehicleId
) )
if (selected) { if (selected && form.licensePlate !== selected.plate) {
form.licensePlate = selected.plate runWithVehicleSyncLock(() => {
allowAnyLicensePlate.value = false form.licensePlate = selected.plate
allowAnyLicensePlate.value = false
})
} }
} }
) )
@@ -905,6 +974,12 @@ watch(
watch( watch(
() => [form.licensePlate, form.carrierId, vehicles.value], () => [form.licensePlate, form.carrierId, vehicles.value],
() => { () => {
if (vehicleSyncLock.value) {
return
}
if (isHydrating.value) {
return
}
if (!isLiotCarrier.value || form.vehicleId) { if (!isLiotCarrier.value || form.vehicleId) {
return return
} }
@@ -912,7 +987,12 @@ watch(
(vehicle) => vehicle.plate === form.licensePlate (vehicle) => vehicle.plate === form.licensePlate
) )
if (match) { if (match) {
form.vehicleId = String(match.id) const nextVehicleId = String(match.id)
if (form.vehicleId !== nextVehicleId) {
runWithVehicleSyncLock(() => {
form.vehicleId = nextVehicleId
})
}
} }
} }
) )