Complete selected-customer invoice period tree (#252)

Add the complete selected-customer invoice collection tree, revision-bound actions, fallback handling, and focused frontend coverage.
This commit is contained in:
Jeppe B
2026-08-03 12:15:55 +02:00
committed by GitHub
parent 664b50d4ef
commit f4816124c2
29 changed files with 1621 additions and 190 deletions
@@ -12,6 +12,10 @@ import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/Invoi
import { INVOICE_COLLECTION_BULK_ACTIONS } from "@/components/displays/department/pos/orders/invoiceCollectionBulkActions.js";
import SuperuserInvoiceRowActions from "@/components/displays/superuser/tables/SuperuserInvoiceRowActions.vue";
import { useLargeTableHeaders } from "@/services/tableHeaderPreferences.js";
import {
normalizeInvoiceCollectionActionPreview,
renderInvoiceCollectionActionPreviewHtml,
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoiceCollectionActionPreview.ts";
const props = defineProps({
orders: {
@@ -218,14 +222,6 @@ const normalizeInvoiceCollectionId = (invoiceCollectionId) => {
const getApiPayload = (response) => response?.data?.data ?? response?.data ?? response ?? {};
const escapeHtml = (value) =>
String(value ?? "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
const getInvoiceCollectionResponseOrders = (response) => {
if (Array.isArray(response?.orders)) {
return response.orders;
@@ -503,84 +499,11 @@ const selectableInvoiceCollectionCount = computed(() => getUniqueInvoiceCollecti
const getBulkActionLabel = (action) => t(`invoicing_period.invoice_collection_actions.actions.${action}`);
const renderBulkActionPreviewHtml = (preview) => {
const summary = preview?.summary || {};
const collections = Number(summary.collections ?? preview?.invoice_collection_ids?.length ?? 0);
const changedCount = Number(summary.changed_count ?? summary.order_items ?? summary.orders_to_move ?? 0);
const skippedCount = Number(summary.skipped_count ?? 0);
const blockers = Array.isArray(preview?.blockers) ? preview.blockers : [];
const orderItems = Array.isArray(preview?.order_items) ? preview.order_items : [];
const orders = Array.isArray(preview?.orders) ? preview.orders : [];
const splitItems = Array.isArray(preview?.items) ? preview.items : [];
const detailItems = [
...orderItems.map((item) =>
t("invoicing_period.invoice_collection_actions.preview.order_item_line", {
collection: item.invoice_collection_id,
order: item.order_id,
product: item.product_name || item.product_id,
})
),
...orders.map((item) =>
t("invoicing_period.invoice_collection_actions.preview.order_move_line", {
order: item.order_id,
source: item.source_invoice_collection_id,
target: item.target_invoice_collection_id,
})
),
...splitItems.map((item) =>
t("invoicing_period.invoice_collection_actions.preview.collection_status_line", {
collection: item.invoice_collection_id,
status: item.status,
})
),
].slice(0, 10);
const summaryLines = [
t("invoicing_period.invoice_collection_actions.preview.collections", { count: collections }),
t("invoicing_period.invoice_collection_actions.preview.changed", { count: changedCount }),
...(skippedCount > 0
? [t("invoicing_period.invoice_collection_actions.preview.skipped", { count: skippedCount })]
: []),
...(preview?.target_invoice_collection_id
? [
t("invoicing_period.invoice_collection_actions.preview.merge_target", {
id: preview.target_invoice_collection_id,
}),
]
: []),
];
const html = [
`<p>${escapeHtml(
t("invoicing_period.invoice_collection_actions.preview.requires_confirmation", {
phrase: preview?.confirmation_phrase || "",
})
)}</p>`,
`<ul class="has-text-left">${summaryLines.map((line) => `<li>${escapeHtml(line)}</li>`).join("")}</ul>`,
];
if (detailItems.length > 0) {
html.push(
`<hr><p class="has-text-left has-text-weight-bold">${escapeHtml(
t("invoicing_period.invoice_collection_actions.preview.affected_examples")
)}</p>`
);
html.push(`<ul class="has-text-left">${detailItems.map((line) => `<li>${escapeHtml(line)}</li>`).join("")}</ul>`);
}
if (blockers.length > 0) {
html.push(
`<hr><p class="has-text-left has-text-weight-bold has-text-danger">${escapeHtml(
t("invoicing_period.invoice_collection_actions.preview.blockers")
)}</p>`
);
html.push(
`<ul class="has-text-left">${blockers
.map((blocker) => `<li>${escapeHtml(blocker.message || blocker.code)}</li>`)
.join("")}</ul>`
);
}
return html.join("");
return renderInvoiceCollectionActionPreviewHtml({
preview: normalizeInvoiceCollectionActionPreview(preview, getSelectedInvoiceCollectionIds().length),
t,
formatCurrency: (value) => SessionUser.functions.currency.toLocal(value),
});
};
const chooseMergeTargetInvoiceCollection = async (invoiceCollectionIds) => {
@@ -516,12 +516,24 @@ export const CollectedOrderInvoices = {
throw error;
});
},
bulk_action_preview: async (action, invoiceCollectionIds = [], options = {}, locale = null) => {
bulk_action_preview: async (
action,
invoiceCollectionIds = [],
options = {},
locale = null,
snapshotBinding = {}
) => {
return authenticatedRequest('/collected-invoices/bulk-actions/preview', 'POST', {
action,
invoice_collection_ids: invoiceCollectionIds.map((id) => parseInt(id)).filter((id) => Number.isInteger(id) && id > 0),
options,
...(locale ? { locale } : {}),
...(Number.isInteger(parseInt(snapshotBinding.customer_number))
? { customer_number: parseInt(snapshotBinding.customer_number) }
: {}),
...(snapshotBinding.snapshot_revision
? { snapshot_revision: String(snapshotBinding.snapshot_revision) }
: {}),
}).then((response) => {
console.log(response);
return response;
@@ -537,6 +549,8 @@ export const CollectedOrderInvoices = {
options = {},
confirmation_text,
locale = null,
customer_number = null,
snapshot_revision = null,
}) => {
return authenticatedRequest('/collected-invoices/bulk-actions/apply', 'POST', {
preview_id,
@@ -545,6 +559,8 @@ export const CollectedOrderInvoices = {
options,
confirmation_text,
...(locale ? { locale } : {}),
...(Number.isInteger(parseInt(customer_number)) ? { customer_number: parseInt(customer_number) } : {}),
...(snapshot_revision ? { snapshot_revision: String(snapshot_revision) } : {}),
}).then((response) => {
console.log(response);
return response;
@@ -553,6 +569,28 @@ export const CollectedOrderInvoices = {
throw error;
});
},
period_tree_action_preview: async ({
action,
invoice_collection_ids = [],
options = {},
customer_number,
snapshot_revision,
locale = null,
}) => authenticatedRequest('/superuser/invoicing/period/tree-actions/preview', 'POST', {
action,
invoice_collection_ids: invoice_collection_ids
.map((id) => parseInt(id))
.filter((id) => Number.isInteger(id) && id > 0),
options,
customer_number: parseInt(customer_number),
snapshot_revision: String(snapshot_revision || ''),
...(locale ? { locale } : {}),
}),
period_tree_action_apply: async ({ preview_id, confirmation_text }) =>
authenticatedRequest('/superuser/invoicing/period/tree-actions/apply', 'POST', {
preview_id: String(preview_id || ''),
confirmation_text: String(confirmation_text || ''),
}),
move_to_customer: async (id, customerNumber) => {
const invoiceCollectionId = parseInt(id);
const targetCustomerNumber = parseInt(customerNumber);
+5
View File
@@ -4310,6 +4310,7 @@
"no_changes_title": "Ingen ændringer",
"order_item_line": "Fakturasamling #{collection}, ordre #{order}: {product}",
"order_move_line": "Ordre #{order}: #{source} til #{target}",
"off_period_impact": "Uden for valgt periode: {count} orders · {amount}",
"requires_confirmation": "Gennemgå forhåndsvisningen. Ingen ændringer udføres før du skriver {phrase}.",
"skipped": "Sprunget over: {count}",
"title": "Forhåndsvis {action}"
@@ -4500,6 +4501,7 @@
},
"nodes": {
"attachment_fallback": "Vedhæftning #{id}",
"agreement": "Aftale #{id}",
"booking": "Booking #{id}",
"booking_item_fallback": "Bookinglinje #{id}",
"collection": "Fakturasamling #{id}",
@@ -4512,6 +4514,7 @@
"order_item_fallback": "Linje #{id}",
"orders_without_collection": "Orders uden fakturasamling",
"payment_for_order": "Kortbetaling for ordre #{id}",
"payment": "Betaling #{id}",
"vehicle_subscription": "Vaskeabonnement",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
@@ -4527,9 +4530,11 @@
"loading": "Indlæser preview"
},
"subtitles": {
"complete_collection_summary": "Hele samlingen: {count} orders · {amount}",
"collection": "Samling #{id}",
"order": "Vask #{id}",
"quantity": "Antal {count}",
"selected_period_summary": "Valgt periode: {count} orders · {amount}",
"wash_id": "WashId {id}"
},
"success": {
+5
View File
@@ -4420,6 +4420,7 @@
"no_changes_title": "Keine Änderungen",
"order_item_line": "Rechnungssammlung #{collection}, Auftrag #{order}: {product}",
"order_move_line": "Auftrag #{order}: #{source} nach #{target}",
"off_period_impact": "Außerhalb des ausgewählten Zeitraums: {count} Aufträge · {amount}",
"requires_confirmation": "Prüfen Sie die Vorschau. Es werden keine Änderungen ausgeführt, bevor Sie {phrase} eingeben.",
"skipped": "Übersprungen: {count}",
"title": "{action} Vorschau"
@@ -4610,6 +4611,7 @@
},
"nodes": {
"attachment_fallback": "Anhang #{id}",
"agreement": "Vereinbarung #{id}",
"booking": "Buchung #{id}",
"booking_item_fallback": "Buchungsposition #{id}",
"collection": "Fakturasammlung #{id}",
@@ -4622,6 +4624,7 @@
"order_item_fallback": "Position #{id}",
"orders_without_collection": "Orders ohne Fakturasammlung",
"payment_for_order": "Kartenzahlung für Auftrag #{id}",
"payment": "Zahlung #{id}",
"vehicle_subscription": "Waschabonnement",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
@@ -4637,9 +4640,11 @@
"loading": "Vorschau wird geladen"
},
"subtitles": {
"complete_collection_summary": "Gesamte Sammlung: {count} Aufträge · {amount}",
"collection": "Sammlung #{id}",
"order": "Auftrag #{id}",
"quantity": "Anzahl {count}",
"selected_period_summary": "Ausgewählter Zeitraum: {count} Aufträge · {amount}",
"wash_id": "WashId {id}"
},
"success": {
+5
View File
@@ -4141,6 +4141,7 @@
"no_changes_title": "No changes",
"order_item_line": "Invoice collection #{collection}, order #{order}: {product}",
"order_move_line": "Order #{order}: #{source} to #{target}",
"off_period_impact": "Outside selected period: {count} orders · {amount}",
"requires_confirmation": "Review the preview. No changes are applied until you type {phrase}.",
"skipped": "Skipped: {count}",
"title": "Preview {action}"
@@ -4331,6 +4332,7 @@
},
"nodes": {
"attachment_fallback": "Attachment #{id}",
"agreement": "Agreement #{id}",
"booking": "Booking #{id}",
"booking_item_fallback": "Booking item #{id}",
"collection": "Invoice collection #{id}",
@@ -4343,6 +4345,7 @@
"order_item_fallback": "Line #{id}",
"orders_without_collection": "Orders without invoice collection",
"payment_for_order": "Card payment for order #{id}",
"payment": "Payment #{id}",
"vehicle_subscription": "Wash subscription",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
@@ -4358,9 +4361,11 @@
"loading": "Loading preview"
},
"subtitles": {
"complete_collection_summary": "Whole collection: {count} orders · {amount}",
"collection": "Collection #{id}",
"order": "Wash #{id}",
"quantity": "Quantity {count}",
"selected_period_summary": "Selected period: {count} orders · {amount}",
"wash_id": "WashId {id}"
},
"success": {
+5
View File
@@ -3529,6 +3529,7 @@
"no_changes_title": "@:{'templates.generated.compat.invoicing_period.invoice_collection_actions.preview.no_changes_title'}",
"order_item_line": "@:{'templates.generated.compat.invoicing_period.invoice_collection_actions.preview.order_item_line'}",
"order_move_line": "@:{'templates.generated.compat.invoicing_period.invoice_collection_actions.preview.order_move_line'}",
"off_period_impact": "@:{'templates.generated.compat.invoicing_period.invoice_collection_actions.preview.off_period_impact'}",
"requires_confirmation": "@:{'templates.generated.compat.invoicing_period.invoice_collection_actions.preview.requires_confirmation'}",
"skipped": "@:{'templates.generated.compat.invoicing_period.invoice_collection_actions.preview.skipped'}",
"title": "@:{'templates.generated.compat.invoicing_period.invoice_collection_actions.preview.title'}"
@@ -3717,6 +3718,7 @@
},
"nodes": {
"attachment_fallback": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.attachment_fallback'}",
"agreement": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.agreement'}",
"booking": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.booking'}",
"booking_item_fallback": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.booking_item_fallback'}",
"collection": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.collection'}",
@@ -3729,6 +3731,7 @@
"order_item_fallback": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.order_item_fallback'}",
"orders_without_collection": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.orders_without_collection'}",
"payment_for_order": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.payment_for_order'}",
"payment": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.payment'}",
"vehicle_subscription": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.vehicle_subscription'}",
"xlvask": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.xlvask'}",
"xlvask_empty": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.xlvask_empty'}",
@@ -3744,9 +3747,11 @@
"loading": "@:{'templates.generated.compat.invoicing_period.object_tree.preview.loading'}"
},
"subtitles": {
"complete_collection_summary": "@:{'templates.generated.compat.invoicing_period.object_tree.subtitles.complete_collection_summary'}",
"collection": "@:{'templates.generated.compat.invoicing_period.object_tree.subtitles.collection'}",
"order": "@:{'templates.generated.compat.invoicing_period.object_tree.subtitles.order'}",
"quantity": "@:{'templates.generated.compat.invoicing_period.object_tree.subtitles.quantity'}",
"selected_period_summary": "@:{'templates.generated.compat.invoicing_period.object_tree.subtitles.selected_period_summary'}",
"wash_id": "@:{'templates.generated.compat.invoicing_period.object_tree.subtitles.wash_id'}"
},
"success": {
+5
View File
@@ -4423,6 +4423,7 @@
"no_changes_title": "Ingen endringer",
"order_item_line": "Fakturasamling #{collection}, ordre #{order}: {product}",
"order_move_line": "Ordre #{order}: #{source} til #{target}",
"off_period_impact": "Utenfor valgt periode: {count} ordrer · {amount}",
"requires_confirmation": "Gå gjennom forhåndsvisningen. Ingen endringer utføres før du skriver {phrase}.",
"skipped": "Hoppet over: {count}",
"title": "Forhåndsvis {action}"
@@ -4613,6 +4614,7 @@
},
"nodes": {
"attachment_fallback": "Vedlegg #{id}",
"agreement": "Avtale #{id}",
"booking": "Booking #{id}",
"booking_item_fallback": "Bookinglinje #{id}",
"collection": "Fakturasamling #{id}",
@@ -4625,6 +4627,7 @@
"order_item_fallback": "Linje #{id}",
"orders_without_collection": "Orders uten fakturasamling",
"payment_for_order": "Kortbetaling for ordre #{id}",
"payment": "Betaling #{id}",
"vehicle_subscription": "Vaskeabonnement",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
@@ -4640,9 +4643,11 @@
"loading": "Laster forhåndsvisning"
},
"subtitles": {
"complete_collection_summary": "Hele samlingen: {count} ordrer · {amount}",
"collection": "Samling #{id}",
"order": "Ordre #{id}",
"quantity": "Antall {count}",
"selected_period_summary": "Valgt periode: {count} ordrer · {amount}",
"wash_id": "WashId {id}"
},
"success": {
+5
View File
@@ -4473,6 +4473,7 @@
"no_changes_title": "Inga ändringar",
"order_item_line": "Fakturasamling #{collection}, order #{order}: {product}",
"order_move_line": "Order #{order}: #{source} till #{target}",
"off_period_impact": "Utanför vald period: {count} ordrar · {amount}",
"requires_confirmation": "Granska förhandsvisningen. Inga ändringar görs innan du skriver {phrase}.",
"skipped": "Hoppade över: {count}",
"title": "Förhandsvisa {action}"
@@ -4663,6 +4664,7 @@
},
"nodes": {
"attachment_fallback": "Bilaga #{id}",
"agreement": "Avtal #{id}",
"booking": "Bokning #{id}",
"booking_item_fallback": "Bokningsrad #{id}",
"collection": "Fakturasamling #{id}",
@@ -4675,6 +4677,7 @@
"order_item_fallback": "Rad #{id}",
"orders_without_collection": "Orders utan fakturasamling",
"payment_for_order": "Kortbetalning för order #{id}",
"payment": "Betalning #{id}",
"vehicle_subscription": "Tvättabonnemang",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
@@ -4690,9 +4693,11 @@
"loading": "Läser in förhandsvisning"
},
"subtitles": {
"complete_collection_summary": "Hela samlingen: {count} ordrar · {amount}",
"collection": "Samling #{id}",
"order": "Order #{id}",
"quantity": "Antal {count}",
"selected_period_summary": "Vald period: {count} ordrar · {amount}",
"wash_id": "WashId {id}"
},
"success": {
@@ -36,6 +36,7 @@
"no_changes_title": "Ingen ændringer",
"order_item_line": "Fakturasamling #{collection}, ordre #{order}: {product}",
"order_move_line": "Ordre #{order}: #{source} til #{target}",
"off_period_impact": "Uden for valgt periode: {count} orders · {amount}",
"requires_confirmation": "Gennemgå forhåndsvisningen. Ingen ændringer udføres før du skriver {phrase}.",
"skipped": "Sprunget over: {count}",
"title": "Forhåndsvis {action}"
@@ -158,6 +158,7 @@
},
"nodes": {
"attachment_fallback": "Vedhæftning #{id}",
"agreement": "Aftale #{id}",
"booking": "Booking #{id}",
"booking_item_fallback": "Bookinglinje #{id}",
"collection": "Fakturasamling #{id}",
@@ -170,6 +171,7 @@
"order_item_fallback": "Linje #{id}",
"orders_without_collection": "Orders uden fakturasamling",
"payment_for_order": "Kortbetaling for ordre #{id}",
"payment": "Betaling #{id}",
"vehicle_subscription": "Vaskeabonnement",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
@@ -185,9 +187,11 @@
"loading": "Indlæser preview"
},
"subtitles": {
"complete_collection_summary": "Hele samlingen: {count} orders · {amount}",
"collection": "Samling #{id}",
"order": "Vask #{id}",
"quantity": "Antal {count}",
"selected_period_summary": "Valgt periode: {count} orders · {amount}",
"wash_id": "WashId {id}"
},
"success": {
@@ -36,6 +36,7 @@
"no_changes_title": "Keine Änderungen",
"order_item_line": "Rechnungssammlung #{collection}, Auftrag #{order}: {product}",
"order_move_line": "Auftrag #{order}: #{source} nach #{target}",
"off_period_impact": "Außerhalb des ausgewählten Zeitraums: {count} Aufträge · {amount}",
"requires_confirmation": "Prüfen Sie die Vorschau. Es werden keine Änderungen ausgeführt, bevor Sie {phrase} eingeben.",
"skipped": "Übersprungen: {count}",
"title": "{action} Vorschau"
@@ -158,6 +158,7 @@
},
"nodes": {
"attachment_fallback": "Anhang #{id}",
"agreement": "Vereinbarung #{id}",
"booking": "Buchung #{id}",
"booking_item_fallback": "Buchungsposition #{id}",
"collection": "Fakturasammlung #{id}",
@@ -170,6 +171,7 @@
"order_item_fallback": "Position #{id}",
"orders_without_collection": "Orders ohne Fakturasammlung",
"payment_for_order": "Kartenzahlung für Auftrag #{id}",
"payment": "Zahlung #{id}",
"vehicle_subscription": "Waschabonnement",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
@@ -185,9 +187,11 @@
"loading": "Vorschau wird geladen"
},
"subtitles": {
"complete_collection_summary": "Gesamte Sammlung: {count} Aufträge · {amount}",
"collection": "Sammlung #{id}",
"order": "Auftrag #{id}",
"quantity": "Anzahl {count}",
"selected_period_summary": "Ausgewählter Zeitraum: {count} Aufträge · {amount}",
"wash_id": "WashId {id}"
},
"success": {
@@ -36,6 +36,7 @@
"no_changes_title": "No changes",
"order_item_line": "Invoice collection #{collection}, order #{order}: {product}",
"order_move_line": "Order #{order}: #{source} to #{target}",
"off_period_impact": "Outside selected period: {count} orders · {amount}",
"requires_confirmation": "Review the preview. No changes are applied until you type {phrase}.",
"skipped": "Skipped: {count}",
"title": "Preview {action}"
@@ -158,6 +158,7 @@
},
"nodes": {
"attachment_fallback": "Attachment #{id}",
"agreement": "Agreement #{id}",
"booking": "Booking #{id}",
"booking_item_fallback": "Booking item #{id}",
"collection": "Invoice collection #{id}",
@@ -170,6 +171,7 @@
"order_item_fallback": "Line #{id}",
"orders_without_collection": "Orders without invoice collection",
"payment_for_order": "Card payment for order #{id}",
"payment": "Payment #{id}",
"vehicle_subscription": "Wash subscription",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
@@ -185,9 +187,11 @@
"loading": "Loading preview"
},
"subtitles": {
"complete_collection_summary": "Whole collection: {count} orders · {amount}",
"collection": "Collection #{id}",
"order": "Wash #{id}",
"quantity": "Quantity {count}",
"selected_period_summary": "Selected period: {count} orders · {amount}",
"wash_id": "WashId {id}"
},
"success": {
@@ -35,6 +35,7 @@
"no_changes_title": "@:{'phrases.compat.invoicing_period.invoice_collection_actions.preview.no_changes_title'}",
"order_item_line": "@:{'phrases.compat.invoicing_period.invoice_collection_actions.preview.order_item_line'}",
"order_move_line": "@:{'phrases.compat.invoicing_period.invoice_collection_actions.preview.order_move_line'}",
"off_period_impact": "@:{'phrases.compat.invoicing_period.invoice_collection_actions.preview.off_period_impact'}",
"requires_confirmation": "@:{'phrases.compat.invoicing_period.invoice_collection_actions.preview.requires_confirmation'}",
"skipped": "@:{'phrases.compat.invoicing_period.invoice_collection_actions.preview.skipped'}",
"title": "@:{'phrases.compat.invoicing_period.invoice_collection_actions.preview.title'}"
@@ -154,6 +154,7 @@
},
"nodes": {
"attachment_fallback": "@:{'phrases.compat.invoicing_period.object_tree.nodes.attachment_fallback'}",
"agreement": "@:{'phrases.compat.invoicing_period.object_tree.nodes.agreement'}",
"booking": "@:{'phrases.compat.invoicing_period.object_tree.nodes.booking'}",
"booking_item_fallback": "@:{'phrases.compat.invoicing_period.object_tree.nodes.booking_item_fallback'}",
"collection": "@:{'phrases.compat.invoicing_period.object_tree.nodes.collection'}",
@@ -166,6 +167,7 @@
"order_item_fallback": "@:{'phrases.compat.invoicing_period.object_tree.nodes.order_item_fallback'}",
"orders_without_collection": "@:{'phrases.compat.invoicing_period.object_tree.nodes.orders_without_collection'}",
"payment_for_order": "@:{'phrases.compat.invoicing_period.object_tree.nodes.payment_for_order'}",
"payment": "@:{'phrases.compat.invoicing_period.object_tree.nodes.payment'}",
"vehicle_subscription": "@:{'phrases.compat.invoicing_period.object_tree.nodes.vehicle_subscription'}",
"xlvask": "@:{'phrases.compat.invoicing_period.object_tree.nodes.xlvask'}",
"xlvask_empty": "@:{'phrases.compat.invoicing_period.object_tree.nodes.xlvask_empty'}",
@@ -181,9 +183,11 @@
"loading": "@:{'phrases.compat.invoicing_period.object_tree.preview.loading'}"
},
"subtitles": {
"complete_collection_summary": "@:{'phrases.compat.invoicing_period.object_tree.subtitles.complete_collection_summary'}",
"collection": "@:{'phrases.compat.invoicing_period.object_tree.subtitles.collection'}",
"order": "@:{'phrases.compat.invoicing_period.object_tree.subtitles.order'}",
"quantity": "@:{'phrases.compat.invoicing_period.object_tree.subtitles.quantity'}",
"selected_period_summary": "@:{'phrases.compat.invoicing_period.object_tree.subtitles.selected_period_summary'}",
"wash_id": "@:{'phrases.compat.invoicing_period.object_tree.subtitles.wash_id'}"
},
"success": {
@@ -36,6 +36,7 @@
"no_changes_title": "Ingen endringer",
"order_item_line": "Fakturasamling #{collection}, ordre #{order}: {product}",
"order_move_line": "Ordre #{order}: #{source} til #{target}",
"off_period_impact": "Utenfor valgt periode: {count} ordrer · {amount}",
"requires_confirmation": "Gå gjennom forhåndsvisningen. Ingen endringer utføres før du skriver {phrase}.",
"skipped": "Hoppet over: {count}",
"title": "Forhåndsvis {action}"
@@ -158,6 +158,7 @@
},
"nodes": {
"attachment_fallback": "Vedlegg #{id}",
"agreement": "Avtale #{id}",
"booking": "Booking #{id}",
"booking_item_fallback": "Bookinglinje #{id}",
"collection": "Fakturasamling #{id}",
@@ -170,6 +171,7 @@
"order_item_fallback": "Linje #{id}",
"orders_without_collection": "Orders uten fakturasamling",
"payment_for_order": "Kortbetaling for ordre #{id}",
"payment": "Betaling #{id}",
"vehicle_subscription": "Vaskeabonnement",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
@@ -185,9 +187,11 @@
"loading": "Laster forhåndsvisning"
},
"subtitles": {
"complete_collection_summary": "Hele samlingen: {count} ordrer · {amount}",
"collection": "Samling #{id}",
"order": "Ordre #{id}",
"quantity": "Antall {count}",
"selected_period_summary": "Valgt periode: {count} ordrer · {amount}",
"wash_id": "WashId {id}"
},
"success": {
@@ -36,6 +36,7 @@
"no_changes_title": "Inga ändringar",
"order_item_line": "Fakturasamling #{collection}, order #{order}: {product}",
"order_move_line": "Order #{order}: #{source} till #{target}",
"off_period_impact": "Utanför vald period: {count} ordrar · {amount}",
"requires_confirmation": "Granska förhandsvisningen. Inga ändringar görs innan du skriver {phrase}.",
"skipped": "Hoppade över: {count}",
"title": "Förhandsvisa {action}"
@@ -158,6 +158,7 @@
},
"nodes": {
"attachment_fallback": "Bilaga #{id}",
"agreement": "Avtal #{id}",
"booking": "Bokning #{id}",
"booking_item_fallback": "Bokningsrad #{id}",
"collection": "Fakturasamling #{id}",
@@ -170,6 +171,7 @@
"order_item_fallback": "Rad #{id}",
"orders_without_collection": "Orders utan fakturasamling",
"payment_for_order": "Kortbetalning för order #{id}",
"payment": "Betalning #{id}",
"vehicle_subscription": "Tvättabonnemang",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
@@ -185,9 +187,11 @@
"loading": "Läser in förhandsvisning"
},
"subtitles": {
"complete_collection_summary": "Hela samlingen: {count} ordrar · {amount}",
"collection": "Samling #{id}",
"order": "Order #{id}",
"quantity": "Antal {count}",
"selected_period_summary": "Vald period: {count} ordrar · {amount}",
"wash_id": "WashId {id}"
},
"success": {
@@ -10,6 +10,17 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { getAttachmentPreviewKind, releaseObjectUrl } from "@/services/attachmentPreview.js";
import { invoiceQueue } from "../imports/InvoicingBillingPeriodImportInvoiceQueue.vue";
import { INVOICE_COLLECTION_BULK_ACTIONS } from "@/components/displays/department/pos/orders/invoiceCollectionBulkActions.js";
import {
normalizeInvoiceCollectionActionPreview,
renderInvoiceCollectionActionPreviewHtml,
isInvoiceCollectionTreeActionUnavailable,
} from "../services/invoiceCollectionActionPreview.ts";
import {
buildCompleteSnapshotRootNodes,
createInvoicingPeriodTreeSnapshotLoader,
isTreeEndpointUnavailable,
type CompleteInvoicingPeriodTreeSnapshot,
} from "../services/invoicingPeriodTreeSnapshot.ts";
import {
TREE_CATEGORY_TYPES,
TREE_NODE_TYPES,
@@ -36,6 +47,7 @@ const props = withDefaults(
transactions: any[];
excludedOrderIds?: any[];
dates: { dateFrom: string; dateTo: string };
capabilities?: Record<string, any>;
invoicePeriodFlags?: any[];
autoExpandAll?: boolean;
}>(),
@@ -43,6 +55,7 @@ const props = withDefaults(
transactions: () => [],
excludedOrderIds: () => [],
invoicePeriodFlags: () => [],
capabilities: () => ({}),
autoExpandAll: false,
}
);
@@ -67,6 +80,13 @@ const previewErrorByNodeId = ref<Record<string, string>>({});
const attachmentPreviewByNodeId = ref<Record<string, any>>({});
const economicDetailsByCollectionId = ref<Record<number, any>>({});
const generatedPreviewObjectUrls = new Set<string>();
const activeSnapshot = ref<CompleteInvoicingPeriodTreeSnapshot | null>(null);
const snapshotLoading = ref(false);
const snapshotFallback = ref(false);
const legacySnapshotFallbackAllowed = ref(false);
const treeDataVersion = ref(0);
const snapshotLoader = createInvoicingPeriodTreeSnapshotLoader(SessionUser.request);
let snapshotRequestGeneration = 0;
const toPositiveInteger = (value: any) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
@@ -138,16 +158,28 @@ const nodeLabel = {
treeText("nodes.xlvask_item_fallback", `XL Vask linje ${index + 1}`, { index: index + 1 }),
};
const currentSignature = computed(() =>
const legacyDataSignature = computed(() =>
JSON.stringify({
customer: props.customer?.customer_number,
ids: props.transactions.map((transaction) => transaction?.id),
transactions: props.transactions.map((transaction) => ({
id: transaction?.id,
collectionId: transaction?.invoice_collection_id,
amount: transaction?.total_net_amount ?? transaction?.amount,
state: transaction?.invoice_state,
})),
excluded: props.excludedOrderIds,
dateFrom: props.dates?.dateFrom,
dateTo: props.dates?.dateTo,
autoExpandAll: props.autoExpandAll,
})
);
const snapshotIdentity = computed(() =>
JSON.stringify({
customerNumber: toPositiveInteger(props.customer?.customer_number),
dateFrom: props.dates?.dateFrom,
dateTo: props.dates?.dateTo,
})
);
const currentSignature = computed(() => `${snapshotIdentity.value}:${treeDataVersion.value}:${props.autoExpandAll}`);
const rememberNodes = (nodes: TreeNode[]) => {
const next = { ...nodeById.value };
@@ -163,6 +195,109 @@ const rememberNodes = (nodes: TreeNode[]) => {
};
const rootNodes = ref<TreeNode[]>([]);
const snapshotTreeLabels = () => ({
collection: nodeLabel.collection,
ordersWithoutCollection: nodeLabel.ordersWithoutCollection(),
orders: treeText("categories.orders", "Orders"),
items: treeText("categories.order_items", "Ordrelinjer"),
attachments: {
certificates: treeText("categories.certificates", "Vaskecertifikater"),
images: treeText("categories.images", "Billeder"),
other: treeText("categories.other_attachments", "Andre vedhæftninger"),
},
bookings: treeText("categories.bookings", "Bookinger"),
booking: nodeLabel.booking,
xlvask: nodeLabel.xlvaskEmpty(),
economic: treeText("categories.economic", "E-conomic"),
agreement: (id: any) => treeText("nodes.agreement", id ? `Aftale #${id}` : "Aftaler", { id }),
payment: (id: any) => treeText("nodes.payment", id ? `Betaling #${id}` : "Betalinger", { id }),
order: nodeLabel.order,
orderItemFallback: nodeLabel.orderItemFallback,
attachmentFallback: nodeLabel.attachmentFallback,
bookingItemFallback: nodeLabel.bookingItemFallback,
xlvaskItemFallback: nodeLabel.xlvaskItemFallback,
});
const buildLegacyRootNodes = () =>
buildCollectionRootNodes(props.customer, props.transactions, props.excludedOrderIds, {
collection: nodeLabel.collection,
ordersWithoutCollection: nodeLabel.ordersWithoutCollection(),
dateFrom: props.dates?.dateFrom,
dateTo: props.dates?.dateTo,
locale: localeValue(),
});
const replaceRootNodes = (
nodes: TreeNode[],
{ preserveState = false, consumedKeys = [] as any[] }: { preserveState?: boolean; consumedKeys?: any[] } = {}
) => {
const previousExpanded = preserveState ? [...expandedKeys.value] : [];
const previousChecked = preserveState ? [...checkedKeys.value] : [];
const consumed = new Set(consumedKeys.map(String));
nodeById.value = {};
rootNodes.value = rememberNodes(nodes);
const validKeys = new Set(Object.keys(nodeById.value));
expandedKeys.value = previousExpanded.filter((key) => validKeys.has(String(key)));
checkedKeys.value = previousChecked.filter((key) => validKeys.has(String(key)) && !consumed.has(String(key)));
treeDataVersion.value += 1;
};
const customerAdvertisesSnapshot = () => {
const capabilities = props.capabilities ?? props.customer?.capabilities ?? props.customer?.invoicing_period_capabilities;
return capabilities?.object_tree_v2 === true;
};
const refreshSnapshot = async ({ force = false, consumedKeys = [] as any[] } = {}) => {
const requestGeneration = ++snapshotRequestGeneration;
const customerNumber = toPositiveInteger(props.customer?.customer_number);
if (!customerNumber || !props.dates?.dateFrom || !props.dates?.dateTo || !customerAdvertisesSnapshot()) {
snapshotLoading.value = false;
snapshotFallback.value = true;
legacySnapshotFallbackAllowed.value = false;
return null;
}
const requestedIdentity = snapshotIdentity.value;
snapshotLoading.value = true;
legacySnapshotFallbackAllowed.value = false;
try {
const snapshot = await snapshotLoader.load(
{ customerNumber, dateFrom: props.dates.dateFrom, dateTo: props.dates.dateTo },
{ force }
);
if (requestGeneration !== snapshotRequestGeneration || requestedIdentity !== snapshotIdentity.value) {
return null;
}
if (
!snapshot ||
snapshot.customer_number !== customerNumber ||
snapshot.date_from !== props.dates.dateFrom ||
snapshot.date_to !== props.dates.dateTo
) {
snapshotFallback.value = true;
legacySnapshotFallbackAllowed.value = false;
return null;
}
activeSnapshot.value = snapshot;
snapshotFallback.value = false;
legacySnapshotFallbackAllowed.value = false;
replaceRootNodes(buildCompleteSnapshotRootNodes(snapshot, snapshotTreeLabels()), {
preserveState: true,
consumedKeys,
});
return snapshot;
} catch (error: any) {
if (requestGeneration === snapshotRequestGeneration && requestedIdentity === snapshotIdentity.value) {
const endpointUnavailable = isTreeEndpointUnavailable(error);
snapshotFallback.value = !endpointUnavailable;
legacySnapshotFallbackAllowed.value = endpointUnavailable;
}
return null;
} finally {
if (requestGeneration === snapshotRequestGeneration && requestedIdentity === snapshotIdentity.value) {
snapshotLoading.value = false;
}
}
};
const expandableNodeKeys = computed(() =>
Object.values(nodeById.value)
.filter((node) => node?.isLeaf !== true && node?.disabled !== true && node?.id !== undefined && node?.id !== null)
@@ -190,31 +325,60 @@ const toggleExpandAll = async () => {
};
watch(
currentSignature,
snapshotIdentity,
() => {
checkedKeys.value = [];
expandedKeys.value = [];
nodeById.value = {};
nodeErrors.value = {};
orderItemRowsByOrderId.value = {};
attachmentRowsByOrderId.value = {};
activeTreeActionKey.value = null;
openActionGroupType.value = null;
activeSnapshot.value = null;
snapshotFallback.value = false;
legacySnapshotFallbackAllowed.value = false;
resetPreviewState();
rootNodes.value = rememberNodes(
buildCollectionRootNodes(props.customer, props.transactions, props.excludedOrderIds, {
collection: nodeLabel.collection,
ordersWithoutCollection: nodeLabel.ordersWithoutCollection(),
dateFrom: props.dates?.dateFrom,
dateTo: props.dates?.dateTo,
locale: localeValue(),
})
);
replaceRootNodes(buildLegacyRootNodes());
void refreshSnapshot();
},
{ immediate: true }
);
watch(legacyDataSignature, () => {
if (!activeSnapshot.value) {
replaceRootNodes(buildLegacyRootNodes());
}
});
watch(
() => customerAdvertisesSnapshot(),
(enabled) => {
if (!enabled) {
snapshotRequestGeneration += 1;
snapshotLoader.dispose();
snapshotLoading.value = false;
activeSnapshot.value = null;
snapshotFallback.value = false;
legacySnapshotFallbackAllowed.value = false;
replaceRootNodes(buildLegacyRootNodes(), { preserveState: true });
return;
}
void refreshSnapshot({ force: true });
}
);
watch(
() => localeValue(),
() => {
replaceRootNodes(
activeSnapshot.value
? buildCompleteSnapshotRootNodes(activeSnapshot.value, snapshotTreeLabels())
: buildLegacyRootNodes(),
{ preserveState: true }
);
}
);
const selectedNodes = computed(() =>
checkedKeys.value.map((key) => nodeById.value[String(key)]).filter((node) => node?.actionable === true)
);
@@ -300,6 +464,7 @@ const certificateAttachmentOrderIds = computed(() => certificateAttachmentOrderI
const actionableXlvaskNodes = computed(() => actionableXlvaskNodesFromNodes(selectedNodes.value));
onBeforeUnmount(() => {
snapshotLoader.dispose();
resetPreviewState();
});
@@ -1066,6 +1231,20 @@ const getNodeIconColorClass = (node: TreeNode) =>
}[getNodeInvoiceState(node)]);
const getNodeSubtitle = (node: TreeNode) => {
if (node.type === TREE_NODE_TYPES.COLLECTION) {
if (activeSnapshot.value && node.meta.completeOrderCount !== undefined) {
return [
treeText(
"subtitles.selected_period_summary",
`Periode: ${node.meta.periodOrderCount} orders · ${formatCurrency(node.meta.periodTotalNetAmount)}`,
{ count: node.meta.periodOrderCount, amount: formatCurrency(node.meta.periodTotalNetAmount) }
),
treeText(
"subtitles.complete_collection_summary",
`Hele samlingen: ${node.meta.completeOrderCount} orders · ${formatCurrency(node.meta.completeTotalNetAmount)}`,
{ count: node.meta.completeOrderCount, amount: formatCurrency(node.meta.completeTotalNetAmount) }
),
].join(" · ");
}
return `${node.meta.orderCount} orders · ${formatCurrency(node.meta.totalNetAmount)}`;
}
if (node.type === TREE_NODE_TYPES.ORDER) {
@@ -1381,7 +1560,7 @@ const downloadEconomicInvoiceNode = async (node: TreeNode) => {
economicType
);
openUrlInNewTab(getApiPayload(response)?.url);
} catch (error) {
} catch (error: any) {
await Swal.fire({
title: treeText("errors.download_failed", "Download mislykkedes"),
text: SessionUser.functions.parseErrorMessage?.(error) || String(error),
@@ -1871,7 +2050,7 @@ const runActionWithPreview = async (
showConfirmButton: false,
});
emit("refresh");
} catch (error) {
} catch (error: any) {
await Swal.fire({
title: treeText("errors.action_failed", "Handlingen mislykkedes"),
text: SessionUser.functions.parseErrorMessage?.(error) || String(error),
@@ -1913,44 +2092,11 @@ const normalizeInvoiceCollectionId = (value: any) => {
};
const renderBulkActionPreviewHtml = (preview: any) => {
const summary = preview?.summary || {};
const blockers = Array.isArray(preview?.blockers) ? preview.blockers : [];
const examples = Array.isArray(preview?.examples) ? preview.examples : [];
const lines = [
t("invoicing_period.invoice_collection_actions.preview.collections", {
count: summary.collection_count ?? collectionIds().length,
}),
t("invoicing_period.invoice_collection_actions.preview.changed", {
count: summary.changed_count ?? 0,
}),
t("invoicing_period.invoice_collection_actions.preview.skipped", {
count: summary.skipped_count ?? 0,
}),
];
if (preview?.options?.target_invoice_collection_id) {
lines.push(
t("invoicing_period.invoice_collection_actions.preview.merge_target", {
id: preview.options.target_invoice_collection_id,
})
);
}
if (examples.length > 0) {
lines.push(t("invoicing_period.invoice_collection_actions.preview.affected_examples"));
examples.slice(0, 8).forEach((example: any) => {
lines.push(example.message || JSON.stringify(example));
});
}
if (blockers.length > 0) {
lines.push(t("invoicing_period.invoice_collection_actions.preview.blockers"));
blockers.forEach((blocker: any) => {
lines.push(blocker.message || blocker.code);
});
}
return `<div class="has-text-left">${lines.map((line) => `<p>${escapeHtml(line)}</p>`).join("")}</div>`;
return renderInvoiceCollectionActionPreviewHtml({
preview: normalizeInvoiceCollectionActionPreview(preview, collectionIds().length),
t,
formatCurrency,
});
};
const chooseMergeTargetInvoiceCollection = async (invoiceCollectionIds: number[]) => {
@@ -2005,15 +2151,43 @@ const runCollectionBulkAction = async (
}
try {
const previewResponse = await SessionUser.objects.collectedOrderInvoices.functions.bulk_action_preview(
action,
ids,
options,
localeValue()
);
const preview = getApiPayload(previewResponse);
const blockers = Array.isArray(preview.blockers) ? preview.blockers : [];
const changedCount = Number(preview?.summary?.changed_count ?? 0);
const canUseTreeAction =
(activeSnapshot.value?.capabilities.actions as Record<string, boolean> | undefined)?.[action] === true;
let useTreeAction = canUseTreeAction;
let previewResponse: any;
if (useTreeAction) {
try {
previewResponse = await SessionUser.objects.collectedOrderInvoices.functions.period_tree_action_preview({
action,
invoice_collection_ids: ids,
options,
customer_number: activeSnapshot.value?.customer_number,
snapshot_revision: activeSnapshot.value?.snapshot_revision,
locale: localeValue(),
});
} catch (error) {
if (!isInvoiceCollectionTreeActionUnavailable(error)) {
throw error;
}
useTreeAction = false;
}
}
if (!useTreeAction) {
previewResponse = await SessionUser.objects.collectedOrderInvoices.functions.bulk_action_preview(
action,
ids,
options,
localeValue(),
{
customer_number: activeSnapshot.value?.customer_number,
snapshot_revision: activeSnapshot.value?.snapshot_revision,
}
);
}
const previewRaw = getApiPayload(previewResponse);
const preview = normalizeInvoiceCollectionActionPreview(previewRaw, ids.length);
const blockers = preview.blockers;
const changedCount = preview.changedCount;
if (blockers.length > 0 || changedCount === 0) {
await Swal.fire({
@@ -2022,7 +2196,7 @@ const runCollectionBulkAction = async (
blockers.length > 0
? t("invoicing_period.invoice_collection_actions.preview.blocked_title")
: t("invoicing_period.invoice_collection_actions.preview.no_changes_title"),
html: renderBulkActionPreviewHtml(preview),
html: renderBulkActionPreviewHtml(previewRaw),
});
return;
}
@@ -2032,19 +2206,19 @@ const runCollectionBulkAction = async (
title: t("invoicing_period.invoice_collection_actions.preview.title", {
action: getBulkActionLabel(action),
}),
html: renderBulkActionPreviewHtml(preview),
html: renderBulkActionPreviewHtml(previewRaw),
input: "text",
inputLabel: t("invoicing_period.invoice_collection_actions.preview.confirmation_label", {
phrase: preview.confirmation_phrase,
phrase: preview.confirmationPhrase,
}),
inputPlaceholder: preview.confirmation_phrase,
inputPlaceholder: preview.confirmationPhrase,
showCancelButton: true,
confirmButtonText: t("invoicing_period.invoice_collection_actions.preview.confirm_button"),
cancelButtonText: commonText("cancel", "Annuller"),
inputValidator: (value) => {
if (String(value ?? "").trim() !== String(preview.confirmation_phrase ?? "")) {
if (String(value ?? "").trim() !== String(preview.confirmationPhrase ?? "")) {
return t("invoicing_period.invoice_collection_actions.preview.confirmation_mismatch", {
phrase: preview.confirmation_phrase,
phrase: preview.confirmationPhrase,
});
}
return undefined;
@@ -2055,14 +2229,32 @@ const runCollectionBulkAction = async (
return;
}
const applyResponse = await SessionUser.objects.collectedOrderInvoices.functions.bulk_action_apply({
preview_id: preview.preview_id,
action,
invoice_collection_ids: ids,
options,
confirmation_text: confirmation.value,
locale: localeValue(),
});
let applyResponse: any;
if (useTreeAction) {
try {
applyResponse = await SessionUser.objects.collectedOrderInvoices.functions.period_tree_action_apply({
preview_id: preview.previewId,
confirmation_text: confirmation.value,
});
} catch (error) {
if (!isInvoiceCollectionTreeActionUnavailable(error)) {
throw error;
}
useTreeAction = false;
}
}
if (!useTreeAction) {
applyResponse = await SessionUser.objects.collectedOrderInvoices.functions.bulk_action_apply({
preview_id: preview.previewId,
action,
invoice_collection_ids: ids,
options,
confirmation_text: confirmation.value,
locale: localeValue(),
customer_number: activeSnapshot.value?.customer_number,
snapshot_revision: activeSnapshot.value?.snapshot_revision,
});
}
const applied = getApiPayload(applyResponse);
if (action === INVOICE_COLLECTION_BULK_ACTIONS.QUEUE_ECONOMIC) {
@@ -2079,7 +2271,26 @@ const runCollectionBulkAction = async (
invoiceQueue.processInvoiceCollectionQueue();
}
}
if (runOptions.clearSelection !== false) {
const actedCollectionIds = new Set(ids.map(Number));
const consumedNodeKeys = new Set<string>();
const collectConsumedNodeKeys = (node: TreeNode, withinActedCollection = false) => {
const isActedCollection =
withinActedCollection ||
(node.type === TREE_NODE_TYPES.COLLECTION && actedCollectionIds.has(Number(node.meta?.collectionId)));
if (isActedCollection) {
consumedNodeKeys.add(String(node.id));
}
(node.children || []).forEach((child: TreeNode) => collectConsumedNodeKeys(child, isActedCollection));
};
rootNodes.value.forEach((node) => collectConsumedNodeKeys(node));
const consumedKeys = checkedKeys.value.filter((key) => consumedNodeKeys.has(String(key)));
if (activeSnapshot.value) {
const refreshedSnapshot = await refreshSnapshot({ force: true, consumedKeys });
if (!refreshedSnapshot) {
const consumed = new Set(consumedKeys.map(String));
checkedKeys.value = checkedKeys.value.filter((key) => !consumed.has(String(key)));
}
} else if (runOptions.clearSelection !== false) {
checkedKeys.value = [];
}
await Swal.fire({
@@ -2092,7 +2303,10 @@ const runCollectionBulkAction = async (
showConfirmButton: false,
});
emit("refresh");
} catch (error) {
} catch (error: any) {
if (activeSnapshot.value && Number(error?.response?.status) === 409) {
await refreshSnapshot({ force: true });
}
await Swal.fire({
icon: "error",
title: t("invoicing_period.invoice_collection_actions.error_title"),
@@ -2420,45 +2634,53 @@ const decideXlvaskAutomation = (
const decideSelectedXlvaskAutomation = (decision: "accept" | "deny") =>
decideXlvaskAutomation(decision, actionableXlvaskNodes.value);
const isCollectionActionAvailable = (action: string) =>
(!activeSnapshot.value && !customerAdvertisesSnapshot()) ||
(!activeSnapshot.value && legacySnapshotFallbackAllowed.value && !snapshotLoading.value) ||
(!!activeSnapshot.value && !snapshotFallback.value &&
(activeSnapshot.value.capabilities.actions as Record<string, boolean> | undefined)?.[action] === true);
const availableCollectionAction = (capability: string, action: TreeAction): TreeAction | null =>
isCollectionActionAvailable(capability) ? action : null;
const collectionActions = (): TreeAction[] => [
{
availableCollectionAction(INVOICE_COLLECTION_BULK_ACTIONS.QUEUE_ECONOMIC, {
key: "collection:queue-economic",
label: t("invoicing_period.invoice_collection_actions.actions.queue_economic"),
icon: "fa-file-invoice",
tone: "dark",
affectedCount: selectedCollectionCount.value,
run: () => runCollectionBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.QUEUE_ECONOMIC),
},
{
}),
availableCollectionAction(INVOICE_COLLECTION_BULK_ACTIONS.CLEAN_CUSTOMER_RULES, {
key: "collection:clean-customer-rules",
label: t("invoicing_period.invoice_collection_actions.actions.remove_customer_rule_violations"),
icon: "fa-broom",
affectedCount: selectedCollectionCount.value,
run: () => runCollectionBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.CLEAN_CUSTOMER_RULES),
},
{
}),
availableCollectionAction(INVOICE_COLLECTION_BULK_ACTIONS.MERGE, {
key: "collection:merge",
label: t("invoicing_period.invoice_collection_actions.actions.merge_collections"),
icon: "fa-compress-arrows-alt",
affectedCount: selectedCollectionCount.value > 1 ? selectedCollectionCount.value : 0,
disabled: selectedCollectionCount.value < 2,
run: () => runCollectionBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.MERGE),
},
{
}),
availableCollectionAction(INVOICE_COLLECTION_BULK_ACTIONS.SPLIT_BY_MONTH, {
key: "collection:split-by-month",
label: t("invoicing_period.invoice_collection_actions.actions.split_by_month"),
icon: "fa-calendar-alt",
affectedCount: selectedCollectionCount.value,
run: () => runCollectionBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.SPLIT_BY_MONTH),
},
{
}),
availableCollectionAction(INVOICE_COLLECTION_BULK_ACTIONS.RESET_HIDDEN_PRICES, {
key: "collection:reset-hidden-prices",
label: t("invoicing_period.invoice_collection_actions.actions.reset_hidden_item_prices"),
icon: "fa-tags",
affectedCount: selectedCollectionCount.value,
run: () => runCollectionBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.RESET_HIDDEN_PRICES),
},
];
}),
].filter(Boolean) as TreeAction[];
const orderActions = (): TreeAction[] => [
{
@@ -2716,15 +2938,15 @@ const collectionNodeActions = (node: TreeNode): TreeAction[] => {
}
return [
rowAction(node, "collection:queue-economic", {
isCollectionActionAvailable(INVOICE_COLLECTION_BULK_ACTIONS.QUEUE_ECONOMIC) ? rowAction(node, "collection:queue-economic", {
label: t("invoicing_period.invoice_collection_actions.actions.queue_economic"),
icon: "fa-file-invoice",
tone: "dark",
affectedCount: 1,
run: () =>
runCollectionBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.QUEUE_ECONOMIC, [collectionId], rowActionOptions()),
}),
rowAction(node, "collection:clean-customer-rules", {
}) : null,
isCollectionActionAvailable(INVOICE_COLLECTION_BULK_ACTIONS.CLEAN_CUSTOMER_RULES) ? rowAction(node, "collection:clean-customer-rules", {
label: t("invoicing_period.invoice_collection_actions.actions.remove_customer_rule_violations"),
icon: "fa-broom",
affectedCount: 1,
@@ -2734,15 +2956,15 @@ const collectionNodeActions = (node: TreeNode): TreeAction[] => {
[collectionId],
rowActionOptions()
),
}),
rowAction(node, "collection:split-by-month", {
}) : null,
isCollectionActionAvailable(INVOICE_COLLECTION_BULK_ACTIONS.SPLIT_BY_MONTH) ? rowAction(node, "collection:split-by-month", {
label: t("invoicing_period.invoice_collection_actions.actions.split_by_month"),
icon: "fa-calendar-alt",
affectedCount: 1,
run: () =>
runCollectionBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.SPLIT_BY_MONTH, [collectionId], rowActionOptions()),
}),
rowAction(node, "collection:reset-hidden-prices", {
}) : null,
isCollectionActionAvailable(INVOICE_COLLECTION_BULK_ACTIONS.RESET_HIDDEN_PRICES) ? rowAction(node, "collection:reset-hidden-prices", {
label: t("invoicing_period.invoice_collection_actions.actions.reset_hidden_item_prices"),
icon: "fa-tags",
affectedCount: 1,
@@ -2752,8 +2974,8 @@ const collectionNodeActions = (node: TreeNode): TreeAction[] => {
[collectionId],
rowActionOptions()
),
}),
];
}) : null,
].filter(Boolean) as TreeAction[];
};
const orderNodeActions = (node: TreeNode): TreeAction[] => {
@@ -3012,6 +3234,10 @@ const nodeActionWheelProps = (node: TreeNode) => {
}}
</span>
</button>
<span v-if="snapshotLoading" class="tag is-light" role="status" data-testid="invoice-period-tree-snapshot-loading">
<span class="icon is-small" aria-hidden="true"><i class="fas fa-spinner fa-spin"></i></span>
<span>{{ treeText("preview.loading", "Indlæser forhåndsvisning") }}</span>
</span>
<span v-if="hasSelection" class="tag is-dark is-light">{{ selectedCountLabel }}</span>
</div>
<div v-if="hasSelection" class="invoice-period-tree-toolbar__actions">
@@ -0,0 +1,172 @@
export interface InvoiceCollectionActionPreview {
previewId: string;
confirmationPhrase: string;
collectionCount: number;
changedCount: number;
skippedCount: number;
targetInvoiceCollectionId: number | null;
offPeriodOrderCount: number;
offPeriodTotalNetAmount: number;
blockers: Record<string, any>[];
changes: Record<string, any>[];
raw: Record<string, any>;
}
type Translate = (key: string, params?: Record<string, unknown>) => string;
const asArray = (value: unknown): Record<string, any>[] => (Array.isArray(value) ? value : []);
const finiteNumber = (value: unknown) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
};
const positiveInteger = (value: unknown) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
export const escapeInvoiceCollectionPreviewHtml = (value: unknown) =>
String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
export const normalizeInvoiceCollectionActionPreview = (
value: unknown,
fallbackCollectionCount = 0
): InvoiceCollectionActionPreview => {
const preview = value && typeof value === "object" ? (value as Record<string, any>) : {};
const summary = preview.summary && typeof preview.summary === "object" ? preview.summary : {};
const impact = preview.off_period_impact && typeof preview.off_period_impact === "object"
? preview.off_period_impact
: {};
const options = preview.options && typeof preview.options === "object" ? preview.options : {};
const changes = [
...asArray(preview.changes),
...asArray(preview.order_items),
...asArray(preview.orders),
...asArray(preview.items),
...asArray(preview.examples),
];
return {
previewId: String(preview.preview_id ?? ""),
confirmationPhrase: String(preview.confirmation_phrase ?? ""),
collectionCount: finiteNumber(
summary.collection_count ?? summary.collections ?? asArray(preview.invoice_collection_ids).length ?? fallbackCollectionCount
) || fallbackCollectionCount,
changedCount: finiteNumber(summary.changed_count ?? summary.order_items ?? summary.orders_to_move),
skippedCount: finiteNumber(summary.skipped_count),
targetInvoiceCollectionId: positiveInteger(
preview.target_invoice_collection_id
?? options.target_invoice_collection_id
?? summary.target_invoice_collection_id
),
offPeriodOrderCount: finiteNumber(
impact.order_count
?? impact.orders
?? summary.off_period_order_count
?? asArray(preview.off_period_orders).length
),
offPeriodTotalNetAmount: finiteNumber(
impact.total_net_amount
?? impact.amount
?? summary.off_period_total_net_amount
),
blockers: asArray(preview.blockers),
changes,
raw: preview,
};
};
const translated = (t: Translate, key: string, params: Record<string, unknown>, fallback: string) => {
const value = t(key, params);
return value === key ? fallback : value;
};
const concreteChangeLabel = (change: Record<string, any>, t: Translate) => {
if (change.message) {
return String(change.message);
}
if (change.order_item_id || change.product_id) {
return t("invoicing_period.invoice_collection_actions.preview.order_item_line", {
collection: change.invoice_collection_id,
order: change.order_id,
product: change.product_name || change.product_id || change.order_item_id,
});
}
if (change.order_id && (change.source_invoice_collection_id || change.target_invoice_collection_id)) {
return t("invoicing_period.invoice_collection_actions.preview.order_move_line", {
order: change.order_id,
source: change.source_invoice_collection_id,
target: change.target_invoice_collection_id,
});
}
if (change.invoice_collection_id && change.status) {
return t("invoicing_period.invoice_collection_actions.preview.collection_status_line", {
collection: change.invoice_collection_id,
status: change.status,
});
}
return Object.entries(change)
.filter(([, value]) => ["string", "number", "boolean"].includes(typeof value))
.slice(0, 4)
.map(([key, value]) => `${key}: ${value}`)
.join(" · ");
};
export const renderInvoiceCollectionActionPreviewHtml = ({
preview,
t,
formatCurrency = (value: number) => String(value),
}: {
preview: InvoiceCollectionActionPreview;
t: Translate;
formatCurrency?: (value: number) => string;
}) => {
const lines = [
t("invoicing_period.invoice_collection_actions.preview.collections", { count: preview.collectionCount }),
t("invoicing_period.invoice_collection_actions.preview.changed", { count: preview.changedCount }),
...(preview.skippedCount > 0
? [t("invoicing_period.invoice_collection_actions.preview.skipped", { count: preview.skippedCount })]
: []),
...(preview.targetInvoiceCollectionId
? [t("invoicing_period.invoice_collection_actions.preview.merge_target", { id: preview.targetInvoiceCollectionId })]
: []),
...(preview.offPeriodOrderCount > 0
? [translated(
t,
"invoicing_period.invoice_collection_actions.preview.off_period_impact",
{ count: preview.offPeriodOrderCount, amount: formatCurrency(preview.offPeriodTotalNetAmount) },
`${preview.offPeriodOrderCount} orders outside the selected period · ${formatCurrency(preview.offPeriodTotalNetAmount)}`
)]
: []),
];
const html = [
`<p>${escapeInvoiceCollectionPreviewHtml(t(
"invoicing_period.invoice_collection_actions.preview.requires_confirmation",
{ phrase: preview.confirmationPhrase }
))}</p>`,
`<ul class="has-text-left">${lines.map((line) => `<li>${escapeInvoiceCollectionPreviewHtml(line)}</li>`).join("")}</ul>`,
];
const changes = preview.changes.map((change) => concreteChangeLabel(change, t)).filter(Boolean).slice(0, 10);
if (changes.length > 0) {
html.push(`<hr><p class="has-text-left has-text-weight-bold">${escapeInvoiceCollectionPreviewHtml(
t("invoicing_period.invoice_collection_actions.preview.affected_examples")
)}</p>`);
html.push(`<ul class="has-text-left">${changes.map((line) => `<li>${escapeInvoiceCollectionPreviewHtml(line)}</li>`).join("")}</ul>`);
}
if (preview.blockers.length > 0) {
html.push(`<hr><p class="has-text-left has-text-weight-bold has-text-danger">${escapeInvoiceCollectionPreviewHtml(
t("invoicing_period.invoice_collection_actions.preview.blockers")
)}</p>`);
html.push(`<ul class="has-text-left">${preview.blockers.map((blocker) => `<li>${escapeInvoiceCollectionPreviewHtml(
blocker.message || blocker.code
)}</li>`).join("")}</ul>`);
}
return html.join("");
};
export const isInvoiceCollectionTreeActionUnavailable = (error: any) =>
[404, 405, 410, 501].includes(Number(error?.response?.status));
@@ -0,0 +1,418 @@
import {
TREE_CATEGORY_TYPES,
TREE_NODE_TYPES,
buildOrderItemTree,
classifyAttachment,
getTreeAmount,
makeAttachmentNode,
makeBookingItemNode,
makeBookingNode,
makeCategoryNode,
makeCollectionNode,
makeEconomicInvoiceNode,
makeNodeId,
makeOrderNode,
makeXlvaskInferredItemNode,
makeXlvaskNode,
} from "./invoicingPeriodTreeNodes.js";
export type InvoiceCollectionActionCapability =
| "remove_customer_rule_violations"
| "merge_collections"
| "split_by_month"
| "reset_hidden_item_prices"
| "queue_economic";
export interface InvoicingPeriodTreeCapabilities {
object_tree_v2: boolean;
actions: Partial<Record<InvoiceCollectionActionCapability, boolean>>;
}
export interface InvoicingPeriodSnapshotOrder extends Record<string, unknown> {
id: number;
in_selected_period: boolean;
items: Record<string, unknown>[];
attachments: Record<string, unknown>[];
bookings: Record<string, unknown>[];
xlvask: Record<string, unknown>[];
}
export interface InvoicingPeriodSnapshotCollection extends Record<string, unknown> {
id: number;
in_selected_period: boolean;
complete_order_count: number;
complete_total_net_amount: number;
period_order_count: number;
period_total_net_amount: number;
orders: InvoicingPeriodSnapshotOrder[];
}
interface SnapshotBase {
customer_number: number;
date_from: string;
date_to: string;
capabilities: InvoicingPeriodTreeCapabilities;
customer: Record<string, unknown>;
collections: InvoicingPeriodSnapshotCollection[];
uncollected_orders: InvoicingPeriodSnapshotOrder[];
agreements: Record<string, unknown>[];
payments: Record<string, unknown>[];
economic_invoices: Record<string, unknown>[];
}
export interface CompleteInvoicingPeriodTreeSnapshot extends SnapshotBase {
complete: true;
source: "snapshot";
snapshot_revision: string;
}
export interface LegacyInvoicingPeriodTreeSnapshot extends SnapshotBase {
complete: false;
source: "legacy";
snapshot_revision: null;
}
export type InvoicingPeriodTreeSnapshot =
| CompleteInvoicingPeriodTreeSnapshot
| LegacyInvoicingPeriodTreeSnapshot;
export interface SnapshotTreeLabels {
collection: (id: number) => string;
ordersWithoutCollection: string;
orders: string;
items: string;
attachments: {
certificates: string;
images: string;
other: string;
};
bookings: string;
booking: (id: number) => string;
xlvask: string;
economic: string;
agreement: (id: number | string) => string;
payment: (id: number | string) => string;
order: (id: number) => string;
orderItemFallback: (id: number | string) => string;
attachmentFallback: (id: number | string) => string;
bookingItemFallback: (id: number | string) => string;
xlvaskItemFallback: (index: number) => string;
}
type RequestFunction = (
url: string,
method: string,
data?: Record<string, unknown>,
catchCallable?: null,
thenCallable?: null,
options?: Record<string, unknown>
) => Promise<unknown>;
const asObject = (value: unknown): Record<string, any> =>
value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, any>) : {};
const asArray = <T = Record<string, unknown>>(value: unknown): T[] => (Array.isArray(value) ? value : []);
const asArrayOrSingle = <T = Record<string, unknown>>(value: unknown): T[] =>
Array.isArray(value) ? value : value && typeof value === "object" ? [value as T] : [];
const asString = (value: unknown) => String(value ?? "").trim();
const positiveInteger = (value: unknown) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const finiteNumber = (value: unknown) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
};
const booleanValue = (value: unknown) => value === true || value === 1 || value === "1";
const apiPayload = (response: any) => response?.data?.data ?? response?.data ?? response ?? {};
const normalizeOrder = (value: unknown): InvoicingPeriodSnapshotOrder | null => {
const order = asObject(value);
const id = positiveInteger(order.id ?? order.order_id);
if (!id) {
return null;
}
const xlvaskValue = order.xlvask ?? order.xlvask_usage ?? order.xlvask_usage_logs;
return {
...order,
id,
in_selected_period: booleanValue(order.in_selected_period),
items: asArray(order.items ?? order.order_items),
attachments: asArray(order.attachments),
bookings: asArrayOrSingle(order.bookings ?? order.booking),
xlvask: asArrayOrSingle(xlvaskValue),
};
};
const normalizeCollection = (value: unknown): InvoicingPeriodSnapshotCollection | null => {
const collection = asObject(value);
const id = positiveInteger(collection.id ?? collection.invoice_collection_id);
if (!id) {
return null;
}
const orders = asArray(collection.orders).map(normalizeOrder).filter(Boolean) as InvoicingPeriodSnapshotOrder[];
const periodOrders = orders.filter((order) => order.in_selected_period);
return {
...collection,
id,
in_selected_period: booleanValue(collection.in_selected_period) || periodOrders.length > 0,
complete_order_count: Math.max(0, finiteNumber(collection.complete_order_count ?? orders.length)),
complete_total_net_amount: finiteNumber(
collection.complete_total_net_amount ?? collection.total_net_amount ?? orders.reduce((sum, order) => sum + getTreeAmount(order), 0)
),
period_order_count: Math.max(0, finiteNumber(collection.period_order_count ?? periodOrders.length)),
period_total_net_amount: finiteNumber(
collection.period_total_net_amount ?? periodOrders.reduce((sum, order) => sum + getTreeAmount(order), 0)
),
orders,
};
};
const normalizeCapabilities = (value: unknown): InvoicingPeriodTreeCapabilities => {
const capabilities = asObject(value);
const actions = asObject(capabilities.actions);
return {
object_tree_v2: booleanValue(capabilities.object_tree_v2),
actions: Object.fromEntries(Object.entries(actions).map(([key, enabled]) => [key, booleanValue(enabled)])),
};
};
export const normalizeInvoicingPeriodTreeSnapshot = (response: unknown): CompleteInvoicingPeriodTreeSnapshot | null => {
const payload = asObject(apiPayload(response));
const capabilities = normalizeCapabilities(payload.capabilities);
const customerNumber = positiveInteger(payload.customer_number ?? payload.customer?.customer_number);
const revision = asString(payload.snapshot_revision);
if (payload.complete !== true || !capabilities.object_tree_v2 || !customerNumber || !revision) {
return null;
}
return {
source: "snapshot",
complete: true,
snapshot_revision: revision,
customer_number: customerNumber,
date_from: asString(payload.date_from),
date_to: asString(payload.date_to),
capabilities,
customer: { ...asObject(payload.customer), customer_number: customerNumber },
collections: asArray(payload.collections).map(normalizeCollection).filter(Boolean) as InvoicingPeriodSnapshotCollection[],
uncollected_orders: asArray(payload.uncollected_orders).map(normalizeOrder).filter(Boolean) as InvoicingPeriodSnapshotOrder[],
agreements: asArray(payload.agreements),
payments: asArray(payload.payments),
economic_invoices: asArray(payload.economic_invoices),
};
};
export const makeLegacyTreeSnapshot = ({
customer,
transactions,
excludedOrderIds,
dateFrom,
dateTo,
}: {
customer: Record<string, any>;
transactions: Record<string, any>[];
excludedOrderIds: unknown[];
dateFrom: string;
dateTo: string;
}): LegacyInvoicingPeriodTreeSnapshot => {
const excluded = new Set(excludedOrderIds.map(Number));
return {
source: "legacy",
complete: false,
snapshot_revision: null,
customer_number: positiveInteger(customer?.customer_number) ?? 0,
date_from: dateFrom,
date_to: dateTo,
capabilities: { object_tree_v2: false, actions: {} },
customer,
collections: [],
uncollected_orders: transactions.filter((order) => !excluded.has(Number(order?.id))).map(normalizeOrder).filter(Boolean) as InvoicingPeriodSnapshotOrder[],
agreements: [],
payments: [],
economic_invoices: [],
};
};
const belongsToCollection = (object: Record<string, any>, collectionId: number) =>
positiveInteger(object.invoice_collection_id ?? object.collection_id ?? object.collected_invoice_id) === collectionId;
const category = (id: string, label: string, categoryType: string, parentType: string, parentId: string, children: any[]) => ({
...makeCategoryNode({ id, label, category: categoryType, parentType, parentId, count: children.length, checkable: true }),
isLeaf: children.length === 0,
children,
});
const buildBookingNode = (booking: Record<string, any>, labels: SnapshotTreeLabels) => {
const bookingId = positiveInteger(booking.id) ?? 0;
const node = makeBookingNode(booking, { label: labels.booking(bookingId) });
const items = asArray(booking.items ?? booking.booking_items).map((item, index) =>
makeBookingItemNode(item, bookingId, { fallbackLabel: labels.bookingItemFallback(positiveInteger(item?.id) ?? index + 1) })
);
node.children = items.length > 0
? [category(makeNodeId(TREE_NODE_TYPES.CATEGORY, bookingId, TREE_CATEGORY_TYPES.BOOKING_ITEMS), labels.items, TREE_CATEGORY_TYPES.BOOKING_ITEMS, node.type, node.id, items)]
: [];
node.isLeaf = node.children.length === 0;
return node;
};
const buildCompleteOrderNode = (order: InvoicingPeriodSnapshotOrder, labels: SnapshotTreeLabels) => {
const node = makeOrderNode(order, { label: labels.order(order.id) });
node.meta.inSelectedPeriod = order.in_selected_period;
const itemNodes = buildOrderItemTree(order.items, {
fallbackLabel: (item: any, index: number) => labels.orderItemFallback(positiveInteger(item?.id) ?? index + 1),
invoiceState: order.invoice_state,
});
const attachmentGroups = new Map<string, any[]>([["certificate", []], ["image", []], ["other", []]]);
order.attachments.forEach((attachment: any, index: number) => {
attachmentGroups.get(classifyAttachment(attachment))?.push(
makeAttachmentNode(attachment, order.id, { fallbackLabel: labels.attachmentFallback(positiveInteger(attachment?.id) ?? index + 1) })
);
});
const bookingNodes = order.bookings.map((booking) => buildBookingNode(booking, labels));
const xlvaskNodes = order.xlvask.map((usage: any) => {
const xlvaskNode = makeXlvaskNode(usage, order, { emptyLabel: labels.xlvask });
const items = asArray(usage.WashItems ?? usage.items).map((item, index) =>
makeXlvaskInferredItemNode(item, xlvaskNode.meta.washId, index, { fallbackLabel: labels.xlvaskItemFallback(index) })
);
xlvaskNode.children = items.length > 0
? [category(makeNodeId(TREE_NODE_TYPES.CATEGORY, xlvaskNode.id, TREE_CATEGORY_TYPES.XLVASK_ITEMS), labels.items, TREE_CATEGORY_TYPES.XLVASK_ITEMS, xlvaskNode.type, xlvaskNode.id, items)]
: [];
xlvaskNode.isLeaf = xlvaskNode.children.length === 0;
return xlvaskNode;
});
const categories = [
itemNodes.length > 0
? category(makeNodeId(TREE_NODE_TYPES.CATEGORY, order.id, TREE_CATEGORY_TYPES.ORDER_ITEMS), labels.items, TREE_CATEGORY_TYPES.ORDER_ITEMS, node.type, node.id, itemNodes)
: null,
...(["certificate", "image", "other"] as const).map((kind) => {
const children = attachmentGroups.get(kind) ?? [];
const categoryType = {
certificate: TREE_CATEGORY_TYPES.ORDER_ATTACHMENTS_CERTIFICATES,
image: TREE_CATEGORY_TYPES.ORDER_ATTACHMENTS_IMAGES,
other: TREE_CATEGORY_TYPES.ORDER_ATTACHMENTS_OTHER,
}[kind];
return children.length > 0
? category(makeNodeId(TREE_NODE_TYPES.CATEGORY, order.id, categoryType), labels.attachments[kind === "certificate" ? "certificates" : kind === "image" ? "images" : "other"], categoryType, node.type, node.id, children)
: null;
}),
bookingNodes.length > 0
? category(makeNodeId(TREE_NODE_TYPES.CATEGORY, order.id, TREE_CATEGORY_TYPES.ORDER_BOOKINGS), labels.bookings, TREE_CATEGORY_TYPES.ORDER_BOOKINGS, node.type, node.id, bookingNodes)
: null,
xlvaskNodes.length > 0
? category(makeNodeId(TREE_NODE_TYPES.CATEGORY, order.id, TREE_CATEGORY_TYPES.ORDER_XLVASK), labels.xlvask, TREE_CATEGORY_TYPES.ORDER_XLVASK, node.type, node.id, xlvaskNodes)
: null,
].filter(Boolean);
node.children = categories;
node.isLeaf = categories.length === 0;
return node;
};
const genericObjectNode = (type: string, object: Record<string, any>, label: string, collectionId: number) => {
const objectId = positiveInteger(object.id ?? object[`${type}_id`]) ?? (asString(object.id ?? object.external_id) || "unknown");
return {
id: makeNodeId(type, objectId, collectionId),
label,
type,
isLeaf: true,
children: [],
selectable: true,
actionable: false,
icon: type === TREE_NODE_TYPES.PAYMENT ? "fa-credit-card" : "fa-file-contract",
meta: { object, collectionId, amount: getTreeAmount(object), totalNetAmount: getTreeAmount(object) },
};
};
export const buildCompleteSnapshotRootNodes = (snapshot: CompleteInvoicingPeriodTreeSnapshot, labels: SnapshotTreeLabels) => {
const customer = snapshot.customer;
const collectionNodes = [...snapshot.collections].sort((left, right) => left.id - right.id).map((collection) => {
const node = makeCollectionNode(collection.id, collection.orders, customer, {
collectionSummary: collection,
invoiceState: asString(collection.invoice_state ?? collection.state),
completeOrderCount: collection.complete_order_count,
completeTotalNetAmount: collection.complete_total_net_amount,
periodOrderCount: collection.period_order_count,
periodTotalNetAmount: collection.period_total_net_amount,
offPeriodOrderCount: Math.max(0, collection.complete_order_count - collection.period_order_count),
offPeriodTotalNetAmount: collection.complete_total_net_amount - collection.period_total_net_amount,
inSelectedPeriod: collection.in_selected_period,
});
node.meta.orderCount = collection.complete_order_count;
node.meta.totalNetAmount = collection.complete_total_net_amount;
const orders = [...collection.orders].sort((left, right) => left.id - right.id).map((order) => buildCompleteOrderNode(order, labels));
const agreements = snapshot.agreements.filter((item) => belongsToCollection(item, collection.id)).map((item, index) =>
genericObjectNode(TREE_NODE_TYPES.AGREEMENT, item, labels.agreement(positiveInteger(item.id) ?? index + 1), collection.id)
);
const payments = snapshot.payments.filter((item) => belongsToCollection(item, collection.id)).map((item, index) =>
genericObjectNode(TREE_NODE_TYPES.PAYMENT, item, labels.payment(positiveInteger(item.id) ?? index + 1), collection.id)
);
const economicInvoices = snapshot.economic_invoices.filter((item) => belongsToCollection(item, collection.id)).map((item: any) =>
makeEconomicInvoiceNode({
collectionId: collection.id,
invoiceType: asString(item.invoice_type ?? item.type) === "draft" ? "draft" : "booked",
economicInvoiceId: positiveInteger(item.economic_invoice_id ?? item.invoice_id ?? item.id),
details: item,
label: asString(item.label) || null,
})
);
node.children = [
orders.length > 0 ? category(makeNodeId(TREE_NODE_TYPES.CATEGORY, collection.id, TREE_CATEGORY_TYPES.COLLECTION_ORDERS), labels.orders, TREE_CATEGORY_TYPES.COLLECTION_ORDERS, node.type, node.id, orders) : null,
agreements.length > 0 ? category(makeNodeId(TREE_NODE_TYPES.CATEGORY, collection.id, TREE_CATEGORY_TYPES.COLLECTION_AGREEMENTS), labels.agreement(""), TREE_CATEGORY_TYPES.COLLECTION_AGREEMENTS, node.type, node.id, agreements) : null,
payments.length > 0 ? category(makeNodeId(TREE_NODE_TYPES.CATEGORY, collection.id, TREE_CATEGORY_TYPES.COLLECTION_PAYMENTS), labels.payment(""), TREE_CATEGORY_TYPES.COLLECTION_PAYMENTS, node.type, node.id, payments) : null,
economicInvoices.length > 0 ? category(makeNodeId(TREE_NODE_TYPES.CATEGORY, collection.id, TREE_CATEGORY_TYPES.COLLECTION_ECONOMIC), labels.economic, TREE_CATEGORY_TYPES.COLLECTION_ECONOMIC, node.type, node.id, economicInvoices) : null,
].filter(Boolean);
node.isLeaf = node.children.length === 0;
return node;
});
if (snapshot.uncollected_orders.length > 0) {
const orders = snapshot.uncollected_orders.map((order) => buildCompleteOrderNode(order, labels));
collectionNodes.push(category(
makeNodeId(TREE_NODE_TYPES.CATEGORY, "orders_without_collection", snapshot.customer_number),
labels.ordersWithoutCollection,
TREE_CATEGORY_TYPES.COLLECTION_ORDERS,
"customer",
String(snapshot.customer_number),
orders
) as any);
}
return collectionNodes;
};
export const createInvoicingPeriodTreeSnapshotLoader = (request: RequestFunction) => {
let active: { key: string; controller: AbortController; promise: Promise<CompleteInvoicingPeriodTreeSnapshot | null> } | null = null;
const load = (parameters: { customerNumber: number; dateFrom: string; dateTo: string }, options: { force?: boolean } = {}) => {
const key = JSON.stringify(parameters);
if (!options.force && active?.key === key) {
return active.promise;
}
active?.controller.abort();
const controller = new AbortController();
const promise = request(
"/superuser/invoicing/period/tree",
"GET",
parameters,
null,
null,
{ signal: controller.signal }
).then(normalizeInvoicingPeriodTreeSnapshot);
active = { key, controller, promise };
const clearActive = () => {
if (active?.promise === promise) {
active = null;
}
};
void promise.then(clearActive, clearActive);
return promise;
};
return {
load,
dispose: () => {
active?.controller.abort();
active = null;
},
};
};
export const isTreeEndpointUnavailable = (error: any) =>
[404, 405, 410, 501].includes(Number(error?.response?.status));
@@ -255,6 +255,7 @@ const selectedCustomer = computed(() => {
const selectedNumber = Number(periodPaging.selectedCustomerNumber || 0);
return reviewQueueCustomers.value.find((customer: any) => Number(customer.customer_number) === selectedNumber) ?? null;
});
const periodCapabilities = computed(() => view.variables.sharedVariables.value?.capabilities ?? {});
const selectCustomerByOffset = (offset: number) => {
const needingReview = reviewQueueCustomers.value.filter((customer: any) => getCustomerReview(customer).state !== "completed");
@@ -1585,6 +1586,7 @@ const getTransactionQueryParameters = () => {
>
<InvoicingPeriodObjectTree
:customer="selectedCustomer"
:capabilities="periodCapabilities"
:transactions="getTransactionsInView(selectedCustomer)"
:excluded-order-ids="getExcludedTransactionIds(selectedCustomer)"
:auto-expand-all="view.variables.currentView.value === 'invoice_per_order'"
+167
View File
@@ -214,6 +214,71 @@ function createObjectTreePeriodPayload({ dateFrom = "2026-07-14" } = {}) {
return payload;
}
function createObjectTreeV2PeriodPayload(options = {}) {
const payload = createObjectTreePeriodPayload(options);
payload.capabilities = { object_tree_v2: true };
return payload;
}
function createObjectTreeV2Snapshot(revision = "snapshot-rev-1") {
const periodOrder = createObjectTreePeriodPayload({ dateFrom: "2026-07-01" }).types.all[0].transactions[0];
return {
complete: true,
snapshot_revision: revision,
customer_number: 4101,
date_from: "2026-07-01",
date_to: "2026-07-31",
capabilities: {
object_tree_v2: true,
actions: {
queue_economic: true,
remove_customer_rule_violations: false,
merge_collections: false,
split_by_month: false,
reset_hidden_item_prices: false,
},
},
customer: { customer_number: 4101, customer_name: "Object Tree Logistics" },
collections: [
{
id: 3001,
in_selected_period: true,
complete_order_count: 2,
complete_total_net_amount: 460,
period_order_count: 1,
period_total_net_amount: 360,
orders: [
{
...periodOrder,
in_selected_period: true,
items: [{ id: 7701, order_id: 9001, product_name: "Period wash", price: 360, quantity: 1 }],
attachments: [],
bookings: [],
xlvask: [],
},
{
...periodOrder,
id: 9002,
date: "2026-06-29T10:00:00.000Z",
created_at: "2026-06-29 10:00:00",
amount: 100,
total_net_amount: 100,
in_selected_period: false,
items: [{ id: 7702, order_id: 9002, product_name: "Off-period wash", price: 100, quantity: 1 }],
attachments: [],
bookings: [],
xlvask: [],
},
],
},
],
uncollected_orders: [],
agreements: [],
payments: [],
economic_invoices: [],
};
}
function createFlaggedPeriodPayload({
resolvedManualFlagIds = [],
resolvedAutomaticFingerprints = [],
@@ -1216,6 +1281,48 @@ async function routeObjectTreeOrderEndpoints(page) {
});
}
async function routeObjectTreeV2Endpoints(page, state) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url());
if (route.request().method() === "GET" && url.pathname.endsWith("/superuser/invoicing/period/tree")) {
state.snapshotRequests.push(url.searchParams.get("customerNumber"));
const revision = state.snapshotRequests.length === 1 ? "snapshot-rev-1" : "snapshot-rev-2";
await route.fulfill(json({ data: createObjectTreeV2Snapshot(revision) }));
return;
}
if (route.request().method() === "POST" && url.pathname.endsWith("/tree-actions/preview")) {
state.previewRequests.push(route.request().postDataJSON());
await route.fulfill(
json({
data: {
preview_id: `preview-${state.previewRequests.length}`,
confirmation_phrase: "CONFIRM",
summary: {
collection_count: 1,
changed_count: 1,
off_period_order_count: 1,
off_period_total_net_amount: 100,
},
changes: [{ message: "Queue invoice collection #3001" }],
blockers: [],
},
})
);
return;
}
if (route.request().method() === "POST" && url.pathname.endsWith("/tree-actions/apply")) {
state.applyRequests.push(route.request().postDataJSON());
await route.fulfill(
state.applyRequests.length === 1
? json({ message: "Snapshot revision is stale" }, 409)
: json({ data: { jobs: [{ id: 701 }], result: { changed_count: 1 } } })
);
return;
}
await route.fallback();
});
}
async function expandTreeNode(page, key) {
const node = page.locator(`[data-node-key="${key}"]`).first();
await expect(node).toBeVisible();
@@ -1474,6 +1581,66 @@ test.describe("Invoicing period tab", () => {
}
});
test("@invoice-tree-v2 selected-customer snapshot handles stale revision then applies against the refreshed tree", async ({
page,
}) => {
const state = { snapshotRequests: [], previewRequests: [], applyRequests: [] };
const orderItemRequests = [];
page.on("request", (request) => {
if (request.method() === "GET" && matchesApiPath(request.url(), "/order/items")) {
orderItemRequests.push(request.url());
}
});
await openPeriodView(page, {
payloadFactory: createObjectTreeV2PeriodPayload,
beforeGoto: (currentPage) => routeObjectTreeV2Endpoints(currentPage, state),
});
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoice-period-tree-node-collected_order_invoice:3001")).toContainText(
/Valgt periode|Selected period/i
);
await expect(page.getByTestId("invoice-period-tree-node-collected_order_invoice:3001")).toContainText(
/Hele samlingen|Whole collection/i
);
await page.getByTestId("invoice-period-tree-toggle-all").click();
await expect(page.getByTestId("invoice-period-tree-node-order:9002")).toBeVisible();
expect(orderItemRequests).toHaveLength(0);
const collectionNode = page.locator('[data-node-key="collected_order_invoice:3001"]');
await collectionNode.locator(":scope > .b-tree-node-content .b-tree-node-checkbox label").click();
const runQueueAction = async () => {
await page.getByTestId("invoice-period-tree-actions-trigger-collected_order_invoice").click();
await page.getByTestId("invoice-period-tree-action-collection:queue-economic").click();
await expect(page.locator(".swal2-html-container")).toContainText(
/Uden for valgt periode|Outside selected period/i
);
await page.locator(".swal2-input").fill("CONFIRM");
await page.locator(".swal2-confirm").click();
};
await runQueueAction();
await expect.poll(() => state.applyRequests.length).toBe(1);
await expect.poll(() => state.snapshotRequests.length).toBeGreaterThanOrEqual(2);
await expect(page.locator(".swal2-popup")).toBeVisible();
await page.locator(".swal2-confirm").click();
await runQueueAction();
await expect.poll(() => state.applyRequests.length).toBe(2);
await expect.poll(() => state.snapshotRequests.length).toBeGreaterThanOrEqual(3);
expect(state.previewRequests[0]).toMatchObject({
action: "queue_economic",
customer_number: 4101,
snapshot_revision: "snapshot-rev-1",
invoice_collection_ids: [3001],
});
expect(state.previewRequests[1]).toMatchObject({ snapshot_revision: "snapshot-rev-2" });
expect(state.applyRequests).toEqual([
{ preview_id: "preview-1", confirmation_text: "CONFIRM" },
{ preview_id: "preview-2", confirmation_text: "CONFIRM" },
]);
});
test("@smoke period view opens Selvvask import and attaching view", async ({ page }) => {
const usageOrderRequests = [];
const fastLinkRequests = [];
+166 -3
View File
@@ -204,6 +204,8 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
},
bulk_action_preview: vi.fn(),
bulk_action_apply: vi.fn(),
period_tree_action_preview: vi.fn(),
period_tree_action_apply: vi.fn(),
},
showEditObjectFieldForm: vi.fn(),
},
@@ -237,15 +239,64 @@ vi.mock(
vi.mock("@/components/displays/department/pos/orders/invoiceCollectionBulkActions.js", () => ({
INVOICE_COLLECTION_BULK_ACTIONS: {
QUEUE_ECONOMIC: "queue_economic",
CLEAN_CUSTOMER_RULES: "clean_customer_rules",
MERGE: "merge",
CLEAN_CUSTOMER_RULES: "remove_customer_rule_violations",
MERGE: "merge_collections",
SPLIT_BY_MONTH: "split_by_month",
RESET_HIDDEN_PRICES: "reset_hidden_prices",
RESET_HIDDEN_PRICES: "reset_hidden_item_prices",
},
}));
import InvoicingPeriodObjectTree from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import Swal from "sweetalert2";
const snapshotResponse = (revision = "rev-1", actions = {}) => ({
data: {
data: {
complete: true,
snapshot_revision: revision,
customer_number: 2001,
date_from: "2026-06-01",
date_to: "2026-06-30",
capabilities: { object_tree_v2: true, actions },
customer: { customer_number: 2001, customer_name: "ACME" },
collections: [
{
id: 3001,
complete_order_count: 2,
complete_total_net_amount: 300,
period_order_count: 1,
period_total_net_amount: 240,
in_selected_period: true,
orders: [
{
...mocks.order,
in_selected_period: true,
items: mocks.orderItems,
attachments: [],
bookings: [],
xlvask: [],
},
{
id: 9002,
invoice_collection_id: 3001,
in_selected_period: false,
total_net_amount: 60,
items: [],
attachments: [],
bookings: [],
xlvask: [],
},
],
},
],
uncollected_orders: [],
agreements: [],
payments: [],
economic_invoices: [],
},
},
});
const mountTree = (overrides = {}) =>
mount(InvoicingPeriodObjectTree, {
@@ -716,4 +767,116 @@ describe("InvoicingPeriodObjectTree", () => {
wrapper.unmount();
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:attachment-preview");
});
it("uses the complete snapshot only when advertised and hides disabled collection actions", async () => {
mocks.request.mockResolvedValueOnce(snapshotResponse("rev-capabilities", { queue_economic: true }));
const wrapper = mountTree({ capabilities: { object_tree_v2: true } });
await flushPromises();
expect(mocks.request).toHaveBeenCalledWith(
"/superuser/invoicing/period/tree",
"GET",
{ customerNumber: 2001, dateFrom: "2026-06-01", dateTo: "2026-06-30" },
null,
null,
expect.objectContaining({ signal: expect.any(AbortSignal) })
);
const collectionWheel = wrapper.get(
'[data-testid="invoice-period-tree-action-wheel-collected_order_invoice:3001"]'
);
expect(collectionWheel.attributes("data-action-count")).toBe("1");
expect(
wrapper.get('[data-node-key="collected_order_invoice:3001"] .invoice-period-tree-node__subtitle').text()
).toContain("Periode: 1 orders · 240 DKK");
expect(wrapper.text()).toContain("Hele samlingen: 2 orders · 300 DKK");
await wrapper.get('[data-testid="invoice-period-tree-toggle-all"]').trigger("click");
await flushPromises();
expect(wrapper.find('[data-node-key="order:9002"]').exists()).toBe(true);
expect(mocks.request.mock.calls.some(([url]) => url === "/order/items")).toBe(false);
});
it("keeps legacy collection mutations disabled while a superseding snapshot request is pending", async () => {
const pending = [];
mocks.request.mockImplementation((url, _method, _parameters, _catcher, _then, options) =>
url === "/superuser/invoicing/period/tree"
? new Promise((resolve, reject) => {
options.signal.addEventListener("abort", () =>
reject(Object.assign(new Error("aborted"), { name: "AbortError" }))
);
pending.push({ resolve, signal: options.signal });
})
: Promise.resolve({ data: { data: [] } })
);
const wrapper = mountTree({ capabilities: { object_tree_v2: false } });
await wrapper.setProps({
dates: { dateFrom: "2026-07-01", dateTo: "2026-07-31" },
capabilities: { object_tree_v2: true },
});
await flushPromises();
expect(pending).toHaveLength(2);
expect(pending[0].signal.aborted).toBe(true);
expect(wrapper.find('[data-testid="invoice-period-tree-snapshot-loading"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="invoice-period-tree-action-wheel-collected_order_invoice:3001"]').exists()).toBe(
false
);
pending[1].resolve({
...snapshotResponse("rev-overlap", { queue_economic: true }),
data: {
data: {
...snapshotResponse("rev-overlap", { queue_economic: true }).data.data,
date_from: "2026-07-01",
date_to: "2026-07-31",
},
},
});
await flushPromises();
expect(wrapper.find('[data-testid="invoice-period-tree-snapshot-loading"]').exists()).toBe(false);
expect(
wrapper
.get('[data-testid="invoice-period-tree-action-wheel-collected_order_invoice:3001"]')
.attributes("data-action-count")
).toBe("1");
});
it("refreshes the complete snapshot after a stale-revision 409", async () => {
const actions = { queue_economic: true };
mocks.request
.mockResolvedValueOnce(snapshotResponse("rev-stale", actions))
.mockResolvedValueOnce(snapshotResponse("rev-current", actions));
SessionUser.objects.collectedOrderInvoices.functions.period_tree_action_preview.mockResolvedValueOnce({
data: {
data: {
preview_id: "preview-1",
confirmation_phrase: "CONFIRM",
summary: { collection_count: 1, changed_count: 1 },
blockers: [],
},
},
});
SessionUser.objects.collectedOrderInvoices.functions.period_tree_action_apply.mockRejectedValueOnce({
response: { status: 409 },
message: "Snapshot is stale",
});
Swal.fire.mockResolvedValueOnce({ isConfirmed: true, value: "CONFIRM" });
const wrapper = mountTree({ capabilities: { object_tree_v2: true } });
await flushPromises();
await wrapper.get('[data-node-key="collected_order_invoice:3001"] .b-checkbox-stub').trigger("click");
await flushPromises();
await wrapper.get('[data-testid="invoice-period-tree-actions-trigger-collected_order_invoice"]').trigger("click");
await wrapper.get('[data-testid="invoice-period-tree-action-collection:queue-economic"]').trigger("click");
await flushPromises();
expect(SessionUser.objects.collectedOrderInvoices.functions.period_tree_action_apply).toHaveBeenCalledWith({
preview_id: "preview-1",
confirmation_text: "CONFIRM",
});
expect(mocks.request.mock.calls.filter(([url]) => url === "/superuser/invoicing/period/tree")).toHaveLength(2);
expect(SessionUser.objects.collectedOrderInvoices.functions.bulk_action_apply).not.toHaveBeenCalled();
});
});
@@ -18,6 +18,10 @@ import {
makeXlvaskInferredItemNode,
makeXlvaskNode,
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeNodes.js";
import {
buildCompleteSnapshotRootNodes,
normalizeInvoicingPeriodTreeSnapshot,
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeSnapshot.ts";
describe("invoicing period tree node builders", () => {
it("groups visible orders by invoice collection and keeps ungrouped orders in a category", () => {
@@ -189,6 +193,80 @@ describe("invoicing period tree node builders", () => {
expect(hasWashCertificateOrderItem([{ product_name: "Kassevogn/Varevogn" }])).toBe(false);
});
it("builds complete snapshot trees with distinct collection and selected-period totals", () => {
const snapshot = normalizeInvoicingPeriodTreeSnapshot({
complete: true,
snapshot_revision: "a".repeat(64),
customer_number: 2001,
date_from: "2026-06-01",
date_to: "2026-06-30",
capabilities: {
object_tree_v2: true,
actions: { merge_collections: true },
},
customer: { customer_number: 2001, customer_name: "ACME" },
collections: [
{
id: 3001,
complete_order_count: 2,
complete_total_net_amount: 300,
period_order_count: 1,
period_total_net_amount: 100,
orders: [
{
id: 91,
invoice_collection_id: 3001,
in_selected_period: true,
amount: 100,
order_items: [{ id: 501, order_id: 91, product_name: "Wash", price: 100, quantity: 1 }],
},
{
id: 92,
invoice_collection_id: 3001,
in_selected_period: false,
amount: 200,
order_items: [{ id: 502, order_id: 92, product_name: "Hidden month", price: 200, quantity: 1 }],
},
],
},
],
});
expect(snapshot).not.toBeNull();
const roots = buildCompleteSnapshotRootNodes(snapshot, {
collection: (id) => `Collection ${id}`,
ordersWithoutCollection: "Loose",
orders: "Orders",
items: "Items",
attachments: { certificates: "Certificates", images: "Images", other: "Other" },
bookings: "Bookings",
booking: (id) => `Booking ${id}`,
xlvask: "XL",
economic: "Economic",
agreement: () => "Agreement",
payment: () => "Payment",
order: (id) => `Order ${id}`,
orderItemFallback: (id) => `Item ${id}`,
attachmentFallback: (id) => `Attachment ${id}`,
bookingItemFallback: (id) => `Booking item ${id}`,
xlvaskItemFallback: (index) => `XL item ${index + 1}`,
});
expect(roots[0]).toMatchObject({
id: "collected_order_invoice:3001",
meta: {
completeOrderCount: 2,
completeTotalNetAmount: 300,
periodOrderCount: 1,
periodTotalNetAmount: 100,
offPeriodOrderCount: 1,
offPeriodTotalNetAmount: 200,
},
});
expect(roots[0].children[0].children).toHaveLength(2);
expect(roots[0].children[0].children[1].meta.inSelectedPeriod).toBe(false);
});
it("creates selectable object nodes with typed metadata for lazy children and actions", () => {
const orderNode = makeOrderNode(
{
@@ -0,0 +1,167 @@
import { describe, expect, it, vi } from "vitest";
import {
buildCompleteSnapshotRootNodes,
createInvoicingPeriodTreeSnapshotLoader,
isTreeEndpointUnavailable,
normalizeInvoicingPeriodTreeSnapshot,
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeSnapshot.ts";
import {
normalizeInvoiceCollectionActionPreview,
renderInvoiceCollectionActionPreviewHtml,
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoiceCollectionActionPreview.ts";
const payload = (revision = "rev-1") => ({
complete: true,
snapshot_revision: revision,
customer_number: 2001,
date_from: "2026-06-01",
date_to: "2026-06-30",
capabilities: {
object_tree_v2: true,
actions: { merge_collections: true, queue_economic: false },
},
customer: { customer_number: 2001, customer_name: "ACME" },
collections: [
{
id: 3001,
in_selected_period: true,
complete_order_count: 2,
complete_total_net_amount: 300,
period_order_count: 1,
period_total_net_amount: 200,
orders: [
{
id: 9001,
invoice_collection_id: 3001,
in_selected_period: true,
total_net_amount: 200,
items: [{ id: 501, order_id: 9001, product_name: "Wash", quantity: 1, price: 200 }],
attachments: [],
bookings: [],
xlvask: [],
},
{
id: 9002,
invoice_collection_id: 3001,
in_selected_period: false,
total_net_amount: 100,
items: [],
attachments: [],
bookings: [],
xlvask: [],
},
],
},
],
uncollected_orders: [],
agreements: [],
payments: [],
economic_invoices: [],
});
const labels = {
collection: (id) => `Collection ${id}`,
ordersWithoutCollection: "Loose orders",
orders: "Orders",
items: "Items",
attachments: { certificates: "Certificates", images: "Images", other: "Attachments" },
bookings: "Bookings",
booking: (id) => `Booking ${id}`,
xlvask: "XL Vask",
economic: "Economic",
agreement: (id) => `Agreement ${id}`,
payment: (id) => `Payment ${id}`,
order: (id) => `Order ${id}`,
orderItemFallback: (id) => `Item ${id}`,
attachmentFallback: (id) => `Attachment ${id}`,
bookingItemFallback: (id) => `Booking item ${id}`,
xlvaskItemFallback: (index) => `XL item ${index}`,
};
describe("invoicing period selected-customer tree snapshot", () => {
it("normalizes a complete capability-gated snapshot and keeps off-period orders in the complete collection", () => {
const snapshot = normalizeInvoicingPeriodTreeSnapshot({ data: { data: payload() } });
expect(snapshot).toMatchObject({
complete: true,
source: "snapshot",
snapshot_revision: "rev-1",
capabilities: { object_tree_v2: true },
});
const roots = buildCompleteSnapshotRootNodes(snapshot, labels);
expect(roots[0].meta).toMatchObject({
periodOrderCount: 1,
periodTotalNetAmount: 200,
completeOrderCount: 2,
completeTotalNetAmount: 300,
offPeriodOrderCount: 1,
offPeriodTotalNetAmount: 100,
});
const orderNodes = roots[0].children[0].children;
expect(orderNodes.map((node) => node.id)).toEqual(["order:9001", "order:9002"]);
expect(orderNodes[1].meta.inSelectedPeriod).toBe(false);
expect(orderNodes[0].children[0].children[0].id).toBe("order_item:501");
});
it("rejects incomplete or capability-disabled payloads", () => {
expect(normalizeInvoicingPeriodTreeSnapshot({ ...payload(), complete: false })).toBeNull();
expect(
normalizeInvoicingPeriodTreeSnapshot({
...payload(),
capabilities: { object_tree_v2: false, actions: {} },
})
).toBeNull();
});
it("deduplicates identical loads and aborts a superseded customer request", async () => {
const pending = [];
const request = vi.fn(
(_url, _method, parameters, _catcher, _then, options) =>
new Promise((resolve, reject) => {
options.signal.addEventListener("abort", () =>
reject(Object.assign(new Error("aborted"), { name: "AbortError" }))
);
pending.push({ parameters, resolve, signal: options.signal });
})
);
const loader = createInvoicingPeriodTreeSnapshotLoader(request);
const first = loader.load({ customerNumber: 2001, dateFrom: "2026-06-01", dateTo: "2026-06-30" });
const duplicate = loader.load({ customerNumber: 2001, dateFrom: "2026-06-01", dateTo: "2026-06-30" });
expect(duplicate).toBe(first);
const second = loader.load({ customerNumber: 2002, dateFrom: "2026-06-01", dateTo: "2026-06-30" });
await expect(first).rejects.toMatchObject({ name: "AbortError" });
expect(pending[0].signal.aborted).toBe(true);
pending[1].resolve({
data: { data: { ...payload("rev-2"), customer_number: 2002, customer: { customer_number: 2002 } } },
});
await expect(second).resolves.toMatchObject({ snapshot_revision: "rev-2", customer_number: 2002 });
expect(request).toHaveBeenCalledTimes(2);
});
it("does not classify internal request cancellation as endpoint unavailability", () => {
expect(isTreeEndpointUnavailable(Object.assign(new Error("aborted"), { name: "AbortError" }))).toBe(false);
expect(isTreeEndpointUnavailable({ code: "ERR_CANCELED" })).toBe(false);
expect(isTreeEndpointUnavailable({ response: { status: 404 } })).toBe(true);
expect(isTreeEndpointUnavailable({ response: { status: 501 } })).toBe(true);
});
it("renders escaped concrete changes, merge target, and off-period impact", () => {
const preview = normalizeInvoiceCollectionActionPreview({
preview_id: "preview-1",
confirmation_phrase: "CONFIRM",
summary: { collection_count: 2, changed_count: 1, off_period_order_count: 1, off_period_total_net_amount: 100 },
target_invoice_collection_id: 3002,
changes: [{ message: "Move <script>alert(1)</script>" }],
});
const html = renderInvoiceCollectionActionPreviewHtml({
preview,
t: (key, params = {}) => `${key}:${JSON.stringify(params)}`,
formatCurrency: (value) => `${value} DKK`,
});
expect(html).toContain("3002");
expect(html).toContain("1");
expect(html).toContain("100 DKK");
expect(html).toContain("&lt;script&gt;");
expect(html).not.toContain("<script>");
});
});
@@ -104,10 +104,18 @@ const periodObjectTreeSource = readFileSync(
join(root, "src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue"),
"utf8"
);
const periodTreeSnapshotSource = readFileSync(
join(root, "src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeSnapshot.ts"),
"utf8"
);
const periodTreeNodeServiceSource = readFileSync(
join(root, "src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeNodes.js"),
"utf8"
);
const collectedOrderInvoicesObjectSource = readFileSync(
join(root, "src/components/session/token/SessionUser/Objects/CollectedOrderInvoices.vue"),
"utf8"
);
const buefyTreeSource = readFileSync(join(root, "src/components/buefy/tree/BuefyTree.vue"), "utf8");
const buefyTreeNodeSource = readFileSync(join(root, "src/components/buefy/tree/BuefyTreeNode.vue"), "utf8");
const periodViewSelfWashSource = readFileSync(
@@ -763,6 +771,15 @@ describe("Periode tab contract", () => {
expect(periodObjectTreeSource).toContain("bulk_action_preview");
expect(periodObjectTreeSource).toContain("bulk_action_apply");
expect(periodObjectTreeSource).toContain("createInvoicingPeriodTreeSnapshotLoader");
expect(periodObjectTreeSource).toContain("period_tree_action_preview");
expect(periodObjectTreeSource).toContain("snapshot_revision: activeSnapshot.value?.snapshot_revision");
expect(periodTreeSnapshotSource).toContain('"/superuser/invoicing/period/tree"');
expect(periodTreeSnapshotSource).toContain("complete_total_net_amount");
expect(periodTreeSnapshotSource).toContain("period_total_net_amount");
expect(collectedOrderInvoicesObjectSource).toContain("period_tree_action_preview");
expect(collectedOrderInvoicesObjectSource).toContain("customer_number: parseInt(customer_number)");
expect(collectedOrderInvoicesObjectSource).toContain("snapshot_revision: String(snapshot_revision || '')");
expect(periodObjectTreeSource).toContain("chooseMergeTargetInvoiceCollection");
expect(periodObjectTreeSource).toContain("checkable: true");
expect(periodObjectTreeSource).toContain("uniqueNodesByKey");