diff --git a/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue b/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue
index b693ba44..85810ca6 100644
--- a/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue
+++ b/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue
@@ -38,17 +38,18 @@ import {
reg_2,
reg_3,
department_id,
- customer_id,
+ customer_id,
customer_attributes,
customer_attributes_status,
- customer_name,
- getAddonRestriction,
- getProductRestriction,
+ customer_name,
+ getAddonRestriction,
+ getProductRestriction,
retryCustomerAttributes,
- registerPosStepSaveBarrier,
- saveOrderMetadataField,
- } from "@/components/shop/POSDepartmentProcess.vue";
+ registerPosStepSaveBarrier,
+ saveOrderMetadataField,
+} from "@/components/shop/POSDepartmentProcess.vue";
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
+import { syncMobileOrderItems } from "@/components/displays/department/pos/steps/mobile/utils/syncMobileOrderItems.js";
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import PosDepartmentStepMobileButtonClearAll from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
@@ -345,14 +346,14 @@ const applyPendingBookingFromSelection = async () => {
effectivePrimaryProduct = firstWash;
}
}
- effectivePrimaryProduct.addons = preparedAddons as any;
- transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
- if (transactionItems.primaryItem.value) {
- transactionItems.primaryItem.value.addons = preparedAddons as any;
- }
- sanitizeRestrictedTransactionItems();
+ effectivePrimaryProduct.addons = preparedAddons as any;
+ transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
+ if (transactionItems.primaryItem.value) {
+ transactionItems.primaryItem.value.addons = preparedAddons as any;
+ }
+ sanitizeRestrictedTransactionItems();
- lastAppliedBookingId.value = booking.id;
+ lastAppliedBookingId.value = booking.id;
//console.warn('Applied pending booking to cart (primary + addons):', booking.id, primaryProduct, preparedAddons);
//console.warn('Current transaction items after applying booking:', transactionItems.primaryItem.value);
lastFetchedPrimaryItemProduct.value = effectivePrimaryProduct; // Update last fetched primary item
@@ -885,83 +886,12 @@ watch(
{ immediate: true }
);
-const normalizeOrderItemShape = (item: any) => ({
- product_id: Number(item?.product_id ?? item?.product?.id ?? 0),
- quantity: Number(item?.quantity ?? 0),
- related_item_id:
- item?.related_item_id === null || item?.related_item_id === undefined ? null : Number(item.related_item_id),
- price: Number(item?.price ?? 0),
- notes: String(item?.notes ?? ""),
-});
-
-const buildDesiredOrderItemShapes = () => {
- if (!transactionItems.primaryItem.value) {
- return [];
- }
-
- const primaryShape = {
- kind: "primary",
- relatedKey: "primary",
- product_id: Number(transactionItems.primaryItem.value.id),
- quantity: 1,
- related_item_id: null,
- price: Number(transactionItems.primaryItem.value.price ?? 0),
- notes: String(transactionItems.primaryItem.value?.notes ?? ""),
- skip_price_override: transactionItems.primaryItem.value?.skip_price_override === true,
- };
-
- const addonShapes = (transactionItems.primaryItem.value.addons || [])
- .filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
- .map((addon: any) => {
- const addonProduct = addon?.product ?? addon;
- return {
- kind: "addon",
- relatedKey: "primary",
- product_id: Number(addonProduct?.id ?? addon?.id ?? 0),
- quantity: Number(addon?.quantity ?? 0),
- related_item_id: "__PRIMARY__",
- price: Number(addonProduct?.price ?? addon?.price ?? 0),
- notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
- skip_price_override: addonProduct?.skip_price_override === true || addon?.skip_price_override === true,
- };
- });
-
- const additionalShapes = (transactionItems.additionalItems.value || [])
- .filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
- .map((item: any) => ({
- kind: "additional",
- relatedKey: null,
- product_id: Number(item?.id ?? 0),
- quantity: Number(item?.quantity ?? 0),
- related_item_id: null,
- price: Number(item?.price ?? 0),
- notes: String(item?.notes ?? ""),
- skip_price_override: item?.skip_price_override === true,
- }));
-
- return [primaryShape, ...addonShapes, ...additionalShapes];
-};
-
-const normalizeExistingOrderItemShapes = (items: any[]) => {
- const primaryItems = items.filter(
- (item: any) => item?.related_item_id === null || item?.related_item_id === undefined
- );
- if (primaryItems.length === 0) {
- return [];
- }
-
- const additionalItems = primaryItems.filter(
- (item: any) =>
- Number(item?.product?.id ?? item?.product_id ?? 0) !== Number(transactionItems.primaryItem.value?.id ?? 0)
- );
- const primaryItemShape = normalizeOrderItemShape(primaryItems[0]);
- const addonShapes = items
- .filter((item: any) => item?.related_item_id === primaryItems[0]?.id)
- .map(normalizeOrderItemShape);
- const additionalShapes = additionalItems.map(normalizeOrderItemShape);
-
- return [primaryItemShape, ...addonShapes, ...additionalShapes];
-};
+// The pre-existing helpers buildDesiredOrderItemShapes and
+// normalizeExistingOrderItemShapes (and their shared normalizeOrderItemShape
+// normalizer) used to live here. They have been extracted to
+// src/components/displays/department/pos/steps/mobile/utils/syncMobileOrderItems.js
+// so they can be unit-tested in isolation and so the same comparison logic
+// is used by syncCurrentTransactionToOrder and any future caller.
const sortComparableLastWashShapes = (shapes: Array<{ kind: string; product_id: number; quantity: number }>) =>
shapes.slice().sort((left, right) => {
@@ -988,8 +918,8 @@ const buildCurrentSelectionComparableShapes = () => {
};
const addonShapes = sortComparableLastWashShapes(
- (transactionItems.primaryItem.value.addons || [])
- .filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
+ (transactionItems.primaryItem.value.addons || [])
+ .filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.map((addon: any) => ({
kind: "addon",
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
@@ -998,8 +928,8 @@ const buildCurrentSelectionComparableShapes = () => {
);
const additionalShapes = sortComparableLastWashShapes(
- (transactionItems.additionalItems.value || [])
- .filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
+ (transactionItems.additionalItems.value || [])
+ .filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.map((item: any) => ({
kind: "additional",
product_id: Number(item?.id ?? 0),
@@ -1074,65 +1004,19 @@ const syncCurrentTransactionToOrder = async () => {
return false;
}
- const existingItemsResponse = await getOrderItems(normalizedOrderId);
- const existingItems = Array.isArray(existingItemsResponse?.data?.data) ? existingItemsResponse.data.data : [];
-
- const desiredShapes = buildDesiredOrderItemShapes();
- const currentShapes = normalizeExistingOrderItemShapes(existingItems);
- const shouldForceRecreateForRepricing = desiredShapes.some((shape) => shape.skip_price_override === true);
- const comparableDesiredShapes = desiredShapes.map(({
- kind: _kind,
- relatedKey: _relatedKey,
- skip_price_override: _skipPriceOverride,
- ...shape
- }) => shape);
-
- if (!shouldForceRecreateForRepricing && JSON.stringify(currentShapes) === JSON.stringify(comparableDesiredShapes)) {
- return true;
- }
-
- await Promise.all(existingItems.map((item: any) => removeOrderItem(item.id)));
-
- const createdPrimaryItemResponse = await createOrderItem(
- normalizedOrderId,
- transactionItems.primaryItem.value.id,
- 1,
- null,
- transactionItems.primaryItem.value?.notes || "",
- transactionItems.primaryItem.value.skip_price_override === true ? null : transactionItems.primaryItem.value.price
- );
- const createdPrimaryItemId = createdPrimaryItemResponse?.data?.data?.id;
-
- const addonPromises = (transactionItems.primaryItem.value.addons || [])
- .filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
- .map((addon: any) => {
- const addonProduct = addon?.product ?? addon;
- return createOrderItem(
- normalizedOrderId,
- addonProduct.id,
- Number(addon.quantity),
- createdPrimaryItemId,
- addonProduct?.notes || addon?.notes || "",
- addonProduct?.skip_price_override === true || addon?.skip_price_override === true
- ? null
- : addonProduct.price ?? addon.price
- );
- });
-
- const additionalPromises = (transactionItems.additionalItems.value || [])
- .filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
- .map((item: any) =>
- createOrderItem(
- normalizedOrderId,
- item.id,
- Number(item.quantity),
- null,
- item?.notes || "",
- item?.skip_price_override === true ? null : item.price
- )
- );
-
- await Promise.all([...addonPromises, ...additionalPromises]);
+ // The sync helper owns the create / rollback / allSettled logic and lives
+ // in tests/unit/pos-mobile-step-2-addon-sync.spec.js. Returning its result
+ // unchanged preserves the existing contract: true = synced (or already in
+ // sync), false = primary product blocked, throw = partial failure.
+ await syncMobileOrderItems({
+ orderId: normalizedOrderId,
+ primaryItem: transactionItems.primaryItem.value,
+ additionalItems: transactionItems.additionalItems.value || [],
+ isAddonRestricted: isMobileAddonRestricted,
+ isStandaloneRestricted: isStandaloneAdditionalItemRestricted,
+ isPrimaryRestricted: (item) => getProductRestriction(item).restricted,
+ api: { createOrderItem, getOrderItems, removeOrderItem },
+ });
return true;
};
@@ -1140,8 +1024,7 @@ const normalizeText = (value: unknown) => String(value ?? "").trim();
const isEnabledFlag = (value: unknown) => value === true || value === 1 || value === "1" || value === "true";
-const getProductId = (product: any) =>
- Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
+const getProductId = (product: any) => Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
const getProductName = (product: any) => normalizeText(product?.product?.name ?? product?.name);
@@ -1157,8 +1040,7 @@ const productRequiresOrderItemNote = (product: any) => {
);
};
-const productHasOrderItemNote = (product: any) =>
- normalizeText(product?.notes ?? product?.product?.notes).length > 0;
+const productHasOrderItemNote = (product: any) => normalizeText(product?.notes ?? product?.product?.notes).length > 0;
const getSelectedProductsMissingRequiredNotes = () => {
const missingProducts: any[] = [];
@@ -1168,8 +1050,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
missingProducts.push(primaryProduct);
}
- (primaryProduct?.addons || [])
- .filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
+ (primaryProduct?.addons || [])
+ .filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.forEach((addon: any) => {
const addonProduct = addon?.product ?? addon;
if (
@@ -1181,8 +1063,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
}
});
- (transactionItems.additionalItems.value || [])
- .filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
+ (transactionItems.additionalItems.value || [])
+ .filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.forEach((item: any) => {
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
missingProducts.push(item);
@@ -1538,15 +1420,15 @@ const filteredAddons = computed(() => {
-
-
-
- {{ t(restrictionWarningMessageKey) }}
-
+
+
+
+ {{ t(restrictionWarningMessageKey) }}
+
{
{{ t("common.retry") }}
-
-
+ 0 that is not
+ * customer-rule-restricted becomes its own order_items row, linked to
+ * the freshly-created primary row by related_item_id.
+ * - Every additional (standalone) item with quantity > 0 that is not
+ * customer-rule-restricted becomes its own order_items row.
+ * - The old "Promise.all over parallel POSTs" fan-out silently dropped
+ * rows on a single rejection: the rows that already landed stayed on
+ * the server while the operator saw only a generic failure popup.
+ * This helper uses Promise.allSettled, collects every per-product
+ * failure, and rolls back every order_items row created during this
+ * attempt before throwing, so a retry starts from a clean state.
+ */
+
+const PLACEHOLDER_PRIMARY_RELATED_ITEM_ID = "__PRIMARY__";
+
+const toInteger = (value) => {
+ const parsed = Number.parseInt(String(value ?? ""), 10);
+ return Number.isInteger(parsed) ? parsed : null;
+};
+
+const toPositiveInteger = (value) => {
+ const parsed = toInteger(value);
+ return parsed !== null && parsed > 0 ? parsed : null;
+};
+
+const toNonNegativeNumber = (value) => {
+ const parsed = Number(value ?? 0);
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
+};
+
+const safeGet = (object, ...keys) => {
+ for (const key of keys) {
+ const candidate = object?.[key];
+ if (candidate !== undefined && candidate !== null && candidate !== "") {
+ return candidate;
+ }
+ }
+ return undefined;
+};
+
+/**
+ * Normalize a single order item from the server (returned by GET
+ * /order/items) into the comparison shape used by the idempotency check.
+ *
+ * related_item_id is intentionally excluded from the comparison shape so
+ * that "desired add-on with placeholder related_item_id" can be matched
+ * against "existing add-on with real numeric related_item_id". The other
+ * fields (product_id, quantity, price, notes) fully characterize the row.
+ */
+export const normalizeExistingOrderItemShape = (item) => ({
+ product_id: Number(safeGet(item, "product_id", "product_id") ?? 0),
+ quantity: toNonNegativeNumber(safeGet(item, "quantity", "quantity")),
+ price: Number(safeGet(item, "price", "price") ?? 0),
+ notes: String(safeGet(item, "notes", "notes") ?? ""),
+});
+
+/**
+ * Group raw server items into [primaryShape, ...addonShapes, ...additionalShapes]
+ * so we can compare the server state with what we want the server to look like.
+ *
+ * - `primaryItems` is every item with related_item_id === null. The first one
+ * is treated as the primary; subsequent ones are standalone "additional"
+ * items unless their product id matches the in-memory primary product.
+ * - `addonShapes` are items whose related_item_id equals the primary's id.
+ */
+export const normalizeExistingOrderItemShapes = (existingItems, primaryProductId) => {
+ const items = Array.isArray(existingItems) ? existingItems : [];
+ const primaryItems = items.filter((item) => item?.related_item_id === null || item?.related_item_id === undefined);
+ if (primaryItems.length === 0) {
+ return [];
+ }
+
+ const primaryItem = primaryItems[0];
+ const primaryItemId = Number(primaryItem?.id ?? 0);
+ const primaryShape = normalizeExistingOrderItemShape(primaryItem);
+
+ const additionalItems = primaryItems
+ .filter((item) => Number(item?.product?.id ?? item?.product_id ?? 0) !== Number(primaryProductId ?? 0))
+ .map(normalizeExistingOrderItemShape);
+
+ const addonShapes =
+ primaryItemId > 0
+ ? items
+ .filter((item) => Number(item?.related_item_id ?? 0) === primaryItemId)
+ .map(normalizeExistingOrderItemShape)
+ : [];
+
+ return [primaryShape, ...addonShapes, ...additionalItems];
+};
+
+/**
+ * Build the desired shapes from the in-memory primaryItem + additionalItems.
+ * Uses a stable placeholder ("__PRIMARY__") for the add-on related_item_id
+ * because the real primary id is not known until the primary POST resolves.
+ */
+export const buildDesiredOrderItemShapes = ({
+ primaryItem,
+ additionalItems,
+ isAddonRestricted = () => false,
+ isStandaloneRestricted = () => false,
+}) => {
+ if (!primaryItem) {
+ return [];
+ }
+
+ const primaryShape = {
+ kind: "primary",
+ relatedKey: "primary",
+ product_id: Number(primaryItem.id ?? 0),
+ quantity: 1,
+ related_item_id: null,
+ price: Number(primaryItem.price ?? 0),
+ notes: String(primaryItem.notes ?? ""),
+ skip_price_override: primaryItem.skip_price_override === true,
+ };
+
+ const addonShapes = (Array.isArray(primaryItem.addons) ? primaryItem.addons : [])
+ .filter((addon) => Number(addon?.quantity ?? 0) > 0 && !isAddonRestricted(addon))
+ .map((addon) => {
+ const addonProduct = addon?.product ?? addon;
+ return {
+ kind: "addon",
+ relatedKey: "primary",
+ product_id: Number(addonProduct?.id ?? addon?.id ?? 0),
+ quantity: Number(addon?.quantity ?? 0),
+ related_item_id: PLACEHOLDER_PRIMARY_RELATED_ITEM_ID,
+ price: Number(addonProduct?.price ?? addon?.price ?? 0),
+ notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
+ skip_price_override: addonProduct?.skip_price_override === true || addon?.skip_price_override === true,
+ };
+ });
+
+ const additionalShapes = (Array.isArray(additionalItems) ? additionalItems : [])
+ .filter((item) => Number(item?.quantity ?? 0) > 0 && !isStandaloneRestricted(item))
+ .map((item) => ({
+ kind: "additional",
+ relatedKey: null,
+ product_id: Number(item?.id ?? 0),
+ quantity: Number(item?.quantity ?? 0),
+ related_item_id: null,
+ price: Number(item?.price ?? 0),
+ notes: String(item?.notes ?? ""),
+ skip_price_override: item?.skip_price_override === true,
+ }));
+
+ return [primaryShape, ...addonShapes, ...additionalShapes];
+};
+
+const stripKindMarkers = (shape) => {
+ const {
+ kind: _kind,
+ relatedKey: _relatedKey,
+ skip_price_override: _skip,
+ // related_item_id is intentionally stripped: in the desired shapes it is
+ // the placeholder "__PRIMARY__" for add-ons (the real primary id is not
+ // known until the primary POST resolves), while in the existing shapes
+ // it is the real numeric primary id. Without stripping, the comparison
+ // would always fail and the helper would rebuild the order on every
+ // Fuldfør click.
+ related_item_id: _relatedItemId,
+ ...rest
+ } = shape;
+ return rest;
+};
+
+const shallowEqual = (left, right) => {
+ if (left === right) return true;
+ if (!left || !right) return false;
+ const leftKeys = Object.keys(left);
+ const rightKeys = Object.keys(right);
+ if (leftKeys.length !== rightKeys.length) return false;
+ for (const key of leftKeys) {
+ if (left[key] !== right[key]) return false;
+ }
+ return true;
+};
+
+const arraysEqual = (left, right) => {
+ if (left === right) return true;
+ if (!Array.isArray(left) || !Array.isArray(right)) return false;
+ if (left.length !== right.length) return false;
+ for (let i = 0; i < left.length; i += 1) {
+ if (!shallowEqual(left[i], right[i])) return false;
+ }
+ return true;
+};
+
+/**
+ * Map an axios-like rejection to the human-readable string the operator
+ * should see. Falls back to the error message itself.
+ */
+export const extractErrorMessage = (reason) => {
+ const message =
+ reason?.response?.data?.data?.message ?? reason?.response?.data?.message ?? reason?.message ?? "Unknown error";
+ return String(message);
+};
+
+/**
+ * Format a per-product failure into a human-readable fragment.
+ */
+export const formatFailureFragment = ({ productId, message }) => {
+ if (!Number.isFinite(Number(productId)) || Number(productId) <= 0) {
+ return message;
+ }
+ return `Product ${productId}: ${message}`;
+};
+
+/**
+ * Custom error thrown when one or more order_items POSTs fail. Carries the
+ * aggregated message and the per-product failure list so the UI layer can
+ * either display it directly or surface structured details.
+ */
+export class OrderItemsPartialSyncError extends Error {
+ constructor(message, failures, rolledBackIds) {
+ super(message);
+ this.name = "OrderItemsPartialSyncError";
+ this.failures = failures;
+ this.rolledBackIds = rolledBackIds;
+ }
+}
+
+/**
+ * @typedef {Object} OrderItemApi
+ * @property {(orderId, productId, quantity, relatedItemId, notes, price) => Promise<{data:{data:{id:number}}}>} createOrderItem
+ * @property {(orderId) => Promise<{data:{data:Array