diff --git a/src/components/session/token/SessionUser/Objects/CollectedOrderInvoices.vue b/src/components/session/token/SessionUser/Objects/CollectedOrderInvoices.vue index e08d996b..419f3962 100644 --- a/src/components/session/token/SessionUser/Objects/CollectedOrderInvoices.vue +++ b/src/components/session/token/SessionUser/Objects/CollectedOrderInvoices.vue @@ -392,6 +392,103 @@ export const CollectedOrderInvoices = { throw error; }); }, + v2: { + details: async (collectedInvoiceId) => { + return authenticatedRequest('/collected-invoices/economic/v2/details', 'GET', { + collected_invoice_id: parseInt(collectedInvoiceId), + }).then((response) => { + console.warn(response); + return response; + }).catch((error) => { + console.warn(error); + throw error; + }); + }, + compare: async (collectedInvoiceId) => { + return authenticatedRequest('/collected-invoices/economic/v2/compare', 'GET', { + collected_invoice_id: parseInt(collectedInvoiceId), + }).then((response) => { + console.warn(response); + return response; + }).catch((error) => { + console.warn(error); + throw error; + }); + }, + compareBulk: async (collectedInvoiceIds = []) => { + const MAX_BULK_COMPARE_IDS = 200; + const sanitizedIds = (Array.isArray(collectedInvoiceIds) ? collectedInvoiceIds : []) + .map((id) => parseInt(id)) + .filter((id) => Number.isInteger(id) && id > 0); + + if (!sanitizedIds.length) { + return { + data: { + data: { + requested: 0, + compared: 0, + failed: 0, + results: [], + errors: [], + }, + }, + }; + } + + if (sanitizedIds.length <= MAX_BULK_COMPARE_IDS) { + return authenticatedRequest('/collected-invoices/economic/v2/compare/bulk', 'POST', { + collected_invoice_ids: sanitizedIds, + }).then((response) => { + console.warn(response); + return response; + }).catch((error) => { + console.warn(error); + throw error; + }); + } + + const aggregated = { + requested: sanitizedIds.length, + compared: 0, + failed: 0, + results: [], + errors: [], + }; + + for (let index = 0; index < sanitizedIds.length; index += MAX_BULK_COMPARE_IDS) { + const chunkIds = sanitizedIds.slice(index, index + MAX_BULK_COMPARE_IDS); + const response = await authenticatedRequest('/collected-invoices/economic/v2/compare/bulk', 'POST', { + collected_invoice_ids: chunkIds, + }); + const payload = response?.data?.data || response?.data || {}; + const results = Array.isArray(payload?.results) ? payload.results : []; + const errors = Array.isArray(payload?.errors) ? payload.errors : []; + const comparedCount = Number(payload?.compared); + const failedCount = Number(payload?.failed); + + aggregated.compared += Number.isFinite(comparedCount) ? comparedCount : results.length; + aggregated.failed += Number.isFinite(failedCount) ? failedCount : errors.length; + aggregated.results.push(...results); + aggregated.errors.push(...errors); + } + + return { + data: { + data: aggregated, + }, + }; + }, + revenueStatistics: async (params = {}) => { + return authenticatedRequest('/collected-invoices/economic/v2/revenue-statistics', 'GET', params) + .then((response) => { + console.warn(response); + return response; + }).catch((error) => { + console.warn(error); + throw error; + }); + }, + }, // The additionalBody can contain: // - send_as_is: boolean (default: false) - If true, the invoice will be sent to E-conomic as is, without any modifications made by fixed pricing or vehicle subscriptions. invoice: async (invoiceId, additionalBody = { @@ -511,4 +608,4 @@ export const CollectedOrderInvoices = { ); } }; - \ No newline at end of file + diff --git a/src/i18n/locales/da.json b/src/i18n/locales/da.json index aeacc448..5244e17d 100644 --- a/src/i18n/locales/da.json +++ b/src/i18n/locales/da.json @@ -3963,6 +3963,7 @@ "distribution": "Departmental distribution", "fixed_pricing": "Fixed pricing", "subscriptions": "Vehicle subscriptions", + "customer_prices": "Customer prices", "other": "Other booked income", "months": "Months in view" }, @@ -3982,6 +3983,7 @@ "department": "Department", "fixed_pricing": "Fixed pricing", "subscriptions": "Subscriptions", + "customer_prices": "Customer prices", "total": "Total", "share": "Share", "estimated_booked_allocation": "Estimated booked allocation", @@ -4010,6 +4012,11 @@ "month_load_failed": "Could not load selected month", "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." + }, "empty": { "no_months": "No months to display", "no_departments": "No departments for this month", @@ -4042,7 +4049,8 @@ "run_short": "Run compare", "progress": "Processing {processed} / {total}", "sorted_hint": "Rows are sorted by mismatches and warning severity first.", - "warning_details": "Warnings for invoice #{invoiceId}" + "warning_details": "Warnings for invoice #{invoiceId}", + "status_label": "Comparison status" }, "sections": { "trend": "Monthly trend", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index aa939309..44d99bcd 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3962,6 +3962,7 @@ "distribution": "Departmental distribution", "fixed_pricing": "Fixed pricing", "subscriptions": "Vehicle subscriptions", + "customer_prices": "Customer prices", "other": "Other booked income", "months": "Months in view" }, @@ -3981,6 +3982,7 @@ "department": "Department", "fixed_pricing": "Fixed pricing", "subscriptions": "Subscriptions", + "customer_prices": "Customer prices", "total": "Total", "share": "Share", "estimated_booked_allocation": "Estimated booked allocation", @@ -4009,6 +4011,11 @@ "month_load_failed": "Could not load selected month", "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." + }, "empty": { "no_months": "No months to display", "no_departments": "No departments for this month", @@ -4041,7 +4048,8 @@ "run_short": "Run compare", "progress": "Processing {processed} / {total}", "sorted_hint": "Rows are sorted by mismatches and warning severity first.", - "warning_details": "Warnings for invoice #{invoiceId}" + "warning_details": "Warnings for invoice #{invoiceId}", + "status_label": "Comparison status" }, "sections": { "trend": "Monthly trend", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 44e5cbff..9041e223 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -3952,6 +3952,7 @@ "distribution": "Departmental distribution", "fixed_pricing": "Fixed pricing", "subscriptions": "Vehicle subscriptions", + "customer_prices": "Customer prices", "other": "Other booked income", "months": "Months in view" }, @@ -3971,6 +3972,7 @@ "department": "Department", "fixed_pricing": "Fixed pricing", "subscriptions": "Subscriptions", + "customer_prices": "Customer prices", "total": "Total", "share": "Share", "estimated_booked_allocation": "Estimated booked allocation", @@ -3999,6 +4001,11 @@ "month_load_failed": "Could not load selected month", "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." + }, "empty": { "no_months": "No months to display", "no_departments": "No departments for this month", @@ -4031,7 +4038,8 @@ "run_short": "Run compare", "progress": "Processing {processed} / {total}", "sorted_hint": "Rows are sorted by mismatches and warning severity first.", - "warning_details": "Warnings for invoice #{invoiceId}" + "warning_details": "Warnings for invoice #{invoiceId}", + "status_label": "Comparison status" }, "sections": { "trend": "Monthly trend", diff --git a/src/i18n/locales/no.json b/src/i18n/locales/no.json index 87036871..a50ea3b8 100644 --- a/src/i18n/locales/no.json +++ b/src/i18n/locales/no.json @@ -3962,6 +3962,7 @@ "distribution": "Departmental distribution", "fixed_pricing": "Fixed pricing", "subscriptions": "Vehicle subscriptions", + "customer_prices": "Customer prices", "other": "Other booked income", "months": "Months in view" }, @@ -3981,6 +3982,7 @@ "department": "Department", "fixed_pricing": "Fixed pricing", "subscriptions": "Subscriptions", + "customer_prices": "Customer prices", "total": "Total", "share": "Share", "estimated_booked_allocation": "Estimated booked allocation", @@ -4009,6 +4011,11 @@ "month_load_failed": "Could not load selected month", "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." + }, "empty": { "no_months": "No months to display", "no_departments": "No departments for this month", @@ -4041,7 +4048,8 @@ "run_short": "Run compare", "progress": "Processing {processed} / {total}", "sorted_hint": "Rows are sorted by mismatches and warning severity first.", - "warning_details": "Warnings for invoice #{invoiceId}" + "warning_details": "Warnings for invoice #{invoiceId}", + "status_label": "Comparison status" }, "sections": { "trend": "Monthly trend", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 9be69e08..9764acfc 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -3962,6 +3962,7 @@ "distribution": "Departmental distribution", "fixed_pricing": "Fixed pricing", "subscriptions": "Vehicle subscriptions", + "customer_prices": "Customer prices", "other": "Other booked income", "months": "Months in view" }, @@ -3981,6 +3982,7 @@ "department": "Department", "fixed_pricing": "Fixed pricing", "subscriptions": "Subscriptions", + "customer_prices": "Customer prices", "total": "Total", "share": "Share", "estimated_booked_allocation": "Estimated booked allocation", @@ -4009,6 +4011,11 @@ "month_load_failed": "Could not load selected month", "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." + }, "empty": { "no_months": "No months to display", "no_departments": "No departments for this month", @@ -4041,7 +4048,8 @@ "run_short": "Run compare", "progress": "Processing {processed} / {total}", "sorted_hint": "Rows are sorted by mismatches and warning severity first.", - "warning_details": "Warnings for invoice #{invoiceId}" + "warning_details": "Warnings for invoice #{invoiceId}", + "status_label": "Comparison status" }, "sections": { "trend": "Monthly trend", diff --git a/src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionMonthView.vue b/src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionMonthView.vue index 74b18cc9..e4124898 100644 --- a/src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionMonthView.vue +++ b/src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionMonthView.vue @@ -53,6 +53,7 @@ const errorMessage = ref(''); const summary = ref(null); const fixedDistribution = ref({}); const subscriptionDistribution = ref({}); +const customerPriceDistribution = ref({}); const departments = ref([]); const departmentSearch = ref(''); @@ -68,6 +69,8 @@ const compareStats = ref({ total: 0, mismatches: 0, warningCount: 0, totalDiffer const compareProgress = ref({ processed: 0, total: 0 }); const monthComparison = ref(null); const selectedWarningInvoiceId = ref(null); +const monthFallback = ref({ usedLegacyDistribution: false, distributionFallbackReason: null }); +const compareFallback = ref({ usedLegacyCompare: false, compareFallbackReason: null }); const syncingRouteQuery = ref(false); @@ -84,6 +87,7 @@ const departmentRows = computed(() => buildDepartmentAllocations({ departments: departments.value, fixedDistribution: fixedDistribution.value, subscriptionDistribution: subscriptionDistribution.value, + customerPriceDistribution: customerPriceDistribution.value, bookedAmount: summary.value?.bookedAmount || 0, })); @@ -92,6 +96,7 @@ const filteredDepartmentRows = computed(() => filterDepartmentRows({ rows: depar const customerRows = computed(() => buildCustomerAllocationRows({ fixedDistribution: fixedDistribution.value, subscriptionDistribution: subscriptionDistribution.value, + customerPriceDistribution: customerPriceDistribution.value, year: selectedYear.value, month: selectedMonth.value, departments: departments.value, @@ -148,6 +153,12 @@ const summaryRibbonItems = computed(() => ([ value: formatCurrency(summary.value?.distributionAmount || 0), icon: 'fa-chart-pie', }, + { + key: 'customer_prices', + label: t('superuser_invoice_distribution.metrics.customer_prices'), + value: formatCurrency(summary.value?.customerPriceAmount || 0), + icon: 'fa-tags', + }, { key: 'compare', label: t('superuser_invoice_distribution.compare.mismatches'), @@ -262,6 +273,8 @@ const loadMonth = async () => { summary.value = monthData.summary; fixedDistribution.value = monthData.fixedDistribution; subscriptionDistribution.value = monthData.subscriptionDistribution; + customerPriceDistribution.value = monthData.customerPriceDistribution; + monthFallback.value = monthData.fallback || { usedLegacyDistribution: false, distributionFallbackReason: null }; departments.value = await fetchDepartments(); const isValidCompareMonth = compareMonthOptions.value.some((option) => option.key === compareMonthKey.value); @@ -302,6 +315,7 @@ const runCompare = async () => { compareStats.value = { total: 0, mismatches: 0, warningCount: 0, totalDifference: 0 }; compareProgress.value = { processed: 0, total: 0 }; selectedWarningInvoiceId.value = null; + compareFallback.value = { usedLegacyCompare: false, compareFallbackReason: null }; try { const result = await compareCollectedInvoicesForMonth({ @@ -315,6 +329,7 @@ const runCompare = async () => { compareRows.value = result.rows; compareStats.value = result.summary; + compareFallback.value = result.fallback || { usedLegacyCompare: false, compareFallbackReason: null }; } catch (error) { compareError.value = SessionUser.functions.parseErrorMessage(error) || String(error?.message || error); } finally { @@ -507,6 +522,17 @@ onMounted(async () => { + +

