diff --git a/src/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue b/src/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue index 2e4459c9..0d213db8 100644 --- a/src/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue +++ b/src/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue @@ -274,10 +274,15 @@ const actions = { }, approval: { accept: (object) => { - return runReviewDecision(object, "create_order"); + // Manual fallback: when the AI has no suggestion yet, force the backend + // to materialise a deterministic proposal from the wash log itself. + return runReviewDecision(object, "create_order", { forceManual: !canRunReviewAction(object, 'create_order') }); }, reject: (object) => { - return requestReviewReason(object, "deny"); + return requestReviewReason(object, "deny", { forceManual: !canRunReviewAction(object, 'deny') }); + }, + ignore: (object) => { + return requestReviewReason(object, "ignore", { forceManual: !canRunReviewAction(object, 'ignore') }); }, history: (object) => { object.showAutopilotHistory = !object.showAutopilotHistory; @@ -347,6 +352,18 @@ const canCreateAutomation = (object) => props.allowReviewActions && isXlvaskReviewEligible(object) && object?.automation?.can_create_order === true; +// Manual review eligibility: review actions are allowed when review is enabled +// AND the row is review-eligible (i.e. still has a chance of accepting / denying). +// This unlocks Accept / Reject / Ignore buttons even when the AI autopilot has +// not produced a suggestion yet, so operators can process the import queue +// during the AI autopilot readiness window. +const canRunManualReviewAction = (object) => props.allowReviewActions + && isXlvaskReviewEligible(object) + && object?.automation?.status !== 'accepted' + && object?.automation?.status !== 'denied' + && object?.automation?.status !== 'auto_accepted' + && !isObjectAttachedToOrder(object); + const canCompareAutomation = (object) => props.allowReviewActions && isXlvaskReviewEligible(object) && doesObjectHaveDuplicates(object); @@ -642,8 +659,13 @@ const applyDecisionPreview = async (objectOrObjects, preview, expectedVersions = return true; }; -const runReviewDecision = async (object, action, { orderId = null, reason = "" } = {}) => { - if (!canRunReviewAction(object, action) || !object?.id || object.automationLoading) return; +const runReviewDecision = async (object, action, { orderId = null, reason = "", forceManual = false } = {}) => { + // Allow either an AI-driven suggestion or a manual operator decision. + const hasAiCapability = canRunReviewAction(object, action); + const hasManualCapability = canRunManualReviewAction(object) && ( + action === 'create_order' || action === 'attach_order' || action === 'deny' || action === 'ignore' + ); + if ((!hasAiCapability && !hasManualCapability) || !object?.id || object.automationLoading) return; object.automationLoading = true; try { const response = await SessionUser.request( @@ -655,6 +677,7 @@ const runReviewDecision = async (object, action, { orderId = null, reason = "" } suggestion_id: object?.automation?.id ?? null, ...(orderId ? { order_id: Number(orderId) } : {}), ...(reason ? { reason } : {}), + ...(forceManual || (!hasAiCapability && hasManualCapability) ? { force_manual: true } : {}), expected_versions: getExpectedVersions(object), }, ); @@ -674,7 +697,7 @@ const runReviewDecision = async (object, action, { orderId = null, reason = "" } } }; -const requestReviewReason = async (object, action) => { +const requestReviewReason = async (object, action, options = {}) => { const result = await Swal.fire({ title: t(`invoicing_period.xlvask_autopilot.actions.${action}`), input: "textarea", @@ -687,7 +710,10 @@ const requestReviewReason = async (object, action) => { : t("invoicing_period.xlvask_autopilot.preview.reason_required"), }); if (result.isConfirmed) { - await runReviewDecision(object, action, { reason: String(result.value).trim() }); + await runReviewDecision(object, action, { + reason: String(result.value).trim(), + forceManual: options.forceManual === true || !canRunReviewAction(object, action), + }); } }; @@ -1181,20 +1207,47 @@ const filteredObjects = computed(() => { - - + + {{ t('invoicing_period.xlvask_autopilot.actions.create_order') }} - + {{ t('invoicing_period.xlvask_autopilot.actions.deny') }} + + {{ t('invoicing_period.xlvask_autopilot.actions.ignore') }} + {{ t('invoicing_period.xlvask_autopilot.audit') }} - + {{ t('invoicing_period.xlvask_autopilot.actions.attach_order') }} diff --git a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue index 55f4439a..1a05d893 100644 --- a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue +++ b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue @@ -1521,7 +1521,7 @@ const getTransactionQueryParameters = () => { { {{ SessionUser.objects.global.language.invoice_now }} diff --git a/tests/unit/xlvask-manual-review.spec.js b/tests/unit/xlvask-manual-review.spec.js new file mode 100644 index 00000000..d90b4c58 --- /dev/null +++ b/tests/unit/xlvask-manual-review.spec.js @@ -0,0 +1,203 @@ +// @vitest-environment jsdom + +import { flushPromises, shallowMount } from "@vue/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ref } from "vue"; +import { createI18n } from "vue-i18n"; + +const { request, loadList, fire } = vi.hoisted(() => ({ + request: vi.fn(), + loadList: vi.fn(), + fire: vi.fn(), +})); + +vi.mock("sweetalert2", () => ({ default: { fire } })); + +vi.mock("@/components/session/token/SessionUser.vue", () => ({ + SessionUser: { + request, + functions: { + currency: { toLocal: (value) => String(value) }, + parseErrorMessage: (error) => String(error), + }, + objects: { + global: { + language: { + completed: "Completed", + generated: "Generated", + possible_duplicates: "Possible duplicates", + price_match: "Price match", + price_unmatch: "Price mismatch", + }, + }, + orders: { meta: { title: "Orders" } }, + }, + }, +})); + +vi.mock("@/components/pagination/paginatedList.vue", () => ({ + usePaginatedListInstance: () => ({ loadList }), +})); + +vi.mock("@/components/pagination/departmentTabs.vue", () => ({ + departments: ref([{ id: 1, name: "Hall 1" }]), + getDepartments: vi.fn(), + getDepartmentName: () => "Hall 1", +})); + +vi.mock("@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js", () => ({ + clearCachedXlvaskUsageAmount: vi.fn(), + getCachedXlvaskUsageAmount: vi.fn(() => null), + setCachedXlvaskUsageAmount: vi.fn(), +})); + +import XlvaskUsageOrdersTable from "@/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue"; + +const reviewEligibleRow = (overrides = {}) => ({ + id: 9101, + reg_1: "ZZ99001", + created_at: new Date().toISOString(), + customer_name: "Test customer", + department_id: 1, + lane: 1, + total_net_amount: 250, + import_state: "new", + resolution_state: "needs_review", + certainty: "uncertain", + planned_action: "none", + // No AI suggestion yet — automation is empty (the autopilot hasn't run). + automation: { + status: "none", + review_eligible: true, + }, + ...overrides, +}); + +const mountTable = (props = {}) => + shallowMount(XlvaskUsageOrdersTable, { + props: { + objects: [reviewEligibleRow()], + allowReviewActions: true, + allowSelectMultiple: true, + ...props, + }, + global: { + plugins: [ + createI18n({ legacy: false, locale: "en", missingWarn: false, fallbackWarn: false, messages: { en: {} } }), + ], + stubs: { + WhiteBoxCard: { + template: "", + }, + OrderItemsTable: true, + }, + }, + }); + +describe("XL-Vask manual review buttons", () => { + beforeEach(() => { + request.mockReset(); + loadList.mockReset(); + fire.mockReset(); + }); + + it("exposes accept / reject / ignore buttons even when the autopilot has no suggestion", () => { + const wrapper = mountTable(); + + expect(wrapper.find("[data-testid='xlvask-accept-9101']").exists()).toBe(true); + expect(wrapper.find("[data-testid='xlvask-reject-9101']").exists()).toBe(true); + expect(wrapper.find("[data-testid='xlvask-ignore-9101']").exists()).toBe(true); + }); + + it("sends force_manual=true when accepting without an AI suggestion", async () => { + const wrapper = mountTable(); + + request.mockResolvedValue({ + data: { + data: { + preview: { id: "preview-id", selection_hash: "abc12345".repeat(8) }, + }, + }, + }); + + await wrapper.find("[data-testid='xlvask-accept-9101']").trigger("click"); + await flushPromises(); + + const previewCall = request.mock.calls.find( + ([endpoint, method]) => + endpoint === "/modules/xlvask/services/usage/automation/decisions/preview" && method === "POST" + ); + expect(previewCall).toBeDefined(); + const payload = previewCall[2]; + expect(payload).toMatchObject({ + usage_log_ids: [9101], + action: "create_order", + force_manual: true, + }); + }); + + it("does not send force_manual when the AI already has a matching suggestion", async () => { + const row = reviewEligibleRow({ + automation: { + id: 5001, + status: "suggested", + action: "create_order", + can_accept: true, + can_deny: true, + can_ignore: true, + can_attach_order: true, + can_create_order: true, + review_eligible: true, + }, + }); + const wrapper = mountTable({ objects: [row] }); + + request.mockResolvedValue({ + data: { + data: { + preview: { id: "preview-id-2", selection_hash: "def67890".repeat(8) }, + }, + }, + }); + + await wrapper.find("[data-testid='xlvask-accept-9101']").trigger("click"); + await flushPromises(); + + const previewCall = request.mock.calls.find( + ([endpoint, method]) => + endpoint === "/modules/xlvask/services/usage/automation/decisions/preview" && method === "POST" + ); + expect(previewCall).toBeDefined(); + const payload = previewCall[2]; + expect(payload.action).toBe("create_order"); + expect(payload).not.toHaveProperty("force_manual"); + }); + + it("hides the buttons once the row is already resolved (no double-action)", () => { + const row = reviewEligibleRow({ + resolution_state: "auto_linked", + automation: { status: "accepted", action: "attach_order", review_eligible: false }, + }); + const wrapper = mountTable({ objects: [row] }); + + expect(wrapper.find("[data-testid='xlvask-accept-9101']").exists()).toBe(false); + expect(wrapper.find("[data-testid='xlvask-reject-9101']").exists()).toBe(false); + expect(wrapper.find("[data-testid='xlvask-ignore-9101']").exists()).toBe(false); + }); + + it("surfaces the backend error when the manual review is rejected", async () => { + const wrapper = mountTable(); + + request.mockRejectedValue(new Error("Wash-id uniqueness activation is blocked")); + + await wrapper.find("[data-testid='xlvask-accept-9101']").trigger("click"); + await flushPromises(); + + expect(fire).toHaveBeenCalledWith( + expect.objectContaining({ + icon: "error", + title: "invoicing_period.xlvask_autopilot.preview.error_title", + }) + ); + }); +});