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:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+285
-5
@@ -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>
|
||||
|
||||
+224
-6
@@ -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>
|
||||
|
||||
+8
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
+244
-51
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
+442
-69
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user