Files
pleno-vue/tests/unit/invoicing-period-tree-nodes.spec.js
T
Jeppe B f4816124c2 Complete selected-customer invoice period tree (#252)
Add the complete selected-customer invoice collection tree, revision-bound actions, fallback handling, and focused frontend coverage.
2026-08-03 12:15:55 +02:00

373 lines
12 KiB
JavaScript

import { describe, expect, it } from "vitest";
import {
TREE_CATEGORY_TYPES,
TREE_NODE_TYPES,
buildRelativeCollectionLabel,
buildCollectionRootNodes,
buildOrderItemTree,
classifyAttachment,
getTreeAmount,
hasWashCertificateOrderItem,
makeAttachmentNode,
makeBookingItemNode,
makeBookingNode,
makeCategoryNode,
makeEconomicInvoiceNode,
makeOrderItemNode,
makeOrderNode,
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", () => {
const nodes = buildCollectionRootNodes(
{ customer_number: 2001, customer_name: "ACME" },
[
{ id: 11, invoice_collection_id: 3002, total_net_amount: 125 },
{ id: 12, invoice_collection_id: 3001, total_net_amount: 50 },
{ id: 13, invoice_collection_id: null, amount: 75 },
{ id: 14, invoice_collection_id: 3001, amount: 25, total_net_amount: 0 },
],
[11],
{
collection: (id) => `Collection ${id}`,
ordersWithoutCollection: "Loose orders",
}
);
expect(nodes).toHaveLength(2);
expect(nodes[0]).toMatchObject({
id: `${TREE_NODE_TYPES.COLLECTION}:3001`,
label: "Collection 3001",
selectable: true,
actionable: true,
meta: {
collectionId: 3001,
customerNumber: 2001,
orderCount: 2,
totalNetAmount: 75,
},
});
expect(nodes[1]).toMatchObject({
label: "Loose orders",
type: TREE_NODE_TYPES.CATEGORY,
category: TREE_CATEGORY_TYPES.COLLECTION_ORDERS,
selectable: false,
checkable: true,
actionable: false,
meta: {
count: 1,
},
});
});
it("uses relative collection labels for whole-month and multi-collection ranges", () => {
const wholeMonthNodes = buildCollectionRootNodes(
{ customer_number: 2001, customer_name: "ACME" },
[
{ id: 11, invoice_collection_id: 3001, created_at: "2026-06-02", total_net_amount: 125 },
{ id: 12, invoice_collection_id: 3001, created_at: "2026-06-18", total_net_amount: 50 },
],
[],
{
collection: (id) => `Collection ${id}`,
dateFrom: "2026-06-01",
dateTo: "2026-06-30",
locale: "da-DK",
}
);
expect(wholeMonthNodes[0].label).toBe("Juni");
expect(wholeMonthNodes[0].meta.relativeLabel).toMatchObject({
kind: "month",
monthLabel: "Juni",
});
const rangeNodes = buildCollectionRootNodes(
{ customer_number: 2001, customer_name: "ACME" },
[
{ id: 11, invoice_collection_id: 3001, created_at: "2026-06-01", total_net_amount: 125 },
{ id: 12, invoice_collection_id: 3001, created_at: "2026-06-18", total_net_amount: 50 },
{ id: 13, invoice_collection_id: 3002, created_at: "2026-06-19", total_net_amount: 75 },
],
[],
{
collection: (id) => `Collection ${id}`,
dateFrom: "2026-06-01",
dateTo: "2026-06-30",
locale: "da-DK",
}
);
expect(rangeNodes[0].label).toBe("1 Juni → 18 Juni");
expect(rangeNodes[0].meta.relativeLabel).toMatchObject({
kind: "range",
startLabel: "1 Juni",
endLabel: "18 Juni",
});
expect(
buildRelativeCollectionLabel({
collectionId: 3003,
orders: [],
fallbackLabel: "Collection 3003",
}).label
).toBe("Collection 3003");
});
it("normalizes tree amounts from the first non-zero backend amount field", () => {
expect(getTreeAmount({ amount: 240, total_net_amount: 0 })).toBe(240);
expect(getTreeAmount({ amount: 0, total_net_amount: 140, total: 99 })).toBe(140);
expect(getTreeAmount({ amount: 0, total_net_amount: 0, total: 99 })).toBe(99);
expect(makeOrderNode({ id: 91, amount: 240, total_net_amount: 0 }).meta.totalNetAmount).toBe(240);
});
it("keeps collection-summary roots even when no transaction from the page references them", () => {
const nodes = buildCollectionRootNodes(
{
customer_number: 2001,
customer_name: "ACME",
invoice_collections: [
{
id: 4401,
state: "error",
error_message: "Missing debtor",
booked_invoice_id: 551,
total_net_amount: 875,
},
],
},
[],
[],
{ collection: (id) => `Collection ${id}` }
);
expect(nodes).toHaveLength(1);
expect(nodes[0]).toMatchObject({
id: "collected_order_invoice:4401",
meta: {
collectionId: 4401,
orderCount: 0,
totalNetAmount: 875,
invoiceState: "error",
collectionSummary: {
error_message: "Missing debtor",
booked_invoice_id: 551,
},
},
});
});
it("creates non-selectable but checkable category branch nodes for bulk selection", () => {
const node = makeCategoryNode({
id: "category:orders",
label: "Orders",
category: TREE_CATEGORY_TYPES.COLLECTION_ORDERS,
parentType: TREE_NODE_TYPES.COLLECTION,
parentId: "collection:1",
checkable: true,
});
expect(node).toMatchObject({
selectable: false,
checkable: true,
actionable: false,
});
});
it("classifies attachments into certificate, image, and other buckets", () => {
expect(classifyAttachment({ content: { other: "wash_certificate_42.pdf" } })).toBe("certificate");
expect(classifyAttachment({ file_name: "front.jpg", content_type: "application/octet-stream" })).toBe("image");
expect(classifyAttachment({ file_name: "note.txt", content_type: "text/plain" })).toBe("other");
});
it("detects wash certificate order items from product names and pictograms", () => {
expect(
hasWashCertificateOrderItem([{ product_name: "Normal vask" }, { product: { name: "Vaskecertifikat" } }])
).toBe(true);
expect(hasWashCertificateOrderItem([{ piktogram: "certificate", name: "Seal" }])).toBe(true);
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(
{
id: 91,
invoice_collection_id: 3001,
booking_id: 77,
wash_id: "W-12",
total_net_amount: 240,
},
{ label: "Order label" }
);
const itemNode = makeOrderItemNode({
id: 501,
order_id: 91,
product_name: "Premium wash",
quantity: 2,
price: 120,
});
const attachmentNode = makeAttachmentNode({ id: 8, file_name: "photo.png" }, 91);
const bookingNode = makeBookingNode({ id: 77, order_id: 91, items: [{ id: 1, name: "Slot" }] });
const bookingItemNode = makeBookingItemNode({ id: 1, name: "Slot" }, 77);
const xlvaskNode = makeXlvaskNode({ id: 456, WashId: "W-12", WashItems: [{ Name: "Wash" }] }, { id: 91 });
const inferredNode = makeXlvaskInferredItemNode({ Name: "Wash" }, "W-12", 0);
expect(orderNode).toMatchObject({
label: "Order label",
type: TREE_NODE_TYPES.ORDER,
isLeaf: false,
selectable: true,
actionable: true,
meta: {
orderId: 91,
collectionId: 3001,
bookingId: 77,
washId: "W-12",
},
});
expect(itemNode).toMatchObject({ type: TREE_NODE_TYPES.ORDER_ITEM, isLeaf: true, actionable: true });
expect(attachmentNode).toMatchObject({
type: TREE_NODE_TYPES.ATTACHMENT,
meta: { attachmentId: 8, orderId: 91, attachmentType: "image" },
});
expect(bookingNode).toMatchObject({ type: TREE_NODE_TYPES.BOOKING, meta: { bookingId: 77, orderId: 91 } });
expect(bookingItemNode).toMatchObject({ type: TREE_NODE_TYPES.BOOKING_ITEM, actionable: false });
expect(xlvaskNode).toMatchObject({
type: TREE_NODE_TYPES.XLVASK_WASH,
actionable: true,
meta: { usageId: 456, washId: "W-12", orderId: 91 },
});
expect(inferredNode).toMatchObject({ type: TREE_NODE_TYPES.XLVASK_INFERRED_ITEM, actionable: false });
});
it("builds related order items as a stable tree and leaves malformed relationships visible", () => {
const nodes = buildOrderItemTree(
[
{ id: 501, order_id: 91, product_name: "Wash" },
{ id: 502, order_id: 91, related_item_id: 501, product_name: "Interior" },
{ id: 503, order_id: 91, related_item_id: 502, product_name: "Mat cleaning" },
{ id: 504, order_id: 91, related_item_id: 999, product_name: "Orphan" },
{ id: 505, order_id: 91, related_item_id: 506, product_name: "Cycle A" },
{ id: 506, order_id: 91, related_item_id: 505, product_name: "Cycle B" },
{ id: 507, order_id: 92, related_item_id: 501, product_name: "Cross-order" },
],
{ invoiceState: "closed" }
);
expect(nodes.map((node) => node.meta.itemId)).toEqual([501, 504, 505, 506, 507]);
expect(nodes[0]).toMatchObject({
isLeaf: false,
meta: { invoiceState: "closed" },
children: [
{
meta: { itemId: 502, relatedItemId: 501 },
children: [{ meta: { itemId: 503, relatedItemId: 502 } }],
},
],
});
expect(nodes.slice(1).every((node) => node.isLeaf)).toBe(true);
});
it("creates economic invoice nodes with draft/booked metadata", () => {
const node = makeEconomicInvoiceNode({
collectionId: 3001,
invoiceType: "draft",
economicInvoiceId: 812,
details: { economic: { draft_id: 812 } },
});
expect(node).toMatchObject({
id: `${TREE_NODE_TYPES.ECONOMIC_INVOICE}:3001:draft:812`,
label: "E-conomic kladde #812",
type: TREE_NODE_TYPES.ECONOMIC_INVOICE,
isLeaf: true,
actionable: false,
icon: "fa-file-alt",
meta: {
collectionId: 3001,
economicType: "draft",
economicInvoiceId: 812,
},
});
});
});