diff --git a/src/components/forms/department/pos/SelectVehicleFormPOS.vue b/src/components/forms/department/pos/SelectVehicleFormPOS.vue index b15c4162..5f0dfa07 100644 --- a/src/components/forms/department/pos/SelectVehicleFormPOS.vue +++ b/src/components/forms/department/pos/SelectVehicleFormPOS.vue @@ -217,6 +217,44 @@ const getVehicleReferenceValue = () => { return String(vehicleObject.value?.reference ?? "").trim(); }; +const getVehicleStateKey = (vehicle) => { + if (!vehicle) { + return ""; + } + + return [ + vehicle?.id ?? "", + normalizePlateValue(vehicle?.reg), + normalizeCustomerNumber(vehicle?.customer_id) ?? "", + vehicle?.type ?? "", + vehicle?.booking_id ?? "", + String(vehicle?.reference ?? "").trim(), + ].join("|"); +}; + +const getBookingStateKey = (booking) => { + if (!booking) { + return ""; + } + + return [ + booking?.id ?? "", + getBookingReg1Value(booking), + getBookingReg2Value(booking), + getBookingCustomerNumber(booking) ?? "", + String(booking?.reference_number ?? booking?.reference ?? "").trim(), + String(booking?.po ?? "").trim(), + ].join("|"); +}; + +const getBookingMatchesStateKey = (matches) => { + if (!Array.isArray(matches) || matches.length === 0) { + return ""; + } + + return matches.map((booking) => getBookingStateKey(booking)).join("||"); +}; + const setVehicleObject = (emittedVehicleObject, options = {}) => { const normalizedOptions = { preserveManualReference: true, @@ -224,17 +262,26 @@ const setVehicleObject = (emittedVehicleObject, options = {}) => { ...options, }; - vehicleObject.value = emittedVehicleObject; - - const emittedVehiclePlate = normalizePlateValue(vehicleObject.value?.reg || reg_1.value); + const emittedVehiclePlate = normalizePlateValue(emittedVehicleObject?.reg || reg_1.value); if ( emittedVehiclePlate && skippedDesktopBookingVehiclePlate.value === emittedVehiclePlate && - !isBookingMarkedVehicle(vehicleObject.value) + !isBookingMarkedVehicle(emittedVehicleObject) ) { skippedDesktopBookingVehiclePlate.value = ""; } + const currentVehicleKey = getVehicleStateKey(vehicleObject.value); + const nextVehicleKey = getVehicleStateKey(emittedVehicleObject); + if (currentVehicleKey === nextVehicleKey) { + if (normalizedOptions.nextSelectionSource) { + setSelectionSource(normalizedOptions.nextSelectionSource); + } + return; + } + + vehicleObject.value = emittedVehicleObject; + const matchedVehicleCustomerNumber = normalizeCustomerNumber(vehicleObject.value?.customer_id); const selectedCustomerNumber = normalizeCustomerNumber(customer_id.value); @@ -274,6 +321,10 @@ const setBookingObject = (emittedBookingObject, options = {}) => { ...options, }; + if (getBookingStateKey(bookingObject.value) === getBookingStateKey(emittedBookingObject)) { + return; + } + bookingObject.value = emittedBookingObject; if (emittedBookingObject && emittedBookingObject.reference_number) { @@ -284,7 +335,12 @@ const setBookingObject = (emittedBookingObject, options = {}) => { }; const setBookingMatches = (emittedBookingMatches) => { - bookingMatches.value = Array.isArray(emittedBookingMatches) ? emittedBookingMatches : []; + const nextBookingMatches = Array.isArray(emittedBookingMatches) ? emittedBookingMatches : []; + if (getBookingMatchesStateKey(bookingMatches.value) === getBookingMatchesStateKey(nextBookingMatches)) { + return; + } + + bookingMatches.value = nextBookingMatches; }; const mergeBookingMatchDetails = (booking) => { diff --git a/src/components/forms/department/pos/input/LicensePlateReg1Input.vue b/src/components/forms/department/pos/input/LicensePlateReg1Input.vue index 769d79fe..ef31825d 100644 --- a/src/components/forms/department/pos/input/LicensePlateReg1Input.vue +++ b/src/components/forms/department/pos/input/LicensePlateReg1Input.vue @@ -416,6 +416,21 @@ const shouldPreserveCustomerSelection = (plateValue) => { }; let pendingVehicleCustomerLookup = null; +const lastAutoSyncedVehicleKey = ref(""); + +const getVehicleAutoSyncKey = (vehicle, plateOverride = null) => { + if (!vehicle) { + return ""; + } + + return [ + resolveBookingPlate(vehicle, plateOverride), + vehicle?.id ?? "", + normalizeCustomerNumber(vehicle?.customer_id) ?? "", + normalizeCustomerNumber(customer_id.value) ?? "", + ].join("|"); +}; + const syncMatchedVehicleSelection = (vehicle, options = {}) => { const normalizedOptions = { plateOverride: null, @@ -590,6 +605,7 @@ watch(reg_1, (newValue) => { // Define the search ID for this input change const search_id = register_new_search(); console.log("reg_1 changed:", newValue); + lastAutoSyncedVehicleKey.value = ""; const shouldKeepCustomerSelection = shouldPreserveCustomerSelection(newValue); selectedDropdownItem.value = -1; // Check if the new value is empty, if so, clear the vehicles_matching array @@ -847,11 +863,17 @@ watch(vehicles_matching, (newValue) => { const currentValue = reg_1.value; const vehicle = newValue.find((vehicle) => vehicle.reg === currentValue); if (vehicle) { - syncMatchedVehicleSelection(vehicle); + const nextAutoSyncKey = getVehicleAutoSyncKey(vehicle, currentValue); + if (lastAutoSyncedVehicleKey.value !== nextAutoSyncKey) { + lastAutoSyncedVehicleKey.value = nextAutoSyncKey; + syncMatchedVehicleSelection(vehicle); + } } else if (getBookingMatchesForSelection(null, currentValue).length > 0) { + lastAutoSyncedVehicleKey.value = ""; clearCustomerConflict(); emitVehicleObject(null, currentValue); } else { + lastAutoSyncedVehicleKey.value = ""; clearCustomerConflict(); } // Check if the selectedDropdownItem index is valid diff --git a/src/components/shop/POSDepartmentProcess.vue b/src/components/shop/POSDepartmentProcess.vue index 71ab874a..9960a36f 100644 --- a/src/components/shop/POSDepartmentProcess.vue +++ b/src/components/shop/POSDepartmentProcess.vue @@ -1188,6 +1188,7 @@ export const selectScan = (scan) => { }; const cached_customer_names = []; +const activeCustomerSelectionRequests = new Map(); /** Get the customer's name */ export const getCustomerName = async (customerNumber) => { @@ -1216,23 +1217,62 @@ export const getCustomerName = async (customerNumber) => { /** Search, then select customer by customer number */ export const searchAndSelectCustomer = async (customerNumber, options = {}) => { + const normalizedOptions = { + forceRefresh: false, + ...options, + }; + const normalizedCustomerNumber = resolveCustomerNumber(customerNumber); + if (!normalizedCustomerNumber) { + return null; + } + + const selectedCustomerNumber = getSelectedCustomerNumber(); + const hasSelectedCustomerData = + customer_data.value && + typeof customer_data.value === "object" && + !Array.isArray(customer_data.value) && + Object.keys(customer_data.value).length > 0; + + if ( + !normalizedOptions.forceRefresh && + selectedCustomerNumber === normalizedCustomerNumber && + hasSelectedCustomerData + ) { + return customer_data.value; + } + + if (!normalizedOptions.forceRefresh && activeCustomerSelectionRequests.has(normalizedCustomerNumber)) { + return await activeCustomerSelectionRequests.get(normalizedCustomerNumber); + } + // Get the customer data - return await authenticatedRequest(`/users/customer?customer_number=${customerNumber}`, "GET") + const request = authenticatedRequest(`/users/customer?customer_number=${normalizedCustomerNumber}`, "GET") .then((response) => { console.log(response); const responseData = response?.data?.data ?? {}; const rawEconomicCustomer = responseData.economic_customer ?? null; const normalizedCustomer = normalizeCustomerRecord(rawEconomicCustomer, { ...responseData, - customerNumber: resolveCustomerNumber(rawEconomicCustomer) ?? resolveCustomerNumber(customerNumber), + customerNumber: resolveCustomerNumber(rawEconomicCustomer) ?? normalizedCustomerNumber, }); - selectCustomer(normalizedCustomer, options); + selectCustomer(normalizedCustomer, normalizedOptions); return normalizedCustomer; }) .catch((error) => { console.error(error); return null; + }) + .finally(() => { + if (activeCustomerSelectionRequests.get(normalizedCustomerNumber) === request) { + activeCustomerSelectionRequests.delete(normalizedCustomerNumber); + } }); + + if (!normalizedOptions.forceRefresh) { + activeCustomerSelectionRequests.set(normalizedCustomerNumber, request); + } + + return await request; }; /** Get order items */ diff --git a/tests/unit/pos-department-process.spec.js b/tests/unit/pos-department-process.spec.js index e22b65e9..99b4fc24 100644 --- a/tests/unit/pos-department-process.spec.js +++ b/tests/unit/pos-department-process.spec.js @@ -116,12 +116,17 @@ vi.mock("sweetalert2", () => ({ })); import axios from "axios"; +import { getAttributes } from "@/components/shop/CustomerAttributes.vue"; +import { getNotes } from "@/components/shop/CustomerNotes.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue"; +import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue"; import { clearActivePosOrderContext, + clearCustomerSelection, clearStoredPosOrderId, createOrder, completed_at, + customer_data, customer_id, customer_name, department_id, @@ -137,6 +142,7 @@ import { reg_2, reg_3, restoreStoredPosOrderId, + searchAndSelectCustomer, scan_data, scans, step, @@ -192,6 +198,77 @@ describe("POSDepartmentProcess.loadOrderItems", () => { }); }); +describe("POSDepartmentProcess.searchAndSelectCustomer", () => { + beforeEach(() => { + clearCustomerSelection(); + authenticatedRequest.mockReset(); + getNotes.mockReset(); + getNotes.mockResolvedValue({ data: { data: [] } }); + getAttributes.mockReset(); + getAttributes.mockResolvedValue({ data: { success: true, data: [] } }); + }); + + const mockCustomerResponse = (customerNumber = 12345679, name = "Pleno Logistics") => ({ + data: { + data: { + customer_number: customerNumber, + customer_name: name, + economic_customer: { + customerNumber, + name, + barred: false, + }, + }, + }, + }); + + it("returns the already selected customer without a duplicate lookup", async () => { + authenticatedRequest.mockResolvedValueOnce(mockCustomerResponse()); + + const selectedCustomer = await searchAndSelectCustomer(12345679); + const cachedCustomer = await searchAndSelectCustomer(12345679); + + expect(authenticatedRequest).toHaveBeenCalledTimes(1); + expect(authenticatedRequest).toHaveBeenCalledWith("/users/customer?customer_number=12345679", "GET"); + expect(cachedCustomer).toBe(customer_data.value); + expect(cachedCustomer).toEqual(selectedCustomer); + expect(customer_id.value).toBe(12345679); + expect(customer_name.value).toBe("Pleno Logistics"); + }); + + it("shares concurrent lookups for the same customer number", async () => { + let resolveRequest; + authenticatedRequest.mockReturnValueOnce( + new Promise((resolve) => { + resolveRequest = resolve; + }) + ); + + const firstLookup = searchAndSelectCustomer(12345679); + const secondLookup = searchAndSelectCustomer(12345679); + resolveRequest(mockCustomerResponse()); + + const [firstCustomer, secondCustomer] = await Promise.all([firstLookup, secondLookup]); + + expect(authenticatedRequest).toHaveBeenCalledTimes(1); + expect(firstCustomer).toEqual(secondCustomer); + expect(customer_id.value).toBe(12345679); + }); + + it("performs a new lookup when forceRefresh is requested", async () => { + authenticatedRequest + .mockResolvedValueOnce(mockCustomerResponse(12345679, "Pleno Logistics")) + .mockResolvedValueOnce(mockCustomerResponse(12345679, "Pleno Logistics Updated")); + + await searchAndSelectCustomer(12345679); + const refreshedCustomer = await searchAndSelectCustomer(12345679, { forceRefresh: true }); + + expect(authenticatedRequest).toHaveBeenCalledTimes(2); + expect(refreshedCustomer.name).toBe("Pleno Logistics Updated"); + expect(customer_name.value).toBe("Pleno Logistics Updated"); + }); +}); + describe("POSDepartmentProcess.clearActivePosOrderContext", () => { it("clears active order, customer, metadata and stored mobile order id", () => { localStorage.setItem("pos_order_id", "51211"); diff --git a/tests/unit/select-vehicle-form-pos.spec.js b/tests/unit/select-vehicle-form-pos.spec.js index b900cfa4..e8d536e0 100644 --- a/tests/unit/select-vehicle-form-pos.spec.js +++ b/tests/unit/select-vehicle-form-pos.spec.js @@ -1,5 +1,6 @@ // @vitest-environment jsdom import { nextTick, ref } from "vue"; +import { flushPromises } from "@vue/test-utils"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { mountWithApp } from "./helpers/mountWithApp.js"; @@ -223,9 +224,17 @@ const LicensePlateReg1InputStub = { emitLinkedVehicle() { this.$emit("update:vehicleObject", { ...linkedVehicle }); }, + emitLinkedVehicleTwice() { + this.$emit("update:vehicleObject", { ...linkedVehicle }); + this.$emit("update:vehicleObject", { ...linkedVehicle }); + }, emitBookedVehicle() { this.$emit("update:vehicleObject", { ...bookedVehicle }); }, + emitLinkedBookingMatchesTwice() { + this.$emit("update:bookingMatches", [{ ...bookingWithItems }]); + this.$emit("update:bookingMatches", [{ ...bookingWithItems }]); + }, }, template: `
@@ -243,6 +252,13 @@ const LicensePlateReg1InputStub = { > Emit linked vehicle + +
`, }; @@ -422,6 +445,27 @@ describe("SelectVehicleFormPOS", () => { expect(wrapper.findAll('[data-testid="customer-search-card-payment-stub"]')).toHaveLength(2); }); + it("ignores repeated identical linked vehicle emissions", async () => { + const wrapper = mountForm(); + + await wrapper.get('[data-testid="emit-linked-vehicle-twice"]').trigger("click"); + await nextTick(); + + expect(wrapper.findAll('[data-testid="customer-search-field-pos-stub"]')).toHaveLength(0); + expect(posState.searchAndSelectCustomer).not.toHaveBeenCalled(); + }); + + it("does not re-apply identical booking matches", async () => { + const wrapper = mountForm(); + + await wrapper.get('[data-testid="emit-linked-booking-matches-twice"]').trigger("click"); + await flushPromises(); + + expect(posState.setSelectedOrderBookingSelection).toHaveBeenCalledTimes(1); + expect(posState.searchAndSelectCustomer).toHaveBeenCalledTimes(1); + expect(posState.searchAndSelectCustomer).toHaveBeenCalledWith(12345679); + }); + it("hides a booked desktop vehicle summary after continuing without booking", async () => { const wrapper = mountForm();