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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+442
-394
File diff suppressed because it is too large
Load Diff
+244
-57
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user