Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard c26da10677 Warn before invoicing multiple months together 2026-07-06 13:36:50 +02:00
20 changed files with 931 additions and 25 deletions
@@ -12,6 +12,11 @@ import UserOtherVaskeabonnement
import Swal from "sweetalert2"; import Swal from "sweetalert2";
import OrderItemsTable from "@/components/displays/department/pos/order/orderItemsTable.vue"; import OrderItemsTable from "@/components/displays/department/pos/order/orderItemsTable.vue";
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue"; import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
import {
buildMultiMonthInvoiceContext,
MULTI_MONTH_INVOICE_ACTION,
promptMultiMonthInvoiceWarning,
} from "@/services/invoiceMonthSplitWarning.js";
const props = defineProps({ const props = defineProps({
orders: { orders: {
@@ -126,32 +131,34 @@ const isAnyOrderSelected = () => {
return selectedInvoiceCollections.value.length > 0; return selectedInvoiceCollections.value.length > 0;
} }
const getSelectedOrders = () => {
return props.orders.filter((order) => selectedInvoiceCollections.value.includes(order.invoice_collection_id));
}
/** Invoice collections */ /** Invoice collections */
const onInvoiceCollections = async () => { const onInvoiceCollections = async () => {
// Check if any orders are selected // Check if any orders are selected
if (selectedInvoiceCollections.value.length === 0) { if (selectedInvoiceCollections.value.length === 0) {
return; return;
} }
/** Create the invoice */ const selectedOrders = getSelectedOrders();
const onCreateInvoiceDraft = async () => { const invoiceWarningContext = buildMultiMonthInvoiceContext(selectedOrders, {
// Create the invoice getDate: (order) => order?.created_at ?? order?.date,
console.log('Create invoice'); getInvoiceCollectionId: (order) => order?.invoice_collection_id,
await SessionUser.objects.collectedOrderInvoices.functions.economic.invoice(parseInt(props.collectedOrderInvoice.id)).then((response) => {
console.log('Invoice created successfully', response);
Swal.fire({
title: 'Fakturaen er oprettet',
text: 'Fakturaen er oprettet i E-conomic',
icon: 'success',
showConfirmButton: false,
timer: 2000
}).then(() => {
location.reload();
}); });
}).catch((error) => { const invoiceWarningAction = await promptMultiMonthInvoiceWarning({
console.log('Error creating invoice', error); context: invoiceWarningContext,
splitByMonth: SessionUser.objects.collectedOrderInvoices.functions.split_by_month,
parseErrorMessage: SessionUser.functions.parseErrorMessage,
});
if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.SPLIT) {
location.reload();
return;
} }
) if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.CANCEL) {
return;
} }
for (let i = 0; i < selectedInvoiceCollections.value.length; i++) { for (let i = 0; i < selectedInvoiceCollections.value.length; i++) {
const selectedInvoiceCollectionId = selectedInvoiceCollections.value[i]; const selectedInvoiceCollectionId = selectedInvoiceCollections.value[i];
// Check if the invoice collection is already booked // Check if the invoice collection is already booked
@@ -210,6 +217,7 @@ const isOrderContentVisible = (order) => {
class="button is-small" class="button is-small"
@click="onInvoiceCollections()" @click="onInvoiceCollections()"
:disabled="!isAnyOrderSelected()" :disabled="!isAnyOrderSelected()"
data-testid="invoice-order-table-invoice-button"
> >
{{ $t('global.invoice_now') }} {{ $t('global.invoice_now') }}
</button> </button>
@@ -444,10 +444,14 @@ export const CollectedOrderInvoices = {
}); });
}, },
split_by_month: async (dateFrom, dateTo, options = {}) => { split_by_month: async (dateFrom, dateTo, options = {}) => {
const invoiceCollectionIds = Array.isArray(options.invoiceCollectionIds)
? options.invoiceCollectionIds
: options.invoice_collection_ids;
return authenticatedRequest('/collected-invoices/split-by-month', 'POST', { return authenticatedRequest('/collected-invoices/split-by-month', 'POST', {
dateFrom, dateFrom,
dateTo, dateTo,
...(options.preview !== undefined ? { preview: !!options.preview } : {}), ...(options.preview !== undefined ? { preview: !!options.preview } : {}),
...(Array.isArray(invoiceCollectionIds) ? { invoice_collection_ids: invoiceCollectionIds } : {}),
}).then((response) => { }).then((response) => {
console.log(response); console.log(response);
return response; return response;
+9
View File
@@ -4028,6 +4028,15 @@
"preview_title": "@:{'words.generated.forhandsvis'} @:{'words.generated.manedsopdeling'}", "preview_title": "@:{'words.generated.forhandsvis'} @:{'words.generated.manedsopdeling'}",
"success_text": "@:{'words.generated.behandlede'} {processed} @:{'words.generated.fakturasamlinger'}. Opdelte {changed} @:{'words.generated.og'} sprang {skipped} @:{'words.generated.over'}.", "success_text": "@:{'words.generated.behandlede'} {processed} @:{'words.generated.fakturasamlinger'}. Opdelte {changed} @:{'words.generated.og'} sprang {skipped} @:{'words.generated.over'}.",
"success_title": "@.capitalize:{'words.generated.manedsopdeling'} @:{'words.generated.fuldført'}" "success_title": "@.capitalize:{'words.generated.manedsopdeling'} @:{'words.generated.fuldført'}"
},
"multi_month_invoice_warning": {
"invoice_together": "Fakturer sammen",
"split_by_month": "Opdel efter måned",
"split_error_title": "Månedsopdeling mislykkedes",
"split_success_text": "Behandlede {processed} fakturasamlinger. Opdelte {changed} og sprang {skipped} over.",
"split_success_title": "Månedsopdeling fuldført",
"text": "Du er ved at fakturere ordrer fra flere måneder sammen ({months}). Skal de i stedet opdeles efter måned?",
"title": "Ordrer fra flere måneder"
} }
}, },
"invoicing": { "invoicing": {
+9
View File
@@ -4139,6 +4139,15 @@
"preview_title": "Preview @:{'words.generated.monthly'} @:{'words.generated.split'}", "preview_title": "Preview @:{'words.generated.monthly'} @:{'words.generated.split'}",
"success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.", "success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.",
"success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}" "success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}"
},
"multi_month_invoice_warning": {
"invoice_together": "Zusammen abrechnen",
"split_by_month": "Nach Monat aufteilen",
"split_error_title": "Monatsaufteilung fehlgeschlagen",
"split_success_text": "{processed} Rechnungssammlungen verarbeitet. {changed} aufgeteilt, {skipped} übersprungen.",
"split_success_title": "Monatsaufteilung abgeschlossen",
"text": "Sie sind dabei, Aufträge aus mehreren Monaten gemeinsam abzurechnen ({months}). Sollen sie stattdessen nach Monat aufgeteilt werden?",
"title": "Aufträge aus mehreren Monaten"
} }
}, },
"invoicing": { "invoicing": {
+9
View File
@@ -3860,6 +3860,15 @@
"preview_title": "@.capitalize:{'words.generated.preview'} @:{'words.generated.monthly'} @:{'words.generated.split'}", "preview_title": "@.capitalize:{'words.generated.preview'} @:{'words.generated.monthly'} @:{'words.generated.split'}",
"success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.", "success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.",
"success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}" "success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}"
},
"multi_month_invoice_warning": {
"invoice_together": "Invoice together",
"split_by_month": "Split by month",
"split_error_title": "Monthly split failed",
"split_success_text": "Processed {processed} invoice collections. Split {changed}, skipped {skipped}.",
"split_success_title": "Monthly split completed",
"text": "You are about to invoice orders from multiple months together ({months}). Should they be split by month instead?",
"title": "Orders from multiple months"
} }
}, },
"invoicing": { "invoicing": {
+9
View File
@@ -3149,6 +3149,15 @@
"preview_title": "@:{'templates.generated.compat.invoicing_period.monthly_split.preview_title'}", "preview_title": "@:{'templates.generated.compat.invoicing_period.monthly_split.preview_title'}",
"success_text": "@:{'templates.generated.compat.invoicing_period.monthly_split.success_text'}", "success_text": "@:{'templates.generated.compat.invoicing_period.monthly_split.success_text'}",
"success_title": "@:{'templates.generated.compat.invoicing_period.monthly_split.success_title'}" "success_title": "@:{'templates.generated.compat.invoicing_period.monthly_split.success_title'}"
},
"multi_month_invoice_warning": {
"invoice_together": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.invoice_together'}",
"split_by_month": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_by_month'}",
"split_error_title": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_error_title'}",
"split_success_text": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_success_text'}",
"split_success_title": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_success_title'}",
"text": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.text'}",
"title": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.title'}"
} }
}, },
"invoicing": { "invoicing": {
+9
View File
@@ -4142,6 +4142,15 @@
"preview_title": "Preview @:{'words.generated.monthly'} @:{'words.generated.split'}", "preview_title": "Preview @:{'words.generated.monthly'} @:{'words.generated.split'}",
"success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.", "success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.",
"success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}" "success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}"
},
"multi_month_invoice_warning": {
"invoice_together": "Fakturer samlet",
"split_by_month": "Del opp etter måned",
"split_error_title": "Månedsdeling mislyktes",
"split_success_text": "Behandlet {processed} fakturasamlinger. Delte opp {changed}, hoppet over {skipped}.",
"split_success_title": "Månedsdeling fullført",
"text": "Du er i ferd med å fakturere ordrer fra flere måneder samlet ({months}). Skal de i stedet deles opp etter måned?",
"title": "Ordrer fra flere måneder"
} }
}, },
"invoicing": { "invoicing": {
+9
View File
@@ -4192,6 +4192,15 @@
"preview_title": "Preview @:{'words.generated.monthly'} @:{'words.generated.split'}", "preview_title": "Preview @:{'words.generated.monthly'} @:{'words.generated.split'}",
"success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.", "success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.",
"success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}" "success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}"
},
"multi_month_invoice_warning": {
"invoice_together": "Fakturera tillsammans",
"split_by_month": "Dela upp per månad",
"split_error_title": "Månadsuppdelning misslyckades",
"split_success_text": "Bearbetade {processed} fakturasamlingar. Delade upp {changed}, hoppade över {skipped}.",
"split_success_title": "Månadsuppdelning klar",
"text": "Du håller på att fakturera ordrar från flera månader tillsammans ({months}). Ska de delas upp per månad i stället?",
"title": "Ordrar från flera månader"
} }
}, },
"invoicing": { "invoicing": {
@@ -0,0 +1,15 @@
{
"compat": {
"invoicing_period": {
"multi_month_invoice_warning": {
"invoice_together": "Fakturer sammen",
"split_by_month": "Opdel efter måned",
"split_error_title": "Månedsopdeling mislykkedes",
"split_success_text": "Behandlede {processed} fakturasamlinger. Opdelte {changed} og sprang {skipped} over.",
"split_success_title": "Månedsopdeling fuldført",
"text": "Du er ved at fakturere ordrer fra flere måneder sammen ({months}). Skal de i stedet opdeles efter måned?",
"title": "Ordrer fra flere måneder"
}
}
}
}
@@ -0,0 +1,15 @@
{
"compat": {
"invoicing_period": {
"multi_month_invoice_warning": {
"invoice_together": "Zusammen abrechnen",
"split_by_month": "Nach Monat aufteilen",
"split_error_title": "Monatsaufteilung fehlgeschlagen",
"split_success_text": "{processed} Rechnungssammlungen verarbeitet. {changed} aufgeteilt, {skipped} übersprungen.",
"split_success_title": "Monatsaufteilung abgeschlossen",
"text": "Sie sind dabei, Aufträge aus mehreren Monaten gemeinsam abzurechnen ({months}). Sollen sie stattdessen nach Monat aufgeteilt werden?",
"title": "Aufträge aus mehreren Monaten"
}
}
}
}
@@ -0,0 +1,15 @@
{
"compat": {
"invoicing_period": {
"multi_month_invoice_warning": {
"invoice_together": "Invoice together",
"split_by_month": "Split by month",
"split_error_title": "Monthly split failed",
"split_success_text": "Processed {processed} invoice collections. Split {changed}, skipped {skipped}.",
"split_success_title": "Monthly split completed",
"text": "You are about to invoice orders from multiple months together ({months}). Should they be split by month instead?",
"title": "Orders from multiple months"
}
}
}
}
@@ -0,0 +1,13 @@
{
"invoicing_period": {
"multi_month_invoice_warning": {
"invoice_together": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.invoice_together'}",
"split_by_month": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_by_month'}",
"split_error_title": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_error_title'}",
"split_success_text": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_success_text'}",
"split_success_title": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_success_title'}",
"text": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.text'}",
"title": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.title'}"
}
}
}
@@ -0,0 +1,15 @@
{
"compat": {
"invoicing_period": {
"multi_month_invoice_warning": {
"invoice_together": "Fakturer samlet",
"split_by_month": "Del opp etter måned",
"split_error_title": "Månedsdeling mislyktes",
"split_success_text": "Behandlet {processed} fakturasamlinger. Delte opp {changed}, hoppet over {skipped}.",
"split_success_title": "Månedsdeling fullført",
"text": "Du er i ferd med å fakturere ordrer fra flere måneder samlet ({months}). Skal de i stedet deles opp etter måned?",
"title": "Ordrer fra flere måneder"
}
}
}
}
@@ -0,0 +1,15 @@
{
"compat": {
"invoicing_period": {
"multi_month_invoice_warning": {
"invoice_together": "Fakturera tillsammans",
"split_by_month": "Dela upp per månad",
"split_error_title": "Månadsuppdelning misslyckades",
"split_success_text": "Bearbetade {processed} fakturasamlingar. Delade upp {changed}, hoppade över {skipped}.",
"split_success_title": "Månadsuppdelning klar",
"text": "Du håller på att fakturera ordrar från flera månader tillsammans ({months}). Ska de delas upp per månad i stället?",
"title": "Ordrar från flera månader"
}
}
}
}
+139
View File
@@ -0,0 +1,139 @@
import Swal from "sweetalert2";
import i18n from "@/i18n";
export const MULTI_MONTH_INVOICE_ACTION = {
CONTINUE: "continue",
CANCEL: "cancel",
SPLIT: "split",
};
const toPositiveInteger = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const getDateKey = (value) => {
const rawValue = String(value ?? "").trim();
const directMatch = rawValue.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (directMatch) {
return `${directMatch[1]}-${directMatch[2]}-${directMatch[3]}`;
}
const parsedDate = new Date(rawValue);
if (Number.isNaN(parsedDate.getTime())) {
return null;
}
const year = parsedDate.getFullYear();
const month = String(parsedDate.getMonth() + 1).padStart(2, "0");
const day = String(parsedDate.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
export const getMonthKey = (value) => {
const dateKey = getDateKey(value);
return dateKey ? dateKey.slice(0, 7) : null;
};
const translate = (key, params = {}) => i18n.global.t(key, params);
const getSplitResponsePayload = (response = {}) => response?.data?.data ?? response?.data ?? {};
export const buildMultiMonthInvoiceContext = (
items = [],
{
getDate = (item) => item?.created_at ?? item?.date,
getInvoiceCollectionId = (item) => item?.invoice_collection_id,
} = {}
) => {
const months = new Set();
const dateKeys = [];
const invoiceCollectionIds = new Set();
(Array.isArray(items) ? items : []).forEach((item) => {
const dateValue = getDate(item);
const dateKey = getDateKey(dateValue);
if (dateKey) {
dateKeys.push(dateKey);
months.add(dateKey.slice(0, 7));
}
const invoiceCollectionId = toPositiveInteger(getInvoiceCollectionId(item));
if (invoiceCollectionId) {
invoiceCollectionIds.add(invoiceCollectionId);
}
});
dateKeys.sort();
return {
months: Array.from(months).sort(),
dateFrom: dateKeys[0] ?? null,
dateTo: dateKeys[dateKeys.length - 1] ?? null,
invoiceCollectionIds: Array.from(invoiceCollectionIds).sort((left, right) => left - right),
};
};
export const shouldWarnAboutMultiMonthInvoice = (context = {}) => (
Array.isArray(context.months) &&
context.months.length > 1 &&
Array.isArray(context.invoiceCollectionIds) &&
context.invoiceCollectionIds.length > 0 &&
Boolean(context.dateFrom) &&
Boolean(context.dateTo)
);
export const promptMultiMonthInvoiceWarning = async ({
context,
splitByMonth,
parseErrorMessage = (error) => error?.message ?? String(error),
} = {}) => {
if (!shouldWarnAboutMultiMonthInvoice(context)) {
return MULTI_MONTH_INVOICE_ACTION.CONTINUE;
}
const months = context.months.join(", ");
const confirmation = await Swal.fire({
icon: "warning",
title: translate("invoicing_period.multi_month_invoice_warning.title"),
text: translate("invoicing_period.multi_month_invoice_warning.text", { months }),
showCancelButton: true,
showDenyButton: true,
confirmButtonText: translate("invoicing_period.multi_month_invoice_warning.split_by_month"),
denyButtonText: translate("invoicing_period.multi_month_invoice_warning.invoice_together"),
cancelButtonText: translate("common.cancel"),
});
if (confirmation.isDenied) {
return MULTI_MONTH_INVOICE_ACTION.CONTINUE;
}
if (!confirmation.isConfirmed) {
return MULTI_MONTH_INVOICE_ACTION.CANCEL;
}
try {
const response = await splitByMonth(context.dateFrom, context.dateTo, {
invoiceCollectionIds: context.invoiceCollectionIds,
preview: false,
});
const result = getSplitResponsePayload(response);
await Swal.fire({
icon: "success",
title: translate("invoicing_period.multi_month_invoice_warning.split_success_title"),
text: translate("invoicing_period.multi_month_invoice_warning.split_success_text", {
processed: result.processed_count ?? 0,
changed: result.changed_count ?? 0,
skipped: result.skipped_count ?? 0,
}),
});
return MULTI_MONTH_INVOICE_ACTION.SPLIT;
} catch (error) {
await Swal.fire({
icon: "error",
title: translate("invoicing_period.multi_month_invoice_warning.split_error_title"),
text: parseErrorMessage(error),
});
return MULTI_MONTH_INVOICE_ACTION.CANCEL;
}
};
@@ -22,6 +22,11 @@ import InvoicingBillingPeriodFilters from "@/views/dashboards/superUserDashboard
import InvoicingBillingPeriodCustomerAttributes from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue"; import InvoicingBillingPeriodCustomerAttributes from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue";
import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue"; import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue"; import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import {
buildMultiMonthInvoiceContext,
MULTI_MONTH_INVOICE_ACTION,
promptMultiMonthInvoiceWarning,
} from "@/services/invoiceMonthSplitWarning.js";
import { import {
buildPossibleDuplicateGroups, buildPossibleDuplicateGroups,
formatDuplicateDateLabel, formatDuplicateDateLabel,
@@ -330,6 +335,13 @@ const parsePositiveInteger = (value: any) => {
const getCustomerNumber = (customer: any) => parsePositiveInteger(customer?.customer_number); const getCustomerNumber = (customer: any) => parsePositiveInteger(customer?.customer_number);
const markCustomerPeriodRefreshLoading = (customer: any) => {
const customerNumber = getCustomerNumber(customer);
if (customerNumber) {
invoiceQueue.markPeriodRefreshLoading?.([customerNumber], []);
}
};
const queueInvoiceCollections = (invoiceCollectionIds: number[], customer: any = null) => { const queueInvoiceCollections = (invoiceCollectionIds: number[], customer: any = null) => {
const uniqueInvoiceCollectionIds = Array.from( const uniqueInvoiceCollectionIds = Array.from(
new Set( new Set(
@@ -351,12 +363,10 @@ const queueInvoiceCollections = (invoiceCollectionIds: number[], customer: any =
const onClickInvoiceNow = async (customer: any, transactionIds: number[]) => { const onClickInvoiceNow = async (customer: any, transactionIds: number[]) => {
const customerNumber = getCustomerNumber(customer); const customerNumber = getCustomerNumber(customer);
if (customerNumber) {
invoiceQueue.markPeriodRefreshLoading?.([customerNumber], []);
}
try { try {
if (transactionIds.length === 0) { if (transactionIds.length === 0) {
markCustomerPeriodRefreshLoading(customer);
const month = dates.variables.start.value.getMonth() + 1; const month = dates.variables.start.value.getMonth() + 1;
const year = dates.variables.start.value.getFullYear(); const year = dates.variables.start.value.getFullYear();
@@ -392,10 +402,32 @@ const onClickInvoiceNow = async (customer: any, transactionIds: number[]) => {
await fetchMissingInvoiceCollections(customer, transactionIds); await fetchMissingInvoiceCollections(customer, transactionIds);
const invoiceCollectionIds = getInvoiceCollectionIdsForTransactionIds(customer, transactionIds); const invoiceCollectionIds = getInvoiceCollectionIdsForTransactionIds(customer, transactionIds);
if (invoiceCollectionIds.length === 0) { if (invoiceCollectionIds.length === 0) {
invoiceQueue.finishPeriodRefresh?.([customerNumber], []);
return; return;
} }
const invoiceWarningContext = buildMultiMonthInvoiceContext(
transactionIds
.map((transactionId) => getTransactionById(customer, transactionId))
.filter((transaction: any) => transaction !== null),
{
getDate: (transaction: any) => transaction?.date ?? transaction?.created_at,
getInvoiceCollectionId: (transaction: any) => getTransactionInvoiceCollectionId(transaction),
}
);
const invoiceWarningAction = await promptMultiMonthInvoiceWarning({
context: invoiceWarningContext,
splitByMonth: SessionUser.objects.collectedOrderInvoices.functions.split_by_month,
parseErrorMessage: SessionUser.functions.parseErrorMessage,
});
if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.SPLIT) {
reloadPeriodPage();
return;
}
if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.CANCEL) {
return;
}
markCustomerPeriodRefreshLoading(customer);
queueInvoiceCollections(invoiceCollectionIds, customer); queueInvoiceCollections(invoiceCollectionIds, customer);
} catch (error: any) { } catch (error: any) {
invoiceQueue.finishPeriodRefresh?.([customerNumber], []); invoiceQueue.finishPeriodRefresh?.([customerNumber], []);
+105
View File
@@ -1933,6 +1933,111 @@ test.describe("Invoicing period tab", () => {
await expect(page).toHaveURL(/activeTab=period/); await expect(page).toHaveURL(/activeTab=period/);
}); });
test("@smoke period view warns and splits selected multi-month invoice collections", async ({ page }) => {
const splitRequests = [];
const economicInvoiceRequests = [];
await openPeriodView(page, {
payloadFactory: () => ({
types: {
all: [
{
id: 31,
customer_number: 4301,
customer_name: "Multi Month Fleet",
requires_action: true,
transactions: [
{
id: 8801,
date: "2026-03-28T10:00:00.000Z",
amount: 120,
booked: false,
excluded: false,
invoice_collection_id: 88001,
},
{
id: 8802,
date: "2026-04-02T10:00:00.000Z",
amount: 180,
booked: false,
excluded: false,
invoice_collection_id: 88001,
},
],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
},
],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
}),
});
await page.route("**/collected-invoices/split-by-month**", async (route) => {
if (
route.request().method() !== "POST" ||
!matchesApiPath(route.request().url(), "/collected-invoices/split-by-month")
) {
await route.fallback();
return;
}
const payload = JSON.parse(route.request().postData() || "{}");
splitRequests.push(payload);
await route.fulfill(
json({
data: {
preview: false,
processed_count: 1,
changed_count: 1,
skipped_count: 0,
},
})
);
});
await page.route("**/collected-invoices/economic**", async (route) => {
if (
route.request().method() === "POST" &&
matchesApiPath(route.request().url(), "/collected-invoices/economic")
) {
economicInvoiceRequests.push(JSON.parse(route.request().postData() || "{}"));
await route.fulfill(json({ data: {} }));
return;
}
await route.fallback();
});
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-4301")).toBeVisible();
await page.getByTestId("invoicing-period-customer-invoice-4301").click();
await expect(
page.getByRole("heading", { name: /Orders from multiple months|Ordrer fra flere måneder/i })
).toBeVisible();
await page.getByRole("button", { name: /Split by month|Opdel efter måned/i }).click();
await expect.poll(() => splitRequests.length).toBe(1);
expect(splitRequests[0]).toEqual({
dateFrom: "2026-03-28",
dateTo: "2026-04-02",
invoice_collection_ids: [88001],
preview: false,
});
expect(economicInvoiceRequests).toEqual([]);
await expect(page.getByText(/Monthly split completed|Månedsopdeling fuldført/i)).toBeVisible();
});
test("@smoke period view invoices multiple customers with page refresh and independent loading", async ({ page }) => { test("@smoke period view invoices multiple customers with page refresh and independent loading", async ({ page }) => {
const token = "superuser-period-parallel-token"; const token = "superuser-period-parallel-token";
const periodRequests = []; const periodRequests = [];
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("sweetalert2", () => ({
default: {
fire: vi.fn(),
},
}));
import Swal from "sweetalert2";
import {
buildMultiMonthInvoiceContext,
MULTI_MONTH_INVOICE_ACTION,
promptMultiMonthInvoiceWarning,
shouldWarnAboutMultiMonthInvoice,
} from "@/services/invoiceMonthSplitWarning.js";
describe("invoice month split warning", () => {
beforeEach(() => {
Swal.fire.mockReset();
});
it("builds a scoped multi-month context without timezone shifting date strings", () => {
const context = buildMultiMonthInvoiceContext([
{ id: 1, created_at: "2026-03-31 23:30:00", invoice_collection_id: 501 },
{ id: 2, created_at: "2026-04-01T00:30:00.000Z", invoice_collection_id: "501" },
{ id: 3, created_at: "2026-04-03 09:00:00", invoice_collection_id: 502 },
{ id: 4, created_at: "invalid", invoice_collection_id: null },
]);
expect(context).toEqual({
months: ["2026-03", "2026-04"],
dateFrom: "2026-03-31",
dateTo: "2026-04-03",
invoiceCollectionIds: [501, 502],
});
expect(shouldWarnAboutMultiMonthInvoice(context)).toBe(true);
});
it("continues without a modal for single-month selections", async () => {
const action = await promptMultiMonthInvoiceWarning({
context: buildMultiMonthInvoiceContext([
{ created_at: "2026-04-01 10:00:00", invoice_collection_id: 501 },
{ created_at: "2026-04-02 10:00:00", invoice_collection_id: 501 },
]),
splitByMonth: vi.fn(),
});
expect(action).toBe(MULTI_MONTH_INVOICE_ACTION.CONTINUE);
expect(Swal.fire).not.toHaveBeenCalled();
});
it("splits selected invoice collections when the warning is confirmed", async () => {
const splitByMonth = vi.fn().mockResolvedValue({
data: {
data: {
processed_count: 2,
changed_count: 1,
skipped_count: 1,
},
},
});
Swal.fire.mockResolvedValueOnce({ isConfirmed: true }).mockResolvedValueOnce({ isConfirmed: true });
const action = await promptMultiMonthInvoiceWarning({
context: buildMultiMonthInvoiceContext([
{ created_at: "2026-03-20 10:00:00", invoice_collection_id: 7001 },
{ created_at: "2026-04-02 10:00:00", invoice_collection_id: 7001 },
]),
splitByMonth,
});
expect(action).toBe(MULTI_MONTH_INVOICE_ACTION.SPLIT);
expect(splitByMonth).toHaveBeenCalledWith("2026-03-20", "2026-04-02", {
invoiceCollectionIds: [7001],
preview: false,
});
expect(Swal.fire).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
icon: "warning",
showDenyButton: true,
})
);
expect(Swal.fire).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
icon: "success",
})
);
});
it("continues invoicing together when the warning deny button is selected", async () => {
const splitByMonth = vi.fn();
Swal.fire.mockResolvedValueOnce({ isDenied: true });
const action = await promptMultiMonthInvoiceWarning({
context: buildMultiMonthInvoiceContext([
{ created_at: "2026-03-20 10:00:00", invoice_collection_id: 7001 },
{ created_at: "2026-04-02 10:00:00", invoice_collection_id: 7001 },
]),
splitByMonth,
});
expect(action).toBe(MULTI_MONTH_INVOICE_ACTION.CONTINUE);
expect(splitByMonth).not.toHaveBeenCalled();
});
it("cancels invoicing when the warning is dismissed", async () => {
const splitByMonth = vi.fn();
Swal.fire.mockResolvedValueOnce({ isDismissed: true });
const action = await promptMultiMonthInvoiceWarning({
context: buildMultiMonthInvoiceContext([
{ created_at: "2026-03-20 10:00:00", invoice_collection_id: 7001 },
{ created_at: "2026-04-02 10:00:00", invoice_collection_id: 7001 },
]),
splitByMonth,
});
expect(action).toBe(MULTI_MONTH_INVOICE_ACTION.CANCEL);
expect(splitByMonth).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,170 @@
// @vitest-environment jsdom
import { flushPromises, mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("vue-i18n", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
useI18n: () => ({
t: (key) => key,
}),
};
});
vi.mock("sweetalert2", () => ({
default: {
fire: vi.fn(),
},
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
objects: {
global: {
language: {
hide_content: "Hide",
show_content: "Show",
status: "Status",
completed: "Completed",
not_completed: "Not completed",
},
},
orders: {
columns: {
id: { label: "ID", visible: true },
created_at: { label: "Created", visible: true },
},
},
collectedOrderInvoices: {
functions: {
split_by_month: vi.fn(),
economic: {
invoice: vi.fn(),
},
},
},
vehicles: {
columns: {
wash_subscription: {
label: "Subscription",
},
},
},
},
functions: {
currency: {
toLocal: (value) => String(value),
},
parseErrorMessage: (error) => error?.message ?? String(error),
},
},
}));
vi.mock("@/views/dashboards/superUserDashboard/user/displays/other/UserOtherSpecialArrangement.vue", () => ({
default: { template: "<div />" },
}));
vi.mock("@/views/dashboards/superUserDashboard/user/displays/other/UserOtherVaskeabonnement.vue", () => ({
default: { template: "<div />" },
}));
vi.mock("@/components/displays/department/pos/order/orderItemsTable.vue", () => ({
default: { template: "<div />" },
}));
vi.mock("@/components/displays/superuser/tables/OrderContentTable.vue", () => ({
default: { template: "<div />" },
}));
import Swal from "sweetalert2";
import InvoiceOrderTable from "@/components/displays/superuser/tables/InvoiceOrderTable.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const mountTable = (orders) =>
mount(InvoiceOrderTable, {
props: {
orders,
options: {},
columns: {},
user_id: 42,
},
global: {
mocks: {
$t: (key) =>
({
"global.invoice_now": "Invoice now",
"global.unselect": "Unselect",
"common.all": "All",
"common.select": "Select",
}[key] ?? key),
},
},
});
describe("InvoiceOrderTable multi-month warning", () => {
beforeEach(() => {
Swal.fire.mockReset();
SessionUser.objects.collectedOrderInvoices.functions.split_by_month.mockReset();
SessionUser.objects.collectedOrderInvoices.functions.economic.invoice.mockReset();
SessionUser.objects.collectedOrderInvoices.functions.economic.invoice.mockResolvedValue({});
});
it("warns for selected invoice collections containing orders from multiple months before invoicing together", async () => {
Swal.fire.mockResolvedValueOnce({ isDenied: true }).mockReturnValueOnce(new Promise(() => {}));
const wrapper = mountTable([
{
id: 1,
invoice_collection_id: 9001,
created_at: "2026-03-15 10:00:00",
},
{
id: 2,
invoice_collection_id: 9001,
created_at: "2026-04-02 10:00:00",
},
]);
await wrapper.find("tbody input[type='checkbox']").trigger("click");
await wrapper.get("[data-testid='invoice-order-table-invoice-button']").trigger("click");
await flushPromises();
expect(Swal.fire).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
icon: "warning",
showDenyButton: true,
})
);
expect(SessionUser.objects.collectedOrderInvoices.functions.split_by_month).not.toHaveBeenCalled();
expect(SessionUser.objects.collectedOrderInvoices.functions.economic.invoice).toHaveBeenCalledWith(9001, 42);
});
it("does not warn for selected invoice collections containing only one month", async () => {
Swal.fire.mockReturnValueOnce(new Promise(() => {}));
const wrapper = mountTable([
{
id: 1,
invoice_collection_id: 9002,
created_at: "2026-04-01 10:00:00",
},
{
id: 2,
invoice_collection_id: 9002,
created_at: "2026-04-02 10:00:00",
},
]);
await wrapper.find("tbody input[type='checkbox']").trigger("click");
await wrapper.get("[data-testid='invoice-order-table-invoice-button']").trigger("click");
await flushPromises();
expect(Swal.fire).toHaveBeenCalledTimes(1);
expect(Swal.fire).toHaveBeenCalledWith(
expect.objectContaining({
title: "Fakturaer oprettet",
})
);
expect(SessionUser.objects.collectedOrderInvoices.functions.economic.invoice).toHaveBeenCalledWith(9002, 42);
});
});
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { computed, nextTick } from "vue"; import { computed, nextTick } from "vue";
import { mount } from "@vue/test-utils"; import { flushPromises, mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { const {
@@ -34,6 +34,12 @@ vi.mock("@/services/economicTransferQueue.js", () => ({
}, },
})); }));
vi.mock("sweetalert2", () => ({
default: {
fire: vi.fn(),
},
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({ vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: { SessionUser: {
objects: { objects: {
@@ -62,6 +68,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
createVehicleSubscriptionInvoice: vi.fn(), createVehicleSubscriptionInvoice: vi.fn(),
add_fixed_pricing: vi.fn(), add_fixed_pricing: vi.fn(),
add_vehicle_subscriptions: vi.fn(), add_vehicle_subscriptions: vi.fn(),
split_by_month: vi.fn(),
}, },
}, },
vehicles: { vehicles: {
@@ -76,6 +83,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
currency: { currency: {
toLocal: (value) => String(value), toLocal: (value) => String(value),
}, },
parseErrorMessage: (error) => error?.message ?? String(error),
}, },
}, },
})); }));
@@ -148,6 +156,7 @@ vi.mock(
import InvoicingBillingPeriodViewAll from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue"; import InvoicingBillingPeriodViewAll from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { invoiceQueue } from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportInvoiceQueue.vue"; import { invoiceQueue } from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportInvoiceQueue.vue";
import Swal from "sweetalert2";
import { import {
periodPaging, periodPaging,
resetPeriodPagingState, resetPeriodPagingState,
@@ -222,11 +231,23 @@ describe("Invoicing period queue state", () => {
loadingCustomerNumbersRef.value = []; loadingCustomerNumbersRef.value = [];
SessionUser.objects.orders.get.multiple.mockReset(); SessionUser.objects.orders.get.multiple.mockReset();
SessionUser.objects.orders.get.multiple.mockResolvedValue([]); SessionUser.objects.orders.get.multiple.mockResolvedValue([]);
SessionUser.objects.collectedOrderInvoices.functions.split_by_month.mockReset();
SessionUser.objects.collectedOrderInvoices.functions.split_by_month.mockResolvedValue({
data: {
data: {
processed_count: 1,
changed_count: 1,
skipped_count: 0,
},
},
});
invoiceQueue.addInvoiceCollectionsToQueue.mockReset(); invoiceQueue.addInvoiceCollectionsToQueue.mockReset();
invoiceQueue.processInvoiceCollectionQueue.mockReset(); invoiceQueue.processInvoiceCollectionQueue.mockReset();
invoiceQueue.markPeriodRefreshLoading.mockClear(); invoiceQueue.markPeriodRefreshLoading.mockClear();
invoiceQueue.finishPeriodRefresh.mockClear(); invoiceQueue.finishPeriodRefresh.mockClear();
invoiceQueue.isPeriodCustomerRefreshLoading.mockClear(); invoiceQueue.isPeriodCustomerRefreshLoading.mockClear();
Swal.fire.mockReset();
Swal.fire.mockResolvedValue({ isDenied: true });
resetPeriodPagingState(); resetPeriodPagingState();
sharedVariablesRef.value = { sharedVariablesRef.value = {
types: { types: {
@@ -644,6 +665,168 @@ describe("Invoicing period queue state", () => {
expect(windowOpenSpy).not.toHaveBeenCalled(); expect(windowOpenSpy).not.toHaveBeenCalled();
}); });
it("splits instead of queueing when multi-month invoicing warning is confirmed", async () => {
sharedVariablesRef.value = {
types: {
all: [
{
id: 8,
customer_number: 1008,
customer_name: "Multi Month Customer",
requires_action: true,
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
transactions: [
{
id: 8101,
amount: 75,
booked: false,
excluded: false,
invoice_collection_id: 8100,
date: "2026-03-28T10:00:00.000Z",
},
{
id: 8102,
amount: 125,
booked: false,
excluded: false,
invoice_collection_id: 8100,
date: "2026-04-02T10:00:00.000Z",
},
],
},
],
},
};
Swal.fire.mockResolvedValueOnce({ isConfirmed: true }).mockResolvedValueOnce({ isConfirmed: true });
const wrapper = mountView();
await nextTick();
await wrapper.get("[data-testid='invoicing-period-customer-invoice-1008']").trigger("click");
await flushPromises();
await nextTick();
expect(SessionUser.objects.collectedOrderInvoices.functions.split_by_month).toHaveBeenCalledWith(
"2026-03-28",
"2026-04-02",
{
invoiceCollectionIds: [8100],
preview: false,
}
);
expect(invoiceQueue.addInvoiceCollectionsToQueue).not.toHaveBeenCalled();
expect(invoiceQueue.processInvoiceCollectionQueue).not.toHaveBeenCalled();
});
it("continues queueing together when multi-month invoicing warning is denied", async () => {
sharedVariablesRef.value = {
types: {
all: [
{
id: 9,
customer_number: 1009,
customer_name: "Invoice Together Customer",
requires_action: true,
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
transactions: [
{
id: 8201,
amount: 75,
booked: false,
excluded: false,
invoice_collection_id: 8200,
date: "2026-03-28T10:00:00.000Z",
},
{
id: 8202,
amount: 125,
booked: false,
excluded: false,
invoice_collection_id: 8200,
date: "2026-04-02T10:00:00.000Z",
},
],
},
],
},
};
Swal.fire.mockResolvedValueOnce({ isDenied: true });
const wrapper = mountView();
await nextTick();
await wrapper.get("[data-testid='invoicing-period-customer-invoice-1009']").trigger("click");
await flushPromises();
await nextTick();
expect(SessionUser.objects.collectedOrderInvoices.functions.split_by_month).not.toHaveBeenCalled();
expect(invoiceQueue.addInvoiceCollectionsToQueue).toHaveBeenCalledWith([8200], {
customerNumber: 1009,
});
expect(invoiceQueue.processInvoiceCollectionQueue).toHaveBeenCalledTimes(1);
});
it("does not queue when multi-month invoicing warning is dismissed", async () => {
sharedVariablesRef.value = {
types: {
all: [
{
id: 10,
customer_number: 1010,
customer_name: "Cancel Multi Month Customer",
requires_action: true,
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
transactions: [
{
id: 8301,
amount: 75,
booked: false,
excluded: false,
invoice_collection_id: 8300,
date: "2026-03-28T10:00:00.000Z",
},
{
id: 8302,
amount: 125,
booked: false,
excluded: false,
invoice_collection_id: 8300,
date: "2026-04-02T10:00:00.000Z",
},
],
},
],
},
};
Swal.fire.mockResolvedValueOnce({ isDismissed: true });
const wrapper = mountView();
await nextTick();
await wrapper.get("[data-testid='invoicing-period-customer-invoice-1010']").trigger("click");
await flushPromises();
await nextTick();
expect(SessionUser.objects.collectedOrderInvoices.functions.split_by_month).not.toHaveBeenCalled();
expect(invoiceQueue.addInvoiceCollectionsToQueue).not.toHaveBeenCalled();
expect(invoiceQueue.processInvoiceCollectionQueue).not.toHaveBeenCalled();
});
it("applies invoice-now loading only to the affected customer", async () => { it("applies invoice-now loading only to the affected customer", async () => {
sharedVariablesRef.value = { sharedVariablesRef.value = {
types: { types: {