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:
+241
-9
@@ -5996,10 +5996,10 @@ paths:
|
||||
post:
|
||||
tags:
|
||||
- Invoices
|
||||
summary: Queue transfer of collected invoice to e-conomic
|
||||
summary: Export collected invoice to e-conomic
|
||||
description: |
|
||||
Queues collected invoice transfer to e-conomic.
|
||||
Processing runs asynchronously and can be tracked through queue status endpoints.
|
||||
Exports collected invoice to e-conomic.
|
||||
Uses async queue when available, otherwise falls back to synchronous processing.
|
||||
operationId: queueCollectedInvoiceEconomicTransfer
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -6016,6 +6016,12 @@ paths:
|
||||
type: boolean
|
||||
default: false
|
||||
responses:
|
||||
'200':
|
||||
description: Collected invoice export processed synchronously (fallback)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EconomicTransferSynchronousFallbackResponse'
|
||||
'202':
|
||||
description: Collected invoice transfer queued
|
||||
content:
|
||||
@@ -6028,6 +6034,45 @@ paths:
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'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:
|
||||
get:
|
||||
tags:
|
||||
@@ -6039,9 +6084,14 @@ paths:
|
||||
in: query
|
||||
required: false
|
||||
description: Comma-separated queue statuses to filter by.
|
||||
style: form
|
||||
explode: false
|
||||
schema:
|
||||
type: string
|
||||
example: "QUEUED,FAILED"
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/EconomicTransferQueueStatus'
|
||||
uniqueItems: true
|
||||
example: [QUEUED, FAILED]
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
@@ -6067,6 +6117,7 @@ paths:
|
||||
'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' }
|
||||
|
||||
/collected-invoices/economic/queue/status:
|
||||
@@ -6093,6 +6144,7 @@ paths:
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/collected-invoices/economic/queue/retry:
|
||||
@@ -6120,9 +6172,42 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EconomicTransferQueueRetryResponse'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'409': { $ref: '#/components/responses/Conflict' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'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' }
|
||||
|
||||
/collected-invoices/economic/compare:
|
||||
@@ -7805,8 +7890,8 @@ paths:
|
||||
post:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Queue draft invoice export to e-conomic
|
||||
description: Queue a draft invoice export job for asynchronous processing.
|
||||
summary: Export draft invoice to e-conomic
|
||||
description: Exports draft invoice using queue processing when available, with synchronous fallback when queue dependencies are unavailable.
|
||||
operationId: queueDraftInvoiceExportToEconomic
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -7820,6 +7905,12 @@ paths:
|
||||
type: integer
|
||||
minimum: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Draft invoice export processed synchronously (fallback)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EconomicTransferSynchronousFallbackResponse'
|
||||
'202':
|
||||
description: Draft invoice export queued
|
||||
content:
|
||||
@@ -7856,6 +7947,7 @@ paths:
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/economic/invoice/draft/export/retry:
|
||||
@@ -7886,14 +7978,15 @@ paths:
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/economic/invoice/export:
|
||||
post:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Queue invoice export to e-conomic
|
||||
description: Queue booked invoice export job for asynchronous processing.
|
||||
summary: Export invoice to e-conomic
|
||||
description: Exports booked invoice using queue processing when available, with synchronous fallback when queue dependencies are unavailable.
|
||||
operationId: queueInvoiceExportToEconomic
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -7907,6 +8000,12 @@ paths:
|
||||
type: integer
|
||||
minimum: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Invoice export processed synchronously (fallback)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EconomicTransferSynchronousFallbackResponse'
|
||||
'202':
|
||||
description: Invoice export queued
|
||||
content:
|
||||
@@ -7943,6 +8042,7 @@ paths:
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/economic/invoice/export/retry:
|
||||
@@ -7973,6 +8073,7 @@ paths:
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
# Module - Stripe Endpoints
|
||||
@@ -11056,6 +11157,12 @@ components:
|
||||
application/json:
|
||||
schema:
|
||||
$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:
|
||||
description: Unauthorized - Invalid or missing authentication token
|
||||
content:
|
||||
@@ -11074,6 +11181,12 @@ components:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
ServiceUnavailable:
|
||||
description: Service unavailable - Required async queue dependencies are unavailable
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
InternalServerError:
|
||||
description: Internal server error
|
||||
content:
|
||||
@@ -12598,6 +12711,10 @@ components:
|
||||
type: object
|
||||
nullable: true
|
||||
additionalProperties: true
|
||||
details_summary:
|
||||
type: object
|
||||
nullable: true
|
||||
additionalProperties: true
|
||||
created_by:
|
||||
type: integer
|
||||
nullable: true
|
||||
@@ -12639,10 +12756,14 @@ components:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
job_id:
|
||||
type: integer
|
||||
minimum: 1
|
||||
job:
|
||||
$ref: '#/components/schemas/EconomicTransferQueueJob'
|
||||
required:
|
||||
- message
|
||||
- job_id
|
||||
- job
|
||||
meta:
|
||||
oneOf:
|
||||
@@ -12662,6 +12783,44 @@ components:
|
||||
- meta
|
||||
- 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:
|
||||
type: object
|
||||
properties:
|
||||
@@ -12720,6 +12879,64 @@ components:
|
||||
- meta
|
||||
- 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:
|
||||
type: object
|
||||
properties:
|
||||
@@ -12735,9 +12952,24 @@ components:
|
||||
count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
total:
|
||||
type: integer
|
||||
minimum: 0
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
offset:
|
||||
type: integer
|
||||
minimum: 0
|
||||
has_more:
|
||||
type: boolean
|
||||
required:
|
||||
- items
|
||||
- count
|
||||
- total
|
||||
- limit
|
||||
- offset
|
||||
- has_more
|
||||
meta:
|
||||
oneOf:
|
||||
- type: array
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import {defineProps, defineEmits, onMounted, computed, watch} from 'vue';
|
||||
import {defineProps, defineEmits, onMounted, onBeforeUnmount, computed, watch} from 'vue';
|
||||
import { useSlots } from "vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||
@@ -82,6 +82,37 @@ const props = defineProps({
|
||||
const customerModalVisible = ref(false);
|
||||
const userIdFromCustomerNumber = ref(null);
|
||||
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 () => {
|
||||
if (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 attachmentsFromOrderError = ref(null);
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick);
|
||||
document.addEventListener('keydown', onDocumentKeydown);
|
||||
|
||||
if (props.order_id) {
|
||||
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) => {
|
||||
Swal.fire({
|
||||
title: t('admin.pos.settings_wheel.scan_qr_to_login'),
|
||||
@@ -417,7 +456,7 @@ const defaultActions = computed(() => {
|
||||
icon: 'fas fa-file-invoice-dollar',
|
||||
label: t('admin.pos.settings_wheel.change_invoice_collection'),
|
||||
clickAction: () => {
|
||||
return SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm(props.order_id);
|
||||
return SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm(props.order_id, props.refreshFunction);
|
||||
},
|
||||
showFunction: () => {
|
||||
return !!props.order_id && SessionUser.canAccessSuperUser();
|
||||
@@ -606,9 +645,16 @@ const defaultActions = computed(() => {
|
||||
/>
|
||||
</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">
|
||||
<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">
|
||||
<i :class="props.icon"></i>
|
||||
</span>
|
||||
@@ -616,7 +662,7 @@ const defaultActions = computed(() => {
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="dropdown-menu" role="menu">
|
||||
<div class="dropdown-content">
|
||||
<div class="dropdown-content" @dropdown-action-selected="onActionSelected">
|
||||
<!-- Actions (If defined) -->
|
||||
<template v-if="useSlots().actions">
|
||||
<slot name="actions"></slot>
|
||||
@@ -709,7 +755,7 @@ const defaultActions = computed(() => {
|
||||
v-if="SessionUser.canAccessSuperUser()"
|
||||
:label="t('admin.pos.settings_wheel.change_invoice_collection')"
|
||||
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"
|
||||
/>
|
||||
<!-- Delete the order -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { defineProps } from 'vue';
|
||||
import { defineEmits, defineProps, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
clickAction: Function,
|
||||
@@ -8,6 +8,7 @@ const props = defineProps({
|
||||
disabled: Boolean,
|
||||
template: String // The style of the button (default, danger, success, warning, info, light)
|
||||
});
|
||||
const emit = defineEmits(['selected']);
|
||||
|
||||
const styles = {
|
||||
default: {
|
||||
@@ -72,9 +73,22 @@ const styles = {
|
||||
},
|
||||
};
|
||||
|
||||
const click = () => {
|
||||
if (props.clickAction && !props.disabled)
|
||||
props.clickAction();
|
||||
const isProcessing = ref(false);
|
||||
const click = async (event) => {
|
||||
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 = () => {
|
||||
@@ -111,19 +125,31 @@ const getLabelColor = () => {
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<i :class="getIcon() + ' ' + getIconColor()"></i>
|
||||
</span>
|
||||
<span class="ml-1"
|
||||
:class="getLabelColor()"
|
||||
>{{ getLabel() }}</span>
|
||||
</a>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dropdown-item-action {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.is-disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -134,4 +160,4 @@ const getLabelColor = () => {
|
||||
.is-disabled .icon {
|
||||
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 PosDepartmentStepMobile1 from "@/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.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 VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCustomer.vue";
|
||||
import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue";
|
||||
@@ -65,6 +66,7 @@ applyPosRouteSearch(window.location.search, {
|
||||
<PosDepartmentStepMobilePopupRenderer/>
|
||||
<PosDepartmentStepMobile1 v-if="getCurrentStep() === 1" />
|
||||
<PosDepartmentStepMobile2 v-else-if="getCurrentStep() === 2 && (getOrderId() !== null)" />
|
||||
<PosDepartmentStepMobile3 v-else-if="getCurrentStep() === 3 && (getOrderId() !== null)" />
|
||||
</template>
|
||||
</ViewportResponsiveWrapper>
|
||||
</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:invoice_collection_id="order.invoice_collection_id"
|
||||
v-bind:reg_1="order.reg_1"
|
||||
:refreshFunction="loadList"
|
||||
@deleted="loadList()"
|
||||
>
|
||||
<template v-slot:actions>
|
||||
@@ -1140,6 +1141,7 @@ const formatCashierName = (order) => {
|
||||
v-bind:order_id="order.id"
|
||||
v-bind:invoice_collection_id="order.invoice_collection_id"
|
||||
v-bind:reg_1="order.reg_1"
|
||||
:refreshFunction="loadList"
|
||||
@deleted="loadList()"
|
||||
:displayActionsDirectly="true"
|
||||
>
|
||||
@@ -1503,6 +1505,7 @@ const formatCashierName = (order) => {
|
||||
v-bind:reg_3="selectedOrderForActionsMenu.reg_3"
|
||||
v-bind:order_booking_id="selectedOrderForActionsMenu.booking_id"
|
||||
v-bind:department_id="selectedOrderForActionsMenu.department_id"
|
||||
:refreshFunction="loadList"
|
||||
@deleted="loadList()"
|
||||
:displayActionsDirectly="true"
|
||||
>
|
||||
|
||||
@@ -20,7 +20,7 @@ import PosDepartmentStepMobileButtonNextStep
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.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 { 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 PosDepartmentStepMobileButtonClearAll
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
|
||||
@@ -430,76 +430,143 @@ onUnmounted(() => {
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
});
|
||||
|
||||
const onBeforeComplete = () => {
|
||||
// This function is called before the step is completed
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if the primary item is set
|
||||
if (!transactionItems.primaryItem.value) {
|
||||
reject(new Error('No primary item selected'));
|
||||
return;
|
||||
}
|
||||
// If the transaction items contain a wash certificate, create a booking for it
|
||||
const hasWashCertificate = transactionItems.containsWashCertificate();
|
||||
// Add the order items to the transaction
|
||||
SessionUser.objects.global.add.object(
|
||||
'/order/items',
|
||||
{
|
||||
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
|
||||
quantity: 1,
|
||||
price: primaryItem.value.price,
|
||||
notes: primaryItem.value?.notes || '', // Notes can be added here if needed
|
||||
related_item_id: null,
|
||||
}, // The order item to create
|
||||
{authenticated: true}
|
||||
).then((response) => {
|
||||
const relatedItemId = response.data.data.id; // Get the ID of the created order item
|
||||
// Create the addon items (With a quantity of > 0)
|
||||
const addonItems = transactionItems.primaryItem.value.addons.filter(addon => addon.quantity > 0);
|
||||
const addonPromises = addonItems.map(addon => {
|
||||
// For each addon, create an order item
|
||||
return SessionUser.objects.global.add.object(
|
||||
'/order/items',
|
||||
{
|
||||
order_id: parseInt(order_id.value), // The order ID to which the addon will be added
|
||||
product_id: addon.product.id, // The product ID of the addon
|
||||
quantity: addon.quantity,
|
||||
price: addon.product.price,
|
||||
notes: addon.product?.notes || '', // Notes can be added here if needed
|
||||
related_item_id: relatedItemId, // Link the addon to the primary item
|
||||
},
|
||||
{authenticated: true}
|
||||
);
|
||||
});
|
||||
// Create the additional items (With a quantity of > 0)
|
||||
const additionalItems = transactionItems.additionalItems.value.filter(item => item.quantity > 0);
|
||||
const additionalPromises = additionalItems.map(item => {
|
||||
// For each additional item, create an order item
|
||||
return SessionUser.objects.global.add.object(
|
||||
'/order/items',
|
||||
{
|
||||
order_id: parseInt(order_id.value), // The order ID to which the additional item will be added
|
||||
product_id: item.id, // The product ID of the additional item
|
||||
quantity: item.quantity,
|
||||
price: item.price,
|
||||
notes: item?.notes || '', // Notes can be added here if needed
|
||||
related_item_id: null, // Additional items are not linked to the primary item
|
||||
},
|
||||
{authenticated: true}
|
||||
);
|
||||
});
|
||||
// Combine addon and additional item promises
|
||||
addonPromises.push(...additionalPromises);
|
||||
// IF the booking is being created, wait for it to finish first
|
||||
return Promise.all(addonPromises);
|
||||
}).then(() => {
|
||||
// After successfully adding the order item, resolve the promise
|
||||
resolve(true);
|
||||
}).catch(error => {
|
||||
// If there's an error, reject the promise
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
const getNormalizedOrderId = () => {
|
||||
const parsedOrderId = Number.parseInt(String(order_id.value), 10);
|
||||
return Number.isInteger(parsedOrderId) && parsedOrderId > 0 ? parsedOrderId : null;
|
||||
}
|
||||
|
||||
const normalizeOrderItemShape = (item: any) => ({
|
||||
product_id: Number(item?.product_id ?? item?.product?.id ?? 0),
|
||||
quantity: Number(item?.quantity ?? 0),
|
||||
related_item_id: item?.related_item_id === null || item?.related_item_id === undefined
|
||||
? null
|
||||
: Number(item.related_item_id),
|
||||
price: Number(item?.price ?? 0),
|
||||
notes: String(item?.notes ?? ''),
|
||||
})
|
||||
|
||||
const buildDesiredOrderItemShapes = () => {
|
||||
if (!transactionItems.primaryItem.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const primaryShape = {
|
||||
kind: 'primary',
|
||||
relatedKey: 'primary',
|
||||
product_id: Number(transactionItems.primaryItem.value.id),
|
||||
quantity: 1,
|
||||
related_item_id: null,
|
||||
price: Number(transactionItems.primaryItem.value.price ?? 0),
|
||||
notes: String(transactionItems.primaryItem.value?.notes ?? ''),
|
||||
};
|
||||
|
||||
const addonShapes = (transactionItems.primaryItem.value.addons || [])
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
|
||||
.map((addon: any) => ({
|
||||
kind: 'addon',
|
||||
relatedKey: 'primary',
|
||||
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
|
||||
quantity: Number(addon?.quantity ?? 0),
|
||||
related_item_id: '__PRIMARY__',
|
||||
price: Number(addon?.product?.price ?? addon?.price ?? 0),
|
||||
notes: String(addon?.product?.notes ?? ''),
|
||||
}));
|
||||
|
||||
const additionalShapes = (transactionItems.additionalItems.value || [])
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
|
||||
.map((item: any) => ({
|
||||
kind: 'additional',
|
||||
relatedKey: null,
|
||||
product_id: Number(item?.id ?? 0),
|
||||
quantity: Number(item?.quantity ?? 0),
|
||||
related_item_id: null,
|
||||
price: Number(item?.price ?? 0),
|
||||
notes: String(item?.notes ?? ''),
|
||||
}));
|
||||
|
||||
return [primaryShape, ...addonShapes, ...additionalShapes];
|
||||
}
|
||||
|
||||
const normalizeExistingOrderItemShapes = (items: any[]) => {
|
||||
const primaryItems = items.filter((item: any) => item?.related_item_id === null || item?.related_item_id === undefined);
|
||||
if (primaryItems.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const additionalItems = primaryItems.filter((item: any) => Number(item?.product?.id ?? item?.product_id ?? 0) !== Number(transactionItems.primaryItem.value?.id ?? 0));
|
||||
const primaryItemShape = normalizeOrderItemShape(primaryItems[0]);
|
||||
const addonShapes = items
|
||||
.filter((item: any) => item?.related_item_id === primaryItems[0]?.id)
|
||||
.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
|
||||
|
||||
@@ -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>
|
||||
+4
-4
@@ -228,15 +228,15 @@ const addTransactionToHistory = async (orderId: number) => {
|
||||
|
||||
const step2 = () => {
|
||||
/** 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 (isCustomerSelected() && getCustomerId() === 999) {
|
||||
nextStep({isMobile: true});
|
||||
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 there is a booking ID, complete the booking
|
||||
SessionUser.objects.orders.set.booking_id(order_id.value, metadata.getBookingId());
|
||||
|
||||
+18
-1
@@ -49,6 +49,13 @@ const onClickSuggestion = (customerId: number) => {
|
||||
metadata.setCustomerId(customerId);
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const onClickDirectCardPayment = () => {
|
||||
const cardCustomerId = 999;
|
||||
searchAndSelectCustomer(cardCustomerId);
|
||||
metadata.setCustomerId(cardCustomerId);
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -66,6 +73,16 @@ const onClickSuggestion = (customerId: number) => {
|
||||
<!-- Search results (If any) -->
|
||||
<ControlFieldInputSearchResults v-bind:results="formattedSearchResults" @result-clicked="onClick"/>
|
||||
</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) -->
|
||||
<VehicleCustomerSuggestionsPos v-if="vehicles?.vehicle_1?.value?.reg" :reg_1="vehicles.vehicle_1.value.reg" @customerSelected="onClickSuggestion"/>
|
||||
</div>
|
||||
@@ -164,4 +181,4 @@ input.is-searched {
|
||||
flex-grow: 0;
|
||||
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -228,6 +228,7 @@ const onClickAttachToOrder = (object) => {
|
||||
<!-- Actions -->
|
||||
<ActionSettingsWheelButton
|
||||
:order_id="object.order_id"
|
||||
:refreshFunction="loadList"
|
||||
>
|
||||
<template #actions>
|
||||
<!-- Label -->
|
||||
@@ -268,4 +269,4 @@ const onClickAttachToOrder = (object) => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -752,6 +752,7 @@ const confirmCloseModal = () => {
|
||||
v-bind:order_id="order.id"
|
||||
v-bind:invoice_collection_id="order.invoice_collection_id"
|
||||
v-bind:reg_1="order.reg_1"
|
||||
:refreshFunction="loadList"
|
||||
>
|
||||
<template v-slot:actions>
|
||||
<!--
|
||||
|
||||
@@ -500,6 +500,7 @@ const filteredObjects = computed(() => {
|
||||
<!-- Actions -->
|
||||
<ActionSettingsWheelButton
|
||||
:order_id="object.order_id"
|
||||
:refreshFunction="loadList"
|
||||
>
|
||||
<template #actions>
|
||||
<!-- Label -->
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildCollectedInvoiceEconomicPayload,
|
||||
enqueueEconomicTransferJob,
|
||||
fetchEconomicTransferJobStatus,
|
||||
runEconomicTransferQueueBatch,
|
||||
retryEconomicTransferJob,
|
||||
} 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`
|
||||
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
|
||||
*/
|
||||
@@ -265,6 +387,19 @@ export const CollectedOrderInvoices = {
|
||||
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: {
|
||||
all: async () => {
|
||||
@@ -355,6 +490,16 @@ export const CollectedOrderInvoices = {
|
||||
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) => {
|
||||
return fetchEconomicTransferJobStatus({
|
||||
endpoint: '/collected-invoices/economic/queue/status',
|
||||
@@ -619,6 +764,10 @@ export const CollectedOrderInvoices = {
|
||||
* @returns {Promise<SweetAlertResult<Awaited<any>>>}
|
||||
*/
|
||||
showEditObjectFieldForm: (id, column, value, onAfterSubmit = null) => {
|
||||
if (column === 'closed_at') {
|
||||
return showEditClosedAtObjectFieldForm(id, value, onAfterSubmit);
|
||||
}
|
||||
|
||||
return ObjectsGlobal.showEditObjectFieldForm(
|
||||
CollectedOrderInvoices,
|
||||
id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script>
|
||||
import Swal from "sweetalert2";
|
||||
import {ObjectsGlobal} from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.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;
|
||||
// Get the customer number from the order
|
||||
await SessionUser.objects.orders.functions.get_customer_id(id).then((response) => {
|
||||
@@ -169,7 +169,7 @@ const showChangeOrderInvoiceCollectionForm = async (id) => {
|
||||
// Select the invoice collection
|
||||
await SessionUser.objects.collectedOrderInvoices.functions.showInvoiceCollectionPickerForm(
|
||||
customer_number,
|
||||
(invoice_collection_id) => {
|
||||
async (invoice_collection_id) => {
|
||||
if (!invoice_collection_id) {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
@@ -182,36 +182,30 @@ const showChangeOrderInvoiceCollectionForm = async (id) => {
|
||||
console.log("Selected invoice collection:", invoice_collection_id);
|
||||
console.log("Changing invoice collection for order:", id, " customer:", customer_number);
|
||||
console.log("New invoice collection:", invoice_collection_id);
|
||||
// Change the invoice collection
|
||||
SessionUser.objects.orders.set.invoice_collection_id(
|
||||
parseInt(id),
|
||||
parseInt(invoice_collection_id)
|
||||
)
|
||||
.then((response) => {
|
||||
console.log("Invoice collection changed successfully:", response);
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Faktura samling ændret',
|
||||
timer: 2000,
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error changing invoice collection:", error);
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Fejl ved ændring af faktura samling',
|
||||
timer: 2000,
|
||||
});
|
||||
})
|
||||
try {
|
||||
const response = await SessionUser.objects.orders.set.invoice_collection_id(
|
||||
parseInt(id, 10),
|
||||
parseInt(invoice_collection_id, 10)
|
||||
);
|
||||
console.log("Invoice collection changed successfully:", response);
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Faktura samling ændret',
|
||||
timer: 2000,
|
||||
});
|
||||
if (typeof onAfterSubmit === 'function') {
|
||||
await onAfterSubmit(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error changing invoice collection:", error);
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
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>
|
||||
|
||||
@@ -1,173 +1,230 @@
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { StripeModule } from "@/components/stripe/StripeModule.vue";
|
||||
|
||||
const paymentIntent = ref(null);
|
||||
|
||||
// Create a payment intent
|
||||
const createPaymentIntent = async (reader, order_id, tax_percentage = 0) => {
|
||||
SessionUser.request(
|
||||
const WAITING_FOR_READER_STATUSES = [
|
||||
'requires_payment_method',
|
||||
'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',
|
||||
'POST',
|
||||
{
|
||||
id: order_id,
|
||||
reader: reader.id,
|
||||
tax_percentage: tax_percentage,
|
||||
id: orderId,
|
||||
reader: reader?.id ?? reader ?? null,
|
||||
tax_percentage,
|
||||
},
|
||||
).then((response) => {
|
||||
console.log('Response from Stripe:', response.data.data);
|
||||
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) => {
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error creating payment intent:', error);
|
||||
})
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Get the payment intent
|
||||
const getPaymentIntent = async (order_id) => {
|
||||
SessionUser.request(
|
||||
const getPaymentIntent = async (orderLike) => {
|
||||
const orderId = normalizeOrderId(orderLike);
|
||||
if (!orderId) {
|
||||
setStoredPaymentIntent(null);
|
||||
throw new Error('Order ID is required');
|
||||
}
|
||||
try {
|
||||
return await requestPaymentIntent(
|
||||
'/orders/module/stripe/payment_intent',
|
||||
'GET',
|
||||
{
|
||||
id: order_id,
|
||||
id: orderId,
|
||||
},
|
||||
).then((response) => {
|
||||
console.log('Response from Stripe:', response.data.data);
|
||||
if (response.status === 200) {
|
||||
paymentIntent.value = response.data.data;
|
||||
return response;
|
||||
} else {
|
||||
console.error('Error getting payment intent:', response);
|
||||
return null;
|
||||
}
|
||||
}).catch((error) => {
|
||||
);
|
||||
} catch (error) {
|
||||
setStoredPaymentIntent(null);
|
||||
console.error('Error getting payment intent:', error);
|
||||
// Unset the payment intent if an error occurs
|
||||
paymentIntent.value = null;
|
||||
})
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Delete the payment intent
|
||||
const deletePaymentIntent = async (order_id) => {
|
||||
SessionUser.request(
|
||||
const deletePaymentIntent = async (orderLike) => {
|
||||
const orderId = normalizeOrderId(orderLike);
|
||||
if (!orderId) {
|
||||
setStoredPaymentIntent(null);
|
||||
throw new Error('Order ID is required');
|
||||
}
|
||||
try {
|
||||
return await requestPaymentIntent(
|
||||
'/orders/module/stripe/payment_intent',
|
||||
'DELETE',
|
||||
{
|
||||
id: order_id,
|
||||
id: orderId,
|
||||
},
|
||||
).then((response) => {
|
||||
console.log('Response from Stripe:', response.data.data);
|
||||
if (response.status === 200) {
|
||||
paymentIntent.value = null;
|
||||
return response;
|
||||
} else {
|
||||
console.error('Error deleting payment intent:', response);
|
||||
return null;
|
||||
}
|
||||
}).catch((error) => {
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error deleting payment intent:', error);
|
||||
})
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Is the payment intent ready to capture?
|
||||
const isPaymentIntentReadyToCapture = (paymentIntent) => {
|
||||
return paymentIntent && paymentIntent.status === 'requires_capture';
|
||||
}
|
||||
const isPaymentIntentReadyToCapture = (stripePaymentIntent) => {
|
||||
return stripePaymentIntent?.status === 'requires_capture';
|
||||
};
|
||||
|
||||
// Get the amount of the ready to capture payment intent
|
||||
// { amount: 0, amount_capturable: 0, currency: 'dkk' }
|
||||
const getReadyToCapturePaymentAmount = (paymentIntent) => {
|
||||
let defaultAmount = {
|
||||
const getReadyToCapturePaymentAmount = (stripePaymentIntent) => {
|
||||
const defaultAmount = {
|
||||
amount: null,
|
||||
amount_capturable: null,
|
||||
amount_received: null,
|
||||
currency: null,
|
||||
};
|
||||
if (isPaymentIntentReadyToCapture(paymentIntent) || paymentIntent.amount_received > 0) {
|
||||
defaultAmount.amount = paymentIntent.amount || 0;
|
||||
defaultAmount.amount_capturable = paymentIntent.amount_capturable || 0;
|
||||
defaultAmount.amount_received = paymentIntent.amount_received || 0;
|
||||
defaultAmount.currency = paymentIntent.currency.toUpperCase() || 'DKK';
|
||||
} else {
|
||||
console.error('Payment intent is not ready to capture:', paymentIntent);
|
||||
if (!stripePaymentIntent) {
|
||||
return defaultAmount;
|
||||
}
|
||||
if (isPaymentIntentReadyToCapture(stripePaymentIntent) || Number(stripePaymentIntent.amount_received || 0) > 0) {
|
||||
defaultAmount.amount = Number(stripePaymentIntent.amount || 0);
|
||||
defaultAmount.amount_capturable = Number(stripePaymentIntent.amount_capturable || 0);
|
||||
defaultAmount.amount_received = Number(stripePaymentIntent.amount_received || 0);
|
||||
defaultAmount.currency = String(stripePaymentIntent.currency || 'DKK').toUpperCase();
|
||||
}
|
||||
return defaultAmount;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if the payment intent amount equals the amount
|
||||
const isPaymentIntentAmountEqualToOrderAmount = (paymentIntent, matchesAmount) => {
|
||||
if (paymentIntent && paymentIntent.amount) {
|
||||
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);
|
||||
const isPaymentIntentAmountEqualToOrderAmount = (stripePaymentIntent, matchesAmount) => {
|
||||
if (!stripePaymentIntent || stripePaymentIntent.amount === undefined || stripePaymentIntent.amount === null) {
|
||||
return false;
|
||||
}
|
||||
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 (order_id) => {
|
||||
SessionUser.request(
|
||||
const capturePaymentIntent = async (orderLike) => {
|
||||
const orderId = normalizeOrderId(orderLike);
|
||||
if (!orderId) {
|
||||
throw new Error('Order ID is required');
|
||||
}
|
||||
try {
|
||||
return await requestPaymentIntent(
|
||||
'/orders/module/stripe/payment_intent/capture',
|
||||
'POST',
|
||||
{
|
||||
id: order_id,
|
||||
id: orderId,
|
||||
},
|
||||
).then((response) => {
|
||||
console.log('Response from Stripe:', response.data.data);
|
||||
if (response.status === 200) {
|
||||
paymentIntent.value = response.data.data;
|
||||
return response;
|
||||
} else {
|
||||
console.error('Error capturing payment intent:', response);
|
||||
return null;
|
||||
}
|
||||
}).catch((error) => {
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error capturing payment intent:', 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);
|
||||
throw error;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isPaymentIntentAmountReceived = (stripePaymentIntent) => {
|
||||
return stripePaymentIntent?.status === 'succeeded';
|
||||
};
|
||||
|
||||
export const StripePaymentIntent = {
|
||||
createPaymentIntent,
|
||||
getPaymentIntent,
|
||||
deletePaymentIntent,
|
||||
paymentIntent,
|
||||
extractPaymentIntent,
|
||||
getPaymentIntentState,
|
||||
isPaymentIntentActive,
|
||||
isPaymentIntentReadyToCapture,
|
||||
getReadyToCapturePaymentAmount,
|
||||
capturePaymentIntent,
|
||||
isPaymentIntentAmountEqualToOrderAmount,
|
||||
isPaymentIntentAmountReceived,
|
||||
}
|
||||
</script>
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -136,6 +136,14 @@ export const useEconomicQueueJob = ({
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
@@ -178,11 +186,18 @@ export const useEconomicQueueJob = ({
|
||||
const queueFailureMessage = computed(() => job.value?.error_message || "");
|
||||
const result = computed(() => job.value?.result ?? null);
|
||||
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 isCompleted = computed(() => status.value === ECONOMIC_QUEUE_STATUS.COMPLETED);
|
||||
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);
|
||||
|
||||
return {
|
||||
@@ -192,6 +207,8 @@ export const useEconomicQueueJob = ({
|
||||
progressPercent,
|
||||
progressMessage,
|
||||
queueFailureMessage,
|
||||
attempts,
|
||||
maxAttempts,
|
||||
transportErrorMessage,
|
||||
result,
|
||||
userMessage,
|
||||
@@ -208,4 +225,3 @@ export const useEconomicQueueJob = ({
|
||||
dispose,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,22 @@ const parsePositiveInteger = (value) => {
|
||||
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.") => {
|
||||
if (typeof error === "string" && error.trim() !== "") {
|
||||
return error;
|
||||
@@ -54,38 +70,206 @@ export const getEconomicApiErrorMessage = (error, fallback = "Request failed.")
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const normalizeQueueJob = (rawJob) => {
|
||||
const normalizeQueueJob = (
|
||||
rawJob,
|
||||
{
|
||||
fallbackStatus = null,
|
||||
fallbackProgressMessage = null,
|
||||
fallbackIdCandidates = [],
|
||||
} = {},
|
||||
) => {
|
||||
if (!rawJob || typeof rawJob !== "object") {
|
||||
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);
|
||||
|
||||
return {
|
||||
...rawJob,
|
||||
id,
|
||||
status: rawJob.status ?? null,
|
||||
status: rawJob.status ?? fallbackStatus ?? null,
|
||||
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,
|
||||
result: rawJob.result ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const ensureQueueJob = (rawJob, source = "queue response") => {
|
||||
const normalized = normalizeQueueJob(rawJob);
|
||||
const ensureQueueJob = (
|
||||
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}.`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const parseQueueJobFromEnqueueResponse = (response) => ensureQueueJob(response?.data?.data?.job, "enqueue response");
|
||||
export const parseQueueJobFromStatusResponse = (response) => ensureQueueJob(response?.data?.data, "status response");
|
||||
export const parseQueueJobFromRetryResponse = (response) => ensureQueueJob(response?.data?.data?.job, "retry response");
|
||||
const getQueueEnvelope = (response) => {
|
||||
const envelope = response?.data?.data;
|
||||
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 ({
|
||||
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) => {
|
||||
setTimeout(resolve, durationMs);
|
||||
});
|
||||
@@ -212,4 +411,3 @@ export const getEconomicQueueJobMessage = (job) => {
|
||||
|
||||
return job.progress_message || "";
|
||||
};
|
||||
|
||||
|
||||
+5
-3
@@ -16,8 +16,10 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const sharedTypes = computed(() => view.variables.sharedVariables.value?.types ?? {});
|
||||
|
||||
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) => {
|
||||
// Skip if the view type is "all".
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -53,4 +55,4 @@ const list_views_with_customer = computed(() => {
|
||||
.is-not-clickable {
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+37
-4
@@ -1,12 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, reactive } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { SessionUser } from '@/components/session/token/SessionUser.vue';
|
||||
import { dates } from '../../imports/InvoicingBillingPeriodImportDates.vue';
|
||||
import { invoiceQueue } from '../../imports/InvoicingBillingPeriodImportInvoiceQueue.vue';
|
||||
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
|
||||
import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue";
|
||||
import { view } from '../../imports/InvoicingBillingPeriodImportView.vue';
|
||||
|
||||
const period_result = ref<any>(null);
|
||||
const route = useRoute();
|
||||
|
||||
const normalizePeriodResult = (data: any = {}) => ({
|
||||
...data,
|
||||
types: data?.types ?? {},
|
||||
});
|
||||
|
||||
const status = reactive({
|
||||
loaded: false,
|
||||
@@ -87,7 +95,7 @@ const types = [
|
||||
];
|
||||
|
||||
const getPeriod = async () => {
|
||||
view.variables.sharedVariables.value = {};
|
||||
view.variables.sharedVariables.value = null;
|
||||
status.loading = true;
|
||||
status.loaded = false;
|
||||
status.error = false;
|
||||
@@ -105,8 +113,9 @@ const getPeriod = async () => {
|
||||
},
|
||||
);
|
||||
|
||||
view.variables.sharedVariables.value = response.data.data;
|
||||
period_result.value = response.data.data;
|
||||
const normalizedPeriodResult = normalizePeriodResult(response?.data?.data);
|
||||
view.variables.sharedVariables.value = normalizedPeriodResult;
|
||||
period_result.value = normalizedPeriodResult;
|
||||
status.loaded = true;
|
||||
status.isEmpty = Object.keys(period_result.value?.types || {}).length === 0;
|
||||
} 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
|
||||
const entries = period_result.value.types[type.name] || [];
|
||||
const entries = period_result.value?.types?.[type.name] || [];
|
||||
return {
|
||||
total: entries.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;
|
||||
});
|
||||
|
||||
const isPeriodTabActive = computed(() => route.query.activeTab === 'period');
|
||||
|
||||
onMounted(() => {
|
||||
view.variables.availableViewNames.value = types.reduce((acc: Record<string, string>, type) => {
|
||||
acc[type.name] = type.displayName;
|
||||
@@ -156,6 +167,28 @@ watch(
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
||||
+10
@@ -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 pollIntervalMs = DEFAULT_ECONOMIC_QUEUE_POLL_INTERVAL_MS;
|
||||
export const periodRefreshSignal = ref(0);
|
||||
|
||||
const notifyPeriodRefresh = () => {
|
||||
periodRefreshSignal.value += 1;
|
||||
};
|
||||
|
||||
export const invoiceCollectionQueueInProgress = ref<number[]>([]);
|
||||
export const invoiceCollectionQueueFailed = ref<number[]>([]);
|
||||
@@ -237,6 +242,7 @@ const processInvoiceCollection = async (
|
||||
isTransportError: false,
|
||||
jobId: parseInvoiceCollectionId(initialJob.id as number | string | null | undefined),
|
||||
});
|
||||
notifyPeriodRefresh();
|
||||
|
||||
const queueJobId = parseInvoiceCollectionId(initialJob.id as number | string | null | undefined);
|
||||
if (!queueJobId) {
|
||||
@@ -258,6 +264,7 @@ const processInvoiceCollection = async (
|
||||
isTransportError: false,
|
||||
jobId: parseInvoiceCollectionId(finalJob.id as number | string | null | undefined),
|
||||
});
|
||||
notifyPeriodRefresh();
|
||||
} else {
|
||||
markAsFailed(parsedInvoiceCollectionId);
|
||||
upsertCollectionQueueLog({
|
||||
@@ -271,6 +278,7 @@ const processInvoiceCollection = async (
|
||||
isTransportError: false,
|
||||
jobId: parseInvoiceCollectionId(finalJob.id as number | string | null | undefined),
|
||||
});
|
||||
notifyPeriodRefresh();
|
||||
}
|
||||
} catch (error) {
|
||||
markAsFailed(parsedInvoiceCollectionId);
|
||||
@@ -285,6 +293,7 @@ const processInvoiceCollection = async (
|
||||
isTransportError: true,
|
||||
jobId: getRetryJobIdForInvoiceCollection(parsedInvoiceCollectionId),
|
||||
});
|
||||
notifyPeriodRefresh();
|
||||
} finally {
|
||||
removeFromInProgress(parsedInvoiceCollectionId);
|
||||
processInvoiceCollectionQueue();
|
||||
@@ -413,6 +422,7 @@ export const invoiceQueue = {
|
||||
retryInvoiceCollection,
|
||||
clearQueue,
|
||||
queueStats,
|
||||
periodRefreshSignal,
|
||||
invoiceCollectionQueue,
|
||||
invoiceCollectionQueueInProgress,
|
||||
invoiceCollectionQueueFailed,
|
||||
|
||||
+19
-13
@@ -26,6 +26,10 @@ export const availableViews = {
|
||||
|
||||
export const availableViewNames = ref<Record<keyof typeof availableViews, string>>();
|
||||
|
||||
type BillingPeriodSharedVariables = {
|
||||
types?: Record<string, any[]>;
|
||||
} & Record<string, any>;
|
||||
|
||||
/**
|
||||
* View Module -> Variables
|
||||
* 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
|
||||
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
|
||||
const variablesView = {
|
||||
@@ -62,14 +68,14 @@ const isViewAvailable = (view: keyof typeof availableViews) => {
|
||||
|
||||
const doesViewContainCustomer = (customer: any = {customer_number: Number}, targetView: any) => {
|
||||
// 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
|
||||
);
|
||||
};
|
||||
|
||||
const getViewCustomer = (customer: any = {customer_number: Number}, targetView: any) => {
|
||||
// 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
|
||||
);
|
||||
};
|
||||
@@ -163,29 +169,29 @@ const computedViews = {
|
||||
isCurrentView,
|
||||
currentViewComponent,
|
||||
currentViewTotalNetAmount: computed(() => {
|
||||
const shared = sharedVariables.value;
|
||||
if (!currentView.value || !shared?.types?.[currentView.value]) {
|
||||
const sharedTypes = getSharedTypes();
|
||||
if (!currentView.value || !sharedTypes[currentView.value]) {
|
||||
return 0;
|
||||
}
|
||||
return getViewTotals(shared.types[currentView.value]).total;
|
||||
return getViewTotals(sharedTypes[currentView.value]).total;
|
||||
}),
|
||||
currentViewTotalNetAmountBooked: computed(() => {
|
||||
const shared = sharedVariables.value;
|
||||
if (!currentView.value || !shared?.types?.[currentView.value]) {
|
||||
const sharedTypes = getSharedTypes();
|
||||
if (!currentView.value || !sharedTypes[currentView.value]) {
|
||||
return 0;
|
||||
}
|
||||
return getViewTotals(shared.types[currentView.value]).booked;
|
||||
return getViewTotals(sharedTypes[currentView.value]).booked;
|
||||
}),
|
||||
currentViewTotalNetAmountNotBooked: computed(() => {
|
||||
const shared = sharedVariables.value;
|
||||
if (!currentView.value || !shared?.types?.[currentView.value]) {
|
||||
const sharedTypes = getSharedTypes();
|
||||
if (!currentView.value || !sharedTypes[currentView.value]) {
|
||||
return 0;
|
||||
}
|
||||
return getViewTotals(shared.types[currentView.value]).notBooked;
|
||||
return getViewTotals(sharedTypes[currentView.value]).notBooked;
|
||||
}),
|
||||
isLoaded: computed(() => {
|
||||
// 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,
|
||||
};
|
||||
|
||||
+319
-246
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { view } from '../imports/InvoicingBillingPeriodImportView.vue';
|
||||
import { dates } from '../imports/InvoicingBillingPeriodImportDates.vue';
|
||||
import { invoiceQueue } from '../imports/InvoicingBillingPeriodImportInvoiceQueue.vue';
|
||||
import { SessionUser } from '@/components/session/token/SessionUser.vue';
|
||||
import {computed, ref} from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { view } from "../imports/InvoicingBillingPeriodImportView.vue";
|
||||
import { dates } from "../imports/InvoicingBillingPeriodImportDates.vue";
|
||||
import { invoiceQueue } from "../imports/InvoicingBillingPeriodImportInvoiceQueue.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 WhiteBox from "@/components/displays/boxes/WhiteBox.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 InvoicingBillingPeriodStatistics from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodStatistics.vue";
|
||||
import InvoicingBillingPeriodFilters from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodFilters.vue";
|
||||
import InvoicingBillingPeriodCustomerAttributes
|
||||
from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue";
|
||||
import InvoicingBillingPeriodInvoiceProgressBar
|
||||
from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/InvoicingBillingPeriodInvoiceProgressBar.vue";
|
||||
import InvoicingBillingPeriodCustomerAttributes from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue";
|
||||
|
||||
const onClickCustomer = (customer: any) => {
|
||||
//console.log('Clicked customer:', customer);
|
||||
if (customer.expanded !== undefined && customer.expanded !== null) {
|
||||
customer.expanded = !customer.expanded;
|
||||
} else {
|
||||
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) => {
|
||||
let tmp = [];
|
||||
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;
|
||||
}
|
||||
return getTransactionsInView(customer).map((transaction: any) => transaction.id);
|
||||
};
|
||||
|
||||
const doesCustomerHaveTransactionsDifferentDays = (customer: any) => {
|
||||
if (!customer.transactions || customer.transactions.length < 2) {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const tmpFilters = ref({
|
||||
displayRequiresAction: true,
|
||||
@@ -54,309 +55,381 @@ const tmpFilters = ref({
|
||||
|
||||
const onFilterChanged = (filters: any) => {
|
||||
tmpFilters.value = filters;
|
||||
}
|
||||
};
|
||||
|
||||
const doesViewtypeExist = (types: any, componentName: string) => {
|
||||
return types && types[componentName] && Array.isArray(types[componentName]);
|
||||
}
|
||||
const customersInCurrentView = computed(() => (
|
||||
view.variables.sharedVariables.value?.types?.[view.computed.componentName.value] ?? []
|
||||
));
|
||||
|
||||
const getTransactionIds = (customer: any) => {
|
||||
if (!customer.transactions || customer.transactions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
// Filter out excluded transactions
|
||||
const filteredTransactions = customer.transactions.filter(transaction => transaction.excluded !== true);
|
||||
return filteredTransactions.map(transaction => transaction.id);
|
||||
}
|
||||
|
||||
return customer.transactions
|
||||
.filter((transaction: any) => transaction.excluded !== true)
|
||||
.map((transaction: any) => transaction.id);
|
||||
};
|
||||
|
||||
const getExcludedTransactionIds = (customer: any) => {
|
||||
if (!customer.transactions || customer.transactions.length === 0) {
|
||||
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[]) => {
|
||||
// Emit event to parent component to handle invoicing
|
||||
console.warn('Invoicing transactions for customer:', customer, transactionIds);
|
||||
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) {
|
||||
console.warn('Customer has fixed pricing, but no transactions. Please create an order for invoicing.');
|
||||
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 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 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;
|
||||
console.warn('New invoice collection ID:', newInvoiceCollectionId);
|
||||
if (newInvoiceCollectionId) {
|
||||
console.warn('Vehicle subscription invoice created successfully:', response);
|
||||
SessionUser.objects.collectedOrderInvoices.functions.add_vehicle_subscriptions(newInvoiceCollectionId).then((response) => {
|
||||
const newTransaction = response?.data?.data;
|
||||
// Add the new transaction to the customer view
|
||||
// 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
|
||||
if (!newInvoiceCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
SessionUser.objects.collectedOrderInvoices.functions.add_fixed_pricing(newInvoiceCollectionId)
|
||||
.then(() => {
|
||||
invoiceQueue.addInvoiceCollectionsToQueue([newInvoiceCollectionId]);
|
||||
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;
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
if (uniqueInvoiceCollectionIds.length > 0) {
|
||||
invoiceQueue.addInvoiceCollectionsToQueue(uniqueInvoiceCollectionIds);
|
||||
invoiceQueue.processInvoiceCollectionQueue();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return SessionUser.objects.orders.get.multiple(uncachedTransactionIds).then((orders: any) => {
|
||||
orders.forEach((order: any) => {
|
||||
transactionIdsInvoiceCollectionCache.value[order.id] = order.invoice_collection_id;
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isAnyTransactionInQueue = (transactionIds: number[]) => {
|
||||
return transactionIds.some(id =>
|
||||
invoiceQueue.invoiceCollectionQueueInProgress.value.includes(transactionIdsInvoiceCollectionCache.value[id]!) ||
|
||||
invoiceQueue.invoiceCollectionQueue.value.includes(transactionIdsInvoiceCollectionCache.value[id]!)
|
||||
);
|
||||
}
|
||||
const getTransactionInvoiceCollectionId = (transaction: any) => {
|
||||
const directInvoiceCollectionId = Number.parseInt(String(transaction?.invoice_collection_id ?? ""), 10);
|
||||
if (Number.isInteger(directInvoiceCollectionId) && directInvoiceCollectionId > 0) {
|
||||
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[]) => {
|
||||
if (!customer.transactions || customer.transactions.length === 0) {
|
||||
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);
|
||||
return (transactionIds.every(id => bookedTransactionIds.includes(id)) || transactionIds.every(id => excludedTransactionIds.includes(id)));
|
||||
}
|
||||
|
||||
const bookedTransactionIds = customer.transactions
|
||||
.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 = () => {
|
||||
return {
|
||||
show_wash_subscription: true,
|
||||
show_vehicle_subscription: true,
|
||||
show_fixed_pricing: true
|
||||
}
|
||||
}
|
||||
show_fixed_pricing: true,
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-testid="invoicing-period-view-all" :data-current-view="view.variables.currentView.value">
|
||||
<!--{{ view.variables.sharedVariables.value }}-->
|
||||
<InvoicingBillingPeriodStatistics/>
|
||||
<!-- Filters for customer visibility -->
|
||||
<InvoicingBillingPeriodFilters
|
||||
@filterChanged="(filters) => onFilterChanged(filters)"
|
||||
/>
|
||||
<InvoicingBillingPeriodStatistics />
|
||||
<InvoicingBillingPeriodFilters @filterChanged="(filters) => onFilterChanged(filters)" />
|
||||
<div style="min-height: 300px;"><!--Spacer--></div>
|
||||
|
||||
<div class="columns is-multiline is-mobile">
|
||||
<template
|
||||
v-if="doesViewtypeExist(view.variables.sharedVariables.value.types, view.computed.componentName.value)"
|
||||
v-for="customer in view.variables.sharedVariables.value.types[view.computed.componentName.value]"
|
||||
:key="customer.customer_number"
|
||||
v-for="customer in customersInCurrentView"
|
||||
:key="customer.customer_number"
|
||||
>
|
||||
<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}`">
|
||||
<!--{{ customer }}-->
|
||||
<div class="columns is-vcentered is-clickable" @click="onClickCustomer(customer)">
|
||||
<div class="column">
|
||||
<ColorIndicator
|
||||
v-bind:color_class="(customer.requires_action) ? 'has-text-danger' : 'has-text-success'"
|
||||
v-bind:label="{
|
||||
text: customer.customer_name,
|
||||
classes: [],
|
||||
}"
|
||||
v-bind:visibility="{
|
||||
icon: true,
|
||||
dropdown: false,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<!-- Attributes -->
|
||||
<div class="column is-narrow">
|
||||
<InvoicingBillingPeriodCustomerAttributes
|
||||
v-bind:customer="customer"
|
||||
v-bind:is-expanded="customer.expanded || false"
|
||||
/>
|
||||
</div>
|
||||
<!-- Small customer activity chart -->
|
||||
<div class="column is-narrow">
|
||||
<SmallCustomerActivityChart
|
||||
<div class="columns is-vcentered is-clickable" @click="onClickCustomer(customer)">
|
||||
<div class="column">
|
||||
<ColorIndicator
|
||||
:color_class="customer.requires_action ? 'has-text-danger' : 'has-text-success'"
|
||||
:label="{
|
||||
text: customer.customer_name,
|
||||
classes: [],
|
||||
}"
|
||||
:visibility="{
|
||||
icon: true,
|
||||
dropdown: false,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<InvoicingBillingPeriodCustomerAttributes
|
||||
:customer="customer"
|
||||
:is-expanded="customer.expanded || false"
|
||||
/>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<SmallCustomerActivityChart
|
||||
v-if="tmpFilters.displayStatistics"
|
||||
v-show="customer.transactions.length > 1 && doesCustomerHaveTransactionsDifferentDays(customer)"
|
||||
:dates="{
|
||||
dateFrom: dates.computed.formattedStartDate,
|
||||
dateTo: dates.computed.formattedEndDate
|
||||
dateTo: dates.computed.formattedEndDate,
|
||||
}"
|
||||
v-bind:transactions="customer.transactions"
|
||||
:transactions="customer.transactions"
|
||||
/>
|
||||
</div>
|
||||
<!-- Transactions count -->
|
||||
<div class="column is-narrow">
|
||||
<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>
|
||||
<p v-else>{{ SessionUser.objects.global.language.none }} {{ SessionUser.objects.orders.meta.labels.multiple }}</p>
|
||||
</div>
|
||||
<!-- Total amount -->
|
||||
<div class="column is-narrow">
|
||||
<p>{{ SessionUser.functions.currency.toLocal(view.functions.getCustomerViewTotalNetAmount(customer, { includeFixedPricing: true, includeVehicleSubscriptions: true, 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 })" class="has-text-grey-light is-size-7">
|
||||
({{ SessionUser.functions.currency.toLocal(view.functions.getCustomerViewTotalNetAmount(customer, { includeFixedPricing: false, includeVehicleSubscriptions: false, includeTransactions: true })) }})
|
||||
</p>
|
||||
</div>
|
||||
<!-- 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}`"
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<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>
|
||||
<p v-else>{{ SessionUser.objects.global.language.none }} {{ SessionUser.objects.orders.meta.labels.multiple }}</p>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<p>{{ SessionUser.functions.currency.toLocal(view.functions.getCustomerViewTotalNetAmount(customer, { includeFixedPricing: true, includeVehicleSubscriptions: true, 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 })"
|
||||
class="has-text-grey-light is-size-7"
|
||||
>
|
||||
<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" 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"
|
||||
({{ SessionUser.functions.currency.toLocal(view.functions.getCustomerViewTotalNetAmount(customer, { includeFixedPricing: false, includeVehicleSubscriptions: false, includeTransactions: true })) }})
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="column is-narrow"
|
||||
v-if="isAllCustomerTransactionsBooked(customer, getCustomerTransactionsInView(customer)) && !customer.requires_action && !isCustomerQueueBlocked(customer)"
|
||||
>
|
||||
<template #actions>
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
<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="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 v-if="customer.expanded">
|
||||
<template v-if="view.variables.currentView.value === 'possible_duplicates'">
|
||||
<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="false"
|
||||
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="{
|
||||
<div v-if="customer.expanded">
|
||||
<template v-if="view.variables.currentView.value === 'possible_duplicates'">
|
||||
<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="false"
|
||||
:dates="{
|
||||
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)"
|
||||
v-bind:excluded-order-ids="getExcludedTransactionIds(customer)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
:show-only-with-ids="getTransactionIds(customer)"
|
||||
:query-parameters="getTransactionQueryParameters()"
|
||||
:excluded-order-ids="getExcludedTransactionIds(customer)"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
||||
+8
-4
@@ -24,7 +24,10 @@ const { t } = useI18n();
|
||||
|
||||
// Get the id from the URL
|
||||
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
|
||||
const orders = ref([]);
|
||||
@@ -40,7 +43,7 @@ const formattedTitle = firstLetter + restOfTitle;
|
||||
// Get the collected order invoice
|
||||
const collectedOrderInvoice = ref(null);
|
||||
const getCollectedOrderInvoice = async () => {
|
||||
SessionUser.objects.collectedOrderInvoices.get.single(collectedOrderInvoiceId).then((response) => {
|
||||
SessionUser.objects.collectedOrderInvoices.get.single(collectedOrderInvoiceId.value).then((response) => {
|
||||
const raw_data = response;
|
||||
console.log(raw_data);
|
||||
orders.value = raw_data.orders || [];
|
||||
@@ -82,6 +85,7 @@ const orderIds = computed(() => {
|
||||
v-bind:total="orders.length"
|
||||
v-bind:invoices="orders"
|
||||
v-bind:collectedOrderInvoice="collectedOrderInvoice"
|
||||
@updated="getCollectedOrderInvoice"
|
||||
/>
|
||||
<!-- Special Arrangement -->
|
||||
<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"/>
|
||||
<!-- Manage -->
|
||||
<CollectedOrderInvoiceManage
|
||||
:invoiceId="parseInt(collectedOrderInvoiceId)"
|
||||
:invoiceId="collectedOrderInvoiceId"
|
||||
v-bind:collectedOrderInvoice="collectedOrderInvoice"
|
||||
/>
|
||||
<!-- Orders -->
|
||||
@@ -104,4 +108,4 @@ const orderIds = computed(() => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+72
-32
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import {defineProps, ref} from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -19,39 +19,70 @@ const props = defineProps({
|
||||
description: 'The collected order invoice object'
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updated']);
|
||||
const column_width = 'is-3';
|
||||
const displays = [
|
||||
{
|
||||
title: 'Transaktioner',
|
||||
value: props.invoices.length,
|
||||
icon: 'fas fa-file-invoice-dollar',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
title: 'Arkiveret',
|
||||
value: props.collectedOrderInvoice.closed_at ? props.collectedOrderInvoice.closed_at : 'Nej',
|
||||
icon: 'fas fa-lock',
|
||||
},
|
||||
{
|
||||
title: 'Eksternt identifikationsnummer',
|
||||
value: props.collectedOrderInvoice.external_id,
|
||||
icon: 'fas fa-lock-open',
|
||||
},
|
||||
{
|
||||
title: 'PO-nummer',
|
||||
value: props.collectedOrderInvoice.po_number || SessionUser.objects.global.language.no_data,
|
||||
icon: 'fas fa-file-alt',
|
||||
},
|
||||
];
|
||||
|
||||
const onEditClosedAt = async () => {
|
||||
if (!props.collectedOrderInvoice?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
await SessionUser.objects.collectedOrderInvoices.showEditObjectFieldForm(
|
||||
parseInt(props.collectedOrderInvoice.id),
|
||||
'closed_at',
|
||||
props.collectedOrderInvoice.closed_at,
|
||||
() => {
|
||||
emit('updated');
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const displays = computed(() => {
|
||||
return [
|
||||
{
|
||||
key: 'transactions',
|
||||
title: 'Transaktioner',
|
||||
value: props.invoices.length,
|
||||
icon: 'fas fa-file-invoice-dollar',
|
||||
editable: false,
|
||||
},
|
||||
{
|
||||
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>
|
||||
|
||||
<template>
|
||||
<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="card">
|
||||
<!-- Header -->
|
||||
@@ -64,8 +95,17 @@ const displays = [
|
||||
</a>
|
||||
<!-- Title -->
|
||||
<p class="card-header-title">{{ display.title }}</p>
|
||||
<!-- Dropdown icon -->
|
||||
<a class="card-header-icon">
|
||||
<a
|
||||
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">
|
||||
<i class="fas fa-angle-down"></i>
|
||||
</span>
|
||||
@@ -83,4 +123,4 @@ const displays = [
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+675
-114
@@ -1,176 +1,575 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "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 MANUAL_RUN_LIMIT = 10;
|
||||
const POLL_INTERVAL_MS = 4000;
|
||||
|
||||
const statusFilter = ref("PROCESSING");
|
||||
const statusFilter = ref("");
|
||||
const limitFilter = ref(DEFAULT_LIMIT);
|
||||
const offsetFilter = ref(0);
|
||||
|
||||
const jobs = ref([]);
|
||||
const total = ref(0);
|
||||
const hasMore = ref(false);
|
||||
const loading = ref(false);
|
||||
const refreshing = ref(false);
|
||||
const runningQueue = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const pollErrorMessage = ref("");
|
||||
const manualRunUnavailable = ref(false);
|
||||
const runSummary = ref(null);
|
||||
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);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
return fallback;
|
||||
}
|
||||
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 normalizedStatus = String(rawJob?.status ?? "").toUpperCase();
|
||||
const payload = rawJob?.payload && typeof rawJob.payload === "object"
|
||||
? rawJob.payload
|
||||
: {};
|
||||
const progressPercent = Number(rawJob?.progress_percent ?? 0);
|
||||
|
||||
return {
|
||||
id: toPositiveInt(rawJob?.id ?? rawJob?.job_id),
|
||||
status: normalizedStatus || "UNKNOWN",
|
||||
...rawJob,
|
||||
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)
|
||||
? Math.max(0, Math.min(100, progressPercent))
|
||||
: 0,
|
||||
progress_message: String(rawJob?.progress_message ?? ""),
|
||||
error_message: String(rawJob?.error_message ?? ""),
|
||||
attempts: toNonNegativeInt(rawJob?.attempts, 0),
|
||||
max_attempts: toNonNegativeInt(rawJob?.max_attempts, 0),
|
||||
payload,
|
||||
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,
|
||||
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 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 hasActiveJobs = computed(() => jobs.value.some((job) => ACTIVE_STATUSES.has(job.status)));
|
||||
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) => {
|
||||
switch (status) {
|
||||
case "QUEUED":
|
||||
case ECONOMIC_QUEUE_STATUS.QUEUED:
|
||||
return "is-warning";
|
||||
case "PROCESSING":
|
||||
case ECONOMIC_QUEUE_STATUS.PROCESSING:
|
||||
return "is-info";
|
||||
case "COMPLETED":
|
||||
case ECONOMIC_QUEUE_STATUS.COMPLETED:
|
||||
return "is-success";
|
||||
case "FAILED":
|
||||
case ECONOMIC_QUEUE_STATUS.FAILED:
|
||||
return "is-danger";
|
||||
default:
|
||||
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 = () => {
|
||||
if (pollInterval !== null) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
if (pollTimer !== null) {
|
||||
clearTimeout(pollTimer);
|
||||
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) {
|
||||
refreshing.value = true;
|
||||
} else {
|
||||
loading.value = true;
|
||||
}
|
||||
|
||||
if (!isPoll) {
|
||||
pollErrorMessage.value = "";
|
||||
}
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
const response = await SessionUser.objects.collectedOrderInvoices.functions.economic.queue.list({
|
||||
status: statusFilter.value || undefined,
|
||||
limit: limitFilter.value,
|
||||
offset: offsetFilter.value,
|
||||
});
|
||||
loadPromise = (async () => {
|
||||
try {
|
||||
const response = await SessionUser.objects.collectedOrderInvoices.functions.economic.queue.list({
|
||||
status: statusFilter.value || undefined,
|
||||
limit: limitFilter.value,
|
||||
offset: offsetFilter.value,
|
||||
});
|
||||
|
||||
const parsed = parseQueueListResponse(response);
|
||||
jobs.value = parsed.items;
|
||||
total.value = parsed.total;
|
||||
const parsed = parseEconomicTransferQueueListResponse(response, {
|
||||
defaultLimit: limitFilter.value,
|
||||
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) {
|
||||
errorMessage.value = SessionUser.functions.parseErrorMessage(error) || "Failed to load queue history.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
refreshing.value = false;
|
||||
if (isManualRunEndpointMissing(error)) {
|
||||
manualRunUnavailable.value = true;
|
||||
return runQueueViaLegacyFallback();
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
const onRefresh = async () => {
|
||||
runSummary.value = null;
|
||||
pollErrorMessage.value = "";
|
||||
stopPolling();
|
||||
pollInterval = setInterval(() => {
|
||||
if (!hasActiveJobs.value) {
|
||||
stopPolling();
|
||||
return;
|
||||
|
||||
try {
|
||||
if (hasActiveJobs.value) {
|
||||
await runQueueBatchWithFallback();
|
||||
}
|
||||
loadQueueHistory({ silent: true });
|
||||
}, POLL_INTERVAL_MS);
|
||||
await loadQueueHistory({ silent: true });
|
||||
} catch (error) {
|
||||
errorMessage.value = getEconomicApiErrorMessage(error, "Refresh failed.");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onRetryJob = async (jobId) => {
|
||||
if (!jobId || retryingJobIds.value.includes(jobId)) {
|
||||
const onRetryJob = async (job) => {
|
||||
if (!job?.id || retryingJobIds.value.includes(job.id)) {
|
||||
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 = "";
|
||||
pollErrorMessage.value = "";
|
||||
stopPolling();
|
||||
|
||||
try {
|
||||
await SessionUser.objects.collectedOrderInvoices.functions.economic.queue.retry(jobId);
|
||||
await SessionUser.objects.collectedOrderInvoices.functions.economic.queue.retry(job.id);
|
||||
await loadQueueHistory({ silent: true });
|
||||
} catch (error) {
|
||||
errorMessage.value = SessionUser.functions.parseErrorMessage(error) || "Retry failed.";
|
||||
errorMessage.value = getEconomicApiErrorMessage(error, "Retry failed.");
|
||||
} 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) => {
|
||||
limitFilter.value = toPositiveInt(event?.target?.value, DEFAULT_LIMIT) || DEFAULT_LIMIT;
|
||||
limitFilter.value = toPositiveInt(event?.target?.value, DEFAULT_LIMIT) ?? DEFAULT_LIMIT;
|
||||
offsetFilter.value = 0;
|
||||
};
|
||||
|
||||
const onOffsetInput = (event) => {
|
||||
offsetFilter.value = toPositiveInt(event?.target?.value, 0);
|
||||
offsetFilter.value = toNonNegativeInt(event?.target?.value, 0);
|
||||
};
|
||||
|
||||
const goToPreviousPage = () => {
|
||||
@@ -178,23 +577,35 @@ const goToPreviousPage = () => {
|
||||
};
|
||||
|
||||
const goToNextPage = () => {
|
||||
if (!hasMore.value) {
|
||||
return;
|
||||
}
|
||||
offsetFilter.value = offsetFilter.value + limitFilter.value;
|
||||
};
|
||||
|
||||
const openDetails = (job) => {
|
||||
selectedJob.value = job;
|
||||
};
|
||||
|
||||
const closeDetails = () => {
|
||||
selectedJob.value = null;
|
||||
};
|
||||
|
||||
watch([statusFilter, limitFilter, offsetFilter], () => {
|
||||
loadQueueHistory();
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadQueueHistory();
|
||||
});
|
||||
|
||||
watch(hasActiveJobs, (isActive) => {
|
||||
if (isActive) {
|
||||
startPolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
}
|
||||
watch(hasActiveJobs, () => {
|
||||
syncPollingState();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadQueueHistory();
|
||||
isMounted = true;
|
||||
void loadQueueHistory();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -210,17 +621,21 @@ onBeforeUnmount(() => {
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select v-model="statusFilter" data-testid="economic-queue-history-status-filter">
|
||||
<option value="">All</option>
|
||||
<option value="QUEUED">QUEUED</option>
|
||||
<option value="PROCESSING">PROCESSING</option>
|
||||
<option value="COMPLETED">COMPLETED</option>
|
||||
<option value="FAILED">FAILED</option>
|
||||
<option :value="ECONOMIC_QUEUE_STATUS.QUEUED">QUEUED</option>
|
||||
<option :value="ECONOMIC_QUEUE_STATUS.PROCESSING">PROCESSING</option>
|
||||
<option :value="ECONOMIC_QUEUE_STATUS.COMPLETED">COMPLETED</option>
|
||||
<option :value="ECONOMIC_QUEUE_STATUS.FAILED">FAILED</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<label class="label is-small">Limit</label>
|
||||
<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="25">25</option>
|
||||
<option value="50">50</option>
|
||||
@@ -242,26 +657,38 @@ onBeforeUnmount(() => {
|
||||
<div class="column is-3 has-text-right">
|
||||
<label class="label is-small"> </label>
|
||||
<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
|
||||
class="button is-small"
|
||||
type="button"
|
||||
@click="goToPreviousPage"
|
||||
:disabled="!canGoToPrevious || loading"
|
||||
:disabled="!canGoToPrevious || loading || refreshing || runningQueue"
|
||||
data-testid="economic-queue-history-prev-page"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
class="button is-small"
|
||||
type="button"
|
||||
@click="goToNextPage"
|
||||
:disabled="!canGoToNext || loading"
|
||||
:disabled="!canGoToNext || loading || refreshing || runningQueue"
|
||||
data-testid="economic-queue-history-next-page"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<button
|
||||
class="button is-small is-link is-light"
|
||||
@click="loadQueueHistory({ silent: true })"
|
||||
:disabled="loading || refreshing"
|
||||
type="button"
|
||||
@click="onRefresh"
|
||||
:disabled="loading || refreshing || runningQueue"
|
||||
data-testid="economic-queue-history-refresh"
|
||||
>
|
||||
Refresh
|
||||
@@ -273,68 +700,202 @@ onBeforeUnmount(() => {
|
||||
<div class="mb-3" data-testid="economic-queue-history-summary">
|
||||
<small class="has-text-grey">
|
||||
Showing {{ jobs.length }} of {{ total }} jobs.
|
||||
<span v-if="hasMore">More results available.</span>
|
||||
<span v-if="refreshing">Refreshing...</span>
|
||||
<span v-if="manualRunUnavailable">Legacy fallback active while the backend queue runner is unavailable.</span>
|
||||
</small>
|
||||
</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">
|
||||
{{ errorMessage }}
|
||||
</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...
|
||||
</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.
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<tr>
|
||||
<th>Job</th>
|
||||
<th>Invoice</th>
|
||||
<th>Status</th>
|
||||
<th>Attempts</th>
|
||||
<th>Progress</th>
|
||||
<th>Timestamps</th>
|
||||
<th>Details</th>
|
||||
<th class="has-text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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.collected_invoice_id || "-" }}</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 }}
|
||||
</span>
|
||||
</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>
|
||||
<progress class="progress is-small is-link" max="100" :value="job.progress_percent"></progress>
|
||||
</td>
|
||||
<td style="max-width: 320px;">
|
||||
<small class="is-block has-text-danger" v-if="job.error_message">{{ job.error_message }}</small>
|
||||
<small class="is-block has-text-grey" v-else>{{ getResultMessage(job) || "-" }}</small>
|
||||
<td style="min-width: 220px;">
|
||||
<small class="is-block">Created: {{ formatDateTime(job.created_at) }}</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 class="has-text-right">
|
||||
<button
|
||||
v-if="job.status === 'FAILED'"
|
||||
v-if="job.status === ECONOMIC_QUEUE_STATUS.FAILED"
|
||||
class="button is-small is-danger is-light"
|
||||
@click="onRetryJob(job.id)"
|
||||
:disabled="retryingJobIds.includes(job.id)"
|
||||
type="button"
|
||||
@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}`"
|
||||
>
|
||||
Retry
|
||||
{{ retryingJobIds.includes(job.id) ? "Retrying..." : "Retry" }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal {
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.queue-details-modal-card {
|
||||
width: min(960px, calc(100vw - 2rem));
|
||||
margin-top: 4rem;
|
||||
}
|
||||
</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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -303,6 +303,67 @@ test.describe("Economic queue async export workflow", () => {
|
||||
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 }) => {
|
||||
let enqueueCalls = 0;
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
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) {
|
||||
return {
|
||||
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) {
|
||||
await page.addInitScript(() => {
|
||||
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();
|
||||
}
|
||||
|
||||
test.describe("Invoice transfer queue history page", () => {
|
||||
test("@smoke queue tab loads in-progress jobs and polls updates", async ({ page }) => {
|
||||
test.describe("Invoice transfer queue history reliability", () => {
|
||||
test("@smoke polling progression reaches terminal state and stops", async ({ page }) => {
|
||||
let queueListCalls = 0;
|
||||
const queueRequests = [];
|
||||
const token = await bootstrapAuthenticatedSuperuser(page);
|
||||
|
||||
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")) {
|
||||
if (route.request().method() !== "GET" || !isQueuePath(route.request().url(), QUEUE_LIST_PATH)) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
|
||||
queueListCalls += 1;
|
||||
queueRequests.push({
|
||||
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";
|
||||
|
||||
const isTerminal = queueListCalls > 1;
|
||||
await route.fulfill(json({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: 9301,
|
||||
status: "PROCESSING",
|
||||
progress_percent: progressPercent,
|
||||
progress_message: progressMessage,
|
||||
collected_invoice_id: 7011,
|
||||
status: isTerminal ? "COMPLETED" : "PROCESSING",
|
||||
progress_percent: isTerminal ? 100 : 45,
|
||||
progress_message: isTerminal ? "Completed" : "Exporting line items",
|
||||
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,
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
has_more: false,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
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.poll(() => queueRequests.some((request) => request.status === "PROCESSING")).toBeTruthy();
|
||||
await expect.poll(() => queueListCalls, { timeout: 12000 }).toBeGreaterThanOrEqual(2);
|
||||
await expect(page.getByTestId("economic-queue-history-row-9301")).toContainText("Exporting line items");
|
||||
await expect.poll(async () => page.getByTestId("economic-queue-history-status-9301").innerText(), {
|
||||
timeout: 12_000,
|
||||
}).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 retryTriggered = false;
|
||||
const token = await bootstrapAuthenticatedSuperuser(page);
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = route.request().postDataJSON();
|
||||
expect(payload).toEqual({ job_id: 9402 });
|
||||
retryCalls += 1;
|
||||
expect(route.request().postDataJSON()).toEqual({ job_id: 9402 });
|
||||
retryTriggered = true;
|
||||
|
||||
await route.fulfill(json({
|
||||
@@ -135,110 +281,286 @@ test.describe("Invoice transfer queue history page", () => {
|
||||
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) => {
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
queueRequests.push({
|
||||
status: url.searchParams.get("status"),
|
||||
limit: url.searchParams.get("limit"),
|
||||
offset: url.searchParams.get("offset"),
|
||||
});
|
||||
|
||||
await route.fulfill(json({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: 9501,
|
||||
id: 9401,
|
||||
status: "COMPLETED",
|
||||
progress_percent: 100,
|
||||
progress_message: "Completed",
|
||||
result: {
|
||||
message: "Transfer completed.",
|
||||
},
|
||||
collected_invoice_id: 7301,
|
||||
collected_invoice_id: 7401,
|
||||
attempts: 1,
|
||||
max_attempts: 3,
|
||||
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 page.getByTestId("economic-queue-history-status-filter").selectOption("COMPLETED");
|
||||
await page.getByTestId("economic-queue-history-limit-filter").selectOption("10");
|
||||
await page.getByTestId("economic-queue-history-next-page").click();
|
||||
await expect(page.getByTestId("economic-queue-history-retry-9401")).toHaveCount(0);
|
||||
await expect(page.getByTestId("economic-queue-history-retry-9403")).toBeDisabled();
|
||||
|
||||
await expect.poll(() => queueRequests.some((request) => request.status === "COMPLETED")).toBeTruthy();
|
||||
await expect.poll(() => queueRequests.some((request) => request.limit === "10")).toBeTruthy();
|
||||
await expect.poll(() => queueRequests.some((request) => request.limit === "10" && request.offset === "10")).toBeTruthy();
|
||||
await expect(page.getByTestId("economic-queue-history-row-9501")).toBeVisible();
|
||||
await page.getByTestId("economic-queue-history-retry-9402").click();
|
||||
await expect.poll(() => retryCalls, { timeout: 8_000 }).toBe(1);
|
||||
await expect(page.getByTestId("economic-queue-history-status-9402")).toContainText("QUEUED");
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
await page.addInitScript(() => {
|
||||
const STYLE_ID = "__e2e-hide-vue-devtools";
|
||||
|
||||
const apply = () => {
|
||||
const target = document.head || document.documentElement;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!document.getElementById(STYLE_ID)) {
|
||||
const style = document.createElement("style");
|
||||
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; }";
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
target.appendChild(style);
|
||||
}
|
||||
|
||||
const container = document.getElementById("__vue-devtools-container__");
|
||||
@@ -46,6 +56,12 @@ function createPeriodPayload() {
|
||||
transactions: [
|
||||
{ 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: {
|
||||
fixed_pricing: {
|
||||
price: 500,
|
||||
@@ -60,6 +76,12 @@ function createPeriodPayload() {
|
||||
transactions: [
|
||||
{ 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: {},
|
||||
},
|
||||
],
|
||||
@@ -72,6 +94,12 @@ function createPeriodPayload() {
|
||||
transactions: [
|
||||
{ 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: {},
|
||||
},
|
||||
],
|
||||
@@ -82,6 +110,12 @@ function createPeriodPayload() {
|
||||
customer_name: "Acme Fleet",
|
||||
requires_action: true,
|
||||
transactions: [],
|
||||
queue: {
|
||||
has_active_job: false,
|
||||
statuses: [],
|
||||
invoice_collection_ids: [],
|
||||
is_action_blocked: false,
|
||||
},
|
||||
meta: {
|
||||
fixed_pricing: {
|
||||
price: 500,
|
||||
@@ -100,6 +134,12 @@ function createPeriodPayload() {
|
||||
transactions: [
|
||||
{ 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: {},
|
||||
},
|
||||
],
|
||||
@@ -120,6 +160,12 @@ function createPeriodPayloadForChangedRange() {
|
||||
transactions: [
|
||||
{ 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: {},
|
||||
},
|
||||
],
|
||||
@@ -132,6 +178,12 @@ function createPeriodPayloadForChangedRange() {
|
||||
transactions: [
|
||||
{ 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: {},
|
||||
},
|
||||
],
|
||||
@@ -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) {
|
||||
let initialRange = null;
|
||||
|
||||
@@ -154,6 +246,10 @@ async function setupPeriodEndpoints(page, requests) {
|
||||
}
|
||||
|
||||
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 dateTo = url.searchParams.get("dateTo");
|
||||
requests.push({
|
||||
@@ -257,19 +353,42 @@ async function openPeriodView(page) {
|
||||
authenticated: true,
|
||||
permissions: ["superuser", "user"],
|
||||
loginToken: token,
|
||||
invoiceDistribution: true,
|
||||
});
|
||||
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 expect(page).toHaveURL(/activeTab=period/);
|
||||
await expect(page.getByTestId("invoicing-period-view")).toBeVisible();
|
||||
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("@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 }) => {
|
||||
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.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);
|
||||
|
||||
await page.getByTestId("invoicing-period-view-selector-all").click();
|
||||
const initialRequestCount = periodRequests.length;
|
||||
await page.getByTestId("invoicing-period-reload-button").click();
|
||||
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).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 { createI18n } from "vue-i18n";
|
||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const getUserIdMock = vi.hoisted(() =>
|
||||
vi.fn(() => Promise.resolve({ data: { data: { user_id: 777 } } }))
|
||||
@@ -128,6 +129,7 @@ const flushMicrotasks = async () => {
|
||||
describe("ActionSettingsWheelButton", () => {
|
||||
beforeEach(() => {
|
||||
getUserIdMock.mockClear();
|
||||
SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm.mockClear();
|
||||
});
|
||||
|
||||
it("resolves customer user id once on mount and not on unrelated rerenders", async () => {
|
||||
@@ -181,4 +183,80 @@ describe("ActionSettingsWheelButton", () => {
|
||||
await flushMicrotasks();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
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) => ({
|
||||
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 () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -265,4 +297,405 @@ describe("economic transfer queue workflow", () => {
|
||||
expect(statusCallCount).toBe(1);
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user