{{ t('superuser_invoice_distribution.warnings.legacy_distribution_month') }}

+

{{ monthFallback.distributionFallbackReason }}

+
+
@@ -527,21 +553,28 @@ onMounted(async () => {
-
+

{{ t('superuser_invoice_distribution.metrics.fixed_pricing') }}

{{ formatCurrency(summary?.fixedPricingAmount || 0) }}

{{ formatPercent(sourceComposition.fixedPercent) }} {{ t('superuser_invoice_distribution.metrics.distribution') }}

-
+

{{ t('superuser_invoice_distribution.metrics.subscriptions') }}

{{ formatCurrency(summary?.subscriptionAmount || 0) }}

{{ formatPercent(sourceComposition.subscriptionPercent) }} {{ t('superuser_invoice_distribution.metrics.distribution') }}

-
+
+ +

{{ t('superuser_invoice_distribution.metrics.customer_prices') }}

+

{{ formatCurrency(summary?.customerPriceAmount || 0) }}

+

{{ formatPercent(sourceComposition.customerPricePercent) }} {{ t('superuser_invoice_distribution.metrics.distribution') }}

+
+
+

{{ t('superuser_invoice_distribution.metrics.distribution') }}

{{ formatCurrency(summary?.distributionAmount || 0) }}

@@ -582,6 +615,12 @@ onMounted(async () => { {{ formatCurrency(monthComparison.distributionAmount.target) }} {{ formatCurrency(monthComparison.distributionAmount.delta) }} + + {{ t('superuser_invoice_distribution.metrics.customer_prices') }} + {{ formatCurrency(monthComparison.customerPriceAmount.base) }} + {{ formatCurrency(monthComparison.customerPriceAmount.target) }} + {{ formatCurrency(monthComparison.customerPriceAmount.delta) }} +
@@ -637,6 +676,7 @@ onMounted(async () => { {{ t('superuser_invoice_distribution.table.department') }} {{ t('superuser_invoice_distribution.table.fixed_pricing') }} {{ t('superuser_invoice_distribution.table.subscriptions') }} + {{ t('superuser_invoice_distribution.table.customer_prices') }} {{ t('superuser_invoice_distribution.table.total') }} {{ t('superuser_invoice_distribution.table.share') }} {{ t('superuser_invoice_distribution.table.estimated_booked_allocation') }} @@ -647,6 +687,7 @@ onMounted(async () => { {{ row.departmentName }} {{ formatCurrency(row.fixedAmount) }} {{ formatCurrency(row.subscriptionAmount) }} + {{ formatCurrency(row.customerPriceAmount) }} {{ formatCurrency(row.totalAmount) }} {{ row.sharePercent }}% {{ formatCurrency(row.estimatedBookedAllocation) }} @@ -696,6 +737,7 @@ onMounted(async () => { +
@@ -730,7 +772,8 @@ onMounted(async () => { {{ t('superuser_invoice_distribution.metrics.fixed_pricing') }} - {{ t('superuser_invoice_distribution.metrics.subscriptions') }} + {{ t('superuser_invoice_distribution.metrics.subscriptions') }} + {{ t('superuser_invoice_distribution.metrics.customer_prices') }} {{ formatCurrency(row.amount) }} @@ -823,6 +866,11 @@ onMounted(async () => { {{ t('superuser_invoice_distribution.errors.compare_failed') }}: {{ compareError }} + +

{{ t('superuser_invoice_distribution.warnings.legacy_compare_fallback') }}

+

{{ compareFallback.compareFallbackReason }}

+
+
@@ -892,6 +940,10 @@ onMounted(async () => { {{ t('superuser_invoice_distribution.actions.close') }}
    +
  • + {{ t('superuser_invoice_distribution.compare.status_label') }}: {{ selectedWarningRow.compareStatus }} +
  • +
  • {{ reason }}
  • {{ warning }}
@@ -901,12 +953,240 @@ onMounted(async () => { diff --git a/src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionOverview.vue b/src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionOverview.vue index 86e57eab..e273e05e 100644 --- a/src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionOverview.vue +++ b/src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionOverview.vue @@ -52,6 +52,7 @@ const totals = computed(() => { acc.distributionAmount += toNumber(summary.distributionAmount); acc.fixedPricingAmount += toNumber(summary.fixedPricingAmount); acc.subscriptionAmount += toNumber(summary.subscriptionAmount); + acc.customerPriceAmount += toNumber(summary.customerPriceAmount); acc.totalAmount += toNumber(summary.totalAmount); return acc; }, { @@ -59,11 +60,19 @@ const totals = computed(() => { distributionAmount: 0, fixedPricingAmount: 0, subscriptionAmount: 0, + customerPriceAmount: 0, totalAmount: 0, }); }); const totalComposition = computed(() => buildSourceComposition(totals.value)); +const monthsUsingLegacyDistribution = computed(() => { + return sortedMonthSummaries.value.filter((summary) => summary?.fallback?.usedLegacyDistribution); +}); +const latestDistributionFallbackReason = computed(() => { + const last = monthsUsingLegacyDistribution.value[0]; + return last?.fallback?.distributionFallbackReason || ''; +}); const topMonths = computed(() => { return sortMonthSummaries(sortedMonthSummaries.value, 'total', 'desc').slice(0, 3); @@ -137,10 +146,11 @@ onMounted(() => { diff --git a/src/views/dashboards/superUserDashboard/invoiceDistribution/components/InvoiceDistributionTrendChart.vue b/src/views/dashboards/superUserDashboard/invoiceDistribution/components/InvoiceDistributionTrendChart.vue index 08d9138b..876ff088 100644 --- a/src/views/dashboards/superUserDashboard/invoiceDistribution/components/InvoiceDistributionTrendChart.vue +++ b/src/views/dashboards/superUserDashboard/invoiceDistribution/components/InvoiceDistributionTrendChart.vue @@ -29,6 +29,7 @@ const props = defineProps({ total: 'Total', booked: 'Booked', distribution: 'Distribution', + customerPrices: 'Customer prices', }), }, locale: { @@ -77,6 +78,13 @@ const chartData = computed(() => { data: sortedMonths.value.map((month) => toNumber(month.distributionAmount)), tension: 0.25, }, + { + label: props.labels.customerPrices, + borderColor: '#c44536', + backgroundColor: 'rgba(196,69,54,0.18)', + data: sortedMonths.value.map((month) => toNumber(month.customerPriceAmount)), + tension: 0.25, + }, ], }; }); diff --git a/src/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionApi.js b/src/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionApi.js index b3fa3406..7bb2639a 100644 --- a/src/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionApi.js +++ b/src/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionApi.js @@ -18,6 +18,114 @@ const getPagination = (response) => { return response?.data?.meta?.pagination || response?.meta?.pagination || null; }; +const toErrorMessage = (error) => { + return SessionUser.functions.parseErrorMessage(error) || String(error?.message || error || 'Unknown error'); +}; + +const normalizeCategoryResponse = (category = {}, legacyCollectiveKey = null) => { + const customers = safeArray(category?.customers || category?.data); + const collectiveResults = ( + category?.collective_results + || category?.includes?.[legacyCollectiveKey] + || category?.includes?.collective_results + || {} + ); + const warnings = safeArray(category?.warnings); + + return { + customers, + collective_results: collectiveResults, + warnings, + // Keep legacy-compatible aliases for existing calculations and UI call sites. + data: customers, + includes: { + ...(category?.includes || {}), + collective_results: collectiveResults, + ...(legacyCollectiveKey ? { [legacyCollectiveKey]: collectiveResults } : {}), + }, + meta: category?.meta || {}, + }; +}; + +const normalizeV2DistributionPayload = (payload = {}) => { + const rawFixed = payload?.fixed_pricing; + const rawSubscriptions = payload?.wash_subscriptions; + const rawCustomerPrices = payload?.customer_prices; + + if (!rawFixed || !rawSubscriptions || !rawCustomerPrices) { + throw new Error('V2 distribution payload is missing one or more required categories'); + } + + return { + fixedDistribution: normalizeCategoryResponse(rawFixed, 'collective_fixed_pricing_results'), + subscriptionDistribution: normalizeCategoryResponse(rawSubscriptions, 'collective_subscription_results'), + customerPriceDistribution: normalizeCategoryResponse(rawCustomerPrices, 'collective_customer_price_results'), + }; +}; + +const fetchV2DistributionForMonth = async (year, month) => { + const { dateFrom, dateTo } = getMonthDateRange(year, month); + const response = await SessionUser.request('/superuser/invoicing/period/distribution/v2/all', 'GET', { + dateFrom, + dateTo, + }); + return normalizeV2DistributionPayload(getResponseData(response)); +}; + +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 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, + }); + return normalizeCategoryResponse(response?.data || response, 'collective_subscription_results'); +}; + +const fetchLegacyDistributionForMonth = async (year, month) => { + const [fixedDistribution, subscriptionDistribution] = await Promise.all([ + fetchLegacyFixedPricingDistributionForMonth(year, month), + fetchLegacySubscriptionDistributionForMonth(year, month), + ]); + + return { + fixedDistribution, + subscriptionDistribution, + customerPriceDistribution: normalizeCategoryResponse({}, 'collective_customer_price_results'), + }; +}; + +const fetchDistributionForMonth = async (year, month) => { + try { + const data = await fetchV2DistributionForMonth(year, month); + return { + ...data, + fallback: { + usedLegacyDistribution: false, + distributionFallbackReason: null, + }, + }; + } catch (error) { + console.warn('Falling back to legacy distribution endpoints', error); + const legacyData = await fetchLegacyDistributionForMonth(year, month); + return { + ...legacyData, + fallback: { + usedLegacyDistribution: true, + distributionFallbackReason: toErrorMessage(error), + }, + }; + } +}; + export const fetchFirstOrderDate = async () => { try { const response = await SessionUser.request('/orders', 'GET', { @@ -50,44 +158,28 @@ export const fetchPeriodForMonth = async (year, month) => { return getResponseData(response); }; -export const fetchFixedPricingDistributionForMonth = async (year, month) => { - const { dateFrom, dateTo } = getMonthDateRange(year, month); - const response = await SessionUser.request('/superuser/invoicing/period/distribution/fixed-pricing', 'GET', { - dateFrom, - dateTo, - }); - return response?.data || response; -}; - -export const fetchSubscriptionDistributionForMonth = async (year, month) => { - const { dateFrom, dateTo } = getMonthDateRange(year, month); - const response = await SessionUser.request('/superuser/invoicing/period/distribution/wash-subscriptions', 'GET', { - dateFrom, - dateTo, - }); - return response?.data || response; -}; - export const fetchMonthData = async (year, month) => { - const [periodData, fixedDistribution, subscriptionDistribution] = await Promise.all([ + const [periodData, distributionData] = await Promise.all([ fetchPeriodForMonth(year, month), - fetchFixedPricingDistributionForMonth(year, month), - fetchSubscriptionDistributionForMonth(year, month), + fetchDistributionForMonth(year, month), ]); const summary = buildMonthSummary({ year, month, periodData, - fixedDistribution, - subscriptionDistribution, + fixedDistribution: distributionData.fixedDistribution, + subscriptionDistribution: distributionData.subscriptionDistribution, + customerPriceDistribution: distributionData.customerPriceDistribution, }); return { summary, periodData, - fixedDistribution, - subscriptionDistribution, + fixedDistribution: distributionData.fixedDistribution, + subscriptionDistribution: distributionData.subscriptionDistribution, + customerPriceDistribution: distributionData.customerPriceDistribution, + fallback: distributionData.fallback, }; }; @@ -101,9 +193,11 @@ export const fetchDistributionOverviewMonths = async ({ for (let index = 0; index < months.length; index += 1) { const month = months[index]; - // Keep requests controlled to avoid hammering APIs. const monthData = await fetchMonthData(month.year, month.month); - results.push(monthData.summary); + results.push({ + ...monthData.summary, + fallback: monthData.fallback, + }); if (typeof onProgress === 'function') { onProgress({ @@ -181,36 +275,25 @@ export const fetchCollectedInvoicesForMonth = async (year, month) => { return invoices; }; -export const compareCollectedInvoicesForMonth = async ({ - year, - month, - mode = 'invoice_total', - onProgress = null, - batchSize = 5, +const compareCollectedInvoicesLegacy = async ({ + invoices, + mode, + onProgress, + batchSize, }) => { - const invoices = await fetchCollectedInvoicesForMonth(year, month); - if (!invoices.length) { - return { - rows: [], - summary: aggregateCompareRows([]), - invoiceCount: 0, - }; - } - const rows = []; let processed = 0; - const chunks = chunkArray(invoices, batchSize); + 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 normalized = normalizeCompareResult(invoice, getResponseData(response), mode); - return normalized; + return normalizeCompareResult(invoice, getResponseData(response), mode); } catch (error) { const fallbackPayload = { collected_invoice_id: invoice?.id, - warnings: [SessionUser.functions.parseErrorMessage(error) || 'Unknown compare error'], + warnings: [toErrorMessage(error)], internal_total: invoice?.total_net_amount || 0, booked_total: null, draft_total: null, @@ -234,9 +317,119 @@ export const compareCollectedInvoicesForMonth = async ({ } } - return { - rows, - summary: aggregateCompareRows(rows), - invoiceCount: invoices.length, - }; + return rows; +}; + +const compareCollectedInvoicesV2Bulk = async ({ + invoices, + mode, + onProgress, + batchSize, +}) => { + const rows = []; + let processed = 0; + 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 payload = getResponseData(response) || {}; + const resultRows = safeArray(payload?.results); + const errors = safeArray(payload?.errors); + const resultMap = new Map(resultRows.map((result) => [Number(result?.collected_invoice_id), result])); + const errorMap = new Map(errors.map((entry) => [Number(entry?.collected_invoice_id), entry])); + + for (const invoice of chunk) { + const invoiceId = Number(invoice?.id); + const comparePayload = resultMap.get(invoiceId); + if (comparePayload) { + rows.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); + } + + processed += chunk.length; + if (typeof onProgress === 'function') { + onProgress({ + total: invoices.length, + processed, + }); + } + } + + return rows; +}; + +export const compareCollectedInvoicesForMonth = async ({ + year, + month, + mode = 'invoice_total', + onProgress = null, + batchSize = 100, + legacyBatchSize = 5, +}) => { + const invoices = await fetchCollectedInvoicesForMonth(year, month); + if (!invoices.length) { + return { + rows: [], + summary: aggregateCompareRows([]), + invoiceCount: 0, + fallback: { + usedLegacyCompare: false, + compareFallbackReason: null, + }, + }; + } + + try { + const rows = await compareCollectedInvoicesV2Bulk({ + invoices, + mode, + onProgress, + batchSize, + }); + + return { + rows, + summary: aggregateCompareRows(rows), + invoiceCount: invoices.length, + fallback: { + usedLegacyCompare: false, + compareFallbackReason: null, + }, + }; + } catch (error) { + console.warn('Falling back to legacy compare endpoint', error); + const rows = await compareCollectedInvoicesLegacy({ + invoices, + mode, + onProgress, + batchSize: legacyBatchSize, + }); + + return { + rows, + summary: aggregateCompareRows(rows), + invoiceCount: invoices.length, + fallback: { + usedLegacyCompare: true, + compareFallbackReason: toErrorMessage(error), + }, + }; + } }; diff --git a/src/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionCalculations.js b/src/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionCalculations.js index 75a23abe..e47145cb 100644 --- a/src/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionCalculations.js +++ b/src/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionCalculations.js @@ -9,7 +9,7 @@ export const DEFAULT_DISTRIBUTION_QUERY_STATE = { departmentSearch: '', }; -const CUSTOMER_SOURCES = ['all', 'fixed_pricing', 'vehicle_subscriptions']; +const CUSTOMER_SOURCES = ['all', 'fixed_pricing', 'vehicle_subscriptions', 'customer_prices']; const getQueryValue = (value) => { if (Array.isArray(value)) { @@ -22,6 +22,39 @@ const normalizeQueryText = (value) => { return String(value || '').trim(); }; +const asArray = (value) => (Array.isArray(value) ? value : []); + +const readFirstFiniteNumber = (...values) => { + for (const value of values) { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return null; +}; + +const readFirstNumberFromObject = (source = {}, keys = []) => { + if (!source || typeof source !== 'object' || Array.isArray(source)) { + return null; + } + for (const key of keys) { + const parsed = Number(source?.[key]); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return null; +}; + +const uniqueStrings = (items = []) => { + return Array.from(new Set( + asArray(items) + .map((item) => String(item || '').trim()) + .filter((item) => item.length > 0) + )); +}; + export const toNumber = (value) => { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; @@ -193,23 +226,52 @@ export const sumTransactions = (transactions = [], options = {}) => { }; export const sumBookedTransactions = (customers = []) => { - return (Array.isArray(customers) ? customers : []).reduce((sum, customer) => { + return asArray(customers).reduce((sum, customer) => { return sum + sumTransactions(customer?.transactions, { bookedOnly: true, includeExcluded: false }); }, 0); }; export const sumAllTransactions = (customers = []) => { - return (Array.isArray(customers) ? customers : []).reduce((sum, customer) => { + return asArray(customers).reduce((sum, customer) => { return sum + sumTransactions(customer?.transactions, { bookedOnly: false, includeExcluded: false }); }, 0); }; +export const getCategoryCustomers = (categoryResponse = {}) => { + return asArray(categoryResponse?.customers || categoryResponse?.data); +}; + +const getCategoryCollectiveResults = (categoryResponse = {}, legacyKey = null) => { + if ( + categoryResponse?.collective_results + && typeof categoryResponse.collective_results === 'object' + && !Array.isArray(categoryResponse.collective_results) + ) { + return categoryResponse.collective_results; + } + + if ( + legacyKey + && categoryResponse?.includes?.[legacyKey] + && typeof categoryResponse.includes[legacyKey] === 'object' + && !Array.isArray(categoryResponse.includes[legacyKey]) + ) { + return categoryResponse.includes[legacyKey]; + } + + return {}; +}; + export const getCollectiveFixedResults = (fixedDistributionResponse = {}) => { - return fixedDistributionResponse?.includes?.collective_fixed_pricing_results || {}; + return getCategoryCollectiveResults(fixedDistributionResponse, 'collective_fixed_pricing_results'); }; export const getCollectiveSubscriptionResults = (subscriptionDistributionResponse = {}) => { - return subscriptionDistributionResponse?.includes?.collective_subscription_results || {}; + return getCategoryCollectiveResults(subscriptionDistributionResponse, 'collective_subscription_results'); +}; + +export const getCollectiveCustomerPriceResults = (customerPriceDistributionResponse = {}) => { + return getCategoryCollectiveResults(customerPriceDistributionResponse, 'collective_customer_price_results'); }; export const getBookedAmountFromPeriod = (periodData = {}) => { @@ -222,21 +284,101 @@ export const getTotalAmountFromPeriod = (periodData = {}) => { return safeRoundCurrency(sumAllTransactions(customers)); }; +const inferCategoryAmount = ({ + collective = {}, + customers = [], + collectiveAmountKeys = [], + metaAmountKeys = [], +}) => { + const collectiveAmount = readFirstNumberFromObject(collective, collectiveAmountKeys); + if (Number.isFinite(collectiveAmount)) { + return safeRoundCurrency(collectiveAmount); + } + + const metaTotal = asArray(customers).reduce((sum, customer) => { + const metaCandidates = asArray([ + customer?.meta, + customer?.meta?.fixed_pricing, + customer?.meta?.wash_subscription, + customer?.meta?.wash_subscriptions, + customer?.meta?.subscription, + customer?.meta?.vehicle_subscription, + customer?.meta?.vehicle_subscriptions, + customer?.meta?.customer_price, + customer?.meta?.customer_prices, + customer?.meta?.discount_override, + customer?.meta?.discount, + customer?.subscription, + customer?.customer_price, + customer?.discount_override, + ]).filter(Boolean); + + const fromMeta = readFirstFiniteNumber( + ...metaCandidates.map((candidate) => readFirstNumberFromObject(candidate, metaAmountKeys)) + ); + if (Number.isFinite(fromMeta)) { + return sum + fromMeta; + } + + return sum + sumTransactions(customer?.transactions, { bookedOnly: false, includeExcluded: false }); + }, 0); + + return safeRoundCurrency(metaTotal); +}; + export const buildMonthSummary = ({ year, month, periodData = {}, fixedDistribution = {}, subscriptionDistribution = {}, + customerPriceDistribution = {}, }) => { const fixedCollective = getCollectiveFixedResults(fixedDistribution); const subscriptionCollective = getCollectiveSubscriptionResults(subscriptionDistribution); + const customerPriceCollective = getCollectiveCustomerPriceResults(customerPriceDistribution); + + const fixedCustomers = getCategoryCustomers(fixedDistribution); + const subscriptionCustomers = getCategoryCustomers(subscriptionDistribution); + const customerPriceCustomers = getCategoryCustomers(customerPriceDistribution); const bookedAmount = getBookedAmountFromPeriod(periodData); const allTransactionsAmount = getTotalAmountFromPeriod(periodData); - const fixedPricingAmount = safeRoundCurrency(fixedCollective?.total_fixed_price); - const subscriptionAmount = safeRoundCurrency(subscriptionCollective?.total_subscription_price); - const distributionAmount = safeRoundCurrency(fixedPricingAmount + subscriptionAmount); + const fixedPricingAmount = inferCategoryAmount({ + collective: fixedCollective, + customers: fixedCustomers, + collectiveAmountKeys: ['total_fixed_price', 'total_price', 'total_amount', 'net_total'], + metaAmountKeys: ['price', 'fixed_price', 'total_price', 'amount', 'total_amount'], + }); + const subscriptionAmount = inferCategoryAmount({ + collective: subscriptionCollective, + customers: subscriptionCustomers, + collectiveAmountKeys: ['total_subscription_price', 'total_price', 'total_amount', 'net_total'], + metaAmountKeys: ['price', 'subscription_price', 'total_price', 'amount', 'total_amount'], + }); + const customerPriceAmount = inferCategoryAmount({ + collective: customerPriceCollective, + customers: customerPriceCustomers, + collectiveAmountKeys: [ + 'total_customer_price', + 'total_customer_prices', + 'total_discount_price', + 'total_discount_amount', + 'total_price', + 'total_amount', + 'net_total', + ], + metaAmountKeys: [ + 'price', + 'customer_price', + 'discount_price', + 'discount_amount', + 'total_price', + 'amount', + 'total_amount', + ], + }); + const distributionAmount = safeRoundCurrency(fixedPricingAmount + subscriptionAmount + customerPriceAmount); const otherBookedAmount = safeRoundCurrency(Math.max(bookedAmount - distributionAmount, 0)); return { @@ -247,10 +389,12 @@ export const buildMonthSummary = ({ allTransactionsAmount, fixedPricingAmount, subscriptionAmount, + customerPriceAmount, distributionAmount, otherBookedAmount, - fixedCustomers: Array.isArray(fixedDistribution?.data) ? fixedDistribution.data.length : 0, - subscriptionCustomers: Array.isArray(subscriptionDistribution?.data) ? subscriptionDistribution.data.length : 0, + fixedCustomers: fixedCustomers.length, + subscriptionCustomers: subscriptionCustomers.length, + customerPriceCustomers: customerPriceCustomers.length, totalAmount: safeRoundCurrency(bookedAmount + distributionAmount), }; }; @@ -258,20 +402,28 @@ export const buildMonthSummary = ({ export const buildSourceComposition = (summary = {}) => { const fixedAmount = safeRoundCurrency(summary?.fixedPricingAmount); const subscriptionAmount = safeRoundCurrency(summary?.subscriptionAmount); - const distributionAmount = safeRoundCurrency(summary?.distributionAmount || fixedAmount + subscriptionAmount); + const customerPriceAmount = safeRoundCurrency(summary?.customerPriceAmount); + const distributionAmount = safeRoundCurrency( + summary?.distributionAmount || fixedAmount + subscriptionAmount + customerPriceAmount + ); const fixedPercent = distributionAmount > 0 ? safeRoundCurrency((fixedAmount / distributionAmount) * 100) : 0; const subscriptionPercent = distributionAmount > 0 ? safeRoundCurrency((subscriptionAmount / distributionAmount) * 100) : 0; + const customerPricePercent = distributionAmount > 0 + ? safeRoundCurrency((customerPriceAmount / distributionAmount) * 100) + : 0; return { fixedAmount, subscriptionAmount, + customerPriceAmount, distributionAmount, fixedPercent, subscriptionPercent, + customerPricePercent, }; }; @@ -296,12 +448,58 @@ export const buildDepartmentAllocations = ({ departments = [], fixedDistribution = {}, subscriptionDistribution = {}, + customerPriceDistribution = {}, bookedAmount = 0, }) => { const fixedCollective = getCollectiveFixedResults(fixedDistribution); const subscriptionCollective = getCollectiveSubscriptionResults(subscriptionDistribution); - const fixedByName = normalizeObjectNumberMap(fixedCollective?.total_department_totals_relative_parsed); - const subscriptionByName = normalizeObjectNumberMap(subscriptionCollective?.subscription_price_department_distribution_parsed); + const customerPriceCollective = getCollectiveCustomerPriceResults(customerPriceDistribution); + + const getCollectiveDepartmentMap = (collective = {}, keys = []) => { + for (const key of keys) { + const candidate = normalizeObjectNumberMap(collective?.[key]); + if (Object.keys(candidate).length > 0) { + return candidate; + } + } + return {}; + }; + + const fixedByName = normalizeDepartmentAllocationToNames( + getCollectiveDepartmentMap(fixedCollective, [ + 'total_department_totals_relative_parsed', + 'total_department_totals_parsed', + 'department_distribution_parsed', + 'department_distribution', + 'department_totals_relative', + 'department_totals', + ]), + departments + ); + const subscriptionByName = normalizeDepartmentAllocationToNames( + getCollectiveDepartmentMap(subscriptionCollective, [ + 'subscription_price_department_distribution_parsed', + 'subscription_price_department_distribution', + 'total_department_totals_relative_parsed', + 'department_distribution_parsed', + 'department_distribution', + 'department_totals_relative', + 'department_totals', + ]), + departments + ); + const customerPriceByName = normalizeDepartmentAllocationToNames( + getCollectiveDepartmentMap(customerPriceCollective, [ + 'customer_price_department_distribution_parsed', + 'customer_prices_department_distribution_parsed', + 'discount_department_distribution_parsed', + 'department_distribution_parsed', + 'department_distribution', + 'department_totals_relative', + 'department_totals', + ]), + departments + ); const byId = toDepartmentNameById(departments); const byName = (Array.isArray(departments) ? departments : []).reduce((acc, department) => { @@ -312,6 +510,7 @@ export const buildDepartmentAllocations = ({ const allNames = new Set([ ...Object.keys(fixedByName), ...Object.keys(subscriptionByName), + ...Object.keys(customerPriceByName), ...Object.keys(byName), ]); @@ -319,12 +518,14 @@ export const buildDepartmentAllocations = ({ const totalSharedAmount = safeRoundCurrency( Object.values(fixedByName).reduce((sum, value) => sum + value, 0) + Object.values(subscriptionByName).reduce((sum, value) => sum + value, 0) + + Object.values(customerPriceByName).reduce((sum, value) => sum + value, 0) ); allNames.forEach((name) => { const fixedAmount = safeRoundCurrency(fixedByName[name]); const subscriptionAmount = safeRoundCurrency(subscriptionByName[name]); - const totalAmount = safeRoundCurrency(fixedAmount + subscriptionAmount); + const customerPriceAmount = safeRoundCurrency(customerPriceByName[name]); + const totalAmount = safeRoundCurrency(fixedAmount + subscriptionAmount + customerPriceAmount); const ratio = totalSharedAmount > 0 ? (totalAmount / totalSharedAmount) : 0; rows.push({ @@ -332,6 +533,7 @@ export const buildDepartmentAllocations = ({ departmentName: name, fixedAmount, subscriptionAmount, + customerPriceAmount, totalAmount, sharePercent: safeRoundCurrency(ratio * 100), estimatedBookedAllocation: safeRoundCurrency(toNumber(bookedAmount) * ratio), @@ -369,6 +571,58 @@ const getSubscriptionCustomerMeta = (customer = {}) => { return candidates.find((candidate) => candidate && typeof candidate === 'object') || null; }; +const getFixedCustomerMeta = (customer = {}) => { + return customer?.meta?.fixed_pricing || customer?.meta?.fixedPricing || customer?.fixed_pricing || null; +}; + +const getCustomerPriceMeta = (customer = {}) => { + const candidates = [ + customer?.meta?.customer_price, + customer?.meta?.customer_prices, + customer?.meta?.discount_override, + customer?.meta?.discount, + customer?.customer_price, + customer?.discount_override, + ]; + return candidates.find((candidate) => candidate && typeof candidate === 'object') || null; +}; + +const getCustomerDepartmentTotals = (customer = {}, meta = {}) => { + return normalizeDepartmentAllocationMap( + meta?.department_totals_relative + || meta?.department_totals + || meta?.subscription_price_department_distribution + || meta?.customer_price_department_distribution + || meta?.discount_department_distribution + || meta?.department_distribution + || customer?.department_totals_relative + || customer?.department_totals + || customer?.department_distribution + || {} + ); +}; + +const getCustomerCreatedAt = (customer = {}, meta = {}) => { + return meta?.created_at || customer?.created_at || asArray(customer?.transactions)?.[0]?.date || null; +}; + +const getCustomerAmount = ({ + customer = {}, + meta = {}, + amountKeys = [], +}) => { + const amount = readFirstFiniteNumber( + readFirstNumberFromObject(meta, amountKeys), + readFirstNumberFromObject(customer, amountKeys) + ); + + if (Number.isFinite(amount)) { + return safeRoundCurrency(amount); + } + + return safeRoundCurrency(sumTransactions(customer?.transactions, { bookedOnly: false, includeExcluded: false })); +}; + export const toIsoDateOnly = (dateLike) => { if (!dateLike) { return null; @@ -409,71 +663,109 @@ export const formatDepartmentAllocationsText = (departmentAllocations = {}) => { export const buildCustomerAllocationRows = ({ fixedDistribution = {}, subscriptionDistribution = {}, + customerPriceDistribution = {}, year, month, departments = [], }) => { const rows = []; - const fixedCustomers = Array.isArray(fixedDistribution?.data) ? fixedDistribution.data : []; + const fixedCustomers = getCategoryCustomers(fixedDistribution); fixedCustomers.forEach((customer) => { - const fixedPricing = customer?.meta?.fixed_pricing || {}; - const isWithinMonth = isCreatedOnOrBeforeMonthEnd(fixedPricing?.created_at, year, month); + const fixedPricing = getFixedCustomerMeta(customer) || {}; + const createdAt = getCustomerCreatedAt(customer, fixedPricing); + const isWithinMonth = isCreatedOnOrBeforeMonthEnd(createdAt, year, month); if (!isWithinMonth) { return; } - const departmentTotals = normalizeDepartmentAllocationMap( - fixedPricing?.department_totals_relative || fixedPricing?.department_totals || {} - ); + const departmentTotals = getCustomerDepartmentTotals(customer, fixedPricing); rows.push({ source: 'fixed_pricing', customerId: customer?.id || null, customerNumber: customer?.customer_number || null, - customerName: customer?.customer_name || '-', - amount: safeRoundCurrency(fixedPricing?.price), - originalAmount: safeRoundCurrency(fixedPricing?.original_price), - createdAt: toIsoDateOnly(fixedPricing?.created_at), + customerName: customer?.customer_name || customer?.name || '-', + amount: getCustomerAmount({ + customer, + meta: fixedPricing, + amountKeys: ['price', 'fixed_price', 'total_price', 'amount', 'total_amount'], + }), + originalAmount: safeRoundCurrency(readFirstFiniteNumber( + fixedPricing?.original_price, + customer?.original_price + )), + createdAt: toIsoDateOnly(createdAt), departmentAllocations: normalizeDepartmentAllocationToNames(departmentTotals, departments), requiresAction: Boolean(customer?.requires_action), }); }); - const subscriptionCustomers = Array.isArray(subscriptionDistribution?.data) ? subscriptionDistribution.data : []; + const subscriptionCustomers = getCategoryCustomers(subscriptionDistribution); subscriptionCustomers.forEach((customer) => { - const subscriptionMeta = getSubscriptionCustomerMeta(customer); - if (!subscriptionMeta) { - return; - } - - const isWithinMonth = isCreatedOnOrBeforeMonthEnd(subscriptionMeta?.created_at, year, month); + const subscriptionMeta = getSubscriptionCustomerMeta(customer) || {}; + const createdAt = getCustomerCreatedAt(customer, subscriptionMeta); + const isWithinMonth = isCreatedOnOrBeforeMonthEnd(createdAt, year, month); if (!isWithinMonth) { return; } - const departmentTotals = normalizeDepartmentAllocationMap( - subscriptionMeta?.department_totals_relative - || subscriptionMeta?.department_totals - || subscriptionMeta?.subscription_price_department_distribution - || {} - ); - - const amount = safeRoundCurrency( - subscriptionMeta?.price - || subscriptionMeta?.total_price - || subscriptionMeta?.subscription_price - || customer?.total_subscription_price - ); + const departmentTotals = getCustomerDepartmentTotals(customer, subscriptionMeta); rows.push({ source: 'vehicle_subscriptions', customerId: customer?.id || null, customerNumber: customer?.customer_number || null, - customerName: customer?.customer_name || '-', - amount, - originalAmount: safeRoundCurrency(subscriptionMeta?.original_price), - createdAt: toIsoDateOnly(subscriptionMeta?.created_at), + customerName: customer?.customer_name || customer?.name || '-', + amount: getCustomerAmount({ + customer, + meta: subscriptionMeta, + amountKeys: ['price', 'total_price', 'subscription_price', 'amount', 'total_amount'], + }), + originalAmount: safeRoundCurrency(readFirstFiniteNumber( + subscriptionMeta?.original_price, + customer?.original_price + )), + createdAt: toIsoDateOnly(createdAt), + departmentAllocations: normalizeDepartmentAllocationToNames(departmentTotals, departments), + requiresAction: Boolean(customer?.requires_action), + }); + }); + + const customerPriceCustomers = getCategoryCustomers(customerPriceDistribution); + customerPriceCustomers.forEach((customer) => { + const customerPriceMeta = getCustomerPriceMeta(customer) || {}; + const createdAt = getCustomerCreatedAt(customer, customerPriceMeta); + const isWithinMonth = isCreatedOnOrBeforeMonthEnd(createdAt, year, month); + if (!isWithinMonth) { + return; + } + + const departmentTotals = getCustomerDepartmentTotals(customer, customerPriceMeta); + + rows.push({ + source: 'customer_prices', + customerId: customer?.id || null, + customerNumber: customer?.customer_number || null, + customerName: customer?.customer_name || customer?.name || '-', + amount: getCustomerAmount({ + customer, + meta: customerPriceMeta, + amountKeys: [ + 'price', + 'customer_price', + 'discount_price', + 'discount_amount', + 'total_price', + 'amount', + 'total_amount', + ], + }), + originalAmount: safeRoundCurrency(readFirstFiniteNumber( + customerPriceMeta?.original_price, + customer?.original_price + )), + createdAt: toIsoDateOnly(createdAt), departmentAllocations: normalizeDepartmentAllocationToNames(departmentTotals, departments), requiresAction: Boolean(customer?.requires_action), }); @@ -516,6 +808,7 @@ export const buildMonthComparison = (baseSummary = {}, compareSummary = {}) => { 'bookedAmount', 'fixedPricingAmount', 'subscriptionAmount', + 'customerPriceAmount', 'distributionAmount', 'otherBookedAmount', 'totalAmount', @@ -539,23 +832,63 @@ export const buildMonthComparison = (baseSummary = {}, compareSummary = {}) => { export const normalizeCompareResult = (invoice = {}, compareResponse = null, mode = 'invoice_total') => { const payload = compareResponse?.data || compareResponse || {}; - const warnings = Array.isArray(payload?.warnings) ? payload.warnings : []; - const orderIds = Array.isArray(payload?.order_ids) ? payload.order_ids : []; + const draftTarget = payload?.comparison?.targets?.draft || null; + const bookedTarget = payload?.comparison?.targets?.booked || null; - const internalTotal = safeRoundCurrency(payload?.internal_total ?? invoice?.total_net_amount); - const bookedTotal = payload?.booked_total === null || payload?.booked_total === undefined - ? null - : safeRoundCurrency(payload?.booked_total); - const draftTotal = payload?.draft_total === null || payload?.draft_total === undefined - ? null - : safeRoundCurrency(payload?.draft_total); + const internalTotal = safeRoundCurrency(readFirstFiniteNumber( + payload?.comparison?.totals?.internal_net_total, + payload?.details?.internal?.normalized?.totals?.net_total, + payload?.internal_total, + invoice?.total_net_amount + )); + + const draftTotal = ( + readFirstFiniteNumber( + draftTarget?.totals?.target_net_total, + draftTarget?.target_net_total, + draftTarget?.net_total + ) + ); + const bookedTotal = ( + readFirstFiniteNumber( + bookedTarget?.totals?.target_net_total, + bookedTarget?.target_net_total, + bookedTarget?.net_total + ) + ); + const normalizedDraftTotal = Number.isFinite(draftTotal) + ? safeRoundCurrency(draftTotal) + : ( + payload?.draft_total === null || payload?.draft_total === undefined + ? null + : safeRoundCurrency(payload?.draft_total) + ); + const normalizedBookedTotal = Number.isFinite(bookedTotal) + ? safeRoundCurrency(bookedTotal) + : ( + payload?.booked_total === null || payload?.booked_total === undefined + ? null + : safeRoundCurrency(payload?.booked_total) + ); + + const selectedTargetName = bookedTarget ? 'booked' : (draftTarget ? 'draft' : null); + const selectedTarget = selectedTargetName === 'booked' ? bookedTarget : draftTarget; + const selectedTargetTotal = selectedTargetName === 'booked' ? normalizedBookedTotal : normalizedDraftTotal; let difference = payload?.difference; if (difference === null || difference === undefined) { - if (bookedTotal !== null) { - difference = safeRoundCurrency(internalTotal - bookedTotal); - } else if (draftTotal !== null) { - difference = safeRoundCurrency(internalTotal - draftTotal); + const explicitTargetDifference = readFirstFiniteNumber( + selectedTarget?.totals?.difference, + selectedTarget?.difference + ); + if (Number.isFinite(explicitTargetDifference)) { + difference = safeRoundCurrency(explicitTargetDifference); + } else if (selectedTargetTotal !== null && selectedTargetTotal !== undefined) { + difference = safeRoundCurrency(internalTotal - selectedTargetTotal); + } else if (normalizedBookedTotal !== null) { + difference = safeRoundCurrency(internalTotal - normalizedBookedTotal); + } else if (normalizedDraftTotal !== null) { + difference = safeRoundCurrency(internalTotal - normalizedDraftTotal); } else { difference = null; } @@ -563,28 +896,68 @@ export const normalizeCompareResult = (invoice = {}, compareResponse = null, mod difference = safeRoundCurrency(difference); } - const hasMismatch = difference !== null ? Math.abs(difference) > 0.009 : warnings.length > 0; + const mismatchReasons = uniqueStrings([ + ...(selectedTarget?.mismatch_reasons || []), + ...(selectedTarget?.reasons || []), + ]); + const warnings = uniqueStrings([ + ...(payload?.warnings || []), + ...(payload?.comparison?.warnings || []), + ...(selectedTarget?.warnings || []), + ...mismatchReasons, + ]); + const orderIds = asArray(payload?.details?.order_ids || payload?.order_ids); + const compareStatus = selectedTarget?.status || null; + + const isTargetMismatch = selectedTarget + ? ( + selectedTarget?.overall_match === false + || ['partial_mismatch', 'total_mismatch', 'missing_target'].includes(compareStatus) + ) + : false; + const hasMismatch = isTargetMismatch || (difference !== null ? Math.abs(difference) > 0.009 : warnings.length > 0); const row = { invoiceId: invoice?.id ?? payload?.collected_invoice_id ?? null, - customerNumber: invoice?.customer_number ?? null, - customerName: invoice?.customer_name || invoice?.name || '-', + customerNumber: invoice?.customer_number ?? payload?.details?.customer?.internal_customer_number ?? null, + customerName: invoice?.customer_name || invoice?.name || payload?.details?.customer?.name || '-', internalTotal, - bookedTotal, - draftTotal, + bookedTotal: normalizedBookedTotal, + draftTotal: normalizedDraftTotal, difference, warnings, warningCount: warnings.length, orderCount: orderIds.length, orderIds, + mismatchReasons, + compareTarget: selectedTargetName, + compareStatus, mode, status: hasMismatch ? 'mismatch' : 'ok', checkedAt: new Date().toISOString(), }; if (mode === 'line_by_line') { - row.localLineCount = orderIds.length; - row.economicLineCount = null; - row.lineMismatchCount = warnings.filter((warning) => String(warning).toLowerCase().includes('line')).length; + const localLineCount = readFirstFiniteNumber( + payload?.details?.internal?.normalized?.totals?.billable_line_count, + payload?.details?.internal?.normalized?.totals?.line_count, + orderIds.length + ); + const economicLineCount = readFirstFiniteNumber( + selectedTarget?.lines?.summary?.target_billable_count, + selectedTarget?.lines?.summary?.line_count + ); + const selectedTargetDiffCount = selectedTarget + ? asArray(selectedTarget?.lines?.diff).length + : undefined; + const lineMismatchCount = readFirstFiniteNumber( + selectedTarget?.lines?.summary?.mismatch_count, + selectedTargetDiffCount, + warnings.filter((warning) => String(warning).toLowerCase().includes('line')).length + ); + + row.localLineCount = Number.isFinite(localLineCount) ? localLineCount : 0; + row.economicLineCount = Number.isFinite(economicLineCount) ? economicLineCount : null; + row.lineMismatchCount = Number.isFinite(lineMismatchCount) ? lineMismatchCount : 0; } return row; diff --git a/tests/e2e/invoice-distribution.smoke.spec.js b/tests/e2e/invoice-distribution.smoke.spec.js index e123c759..8f9e41e7 100644 --- a/tests/e2e/invoice-distribution.smoke.spec.js +++ b/tests/e2e/invoice-distribution.smoke.spec.js @@ -122,4 +122,25 @@ test.describe("Invoice distribution smoke", () => { await page.getByRole("tab", { name: /Departments/i }).click(); await expect(page.locator(".table-container--scroll:visible").first()).toBeVisible(); }); + + test("@smoke fallback mode keeps results and surfaces warning banners", async ({ page }) => { + await mockApi(page, { + authenticated: true, + permissions: ["superuser", "user"], + invoiceDistribution: true, + invoiceDistributionForceLegacyFallback: true, + invoiceDistributionForceCompareFallback: true + }); + 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 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 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(); + }); }); diff --git a/tests/e2e/support/network.js b/tests/e2e/support/network.js index 09cd2b02..8f1479c3 100644 --- a/tests/e2e/support/network.js +++ b/tests/e2e/support/network.js @@ -96,6 +96,8 @@ export async function mockApi(page, options = {}) { const monthFromDate = parsedUrl.searchParams.get("dateFrom"); const monthNumber = monthFromDate ? Number(monthFromDate.split("-")[1]) : 1; const monthBase = Number.isFinite(monthNumber) ? monthNumber * 10 : 10; + const forceDistributionLegacy = Boolean(options.invoiceDistributionForceLegacyFallback); + const forceCompareLegacy = Boolean(options.invoiceDistributionForceCompareFallback); if (pathname.endsWith("/orders") && method === "GET") { await route.fulfill( @@ -132,6 +134,98 @@ export async function mockApi(page, options = {}) { return; } + if (pathname.endsWith("/superuser/invoicing/period/distribution/v2/all") && method === "GET") { + if (forceDistributionLegacy) { + await route.fulfill(json({ message: "v2 distribution temporarily unavailable" }, 500)); + return; + } + + await route.fulfill( + json({ + fixed_pricing: { + 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: [] + }, + wash_subscriptions: { + 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: [] + }, + customer_prices: { + 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({ @@ -243,6 +337,77 @@ export async function mockApi(page, options = {}) { ); return; } + + if (pathname.endsWith("/collected-invoices/economic/v2/compare/bulk") && method === "POST") { + if (forceCompareLegacy) { + await route.fulfill(json({ message: "v2 compare bulk temporarily unavailable" }, 500)); + return; + } + + const body = request.postDataJSON?.() || {}; + const ids = Array.isArray(body.collected_invoice_ids) ? body.collected_invoice_ids : []; + const results = ids.map((invoiceId) => { + const mismatch = Number(invoiceId) === 101 || Number(invoiceId) === 103; + const internalTotal = mismatch ? 150 : 120; + const targetTotal = mismatch ? 145 : 120; + + return { + collected_invoice_id: Number(invoiceId), + warnings: mismatch ? ["Line mismatch found"] : [], + details: { + order_ids: mismatch ? [1, 2] : [3], + customer: { + internal_customer_number: 4000 + Number(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: targetTotal + }, + mismatch_reasons: mismatch ? ["department_total_mismatch"] : [], + warnings: mismatch ? ["Line mismatch found"] : [], + lines: { + summary: { + internal_billable_count: mismatch ? 2 : 1, + target_billable_count: mismatch ? 2 : 1, + mismatch_count: mismatch ? 1 : 0 + } + } + } + }, + warnings: mismatch ? ["comparison warning"] : [] + } + }; + }); + + await route.fulfill( + json({ + requested: ids.length, + compared: ids.length, + failed: 0, + results, + errors: [] + }) + ); + return; + } } if (options.fallbackPassthrough) { diff --git a/tests/unit/invoice-distribution-api.spec.js b/tests/unit/invoice-distribution-api.spec.js new file mode 100644 index 00000000..e220f7d3 --- /dev/null +++ b/tests/unit/invoice-distribution-api.spec.js @@ -0,0 +1,156 @@ +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: { + request, + 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'; + +const buildInvoice = (id) => ({ + id, + customer_number: 2000 + id, + customer_name: `Customer ${id}`, + total_net_amount: 100, +}); + +describe('invoice distribution api adapter', () => { + let consoleWarnSpy; + + beforeEach(() => { + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + SessionUser.request.mockReset(); + SessionUser.functions.parseErrorMessage.mockReset(); + SessionUser.functions.parseErrorMessage.mockImplementation((error) => error?.message || String(error || 'Unknown error')); + 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 () => { + const invoices = Array.from({ length: 250 }, (_, index) => buildInvoice(index + 1)); + + SessionUser.request.mockImplementation(async (endpoint, method, params) => { + if (endpoint === '/collected-invoices' && method === 'GET') { + const page = Number(params?.page || 1); + const perPage = 100; + const start = (page - 1) * perPage; + const data = invoices.slice(start, start + perPage); + return { + data: { + data, + meta: { + pagination: { + total: invoices.length, + per_page: perPage, + }, + }, + }, + }; + } + + 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, + mode: 'invoice_total', + 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); + 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)]; + + SessionUser.request.mockImplementation(async (endpoint, method) => { + if (endpoint === '/collected-invoices' && method === 'GET') { + return { + data: { + data: invoices, + }, + }; + } + 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', + }); + + 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'); + }); +}); diff --git a/tests/unit/invoice-distribution-calculations.spec.js b/tests/unit/invoice-distribution-calculations.spec.js index d63be443..38d0ece5 100644 --- a/tests/unit/invoice-distribution-calculations.spec.js +++ b/tests/unit/invoice-distribution-calculations.spec.js @@ -51,14 +51,20 @@ describe('invoice distribution calculations', () => { }, }, }, + customerPriceDistribution: { + collective_results: { + total_customer_price: 15, + }, + }, }); expect(summary.bookedAmount).toBe(100); expect(summary.fixedPricingAmount).toBe(40); expect(summary.subscriptionAmount).toBe(20); - expect(summary.distributionAmount).toBe(60); - expect(summary.otherBookedAmount).toBe(40); - expect(summary.totalAmount).toBe(160); + expect(summary.customerPriceAmount).toBe(15); + expect(summary.distributionAmount).toBe(75); + expect(summary.otherBookedAmount).toBe(25); + expect(summary.totalAmount).toBe(175); }); it('builds department allocations with percentages and booked estimate', () => { @@ -87,13 +93,20 @@ describe('invoice distribution calculations', () => { }, }, }, + customerPriceDistribution: { + collective_results: { + customer_price_department_distribution_parsed: { + Beta: 10, + }, + }, + }, }); expect(rows).toHaveLength(2); expect(rows[0].departmentName).toBe('Alpha'); expect(rows[0].totalAmount).toBe(90); - expect(rows[0].sharePercent).toBe(69.23); - expect(rows[0].estimatedBookedAllocation).toBe(207.69); + expect(rows[0].sharePercent).toBe(64.29); + expect(rows[0].estimatedBookedAllocation).toBe(192.86); }); it('builds customer rows and respects creation date filter', () => { @@ -134,11 +147,29 @@ describe('invoice distribution calculations', () => { subscriptionDistribution: { data: [], }, + customerPriceDistribution: { + customers: [ + { + id: 7, + customer_number: 99, + customer_name: 'Discount Customer', + transactions: [{ id: 1, amount: 22, booked: true, excluded: false, date: '2026-02-05T00:00:00Z' }], + meta: { + customer_price: { + created_at: '2026-02-05T00:00:00Z', + price: 22, + department_totals_relative: { 1: 22 }, + }, + }, + }, + ], + }, }); - expect(rows).toHaveLength(1); + expect(rows).toHaveLength(2); expect(rows[0].customerName).toBe('Customer A'); expect(rows[0].departmentAllocations).toEqual({ Alpha: 100 }); + expect(rows[1].source).toBe('customer_prices'); }); it('builds month-over-month comparison deltas', () => { @@ -157,7 +188,7 @@ describe('invoice distribution calculations', () => { compareMonth: '2026-02', compareMode: 'line_by_line', customerSearch: ' acme ', - customerSource: 'fixed_pricing', + customerSource: 'customer_prices', customerDepartment: 'Ops', departmentSearch: ' nord ', }); @@ -166,7 +197,7 @@ describe('invoice distribution calculations', () => { compareMonth: '2026-02', compareMode: 'line_by_line', customerSearch: 'acme', - customerSource: 'fixed_pricing', + customerSource: 'customer_prices', customerDepartment: 'Ops', departmentSearch: 'nord', }); @@ -176,7 +207,7 @@ describe('invoice distribution calculations', () => { compareMonth: '2026-02', compareMode: 'line_by_line', customerSearch: 'acme', - customerSource: 'fixed_pricing', + customerSource: 'customer_prices', customerDepartment: 'Ops', departmentSearch: 'nord', }); @@ -209,10 +240,12 @@ describe('invoice distribution calculations', () => { const composition = buildSourceComposition({ fixedPricingAmount: 60, subscriptionAmount: 40, - distributionAmount: 100, + customerPriceAmount: 20, + distributionAmount: 120, }); - expect(composition.fixedPercent).toBe(60); - expect(composition.subscriptionPercent).toBe(40); + expect(composition.fixedPercent).toBe(50); + expect(composition.subscriptionPercent).toBe(33.33); + expect(composition.customerPricePercent).toBe(16.67); }); it('normalizes compare payloads for both modes and aggregates', () => { @@ -240,14 +273,64 @@ describe('invoice distribution calculations', () => { 'line_by_line' ); + const v2Row = normalizeCompareResult( + { id: 3, customer_name: 'C', total_net_amount: 200 }, + { + collected_invoice_id: 3, + warnings: ['Top-level warning'], + details: { + order_ids: [10, 20], + internal: { + normalized: { + totals: { + net_total: 200, + billable_line_count: 2, + }, + }, + }, + customer: { + name: 'Customer V2', + }, + }, + comparison: { + totals: { + internal_net_total: 200, + }, + targets: { + booked: { + status: 'partial_mismatch', + overall_match: false, + totals: { + target_net_total: 180, + }, + mismatch_reasons: ['department_total_mismatch'], + lines: { + summary: { + mismatch_count: 1, + target_billable_count: 2, + }, + }, + }, + }, + warnings: ['comparison warning'], + }, + }, + 'line_by_line' + ); + expect(okRow.status).toBe('ok'); expect(mismatchRow.status).toBe('mismatch'); expect(mismatchRow.lineMismatchCount).toBe(1); + expect(v2Row.status).toBe('mismatch'); + expect(v2Row.bookedTotal).toBe(180); + expect(v2Row.difference).toBe(20); + expect(v2Row.mismatchReasons).toContain('department_total_mismatch'); + expect(v2Row.lineMismatchCount).toBe(1); - const aggregate = aggregateCompareRows([okRow, mismatchRow]); - expect(aggregate.total).toBe(2); - expect(aggregate.mismatches).toBe(1); - expect(aggregate.warningCount).toBe(1); - expect(aggregate.totalDifference).toBe(10); + const aggregate = aggregateCompareRows([okRow, mismatchRow, v2Row]); + expect(aggregate.total).toBe(3); + expect(aggregate.mismatches).toBe(2); + expect(aggregate.warningCount).toBe(4); + expect(aggregate.totalDifference).toBe(30); }); }); diff --git a/tests/unit/invoice-distribution-i18n.spec.js b/tests/unit/invoice-distribution-i18n.spec.js index 33d6a079..b6d80f12 100644 --- a/tests/unit/invoice-distribution-i18n.spec.js +++ b/tests/unit/invoice-distribution-i18n.spec.js @@ -64,12 +64,18 @@ describe('invoice distribution i18n coverage', () => { 'compare.run_short', 'compare.progress', 'compare.warning_details', + 'compare.status_label', 'compare.status.ok', 'compare.status.mismatch', 'errors.overview_load_failed', 'errors.month_load_failed', 'errors.compare_failed', + 'warnings.legacy_distribution_fallback', + 'warnings.legacy_distribution_month', + 'warnings.legacy_compare_fallback', 'sections.trend', + 'metrics.customer_prices', + 'table.customer_prices', 'table.source_mix', 'aria.month_table_caption', 'aria.compare_table_caption' diff --git a/tests/unit/superuser-invoice-distribution-view.spec.js b/tests/unit/superuser-invoice-distribution-view.spec.js index e52ee871..25fe7033 100644 --- a/tests/unit/superuser-invoice-distribution-view.spec.js +++ b/tests/unit/superuser-invoice-distribution-view.spec.js @@ -33,7 +33,7 @@ describe('CollectedOrderInvoices distribution tab contract', () => { describe('Distribution overview contract', () => { it('renders decision-first shell sections and executive scanning order', () => { - expect(distributionOverviewSource).toContain('class="overview-header"'); + expect(distributionOverviewSource).toContain('class="overview-header hero-surface"'); expect(distributionOverviewSource).toContain('class="control-toolbar'); expect(distributionOverviewSource).toContain('class="kpi-strip"'); expect(distributionOverviewSource).toContain('class="chart-section"'); @@ -49,6 +49,8 @@ describe('Distribution overview contract', () => { expect(distributionOverviewSource).toContain("sortMonthSummaries(filteredMonthSummaries.value"); expect(distributionOverviewSource).toContain('source-composition__segment--fixed'); expect(distributionOverviewSource).toContain('source-composition__segment--subscription'); + expect(distributionOverviewSource).toContain('source-composition__segment--customer-prices'); + expect(distributionOverviewSource).toContain("metrics.customer_prices"); }); it('keeps loading, error, and empty states with recovery actions', () => { @@ -99,6 +101,9 @@ describe('Distribution monthly view contract', () => { 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('compareFallback.usedLegacyCompare'); }); it('keeps compare mode values and progress visibility', () => { @@ -122,28 +127,33 @@ describe('Distribution accessibility contract', () => { }); describe('Distribution API adapter contract', () => { - it('uses period and distribution endpoints with dateFrom/dateTo', () => { + it('uses period endpoint and prefers v2 all 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/fixed-pricing'"); expect(distributionApiSource).toContain("'/superuser/invoicing/period/distribution/wash-subscriptions'"); + expect(distributionApiSource).toContain('usedLegacyDistribution'); expect(distributionApiSource).toContain('dateFrom'); expect(distributionApiSource).toContain('dateTo'); }); - it('runs month compare against economic compare endpoint through SessionUser object', () => { + it('runs month compare against v2 bulk endpoint with legacy fallback path', () => { expect(distributionApiSource).toContain('compareCollectedInvoicesForMonth'); + expect(distributionApiSource).toContain('economic.v2.compareBulk'); expect(distributionApiSource).toContain('SessionUser.objects.collectedOrderInvoices.functions.economic.compareToEconomic'); + expect(distributionApiSource).toContain('usedLegacyCompare'); expect(distributionApiSource).toContain('normalizeCompareResult'); expect(distributionApiSource).toContain('aggregateCompareRows'); }); }); describe('Distribution visual components contract', () => { - it('uses chart.js line chart with three datasets and aria label support', () => { + it('uses chart.js line chart with four datasets and aria label support', () => { expect(distributionChartSource).toContain('import { Line } from \'vue-chartjs\''); expect(distributionChartSource).toContain('label: props.labels.total'); expect(distributionChartSource).toContain('label: props.labels.booked'); expect(distributionChartSource).toContain('label: props.labels.distribution'); + expect(distributionChartSource).toContain('label: props.labels.customerPrices'); expect(distributionChartSource).toContain('ariaLabel'); });