From 1548ae8cd5def898fab526255632b7da2e6fc02a Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Tue, 4 Aug 2026 18:13:23 +0200 Subject: [PATCH] Add multiple select customer product price recalculation (#261) Co-authored-by: Jeppe Bundgaard --- .../pos/order/PosOrderItemEditModal.vue | 9 +++- .../department/pos/SelectProductsFormPOS.vue | 16 +++++- .../token/SessionUser/Objects/Orders.vue | 1 + src/components/shop/OrdersItems.vue | 29 +++++++++-- .../components/InvoicingPeriodObjectTree.vue | 52 +++++++++++++++++++ tests/e2e/pos-mobile-order-flow.spec.js | 2 + tests/e2e/support/mobilePos.js | 3 ++ tests/e2e/support/network.js | 3 ++ .../unit/invoicing-period-object-tree.spec.js | 2 +- 9 files changed, 108 insertions(+), 9 deletions(-) diff --git a/src/components/displays/department/pos/order/PosOrderItemEditModal.vue b/src/components/displays/department/pos/order/PosOrderItemEditModal.vue index 77389de4..e0cc3301 100644 --- a/src/components/displays/department/pos/order/PosOrderItemEditModal.vue +++ b/src/components/displays/department/pos/order/PosOrderItemEditModal.vue @@ -2,7 +2,7 @@ import { computed, reactive, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { SessionUser } from "@/components/session/token/SessionUser.vue"; -import { editOrderItem } from "@/components/shop/OrdersItems.vue"; +import { buildAuditedOrderItemReasonPayload, editOrderItem } from "@/components/shop/OrdersItems.vue"; const props = defineProps({ modelValue: { @@ -85,7 +85,12 @@ const saveChanges = async () => { Number(form.price), form.notes, form.reference, - Number(form.quantity) + Number(form.quantity), + buildAuditedOrderItemReasonPayload(props.orderItem.product_id ?? props.orderItem.product?.id, form.notes, { + reason_code: props.orderItem.reason_code, + reason_label_snapshot: props.orderItem.reason_label_snapshot, + reason_comment: form.notes, + }) ); emits('saved'); } catch (error) { diff --git a/src/components/forms/department/pos/SelectProductsFormPOS.vue b/src/components/forms/department/pos/SelectProductsFormPOS.vue index d6251930..780cf3a7 100644 --- a/src/components/forms/department/pos/SelectProductsFormPOS.vue +++ b/src/components/forms/department/pos/SelectProductsFormPOS.vue @@ -20,7 +20,7 @@ import { loadCustomerAttributes } from "@/components/shop/POSDepartmentProcess.vue"; import {getProductCategory, getProducts} from "@/components/shop/Products.vue"; -import {createOrderItem} from "@/components/shop/OrdersItems.vue"; +import { AUDITED_ORDER_ITEM_PRODUCT_IDS, createOrderItem } from "@/components/shop/OrdersItems.vue"; import {SessionUser} from "@/components/session/token/SessionUser.vue"; import { useRoute } from 'vue-router'; import { useI18n } from "vue-i18n"; @@ -435,6 +435,18 @@ const showAddMultipleProducts = (productId) => { }); }; +const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi"; + +const productRequiresOrderItemNote = (product) => { + if (!product) { + return false; + } + + return Boolean(product.requires_note) + || AUDITED_ORDER_ITEM_PRODUCT_IDS.has(Number(product.id)) + || String(product.name || "").trim() === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME; +}; + // Define the categories const categories_static = ref([ { @@ -636,7 +648,7 @@ const addProductWithAddonsToOrder = async (product_id) => { return; } // Check if the product requires a note - if (product.requires_note) { + if (productRequiresOrderItemNote(product)) { // Show the note input await Swal.fire({ title: 'Tilføj en note', diff --git a/src/components/session/token/SessionUser/Objects/Orders.vue b/src/components/session/token/SessionUser/Objects/Orders.vue index 32c70be8..159d1b8b 100644 --- a/src/components/session/token/SessionUser/Objects/Orders.vue +++ b/src/components/session/token/SessionUser/Objects/Orders.vue @@ -864,6 +864,7 @@ const assignDraftOrderCustomer = async ({ */ showChangeCustomerForm: showChangeOrderCustomerForm, assignDraftCustomer: assignDraftOrderCustomer, + recalculateOrderItemPricesForCustomer, /** * Show the change invoice collection form * @param id (order id) diff --git a/src/components/shop/OrdersItems.vue b/src/components/shop/OrdersItems.vue index 50a8fa13..286d298f 100644 --- a/src/components/shop/OrdersItems.vue +++ b/src/components/shop/OrdersItems.vue @@ -3,6 +3,25 @@ import axios from 'axios' import {API_URL} from "@/config.js"; +export const AUDITED_ORDER_ITEM_PRODUCT_IDS = new Set([21, 22, 24, 25, 26, 27]); +export const DEFAULT_AUDITED_ORDER_ITEM_REASON_CODE = "customer_approved_extra_work"; +export const DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL = "Kunde godkendte ekstra arbejde"; + +export const buildAuditedOrderItemReasonPayload = (product_id, notes = null, reasonData = null) => { + if (!AUDITED_ORDER_ITEM_PRODUCT_IDS.has(Number(product_id))) { + return {}; + } + + const reason = reasonData && typeof reasonData === "object" ? reasonData : {}; + const comment = String(reason.reason_comment ?? reason.comment ?? notes ?? "").trim(); + + return { + reason_code: String(reason.reason_code || DEFAULT_AUDITED_ORDER_ITEM_REASON_CODE), + reason_label_snapshot: String(reason.reason_label_snapshot || DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL), + reason_comment: comment, + }; +}; + export const getOrderItems = (order_id) => { const token = localStorage.getItem('token'); if (!token) { @@ -15,7 +34,7 @@ export const getOrderItems = (order_id) => { }); }; -export const createOrderItem = (order_id, product_id, quantity, related_item_id = null, notes = null, forcePrice = null) => { +export const createOrderItem = (order_id, product_id, quantity, related_item_id = null, notes = null, forcePrice = null, reasonData = null) => { const token = localStorage.getItem('token'); if (!token) { return null; @@ -29,7 +48,8 @@ export const createOrderItem = (order_id, product_id, quantity, related_item_id product_id, quantity, related_item_id, - notes + notes, + ...buildAuditedOrderItemReasonPayload(product_id, notes, reasonData), }; if (forcePrice !== null && forcePrice !== undefined) { payload.price = forcePrice; @@ -53,7 +73,7 @@ export const removeOrderItem = (id) => { }); }; -export const editOrderItem = (id, price, notes, reference, quantity) => { +export const editOrderItem = (id, price, notes, reference, quantity, reasonData = null) => { const token = localStorage.getItem('token'); if (!token) { return null; @@ -63,7 +83,8 @@ export const editOrderItem = (id, price, notes, reference, quantity) => { price, notes, reference, - quantity + quantity, + ...(reasonData && typeof reasonData === "object" ? reasonData : {}), }, { headers: { Authorization: `Bearer ${token}` diff --git a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue index ded388c1..0090c8f7 100644 --- a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue +++ b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue @@ -481,6 +481,17 @@ const orderIdsFromNodes = (nodes: TreeNode[]) => orderNodesFromNodes(nodes) .map((node) => node.meta.orderId) .filter(Boolean); +const orderRepricingTargetsFromNodes = (nodes: TreeNode[]) => + orderNodesFromNodes(nodes) + .map((node) => { + const order = node.meta?.order || {}; + return { + orderId: toPositiveInteger(node.meta?.orderId), + departmentId: toPositiveInteger(order.department_id ?? node.meta?.departmentId), + customerNumber: toPositiveInteger(order.customer_number ?? props.customer?.customer_number), + }; + }) + .filter((target) => target.orderId && target.departmentId && target.customerNumber); const orderItemIdsFromNodes = (nodes: TreeNode[]) => uniqueValues(nodesOfType(nodes, TREE_NODE_TYPES.ORDER_ITEM).map((node) => node.meta.itemId)); const attachmentNodesFromNodes = (nodes: TreeNode[]) => @@ -517,6 +528,7 @@ const selectedOrdersWithBookingCount = computed( () => orderNodes().filter((node: TreeNode) => node.meta.bookingId).length ); const selectedOrdersWithWashCount = computed(() => orderNodes().filter((node: TreeNode) => node.meta.washId).length); +const selectedOrderRepricingCount = computed(() => orderRepricingTargetsFromNodes(selectedNodes.value).length); const certificateAttachmentOrderIds = computed(() => certificateAttachmentOrderIdsFromNodes(selectedNodes.value)); const actionableXlvaskNodes = computed(() => actionableXlvaskNodesFromNodes(selectedNodes.value)); @@ -2481,6 +2493,31 @@ const moveOrdersToInvoiceCollection = async (nodes: TreeNode[] = orderNodes(), o const moveSelectedOrdersToInvoiceCollection = () => moveOrdersToInvoiceCollection(orderNodes()); +const recalculateOrderPrices = (nodes: TreeNode[] = orderNodes(), options: TreeActionRunOptions = {}) => { + const targets = orderRepricingTargetsFromNodes(nodes); + return runActionWithPreview( + treeText("actions.orders.recalculate_prices.title", "Genberegn ordrepriser"), + [ + treeText("actions.orders.recalculate_prices.preview", "{count} orders får genberegnet priser.", { + count: targets.length, + }), + ], + async () => { + for (const target of targets) { + await SessionUser.objects.orders.functions.recalculateOrderItemPricesForCustomer({ + order_id: target.orderId, + department_id: target.departmentId, + customer_id: target.customerNumber, + }); + } + }, + true, + options + ); +}; + +const recalculateSelectedOrderPrices = () => recalculateOrderPrices(orderNodes()); + const unlinkOrderBookings = (nodes: TreeNode[] = orderNodes(), options: TreeActionRunOptions = {}) => { const orders = orderNodesFromNodes(nodes).filter((node) => node.meta.bookingId); return runActionWithPreview( @@ -2769,6 +2806,14 @@ const orderActions = (): TreeAction[] => [ affectedCount: orderIds().length, run: moveSelectedOrdersToInvoiceCollection, }, + { + key: "order:recalculate-prices", + label: treeText("buttons.recalculate_prices", "Genberegn priser"), + icon: "fa-calculator", + affectedCount: selectedOrderRepricingCount.value, + disabled: selectedOrderRepricingCount.value === 0, + run: recalculateSelectedOrderPrices, + }, { key: "order:unlink-booking", label: treeText("buttons.unlink_booking", "Fjern booking"), @@ -3059,6 +3104,13 @@ const orderNodeActions = (node: TreeNode): TreeAction[] => { affectedCount: 1, run: () => setOrdersInvoiceIncluded(false, nodes, rowActionOptions()), }), + rowAction(node, "order:recalculate-prices", { + label: treeText("buttons.recalculate_prices", "Genberegn priser"), + icon: "fa-calculator", + affectedCount: orderRepricingTargetsFromNodes(nodes).length, + disabled: orderRepricingTargetsFromNodes(nodes).length === 0, + run: () => recalculateOrderPrices(nodes, rowActionOptions()), + }), hasBooking ? rowAction(node, "order:unlink-booking", { label: treeText("buttons.unlink_booking", "Fjern booking"), diff --git a/tests/e2e/pos-mobile-order-flow.spec.js b/tests/e2e/pos-mobile-order-flow.spec.js index 01a98256..4ba1b428 100644 --- a/tests/e2e/pos-mobile-order-flow.spec.js +++ b/tests/e2e/pos-mobile-order-flow.spec.js @@ -4041,6 +4041,8 @@ test.describe("POS mobile order flow", () => { await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1); const product27Create = fixture.requestLog.orderItemCreates.find((entry) => Number(entry.product_id) === 27); expect(product27Create?.notes).toBe("Extra chemical treatment on left side"); + expect(product27Create?.reason_code).toBe("customer_approved_extra_work"); + expect(product27Create?.reason_comment).toBe("Extra chemical treatment on left side"); await waitForStepReset(page); }); diff --git a/tests/e2e/support/mobilePos.js b/tests/e2e/support/mobilePos.js index 42dfa1d2..a18fee52 100644 --- a/tests/e2e/support/mobilePos.js +++ b/tests/e2e/support/mobilePos.js @@ -127,6 +127,9 @@ function buildOrderItem(product, overrides = {}, id = null) { reference: String(overrides.reference ?? ""), related_item_id: overrides.related_item_id ?? null, price: Number(overrides.price ?? product.price ?? 0), + reason_code: String(overrides.reason_code ?? ""), + reason_label_snapshot: String(overrides.reason_label_snapshot ?? ""), + reason_comment: String(overrides.reason_comment ?? ""), }; } diff --git a/tests/e2e/support/network.js b/tests/e2e/support/network.js index 74a618cb..251c752a 100644 --- a/tests/e2e/support/network.js +++ b/tests/e2e/support/network.js @@ -2855,6 +2855,9 @@ function buildPosOrderItem(product, body, id) { reference: body.reference || "", related_item_id: body.related_item_id ?? null, price: Number(body.price ?? product.price ?? 0), + reason_code: body.reason_code || "", + reason_label_snapshot: body.reason_label_snapshot || "", + reason_comment: body.reason_comment || "", }; } diff --git a/tests/unit/invoicing-period-object-tree.spec.js b/tests/unit/invoicing-period-object-tree.spec.js index 9c3c1d92..c120d5e5 100644 --- a/tests/unit/invoicing-period-object-tree.spec.js +++ b/tests/unit/invoicing-period-object-tree.spec.js @@ -593,7 +593,7 @@ describe("InvoicingPeriodObjectTree", () => { expect(orderItemWheel.exists()).toBe(true); expect(collectionWheel.attributes("data-action-count")).toBe("4"); expect(collectionWheel.attributes("data-invoice-collection-id")).toBe("3001"); - expect(orderWheel.attributes("data-action-count")).toBe("3"); + expect(orderWheel.attributes("data-action-count")).toBe("4"); expect(orderWheel.attributes("data-order-id")).toBe("9001"); expect(orderWheel.attributes("data-department-id")).toBe("75"); expect(orderItemWheel.attributes("data-action-count")).toBe("1");