Files
pleno-vue/tests/unit/invoice-distribution-calculations.spec.js
T
Jeppe Bundgaard 412339744f Introduce footer totals for department allocations:
- Add `buildDepartmentAllocationFooterTotals` utility to compute department footer totals, excluding customer prices from the total column.
- Update department allocation summaries to prioritize actual distributions for fixed and subscription amounts.
- Adjust distribution metrics calculation logic and related tests for consistency.
- Enhance footer visuals with clearly marked totals and accompanying notes.
- Expand `buildDepartmentAllocations` to properly account for totals in UI rendering.
- Update associated unit tests to cover footer totals and exclude customer price impacts.
2026-03-16 13:04:56 +01:00

411 lines
12 KiB
JavaScript

import { describe, expect, it } from 'vitest';
import {
buildDistributionQueryState,
getMonthRange,
buildMonthSummary,
buildDepartmentAllocations,
buildDepartmentAllocationFooterTotals,
buildCustomerAllocationRows,
buildMonthComparison,
buildSourceComposition,
normalizeDistributionQueryState,
normalizeCompareResult,
aggregateCompareRows,
sortCompareRows,
sortMonthSummaries,
} from '@/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionCalculations.js';
describe('invoice distribution calculations', () => {
it('builds month ranges from first month to current month', () => {
const result = getMonthRange(new Date('2026-01-10T00:00:00Z'), new Date('2026-03-20T00:00:00Z'), true);
expect(result.map((item) => item.key)).toEqual(['2026-03', '2026-02', '2026-01']);
});
it('builds month summary with booked and source split', () => {
const summary = buildMonthSummary({
year: 2026,
month: 2,
periodData: {
types: {
all: [
{
transactions: [
{ amount: 100, booked: true, excluded: false },
{ amount: 25, booked: false, excluded: false },
{ amount: 30, booked: true, excluded: true },
],
},
],
},
},
fixedDistribution: {
includes: {
collective_fixed_pricing_results: {
total_fixed_price: 40,
},
},
},
subscriptionDistribution: {
includes: {
collective_subscription_results: {
total_subscription_price: 20,
},
},
},
customerPriceDistribution: {
collective_results: {
total_customer_price: 15,
},
},
});
expect(summary.bookedAmount).toBe(100);
expect(summary.fixedPricingAmount).toBe(40);
expect(summary.subscriptionAmount).toBe(20);
expect(summary.customerPriceAmount).toBe(15);
expect(summary.distributionAmount).toBe(60);
expect(summary.otherBookedAmount).toBe(25);
expect(summary.totalAmount).toBe(160);
});
it('prefers actually distributed fixed and subscription amounts for summary totals', () => {
const summary = buildMonthSummary({
year: 2026,
month: 2,
periodData: {
types: {
all: [
{
transactions: [
{ amount: 200, booked: true, excluded: false },
],
},
],
},
},
fixedDistribution: {
collective_results: {
total_fixed_price: 50,
total_department_totals_relative_parsed: {
Alpha: 30,
Beta: 10,
},
},
},
subscriptionDistribution: {
collective_results: {
total_subscription_price: 120,
subscription_price_department_distribution_parsed: {
Alpha: 70,
Beta: 20,
},
},
},
customerPriceDistribution: {
collective_results: {
total_customer_price: 15,
},
},
});
expect(summary.fixedPricingAmount).toBe(40);
expect(summary.subscriptionAmount).toBe(90);
expect(summary.customerPriceAmount).toBe(15);
expect(summary.distributionAmount).toBe(130);
expect(summary.totalAmount).toBe(330);
});
it('builds department allocations with percentages and booked estimate', () => {
const rows = buildDepartmentAllocations({
departments: [
{ id: 1, name: 'Alpha' },
{ id: 2, name: 'Beta' },
],
bookedAmount: 300,
fixedDistribution: {
includes: {
collective_fixed_pricing_results: {
total_department_totals_relative_parsed: {
Alpha: 60,
Beta: 40,
},
},
},
},
subscriptionDistribution: {
includes: {
collective_subscription_results: {
subscription_price_department_distribution_parsed: {
Alpha: 30,
},
},
},
},
customerPriceDistribution: {
collective_results: {
customer_price_department_distribution_parsed: {
Beta: 10,
},
},
},
});
expect(rows).toHaveLength(2);
expect(rows[0].departmentName).toBe('Alpha');
expect(rows[0].totalAmount).toBe(90);
expect(rows[0].sharePercent).toBe(69.23);
expect(rows[0].estimatedBookedAllocation).toBe(207.69);
expect(rows[1].departmentName).toBe('Beta');
expect(rows[1].customerPriceAmount).toBe(10);
expect(rows[1].totalAmount).toBe(40);
});
it('builds department footer totals without adding customer prices to the total column', () => {
const totals = buildDepartmentAllocationFooterTotals([
{
fixedAmount: 120000,
subscriptionAmount: 80000,
customerPriceAmount: 15000,
totalAmount: 215000,
},
{
fixedAmount: 40000,
subscriptionAmount: 57000,
customerPriceAmount: 12000,
totalAmount: 109000,
},
]);
expect(totals.fixedAmount).toBe(160000);
expect(totals.subscriptionAmount).toBe(137000);
expect(totals.customerPriceAmount).toBe(27000);
expect(totals.totalAmount).toBe(297000);
});
it('builds customer rows and respects creation date filter', () => {
const rows = buildCustomerAllocationRows({
year: 2026,
month: 2,
departments: [{ id: 1, name: 'Alpha' }],
fixedDistribution: {
data: [
{
id: 1,
customer_number: 10,
customer_name: 'Customer A',
meta: {
fixed_pricing: {
created_at: '2026-02-01T00:00:00Z',
price: 100,
original_price: 140,
department_totals_relative: { 1: 100 },
},
},
},
{
id: 2,
customer_number: 20,
customer_name: 'Future Customer',
meta: {
fixed_pricing: {
created_at: '2026-03-01T00:00:00Z',
price: 100,
original_price: 140,
department_totals_relative: { 1: 100 },
},
},
},
],
},
subscriptionDistribution: {
data: [],
},
customerPriceDistribution: {
customers: [
{
id: 7,
customer_number: 99,
customer_name: 'Discount Customer',
transactions: [{ id: 1, amount: 22, booked: true, excluded: false, date: '2026-02-05T00:00:00Z' }],
meta: {
customer_price: {
created_at: '2026-02-05T00:00:00Z',
price: 22,
department_totals_relative: { 1: 22 },
},
},
},
],
},
});
expect(rows).toHaveLength(2);
expect(rows[0].customerName).toBe('Customer A');
expect(rows[0].departmentAllocations).toEqual({ Alpha: 100 });
expect(rows[1].source).toBe('customer_prices');
});
it('builds month-over-month comparison deltas', () => {
const comparison = buildMonthComparison(
{ totalAmount: 200, bookedAmount: 140, distributionAmount: 60 },
{ totalAmount: 100, bookedAmount: 120, distributionAmount: 30 }
);
expect(comparison.totalAmount.delta).toBe(100);
expect(comparison.bookedAmount.delta).toBe(20);
expect(comparison.distributionAmount.delta).toBe(30);
});
it('normalizes and serializes distribution query state', () => {
const normalized = normalizeDistributionQueryState({
compareMonth: '2026-02',
compareMode: 'line_by_line',
customerSearch: ' acme ',
customerSource: 'customer_prices',
customerDepartment: 'Ops',
departmentSearch: ' nord ',
});
expect(normalized).toEqual({
compareMonth: '2026-02',
compareMode: 'line_by_line',
compareVisibility: 'all',
customerSearch: 'acme',
customerSource: 'customer_prices',
customerDepartment: 'Ops',
departmentSearch: 'nord',
});
const serialized = buildDistributionQueryState(normalized);
expect(serialized).toEqual({
compareMonth: '2026-02',
compareMode: 'line_by_line',
customerSearch: 'acme',
customerSource: 'customer_prices',
customerDepartment: 'Ops',
departmentSearch: 'nord',
});
});
it('sorts month summaries and compare rows for decision-first tables', () => {
const sortedMonths = sortMonthSummaries(
[
{ key: '2026-01', year: 2026, month: 1, totalAmount: 10 },
{ key: '2026-03', year: 2026, month: 3, totalAmount: 40 },
{ key: '2026-02', year: 2026, month: 2, totalAmount: 20 },
],
'month',
'desc'
);
expect(sortedMonths.map((row) => row.key)).toEqual(['2026-03', '2026-02', '2026-01']);
const sortedTotals = sortMonthSummaries(sortedMonths, 'total', 'asc');
expect(sortedTotals.map((row) => row.totalAmount)).toEqual([10, 20, 40]);
const sortedCompare = sortCompareRows([
{ invoiceId: 3, status: 'ok', warningCount: 0, difference: 0 },
{ invoiceId: 2, status: 'ok', warningCount: 2, difference: 4 },
{ invoiceId: 1, status: 'mismatch', warningCount: 1, difference: 10 },
]);
expect(sortedCompare.map((row) => row.invoiceId)).toEqual([1, 2, 3]);
});
it('builds source composition percentages for KPI cues', () => {
const composition = buildSourceComposition({
fixedPricingAmount: 60,
subscriptionAmount: 40,
customerPriceAmount: 20,
distributionAmount: 100,
});
expect(composition.fixedPercent).toBe(50);
expect(composition.subscriptionPercent).toBe(33.33);
expect(composition.customerPricePercent).toBe(16.67);
});
it('normalizes compare payloads for both modes and aggregates', () => {
const okRow = normalizeCompareResult(
{ id: 1, customer_name: 'A', total_net_amount: 100 },
{
internal_total: 100,
booked_total: 100,
difference: 0,
warnings: [],
order_ids: [1, 2],
},
'invoice_total'
);
const mismatchRow = normalizeCompareResult(
{ id: 2, customer_name: 'B', total_net_amount: 100 },
{
internal_total: 100,
booked_total: 90,
difference: 10,
warnings: ['Line mismatch'],
order_ids: [3],
},
'line_by_line'
);
const v2Row = normalizeCompareResult(
{ id: 3, customer_name: 'C', total_net_amount: 200 },
{
collected_invoice_id: 3,
warnings: ['Top-level warning'],
details: {
order_ids: [10, 20],
internal: {
normalized: {
totals: {
net_total: 200,
billable_line_count: 2,
},
},
},
customer: {
name: 'Customer V2',
},
},
comparison: {
totals: {
internal_net_total: 200,
},
targets: {
booked: {
status: 'partial_mismatch',
overall_match: false,
totals: {
target_net_total: 180,
},
mismatch_reasons: ['department_total_mismatch'],
lines: {
summary: {
mismatch_count: 1,
target_billable_count: 2,
},
},
},
},
warnings: ['comparison warning'],
},
},
'line_by_line'
);
expect(okRow.status).toBe('ok');
expect(mismatchRow.status).toBe('mismatch');
expect(mismatchRow.lineMismatchCount).toBe(1);
expect(v2Row.status).toBe('mismatch');
expect(v2Row.bookedTotal).toBe(180);
expect(v2Row.difference).toBe(20);
expect(v2Row.mismatchReasons).toContain('department_total_mismatch');
expect(v2Row.lineMismatchCount).toBe(1);
const aggregate = aggregateCompareRows([okRow, mismatchRow, v2Row]);
expect(aggregate.total).toBe(3);
expect(aggregate.mismatches).toBe(2);
expect(aggregate.warningCount).toBe(4);
expect(aggregate.totalDifference).toBe(30);
});
});