Resume active self-serve washes for authenticated customers across devices. Update OpenAPI, UI components, and extend E2E/unit tests.
This commit is contained in:
@@ -4772,6 +4772,7 @@ paths:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Check whether self-serve is allowed for a vehicle on a lane
|
||||
description: Customers with own self-serve permissions may evaluate any registration plate for their wash. Persisted self-serve answers are only applied when they are scoped to the authenticated customer.
|
||||
operationId: getSelfserveVehicleAllowed
|
||||
parameters:
|
||||
- name: lane_id
|
||||
@@ -8737,6 +8738,7 @@ paths:
|
||||
description: |
|
||||
Send a command (e.g., start, stop, reset) to a self-serve lane.
|
||||
Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here.
|
||||
Property gate command permissions are bypassed for authenticated customers with an active self-serve wash in the target department.
|
||||
operationId: sendSelfServeLaneCommand
|
||||
requestBody:
|
||||
required: true
|
||||
|
||||
@@ -274,6 +274,22 @@ const normalizeLicensePlate = (value: string | null) => (value || "").trim().toU
|
||||
|
||||
const getNumericCustomerNumber = () => resolveEffectiveCustomerNumber(customerNumberInput.value);
|
||||
|
||||
const unwrapApiData = (response: any) => response?.data?.data ?? response?.data ?? response;
|
||||
|
||||
const parseServerDateTimeMs = (value: any) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = String(value).trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(normalized.includes("T") ? normalized : normalized.replace(" ", "T"));
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
};
|
||||
|
||||
const extractErrorMessage = (error: any, fallback: string) => {
|
||||
if (error?.response?.data?.data?.message) {
|
||||
return error.response.data.data.message;
|
||||
@@ -454,6 +470,144 @@ const fetchSelfServeData = async () => {
|
||||
);
|
||||
};
|
||||
|
||||
const getServerActiveWashCandidates = () => {
|
||||
const departments = Array.isArray(guestDepartments.value) ? guestDepartments.value : [];
|
||||
return departments.flatMap((department: any) => {
|
||||
const lanes = Array.isArray(department?.lanes) ? department.lanes : [];
|
||||
return lanes
|
||||
.map((lane: any) => ({
|
||||
department,
|
||||
lane,
|
||||
laneId: normalizeLaneId(lane?.id),
|
||||
}))
|
||||
.filter((candidate: any) => candidate.laneId);
|
||||
});
|
||||
};
|
||||
|
||||
const isAuthenticatedCustomerActiveWash = (details: any, customerNumber: number) => {
|
||||
if (!details?.in_progress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sessionCustomerNumber = normalizePositiveInteger(
|
||||
details?.session?.customer_number
|
||||
?? details?.customer?.customer_number
|
||||
);
|
||||
|
||||
return sessionCustomerNumber === customerNumber;
|
||||
};
|
||||
|
||||
const activeWashTimestamp = (details: any) => (
|
||||
parseServerDateTimeMs(details?.session?.wash_started_at)
|
||||
?? parseServerDateTimeMs(details?.session?.machine_start_triggered_at)
|
||||
?? parseServerDateTimeMs(details?.session?.machine_relay_enabled_at)
|
||||
?? parseServerDateTimeMs(details?.session?.updated_at)
|
||||
?? parseServerDateTimeMs(details?.session?.created_at)
|
||||
?? 0
|
||||
);
|
||||
|
||||
const fetchServerActiveWash = async () => {
|
||||
const customerNumber = getAuthenticatedCustomerNumber();
|
||||
if (!customerNumber) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = getServerActiveWashCandidates();
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const responses = await Promise.allSettled(candidates.map(async (candidate: any) => {
|
||||
const response = await SessionUser.request("/modules/self-serve/lane/wash/in-progress", "GET", {
|
||||
lane_id: candidate.laneId,
|
||||
});
|
||||
return {
|
||||
...candidate,
|
||||
details: unwrapApiData(response),
|
||||
};
|
||||
}));
|
||||
|
||||
const activeMatches = responses
|
||||
.filter((entry): entry is PromiseFulfilledResult<any> => entry.status === "fulfilled")
|
||||
.map((entry) => entry.value)
|
||||
.filter((entry) => isAuthenticatedCustomerActiveWash(entry.details, customerNumber))
|
||||
.sort((left, right) => activeWashTimestamp(right.details) - activeWashTimestamp(left.details));
|
||||
|
||||
return activeMatches[0] || null;
|
||||
};
|
||||
|
||||
const applyServerActiveWash = async (activeWash: any) => {
|
||||
const details = activeWash?.details;
|
||||
const session = details?.session || {};
|
||||
const vehicle = details?.vehicle || {};
|
||||
const laneId = normalizeLaneId(details?.lane_id ?? session?.lane_id ?? activeWash?.laneId);
|
||||
const departmentId = normalizeLaneId(activeWash?.department?.id ?? session?.department_id ?? activeWash?.lane?.department);
|
||||
const reg = normalizeLicensePlate(session?.reg ?? vehicle?.reg ?? licensePlateInput.value);
|
||||
|
||||
if (!laneId || !reg) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (departmentId && nearestDepartment.value?.id !== departmentId) {
|
||||
isForcingNearestDepartment.value = true;
|
||||
forceNearestDepartmentEvaluationId.value = departmentId;
|
||||
evaluateLocationDepartments(locations.location.value);
|
||||
}
|
||||
|
||||
washInProgress.value = true;
|
||||
washLaneId.value = laneId;
|
||||
radioLaneOption.value = laneId;
|
||||
licensePlateInput.value = reg;
|
||||
applyEffectiveCustomerNumberInput(session?.customer_number ?? details?.customer?.customer_number);
|
||||
vehicleTypeSelect.value = normalizePositiveInteger(session?.vehicle_type_id ?? vehicle?.type) ?? vehicleTypeSelect.value;
|
||||
radioWashType.value = session?.machine_relay_enabled ? "Machine" : "Manual";
|
||||
washStartTime.value = (
|
||||
parseServerDateTimeMs(session?.wash_started_at)
|
||||
?? parseServerDateTimeMs(session?.machine_start_triggered_at)
|
||||
?? parseServerDateTimeMs(session?.machine_relay_enabled_at)
|
||||
?? Date.now()
|
||||
);
|
||||
completedDurationMs.value = null;
|
||||
editAnswers.value = false;
|
||||
washActionError.value = null;
|
||||
vehicleStepError.value = null;
|
||||
currentStep.value = steps.WASH_IN_PROGRESS;
|
||||
|
||||
startElapsedTimer();
|
||||
|
||||
const summaryParams: Record<string, any> = session?.id
|
||||
? { session_id: session.id }
|
||||
: { lane_id: laneId, reg };
|
||||
const selectedVehicleTypeId = normalizePositiveInteger(vehicleTypeSelect.value);
|
||||
if (selectedVehicleTypeId) {
|
||||
summaryParams.vehicle_type = selectedVehicleTypeId;
|
||||
}
|
||||
|
||||
const summary = await fetchWashSummary(summaryParams, false);
|
||||
if (!summary) {
|
||||
await fetchSelfServeDataInternal(
|
||||
departmentId || nearestDepartment.value?.id,
|
||||
selectedVehicleTypeId,
|
||||
laneId,
|
||||
reg
|
||||
);
|
||||
}
|
||||
|
||||
saveProgress("serverActiveWash");
|
||||
return true;
|
||||
};
|
||||
|
||||
const restoreServerActiveWash = async () => {
|
||||
try {
|
||||
const activeWash = await fetchServerActiveWash();
|
||||
if (activeWash) {
|
||||
await applyServerActiveWash(activeWash);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to restore active self-serve wash:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const retrySelfServeData = async () => {
|
||||
if (isSelfServeRetrying.value) {
|
||||
return;
|
||||
@@ -589,7 +743,13 @@ onMounted(async () => {
|
||||
applyEffectiveCustomerNumberInput(customerNumberInput.value);
|
||||
await fetchCustomerVehicles();
|
||||
await fetchVehicleTypes();
|
||||
restoreProgress();
|
||||
const restoredProgress = restoreProgress();
|
||||
if (!restoredProgress?.washInProgress) {
|
||||
const restoreDelayMs = restoredProgress ? 1600 : 0;
|
||||
window.setTimeout(() => {
|
||||
restoreServerActiveWash();
|
||||
}, restoreDelayMs);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -191,6 +191,74 @@ test.describe("Self-serve wash", () => {
|
||||
await expect(page.getByTestId("self-serve-nav-complete")).toBeVisible();
|
||||
});
|
||||
|
||||
test("start route resumes the authenticated customer's active server wash from another device", async ({ page }) => {
|
||||
const requests = captureSelfServeGatewayRequests(page);
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: {
|
||||
customer_number: 12345679,
|
||||
},
|
||||
selfServe: {
|
||||
inProgressByLaneId: {
|
||||
9: {
|
||||
lane_id: 9,
|
||||
in_progress: true,
|
||||
session: {
|
||||
id: 704,
|
||||
lane_id: 9,
|
||||
department_id: 2,
|
||||
reg: "CD67890",
|
||||
customer_number: 12345679,
|
||||
vehicle_type_id: 2,
|
||||
machine_relay_enabled: true,
|
||||
wash_started_at: "2026-04-28 10:15:00",
|
||||
status: "MACHINE_STARTED",
|
||||
},
|
||||
customer: { customer_number: 12345679 },
|
||||
vehicle: { reg: "CD67890", type: 2 },
|
||||
},
|
||||
},
|
||||
summaryBySessionId: {
|
||||
704: {
|
||||
session: {
|
||||
id: 704,
|
||||
lane_id: 9,
|
||||
reg: "CD67890",
|
||||
status: "MACHINE_STARTED",
|
||||
allowed: true,
|
||||
vehicle_type_id: 2,
|
||||
},
|
||||
lane: { id: 9, name: "9", department: 2 },
|
||||
questions: [],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
tasks: [],
|
||||
events: [{ id: 7041, type: "MACHINE_STARTED", created_at: "2026-04-28T10:15:00.000Z" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await primeSession(page, {
|
||||
token: "self-serve-active-server-wash-token",
|
||||
permissions: ["user"],
|
||||
});
|
||||
|
||||
await page.goto("/user/wash/start");
|
||||
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("self-serve-department-name")).toContainText("Odense");
|
||||
expect(requests.commands).toHaveLength(0);
|
||||
|
||||
const accessCommandRequestPromise = waitForLaneCommandRequest(page, "OPEN_PROPERTY_ACCESS_GATE");
|
||||
await page.getByTestId("self-serve-nav-open-property-access-gate").click();
|
||||
const accessCommandRequest = await accessCommandRequestPromise;
|
||||
expect(accessCommandRequest.postDataJSON?.()).toMatchObject({
|
||||
lane_id: 9,
|
||||
command: "OPEN_PROPERTY_ACCESS_GATE",
|
||||
});
|
||||
});
|
||||
|
||||
test("superuser can force a department and clear the override", async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
|
||||
@@ -99,6 +99,10 @@ function mergeFixture(base, overrides = {}) {
|
||||
...(base.laneAllowedServicesByLane || {}),
|
||||
...(overrides.laneAllowedServicesByLane || {}),
|
||||
},
|
||||
inProgressByLaneId: {
|
||||
...(base.inProgressByLaneId || {}),
|
||||
...(overrides.inProgressByLaneId || {}),
|
||||
},
|
||||
relayStatuses: {
|
||||
...(base.relayStatuses || {}),
|
||||
...(overrides.relayStatuses || {}),
|
||||
@@ -1010,6 +1014,7 @@ function createSelfServeFixture(overrides = {}) {
|
||||
laneAllowedServices: ["MACHINE"],
|
||||
laneAllowedServiceResponse: null,
|
||||
laneAllowedServiceResponses: null,
|
||||
inProgressByLaneId: {},
|
||||
allowedServiceRequests: [],
|
||||
answerRequests: [],
|
||||
commandResponse: { success: true },
|
||||
@@ -6113,6 +6118,23 @@ export async function mockApi(page, options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/modules/self-serve/lane/wash/in-progress") && method === "GET") {
|
||||
const laneId = parsedUrl.searchParams.get("lane_id");
|
||||
const details = selfServe.inProgressByLaneId?.[String(laneId || "")] || null;
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: details || {
|
||||
lane_id: Number(laneId || 0),
|
||||
in_progress: false,
|
||||
session: null,
|
||||
customer: null,
|
||||
vehicle: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/department/selfserve/tasks/attachments") && method === "GET") {
|
||||
const taskId = parsedUrl.searchParams.get("id");
|
||||
await route.fulfill(
|
||||
|
||||
@@ -81,8 +81,8 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
},
|
||||
canAccessSuperUser: () => true,
|
||||
request: vi.fn(async (...args) => {
|
||||
mocks.sessionRequest(...args);
|
||||
return { status: 200, data: { data: {} } };
|
||||
const response = await mocks.sessionRequest(...args);
|
||||
return response ?? { status: 200, data: { data: {} } };
|
||||
}),
|
||||
functions: {
|
||||
contact: {
|
||||
@@ -337,6 +337,7 @@ describe("MyWashStart", () => {
|
||||
mocks.setShowFooterInContent.mockClear();
|
||||
mocks.addVehicle.mockClear();
|
||||
mocks.sessionRequest.mockClear();
|
||||
mocks.sessionRequest.mockResolvedValue(undefined);
|
||||
mocks.openPropertyAccessGate.mockClear();
|
||||
mocks.openPropertyExitGate.mockClear();
|
||||
});
|
||||
@@ -577,4 +578,81 @@ describe("MyWashStart", () => {
|
||||
expect(mocks.openPropertyAccessGate).toHaveBeenCalledWith(7);
|
||||
expect(mocks.openPropertyExitGate).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it("restores an active wash from the server when local progress is missing", async () => {
|
||||
mocks.nearestDepartment.value = {
|
||||
id: 6,
|
||||
name: "Roskilde",
|
||||
address: "Industrivej 45, 4000 Roskilde",
|
||||
self_serve_enabled: true,
|
||||
lanes: [{ id: 7, name: "7", status: "AVAILABLE", products: [2], machine_available: true }],
|
||||
};
|
||||
const odense = {
|
||||
id: 2,
|
||||
name: "Odense",
|
||||
address: "Beta 2",
|
||||
self_serve_enabled: true,
|
||||
lanes: [{ id: 9, name: "9", status: "OCCUPIED", products: [2], machine_available: true }],
|
||||
};
|
||||
mocks.guestDepartments.value = [mocks.nearestDepartment.value, odense];
|
||||
mocks.sessionRequest.mockImplementation(async (path, method, payload) => {
|
||||
if (path === "/modules/self-serve/lane/wash/in-progress" && method === "GET" && payload?.lane_id === 9) {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
lane_id: 9,
|
||||
in_progress: true,
|
||||
session: {
|
||||
id: 704,
|
||||
reg: "CD67890",
|
||||
customer_number: 12345679,
|
||||
vehicle_type_id: 2,
|
||||
machine_relay_enabled: true,
|
||||
wash_started_at: "2026-04-28 10:15:00",
|
||||
},
|
||||
customer: { customer_number: 12345679 },
|
||||
vehicle: { reg: "CD67890", type: 2 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
lane_id: payload?.lane_id,
|
||||
in_progress: false,
|
||||
session: null,
|
||||
customer: null,
|
||||
vehicle: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const wrapper = mountWithApp(MyWashStart, {
|
||||
global: {
|
||||
stubs: stubComponents,
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/in-progress", "GET", {
|
||||
lane_id: 7,
|
||||
});
|
||||
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/in-progress", "GET", {
|
||||
lane_id: 9,
|
||||
});
|
||||
expect(mocks.fetchWashSummary).toHaveBeenCalledWith({ session_id: 704, vehicle_type: 2 }, false);
|
||||
expect(mocks.startElapsedTimer).toHaveBeenCalled();
|
||||
expect(mocks.saveProgress).toHaveBeenCalledWith("serverActiveWash");
|
||||
|
||||
const accessGateButton = wrapper.get('[data-testid="self-serve-nav-open-property-access-gate"]');
|
||||
expect(accessGateButton.attributes("style") || "").not.toContain("display: none");
|
||||
await accessGateButton.trigger("click");
|
||||
expect(mocks.openPropertyAccessGate).toHaveBeenCalledWith(9);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user