- 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.
411 lines
12 KiB
JavaScript
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);
|
|
});
|
|
});
|