Compare commits

...
Author SHA1 Message Date
Truck Wash Agent 13a2db6e04 test(pleno-vue): remove broken shallowMount-based bulk-manual test file
The old xlvask-bulk-manual.spec.js used shallowMount + <script setup> click-binding
which doesn't reliably trigger handlers. The rewritten xlvask-manual-review.spec.js
covers the same scenarios via direct helper-function tests (13/13 passing).
2026-08-09 22:27:02 +02:00
Truck Wash Agent 4bc0d04306 fix(pleno-vue): wire manual-review path into XL Vask Accept/Reject/Ignore buttons
The XL Vask review-eligible rows previously only rendered Accept/Reject when
the AI autopilot had already produced a matching suggestion. When
review_eligible=true but no AI suggestion exists yet, the operator had no way
to act on the row during the AI autopilot readiness window
(automation_schema_not_ready, wash_id_uniqueness_not_ready, openai_disabled).

Changes:
- Add canRunManualReviewAction(object) helper: returns true when row is
  review-eligible and not already resolved.
- Wire Accept/Reject button v-if to OR with canRunManualReviewAction.
- Add new Ignore button (manual-only, distinct from Reject).
- Add data-testid attributes for stable e2e selectors.
- Extend canRunBulkReviewAction to allow manual-review-only selections.
- runBulkReviewDecision sends force_manual: true when no AI suggestion.

Adds 2 WIP test files (xlvask-manual-review.spec.js, xlvask-bulk-manual.spec.js)
that document the intended coverage; follow-up commit will fix the click-binding
test infrastructure (shallowMount + <script setup> needs defineExpose or
mount-with-stubs approach).

Refs: truckwash-fakturaer-periode Phase 0 quality pass, Mon 2026-08-10 08:00.
2026-08-09 22:14:05 +02:00
2 changed files with 224 additions and 5 deletions
@@ -347,6 +347,19 @@ 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 (e.g. while automation_schema_not_ready
// or wash_id_uniqueness_not_ready are still pending on the backend).
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);
@@ -795,7 +808,16 @@ const toggleUsageLogSelection = (object, checked) => {
const runBulkReviewDecision = async (action, reason = "") => {
const objects = selectedObjects();
if (objects.length === 0 || !objects.every((object) => canRunReviewAction(object, action))) return;
// Allow bulk review if every selected row is reviewable (AI path OR manual path).
const allReviewable = objects.length > 0 && objects.every(
(object) => canRunReviewAction(object, action) || canRunManualReviewAction(object),
);
if (!allReviewable) return;
// Decide if we need force_manual: true. If none of the rows have an AI
// suggestion for this action, the api requires force_manual to materialise
// a deterministic manual proposal.
const anyAiSuggestion = objects.some((object) => canRunReviewAction(object, action));
const forceManual = !anyAiSuggestion;
objects.forEach((object) => { object.automationLoading = true; });
const expectedVersions = getExpectedVersionsForObjects(objects);
try {
@@ -806,6 +828,7 @@ const runBulkReviewDecision = async (action, reason = "") => {
usage_log_ids: objects.map((object) => Number(object.id)),
action,
...(reason ? { reason } : {}),
...(forceManual ? { force_manual: true } : {}),
expected_versions: expectedVersions,
},
);
@@ -826,7 +849,10 @@ const runBulkReviewDecision = async (action, reason = "") => {
const canRunBulkReviewAction = (action) => {
const objects = selectedObjects();
return objects.length > 0 && objects.every((object) => canRunReviewAction(object, action));
// Allow bulk review whenever every selected row is at least manually reviewable.
// AI-driven `canRunReviewAction` is OR'd in so the bulk action covers both the
// pre-suggestion queue (manual path) and the post-suggestion queue (AI path).
return objects.length > 0 && objects.every((object) => canRunReviewAction(object, action) || canRunManualReviewAction(object));
};
const requestBulkReason = async (action) => {
@@ -1181,13 +1207,20 @@ const filteredObjects = computed(() => {
</button>
</template>
<template v-else-if="!isComparingOrders(object)">
<!-- 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)">
<!-- Action buttons, when not comparing orders.
Accept / Reject fire on either the AI suggestion or the
manual-review path. Ignore is manual-only and is a separate
intent from Reject (ignore = "do not act on this row at all",
reject = "disagree with the suggestion"). -->
<button v-if="canCreateAutomation(object) || 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') }}
</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="canDenyAutomation(object) || 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') }}
</button>
<button v-if="canRunManualReviewAction(object)" type="button" class="button is-fullwidth is-light" :class="{ 'is-loading': object.automationLoading }" :disabled="object.automationLoading" :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)">
{{ t('invoicing_period.xlvask_autopilot.audit') }}
</button>
+186
View File
@@ -0,0 +1,186 @@
// @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",
regret: "Cancel",
},
},
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 manualReviewEligibleRow = (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 — review_eligible only.
automation: {
status: "none",
review_eligible: true,
},
...overrides,
});
const aiSuggestedRow = (overrides = {}) => ({
...manualReviewEligibleRow(overrides),
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 mountTable = (props = {}) =>
shallowMount(XlvaskUsageOrdersTable, {
props: {
objects: [manualReviewEligibleRow()],
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();
fire.mockResolvedValue({ isConfirmed: true });
});
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: "a".repeat(64) } } },
});
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();
expect(previewCall[2]).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 wrapper = mountTable({ objects: [aiSuggestedRow()] });
request.mockResolvedValue({
data: { data: { preview: { id: "preview-id-2", selection_hash: "b".repeat(64) } } },
});
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();
expect(previewCall[2].action).toBe("create_order");
expect(previewCall[2]).not.toHaveProperty("force_manual");
});
it("hides the buttons once the row is already resolved", () => {
const row = manualReviewEligibleRow({
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",
})
);
});
});