fix(pleno-vue): XL Vask manual review buttons + Fakturer nu visibility (#271)
Makes XL Vask accept/reject/ignore buttons always visible when review is enabled (not gated on AI autopilot suggestion). Keeps the Fakturer nu button visible when a customer has multiple red flags. Includes vitest tests for the manual-review flow. Required for tomorrow's manual review + accepted order workflow. Co-authored-by: Cleanup Agent <agent@truckwash.io>
This commit is contained in:
@@ -274,10 +274,15 @@ const actions = {
|
|||||||
},
|
},
|
||||||
approval: {
|
approval: {
|
||||||
accept: (object) => {
|
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) => {
|
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) => {
|
history: (object) => {
|
||||||
object.showAutopilotHistory = !object.showAutopilotHistory;
|
object.showAutopilotHistory = !object.showAutopilotHistory;
|
||||||
@@ -347,6 +352,18 @@ const canCreateAutomation = (object) => props.allowReviewActions
|
|||||||
&& isXlvaskReviewEligible(object)
|
&& isXlvaskReviewEligible(object)
|
||||||
&& object?.automation?.can_create_order === true;
|
&& 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
|
const canCompareAutomation = (object) => props.allowReviewActions
|
||||||
&& isXlvaskReviewEligible(object)
|
&& isXlvaskReviewEligible(object)
|
||||||
&& doesObjectHaveDuplicates(object);
|
&& doesObjectHaveDuplicates(object);
|
||||||
@@ -642,8 +659,13 @@ const applyDecisionPreview = async (objectOrObjects, preview, expectedVersions =
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const runReviewDecision = async (object, action, { orderId = null, reason = "" } = {}) => {
|
const runReviewDecision = async (object, action, { orderId = null, reason = "", forceManual = false } = {}) => {
|
||||||
if (!canRunReviewAction(object, action) || !object?.id || object.automationLoading) return;
|
// 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;
|
object.automationLoading = true;
|
||||||
try {
|
try {
|
||||||
const response = await SessionUser.request(
|
const response = await SessionUser.request(
|
||||||
@@ -655,6 +677,7 @@ const runReviewDecision = async (object, action, { orderId = null, reason = "" }
|
|||||||
suggestion_id: object?.automation?.id ?? null,
|
suggestion_id: object?.automation?.id ?? null,
|
||||||
...(orderId ? { order_id: Number(orderId) } : {}),
|
...(orderId ? { order_id: Number(orderId) } : {}),
|
||||||
...(reason ? { reason } : {}),
|
...(reason ? { reason } : {}),
|
||||||
|
...(forceManual || (!hasAiCapability && hasManualCapability) ? { force_manual: true } : {}),
|
||||||
expected_versions: getExpectedVersions(object),
|
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({
|
const result = await Swal.fire({
|
||||||
title: t(`invoicing_period.xlvask_autopilot.actions.${action}`),
|
title: t(`invoicing_period.xlvask_autopilot.actions.${action}`),
|
||||||
input: "textarea",
|
input: "textarea",
|
||||||
@@ -687,7 +710,10 @@ const requestReviewReason = async (object, action) => {
|
|||||||
: t("invoicing_period.xlvask_autopilot.preview.reason_required"),
|
: t("invoicing_period.xlvask_autopilot.preview.reason_required"),
|
||||||
});
|
});
|
||||||
if (result.isConfirmed) {
|
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(() => {
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="!isComparingOrders(object)">
|
<template v-else-if="!isComparingOrders(object)">
|
||||||
<!-- Action buttons, when not comparing orders -->
|
<!-- Action buttons, when not comparing orders.
|
||||||
<button v-if="canCreateAutomation(object)" type="button" class="button is-fullwidth is-success is-light" @click="actions.click.approval.accept(object)">
|
Always show Accept / Reject / Ignore when review is enabled and
|
||||||
|
the row is actionable, so manual review works even when the
|
||||||
|
AI autopilot has not yet produced a suggestion. -->
|
||||||
|
<button
|
||||||
|
v-if="canRunReviewAction(object, 'create_order') || canRunManualReviewAction(object)"
|
||||||
|
type="button"
|
||||||
|
class="button is-fullwidth is-success is-light"
|
||||||
|
:class="{ 'is-loading': object.automationLoading }"
|
||||||
|
:disabled="object.automationLoading"
|
||||||
|
:data-testid="'xlvask-accept-' + object.id"
|
||||||
|
@click="actions.click.approval.accept(object)"
|
||||||
|
>
|
||||||
{{ t('invoicing_period.xlvask_autopilot.actions.create_order') }}
|
{{ t('invoicing_period.xlvask_autopilot.actions.create_order') }}
|
||||||
</button>
|
</button>
|
||||||
<button v-if="canDenyAutomation(object)" type="button" class="button is-fullwidth is-danger is-light" @click="actions.click.approval.reject(object)">
|
<button
|
||||||
|
v-if="canRunReviewAction(object, 'deny') || canRunManualReviewAction(object)"
|
||||||
|
type="button"
|
||||||
|
class="button is-fullwidth is-danger is-light"
|
||||||
|
:class="{ 'is-loading': object.automationLoading }"
|
||||||
|
:disabled="object.automationLoading"
|
||||||
|
:data-testid="'xlvask-reject-' + object.id"
|
||||||
|
@click="actions.click.approval.reject(object)"
|
||||||
|
>
|
||||||
{{ t('invoicing_period.xlvask_autopilot.actions.deny') }}
|
{{ t('invoicing_period.xlvask_autopilot.actions.deny') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-fullwidth is-light"
|
||||||
|
:data-testid="'xlvask-ignore-' + object.id"
|
||||||
|
@click="actions.click.approval.ignore(object)"
|
||||||
|
>
|
||||||
|
{{ t('invoicing_period.xlvask_autopilot.actions.ignore') }}
|
||||||
|
</button>
|
||||||
<button type="button" class="button is-fullwidth is-light" @click="actions.click.approval.history(object)">
|
<button type="button" class="button is-fullwidth is-light" @click="actions.click.approval.history(object)">
|
||||||
{{ t('invoicing_period.xlvask_autopilot.audit') }}
|
{{ t('invoicing_period.xlvask_autopilot.audit') }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="isComparingOrders(object)">
|
<template v-else-if="isComparingOrders(object)">
|
||||||
<!-- Action buttons, when comparing orders -->
|
<!-- Action buttons, when comparing orders -->
|
||||||
<button v-if="canAttachAutomation(object)" type="button" class="button is-fullwidth is-success is-light" @click="actions.click.comparison.isEqual(object)">
|
<button v-if="canAttachAutomation(object) || canRunManualReviewAction(object)" type="button" class="button is-fullwidth is-success is-light" @click="actions.click.comparison.isEqual(object)">
|
||||||
{{ t('invoicing_period.xlvask_autopilot.actions.attach_order') }}
|
{{ t('invoicing_period.xlvask_autopilot.actions.attach_order') }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="button is-fullwidth is-warning is-light" @click="actions.click.comparison.isNotEqual(object)">
|
<button type="button" class="button is-fullwidth is-warning is-light" @click="actions.click.comparison.isNotEqual(object)">
|
||||||
|
|||||||
+3
-3
@@ -1521,7 +1521,7 @@ const getTransactionQueryParameters = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="column is-narrow period-customer-card__state" v-else>
|
<div class="column is-narrow period-customer-card__state" v-else>
|
||||||
<button
|
<button
|
||||||
v-if="tmpFilters.displayRequiresAction && customer.requires_action && !hasMultipleRedFlags(customer)"
|
v-if="tmpFilters.displayRequiresAction && customer.requires_action"
|
||||||
type="button"
|
type="button"
|
||||||
class="button is-small is-dark is-inverted"
|
class="button is-small is-dark is-inverted"
|
||||||
:class="{ 'is-loading': isCustomerPeriodActionLoading(customer) }"
|
:class="{ 'is-loading': isCustomerPeriodActionLoading(customer) }"
|
||||||
@@ -1535,8 +1535,8 @@ const getTransactionQueryParameters = () => {
|
|||||||
<span>{{ SessionUser.objects.global.language.invoice_now }}</span>
|
<span>{{ SessionUser.objects.global.language.invoice_now }}</span>
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
v-else-if="tmpFilters.displayRequiresAction && customer.requires_action && hasMultipleRedFlags(customer)"
|
v-if="tmpFilters.displayRequiresAction && customer.requires_action && hasMultipleRedFlags(customer)"
|
||||||
class="tag is-danger is-light is-small"
|
class="tag is-danger is-light is-small ml-1"
|
||||||
:data-testid="`invoicing-period-customer-multiple-red-flag-${customer.customer_number}`"
|
:data-testid="`invoicing-period-customer-multiple-red-flag-${customer.customer_number}`"
|
||||||
:title="tr('card.multiple_red_flags_title', 'Flere røde flag — gennemgå kunden før fakturering.')"
|
:title="tr('card.multiple_red_flags_title', 'Flere røde flag — gennemgå kunden før fakturering.')"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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: "<section><slot name='header'/><slot name='content'/></section>",
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user