diff --git a/src/components/displays/department/pos/orders/ordersTable.vue b/src/components/displays/department/pos/orders/ordersTable.vue index dd3244b7..9a29d2e1 100644 --- a/src/components/displays/department/pos/orders/ordersTable.vue +++ b/src/components/displays/department/pos/orders/ordersTable.vue @@ -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, "'"); - 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 = [ - `

${escapeHtml( - t("invoicing_period.invoice_collection_actions.preview.requires_confirmation", { - phrase: preview?.confirmation_phrase || "", - }) - )}

`, - ``, - ]; - - if (detailItems.length > 0) { - html.push( - `

${escapeHtml( - t("invoicing_period.invoice_collection_actions.preview.affected_examples") - )}

` - ); - html.push(``); - } - - if (blockers.length > 0) { - html.push( - `

${escapeHtml( - t("invoicing_period.invoice_collection_actions.preview.blockers") - )}

` - ); - html.push( - `` - ); - } - - return html.join(""); + return renderInvoiceCollectionActionPreviewHtml({ + preview: normalizeInvoiceCollectionActionPreview(preview, getSelectedInvoiceCollectionIds().length), + t, + formatCurrency: (value) => SessionUser.functions.currency.toLocal(value), + }); }; const chooseMergeTargetInvoiceCollection = async (invoiceCollectionIds) => { diff --git a/src/components/session/token/SessionUser/Objects/CollectedOrderInvoices.vue b/src/components/session/token/SessionUser/Objects/CollectedOrderInvoices.vue index 7025b1c1..3ab6ec60 100644 --- a/src/components/session/token/SessionUser/Objects/CollectedOrderInvoices.vue +++ b/src/components/session/token/SessionUser/Objects/CollectedOrderInvoices.vue @@ -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); diff --git a/src/i18n/generated/da-v2.json b/src/i18n/generated/da-v2.json index 9cd6fa50..fe1e047c 100644 --- a/src/i18n/generated/da-v2.json +++ b/src/i18n/generated/da-v2.json @@ -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": { diff --git a/src/i18n/generated/de-v2.json b/src/i18n/generated/de-v2.json index 21bdce6f..44d23528 100644 --- a/src/i18n/generated/de-v2.json +++ b/src/i18n/generated/de-v2.json @@ -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": { diff --git a/src/i18n/generated/en-v2.json b/src/i18n/generated/en-v2.json index e1017a1b..d1be3a36 100644 --- a/src/i18n/generated/en-v2.json +++ b/src/i18n/generated/en-v2.json @@ -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": { diff --git a/src/i18n/generated/global-v2.json b/src/i18n/generated/global-v2.json index 10cdd4bc..f75313de 100644 --- a/src/i18n/generated/global-v2.json +++ b/src/i18n/generated/global-v2.json @@ -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": { diff --git a/src/i18n/generated/no-v2.json b/src/i18n/generated/no-v2.json index f29c6d06..18e5381a 100644 --- a/src/i18n/generated/no-v2.json +++ b/src/i18n/generated/no-v2.json @@ -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": { diff --git a/src/i18n/generated/sv-v2.json b/src/i18n/generated/sv-v2.json index 36d343e0..2a11376c 100644 --- a/src/i18n/generated/sv-v2.json +++ b/src/i18n/generated/sv-v2.json @@ -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": { diff --git a/src/i18n/source/da/phrases/compat/invoicing_period/invoice_collection_actions.json b/src/i18n/source/da/phrases/compat/invoicing_period/invoice_collection_actions.json index f7791772..98563449 100644 --- a/src/i18n/source/da/phrases/compat/invoicing_period/invoice_collection_actions.json +++ b/src/i18n/source/da/phrases/compat/invoicing_period/invoice_collection_actions.json @@ -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}" diff --git a/src/i18n/source/da/phrases/compat/invoicing_period/object_tree.json b/src/i18n/source/da/phrases/compat/invoicing_period/object_tree.json index 6155bbef..a87da8bc 100644 --- a/src/i18n/source/da/phrases/compat/invoicing_period/object_tree.json +++ b/src/i18n/source/da/phrases/compat/invoicing_period/object_tree.json @@ -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": { diff --git a/src/i18n/source/de/phrases/compat/invoicing_period/invoice_collection_actions.json b/src/i18n/source/de/phrases/compat/invoicing_period/invoice_collection_actions.json index a805b2a9..a12f637e 100644 --- a/src/i18n/source/de/phrases/compat/invoicing_period/invoice_collection_actions.json +++ b/src/i18n/source/de/phrases/compat/invoicing_period/invoice_collection_actions.json @@ -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" diff --git a/src/i18n/source/de/phrases/compat/invoicing_period/object_tree.json b/src/i18n/source/de/phrases/compat/invoicing_period/object_tree.json index 5aea98a6..462d4f88 100644 --- a/src/i18n/source/de/phrases/compat/invoicing_period/object_tree.json +++ b/src/i18n/source/de/phrases/compat/invoicing_period/object_tree.json @@ -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": { diff --git a/src/i18n/source/en/phrases/compat/invoicing_period/invoice_collection_actions.json b/src/i18n/source/en/phrases/compat/invoicing_period/invoice_collection_actions.json index 2f804a85..e29857e6 100644 --- a/src/i18n/source/en/phrases/compat/invoicing_period/invoice_collection_actions.json +++ b/src/i18n/source/en/phrases/compat/invoicing_period/invoice_collection_actions.json @@ -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}" diff --git a/src/i18n/source/en/phrases/compat/invoicing_period/object_tree.json b/src/i18n/source/en/phrases/compat/invoicing_period/object_tree.json index f91493a3..cbe1ae0a 100644 --- a/src/i18n/source/en/phrases/compat/invoicing_period/object_tree.json +++ b/src/i18n/source/en/phrases/compat/invoicing_period/object_tree.json @@ -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": { diff --git a/src/i18n/source/global/shared/invoicing_period/invoice_collection_actions.json b/src/i18n/source/global/shared/invoicing_period/invoice_collection_actions.json index 1e209525..7b313540 100644 --- a/src/i18n/source/global/shared/invoicing_period/invoice_collection_actions.json +++ b/src/i18n/source/global/shared/invoicing_period/invoice_collection_actions.json @@ -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'}" diff --git a/src/i18n/source/global/shared/invoicing_period/object_tree.json b/src/i18n/source/global/shared/invoicing_period/object_tree.json index 11161bbb..bbde25ea 100644 --- a/src/i18n/source/global/shared/invoicing_period/object_tree.json +++ b/src/i18n/source/global/shared/invoicing_period/object_tree.json @@ -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": { diff --git a/src/i18n/source/no/phrases/compat/invoicing_period/invoice_collection_actions.json b/src/i18n/source/no/phrases/compat/invoicing_period/invoice_collection_actions.json index 7aed0a47..cb7af6c1 100644 --- a/src/i18n/source/no/phrases/compat/invoicing_period/invoice_collection_actions.json +++ b/src/i18n/source/no/phrases/compat/invoicing_period/invoice_collection_actions.json @@ -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}" diff --git a/src/i18n/source/no/phrases/compat/invoicing_period/object_tree.json b/src/i18n/source/no/phrases/compat/invoicing_period/object_tree.json index 366077b1..13ad8680 100644 --- a/src/i18n/source/no/phrases/compat/invoicing_period/object_tree.json +++ b/src/i18n/source/no/phrases/compat/invoicing_period/object_tree.json @@ -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": { diff --git a/src/i18n/source/sv/phrases/compat/invoicing_period/invoice_collection_actions.json b/src/i18n/source/sv/phrases/compat/invoicing_period/invoice_collection_actions.json index af91628f..d1721432 100644 --- a/src/i18n/source/sv/phrases/compat/invoicing_period/invoice_collection_actions.json +++ b/src/i18n/source/sv/phrases/compat/invoicing_period/invoice_collection_actions.json @@ -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}" diff --git a/src/i18n/source/sv/phrases/compat/invoicing_period/object_tree.json b/src/i18n/source/sv/phrases/compat/invoicing_period/object_tree.json index 521c42ce..c81c386f 100644 --- a/src/i18n/source/sv/phrases/compat/invoicing_period/object_tree.json +++ b/src/i18n/source/sv/phrases/compat/invoicing_period/object_tree.json @@ -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": { diff --git a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue index 63a69510..8f3c89c2 100644 --- a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue +++ b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue @@ -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; invoicePeriodFlags?: any[]; autoExpandAll?: boolean; }>(), @@ -43,6 +55,7 @@ const props = withDefaults( transactions: () => [], excludedOrderIds: () => [], invoicePeriodFlags: () => [], + capabilities: () => ({}), autoExpandAll: false, } ); @@ -67,6 +80,13 @@ const previewErrorByNodeId = ref>({}); const attachmentPreviewByNodeId = ref>({}); const economicDetailsByCollectionId = ref>({}); const generatedPreviewObjectUrls = new Set(); +const activeSnapshot = ref(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([]); +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 `
${lines.map((line) => `

${escapeHtml(line)}

`).join("")}
`; + 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 | 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(); + 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 | 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) => { }} + + + {{ treeText("preview.loading", "Indlæser forhåndsvisning") }} + {{ selectedCountLabel }}
diff --git a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoiceCollectionActionPreview.ts b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoiceCollectionActionPreview.ts new file mode 100644 index 00000000..563e4441 --- /dev/null +++ b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoiceCollectionActionPreview.ts @@ -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[]; + changes: Record[]; + raw: Record; +} + +type Translate = (key: string, params?: Record) => string; + +const asArray = (value: unknown): Record[] => (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) : {}; + 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, fallback: string) => { + const value = t(key, params); + return value === key ? fallback : value; +}; + +const concreteChangeLabel = (change: Record, 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 = [ + `

${escapeInvoiceCollectionPreviewHtml(t( + "invoicing_period.invoice_collection_actions.preview.requires_confirmation", + { phrase: preview.confirmationPhrase } + ))}

`, + `
    ${lines.map((line) => `
  • ${escapeInvoiceCollectionPreviewHtml(line)}
  • `).join("")}
`, + ]; + const changes = preview.changes.map((change) => concreteChangeLabel(change, t)).filter(Boolean).slice(0, 10); + if (changes.length > 0) { + html.push(`

${escapeInvoiceCollectionPreviewHtml( + t("invoicing_period.invoice_collection_actions.preview.affected_examples") + )}

`); + html.push(`
    ${changes.map((line) => `
  • ${escapeInvoiceCollectionPreviewHtml(line)}
  • `).join("")}
`); + } + if (preview.blockers.length > 0) { + html.push(`

${escapeInvoiceCollectionPreviewHtml( + t("invoicing_period.invoice_collection_actions.preview.blockers") + )}

`); + html.push(`
    ${preview.blockers.map((blocker) => `
  • ${escapeInvoiceCollectionPreviewHtml( + blocker.message || blocker.code + )}
  • `).join("")}
`); + } + return html.join(""); +}; + +export const isInvoiceCollectionTreeActionUnavailable = (error: any) => + [404, 405, 410, 501].includes(Number(error?.response?.status)); diff --git a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeSnapshot.ts b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeSnapshot.ts new file mode 100644 index 00000000..990ef86b --- /dev/null +++ b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeSnapshot.ts @@ -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>; +} + +export interface InvoicingPeriodSnapshotOrder extends Record { + id: number; + in_selected_period: boolean; + items: Record[]; + attachments: Record[]; + bookings: Record[]; + xlvask: Record[]; +} + +export interface InvoicingPeriodSnapshotCollection extends Record { + 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; + collections: InvoicingPeriodSnapshotCollection[]; + uncollected_orders: InvoicingPeriodSnapshotOrder[]; + agreements: Record[]; + payments: Record[]; + economic_invoices: Record[]; +} + +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, + catchCallable?: null, + thenCallable?: null, + options?: Record +) => Promise; + +const asObject = (value: unknown): Record => + value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +const asArray = >(value: unknown): T[] => (Array.isArray(value) ? value : []); +const asArrayOrSingle = >(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; + transactions: Record[]; + 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, 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, 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([["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, 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 } | 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)); diff --git a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue index 694c4039..148cbe86 100644 --- a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue +++ b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue @@ -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 = () => { > { + 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 = []; diff --git a/tests/unit/invoicing-period-object-tree.spec.js b/tests/unit/invoicing-period-object-tree.spec.js index 81b2014e..b986f6ef 100644 --- a/tests/unit/invoicing-period-object-tree.spec.js +++ b/tests/unit/invoicing-period-object-tree.spec.js @@ -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(); + }); }); diff --git a/tests/unit/invoicing-period-tree-nodes.spec.js b/tests/unit/invoicing-period-tree-nodes.spec.js index cbc3720c..d47b2032 100644 --- a/tests/unit/invoicing-period-tree-nodes.spec.js +++ b/tests/unit/invoicing-period-tree-nodes.spec.js @@ -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( { diff --git a/tests/unit/invoicing-period-tree-snapshot.spec.js b/tests/unit/invoicing-period-tree-snapshot.spec.js new file mode 100644 index 00000000..32af911d --- /dev/null +++ b/tests/unit/invoicing-period-tree-snapshot.spec.js @@ -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 " }], + }); + 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("