Fix POS release-blocking customer rules and completion (#208)
Resolve direct-order product reconciliation context, preserve valid selections when restricted products are activated, remove the redundant certificate request, and add focused regression coverage.
This commit is contained in:
@@ -135,6 +135,12 @@ export const sourceMappings = [
|
||||
specs: ["tests/e2e/superuser-customer-rules.spec.ts", "tests/e2e/superuser-users.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "shared-pos-product-selection",
|
||||
patterns: [/^src\/components\/forms\/department\/pos\/SelectProductsFormPOS\.vue$/u],
|
||||
specs: ["tests/e2e/pos-customer-rules.spec.js", "tests/e2e/userBookWash.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "pos",
|
||||
patterns: [
|
||||
|
||||
@@ -186,11 +186,7 @@ const fetchProductsForCategory = async ({
|
||||
try {
|
||||
if (customerNumber) {
|
||||
await loadCustomerAttributes(customerNumber);
|
||||
if (
|
||||
requestId !== latestProductsRequestId.value ||
|
||||
customer_attributes_status.value !== 'ready' ||
|
||||
Number(customer_attributes_customer_number.value) !== Number(customerNumber)
|
||||
) {
|
||||
if (requestId !== latestProductsRequestId.value) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1057,7 +1053,9 @@ watch([isGridLayout, effectiveSelectedId], (newVals) => {
|
||||
const selectProduct = (product) => {
|
||||
const restriction = getScopedProductRestriction(product);
|
||||
if (!customerRulesReady.value || restriction.restricted) {
|
||||
void showRestrictionWarning(restriction.messageKey);
|
||||
void showRestrictionWarning(
|
||||
restriction.messageKey || (!customerRulesReady.value ? "pos.restrictions.loading" : undefined)
|
||||
);
|
||||
return;
|
||||
}
|
||||
emits('onSelectProduct', product);
|
||||
@@ -1554,8 +1552,7 @@ const onSelectCategory = (rawId) => {
|
||||
:data-testid="`pos-product-card-${product.id}`"
|
||||
:data-restricted="isProductUnavailable(product) ? 'true' : 'false'"
|
||||
role="button"
|
||||
:tabindex="isProductUnavailable(product) ? -1 : 0"
|
||||
:aria-disabled="isProductUnavailable(product) ? 'true' : 'false'"
|
||||
tabindex="0"
|
||||
@keydown.enter.prevent="selectProduct(product)"
|
||||
@keydown.space.prevent="selectProduct(product)"
|
||||
:ref="el => setItemRef(el, product.id)"
|
||||
|
||||
@@ -1459,6 +1459,74 @@ export const searchAndSelectCustomer = async (customerNumber, options = {}) => {
|
||||
};
|
||||
|
||||
/** Get order items */
|
||||
export const reconcileOrderItemProducts = async (items = [], { departmentId, customerId } = {}) => {
|
||||
const normalizedItems = Array.isArray(items) ? items : [];
|
||||
const normalizedDepartmentId =
|
||||
departmentId === undefined ? toPositiveInteger(department_id.value) : toPositiveInteger(departmentId);
|
||||
const normalizedCustomerId =
|
||||
customerId === undefined ? getSelectedCustomerNumber() : resolveCustomerNumber(customerId);
|
||||
const productsById = new Map();
|
||||
const productRequestsById = new Map();
|
||||
|
||||
for (const item of normalizedItems) {
|
||||
const productId = toPositiveInteger(item?.product_id);
|
||||
const embeddedProductId = toPositiveInteger(item?.product?.id);
|
||||
if (productId && productId === embeddedProductId) {
|
||||
productsById.set(embeddedProductId, item.product);
|
||||
}
|
||||
}
|
||||
|
||||
const getCanonicalProduct = async (productId, orderItemId) => {
|
||||
const embeddedProduct = productsById.get(productId) ?? null;
|
||||
if (embeddedProduct) {
|
||||
return embeddedProduct;
|
||||
}
|
||||
|
||||
let productRequest = productRequestsById.get(productId);
|
||||
if (!productRequest) {
|
||||
productRequest = SessionUser.objects.products.get.single(productId, {
|
||||
department_id: normalizedDepartmentId,
|
||||
customer_id: normalizedCustomerId,
|
||||
category_id: null,
|
||||
final_price: true,
|
||||
}).then((canonicalProduct) => {
|
||||
if (canonicalProduct) {
|
||||
productsById.set(productId, canonicalProduct);
|
||||
}
|
||||
return canonicalProduct;
|
||||
}).catch((error) => {
|
||||
console.warn(`Unable to reconcile product ${productId} for order item ${orderItemId ?? "unknown"}`, error);
|
||||
return null;
|
||||
});
|
||||
productRequestsById.set(productId, productRequest);
|
||||
}
|
||||
|
||||
return await productRequest;
|
||||
};
|
||||
|
||||
return await Promise.all(normalizedItems.map(async (item) => {
|
||||
const productId = toPositiveInteger(item?.product_id);
|
||||
const embeddedProductId = toPositiveInteger(item?.product?.id);
|
||||
if (!productId || productId === embeddedProductId) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const canonicalProduct = await getCanonicalProduct(productId, item?.id);
|
||||
|
||||
if (!canonicalProduct) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
product: {
|
||||
...(item?.product || {}),
|
||||
...canonicalProduct,
|
||||
},
|
||||
};
|
||||
}));
|
||||
};
|
||||
|
||||
export const loadOrderItems = async () => {
|
||||
const normalizedOrderId = Number(order_id.value);
|
||||
if (!Number.isInteger(normalizedOrderId) || normalizedOrderId <= 0) {
|
||||
@@ -1470,7 +1538,7 @@ export const loadOrderItems = async () => {
|
||||
order_items.value = [];
|
||||
return order_items.value;
|
||||
}
|
||||
order_items.value = response.data.data;
|
||||
order_items.value = await reconcileOrderItemProducts(response.data.data);
|
||||
return order_items.value;
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import "@/components/displays/department/pos/PosDepartmentMVP.vue";
|
||||
import "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosNavigation.vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {computed, onMounted, onUnmounted, ref, watch} from "vue";
|
||||
import { selectCustomer, setOrderId, customer_name, showDeleteOrderDialog, invoiceAllOrdersIndividually, invoiceUsingStripe, setProductsCategory, order_items, customer_id, invoiceCollectionId, fetchAttachments, deleteAttachment, uploadAttachment, downloadAttachment, getAttachmentPreviewLink, attachments } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { setOrderId, applyOrderDetailsToPosState, customer_name, showDeleteOrderDialog, invoiceAllOrdersIndividually, invoiceUsingStripe, setProductsCategory, order_items, customer_id, invoiceCollectionId, fetchAttachments, deleteAttachment, uploadAttachment, downloadAttachment, getAttachmentPreviewLink, attachments, reconcileOrderItemProducts } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { getOrder } from "@/components/shop/Orders.vue";
|
||||
import { getOrderItems } from "@/components/shop/OrdersItems.vue";
|
||||
import OrderItemsTable from "@/components/displays/department/pos/order/orderItemsTable.vue";
|
||||
@@ -122,23 +122,27 @@ const loadOrder = async () => {
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
order.value = response.data.data;
|
||||
order_items.value = response.data.includes.orderItems; // Get the order items
|
||||
customer.value = response.data.includes.customer; // Get the customer
|
||||
cashier.value = response.data.includes.cashier; // Get the cashier
|
||||
economicModule.value = response.data.includes.economicModuleOrders; // Get the economic module orders
|
||||
stripeModule.value = response.data.includes.stripeModuleOrders; // Get the stripe module orders
|
||||
closed_at.value = response.data.data.closed_at;
|
||||
const orderData = response.data.data;
|
||||
const includes = response.data.includes ?? {};
|
||||
order.value = orderData;
|
||||
setOrderId(orderId.value, {
|
||||
departmentId: response.data.data.department_id,
|
||||
departmentId: orderData.department_id,
|
||||
syncDepartmentWithSelection: false,
|
||||
loadItems: false,
|
||||
});
|
||||
applyOrderDetailsToPosState(orderData, includes);
|
||||
order_items.value = await reconcileOrderItemProducts(includes.orderItems, {
|
||||
departmentId: orderData.department_id,
|
||||
customerId: orderData.customer_id,
|
||||
}); // Get the order items
|
||||
customer.value = includes.customer; // Get the customer
|
||||
cashier.value = includes.cashier; // Get the cashier
|
||||
economicModule.value = includes.economicModuleOrders; // Get the economic module orders
|
||||
stripeModule.value = includes.stripeModuleOrders; // Get the stripe module orders
|
||||
closed_at.value = orderData.closed_at;
|
||||
await fetchAttachments(orderId.value);
|
||||
isLoading.value = false;
|
||||
selectCustomer(customer.value.economic_customer);
|
||||
invoiceCollectionId.value = response.data.data.invoice_collection_id || null;
|
||||
if (response.data.data.id) {
|
||||
if (orderData.id) {
|
||||
doesOrderExist.value = true;
|
||||
}
|
||||
if (economicModule.value.invoice_draft_id) {
|
||||
@@ -171,7 +175,7 @@ const isInvoicedWithEconomic = () => {
|
||||
|
||||
const loadOrderItems = async () => {
|
||||
const response = await getOrderItems(orderId.value);
|
||||
order_items.value = response.data.data;
|
||||
order_items.value = await reconcileOrderItemProducts(response.data.data);
|
||||
};
|
||||
|
||||
const attachWashCertificateToOrder = async () => {
|
||||
|
||||
@@ -814,6 +814,69 @@ test.describe("Admin POS Orders - desktop settings", () => {
|
||||
expect(combinedMessages).not.toContain("Unhandled error during execution of setup function");
|
||||
});
|
||||
|
||||
test("reconciles a mismatched embedded product using the direct order response context", async ({ page }) => {
|
||||
const baseFixture = createPosFixture();
|
||||
const directOrderId = 54519;
|
||||
const responseDepartmentId = 44;
|
||||
const responseCustomerId = 12345679;
|
||||
const canonicalProduct = {
|
||||
...baseFixture.products.find((product) => Number(product.id) === 63),
|
||||
name: "Canonical direct-order product",
|
||||
};
|
||||
const fixture = createPosFixture({
|
||||
products: baseFixture.products.map((product) => (Number(product.id) === 63 ? canonicalProduct : product)),
|
||||
ordersById: {
|
||||
...baseFixture.ordersById,
|
||||
[directOrderId]: {
|
||||
...baseFixture.ordersById[54518],
|
||||
id: directOrderId,
|
||||
department_id: responseDepartmentId,
|
||||
customer_id: responseCustomerId,
|
||||
},
|
||||
},
|
||||
orderItemsByOrderId: {
|
||||
...baseFixture.orderItemsByOrderId,
|
||||
[directOrderId]: [
|
||||
{
|
||||
...baseFixture.orderItemsByOrderId[54518][0],
|
||||
id: 9150,
|
||||
order_id: directOrderId,
|
||||
product_id: 63,
|
||||
product: {
|
||||
...baseFixture.products.find((product) => Number(product.id) === 53),
|
||||
name: "Stale embedded order product",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
attachmentsByOrderId: {
|
||||
...baseFixture.attachmentsByOrderId,
|
||||
[directOrderId]: [],
|
||||
},
|
||||
});
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: POS_PERMISSIONS,
|
||||
edgeGateways: false,
|
||||
pos: fixture,
|
||||
});
|
||||
await primeOperatorSession(page, "pos-direct-order-reconciliation-token");
|
||||
|
||||
const canonicalProductRequest = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url());
|
||||
return request.method() === "GET" && url.pathname.endsWith("/products") && url.searchParams.get("id") === "63";
|
||||
});
|
||||
|
||||
await openOrderDetail(page, directOrderId);
|
||||
const requestUrl = new URL((await canonicalProductRequest).url());
|
||||
|
||||
expect(requestUrl.searchParams.get("department_id")).toBe(String(responseDepartmentId));
|
||||
expect(requestUrl.searchParams.get("customer_id")).toBe(String(responseCustomerId));
|
||||
expect(requestUrl.searchParams.get("final_price")).toBe("true");
|
||||
await expect(page.getByTestId("pos-order-item-name-9150")).toContainText("Canonical direct-order product");
|
||||
});
|
||||
|
||||
test("renders order detail metadata and item actions without the Excel export affordance", async ({ page }) => {
|
||||
await openOrderDetail(page);
|
||||
await expect(page.getByTestId("pos-order-header-export-actions")).toHaveCount(0);
|
||||
@@ -2119,6 +2182,7 @@ test.describe("Admin POS wash certificate completion", () => {
|
||||
id: 9802,
|
||||
order_id: materialOrderId,
|
||||
related_item_id: 9801,
|
||||
product: baseFixture.products.find((product) => Number(product.id) === 64),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2136,6 +2200,22 @@ test.describe("Admin POS wash certificate completion", () => {
|
||||
});
|
||||
await primeOperatorSession(page, "pos-material-sequencing-token");
|
||||
|
||||
const completionRequests: string[] = [];
|
||||
const washCertificateRequests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") {
|
||||
return;
|
||||
}
|
||||
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (pathname.endsWith("/orders/mark_as_completed")) {
|
||||
completionRequests.push(request.url());
|
||||
}
|
||||
if (pathname.endsWith("/order/wash-certificate")) {
|
||||
washCertificateRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(`/admin/12/modules/pos?id=${materialOrderId}&customer_id=12345679&step=3`);
|
||||
await expect(page.getByTestId("pos-step-3")).toBeVisible();
|
||||
|
||||
@@ -2162,6 +2242,9 @@ test.describe("Admin POS wash certificate completion", () => {
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
await expect(page.locator(".swal2-popup:visible")).toContainText(/Fuldført|Completed/i);
|
||||
expect(completionRequests).toHaveLength(1);
|
||||
expect(washCertificateRequests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -346,6 +346,13 @@ test("only tankcleaning customers can only add tankcleaning products", async ({
|
||||
});
|
||||
await primeOperatorSession(page, "pos-only-tankcleaning-token");
|
||||
|
||||
const orderItemPosts = [];
|
||||
page.on("request", (request) => {
|
||||
if (request.method() === "POST" && new URL(request.url()).pathname.endsWith("/order/items")) {
|
||||
orderItemPosts.push(request.postDataJSON());
|
||||
}
|
||||
});
|
||||
|
||||
const customer = fixture.customersByNumber[12345679];
|
||||
const customerAttributesResponse = page.waitForResponse((response) => {
|
||||
return (
|
||||
@@ -359,12 +366,13 @@ test("only tankcleaning customers can only add tankcleaning products", async ({
|
||||
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.getByTestId("pos-add-to-cart-restriction-tooltip-53").hover();
|
||||
await expect(page.locator(".tooltip-content:visible").last()).toContainText(
|
||||
"Produktet er ikke tilladt for denne kunde"
|
||||
);
|
||||
const restrictedProductCard = page.getByTestId("pos-product-card-53").first();
|
||||
await restrictedProductCard.click();
|
||||
await expect(restrictedProductCard).not.toHaveClass(/is-selected/);
|
||||
await expect(page.locator(".swal2-popup:visible")).toContainText("Produktet er ikke tilladt for denne kunde");
|
||||
await page.locator(".swal2-confirm:visible").click();
|
||||
await expect(page.getByTestId("pos-add-to-cart-53")).toHaveCount(0);
|
||||
expect(orderItemPosts).toEqual([]);
|
||||
|
||||
await page.locator(".tabs li").filter({ hasText: "Tankcleaning" }).click();
|
||||
await expect(page.getByTestId("pos-product-card-66").first()).toBeVisible();
|
||||
@@ -377,6 +385,7 @@ test("only tankcleaning customers can only add tankcleaning products", async ({
|
||||
await page.getByTestId("pos-add-to-cart-66").first().click();
|
||||
const request = await addRequest;
|
||||
expect(request.postDataJSON().product_id).toBe(66);
|
||||
expect(orderItemPosts).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("exact customer-rule configuration disables desktop add-on controls by option_id without placeholder rows", async ({
|
||||
@@ -536,12 +545,12 @@ test("desktop remains fail-closed when an active product rule has a malformed re
|
||||
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
await expect(page.getByTestId("pos-customer-restrictions-load-error")).toBeVisible();
|
||||
|
||||
await page.getByTestId("pos-product-card-53").first().click();
|
||||
await expect(page.getByTestId("pos-add-to-cart-53").first()).toBeDisabled();
|
||||
await page
|
||||
.getByTestId("pos-add-to-cart-53")
|
||||
.first()
|
||||
.evaluate((button) => button.click());
|
||||
const blockedProductCard = page.getByTestId("pos-product-card-53").first();
|
||||
await blockedProductCard.click();
|
||||
await expect(blockedProductCard).not.toHaveClass(/is-selected/);
|
||||
await expect(page.locator(".swal2-popup:visible")).toContainText(/Kunderegel|Customer rule/i);
|
||||
await page.locator(".swal2-confirm:visible").click();
|
||||
await expect(page.locator('[data-testid^="pos-add-to-cart-"]:visible')).toHaveCount(0);
|
||||
expect(orderItemPosts).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -179,18 +179,35 @@ test("[BOOKINGS][User][Rules] should block restricted products but allow an exac
|
||||
});
|
||||
await goToBookingProductSelectionStepWithOptions(page, bookingTestData, { selectInitialProduct: false });
|
||||
|
||||
const restrictedCard = page.locator('[data-testid="pos-product-card-53"]:visible').first();
|
||||
await expect(restrictedCard).toHaveAttribute("data-restricted", "true");
|
||||
await expect(restrictedCard.getByTestId("pos-product-restriction-53")).toContainText(/Ikke tilgængelig|Unavailable/i);
|
||||
await restrictedCard.press("Enter");
|
||||
await expect(restrictedCard).not.toHaveClass(/is-selected/);
|
||||
await expect(page.locator(".swal2-popup:visible")).toContainText(/Kunderegel|Customer rule/i);
|
||||
await page.locator(".swal2-confirm:visible").click();
|
||||
const restrictedMutationRequests: Array<Record<string, unknown>> = [];
|
||||
page.on("request", (request) => {
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (request.method() === "POST" && (pathname.endsWith("/order/items") || pathname.endsWith("/order-bookings"))) {
|
||||
restrictedMutationRequests.push(request.postDataJSON() as Record<string, unknown>);
|
||||
}
|
||||
});
|
||||
|
||||
const allowedCard = page.locator('[data-testid="pos-product-card-54"]:visible').first();
|
||||
await expect(allowedCard).toHaveAttribute("data-restricted", "false");
|
||||
await allowedCard.click();
|
||||
await expect(allowedCard).toHaveClass(/is-selected/);
|
||||
|
||||
const restrictedCard = page.locator('[data-testid="pos-product-card-53"]:visible').first();
|
||||
await expect(restrictedCard).toHaveAttribute("data-restricted", "true");
|
||||
await expect(restrictedCard.getByTestId("pos-product-restriction-53")).toContainText(/Ikke tilgængelig|Unavailable/i);
|
||||
await restrictedCard.press("Enter");
|
||||
await expect(restrictedCard).not.toHaveClass(/is-selected/);
|
||||
await expect(allowedCard).toHaveClass(/is-selected/);
|
||||
await expect(page.locator(".swal2-popup:visible")).toContainText(/Kunderegel|Customer rule/i);
|
||||
await page.locator(".swal2-confirm:visible").click();
|
||||
|
||||
await restrictedCard.click();
|
||||
await expect(restrictedCard).not.toHaveClass(/is-selected/);
|
||||
await expect(allowedCard).toHaveClass(/is-selected/);
|
||||
await expect(page.locator(".swal2-popup:visible")).toContainText(/Kunderegel|Customer rule/i);
|
||||
await page.locator(".swal2-confirm:visible").click();
|
||||
|
||||
expect(restrictedMutationRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("[BOOKINGS][User][Rules] should block an interior wash when its required certificate is restricted", async ({
|
||||
|
||||
@@ -180,6 +180,15 @@ describe("Playwright PR mapping", () => {
|
||||
expect(specsFor("src/components/shop/POSDepartmentProcess.vue")).toContain("tests/e2e/admin-pos-orders.spec.ts");
|
||||
});
|
||||
|
||||
it("maps the shared POS product selector to POS and booking customer-rule coverage", () => {
|
||||
const specs = specsFor("src/components/forms/department/pos/SelectProductsFormPOS.vue");
|
||||
const mapping = sourceMappings.find((candidate) => candidate.name === "shared-pos-product-selection");
|
||||
|
||||
expect(specs).toContain("tests/e2e/pos-customer-rules.spec.js");
|
||||
expect(specs).toContain("tests/e2e/userBookWash.spec.ts");
|
||||
expect(mapping?.projects).toEqual(["chromium-desktop", "chromium-mobile"]);
|
||||
});
|
||||
|
||||
it("maps global customer rule configuration changes to its editor coverage", () => {
|
||||
expect(specsFor("src/views/dashboards/superUserDashboard/CustomerRuleProductRestrictions.vue")).toContain(
|
||||
"tests/e2e/superuser-customer-rules.spec.ts"
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { canonicalProductLookup } = vi.hoisted(() => ({
|
||||
canonicalProductLookup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/numberplatescanners/Scans.vue", () => ({
|
||||
getScansDepartmentPagination: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/components/request/HandleGlobalError.vue", () => ({
|
||||
clearErrors: vi.fn(),
|
||||
parseError: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/components/shop/CustomerNotes.vue", () => ({
|
||||
createNote: vi.fn(),
|
||||
deleteNote: vi.fn(),
|
||||
getNotes: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/components/shop/OrdersItems.vue", () => ({
|
||||
createOrderItem: vi.fn(),
|
||||
getOrderItems: vi.fn(),
|
||||
removeOrderItem: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/components/shop/CustomerAttributes.vue", () => ({
|
||||
getAttributes: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
SessionUser: {
|
||||
functions: {},
|
||||
objects: {
|
||||
products: {
|
||||
get: {
|
||||
single: canonicalProductLookup,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
|
||||
authenticatedRequest: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/components/displays/department/pos/utils/washCertificate.js", () => ({
|
||||
doesOrderContainWashCertificateProduct: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("@/features/customer/customerProductRules.js", () => ({
|
||||
getCustomerProductRestriction: vi.fn(),
|
||||
getProductCategoryRestrictionForCustomer: vi.fn(),
|
||||
hasValidCustomerProductRestrictionContract: vi.fn(() => true),
|
||||
isProductCategoryRestrictedForCustomer: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("sweetalert2", () => ({
|
||||
default: { fire: vi.fn() },
|
||||
}));
|
||||
|
||||
import { customer_id, department_id, reconcileOrderItemProducts } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
|
||||
const inconsistentItem = (overrides = {}) => ({
|
||||
id: 9101,
|
||||
order_id: 54518,
|
||||
product_id: 63,
|
||||
product: {
|
||||
id: 53,
|
||||
name: "Stale embedded product",
|
||||
embedded_only: "preserve me",
|
||||
},
|
||||
quantity: 2,
|
||||
price: 777,
|
||||
notes: "Order-item note",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("reconcileOrderItemProducts", () => {
|
||||
beforeEach(() => {
|
||||
canonicalProductLookup.mockReset();
|
||||
canonicalProductLookup.mockResolvedValue({
|
||||
id: 63,
|
||||
name: "Canonical product",
|
||||
price: 399,
|
||||
canonical_only: true,
|
||||
});
|
||||
department_id.value = 12;
|
||||
customer_id.value = 12345679;
|
||||
});
|
||||
|
||||
it("uses explicit order context instead of stale global POS context", async () => {
|
||||
await reconcileOrderItemProducts([inconsistentItem()], {
|
||||
departmentId: 44,
|
||||
customerId: 87654321,
|
||||
});
|
||||
|
||||
expect(canonicalProductLookup).toHaveBeenCalledOnce();
|
||||
expect(canonicalProductLookup).toHaveBeenCalledWith(63, {
|
||||
department_id: 44,
|
||||
customer_id: 87654321,
|
||||
category_id: null,
|
||||
final_price: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("merges canonical product data without losing order-item or embedded product fields", async () => {
|
||||
const [result] = await reconcileOrderItemProducts([inconsistentItem()]);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: 9101,
|
||||
order_id: 54518,
|
||||
product_id: 63,
|
||||
quantity: 2,
|
||||
price: 777,
|
||||
notes: "Order-item note",
|
||||
product: {
|
||||
id: 63,
|
||||
name: "Canonical product",
|
||||
price: 399,
|
||||
embedded_only: "preserve me",
|
||||
canonical_only: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches a missing canonical product only once for duplicate product IDs", async () => {
|
||||
const results = await reconcileOrderItemProducts([
|
||||
inconsistentItem({ id: 9101 }),
|
||||
inconsistentItem({ id: 9102, product: { id: 54, name: "Another stale product" } }),
|
||||
]);
|
||||
|
||||
expect(canonicalProductLookup).toHaveBeenCalledOnce();
|
||||
expect(results.map((item) => item.product.id)).toEqual([63, 63]);
|
||||
expect(results.map((item) => item.product.name)).toEqual(["Canonical product", "Canonical product"]);
|
||||
});
|
||||
|
||||
it("does not reuse a mismatched embedded product as canonical data for another item", async () => {
|
||||
const [result] = await reconcileOrderItemProducts([
|
||||
inconsistentItem({ id: 9101 }),
|
||||
inconsistentItem({
|
||||
id: 9102,
|
||||
product_id: 64,
|
||||
product: { id: 63, name: "Stale product from another mismatched item" },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(canonicalProductLookup).toHaveBeenCalledWith(63, expect.any(Object));
|
||||
expect(result.product.name).toBe("Canonical product");
|
||||
});
|
||||
|
||||
it("does not fall back to stale global context when explicit order context is empty", async () => {
|
||||
await reconcileOrderItemProducts([inconsistentItem()], {
|
||||
departmentId: null,
|
||||
customerId: null,
|
||||
});
|
||||
|
||||
expect(canonicalProductLookup).toHaveBeenCalledWith(63, {
|
||||
department_id: null,
|
||||
customer_id: null,
|
||||
category_id: null,
|
||||
final_price: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([undefined, null, {}, "not-an-array"])(
|
||||
"normalizes malformed item input %# to an empty list",
|
||||
async (items) => {
|
||||
await expect(reconcileOrderItemProducts(items)).resolves.toEqual([]);
|
||||
expect(canonicalProductLookup).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user