Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fac27697b |
@@ -234,6 +234,7 @@ jobs:
|
||||
--env PLAYWRIGHT_WORKERS="$PLAYWRIGHT_WORKERS" \
|
||||
--env PLAYWRIGHT_VIDEO_MODE="$PLAYWRIGHT_VIDEO_MODE" \
|
||||
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
|
||||
--env PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" \
|
||||
--env MATRIX_SUITE="$MATRIX_SUITE" \
|
||||
--env MATRIX_PROJECT="$MATRIX_PROJECT" \
|
||||
--env DIFF_BASE_REF="$DIFF_BASE_REF" \
|
||||
|
||||
@@ -65,8 +65,19 @@ export const sourceMappings = [
|
||||
},
|
||||
{
|
||||
name: "pos",
|
||||
patterns: [/\/pos[/-]/iu, /POS/iu, /^src\/assets\/pos\.css$/u],
|
||||
specs: ["tests/e2e/pos-flow.spec.js", "tests/e2e/pos-mobile-order-flow.spec.js", "tests/e2e/admin-pos-orders.spec.ts"],
|
||||
patterns: [
|
||||
/\/pos[/-]/iu,
|
||||
/POS/iu,
|
||||
/^src\/assets\/pos\.css$/u,
|
||||
/^src\/components\/displays\/boxes\/ProductBox\.vue$/u,
|
||||
/^src\/features\/customer\/customerProductRules\.js$/u,
|
||||
],
|
||||
specs: [
|
||||
"tests/e2e/pos-flow.spec.js",
|
||||
"tests/e2e/pos-mobile-order-flow.spec.js",
|
||||
"tests/e2e/admin-pos-orders.spec.ts",
|
||||
"tests/e2e/pos-customer-rules.spec.js",
|
||||
],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import Swal from "sweetalert2";
|
||||
import CustomerProductDiscountDisplay
|
||||
from "@/components/displays/department/pos/displays/CustomerProductDiscountDisplay.vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { isProductRestrictedForCustomer } from "@/features/customer/customerProductRules.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
const expandIcon = ref(null);
|
||||
@@ -83,23 +84,6 @@ const hasRestrictAdditionalServices = () => {
|
||||
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictAdditionalServices');
|
||||
};
|
||||
|
||||
const hasRestrictSpotFree = () => {
|
||||
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictSpotFree');
|
||||
};
|
||||
|
||||
const hasRestrictInteriorCleaning = () => {
|
||||
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictInteriorCleaning');
|
||||
};
|
||||
|
||||
const hasRestrictTankCleaning = () => {
|
||||
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictTankCleaning');
|
||||
};
|
||||
|
||||
const hasOnlyTankCleaning = () => {
|
||||
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'onlyTankCleaning');
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* On before add product addon
|
||||
* This is used to check if adding a product has any requirements.
|
||||
@@ -162,37 +146,19 @@ const doesAddonHaveNoteRequirement = (addon) => {
|
||||
};
|
||||
|
||||
const isAddonRestricted = (addon) => {
|
||||
const addonName = getAddonName(addon).toLowerCase();
|
||||
// Check if the addon has "Spot Free" in the name
|
||||
if (hasRestrictSpotFree() && addonName.includes('spot free')) {
|
||||
return true;
|
||||
}
|
||||
// Check if the addon has "Indvendig vask" in the name
|
||||
if (hasRestrictInteriorCleaning() && addonName.includes('indvendig vask')) {
|
||||
return true;
|
||||
}
|
||||
const addonProduct = getAddonProduct(addon);
|
||||
return isProductRestrictedForCustomer({
|
||||
...addonProduct,
|
||||
name: getAddonName(addon) || addonProduct.name,
|
||||
category: addonProduct.category ?? addon?.category,
|
||||
}, customerAttributesSafe.value);
|
||||
};
|
||||
|
||||
const isProductRestricted = () => {
|
||||
const productName = String(props.name || '').toLowerCase();
|
||||
// Check if the product has "Spot Free" in the name
|
||||
if (hasRestrictSpotFree() && productName.includes('spot free')) {
|
||||
return true;
|
||||
}
|
||||
// Check if the product has "Indvendig vask" in the name
|
||||
if (hasRestrictInteriorCleaning() && productName.includes('indvendig vask')) {
|
||||
return true;
|
||||
}
|
||||
// Check if the product category is 5 (Tank Cleaning)
|
||||
if (props.product?.category === 5) {
|
||||
if (hasRestrictTankCleaning()) {
|
||||
return true;
|
||||
}
|
||||
if (!hasOnlyTankCleaning()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return isProductRestrictedForCustomer({
|
||||
...props.product,
|
||||
name: props.name || props.product?.name,
|
||||
}, customerAttributesSafe.value);
|
||||
};
|
||||
|
||||
const orderByOrderPriority = (addons) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getProductsCategory,
|
||||
loadOrderItems,
|
||||
productCategoryAllowed,
|
||||
isProductRestricted,
|
||||
setProductsCategory,
|
||||
getUserProductDiscount,
|
||||
showFakeCreateOrderItem,
|
||||
@@ -13,7 +14,8 @@ import {
|
||||
getUserGlobalDiscount,
|
||||
getUserDiscounts,
|
||||
customer_id,
|
||||
customer_attributes
|
||||
customer_attributes,
|
||||
loadCustomerAttributes
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import {getProductCategory, getProducts} from "@/components/shop/Products.vue";
|
||||
import {createOrderItem} from "@/components/shop/OrdersItems.vue";
|
||||
@@ -151,6 +153,10 @@ const fetchProductsForCategory = async ({
|
||||
products.value = [];
|
||||
|
||||
try {
|
||||
if (customerNumber) {
|
||||
await loadCustomerAttributes(customerNumber);
|
||||
}
|
||||
|
||||
const response = normalizedCategory === null
|
||||
? await getProducts(departmentId, props.finalPrice, customerNumber)
|
||||
: await getProductCategory(normalizedCategory, departmentId, props.finalPrice, customerNumber);
|
||||
@@ -593,7 +599,7 @@ const getRecommendedProductPrice = (productId) => {
|
||||
|
||||
const isRecommendedProductCategoryAllowed = (productId) => {
|
||||
const product = getProductById(productId);
|
||||
return !!product && productCategoryAllowed(product.category);
|
||||
return !!product && !isProductRestricted(product);
|
||||
};
|
||||
|
||||
const addRecommendedProductToOrder = async (productId) => {
|
||||
|
||||
@@ -10,6 +10,10 @@ import { getAttributes } from "@/components/shop/CustomerAttributes.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
import { doesOrderContainWashCertificateProduct } from "@/components/displays/department/pos/utils/washCertificate.js";
|
||||
import {
|
||||
isProductCategoryRestrictedForCustomer,
|
||||
isProductRestrictedForCustomer,
|
||||
} from "@/features/customer/customerProductRules.js";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
/**
|
||||
@@ -153,6 +157,8 @@ export const nextStep = async (options = { isMobile: false, orderCreation: true
|
||||
clearErrors();
|
||||
// If the current step is 1, create the order
|
||||
if (step.value === 1) {
|
||||
await loadCustomerAttributes(customer_id.value);
|
||||
|
||||
if (normalizedOptions.orderCreation === false) {
|
||||
step.value++;
|
||||
return;
|
||||
@@ -1613,24 +1619,7 @@ export const customerUsesPONumbers = () => {
|
||||
|
||||
/** Check if the product category is allowed */
|
||||
export const productCategoryAllowed = (category) => {
|
||||
// Check if the product category is the addons category
|
||||
if (category === "addons") {
|
||||
// Check if the customer has the attribute restrictAdditionalServices
|
||||
return !hasAttribute("restrictAdditionalServices");
|
||||
}
|
||||
if (category === "spot_free") {
|
||||
// Check if the customer has the attribute restrictSpotFree
|
||||
return !hasAttribute("restrictSpotFree");
|
||||
}
|
||||
if (category === "interior_cleaning") {
|
||||
// Check if the customer has the attribute restrictInteriorCleaning
|
||||
return !hasAttribute("restrictInteriorCleaning");
|
||||
}
|
||||
if (category === "tank_cleaning") {
|
||||
// Check if the customer has the attribute restrictTankCleaning
|
||||
return !hasAttribute("restrictTankCleaning");
|
||||
}
|
||||
return true;
|
||||
return !isProductCategoryRestrictedForCustomer(category, customer_attributes.value);
|
||||
};
|
||||
|
||||
/** Check if the customer should be billed per order, or per billing period */
|
||||
@@ -1676,43 +1665,12 @@ export const hasOnlyTankCleaning = () => {
|
||||
|
||||
/** Check if a product is restricted based on customer attributes */
|
||||
export const isProductRestricted = (product) => {
|
||||
if (!product) return false;
|
||||
const productName = (product.name || "").toLowerCase();
|
||||
|
||||
// Check if the product has "Spot Free" in the name
|
||||
if (!canBuySpotFree() && productName.includes("spot free")) {
|
||||
return true;
|
||||
}
|
||||
// Check if the product has "Indvendig vask" in the name
|
||||
if (!canBuyInteriorCleaning() && productName.includes("indvendig vask")) {
|
||||
return true;
|
||||
}
|
||||
// Check if the product category is 5 (Tank Cleaning)
|
||||
if (product.category === 5) {
|
||||
if (!canBuyTankCleaning()) {
|
||||
return true;
|
||||
}
|
||||
if (!hasOnlyTankCleaning()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return isProductRestrictedForCustomer(product, customer_attributes.value);
|
||||
};
|
||||
|
||||
/** Check if an addon is restricted based on customer attributes */
|
||||
export const isAddonRestricted = (addon) => {
|
||||
if (!addon) return false;
|
||||
const addonName = (addon.name || "").toLowerCase();
|
||||
|
||||
// Check if the addon has "Spot Free" in the name
|
||||
if (!canBuySpotFree() && addonName.includes("spot free")) {
|
||||
return true;
|
||||
}
|
||||
// Check if the addon has "Indvendig vask" in the name
|
||||
if (!canBuyInteriorCleaning() && addonName.includes("indvendig vask")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return isProductRestrictedForCustomer(addon, customer_attributes.value);
|
||||
};
|
||||
|
||||
export function loadOrderDetails(onAfterSuccess = null, options = {}) {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
const TANK_CLEANING_TERMS = ["tank cleaning", "tankcleaning", "tankrens"];
|
||||
|
||||
const normalizeAttributeName = (attribute) => {
|
||||
if (typeof attribute === "string") {
|
||||
return attribute;
|
||||
}
|
||||
|
||||
return String(attribute?.attribute ?? "");
|
||||
};
|
||||
|
||||
export const hasCustomerAttribute = (attributes, attributeName) => {
|
||||
if (!Array.isArray(attributes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return attributes.some((attribute) => normalizeAttributeName(attribute) === attributeName);
|
||||
};
|
||||
|
||||
const normalizedText = (value) => String(value ?? "").trim().toLowerCase();
|
||||
|
||||
const textContainsAny = (value, terms) => {
|
||||
const text = normalizedText(value);
|
||||
return terms.some((term) => term !== "" && text.includes(term));
|
||||
};
|
||||
|
||||
export const isTankCleaningCategory = (category) => {
|
||||
if (Number(category) === 5) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return textContainsAny(category, ["tank_cleaning", ...TANK_CLEANING_TERMS]);
|
||||
};
|
||||
|
||||
export const isTankCleaningProduct = (product) => {
|
||||
if (!product) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isTankCleaningCategory(product.category ?? product.product_category)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return textContainsAny(
|
||||
[
|
||||
product.name,
|
||||
product.product_name,
|
||||
product.category_name,
|
||||
product.categoryName,
|
||||
].join(" "),
|
||||
TANK_CLEANING_TERMS
|
||||
);
|
||||
};
|
||||
|
||||
export const isProductCategoryRestrictedForCustomer = (category, attributes = []) => {
|
||||
if (category === "addons" && hasCustomerAttribute(attributes, "restrictAdditionalServices")) {
|
||||
return true;
|
||||
}
|
||||
if (category === "spot_free" && hasCustomerAttribute(attributes, "restrictSpotFree")) {
|
||||
return true;
|
||||
}
|
||||
if (category === "interior_cleaning" && hasCustomerAttribute(attributes, "restrictInteriorCleaning")) {
|
||||
return true;
|
||||
}
|
||||
if (isTankCleaningCategory(category) && hasCustomerAttribute(attributes, "restrictTankCleaning")) {
|
||||
return true;
|
||||
}
|
||||
if (hasCustomerAttribute(attributes, "onlyTankCleaning") && !isTankCleaningCategory(category)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isProductRestrictedForCustomer = (product, attributes = []) => {
|
||||
if (!product) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const productName = normalizedText(product.name ?? product.product_name);
|
||||
|
||||
if (hasCustomerAttribute(attributes, "restrictSpotFree") && productName.includes("spot free")) {
|
||||
return true;
|
||||
}
|
||||
if (hasCustomerAttribute(attributes, "restrictInteriorCleaning") && productName.includes("indvendig vask")) {
|
||||
return true;
|
||||
}
|
||||
if (isTankCleaningProduct(product) && hasCustomerAttribute(attributes, "restrictTankCleaning")) {
|
||||
return true;
|
||||
}
|
||||
if (hasCustomerAttribute(attributes, "onlyTankCleaning") && !isTankCleaningProduct(product)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -257,3 +257,70 @@ test("customer details renders economic customer mobilePhone object as number on
|
||||
await expect(phoneValue).not.toContainText("country_code");
|
||||
await expect(phoneValue).not.toContainText("number");
|
||||
});
|
||||
|
||||
test("only tankcleaning customers can only add tankcleaning products", async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
|
||||
|
||||
const baseFixture = createPosFixture();
|
||||
const tankCleaningProduct = {
|
||||
id: 66,
|
||||
name: "Tank cleaning 4 spulehoveder",
|
||||
description: "Tankcleaning service",
|
||||
price: 799,
|
||||
subscription_allowed: true,
|
||||
category: 5,
|
||||
piktogram: "truck",
|
||||
apply_category_discount: true,
|
||||
requires_note: false,
|
||||
is_wash: false,
|
||||
display_in_booking_form: true,
|
||||
order_priority: 1,
|
||||
addons: [],
|
||||
};
|
||||
const fixture = createPosFixture({
|
||||
customerAttributesByNumber: {
|
||||
12345679: [{ id: 1, customer_number: 12345679, attribute: "onlyTankCleaning" }],
|
||||
},
|
||||
products: [...baseFixture.products, tankCleaningProduct],
|
||||
departmentCategories: [
|
||||
...baseFixture.departmentCategories,
|
||||
{ id: 13, department_id: 12, category: { id: 5, name: "Tankcleaning", meta: { products: [66] } } },
|
||||
],
|
||||
});
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: POS_PERMISSIONS,
|
||||
edgeGateways: false,
|
||||
pos: fixture,
|
||||
});
|
||||
await primeOperatorSession(page, "pos-only-tankcleaning-token");
|
||||
|
||||
const customer = fixture.customersByNumber[12345679];
|
||||
const customerAttributesResponse = page.waitForResponse((response) => {
|
||||
return (
|
||||
response.request().method() === "GET" &&
|
||||
response.url().includes(`/customer/attributes?customer_number=${customer.customerNumber}`)
|
||||
);
|
||||
});
|
||||
await openPosAndSelectCustomer(page, customer);
|
||||
await customerAttributesResponse;
|
||||
|
||||
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
|
||||
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
|
||||
await page.getByTestId("pos-product-card-53").first().click();
|
||||
await expect(page.getByTestId("pos-add-to-cart-53").first()).toBeDisabled();
|
||||
|
||||
await page.locator(".tabs li").filter({ hasText: "Tankcleaning" }).click();
|
||||
await expect(page.getByTestId("pos-product-card-66").first()).toBeVisible();
|
||||
await page.getByTestId("pos-product-card-66").first().click();
|
||||
await expect(page.getByTestId("pos-add-to-cart-66").first()).toBeEnabled();
|
||||
|
||||
const addRequest = page.waitForRequest((request) => {
|
||||
return request.method() === "POST" && request.url().includes("/order/items");
|
||||
});
|
||||
await page.getByTestId("pos-add-to-cart-66").first().click();
|
||||
const request = await addRequest;
|
||||
expect(request.postDataJSON().product_id).toBe(66);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isProductCategoryRestrictedForCustomer,
|
||||
isProductRestrictedForCustomer,
|
||||
isTankCleaningProduct,
|
||||
} from "@/features/customer/customerProductRules.js";
|
||||
|
||||
const onlyTankCleaning = [{ attribute: "onlyTankCleaning" }];
|
||||
const restrictTankCleaning = [{ attribute: "restrictTankCleaning" }];
|
||||
|
||||
describe("customer product rules", () => {
|
||||
it("recognizes tankcleaning products by category and legacy names", () => {
|
||||
expect(isTankCleaningProduct({ category: 5, name: "Saebe/kemi" })).toBe(true);
|
||||
expect(isTankCleaningProduct({ category: 4, name: "Tank cleaning 4 spulehoveder" })).toBe(true);
|
||||
expect(isTankCleaningProduct({ category: 4, name: "Saebe/kemi", category_name: "Tankrens" })).toBe(true);
|
||||
expect(isTankCleaningProduct({ category: 4, name: "Forvogn" })).toBe(false);
|
||||
});
|
||||
|
||||
it("limits only-tankcleaning customers to tankcleaning products", () => {
|
||||
expect(isProductRestrictedForCustomer({ category: 4, name: "Forvogn" }, onlyTankCleaning)).toBe(true);
|
||||
expect(isProductRestrictedForCustomer({ category: 5, name: "Tank cleaning" }, onlyTankCleaning)).toBe(false);
|
||||
expect(isProductRestrictedForCustomer({ category: 4, name: "Forvogn" }, [])).toBe(false);
|
||||
});
|
||||
|
||||
it("does not invert tankcleaning access for regular customers", () => {
|
||||
expect(isProductRestrictedForCustomer({ category: 5, name: "Tank cleaning" }, [])).toBe(false);
|
||||
expect(isProductRestrictedForCustomer({ category: 5, name: "Tank cleaning" }, restrictTankCleaning)).toBe(true);
|
||||
});
|
||||
|
||||
it("applies category restrictions consistently", () => {
|
||||
expect(isProductCategoryRestrictedForCustomer(4, onlyTankCleaning)).toBe(true);
|
||||
expect(isProductCategoryRestrictedForCustomer(5, onlyTankCleaning)).toBe(false);
|
||||
expect(isProductCategoryRestrictedForCustomer("tank_cleaning", restrictTankCleaning)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,11 @@ describe("Playwright PR mapping", () => {
|
||||
expect(triggersFallback("scripts/run-playwright-full-slice.mjs")).toBe(false);
|
||||
});
|
||||
|
||||
it("maps customer product rule changes to POS customer rule E2E coverage", () => {
|
||||
expect(specsFor("src/features/customer/customerProductRules.js")).toContain("tests/e2e/pos-customer-rules.spec.js");
|
||||
expect(specsFor("src/components/displays/boxes/ProductBox.vue")).toContain("tests/e2e/pos-customer-rules.spec.js");
|
||||
});
|
||||
|
||||
it("maps limited backoffice changes to the limited backoffice E2E coverage", () => {
|
||||
expect(specsFor("src/views/backoffice/LimitedBackofficeEmployees.vue")).toContain(
|
||||
"tests/e2e/limited-backoffice.spec.ts"
|
||||
|
||||
Reference in New Issue
Block a user