- Introduced `PosDesktopOrderWorkspace.vue` for improved order and customer management. - Added `PosOrderItemsDefaultTable.vue` with detailed row actions, price tooltips, and order item grouping. - Enhanced `LicensePlateReg1Input.vue` to preserve customer selection when switching license plates. - Improved `PosDepartmentStep3.vue` with updated desktop layout using the new POS workspace. - Added e2e test for customer rules tab to verify dynamic attribute reloading.
674 lines
19 KiB
JavaScript
674 lines
19 KiB
JavaScript
import { expect, test } from "@playwright/test";
|
|
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
|
|
|
const API_HOST = /https?:\/\/(?:api\.truckwash\.io(?::\d+)?\/.*|localhost(?::\d+)?\/api\/.*|127\.0\.0\.1(?::\d+)?\/api\/.*)/i;
|
|
|
|
function json(body, status = 200) {
|
|
return {
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
};
|
|
}
|
|
|
|
function suppressVueDevtoolsOverlay(page) {
|
|
return page.addInitScript(() => {
|
|
const style = document.createElement("style");
|
|
style.textContent = "#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }";
|
|
document.documentElement.appendChild(style);
|
|
});
|
|
}
|
|
|
|
async function primeSession(page, { token, permissions, sessionData = {} }) {
|
|
await seedAuthenticatedState(page, token);
|
|
await page.goto("/login");
|
|
await page.evaluate(async ({ sessionToken, sessionPermissions, data }) => {
|
|
const sessionModule = await import("/src/components/session/token/SessionUser.vue");
|
|
window.localStorage.setItem("token", sessionToken);
|
|
sessionModule.SessionUser.token.value = sessionToken;
|
|
sessionModule.SessionUser.authenticated.value = true;
|
|
sessionModule.SessionUser.permissions.value = sessionPermissions;
|
|
sessionModule.SessionUser.initiated.value = true;
|
|
sessionModule.SessionUser.user.customer_number.value = data.customer_number ?? 12345;
|
|
sessionModule.SessionUser.user.display_name.value = data.display_name ?? "POS E2E";
|
|
}, {
|
|
sessionToken: token,
|
|
sessionPermissions: permissions,
|
|
data: sessionData,
|
|
});
|
|
}
|
|
|
|
async function expectDesktopOrderItemsTable(scope) {
|
|
await expect(scope.getByTestId("pos-order-panel-cart")).toBeVisible();
|
|
await expect(scope.getByTestId("pos-order-detail-items-table")).toBeVisible();
|
|
await expect(scope.getByTestId("pos-order-metadata-grid")).toBeVisible();
|
|
await expect(scope.getByTestId("pos-order-total")).toBeVisible();
|
|
await expect(scope.locator('[data-auto-excel-export-button="1"]')).toHaveCount(0);
|
|
}
|
|
|
|
function createPosFixture() {
|
|
const customer = {
|
|
id: 1,
|
|
customerNumber: 12345,
|
|
name: "Pleno Logistics",
|
|
address: "Demo Street 1",
|
|
zip: "2630",
|
|
city: "Taastrup",
|
|
mobilePhone: "12345678",
|
|
email: "pos-e2e@example.com",
|
|
corporateIdentificationNumber: "12345678",
|
|
barred: false,
|
|
};
|
|
|
|
const products = [
|
|
{
|
|
id: 53,
|
|
name: "Tankvogn med hænger",
|
|
description: "Material product used for completion flow",
|
|
price: 599,
|
|
subscription_allowed: true,
|
|
category: 4,
|
|
piktogram: "truck",
|
|
apply_category_discount: true,
|
|
requires_note: false,
|
|
is_wash: true,
|
|
display_in_booking_form: true,
|
|
order_priority: 1,
|
|
addons: [],
|
|
},
|
|
{
|
|
id: 63,
|
|
name: "Dolly",
|
|
description: "Support product",
|
|
price: 275,
|
|
subscription_allowed: true,
|
|
category: 4,
|
|
piktogram: "truck",
|
|
apply_category_discount: true,
|
|
requires_note: false,
|
|
is_wash: true,
|
|
display_in_booking_form: true,
|
|
order_priority: 2,
|
|
addons: [],
|
|
},
|
|
];
|
|
|
|
const ordersById = {
|
|
9201: {
|
|
id: 9201,
|
|
customer_id: customer.customerNumber,
|
|
department_id: 1,
|
|
reference: "REF-9201",
|
|
notes: "",
|
|
reg_1: "AB12345",
|
|
reg_2: "",
|
|
reg_3: "",
|
|
invoice_collection_id: null,
|
|
booking_id: null,
|
|
completed_at: null,
|
|
created_at: "2026-01-01T10:00:00.000Z",
|
|
},
|
|
};
|
|
|
|
const orderItemsByOrderId = {
|
|
9201: [],
|
|
};
|
|
|
|
return {
|
|
customer,
|
|
products,
|
|
departmentCategories: [
|
|
{
|
|
id: 11,
|
|
department_id: 1,
|
|
category: {
|
|
id: 4,
|
|
name: "Udvendig",
|
|
meta: {
|
|
products: [53, 63],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
vehicles: [
|
|
{
|
|
id: 7001,
|
|
reg: "AB12345",
|
|
customer_id: customer.customerNumber,
|
|
customer_name: customer.name,
|
|
type: 53,
|
|
status: "verified",
|
|
barred: false,
|
|
wash_subscription: false,
|
|
addons: {
|
|
enabled: 0,
|
|
available: 0,
|
|
list: [],
|
|
},
|
|
reference: "REF-AB12345",
|
|
last_order_id: null,
|
|
},
|
|
],
|
|
unknownVehicles: [],
|
|
ordersById,
|
|
orderItemsByOrderId,
|
|
markCompletedOrderIds: [],
|
|
nextOrderId: 9300,
|
|
nextOrderItemId: 9800,
|
|
};
|
|
}
|
|
|
|
function buildOrderItem(product, body, id) {
|
|
const quantity = Number(body.quantity || 1);
|
|
return {
|
|
id,
|
|
order_id: Number(body.order_id),
|
|
product_id: product.id,
|
|
product,
|
|
quantity,
|
|
notes: body.notes || "",
|
|
reference: body.reference || "",
|
|
related_item_id: body.related_item_id ?? null,
|
|
price: Number(body.price ?? product.price ?? 0),
|
|
};
|
|
}
|
|
|
|
async function mockPosApi(page, fixture) {
|
|
await page.route(API_HOST, async (route) => {
|
|
const request = route.request();
|
|
const url = request.url();
|
|
const parsedUrl = new URL(url);
|
|
const pathname = parsedUrl.pathname;
|
|
const method = request.method();
|
|
|
|
if (pathname.endsWith("/departments") && method === "GET") {
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: [
|
|
{
|
|
id: 1,
|
|
name: "Taastrup",
|
|
},
|
|
],
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/departments/categories") && method === "GET") {
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: fixture.departmentCategories,
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/bookings") && method === "GET") {
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: [],
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/vehicles") && method === "GET") {
|
|
const search = (parsedUrl.searchParams.get("search") || "").toUpperCase();
|
|
const matches = fixture.vehicles.filter((vehicle) => vehicle.reg.includes(search));
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: matches,
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/vehicles/unknown-customer") && method === "GET") {
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: fixture.unknownVehicles,
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/users/customer") && method === "GET") {
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: {
|
|
customer_name: fixture.customer.name,
|
|
economic_customer: fixture.customer,
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/customers") && method === "GET") {
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: [fixture.customer],
|
|
meta: {
|
|
pagination: {
|
|
page: 1,
|
|
limit: 10,
|
|
total: 1,
|
|
},
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/customer/notes") && method === "GET") {
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: [],
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/customer/attributes") && method === "GET") {
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: [],
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/user/discounts") && method === "GET") {
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: [],
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/products") && method === "GET") {
|
|
const id = parsedUrl.searchParams.get("id");
|
|
const category = parsedUrl.searchParams.get("category");
|
|
if (id) {
|
|
const product = fixture.products.find((entry) => Number(entry.id) === Number(id));
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: product || null,
|
|
}));
|
|
return;
|
|
}
|
|
|
|
const list = category
|
|
? fixture.products.filter((entry) => Number(entry.category) === Number(category))
|
|
: fixture.products;
|
|
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: list,
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/orders") && method === "GET") {
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: [],
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/orders") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const orderId = fixture.nextOrderId++;
|
|
fixture.ordersById[orderId] = {
|
|
id: orderId,
|
|
customer_id: Number(body.customer_id),
|
|
department_id: Number(body.department_id),
|
|
reference: body.reference || "",
|
|
notes: body.notes || "",
|
|
reg_1: body.reg_1 || "",
|
|
reg_2: body.reg_2 || "",
|
|
reg_3: body.reg_3 || "",
|
|
invoice_collection_id: null,
|
|
booking_id: null,
|
|
completed_at: null,
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
fixture.orderItemsByOrderId[orderId] = [];
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: {
|
|
id: orderId,
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/order") && method === "GET") {
|
|
const id = Number(parsedUrl.searchParams.get("id"));
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: fixture.ordersById[id] || null,
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/order") && method === "PUT") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const orderId = Number(body.id);
|
|
if (fixture.ordersById[orderId]) {
|
|
if (body.field) {
|
|
fixture.ordersById[orderId][body.field] = body.value;
|
|
} else {
|
|
fixture.ordersById[orderId] = {
|
|
...fixture.ordersById[orderId],
|
|
...body,
|
|
};
|
|
}
|
|
}
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: fixture.ordersById[orderId] || null,
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/order/items") && method === "GET") {
|
|
const orderId = Number(parsedUrl.searchParams.get("order_id"));
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: fixture.orderItemsByOrderId[orderId] || [],
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/order/items") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const orderId = Number(body.order_id);
|
|
const productId = Number(body.product_id);
|
|
const product = fixture.products.find((entry) => Number(entry.id) === productId);
|
|
if (!product || !fixture.ordersById[orderId]) {
|
|
await route.fulfill(json({
|
|
success: false,
|
|
data: {
|
|
message: "Order or product not found",
|
|
},
|
|
}, 422));
|
|
return;
|
|
}
|
|
|
|
const item = buildOrderItem(product, body, fixture.nextOrderItemId++);
|
|
if (!Array.isArray(fixture.orderItemsByOrderId[orderId])) {
|
|
fixture.orderItemsByOrderId[orderId] = [];
|
|
}
|
|
fixture.orderItemsByOrderId[orderId].push(item);
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: item,
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/order/items") && method === "PUT") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const orderItemId = Number(body.id);
|
|
Object.keys(fixture.orderItemsByOrderId).forEach((orderIdKey) => {
|
|
fixture.orderItemsByOrderId[orderIdKey] = (fixture.orderItemsByOrderId[orderIdKey] || []).map((item) => {
|
|
if (item.id !== orderItemId) {
|
|
return item;
|
|
}
|
|
return {
|
|
...item,
|
|
price: Number(body.price ?? item.price),
|
|
notes: body.notes ?? item.notes,
|
|
reference: body.reference ?? item.reference,
|
|
quantity: Number(body.quantity ?? item.quantity),
|
|
};
|
|
});
|
|
});
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: {
|
|
id: orderItemId,
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/order/items") && method === "DELETE") {
|
|
const orderItemId = Number(parsedUrl.searchParams.get("id"));
|
|
Object.keys(fixture.orderItemsByOrderId).forEach((orderIdKey) => {
|
|
fixture.orderItemsByOrderId[orderIdKey] = (fixture.orderItemsByOrderId[orderIdKey] || []).filter((item) => item.id !== orderItemId);
|
|
});
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: true,
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/departments/order/recommended") && method === "GET") {
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: {
|
|
reg_1: {
|
|
motorapi: [53],
|
|
order_history: {
|
|
2: [],
|
|
3: [],
|
|
4: [],
|
|
5: [],
|
|
},
|
|
},
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/orders/mark_as_completed") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const orderId = Number(body.id);
|
|
fixture.markCompletedOrderIds.push(orderId);
|
|
if (fixture.ordersById[orderId]) {
|
|
fixture.ordersById[orderId].completed_at = new Date().toISOString();
|
|
}
|
|
await route.fulfill(json({
|
|
success: true,
|
|
data: {
|
|
id: orderId,
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
|
|
await route.fallback();
|
|
});
|
|
}
|
|
|
|
function seedMobilePosState(page) {
|
|
const state = {
|
|
vehicles: {
|
|
vehicle_1: {
|
|
reg: "AB12345",
|
|
customer_id: 12345,
|
|
type: 53,
|
|
status: "verified",
|
|
barred: false,
|
|
booking_id: null,
|
|
addons: [],
|
|
},
|
|
vehicle_2: null,
|
|
vehicle_3: null,
|
|
activeVehicleIndex: 1,
|
|
},
|
|
views: {
|
|
manualInput: false,
|
|
vehicleSelection: false,
|
|
additionalItemSelection: false,
|
|
transactionHistoryView: false,
|
|
},
|
|
transactionItems: {
|
|
primaryItem: {
|
|
id: 53,
|
|
name: "Tankvogn med hænger",
|
|
description: "Material product used for completion flow",
|
|
price: 599,
|
|
category: 4,
|
|
is_wash: true,
|
|
addons: [],
|
|
},
|
|
additionalItems: [],
|
|
},
|
|
categories: {
|
|
list: [],
|
|
selected: null,
|
|
},
|
|
productList: {
|
|
list: [],
|
|
},
|
|
metadata: {
|
|
customerId: 12345,
|
|
notes: "",
|
|
reference: "REF-9201",
|
|
washId: null,
|
|
bookingId: null,
|
|
laneId: null,
|
|
},
|
|
attachments: {
|
|
files: [],
|
|
base64: [],
|
|
wash_certificate: false,
|
|
},
|
|
transactionHistory: [],
|
|
lastVehicleOrders: {
|
|
vehicle_1: null,
|
|
vehicle_2: null,
|
|
vehicle_3: null,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
return page.addInitScript((payload) => {
|
|
window.localStorage.setItem("pos", JSON.stringify(payload));
|
|
}, state);
|
|
}
|
|
|
|
test.describe("POS flow", () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await suppressVueDevtoolsOverlay(page);
|
|
});
|
|
|
|
test("desktop bootstrap ignores customer_id-only query for order loading", async ({ page }, testInfo) => {
|
|
test.skip(testInfo.project.name !== "chromium-desktop", "Desktop POS bootstrap is validated on chromium-desktop.");
|
|
|
|
const fixture = createPosFixture();
|
|
const permissions = ["admin", "department_access_1"];
|
|
const invalidOrderRequests = [];
|
|
const reg1Errors = [];
|
|
|
|
page.on("request", (request) => {
|
|
const requestUrl = request.url();
|
|
if (
|
|
request.method() === "GET" &&
|
|
(
|
|
requestUrl.includes("/order/items?order_id=null") ||
|
|
requestUrl.includes("/order/items?order_id=undefined") ||
|
|
requestUrl.includes("/order/items?order_id=NaN") ||
|
|
requestUrl.includes("/order?id=null") ||
|
|
requestUrl.includes("/order?id=undefined") ||
|
|
requestUrl.includes("/order?id=NaN")
|
|
)
|
|
) {
|
|
invalidOrderRequests.push(requestUrl);
|
|
}
|
|
});
|
|
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error" && message.text().includes("reg_1")) {
|
|
reg1Errors.push(message.text());
|
|
}
|
|
});
|
|
|
|
await mockApi(page, {
|
|
authenticated: true,
|
|
permissions,
|
|
});
|
|
await mockPosApi(page, fixture);
|
|
await primeSession(page, {
|
|
token: "pos-bootstrap-token",
|
|
permissions,
|
|
sessionData: {
|
|
display_name: "POS Bootstrap",
|
|
},
|
|
});
|
|
|
|
await page.goto("/admin/1/modules/pos?customer_id=12345&step=1");
|
|
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: 10_000 });
|
|
await expect(page.getByTestId("pos-step-1").getByText("Nummerplader").first()).toBeVisible({ timeout: 10_000 });
|
|
|
|
expect(invalidOrderRequests).toEqual([]);
|
|
expect(reg1Errors).toEqual([]);
|
|
});
|
|
|
|
test("desktop flow creates transaction, renders cart, and reaches completion step", async ({ page }, testInfo) => {
|
|
test.skip(testInfo.project.name !== "chromium-desktop", "Desktop POS flow is validated on chromium-desktop.");
|
|
|
|
const fixture = createPosFixture();
|
|
const permissions = ["admin", "department_access_1"];
|
|
|
|
await mockApi(page, {
|
|
authenticated: true,
|
|
permissions,
|
|
});
|
|
await mockPosApi(page, fixture);
|
|
await primeSession(page, {
|
|
token: "pos-desktop-token",
|
|
permissions,
|
|
sessionData: {
|
|
display_name: "POS Desktop",
|
|
},
|
|
});
|
|
|
|
await page.goto("/admin/1/modules/pos");
|
|
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: 10_000 });
|
|
|
|
await page.locator("#reg_1").fill("AB12345");
|
|
await expect(page.getByTestId("pos-step-1").getByText("Pleno Logistics").first()).toBeVisible({ timeout: 10_000 });
|
|
await expect(page.getByTestId("pos-step-1").locator(".pos-card-tabs > .tabs li.is-active")).toContainText("Kunde", { timeout: 10_000 });
|
|
|
|
await page.locator('[data-testid="pos-next-step"]:visible').click();
|
|
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: 10_000 });
|
|
await expect(page).toHaveURL(/step=2/);
|
|
await expectDesktopOrderItemsTable(page.getByTestId("pos-step-2"));
|
|
|
|
await page.getByTestId("pos-product-card-53").click();
|
|
await expect(page.getByTestId("pos-add-to-cart-53")).toBeVisible({ timeout: 10_000 });
|
|
|
|
const addItemRequest = page.waitForRequest((request) => {
|
|
return request.method() === "POST" && request.url().includes("/order/items");
|
|
});
|
|
await page.getByTestId("pos-add-to-cart-53").click();
|
|
await addItemRequest;
|
|
|
|
await expect(page.getByTestId("pos-step-2")).toContainText("Tankvogn med hænger");
|
|
|
|
await page.locator('[data-testid="pos-next-step"]:visible').click();
|
|
await expect(page.getByTestId("pos-step-3")).toBeVisible({ timeout: 10_000 });
|
|
await expect(page).toHaveURL(/step=3/);
|
|
await expectDesktopOrderItemsTable(page.getByTestId("pos-step-3"));
|
|
|
|
const markCompletedRequest = page.waitForRequest((request) => {
|
|
return request.method() === "POST" && request.url().includes("/orders/mark_as_completed");
|
|
});
|
|
await page.locator('[data-testid="pos-next-step"]:visible').click();
|
|
await markCompletedRequest;
|
|
|
|
await expect.poll(async () => {
|
|
if (await page.getByTestId("pos-step-4").isVisible()) {
|
|
return "step4";
|
|
}
|
|
if (/\/admin\/1\/modules\/pos(?:\?|$)/.test(page.url()) && !page.url().includes("step=3")) {
|
|
return "reset";
|
|
}
|
|
return "pending";
|
|
}, { timeout: 10_000 }).not.toBe("pending");
|
|
|
|
if (await page.getByTestId("pos-step-4").isVisible()) {
|
|
await expectDesktopOrderItemsTable(page.getByTestId("pos-step-4"));
|
|
}
|
|
});
|
|
|
|
});
|