From 1a1c3db8e55d9ac7bae602bffa2a220a67cf57cc Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 10:33:03 +0200 Subject: [PATCH 01/11] Fix invoicing period selfwash counts --- .../pos/sync/xlvaskUsageOrdersTable.vue | 4 +- .../pos/sync/xlvaskUsagePeriodConstants.js | 1 + .../DepartmentPos/XLVaskUsagePagination.vue | 40 +++++++++++-------- .../displays/layout/Right.vue | 3 +- .../InvoicingBillingPeriodImportPaging.js | 2 +- tests/e2e/invoicing-period.smoke.spec.js | 6 ++- ...cing-period-queue-refresh.behavior.spec.js | 36 +++++++++++++++++ tests/unit/superuser-invoices-view.spec.js | 16 +++++++- 8 files changed, 86 insertions(+), 22 deletions(-) create mode 100644 src/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js diff --git a/src/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue b/src/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue index 23f80846..008031d9 100644 --- a/src/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue +++ b/src/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue @@ -22,7 +22,7 @@ const props = defineProps({ import {computed, ref, watch} from 'vue'; import { departments, getDepartments, getDepartmentName} from "@/components/pagination/departmentTabs.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue"; -import {orderBy, orderDirection, setOrder, loadList} from "@/components/pagination/paginatedList.vue"; +import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue"; import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue"; import Swal from "sweetalert2"; import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue"; @@ -38,6 +38,8 @@ import { setCachedXlvaskUsageAmount } from "@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js"; +const { orderBy, orderDirection, setOrder, loadList } = usePaginatedListInstance(); + const redirectDepartmentOrderPage = (orderId, departmentId) => { // Send the user to the order page (In a new tab) // `/admin/${departmentId}/modules/pos/orders/${orderId}` diff --git a/src/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js b/src/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js new file mode 100644 index 00000000..b991f626 --- /dev/null +++ b/src/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js @@ -0,0 +1 @@ +export const SELFWASH_PERIOD_ALL_LIMIT = 10000; diff --git a/src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue b/src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue index f148fe44..97521f21 100644 --- a/src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue +++ b/src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue @@ -21,9 +21,31 @@ const props = defineProps({ type: Number, default: 0 } }) -import {computed, ref, watch } from "vue"; +import {computed, provide, ref, watch } from "vue"; import { useRouter } from "vue-router"; import { + PaginatedListKey, + usePaginatedList, +} from "@/components/pagination/paginatedList.vue"; +import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue"; + +import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"; +import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue"; +import XlvaskUsageOrdersTable from "@/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue"; +import PaginationDisplayTemplateDates + from "@/components/displays/pagination/templates/PaginationDisplayTemplateDates.vue"; +import ShowErrorField from "@/components/global/ShowErrorField.vue"; +import { useI18n } from 'vue-i18n' +import { BSwitch } from "buefy"; +import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js"; +import { SELFWASH_PERIOD_ALL_LIMIT } from "@/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js"; + +const { t } = useI18n() +const router = useRouter(); +const paginatedList = usePaginatedList(); +provide(PaginatedListKey, paginatedList); + +const { isLoading, list, loadList, @@ -38,22 +60,8 @@ import { setOrder, hideSearchField, setHideSearchField, -} from "@/components/pagination/paginatedList.vue"; -import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue"; +} = paginatedList; -import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"; -import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue"; -import XlvaskUsageOrdersTable from "@/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue"; -import PaginationDisplayTemplateDates - from "@/components/displays/pagination/templates/PaginationDisplayTemplateDates.vue"; -import ShowErrorField from "@/components/global/ShowErrorField.vue"; -import { useI18n } from 'vue-i18n' -import { BSwitch } from "buefy"; -import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js"; - -const { t } = useI18n() -const router = useRouter(); -const SELFWASH_PERIOD_ALL_LIMIT = 10000; setEndpoint("/modules/xlvask/services/usage/orders", false); if (props.loadAllAtOnce) { setMetaItemsPerPage(SELFWASH_PERIOD_ALL_LIMIT, false); diff --git a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/Right.vue b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/Right.vue index 58b990cd..1b0707e7 100644 --- a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/Right.vue +++ b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/Right.vue @@ -23,6 +23,7 @@ import { } from "../../imports/InvoicingBillingPeriodImportPaging.js"; import { buildPossibleDuplicateGroups } from "../../utils/possibleDuplicateGroups.js"; import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js"; +import { SELFWASH_PERIOD_ALL_LIMIT } from "@/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js"; const period_result = ref(null); const route = useRoute(); @@ -416,7 +417,7 @@ const loadSelfWashCounts = async ({ forceRefresh = false } = {}) => { 'GET', { page: 1, - limit: 500, + limit: SELFWASH_PERIOD_ALL_LIMIT, filters, order: 'StartTime:desc', }, diff --git a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js index 0665d064..ad90c97c 100644 --- a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js +++ b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js @@ -4,7 +4,7 @@ export const PERIOD_CACHE_TTL_MS = 10 * 60 * 1000; export const PERIOD_LIMIT_OPTIONS = [10, 25, 50, 100, 200, 500, "all"]; const CACHE_PREFIX = "invoicing-period-page:v2:"; -const SELF_WASH_COUNTS_CACHE_PREFIX = "invoicing-period-self-wash-counts:"; +const SELF_WASH_COUNTS_CACHE_PREFIX = "invoicing-period-self-wash-counts:v2:"; const DEFAULT_LIMIT = 100; const ALL_LIMIT = "all"; diff --git a/tests/e2e/invoicing-period.smoke.spec.js b/tests/e2e/invoicing-period.smoke.spec.js index 6ee85f7f..6c6d3543 100644 --- a/tests/e2e/invoicing-period.smoke.spec.js +++ b/tests/e2e/invoicing-period.smoke.spec.js @@ -1132,7 +1132,6 @@ test.describe("Invoicing period tab", () => { }; await openPeriodView(page); - await setEntireMarchPeriod(page); await page.route("**/modules/xlvask/services/usage/orders**", async (route) => { const path = new URL(route.request().url()).pathname; @@ -1242,8 +1241,10 @@ test.describe("Invoicing period tab", () => { ); }); + await setEntireMarchPeriod(page); + await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toBeVisible(); - await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toContainText("Selvvask (0/1)"); + await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toContainText("Selvvask (0/2)"); await expect(page.getByTestId("invoicing-period-view-selector-progress-self_wash")).toBeVisible(); await page.getByTestId("invoicing-period-view-selector-self_wash").click(); @@ -1273,6 +1274,7 @@ test.describe("Invoicing period tab", () => { await page.getByTestId("xlvask-automation-accept-8101").click(); await expect(page.getByTestId("xlvask-automation-suggestion-8101")).toContainText("Accepteret #7001"); expect(automationAcceptRequests).toEqual([{ suggestion_id: 7101 }]); + await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toContainText("Selvvask (1/2)"); await expect.poll(() => usageOrderRequests.length).toBeGreaterThan(0); expect(usageOrderRequests.some((request) => request.limit === "10000")).toBe(true); diff --git a/tests/unit/invoicing-period-queue-refresh.behavior.spec.js b/tests/unit/invoicing-period-queue-refresh.behavior.spec.js index 16b50b6f..bc838e08 100644 --- a/tests/unit/invoicing-period-queue-refresh.behavior.spec.js +++ b/tests/unit/invoicing-period-queue-refresh.behavior.spec.js @@ -755,6 +755,42 @@ describe("Invoicing period queue-driven refresh", () => { expect(selfWashCalls()).toHaveLength(1); }); + it("loads Selvvask selector counts with the full selected-period limit", async () => { + requestMock.mockImplementation((url) => { + if (url === "/superuser/invoicing/period") { + return Promise.resolve(emptyPeriodResponse()); + } + + return Promise.resolve( + selfWashResponse( + [ + { id: 8101, linked_order_id: 7001 }, + { + id: 8102, + linked_order_id: null, + automation: { + status: "accepted", + action: "attach_order", + }, + }, + { id: 8103, linked_order_id: null }, + ], + 3 + ) + ); + }); + + const wrapper = mountRight(); + await flushAll(); + + expect(wrapper.get("[data-testid='invoicing-period-view-selector-self_wash']").text()).toContain("Selvvask (2/3)"); + expect(selfWashCalls()[0][2]).toMatchObject({ + limit: 10000, + filters: "StartTime-date_from:2026-04-01,StartTime-date_to:2026-04-30", + order: "StartTime:desc", + }); + }); + it("ignores stale period responses that resolve after a newer search request", async () => { const deferred = []; requestMock.mockImplementation((url) => { diff --git a/tests/unit/superuser-invoices-view.spec.js b/tests/unit/superuser-invoices-view.spec.js index 2e07fe1f..66aad115 100644 --- a/tests/unit/superuser-invoices-view.spec.js +++ b/tests/unit/superuser-invoices-view.spec.js @@ -100,6 +100,10 @@ const xlvaskUsagePaginationSource = readFileSync( join(root, "src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue"), "utf8" ); +const xlvaskUsagePeriodConstantsSource = readFileSync( + join(root, "src/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js"), + "utf8" +); const xlvaskUsageOrdersTableSource = readFileSync( join(root, "src/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue"), "utf8" @@ -477,7 +481,7 @@ describe("Periode tab contract", () => { expect(periodViewSelfWashSource).toContain(':load-all-at-once="true"'); expect(xlvaskUsagePaginationSource).toContain("inheritPeriodFilters"); expect(xlvaskUsagePaginationSource).toContain("loadAllAtOnce"); - expect(xlvaskUsagePaginationSource).toContain("const SELFWASH_PERIOD_ALL_LIMIT = 10000"); + expect(xlvaskUsagePeriodConstantsSource).toContain("export const SELFWASH_PERIOD_ALL_LIMIT = 10000"); expect(xlvaskUsagePaginationSource).toContain("setMetaItemsPerPage(SELFWASH_PERIOD_ALL_LIMIT, false)"); expect(xlvaskUsagePaginationSource).toContain( "const shouldShowLocalFilters = computed(() => !props.inheritPeriodFilters)" @@ -494,6 +498,7 @@ describe("Periode tab contract", () => { expect(periodRightSource).toContain("getCachedSelfWashCounts"); expect(periodRightSource).toContain("setCachedSelfWashCounts"); expect(periodRightSource).toContain("'/modules/xlvask/services/usage/orders'"); + expect(periodRightSource).toContain("limit: SELFWASH_PERIOD_ALL_LIMIT"); expect(periodRightSource).toContain("StartTime-date_from:${dates.computed.formattedStartDate.value}"); expect(periodRightSource).toContain("StartTime-date_to:${dates.computed.formattedEndDate.value}"); expect(periodRightSource).toContain("if (type.name === 'self_wash')"); @@ -502,6 +507,15 @@ describe("Periode tab contract", () => { expect(periodRightSource).not.toContain("showProgress: false"); }); + it("keeps Selvvask usage pagination scoped to the period view", () => { + expect(xlvaskUsagePaginationSource).toContain("usePaginatedList"); + expect(xlvaskUsagePaginationSource).toContain("provide(PaginatedListKey, paginatedList)"); + expect(xlvaskUsageOrdersTableSource).toContain("usePaginatedListInstance"); + expect(xlvaskUsageOrdersTableSource).toContain( + "const { orderBy, orderDirection, setOrder, loadList } = usePaginatedListInstance();" + ); + }); + it("keeps Selvvask usage entries wrapped inside the period content area", () => { expect(xlvaskUsageOrdersTableSource).toContain("xlvask-usage-card-primary"); expect(xlvaskUsageOrdersTableSource).toContain("xlvask-usage-card-status"); From ce844137a0c44c2b5578dd9fda8ec88bdf424e7d Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 12:18:20 +0200 Subject: [PATCH 02/11] Harden POS order detail E2E wait --- tests/e2e/admin-pos-orders.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/admin-pos-orders.spec.ts b/tests/e2e/admin-pos-orders.spec.ts index 060b07d0..f2d2c515 100644 --- a/tests/e2e/admin-pos-orders.spec.ts +++ b/tests/e2e/admin-pos-orders.spec.ts @@ -316,7 +316,7 @@ async function openOrderDetail(page: Page, orderId = 54518) { for (let attempt = 0; attempt < 2; attempt += 1) { try { await page.goto(orderUrl, { waitUntil: "domcontentloaded", timeout: 30_000 }); - await expect(page.getByTestId("pos-order-detail")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("pos-order-detail")).toBeVisible({ timeout: 20_000 }); return; } catch (error) { if (attempt === 1) { From 2fd0893773175f67d4f58b34f4ccc23cd939c7c4 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 12:52:31 +0200 Subject: [PATCH 03/11] Add customer product fixed price UI --- .../CustomerDiscountsDepartmentDisplay.vue | 24 +++- .../CustomerProductDiscountDisplay.vue | 66 ++++++++- .../department/pos/SelectProductsFormPOS.vue | 11 +- src/components/shop/POSDepartmentProcess.vue | 53 +++++++- .../page/headers/menu/systemSearchSupport.ts | 3 +- .../user/SuperUserSelectedUserObject.vue | 45 ++++++- .../superUserDashboard/user/UserPricing.vue | 83 +++++++++++- .../unit/customer-product-fixed-price.spec.js | 125 ++++++++++++++++++ .../system-search-support-registry.spec.js | 2 + 9 files changed, 387 insertions(+), 25 deletions(-) create mode 100644 tests/unit/customer-product-fixed-price.spec.js diff --git a/src/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue b/src/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue index 6c7f53d2..6797183d 100644 --- a/src/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue +++ b/src/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue @@ -24,8 +24,24 @@ const onCustomerChange = () => { isCustomerSelected.value = false; } -const parseValue = (value) => { - let tmp_value = parseFloat(value).toFixed(2); +const parseFixedPriceValue = (value) => { + if (value === null || value === undefined || value === '') { + return null; + } + + const parsed = Number.parseInt(String(value), 10); + return Number.isFinite(parsed) ? parsed : null; +} + +const formatNumber = (value) => Number.isInteger(value) ? String(value) : value.toFixed(2); + +const parseValue = (discount) => { + const fixedPrice = parseFixedPriceValue(discount.fixed_price); + if (fixedPrice !== null) { + return `${formatNumber(fixedPrice)} Kr.`; + } + + let tmp_value = parseFloat(discount.percentage).toFixed(2); // Add the percentage sign return `${tmp_value}%`; } @@ -78,7 +94,7 @@ watch(customer_id, onCustomerChange, { immediate: true }); - {{ parseValue(discount.percentage) }} + {{ parseValue(discount) }} @@ -86,4 +102,4 @@ watch(customer_id, onCustomerChange, { immediate: true }); \ No newline at end of file + diff --git a/src/components/displays/department/pos/displays/CustomerProductDiscountDisplay.vue b/src/components/displays/department/pos/displays/CustomerProductDiscountDisplay.vue index edc7d689..1d8f44c9 100644 --- a/src/components/displays/department/pos/displays/CustomerProductDiscountDisplay.vue +++ b/src/components/displays/department/pos/displays/CustomerProductDiscountDisplay.vue @@ -29,6 +29,8 @@ const discount_product = ref(null); const discount_category = ref(null); // The global discount (if any) const discount_global = ref(null); +// The fixed product price (if any) +const fixed_product_price = ref(null); // Show the discounts dropdown const showDiscountsDropdown = ref(false); @@ -49,6 +51,19 @@ const hasGlobalDiscount = () => { return discount_global.value > 0; } +const hasFixedProductPrice = () => { + return fixed_product_price.value !== null; +} + +const parseFixedPriceValue = (value) => { + if (value === null || value === undefined || value === '') { + return null; + } + + const parsed = Number.parseInt(String(value), 10); + return Number.isFinite(parsed) ? parsed : null; +} + // Set the product discount const setProductDiscount = (percentage) => { // Check if the percentage is a number @@ -83,6 +98,16 @@ const setGlobalDiscount = (percentage) => { discount_global.value = percentage; } +const setFixedProductPrice = (fixedPrice) => { + const parsedFixedPrice = parseFixedPriceValue(fixedPrice); + if (parsedFixedPrice === null) { + fixed_product_price.value = null; + return; + } + + fixed_product_price.value = parsedFixedPrice; +} + // Debugging function const getDiscountDebug = () => { @@ -101,9 +126,15 @@ const getDiscountDebug = () => { // Parse the customer discounts const parseCustomerDiscounts = () => { - const tmp_discount_product = props.customer_discounts.find((discount) => discount.product_or_category_id === props.product.id && !discount.is_category) - const tmp_discount_category = props.customer_discounts.find((discount) => discount.product_or_category_id === props.product.category && discount.is_category); - const tmp_discount_global = props.customer_discounts.find((discount) => discount.id === 999999 && discount.is_category); + discount_product.value = null; + discount_category.value = null; + discount_global.value = null; + fixed_product_price.value = null; + + const customerDiscounts = Array.isArray(props.customer_discounts) ? props.customer_discounts : []; + const tmp_discount_product = customerDiscounts.find((discount) => discount.product_or_category_id == props.product.id && Number(discount.is_category) === 0) + const tmp_discount_category = customerDiscounts.find((discount) => discount.product_or_category_id == props.product.category && Number(discount.is_category) === 1); + const tmp_discount_global = customerDiscounts.find((discount) => discount.id === 999999 && Number(discount.is_category) === 1); //console.log("Product discount: ", tmp_discount_product); //console.log("Category discount: ", tmp_discount_category); @@ -112,6 +143,7 @@ const parseCustomerDiscounts = () => { // Set the product discount if (tmp_discount_product) { setProductDiscount(tmp_discount_product.percentage); + setFixedProductPrice(tmp_discount_product.fixed_price); } // Set the category discount if (tmp_discount_category) { @@ -125,6 +157,10 @@ const parseCustomerDiscounts = () => { // Get the best discount for the customer const getBestDiscount = () => { + if (hasFixedProductPrice()) { + highestEligibleDiscount.value = 0; + return 0; + } // Set the best discount to 0 let tmp_best_discount = 0; // Check if the product discount is higher than the current best discount @@ -182,13 +218,32 @@ watch(() => props.customer_discounts, () => { {{ fixed_product_price }} Kr. + -{{ highestEligibleDiscount }}% @@ -254,4 +310,4 @@ watch(() => props.customer_discounts, () => { .text-can-not-select { user-select: none; } - \ No newline at end of file + diff --git a/src/components/forms/department/pos/SelectProductsFormPOS.vue b/src/components/forms/department/pos/SelectProductsFormPOS.vue index dd9dec67..c9f62f86 100644 --- a/src/components/forms/department/pos/SelectProductsFormPOS.vue +++ b/src/components/forms/department/pos/SelectProductsFormPOS.vue @@ -6,7 +6,7 @@ import { loadOrderItems, productCategoryAllowed, setProductsCategory, - getUserProductDiscount, + getUserProductPrice, showFakeCreateOrderItem, department_id, user_discounts, @@ -549,10 +549,9 @@ const addPreviousOrderToCurrent = async (previousOrder) => { continue; } - const previousOrderProduct = getPreviousOrderProduct(orderItem); - const discount = getUserProductDiscount(previousOrderProduct || {}); + const previousOrderProduct = getPreviousOrderProduct(orderItem) || { id: productId, price: getPreviousOrderProductPrice(orderItem) }; const basePrice = getPreviousOrderProductPrice(orderItem); - const discountedPrice = (basePrice - (basePrice * (discount / 100))).toFixed(0); + const discountedPrice = String(getUserProductPrice({ ...previousOrderProduct, price: basePrice })); showFakeCreateOrderItem(productId, quantity, discountedPrice); await createOrderItem(orderId, productId, quantity); @@ -586,9 +585,7 @@ const getRecommendedProductPrice = (productId) => { return '0'; } - const discount = getUserProductDiscount(product); - const price = Number(product.price ?? 0); - return (price - (price * (discount / 100))).toFixed(0); + return String(getUserProductPrice(product)); }; const isRecommendedProductCategoryAllowed = (productId) => { diff --git a/src/components/shop/POSDepartmentProcess.vue b/src/components/shop/POSDepartmentProcess.vue index e9bf9be8..6a641140 100644 --- a/src/components/shop/POSDepartmentProcess.vue +++ b/src/components/shop/POSDepartmentProcess.vue @@ -1844,13 +1844,30 @@ export const getUserDiscounts = async () => { }); }; -/** Get user product discount */ -export const getUserProductDiscount = (product, allowCategory = null, onlyCategory = null) => { - // Check if the user discounts are loaded +const ensureUserDiscountsLoaded = () => { if (!has_user_discounts_loaded.value) { has_user_discounts_loaded.value = true; getUserDiscounts().then(() => {}); } +}; + +const isDirectCustomerProductDiscount = (discount, productId) => { + return discount?.product_or_category_id == productId && Number(discount?.is_category) === 0; +}; + +const parseFixedPriceValue = (value) => { + if (value === null || value === undefined || value === '') { + return null; + } + + const parsed = Number.parseInt(String(value), 10); + return Number.isFinite(parsed) ? parsed : null; +}; + +/** Get user product discount */ +export const getUserProductDiscount = (product, allowCategory = null, onlyCategory = null) => { + // Check if the user discounts are loaded + ensureUserDiscountsLoaded(); // Get the product's id const productId = product.id; @@ -1948,6 +1965,36 @@ export const getUserProductDiscount = (product, allowCategory = null, onlyCatego return appliedDiscount; }; +export const getUserProductFixedPrice = (product) => { + ensureUserDiscountsLoaded(); + + const productId = product?.id; + if (productId === null || productId === undefined) { + return null; + } + + const fixedPriceDiscount = (Array.isArray(user_discounts.value) ? user_discounts.value : []).find((discount) => ( + isDirectCustomerProductDiscount(discount, productId) && parseFixedPriceValue(discount.fixed_price) !== null + )); + + return fixedPriceDiscount ? parseFixedPriceValue(fixedPriceDiscount.fixed_price) : null; +}; + +export const getUserProductPrice = (product) => { + const fixedPrice = getUserProductFixedPrice(product); + if (fixedPrice !== null) { + return fixedPrice; + } + + const price = Number(product?.price ?? 0); + const discount = Number(getUserProductDiscount(product) || 0); + if (!Number.isFinite(price)) { + return 0; + } + + return Number((price - (price * (discount / 100))).toFixed(0)); +}; + export const showFakeCreateOrderItem = (product, quantity, price) => { order_items.value.push({ id: Math.floor(Math.random() * 1000), diff --git a/src/components/viewport/page/headers/menu/systemSearchSupport.ts b/src/components/viewport/page/headers/menu/systemSearchSupport.ts index f451f665..47ad381b 100644 --- a/src/components/viewport/page/headers/menu/systemSearchSupport.ts +++ b/src/components/viewport/page/headers/menu/systemSearchSupport.ts @@ -189,7 +189,7 @@ const descriptionKeyOverrides: Partial> = { xlvask_customers: ['email', 'city', 'address', 'vatnumber'], motorapi_lookups: ['endpoint', 'license_plate', 'result'], fxratesapi_conversion_rates: ['base', 'target', 'endpoint', 'result'], - customer_discounts: ['customer_number', 'percentage'], + customer_discounts: ['customer_number', 'fixed_price', 'percentage'], module_config: ['description', 'value'], department_goals: ['criteria.type', 'criteria.progress_alert_frequency', 'criteria.progress_alert_destination'] }; @@ -251,6 +251,7 @@ const keyFieldOverrides: Partial> = { ), customer_discounts: fieldList( field('Discount', 'discount', 'percentage'), + field('Fixed price', 'fixed_price'), field('Target', 'product_or_category_id', 'object_id'), field('Category', 'is_category'), field('Customer #', 'customer_number'), diff --git a/src/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue b/src/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue index 7749b619..28a4d523 100644 --- a/src/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue +++ b/src/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue @@ -61,6 +61,19 @@ export const getUserData = async () => { }); }; +const isDirectProductDiscount = (discount, productId) => { + return discount?.product_or_category_id == productId && Number(discount?.is_category) === 0; +}; + +const parseFixedPriceValue = (value) => { + if (value === null || value === undefined || value === '') { + return null; + } + + const parsed = Number.parseInt(String(value), 10); + return Number.isFinite(parsed) ? parsed : null; +}; + /** Get user product discount */ export const getUserProductDiscount = (product, allowCategory = 1, onlyCategory = 0, allowGlobal = 0) => { // Get the product id @@ -133,12 +146,25 @@ export const getUserProductOnlyDiscount = (product) => { // Find the discount for the product let discount = null; try { - discount = user.discounts.value.find(discount => discount.product_or_category_id == productId && !discount.is_category) || null; + discount = user.discounts.value.find(discount => isDirectProductDiscount(discount, productId)) || null; } catch (e) {} // If the discount is not found, return 0 return discount ? discount.percentage : 0; }; +export const getUserProductFixedPrice = (product) => { + const productId = product.id; + let fixedPriceDiscount = null; + + try { + fixedPriceDiscount = user.discounts.value.find((discount) => ( + isDirectProductDiscount(discount, productId) && parseFixedPriceValue(discount.fixed_price) !== null + )) || null; + } catch (e) {} + + return fixedPriceDiscount ? parseFixedPriceValue(fixedPriceDiscount.fixed_price) : null; +}; + export const isProductAllowedToApplyCategoryDiscounts = (product) => { // Check if the product is allowed to apply category discounts return parseInt(product.apply_category_discount) === 1; @@ -166,6 +192,21 @@ export const getProductBestApplicableDiscount = (product) => { return Math.max(productDiscount, categoryDiscount, globalDiscount); } +export const getProductEffectivePrice = (product) => { + const fixedPrice = getUserProductFixedPrice(product); + if (fixedPrice !== null) { + return fixedPrice; + } + + const basePrice = Number(product.price ?? 0); + const discount = Number(getProductBestApplicableDiscount(product) || 0); + if (!Number.isFinite(basePrice)) { + return 0; + } + + return Number((basePrice - (basePrice * (discount / 100))).toFixed(0)); +} + export const user = { id: ref(''), customer_number: ref(''), @@ -199,9 +240,11 @@ export const user = { getUserCategoryDiscount: getUserCategoryDiscount, getUserGlobalDiscount: getUserGlobalDiscount, getUserProductOnlyDiscount: getUserProductOnlyDiscount, + getUserProductFixedPrice: getUserProductFixedPrice, isProductAllowedToApplyCategoryDiscounts: isProductAllowedToApplyCategoryDiscounts, getProductCategory: getProductCategory, getProductBestApplicableDiscount: getProductBestApplicableDiscount, + getProductEffectivePrice: getProductEffectivePrice, }, }; diff --git a/src/views/dashboards/superUserDashboard/user/UserPricing.vue b/src/views/dashboards/superUserDashboard/user/UserPricing.vue index cab8ec91..322e74fa 100644 --- a/src/views/dashboards/superUserDashboard/user/UserPricing.vue +++ b/src/views/dashboards/superUserDashboard/user/UserPricing.vue @@ -3,7 +3,7 @@ import UserSubPageWrapper from "@/views/dashboards/superUserDashboard/user/UserSubPageWrapper.vue"; import PageTitle from "@/components/global/PageTitle.vue"; import { useRouter } from 'vue-router' -import { user, getUserData, userId, setUser, getUserProductDiscount, getUserGlobalDiscount, getProductBestApplicableDiscount, getUserProductOnlyDiscount, getProductCategory, getUserCategoryDiscount, isProductAllowedToApplyCategoryDiscounts } from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue"; +import { user, getUserData, userId, setUser, getUserProductDiscount, getUserGlobalDiscount, getProductBestApplicableDiscount, getUserProductOnlyDiscount, getUserProductFixedPrice, getProductEffectivePrice, getProductCategory, getUserCategoryDiscount, isProductAllowedToApplyCategoryDiscounts } from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue"; import { getProducts } from "@/components/shop/Products.vue"; import { ref } from 'vue'; import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue"; @@ -56,6 +56,72 @@ const editDiscount = (product, isCategory) => { allowOutsideClick: () => !Swal.isLoading() }); } + +const formatPrice = (price) => `${Number(price || 0).toFixed(0)} Kr.`; + +const getProductPriceDisplay = (product) => { + const fixedPrice = getUserProductFixedPrice(product); + if (fixedPrice !== null) { + return `${formatPrice(fixedPrice)} (fast pris)`; + } + + const discount = getProductBestApplicableDiscount(product); + if (discount === 0) { + return formatPrice(product.price); + } + + return `${formatPrice(getProductEffectivePrice(product))} (${discount}% rabat)`; +}; + +const editFixedPrice = (product) => { + const fixedPrice = getUserProductFixedPrice(product); + Swal.fire({ + title: product.name + ' ( product: ' + product.id + ' )', + input: 'number', + inputValue: fixedPrice === null ? '' : fixedPrice, + inputLabel: 'Fast pris (Kr.)', + inputAttributes: { + autocapitalize: 'off', + min: 0, + step: 1 + }, + showCancelButton: true, + confirmButtonText: 'Save', + showLoaderOnConfirm: true, + inputValidator: (value) => { + const normalizedValue = String(value ?? '').trim(); + if (normalizedValue === '') { + return null; + } + + const parsedValue = Number(normalizedValue); + if (!Number.isInteger(parsedValue) || parsedValue < 0) { + return 'Fast pris skal være et heltal eller tom.'; + } + + return null; + }, + preConfirm: (value) => { + const normalizedValue = String(value ?? '').trim(); + const fixedPriceValue = normalizedValue === '' ? null : Number.parseInt(normalizedValue, 10); + return authenticatedRequest(`/superuser/user/discounts`, "POST", { + user_id: userId.value, + object_id: product.id, + discount: getUserProductOnlyDiscount(product), + fixed_price: fixedPriceValue, + is_category: false + }) + .then((response) => { + console.log(response); + getUserData(); + }) + .catch((error) => { + console.log(error); + }); + }, + allowOutsideClick: () => !Swal.isLoading() + }); +} @@ -882,7 +1048,24 @@ onMounted(() => { } .limited-employees-table__actions { - width: 190px; + width: 300px; +} + +.limited-login-link-modal { + width: 100%; + max-width: 420px; +} + +.limited-login-link-modal__qr { + display: flex; + justify-content: center; + margin-bottom: 1rem; +} + +.limited-login-link-modal__qr img { + width: 240px; + max-width: 100%; + height: auto; } .limited-role-permissions-modal { diff --git a/tests/e2e/limited-backoffice.spec.ts b/tests/e2e/limited-backoffice.spec.ts index 69c08cd7..3f90ab73 100644 --- a/tests/e2e/limited-backoffice.spec.ts +++ b/tests/e2e/limited-backoffice.spec.ts @@ -289,6 +289,7 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData, opt const priceUpdateCalls: unknown[] = []; const employeeCreateCalls: unknown[] = []; const employeeUpdateCalls: unknown[] = []; + const loginLinkCalls: string[] = []; const currentPricePayload = { value: cloneJson(options.pricePayload ?? pricePayload), }; @@ -400,6 +401,27 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData, opt return; } + const loginLinkMatch = pathname.match(/\/limited-backoffice\/employees\/(\d+)\/login-link$/); + if (loginLinkMatch && method === "POST") { + const employeeId = loginLinkMatch[1]; + const status = options.loginLinkStatus ?? 200; + loginLinkCalls.push(employeeId); + await route.fulfill( + json( + status === 200 + ? { + data: { + employee_id: Number(employeeId), + login_path: `/login/qr?token=${"a".repeat(64)}`, + }, + } + : { message: options.loginLinkMessage ?? "Could not create employee login link." }, + status + ) + ); + return; + } + const employeeMatch = pathname.match(/\/limited-backoffice\/employees\/(\d+)$/); if (employeeMatch && method === "PUT") { const body = request.postDataJSON?.() || {}; @@ -436,6 +458,7 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData, opt priceUpdateCalls, employeeCreateCalls, employeeUpdateCalls, + loginLinkCalls, }; } @@ -710,6 +733,49 @@ test.describe("Limited backoffice", () => { expect(api.forbiddenCalls).toEqual([]); }); + test("generates an employee QR login link", async ({ page }, testInfo) => { + test.skip(!isDesktopProject(testInfo), "Desktop only"); + + await seedLimitedBackofficeSession(page); + const api = await mockLimitedBackofficeApi(page); + + await page.goto("/backoffice/employees"); + + await page.getByTestId("limited-employee-login-link-501").click(); + + const modal = page.getByTestId("limited-login-link-modal"); + await expect(modal).toBeVisible(); + await expect(modal).toContainText("Employee login link"); + await expect(modal).toContainText("Casey Clerk"); + await expect(page.getByTestId("limited-login-link-qr")).toHaveAttribute("src", /^data:image\/png;base64,/); + await expect(page.getByTestId("limited-login-link-url")).toHaveValue(/\/login\/qr\?token=a{64}$/); + + await page.getByTestId("limited-login-link-copy").click(); + + await expect(page.getByTestId("limited-login-link-copy-message")).toHaveText("Link copied."); + expect(api.loginLinkCalls).toEqual(["501"]); + expect(api.forbiddenCalls).toEqual([]); + }); + + test("shows an error when employee QR login link generation fails", async ({ page }, testInfo) => { + test.skip(!isDesktopProject(testInfo), "Desktop only"); + + await seedLimitedBackofficeSession(page); + const api = await mockLimitedBackofficeApi(page, sessionData, { + loginLinkMessage: "Missing employee management permission.", + loginLinkStatus: 403, + }); + + await page.goto("/backoffice/employees"); + + await page.getByTestId("limited-employee-login-link-501").click(); + + await expect(page.getByTestId("limited-employees-error")).toHaveText("Missing employee management permission."); + await expect(page.getByTestId("limited-login-link-modal")).toHaveCount(0); + expect(api.loginLinkCalls).toEqual(["501"]); + expect(api.forbiddenCalls).toEqual([]); + }); + test("shows grouped human-readable role permission help without raw permission keys", async ({ page }, testInfo) => { await seedLimitedBackofficeSession(page); await mockLimitedBackofficeApi(page); From aa5e0a55075d741e1a0763b5926aead8ab961e4a Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 6 Jul 2026 18:12:05 +0200 Subject: [PATCH 11/11] Add EAN to customer registration flows (#147) * Add EAN to economic customer registration * Focus EAN PR Playwright mapping --------- Co-authored-by: Jeppe Bundgaard --- openapi.yaml | 12 ++++++++ scripts/playwright-pr-mapping.mjs | 1 + ...osDepartmentStepMobilePopupAddCustomer.vue | 19 ++++++++++++ .../mobile/elements/posAddCustomerAutofill.js | 4 +++ .../forms/auth/CustomerRegistrationForm.vue | 29 +++++++++++++++++-- .../pos/input/customerSearchFieldPos.vue | 18 ++++++++++++ src/composables/usePosAddCustomerForm.js | 11 +++++-- src/i18n/generated/da-v2.json | 2 ++ src/i18n/generated/de-v2.json | 2 ++ src/i18n/generated/en-v2.json | 2 ++ src/i18n/generated/global-v2.json | 2 ++ src/i18n/generated/no-v2.json | 2 ++ src/i18n/generated/sv-v2.json | 2 ++ .../da/phrases/compat/admin/index-01.json | 1 + .../compat/customer_creation/index.json | 1 + .../de/phrases/compat/admin/index-01.json | 1 + .../compat/customer_creation/index.json | 1 + .../en/phrases/compat/admin/index-01.json | 1 + .../compat/customer_creation/index.json | 1 + .../source/global/shared/admin/index-01.json | 1 + .../shared/customer_creation/index.json | 1 + .../no/phrases/compat/admin/index-01.json | 1 + .../compat/customer_creation/index.json | 1 + .../sv/phrases/compat/admin/index-01.json | 1 + .../compat/customer_creation/index.json | 1 + src/services/economicCustomerIdentifiers.js | 12 ++++++++ tests/e2e/pos-flow.spec.js | 6 ++++ tests/e2e/pos-mobile-order-flow.spec.js | 4 +++ tests/unit/customer-search-field-pos.spec.js | 3 ++ tests/unit/playwright-pr-mapping.spec.js | 7 +++++ .../pos-mobile-add-customer-autofill.spec.js | 22 ++++++++++++++ 31 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 src/services/economicCustomerIdentifiers.js diff --git a/openapi.yaml b/openapi.yaml index 5dac57d9..689e13cd 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2415,6 +2415,12 @@ paths: type: string description: Contact person name example: "Mikkel" + ean: + type: string + description: Optional EAN used for e-invoicing in e-conomic + maxLength: 13 + pattern: '^[0-9]{1,13}$' + example: "5790001234567" g_recaptcha_response: type: string description: reCAPTCHA verification token @@ -8189,6 +8195,12 @@ paths: email: {type: string} phone: {type: integer} name: {type: string} + ean: + type: string + description: Optional EAN used for e-invoicing in e-conomic + maxLength: 13 + pattern: '^[0-9]{1,13}$' + example: "5790001234567" responses: '200': description: Success diff --git a/scripts/playwright-pr-mapping.mjs b/scripts/playwright-pr-mapping.mjs index 43989110..b8e30b00 100644 --- a/scripts/playwright-pr-mapping.mjs +++ b/scripts/playwright-pr-mapping.mjs @@ -95,6 +95,7 @@ export const sourceMappings = [ /^src\/assets\/pos\.css$/u, /^src\/components\/displays\/boxes\/ProductBox\.vue$/u, /^src\/features\/customer\/customerProductRules\.js$/u, + /^src\/services\/economicCustomerIdentifiers\.js$/u, ], specs: [ "tests/e2e/pos-flow.spec.js", diff --git a/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupAddCustomer.vue b/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupAddCustomer.vue index 0eb8721e..0ce87f33 100644 --- a/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupAddCustomer.vue +++ b/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupAddCustomer.vue @@ -62,6 +62,7 @@ watch( form.invoiceEmail, form.contactEmail, form.contactPhone, + form.ean, canCreateCustomer.value, searchResult.value, errorMessage.value, @@ -124,6 +125,24 @@ watch( /> +
+ +
+ +
+
{ removeError("authRegisterCvr"); try { - await axios.post(API_URL + "/auth/register/cvr", { + const payload = { cvr: customerFormData.cvr, invoiceEmail: customerFormData.contactEmail, contactEmail: customerFormData.contactEmail, contactPhone: customerFormData.contactPhone, companyPhone: customerFormData.contactPhone - }); + }; + const ean = economicEanPayloadValue(customerFormData.ean); + if (ean !== undefined) { + payload.ean = ean; + } + + await axios.post(API_URL + "/auth/register/cvr", payload); customerFormData.contactPhone = null; customerFormData.contactEmail = null; customerFormData.cvr = null; + customerFormData.ean = null; deliverSuccess.value = true; deliverTimeoutId = setTimeout(() => { @@ -59,6 +68,20 @@ onBeforeUnmount(() => { >
+
+ + +
+
diff --git a/src/components/forms/department/pos/input/customerSearchFieldPos.vue b/src/components/forms/department/pos/input/customerSearchFieldPos.vue index 8e842731..e349c019 100644 --- a/src/components/forms/department/pos/input/customerSearchFieldPos.vue +++ b/src/components/forms/department/pos/input/customerSearchFieldPos.vue @@ -405,6 +405,24 @@ watch(activeQuickAction, (nextMode, previousMode) => { />
+
+ +
+ +
+
{ const invoiceEmail = page.getByTestId("pos-desktop-add-customer-invoice-email"); const contactEmail = page.getByTestId("pos-desktop-add-customer-contact-email"); const contactPhone = page.getByTestId("pos-desktop-add-customer-contact-phone"); + const ean = page.getByTestId("pos-desktop-add-customer-ean"); const submitButton = page.getByTestId("pos-desktop-add-customer-submit"); await expect(newCustomerButton).toBeVisible(); @@ -1517,15 +1519,18 @@ test.describe("POS flow", () => { await expect(invoiceEmail).toHaveValue("mikkel@truckwash.dk"); await expect(contactEmail).toHaveValue("mikkel@truckwash.dk"); await expect(contactPhone).toHaveValue("21754690"); + await expect(ean).toHaveValue(""); await companyPhone.fill("55550000"); await contactEmail.fill("dispatch@truckwash.test"); + await ean.fill("5790001234567"); await searchInput.fill("43423010"); await expect(companyPhone).toHaveValue("55550000", { timeout: 10_000 }); await expect(contactEmail).toHaveValue("dispatch@truckwash.test"); await expect(invoiceEmail).toHaveValue("billing@wash-group.test"); await expect(contactPhone).toHaveValue("42331128"); + await expect(ean).toHaveValue("5790001234567"); await expect(submitButton).toBeEnabled(); await submitButton.click(); @@ -1536,6 +1541,7 @@ test.describe("POS flow", () => { await expect(page.locator(".field.has-addons input[disabled]")).toHaveValue("Wash Group ApS", { timeout: 10_000 }); await expect(page.getByRole("button", { name: "Ryd" })).toBeVisible(); await expect.poll(() => fixture.customerRegistrations.length, { timeout: 10_000 }).toBe(1); + expect(fixture.customerRegistrations[0]).toMatchObject({ ean: "5790001234567" }); await expect.poll(() => fixture.cvrSearches.length, { timeout: 10_000 }).toBe(2); }); diff --git a/tests/e2e/pos-mobile-order-flow.spec.js b/tests/e2e/pos-mobile-order-flow.spec.js index 549b6f0e..42a2d411 100644 --- a/tests/e2e/pos-mobile-order-flow.spec.js +++ b/tests/e2e/pos-mobile-order-flow.spec.js @@ -1015,6 +1015,7 @@ test.describe("POS mobile order flow", () => { const invoiceEmail = page.getByTestId("pos-mobile-add-customer-invoice-email"); const contactEmail = page.getByTestId("pos-mobile-add-customer-contact-email"); const contactPhone = page.getByTestId("pos-mobile-add-customer-contact-phone"); + const ean = page.getByTestId("pos-mobile-add-customer-ean"); const submitButton = page.getByTestId("pos-mobile-add-customer-submit"); await searchInput.fill("41004355"); @@ -1022,10 +1023,12 @@ test.describe("POS mobile order flow", () => { await expect(invoiceEmail).toHaveValue("mikkel@truckwash.dk"); await expect(contactEmail).toHaveValue("mikkel@truckwash.dk"); await expect(contactPhone).toHaveValue("21754690"); + await expect(ean).toHaveValue(""); await expect(submitButton).toBeVisible({ timeout: 10_000 }); await companyPhone.fill("55550000"); await contactEmail.fill("dispatch@truckwash.test"); + await ean.fill("5790001234567"); await searchInput.fill("43423010"); @@ -1033,6 +1036,7 @@ test.describe("POS mobile order flow", () => { await expect(contactEmail).toHaveValue("dispatch@truckwash.test"); await expect(invoiceEmail).toHaveValue("billing@wash-group.test"); await expect(contactPhone).toHaveValue("42331128"); + await expect(ean).toHaveValue("5790001234567"); await expect(submitButton).toBeVisible({ timeout: 10_000 }); }); diff --git a/tests/unit/customer-search-field-pos.spec.js b/tests/unit/customer-search-field-pos.spec.js index ede6f9a1..def7cd6f 100644 --- a/tests/unit/customer-search-field-pos.spec.js +++ b/tests/unit/customer-search-field-pos.spec.js @@ -134,6 +134,7 @@ function mountComponent(props = {}) { invoice_email: "Invoice email", contact_email: "Contact email", contact_phone: "Contact phone", + ean: "EAN", add_customer: "Add customer", customer_creation_error: "Error: {error}", customer_creation_error_generic: "Unable to create customer", @@ -253,6 +254,7 @@ describe("CustomerSearchFieldPos", () => { invoiceEmail: "billing@wash-group.test", contactEmail: "dispatch@wash-group.test", contactPhone: 42331128, + ean: "5790001234567", }); return { @@ -286,6 +288,7 @@ describe("CustomerSearchFieldPos", () => { await wrapper.get('[data-testid="pos-desktop-add-customer-company-phone"]').setValue("55550000"); await wrapper.get('[data-testid="pos-desktop-add-customer-contact-email"]').setValue("dispatch@wash-group.test"); + await wrapper.get('[data-testid="pos-desktop-add-customer-ean"]').setValue("5790001234567"); await wrapper.get('[data-testid="pos-desktop-add-customer-submit"]').trigger("click"); await flushComponentUpdates(); diff --git a/tests/unit/playwright-pr-mapping.spec.js b/tests/unit/playwright-pr-mapping.spec.js index ca53177f..0658c056 100644 --- a/tests/unit/playwright-pr-mapping.spec.js +++ b/tests/unit/playwright-pr-mapping.spec.js @@ -91,4 +91,11 @@ describe("Playwright PR mapping", () => { "tests/e2e/limited-backoffice.spec.ts" ); }); + + it("maps e-conomic customer identifier changes to POS customer registration E2E coverage", () => { + const specs = specsFor("src/services/economicCustomerIdentifiers.js"); + + expect(specs).toContain("tests/e2e/pos-flow.spec.js"); + expect(specs).toContain("tests/e2e/pos-mobile-order-flow.spec.js"); + }); }); diff --git a/tests/unit/pos-mobile-add-customer-autofill.spec.js b/tests/unit/pos-mobile-add-customer-autofill.spec.js index fa60f2e6..77976a7a 100644 --- a/tests/unit/pos-mobile-add-customer-autofill.spec.js +++ b/tests/unit/pos-mobile-add-customer-autofill.spec.js @@ -35,6 +35,7 @@ describe("POS mobile add-customer autofill", () => { invoiceEmail: "mikkel@truckwash.dk", contactEmail: "mikkel@truckwash.dk", contactPhone: "21754690", + ean: "", }); }); @@ -69,6 +70,7 @@ describe("POS mobile add-customer autofill", () => { invoiceEmail: "billing@wash-group.test", contactEmail: "billing@wash-group.test", contactPhone: "42331128", + ean: "", }); }); @@ -76,6 +78,7 @@ describe("POS mobile add-customer autofill", () => { const manualOverrides = createPosAddCustomerManualOverrides({ companyPhone: true, contactEmail: true, + ean: true, }); const nextFields = applyPosAddCustomerSearchResult({ @@ -85,6 +88,7 @@ describe("POS mobile add-customer autofill", () => { invoiceEmail: "old-invoice@example.com", contactEmail: "dispatch@example.com", contactPhone: "21754690", + ean: "5790001234567", }), manualOverrides, searchResult: createSearchResult({ @@ -100,6 +104,24 @@ describe("POS mobile add-customer autofill", () => { expect(nextFields.invoiceEmail).toBe("billing@wash-group.test"); expect(nextFields.contactPhone).toBe("42331128"); expect(nextFields.cvr).toBe("43423010"); + expect(nextFields.ean).toBe("5790001234567"); + }); + + it("preserves manually entered EAN across later autofill passes", () => { + const nextFields = applyPosAddCustomerSearchResult({ + fields: createPosAddCustomerFields({ + ean: "5790001234567", + }), + manualOverrides: createPosAddCustomerManualOverrides({ + ean: true, + }), + searchResult: createSearchResult({ + ean: "5790007654321", + }), + notFoundLabel: "Not found", + }); + + expect(nextFields.ean).toBe("5790001234567"); }); it("refills a field after the operator clears a manual override", () => {