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`).
This commit is contained in:
Jeppe Bundgaard
2026-04-13 21:53:16 +02:00
parent 6549eb5015
commit a93b0dc954
5 changed files with 236 additions and 16 deletions
@@ -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 {
@@ -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);
@@ -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;
};
+120
View File
@@ -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) => {
+12
View File
@@ -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,