Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard 3fac27697b Enforce only tankcleaning POS product rules 2026-07-06 16:02:24 +02:00
9 changed files with 244 additions and 100 deletions
+1
View File
@@ -234,6 +234,7 @@ jobs:
--env PLAYWRIGHT_WORKERS="$PLAYWRIGHT_WORKERS" \ --env PLAYWRIGHT_WORKERS="$PLAYWRIGHT_WORKERS" \
--env PLAYWRIGHT_VIDEO_MODE="$PLAYWRIGHT_VIDEO_MODE" \ --env PLAYWRIGHT_VIDEO_MODE="$PLAYWRIGHT_VIDEO_MODE" \
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \ --env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
--env PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" \
--env MATRIX_SUITE="$MATRIX_SUITE" \ --env MATRIX_SUITE="$MATRIX_SUITE" \
--env MATRIX_PROJECT="$MATRIX_PROJECT" \ --env MATRIX_PROJECT="$MATRIX_PROJECT" \
--env DIFF_BASE_REF="$DIFF_BASE_REF" \ --env DIFF_BASE_REF="$DIFF_BASE_REF" \
+13 -2
View File
@@ -65,8 +65,19 @@ export const sourceMappings = [
}, },
{ {
name: "pos", name: "pos",
patterns: [/\/pos[/-]/iu, /POS/iu, /^src\/assets\/pos\.css$/u], patterns: [
specs: ["tests/e2e/pos-flow.spec.js", "tests/e2e/pos-mobile-order-flow.spec.js", "tests/e2e/admin-pos-orders.spec.ts"], /\/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, projects: chromiumProjects,
}, },
{ {
+11 -45
View File
@@ -6,6 +6,7 @@ import Swal from "sweetalert2";
import CustomerProductDiscountDisplay import CustomerProductDiscountDisplay
from "@/components/displays/department/pos/displays/CustomerProductDiscountDisplay.vue"; from "@/components/displays/department/pos/displays/CustomerProductDiscountDisplay.vue";
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { isProductRestrictedForCustomer } from "@/features/customer/customerProductRules.js";
const { t } = useI18n(); const { t } = useI18n();
const expandIcon = ref(null); const expandIcon = ref(null);
@@ -83,23 +84,6 @@ const hasRestrictAdditionalServices = () => {
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictAdditionalServices'); 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 * On before add product addon
* This is used to check if adding a product has any requirements. * This is used to check if adding a product has any requirements.
@@ -162,37 +146,19 @@ const doesAddonHaveNoteRequirement = (addon) => {
}; };
const isAddonRestricted = (addon) => { const isAddonRestricted = (addon) => {
const addonName = getAddonName(addon).toLowerCase(); const addonProduct = getAddonProduct(addon);
// Check if the addon has "Spot Free" in the name return isProductRestrictedForCustomer({
if (hasRestrictSpotFree() && addonName.includes('spot free')) { ...addonProduct,
return true; name: getAddonName(addon) || addonProduct.name,
} category: addonProduct.category ?? addon?.category,
// Check if the addon has "Indvendig vask" in the name }, customerAttributesSafe.value);
if (hasRestrictInteriorCleaning() && addonName.includes('indvendig vask')) {
return true;
}
}; };
const isProductRestricted = () => { const isProductRestricted = () => {
const productName = String(props.name || '').toLowerCase(); return isProductRestrictedForCustomer({
// Check if the product has "Spot Free" in the name ...props.product,
if (hasRestrictSpotFree() && productName.includes('spot free')) { name: props.name || props.product?.name,
return true; }, customerAttributesSafe.value);
}
// 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;
}
}
}; };
const orderByOrderPriority = (addons) => { const orderByOrderPriority = (addons) => {
@@ -5,6 +5,7 @@ import {
getProductsCategory, getProductsCategory,
loadOrderItems, loadOrderItems,
productCategoryAllowed, productCategoryAllowed,
isProductRestricted,
setProductsCategory, setProductsCategory,
getUserProductDiscount, getUserProductDiscount,
showFakeCreateOrderItem, showFakeCreateOrderItem,
@@ -13,7 +14,8 @@ import {
getUserGlobalDiscount, getUserGlobalDiscount,
getUserDiscounts, getUserDiscounts,
customer_id, customer_id,
customer_attributes customer_attributes,
loadCustomerAttributes
} from "@/components/shop/POSDepartmentProcess.vue"; } from "@/components/shop/POSDepartmentProcess.vue";
import {getProductCategory, getProducts} from "@/components/shop/Products.vue"; import {getProductCategory, getProducts} from "@/components/shop/Products.vue";
import {createOrderItem} from "@/components/shop/OrdersItems.vue"; import {createOrderItem} from "@/components/shop/OrdersItems.vue";
@@ -151,6 +153,10 @@ const fetchProductsForCategory = async ({
products.value = []; products.value = [];
try { try {
if (customerNumber) {
await loadCustomerAttributes(customerNumber);
}
const response = normalizedCategory === null const response = normalizedCategory === null
? await getProducts(departmentId, props.finalPrice, customerNumber) ? await getProducts(departmentId, props.finalPrice, customerNumber)
: await getProductCategory(normalizedCategory, departmentId, props.finalPrice, customerNumber); : await getProductCategory(normalizedCategory, departmentId, props.finalPrice, customerNumber);
@@ -593,7 +599,7 @@ const getRecommendedProductPrice = (productId) => {
const isRecommendedProductCategoryAllowed = (productId) => { const isRecommendedProductCategoryAllowed = (productId) => {
const product = getProductById(productId); const product = getProductById(productId);
return !!product && productCategoryAllowed(product.category); return !!product && !isProductRestricted(product);
}; };
const addRecommendedProductToOrder = async (productId) => { const addRecommendedProductToOrder = async (productId) => {
+9 -51
View File
@@ -10,6 +10,10 @@ import { getAttributes } from "@/components/shop/CustomerAttributes.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue"; import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { doesOrderContainWashCertificateProduct } from "@/components/displays/department/pos/utils/washCertificate.js"; import { doesOrderContainWashCertificateProduct } from "@/components/displays/department/pos/utils/washCertificate.js";
import {
isProductCategoryRestrictedForCustomer,
isProductRestrictedForCustomer,
} from "@/features/customer/customerProductRules.js";
import Swal from "sweetalert2"; import Swal from "sweetalert2";
/** /**
@@ -153,6 +157,8 @@ export const nextStep = async (options = { isMobile: false, orderCreation: true
clearErrors(); clearErrors();
// If the current step is 1, create the order // If the current step is 1, create the order
if (step.value === 1) { if (step.value === 1) {
await loadCustomerAttributes(customer_id.value);
if (normalizedOptions.orderCreation === false) { if (normalizedOptions.orderCreation === false) {
step.value++; step.value++;
return; return;
@@ -1613,24 +1619,7 @@ export const customerUsesPONumbers = () => {
/** Check if the product category is allowed */ /** Check if the product category is allowed */
export const productCategoryAllowed = (category) => { export const productCategoryAllowed = (category) => {
// Check if the product category is the addons category return !isProductCategoryRestrictedForCustomer(category, customer_attributes.value);
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;
}; };
/** Check if the customer should be billed per order, or per billing period */ /** 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 */ /** Check if a product is restricted based on customer attributes */
export const isProductRestricted = (product) => { export const isProductRestricted = (product) => {
if (!product) return false; return isProductRestrictedForCustomer(product, customer_attributes.value);
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;
}; };
/** Check if an addon is restricted based on customer attributes */ /** Check if an addon is restricted based on customer attributes */
export const isAddonRestricted = (addon) => { export const isAddonRestricted = (addon) => {
if (!addon) return false; return isProductRestrictedForCustomer(addon, customer_attributes.value);
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;
}; };
export function loadOrderDetails(onAfterSuccess = null, options = {}) { 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;
};
+67
View File
@@ -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("country_code");
await expect(phoneValue).not.toContainText("number"); 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);
});
+35
View File
@@ -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);
});
});
+5
View File
@@ -54,6 +54,11 @@ describe("Playwright PR mapping", () => {
expect(triggersFallback("scripts/run-playwright-full-slice.mjs")).toBe(false); 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", () => { it("maps limited backoffice changes to the limited backoffice E2E coverage", () => {
expect(specsFor("src/views/backoffice/LimitedBackofficeEmployees.vue")).toContain( expect(specsFor("src/views/backoffice/LimitedBackofficeEmployees.vue")).toContain(
"tests/e2e/limited-backoffice.spec.ts" "tests/e2e/limited-backoffice.spec.ts"