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:
@@ -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, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
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);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+326
-100
@@ -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">
|
||||
|
||||
+172
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
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));
|
||||
+418
@@ -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));
|
||||
+2
@@ -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'"
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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("<script>");
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user