- 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.
527 lines
16 KiB
JavaScript
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');
|
|
});
|
|
});
|