Add the complete selected-customer invoice collection tree, revision-bound actions, fallback handling, and focused frontend coverage.
373 lines
12 KiB
JavaScript
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,
|
|
},
|
|
});
|
|
});
|
|
});
|