Add comprehensive unit and e2e tests for invoice collection, queue logic, and modal interactions:

- Introduced e2e test for "Change Invoice Collection" in `change-invoice-collection.spec.ts`.
- Added unit tests for `CollectedOrderInvoiceOverview.vue` including edit flow for `closed_at`.
- Implemented tests for queue reliability, state handling, and customer actions:
  - `CollectedOrderInvoicesQueueHistory.vue`: polling, error handling, richer diagnostics, and retry logic.
  - `InvoicingBillingPeriod` views: refresh and queue state handling.
- Enhanced test coverage for Stripe queue functionality and related actions.
This commit is contained in:
Jeppe Bundgaard
2026-04-08 15:53:52 +02:00
parent 2b6129db29
commit 8eb315f439
40 changed files with 6673 additions and 1064 deletions
+241 -9
View File
@@ -5996,10 +5996,10 @@ paths:
post: post:
tags: tags:
- Invoices - Invoices
summary: Queue transfer of collected invoice to e-conomic summary: Export collected invoice to e-conomic
description: | description: |
Queues collected invoice transfer to e-conomic. Exports collected invoice to e-conomic.
Processing runs asynchronously and can be tracked through queue status endpoints. Uses async queue when available, otherwise falls back to synchronous processing.
operationId: queueCollectedInvoiceEconomicTransfer operationId: queueCollectedInvoiceEconomicTransfer
requestBody: requestBody:
required: true required: true
@@ -6016,6 +6016,12 @@ paths:
type: boolean type: boolean
default: false default: false
responses: responses:
'200':
description: Collected invoice export processed synchronously (fallback)
content:
application/json:
schema:
$ref: '#/components/schemas/EconomicTransferSynchronousFallbackResponse'
'202': '202':
description: Collected invoice transfer queued description: Collected invoice transfer queued
content: content:
@@ -6028,6 +6034,45 @@ paths:
'404': { $ref: '#/components/responses/NotFound' } '404': { $ref: '#/components/responses/NotFound' }
'500': { $ref: '#/components/responses/InternalServerError' } '500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/stripe/book:
post:
tags:
- Invoices
summary: Export Stripe collected invoice to e-conomic
description: |
Exports a Stripe-backed collected invoice to e-conomic.
Uses async queue when available, otherwise falls back to synchronous processing.
operationId: queueStripeCollectedInvoiceEconomicTransfer
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id]
properties:
id:
type: integer
minimum: 1
responses:
'200':
description: Stripe collected invoice export processed synchronously (fallback)
content:
application/json:
schema:
$ref: '#/components/schemas/EconomicTransferSynchronousFallbackResponse'
'202':
description: Stripe collected invoice transfer queued
content:
application/json:
schema:
$ref: '#/components/schemas/EconomicTransferQueueEnqueueResponse'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/queue: /collected-invoices/economic/queue:
get: get:
tags: tags:
@@ -6039,9 +6084,14 @@ paths:
in: query in: query
required: false required: false
description: Comma-separated queue statuses to filter by. description: Comma-separated queue statuses to filter by.
style: form
explode: false
schema: schema:
type: string type: array
example: "QUEUED,FAILED" items:
$ref: '#/components/schemas/EconomicTransferQueueStatus'
uniqueItems: true
example: [QUEUED, FAILED]
- name: limit - name: limit
in: query in: query
required: false required: false
@@ -6067,6 +6117,7 @@ paths:
'400': { $ref: '#/components/responses/BadRequest' } '400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' } '401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' } '403': { $ref: '#/components/responses/Forbidden' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' } '500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/queue/status: /collected-invoices/economic/queue/status:
@@ -6093,6 +6144,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' } '401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' } '403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' } '404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' } '500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/queue/retry: /collected-invoices/economic/queue/retry:
@@ -6120,9 +6172,42 @@ paths:
schema: schema:
$ref: '#/components/schemas/EconomicTransferQueueRetryResponse' $ref: '#/components/schemas/EconomicTransferQueueRetryResponse'
'400': { $ref: '#/components/responses/BadRequest' } '400': { $ref: '#/components/responses/BadRequest' }
'409': { $ref: '#/components/responses/Conflict' }
'401': { $ref: '#/components/responses/Unauthorized' } '401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' } '403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' } '404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/queue/run:
post:
tags:
- Invoices
summary: Run one collected-invoice queue batch immediately
operationId: runCollectedInvoiceEconomicQueueBatch
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
limit:
type: integer
minimum: 1
maximum: 10
default: 10
responses:
'200':
description: Queue batch processed
content:
application/json:
schema:
$ref: '#/components/schemas/EconomicTransferQueueRunResponse'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' } '500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/compare: /collected-invoices/economic/compare:
@@ -7805,8 +7890,8 @@ paths:
post: post:
tags: tags:
- Modules - Modules
summary: Queue draft invoice export to e-conomic summary: Export draft invoice to e-conomic
description: Queue a draft invoice export job for asynchronous processing. description: Exports draft invoice using queue processing when available, with synchronous fallback when queue dependencies are unavailable.
operationId: queueDraftInvoiceExportToEconomic operationId: queueDraftInvoiceExportToEconomic
requestBody: requestBody:
required: true required: true
@@ -7820,6 +7905,12 @@ paths:
type: integer type: integer
minimum: 1 minimum: 1
responses: responses:
'200':
description: Draft invoice export processed synchronously (fallback)
content:
application/json:
schema:
$ref: '#/components/schemas/EconomicTransferSynchronousFallbackResponse'
'202': '202':
description: Draft invoice export queued description: Draft invoice export queued
content: content:
@@ -7856,6 +7947,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' } '401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' } '403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' } '404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' } '500': { $ref: '#/components/responses/InternalServerError' }
/economic/invoice/draft/export/retry: /economic/invoice/draft/export/retry:
@@ -7886,14 +7978,15 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' } '401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' } '403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' } '404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' } '500': { $ref: '#/components/responses/InternalServerError' }
/economic/invoice/export: /economic/invoice/export:
post: post:
tags: tags:
- Modules - Modules
summary: Queue invoice export to e-conomic summary: Export invoice to e-conomic
description: Queue booked invoice export job for asynchronous processing. description: Exports booked invoice using queue processing when available, with synchronous fallback when queue dependencies are unavailable.
operationId: queueInvoiceExportToEconomic operationId: queueInvoiceExportToEconomic
requestBody: requestBody:
required: true required: true
@@ -7907,6 +8000,12 @@ paths:
type: integer type: integer
minimum: 1 minimum: 1
responses: responses:
'200':
description: Invoice export processed synchronously (fallback)
content:
application/json:
schema:
$ref: '#/components/schemas/EconomicTransferSynchronousFallbackResponse'
'202': '202':
description: Invoice export queued description: Invoice export queued
content: content:
@@ -7943,6 +8042,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' } '401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' } '403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' } '404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' } '500': { $ref: '#/components/responses/InternalServerError' }
/economic/invoice/export/retry: /economic/invoice/export/retry:
@@ -7973,6 +8073,7 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' } '401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' } '403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' } '404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' } '500': { $ref: '#/components/responses/InternalServerError' }
# Module - Stripe Endpoints # Module - Stripe Endpoints
@@ -11056,6 +11157,12 @@ components:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
Conflict:
description: Conflict - Request could not be completed due to current resource state
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
Unauthorized: Unauthorized:
description: Unauthorized - Invalid or missing authentication token description: Unauthorized - Invalid or missing authentication token
content: content:
@@ -11074,6 +11181,12 @@ components:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
ServiceUnavailable:
description: Service unavailable - Required async queue dependencies are unavailable
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
InternalServerError: InternalServerError:
description: Internal server error description: Internal server error
content: content:
@@ -12598,6 +12711,10 @@ components:
type: object type: object
nullable: true nullable: true
additionalProperties: true additionalProperties: true
details_summary:
type: object
nullable: true
additionalProperties: true
created_by: created_by:
type: integer type: integer
nullable: true nullable: true
@@ -12639,10 +12756,14 @@ components:
properties: properties:
message: message:
type: string type: string
job_id:
type: integer
minimum: 1
job: job:
$ref: '#/components/schemas/EconomicTransferQueueJob' $ref: '#/components/schemas/EconomicTransferQueueJob'
required: required:
- message - message
- job_id
- job - job
meta: meta:
oneOf: oneOf:
@@ -12662,6 +12783,44 @@ components:
- meta - meta
- includes - includes
EconomicTransferSynchronousFallbackResponse:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
message:
type: string
mode:
type: string
enum: [synchronous_fallback]
result:
type: object
additionalProperties: true
required:
- message
- mode
- result
meta:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
includes:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
required:
- success
- data
- meta
- includes
EconomicTransferQueueStatusResponse: EconomicTransferQueueStatusResponse:
type: object type: object
properties: properties:
@@ -12720,6 +12879,64 @@ components:
- meta - meta
- includes - includes
EconomicTransferQueueRunResponse:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
message:
type: string
processed:
type: integer
minimum: 0
completed:
type: integer
minimum: 0
failed:
type: integer
minimum: 0
jobs:
type: array
items:
type: integer
minimum: 1
limit:
type: integer
minimum: 1
maximum: 10
transfer_type:
type: string
enum:
- COLLECTED_INVOICE_EXPORT
required:
- message
- processed
- completed
- failed
- jobs
- limit
- transfer_type
meta:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
includes:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
required:
- success
- data
- meta
- includes
EconomicTransferQueueListResponse: EconomicTransferQueueListResponse:
type: object type: object
properties: properties:
@@ -12735,9 +12952,24 @@ components:
count: count:
type: integer type: integer
minimum: 0 minimum: 0
total:
type: integer
minimum: 0
limit:
type: integer
minimum: 1
offset:
type: integer
minimum: 0
has_more:
type: boolean
required: required:
- items - items
- count - count
- total
- limit
- offset
- has_more
meta: meta:
oneOf: oneOf:
- type: array - type: array
@@ -1,5 +1,5 @@
<script setup> <script setup>
import {defineProps, defineEmits, onMounted, computed, watch} from 'vue'; import {defineProps, defineEmits, onMounted, onBeforeUnmount, computed, watch} from 'vue';
import { useSlots } from "vue"; import { useSlots } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue"; import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
@@ -82,6 +82,37 @@ const props = defineProps({
const customerModalVisible = ref(false); const customerModalVisible = ref(false);
const userIdFromCustomerNumber = ref(null); const userIdFromCustomerNumber = ref(null);
const userLookupRequestId = ref(0); const userLookupRequestId = ref(0);
const dropdownRoot = ref(null);
const isDropdownOpen = ref(false);
const closeDropdown = () => {
isDropdownOpen.value = false;
};
const toggleDropdown = () => {
isDropdownOpen.value = !isDropdownOpen.value;
};
const onActionSelected = () => {
closeDropdown();
};
const onDocumentClick = (event) => {
if (!isDropdownOpen.value) {
return;
}
if (dropdownRoot.value && !dropdownRoot.value.contains(event.target)) {
closeDropdown();
}
};
const onDocumentKeydown = (event) => {
if (event.key === 'Escape') {
closeDropdown();
}
};
const resolveUserId = async () => { const resolveUserId = async () => {
if (props.user_id) { if (props.user_id) {
userIdFromCustomerNumber.value = props.user_id; userIdFromCustomerNumber.value = props.user_id;
@@ -183,6 +214,9 @@ const hasUser = computed(() => Boolean(props.user_id || props.customer_number ||
const attachmentsFromOrder = ref([]); const attachmentsFromOrder = ref([]);
const attachmentsFromOrderError = ref(null); const attachmentsFromOrderError = ref(null);
onMounted(() => { onMounted(() => {
document.addEventListener('click', onDocumentClick);
document.addEventListener('keydown', onDocumentKeydown);
if (props.order_id) { if (props.order_id) {
SessionUser.objects.orders.functions.fetchAttachments(props.order_id).then((response) => { SessionUser.objects.orders.functions.fetchAttachments(props.order_id).then((response) => {
/** /**
@@ -217,6 +251,11 @@ onMounted(() => {
} }
}); });
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('keydown', onDocumentKeydown);
});
const onShowImpersonationQRCode = (src, directLink) => { const onShowImpersonationQRCode = (src, directLink) => {
Swal.fire({ Swal.fire({
title: t('admin.pos.settings_wheel.scan_qr_to_login'), title: t('admin.pos.settings_wheel.scan_qr_to_login'),
@@ -417,7 +456,7 @@ const defaultActions = computed(() => {
icon: 'fas fa-file-invoice-dollar', icon: 'fas fa-file-invoice-dollar',
label: t('admin.pos.settings_wheel.change_invoice_collection'), label: t('admin.pos.settings_wheel.change_invoice_collection'),
clickAction: () => { clickAction: () => {
return SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm(props.order_id); return SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm(props.order_id, props.refreshFunction);
}, },
showFunction: () => { showFunction: () => {
return !!props.order_id && SessionUser.canAccessSuperUser(); return !!props.order_id && SessionUser.canAccessSuperUser();
@@ -606,9 +645,16 @@ const defaultActions = computed(() => {
/> />
</template> </template>
</template> </template>
<div class="dropdown is-right is-hoverable" v-else> <div class="dropdown is-right" :class="{ 'is-active': isDropdownOpen }" v-else ref="dropdownRoot">
<div class="dropdown-trigger"> <div class="dropdown-trigger">
<button class="button is-small is-dark" aria-haspopup="true" aria-controls="dropdown-menu"> <button
type="button"
class="button is-small is-dark"
aria-haspopup="true"
aria-controls="dropdown-menu"
:aria-expanded="isDropdownOpen ? 'true' : 'false'"
@click.stop="toggleDropdown"
>
<span class="icon"> <span class="icon">
<i :class="props.icon"></i> <i :class="props.icon"></i>
</span> </span>
@@ -616,7 +662,7 @@ const defaultActions = computed(() => {
</button> </button>
</div> </div>
<div class="dropdown-menu" id="dropdown-menu" role="menu"> <div class="dropdown-menu" id="dropdown-menu" role="menu">
<div class="dropdown-content"> <div class="dropdown-content" @dropdown-action-selected="onActionSelected">
<!-- Actions (If defined) --> <!-- Actions (If defined) -->
<template v-if="useSlots().actions"> <template v-if="useSlots().actions">
<slot name="actions"></slot> <slot name="actions"></slot>
@@ -709,7 +755,7 @@ const defaultActions = computed(() => {
v-if="SessionUser.canAccessSuperUser()" v-if="SessionUser.canAccessSuperUser()"
:label="t('admin.pos.settings_wheel.change_invoice_collection')" :label="t('admin.pos.settings_wheel.change_invoice_collection')"
icon="fas fa-file-invoice-dollar" icon="fas fa-file-invoice-dollar"
:click-action="() => SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm(props.order_id)" :click-action="() => SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm(props.order_id, props.refreshFunction)"
:disabled="false" :disabled="false"
/> />
<!-- Delete the order --> <!-- Delete the order -->
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { defineProps } from 'vue'; import { defineEmits, defineProps, ref } from 'vue';
const props = defineProps({ const props = defineProps({
clickAction: Function, clickAction: Function,
@@ -8,6 +8,7 @@ const props = defineProps({
disabled: Boolean, disabled: Boolean,
template: String // The style of the button (default, danger, success, warning, info, light) template: String // The style of the button (default, danger, success, warning, info, light)
}); });
const emit = defineEmits(['selected']);
const styles = { const styles = {
default: { default: {
@@ -72,9 +73,22 @@ const styles = {
}, },
}; };
const click = () => { const isProcessing = ref(false);
if (props.clickAction && !props.disabled) const click = async (event) => {
props.clickAction(); if (isDisabled() || isProcessing.value) {
return;
}
isProcessing.value = true;
try {
if (props.clickAction) {
await props.clickAction();
}
} finally {
isProcessing.value = false;
emit('selected');
event?.currentTarget?.dispatchEvent(new CustomEvent('dropdown-action-selected', { bubbles: true }));
}
} }
const isDisabled = () => { const isDisabled = () => {
@@ -111,19 +125,31 @@ const getLabelColor = () => {
</script> </script>
<template> <template>
<a class="dropdown-item" @click="click" :class="{'is-disabled': isDisabled()}"> <button
type="button"
class="dropdown-item dropdown-item-action"
@click.stop.prevent="click"
:class="{'is-disabled': isDisabled()}"
:disabled="isDisabled()"
>
<span class="icon"> <span class="icon">
<i :class="getIcon() + ' ' + getIconColor()"></i> <i :class="getIcon() + ' ' + getIconColor()"></i>
</span> </span>
<span class="ml-1" <span class="ml-1"
:class="getLabelColor()" :class="getLabelColor()"
>{{ getLabel() }}</span> >{{ getLabel() }}</span>
</a> </button>
</template> </template>
<style scoped> <style scoped>
.dropdown-item-action {
width: 100%;
text-align: left;
background: transparent;
border: 0;
}
.is-disabled { .is-disabled {
pointer-events: none;
opacity: 0.5; opacity: 0.5;
} }
@@ -134,4 +160,4 @@ const getLabelColor = () => {
.is-disabled .icon { .is-disabled .icon {
opacity: 0.5; opacity: 0.5;
} }
</style> </style>
@@ -8,6 +8,7 @@ import PosDepartmentStep4 from "@/components/displays/department/pos/steps/PosDe
import ViewportResponsiveWrapper from "@/components/viewport/conditions/elements/ViewportResponsiveWrapper.vue"; import ViewportResponsiveWrapper from "@/components/viewport/conditions/elements/ViewportResponsiveWrapper.vue";
import PosDepartmentStepMobile1 from "@/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue"; import PosDepartmentStepMobile1 from "@/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue";
import PosDepartmentStepMobile2 from "@/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue"; import PosDepartmentStepMobile2 from "@/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue";
import PosDepartmentStepMobile3 from "@/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile3.vue";
import { pos } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue"; import { pos } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCustomer.vue"; import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCustomer.vue";
import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue"; import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue";
@@ -65,6 +66,7 @@ applyPosRouteSearch(window.location.search, {
<PosDepartmentStepMobilePopupRenderer/> <PosDepartmentStepMobilePopupRenderer/>
<PosDepartmentStepMobile1 v-if="getCurrentStep() === 1" /> <PosDepartmentStepMobile1 v-if="getCurrentStep() === 1" />
<PosDepartmentStepMobile2 v-else-if="getCurrentStep() === 2 && (getOrderId() !== null)" /> <PosDepartmentStepMobile2 v-else-if="getCurrentStep() === 2 && (getOrderId() !== null)" />
<PosDepartmentStepMobile3 v-else-if="getCurrentStep() === 3 && (getOrderId() !== null)" />
</template> </template>
</ViewportResponsiveWrapper> </ViewportResponsiveWrapper>
</div> </div>
File diff suppressed because it is too large Load Diff
@@ -972,6 +972,7 @@ const formatCashierName = (order) => {
v-bind:order_id="order.id" v-bind:order_id="order.id"
v-bind:invoice_collection_id="order.invoice_collection_id" v-bind:invoice_collection_id="order.invoice_collection_id"
v-bind:reg_1="order.reg_1" v-bind:reg_1="order.reg_1"
:refreshFunction="loadList"
@deleted="loadList()" @deleted="loadList()"
> >
<template v-slot:actions> <template v-slot:actions>
@@ -1140,6 +1141,7 @@ const formatCashierName = (order) => {
v-bind:order_id="order.id" v-bind:order_id="order.id"
v-bind:invoice_collection_id="order.invoice_collection_id" v-bind:invoice_collection_id="order.invoice_collection_id"
v-bind:reg_1="order.reg_1" v-bind:reg_1="order.reg_1"
:refreshFunction="loadList"
@deleted="loadList()" @deleted="loadList()"
:displayActionsDirectly="true" :displayActionsDirectly="true"
> >
@@ -1503,6 +1505,7 @@ const formatCashierName = (order) => {
v-bind:reg_3="selectedOrderForActionsMenu.reg_3" v-bind:reg_3="selectedOrderForActionsMenu.reg_3"
v-bind:order_booking_id="selectedOrderForActionsMenu.booking_id" v-bind:order_booking_id="selectedOrderForActionsMenu.booking_id"
v-bind:department_id="selectedOrderForActionsMenu.department_id" v-bind:department_id="selectedOrderForActionsMenu.department_id"
:refreshFunction="loadList"
@deleted="loadList()" @deleted="loadList()"
:displayActionsDirectly="true" :displayActionsDirectly="true"
> >
@@ -20,7 +20,7 @@ import PosDepartmentStepMobileButtonNextStep
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue"; from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import { primaryItem } from "./objects/PosDepartmentStepMobileFlow.vue"; import { primaryItem } from "./objects/PosDepartmentStepMobileFlow.vue";
import { order_id, order_notes, department_id, customer_id, getCustomerEmail, customer_name, isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue"; import { order_id, order_notes, department_id, customer_id, getCustomerEmail, customer_name, isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue";
import { getOrderItems } from "@/components/shop/OrdersItems.vue"; import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue"; import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import PosDepartmentStepMobileButtonClearAll import PosDepartmentStepMobileButtonClearAll
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue"; from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
@@ -430,76 +430,143 @@ onUnmounted(() => {
window.removeEventListener('scroll', onScroll, true); window.removeEventListener('scroll', onScroll, true);
}); });
const onBeforeComplete = () => { const getNormalizedOrderId = () => {
// This function is called before the step is completed const parsedOrderId = Number.parseInt(String(order_id.value), 10);
return new Promise((resolve, reject) => { return Number.isInteger(parsedOrderId) && parsedOrderId > 0 ? parsedOrderId : null;
// Check if the primary item is set }
if (!transactionItems.primaryItem.value) {
reject(new Error('No primary item selected')); const normalizeOrderItemShape = (item: any) => ({
return; product_id: Number(item?.product_id ?? item?.product?.id ?? 0),
} quantity: Number(item?.quantity ?? 0),
// If the transaction items contain a wash certificate, create a booking for it related_item_id: item?.related_item_id === null || item?.related_item_id === undefined
const hasWashCertificate = transactionItems.containsWashCertificate(); ? null
// Add the order items to the transaction : Number(item.related_item_id),
SessionUser.objects.global.add.object( price: Number(item?.price ?? 0),
'/order/items', notes: String(item?.notes ?? ''),
{ })
order_id: parseInt(order_id.value), // The order ID to which the item will be added
product_id: primaryItem.value.id, // The product ID of the primary item const buildDesiredOrderItemShapes = () => {
quantity: 1, if (!transactionItems.primaryItem.value) {
price: primaryItem.value.price, return [];
notes: primaryItem.value?.notes || '', // Notes can be added here if needed }
related_item_id: null,
}, // The order item to create const primaryShape = {
{authenticated: true} kind: 'primary',
).then((response) => { relatedKey: 'primary',
const relatedItemId = response.data.data.id; // Get the ID of the created order item product_id: Number(transactionItems.primaryItem.value.id),
// Create the addon items (With a quantity of > 0) quantity: 1,
const addonItems = transactionItems.primaryItem.value.addons.filter(addon => addon.quantity > 0); related_item_id: null,
const addonPromises = addonItems.map(addon => { price: Number(transactionItems.primaryItem.value.price ?? 0),
// For each addon, create an order item notes: String(transactionItems.primaryItem.value?.notes ?? ''),
return SessionUser.objects.global.add.object( };
'/order/items',
{ const addonShapes = (transactionItems.primaryItem.value.addons || [])
order_id: parseInt(order_id.value), // The order ID to which the addon will be added .filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
product_id: addon.product.id, // The product ID of the addon .map((addon: any) => ({
quantity: addon.quantity, kind: 'addon',
price: addon.product.price, relatedKey: 'primary',
notes: addon.product?.notes || '', // Notes can be added here if needed product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
related_item_id: relatedItemId, // Link the addon to the primary item quantity: Number(addon?.quantity ?? 0),
}, related_item_id: '__PRIMARY__',
{authenticated: true} price: Number(addon?.product?.price ?? addon?.price ?? 0),
); notes: String(addon?.product?.notes ?? ''),
}); }));
// Create the additional items (With a quantity of > 0)
const additionalItems = transactionItems.additionalItems.value.filter(item => item.quantity > 0); const additionalShapes = (transactionItems.additionalItems.value || [])
const additionalPromises = additionalItems.map(item => { .filter((item: any) => Number(item?.quantity ?? 0) > 0)
// For each additional item, create an order item .map((item: any) => ({
return SessionUser.objects.global.add.object( kind: 'additional',
'/order/items', relatedKey: null,
{ product_id: Number(item?.id ?? 0),
order_id: parseInt(order_id.value), // The order ID to which the additional item will be added quantity: Number(item?.quantity ?? 0),
product_id: item.id, // The product ID of the additional item related_item_id: null,
quantity: item.quantity, price: Number(item?.price ?? 0),
price: item.price, notes: String(item?.notes ?? ''),
notes: item?.notes || '', // Notes can be added here if needed }));
related_item_id: null, // Additional items are not linked to the primary item
}, return [primaryShape, ...addonShapes, ...additionalShapes];
{authenticated: true} }
);
}); const normalizeExistingOrderItemShapes = (items: any[]) => {
// Combine addon and additional item promises const primaryItems = items.filter((item: any) => item?.related_item_id === null || item?.related_item_id === undefined);
addonPromises.push(...additionalPromises); if (primaryItems.length === 0) {
// IF the booking is being created, wait for it to finish first return [];
return Promise.all(addonPromises); }
}).then(() => {
// After successfully adding the order item, resolve the promise const additionalItems = primaryItems.filter((item: any) => Number(item?.product?.id ?? item?.product_id ?? 0) !== Number(transactionItems.primaryItem.value?.id ?? 0));
resolve(true); const primaryItemShape = normalizeOrderItemShape(primaryItems[0]);
}).catch(error => { const addonShapes = items
// If there's an error, reject the promise .filter((item: any) => item?.related_item_id === primaryItems[0]?.id)
reject(error); .map(normalizeOrderItemShape);
}); const additionalShapes = additionalItems.map(normalizeOrderItemShape);
});
return [primaryItemShape, ...addonShapes, ...additionalShapes];
}
const syncCurrentTransactionToOrder = async () => {
const normalizedOrderId = getNormalizedOrderId();
if (!normalizedOrderId) {
throw new Error('Order ID is required');
}
if (!transactionItems.primaryItem.value) {
throw new Error('No primary item selected');
}
const existingItemsResponse = await getOrderItems(normalizedOrderId);
const existingItems = Array.isArray(existingItemsResponse?.data?.data) ? existingItemsResponse.data.data : [];
const desiredShapes = buildDesiredOrderItemShapes();
const currentShapes = normalizeExistingOrderItemShapes(existingItems);
if (JSON.stringify(currentShapes) === JSON.stringify(desiredShapes.map(({ kind, relatedKey, ...shape }) => shape))) {
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.price
);
const createdPrimaryItemId = createdPrimaryItemResponse?.data?.data?.id;
const addonPromises = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.map((addon: any) => createOrderItem(
normalizedOrderId,
addon.product.id,
Number(addon.quantity),
createdPrimaryItemId,
addon.product?.notes || '',
addon.product.price
));
const additionalPromises = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
.map((item: any) => createOrderItem(
normalizedOrderId,
item.id,
Number(item.quantity),
null,
item?.notes || '',
item.price
));
await Promise.all([...addonPromises, ...additionalPromises]);
return true;
}
const onBeforeComplete = async () => {
if (!transactionItems.primaryItem.value) {
throw new Error('No primary item selected');
}
await syncCurrentTransactionToOrder();
return true;
} }
// Re-try applying booking whenever booking id or vehicle reg changes // Re-try applying booking whenever booking id or vehicle reg changes
@@ -0,0 +1,295 @@
<script setup>
import { computed, ref } from 'vue';
import Swal from "sweetalert2";
import PayWithStripeButton from "@/components/displays/department/pos/displays/PayWithStripeButton.vue";
import {
customer_id,
department_id,
order_id,
order_items,
reg_1,
reset_all_values,
setStep,
} from "@/components/shop/POSDepartmentProcess.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { StripeModule } from "@/components/stripe/StripeModule.vue";
import {
metadata,
popups,
resetPos,
transactionItems,
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
const isCompleting = ref(false);
const completionError = ref(null);
const completedOrderId = ref(null);
const normalizedOrderId = computed(() => {
const parsed = Number.parseInt(String(order_id.value), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
});
const normalizedDepartmentId = computed(() => {
const parsed = Number.parseInt(String(department_id.value), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
});
const currentPaymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
const paymentIntentState = computed(() => StripeModule.paymentIntents.getPaymentIntentState(currentPaymentIntent.value));
const hasActivePaymentIntent = computed(() => StripeModule.paymentIntents.isPaymentIntentActive(currentPaymentIntent.value));
const orderItemsTotal = computed(() => {
return (Array.isArray(order_items.value) ? order_items.value : []).reduce((sum, item) => {
const quantity = Number(item?.quantity ?? 1);
const price = Number(item?.price ?? item?.product?.price ?? 0);
return sum + (Number.isFinite(quantity) ? quantity : 0) * (Number.isFinite(price) ? price : 0);
}, 0);
});
const mobileOrderTotal = computed(() => {
const transactionTotal = Number(transactionItems.getTransactionTotal?.() ?? 0);
if (Number.isFinite(transactionTotal) && transactionTotal > 0) {
return transactionTotal;
}
return orderItemsTotal.value;
});
const orderReference = computed(() => metadata.getReference?.() || '');
const orderNotes = computed(() => metadata.getNotes?.() || '');
const orderRegistration = computed(() => reg_1.value || 'Unknown vehicle');
const isBackDisabled = computed(() => isCompleting.value || paymentIntentState.value === 'succeeded');
const formatCurrency = (amount) => {
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK',
}).format(Number(amount || 0));
};
const navigateToItems = () => {
setStep(2);
if (normalizedOrderId.value) {
window.history.pushState({}, '', `?id=${normalizedOrderId.value}&customer_id=${customer_id.value}&step=2`);
return;
}
window.history.pushState({}, '', `?step=2`);
};
const clearSuccessfulMobileCardFlow = () => {
window.setTimeout(() => {
reset_all_values();
order_id.value = 0;
popups.clear();
resetPos();
}, 3000);
};
const completeMobileCardPayment = async () => {
if (!normalizedOrderId.value) {
return;
}
if (isCompleting.value || completedOrderId.value === normalizedOrderId.value) {
return;
}
isCompleting.value = true;
completionError.value = null;
completedOrderId.value = normalizedOrderId.value;
try {
await SessionUser.objects.orders.functions.mark_as_completed(normalizedOrderId.value);
popups.select('completed_transaction', {
message: `Order #${normalizedOrderId.value} successfully created.`,
});
clearSuccessfulMobileCardFlow();
} catch (error) {
completedOrderId.value = null;
completionError.value = SessionUser.functions.parseErrorMessage(error) || 'Unable to complete card payment.';
console.error('Failed to complete mobile card payment:', error);
} finally {
isCompleting.value = false;
}
};
const onBackClick = async () => {
if (paymentIntentState.value === 'succeeded') {
return;
}
if (!hasActivePaymentIntent.value) {
navigateToItems();
return;
}
const result = await Swal.fire({
title: 'Payment in progress',
text: 'Resume the current payment or cancel it before returning to the item step.',
icon: 'warning',
showDenyButton: true,
showCancelButton: true,
confirmButtonText: 'Resume payment',
denyButtonText: 'Cancel payment',
cancelButtonText: 'Stay here',
reverseButtons: true,
});
if (result.isConfirmed || result.dismiss) {
return;
}
if (result.isDenied && normalizedOrderId.value) {
try {
await StripeModule.paymentIntents.deletePaymentIntent(normalizedOrderId.value);
navigateToItems();
} catch (error) {
completionError.value = SessionUser.functions.parseErrorMessage(error) || 'Unable to cancel the current payment.';
}
}
};
</script>
<template>
<div class="mobile-card-payment-view" data-testid="pos-mobile-step-3">
<div class="mobile-card-payment-view__header">
<div>
<p class="mobile-card-payment-view__eyebrow">Mobile POS</p>
<h3 class="mobile-card-payment-view__title">Card payment</h3>
</div>
<button
class="button is-small is-light"
type="button"
data-testid="pos-mobile-step-3-back"
:disabled="isBackDisabled"
@click="onBackClick"
>
Back
</button>
</div>
<div class="mobile-card-payment-view__summary box">
<div class="mobile-card-payment-view__summary-row">
<span>Order</span>
<strong data-testid="pos-mobile-step-3-order-id">
#{{ normalizedOrderId || 'Pending' }}
</strong>
</div>
<div class="mobile-card-payment-view__summary-row">
<span>Total</span>
<strong data-testid="pos-mobile-step-3-total">
{{ formatCurrency(mobileOrderTotal) }}
</strong>
</div>
<div class="mobile-card-payment-view__summary-row">
<span>Vehicle</span>
<strong data-testid="pos-mobile-step-3-registration">{{ orderRegistration }}</strong>
</div>
<div v-if="orderReference" class="mobile-card-payment-view__summary-row">
<span>Reference</span>
<strong data-testid="pos-mobile-step-3-reference">{{ orderReference }}</strong>
</div>
<div v-if="orderNotes" class="mobile-card-payment-view__notes" data-testid="pos-mobile-step-3-notes">
{{ orderNotes }}
</div>
</div>
<div
v-if="paymentIntentState === 'succeeded' && isCompleting"
class="notification is-info is-light"
data-testid="pos-mobile-step-3-completing"
>
Finalizing the paid order.
</div>
<div v-if="completionError" class="notification is-danger is-light" data-testid="pos-mobile-step-3-error">
<div>{{ completionError }}</div>
<button
v-if="paymentIntentState === 'succeeded'"
class="button is-small is-danger is-light mobile-card-payment-view__retry"
type="button"
data-testid="pos-mobile-step-3-retry-complete"
:disabled="isCompleting"
@click="completeMobileCardPayment"
>
Retry completion
</button>
</div>
<p class="mobile-card-payment-view__description">
Use the selected Stripe Terminal reader to collect and capture the card payment for this order.
</p>
<PayWithStripeButton
:department-id="normalizedDepartmentId"
:order_id="normalizedOrderId || 0"
:order_items="order_items"
:mobile-mode="true"
:on-payment-success="completeMobileCardPayment"
label="Start card payment"
/>
</div>
</template>
<style scoped>
.mobile-card-payment-view {
display: flex;
flex-direction: column;
gap: 1rem;
}
.mobile-card-payment-view__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.mobile-card-payment-view__eyebrow {
margin: 0;
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6b7280;
}
.mobile-card-payment-view__title {
margin: 0.15rem 0 0;
font-size: 1.2rem;
font-weight: 700;
}
.mobile-card-payment-view__summary {
border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.08);
}
.mobile-card-payment-view__summary-row {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
padding: 0.1rem 0;
}
.mobile-card-payment-view__summary-row span {
color: #6b7280;
}
.mobile-card-payment-view__description {
margin: 0;
color: #6b7280;
font-size: 0.9rem;
}
.mobile-card-payment-view__notes {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(15, 23, 42, 0.08);
color: #374151;
font-size: 0.9rem;
}
.mobile-card-payment-view__retry {
margin-top: 0.75rem;
}
</style>
@@ -228,15 +228,15 @@ const addTransactionToHistory = async (orderId: number) => {
const step2 = () => { const step2 = () => {
/** This function can be used to perform any specific actions for step 2 */ /** This function can be used to perform any specific actions for step 2 */
// Add all additional items to the order
popups.select('completed_transaction', {
message: `Order #${order_id.value} successfully created.`,
});
// If the customer is paying with a card, go to the payment step // If the customer is paying with a card, go to the payment step
if (isCustomerSelected() && getCustomerId() === 999) { if (isCustomerSelected() && getCustomerId() === 999) {
nextStep({isMobile: true}); nextStep({isMobile: true});
return; return;
} }
// Add all additional items to the order
popups.select('completed_transaction', {
message: `Order #${order_id.value} successfully created.`,
});
if (metadata.getBookingId() && metadata.getBookingId() > 0) { if (metadata.getBookingId() && metadata.getBookingId() > 0) {
// If there is a booking ID, complete the booking // If there is a booking ID, complete the booking
SessionUser.objects.orders.set.booking_id(order_id.value, metadata.getBookingId()); SessionUser.objects.orders.set.booking_id(order_id.value, metadata.getBookingId());
@@ -49,6 +49,13 @@ const onClickSuggestion = (customerId: number) => {
metadata.setCustomerId(customerId); metadata.setCustomerId(customerId);
emit('close'); emit('close');
}; };
const onClickDirectCardPayment = () => {
const cardCustomerId = 999;
searchAndSelectCustomer(cardCustomerId);
metadata.setCustomerId(cardCustomerId);
emit('close');
};
</script> </script>
<template> <template>
@@ -66,6 +73,16 @@ const onClickSuggestion = (customerId: number) => {
<!-- Search results (If any) --> <!-- Search results (If any) -->
<ControlFieldInputSearchResults v-bind:results="formattedSearchResults" @result-clicked="onClick"/> <ControlFieldInputSearchResults v-bind:results="formattedSearchResults" @result-clicked="onClick"/>
</template> </template>
<div class="mt-2">
<button
class="button is-text is-fullwidth"
type="button"
data-testid="pos-mobile-direct-card-payment"
@click="onClickDirectCardPayment"
>
Vælg direkte betaling med betalingskort
</button>
</div>
<!-- Customer suggestions (If any) --> <!-- Customer suggestions (If any) -->
<VehicleCustomerSuggestionsPos v-if="vehicles?.vehicle_1?.value?.reg" :reg_1="vehicles.vehicle_1.value.reg" @customerSelected="onClickSuggestion"/> <VehicleCustomerSuggestionsPos v-if="vehicles?.vehicle_1?.value?.reg" :reg_1="vehicles.vehicle_1.value.reg" @customerSelected="onClickSuggestion"/>
</div> </div>
@@ -164,4 +181,4 @@ input.is-searched {
flex-grow: 0; flex-grow: 0;
} }
</style> </style>
@@ -228,6 +228,7 @@ const onClickAttachToOrder = (object) => {
<!-- Actions --> <!-- Actions -->
<ActionSettingsWheelButton <ActionSettingsWheelButton
:order_id="object.order_id" :order_id="object.order_id"
:refreshFunction="loadList"
> >
<template #actions> <template #actions>
<!-- Label --> <!-- Label -->
@@ -268,4 +269,4 @@ const onClickAttachToOrder = (object) => {
</template> </template>
<style scoped> <style scoped>
</style> </style>
@@ -752,6 +752,7 @@ const confirmCloseModal = () => {
v-bind:order_id="order.id" v-bind:order_id="order.id"
v-bind:invoice_collection_id="order.invoice_collection_id" v-bind:invoice_collection_id="order.invoice_collection_id"
v-bind:reg_1="order.reg_1" v-bind:reg_1="order.reg_1"
:refreshFunction="loadList"
> >
<template v-slot:actions> <template v-slot:actions>
<!-- <!--
@@ -500,6 +500,7 @@ const filteredObjects = computed(() => {
<!-- Actions --> <!-- Actions -->
<ActionSettingsWheelButton <ActionSettingsWheelButton
:order_id="object.order_id" :order_id="object.order_id"
:refreshFunction="loadList"
> >
<template #actions> <template #actions>
<!-- Label --> <!-- Label -->
@@ -9,6 +9,7 @@ import {
buildCollectedInvoiceEconomicPayload, buildCollectedInvoiceEconomicPayload,
enqueueEconomicTransferJob, enqueueEconomicTransferJob,
fetchEconomicTransferJobStatus, fetchEconomicTransferJobStatus,
runEconomicTransferQueueBatch,
retryEconomicTransferJob, retryEconomicTransferJob,
} from "@/services/economicTransferQueue.js"; } from "@/services/economicTransferQueue.js";
@@ -52,6 +53,10 @@ const showInvoiceCollectionPickerModal = async (customerNumber, onSuccessFunctio
} }
}) })
// This modal is mounted via a standalone Vue app instance.
// Install i18n explicitly so nested components using useI18n() work.
app.use(i18n);
// Mount the Vue app to the dynamically created `div` // Mount the Vue app to the dynamically created `div`
app.mount(div) app.mount(div)
@@ -105,6 +110,123 @@ const showCreateCustomInvoiceCollectionForm = async (customerNumber) => {
} }
} }
const normalizeClosedAtForDateInput = (value) => {
if (!value) {
return '';
}
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) {
return '';
}
return `${value.getFullYear()}-${padTwoDigits(value.getMonth() + 1)}-${padTwoDigits(value.getDate())}`;
}
const parsed = String(value).trim();
if (!parsed) {
return '';
}
const directMatch = parsed.match(/^(\d{4}-\d{2}-\d{2})/);
if (directMatch && directMatch[1]) {
return directMatch[1];
}
const parsedDate = new Date(parsed);
if (Number.isNaN(parsedDate.getTime())) {
return '';
}
return `${parsedDate.getFullYear()}-${padTwoDigits(parsedDate.getMonth() + 1)}-${padTwoDigits(parsedDate.getDate())}`;
};
const padTwoDigits = (value) => String(value).padStart(2, '0');
const normalizeClosedAtForApi = (value) => {
if (!value) {
return null;
}
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) {
return null;
}
return `${value.getFullYear()}-${padTwoDigits(value.getMonth() + 1)}-${padTwoDigits(value.getDate())}`;
}
const inputValue = String(value).trim();
if (!inputValue) {
return null;
}
const directMatch = inputValue.match(/^(\d{4}-\d{2}-\d{2})/);
if (directMatch && directMatch[1]) {
return directMatch[1];
}
const parsedDate = new Date(inputValue);
if (Number.isNaN(parsedDate.getTime())) {
return null;
}
return `${parsedDate.getFullYear()}-${padTwoDigits(parsedDate.getMonth() + 1)}-${padTwoDigits(parsedDate.getDate())}`;
};
const showEditClosedAtObjectFieldForm = async (id, value, onAfterSubmit = null) => {
const inputId = 'collected-order-invoice-closed-at-input';
const clearButtonId = 'collected-order-invoice-closed-at-clear-button';
return Swal.fire({
title: ObjectsGlobal.language.field(CollectedOrderInvoices, 'closed_at'),
html: `<div class="field">
<label class="label has-text-black">${CollectedOrderInvoices.columns.closed_at.label}</label>
<div class="control mb-3">
<input class="input has-background-light has-text-black" type="date" id="${inputId}" value="${normalizeClosedAtForDateInput(value)}">
</div>
<div class="control">
<button id="${clearButtonId}" type="button" class="button is-light is-small">${ObjectsGlobal.language.clear}</button>
</div>
</div>`,
showCancelButton: true,
confirmButtonText: ObjectsGlobal.language.save,
cancelButtonText: ObjectsGlobal.language.cancel,
didOpen: () => {
const clearButton = document.getElementById(clearButtonId);
const inputElement = document.getElementById(inputId);
if (clearButton && inputElement) {
clearButton.addEventListener('click', () => {
inputElement.value = '';
});
}
},
preConfirm: () => {
const inputElement = document.getElementById(inputId);
if (!inputElement) {
Swal.showValidationMessage('Kunne ikke finde inputfelt for dato.');
return false;
}
const newValue = inputElement.value ? inputElement.value : null;
if (newValue !== null && !/^\d{4}-\d{2}-\d{2}$/.test(newValue)) {
Swal.showValidationMessage('Ugyldig dato.');
return false;
}
return CollectedOrderInvoices.set.closed_at(id, newValue).then(() => {
if (onAfterSubmit !== null) {
onAfterSubmit();
}
}).catch((error) => {
Swal.showValidationMessage(`Fejl: ${error.message || error}`);
return false;
});
}
});
};
/** /**
* The CollectOrderInvoices object * The CollectOrderInvoices object
*/ */
@@ -265,6 +387,19 @@ export const CollectedOrderInvoices = {
po_number po_number
) )
}, },
closed_at: async (id, closed_at) => {
const apiClosedAt = normalizeClosedAtForApi(closed_at);
if (closed_at && !apiClosedAt) {
throw new Error('Invalid date format. Expected: Y-m-d');
}
return ObjectsGlobal.set.column(
CollectedOrderInvoices.meta.endpoint,
id,
"closed_at",
apiClosedAt
)
}
}, },
get: { get: {
all: async () => { all: async () => {
@@ -355,6 +490,16 @@ export const CollectedOrderInvoices = {
throw error; throw error;
}); });
}, },
run: async (limit = 10) => {
return runEconomicTransferQueueBatch({
endpoint: '/collected-invoices/economic/queue/run',
limit,
requestFn: authenticatedRequest,
}).catch((error) => {
console.log(error);
throw error;
});
},
status: async (job_id) => { status: async (job_id) => {
return fetchEconomicTransferJobStatus({ return fetchEconomicTransferJobStatus({
endpoint: '/collected-invoices/economic/queue/status', endpoint: '/collected-invoices/economic/queue/status',
@@ -619,6 +764,10 @@ export const CollectedOrderInvoices = {
* @returns {Promise<SweetAlertResult<Awaited<any>>>} * @returns {Promise<SweetAlertResult<Awaited<any>>>}
*/ */
showEditObjectFieldForm: (id, column, value, onAfterSubmit = null) => { showEditObjectFieldForm: (id, column, value, onAfterSubmit = null) => {
if (column === 'closed_at') {
return showEditClosedAtObjectFieldForm(id, value, onAfterSubmit);
}
return ObjectsGlobal.showEditObjectFieldForm( return ObjectsGlobal.showEditObjectFieldForm(
CollectedOrderInvoices, CollectedOrderInvoices,
id, id,
@@ -1,4 +1,4 @@
<script> <script>
import Swal from "sweetalert2"; import Swal from "sweetalert2";
import {ObjectsGlobal} from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue"; import {ObjectsGlobal} from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
import {SessionUser} from "@/components/session/token/SessionUser.vue"; import {SessionUser} from "@/components/session/token/SessionUser.vue";
@@ -153,7 +153,7 @@ const showChangeOrderCustomerForm = async (id) => {
} }
const showChangeOrderInvoiceCollectionForm = async (id) => { const showChangeOrderInvoiceCollectionForm = async (id, onAfterSubmit = null) => {
let customer_number = null; let customer_number = null;
// Get the customer number from the order // Get the customer number from the order
await SessionUser.objects.orders.functions.get_customer_id(id).then((response) => { await SessionUser.objects.orders.functions.get_customer_id(id).then((response) => {
@@ -169,7 +169,7 @@ const showChangeOrderInvoiceCollectionForm = async (id) => {
// Select the invoice collection // Select the invoice collection
await SessionUser.objects.collectedOrderInvoices.functions.showInvoiceCollectionPickerForm( await SessionUser.objects.collectedOrderInvoices.functions.showInvoiceCollectionPickerForm(
customer_number, customer_number,
(invoice_collection_id) => { async (invoice_collection_id) => {
if (!invoice_collection_id) { if (!invoice_collection_id) {
Swal.fire({ Swal.fire({
icon: 'error', icon: 'error',
@@ -182,36 +182,30 @@ const showChangeOrderInvoiceCollectionForm = async (id) => {
console.log("Selected invoice collection:", invoice_collection_id); console.log("Selected invoice collection:", invoice_collection_id);
console.log("Changing invoice collection for order:", id, " customer:", customer_number); console.log("Changing invoice collection for order:", id, " customer:", customer_number);
console.log("New invoice collection:", invoice_collection_id); console.log("New invoice collection:", invoice_collection_id);
// Change the invoice collection try {
SessionUser.objects.orders.set.invoice_collection_id( const response = await SessionUser.objects.orders.set.invoice_collection_id(
parseInt(id), parseInt(id, 10),
parseInt(invoice_collection_id) parseInt(invoice_collection_id, 10)
) );
.then((response) => { console.log("Invoice collection changed successfully:", response);
console.log("Invoice collection changed successfully:", response); Swal.fire({
Swal.fire({ icon: 'success',
icon: 'success', title: 'Faktura samling ændret',
title: 'Faktura samling ændret', timer: 2000,
timer: 2000, });
}) if (typeof onAfterSubmit === 'function') {
}) await onAfterSubmit(response);
.catch((error) => { }
console.error("Error changing invoice collection:", error); } catch (error) {
Swal.fire({ console.error("Error changing invoice collection:", error);
icon: 'error', Swal.fire({
title: 'Fejl ved ændring af faktura samling', icon: 'error',
timer: 2000, title: 'Fejl ved ændring af faktura samling',
}); timer: 2000,
}) });
}
} }
).finally(() => { )
// Wait 2 seconds before reloading the page
// This is to give the user time to see the success / error message
setTimeout(() => {
// Reload the page
window.location.reload();
}, 2000);
})
} }
/** /**
@@ -750,4 +744,4 @@ const showChangeOrderInvoiceCollectionForm = async (id) => {
); );
} }
}; };
</script> </script>
+177 -120
View File
@@ -1,173 +1,230 @@
<script> <script>
import { ref } from 'vue'; import { ref } from 'vue';
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { StripeModule } from "@/components/stripe/StripeModule.vue";
const paymentIntent = ref(null); const paymentIntent = ref(null);
// Create a payment intent const WAITING_FOR_READER_STATUSES = [
const createPaymentIntent = async (reader, order_id, tax_percentage = 0) => { 'requires_payment_method',
SessionUser.request( 'requires_confirmation',
'requires_action',
'processing',
];
const READY_TO_CAPTURE_STATUSES = [
'requires_capture',
];
const SUCCEEDED_STATUSES = [
'succeeded',
];
const RESETTABLE_STATUSES = [
'canceled',
];
const toPositiveInteger = (value) => {
const parsed = Number.parseInt(String(value ?? ''), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const normalizeOrderId = (orderLike) => {
if (orderLike && typeof orderLike === 'object') {
return toPositiveInteger(
orderLike.order_id
?? orderLike?.metadata?.order_id
?? orderLike?.id
);
}
return toPositiveInteger(orderLike);
};
const extractPaymentIntent = (response) => {
const responseData = response?.data?.data;
if (responseData && typeof responseData === 'object' && !Array.isArray(responseData)) {
if (Object.prototype.hasOwnProperty.call(responseData, 'payment_intent')) {
return responseData.payment_intent ?? null;
}
}
return responseData ?? null;
};
const setStoredPaymentIntent = (nextPaymentIntent) => {
paymentIntent.value = nextPaymentIntent && typeof nextPaymentIntent === 'object'
? nextPaymentIntent
: null;
return paymentIntent.value;
};
const setPaymentIntentFromResponse = (response) => {
if (response?.status === 200) {
return setStoredPaymentIntent(extractPaymentIntent(response));
}
return setStoredPaymentIntent(null);
};
const getPaymentIntentState = (stripePaymentIntent) => {
if (!stripePaymentIntent) {
return 'idle';
}
const status = String(stripePaymentIntent?.status || '').toLowerCase();
if (READY_TO_CAPTURE_STATUSES.includes(status)) {
return 'ready_to_capture';
}
if (SUCCEEDED_STATUSES.includes(status)) {
return 'succeeded';
}
if (WAITING_FOR_READER_STATUSES.includes(status)) {
return 'waiting_for_reader';
}
if (RESETTABLE_STATUSES.includes(status)) {
return 'idle';
}
return 'failed';
};
const isPaymentIntentActive = (stripePaymentIntent) => {
return ['waiting_for_reader', 'ready_to_capture'].includes(getPaymentIntentState(stripePaymentIntent));
};
const requestPaymentIntent = async (url, method, payload) => {
const response = await SessionUser.request(url, method, payload);
setPaymentIntentFromResponse(response);
return response;
};
const createPaymentIntent = async (reader, orderLike, tax_percentage = 0) => {
const orderId = normalizeOrderId(orderLike);
if (!orderId) {
throw new Error('Order ID is required');
}
try {
return await requestPaymentIntent(
'/orders/module/stripe/payment_intent', '/orders/module/stripe/payment_intent',
'POST', 'POST',
{ {
id: order_id, id: orderId,
reader: reader.id, reader: reader?.id ?? reader ?? null,
tax_percentage: tax_percentage, tax_percentage,
}, },
).then((response) => { );
console.log('Response from Stripe:', response.data.data); } catch (error) {
if (response.status === 200) {
// Get the payment intent
StripeModule.paymentIntents.getPaymentIntent(order_id);
return response;
} else {
console.error('Error creating payment intent:', response);
return null;
}
}).catch((error) => {
console.error('Error creating payment intent:', error); console.error('Error creating payment intent:', error);
}) throw error;
} }
};
// Get the payment intent const getPaymentIntent = async (orderLike) => {
const getPaymentIntent = async (order_id) => { const orderId = normalizeOrderId(orderLike);
SessionUser.request( if (!orderId) {
setStoredPaymentIntent(null);
throw new Error('Order ID is required');
}
try {
return await requestPaymentIntent(
'/orders/module/stripe/payment_intent', '/orders/module/stripe/payment_intent',
'GET', 'GET',
{ {
id: order_id, id: orderId,
}, },
).then((response) => { );
console.log('Response from Stripe:', response.data.data); } catch (error) {
if (response.status === 200) { setStoredPaymentIntent(null);
paymentIntent.value = response.data.data;
return response;
} else {
console.error('Error getting payment intent:', response);
return null;
}
}).catch((error) => {
console.error('Error getting payment intent:', error); console.error('Error getting payment intent:', error);
// Unset the payment intent if an error occurs throw error;
paymentIntent.value = null; }
}) };
}
// Delete the payment intent const deletePaymentIntent = async (orderLike) => {
const deletePaymentIntent = async (order_id) => { const orderId = normalizeOrderId(orderLike);
SessionUser.request( if (!orderId) {
setStoredPaymentIntent(null);
throw new Error('Order ID is required');
}
try {
return await requestPaymentIntent(
'/orders/module/stripe/payment_intent', '/orders/module/stripe/payment_intent',
'DELETE', 'DELETE',
{ {
id: order_id, id: orderId,
}, },
).then((response) => { );
console.log('Response from Stripe:', response.data.data); } catch (error) {
if (response.status === 200) {
paymentIntent.value = null;
return response;
} else {
console.error('Error deleting payment intent:', response);
return null;
}
}).catch((error) => {
console.error('Error deleting payment intent:', error); console.error('Error deleting payment intent:', error);
}) throw error;
} }
};
// Is the payment intent ready to capture? const isPaymentIntentReadyToCapture = (stripePaymentIntent) => {
const isPaymentIntentReadyToCapture = (paymentIntent) => { return stripePaymentIntent?.status === 'requires_capture';
return paymentIntent && paymentIntent.status === 'requires_capture'; };
}
// Get the amount of the ready to capture payment intent const getReadyToCapturePaymentAmount = (stripePaymentIntent) => {
// { amount: 0, amount_capturable: 0, currency: 'dkk' } const defaultAmount = {
const getReadyToCapturePaymentAmount = (paymentIntent) => {
let defaultAmount = {
amount: null, amount: null,
amount_capturable: null, amount_capturable: null,
amount_received: null, amount_received: null,
currency: null, currency: null,
}; };
if (isPaymentIntentReadyToCapture(paymentIntent) || paymentIntent.amount_received > 0) { if (!stripePaymentIntent) {
defaultAmount.amount = paymentIntent.amount || 0; return defaultAmount;
defaultAmount.amount_capturable = paymentIntent.amount_capturable || 0; }
defaultAmount.amount_received = paymentIntent.amount_received || 0; if (isPaymentIntentReadyToCapture(stripePaymentIntent) || Number(stripePaymentIntent.amount_received || 0) > 0) {
defaultAmount.currency = paymentIntent.currency.toUpperCase() || 'DKK'; defaultAmount.amount = Number(stripePaymentIntent.amount || 0);
} else { defaultAmount.amount_capturable = Number(stripePaymentIntent.amount_capturable || 0);
console.error('Payment intent is not ready to capture:', paymentIntent); defaultAmount.amount_received = Number(stripePaymentIntent.amount_received || 0);
defaultAmount.currency = String(stripePaymentIntent.currency || 'DKK').toUpperCase();
} }
return defaultAmount; return defaultAmount;
} };
// Check if the payment intent amount equals the amount const isPaymentIntentAmountEqualToOrderAmount = (stripePaymentIntent, matchesAmount) => {
const isPaymentIntentAmountEqualToOrderAmount = (paymentIntent, matchesAmount) => { if (!stripePaymentIntent || stripePaymentIntent.amount === undefined || stripePaymentIntent.amount === null) {
if (paymentIntent && paymentIntent.amount) { return false;
if (paymentIntent.amount === matchesAmount) {
console.log('Payment intent amount matches:', paymentIntent.amount, matchesAmount);
return true;
} else if (paymentIntent.amount === 0) {
console.log('Payment intent amount is zero:', paymentIntent.amount, matchesAmount);
return true;
} else if (paymentIntent.amount > 0) {
console.error('Payment intent amount is greater than zero:', paymentIntent.amount, matchesAmount);
} else {
console.error('Payment intent amount does not match:', paymentIntent.amount, matchesAmount);
}
} else {
console.error('Payment intent is not valid:', paymentIntent);
} }
return false; const intentAmount = Number(stripePaymentIntent.amount);
} if (Number.isNaN(intentAmount)) {
return false;
}
return intentAmount === Number(matchesAmount) || intentAmount === 0;
};
// Capture the payment intent const capturePaymentIntent = async (orderLike) => {
const capturePaymentIntent = async (order_id) => { const orderId = normalizeOrderId(orderLike);
SessionUser.request( if (!orderId) {
throw new Error('Order ID is required');
}
try {
return await requestPaymentIntent(
'/orders/module/stripe/payment_intent/capture', '/orders/module/stripe/payment_intent/capture',
'POST', 'POST',
{ {
id: order_id, id: orderId,
}, },
).then((response) => { );
console.log('Response from Stripe:', response.data.data); } catch (error) {
if (response.status === 200) {
paymentIntent.value = response.data.data;
return response;
} else {
console.error('Error capturing payment intent:', response);
return null;
}
}).catch((error) => {
console.error('Error capturing payment intent:', error); console.error('Error capturing payment intent:', error);
}) throw error;
}
// Check if the payment intent amount is received (status: 'succeeded')
const isPaymentIntentAmountReceived = (paymentIntent) => {
if (paymentIntent && paymentIntent.status) {
if (paymentIntent.status === 'succeeded') {
console.log('Payment intent amount received:', paymentIntent.status);
return true;
} else {
console.error('Payment intent amount not received:', paymentIntent.status);
}
} else {
console.error('Payment intent is not valid:', paymentIntent);
} }
return false; };
}
const isPaymentIntentAmountReceived = (stripePaymentIntent) => {
return stripePaymentIntent?.status === 'succeeded';
};
export const StripePaymentIntent = { export const StripePaymentIntent = {
createPaymentIntent, createPaymentIntent,
getPaymentIntent, getPaymentIntent,
deletePaymentIntent, deletePaymentIntent,
paymentIntent, paymentIntent,
extractPaymentIntent,
getPaymentIntentState,
isPaymentIntentActive,
isPaymentIntentReadyToCapture, isPaymentIntentReadyToCapture,
getReadyToCapturePaymentAmount, getReadyToCapturePaymentAmount,
capturePaymentIntent, capturePaymentIntent,
isPaymentIntentAmountEqualToOrderAmount, isPaymentIntentAmountEqualToOrderAmount,
isPaymentIntentAmountReceived, isPaymentIntentAmountReceived,
} };
</script> </script>
+18 -2
View File
@@ -136,6 +136,14 @@ export const useEconomicQueueJob = ({
throw new Error("Cannot retry without a queue job id."); throw new Error("Cannot retry without a queue job id.");
} }
if (status.value !== ECONOMIC_QUEUE_STATUS.FAILED) {
throw new Error("Retry is only allowed for failed queue jobs.");
}
if (maxAttempts.value > 0 && attempts.value >= maxAttempts.value) {
throw new Error("Retry is not allowed because max attempts were reached.");
}
if (disposed) { if (disposed) {
return null; return null;
} }
@@ -178,11 +186,18 @@ export const useEconomicQueueJob = ({
const queueFailureMessage = computed(() => job.value?.error_message || ""); const queueFailureMessage = computed(() => job.value?.error_message || "");
const result = computed(() => job.value?.result ?? null); const result = computed(() => job.value?.result ?? null);
const userMessage = computed(() => getEconomicQueueJobMessage(job.value)); const userMessage = computed(() => getEconomicQueueJobMessage(job.value));
const attempts = computed(() => Number.parseInt(String(job.value?.attempts ?? 0), 10) || 0);
const maxAttempts = computed(() => Number.parseInt(String(job.value?.max_attempts ?? 0), 10) || 0);
const isQueuedOrProcessing = computed(() => isEconomicQueueStatusActive(status.value)); const isQueuedOrProcessing = computed(() => isEconomicQueueStatusActive(status.value));
const isCompleted = computed(() => status.value === ECONOMIC_QUEUE_STATUS.COMPLETED); const isCompleted = computed(() => status.value === ECONOMIC_QUEUE_STATUS.COMPLETED);
const isFailed = computed(() => status.value === ECONOMIC_QUEUE_STATUS.FAILED); const isFailed = computed(() => status.value === ECONOMIC_QUEUE_STATUS.FAILED);
const canRetry = computed(() => Boolean(retryEndpoint) && isFailed.value && Boolean(jobId.value)); const canRetry = computed(() => (
Boolean(retryEndpoint)
&& isFailed.value
&& Boolean(jobId.value)
&& (maxAttempts.value < 1 || attempts.value < maxAttempts.value)
));
const disableSubmit = computed(() => isSubmitting.value || isQueuedOrProcessing.value); const disableSubmit = computed(() => isSubmitting.value || isQueuedOrProcessing.value);
return { return {
@@ -192,6 +207,8 @@ export const useEconomicQueueJob = ({
progressPercent, progressPercent,
progressMessage, progressMessage,
queueFailureMessage, queueFailureMessage,
attempts,
maxAttempts,
transportErrorMessage, transportErrorMessage,
result, result,
userMessage, userMessage,
@@ -208,4 +225,3 @@ export const useEconomicQueueJob = ({
dispose, dispose,
}; };
}; };
+209 -11
View File
@@ -27,6 +27,22 @@ const parsePositiveInteger = (value) => {
return Number.isInteger(parsed) && parsed > 0 ? parsed : null; return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}; };
const parseNonNegativeInteger = (value) => {
const parsed = Number.parseInt(String(value), 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
};
const firstPositiveInteger = (...values) => {
for (const value of values) {
const parsed = parsePositiveInteger(value);
if (parsed) {
return parsed;
}
}
return null;
};
export const getEconomicApiErrorMessage = (error, fallback = "Request failed.") => { export const getEconomicApiErrorMessage = (error, fallback = "Request failed.") => {
if (typeof error === "string" && error.trim() !== "") { if (typeof error === "string" && error.trim() !== "") {
return error; return error;
@@ -54,38 +70,206 @@ export const getEconomicApiErrorMessage = (error, fallback = "Request failed.")
return fallback; return fallback;
}; };
const normalizeQueueJob = (rawJob) => { const normalizeQueueJob = (
rawJob,
{
fallbackStatus = null,
fallbackProgressMessage = null,
fallbackIdCandidates = [],
} = {},
) => {
if (!rawJob || typeof rawJob !== "object") { if (!rawJob || typeof rawJob !== "object") {
return null; return null;
} }
const id = parsePositiveInteger(rawJob.id); const id = firstPositiveInteger(
rawJob.id,
rawJob.job_id,
rawJob.queue_job_id,
...(Array.isArray(fallbackIdCandidates) ? fallbackIdCandidates : []),
);
const progressPercent = Number(rawJob.progress_percent); const progressPercent = Number(rawJob.progress_percent);
return { return {
...rawJob, ...rawJob,
id, id,
status: rawJob.status ?? null, status: rawJob.status ?? fallbackStatus ?? null,
progress_percent: Number.isFinite(progressPercent) ? progressPercent : 0, progress_percent: Number.isFinite(progressPercent) ? progressPercent : 0,
progress_message: rawJob.progress_message ?? null, progress_message: rawJob.progress_message ?? fallbackProgressMessage ?? null,
error_message: rawJob.error_message ?? null, error_message: rawJob.error_message ?? null,
result: rawJob.result ?? null, result: rawJob.result ?? null,
}; };
}; };
const ensureQueueJob = (rawJob, source = "queue response") => { const ensureQueueJob = (
const normalized = normalizeQueueJob(rawJob); rawJob,
source = "queue response",
{
fallbackStatus = null,
fallbackProgressMessage = null,
fallbackIdCandidates = [],
} = {},
) => {
const normalized = normalizeQueueJob(rawJob, {
fallbackStatus,
fallbackProgressMessage,
fallbackIdCandidates,
});
if (!normalized || !parsePositiveInteger(normalized.id)) { if (!normalized || !normalized.id) {
throw new Error(`Missing queue job id in ${source}.`); throw new Error(`Missing queue job id in ${source}.`);
} }
return normalized; return normalized;
}; };
export const parseQueueJobFromEnqueueResponse = (response) => ensureQueueJob(response?.data?.data?.job, "enqueue response"); const getQueueEnvelope = (response) => {
export const parseQueueJobFromStatusResponse = (response) => ensureQueueJob(response?.data?.data, "status response"); const envelope = response?.data?.data;
export const parseQueueJobFromRetryResponse = (response) => ensureQueueJob(response?.data?.data?.job, "retry response"); return envelope && typeof envelope === "object" ? envelope : null;
};
const getQueueJobFromEnvelope = (envelope) => {
if (!envelope || typeof envelope !== "object") {
return null;
}
if (envelope.job && typeof envelope.job === "object") {
return envelope.job;
}
return envelope;
};
export const parseQueueJobFromEnqueueResponse = (response) => {
const envelope = getQueueEnvelope(response);
return ensureQueueJob(
getQueueJobFromEnvelope(envelope),
"enqueue response",
{
fallbackStatus: ECONOMIC_QUEUE_STATUS.QUEUED,
fallbackProgressMessage: envelope?.message ?? null,
fallbackIdCandidates: [envelope?.job_id, envelope?.queue_job_id, envelope?.id],
},
);
};
export const parseQueueJobFromStatusResponse = (response) => {
const envelope = getQueueEnvelope(response);
return ensureQueueJob(
getQueueJobFromEnvelope(envelope),
"status response",
{
fallbackProgressMessage: envelope?.message ?? null,
fallbackIdCandidates: [envelope?.job_id, envelope?.queue_job_id, envelope?.id],
},
);
};
export const parseQueueJobFromRetryResponse = (response) => {
const envelope = getQueueEnvelope(response);
return ensureQueueJob(
getQueueJobFromEnvelope(envelope),
"retry response",
{
fallbackStatus: ECONOMIC_QUEUE_STATUS.QUEUED,
fallbackProgressMessage: envelope?.message ?? null,
fallbackIdCandidates: [envelope?.job_id, envelope?.queue_job_id, envelope?.id],
},
);
};
const normalizeQueueListJob = (rawJob) => {
if (!rawJob || typeof rawJob !== "object") {
return null;
}
const normalized = normalizeQueueJob(rawJob, {
fallbackIdCandidates: [rawJob?.payload?.job_id],
});
if (!normalized?.id) {
return null;
}
return {
...normalized,
attempts: parseNonNegativeInteger(rawJob?.attempts) ?? 0,
max_attempts: parseNonNegativeInteger(rawJob?.max_attempts) ?? 0,
transfer_type: rawJob?.transfer_type ?? null,
created_by: parseNonNegativeInteger(rawJob?.created_by) ?? 0,
created_at: rawJob?.created_at ?? null,
updated_at: rawJob?.updated_at ?? null,
started_at: rawJob?.started_at ?? null,
completed_at: rawJob?.completed_at ?? null,
next_retry_at: rawJob?.next_retry_at ?? null,
payload: rawJob?.payload ?? null,
details_summary: rawJob?.details_summary ?? null,
};
};
export const parseEconomicTransferQueueListResponse = (
response,
{
defaultLimit = 50,
defaultOffset = 0,
} = {},
) => {
const envelope = getQueueEnvelope(response) ?? response?.data ?? {};
const rawItems = Array.isArray(envelope)
? envelope
: (Array.isArray(envelope?.items)
? envelope.items
: (Array.isArray(envelope?.jobs) ? envelope.jobs : []));
const items = rawItems
.map((rawJob) => normalizeQueueListJob(rawJob))
.filter(Boolean);
const count = parseNonNegativeInteger(
envelope?.count ?? envelope?.pagination?.count,
) ?? items.length;
const total = parseNonNegativeInteger(
envelope?.total ?? envelope?.pagination?.total,
) ?? count;
const limit = parsePositiveInteger(
envelope?.limit ?? envelope?.pagination?.limit,
) ?? parsePositiveInteger(defaultLimit) ?? 50;
const offset = parseNonNegativeInteger(
envelope?.offset ?? envelope?.pagination?.offset,
) ?? parseNonNegativeInteger(defaultOffset) ?? 0;
const hasMore = typeof envelope?.has_more === "boolean"
? envelope.has_more
: typeof envelope?.hasMore === "boolean"
? envelope.hasMore
: (offset + count) < total;
return {
items,
count,
total,
limit,
offset,
hasMore,
};
};
export const parseQueueRunResponse = (response) => {
const envelope = getQueueEnvelope(response) ?? response?.data ?? {};
return {
message: typeof envelope?.message === "string" && envelope.message.trim() !== ""
? envelope.message
: "Collected invoice queue batch processed",
processed: parseNonNegativeInteger(envelope?.processed) ?? 0,
completed: parseNonNegativeInteger(envelope?.completed) ?? 0,
failed: parseNonNegativeInteger(envelope?.failed) ?? 0,
jobs: (Array.isArray(envelope?.jobs) ? envelope.jobs : [])
.map((jobId) => parsePositiveInteger(jobId))
.filter(Boolean),
limit: parsePositiveInteger(envelope?.limit) ?? 10,
transfer_type: typeof envelope?.transfer_type === "string" && envelope.transfer_type.trim() !== ""
? envelope.transfer_type
: null,
fallback: envelope?.fallback === true,
};
};
export const enqueueEconomicTransferJob = async ({ export const enqueueEconomicTransferJob = async ({
endpoint, endpoint,
@@ -133,6 +317,21 @@ export const retryEconomicTransferJob = async ({
}; };
}; };
export const runEconomicTransferQueueBatch = async ({
endpoint,
limit = 10,
requestFn = authenticatedRequest,
}) => {
const parsedLimit = parsePositiveInteger(limit) ?? 10;
const response = await requestFn(endpoint, "POST", {
limit: Math.max(1, Math.min(10, parsedLimit)),
});
return {
response,
run: parseQueueRunResponse(response),
};
};
const wait = (durationMs) => new Promise((resolve) => { const wait = (durationMs) => new Promise((resolve) => {
setTimeout(resolve, durationMs); setTimeout(resolve, durationMs);
}); });
@@ -212,4 +411,3 @@ export const getEconomicQueueJobMessage = (job) => {
return job.progress_message || ""; return job.progress_message || "";
}; };
@@ -16,8 +16,10 @@ const props = defineProps({
} }
}) })
const sharedTypes = computed(() => view.variables.sharedVariables.value?.types ?? {});
const view_keys = computed(() => { const view_keys = computed(() => {
return Object.keys(view.variables.sharedVariables.value.types); return Object.keys(sharedTypes.value);
}); });
@@ -25,7 +27,7 @@ const list_views_with_customer = computed(() => {
return view_keys.value.filter((view_key) => { return view_keys.value.filter((view_key) => {
// Skip if the view type is "all". // Skip if the view type is "all".
if (view_key === 'all') return false; if (view_key === 'all') return false;
const view_type = view.variables.sharedVariables.value.types[view_key]; const view_type = sharedTypes.value[view_key];
return view_type && view_type.some((v: any) => v.customer_number === props.customer.customer_number); return view_type && view_type.some((v: any) => v.customer_number === props.customer.customer_number);
}); });
}); });
@@ -53,4 +55,4 @@ const list_views_with_customer = computed(() => {
.is-not-clickable { .is-not-clickable {
cursor: default; cursor: default;
} }
</style> </style>
@@ -1,12 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted, reactive } from 'vue'; import { ref, computed, watch, onMounted, reactive } from 'vue';
import { useRoute } from 'vue-router';
import { SessionUser } from '@/components/session/token/SessionUser.vue'; import { SessionUser } from '@/components/session/token/SessionUser.vue';
import { dates } from '../../imports/InvoicingBillingPeriodImportDates.vue'; import { dates } from '../../imports/InvoicingBillingPeriodImportDates.vue';
import { invoiceQueue } from '../../imports/InvoicingBillingPeriodImportInvoiceQueue.vue';
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue"; import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue"; import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue";
import { view } from '../../imports/InvoicingBillingPeriodImportView.vue'; import { view } from '../../imports/InvoicingBillingPeriodImportView.vue';
const period_result = ref<any>(null); const period_result = ref<any>(null);
const route = useRoute();
const normalizePeriodResult = (data: any = {}) => ({
...data,
types: data?.types ?? {},
});
const status = reactive({ const status = reactive({
loaded: false, loaded: false,
@@ -87,7 +95,7 @@ const types = [
]; ];
const getPeriod = async () => { const getPeriod = async () => {
view.variables.sharedVariables.value = {}; view.variables.sharedVariables.value = null;
status.loading = true; status.loading = true;
status.loaded = false; status.loaded = false;
status.error = false; status.error = false;
@@ -105,8 +113,9 @@ const getPeriod = async () => {
}, },
); );
view.variables.sharedVariables.value = response.data.data; const normalizedPeriodResult = normalizePeriodResult(response?.data?.data);
period_result.value = response.data.data; view.variables.sharedVariables.value = normalizedPeriodResult;
period_result.value = normalizedPeriodResult;
status.loaded = true; status.loaded = true;
status.isEmpty = Object.keys(period_result.value?.types || {}).length === 0; status.isEmpty = Object.keys(period_result.value?.types || {}).length === 0;
} catch (error: any) { } catch (error: any) {
@@ -130,7 +139,7 @@ const counts = computed(() => (type: typeof types[number]) => {
}; };
} }
// Assuming period_result has a structure that includes counts for each type // Assuming period_result has a structure that includes counts for each type
const entries = period_result.value.types[type.name] || []; const entries = period_result.value?.types?.[type.name] || [];
return { return {
total: entries.length, total: entries.length,
requires_action: entries.filter(entry => entry.requires_action).length, requires_action: entries.filter(entry => entry.requires_action).length,
@@ -142,6 +151,8 @@ const isViewAvailable = computed(() => (type: typeof types[number]) => {
return type.isAvailable ? type.isAvailable.value : true; return type.isAvailable ? type.isAvailable.value : true;
}); });
const isPeriodTabActive = computed(() => route.query.activeTab === 'period');
onMounted(() => { onMounted(() => {
view.variables.availableViewNames.value = types.reduce((acc: Record<string, string>, type) => { view.variables.availableViewNames.value = types.reduce((acc: Record<string, string>, type) => {
acc[type.name] = type.displayName; acc[type.name] = type.displayName;
@@ -156,6 +167,28 @@ watch(
getPeriod(); getPeriod();
} }
); );
watch(
() => route.query.activeTab,
(newTab, oldTab) => {
if (newTab === 'period' && oldTab !== 'period') {
getPeriod();
}
}
);
watch(
() => invoiceQueue.periodRefreshSignal.value,
(nextSignal, previousSignal) => {
if (nextSignal === previousSignal) {
return;
}
if (!isPeriodTabActive.value) {
return;
}
getPeriod();
}
);
</script> </script>
<template> <template>
@@ -38,6 +38,11 @@ const COLLECTED_INVOICE_STATUS_ENDPOINT = "/collected-invoices/economic/queue/st
const COLLECTED_INVOICE_RETRY_ENDPOINT = "/collected-invoices/economic/queue/retry"; const COLLECTED_INVOICE_RETRY_ENDPOINT = "/collected-invoices/economic/queue/retry";
const pollIntervalMs = DEFAULT_ECONOMIC_QUEUE_POLL_INTERVAL_MS; const pollIntervalMs = DEFAULT_ECONOMIC_QUEUE_POLL_INTERVAL_MS;
export const periodRefreshSignal = ref(0);
const notifyPeriodRefresh = () => {
periodRefreshSignal.value += 1;
};
export const invoiceCollectionQueueInProgress = ref<number[]>([]); export const invoiceCollectionQueueInProgress = ref<number[]>([]);
export const invoiceCollectionQueueFailed = ref<number[]>([]); export const invoiceCollectionQueueFailed = ref<number[]>([]);
@@ -237,6 +242,7 @@ const processInvoiceCollection = async (
isTransportError: false, isTransportError: false,
jobId: parseInvoiceCollectionId(initialJob.id as number | string | null | undefined), jobId: parseInvoiceCollectionId(initialJob.id as number | string | null | undefined),
}); });
notifyPeriodRefresh();
const queueJobId = parseInvoiceCollectionId(initialJob.id as number | string | null | undefined); const queueJobId = parseInvoiceCollectionId(initialJob.id as number | string | null | undefined);
if (!queueJobId) { if (!queueJobId) {
@@ -258,6 +264,7 @@ const processInvoiceCollection = async (
isTransportError: false, isTransportError: false,
jobId: parseInvoiceCollectionId(finalJob.id as number | string | null | undefined), jobId: parseInvoiceCollectionId(finalJob.id as number | string | null | undefined),
}); });
notifyPeriodRefresh();
} else { } else {
markAsFailed(parsedInvoiceCollectionId); markAsFailed(parsedInvoiceCollectionId);
upsertCollectionQueueLog({ upsertCollectionQueueLog({
@@ -271,6 +278,7 @@ const processInvoiceCollection = async (
isTransportError: false, isTransportError: false,
jobId: parseInvoiceCollectionId(finalJob.id as number | string | null | undefined), jobId: parseInvoiceCollectionId(finalJob.id as number | string | null | undefined),
}); });
notifyPeriodRefresh();
} }
} catch (error) { } catch (error) {
markAsFailed(parsedInvoiceCollectionId); markAsFailed(parsedInvoiceCollectionId);
@@ -285,6 +293,7 @@ const processInvoiceCollection = async (
isTransportError: true, isTransportError: true,
jobId: getRetryJobIdForInvoiceCollection(parsedInvoiceCollectionId), jobId: getRetryJobIdForInvoiceCollection(parsedInvoiceCollectionId),
}); });
notifyPeriodRefresh();
} finally { } finally {
removeFromInProgress(parsedInvoiceCollectionId); removeFromInProgress(parsedInvoiceCollectionId);
processInvoiceCollectionQueue(); processInvoiceCollectionQueue();
@@ -413,6 +422,7 @@ export const invoiceQueue = {
retryInvoiceCollection, retryInvoiceCollection,
clearQueue, clearQueue,
queueStats, queueStats,
periodRefreshSignal,
invoiceCollectionQueue, invoiceCollectionQueue,
invoiceCollectionQueueInProgress, invoiceCollectionQueueInProgress,
invoiceCollectionQueueFailed, invoiceCollectionQueueFailed,
@@ -26,6 +26,10 @@ export const availableViews = {
export const availableViewNames = ref<Record<keyof typeof availableViews, string>>(); export const availableViewNames = ref<Record<keyof typeof availableViews, string>>();
type BillingPeriodSharedVariables = {
types?: Record<string, any[]>;
} & Record<string, any>;
/** /**
* View Module -> Variables * View Module -> Variables
* These variables are used to manage the current view and its related state. * These variables are used to manage the current view and its related state.
@@ -33,7 +37,9 @@ export const availableViewNames = ref<Record<keyof typeof availableViews, string
// Current view component // Current view component
const currentView = ref<keyof typeof availableViews>("home"); const currentView = ref<keyof typeof availableViews>("home");
const sharedVariables = ref(); const sharedVariables = ref<BillingPeriodSharedVariables | null>(null);
const getSharedTypes = () => sharedVariables.value?.types ?? {};
// Combine all view-related variables into a single object // Combine all view-related variables into a single object
const variablesView = { const variablesView = {
@@ -62,14 +68,14 @@ const isViewAvailable = (view: keyof typeof availableViews) => {
const doesViewContainCustomer = (customer: any = {customer_number: Number}, targetView: any) => { const doesViewContainCustomer = (customer: any = {customer_number: Number}, targetView: any) => {
// Check if the customer is in the current view // Check if the customer is in the current view
return view.variables.sharedVariables.value.types[targetView.value]?.some( return getSharedTypes()[targetView.value]?.some(
(c: any) => c.customer_number === customer.customer_number (c: any) => c.customer_number === customer.customer_number
); );
}; };
const getViewCustomer = (customer: any = {customer_number: Number}, targetView: any) => { const getViewCustomer = (customer: any = {customer_number: Number}, targetView: any) => {
// Get the customer object in the current view // Get the customer object in the current view
return view.variables.sharedVariables.value.types[targetView.value]?.find( return getSharedTypes()[targetView.value]?.find(
(c: any) => c.customer_number === customer.customer_number (c: any) => c.customer_number === customer.customer_number
); );
}; };
@@ -163,29 +169,29 @@ const computedViews = {
isCurrentView, isCurrentView,
currentViewComponent, currentViewComponent,
currentViewTotalNetAmount: computed(() => { currentViewTotalNetAmount: computed(() => {
const shared = sharedVariables.value; const sharedTypes = getSharedTypes();
if (!currentView.value || !shared?.types?.[currentView.value]) { if (!currentView.value || !sharedTypes[currentView.value]) {
return 0; return 0;
} }
return getViewTotals(shared.types[currentView.value]).total; return getViewTotals(sharedTypes[currentView.value]).total;
}), }),
currentViewTotalNetAmountBooked: computed(() => { currentViewTotalNetAmountBooked: computed(() => {
const shared = sharedVariables.value; const sharedTypes = getSharedTypes();
if (!currentView.value || !shared?.types?.[currentView.value]) { if (!currentView.value || !sharedTypes[currentView.value]) {
return 0; return 0;
} }
return getViewTotals(shared.types[currentView.value]).booked; return getViewTotals(sharedTypes[currentView.value]).booked;
}), }),
currentViewTotalNetAmountNotBooked: computed(() => { currentViewTotalNetAmountNotBooked: computed(() => {
const shared = sharedVariables.value; const sharedTypes = getSharedTypes();
if (!currentView.value || !shared?.types?.[currentView.value]) { if (!currentView.value || !sharedTypes[currentView.value]) {
return 0; return 0;
} }
return getViewTotals(shared.types[currentView.value]).notBooked; return getViewTotals(sharedTypes[currentView.value]).notBooked;
}), }),
isLoaded: computed(() => { isLoaded: computed(() => {
// Check if the shared variables are loaded // Check if the shared variables are loaded
return sharedVariables.value !== undefined && Object.keys(sharedVariables.value).length > 0; return sharedVariables.value !== undefined && sharedVariables.value !== null;
}), }),
getViewFriendlyName, getViewFriendlyName,
}; };
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { view } from '../imports/InvoicingBillingPeriodImportView.vue'; import { computed, ref } from "vue";
import { dates } from '../imports/InvoicingBillingPeriodImportDates.vue'; import { view } from "../imports/InvoicingBillingPeriodImportView.vue";
import { invoiceQueue } from '../imports/InvoicingBillingPeriodImportInvoiceQueue.vue'; import { dates } from "../imports/InvoicingBillingPeriodImportDates.vue";
import { SessionUser } from '@/components/session/token/SessionUser.vue'; import { invoiceQueue } from "../imports/InvoicingBillingPeriodImportInvoiceQueue.vue";
import {computed, ref} from "vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { ECONOMIC_QUEUE_STATUS } from "@/services/economicTransferQueue.js";
import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue"; import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue";
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue"; import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue"; import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
@@ -11,39 +12,39 @@ import InvoiceOrdersPagination from "@/components/displays/pagination/models/Sup
import SmallCustomerActivityChart from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/SmallCustomerActivityChart.vue"; import SmallCustomerActivityChart from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/SmallCustomerActivityChart.vue";
import InvoicingBillingPeriodStatistics from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodStatistics.vue"; import InvoicingBillingPeriodStatistics from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodStatistics.vue";
import InvoicingBillingPeriodFilters from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodFilters.vue"; import InvoicingBillingPeriodFilters from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodFilters.vue";
import InvoicingBillingPeriodCustomerAttributes import InvoicingBillingPeriodCustomerAttributes from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue";
from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue";
import InvoicingBillingPeriodInvoiceProgressBar
from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/InvoicingBillingPeriodInvoiceProgressBar.vue";
const onClickCustomer = (customer: any) => { const onClickCustomer = (customer: any) => {
//console.log('Clicked customer:', customer);
if (customer.expanded !== undefined && customer.expanded !== null) { if (customer.expanded !== undefined && customer.expanded !== null) {
customer.expanded = !customer.expanded; customer.expanded = !customer.expanded;
} else { } else {
customer.expanded = true; customer.expanded = true;
} }
} };
const getTransactionsInView = (customer: any) => {
if (!customer.transactions || customer.transactions.length === 0) {
return [];
}
return customer.transactions.filter((transaction: any) => {
const transactionDate = new Date(transaction.date);
return transactionDate >= dates.variables.start.value && transactionDate <= dates.variables.end.value;
});
};
const getCustomerTransactionsInView = (customer: any) => { const getCustomerTransactionsInView = (customer: any) => {
let tmp = []; return getTransactionsInView(customer).map((transaction: any) => transaction.id);
if (customer.transactions && customer.transactions.length > 0) { };
tmp = customer.transactions.filter(transaction => {
const transactionDate = new Date(transaction.date);
return transactionDate >= dates.variables.start.value && transactionDate <= dates.variables.end.value;
}).map(transaction => transaction.id);
}
return tmp;
}
const doesCustomerHaveTransactionsDifferentDays = (customer: any) => { const doesCustomerHaveTransactionsDifferentDays = (customer: any) => {
if (!customer.transactions || customer.transactions.length < 2) { if (!customer.transactions || customer.transactions.length < 2) {
return false; return false;
} }
const datesSet = new Set(customer.transactions.map(transaction => new Date(transaction.date).toISOString().split('T')[0]));
const datesSet = new Set(customer.transactions.map((transaction: any) => new Date(transaction.date).toISOString().split("T")[0]));
return datesSet.size > 1; return datesSet.size > 1;
} };
const tmpFilters = ref({ const tmpFilters = ref({
displayRequiresAction: true, displayRequiresAction: true,
@@ -54,309 +55,381 @@ const tmpFilters = ref({
const onFilterChanged = (filters: any) => { const onFilterChanged = (filters: any) => {
tmpFilters.value = filters; tmpFilters.value = filters;
} };
const doesViewtypeExist = (types: any, componentName: string) => { const customersInCurrentView = computed(() => (
return types && types[componentName] && Array.isArray(types[componentName]); view.variables.sharedVariables.value?.types?.[view.computed.componentName.value] ?? []
} ));
const getTransactionIds = (customer: any) => { const getTransactionIds = (customer: any) => {
if (!customer.transactions || customer.transactions.length === 0) { if (!customer.transactions || customer.transactions.length === 0) {
return []; return [];
} }
// Filter out excluded transactions
const filteredTransactions = customer.transactions.filter(transaction => transaction.excluded !== true); return customer.transactions
return filteredTransactions.map(transaction => transaction.id); .filter((transaction: any) => transaction.excluded !== true)
} .map((transaction: any) => transaction.id);
};
const getExcludedTransactionIds = (customer: any) => { const getExcludedTransactionIds = (customer: any) => {
if (!customer.transactions || customer.transactions.length === 0) { if (!customer.transactions || customer.transactions.length === 0) {
return []; return [];
} }
// Get excluded transactions
const excludedTransactions = customer.transactions.filter(transaction => transaction.excluded === true);
return excludedTransactions.map(transaction => transaction.id);
}
const transactionIdsInvoiceCollectionCache = ref<{ [key: number]: number | null }>({}); // Cache for transaction ID to invoice collection ID mapping return customer.transactions
.filter((transaction: any) => transaction.excluded === true)
.map((transaction: any) => transaction.id);
};
const transactionIdsInvoiceCollectionCache = ref<{ [key: number]: number | null }>({});
const onClickInvoiceNow = async (customer: any, transactionIds: number[]) => { const onClickInvoiceNow = async (customer: any, transactionIds: number[]) => {
// Emit event to parent component to handle invoicing
console.warn('Invoicing transactions for customer:', customer, transactionIds);
if (transactionIds.length === 0) { if (transactionIds.length === 0) {
console.warn('No transactions to invoice');
/** Check if the customer has fixed pricing and create an invoice for it */
if (customer?.meta?.fixed_pricing) { if (customer?.meta?.fixed_pricing) {
console.warn('Customer has fixed pricing, but no transactions. Please create an order for invoicing.'); const month = dates.variables.start.value.getMonth() + 1;
const month = (dates.variables.start.value.getMonth() + 1); const year = dates.variables.start.value.getFullYear();
const year = (dates.variables.start.value.getFullYear());
//const month = (new Date()).getMonth() + 1; await SessionUser.objects.collectedOrderInvoices.functions.createVehicleSubscriptionInvoice(customer.customer_number, month, year)
//const year = (new Date()).getFullYear(); .then((response: any) => {
console.warn('Creating fixed pricing invoice for customer:', customer.customer_number, 'for', month, year);
await SessionUser.objects.collectedOrderInvoices.functions.createVehicleSubscriptionInvoice(customer.customer_number, month, year).then((response) => {
const newInvoiceCollectionId = response?.data?.data?.id;
console.warn('New invoice collection ID:', newInvoiceCollectionId);
if (newInvoiceCollectionId) {
console.warn('Fixed pricing invoice created successfully:', response);
SessionUser.objects.collectedOrderInvoices.functions.add_fixed_pricing(newInvoiceCollectionId).then((response) => {
console.warn('Fixed pricing added to invoice collection successfully:', response);
invoiceQueue.addInvoiceCollectionsToQueue([newInvoiceCollectionId]);
invoiceQueue.processInvoiceCollectionQueue();
}).catch((error) => {
console.error('Error adding fixed pricing to invoice collection:', error);
});
}
}).catch((error) => {
console.error('Error creating fixed pricing invoice:', error);
});
}
/** Check if the customer has vehicle subscriptions and create an invoice for it */
else {
console.warn('Customer has no transactions and no fixed pricing. Please create an order for invoicing.', customer);
// Check if the customer is in the vehicle subscriptions view
if (view.variables.currentView.value === 'vehicle_subscriptions') {
console.warn('Customer is in the vehicle subscriptions view. Creating vehicle subscription invoice.');
const month = (dates.variables.start.value.getMonth() + 1);
const year = (dates.variables.start.value.getFullYear());
//const month = (new Date()).getMonth() + 1;
//const year = (new Date()).getFullYear();
console.warn('Creating vehicle subscription invoice for customer:', customer.customer_number, 'for', month, year);
await SessionUser.objects.collectedOrderInvoices.functions.createVehicleSubscriptionInvoice(customer.customer_number, month, year).then((response) => {
const newInvoiceCollectionId = response?.data?.data?.id; const newInvoiceCollectionId = response?.data?.data?.id;
console.warn('New invoice collection ID:', newInvoiceCollectionId); if (!newInvoiceCollectionId) {
if (newInvoiceCollectionId) { return;
console.warn('Vehicle subscription invoice created successfully:', response); }
SessionUser.objects.collectedOrderInvoices.functions.add_vehicle_subscriptions(newInvoiceCollectionId).then((response) => {
const newTransaction = response?.data?.data; SessionUser.objects.collectedOrderInvoices.functions.add_fixed_pricing(newInvoiceCollectionId)
// Add the new transaction to the customer view .then(() => {
// view.functions.addViewCustomerTransaction(customer, { id: newTransaction.id, date: newTransaction.date, amount: newTransaction.total_net_amount, booked: false });
console.warn('Vehicle subscriptions added to invoice collection successfully:', response);
// Queue the invoice collection for processing
invoiceQueue.addInvoiceCollectionsToQueue([newInvoiceCollectionId]); invoiceQueue.addInvoiceCollectionsToQueue([newInvoiceCollectionId]);
invoiceQueue.processInvoiceCollectionQueue(); invoiceQueue.processInvoiceCollectionQueue();
}).catch((error) => { })
console.error('Error adding vehicle subscriptions to invoice collection:', error); .catch((error: any) => {
console.error("Error adding fixed pricing to invoice collection:", error);
}); });
})
.catch((error: any) => {
console.error("Error creating fixed pricing invoice:", error);
});
} else if (view.variables.currentView.value === "vehicle_subscriptions") {
const month = dates.variables.start.value.getMonth() + 1;
const year = dates.variables.start.value.getFullYear();
await SessionUser.objects.collectedOrderInvoices.functions.createVehicleSubscriptionInvoice(customer.customer_number, month, year)
.then((response: any) => {
const newInvoiceCollectionId = response?.data?.data?.id;
if (!newInvoiceCollectionId) {
return;
} }
}).catch((error) => {
console.error('Error creating vehicle subscription invoice:', error); SessionUser.objects.collectedOrderInvoices.functions.add_vehicle_subscriptions(newInvoiceCollectionId)
.then(() => {
invoiceQueue.addInvoiceCollectionsToQueue([newInvoiceCollectionId]);
invoiceQueue.processInvoiceCollectionQueue();
})
.catch((error: any) => {
console.error("Error adding vehicle subscriptions to invoice collection:", error);
});
})
.catch((error: any) => {
console.error("Error creating vehicle subscription invoice:", error);
}); });
}
} }
return; return;
} }
fetchMissingInvoiceCollections(transactionIds).then(() => { fetchMissingInvoiceCollections(transactionIds).then(() => {
const invoiceCollectionIds = transactionIds.map(id => transactionIdsInvoiceCollectionCache.value[id]).filter(id => id !== null) as number[]; const invoiceCollectionIds = transactionIds
.map((transactionId) => transactionIdsInvoiceCollectionCache.value[transactionId])
.filter((invoiceCollectionId): invoiceCollectionId is number => invoiceCollectionId !== null);
const uniqueInvoiceCollectionIds = Array.from(new Set(invoiceCollectionIds)); const uniqueInvoiceCollectionIds = Array.from(new Set(invoiceCollectionIds));
if (uniqueInvoiceCollectionIds.length > 0) { if (uniqueInvoiceCollectionIds.length > 0) {
invoiceQueue.addInvoiceCollectionsToQueue(uniqueInvoiceCollectionIds); invoiceQueue.addInvoiceCollectionsToQueue(uniqueInvoiceCollectionIds);
invoiceQueue.processInvoiceCollectionQueue(); invoiceQueue.processInvoiceCollectionQueue();
} }
}); });
} };
const fetchMissingInvoiceCollections = async (transactionIds: number[]) => { const fetchMissingInvoiceCollections = async (transactionIds: number[]) => {
const uncachedTransactionIds = transactionIds.filter(id => !(id in transactionIdsInvoiceCollectionCache.value)); const uncachedTransactionIds = transactionIds.filter((transactionId) => !(transactionId in transactionIdsInvoiceCollectionCache.value));
if (uncachedTransactionIds.length === 0) { if (uncachedTransactionIds.length === 0) {
return Promise.resolve(); return Promise.resolve();
} }
return SessionUser.objects.orders.get.multiple(uncachedTransactionIds).then((orders: any) => { return SessionUser.objects.orders.get.multiple(uncachedTransactionIds).then((orders: any) => {
orders.forEach((order: any) => { orders.forEach((order: any) => {
transactionIdsInvoiceCollectionCache.value[order.id] = order.invoice_collection_id; transactionIdsInvoiceCollectionCache.value[order.id] = order.invoice_collection_id;
}); });
}); });
} };
const isAnyTransactionInQueue = (transactionIds: number[]) => { const getTransactionInvoiceCollectionId = (transaction: any) => {
return transactionIds.some(id => const directInvoiceCollectionId = Number.parseInt(String(transaction?.invoice_collection_id ?? ""), 10);
invoiceQueue.invoiceCollectionQueueInProgress.value.includes(transactionIdsInvoiceCollectionCache.value[id]!) || if (Number.isInteger(directInvoiceCollectionId) && directInvoiceCollectionId > 0) {
invoiceQueue.invoiceCollectionQueue.value.includes(transactionIdsInvoiceCollectionCache.value[id]!) return directInvoiceCollectionId;
); }
}
const cachedInvoiceCollectionId = transactionIdsInvoiceCollectionCache.value[transaction?.id];
return Number.isInteger(cachedInvoiceCollectionId) && (cachedInvoiceCollectionId ?? 0) > 0
? cachedInvoiceCollectionId
: null;
};
const isInvoiceCollectionQueuedLocally = (invoiceCollectionId: number | null | undefined) => {
if (!invoiceCollectionId) {
return false;
}
return invoiceQueue.invoiceCollectionQueueInProgress.value.includes(invoiceCollectionId)
|| invoiceQueue.invoiceCollectionQueue.value.includes(invoiceCollectionId);
};
const getCustomerActionableTransactions = (customer: any) => {
return getTransactionsInView(customer).filter((transaction: any) => transaction?.booked !== true && transaction?.excluded !== true);
};
const getLocalQueuedInvoiceCollectionIdsForCustomer = (customer: any) => {
return Array.from(new Set(
getCustomerActionableTransactions(customer)
.map((transaction: any) => getTransactionInvoiceCollectionId(transaction))
.filter((invoiceCollectionId): invoiceCollectionId is number => (
Number.isInteger(invoiceCollectionId) && (invoiceCollectionId ?? 0) > 0 && isInvoiceCollectionQueuedLocally(invoiceCollectionId)
))
));
};
const getCustomerQueueStatuses = (customer: any) => {
const backendStatuses = Array.isArray(customer?.queue?.statuses)
? Array.from(new Set(customer.queue.statuses.filter((status: string) => typeof status === "string" && status.length > 0)))
: [];
if (backendStatuses.length > 0) {
return backendStatuses;
}
const queuedInvoiceCollectionIds = getLocalQueuedInvoiceCollectionIdsForCustomer(customer);
if (queuedInvoiceCollectionIds.length === 0) {
return [];
}
const statuses = [];
const hasProcessing = queuedInvoiceCollectionIds.some((invoiceCollectionId) => (
invoiceQueue.invoiceCollectionQueueInProgress.value.includes(invoiceCollectionId)
));
const hasQueued = queuedInvoiceCollectionIds.some((invoiceCollectionId) => (
invoiceQueue.invoiceCollectionQueue.value.includes(invoiceCollectionId)
));
if (hasProcessing) {
statuses.push(ECONOMIC_QUEUE_STATUS.PROCESSING);
}
if (hasQueued) {
statuses.push(ECONOMIC_QUEUE_STATUS.QUEUED);
}
return statuses;
};
const isCustomerQueueBlocked = (customer: any) => {
if (customer?.queue?.is_action_blocked === true) {
return true;
}
const actionableTransactions = getCustomerActionableTransactions(customer);
if (actionableTransactions.length === 0) {
return false;
}
return actionableTransactions.every((transaction: any) => {
const invoiceCollectionId = getTransactionInvoiceCollectionId(transaction);
return Number.isInteger(invoiceCollectionId) && (invoiceCollectionId ?? 0) > 0 && isInvoiceCollectionQueuedLocally(invoiceCollectionId);
});
};
const isCustomerQueueProcessing = (customer: any) => {
return getCustomerQueueStatuses(customer).includes(ECONOMIC_QUEUE_STATUS.PROCESSING);
};
const getCustomerQueueLabel = (customer: any) => {
if (isCustomerQueueProcessing(customer)) {
return SessionUser.objects.global.language.processing ?? "Processing";
}
return SessionUser.objects.global.language.queued ?? "Queued";
};
const isAllCustomerTransactionsBooked = (customer: any, transactionIds: number[]) => { const isAllCustomerTransactionsBooked = (customer: any, transactionIds: number[]) => {
if (!customer.transactions || customer.transactions.length === 0) { if (!customer.transactions || customer.transactions.length === 0) {
return false; return false;
} }
const bookedTransactionIds = customer.transactions.filter((t: any) => t.booked === true).map((t: any) => t.id);
const excludedTransactionIds = customer.transactions.filter((t: any) => t.excluded === true).map((t: any) => t.id); const bookedTransactionIds = customer.transactions
return (transactionIds.every(id => bookedTransactionIds.includes(id)) || transactionIds.every(id => excludedTransactionIds.includes(id))); .filter((transaction: any) => transaction.booked === true)
} .map((transaction: any) => transaction.id);
const excludedTransactionIds = customer.transactions
.filter((transaction: any) => transaction.excluded === true)
.map((transaction: any) => transaction.id);
return transactionIds.every((transactionId) => bookedTransactionIds.includes(transactionId))
|| transactionIds.every((transactionId) => excludedTransactionIds.includes(transactionId));
};
const getTransactionQueryParameters = () => { const getTransactionQueryParameters = () => {
return { return {
show_wash_subscription: true, show_wash_subscription: true,
show_vehicle_subscription: true, show_vehicle_subscription: true,
show_fixed_pricing: true show_fixed_pricing: true,
} };
} };
</script> </script>
<template> <template>
<div data-testid="invoicing-period-view-all" :data-current-view="view.variables.currentView.value"> <div data-testid="invoicing-period-view-all" :data-current-view="view.variables.currentView.value">
<!--{{ view.variables.sharedVariables.value }}--> <InvoicingBillingPeriodStatistics />
<InvoicingBillingPeriodStatistics/> <InvoicingBillingPeriodFilters @filterChanged="(filters) => onFilterChanged(filters)" />
<!-- Filters for customer visibility -->
<InvoicingBillingPeriodFilters
@filterChanged="(filters) => onFilterChanged(filters)"
/>
<div style="min-height: 300px;"><!--Spacer--></div> <div style="min-height: 300px;"><!--Spacer--></div>
<div class="columns is-multiline is-mobile"> <div class="columns is-multiline is-mobile">
<template <template
v-if="doesViewtypeExist(view.variables.sharedVariables.value.types, view.computed.componentName.value)" v-for="customer in customersInCurrentView"
v-for="customer in view.variables.sharedVariables.value.types[view.computed.componentName.value]" :key="customer.customer_number"
:key="customer.customer_number"
> >
<div class="column is-12" v-show="tmpFilters.isCustomerVisible(customer) ?? true"> <div class="column is-12" v-show="tmpFilters.isCustomerVisible(customer) ?? true">
<WhiteBox class="mb-2" :has-border="true" :data-testid="`invoicing-period-customer-${customer.customer_number}`"> <WhiteBox class="mb-2" :has-border="true" :data-testid="`invoicing-period-customer-${customer.customer_number}`">
<!--{{ customer }}--> <div class="columns is-vcentered is-clickable" @click="onClickCustomer(customer)">
<div class="columns is-vcentered is-clickable" @click="onClickCustomer(customer)"> <div class="column">
<div class="column"> <ColorIndicator
<ColorIndicator :color_class="customer.requires_action ? 'has-text-danger' : 'has-text-success'"
v-bind:color_class="(customer.requires_action) ? 'has-text-danger' : 'has-text-success'" :label="{
v-bind:label="{ text: customer.customer_name,
text: customer.customer_name, classes: [],
classes: [], }"
}" :visibility="{
v-bind:visibility="{ icon: true,
icon: true, dropdown: false,
dropdown: false, }"
}" />
/> </div>
</div> <div class="column is-narrow">
<!-- Attributes --> <InvoicingBillingPeriodCustomerAttributes
<div class="column is-narrow"> :customer="customer"
<InvoicingBillingPeriodCustomerAttributes :is-expanded="customer.expanded || false"
v-bind:customer="customer" />
v-bind:is-expanded="customer.expanded || false" </div>
/> <div class="column is-narrow">
</div> <SmallCustomerActivityChart
<!-- Small customer activity chart -->
<div class="column is-narrow">
<SmallCustomerActivityChart
v-if="tmpFilters.displayStatistics" v-if="tmpFilters.displayStatistics"
v-show="customer.transactions.length > 1 && doesCustomerHaveTransactionsDifferentDays(customer)" v-show="customer.transactions.length > 1 && doesCustomerHaveTransactionsDifferentDays(customer)"
:dates="{ :dates="{
dateFrom: dates.computed.formattedStartDate, dateFrom: dates.computed.formattedStartDate,
dateTo: dates.computed.formattedEndDate dateTo: dates.computed.formattedEndDate,
}" }"
v-bind:transactions="customer.transactions" :transactions="customer.transactions"
/> />
</div> </div>
<!-- Transactions count --> <div class="column is-narrow">
<div class="column is-narrow"> <p v-if="customer.transactions.length !== 0">
<p v-if="customer.transactions.length !== 0">{{view.functions.filterExcluded(customer.transactions).length}} {{ view.functions.filterExcluded(customer.transactions).length > 1 ? SessionUser.objects.orders.meta.labels.multiple : SessionUser.objects.orders.meta.labels.single }}</p> {{ view.functions.filterExcluded(customer.transactions).length }}
<p v-else>{{ SessionUser.objects.global.language.none }} {{ SessionUser.objects.orders.meta.labels.multiple }}</p> {{ view.functions.filterExcluded(customer.transactions).length > 1 ? SessionUser.objects.orders.meta.labels.multiple : SessionUser.objects.orders.meta.labels.single }}
</div> </p>
<!-- Total amount --> <p v-else>{{ SessionUser.objects.global.language.none }} {{ SessionUser.objects.orders.meta.labels.multiple }}</p>
<div class="column is-narrow"> </div>
<p>{{ SessionUser.functions.currency.toLocal(view.functions.getCustomerViewTotalNetAmount(customer, { includeFixedPricing: true, includeVehicleSubscriptions: true, includeTransactions: true })) }}</p> <div class="column is-narrow">
<p v-if="view.functions.getCustomerViewTotalNetAmount(customer, { includeFixedPricing: true, includeVehicleSubscriptions: true, includeTransactions: true}) !== view.functions.getCustomerViewTotalNetAmount(customer, { includeFixedPricing: false, includeVehicleSubscriptions: false, includeTransactions: true })" class="has-text-grey-light is-size-7"> <p>{{ SessionUser.functions.currency.toLocal(view.functions.getCustomerViewTotalNetAmount(customer, { includeFixedPricing: true, includeVehicleSubscriptions: true, includeTransactions: true })) }}</p>
({{ SessionUser.functions.currency.toLocal(view.functions.getCustomerViewTotalNetAmount(customer, { includeFixedPricing: false, includeVehicleSubscriptions: false, includeTransactions: true })) }}) <p
</p> v-if="view.functions.getCustomerViewTotalNetAmount(customer, { includeFixedPricing: true, includeVehicleSubscriptions: true, includeTransactions: true }) !== view.functions.getCustomerViewTotalNetAmount(customer, { includeFixedPricing: false, includeVehicleSubscriptions: false, includeTransactions: true })"
</div> class="has-text-grey-light is-size-7"
<!-- QuickAction: Invoice all content ( If requires action) -->
<div class="column is-narrow" v-if="isAllCustomerTransactionsBooked(customer, getCustomerTransactionsInView(customer)) && !customer.requires_action">
<span class="tag is-success is-light is-small">
<span class="icon is-small">
<i class="fas fa-check-circle"></i>
</span>
<span>{{ SessionUser.objects.global.language.all_booked }}</span>
</span>
</div>
<div class="column is-narrow" v-else-if="!isAnyTransactionInQueue(getCustomerTransactionsInView(customer))">
<button
v-if="tmpFilters.displayRequiresAction && customer.requires_action"
class="button is-small is-dark"
@click.stop="onClickInvoiceNow(customer, getTransactionIds(customer))"
:data-testid="`invoicing-period-customer-invoice-${customer.customer_number}`"
> >
<span class="icon is-small"> ({{ SessionUser.functions.currency.toLocal(view.functions.getCustomerViewTotalNetAmount(customer, { includeFixedPricing: false, includeVehicleSubscriptions: false, includeTransactions: true })) }})
<i class="fas fa-file-invoice"></i> </p>
</span> </div>
<span>{{ SessionUser.objects.global.language.invoice_now }}</span> <div
</button> class="column is-narrow"
</div> v-if="isAllCustomerTransactionsBooked(customer, getCustomerTransactionsInView(customer)) && !customer.requires_action && !isCustomerQueueBlocked(customer)"
<div class="column is-narrow" v-else>
<button
class="button is-small is-dark is-loading"
disabled
>
<span class="icon is-small">
<i class="fas fa-file-invoice"></i>
</span>
<span>{{ SessionUser.objects.global.language.processing }}</span>
</button>
</div>
<!-- Actions -->
<div class="column is-narrow">
<ActionSettingsWheelButton
v-bind:user_id="customer.id"
> >
<template #actions> <span class="tag is-success is-light is-small">
</template> <span class="icon is-small">
</ActionSettingsWheelButton> <i class="fas fa-check-circle"></i>
</span>
<span>{{ SessionUser.objects.global.language.all_booked }}</span>
</span>
</div>
<div class="column is-narrow" v-else-if="isCustomerQueueBlocked(customer)">
<button
class="button is-small is-dark"
:class="{ 'is-loading': isCustomerQueueProcessing(customer) }"
disabled
:data-testid="`invoicing-period-customer-queue-${customer.customer_number}`"
>
<span class="icon is-small">
<i class="fas fa-file-invoice"></i>
</span>
<span>{{ getCustomerQueueLabel(customer) }}</span>
</button>
</div>
<div class="column is-narrow" v-else>
<button
v-if="tmpFilters.displayRequiresAction && customer.requires_action"
class="button is-small is-dark"
@click.stop="onClickInvoiceNow(customer, getTransactionIds(customer))"
:data-testid="`invoicing-period-customer-invoice-${customer.customer_number}`"
>
<span class="icon is-small">
<i class="fas fa-file-invoice"></i>
</span>
<span>{{ SessionUser.objects.global.language.invoice_now }}</span>
</button>
</div>
<div class="column is-narrow">
<ActionSettingsWheelButton :user_id="customer.id">
<template #actions></template>
</ActionSettingsWheelButton>
</div>
</div> </div>
</div> <div v-if="customer.expanded">
<div v-if="customer.expanded"> <template v-if="view.variables.currentView.value === 'possible_duplicates'">
<template v-if="view.variables.currentView.value === 'possible_duplicates'"> <InvoiceOrdersPagination
<InvoiceOrdersPagination :set-customer-filter="customer.customer_number"
v-bind:set-customer-filter="customer.customer_number" :hide-search="true"
v-bind:hide-search="true" :hide-filter="true"
v-bind:hide-filter="true" :invoice-view="true"
v-bind:invoice-view="true" :apply-default-filters="false"
v-bind:apply-default-filters="false" :group-invoice-collection="true"
v-bind:group-invoice-collection="true" :limit-results="false"
v-bind:limit-results="false" :hide-pagination="false"
v-bind:hide-pagination="false" :dates="{
v-bind:dates="{
dateFrom: dates.variables.start.value.toISOString().split('T')[0],
dateTo: dates.variables.end.value.toISOString().split('T')[0]
}"
v-bind:show-only-with-ids="getTransactionIds(customer)"
v-bind:query-parameters="getTransactionQueryParameters()"
v-bind:excluded-order-ids="getExcludedTransactionIds(customer)"
/>
</template>
<!-- Expanded view of customer transactions -->
<template v-else>
<InvoiceOrdersPagination
v-bind:set-customer-filter="customer.customer_number"
v-bind:hide-search="true"
v-bind:hide-filter="true"
v-bind:invoice-view="true"
v-bind:apply-default-filters="false"
v-bind:group-invoice-collection="true"
v-bind:limit-results="false"
v-bind:hide-pagination="true"
v-bind:auto-expand-all="(view.variables.currentView.value === 'invoice_per_order')"
v-bind:query-parameters="getTransactionQueryParameters()"
v-bind:dates="{
dateFrom: dates.variables.start.value.toISOString().split('T')[0], dateFrom: dates.variables.start.value.toISOString().split('T')[0],
dateTo: dates.variables.end.value.toISOString().split('T')[0] dateTo: dates.variables.end.value.toISOString().split('T')[0],
}" }"
v-bind:show-only-with-ids="getTransactionIds(customer)" :show-only-with-ids="getTransactionIds(customer)"
v-bind:excluded-order-ids="getExcludedTransactionIds(customer)" :query-parameters="getTransactionQueryParameters()"
/> :excluded-order-ids="getExcludedTransactionIds(customer)"
</template> />
</div> </template>
<template v-else>
<InvoiceOrdersPagination
:set-customer-filter="customer.customer_number"
:hide-search="true"
:hide-filter="true"
:invoice-view="true"
:apply-default-filters="false"
:group-invoice-collection="true"
:limit-results="false"
:hide-pagination="true"
:auto-expand-all="view.variables.currentView.value === 'invoice_per_order'"
:query-parameters="getTransactionQueryParameters()"
:dates="{
dateFrom: dates.variables.start.value.toISOString().split('T')[0],
dateTo: dates.variables.end.value.toISOString().split('T')[0],
}"
:show-only-with-ids="getTransactionIds(customer)"
:excluded-order-ids="getExcludedTransactionIds(customer)"
/>
</template>
</div>
</WhiteBox> </WhiteBox>
</div> </div>
</template> </template>
</div> </div>
<!-- Process Invoice Queue -->
<!--
<InvoicingBillingPeriodInvoiceProgressBar
v-if="invoiceQueue.invoiceCollectionQueue.value.length + invoiceQueue.invoiceCollectionQueueInProgress.value.length + invoiceQueue.invoiceCollectionQueueSuccess.value.length + invoiceQueue.invoiceCollectionQueueFailed.value.length > 0"
v-bind:max-progress="invoiceQueue.invoiceCollectionQueueSuccess.value.length + invoiceQueue.invoiceCollectionQueueFailed.value.length + invoiceQueue.invoiceCollectionQueueInProgress.value.length + invoiceQueue.invoiceCollectionQueue.value.length"
v-bind:progress="invoiceQueue.invoiceCollectionQueueSuccess.value.length"
v-bind:failed="invoiceQueue.invoiceCollectionQueueFailed.value.length"
v-bind:in-progress="invoiceQueue.invoiceCollectionQueueInProgress.value.length"
v-bind:queued="invoiceQueue.invoiceCollectionQueue.value.length"
@dismissed="invoiceQueue.dismissProgressBar()"
/>
-->
</div> </div>
</template> </template>
@@ -24,7 +24,10 @@ const { t } = useI18n();
// Get the id from the URL // Get the id from the URL
const router = useRouter(); const router = useRouter();
const collectedOrderInvoiceId = router.currentRoute.value.params.collectedOrderInvoiceId; const collectedOrderInvoiceId = computed(() => {
const id = parseInt(String(router.currentRoute.value.params.collectedOrderInvoiceId ?? ""), 10);
return Number.isNaN(id) ? 0 : id;
});
// Define the reactive objects // Define the reactive objects
const orders = ref([]); const orders = ref([]);
@@ -40,7 +43,7 @@ const formattedTitle = firstLetter + restOfTitle;
// Get the collected order invoice // Get the collected order invoice
const collectedOrderInvoice = ref(null); const collectedOrderInvoice = ref(null);
const getCollectedOrderInvoice = async () => { const getCollectedOrderInvoice = async () => {
SessionUser.objects.collectedOrderInvoices.get.single(collectedOrderInvoiceId).then((response) => { SessionUser.objects.collectedOrderInvoices.get.single(collectedOrderInvoiceId.value).then((response) => {
const raw_data = response; const raw_data = response;
console.log(raw_data); console.log(raw_data);
orders.value = raw_data.orders || []; orders.value = raw_data.orders || [];
@@ -82,6 +85,7 @@ const orderIds = computed(() => {
v-bind:total="orders.length" v-bind:total="orders.length"
v-bind:invoices="orders" v-bind:invoices="orders"
v-bind:collectedOrderInvoice="collectedOrderInvoice" v-bind:collectedOrderInvoice="collectedOrderInvoice"
@updated="getCollectedOrderInvoice"
/> />
<!-- Special Arrangement --> <!-- Special Arrangement -->
<UserOtherSpecialArrangement :user_id="user.id" class="mb-2" v-if="user" :readOnly="true"/> <UserOtherSpecialArrangement :user_id="user.id" class="mb-2" v-if="user" :readOnly="true"/>
@@ -91,7 +95,7 @@ const orderIds = computed(() => {
<CollectedOrderInvoiceCustomerNotes :user_id="user.id" class="mb-2" v-if="user" :readOnly="true"/> <CollectedOrderInvoiceCustomerNotes :user_id="user.id" class="mb-2" v-if="user" :readOnly="true"/>
<!-- Manage --> <!-- Manage -->
<CollectedOrderInvoiceManage <CollectedOrderInvoiceManage
:invoiceId="parseInt(collectedOrderInvoiceId)" :invoiceId="collectedOrderInvoiceId"
v-bind:collectedOrderInvoice="collectedOrderInvoice" v-bind:collectedOrderInvoice="collectedOrderInvoice"
/> />
<!-- Orders --> <!-- Orders -->
@@ -104,4 +108,4 @@ const orderIds = computed(() => {
<style scoped> <style scoped>
</style> </style>
@@ -1,5 +1,5 @@
<script setup> <script setup>
import {defineProps, ref} from 'vue'; import { computed } from 'vue';
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
const props = defineProps({ const props = defineProps({
@@ -19,39 +19,70 @@ const props = defineProps({
description: 'The collected order invoice object' description: 'The collected order invoice object'
} }
}); });
const emit = defineEmits(['updated']);
const column_width = 'is-3'; const column_width = 'is-3';
const displays = [
{ const onEditClosedAt = async () => {
title: 'Transaktioner', if (!props.collectedOrderInvoice?.id) {
value: props.invoices.length, return;
icon: 'fas fa-file-invoice-dollar', }
},
{ await SessionUser.objects.collectedOrderInvoices.showEditObjectFieldForm(
title: 'Total beløb', parseInt(props.collectedOrderInvoice.id),
value: (props.collectedOrderInvoice.total_net_amount ? SessionUser.functions.currency.toLocal(props.collectedOrderInvoice.total_net_amount) : SessionUser.objects.global.language.no_data), 'closed_at',
icon: 'fas fa-money-bill-wave', props.collectedOrderInvoice.closed_at,
}, () => {
{ emit('updated');
title: 'Arkiveret', }
value: props.collectedOrderInvoice.closed_at ? props.collectedOrderInvoice.closed_at : 'Nej', );
icon: 'fas fa-lock', };
},
{ const displays = computed(() => {
title: 'Eksternt identifikationsnummer', return [
value: props.collectedOrderInvoice.external_id, {
icon: 'fas fa-lock-open', key: 'transactions',
}, title: 'Transaktioner',
{ value: props.invoices.length,
title: 'PO-nummer', icon: 'fas fa-file-invoice-dollar',
value: props.collectedOrderInvoice.po_number || SessionUser.objects.global.language.no_data, editable: false,
icon: 'fas fa-file-alt', },
}, {
]; key: 'total',
title: 'Total beløb',
value: (props.collectedOrderInvoice.total_net_amount ? SessionUser.functions.currency.toLocal(props.collectedOrderInvoice.total_net_amount) : SessionUser.objects.global.language.no_data),
icon: 'fas fa-money-bill-wave',
editable: false,
},
{
key: 'closed_at',
title: 'Arkiveret',
value: props.collectedOrderInvoice.closed_at ? props.collectedOrderInvoice.closed_at : 'Nej',
icon: 'fas fa-lock',
editable: true,
onEdit: onEditClosedAt,
},
{
key: 'external_id',
title: 'Eksternt identifikationsnummer',
value: props.collectedOrderInvoice.external_id,
icon: 'fas fa-lock-open',
editable: false,
},
{
key: 'po_number',
title: 'PO-nummer',
value: props.collectedOrderInvoice.po_number || SessionUser.objects.global.language.no_data,
icon: 'fas fa-file-alt',
editable: false,
},
];
});
</script> </script>
<template> <template>
<div class="columns is-multiline"> <div class="columns is-multiline">
<template v-for="display in displays"> <template v-for="display in displays" :key="display.key">
<div class="column" :class="column_width"> <div class="column" :class="column_width">
<div class="card"> <div class="card">
<!-- Header --> <!-- Header -->
@@ -64,8 +95,17 @@ const displays = [
</a> </a>
<!-- Title --> <!-- Title -->
<p class="card-header-title">{{ display.title }}</p> <p class="card-header-title">{{ display.title }}</p>
<!-- Dropdown icon --> <a
<a class="card-header-icon"> class="card-header-icon"
v-if="display.editable"
@click.stop="display.onEdit"
data-test="edit-closed-at-button"
>
<span class="icon">
<i class="fas fa-edit"></i>
</span>
</a>
<a class="card-header-icon" v-else>
<span class="icon"> <span class="icon">
<i class="fas fa-angle-down"></i> <i class="fas fa-angle-down"></i>
</span> </span>
@@ -83,4 +123,4 @@ const displays = [
<style scoped> <style scoped>
</style> </style>
@@ -1,176 +1,575 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
ECONOMIC_QUEUE_STATUS,
getEconomicApiErrorMessage,
parseEconomicTransferQueueListResponse,
} from "@/services/economicTransferQueue.js";
const ACTIVE_STATUSES = ["QUEUED", "PROCESSING"]; const ACTIVE_STATUSES = new Set([
ECONOMIC_QUEUE_STATUS.QUEUED,
ECONOMIC_QUEUE_STATUS.PROCESSING,
]);
const DEFAULT_LIMIT = 50; const DEFAULT_LIMIT = 50;
const MANUAL_RUN_LIMIT = 10;
const POLL_INTERVAL_MS = 4000; const POLL_INTERVAL_MS = 4000;
const statusFilter = ref("PROCESSING"); const statusFilter = ref("");
const limitFilter = ref(DEFAULT_LIMIT); const limitFilter = ref(DEFAULT_LIMIT);
const offsetFilter = ref(0); const offsetFilter = ref(0);
const jobs = ref([]); const jobs = ref([]);
const total = ref(0); const total = ref(0);
const hasMore = ref(false);
const loading = ref(false); const loading = ref(false);
const refreshing = ref(false); const refreshing = ref(false);
const runningQueue = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const pollErrorMessage = ref("");
const manualRunUnavailable = ref(false);
const runSummary = ref(null);
const retryingJobIds = ref([]); const retryingJobIds = ref([]);
const selectedJob = ref(null);
let pollInterval = null; let pollTimer = null;
let loadPromise = null;
let isMounted = false;
const toPositiveInt = (value, fallback = 0) => { const toPositiveInt = (value, fallback = null) => {
const parsed = Number.parseInt(String(value), 10); const parsed = Number.parseInt(String(value), 10);
if (!Number.isFinite(parsed) || parsed < 0) { if (!Number.isInteger(parsed) || parsed < 1) {
return fallback; return fallback;
} }
return parsed; return parsed;
}; };
const toNonNegativeInt = (value, fallback = 0) => {
const parsed = Number.parseInt(String(value), 10);
if (!Number.isInteger(parsed) || parsed < 0) {
return fallback;
}
return parsed;
};
const formatDateTime = (value) => {
if (!value) {
return "-";
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return String(value);
}
return new Intl.DateTimeFormat("da-DK", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(parsed);
};
const formatScalar = (value, fallback = "-") => {
if (value === null || value === undefined || value === "") {
return fallback;
}
if (typeof value === "boolean") {
return value ? "Ja" : "Nej";
}
return String(value);
};
const formatJson = (value) => {
if (value === null || value === undefined) {
return "{}";
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
const formatMoney = (value) => {
const amount = Number(value);
if (!Number.isFinite(amount)) {
return "-";
}
return `${amount.toFixed(2)} DKK`;
};
const buildLocalFallbackSummary = (rawJob) => {
const payload = rawJob?.payload && typeof rawJob.payload === "object"
? rawJob.payload
: {};
const result = rawJob?.result && typeof rawJob.result === "object"
? rawJob.result
: {};
return {
message: rawJob?.error_message || result?.message || rawJob?.progress_message || "Ingen detaljer endnu.",
target: {
collected_invoice_id: toPositiveInt(
rawJob?.collected_invoice_id ?? payload?.collected_invoice_id,
null,
),
send_as_is: Boolean(payload?.send_as_is ?? false),
requested_by: toPositiveInt(payload?.requested_by ?? rawJob?.created_by, null),
},
customer: {
customer_number: toPositiveInt(
result?.customer_number ?? result?.customer?.customer_number,
null,
),
name: result?.customer_name ?? result?.customer?.name ?? null,
},
outcome: {
status: rawJob?.status ?? null,
economic_invoice_draft_id: toPositiveInt(
result?.economic_invoice_draft_id ?? result?.draft_id,
null,
),
economic_invoice_booked_id: toPositiveInt(
result?.economic_invoice_booked_id ?? result?.booked_id,
null,
),
external_id: result?.external_id ?? null,
total_net_amount: result?.total_net_amount ?? null,
order_count: toNonNegativeInt(
result?.order_count ?? (Array.isArray(result?.order_ids) ? result.order_ids.length : null),
0,
),
error_message: rawJob?.error_message ?? null,
},
transfer: {
transfer_type: rawJob?.transfer_type ?? null,
status: rawJob?.status ?? null,
progress_percent: toNonNegativeInt(rawJob?.progress_percent, 0),
progress_message: rawJob?.progress_message ?? null,
attempts: toNonNegativeInt(rawJob?.attempts, 0),
max_attempts: toNonNegativeInt(rawJob?.max_attempts, 0),
next_retry_at: rawJob?.next_retry_at ?? null,
},
technical: {
job_id: toPositiveInt(rawJob?.id ?? rawJob?.job_id, null),
created_by: toPositiveInt(rawJob?.created_by, null),
created_at: rawJob?.created_at ?? null,
updated_at: rawJob?.updated_at ?? null,
started_at: rawJob?.started_at ?? null,
completed_at: rawJob?.completed_at ?? null,
raw_available: {
payload: payload && Object.keys(payload).length > 0,
result: result && Object.keys(result).length > 0,
},
raw: {
payload,
result,
},
},
};
};
const normalizeDetailsSummary = (rawSummary, rawJob) => {
const fallback = buildLocalFallbackSummary(rawJob);
if (!rawSummary || typeof rawSummary !== "object") {
return fallback;
}
return {
message: rawSummary?.message ?? fallback.message,
target: {
...fallback.target,
...(rawSummary?.target && typeof rawSummary.target === "object" ? rawSummary.target : {}),
},
customer: {
...fallback.customer,
...(rawSummary?.customer && typeof rawSummary.customer === "object" ? rawSummary.customer : {}),
},
outcome: {
...fallback.outcome,
...(rawSummary?.outcome && typeof rawSummary.outcome === "object" ? rawSummary.outcome : {}),
},
transfer: {
...fallback.transfer,
...(rawSummary?.transfer && typeof rawSummary.transfer === "object" ? rawSummary.transfer : {}),
},
technical: {
...fallback.technical,
...(rawSummary?.technical && typeof rawSummary.technical === "object" ? rawSummary.technical : {}),
raw_available: {
...fallback.technical.raw_available,
...(rawSummary?.raw_available && typeof rawSummary.raw_available === "object" ? rawSummary.raw_available : {}),
...(rawSummary?.technical?.raw_available && typeof rawSummary.technical.raw_available === "object"
? rawSummary.technical.raw_available
: {}),
},
raw: rawSummary?.technical?.raw ?? fallback.technical.raw,
},
};
};
const normalizeJob = (rawJob) => { const normalizeJob = (rawJob) => {
const normalizedStatus = String(rawJob?.status ?? "").toUpperCase(); const payload = rawJob?.payload && typeof rawJob.payload === "object"
? rawJob.payload
: {};
const progressPercent = Number(rawJob?.progress_percent ?? 0); const progressPercent = Number(rawJob?.progress_percent ?? 0);
return { return {
id: toPositiveInt(rawJob?.id ?? rawJob?.job_id), ...rawJob,
status: normalizedStatus || "UNKNOWN", id: toPositiveInt(rawJob?.id ?? rawJob?.job_id ?? rawJob?.queue_job_id, null),
collected_invoice_id: toPositiveInt(
rawJob?.collected_invoice_id ?? payload?.collected_invoice_id ?? rawJob?.invoice_collection_id,
null,
),
status: String(rawJob?.status ?? "UNKNOWN").toUpperCase(),
progress_percent: Number.isFinite(progressPercent) progress_percent: Number.isFinite(progressPercent)
? Math.max(0, Math.min(100, progressPercent)) ? Math.max(0, Math.min(100, progressPercent))
: 0, : 0,
progress_message: String(rawJob?.progress_message ?? ""), progress_message: String(rawJob?.progress_message ?? ""),
error_message: String(rawJob?.error_message ?? ""), error_message: String(rawJob?.error_message ?? ""),
attempts: toNonNegativeInt(rawJob?.attempts, 0),
max_attempts: toNonNegativeInt(rawJob?.max_attempts, 0),
payload,
result: rawJob?.result ?? null, result: rawJob?.result ?? null,
collected_invoice_id: rawJob?.collected_invoice_id ?? rawJob?.invoice_collection_id ?? null, created_by: toNonNegativeInt(rawJob?.created_by, 0),
created_at: rawJob?.created_at ?? null, created_at: rawJob?.created_at ?? null,
updated_at: rawJob?.updated_at ?? null, updated_at: rawJob?.updated_at ?? null,
started_at: rawJob?.started_at ?? null,
completed_at: rawJob?.completed_at ?? null,
next_retry_at: rawJob?.next_retry_at ?? null,
transfer_type: String(rawJob?.transfer_type ?? ""),
details_summary: normalizeDetailsSummary(rawJob?.details_summary, rawJob),
}; };
}; };
const parseQueueListResponse = (response) => { const hasActiveJobs = computed(() => jobs.value.some((job) => ACTIVE_STATUSES.has(job.status)));
const payload = response?.data?.data ?? response?.data ?? {};
const responseItems = Array.isArray(payload)
? payload
: (Array.isArray(payload?.items)
? payload.items
: (Array.isArray(payload?.jobs) ? payload.jobs : []));
const parsedCount = Number(
payload?.count
?? payload?.total
?? payload?.pagination?.total
?? responseItems.length
);
return {
items: responseItems.map((job) => normalizeJob(job)),
total: Number.isFinite(parsedCount) ? parsedCount : responseItems.length,
};
};
const hasActiveJobs = computed(() => jobs.value.some((job) => ACTIVE_STATUSES.includes(job.status)));
const canGoToPrevious = computed(() => offsetFilter.value > 0); const canGoToPrevious = computed(() => offsetFilter.value > 0);
const canGoToNext = computed(() => offsetFilter.value + limitFilter.value < total.value); const canGoToNext = computed(() => hasMore.value);
const runSummaryTone = computed(() => {
if (!runSummary.value) {
return "is-info";
}
if ((runSummary.value.failed ?? 0) > 0 && (runSummary.value.completed ?? 0) < 1) {
return "is-danger";
}
if ((runSummary.value.completed ?? 0) > 0) {
return "is-success";
}
if ((runSummary.value.processed ?? 0) < 1) {
return "is-warning";
}
return "is-info";
});
const runSummaryMessage = computed(() => formatRunSummaryMessage(runSummary.value));
const selectedSummary = computed(() => selectedJob.value?.details_summary ?? null);
const formatRunSummaryMessage = (summary) => {
if (!summary) {
return "";
}
const parts = [
summary.message || "Collected invoice queue batch processed",
`Processed ${toNonNegativeInt(summary.processed, 0)}`,
`Completed ${toNonNegativeInt(summary.completed, 0)}`,
`Failed ${toNonNegativeInt(summary.failed, 0)}`,
];
if (Array.isArray(summary.jobs) && summary.jobs.length > 0) {
parts.push(`Jobs ${summary.jobs.join(", ")}`);
}
if (summary.fallback) {
parts.push("Legacy fallback active");
}
return parts.join(" | ");
};
const getStatusTagClass = (status) => { const getStatusTagClass = (status) => {
switch (status) { switch (status) {
case "QUEUED": case ECONOMIC_QUEUE_STATUS.QUEUED:
return "is-warning"; return "is-warning";
case "PROCESSING": case ECONOMIC_QUEUE_STATUS.PROCESSING:
return "is-info"; return "is-info";
case "COMPLETED": case ECONOMIC_QUEUE_STATUS.COMPLETED:
return "is-success"; return "is-success";
case "FAILED": case ECONOMIC_QUEUE_STATUS.FAILED:
return "is-danger"; return "is-danger";
default: default:
return "is-light"; return "is-light";
} }
}; };
const getResultMessage = (job) => {
if (!job?.result) {
return "";
}
if (typeof job.result === "string") {
return job.result;
}
if (typeof job.result?.message === "string") {
return job.result.message;
}
try {
return JSON.stringify(job.result);
} catch {
return "";
}
};
const stopPolling = () => { const stopPolling = () => {
if (pollInterval !== null) { if (pollTimer !== null) {
clearInterval(pollInterval); clearTimeout(pollTimer);
pollInterval = null; pollTimer = null;
} }
}; };
const loadQueueHistory = async ({ silent = false } = {}) => { const syncPollingState = () => {
if (runningQueue.value) {
stopPolling();
return;
}
if (hasActiveJobs.value && !pollErrorMessage.value) {
startPolling();
return;
}
stopPolling();
};
const startPolling = () => {
if (pollTimer !== null || !hasActiveJobs.value || runningQueue.value || pollErrorMessage.value) {
return;
}
pollTimer = setTimeout(async () => {
pollTimer = null;
if (!hasActiveJobs.value || runningQueue.value) {
stopPolling();
return;
}
try {
await runQueueBatchWithFallback();
await loadQueueHistory({ silent: true, isPoll: true });
} catch (error) {
pollErrorMessage.value = `Polling paused due to a transport error: ${getEconomicApiErrorMessage(
error,
"Failed to run queue worker.",
)}`;
stopPolling();
return;
}
syncPollingState();
}, POLL_INTERVAL_MS);
};
const loadQueueHistory = async ({ silent = false, isPoll = false } = {}) => {
if (loadPromise) {
return loadPromise;
}
if (silent) { if (silent) {
refreshing.value = true; refreshing.value = true;
} else { } else {
loading.value = true; loading.value = true;
} }
if (!isPoll) {
pollErrorMessage.value = "";
}
errorMessage.value = ""; errorMessage.value = "";
try { loadPromise = (async () => {
const response = await SessionUser.objects.collectedOrderInvoices.functions.economic.queue.list({ try {
status: statusFilter.value || undefined, const response = await SessionUser.objects.collectedOrderInvoices.functions.economic.queue.list({
limit: limitFilter.value, status: statusFilter.value || undefined,
offset: offsetFilter.value, limit: limitFilter.value,
}); offset: offsetFilter.value,
});
const parsed = parseQueueListResponse(response); const parsed = parseEconomicTransferQueueListResponse(response, {
jobs.value = parsed.items; defaultLimit: limitFilter.value,
total.value = parsed.total; defaultOffset: offsetFilter.value,
});
jobs.value = parsed.items.map((rawJob) => normalizeJob(rawJob));
total.value = toNonNegativeInt(parsed.total, jobs.value.length);
hasMore.value = Boolean(parsed.hasMore);
if (selectedJob.value?.id) {
selectedJob.value = jobs.value.find((job) => job.id === selectedJob.value.id) ?? null;
}
syncPollingState();
return parsed;
} catch (error) {
const message = getEconomicApiErrorMessage(error, "Failed to load queue history.");
if (isPoll) {
pollErrorMessage.value = `Polling paused due to a transport error: ${message}`;
stopPolling();
} else {
errorMessage.value = message;
}
throw error;
} finally {
loading.value = false;
refreshing.value = false;
loadPromise = null;
}
})();
return loadPromise;
};
const isManualRunEndpointMissing = (error) => {
const statusCode = Number(error?.response?.status ?? error?.status ?? 0);
const message = getEconomicApiErrorMessage(error, "").toLowerCase();
return statusCode === 404
|| message.includes("not found")
|| (
statusCode >= 500
&& message.includes("undefined method")
&& message.includes("processpendingbytransfertype")
);
};
const findQueuedJobForLegacyFallback = async () => {
const localQueuedJob = jobs.value.find((job) => job.status === ECONOMIC_QUEUE_STATUS.QUEUED);
if (localQueuedJob?.id) {
return localQueuedJob;
}
const response = await SessionUser.objects.collectedOrderInvoices.functions.economic.queue.list({
status: ECONOMIC_QUEUE_STATUS.QUEUED,
limit: 1,
offset: 0,
});
const parsed = parseEconomicTransferQueueListResponse(response, {
defaultLimit: 1,
defaultOffset: 0,
});
const firstQueuedJob = parsed.items[0] ? normalizeJob(parsed.items[0]) : null;
return firstQueuedJob?.id ? firstQueuedJob : null;
};
const runQueueViaLegacyFallback = async () => {
const queuedJob = await findQueuedJobForLegacyFallback();
if (!queuedJob?.id) {
return {
message: "No queued jobs were available to run.",
processed: 0,
completed: 0,
failed: 0,
jobs: [],
limit: MANUAL_RUN_LIMIT,
transfer_type: "COLLECTED_INVOICE_EXPORT",
status: ECONOMIC_QUEUE_STATUS.QUEUED,
fallback: true,
};
}
await SessionUser.objects.collectedOrderInvoices.functions.economic.queue.status(queuedJob.id);
return {
message: "Run queue now used the legacy queue status tick.",
processed: 1,
completed: 0,
failed: 0,
jobs: [queuedJob.id],
limit: MANUAL_RUN_LIMIT,
transfer_type: "COLLECTED_INVOICE_EXPORT",
status: ECONOMIC_QUEUE_STATUS.QUEUED,
fallback: true,
};
};
const runQueueBatchWithFallback = async () => {
try {
const response = await SessionUser.objects.collectedOrderInvoices.functions.economic.queue.run();
return response?.run ?? response;
} catch (error) { } catch (error) {
errorMessage.value = SessionUser.functions.parseErrorMessage(error) || "Failed to load queue history."; if (isManualRunEndpointMissing(error)) {
} finally { manualRunUnavailable.value = true;
loading.value = false; return runQueueViaLegacyFallback();
refreshing.value = false; }
throw error;
} }
}; };
const startPolling = () => { const onRefresh = async () => {
runSummary.value = null;
pollErrorMessage.value = "";
stopPolling(); stopPolling();
pollInterval = setInterval(() => {
if (!hasActiveJobs.value) { try {
stopPolling(); if (hasActiveJobs.value) {
return; await runQueueBatchWithFallback();
} }
loadQueueHistory({ silent: true }); await loadQueueHistory({ silent: true });
}, POLL_INTERVAL_MS); } catch (error) {
errorMessage.value = getEconomicApiErrorMessage(error, "Refresh failed.");
return;
}
}; };
const onRetryJob = async (jobId) => { const onRetryJob = async (job) => {
if (!jobId || retryingJobIds.value.includes(jobId)) { if (!job?.id || retryingJobIds.value.includes(job.id)) {
return; return;
} }
retryingJobIds.value.push(jobId); if (job.status !== ECONOMIC_QUEUE_STATUS.FAILED) {
errorMessage.value = "Retry is only available for failed queue jobs.";
return;
}
if (job.max_attempts > 0 && job.attempts >= job.max_attempts) {
errorMessage.value = "Retry is no longer available because the max attempts were used.";
return;
}
retryingJobIds.value = [...retryingJobIds.value, job.id];
errorMessage.value = ""; errorMessage.value = "";
pollErrorMessage.value = "";
stopPolling();
try { try {
await SessionUser.objects.collectedOrderInvoices.functions.economic.queue.retry(jobId); await SessionUser.objects.collectedOrderInvoices.functions.economic.queue.retry(job.id);
await loadQueueHistory({ silent: true }); await loadQueueHistory({ silent: true });
} catch (error) { } catch (error) {
errorMessage.value = SessionUser.functions.parseErrorMessage(error) || "Retry failed."; errorMessage.value = getEconomicApiErrorMessage(error, "Retry failed.");
} finally { } finally {
retryingJobIds.value = retryingJobIds.value.filter((id) => id !== jobId); retryingJobIds.value = retryingJobIds.value.filter((id) => id !== job.id);
syncPollingState();
}
};
const onRunQueueNow = async () => {
if (runningQueue.value) {
return;
}
runningQueue.value = true;
errorMessage.value = "";
pollErrorMessage.value = "";
runSummary.value = null;
stopPolling();
try {
const run = await runQueueBatchWithFallback();
runSummary.value = run;
await loadQueueHistory({ silent: true });
} catch (error) {
errorMessage.value = getEconomicApiErrorMessage(error, "Run queue now failed.");
} finally {
runningQueue.value = false;
syncPollingState();
} }
}; };
const onLimitChange = (event) => { const onLimitChange = (event) => {
limitFilter.value = toPositiveInt(event?.target?.value, DEFAULT_LIMIT) || DEFAULT_LIMIT; limitFilter.value = toPositiveInt(event?.target?.value, DEFAULT_LIMIT) ?? DEFAULT_LIMIT;
offsetFilter.value = 0; offsetFilter.value = 0;
}; };
const onOffsetInput = (event) => { const onOffsetInput = (event) => {
offsetFilter.value = toPositiveInt(event?.target?.value, 0); offsetFilter.value = toNonNegativeInt(event?.target?.value, 0);
}; };
const goToPreviousPage = () => { const goToPreviousPage = () => {
@@ -178,23 +577,35 @@ const goToPreviousPage = () => {
}; };
const goToNextPage = () => { const goToNextPage = () => {
if (!hasMore.value) {
return;
}
offsetFilter.value = offsetFilter.value + limitFilter.value; offsetFilter.value = offsetFilter.value + limitFilter.value;
}; };
const openDetails = (job) => {
selectedJob.value = job;
};
const closeDetails = () => {
selectedJob.value = null;
};
watch([statusFilter, limitFilter, offsetFilter], () => { watch([statusFilter, limitFilter, offsetFilter], () => {
loadQueueHistory(); if (!isMounted) {
return;
}
void loadQueueHistory();
}); });
watch(hasActiveJobs, (isActive) => { watch(hasActiveJobs, () => {
if (isActive) { syncPollingState();
startPolling();
} else {
stopPolling();
}
}); });
onMounted(() => { onMounted(() => {
loadQueueHistory(); isMounted = true;
void loadQueueHistory();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -210,17 +621,21 @@ onBeforeUnmount(() => {
<div class="select is-small is-fullwidth"> <div class="select is-small is-fullwidth">
<select v-model="statusFilter" data-testid="economic-queue-history-status-filter"> <select v-model="statusFilter" data-testid="economic-queue-history-status-filter">
<option value="">All</option> <option value="">All</option>
<option value="QUEUED">QUEUED</option> <option :value="ECONOMIC_QUEUE_STATUS.QUEUED">QUEUED</option>
<option value="PROCESSING">PROCESSING</option> <option :value="ECONOMIC_QUEUE_STATUS.PROCESSING">PROCESSING</option>
<option value="COMPLETED">COMPLETED</option> <option :value="ECONOMIC_QUEUE_STATUS.COMPLETED">COMPLETED</option>
<option value="FAILED">FAILED</option> <option :value="ECONOMIC_QUEUE_STATUS.FAILED">FAILED</option>
</select> </select>
</div> </div>
</div> </div>
<div class="column is-3"> <div class="column is-3">
<label class="label is-small">Limit</label> <label class="label is-small">Limit</label>
<div class="select is-small is-fullwidth"> <div class="select is-small is-fullwidth">
<select :value="String(limitFilter)" @change="onLimitChange" data-testid="economic-queue-history-limit-filter"> <select
:value="String(limitFilter)"
@change="onLimitChange"
data-testid="economic-queue-history-limit-filter"
>
<option value="10">10</option> <option value="10">10</option>
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>
@@ -242,26 +657,38 @@ onBeforeUnmount(() => {
<div class="column is-3 has-text-right"> <div class="column is-3 has-text-right">
<label class="label is-small">&nbsp;</label> <label class="label is-small">&nbsp;</label>
<div class="buttons is-right are-small"> <div class="buttons is-right are-small">
<button
class="button is-small is-info"
type="button"
@click="onRunQueueNow"
:disabled="loading || refreshing || runningQueue"
data-testid="economic-queue-history-run-now"
>
{{ runningQueue ? "Running..." : "Run queue now" }}
</button>
<button <button
class="button is-small" class="button is-small"
type="button"
@click="goToPreviousPage" @click="goToPreviousPage"
:disabled="!canGoToPrevious || loading" :disabled="!canGoToPrevious || loading || refreshing || runningQueue"
data-testid="economic-queue-history-prev-page" data-testid="economic-queue-history-prev-page"
> >
Previous Previous
</button> </button>
<button <button
class="button is-small" class="button is-small"
type="button"
@click="goToNextPage" @click="goToNextPage"
:disabled="!canGoToNext || loading" :disabled="!canGoToNext || loading || refreshing || runningQueue"
data-testid="economic-queue-history-next-page" data-testid="economic-queue-history-next-page"
> >
Next Next
</button> </button>
<button <button
class="button is-small is-link is-light" class="button is-small is-link is-light"
@click="loadQueueHistory({ silent: true })" type="button"
:disabled="loading || refreshing" @click="onRefresh"
:disabled="loading || refreshing || runningQueue"
data-testid="economic-queue-history-refresh" data-testid="economic-queue-history-refresh"
> >
Refresh Refresh
@@ -273,68 +700,202 @@ onBeforeUnmount(() => {
<div class="mb-3" data-testid="economic-queue-history-summary"> <div class="mb-3" data-testid="economic-queue-history-summary">
<small class="has-text-grey"> <small class="has-text-grey">
Showing {{ jobs.length }} of {{ total }} jobs. Showing {{ jobs.length }} of {{ total }} jobs.
<span v-if="hasMore">More results available.</span>
<span v-if="refreshing">Refreshing...</span> <span v-if="refreshing">Refreshing...</span>
<span v-if="manualRunUnavailable">Legacy fallback active while the backend queue runner is unavailable.</span>
</small> </small>
</div> </div>
<div
v-if="runSummary"
class="notification is-light"
:class="runSummaryTone"
data-testid="economic-queue-history-run-summary"
>
{{ runSummaryMessage }}
</div>
<div
v-if="pollErrorMessage"
class="notification is-warning is-light"
data-testid="economic-queue-history-poll-error"
>
{{ pollErrorMessage }}
</div>
<div v-if="errorMessage" class="notification is-danger is-light" data-testid="economic-queue-history-error"> <div v-if="errorMessage" class="notification is-danger is-light" data-testid="economic-queue-history-error">
{{ errorMessage }} {{ errorMessage }}
</div> </div>
<div v-if="loading && jobs.length === 0" class="notification is-light" data-testid="economic-queue-history-loading"> <div
v-if="loading && jobs.length === 0"
class="notification is-light"
data-testid="economic-queue-history-loading"
>
Loading queue history... Loading queue history...
</div> </div>
<div v-else-if="jobs.length === 0" class="notification is-light" data-testid="economic-queue-history-empty"> <div
v-else-if="jobs.length === 0"
class="notification is-light"
data-testid="economic-queue-history-empty"
>
No queue jobs found for the selected filters. No queue jobs found for the selected filters.
</div> </div>
<div v-else class="table-container"> <div v-else class="table-container">
<table class="table is-fullwidth is-striped is-hoverable" data-testid="economic-queue-history-table"> <table class="table is-fullwidth is-hoverable is-striped" data-testid="economic-queue-history-table">
<thead> <thead>
<tr> <tr>
<th>Job</th> <th>Job</th>
<th>Invoice</th> <th>Invoice</th>
<th>Status</th> <th>Status</th>
<th>Attempts</th>
<th>Progress</th> <th>Progress</th>
<th>Timestamps</th>
<th>Details</th> <th>Details</th>
<th class="has-text-right">Actions</th> <th class="has-text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="job in jobs" :key="job.id || `${job.status}-${job.created_at}`" :data-testid="`economic-queue-history-row-${job.id}`"> <tr
v-for="job in jobs"
:key="job.id || `${job.status}-${job.created_at}`"
:data-testid="`economic-queue-history-row-${job.id}`"
>
<td>#{{ job.id || "n/a" }}</td> <td>#{{ job.id || "n/a" }}</td>
<td>{{ job.collected_invoice_id || "-" }}</td> <td>{{ job.collected_invoice_id || "-" }}</td>
<td> <td>
<span class="tag" :class="getStatusTagClass(job.status)" :data-testid="`economic-queue-history-status-${job.id}`"> <span
class="tag"
:class="getStatusTagClass(job.status)"
:data-testid="`economic-queue-history-status-${job.id}`"
>
{{ job.status }} {{ job.status }}
</span> </span>
</td> </td>
<td style="min-width: 220px;"> <td>{{ job.attempts }} / {{ job.max_attempts || 0 }}</td>
<td style="min-width: 240px;">
<small class="is-block">{{ job.progress_message || "-" }}</small> <small class="is-block">{{ job.progress_message || "-" }}</small>
<progress class="progress is-small is-link" max="100" :value="job.progress_percent"></progress> <progress class="progress is-small is-link" max="100" :value="job.progress_percent"></progress>
</td> </td>
<td style="max-width: 320px;"> <td style="min-width: 220px;">
<small class="is-block has-text-danger" v-if="job.error_message">{{ job.error_message }}</small> <small class="is-block">Created: {{ formatDateTime(job.created_at) }}</small>
<small class="is-block has-text-grey" v-else>{{ getResultMessage(job) || "-" }}</small> <small class="is-block">Started: {{ formatDateTime(job.started_at) }}</small>
<small class="is-block">Updated: {{ formatDateTime(job.updated_at) }}</small>
<small class="is-block">Completed: {{ formatDateTime(job.completed_at) }}</small>
</td>
<td style="min-width: 220px;">
<small class="is-block">{{ job.details_summary?.message || "-" }}</small>
<small class="is-block" v-if="job.error_message">{{ job.error_message }}</small>
<button
class="button is-small is-light mt-2"
type="button"
@click="openDetails(job)"
:data-testid="`economic-queue-history-details-${job.id}`"
>
Se detaljer
</button>
</td> </td>
<td class="has-text-right"> <td class="has-text-right">
<button <button
v-if="job.status === 'FAILED'" v-if="job.status === ECONOMIC_QUEUE_STATUS.FAILED"
class="button is-small is-danger is-light" class="button is-small is-danger is-light"
@click="onRetryJob(job.id)" type="button"
:disabled="retryingJobIds.includes(job.id)" @click="onRetryJob(job)"
:disabled="retryingJobIds.includes(job.id) || (job.max_attempts > 0 && job.attempts >= job.max_attempts)"
:data-testid="`economic-queue-history-retry-${job.id}`" :data-testid="`economic-queue-history-retry-${job.id}`"
> >
Retry {{ retryingJobIds.includes(job.id) ? "Retrying..." : "Retry" }}
</button> </button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div
v-if="selectedJob && selectedSummary"
class="modal is-active"
data-testid="economic-queue-history-details-modal"
>
<div class="modal-background" @click="closeDetails"></div>
<div class="modal-card queue-details-modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Queue job #{{ selectedJob.id }}</p>
<button
class="delete"
type="button"
aria-label="close"
@click="closeDetails"
data-testid="economic-queue-history-details-close"
></button>
</header>
<section class="modal-card-body">
<article class="box" data-testid="economic-queue-history-details-section-udfald">
<h4 class="title is-6">Udfald</h4>
<p>{{ selectedSummary.message || "-" }}</p>
<p>Status: {{ formatScalar(selectedSummary.outcome?.status) }}</p>
<p>Draft id: {{ formatScalar(selectedSummary.outcome?.economic_invoice_draft_id) }}</p>
<p>Booked id: {{ formatScalar(selectedSummary.outcome?.economic_invoice_booked_id) }}</p>
<p>External id: {{ formatScalar(selectedSummary.outcome?.external_id) }}</p>
<p>Total net: {{ formatMoney(selectedSummary.outcome?.total_net_amount) }}</p>
<p>Order count: {{ formatScalar(selectedSummary.outcome?.order_count) }}</p>
<p v-if="selectedSummary.outcome?.error_message">Error: {{ selectedSummary.outcome.error_message }}</p>
</article>
<article class="box" data-testid="economic-queue-history-details-section-faktura">
<h4 class="title is-6">Faktura</h4>
<p>Collected invoice id: {{ formatScalar(selectedSummary.target?.collected_invoice_id) }}</p>
<p>Send as is: {{ formatScalar(selectedSummary.target?.send_as_is) }}</p>
<p>Requested by: {{ formatScalar(selectedSummary.target?.requested_by) }}</p>
<p>Customer number: {{ formatScalar(selectedSummary.customer?.customer_number) }}</p>
<p>Customer name: {{ formatScalar(selectedSummary.customer?.name) }}</p>
</article>
<article class="box" data-testid="economic-queue-history-details-section-overfoersel">
<h4 class="title is-6">Overfoersel</h4>
<p>Transfer type: {{ formatScalar(selectedSummary.transfer?.transfer_type || selectedJob.transfer_type) }}</p>
<p>Status: {{ formatScalar(selectedSummary.transfer?.status || selectedJob.status) }}</p>
<p>Progress: {{ formatScalar(selectedSummary.transfer?.progress_percent || selectedJob.progress_percent) }}%</p>
<p>Message: {{ formatScalar(selectedSummary.transfer?.progress_message || selectedJob.progress_message) }}</p>
<p>Attempts: {{ formatScalar(selectedSummary.transfer?.attempts || selectedJob.attempts) }}</p>
<p>Max attempts: {{ formatScalar(selectedSummary.transfer?.max_attempts || selectedJob.max_attempts) }}</p>
<p>Next retry at: {{ formatScalar(selectedSummary.transfer?.next_retry_at || selectedJob.next_retry_at) }}</p>
</article>
<article class="box" data-testid="economic-queue-history-details-section-koersel">
<h4 class="title is-6">Koersel</h4>
<p>Created: {{ formatDateTime(selectedSummary.technical?.created_at || selectedJob.created_at) }}</p>
<p>Started: {{ formatDateTime(selectedSummary.technical?.started_at || selectedJob.started_at) }}</p>
<p>Updated: {{ formatDateTime(selectedSummary.technical?.updated_at || selectedJob.updated_at) }}</p>
<p>Completed: {{ formatDateTime(selectedSummary.technical?.completed_at || selectedJob.completed_at) }}</p>
<p>Created by: {{ formatScalar(selectedSummary.technical?.created_by || selectedJob.created_by) }}</p>
<p>Job id: {{ formatScalar(selectedSummary.technical?.job_id || selectedJob.id) }}</p>
</article>
<article class="box" data-testid="economic-queue-history-details-section-teknisk">
<h4 class="title is-6">Teknisk</h4>
<p>Payload available: {{ formatScalar(selectedSummary.technical?.raw_available?.payload) }}</p>
<p>Result available: {{ formatScalar(selectedSummary.technical?.raw_available?.result) }}</p>
<pre
class="is-size-7 has-background-light p-3"
data-testid="economic-queue-history-details-technical-raw"
>{{ formatJson(selectedSummary.technical?.raw) }}</pre>
</article>
</section>
</div>
</div>
</section> </section>
</template> </template>
<style scoped> <style scoped>
.modal {
z-index: 80;
}
.queue-details-modal-card {
width: min(960px, calc(100vw - 2rem));
margin-top: 4rem;
}
</style> </style>
@@ -0,0 +1,95 @@
import { expect, test, type Locator, type Page, type Response } from "@playwright/test";
import { bookingTestData, loginAsOperator } from "./fixtures";
const changeInvoiceCollectionActionRegex =
/Skift fakturasamling|Change invoice collection|Rechnungssammlung|fakturainnsamling|fakturasamling/i;
const findChangeInvoiceCollectionAction = async (page: Page): Promise<Locator | null> => {
const actionTriggers = page.locator("tbody tr .dropdown-trigger > button");
const triggerCount = await actionTriggers.count();
for (let index = 0; index < triggerCount; index += 1) {
const trigger = actionTriggers.nth(index);
if (!(await trigger.isVisible().catch(() => false))) {
continue;
}
await trigger.click();
const dropdownRoot = trigger.locator("xpath=ancestor::div[contains(@class,'dropdown')][1]");
const changeInvoiceCollectionAction = dropdownRoot.getByRole("button", {
name: changeInvoiceCollectionActionRegex,
});
if ((await changeInvoiceCollectionAction.count()) > 0) {
const visibleAction = changeInvoiceCollectionAction.first();
if (await visibleAction.isVisible().catch(() => false)) {
return visibleAction;
}
}
await page.keyboard.press("Escape");
}
return null;
};
test.describe("POS order actions", () => {
test("change invoice collection opens picker and stays on the same page", async ({ page }) => {
await loginAsOperator(page);
const departmentId = bookingTestData.departmentId.toString();
await page.goto(`/admin/${departmentId}/modules/pos/orders`);
await expect(page).toHaveURL(new RegExp(`/admin/${departmentId}/modules/pos/orders`));
await expect(page.locator("table")).toBeVisible({ timeout: 30000 });
const ordersResponse = await page
.waitForResponse(
(response: Response) =>
response.request().method() === "GET" &&
response.url().includes("/orders?") &&
response.url().includes("department_id:"),
{ timeout: 30000 }
)
.catch(() => null);
test.skip(!ordersResponse, "Orders list request did not complete in time for this environment.");
await page.waitForTimeout(500);
const changeInvoiceCollectionAction = await findChangeInvoiceCollectionAction(page);
test.skip(
!changeInvoiceCollectionAction,
"No order action menu with 'Skift fakturasamling' is available for current test data/permissions."
);
if (!changeInvoiceCollectionAction) {
return;
}
await expect(changeInvoiceCollectionAction).toBeVisible();
const beforeUrl = page.url();
const newTabPromise = page.waitForEvent("popup", { timeout: 2000 }).catch(() => null);
await changeInvoiceCollectionAction.click();
const popup = await newTabPromise;
expect(popup).toBeNull();
await expect(page).toHaveURL(beforeUrl);
const pickerModal = page.locator(".swal2-container .modal-card");
await expect(
pickerModal,
"Expected invoice collection picker modal to open after clicking the action."
).toBeVisible();
const selectButtons = pickerModal.getByRole("button", { name: /Vælg|Select/i });
const selectableCount = await selectButtons.count();
test.skip(selectableCount === 0, "No invoice collections available for selection in test data.");
await selectButtons.first().click();
await expect(page).toHaveURL(beforeUrl);
await expect(page.locator(".swal2-container, .swal2-popup")).toContainText(
/Faktura samling|invoice collection/i
);
});
});
+61
View File
@@ -303,6 +303,67 @@ test.describe("Economic queue async export workflow", () => {
await expect(page.getByTestId("collected-economic-completed")).toContainText("Collected invoice exported."); await expect(page.getByTestId("collected-economic-completed")).toContainText("Collected invoice exported.");
}); });
test("collected invoice create accepts enqueue responses with data.job_id alias", async ({ page }) => {
let statusCalls = 0;
await page.route("**/collected-invoices/economic", async (route) => {
if (route.request().method() !== "POST") {
await route.fallback();
return;
}
await route.fulfill(json({
data: {
message: "Queued collected invoice export",
job_id: 1302,
job: {
status: "QUEUED",
progress_percent: 0,
progress_message: "Collected invoice queued",
},
},
}, 202));
});
await page.route("**/collected-invoices/economic/queue/status**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
statusCalls += 1;
if (statusCalls === 1) {
await route.fulfill(json({
data: {
id: 1302,
status: "PROCESSING",
progress_percent: 65,
progress_message: "Creating draft invoice",
},
}));
return;
}
await route.fulfill(json({
data: {
id: 1302,
status: "COMPLETED",
progress_percent: 100,
progress_message: "Completed",
result: {
message: "Collected invoice exported from alias response.",
},
},
}));
});
await openHarness(page);
await page.getByTestId("collected-economic-create-invoice").click();
await expect(page.getByTestId("collected-economic-progress")).toContainText("Creating draft invoice");
await expect(page.getByTestId("collected-economic-completed")).toContainText("Collected invoice exported from alias response.");
});
test("invalid boolean send_as_is is blocked and shown as validation error", async ({ page }) => { test("invalid boolean send_as_is is blocked and shown as validation error", async ({ page }) => {
let enqueueCalls = 0; let enqueueCalls = 0;
+435 -113
View File
@@ -1,6 +1,11 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js"; import { mockApi, seedAuthenticatedState } from "./support/network.js";
const QUEUE_LIST_PATH = "/collected-invoices/economic/queue";
const QUEUE_STATUS_PATH = "/collected-invoices/economic/queue/status";
const QUEUE_RUN_PATH = "/collected-invoices/economic/queue/run";
const QUEUE_RETRY_PATH = "/collected-invoices/economic/queue/retry";
function json(body, status = 200) { function json(body, status = 200) {
return { return {
status, status,
@@ -9,6 +14,11 @@ function json(body, status = 200) {
}; };
} }
function isQueuePath(requestUrl, path) {
const url = new URL(requestUrl);
return url.pathname === path || url.pathname === `/api${path}`;
}
async function suppressVueDevtoolsOverlay(page) { async function suppressVueDevtoolsOverlay(page) {
await page.addInitScript(() => { await page.addInitScript(() => {
const STYLE_ID = "__e2e-hide-vue-devtools"; const STYLE_ID = "__e2e-hide-vue-devtools";
@@ -56,74 +66,210 @@ async function openQueueHistory(page, token) {
await expect(page.getByTestId("economic-queue-history-page")).toBeVisible(); await expect(page.getByTestId("economic-queue-history-page")).toBeVisible();
} }
test.describe("Invoice transfer queue history page", () => { test.describe("Invoice transfer queue history reliability", () => {
test("@smoke queue tab loads in-progress jobs and polls updates", async ({ page }) => { test("@smoke polling progression reaches terminal state and stops", async ({ page }) => {
let queueListCalls = 0; let queueListCalls = 0;
const queueRequests = [];
const token = await bootstrapAuthenticatedSuperuser(page); const token = await bootstrapAuthenticatedSuperuser(page);
await page.route("**/collected-invoices/economic/queue**", async (route) => { await page.route("**/collected-invoices/economic/queue**", async (route) => {
if (route.request().method() !== "GET") { if (route.request().method() !== "GET" || !isQueuePath(route.request().url(), QUEUE_LIST_PATH)) {
await route.fallback();
return;
}
const url = new URL(route.request().url());
if (!(url.pathname === "/collected-invoices/economic/queue" || url.pathname === "/api/collected-invoices/economic/queue")) {
await route.fallback(); await route.fallback();
return; return;
} }
queueListCalls += 1; queueListCalls += 1;
queueRequests.push({ const isTerminal = queueListCalls > 1;
status: url.searchParams.get("status"),
limit: url.searchParams.get("limit"),
offset: url.searchParams.get("offset"),
});
const progressPercent = queueListCalls === 1 ? 20 : 65;
const progressMessage = queueListCalls === 1 ? "Queued for worker" : "Exporting line items";
await route.fulfill(json({ await route.fulfill(json({
data: { data: {
items: [ items: [
{ {
id: 9301, id: 9301,
status: "PROCESSING", status: isTerminal ? "COMPLETED" : "PROCESSING",
progress_percent: progressPercent, progress_percent: isTerminal ? 100 : 45,
progress_message: progressMessage, progress_message: isTerminal ? "Completed" : "Exporting line items",
collected_invoice_id: 7011, collected_invoice_id: 7001,
attempts: 1,
max_attempts: 3,
created_at: "2026-04-08T10:00:00Z",
updated_at: "2026-04-08T10:00:00Z",
}, },
], ],
count: 1, count: 1,
total: 1,
limit: 50,
offset: 0,
has_more: false,
}, },
})); }));
}); });
await openQueueHistory(page, token); await openQueueHistory(page, token);
await expect(page.getByTestId("economic-queue-history-status-filter")).toHaveValue("PROCESSING");
await expect(page.getByTestId("economic-queue-history-row-9301")).toBeVisible();
await expect(page.getByTestId("economic-queue-history-status-9301")).toContainText("PROCESSING"); await expect(page.getByTestId("economic-queue-history-status-9301")).toContainText("PROCESSING");
await expect.poll(() => queueRequests.some((request) => request.status === "PROCESSING")).toBeTruthy(); await expect.poll(async () => page.getByTestId("economic-queue-history-status-9301").innerText(), {
await expect.poll(() => queueListCalls, { timeout: 12000 }).toBeGreaterThanOrEqual(2); timeout: 12_000,
await expect(page.getByTestId("economic-queue-history-row-9301")).toContainText("Exporting line items"); }).toContain("COMPLETED");
const callsAtTerminal = queueListCalls;
await page.waitForTimeout(5_000);
expect(queueListCalls).toBe(callsAtTerminal);
}); });
test("@smoke queue tab supports failed filter and retry action", async ({ page }) => { test("@smoke status/limit/offset filters follow server metadata pagination", async ({ page }) => {
const queueRequests = [];
const token = await bootstrapAuthenticatedSuperuser(page);
await page.route("**/collected-invoices/economic/queue**", async (route) => {
if (route.request().method() !== "GET" || !isQueuePath(route.request().url(), QUEUE_LIST_PATH)) {
await route.fallback();
return;
}
const url = new URL(route.request().url());
const status = url.searchParams.get("status");
const limit = Number(url.searchParams.get("limit") || "50");
const offset = Number(url.searchParams.get("offset") || "0");
queueRequests.push({ status, limit, offset });
const hasMore = offset < 10;
await route.fulfill(json({
data: {
items: [
{
id: hasMore ? 9501 : 9502,
status: "FAILED",
progress_percent: hasMore ? 20 : 100,
progress_message: hasMore ? "Failed" : "Failed and exhausted",
error_message: hasMore ? "Transient API timeout" : "Permanent validation failure",
collected_invoice_id: hasMore ? 7301 : 7302,
attempts: hasMore ? 1 : 3,
max_attempts: 3,
created_at: "2026-04-08T11:00:00Z",
updated_at: "2026-04-08T11:00:30Z",
},
],
count: 1,
total: 11,
limit,
offset,
has_more: hasMore,
},
}));
});
await openQueueHistory(page, token);
await page.getByTestId("economic-queue-history-status-filter").selectOption("FAILED");
await page.getByTestId("economic-queue-history-limit-filter").selectOption("10");
await expect.poll(
() => queueRequests.some((request) => request.status === "FAILED" && request.limit === 10 && request.offset === 0),
{ timeout: 8_000 },
).toBeTruthy();
await expect(page.getByTestId("economic-queue-history-summary")).toContainText("More results available.");
await page.getByTestId("economic-queue-history-next-page").click();
await expect.poll(
() => queueRequests.some((request) => request.status === "FAILED" && request.limit === 10 && request.offset === 10),
{ timeout: 8_000 },
).toBeTruthy();
await expect(page.getByTestId("economic-queue-history-summary")).not.toContainText("More results available.");
await expect(page.getByTestId("economic-queue-history-next-page")).toBeDisabled();
});
test("@smoke run queue now falls back when the backend route is ahead of the queue class deployment", async ({ page }) => {
let statusCalls = 0;
let queueListCalls = 0;
const token = await bootstrapAuthenticatedSuperuser(page);
await page.route("**/collected-invoices/economic/queue/run**", async (route) => {
if (route.request().method() !== "POST" || !isQueuePath(route.request().url(), QUEUE_RUN_PATH)) {
await route.fallback();
return;
}
await route.fulfill(json({
message: "Internal server error: Call to undefined method classes\\economic_transfer_queue::processPendingByTransferType()",
}, 500));
});
await page.route("**/collected-invoices/economic/queue/status**", async (route) => {
if (route.request().method() !== "GET" || !isQueuePath(route.request().url(), QUEUE_STATUS_PATH)) {
await route.fallback();
return;
}
statusCalls += 1;
const url = new URL(route.request().url());
expect(url.searchParams.get("job_id")).toBe("9351");
await route.fulfill(json({
data: {
id: 9351,
status: "PROCESSING",
progress_percent: 45,
progress_message: "Legacy worker tick",
},
}));
});
await page.route("**/collected-invoices/economic/queue**", async (route) => {
if (route.request().method() !== "GET" || !isQueuePath(route.request().url(), QUEUE_LIST_PATH)) {
await route.fallback();
return;
}
queueListCalls += 1;
await route.fulfill(json({
data: {
items: [
{
id: 9351,
status: queueListCalls === 1 ? "QUEUED" : "PROCESSING",
progress_percent: queueListCalls === 1 ? 0 : 45,
progress_message: queueListCalls === 1 ? "Queued" : "Legacy worker tick",
collected_invoice_id: 7351,
attempts: 0,
max_attempts: 3,
created_at: "2026-04-08T11:30:00Z",
updated_at: "2026-04-08T11:30:30Z",
},
],
count: 1,
total: 1,
limit: 50,
offset: 0,
has_more: false,
},
}));
});
await openQueueHistory(page, token);
await page.getByTestId("economic-queue-history-run-now").click();
await expect.poll(() => statusCalls, { timeout: 8_000 }).toBe(1);
await expect(page.getByTestId("economic-queue-history-run-summary")).toContainText("Legacy fallback active");
await expect(page.getByTestId("economic-queue-history-summary")).toContainText("Legacy fallback active while the backend queue runner is unavailable.");
await expect(page.getByTestId("economic-queue-history-error")).toHaveCount(0);
await expect(page.getByTestId("economic-queue-history-status-9351")).toContainText("PROCESSING");
});
test("@smoke failed-job retry succeeds and non-failed retry stays unavailable", async ({ page }) => {
let retryCalls = 0; let retryCalls = 0;
let retryTriggered = false; let retryTriggered = false;
const token = await bootstrapAuthenticatedSuperuser(page); const token = await bootstrapAuthenticatedSuperuser(page);
await page.route("**/collected-invoices/economic/queue/retry**", async (route) => { await page.route("**/collected-invoices/economic/queue/retry**", async (route) => {
if (route.request().method() !== "POST") { if (route.request().method() !== "POST" || !isQueuePath(route.request().url(), QUEUE_RETRY_PATH)) {
await route.fallback(); await route.fallback();
return; return;
} }
const payload = route.request().postDataJSON();
expect(payload).toEqual({ job_id: 9402 });
retryCalls += 1; retryCalls += 1;
expect(route.request().postDataJSON()).toEqual({ job_id: 9402 });
retryTriggered = true; retryTriggered = true;
await route.fulfill(json({ await route.fulfill(json({
@@ -135,110 +281,286 @@ test.describe("Invoice transfer queue history page", () => {
progress_message: "Retry queued", progress_message: "Retry queued",
}, },
}, },
}, 200));
});
await page.route("**/collected-invoices/economic/queue**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
const url = new URL(route.request().url());
if (!(url.pathname === "/collected-invoices/economic/queue" || url.pathname === "/api/collected-invoices/economic/queue")) {
await route.fallback();
return;
}
const status = url.searchParams.get("status");
if (status === "FAILED" && !retryTriggered) {
await route.fulfill(json({
data: {
items: [
{
id: 9402,
status: "FAILED",
progress_percent: 35,
progress_message: "Failed",
error_message: "no billable order items after zero-price/zero-quantity skips",
collected_invoice_id: 7120,
},
],
count: 1,
},
}));
return;
}
await route.fulfill(json({
data: {
items: [],
count: 0,
},
})); }));
}); });
await openQueueHistory(page, token);
await page.getByTestId("economic-queue-history-status-filter").selectOption("FAILED");
await expect(page.getByTestId("economic-queue-history-row-9402")).toBeVisible();
await expect(page.getByTestId("economic-queue-history-row-9402")).toContainText("no billable order items after zero-price/zero-quantity skips");
await page.getByTestId("economic-queue-history-retry-9402").click();
await expect.poll(() => retryCalls).toBe(1);
await expect(page.getByTestId("economic-queue-history-empty")).toBeVisible();
});
test("@smoke queue tab applies status, limit and offset filters to list endpoint", async ({ page }) => {
const queueRequests = [];
const token = await bootstrapAuthenticatedSuperuser(page);
await page.route("**/collected-invoices/economic/queue**", async (route) => { await page.route("**/collected-invoices/economic/queue**", async (route) => {
if (route.request().method() !== "GET") { if (route.request().method() !== "GET" || !isQueuePath(route.request().url(), QUEUE_LIST_PATH)) {
await route.fallback(); await route.fallback();
return; return;
} }
const url = new URL(route.request().url());
if (!(url.pathname === "/collected-invoices/economic/queue" || url.pathname === "/api/collected-invoices/economic/queue")) {
await route.fallback();
return;
}
queueRequests.push({
status: url.searchParams.get("status"),
limit: url.searchParams.get("limit"),
offset: url.searchParams.get("offset"),
});
await route.fulfill(json({ await route.fulfill(json({
data: { data: {
items: [ items: [
{ {
id: 9501, id: 9401,
status: "COMPLETED", status: "COMPLETED",
progress_percent: 100, progress_percent: 100,
progress_message: "Completed", progress_message: "Completed",
result: { collected_invoice_id: 7401,
message: "Transfer completed.", attempts: 1,
}, max_attempts: 3,
collected_invoice_id: 7301, created_at: "2026-04-08T12:00:00Z",
updated_at: "2026-04-08T12:01:00Z",
},
{
id: 9402,
status: retryTriggered ? "QUEUED" : "FAILED",
progress_percent: retryTriggered ? 0 : 40,
progress_message: retryTriggered ? "Retry queued" : "Failed",
error_message: retryTriggered ? "" : "Temporary upstream issue",
collected_invoice_id: 7402,
attempts: retryTriggered ? 1 : 1,
max_attempts: 3,
created_at: "2026-04-08T12:00:00Z",
updated_at: "2026-04-08T12:01:00Z",
},
{
id: 9403,
status: "FAILED",
progress_percent: 100,
progress_message: "Failed",
error_message: "Max attempts reached",
collected_invoice_id: 7403,
attempts: 3,
max_attempts: 3,
created_at: "2026-04-08T12:00:00Z",
updated_at: "2026-04-08T12:01:00Z",
}, },
], ],
count: 40, count: 3,
total: 3,
limit: 50,
offset: 0,
has_more: false,
}, },
})); }));
}); });
await openQueueHistory(page, token); await openQueueHistory(page, token);
await page.getByTestId("economic-queue-history-status-filter").selectOption("COMPLETED"); await expect(page.getByTestId("economic-queue-history-retry-9401")).toHaveCount(0);
await page.getByTestId("economic-queue-history-limit-filter").selectOption("10"); await expect(page.getByTestId("economic-queue-history-retry-9403")).toBeDisabled();
await page.getByTestId("economic-queue-history-next-page").click();
await expect.poll(() => queueRequests.some((request) => request.status === "COMPLETED")).toBeTruthy(); await page.getByTestId("economic-queue-history-retry-9402").click();
await expect.poll(() => queueRequests.some((request) => request.limit === "10")).toBeTruthy(); await expect.poll(() => retryCalls, { timeout: 8_000 }).toBe(1);
await expect.poll(() => queueRequests.some((request) => request.limit === "10" && request.offset === "10")).toBeTruthy(); await expect(page.getByTestId("economic-queue-history-status-9402")).toContainText("QUEUED");
await expect(page.getByTestId("economic-queue-history-row-9501")).toBeVisible(); await expect(page.getByTestId("economic-queue-history-retry-9402")).toHaveCount(0);
});
test("@smoke polling transport error is surfaced and recovery succeeds", async ({ page }) => {
let queueListCalls = 0;
const token = await bootstrapAuthenticatedSuperuser(page);
await page.route("**/collected-invoices/economic/queue**", async (route) => {
if (route.request().method() !== "GET" || !isQueuePath(route.request().url(), QUEUE_LIST_PATH)) {
await route.fallback();
return;
}
queueListCalls += 1;
if (queueListCalls >= 2 && queueListCalls <= 4) {
await route.fulfill(json({
data: {
message: "gateway timeout",
},
}, 503));
return;
}
const isTerminal = queueListCalls >= 6;
await route.fulfill(json({
data: {
items: [
{
id: 9601,
status: isTerminal ? "COMPLETED" : "PROCESSING",
progress_percent: isTerminal ? 100 : 60,
progress_message: isTerminal ? "Completed" : "Retrying worker connection",
collected_invoice_id: 7601,
attempts: 1,
max_attempts: 3,
created_at: "2026-04-08T13:00:00Z",
updated_at: "2026-04-08T13:00:30Z",
},
],
count: 1,
total: 1,
limit: 50,
offset: 0,
has_more: false,
},
}));
});
await openQueueHistory(page, token);
await expect(page.getByTestId("economic-queue-history-status-9601")).toContainText("PROCESSING");
await expect(page.getByTestId("economic-queue-history-poll-error")).toContainText("gateway timeout", {
timeout: 12_000,
});
await page.getByTestId("economic-queue-history-refresh").click();
await expect(page.getByTestId("economic-queue-history-poll-error")).toHaveCount(0);
await expect.poll(async () => page.getByTestId("economic-queue-history-status-9601").innerText(), {
timeout: 12_000,
}).toContain("COMPLETED");
await expect(page.getByTestId("economic-queue-history-error")).toHaveCount(0);
});
test("@smoke slow responses do not create overlapping polling requests", async ({ page }) => {
let queueListCalls = 0;
let inFlight = 0;
let maxInFlight = 0;
const token = await bootstrapAuthenticatedSuperuser(page);
await page.route("**/collected-invoices/economic/queue**", async (route) => {
if (route.request().method() !== "GET" || !isQueuePath(route.request().url(), QUEUE_LIST_PATH)) {
await route.fallback();
return;
}
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
queueListCalls += 1;
const callNumber = queueListCalls;
try {
await new Promise((resolve) => setTimeout(resolve, 700));
const isTerminal = callNumber >= 3;
await route.fulfill(json({
data: {
items: [
{
id: 9701,
status: isTerminal ? "COMPLETED" : "PROCESSING",
progress_percent: isTerminal ? 100 : 55,
progress_message: isTerminal ? "Completed" : "Working",
collected_invoice_id: 7701,
attempts: 1,
max_attempts: 3,
created_at: "2026-04-08T14:00:00Z",
updated_at: "2026-04-08T14:00:30Z",
},
],
count: 1,
total: 1,
limit: 50,
offset: 0,
has_more: false,
},
}));
} finally {
inFlight -= 1;
}
});
await openQueueHistory(page, token);
await expect.poll(() => queueListCalls, { timeout: 20_000 }).toBeGreaterThanOrEqual(3);
expect(maxInFlight).toBe(1);
await expect(page.getByTestId("economic-queue-history-status-9701")).toContainText("COMPLETED");
});
test("@smoke mobile layout parity keeps queue controls and actions accessible", async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("mobile"), "Mobile-specific layout assertions");
const token = await bootstrapAuthenticatedSuperuser(page);
await page.route("**/collected-invoices/economic/queue**", async (route) => {
if (route.request().method() !== "GET" || !isQueuePath(route.request().url(), QUEUE_LIST_PATH)) {
await route.fallback();
return;
}
await route.fulfill(json({
data: {
items: [
{
id: 9801,
status: "FAILED",
progress_percent: 100,
progress_message: "Failed",
error_message: "Retry available",
collected_invoice_id: 7801,
attempts: 1,
max_attempts: 3,
created_at: "2026-04-08T15:00:00Z",
updated_at: "2026-04-08T15:00:30Z",
details_summary: {
message: "Queue failure details",
target: {
collected_invoice_id: 7801,
send_as_is: false,
requested_by: 7,
},
customer: {
customer_number: 43425425,
name: "Carrier A/S",
},
outcome: {
status: "FAILED",
error_message: "Retry available",
economic_invoice_draft_id: null,
economic_invoice_booked_id: null,
external_id: null,
total_net_amount: null,
order_count: 0,
},
transfer: {
transfer_type: "COLLECTED_INVOICE_EXPORT",
status: "FAILED",
progress_percent: 100,
progress_message: "Failed",
attempts: 1,
max_attempts: 3,
},
technical: {
job_id: 9801,
created_by: 7,
created_at: "2026-04-08T15:00:00Z",
updated_at: "2026-04-08T15:00:30Z",
raw_available: {
payload: true,
result: true,
},
raw: {
payload: {
collected_invoice_id: 7801,
},
result: null,
},
},
raw_available: {
payload: true,
result: true,
},
},
},
],
count: 1,
total: 1,
limit: 50,
offset: 0,
has_more: false,
},
}));
});
await openQueueHistory(page, token);
await expect(page.getByTestId("economic-queue-history-run-now")).toBeVisible();
await expect(page.getByTestId("economic-queue-history-refresh")).toBeVisible();
await expect(page.getByTestId("economic-queue-history-prev-page")).toBeVisible();
await expect(page.getByTestId("economic-queue-history-next-page")).toBeVisible();
await expect(page.getByTestId("economic-queue-history-retry-9801")).toBeVisible();
await page.getByTestId("economic-queue-history-details-9801").click();
await expect(page.getByTestId("economic-queue-history-details-modal")).toBeVisible();
await expect(page.getByTestId("economic-queue-history-details-section-overfoersel")).toContainText("FAILED");
await page.getByTestId("economic-queue-history-details-close").click();
await expect(page.getByTestId("economic-queue-history-details-modal")).toHaveCount(0);
}); });
}); });
+205 -5
View File
@@ -9,16 +9,26 @@ function json(body, status = 200) {
}; };
} }
function matchesApiPath(urlString, expectedPath) {
const url = new URL(urlString);
return url.pathname === expectedPath || url.pathname === `/api${expectedPath}`;
}
async function suppressVueDevtoolsOverlay(page) { async function suppressVueDevtoolsOverlay(page) {
await page.addInitScript(() => { await page.addInitScript(() => {
const STYLE_ID = "__e2e-hide-vue-devtools"; const STYLE_ID = "__e2e-hide-vue-devtools";
const apply = () => { const apply = () => {
const target = document.head || document.documentElement;
if (!target) {
return;
}
if (!document.getElementById(STYLE_ID)) { if (!document.getElementById(STYLE_ID)) {
const style = document.createElement("style"); const style = document.createElement("style");
style.id = STYLE_ID; style.id = STYLE_ID;
style.textContent = "#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }"; style.textContent = "#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }";
(document.head || document.documentElement).appendChild(style); target.appendChild(style);
} }
const container = document.getElementById("__vue-devtools-container__"); const container = document.getElementById("__vue-devtools-container__");
@@ -46,6 +56,12 @@ function createPeriodPayload() {
transactions: [ transactions: [
{ id: 9001, date: "2026-03-10T10:00:00.000Z", amount: 120, booked: false, excluded: false }, { id: 9001, date: "2026-03-10T10:00:00.000Z", amount: 120, booked: false, excluded: false },
], ],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: { meta: {
fixed_pricing: { fixed_pricing: {
price: 500, price: 500,
@@ -60,6 +76,12 @@ function createPeriodPayload() {
transactions: [ transactions: [
{ id: 9002, date: "2026-03-11T10:00:00.000Z", amount: 80, booked: true, excluded: false }, { id: 9002, date: "2026-03-11T10:00:00.000Z", amount: 80, booked: true, excluded: false },
], ],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {}, meta: {},
}, },
], ],
@@ -72,6 +94,12 @@ function createPeriodPayload() {
transactions: [ transactions: [
{ id: 9003, date: "2026-03-12T10:00:00.000Z", amount: 75, booked: false, excluded: false }, { id: 9003, date: "2026-03-12T10:00:00.000Z", amount: 75, booked: false, excluded: false },
], ],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {}, meta: {},
}, },
], ],
@@ -82,6 +110,12 @@ function createPeriodPayload() {
customer_name: "Acme Fleet", customer_name: "Acme Fleet",
requires_action: true, requires_action: true,
transactions: [], transactions: [],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: { meta: {
fixed_pricing: { fixed_pricing: {
price: 500, price: 500,
@@ -100,6 +134,12 @@ function createPeriodPayload() {
transactions: [ transactions: [
{ id: 9004, date: "2026-03-13T10:00:00.000Z", amount: 60, booked: false, excluded: false }, { id: 9004, date: "2026-03-13T10:00:00.000Z", amount: 60, booked: false, excluded: false },
], ],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {}, meta: {},
}, },
], ],
@@ -120,6 +160,12 @@ function createPeriodPayloadForChangedRange() {
transactions: [ transactions: [
{ id: 9101, date: "2026-04-10T10:00:00.000Z", amount: 250, booked: false, excluded: false }, { id: 9101, date: "2026-04-10T10:00:00.000Z", amount: 250, booked: false, excluded: false },
], ],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {}, meta: {},
}, },
], ],
@@ -132,6 +178,12 @@ function createPeriodPayloadForChangedRange() {
transactions: [ transactions: [
{ id: 9102, date: "2026-04-11T11:00:00.000Z", amount: 150, booked: false, excluded: false }, { id: 9102, date: "2026-04-11T11:00:00.000Z", amount: 150, booked: false, excluded: false },
], ],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {}, meta: {},
}, },
], ],
@@ -144,6 +196,46 @@ function createPeriodPayloadForChangedRange() {
}; };
} }
function createQueuedPeriodPayload() {
return {
types: {
all: [
{
id: 31,
customer_number: 6001,
customer_name: "Queued Fleet",
requires_action: false,
transactions: [
{
id: 9201,
date: "2026-04-14T10:00:00.000Z",
amount: 210,
booked: false,
excluded: false,
invoice_collection_id: 14578,
queue_status: "QUEUED",
queue_job_id: 88,
},
],
queue: {
has_active_job: true,
statuses: ["QUEUED"],
invoice_collection_ids: [14578],
is_action_blocked: true,
},
meta: {},
},
],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
};
}
async function setupPeriodEndpoints(page, requests) { async function setupPeriodEndpoints(page, requests) {
let initialRange = null; let initialRange = null;
@@ -154,6 +246,10 @@ async function setupPeriodEndpoints(page, requests) {
} }
const url = new URL(route.request().url()); const url = new URL(route.request().url());
if (!matchesApiPath(route.request().url(), "/superuser/invoicing/period")) {
await route.fallback();
return;
}
const dateFrom = url.searchParams.get("dateFrom"); const dateFrom = url.searchParams.get("dateFrom");
const dateTo = url.searchParams.get("dateTo"); const dateTo = url.searchParams.get("dateTo");
requests.push({ requests.push({
@@ -257,19 +353,42 @@ async function openPeriodView(page) {
authenticated: true, authenticated: true,
permissions: ["superuser", "user"], permissions: ["superuser", "user"],
loginToken: token, loginToken: token,
invoiceDistribution: true,
}); });
await setupPeriodEndpoints(page, periodRequests); await setupPeriodEndpoints(page, periodRequests);
await page.goto("/");
await page.evaluate((value) => {
window.localStorage.setItem("token", value);
}, token);
await page.goto("/superuser/invoices?activeTab=period"); await page.goto("/superuser/invoices?activeTab=period");
await expect(page).toHaveURL(/activeTab=period/); await expect(page).toHaveURL(/activeTab=period/);
await expect(page.getByTestId("invoicing-period-view")).toBeVisible(); await expect(page.getByTestId("invoicing-period-view")).toBeVisible();
return { periodRequests }; return { periodRequests };
} }
async function seedInvoicesPage(page, token = "superuser-period-e2e-token") {
await suppressVueDevtoolsOverlay(page);
await seedAuthenticatedState(page, token);
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
loginToken: token,
invoiceDistribution: true,
});
}
test.describe("Invoicing period tab", () => { test.describe("Invoicing period tab", () => {
test("@smoke period view does not throw queue refresh errors on load", async ({ page }) => {
const pageErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
await openPeriodView(page);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible();
await page.waitForTimeout(250);
expect(pageErrors).not.toEqual(expect.arrayContaining([
expect.stringContaining("reading 'value'"),
]));
});
test("@smoke period view loads selectors and displays all-customer list", async ({ page }) => { test("@smoke period view loads selectors and displays all-customer list", async ({ page }) => {
const { periodRequests } = await openPeriodView(page); const { periodRequests } = await openPeriodView(page);
@@ -329,13 +448,94 @@ test.describe("Invoicing period tab", () => {
test("@smoke period view reload button triggers a fresh period query", async ({ page }, testInfo) => { test("@smoke period view reload button triggers a fresh period query", async ({ page }, testInfo) => {
test.skip(/mobile/i.test(testInfo.project.name), "Reload button is not rendered in mobile date selector layout."); test.skip(/mobile/i.test(testInfo.project.name), "Reload button is not rendered in mobile date selector layout.");
const pageErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
const { periodRequests } = await openPeriodView(page); const { periodRequests } = await openPeriodView(page);
await page.getByTestId("invoicing-period-view-selector-all").click(); await page.getByTestId("invoicing-period-view-selector-all").click();
const initialRequestCount = periodRequests.length; const initialRequestCount = periodRequests.length;
await page.getByTestId("invoicing-period-reload-button").click(); await page.getByTestId("invoicing-period-reload-button").click();
await expect.poll(() => periodRequests.length).toBeGreaterThan(initialRequestCount); await expect.poll(() => periodRequests.length).toBeGreaterThan(initialRequestCount);
await page.waitForTimeout(250);
await expect(page.getByTestId("invoicing-period-view-all")).toHaveAttribute("data-current-view", "all"); await expect(page.getByTestId("invoicing-period-view-all")).toHaveAttribute("data-current-view", "all");
await expect(page).toHaveURL(/activeTab=period/); await expect(page).toHaveURL(/activeTab=period/);
expect(pageErrors).not.toEqual(expect.arrayContaining([
expect.stringContaining("reading 'types'"),
]));
});
test("@smoke period view shows a queued CTA when backend queue metadata blocks invoicing", async ({ page }) => {
const periodRequests = [];
await seedInvoicesPage(page);
await setupPeriodEndpoints(page, periodRequests);
await page.route("**/superuser/invoicing/period**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
const url = new URL(route.request().url());
periodRequests.push({
dateFrom: url.searchParams.get("dateFrom"),
dateTo: url.searchParams.get("dateTo"),
});
await route.fulfill(json({
data: createQueuedPeriodPayload(),
}));
});
await page.goto("/superuser/invoices?activeTab=period");
await expect(page).toHaveURL(/activeTab=period/);
await expect(page.getByTestId("invoicing-period-view")).toBeVisible();
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-6001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-queue-6001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-queue-6001")).toBeDisabled();
await expect(page.getByTestId("invoicing-period-customer-invoice-6001")).toHaveCount(0);
expect(periodRequests.length).toBeGreaterThan(0);
});
test("@smoke period view refreshes queue state when the Period route becomes active again", async ({ page }) => {
const periodRequests = [];
let requestCount = 0;
let returnQueuedPayload = false;
await seedInvoicesPage(page);
await setupPeriodEndpoints(page, periodRequests);
await page.route("**/superuser/invoicing/period**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
requestCount += 1;
const url = new URL(route.request().url());
periodRequests.push({
dateFrom: url.searchParams.get("dateFrom"),
dateTo: url.searchParams.get("dateTo"),
});
await route.fulfill(json({
data: returnQueuedPayload ? createQueuedPeriodPayload() : createPeriodPayload(),
}));
});
await page.goto("/superuser/invoices?activeTab=overview");
await expect(page).toHaveURL(/activeTab=overview/);
returnQueuedPayload = true;
await page.goto("/superuser/invoices?activeTab=period");
await expect(page).toHaveURL(/activeTab=period/);
await expect.poll(() => requestCount).toBeGreaterThan(0);
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-queue-6001")).toBeVisible();
}); });
}); });
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18n } from "vue-i18n"; import { createI18n } from "vue-i18n";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue"; import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const getUserIdMock = vi.hoisted(() => const getUserIdMock = vi.hoisted(() =>
vi.fn(() => Promise.resolve({ data: { data: { user_id: 777 } } })) vi.fn(() => Promise.resolve({ data: { data: { user_id: 777 } } }))
@@ -128,6 +129,7 @@ const flushMicrotasks = async () => {
describe("ActionSettingsWheelButton", () => { describe("ActionSettingsWheelButton", () => {
beforeEach(() => { beforeEach(() => {
getUserIdMock.mockClear(); getUserIdMock.mockClear();
SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm.mockClear();
}); });
it("resolves customer user id once on mount and not on unrelated rerenders", async () => { it("resolves customer user id once on mount and not on unrelated rerenders", async () => {
@@ -181,4 +183,80 @@ describe("ActionSettingsWheelButton", () => {
await flushMicrotasks(); await flushMicrotasks();
expect(getUserIdMock).not.toHaveBeenCalled(); expect(getUserIdMock).not.toHaveBeenCalled();
}); });
it("routes 'change invoice collection' action without opening a new tab", async () => {
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
const wrapper = mount(ActionSettingsWheelButton, {
props: {
order_id: 42,
invoice_collection_id: 991,
},
slots: {
actions: "",
},
global: {
plugins: [i18n],
stubs: {
CustomerModal: { template: "<div />" },
},
},
});
await flushMicrotasks();
await wrapper.find(".dropdown-trigger button").trigger("click");
const buttons = wrapper.findAll("button.dropdown-item-action");
const changeInvoiceCollectionButton = buttons.find((buttonWrapper) =>
buttonWrapper.text().includes("admin.pos.settings_wheel.change_invoice_collection")
);
expect(changeInvoiceCollectionButton).toBeTruthy();
await changeInvoiceCollectionButton.trigger("click");
await flushMicrotasks();
expect(SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm).toHaveBeenCalledTimes(1);
expect(SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm).toHaveBeenCalledWith(42, expect.any(Function));
expect(windowOpenSpy).not.toHaveBeenCalled();
windowOpenSpy.mockRestore();
});
it("routes 'view invoice collection in new tab' action to window.open only", async () => {
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
const wrapper = mount(ActionSettingsWheelButton, {
props: {
order_id: 42,
invoice_collection_id: 777,
},
slots: {
actions: "",
},
global: {
plugins: [i18n],
stubs: {
CustomerModal: { template: "<div />" },
},
},
});
await flushMicrotasks();
SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm.mockClear();
await wrapper.find(".dropdown-trigger button").trigger("click");
const buttons = wrapper.findAll("button.dropdown-item-action");
const viewInvoiceCollectionButton = buttons.find((buttonWrapper) =>
buttonWrapper.text().includes("admin.pos.settings_wheel.view_invoice_collection_new_tab")
);
expect(viewInvoiceCollectionButton).toBeTruthy();
await viewInvoiceCollectionButton.trigger("click");
await flushMicrotasks();
expect(windowOpenSpy).toHaveBeenCalledWith("/superuser/invoices/777", "_blank");
expect(SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm).not.toHaveBeenCalled();
windowOpenSpy.mockRestore();
});
}); });
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import { mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { nextTick } from 'vue';
const mocks = vi.hoisted(() => ({
showEditObjectFieldForm: vi.fn(),
}));
vi.mock('@/components/session/token/SessionUser.vue', () => ({
SessionUser: {
objects: {
collectedOrderInvoices: {
showEditObjectFieldForm: mocks.showEditObjectFieldForm,
},
global: {
language: {
no_data: 'Ingen data',
},
},
},
functions: {
currency: {
toLocal: (value) => `${value}`,
},
},
},
}));
import CollectedOrderInvoiceOverview from '@/views/dashboards/superUserDashboard/collectedOrderInvoice/displays/collectedOrderInvoiceOverview.vue';
describe('CollectedOrderInvoiceOverview', () => {
beforeEach(() => {
mocks.showEditObjectFieldForm.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('triggers closed_at edit flow and emits updated', async () => {
mocks.showEditObjectFieldForm.mockImplementation(async (id, column, value, onAfterSubmit) => {
onAfterSubmit?.();
return {};
});
const wrapper = mount(CollectedOrderInvoiceOverview, {
props: {
invoices: [{ id: 1 }, { id: 2 }],
total: 2,
collectedOrderInvoice: {
id: 14560,
total_net_amount: 9894,
closed_at: '2026-04-08 11:43:01',
external_id: 'X-100',
po_number: null,
},
},
});
await wrapper.get('[data-test="edit-closed-at-button"]').trigger('click');
await nextTick();
expect(mocks.showEditObjectFieldForm).toHaveBeenCalledTimes(1);
expect(mocks.showEditObjectFieldForm).toHaveBeenCalledWith(
14560,
'closed_at',
'2026-04-08 11:43:01',
expect.any(Function)
);
expect(wrapper.emitted('updated')).toBeTruthy();
expect(wrapper.emitted('updated').length).toBe(1);
});
});
@@ -0,0 +1,87 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const root = process.cwd();
const source = readFileSync(
join(
root,
"src/views/dashboards/superUserDashboard/collectedOrderInvoices/CollectedOrderInvoicesQueueHistory.vue",
),
"utf8",
);
describe("collected invoice queue history reliability contract", () => {
it("uses server-driven pagination metadata for next-page behavior", () => {
expect(source).toContain("parseEconomicTransferQueueListResponse");
expect(source).toContain("const hasMore = ref(false);");
expect(source).toContain("const canGoToNext = computed(() => hasMore.value);");
expect(source).toContain("hasMore.value = Boolean(parsed.hasMore);");
expect(source).toContain("More results available.");
});
it("keeps polling single-flight and stops on transport errors", () => {
expect(source).toContain("let loadPromise = null;");
expect(source).toContain("if (loadPromise) {");
expect(source).toContain("pollTimer = setTimeout(async () => {");
expect(source).not.toContain("setInterval(");
expect(source).toContain("await runQueueBatchWithFallback();");
expect(source).toContain("pollErrorMessage.value = `Polling paused due to a transport error:");
expect(source).toContain("stopPolling();");
});
it("kicks queue worker before refresh and polling status reads", () => {
expect(source).toContain("const runQueueBatchWithFallback = async () => {");
expect(source).toContain("if (hasActiveJobs.value) {");
expect(source).toContain("await runQueueBatchWithFallback();");
expect(source).toContain("const run = await runQueueBatchWithFallback();");
});
it("shows richer diagnostics and guards retries by failed status and attempts", () => {
expect(source).toContain("attempts: toNonNegativeInt(rawJob?.attempts, 0)");
expect(source).toContain("max_attempts: toNonNegativeInt(rawJob?.max_attempts, 0)");
expect(source).toContain("Created: {{ formatDateTime(job.created_at) }}");
expect(source).toContain("Started: {{ formatDateTime(job.started_at) }}");
expect(source).toContain("Updated: {{ formatDateTime(job.updated_at) }}");
expect(source).toContain("Completed: {{ formatDateTime(job.completed_at) }}");
expect(source).toContain("if (job.status !== ECONOMIC_QUEUE_STATUS.FAILED) {");
expect(source).toContain("if (job.max_attempts > 0 && job.attempts >= job.max_attempts) {");
});
it("exposes a manual run-now action with loading and inline summary state", () => {
expect(source).toContain("const runningQueue = ref(false);");
expect(source).toContain("const manualRunUnavailable = ref(false);");
expect(source).toContain("const runSummary = ref(null);");
expect(source).toContain("const runSummaryTone = computed(() => {");
expect(source).toContain("const formatRunSummaryMessage = (summary) => {");
expect(source).toContain("const isManualRunEndpointMissing = (error) => {");
expect(source).toContain('message.includes("processpendingbytransfertype")');
expect(source).toContain("const runQueueViaLegacyFallback = async () => {");
expect(source).toContain("status: ECONOMIC_QUEUE_STATUS.QUEUED,");
expect(source).toContain("manualRunUnavailable.value = true;");
expect(source).toContain("const onRunQueueNow = async () => {");
expect(source).toContain("await SessionUser.objects.collectedOrderInvoices.functions.economic.queue.run();");
expect(source).toContain("await SessionUser.objects.collectedOrderInvoices.functions.economic.queue.status(queuedJob.id);");
expect(source).toContain("const run = await runQueueBatchWithFallback();");
expect(source).toContain("runSummary.value = run;");
expect(source).toContain("Legacy fallback active while the backend queue runner is unavailable.");
expect(source).toContain("data-testid=\"economic-queue-history-run-now\"");
expect(source).toContain("data-testid=\"economic-queue-history-run-summary\"");
expect(source).toContain(":disabled=\"loading || refreshing || runningQueue\"");
});
it("retains server details_summary, builds local fallback summaries, and renders details modal test ids", () => {
expect(source).toContain("details_summary: normalizeDetailsSummary(rawJob?.details_summary, rawJob)");
expect(source).toContain("const buildLocalFallbackSummary = (rawJob) => {");
expect(source).toContain("const normalizeDetailsSummary = (rawSummary, rawJob) => {");
expect(source).toContain("Se detaljer");
expect(source).toContain("data-testid=\"economic-queue-history-details-modal\"");
expect(source).toContain("data-testid=\"economic-queue-history-details-close\"");
expect(source).toContain("data-testid=\"economic-queue-history-details-section-udfald\"");
expect(source).toContain("data-testid=\"economic-queue-history-details-section-faktura\"");
expect(source).toContain("data-testid=\"economic-queue-history-details-section-overfoersel\"");
expect(source).toContain("data-testid=\"economic-queue-history-details-section-koersel\"");
expect(source).toContain("data-testid=\"economic-queue-history-details-section-teknisk\"");
expect(source).toContain("data-testid=\"economic-queue-history-details-technical-raw\"");
});
});
@@ -0,0 +1,31 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const root = process.cwd();
const stripeManageSource = readFileSync(
join(
root,
"src/views/dashboards/superUserDashboard/collectedOrderInvoice/displays/manage/collectedOrderInvoiceManageStripe.vue"
),
"utf8"
);
describe("collected invoice stripe queue wiring", () => {
it("uses shared queue composable with collected-invoice queue endpoints", () => {
expect(stripeManageSource).toContain("useEconomicQueueJob");
expect(stripeManageSource).toContain("enqueueEndpoint: \"/collected-invoices/stripe/book\"");
expect(stripeManageSource).toContain("statusEndpoint: \"/collected-invoices/economic/queue/status\"");
expect(stripeManageSource).toContain("retryEndpoint: \"/collected-invoices/economic/queue/retry\"");
expect(stripeManageSource).toContain("await stripeTransferQueue.enqueue({});");
expect(stripeManageSource).toContain("await stripeTransferQueue.retry();");
});
it("renders progress and retry states for queued transfers", () => {
expect(stripeManageSource).toContain("isStripeTransferQueuedOrProcessing");
expect(stripeManageSource).toContain("isStripeTransferFailed");
expect(stripeManageSource).toContain("data-testid=\"collected-stripe-progress\"");
expect(stripeManageSource).toContain("data-testid=\"collected-stripe-retry\"");
expect(stripeManageSource).toContain("data-testid=\"collected-stripe-book-invoice\"");
});
});
@@ -0,0 +1,163 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
swalFire: vi.fn(),
swalShowValidationMessage: vi.fn(),
swalClose: vi.fn(),
setColumn: vi.fn(),
showEditObjectFieldForm: vi.fn(),
authenticatedRequest: vi.fn(),
}));
vi.mock('sweetalert2', () => ({
default: {
fire: mocks.swalFire,
showValidationMessage: mocks.swalShowValidationMessage,
close: mocks.swalClose,
},
}));
vi.mock('@/components/session/authenticatedRequest.vue', () => ({
authenticatedRequest: mocks.authenticatedRequest,
}));
vi.mock('@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue', () => ({
ObjectsGlobal: {
set: {
column: mocks.setColumn,
},
showEditObjectFieldForm: mocks.showEditObjectFieldForm,
showCreateObjectForm: vi.fn(),
showDeleteObjectForm: vi.fn(),
add: { object: vi.fn() },
get: { objects: vi.fn(), object: vi.fn() },
delete: { object: vi.fn() },
language: {
field: () => 'Rediger arkiveret',
save: 'Gem',
cancel: 'Annuller',
clear: 'Ryd',
},
},
}));
vi.mock('@/components/session/token/SessionUser.vue', () => ({
SessionUser: {
functions: {
parseErrorMessage: vi.fn(() => 'error'),
redirectTo: { superUser: vi.fn() },
currency: { toLocal: (value) => String(value) },
},
objects: {
global: {
language: {
no_data: 'Ingen data',
},
},
},
},
}));
vi.mock('@/i18n', () => ({
default: {
global: {
t: (key) => key,
},
},
}));
vi.mock('@/services/economicTransferQueue.js', () => ({
buildCollectedInvoiceEconomicPayload: vi.fn(() => ({})),
enqueueEconomicTransferJob: vi.fn(),
fetchEconomicTransferJobStatus: vi.fn(),
retryEconomicTransferJob: vi.fn(),
}));
vi.mock('@/components/displays/modals/PickCustomerInvoiceCollectionModal.vue', () => ({
default: {
name: 'PickCustomerInvoiceCollectionModalStub',
template: '<div />',
},
}));
import { CollectedOrderInvoices } from '@/components/session/token/SessionUser/Objects/CollectedOrderInvoices.vue';
describe('CollectedOrderInvoices closed_at edit modal', () => {
beforeEach(() => {
document.body.innerHTML = '';
mocks.swalFire.mockReset();
mocks.swalShowValidationMessage.mockReset();
mocks.swalClose.mockReset();
mocks.setColumn.mockReset();
mocks.showEditObjectFieldForm.mockReset();
mocks.authenticatedRequest.mockReset();
});
it('opens dedicated closed_at modal, normalizes date value, and saves API date', async () => {
const onAfterSubmit = vi.fn();
mocks.setColumn.mockResolvedValue({});
mocks.swalFire.mockImplementation(async (options) => {
document.body.innerHTML = options.html;
options.didOpen?.();
const input = document.getElementById('collected-order-invoice-closed-at-input');
input.value = '2026-04-08';
await options.preConfirm();
return { isConfirmed: true };
});
await CollectedOrderInvoices.showEditObjectFieldForm(14560, 'closed_at', '2026-04-08 11:43:01', onAfterSubmit);
expect(mocks.swalFire).toHaveBeenCalledTimes(1);
const modalOptions = mocks.swalFire.mock.calls[0][0];
expect(modalOptions.html).toContain('type="date"');
expect(modalOptions.html).toContain('value="2026-04-08"');
expect(mocks.setColumn).toHaveBeenCalledTimes(1);
const setColumnArgs = mocks.setColumn.mock.calls[0];
expect(setColumnArgs[0]).toBe('/collected-invoices');
expect(setColumnArgs[1]).toBe(14560);
expect(setColumnArgs[2]).toBe('closed_at');
expect(setColumnArgs[3]).toBe('2026-04-08');
expect(onAfterSubmit).toHaveBeenCalledTimes(1);
});
it('supports clearing closed_at and saves null', async () => {
const onAfterSubmit = vi.fn();
mocks.setColumn.mockResolvedValue({});
mocks.swalFire.mockImplementation(async (options) => {
document.body.innerHTML = options.html;
options.didOpen?.();
const clearButton = document.getElementById('collected-order-invoice-closed-at-clear-button');
clearButton.click();
await options.preConfirm();
return { isConfirmed: true };
});
await CollectedOrderInvoices.showEditObjectFieldForm(14561, 'closed_at', '2026-04-08 11:43:01', onAfterSubmit);
expect(mocks.setColumn).toHaveBeenCalledTimes(1);
expect(mocks.setColumn.mock.calls[0][3]).toBeNull();
expect(onAfterSubmit).toHaveBeenCalledTimes(1);
expect(mocks.swalShowValidationMessage).not.toHaveBeenCalled();
});
it('falls back to generic object editor for non-closed_at columns', async () => {
const onAfterSubmit = vi.fn();
mocks.showEditObjectFieldForm.mockResolvedValue({});
await CollectedOrderInvoices.showEditObjectFieldForm(11, 'name', 'Invoice name', onAfterSubmit);
expect(mocks.showEditObjectFieldForm).toHaveBeenCalledTimes(1);
expect(mocks.showEditObjectFieldForm).toHaveBeenCalledWith(
CollectedOrderInvoices,
11,
'name',
'Invoice name',
onAfterSubmit
);
expect(mocks.swalFire).not.toHaveBeenCalled();
});
});
+434 -1
View File
@@ -1,6 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useEconomicQueueJob } from "@/composables/useEconomicQueueJob.js"; import { useEconomicQueueJob } from "@/composables/useEconomicQueueJob.js";
import { buildCollectedInvoiceEconomicPayload } from "@/services/economicTransferQueue.js"; import {
buildCollectedInvoiceEconomicPayload,
parseEconomicTransferQueueListResponse,
parseQueueJobFromEnqueueResponse,
parseQueueRunResponse,
parseQueueJobFromRetryResponse,
} from "@/services/economicTransferQueue.js";
const createQueueJobResponse = (job) => ({ const createQueueJobResponse = (job) => ({
data: { data: {
@@ -212,6 +218,32 @@ describe("economic transfer queue workflow", () => {
}); });
}); });
it("parses queue run responses with counts, jobs, and batch limit", () => {
const run = parseQueueRunResponse({
data: {
data: {
message: "Collected invoice queue batch processed",
processed: 2,
completed: 1,
failed: 1,
jobs: [4001, "4002", "invalid"],
limit: 10,
transfer_type: "COLLECTED_INVOICE_EXPORT",
},
},
});
expect(run).toEqual({
message: "Collected invoice queue batch processed",
processed: 2,
completed: 1,
failed: 1,
jobs: [4001, 4002],
limit: 10,
transfer_type: "COLLECTED_INVOICE_EXPORT",
});
});
it("stops polling after dispose (unmount/navigation cancellation)", async () => { it("stops polling after dispose (unmount/navigation cancellation)", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -265,4 +297,405 @@ describe("economic transfer queue workflow", () => {
expect(statusCallCount).toBe(1); expect(statusCallCount).toBe(1);
expect(queue.isPolling.value).toBe(false); expect(queue.isPolling.value).toBe(false);
}); });
it("accepts enqueue responses that only include data.job_id", async () => {
const requestFn = vi.fn();
requestFn.mockImplementation(async (endpoint, method, payload) => {
if (endpoint === "/collected-invoices/economic" && method === "POST") {
expect(payload).toEqual({
id: 55,
send_as_is: false,
});
return {
data: {
data: {
message: "Queued for transfer",
job_id: 4111,
job: {
status: "QUEUED",
progress_percent: 0,
progress_message: "Queued",
},
},
},
};
}
if (endpoint === "/collected-invoices/economic/queue/status" && method === "GET") {
expect(payload).toEqual({ job_id: 4111 });
return createQueueStatusResponse({
id: 4111,
status: "COMPLETED",
progress_percent: 100,
progress_message: "Completed",
result: {
message: "Transferred",
},
});
}
throw new Error(`Unexpected request ${method} ${endpoint}`);
});
const queue = useEconomicQueueJob({
enqueueEndpoint: "/collected-invoices/economic",
statusEndpoint: "/collected-invoices/economic/queue/status",
retryEndpoint: "/collected-invoices/economic/queue/retry",
requestFn,
pollIntervalMs: 250,
buildEnqueuePayload: (payload) => buildCollectedInvoiceEconomicPayload(payload),
});
await queue.enqueue({
id: 55,
send_as_is: false,
});
expect(queue.jobId.value).toBe(4111);
expect(queue.status.value).toBe("COMPLETED");
expect(queue.result.value).toEqual({
message: "Transferred",
});
});
it("accepts retry responses with job.job_id alias", () => {
const job = parseQueueJobFromRetryResponse({
data: {
data: {
job: {
job_id: "8122",
status: "QUEUED",
progress_percent: 0,
progress_message: "Retry queued",
},
},
},
});
expect(job).toEqual({
id: 8122,
job_id: "8122",
status: "QUEUED",
progress_percent: 0,
progress_message: "Retry queued",
error_message: null,
result: null,
});
});
it("still throws when enqueue response has no queue job id", () => {
expect(() => parseQueueJobFromEnqueueResponse({
data: {
data: {
message: "Queued",
job: {
status: "QUEUED",
progress_percent: 0,
},
},
},
})).toThrow("Missing queue job id in enqueue response.");
});
it("parses additive queue list metadata fields from list responses", () => {
const parsed = parseEconomicTransferQueueListResponse({
data: {
data: {
items: [
{
id: 7001,
status: "PROCESSING",
progress_percent: 30,
progress_message: "Exporting",
attempts: 1,
max_attempts: 3,
created_at: "2026-03-10T10:00:00Z",
},
],
count: 1,
total: 8,
limit: 1,
offset: 2,
has_more: true,
},
},
}, {
defaultLimit: 50,
defaultOffset: 0,
});
expect(parsed.items).toHaveLength(1);
expect(parsed.items[0]).toMatchObject({
id: 7001,
status: "PROCESSING",
attempts: 1,
max_attempts: 3,
});
expect(parsed.count).toBe(1);
expect(parsed.total).toBe(8);
expect(parsed.limit).toBe(1);
expect(parsed.offset).toBe(2);
expect(parsed.hasMore).toBe(true);
});
it("keeps additive details_summary payloads intact when parsing queue list responses", () => {
const parsed = parseEconomicTransferQueueListResponse({
data: {
data: {
items: [
{
id: 7401,
status: "COMPLETED",
progress_percent: 100,
details_summary: {
message: "Transferred",
target: {
collected_invoice_id: 501,
send_as_is: false,
requested_by: 7,
},
customer: {
customer_number: 43425425,
name: "Carrier A/S",
},
outcome: {
economic_invoice_draft_id: 91,
economic_invoice_booked_id: 92,
external_id: "ext-92",
total_net_amount: 670,
order_count: 3,
},
raw_available: {
payload: true,
result: true,
},
},
},
],
},
},
});
expect(parsed.items).toHaveLength(1);
expect(parsed.items[0].details_summary).toEqual({
message: "Transferred",
target: {
collected_invoice_id: 501,
send_as_is: false,
requested_by: 7,
},
customer: {
customer_number: 43425425,
name: "Carrier A/S",
},
outcome: {
economic_invoice_draft_id: 91,
economic_invoice_booked_id: 92,
external_id: "ext-92",
total_net_amount: 670,
order_count: 3,
},
raw_available: {
payload: true,
result: true,
},
});
});
it("derives hasMore deterministically when metadata flag is omitted", () => {
const parsed = parseEconomicTransferQueueListResponse({
data: {
data: {
items: [
{ id: 7101, status: "FAILED" },
{ id: 7102, status: "FAILED" },
],
count: 2,
total: 5,
limit: 2,
offset: 2,
},
},
});
expect(parsed.hasMore).toBe(true);
const tailPage = parseEconomicTransferQueueListResponse({
data: {
data: {
items: [
{ id: 7103, status: "COMPLETED" },
],
count: 1,
total: 5,
limit: 2,
offset: 4,
},
},
});
expect(tailPage.hasMore).toBe(false);
});
it("guards retry when current queue status is not FAILED", async () => {
const requestFn = vi.fn();
requestFn.mockImplementation(async (endpoint, method) => {
if (endpoint === "/economic/invoice/export" && method === "POST") {
return createQueueJobResponse({
id: 8201,
status: "QUEUED",
progress_percent: 0,
progress_message: "Queued",
});
}
if (endpoint === "/economic/invoice/export/status" && method === "GET") {
return createQueueStatusResponse({
id: 8201,
status: "COMPLETED",
progress_percent: 100,
progress_message: "Completed",
});
}
throw new Error(`Unexpected request ${method} ${endpoint}`);
});
const queue = useEconomicQueueJob({
enqueueEndpoint: "/economic/invoice/export",
statusEndpoint: "/economic/invoice/export/status",
retryEndpoint: "/economic/invoice/export/retry",
requestFn,
pollIntervalMs: 200,
buildEnqueuePayload: (payload) => payload,
});
await queue.enqueue({ order_id: 11 });
expect(queue.status.value).toBe("COMPLETED");
await expect(queue.retry()).rejects.toThrow("Retry is only allowed for failed queue jobs.");
});
it("guards retry when failed job has reached max attempts", async () => {
const requestFn = vi.fn();
requestFn.mockImplementation(async (endpoint, method) => {
if (endpoint === "/economic/invoice/export" && method === "POST") {
return createQueueJobResponse({
id: 8202,
status: "FAILED",
progress_percent: 100,
progress_message: "Failed",
error_message: "No line items",
attempts: 3,
max_attempts: 3,
});
}
throw new Error(`Unexpected request ${method} ${endpoint}`);
});
const queue = useEconomicQueueJob({
enqueueEndpoint: "/economic/invoice/export",
statusEndpoint: "/economic/invoice/export/status",
retryEndpoint: "/economic/invoice/export/retry",
requestFn,
pollIntervalMs: 200,
buildEnqueuePayload: (payload) => payload,
});
await queue.enqueue({ order_id: 12 });
expect(queue.status.value).toBe("FAILED");
expect(queue.canRetry.value).toBe(false);
await expect(queue.retry()).rejects.toThrow("Retry is not allowed because max attempts were reached.");
});
it("stops polling on transport error and supports a clean polling restart", async () => {
vi.useFakeTimers();
const requestFn = vi.fn();
let mode = "fail";
let statusCalls = 0;
requestFn.mockImplementation(async (endpoint, method) => {
if (endpoint === "/collected-invoices/economic" && method === "POST") {
return createQueueJobResponse({
id: mode === "fail" ? 9101 : 9102,
status: "QUEUED",
progress_percent: 0,
progress_message: "Queued",
});
}
if (endpoint === "/collected-invoices/economic/queue/status" && method === "GET") {
if (mode === "fail") {
throw {
response: {
data: {
data: {
message: "gateway timeout",
},
},
},
};
}
statusCalls += 1;
if (statusCalls === 1) {
return createQueueStatusResponse({
id: 9102,
status: "PROCESSING",
progress_percent: 55,
progress_message: "Working",
});
}
return createQueueStatusResponse({
id: 9102,
status: "COMPLETED",
progress_percent: 100,
progress_message: "Completed",
result: {
message: "Recovered",
},
});
}
throw new Error(`Unexpected request ${method} ${endpoint}`);
});
const queue = useEconomicQueueJob({
enqueueEndpoint: "/collected-invoices/economic",
statusEndpoint: "/collected-invoices/economic/queue/status",
retryEndpoint: "/collected-invoices/economic/queue/retry",
requestFn,
pollIntervalMs: 250,
buildEnqueuePayload: (payload) => buildCollectedInvoiceEconomicPayload(payload),
});
await expect(queue.enqueue({
id: 91,
send_as_is: false,
})).rejects.toBeDefined();
expect(queue.transportErrorMessage.value).toContain("gateway timeout");
expect(queue.isPolling.value).toBe(false);
mode = "success";
await queue.enqueue({
id: 92,
send_as_is: false,
});
expect(queue.transportErrorMessage.value).toBe("");
expect(queue.status.value).toBe("PROCESSING");
await vi.advanceTimersByTimeAsync(250);
expect(queue.status.value).toBe("COMPLETED");
expect(queue.result.value).toEqual({ message: "Recovered" });
});
}); });
@@ -0,0 +1,164 @@
// @vitest-environment jsdom
import { computed, nextTick } from "vue";
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
requestMock,
startDateRef,
endDateRef,
routeState,
periodRefreshSignalRef,
availableViewNamesRef,
currentViewRef,
sharedVariablesRef,
} = vi.hoisted(() => {
const { reactive, ref } = require("vue");
return {
requestMock: vi.fn(),
startDateRef: ref(new Date("2026-04-01T00:00:00.000Z")),
endDateRef: ref(new Date("2026-04-30T23:59:59.000Z")),
routeState: reactive({
query: {
activeTab: "period",
},
}),
periodRefreshSignalRef: ref(0),
availableViewNamesRef: ref({}),
currentViewRef: ref("all"),
sharedVariablesRef: ref({}),
};
});
vi.mock("vue-router", () => ({
useRoute: () => routeState,
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
request: requestMock,
objects: {
global: {
language: {
all: "All",
invoice_per_order: "Invoice per order",
fixed_price_arrangements: "Fixed pricing",
only_tank_cleaning: "Tank cleaning",
special_arrangements: "Special arrangements",
possible_duplicates: "Possible duplicates",
},
},
vehicles: {
columns: {
wash_subscription: {
label: "Vehicle subscriptions",
},
},
},
},
},
}));
vi.mock("@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportDates.vue", () => ({
dates: {
variables: {
start: startDateRef,
end: endDateRef,
},
computed: {
isEntireMonth: computed(() => true),
},
},
}));
vi.mock("@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportView.vue", () => ({
view: {
variables: {
sharedVariables: sharedVariablesRef,
currentView: currentViewRef,
availableViewNames: availableViewNamesRef,
},
},
}));
vi.mock("@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportInvoiceQueue.vue", () => ({
invoiceQueue: {
periodRefreshSignal: periodRefreshSignalRef,
},
}));
import Right from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/Right.vue";
const flushAll = async () => {
await nextTick();
await Promise.resolve();
await nextTick();
};
const mountRight = () =>
mount(Right, {
global: {
stubs: {
WhiteBox: { template: "<div><slot /></div>" },
ColorIndicator: { template: "<div />" },
},
},
});
describe("Invoicing period queue-driven refresh", () => {
beforeEach(() => {
requestMock.mockReset();
requestMock.mockResolvedValue({
data: {
data: {
types: {
all: [],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
},
},
});
routeState.query.activeTab = "period";
periodRefreshSignalRef.value = 0;
sharedVariablesRef.value = {};
availableViewNamesRef.value = {};
currentViewRef.value = "all";
});
it("refreshes the period payload when queue activity signals a backend rehydrate", async () => {
mountRight();
await flushAll();
expect(requestMock).toHaveBeenCalledTimes(1);
periodRefreshSignalRef.value += 1;
await flushAll();
expect(requestMock).toHaveBeenCalledTimes(2);
expect(requestMock).toHaveBeenLastCalledWith("/superuser/invoicing/period", "GET", {
dateFrom: "2026-04-01",
dateTo: "2026-04-30",
});
});
it("refreshes when the Period tab becomes active after being in another tab", async () => {
routeState.query.activeTab = "overview";
mountRight();
await flushAll();
const initialCallCount = requestMock.mock.calls.length;
expect(initialCallCount).toBeGreaterThanOrEqual(1);
routeState.query.activeTab = "period";
await flushAll();
expect(requestMock.mock.calls.length).toBeGreaterThan(initialCallCount);
});
});
@@ -0,0 +1,227 @@
// @vitest-environment jsdom
import { computed, nextTick } from "vue";
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
sharedVariablesRef,
currentViewRef,
startDateRef,
endDateRef,
queueRef,
inProgressRef,
} = vi.hoisted(() => {
const { ref } = require("vue");
return {
sharedVariablesRef: ref({ types: { all: [] } }),
currentViewRef: ref("all"),
startDateRef: ref(new Date("2026-04-01T00:00:00.000Z")),
endDateRef: ref(new Date("2026-04-30T23:59:59.000Z")),
queueRef: ref([]),
inProgressRef: ref([]),
};
});
vi.mock("@/services/economicTransferQueue.js", () => ({
ECONOMIC_QUEUE_STATUS: {
QUEUED: "QUEUED",
PROCESSING: "PROCESSING",
COMPLETED: "COMPLETED",
FAILED: "FAILED",
},
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
objects: {
global: {
language: {
processing: "Processing",
queued: "Queued",
all_booked: "All booked",
none: "None",
invoice_now: "Invoice now",
},
},
orders: {
meta: {
labels: {
single: "order",
multiple: "orders",
},
},
get: {
multiple: vi.fn().mockResolvedValue([]),
},
},
collectedOrderInvoices: {
functions: {
createVehicleSubscriptionInvoice: vi.fn(),
add_fixed_pricing: vi.fn(),
add_vehicle_subscriptions: vi.fn(),
},
},
vehicles: {
columns: {
wash_subscription: {
label: "Subscription",
},
},
},
},
functions: {
currency: {
toLocal: (value) => String(value),
},
},
},
}));
vi.mock("@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportView.vue", () => ({
view: {
variables: {
currentView: currentViewRef,
sharedVariables: sharedVariablesRef,
},
computed: {
componentName: computed(() => currentViewRef.value),
},
functions: {
filterExcluded: (transactions = []) => transactions.filter((transaction) => !transaction?.excluded),
getCustomerViewTotalNetAmount: (customer) => {
const transactions = Array.isArray(customer?.transactions) ? customer.transactions : [];
return transactions.reduce((sum, transaction) => sum + Number(transaction?.amount ?? 0), 0);
},
},
},
}));
vi.mock("@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportDates.vue", () => ({
dates: {
variables: {
start: startDateRef,
end: endDateRef,
},
},
}));
vi.mock("@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportInvoiceQueue.vue", () => ({
invoiceQueue: {
addInvoiceCollectionsToQueue: vi.fn(),
processInvoiceCollectionQueue: vi.fn(),
invoiceCollectionQueue: queueRef,
invoiceCollectionQueueInProgress: inProgressRef,
invoiceCollectionQueueFailed: require("vue").ref([]),
invoiceCollectionQueueSuccess: require("vue").ref([]),
},
}));
import InvoicingBillingPeriodViewAll from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue";
const mountView = () =>
mount(InvoicingBillingPeriodViewAll, {
global: {
stubs: {
InvoicingBillingPeriodStatistics: { template: "<div />" },
InvoicingBillingPeriodFilters: { template: "<div />" },
WhiteBox: { template: "<div><slot /></div>" },
ColorIndicator: { template: "<div><slot /></div>" },
ActionSettingsWheelButton: { template: "<div><slot name='actions' /></div>" },
InvoiceOrdersPagination: { template: "<div />" },
SmallCustomerActivityChart: { template: "<div />" },
InvoicingBillingPeriodCustomerAttributes: { template: "<div />" },
InvoicingBillingPeriodInvoiceProgressBar: { template: "<div />" },
},
},
});
describe("Invoicing period queue state", () => {
beforeEach(() => {
currentViewRef.value = "all";
queueRef.value = [];
inProgressRef.value = [];
sharedVariablesRef.value = {
types: {
all: [
{
id: 1,
customer_number: 1001,
customer_name: "Queued Customer",
requires_action: false,
queue: {
has_active_job: true,
statuses: ["QUEUED"],
invoice_collection_ids: [14578],
is_action_blocked: true,
},
transactions: [
{
id: 5001,
amount: 100,
booked: false,
excluded: false,
queue_status: "QUEUED",
queue_job_id: 9,
invoice_collection_id: 14578,
date: "2026-04-10T10:00:00.000Z",
},
],
},
{
id: 2,
customer_number: 1002,
customer_name: "Mixed Customer",
requires_action: true,
queue: {
has_active_job: true,
statuses: ["PROCESSING"],
invoice_collection_ids: [2001],
is_action_blocked: false,
},
transactions: [
{
id: 5002,
amount: 120,
booked: false,
excluded: false,
queue_status: "PROCESSING",
queue_job_id: 10,
invoice_collection_id: 2001,
date: "2026-04-11T10:00:00.000Z",
},
{
id: 5003,
amount: 80,
booked: false,
excluded: false,
queue_status: null,
queue_job_id: null,
invoice_collection_id: null,
date: "2026-04-12T10:00:00.000Z",
},
],
},
],
},
};
});
it("renders a disabled queued CTA when the backend marks the customer as action-blocked", async () => {
const wrapper = mountView();
await nextTick();
const queuedButton = wrapper.get("[data-testid='invoicing-period-customer-queue-1001']");
expect(queuedButton.text()).toContain("Queued");
expect(queuedButton.attributes("disabled")).toBeDefined();
expect(wrapper.find("[data-testid='invoicing-period-customer-invoice-1001']").exists()).toBe(false);
});
it("keeps the invoice action visible for mixed queued and still-unqueued customers", async () => {
const wrapper = mountView();
await nextTick();
expect(wrapper.find("[data-testid='invoicing-period-customer-queue-1002']").exists()).toBe(false);
expect(wrapper.get("[data-testid='invoicing-period-customer-invoice-1002']").text()).toContain("Invoice now");
});
});
@@ -0,0 +1,97 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const getCustomerIdMock = vi.hoisted(() => vi.fn());
const setInvoiceCollectionIdMock = vi.hoisted(() => vi.fn());
const showInvoiceCollectionPickerFormMock = vi.hoisted(() => vi.fn());
vi.mock("sweetalert2", () => ({
default: {
fire: vi.fn(() => Promise.resolve()),
close: vi.fn(),
},
}));
vi.mock("@/i18n", () => ({
default: {
global: {
t: (key) => key,
},
},
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
objects: {
orders: {
functions: {
get_customer_id: getCustomerIdMock,
},
set: {
invoice_collection_id: setInvoiceCollectionIdMock,
},
},
collectedOrderInvoices: {
functions: {
showInvoiceCollectionPickerForm: showInvoiceCollectionPickerFormMock,
},
},
},
request: vi.fn(() => Promise.resolve({ data: { data: {} } })),
functions: {
parseErrorMessage: vi.fn(() => "error"),
},
},
}));
import Swal from "sweetalert2";
import { Orders } from "@/components/session/token/SessionUser/Objects/Orders.vue";
describe("Orders.showChangeInvoiceCollectionForm", () => {
beforeEach(() => {
getCustomerIdMock.mockReset();
setInvoiceCollectionIdMock.mockReset();
showInvoiceCollectionPickerFormMock.mockReset();
Swal.fire.mockClear();
});
it("updates invoice collection and calls onAfterSubmit without page reload timing", async () => {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const onAfterSubmit = vi.fn(() => Promise.resolve());
getCustomerIdMock.mockResolvedValue(123456);
setInvoiceCollectionIdMock.mockResolvedValue({ data: { success: true } });
showInvoiceCollectionPickerFormMock.mockImplementation(async (_customerNumber, onSelected) => {
await onSelected(9001);
});
await Orders.functions.showChangeInvoiceCollectionForm(45, onAfterSubmit);
expect(getCustomerIdMock).toHaveBeenCalledWith(45);
expect(showInvoiceCollectionPickerFormMock).toHaveBeenCalledWith(123456, expect.any(Function));
expect(setInvoiceCollectionIdMock).toHaveBeenCalledWith(45, 9001);
expect(onAfterSubmit).toHaveBeenCalledTimes(1);
expect(Swal.fire).toHaveBeenCalledWith(expect.objectContaining({ icon: "success" }));
expect(setTimeoutSpy).not.toHaveBeenCalled();
setTimeoutSpy.mockRestore();
});
it("does not update invoice collection when no selection is made", async () => {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const onAfterSubmit = vi.fn(() => Promise.resolve());
getCustomerIdMock.mockResolvedValue(98765);
showInvoiceCollectionPickerFormMock.mockImplementation(async (_customerNumber, onSelected) => {
await onSelected(null);
});
await Orders.functions.showChangeInvoiceCollectionForm(88, onAfterSubmit);
expect(setInvoiceCollectionIdMock).not.toHaveBeenCalled();
expect(onAfterSubmit).not.toHaveBeenCalled();
expect(Swal.fire).toHaveBeenCalledWith(expect.objectContaining({ icon: "error" }));
expect(setTimeoutSpy).not.toHaveBeenCalled();
setTimeoutSpy.mockRestore();
});
});