Update i18n translations for Swedish, German, and Norwegian locales; refactor invoice distribution API with enhanced fallback logic for v2 split and legacy endpoints; and add unit tests for distribution methods and UI integration.

This commit is contained in:
Jeppe Bundgaard
2026-03-12 19:53:52 +01:00
parent 75b7aad56a
commit c97d5c09d7
10 changed files with 1275 additions and 546 deletions
+6 -6
View File
@@ -3963,7 +3963,7 @@
"distribution": "Departmental distribution",
"fixed_pricing": "Fixed pricing",
"subscriptions": "Vehicle subscriptions",
"customer_prices": "Customer prices",
"customer_prices": "Kundepriser",
"other": "Other booked income",
"months": "Months in view"
},
@@ -3983,7 +3983,7 @@
"department": "Department",
"fixed_pricing": "Fixed pricing",
"subscriptions": "Subscriptions",
"customer_prices": "Customer prices",
"customer_prices": "Kundepriser",
"total": "Total",
"share": "Share",
"estimated_booked_allocation": "Estimated booked allocation",
@@ -4013,9 +4013,9 @@
"compare_failed": "Could not complete comparison"
},
"warnings": {
"legacy_distribution_fallback": "Loaded {count} month(s) through legacy distribution endpoints while v2 was unavailable.",
"legacy_distribution_month": "Current month data used legacy distribution endpoints because v2 was unavailable.",
"legacy_compare_fallback": "Comparison is running on the legacy endpoint because v2 bulk compare was unavailable."
"legacy_distribution_fallback": "Indlæste {count} måned(er) via ældre distributions-endpoints, fordi v2 ikke var tilgængelig.",
"legacy_distribution_month": "Månedsdata bruger ældre distributions-endpoints, fordi v2 ikke var tilgængelig.",
"legacy_compare_fallback": "Sammenligningen kører på det ældre endpoint, fordi v2 bulk-sammenligning ikke var tilgængelig."
},
"empty": {
"no_months": "No months to display",
@@ -4050,7 +4050,7 @@
"progress": "Processing {processed} / {total}",
"sorted_hint": "Rows are sorted by mismatches and warning severity first.",
"warning_details": "Warnings for invoice #{invoiceId}",
"status_label": "Comparison status"
"status_label": "Sammenligningsstatus"
},
"sections": {
"trend": "Monthly trend",
+6 -6
View File
@@ -3962,7 +3962,7 @@
"distribution": "Departmental distribution",
"fixed_pricing": "Fixed pricing",
"subscriptions": "Vehicle subscriptions",
"customer_prices": "Customer prices",
"customer_prices": "Kundenpreise",
"other": "Other booked income",
"months": "Months in view"
},
@@ -3982,7 +3982,7 @@
"department": "Department",
"fixed_pricing": "Fixed pricing",
"subscriptions": "Subscriptions",
"customer_prices": "Customer prices",
"customer_prices": "Kundenpreise",
"total": "Total",
"share": "Share",
"estimated_booked_allocation": "Estimated booked allocation",
@@ -4012,9 +4012,9 @@
"compare_failed": "Could not complete comparison"
},
"warnings": {
"legacy_distribution_fallback": "Loaded {count} month(s) through legacy distribution endpoints while v2 was unavailable.",
"legacy_distribution_month": "Current month data used legacy distribution endpoints because v2 was unavailable.",
"legacy_compare_fallback": "Comparison is running on the legacy endpoint because v2 bulk compare was unavailable."
"legacy_distribution_fallback": "Es wurden {count} Monat(e) über Legacy-Verteilungsendpunkte geladen, da v2 nicht verfügbar war.",
"legacy_distribution_month": "Die Monatsdaten verwenden Legacy-Verteilungsendpunkte, da v2 nicht verfügbar war.",
"legacy_compare_fallback": "Der Vergleich läuft über den Legacy-Endpunkt, da der v2-Bulkvergleich nicht verfügbar war."
},
"empty": {
"no_months": "No months to display",
@@ -4049,7 +4049,7 @@
"progress": "Processing {processed} / {total}",
"sorted_hint": "Rows are sorted by mismatches and warning severity first.",
"warning_details": "Warnings for invoice #{invoiceId}",
"status_label": "Comparison status"
"status_label": "Vergleichsstatus"
},
"sections": {
"trend": "Monthly trend",
+6 -6
View File
@@ -3962,7 +3962,7 @@
"distribution": "Departmental distribution",
"fixed_pricing": "Fixed pricing",
"subscriptions": "Vehicle subscriptions",
"customer_prices": "Customer prices",
"customer_prices": "Kundepriser",
"other": "Other booked income",
"months": "Months in view"
},
@@ -3982,7 +3982,7 @@
"department": "Department",
"fixed_pricing": "Fixed pricing",
"subscriptions": "Subscriptions",
"customer_prices": "Customer prices",
"customer_prices": "Kundepriser",
"total": "Total",
"share": "Share",
"estimated_booked_allocation": "Estimated booked allocation",
@@ -4012,9 +4012,9 @@
"compare_failed": "Could not complete comparison"
},
"warnings": {
"legacy_distribution_fallback": "Loaded {count} month(s) through legacy distribution endpoints while v2 was unavailable.",
"legacy_distribution_month": "Current month data used legacy distribution endpoints because v2 was unavailable.",
"legacy_compare_fallback": "Comparison is running on the legacy endpoint because v2 bulk compare was unavailable."
"legacy_distribution_fallback": "Lastet {count} måned(er) via eldre fordelingsendepunkter fordi v2 ikke var tilgjengelig.",
"legacy_distribution_month": "Denne månedsdataen brukte eldre fordelingsendepunkter fordi v2 ikke var tilgjengelig.",
"legacy_compare_fallback": "Sammenligningen kjører på det eldre endepunktet fordi v2 bulk-sammenligning ikke var tilgjengelig."
},
"empty": {
"no_months": "No months to display",
@@ -4049,7 +4049,7 @@
"progress": "Processing {processed} / {total}",
"sorted_hint": "Rows are sorted by mismatches and warning severity first.",
"warning_details": "Warnings for invoice #{invoiceId}",
"status_label": "Comparison status"
"status_label": "Sammenligningsstatus"
},
"sections": {
"trend": "Monthly trend",
+6 -6
View File
@@ -3962,7 +3962,7 @@
"distribution": "Departmental distribution",
"fixed_pricing": "Fixed pricing",
"subscriptions": "Vehicle subscriptions",
"customer_prices": "Customer prices",
"customer_prices": "Kundpriser",
"other": "Other booked income",
"months": "Months in view"
},
@@ -3982,7 +3982,7 @@
"department": "Department",
"fixed_pricing": "Fixed pricing",
"subscriptions": "Subscriptions",
"customer_prices": "Customer prices",
"customer_prices": "Kundpriser",
"total": "Total",
"share": "Share",
"estimated_booked_allocation": "Estimated booked allocation",
@@ -4012,9 +4012,9 @@
"compare_failed": "Could not complete comparison"
},
"warnings": {
"legacy_distribution_fallback": "Loaded {count} month(s) through legacy distribution endpoints while v2 was unavailable.",
"legacy_distribution_month": "Current month data used legacy distribution endpoints because v2 was unavailable.",
"legacy_compare_fallback": "Comparison is running on the legacy endpoint because v2 bulk compare was unavailable."
"legacy_distribution_fallback": "Laddade {count} månad(er) via äldre distributionsendpoints eftersom v2 inte var tillgänglig.",
"legacy_distribution_month": "Den här månadens data använde äldre distributionsendpoints eftersom v2 inte var tillgänglig.",
"legacy_compare_fallback": "Jämförelsen körs på den äldre endpointen eftersom v2-bulkjämförelse inte var tillgänglig."
},
"empty": {
"no_months": "No months to display",
@@ -4049,7 +4049,7 @@
"progress": "Processing {processed} / {total}",
"sorted_hint": "Rows are sorted by mismatches and warning severity first.",
"warning_details": "Warnings for invoice #{invoiceId}",
"status_label": "Comparison status"
"status_label": "Jämförelsestatus"
},
"sections": {
"trend": "Monthly trend",
@@ -8,7 +8,27 @@ import {
aggregateCompareRows,
} from '@/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionCalculations.js';
const DISTRIBUTION_ENDPOINTS = {
v2All: '/superuser/invoicing/period/distribution/v2/all',
v2FixedPricing: '/superuser/invoicing/period/distribution/v2/fixed-pricing',
v2WashSubscriptions: '/superuser/invoicing/period/distribution/v2/wash-subscriptions',
v2CustomerPrices: '/superuser/invoicing/period/distribution/v2/customer-prices',
legacyFixedPricing: '/superuser/invoicing/period/distribution/fixed-pricing',
legacyWashSubscriptions: '/superuser/invoicing/period/distribution/wash-subscriptions',
};
const COMPARE_ENDPOINTS = {
v2Bulk: '/collected-invoices/economic/v2/compare/bulk',
v2Single: '/collected-invoices/economic/v2/compare',
legacySingle: '/collected-invoices/economic/compare',
};
const safeArray = (value) => (Array.isArray(value) ? value : []);
const uniqueStrings = (items = []) => Array.from(new Set(
safeArray(items)
.map((item) => String(item || '').trim())
.filter((item) => item.length > 0)
));
const getResponseData = (response) => {
return response?.data?.data ?? response?.data ?? response;
@@ -22,6 +42,9 @@ const toErrorMessage = (error) => {
return SessionUser.functions.parseErrorMessage(error) || String(error?.message || error || 'Unknown error');
};
const joinReasonMessages = (...reasons) => uniqueStrings(reasons).join(' | ');
const waitForNextUiTick = () => new Promise((resolve) => setTimeout(resolve, 0));
const normalizeCategoryResponse = (category = {}, legacyCollectiveKey = null) => {
const customers = safeArray(category?.customers || category?.data);
const collectiveResults = (
@@ -48,9 +71,9 @@ const normalizeCategoryResponse = (category = {}, legacyCollectiveKey = null) =>
};
const normalizeV2DistributionPayload = (payload = {}) => {
const rawFixed = payload?.fixed_pricing;
const rawSubscriptions = payload?.wash_subscriptions;
const rawCustomerPrices = payload?.customer_prices;
const rawFixed = payload?.fixed_pricing || payload?.fixedPricing;
const rawSubscriptions = payload?.wash_subscriptions || payload?.washSubscriptions;
const rawCustomerPrices = payload?.customer_prices || payload?.customerPrices;
if (!rawFixed || !rawSubscriptions || !rawCustomerPrices) {
throw new Error('V2 distribution payload is missing one or more required categories');
@@ -63,37 +86,93 @@ const normalizeV2DistributionPayload = (payload = {}) => {
};
};
const fetchV2DistributionForMonth = async (year, month) => {
const fetchDistributionCategoryForMonth = async ({
year,
month,
endpoint,
legacyCollectiveKey,
}) => {
const { dateFrom, dateTo } = getMonthDateRange(year, month);
const response = await SessionUser.request('/superuser/invoicing/period/distribution/v2/all', 'GET', {
const response = await SessionUser.request(endpoint, 'GET', {
dateFrom,
dateTo,
});
return normalizeCategoryResponse(getResponseData(response), legacyCollectiveKey);
};
export const fetchV2FixedPricingDistributionForMonth = async (year, month) => {
return fetchDistributionCategoryForMonth({
year,
month,
endpoint: DISTRIBUTION_ENDPOINTS.v2FixedPricing,
legacyCollectiveKey: 'collective_fixed_pricing_results',
});
};
export const fetchV2WashSubscriptionsDistributionForMonth = async (year, month) => {
return fetchDistributionCategoryForMonth({
year,
month,
endpoint: DISTRIBUTION_ENDPOINTS.v2WashSubscriptions,
legacyCollectiveKey: 'collective_subscription_results',
});
};
export const fetchV2CustomerPricesDistributionForMonth = async (year, month) => {
return fetchDistributionCategoryForMonth({
year,
month,
endpoint: DISTRIBUTION_ENDPOINTS.v2CustomerPrices,
legacyCollectiveKey: 'collective_customer_price_results',
});
};
export const fetchV2DistributionAllForMonth = async (year, month) => {
const { dateFrom, dateTo } = getMonthDateRange(year, month);
const response = await SessionUser.request(DISTRIBUTION_ENDPOINTS.v2All, 'GET', {
dateFrom,
dateTo,
});
return normalizeV2DistributionPayload(getResponseData(response));
};
export const fetchV2DistributionCategoriesForMonth = async (year, month) => {
const [fixedDistribution, subscriptionDistribution, customerPriceDistribution] = await Promise.all([
fetchV2FixedPricingDistributionForMonth(year, month),
fetchV2WashSubscriptionsDistributionForMonth(year, month),
fetchV2CustomerPricesDistributionForMonth(year, month),
]);
return {
fixedDistribution,
subscriptionDistribution,
customerPriceDistribution,
};
};
const fetchLegacyFixedPricingDistributionForMonth = async (year, month) => {
const { dateFrom, dateTo } = getMonthDateRange(year, month);
const response = await SessionUser.request('/superuser/invoicing/period/distribution/fixed-pricing', 'GET', {
dateFrom,
dateTo,
return fetchDistributionCategoryForMonth({
year,
month,
endpoint: DISTRIBUTION_ENDPOINTS.legacyFixedPricing,
legacyCollectiveKey: 'collective_fixed_pricing_results',
});
return normalizeCategoryResponse(response?.data || response, 'collective_fixed_pricing_results');
};
const fetchLegacySubscriptionDistributionForMonth = async (year, month) => {
const { dateFrom, dateTo } = getMonthDateRange(year, month);
const response = await SessionUser.request('/superuser/invoicing/period/distribution/wash-subscriptions', 'GET', {
dateFrom,
dateTo,
const fetchLegacyWashSubscriptionsDistributionForMonth = async (year, month) => {
return fetchDistributionCategoryForMonth({
year,
month,
endpoint: DISTRIBUTION_ENDPOINTS.legacyWashSubscriptions,
legacyCollectiveKey: 'collective_subscription_results',
});
return normalizeCategoryResponse(response?.data || response, 'collective_subscription_results');
};
const fetchLegacyDistributionForMonth = async (year, month) => {
export const fetchLegacyDistributionForMonth = async (year, month) => {
const [fixedDistribution, subscriptionDistribution] = await Promise.all([
fetchLegacyFixedPricingDistributionForMonth(year, month),
fetchLegacySubscriptionDistributionForMonth(year, month),
fetchLegacyWashSubscriptionsDistributionForMonth(year, month),
]);
return {
@@ -104,23 +183,44 @@ const fetchLegacyDistributionForMonth = async (year, month) => {
};
const fetchDistributionForMonth = async (year, month) => {
let v2AllError = null;
try {
const data = await fetchV2DistributionForMonth(year, month);
const data = await fetchV2DistributionAllForMonth(year, month);
return {
...data,
fallback: {
usedLegacyDistribution: false,
usedSplitV2Distribution: false,
distributionFallbackReason: null,
},
};
} catch (error) {
console.warn('Falling back to legacy distribution endpoints', error);
v2AllError = error;
}
try {
const splitData = await fetchV2DistributionCategoriesForMonth(year, month);
return {
...splitData,
fallback: {
usedLegacyDistribution: false,
usedSplitV2Distribution: true,
distributionFallbackReason: toErrorMessage(v2AllError),
},
};
} catch (splitError) {
console.warn('Falling back to legacy distribution endpoints', splitError);
const legacyData = await fetchLegacyDistributionForMonth(year, month);
return {
...legacyData,
fallback: {
usedLegacyDistribution: true,
distributionFallbackReason: toErrorMessage(error),
usedSplitV2Distribution: false,
distributionFallbackReason: joinReasonMessages(
toErrorMessage(v2AllError),
toErrorMessage(splitError)
),
},
};
}
@@ -279,6 +379,7 @@ const compareCollectedInvoicesLegacy = async ({
invoices,
mode,
onProgress,
onRows,
batchSize,
}) => {
const rows = [];
@@ -288,7 +389,9 @@ const compareCollectedInvoicesLegacy = async ({
for (const chunk of chunks) {
const chunkResults = await Promise.all(chunk.map(async (invoice) => {
try {
const response = await SessionUser.objects.collectedOrderInvoices.functions.economic.compareToEconomic(invoice.id);
const response = await SessionUser.request(COMPARE_ENDPOINTS.legacySingle, 'GET', {
collected_invoice_id: Number(invoice?.id),
});
return normalizeCompareResult(invoice, getResponseData(response), mode);
} catch (error) {
const fallbackPayload = {
@@ -306,34 +409,86 @@ const compareCollectedInvoicesLegacy = async ({
}
}));
rows.push(...chunkResults);
processed += chunk.length;
for (const row of chunkResults) {
rows.push(row);
processed += 1;
if (typeof onProgress === 'function') {
onProgress({
total: invoices.length,
processed,
});
if (typeof onRows === 'function') {
onRows([row]);
await waitForNextUiTick();
}
if (typeof onProgress === 'function') {
onProgress({
total: invoices.length,
processed,
});
}
}
}
return rows;
};
const compareCollectedInvoicesV2Bulk = async ({
const createCompareFallbackRow = (invoice, mode, warningMessage) => {
const fallbackPayload = {
collected_invoice_id: Number(invoice?.id) || null,
warnings: uniqueStrings([warningMessage || 'Unable to compare invoice']),
internal_total: invoice?.total_net_amount || 0,
booked_total: null,
draft_total: null,
difference: null,
order_ids: [],
};
const normalized = normalizeCompareResult(invoice, fallbackPayload, mode);
normalized.status = 'mismatch';
return normalized;
};
const compareInvoiceWithV2Single = async ({
invoice,
mode,
fallbackWarning,
}) => {
try {
const response = await SessionUser.request(COMPARE_ENDPOINTS.v2Single, 'GET', {
collected_invoice_id: Number(invoice?.id),
});
return normalizeCompareResult(invoice, getResponseData(response), mode);
} catch (error) {
return createCompareFallbackRow(
invoice,
mode,
joinReasonMessages(fallbackWarning, toErrorMessage(error))
);
}
};
const compareCollectedInvoicesV2 = async ({
invoices,
mode,
onProgress,
onRows,
batchSize,
}) => {
const rows = [];
let processed = 0;
let usedSingleCompareFallback = false;
let singleCompareFallbackReason = null;
const safeBatchSize = Math.min(200, Math.max(1, Number(batchSize) || 1));
const chunks = chunkArray(invoices, safeBatchSize);
for (const chunk of chunks) {
const ids = chunk.map((invoice) => Number(invoice?.id)).filter((id) => Number.isInteger(id) && id > 0);
const response = await SessionUser.objects.collectedOrderInvoices.functions.economic.v2.compareBulk(ids);
const ids = chunk
.map((invoice) => Number(invoice?.id))
.filter((id) => Number.isInteger(id) && id > 0);
const unresolved = [];
const chunkRows = [];
const response = await SessionUser.request(COMPARE_ENDPOINTS.v2Bulk, 'POST', {
collected_invoice_ids: ids,
});
const payload = getResponseData(response) || {};
const resultRows = safeArray(payload?.results);
const errors = safeArray(payload?.errors);
@@ -344,35 +499,54 @@ const compareCollectedInvoicesV2Bulk = async ({
const invoiceId = Number(invoice?.id);
const comparePayload = resultMap.get(invoiceId);
if (comparePayload) {
rows.push(normalizeCompareResult(invoice, comparePayload, mode));
chunkRows.push(normalizeCompareResult(invoice, comparePayload, mode));
continue;
}
const errorEntry = errorMap.get(invoiceId);
const fallbackPayload = {
collected_invoice_id: invoiceId,
warnings: [errorEntry?.error || 'Missing comparison result from v2 bulk response'],
internal_total: invoice?.total_net_amount || 0,
booked_total: null,
draft_total: null,
difference: null,
order_ids: [],
};
const normalized = normalizeCompareResult(invoice, fallbackPayload, mode);
normalized.status = 'mismatch';
rows.push(normalized);
unresolved.push({
invoice,
warning: errorMap.get(invoiceId)?.error || 'Missing comparison result from v2 bulk response',
});
}
processed += chunk.length;
if (typeof onProgress === 'function') {
onProgress({
total: invoices.length,
processed,
});
if (unresolved.length) {
usedSingleCompareFallback = true;
singleCompareFallbackReason = singleCompareFallbackReason || unresolved[0]?.warning || null;
const recoveredRows = await Promise.all(unresolved.map(async ({ invoice, warning }) => {
return compareInvoiceWithV2Single({
invoice,
mode,
fallbackWarning: warning,
});
}));
chunkRows.push(...recoveredRows);
}
for (const row of chunkRows) {
rows.push(row);
processed += 1;
if (typeof onRows === 'function') {
onRows([row]);
await waitForNextUiTick();
}
if (typeof onProgress === 'function') {
onProgress({
total: invoices.length,
processed,
});
}
}
}
return rows;
return {
rows,
usedSingleCompareFallback,
singleCompareFallbackReason,
};
};
export const compareCollectedInvoicesForMonth = async ({
@@ -380,6 +554,7 @@ export const compareCollectedInvoicesForMonth = async ({
month,
mode = 'invoice_total',
onProgress = null,
onRows = null,
batchSize = 100,
legacyBatchSize = 5,
}) => {
@@ -391,26 +566,36 @@ export const compareCollectedInvoicesForMonth = async ({
invoiceCount: 0,
fallback: {
usedLegacyCompare: false,
usedSingleCompareFallback: false,
compareFallbackReason: null,
},
};
}
if (typeof onProgress === 'function') {
onProgress({
total: invoices.length,
processed: 0,
});
}
try {
const rows = await compareCollectedInvoicesV2Bulk({
const result = await compareCollectedInvoicesV2({
invoices,
mode,
onProgress,
onRows,
batchSize,
});
return {
rows,
summary: aggregateCompareRows(rows),
rows: result.rows,
summary: aggregateCompareRows(result.rows),
invoiceCount: invoices.length,
fallback: {
usedLegacyCompare: false,
compareFallbackReason: null,
usedSingleCompareFallback: result.usedSingleCompareFallback,
compareFallbackReason: result.singleCompareFallbackReason,
},
};
} catch (error) {
@@ -419,6 +604,7 @@ export const compareCollectedInvoicesForMonth = async ({
invoices,
mode,
onProgress,
onRows,
batchSize: legacyBatchSize,
});
@@ -428,6 +614,7 @@ export const compareCollectedInvoicesForMonth = async ({
invoiceCount: invoices.length,
fallback: {
usedLegacyCompare: true,
usedSingleCompareFallback: false,
compareFallbackReason: toErrorMessage(error),
},
};
+9 -3
View File
@@ -134,13 +134,19 @@ test.describe("Invoice distribution smoke", () => {
await primeSuperuserSession(page);
await page.goto("/superuser/invoices?activeTab=distribution");
await expect(page.getByText(/Loaded \d+ month\(s\) through legacy distribution endpoints/i)).toBeVisible();
await expect(
page.locator(".message.is-warning").filter({ hasText: /v2/i }).first()
).toBeVisible({ timeout: 15_000 });
await page.goto("/superuser/invoices/distribution/2026/3/compare?compareMode=line_by_line");
await expect(page.getByText(/legacy distribution endpoints because v2 was unavailable/i)).toBeVisible();
await expect(
page.locator(".message.is-warning").filter({ hasText: /v2/i }).first()
).toBeVisible();
await page.getByTestId("distribution-compare-submit").click();
await expect(page.getByTestId("distribution-compare-table")).toBeVisible();
await expect(page.getByText(/legacy endpoint because v2 bulk compare was unavailable/i)).toBeVisible();
await expect(
page.locator(".message.is-warning").filter({ hasText: /v2/i }).first()
).toBeVisible();
});
});
+112
View File
@@ -226,6 +226,118 @@ export async function mockApi(page, options = {}) {
return;
}
if (pathname.endsWith("/superuser/invoicing/period/distribution/v2/fixed-pricing") && method === "GET") {
if (forceDistributionLegacy) {
await route.fulfill(json({ message: "v2 fixed pricing distribution temporarily unavailable" }, 500));
return;
}
await route.fulfill(
json({
customers: [
{
id: 10,
customer_number: 1001,
customer_name: "Acme Transport",
requires_action: false,
transactions: [{ id: 1, date: "2026-01-10T00:00:00.000Z", amount: 80 + monthBase, booked: true, department_id: 1, excluded: false }],
meta: {
fixed_pricing: {
created_at: "2026-01-10T00:00:00.000Z",
price: 80 + monthBase,
original_price: 120 + monthBase,
department_totals_relative: { 1: 40 + monthBase, 2: 40 }
}
}
}
],
collective_results: {
total_fixed_price: 80 + monthBase,
total_department_totals_relative_parsed: {
Copenhagen: 40 + monthBase,
Odense: 40
}
},
warnings: []
})
);
return;
}
if (pathname.endsWith("/superuser/invoicing/period/distribution/v2/wash-subscriptions") && method === "GET") {
if (forceDistributionLegacy) {
await route.fulfill(json({ message: "v2 wash subscriptions distribution temporarily unavailable" }, 500));
return;
}
await route.fulfill(
json({
customers: [
{
id: 11,
customer_number: 1002,
customer_name: "Nordic Haul",
requires_action: false,
transactions: [{ id: 2, date: "2026-01-05T00:00:00.000Z", amount: 40 + monthBase, booked: true, department_id: 1, excluded: false }],
meta: {
wash_subscription: {
created_at: "2026-01-05T00:00:00.000Z",
price: 40 + monthBase,
original_price: 55 + monthBase,
department_totals_relative: { 1: 20 + monthBase, 2: 20 }
}
}
}
],
collective_results: {
total_subscription_price: 40 + monthBase,
subscription_price_department_distribution_parsed: {
Copenhagen: 20 + monthBase,
Odense: 20
}
},
warnings: []
})
);
return;
}
if (pathname.endsWith("/superuser/invoicing/period/distribution/v2/customer-prices") && method === "GET") {
if (forceDistributionLegacy) {
await route.fulfill(json({ message: "v2 customer prices distribution temporarily unavailable" }, 500));
return;
}
await route.fulfill(
json({
customers: [
{
id: 12,
customer_number: 1003,
customer_name: "Discount Fleet",
requires_action: false,
transactions: [{ id: 3, date: "2026-01-08T00:00:00.000Z", amount: 15 + monthBase, booked: true, department_id: 2, excluded: false }],
meta: {
customer_price: {
created_at: "2026-01-08T00:00:00.000Z",
price: 15 + monthBase,
department_totals_relative: { 2: 15 + monthBase }
}
}
}
],
collective_results: {
total_customer_price: 15 + monthBase,
customer_price_department_distribution_parsed: {
Odense: 15 + monthBase
}
},
warnings: []
})
);
return;
}
if (pathname.endsWith("/superuser/invoicing/period/distribution/fixed-pricing") && method === "GET") {
await route.fulfill(
json({
+426 -62
View File
@@ -3,8 +3,6 @@ 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'));
const compareBulk = vi.fn();
const compareToEconomic = vi.fn();
return {
SessionUser: {
@@ -12,24 +10,15 @@ vi.mock('@/components/session/token/SessionUser.vue', () => {
functions: {
parseErrorMessage,
},
objects: {
collectedOrderInvoices: {
functions: {
economic: {
compareToEconomic,
v2: {
compareBulk,
},
},
},
},
},
},
};
});
import { SessionUser } from '@/components/session/token/SessionUser.vue';
import { compareCollectedInvoicesForMonth } from '@/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionApi.js';
import {
compareCollectedInvoicesForMonth,
fetchMonthData,
} from '@/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionApi.js';
const buildInvoice = (id) => ({
id,
@@ -38,6 +27,80 @@ const buildInvoice = (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;
@@ -46,15 +109,166 @@ describe('invoice distribution api adapter', () => {
SessionUser.request.mockReset();
SessionUser.functions.parseErrorMessage.mockReset();
SessionUser.functions.parseErrorMessage.mockImplementation((error) => error?.message || String(error || 'Unknown error'));
SessionUser.objects.collectedOrderInvoices.functions.economic.v2.compareBulk.mockReset();
SessionUser.objects.collectedOrderInvoices.functions.economic.compareToEconomic.mockReset();
});
afterEach(() => {
consoleWarnSpy?.mockRestore();
});
it('chunks v2 compare bulk requests to max 200 ids', async () => {
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(200);
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(90);
});
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);
});
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) => {
@@ -76,28 +290,24 @@ describe('invoice distribution api adapter', () => {
};
}
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}`);
});
SessionUser.objects.collectedOrderInvoices.functions.economic.v2.compareBulk.mockImplementation(async (ids) => ({
data: {
data: {
requested: ids.length,
compared: ids.length,
failed: 0,
results: ids.map((id) => ({
collected_invoice_id: id,
internal_total: 100,
booked_total: 100,
difference: 0,
warnings: [],
order_ids: [id],
})),
errors: [],
},
},
}));
const result = await compareCollectedInvoicesForMonth({
year: 2026,
month: 3,
@@ -105,17 +315,20 @@ describe('invoice distribution api adapter', () => {
batchSize: 500,
});
expect(SessionUser.objects.collectedOrderInvoices.functions.economic.v2.compareBulk).toHaveBeenCalledTimes(2);
expect(SessionUser.objects.collectedOrderInvoices.functions.economic.v2.compareBulk.mock.calls[0][0]).toHaveLength(200);
expect(SessionUser.objects.collectedOrderInvoices.functions.economic.v2.compareBulk.mock.calls[1][0]).toHaveLength(50);
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('falls back to legacy compare when v2 bulk compare fails', async () => {
const invoices = [buildInvoice(101), buildInvoice(102)];
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) => {
SessionUser.request.mockImplementation(async (endpoint, method, params) => {
if (endpoint === '/collected-invoices' && method === 'GET') {
return {
data: {
@@ -123,22 +336,174 @@ describe('invoice distribution api adapter', () => {
},
};
}
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}`);
});
SessionUser.objects.collectedOrderInvoices.functions.economic.v2.compareBulk.mockRejectedValue(new Error('v2 compare failed'));
SessionUser.objects.collectedOrderInvoices.functions.economic.compareToEconomic.mockImplementation(async (invoiceId) => ({
data: {
data: {
collected_invoice_id: invoiceId,
internal_total: 100,
booked_total: 100,
difference: 0,
warnings: [],
order_ids: [invoiceId],
},
},
}));
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,
@@ -146,11 +511,10 @@ describe('invoice distribution api adapter', () => {
mode: 'invoice_total',
});
expect(SessionUser.objects.collectedOrderInvoices.functions.economic.v2.compareBulk).toHaveBeenCalledTimes(1);
expect(SessionUser.objects.collectedOrderInvoices.functions.economic.compareToEconomic).toHaveBeenCalledTimes(2);
expect(result.invoiceCount).toBe(2);
expect(result.rows).toHaveLength(2);
expect(result.fallback.usedLegacyCompare).toBe(true);
expect(result.fallback.compareFallbackReason).toContain('v2 compare failed');
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');
});
});
@@ -83,7 +83,8 @@ describe('Distribution monthly view contract', () => {
});
it('keeps workflow toolbar priority and summary ribbon', () => {
expect(distributionMonthSource).toContain('class="distribution-toolbar');
expect(distributionMonthSource).toContain('class="v3-toolbar-card');
expect(distributionMonthSource).toContain('class="v3-hero-card');
expect(distributionMonthSource).toContain('distribution-month-prev');
expect(distributionMonthSource).toContain('distribution-month-next');
expect(distributionMonthSource).toContain('distribution-compare-month-picker');
@@ -96,14 +97,17 @@ describe('Distribution monthly view contract', () => {
expect(distributionMonthSource).toContain("value=\"departments\"");
expect(distributionMonthSource).toContain("value=\"customers\"");
expect(distributionMonthSource).toContain("value=\"compare\"");
expect(distributionMonthSource).toContain('class="sticky-filter-bar');
expect(distributionMonthSource).toContain('class="v3-overview-grid');
expect(distributionMonthSource).toContain('class="v3-surface');
expect(distributionMonthSource).toContain('allocation-chips');
expect(distributionMonthSource).toContain('sortCompareRows(compareRows.value)');
expect(distributionMonthSource).toContain('selectedWarningInvoiceId');
expect(distributionMonthSource).toContain('warning-panel__header');
expect(distributionMonthSource).toContain("value=\"customer_prices\"");
expect(distributionMonthSource).toContain('monthFallback.usedLegacyDistribution');
expect(distributionMonthSource).toContain('monthFallback.usedSplitV2Distribution');
expect(distributionMonthSource).toContain('compareFallback.usedLegacyCompare');
expect(distributionMonthSource).toContain('compareFallback.usedSingleCompareFallback');
});
it('keeps compare mode values and progress visibility', () => {
@@ -112,6 +116,8 @@ describe('Distribution monthly view contract', () => {
expect(distributionMonthSource).toContain("line_by_line");
expect(distributionMonthSource).toContain('compareProgress');
expect(distributionMonthSource).toContain('compareCompletionPercent');
expect(distributionMonthSource).toContain('onRows:');
expect(distributionMonthSource).toContain('aggregateCompareRows(streamedRows)');
});
});
@@ -127,21 +133,27 @@ describe('Distribution accessibility contract', () => {
});
describe('Distribution API adapter contract', () => {
it('uses period endpoint and prefers v2 all distribution with legacy fallback', () => {
it('uses period endpoint and supports v2 all/split distribution with legacy fallback', () => {
expect(distributionApiSource).toContain("'/superuser/invoicing/period'");
expect(distributionApiSource).toContain("'/superuser/invoicing/period/distribution/v2/all'");
expect(distributionApiSource).toContain("'/superuser/invoicing/period/distribution/v2/fixed-pricing'");
expect(distributionApiSource).toContain("'/superuser/invoicing/period/distribution/v2/wash-subscriptions'");
expect(distributionApiSource).toContain("'/superuser/invoicing/period/distribution/v2/customer-prices'");
expect(distributionApiSource).toContain("'/superuser/invoicing/period/distribution/fixed-pricing'");
expect(distributionApiSource).toContain("'/superuser/invoicing/period/distribution/wash-subscriptions'");
expect(distributionApiSource).toContain('usedLegacyDistribution');
expect(distributionApiSource).toContain('usedSplitV2Distribution');
expect(distributionApiSource).toContain('dateFrom');
expect(distributionApiSource).toContain('dateTo');
});
it('runs month compare against v2 bulk endpoint with legacy fallback path', () => {
it('runs month compare against v2 bulk endpoint with single-v2 and legacy fallback paths', () => {
expect(distributionApiSource).toContain('compareCollectedInvoicesForMonth');
expect(distributionApiSource).toContain('economic.v2.compareBulk');
expect(distributionApiSource).toContain('SessionUser.objects.collectedOrderInvoices.functions.economic.compareToEconomic');
expect(distributionApiSource).toContain("'/collected-invoices/economic/v2/compare/bulk'");
expect(distributionApiSource).toContain("'/collected-invoices/economic/v2/compare'");
expect(distributionApiSource).toContain("'/collected-invoices/economic/compare'");
expect(distributionApiSource).toContain('usedLegacyCompare');
expect(distributionApiSource).toContain('usedSingleCompareFallback');
expect(distributionApiSource).toContain('normalizeCompareResult');
expect(distributionApiSource).toContain('aggregateCompareRows');
});