Files
pleno-vue/tests/unit/invoice-distribution-api.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

527 lines
16 KiB
JavaScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/components/session/token/SessionUser.vue', () => {
const request = vi.fn();
const parseErrorMessage = vi.fn((error) => error?.message || String(error || 'Unknown error'));
return {
SessionUser: {
request,
functions: {
parseErrorMessage,
},
},
};
});
import { SessionUser } from '@/components/session/token/SessionUser.vue';
import {
compareCollectedInvoicesForMonth,
fetchMonthData,
} from '@/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionApi.js';
const buildInvoice = (id) => ({
id,
customer_number: 2000 + id,
customer_name: `Customer ${id}`,
total_net_amount: 100,
});
const buildV2Category = (amount) => ({
customers: [
{
id: 1,
customer_number: 5001,
customer_name: 'ACME',
requires_action: false,
transactions: [{ id: 1, amount, booked: true, excluded: false, department_id: 1, date: '2026-03-01T00:00:00Z' }],
meta: {
fixed_pricing: {
price: amount,
department_totals_relative: { 1: amount },
},
},
},
],
collective_results: {
total_amount: amount,
department_distribution_parsed: { Ops: amount },
},
warnings: [],
});
const buildV2CompareResult = (invoiceId, { internalTotal = 100, bookedTotal = 100, mismatch = false } = {}) => ({
collected_invoice_id: invoiceId,
warnings: mismatch ? ['Top level warning'] : [],
details: {
order_ids: mismatch ? [1, 2] : [1],
customer: {
internal_customer_number: 7000 + invoiceId,
name: `Customer ${invoiceId}`,
},
internal: {
normalized: {
totals: {
net_total: internalTotal,
billable_line_count: mismatch ? 2 : 1,
},
},
},
},
comparison: {
totals: {
internal_net_total: internalTotal,
},
targets: {
booked: {
target: 'booked',
status: mismatch ? 'partial_mismatch' : 'exact_match',
overall_match: !mismatch,
totals: {
target_net_total: bookedTotal,
difference: internalTotal - bookedTotal,
},
mismatch_reasons: mismatch ? ['department_total_mismatch'] : [],
warnings: mismatch ? ['Line mismatch'] : [],
lines: {
summary: {
internal_billable_count: mismatch ? 2 : 1,
target_billable_count: mismatch ? 2 : 1,
mismatch_count: mismatch ? 1 : 0,
},
diff: [],
},
departments: {
matches: !mismatch,
diff: [],
},
},
},
warnings: [],
},
});
describe('invoice distribution api adapter', () => {
let consoleWarnSpy;
beforeEach(() => {
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
SessionUser.request.mockReset();
SessionUser.functions.parseErrorMessage.mockReset();
SessionUser.functions.parseErrorMessage.mockImplementation((error) => error?.message || String(error || 'Unknown error'));
});
afterEach(() => {
consoleWarnSpy?.mockRestore();
});
it('uses v2 all endpoint for month distribution when available', async () => {
SessionUser.request.mockImplementation(async (endpoint) => {
if (endpoint === '/superuser/invoicing/period') {
return {
data: {
data: {
types: {
all: [
{
transactions: [
{ amount: 200, booked: true, excluded: false },
],
},
],
},
},
},
};
}
if (endpoint === '/superuser/invoicing/period/distribution/v2/all') {
return {
data: {
fixed_pricing: buildV2Category(80),
wash_subscriptions: buildV2Category(70),
customer_prices: buildV2Category(50),
},
};
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
const result = await fetchMonthData(2026, 3);
expect(result.fallback.usedLegacyDistribution).toBe(false);
expect(result.fallback.usedSplitV2Distribution).toBe(false);
expect(result.summary.distributionAmount).toBe(150);
expect(result.summary.customerPriceAmount).toBe(50);
expect(result.summary.totalAmount).toBe(350);
expect(SessionUser.request).toHaveBeenCalledWith('/superuser/invoicing/period/distribution/v2/all', 'GET', expect.any(Object));
});
it('falls back to v2 split endpoints when v2 all fails', async () => {
SessionUser.request.mockImplementation(async (endpoint) => {
if (endpoint === '/superuser/invoicing/period') {
return {
data: {
data: {
types: {
all: [{ transactions: [{ amount: 120, booked: true, excluded: false }] }],
},
},
},
};
}
if (endpoint === '/superuser/invoicing/period/distribution/v2/all') {
throw new Error('v2 all unavailable');
}
if (endpoint === '/superuser/invoicing/period/distribution/v2/fixed-pricing') {
return { data: buildV2Category(40) };
}
if (endpoint === '/superuser/invoicing/period/distribution/v2/wash-subscriptions') {
return { data: buildV2Category(30) };
}
if (endpoint === '/superuser/invoicing/period/distribution/v2/customer-prices') {
return { data: buildV2Category(20) };
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
const result = await fetchMonthData(2026, 3);
expect(result.fallback.usedLegacyDistribution).toBe(false);
expect(result.fallback.usedSplitV2Distribution).toBe(true);
expect(result.fallback.distributionFallbackReason).toContain('v2 all unavailable');
expect(result.summary.distributionAmount).toBe(70);
expect(result.summary.customerPriceAmount).toBe(20);
expect(result.summary.totalAmount).toBe(190);
});
it('falls back to legacy distribution endpoints when all v2 paths fail', async () => {
SessionUser.request.mockImplementation(async (endpoint) => {
if (endpoint === '/superuser/invoicing/period') {
return {
data: {
data: {
types: {
all: [{ transactions: [{ amount: 120, booked: true, excluded: false }] }],
},
},
},
};
}
if (
endpoint === '/superuser/invoicing/period/distribution/v2/all'
|| endpoint === '/superuser/invoicing/period/distribution/v2/fixed-pricing'
|| endpoint === '/superuser/invoicing/period/distribution/v2/wash-subscriptions'
|| endpoint === '/superuser/invoicing/period/distribution/v2/customer-prices'
) {
throw new Error(`${endpoint} unavailable`);
}
if (endpoint === '/superuser/invoicing/period/distribution/fixed-pricing') {
return {
data: {
customers: [
{
id: 1,
customer_number: 1001,
customer_name: 'Legacy Fixed',
meta: { fixed_pricing: { price: 50 } },
},
],
collective_results: {
total_fixed_price: 50,
total_department_totals_relative_parsed: { Ops: 50 },
},
warnings: [],
},
};
}
if (endpoint === '/superuser/invoicing/period/distribution/wash-subscriptions') {
return {
data: {
customers: [
{
id: 2,
customer_number: 1002,
customer_name: 'Legacy Subscription',
meta: { wash_subscription: { price: 25 } },
},
],
collective_results: {
total_subscription_price: 25,
subscription_price_department_distribution_parsed: { Ops: 25 },
},
warnings: [],
},
};
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
const result = await fetchMonthData(2026, 3);
expect(result.fallback.usedLegacyDistribution).toBe(true);
expect(result.fallback.distributionFallbackReason).toContain('/superuser/invoicing/period/distribution/v2/all unavailable');
expect(result.summary.fixedPricingAmount).toBe(50);
expect(result.summary.subscriptionAmount).toBe(25);
expect(result.summary.distributionAmount).toBe(75);
expect(result.summary.totalAmount).toBe(195);
});
it('chunks v2 bulk compare requests to max 200 ids', async () => {
const invoices = Array.from({ length: 250 }, (_, index) => buildInvoice(index + 1));
SessionUser.request.mockImplementation(async (endpoint, method, params) => {
if (endpoint === '/collected-invoices' && method === 'GET') {
const page = Number(params?.page || 1);
const perPage = 100;
const start = (page - 1) * perPage;
const data = invoices.slice(start, start + perPage);
return {
data: {
data,
meta: {
pagination: {
total: invoices.length,
per_page: perPage,
},
},
},
};
}
if (endpoint === '/collected-invoices/economic/v2/compare/bulk' && method === 'POST') {
const ids = params?.collected_invoice_ids || [];
return {
data: {
data: {
requested: ids.length,
compared: ids.length,
failed: 0,
results: ids.map((id) => buildV2CompareResult(id)),
errors: [],
},
},
};
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
const result = await compareCollectedInvoicesForMonth({
year: 2026,
month: 3,
mode: 'invoice_total',
batchSize: 500,
});
const bulkCalls = SessionUser.request.mock.calls.filter(([endpoint]) => endpoint === '/collected-invoices/economic/v2/compare/bulk');
expect(bulkCalls).toHaveLength(2);
expect(bulkCalls[0][2].collected_invoice_ids).toHaveLength(200);
expect(bulkCalls[1][2].collected_invoice_ids).toHaveLength(50);
expect(result.invoiceCount).toBe(250);
expect(result.fallback.usedLegacyCompare).toBe(false);
});
it('emits incremental compare progress and row chunks while v2 compare runs', async () => {
const invoices = [buildInvoice(1), buildInvoice(2), buildInvoice(3), buildInvoice(4), buildInvoice(5)];
const onRows = vi.fn();
const onProgress = vi.fn();
SessionUser.request.mockImplementation(async (endpoint, method, params) => {
if (endpoint === '/collected-invoices' && method === 'GET') {
return {
data: {
data: invoices,
},
};
}
if (endpoint === '/collected-invoices/economic/v2/compare/bulk' && method === 'POST') {
const ids = params?.collected_invoice_ids || [];
return {
data: {
data: {
requested: ids.length,
compared: ids.length,
failed: 0,
results: ids.map((id) => buildV2CompareResult(id)),
errors: [],
},
},
};
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
const result = await compareCollectedInvoicesForMonth({
year: 2026,
month: 3,
mode: 'invoice_total',
batchSize: 2,
onRows,
onProgress,
});
expect(result.rows).toHaveLength(5);
expect(onRows).toHaveBeenCalledTimes(5);
expect(onRows.mock.calls.map(([rows]) => rows.length)).toEqual([1, 1, 1, 1, 1]);
expect(onProgress.mock.calls.map(([progress]) => `${progress.processed}/${progress.total}`)).toEqual([
'0/5',
'1/5',
'2/5',
'3/5',
'4/5',
'5/5',
]);
});
it('recovers missing bulk compare rows via v2 single endpoint', async () => {
const invoices = [buildInvoice(101), buildInvoice(102)];
SessionUser.request.mockImplementation(async (endpoint, method, params) => {
if (endpoint === '/collected-invoices' && method === 'GET') {
return {
data: {
data: invoices,
},
};
}
if (endpoint === '/collected-invoices/economic/v2/compare/bulk' && method === 'POST') {
return {
data: {
data: {
requested: params?.collected_invoice_ids?.length || 0,
compared: 1,
failed: 1,
results: [buildV2CompareResult(101)],
errors: [{ collected_invoice_id: 102, error: 'temporary mismatch in bulk compare' }],
},
},
};
}
if (endpoint === '/collected-invoices/economic/v2/compare' && method === 'GET') {
return {
data: {
data: buildV2CompareResult(102, { mismatch: true, internalTotal: 110, bookedTotal: 90 }),
},
};
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
const result = await compareCollectedInvoicesForMonth({
year: 2026,
month: 3,
mode: 'line_by_line',
});
expect(result.fallback.usedLegacyCompare).toBe(false);
expect(result.fallback.usedSingleCompareFallback).toBe(true);
expect(result.fallback.compareFallbackReason).toContain('temporary mismatch in bulk compare');
expect(result.rows).toHaveLength(2);
expect(result.rows.some((row) => row.invoiceId === 102)).toBe(true);
});
it('falls back to legacy compare when v2 bulk compare fails', async () => {
const invoices = [buildInvoice(101), buildInvoice(102)];
SessionUser.request.mockImplementation(async (endpoint, method, params) => {
if (endpoint === '/collected-invoices' && method === 'GET') {
return {
data: {
data: invoices,
},
};
}
if (endpoint === '/collected-invoices/economic/v2/compare/bulk' && method === 'POST') {
throw new Error('v2 compare failed');
}
if (endpoint === '/collected-invoices/economic/compare' && method === 'GET') {
const invoiceId = Number(params?.collected_invoice_id);
return {
data: {
data: {
collected_invoice_id: invoiceId,
internal_total: 100,
booked_total: 95,
difference: 5,
warnings: [],
order_ids: [invoiceId],
},
},
};
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
const result = await compareCollectedInvoicesForMonth({
year: 2026,
month: 3,
mode: 'invoice_total',
legacyBatchSize: 10,
});
const legacyCalls = SessionUser.request.mock.calls.filter(([endpoint]) => endpoint === '/collected-invoices/economic/compare');
expect(legacyCalls).toHaveLength(2);
expect(result.fallback.usedLegacyCompare).toBe(true);
expect(result.fallback.compareFallbackReason).toContain('v2 compare failed');
expect(result.rows).toHaveLength(2);
});
it('creates mismatch fallback row when both bulk and single v2 data are missing', async () => {
const invoices = [buildInvoice(301)];
SessionUser.request.mockImplementation(async (endpoint, method) => {
if (endpoint === '/collected-invoices' && method === 'GET') {
return { data: { data: invoices } };
}
if (endpoint === '/collected-invoices/economic/v2/compare/bulk' && method === 'POST') {
return {
data: {
data: {
requested: 1,
compared: 0,
failed: 1,
results: [],
errors: [{ collected_invoice_id: 301, error: 'no bulk result' }],
},
},
};
}
if (endpoint === '/collected-invoices/economic/v2/compare' && method === 'GET') {
throw new Error('single compare down');
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
const result = await compareCollectedInvoicesForMonth({
year: 2026,
month: 3,
mode: 'invoice_total',
});
expect(result.fallback.usedLegacyCompare).toBe(false);
expect(result.fallback.usedSingleCompareFallback).toBe(true);
expect(result.rows).toHaveLength(1);
expect(result.rows[0].status).toBe('mismatch');
expect(result.rows[0].warnings.join(' ')).toContain('single compare down');
});
});