Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard 6463a3d211 Preserve registration save payload formatting 2026-07-06 10:03:19 +02:00
Jeppe Bundgaard e792f5820d Fix POS step metadata persistence 2026-07-06 09:34:01 +02:00
13 changed files with 640 additions and 155 deletions
@@ -1,9 +1,12 @@
<script setup>
import { computed, nextTick, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
import { isBlankPosMetadataValue } from "@/components/shop/POSDepartmentProcess.vue";
import {
isBlankPosMetadataValue,
registerPosStepSaveBarrier,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.vue";
const props = defineProps({
order_id: {
@@ -123,7 +126,7 @@ const referenceField = createCustomerWishField({
key: "reference",
label: computed(() => t("common.reference")),
source: () => props.reference,
saveValue: (value) => SessionUser.objects.orders.set.reference(props.order_id, value),
saveValue: (value) => saveOrderMetadataField("reference", value, props.order_id),
testIdBase: "pos-order-customer-wishes-reference",
isRequired: () => props.referenceRequired,
warningStateWhenEmpty: "danger",
@@ -134,7 +137,7 @@ const poField = createCustomerWishField({
key: "po",
label: computed(() => t("admin.pos.po_number")),
source: () => props.po,
saveValue: (value) => SessionUser.objects.orders.set.po(props.order_id, value),
saveValue: (value) => saveOrderMetadataField("po", value, props.order_id),
testIdBase: "pos-order-customer-wishes-po",
isRequired: () => props.poRequired,
warningStateWhenEmpty: "warning",
@@ -145,7 +148,7 @@ const safetySealField = createCustomerWishField({
key: "safety_seal",
label: computed(() => t("admin.pos.safety_seal")),
source: () => props.safetySeal,
saveValue: (value) => SessionUser.objects.orders.set.safety_seal(props.order_id, value),
saveValue: (value) => saveOrderMetadataField("safety_seal", value, props.order_id),
testIdBase: "pos-order-customer-wishes-safety-seal",
isRequired: () => false,
warningStateWhenEmpty: null,
@@ -161,6 +164,15 @@ const safetySealField = createCustomerWishField({
const fields = computed(() => {
return props.showSafetySeal ? [referenceField, poField, safetySealField] : [referenceField, poField];
});
const flushCustomerWishFields = async () => {
await Promise.all(fields.value.map((field) => field.autosave.flushAndWait()));
};
const unregisterStepSaveBarrier = registerPosStepSaveBarrier(flushCustomerWishFields);
onBeforeUnmount(() => {
unregisterStepSaveBarrier();
});
</script>
<template>
@@ -1,8 +1,11 @@
<script setup>
import { computed, nextTick, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
import {
registerPosStepSaveBarrier,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.vue";
const props = defineProps({
order_id: {
@@ -30,8 +33,7 @@ const isEditing = ref(false);
const autosave = useOrderMetadataAutosave({
source: () => props.note,
saveValue: async (value) => {
await SessionUser.objects.orders.set.notes(props.order_id, value);
return value;
return saveOrderMetadataField("notes", value, props.order_id);
},
onSaved: async (savedValue, requestedValue) => {
if (typeof props.onSaved === "function") {
@@ -68,6 +70,12 @@ const lostFocus = async () => {
isEditing.value = false;
await autosave.flush();
};
const unregisterStepSaveBarrier = registerPosStepSaveBarrier(() => autosave.flushAndWait());
onBeforeUnmount(() => {
unregisterStepSaveBarrier();
});
</script>
<template>
@@ -1,8 +1,11 @@
<script setup>
import { computed, nextTick, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
import {
registerPosStepSaveBarrier,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.vue";
const props = defineProps({
index: {
@@ -36,8 +39,7 @@ autosave = useOrderMetadataAutosave({
source: () => props.modelValue,
normalizeValue: normalizeRegistrationDraft,
saveValue: async (value) => {
await SessionUser.objects.orders.set[fieldKey.value](props.order_id, value);
return value;
return saveOrderMetadataField(fieldKey.value, value, props.order_id);
},
onSaved: async () => {
await props.loadOrder();
@@ -74,6 +76,12 @@ const closeEditor = async () => {
isEditing.value = false;
await autosave.flush();
};
const unregisterStepSaveBarrier = registerPosStepSaveBarrier(() => autosave.flushAndWait());
onBeforeUnmount(() => {
unregisterStepSaveBarrier();
});
</script>
<template>
@@ -4,7 +4,7 @@ import {
setBackgroundColor,
backgroundColors,
} from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
import { onMounted, ref, watch, computed } from "vue";
import { onBeforeUnmount, onMounted, ref, watch, computed } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import PosDepartmentStepMobile2Product from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Product.vue";
import PosDepartmentStepMobile2LastOrder from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2LastOrder.vue";
@@ -43,6 +43,8 @@ import {
customer_name,
isAddonRestricted,
canBuyAdditionalServices,
registerPosStepSaveBarrier,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.vue";
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
@@ -196,8 +198,7 @@ const applyPendingBookingFromSelection = async () => {
// Set PO number on the order if provided by the booking
if (booking.po && typeof booking.po === "string" && booking.po.trim() !== "") {
try {
await SessionUser.objects.orders.set.po(order_id.value, booking.po);
order_po.value = booking.po.trim();
order_po.value = await saveOrderMetadataField("po", booking.po, order_id.value);
} catch (e) {
console.error("Failed to set PO on order from booking", e);
}
@@ -217,9 +218,9 @@ const applyPendingBookingFromSelection = async () => {
const bookingReference = String(fullBookingData.reference ?? fullBookingData.reference_number ?? "").trim();
if (bookingReference !== "") {
try {
await SessionUser.objects.orders.set.reference(order_id.value, bookingReference);
persistedReference.value = bookingReference;
metadata.setReference(bookingReference);
const savedReference = await saveOrderMetadataField("reference", bookingReference, order_id.value);
persistedReference.value = savedReference;
metadata.setReference(savedReference);
hydrateReferenceFromSources();
} catch (setRefError) {
console.error("Failed to set reference on order from booking:", setRefError);
@@ -228,10 +229,10 @@ const applyPendingBookingFromSelection = async () => {
const bookingNotes = String(fullBookingData.notes ?? fullBookingData.note ?? "").trim();
if (bookingNotes !== "") {
try {
await SessionUser.objects.orders.set.notes(order_id.value, bookingNotes);
order_notes.value = bookingNotes;
notes.value = bookingNotes;
metadata.setNotes(bookingNotes);
const savedNotes = await saveOrderMetadataField("notes", bookingNotes, order_id.value);
order_notes.value = savedNotes;
notes.value = savedNotes;
metadata.setNotes(savedNotes);
} catch (setNotesError) {
console.error("Failed to set notes on order from booking:", setNotesError);
}
@@ -243,13 +244,11 @@ const applyPendingBookingFromSelection = async () => {
const currentReg2 = normalizeRegistrationValue(reg_2.value);
if (bookingReg1 && bookingReg1 !== currentReg1) {
reg_1.value = bookingReg1;
await SessionUser.objects.orders.set.reg_1(order_id.value, bookingReg1);
reg_1.value = await saveOrderMetadataField("reg_1", bookingReg1, order_id.value);
}
if (bookingReg2 !== currentReg2) {
reg_2.value = bookingReg2;
await SessionUser.objects.orders.set.reg_2(order_id.value, bookingReg2);
reg_2.value = await saveOrderMetadataField("reg_2", bookingReg2, order_id.value);
}
const bookingMatches = Array.isArray(vehicles.vehicle_1.value?.booking_matches)
@@ -538,8 +537,7 @@ const notesAutosave = useOrderMetadataAutosave({
return value;
}
await SessionUser.objects.orders.set.notes(normalizedOrderId, value);
return value;
return saveOrderMetadataField("notes", value, normalizedOrderId);
},
onSaved: async (value) => {
order_notes.value = value;
@@ -555,8 +553,7 @@ const referenceAutosave = useOrderMetadataAutosave({
return value;
}
await SessionUser.objects.orders.set.reference(normalizedOrderId, value);
return value;
return saveOrderMetadataField("reference", value, normalizedOrderId);
},
onSaved: async (value) => {
persistedReference.value = value;
@@ -572,8 +569,7 @@ const safetySealAutosave = useOrderMetadataAutosave({
return value;
}
await SessionUser.objects.orders.set.safety_seal(normalizedOrderId, value);
return value;
return saveOrderMetadataField("safety_seal", value, normalizedOrderId);
},
onSaved: async (value) => {
order_safety_seal.value = value;
@@ -585,6 +581,18 @@ const stepTwoNotesInput = notesAutosave.draft;
const stepTwoReferenceInput = referenceAutosave.draft;
const stepTwoSafetySealInput = safetySealAutosave.draft;
const showSafetySealField = computed(() => transactionItems.containsWashCertificate());
const flushStepTwoMetadataFields = async () => {
await Promise.all([
notesAutosave.flushAndWait(),
referenceAutosave.flushAndWait(),
safetySealAutosave.flushAndWait(),
]);
};
const unregisterStepSaveBarrier = registerPosStepSaveBarrier(flushStepTwoMetadataFields);
onBeforeUnmount(() => {
unregisterStepSaveBarrier();
});
const resolveReferenceFromSources = () => {
const orderReference = normalizeReferenceValue(persistedReference.value);
@@ -20,6 +20,7 @@ import {
hasAttribute,
uploadAttachment,
restoreStoredPosOrderId,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.vue";
import * as POSDepartmentProcess from "@/components/shop/POSDepartmentProcess.vue";
import { errors } from "@/components/request/HandleGlobalError.vue";
@@ -96,17 +97,30 @@ const props = defineProps({
},
});
const updateReference = () => {
if (!!order_id.value && order_id.value > 0) SessionUser.objects.orders.set.reference(order_id.value, reference.value);
const getActiveOrderId = () => {
const parsedOrderId = Number.parseInt(String(order_id.value ?? ""), 10);
return Number.isInteger(parsedOrderId) && parsedOrderId > 0 ? parsedOrderId : null;
};
const updateNotes = () => {
if (!!order_id.value && order_id.value > 0) SessionUser.objects.orders.set.notes(order_id.value, order_notes.value);
const updateReference = async () => {
const activeOrderId = getActiveOrderId();
if (activeOrderId) {
await saveOrderMetadataField("reference", reference.value, activeOrderId);
}
};
const updateSafetySeal = () => {
if (!!order_id.value && order_id.value > 0)
SessionUser.objects.orders.set.safety_seal(order_id.value, order_safety_seal.value);
const updateNotes = async () => {
const activeOrderId = getActiveOrderId();
if (activeOrderId) {
await saveOrderMetadataField("notes", order_notes.value, activeOrderId);
}
};
const updateSafetySeal = async () => {
const activeOrderId = getActiveOrderId();
if (activeOrderId) {
await saveOrderMetadataField("safety_seal", order_safety_seal.value, activeOrderId);
}
};
// Helpers: extracted for clarity and reuse
@@ -196,9 +210,9 @@ const step1 = async () => {
// Reset the vehicle selection
views.vehicleSelection.value = false;
// Merge references and notes
mergeReferences();
mergeNotes();
mergeSafetySeal();
await mergeReferences();
await mergeNotes();
await mergeSafetySeal();
// We only need to check if the customer requires reference, if the field is empty.
if (!isNonEmptyString(reference.value)) {
const isValid = await validateReferenceRequirements();
@@ -214,37 +228,39 @@ const mergeReferences = () => {
if (metadata.getReference() && metadata.getReference().length >= 1) {
// If there is no reference, set it to the registration number of vehicle 1
reference.value = metadata.getReference();
updateReference();
return updateReference();
} else if (reference.value && reference.value.length >= 1) {
// If there is a reference in the field, set it to the metadata
metadata.setReference(reference.value);
updateReference();
return updateReference();
} else if (vehicles.vehicle_1.value?.reference && vehicles.vehicle_1.value?.reference.length >= 1) {
// If there is no reference, set it to the registration number of vehicle 1
reference.value = vehicles.vehicle_1.value?.reference;
metadata.setReference(reference.value);
updateReference();
return updateReference();
}
return Promise.resolve();
};
const mergeNotes = () => {
if (metadata.getNotes() && metadata.getNotes().length >= 1) {
order_notes.value = metadata.getNotes();
updateNotes();
return updateNotes();
} else if (order_notes.value && order_notes.value.length >= 1) {
metadata.setNotes(order_notes.value);
updateNotes();
return updateNotes();
}
return Promise.resolve();
};
const mergeSafetySeal = () => {
const resolvedSafetySeal = getResolvedMobileSafetySeal();
order_safety_seal.value = resolvedSafetySeal;
syncMobileSafetySealState(resolvedSafetySeal);
updateSafetySeal();
return updateSafetySeal();
};
const finalizeStep1 = async () => {
mergeReferences();
mergeNotes();
mergeSafetySeal();
await mergeReferences();
await mergeNotes();
await mergeSafetySeal();
// Upload the attachments if there are any (And the order id has been created)
if (attachments.getBase64().length > 0 && order_id.value > 0) {
const uploadResults = await Promise.all(
@@ -5,6 +5,7 @@ import {
order_safety_seal,
reference,
clearStoredPosOrderId,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.vue";
import { metadata } from "./PosDepartmentStepMobileFlow.vue";
@@ -52,9 +53,9 @@ export const persistCurrentMobileOrderMetadata = async () => {
order_notes.value = normalizedNotes;
await Promise.all([
SessionUser.objects.orders.set.reference(normalizedOrderId, normalizedReference),
SessionUser.objects.orders.set.notes(normalizedOrderId, normalizedNotes),
SessionUser.objects.orders.set.safety_seal(normalizedOrderId, normalizedSafetySeal),
saveOrderMetadataField("reference", normalizedReference, normalizedOrderId),
saveOrderMetadataField("notes", normalizedNotes, normalizedOrderId),
saveOrderMetadataField("safety_seal", normalizedSafetySeal, normalizedOrderId),
]);
return normalizedOrderId;
@@ -1,6 +1,13 @@
<script setup lang="ts">
import { ref, onBeforeUnmount, onMounted, watch } from "vue";
import { order_id, reg_1, reg_2, reg_3, getOrderDetails } from "@/components/shop/POSDepartmentProcess.vue";
import {
order_id,
reg_1,
reg_2,
reg_3,
registerPosStepSaveBarrier,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.vue";
import PosDepartmentStepMobile1RegistrationNumberInputField from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumberInputField.vue";
import BarcodeScanner from "@/components/viewport/elements/icons/BarcodeScanner.vue";
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
@@ -103,9 +110,7 @@ const createRegistrationAutosave = (vehicleIndex: number) =>
return value;
}
await SessionUser.objects.orders.set[`reg_${vehicleIndex}`](normalizedOrderId, value);
const refreshedOrder = await getOrderDetails(normalizedOrderId);
return refreshedOrder?.[`reg_${vehicleIndex}`] ?? value;
return saveOrderMetadataField(`reg_${vehicleIndex}`, value, normalizedOrderId);
},
onSaved: async (value) => {
setPersistedRegistrationValue(vehicleIndex, value);
@@ -194,6 +199,11 @@ const flushAllRegistrations = async () => {
await Promise.all([1, 2, 3].map((vehicleIndex) => registrationAutosaves[vehicleIndex].flush()));
};
const waitForAllRegistrationSaves = async () => {
await Promise.all([1, 2, 3].map((vehicleIndex) => registrationAutosaves[vehicleIndex].flushAndWait()));
};
const unregisterStepSaveBarrier = registerPosStepSaveBarrier(waitForAllRegistrationSaves);
// Function to set a value if it is not null
const createVehicleSelectionFromSearchResult = (value: PosSearchResult) => ({
...value,
@@ -266,11 +276,12 @@ onMounted(() => {
});
onBeforeUnmount(() => {
unregisterStepSaveBarrier();
void flushAllRegistrations();
});
const closeManualInput = async () => {
await flushAllRegistrations();
await waitForAllRegistrationSaves();
emit("close");
};
</script>
@@ -8,9 +8,9 @@ const props = defineProps(['label', 'isText']);
const label = computed(() => props.label || t('admin.pos.buttons.go_back'));
// Go to previous step
const goToPreviousStep = () => {
const goToPreviousStep = async () => {
// Get the department id
previousStep();
await previousStep();
}
const emptyIfText = (string) => props.isText ? '' : string;
+201 -59
View File
@@ -52,6 +52,42 @@ export const nextStepDelay = ref(0); // 2 seconds delay
export const isCreatingOrder = ref(false);
export const desktopStep1PreflightHandler = ref(null);
let createOrderRequest = null;
const posStepSaveBarriers = new Set();
let latestOrderDetailsRequestId = 0;
let latestOrderMetadataMutationSequence = 0;
let activeOrderMetadataMutationCount = 0;
export const registerPosStepSaveBarrier = (handler) => {
if (typeof handler !== "function") {
return () => {};
}
posStepSaveBarriers.add(handler);
return () => {
posStepSaveBarriers.delete(handler);
};
};
export const unregisterPosStepSaveBarrier = (handler) => {
posStepSaveBarriers.delete(handler);
};
export const flushPosStepSaveBarriers = async () => {
const barriers = Array.from(posStepSaveBarriers);
if (barriers.length === 0) {
return true;
}
const results = await Promise.allSettled(barriers.map((handler) => handler()));
const failedResult = results.find((result) => result.status === "rejected");
if (failedResult) {
parseError(failedResult.reason, "stepError");
return false;
}
return true;
};
export const setDesktopStep1PreflightHandler = (handler = null) => {
desktopStep1PreflightHandler.value = typeof handler === "function" ? handler : null;
@@ -109,6 +145,10 @@ export const nextStep = async (options = { isMobile: false, orderCreation: true
}
// Set the delay to 2 seconds
setNextStepDelay(2);
if (!(await flushPosStepSaveBarriers())) {
nextStepDelay.value = 0;
return;
}
// Clear the errors
clearErrors();
// If the current step is 1, create the order
@@ -188,8 +228,22 @@ export const nextStep = async (options = { isMobile: false, orderCreation: true
step.value++;
};
export const previousStep = () => {
step.value--;
export const previousStep = async () => {
if (nextStepDelay.value > 0) {
return;
}
if (!(await flushPosStepSaveBarriers())) {
return;
}
step.value = Math.max(1, step.value - 1);
if (order_id.value) {
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=${step.value}`);
} else {
pushPosRouteState(`step=${step.value}`);
}
};
export const getCurrentStep = () => {
@@ -784,6 +838,129 @@ watch([reg_1, reg_2, reg_3], () => {
reg_3.value = normalizeRegistrationValue(reg_3.value);
});
const applyOrderMetadataFieldToPosState = (field, value) => {
if (field === "reference") {
reference.value = normalizeOrderMetadataString(value);
return reference.value;
}
if (field === "notes") {
order_notes.value = normalizeOrderMetadataString(value);
return order_notes.value;
}
if (field === "po") {
order_po.value = normalizeOrderMetadataString(value);
return order_po.value;
}
if (field === "safety_seal") {
order_safety_seal.value = normalizeOrderMetadataString(value);
return order_safety_seal.value;
}
if (field === "reg_1") {
reg_1.value = normalizeRegistrationValue(value);
return reg_1.value;
}
if (field === "reg_2") {
reg_2.value = normalizeRegistrationValue(value);
return reg_2.value;
}
if (field === "reg_3") {
reg_3.value = normalizeRegistrationValue(value);
return reg_3.value;
}
return normalizeOrderMetadataString(value);
};
export const applyOrderDetailsToPosState = (orderData, includes = {}) => {
if (!orderData || typeof orderData !== "object") {
return null;
}
const includedCustomer = includes?.customer ?? null;
const normalizedOrderCustomerNumber = resolveCustomerNumber(orderData?.customer_id);
const selectedCustomerBeforeHydration = getSelectedCustomerNumber();
department_id.value = orderData.department_id;
applyOrderMetadataFieldToPosState("reference", orderData.reference);
applyOrderMetadataFieldToPosState("notes", orderData.notes);
applyOrderMetadataFieldToPosState("po", orderData.po);
applyOrderMetadataFieldToPosState("safety_seal", orderData.safety_seal);
applyOrderMetadataFieldToPosState("reg_1", orderData.reg_1);
applyOrderMetadataFieldToPosState("reg_2", orderData.reg_2);
applyOrderMetadataFieldToPosState("reg_3", orderData.reg_3);
invoiceCollectionId.value = orderData.invoice_collection_id || null;
completed_at.value = orderData.completed_at || null;
if (includedCustomer && typeof includedCustomer === "object") {
const customerSource =
includedCustomer.economic_customer && typeof includedCustomer.economic_customer === "object"
? includedCustomer.economic_customer
: includedCustomer;
selectCustomer(
normalizeCustomerRecord(customerSource, {
...includedCustomer,
customerNumber: normalizedOrderCustomerNumber ?? resolveCustomerNumber(includedCustomer),
name: includedCustomer.name ?? includedCustomer.customer_name ?? orderData.customer_name ?? customer_name.value,
customer_name:
includedCustomer.customer_name ?? includedCustomer.name ?? orderData.customer_name ?? customer_name.value,
})
);
} else if (normalizedOrderCustomerNumber) {
customer_id.value = normalizedOrderCustomerNumber;
if (
selectedCustomerBeforeHydration !== normalizedOrderCustomerNumber ||
customer_name.value === "" ||
!customer_data.value
) {
void searchAndSelectCustomer(normalizedOrderCustomerNumber, { forceRefresh: true });
}
} else {
clearSelectedCustomerState();
}
return orderData;
};
export const saveOrderMetadataField = async (field, value, targetOrderId = order_id.value) => {
const normalizedOrderId = toPositiveInteger(targetOrderId);
const setter = SessionUser.objects.orders.set?.[field];
if (!normalizedOrderId || typeof setter !== "function") {
throw new Error(`Unable to save order field: ${field}`);
}
const mutationSequence = latestOrderMetadataMutationSequence + 1;
latestOrderMetadataMutationSequence = mutationSequence;
activeOrderMetadataMutationCount += 1;
const isRegistrationField = field === "reg_1" || field === "reg_2" || field === "reg_3";
const requestValue = isRegistrationField
? String(value ?? "").toUpperCase()
: normalizeOrderMetadataString(value);
const normalizedValue = isRegistrationField ? normalizeRegistrationValue(value) : requestValue;
try {
const response = await setter(normalizedOrderId, requestValue);
const updatedOrder = response?.data?.data;
if (updatedOrder && toPositiveInteger(updatedOrder.id) === normalizedOrderId) {
if (mutationSequence === latestOrderMetadataMutationSequence && activeOrderMetadataMutationCount === 1) {
applyOrderDetailsToPosState(updatedOrder, response?.data?.includes ?? {});
}
return applyOrderMetadataFieldToPosState(field, updatedOrder[field] ?? normalizedValue);
}
return applyOrderMetadataFieldToPosState(field, normalizedValue);
} finally {
activeOrderMetadataMutationCount = Math.max(0, activeOrderMetadataMutationCount - 1);
}
};
/** Get the order details by order id */
export const getOrderDetails = async (id = null) => {
const token = localStorage.getItem("token");
@@ -791,6 +968,9 @@ export const getOrderDetails = async (id = null) => {
if (!token || !targetOrderId) {
return null;
}
const requestId = ++latestOrderDetailsRequestId;
const metadataMutationSequenceAtRequest = latestOrderMetadataMutationSequence;
const hadActiveMetadataMutationAtRequest = activeOrderMetadataMutationCount > 0;
return axios
.get(API_URL + "/order?id=" + targetOrderId, {
headers: {
@@ -799,54 +979,21 @@ export const getOrderDetails = async (id = null) => {
})
.then((response) => {
const order_data = response.data.data;
const includedCustomer = response?.data?.includes?.customer ?? null;
const normalizedOrderCustomerNumber = resolveCustomerNumber(order_data?.customer_id);
const selectedCustomerBeforeHydration = getSelectedCustomerNumber();
// Set the order details
department_id.value = order_data.department_id;
reference.value = order_data.reference || "";
order_notes.value = order_data.notes || "";
order_po.value = order_data.po || "";
order_safety_seal.value = normalizeOrderMetadataString(order_data.safety_seal);
reg_1.value = order_data.reg_1 || "";
reg_2.value = order_data.reg_2 || "";
reg_3.value = order_data.reg_3 || "";
invoiceCollectionId.value = order_data.invoice_collection_id || null;
completed_at.value = order_data.completed_at || null;
if (!order_data) {
return null;
}
if (includedCustomer && typeof includedCustomer === "object") {
const customerSource =
includedCustomer.economic_customer && typeof includedCustomer.economic_customer === "object"
? includedCustomer.economic_customer
: includedCustomer;
const normalizedResponseOrderId = toPositiveInteger(order_data.id ?? targetOrderId);
const normalizedCurrentOrderId = toPositiveInteger(order_id.value);
const isStaleForDifferentOrder =
requestId !== latestOrderDetailsRequestId &&
normalizedCurrentOrderId &&
normalizedResponseOrderId &&
normalizedCurrentOrderId !== normalizedResponseOrderId;
const isStaleBeforeMetadataMutation = metadataMutationSequenceAtRequest !== latestOrderMetadataMutationSequence;
selectCustomer(
normalizeCustomerRecord(customerSource, {
...includedCustomer,
customerNumber: normalizedOrderCustomerNumber ?? resolveCustomerNumber(includedCustomer),
name:
includedCustomer.name ??
includedCustomer.customer_name ??
order_data.customer_name ??
customer_name.value,
customer_name:
includedCustomer.customer_name ??
includedCustomer.name ??
order_data.customer_name ??
customer_name.value,
})
);
} else if (normalizedOrderCustomerNumber) {
customer_id.value = normalizedOrderCustomerNumber;
if (
selectedCustomerBeforeHydration !== normalizedOrderCustomerNumber ||
customer_name.value === "" ||
!customer_data.value
) {
void searchAndSelectCustomer(normalizedOrderCustomerNumber, { forceRefresh: true });
}
} else {
clearSelectedCustomerState();
if (!isStaleForDifferentOrder && !isStaleBeforeMetadataMutation && !hadActiveMetadataMutationAtRequest) {
applyOrderDetailsToPosState(order_data, response?.data?.includes ?? {});
}
// Return the order data
return order_data;
@@ -1338,7 +1485,7 @@ const copyLastWashReferenceToEmptyCurrentOrder = async (sourceReference) => {
const normalizedOrderId = toPositiveInteger(order_id.value);
if (normalizedOrderId) {
await SessionUser.objects.orders.set.reference(normalizedOrderId, normalizedSourceReference);
await saveOrderMetadataField("reference", normalizedSourceReference, normalizedOrderId);
}
};
@@ -2208,32 +2355,27 @@ export const hydrateSelectedOrderBookingForDesktop = async () => {
const bookingReference = getOrderBookingReferenceValue(booking);
if (bookingReference !== "" && bookingReference !== reference.value) {
await SessionUser.objects.orders.set.reference(normalizedOrderId, bookingReference);
reference.value = bookingReference;
reference.value = await saveOrderMetadataField("reference", bookingReference, normalizedOrderId);
}
const bookingReg1 = getOrderBookingReg1Value(booking);
if (bookingReg1 !== "" && bookingReg1 !== reg_1.value) {
await SessionUser.objects.orders.set.reg_1(normalizedOrderId, bookingReg1);
reg_1.value = bookingReg1;
reg_1.value = await saveOrderMetadataField("reg_1", bookingReg1, normalizedOrderId);
}
const bookingReg2 = getOrderBookingReg2Value(booking);
if (bookingReg2 !== reg_2.value) {
await SessionUser.objects.orders.set.reg_2(normalizedOrderId, bookingReg2);
reg_2.value = bookingReg2;
reg_2.value = await saveOrderMetadataField("reg_2", bookingReg2, normalizedOrderId);
}
const bookingNotes = getOrderBookingNotesValue(booking);
if (bookingNotes !== "" && bookingNotes !== order_notes.value) {
await SessionUser.objects.orders.set.notes(normalizedOrderId, bookingNotes);
order_notes.value = bookingNotes;
order_notes.value = await saveOrderMetadataField("notes", bookingNotes, normalizedOrderId);
}
const bookingPo = String(booking?.po ?? "").trim();
if (bookingPo !== "" && isBlankPosMetadataValue(order_po.value)) {
await SessionUser.objects.orders.set.po(normalizedOrderId, bookingPo);
order_po.value = bookingPo;
order_po.value = await saveOrderMetadataField("po", bookingPo, normalizedOrderId);
}
const currentOrderItems = await loadOrderItems();
+76 -32
View File
@@ -23,10 +23,12 @@ export const useOrderMetadataAutosave = ({
const draft = ref(normalizeValue(resolveSourceValue(source)));
const lastSavedValue = ref(normalizeValue(resolveSourceValue(source)));
const isSaving = ref(false);
const lastError = ref(null);
const isDirty = computed(() => normalizeValue(draft.value) !== normalizeValue(lastSavedValue.value));
let timerId = null;
let saveAgainAfterCurrentRequest = false;
let activeSavePromise = null;
let nextSequence = 0;
let lastAppliedSequence = 0;
@@ -66,45 +68,57 @@ export const useOrderMetadataAutosave = ({
const sequence = ++nextSequence;
let saveCompleted = false;
isSaving.value = true;
lastError.value = null;
try {
const result = await saveValue(requestedValue);
const savedValue = normalizeValue(result ?? requestedValue);
const run = (async () => {
try {
const result = await saveValue(requestedValue);
const savedValue = normalizeValue(result ?? requestedValue);
if (sequence >= lastAppliedSequence) {
lastAppliedSequence = sequence;
lastSavedValue.value = savedValue;
if (sequence >= lastAppliedSequence) {
lastAppliedSequence = sequence;
lastSavedValue.value = savedValue;
if (normalizeValue(draft.value) === requestedValue) {
draft.value = savedValue;
if (normalizeValue(draft.value) === requestedValue) {
draft.value = savedValue;
}
}
if (typeof onSaved === "function") {
await onSaved(savedValue, requestedValue);
}
saveCompleted = true;
return true;
} catch (error) {
lastError.value = error;
if (typeof onError === "function") {
onError(error);
} else {
console.error(error);
}
return false;
} finally {
isSaving.value = false;
const shouldSaveAgain = saveAgainAfterCurrentRequest;
saveAgainAfterCurrentRequest = false;
if (saveCompleted && (shouldSaveAgain || isDirty.value) && isDirty.value) {
void persist();
}
}
})();
if (typeof onSaved === "function") {
await onSaved(savedValue, requestedValue);
const trackedSavePromise = run.finally(() => {
if (activeSavePromise === trackedSavePromise) {
activeSavePromise = null;
}
saveCompleted = true;
return true;
} catch (error) {
if (typeof onError === "function") {
onError(error);
} else {
console.error(error);
}
return false;
} finally {
isSaving.value = false;
const shouldSaveAgain = saveAgainAfterCurrentRequest;
saveAgainAfterCurrentRequest = false;
if (saveCompleted && (shouldSaveAgain || isDirty.value) && isDirty.value) {
void persist();
}
}
});
activeSavePromise = trackedSavePromise;
return trackedSavePromise;
};
const scheduleSave = () => {
@@ -123,6 +137,34 @@ export const useOrderMetadataAutosave = ({
return persist();
};
const flushAndWait = async () => {
clearPendingSave();
while (isSaving.value && activeSavePromise) {
await activeSavePromise;
}
if (!isDirty.value) {
return false;
}
const didSave = await persist();
if (!didSave && isDirty.value) {
throw lastError.value || new Error("Unable to save order metadata");
}
while (isSaving.value && activeSavePromise) {
await activeSavePromise;
}
if (isDirty.value) {
return flushAndWait();
}
return didSave;
};
onBeforeUnmount(() => {
clearPendingSave();
@@ -139,10 +181,12 @@ export const useOrderMetadataAutosave = ({
return {
draft,
lastSavedValue,
lastError,
isDirty,
isSaving,
scheduleSave,
flush,
flushAndWait,
clearPendingSave,
syncFromSource,
};
@@ -50,6 +50,8 @@ function createCardOrderFixture(overrides = {}) {
customer_id: CARD_CUSTOMER_ID,
department_id: DEPARTMENT_ID,
reference: "CARD-REF-54518",
po: "",
safety_seal: "",
notes: "Paid at terminal",
reg_1: "ZZ99999",
reg_2: "",
@@ -236,6 +238,61 @@ test.describe("POS desktop card payments", () => {
await expect(stepThree.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 });
});
test("step 2 customer wishes stay visible after moving to card payment step 3", async ({ page }) => {
const stepTwoReference = "CARD-STEP-2-REF";
const stepTwoPo = "PO-CARD-STEP-2";
const fixture = createCardOrderFixture({
ordersById: {
[ORDER_ID]: {
...createCardOrderFixture().ordersById[ORDER_ID],
reference: "",
po: "",
},
},
});
await mockApi(page, {
authenticated: true,
permissions: DESKTOP_POS_PERMISSIONS,
edgeGateways: false,
pos: fixture,
});
await primeSession(page, `desktop-card-step2-${Date.now()}`);
await page.goto(`/admin/${DEPARTMENT_ID}/modules/pos?id=${ORDER_ID}&customer_id=${CARD_CUSTOMER_ID}&step=2`);
const stepTwo = page.getByTestId("pos-step-2");
await expect(stepTwo).toBeVisible({ timeout: 10_000 });
await expect(stepTwo.getByTestId("pos-order-metadata-grid")).toBeVisible({ timeout: 10_000 });
await stepTwo.getByTestId("pos-order-customer-wishes-reference").click();
await stepTwo.getByTestId("pos-order-customer-wishes-reference-input").fill(stepTwoReference);
await stepTwo.getByTestId("pos-order-customer-wishes-po").click();
await stepTwo.getByTestId("pos-order-customer-wishes-po-input").fill(stepTwoPo);
await stepTwo.getByTestId("pos-next-step").click();
const stepThree = page.getByTestId("pos-step-3");
await expect(stepThree).toBeVisible({ timeout: 10_000 });
await expect(stepThree.getByTestId("pos-order-customer-wishes-reference")).toContainText(stepTwoReference, {
timeout: 10_000,
});
await expect(stepThree.getByTestId("pos-order-customer-wishes-po")).toContainText(stepTwoPo, {
timeout: 10_000,
});
await expect.poll(() => fixture.ordersById[ORDER_ID].reference, { timeout: 10_000 }).toBe(stepTwoReference);
await expect.poll(() => fixture.ordersById[ORDER_ID].po, { timeout: 10_000 }).toBe(stepTwoPo);
await page.reload();
const reloadedStepThree = page.getByTestId("pos-step-3");
await expect(reloadedStepThree).toBeVisible({ timeout: 10_000 });
await expect(reloadedStepThree.getByTestId("pos-order-customer-wishes-reference")).toContainText(stepTwoReference, {
timeout: 10_000,
});
await expect(reloadedStepThree.getByTestId("pos-order-customer-wishes-po")).toContainText(stepTwoPo, {
timeout: 10_000,
});
});
test("idle state renders grouped terminal statuses and only keeps ready terminals selectable", async ({ page }) => {
const fixture = createCardOrderFixture({
readers: [
+123
View File
@@ -93,6 +93,8 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
reference: vi.fn(),
reg_1: vi.fn(),
reg_2: vi.fn(),
reg_3: vi.fn(),
safety_seal: vi.fn(),
},
},
order_bookings: {
@@ -152,6 +154,8 @@ import {
getVehiclePlateBookings,
invoiceCollectionId,
loadOrderItems,
nextStep,
nextStepDelay,
order_id,
order_items,
hydrateSelectedOrderBookingForDesktop,
@@ -163,7 +167,10 @@ import {
reg_2,
reg_3,
pendingBookings,
previousStep,
registerPosStepSaveBarrier,
restoreStoredPosOrderId,
saveOrderMetadataField,
searchAndSelectCustomer,
setOrderId,
scan_data,
@@ -172,6 +179,122 @@ import {
step,
} from "@/components/shop/POSDepartmentProcess.vue";
describe("POSDepartmentProcess order metadata persistence", () => {
beforeEach(() => {
window.history.replaceState({}, "", "/admin/12/modules/pos?step=2");
clearActivePosOrderContext({ clearStep: true });
localStorage.setItem("token", "test-token");
nextStepDelay.value = 0;
order_id.value = 54518;
customer_id.value = 999;
customer_name.value = "Card Terminal Customer";
customer_data.value = {};
department_id.value = 12;
step.value = 2;
Object.values(SessionUser.objects.orders.set).forEach((setter) => setter.mockReset?.());
});
it("applies saved metadata responses to shared POS state", async () => {
SessionUser.objects.orders.set.reference.mockResolvedValueOnce({
data: {
data: {
id: 54518,
customer_id: 999,
department_id: 12,
reference: "STEP-2-REF",
notes: "Visible note",
po: "PO-STEP-2",
safety_seal: "SEAL-1",
reg_1: "aa 12345",
reg_2: "",
reg_3: "",
invoice_collection_id: 200,
completed_at: null,
},
},
});
await expect(saveOrderMetadataField("reference", "STEP-2-REF", 54518)).resolves.toBe("STEP-2-REF");
expect(SessionUser.objects.orders.set.reference).toHaveBeenCalledWith(54518, "STEP-2-REF");
expect(reference.value).toBe("STEP-2-REF");
expect(order_notes.value).toBe("Visible note");
expect(order_po.value).toBe("PO-STEP-2");
expect(order_safety_seal.value).toBe("SEAL-1");
expect(reg_1.value).toBe("AA12345");
});
it("preserves registration input formatting for the save request and normalizes shared state", async () => {
SessionUser.objects.orders.set.reg_1.mockResolvedValueOnce({
data: {
data: {
id: 54518,
reg_1: "CD1234",
},
},
});
await expect(saveOrderMetadataField("reg_1", "cd-12 34", 54518)).resolves.toBe("CD1234");
expect(SessionUser.objects.orders.set.reg_1).toHaveBeenCalledWith(54518, "CD-12 34");
expect(reg_1.value).toBe("CD1234");
});
it("waits for registered field saves before moving from step 2 to step 3", async () => {
let resolveBarrier;
const barrier = vi.fn(
() =>
new Promise((resolve) => {
resolveBarrier = () => {
reference.value = "BARRIER-SAVED";
resolve();
};
})
);
const unregister = registerPosStepSaveBarrier(barrier);
try {
const moveToNextStep = nextStep({ isMobile: false, orderCreation: false });
await Promise.resolve();
expect(barrier).toHaveBeenCalledTimes(1);
expect(window.location.search).toBe("?step=2");
resolveBarrier();
await moveToNextStep;
expect(reference.value).toBe("BARRIER-SAVED");
expect(window.location.search).toContain("step=3");
expect(window.location.search).toContain("id=54518");
expect(window.location.search).toContain("customer_id=999");
} finally {
unregister();
nextStepDelay.value = 0;
}
});
it("waits for registered field saves before moving backward", async () => {
step.value = 3;
window.history.replaceState({}, "", "/admin/12/modules/pos?id=54518&customer_id=999&step=3");
const barrier = vi.fn(async () => {
order_po.value = "PO-BACK-SAVED";
});
const unregister = registerPosStepSaveBarrier(barrier);
try {
await previousStep();
expect(barrier).toHaveBeenCalledTimes(1);
expect(order_po.value).toBe("PO-BACK-SAVED");
expect(step.value).toBe(2);
expect(window.location.search).toContain("step=2");
} finally {
unregister();
nextStepDelay.value = 0;
}
});
});
describe("POSDepartmentProcess.ensureVehiclePlateBookingsLoaded", () => {
beforeEach(() => {
department_id.value = 531;
@@ -85,4 +85,59 @@ describe("useOrderMetadataAutosave", () => {
await Promise.resolve();
wrapper.unmount();
});
it("flushAndWait waits for an in-flight save and persists the queued latest draft", async () => {
let resolveFirstSave;
const saveValue = vi.fn((value) => {
if (value === "first") {
return new Promise((resolve) => {
resolveFirstSave = () => resolve(value);
});
}
return Promise.resolve(value);
});
const { autosave, wrapper } = mountAutosave({ saveValue });
autosave.draft.value = "first";
const firstFlush = autosave.flush();
autosave.draft.value = "second";
const waitForSaves = autosave.flushAndWait();
await Promise.resolve();
expect(saveValue).toHaveBeenCalledTimes(1);
expect(saveValue).toHaveBeenNthCalledWith(1, "first");
resolveFirstSave();
await expect(firstFlush).resolves.toBe(true);
await expect(waitForSaves).resolves.toBe(false);
expect(saveValue).toHaveBeenCalledTimes(2);
expect(saveValue).toHaveBeenNthCalledWith(2, "second");
expect(autosave.lastSavedValue.value).toBe("second");
expect(autosave.isDirty.value).toBe(false);
wrapper.unmount();
});
it("flushAndWait rejects when the dirty draft cannot be saved", async () => {
const saveError = new Error("server rejected metadata");
const saveValue = vi.fn(async () => {
throw saveError;
});
const { autosave, onError, wrapper } = mountAutosave({ saveValue });
autosave.draft.value = "rejected";
await expect(autosave.flushAndWait()).rejects.toThrow("server rejected metadata");
expect(saveValue).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(saveError);
expect(autosave.lastError.value).toBe(saveError);
expect(autosave.isDirty.value).toBe(true);
autosave.syncFromSource("rejected");
wrapper.unmount();
});
});