Add unit tests for invoice distribution API, enhance v2 bulk comparison handling, and update UI/locale for customer price metrics.

This commit is contained in:
Jeppe Bundgaard
2026-03-12 18:50:54 +01:00
parent e1e3187d69
commit 75b7aad56a
17 changed files with 1808 additions and 158 deletions
@@ -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 = {
);
}
};
</script>
</script>
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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",
@@ -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 () => {
</div>
</b-message>
<b-message
v-if="monthFallback.usedLegacyDistribution"
type="is-warning"
has-icon
icon-pack="fas"
class="mb-4"
>
<p>{{ t('superuser_invoice_distribution.warnings.legacy_distribution_month') }}</p>
<p v-if="monthFallback.distributionFallbackReason" class="is-size-7">{{ monthFallback.distributionFallbackReason }}</p>
</b-message>
<b-tabs v-model="activeTab" expanded data-testid="distribution-month-tabs" class="distribution-tabs">
<b-tab-item :label="t('superuser_invoice_distribution.tabs.overview')" value="overview" icon="chart-line" icon-pack="fas">
<section class="summary-ribbon mb-3" aria-label="Summary ribbon">
@@ -527,21 +553,28 @@ onMounted(async () => {
</section>
<div class="columns is-mobile is-multiline mb-3">
<div class="column is-12-mobile is-4-tablet">
<div class="column is-12-mobile is-6-tablet is-3-desktop">
<b-card class="metric metric-fixed">
<p class="metric-title">{{ t('superuser_invoice_distribution.metrics.fixed_pricing') }}</p>
<p class="metric-value">{{ formatCurrency(summary?.fixedPricingAmount || 0) }}</p>
<p class="metric-sub">{{ formatPercent(sourceComposition.fixedPercent) }} {{ t('superuser_invoice_distribution.metrics.distribution') }}</p>
</b-card>
</div>
<div class="column is-12-mobile is-4-tablet">
<div class="column is-12-mobile is-6-tablet is-3-desktop">
<b-card class="metric metric-subscription">
<p class="metric-title">{{ t('superuser_invoice_distribution.metrics.subscriptions') }}</p>
<p class="metric-value">{{ formatCurrency(summary?.subscriptionAmount || 0) }}</p>
<p class="metric-sub">{{ formatPercent(sourceComposition.subscriptionPercent) }} {{ t('superuser_invoice_distribution.metrics.distribution') }}</p>
</b-card>
</div>
<div class="column is-12-mobile is-4-tablet">
<div class="column is-12-mobile is-6-tablet is-3-desktop">
<b-card class="metric metric-customer-prices">
<p class="metric-title">{{ t('superuser_invoice_distribution.metrics.customer_prices') }}</p>
<p class="metric-value">{{ formatCurrency(summary?.customerPriceAmount || 0) }}</p>
<p class="metric-sub">{{ formatPercent(sourceComposition.customerPricePercent) }} {{ t('superuser_invoice_distribution.metrics.distribution') }}</p>
</b-card>
</div>
<div class="column is-12-mobile is-6-tablet is-3-desktop">
<b-card class="metric metric-distribution">
<p class="metric-title">{{ t('superuser_invoice_distribution.metrics.distribution') }}</p>
<p class="metric-value">{{ formatCurrency(summary?.distributionAmount || 0) }}</p>
@@ -582,6 +615,12 @@ onMounted(async () => {
<td class="has-text-right is-family-monospace">{{ formatCurrency(monthComparison.distributionAmount.target) }}</td>
<td class="has-text-right is-family-monospace" :class="monthComparison.distributionAmount.delta >= 0 ? 'has-text-success' : 'has-text-danger'">{{ formatCurrency(monthComparison.distributionAmount.delta) }}</td>
</tr>
<tr>
<th scope="row">{{ t('superuser_invoice_distribution.metrics.customer_prices') }}</th>
<td class="has-text-right is-family-monospace">{{ formatCurrency(monthComparison.customerPriceAmount.base) }}</td>
<td class="has-text-right is-family-monospace">{{ formatCurrency(monthComparison.customerPriceAmount.target) }}</td>
<td class="has-text-right is-family-monospace" :class="monthComparison.customerPriceAmount.delta >= 0 ? 'has-text-success' : 'has-text-danger'">{{ formatCurrency(monthComparison.customerPriceAmount.delta) }}</td>
</tr>
</tbody>
</table>
</div>
@@ -637,6 +676,7 @@ onMounted(async () => {
<th scope="col">{{ t('superuser_invoice_distribution.table.department') }}</th>
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.fixed_pricing') }}</th>
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.subscriptions') }}</th>
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.customer_prices') }}</th>
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.total') }}</th>
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.share') }}</th>
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.estimated_booked_allocation') }}</th>
@@ -647,6 +687,7 @@ onMounted(async () => {
<th scope="row">{{ row.departmentName }}</th>
<td class="has-text-right is-family-monospace">{{ formatCurrency(row.fixedAmount) }}</td>
<td class="has-text-right is-family-monospace">{{ formatCurrency(row.subscriptionAmount) }}</td>
<td class="has-text-right is-family-monospace">{{ formatCurrency(row.customerPriceAmount) }}</td>
<td class="has-text-right is-family-monospace">{{ formatCurrency(row.totalAmount) }}</td>
<td class="has-text-right is-family-monospace">{{ row.sharePercent }}%</td>
<td class="has-text-right is-family-monospace">{{ formatCurrency(row.estimatedBookedAllocation) }}</td>
@@ -696,6 +737,7 @@ onMounted(async () => {
<option value="all">{{ t('superuser_invoice_distribution.filters.all_sources') }}</option>
<option value="fixed_pricing">{{ t('superuser_invoice_distribution.metrics.fixed_pricing') }}</option>
<option value="vehicle_subscriptions">{{ t('superuser_invoice_distribution.metrics.subscriptions') }}</option>
<option value="customer_prices">{{ t('superuser_invoice_distribution.metrics.customer_prices') }}</option>
</b-select>
</b-field>
</div>
@@ -730,7 +772,8 @@ onMounted(async () => {
</th>
<td>
<span v-if="row.source === 'fixed_pricing'" class="tag is-warning is-light">{{ t('superuser_invoice_distribution.metrics.fixed_pricing') }}</span>
<span v-else class="tag is-info is-light">{{ t('superuser_invoice_distribution.metrics.subscriptions') }}</span>
<span v-else-if="row.source === 'vehicle_subscriptions'" class="tag is-info is-light">{{ t('superuser_invoice_distribution.metrics.subscriptions') }}</span>
<span v-else class="tag is-danger is-light">{{ t('superuser_invoice_distribution.metrics.customer_prices') }}</span>
</td>
<td class="has-text-right is-family-monospace">{{ formatCurrency(row.amount) }}</td>
<td>
@@ -823,6 +866,11 @@ onMounted(async () => {
{{ t('superuser_invoice_distribution.errors.compare_failed') }}: {{ compareError }}
</b-message>
<b-message v-if="compareFallback.usedLegacyCompare" type="is-warning" has-icon icon-pack="fas" class="mb-3">
<p>{{ t('superuser_invoice_distribution.warnings.legacy_compare_fallback') }}</p>
<p v-if="compareFallback.compareFallbackReason" class="is-size-7">{{ compareFallback.compareFallbackReason }}</p>
</b-message>
<b-card class="mb-3 compare-summary" v-if="sortedCompareRows.length">
<div class="compare-summary-grid">
<article class="compare-summary-card">
@@ -892,6 +940,10 @@ onMounted(async () => {
<b-button size="is-small" type="is-light" class="control-button" @click="clearWarningsPanel">{{ t('superuser_invoice_distribution.actions.close') }}</b-button>
</div>
<ul class="warning-list">
<li v-if="selectedWarningRow.compareStatus">
{{ t('superuser_invoice_distribution.compare.status_label') }}: {{ selectedWarningRow.compareStatus }}
</li>
<li v-for="reason in selectedWarningRow.mismatchReasons || []" :key="`reason-${reason}`">{{ reason }}</li>
<li v-for="warning in selectedWarningRow.warnings" :key="warning">{{ warning }}</li>
</ul>
</b-card>
@@ -901,12 +953,240 @@ onMounted(async () => {
</template>
<style scoped>
:deep(.card) {
border-radius: 16px;
}
.distribution-toolbar {
position: sticky;
top: 0.8rem;
z-index: 8;
border: 1px solid #d8e5ee;
background:
radial-gradient(circle at top right, rgba(15, 107, 134, 0.14), transparent 50%),
linear-gradient(145deg, #f4faff 0%, #f7f8fc 100%);
}
.toolbar-grid {
display: grid;
grid-template-columns: 1.5fr 1fr auto;
gap: 1rem;
align-items: end;
}
.toolbar-label {
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #627a87;
margin-bottom: 0.35rem;
}
.month-controls {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.55rem;
align-items: center;
}
.month-nav-button {
min-width: 38px;
}
.toolbar-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.distribution-tabs :deep(.tabs) {
margin-bottom: 0.8rem;
}
.summary-ribbon {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.7rem;
}
.summary-pill {
border: 1px solid #d8e5ee;
}
.summary-pill__meta {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.summary-pill__label {
color: #627a87;
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.summary-pill__value {
font-weight: 700;
font-size: 1.15rem;
}
.summary-pill__icon {
color: #2e5568;
}
.summary-pill--compare {
background: #f7fbff;
}
.summary-pill--alert {
border-color: #efc7c7;
background: #fff7f7;
}
.metric {
border: 1px solid #d8e5ee;
}
.metric-title {
font-size: 0.8rem;
text-transform: uppercase;
color: #627a87;
}
.metric-value {
font-size: 1.3rem;
font-weight: 700;
}
.metric-sub {
color: #627a87;
}
.metric-customer-prices {
border-color: #f0d6c8;
background: #fffaf7;
}
.sticky-filter-bar {
position: sticky;
top: 1rem;
z-index: 5;
border: 1px solid #d8e5ee;
background: #f9fcff;
}
.table-container--scroll {
overflow-x: auto;
}
.allocation-chips {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.compare-action-panel {
border: 1px solid #d8e5ee;
}
.compare-action-panel__row {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: end;
flex-wrap: wrap;
}
.compare-mode-toggle {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.compare-mode-option {
border: 1px solid #d3e1ea;
border-radius: 999px;
padding: 0.3rem 0.75rem;
margin: 0;
}
.compare-mode-option--active {
border-color: #0f6b86;
background: #f1fbff;
}
.compare-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.7rem;
}
.compare-summary-card {
border: 1px solid #d8e5ee;
border-radius: 12px;
padding: 0.75rem;
background: #ffffff;
}
.compare-summary-card--alert {
border-color: #efc7c7;
background: #fff7f7;
}
.compare-summary-card__label {
color: #627a87;
font-size: 0.78rem;
margin-bottom: 0.35rem;
}
.compare-summary-card__value {
font-size: 1.3rem;
font-weight: 700;
}
.warning-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
margin-bottom: 0.75rem;
}
.warning-list {
padding-left: 1.2rem;
}
@media (max-width: 1140px) {
.toolbar-grid {
grid-template-columns: 1fr;
}
.summary-ribbon {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.compare-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.summary-ribbon {
grid-template-columns: 1fr;
}
.compare-summary-grid {
grid-template-columns: 1fr;
}
}
:deep(.button:focus-visible),
:deep(.input:focus-visible),
:deep(.select select:focus-visible),
:deep(.textarea:focus-visible),
.compare-mode-option:focus-within {
outline: 2px solid rgba(21, 132, 188, 0.55);
outline: 2px solid rgba(15, 107, 134, 0.55);
outline-offset: 2px;
}
</style>
@@ -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(() => {
</script>
<template>
<div class="distribution-overview" data-testid="distribution-overview-page">
<b-card class="overview-header">
<div class="overview-header__top">
<div class="distribution-overview distribution-overview-v2" data-testid="distribution-overview-page">
<b-card class="overview-header hero-surface">
<div class="overview-header__top hero-row">
<div>
<p class="eyebrow">Superuser Revenue Intelligence</p>
<h2 class="title is-4 mb-1">{{ t('superuser_invoice_distribution.overview.title') }}</h2>
<p class="subtitle is-6 mb-0">{{ t('superuser_invoice_distribution.overview.subtitle') }}</p>
</div>
@@ -158,7 +168,7 @@ onMounted(() => {
</b-button>
</div>
<div class="control-toolbar mt-4">
<div class="control-toolbar mt-4 overview-dock">
<b-field :label="t('superuser_invoice_distribution.filters.search')" class="toolbar-field">
<b-input
v-model="search"
@@ -191,6 +201,19 @@ onMounted(() => {
</div>
</b-message>
<b-message
v-if="!loading && monthsUsingLegacyDistribution.length"
type="is-warning"
has-icon
icon-pack="fas"
class="mb-0"
>
<div class="fallback-note">
<p>{{ t('superuser_invoice_distribution.warnings.legacy_distribution_fallback', { count: monthsUsingLegacyDistribution.length }) }}</p>
<p v-if="latestDistributionFallbackReason" class="is-size-7">{{ latestDistributionFallbackReason }}</p>
</div>
</b-message>
<b-card v-if="loading">
<div class="progress-meta mb-3">
<span>{{ t('superuser_invoice_distribution.loading.loading_months', { loaded: loadProgress.loaded, total: loadProgress.total }) }}</span>
@@ -219,6 +242,10 @@ onMounted(() => {
<p class="metric-title">{{ t('superuser_invoice_distribution.metrics.distribution') }}</p>
<p class="metric-value">{{ formatCurrency(totals.distributionAmount) }}</p>
</b-card>
<b-card class="metric-card metric-customer-prices">
<p class="metric-title">{{ t('superuser_invoice_distribution.metrics.customer_prices') }}</p>
<p class="metric-value">{{ formatCurrency(totals.customerPriceAmount) }}</p>
</b-card>
<b-card class="metric-card metric-months">
<p class="metric-title">{{ t('superuser_invoice_distribution.metrics.months') }}</p>
<p class="metric-value">{{ sortedMonthSummaries.length }}</p>
@@ -237,6 +264,7 @@ onMounted(() => {
total: t('superuser_invoice_distribution.metrics.total'),
booked: t('superuser_invoice_distribution.metrics.booked'),
distribution: t('superuser_invoice_distribution.metrics.distribution'),
customerPrices: t('superuser_invoice_distribution.metrics.customer_prices'),
}"
/>
</b-card>
@@ -276,6 +304,7 @@ onMounted(() => {
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.booked') }}</th>
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.fixed_pricing') }}</th>
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.subscriptions') }}</th>
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.customer_prices') }}</th>
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.distribution') }}</th>
<th scope="col" :aria-sort="getAriaSort('total')" class="has-text-right">
<b-button
@@ -299,6 +328,7 @@ onMounted(() => {
<td class="has-text-right is-family-monospace">{{ formatCurrency(summary.bookedAmount) }}</td>
<td class="has-text-right is-family-monospace">{{ formatCurrency(summary.fixedPricingAmount) }}</td>
<td class="has-text-right is-family-monospace">{{ formatCurrency(summary.subscriptionAmount) }}</td>
<td class="has-text-right is-family-monospace">{{ formatCurrency(summary.customerPriceAmount) }}</td>
<td class="has-text-right is-family-monospace">{{ formatCurrency(summary.distributionAmount) }}</td>
<td class="has-text-right is-family-monospace">{{ formatCurrency(summary.totalAmount) }}</td>
<td>
@@ -312,9 +342,17 @@ onMounted(() => {
class="source-composition__segment source-composition__segment--subscription"
:style="{ width: `${buildSourceComposition(summary).subscriptionPercent}%` }"
></span>
<span
class="source-composition__segment source-composition__segment--customer-prices"
:style="{ width: `${buildSourceComposition(summary).customerPricePercent}%` }"
></span>
</div>
<span class="is-size-7 has-text-grey">
{{ formatPercent(buildSourceComposition(summary).fixedPercent) }} / {{ formatPercent(buildSourceComposition(summary).subscriptionPercent) }}
{{ formatPercent(buildSourceComposition(summary).fixedPercent) }}
/
{{ formatPercent(buildSourceComposition(summary).subscriptionPercent) }}
/
{{ formatPercent(buildSourceComposition(summary).customerPricePercent) }}
</span>
</div>
</td>
@@ -349,6 +387,10 @@ onMounted(() => {
<span>{{ t('superuser_invoice_distribution.metrics.subscriptions') }}</span>
<strong>{{ formatCurrency(totalComposition.subscriptionAmount) }} ({{ formatPercent(totalComposition.subscriptionPercent) }})</strong>
</div>
<div class="insight-row">
<span>{{ t('superuser_invoice_distribution.metrics.customer_prices') }}</span>
<strong>{{ formatCurrency(totalComposition.customerPriceAmount) }} ({{ formatPercent(totalComposition.customerPricePercent) }})</strong>
</div>
</b-card>
<b-card class="insight-card">
@@ -374,11 +416,187 @@ onMounted(() => {
</template>
<style scoped>
:root {
--distribution-v2-accent: #0f6b86;
}
:deep(.card) {
border-radius: 16px;
}
.distribution-overview-v2 {
display: grid;
gap: 1rem;
}
.hero-surface {
background:
radial-gradient(circle at top right, rgba(15, 107, 134, 0.2), transparent 55%),
linear-gradient(135deg, #f5fbff 0%, #f7f8fc 100%);
}
.hero-row {
align-items: flex-start;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 700;
font-size: 0.7rem;
margin-bottom: 0.4rem;
color: #38576a;
}
.overview-dock {
display: grid;
grid-template-columns: 1fr 1fr minmax(220px, 1fr);
gap: 0.9rem;
align-items: end;
}
.toolbar-hint {
padding: 0.75rem 0.9rem;
background: #ffffff;
border: 1px solid #d8e5ee;
border-radius: 12px;
}
.toolbar-hint__title {
font-weight: 600;
}
.toolbar-hint__subtitle {
color: #617986;
font-size: 0.82rem;
}
.fallback-note p + p {
margin-top: 0.3rem;
}
.kpi-strip {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.8rem;
}
.metric-card {
background: #ffffff;
border: 1px solid #d8e5ee;
}
.metric-title {
color: #617986;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.metric-value {
font-size: 1.3rem;
font-weight: 700;
}
.metric-customer-prices {
border-color: #f0d6c8;
background: #fffaf7;
}
.chart-section {
border: 1px solid #d8e5ee;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.distribution-table {
font-size: 0.92rem;
}
.source-composition__bar {
width: 100%;
max-width: 160px;
height: 8px;
border-radius: 999px;
overflow: hidden;
background: #dde8ef;
display: flex;
}
.source-composition__segment {
display: inline-block;
height: 100%;
}
.source-composition__segment--fixed {
background: #1f77b4;
}
.source-composition__segment--subscription {
background: #ff7f0e;
}
.source-composition__segment--customer-prices {
background: #c44536;
}
.insights-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.insight-card {
border: 1px solid #d8e5ee;
}
.insight-row {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
padding: 0.35rem 0;
}
.top-month-list {
display: grid;
gap: 0.45rem;
}
.top-month-button {
justify-content: space-between;
}
@media (max-width: 1100px) {
.overview-dock {
grid-template-columns: 1fr;
}
.kpi-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.kpi-strip {
grid-template-columns: 1fr;
}
.insights-grid {
grid-template-columns: 1fr;
}
}
:deep(.button:focus-visible),
:deep(.input:focus-visible),
:deep(.select select:focus-visible),
:deep(.textarea:focus-visible) {
outline: 2px solid rgba(21, 132, 188, 0.55);
outline: 2px solid rgba(15, 107, 134, 0.55);
outline-offset: 2px;
}
</style>
@@ -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,
},
],
};
});
@@ -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),
},
};
}
};
@@ -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;
@@ -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();
});
});
+165
View File
@@ -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) {
+156
View File
@@ -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');
});
});
@@ -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);
});
});
@@ -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'
@@ -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');
});