Fix POS step 1 customer lookup loop

This commit is contained in:
Jeppe B
2026-06-01 12:41:08 +02:00
parent 4c9ba7c30d
commit 683fce32da
5 changed files with 248 additions and 9 deletions
@@ -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) => {
@@ -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
+43 -3
View File
@@ -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 */
+77
View File
@@ -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");
@@ -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: `
<div>
@@ -243,6 +252,13 @@ const LicensePlateReg1InputStub = {
>
Emit linked vehicle
</button>
<button
type="button"
data-testid="emit-linked-vehicle-twice"
@click="emitLinkedVehicleTwice"
>
Emit linked vehicle twice
</button>
<button
type="button"
data-testid="emit-booked-vehicle"
@@ -250,6 +266,13 @@ const LicensePlateReg1InputStub = {
>
Emit booked vehicle
</button>
<button
type="button"
data-testid="emit-linked-booking-matches-twice"
@click="emitLinkedBookingMatchesTwice"
>
Emit linked booking matches twice
</button>
</div>
`,
};
@@ -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();