From a93b0dc9545ece6c4e927587e024b02f690d218e Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 13 Apr 2026 21:53:16 +0200 Subject: [PATCH] Add tests and utilities for dynamic booking preload handling in POS flow: - Introduced logic and utilities to fetch exact plate bookings beyond initial preload (`ensureVehiclePlateBookingsLoaded`, `mergePendingBookings`). - Enhanced booking dropdown and chooser to handle dynamically loaded data for desktop POS. - Updated e2e tests (`pos-flow.spec.js`) with scenarios for exact and partial plate matches in booking preload handling. - Refactored `network.js` to support paginated and filtered responses for bookings based on registration fields. - Improved UX and display logic in booking selectors (`LicensePlateReg1Input.vue`, `PosDepartmentProcess.vue`). --- .../pos/steps/PosDepartmentStep1.vue | 20 +-- .../pos/input/LicensePlateReg1Input.vue | 29 ++++- src/components/shop/POSDepartmentProcess.vue | 71 +++++++++++ tests/e2e/pos-flow.spec.js | 120 ++++++++++++++++++ tests/e2e/support/network.js | 12 ++ 5 files changed, 236 insertions(+), 16 deletions(-) diff --git a/src/components/displays/department/pos/steps/PosDepartmentStep1.vue b/src/components/displays/department/pos/steps/PosDepartmentStep1.vue index def3348d..a510df9f 100644 --- a/src/components/displays/department/pos/steps/PosDepartmentStep1.vue +++ b/src/components/displays/department/pos/steps/PosDepartmentStep1.vue @@ -25,6 +25,10 @@ import DefaultObjectSelector from "@/components/displays/modals/DefaultObjectSel import PosDesktopOrderBookingSelectorModal from "@/components/displays/department/pos/steps/elements/PosDesktopOrderBookingSelectorModal.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { parsePosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js"; +import { + getOrderBookingReferenceValue, + getOrderBookingServiceText, +} from "@/components/displays/department/pos/utils/orderBookingDisplay.js"; const { t, locale } = useI18n(); @@ -179,18 +183,6 @@ const formatBookingDateTime = (booking) => { }).format(parsedValue); }; -const getBookingReferenceValue = (booking) => { - return String(booking?.reference ?? booking?.reference_number ?? "").trim(); -}; - -const getBookingServicesValue = (booking) => { - if (Array.isArray(booking?.parsed_services?.array) && booking.parsed_services.array.length > 0) { - return booking.parsed_services.array.join(", "); - } - - return String(booking?.parsed_services?.string ?? booking?.wash_type ?? "").trim(); -}; - const formatDuplicateOrderDate = (value) => { if (!value) { return t("admin.pos.not_found"); @@ -221,8 +213,8 @@ const bookingSelectionObjects = computed(() => { const contentSegments = [ `${t("admin.pos.order_booking_selector.customer_label")}: ${booking?.customer_name || t("admin.pos.not_found")}`, `${t("admin.pos.order_booking_selector.plates_label")}: ${plateText || t("admin.pos.not_found")}`, - `${t("admin.pos.order_booking_selector.reference_label")}: ${getBookingReferenceValue(booking) || t("admin.pos.not_found")}`, - `${t("admin.pos.order_booking_selector.services_label")}: ${getBookingServicesValue(booking) || t("admin.pos.not_found")}`, + `${t("admin.pos.order_booking_selector.reference_label")}: ${getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")}`, + `${t("admin.pos.order_booking_selector.services_label")}: ${getOrderBookingServiceText(booking) || t("admin.pos.not_found")}`, ]; return { diff --git a/src/components/forms/department/pos/input/LicensePlateReg1Input.vue b/src/components/forms/department/pos/input/LicensePlateReg1Input.vue index cb58430d..436bd35c 100644 --- a/src/components/forms/department/pos/input/LicensePlateReg1Input.vue +++ b/src/components/forms/department/pos/input/LicensePlateReg1Input.vue @@ -12,6 +12,7 @@ import { pendingBookings, hasLoadedPendingBookings, loadPendingBookings, + ensureVehiclePlateBookingsLoaded, doesVehiclePlateHaveBooking, getVehiclePlateBookings, getPreferredVehiclePlateBooking, @@ -144,16 +145,39 @@ const getBookingMatchesForSelection = (vehicle = null, plateOverride = null) => }; const ensureBookingMatchesForSelection = async (vehicle = null, plateOverride = null) => { - let bookingMatches = getBookingMatchesForSelection(vehicle, plateOverride); + const bookingPlate = resolveBookingPlate(vehicle, plateOverride); + let bookingMatches = getBookingMatchesForSelection(vehicle, bookingPlate); if (!hasLoadedPendingBookings.value || (bookingMatches.length === 0 && vehicleIndicatesBooking(vehicle))) { await loadPendingBookings(); - bookingMatches = getBookingMatchesForSelection(vehicle, plateOverride); + bookingMatches = getBookingMatchesForSelection(vehicle, bookingPlate); + } + + if (bookingPlate && vehicleIndicatesBooking(vehicle)) { + await ensureVehiclePlateBookingsLoaded(bookingPlate, { + force: bookingMatches.length <= 1, + }); + bookingMatches = getBookingMatchesForSelection(vehicle, bookingPlate); } return bookingMatches; }; +const prefetchVisibleBookedVehicleBookings = (vehicles = []) => { + vehicles + .filter((vehicle) => vehicleIndicatesBooking(vehicle)) + .forEach((vehicle) => { + const bookingPlate = resolveBookingPlate(vehicle, vehicle?.reg); + if (!bookingPlate) { + return; + } + + void ensureVehiclePlateBookingsLoaded(bookingPlate, { + force: getBookingMatchesForSelection(vehicle, bookingPlate).length <= 1, + }); + }); +}; + // Function to emit the booking object to the parent component const emitBookingObject = (vehicle, plateOverride = null) => { const bookingMatches = getBookingMatchesForSelection(vehicle, plateOverride); @@ -636,6 +660,7 @@ const isSearching = ref(false); // Watch for changes in the vehicles_matching array, to check if the current index is valid watch(vehicles_matching, (newValue) => { + prefetchVisibleBookedVehicleBookings(newValue); // Check if the input directly matches an item in the vehicles_matching array (Then we can automatically select it) const currentValue = reg_1.value; const vehicle = newValue.find((vehicle) => vehicle.reg === currentValue); diff --git a/src/components/shop/POSDepartmentProcess.vue b/src/components/shop/POSDepartmentProcess.vue index 2ac4377b..862ac461 100644 --- a/src/components/shop/POSDepartmentProcess.vue +++ b/src/components/shop/POSDepartmentProcess.vue @@ -1398,6 +1398,9 @@ export const pendingBookings = ref([]); export const hasLoadedPendingBookings = ref(false); export const isLoadingPendingBookings = ref(false); let pendingBookingsRequest = null; +const pendingPlateBookingRequests = new Map(); +const loadedPendingBookingPlates = new Set(); +let pendingBookingsDepartmentKey = null; export const is_latest_search = (search_id) => { // Check if the search ID is the latest @@ -1406,6 +1409,15 @@ export const is_latest_search = (search_id) => { // Get the department booking list export const loadPendingBookings = () => { + const currentDepartmentKey = String(department_id.value ?? ""); + if (pendingBookingsDepartmentKey !== currentDepartmentKey) { + pendingBookingsDepartmentKey = currentDepartmentKey; + pendingBookings.value = []; + hasLoadedPendingBookings.value = false; + pendingPlateBookingRequests.clear(); + loadedPendingBookingPlates.clear(); + } + if (pendingBookingsRequest) { return pendingBookingsRequest; } @@ -1513,6 +1525,12 @@ const dedupeOrderBookings = (bookings = []) => { return Array.from(bookingsByKey.values()); }; +const mergePendingBookings = (bookings = []) => { + const mergedBookings = dedupeOrderBookings([...(pendingBookings.value || []), ...(bookings || [])]); + pendingBookings.value = sortPendingOrderBookings(mergedBookings); + return pendingBookings.value; +}; + const resolveVehiclePlateBookingMatches = (vehiclePlate, bookingMatches = null) => { if (Array.isArray(bookingMatches)) { return sortPendingOrderBookings(dedupeOrderBookings(bookingMatches)); @@ -1553,6 +1571,59 @@ export const getVehiclePlateBookings = (vehiclePlate) => { return sortPendingOrderBookings(matchingBookings); }; +export const ensureVehiclePlateBookingsLoaded = async (vehiclePlate, options = {}) => { + const normalizedOptions = { + force: false, + ...options, + }; + const normalizedVehiclePlate = normalizeVehiclePlateForBookingSelection(vehiclePlate); + if (!normalizedVehiclePlate || !department_id.value) { + return []; + } + + if (!normalizedOptions.force && loadedPendingBookingPlates.has(normalizedVehiclePlate)) { + return getVehiclePlateBookings(normalizedVehiclePlate); + } + + if (pendingPlateBookingRequests.has(normalizedVehiclePlate)) { + return pendingPlateBookingRequests.get(normalizedVehiclePlate); + } + + const fetchBookingsForField = async (field) => { + const filters = [`department:${department_id.value}`, `${field}:${normalizedVehiclePlate}`, "order_id:is null"].join( + "," + ); + const response = await SessionUser.request(SessionUser.objects.order_bookings.meta.endpoint, "GET", { + filters, + page: 1, + limit: 250, + }); + return Array.isArray(response?.data?.data) ? response.data.data : []; + }; + + const requestPromise = Promise.all([ + fetchBookingsForField("reg_1"), + fetchBookingsForField("reg_2"), + ]) + .then(([reg1Bookings, reg2Bookings]) => { + const fetchedBookings = sortPendingOrderBookings(dedupeOrderBookings([...reg1Bookings, ...reg2Bookings])); + mergePendingBookings(fetchedBookings); + loadedPendingBookingPlates.add(normalizedVehiclePlate); + return getVehiclePlateBookings(normalizedVehiclePlate); + }) + .catch((error) => { + console.error("Unable to load pending bookings for plate:", normalizedVehiclePlate, error); + loadedPendingBookingPlates.add(normalizedVehiclePlate); + return getVehiclePlateBookings(normalizedVehiclePlate); + }) + .finally(() => { + pendingPlateBookingRequests.delete(normalizedVehiclePlate); + }); + + pendingPlateBookingRequests.set(normalizedVehiclePlate, requestPromise); + return requestPromise; +}; + export const doesVehiclePlateHaveBooking = (vehiclePlate) => { return getVehiclePlateBookings(vehiclePlate).length > 0; }; diff --git a/tests/e2e/pos-flow.spec.js b/tests/e2e/pos-flow.spec.js index a947938e..a0b8378e 100644 --- a/tests/e2e/pos-flow.spec.js +++ b/tests/e2e/pos-flow.spec.js @@ -1098,6 +1098,126 @@ test.describe("POS flow", () => { ).toBe(true); }); + test("desktop hydrates booked dropdown dates and chooser from exact plate bookings beyond the initial preload", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "Desktop POS booking flow is validated on chromium-desktop." + ); + + const fixture = createPosFixture(); + fixture.vehicles = [ + { + ...fixture.vehicles[0], + id: 7101, + reg: "EC21233", + booking_id: 9101, + status: "booked", + }, + { + ...fixture.vehicles[0], + id: 7102, + reg: "EC21234", + booking_id: 9102, + status: "booked", + }, + ]; + fixture.orderBookings = [ + ...Array.from({ length: 100 }, (_, index) => + buildOrderBooking(9200 + index, { + reg_1: `FILL${String(index + 1).padStart(4, "0")}`, + datetime: `2026-01-${String((index % 28) + 1).padStart(2, "0")}T06:00:00.000Z`, + }) + ), + buildOrderBooking(9102, { + reg_1: "EC21234", + datetime: "2026-03-20T08:00:00.000Z", + reference: "PRELOAD-MISS-A", + reference_number: "PRELOAD-MISS-A", + }), + buildOrderBooking(9103, { + reg_1: "EC21234", + datetime: "2026-03-21T09:00:00.000Z", + reference: "PRELOAD-MISS-B", + reference_number: "PRELOAD-MISS-B", + }), + ]; + + await setupDesktopPosPage(page, fixture, { token: "pos-desktop-booking-preload-miss" }); + + const activeBookingSelector = getActiveDesktopModal(page); + await page.locator("#reg_1").fill("EC212"); + await expect(page.getByTestId("desktop-booked-date-7102")).toContainText("20.03.2026", { timeout: 10_000 }); + + await page + .locator(".dropdown-item") + .filter({ hasText: "EC21234 - Pleno Logistics" }) + .first() + .click(); + + await expect(activeBookingSelector).toBeVisible({ timeout: 10_000 }); + await expect(activeBookingSelector.getByTestId("pos-desktop-order-booking-use-9102")).toBeVisible({ + timeout: 10_000, + }); + await expect(activeBookingSelector.getByTestId("pos-desktop-order-booking-use-9103")).toBeVisible({ + timeout: 10_000, + }); + }); + + test("desktop exact reg entry opens the booking chooser when exact plate bookings were not in the initial preload", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "Desktop POS booking flow is validated on chromium-desktop." + ); + + const fixture = createPosFixture(); + fixture.vehicles = [ + { + ...fixture.vehicles[0], + id: 7110, + reg: "EC21234", + booking_id: 9110, + status: "booked", + }, + ]; + fixture.orderBookings = [ + ...Array.from({ length: 100 }, (_, index) => + buildOrderBooking(9300 + index, { + reg_1: `MISS${String(index + 1).padStart(4, "0")}`, + datetime: `2026-02-${String((index % 28) + 1).padStart(2, "0")}T06:00:00.000Z`, + }) + ), + buildOrderBooking(9110, { + reg_1: "EC21234", + datetime: "2026-03-20T08:00:00.000Z", + reference: "EXACT-PRELOAD-MISS-A", + reference_number: "EXACT-PRELOAD-MISS-A", + }), + buildOrderBooking(9111, { + reg_1: "EC21234", + datetime: "2026-03-21T09:00:00.000Z", + reference: "EXACT-PRELOAD-MISS-B", + reference_number: "EXACT-PRELOAD-MISS-B", + }), + ]; + + await setupDesktopPosPage(page, fixture, { token: "pos-desktop-booking-preload-miss-exact" }); + + const activeBookingSelector = getActiveDesktopModal(page); + await commitDesktopReg1ByBlur(page, "EC21234"); + + await expect(activeBookingSelector).toBeVisible({ timeout: 10_000 }); + await expect(activeBookingSelector.getByTestId("pos-desktop-order-booking-use-9110")).toBeVisible({ + timeout: 10_000, + }); + await expect(activeBookingSelector.getByTestId("pos-desktop-order-booking-use-9111")).toBeVisible({ + timeout: 10_000, + }); + }); + test("desktop auto-applies a single matching order booking and completes that booking", async ({ page, }, testInfo) => { diff --git a/tests/e2e/support/network.js b/tests/e2e/support/network.js index c49dcbd1..d5e14a81 100644 --- a/tests/e2e/support/network.js +++ b/tests/e2e/support/network.js @@ -1245,6 +1245,8 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos if (pathname.endsWith("/order-bookings") && method === "GET") { const id = Number(parsedUrl.searchParams.get("id") || 0); const filters = String(parsedUrl.searchParams.get("filters") || ""); + const page = Number(parsedUrl.searchParams.get("page") || 1); + const limit = Number(parsedUrl.searchParams.get("limit") || 100); let bookings = Array.isArray(posFixture.orderBookings) ? posFixture.orderBookings : []; if (id > 0) { @@ -1268,6 +1270,16 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos bookings = bookings.filter((booking) => String(booking.reg_1 || "").toUpperCase() === String(reg).toUpperCase()); } + if (filters.includes("reg_2:")) { + const reg = filters.split("reg_2:")[1]?.split(",")[0] || ""; + bookings = bookings.filter((booking) => String(booking.reg_2 || "").toUpperCase() === String(reg).toUpperCase()); + } + + const normalizedPage = Number.isFinite(page) && page > 0 ? page : 1; + const normalizedLimit = Number.isFinite(limit) && limit > 0 ? limit : bookings.length || 1; + const offset = (normalizedPage - 1) * normalizedLimit; + bookings = bookings.slice(offset, offset + normalizedLimit); + await route.fulfill( json({ success: true,