Introduce footer totals for department allocations:
- Add `buildDepartmentAllocationFooterTotals` utility to compute department footer totals, excluding customer prices from the total column. - Update department allocation summaries to prioritize actual distributions for fixed and subscription amounts. - Adjust distribution metrics calculation logic and related tests for consistency. - Enhance footer visuals with clearly marked totals and accompanying notes. - Expand `buildDepartmentAllocations` to properly account for totals in UI rendering. - Update associated unit tests to cover footer totals and exclude customer price impacts.
This commit is contained in:
+37
@@ -15,6 +15,7 @@ import {
|
||||
aggregateCompareRows,
|
||||
buildCustomerAllocationRows,
|
||||
buildDepartmentAllocations,
|
||||
buildDepartmentAllocationFooterTotals,
|
||||
buildDistributionQueryState,
|
||||
buildMonthComparison,
|
||||
buildSourceComposition,
|
||||
@@ -104,6 +105,7 @@ const departmentRows = computed(() => buildDepartmentAllocations({
|
||||
}));
|
||||
|
||||
const filteredDepartmentRows = computed(() => filterDepartmentRows({ rows: departmentRows.value, search: departmentSearch.value }));
|
||||
const departmentFooterTotals = computed(() => buildDepartmentAllocationFooterTotals(filteredDepartmentRows.value));
|
||||
|
||||
const customerRows = computed(() => buildCustomerAllocationRows({
|
||||
fixedDistribution: fixedDistribution.value,
|
||||
@@ -716,6 +718,22 @@ onMounted(async () => {
|
||||
<td class="has-text-right is-family-monospace">{{ formatCurrency(row.totalAmount) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="department-table__footer">
|
||||
<th scope="row">
|
||||
<div class="department-table__footer-label">
|
||||
<span>{{ t('common.total') }}</span>
|
||||
<span class="department-table__footer-note">
|
||||
{{ t('superuser_invoice_distribution.metrics.fixed_pricing') }} + {{ t('superuser_invoice_distribution.metrics.subscriptions') }}
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<td class="has-text-right is-family-monospace">{{ formatCurrency(departmentFooterTotals.fixedAmount) }}</td>
|
||||
<td class="has-text-right is-family-monospace">{{ formatCurrency(departmentFooterTotals.subscriptionAmount) }}</td>
|
||||
<td class="has-text-right is-family-monospace has-text-grey">-</td>
|
||||
<td class="has-text-right is-family-monospace">{{ formatCurrency(departmentFooterTotals.totalAmount) }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="has-text-centered has-text-grey py-4">{{ t('superuser_invoice_distribution.empty.no_departments') }}</div>
|
||||
@@ -1156,6 +1174,25 @@ onMounted(async () => {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.department-table__footer th,
|
||||
.department-table__footer td {
|
||||
background: #f7fcff;
|
||||
border-top: 2px solid var(--v3-line);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.department-table__footer-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.department-table__footer-note {
|
||||
color: var(--v3-ink-soft);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.compare-action-panel__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
+87
-38
@@ -273,6 +273,23 @@ const getCategoryCollectiveResults = (categoryResponse = {}, legacyKey = null) =
|
||||
return {};
|
||||
};
|
||||
|
||||
const getCollectiveDepartmentDistributionMap = (collective = {}, keys = []) => {
|
||||
for (const key of keys) {
|
||||
const candidate = normalizeObjectNumberMap(collective?.[key]);
|
||||
if (Object.keys(candidate).length > 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const sumDistributionMap = (distributionMap = {}) => {
|
||||
return safeRoundCurrency(
|
||||
Object.values(distributionMap || {}).reduce((sum, value) => sum + toNumber(value), 0)
|
||||
);
|
||||
};
|
||||
|
||||
export const getCollectiveFixedResults = (fixedDistributionResponse = {}) => {
|
||||
return getCategoryCollectiveResults(fixedDistributionResponse, 'collective_fixed_pricing_results');
|
||||
};
|
||||
@@ -352,21 +369,42 @@ export const buildMonthSummary = ({
|
||||
const fixedCustomers = getCategoryCustomers(fixedDistribution);
|
||||
const subscriptionCustomers = getCategoryCustomers(subscriptionDistribution);
|
||||
const customerPriceCustomers = getCategoryCustomers(customerPriceDistribution);
|
||||
const fixedDepartmentDistribution = getCollectiveDepartmentDistributionMap(fixedCollective, [
|
||||
'total_department_totals_relative_parsed',
|
||||
'total_department_totals_parsed',
|
||||
'department_distribution_parsed',
|
||||
'department_distribution',
|
||||
'department_totals_relative',
|
||||
'department_totals',
|
||||
]);
|
||||
const subscriptionDepartmentDistribution = getCollectiveDepartmentDistributionMap(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',
|
||||
]);
|
||||
|
||||
const bookedAmount = getBookedAmountFromPeriod(periodData);
|
||||
const allTransactionsAmount = getTotalAmountFromPeriod(periodData);
|
||||
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 fixedPricingAmount = Object.keys(fixedDepartmentDistribution).length > 0
|
||||
? sumDistributionMap(fixedDepartmentDistribution)
|
||||
: 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 = Object.keys(subscriptionDepartmentDistribution).length > 0
|
||||
? sumDistributionMap(subscriptionDepartmentDistribution)
|
||||
: 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,
|
||||
@@ -389,8 +427,10 @@ export const buildMonthSummary = ({
|
||||
'total_amount',
|
||||
],
|
||||
});
|
||||
const distributionAmount = safeRoundCurrency(fixedPricingAmount + subscriptionAmount + customerPriceAmount);
|
||||
const otherBookedAmount = safeRoundCurrency(Math.max(bookedAmount - distributionAmount, 0));
|
||||
const distributionAmount = safeRoundCurrency(fixedPricingAmount + subscriptionAmount);
|
||||
const otherBookedAmount = safeRoundCurrency(
|
||||
Math.max(bookedAmount - distributionAmount - customerPriceAmount, 0)
|
||||
);
|
||||
|
||||
return {
|
||||
year,
|
||||
@@ -414,17 +454,16 @@ export const buildSourceComposition = (summary = {}) => {
|
||||
const fixedAmount = safeRoundCurrency(summary?.fixedPricingAmount);
|
||||
const subscriptionAmount = safeRoundCurrency(summary?.subscriptionAmount);
|
||||
const customerPriceAmount = safeRoundCurrency(summary?.customerPriceAmount);
|
||||
const distributionAmount = safeRoundCurrency(
|
||||
summary?.distributionAmount || fixedAmount + subscriptionAmount + customerPriceAmount
|
||||
);
|
||||
const fixedPercent = distributionAmount > 0
|
||||
? safeRoundCurrency((fixedAmount / distributionAmount) * 100)
|
||||
const distributionAmount = safeRoundCurrency(summary?.distributionAmount || fixedAmount + subscriptionAmount);
|
||||
const sourceTotalAmount = safeRoundCurrency(fixedAmount + subscriptionAmount + customerPriceAmount);
|
||||
const fixedPercent = sourceTotalAmount > 0
|
||||
? safeRoundCurrency((fixedAmount / sourceTotalAmount) * 100)
|
||||
: 0;
|
||||
const subscriptionPercent = distributionAmount > 0
|
||||
? safeRoundCurrency((subscriptionAmount / distributionAmount) * 100)
|
||||
const subscriptionPercent = sourceTotalAmount > 0
|
||||
? safeRoundCurrency((subscriptionAmount / sourceTotalAmount) * 100)
|
||||
: 0;
|
||||
const customerPricePercent = distributionAmount > 0
|
||||
? safeRoundCurrency((customerPriceAmount / distributionAmount) * 100)
|
||||
const customerPricePercent = sourceTotalAmount > 0
|
||||
? safeRoundCurrency((customerPriceAmount / sourceTotalAmount) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
@@ -432,6 +471,7 @@ export const buildSourceComposition = (summary = {}) => {
|
||||
subscriptionAmount,
|
||||
customerPriceAmount,
|
||||
distributionAmount,
|
||||
sourceTotalAmount,
|
||||
fixedPercent,
|
||||
subscriptionPercent,
|
||||
customerPricePercent,
|
||||
@@ -466,18 +506,8 @@ export const buildDepartmentAllocations = ({
|
||||
const subscriptionCollective = getCollectiveSubscriptionResults(subscriptionDistribution);
|
||||
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, [
|
||||
getCollectiveDepartmentDistributionMap(fixedCollective, [
|
||||
'total_department_totals_relative_parsed',
|
||||
'total_department_totals_parsed',
|
||||
'department_distribution_parsed',
|
||||
@@ -488,7 +518,7 @@ export const buildDepartmentAllocations = ({
|
||||
departments
|
||||
);
|
||||
const subscriptionByName = normalizeDepartmentAllocationToNames(
|
||||
getCollectiveDepartmentMap(subscriptionCollective, [
|
||||
getCollectiveDepartmentDistributionMap(subscriptionCollective, [
|
||||
'subscription_price_department_distribution_parsed',
|
||||
'subscription_price_department_distribution',
|
||||
'total_department_totals_relative_parsed',
|
||||
@@ -500,7 +530,7 @@ export const buildDepartmentAllocations = ({
|
||||
departments
|
||||
);
|
||||
const customerPriceByName = normalizeDepartmentAllocationToNames(
|
||||
getCollectiveDepartmentMap(customerPriceCollective, [
|
||||
getCollectiveDepartmentDistributionMap(customerPriceCollective, [
|
||||
'customer_price_department_distribution_parsed',
|
||||
'customer_prices_department_distribution_parsed',
|
||||
'discount_department_distribution_parsed',
|
||||
@@ -529,14 +559,13 @@ 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 customerPriceAmount = safeRoundCurrency(customerPriceByName[name]);
|
||||
const totalAmount = safeRoundCurrency(fixedAmount + subscriptionAmount + customerPriceAmount);
|
||||
const totalAmount = safeRoundCurrency(fixedAmount + subscriptionAmount);
|
||||
const ratio = totalSharedAmount > 0 ? (totalAmount / totalSharedAmount) : 0;
|
||||
|
||||
rows.push({
|
||||
@@ -555,6 +584,26 @@ export const buildDepartmentAllocations = ({
|
||||
return rows.sort((a, b) => b.totalAmount - a.totalAmount);
|
||||
};
|
||||
|
||||
export const buildDepartmentAllocationFooterTotals = (rows = []) => {
|
||||
const safeRows = Array.isArray(rows) ? rows : [];
|
||||
const fixedAmount = safeRoundCurrency(
|
||||
safeRows.reduce((sum, row) => sum + toNumber(row?.fixedAmount), 0)
|
||||
);
|
||||
const subscriptionAmount = safeRoundCurrency(
|
||||
safeRows.reduce((sum, row) => sum + toNumber(row?.subscriptionAmount), 0)
|
||||
);
|
||||
const customerPriceAmount = safeRoundCurrency(
|
||||
safeRows.reduce((sum, row) => sum + toNumber(row?.customerPriceAmount), 0)
|
||||
);
|
||||
|
||||
return {
|
||||
fixedAmount,
|
||||
subscriptionAmount,
|
||||
customerPriceAmount,
|
||||
totalAmount: safeRoundCurrency(fixedAmount + subscriptionAmount),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeDepartmentAllocationMap = (value) => {
|
||||
return normalizeObjectNumberMap(value);
|
||||
};
|
||||
|
||||
@@ -151,7 +151,9 @@ describe('invoice distribution api adapter', () => {
|
||||
const result = await fetchMonthData(2026, 3);
|
||||
expect(result.fallback.usedLegacyDistribution).toBe(false);
|
||||
expect(result.fallback.usedSplitV2Distribution).toBe(false);
|
||||
expect(result.summary.distributionAmount).toBe(200);
|
||||
expect(result.summary.distributionAmount).toBe(150);
|
||||
expect(result.summary.customerPriceAmount).toBe(50);
|
||||
expect(result.summary.totalAmount).toBe(350);
|
||||
expect(SessionUser.request).toHaveBeenCalledWith('/superuser/invoicing/period/distribution/v2/all', 'GET', expect.any(Object));
|
||||
});
|
||||
|
||||
@@ -192,7 +194,9 @@ describe('invoice distribution api adapter', () => {
|
||||
expect(result.fallback.usedLegacyDistribution).toBe(false);
|
||||
expect(result.fallback.usedSplitV2Distribution).toBe(true);
|
||||
expect(result.fallback.distributionFallbackReason).toContain('v2 all unavailable');
|
||||
expect(result.summary.distributionAmount).toBe(90);
|
||||
expect(result.summary.distributionAmount).toBe(70);
|
||||
expect(result.summary.customerPriceAmount).toBe(20);
|
||||
expect(result.summary.totalAmount).toBe(190);
|
||||
});
|
||||
|
||||
it('falls back to legacy distribution endpoints when all v2 paths fail', async () => {
|
||||
@@ -266,6 +270,8 @@ describe('invoice distribution api adapter', () => {
|
||||
expect(result.fallback.distributionFallbackReason).toContain('/superuser/invoicing/period/distribution/v2/all unavailable');
|
||||
expect(result.summary.fixedPricingAmount).toBe(50);
|
||||
expect(result.summary.subscriptionAmount).toBe(25);
|
||||
expect(result.summary.distributionAmount).toBe(75);
|
||||
expect(result.summary.totalAmount).toBe(195);
|
||||
});
|
||||
|
||||
it('chunks v2 bulk compare requests to max 200 ids', async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getMonthRange,
|
||||
buildMonthSummary,
|
||||
buildDepartmentAllocations,
|
||||
buildDepartmentAllocationFooterTotals,
|
||||
buildCustomerAllocationRows,
|
||||
buildMonthComparison,
|
||||
buildSourceComposition,
|
||||
@@ -62,9 +63,56 @@ describe('invoice distribution calculations', () => {
|
||||
expect(summary.fixedPricingAmount).toBe(40);
|
||||
expect(summary.subscriptionAmount).toBe(20);
|
||||
expect(summary.customerPriceAmount).toBe(15);
|
||||
expect(summary.distributionAmount).toBe(75);
|
||||
expect(summary.distributionAmount).toBe(60);
|
||||
expect(summary.otherBookedAmount).toBe(25);
|
||||
expect(summary.totalAmount).toBe(175);
|
||||
expect(summary.totalAmount).toBe(160);
|
||||
});
|
||||
|
||||
it('prefers actually distributed fixed and subscription amounts for summary totals', () => {
|
||||
const summary = buildMonthSummary({
|
||||
year: 2026,
|
||||
month: 2,
|
||||
periodData: {
|
||||
types: {
|
||||
all: [
|
||||
{
|
||||
transactions: [
|
||||
{ amount: 200, booked: true, excluded: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
fixedDistribution: {
|
||||
collective_results: {
|
||||
total_fixed_price: 50,
|
||||
total_department_totals_relative_parsed: {
|
||||
Alpha: 30,
|
||||
Beta: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
subscriptionDistribution: {
|
||||
collective_results: {
|
||||
total_subscription_price: 120,
|
||||
subscription_price_department_distribution_parsed: {
|
||||
Alpha: 70,
|
||||
Beta: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
customerPriceDistribution: {
|
||||
collective_results: {
|
||||
total_customer_price: 15,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary.fixedPricingAmount).toBe(40);
|
||||
expect(summary.subscriptionAmount).toBe(90);
|
||||
expect(summary.customerPriceAmount).toBe(15);
|
||||
expect(summary.distributionAmount).toBe(130);
|
||||
expect(summary.totalAmount).toBe(330);
|
||||
});
|
||||
|
||||
it('builds department allocations with percentages and booked estimate', () => {
|
||||
@@ -105,8 +153,33 @@ describe('invoice distribution calculations', () => {
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].departmentName).toBe('Alpha');
|
||||
expect(rows[0].totalAmount).toBe(90);
|
||||
expect(rows[0].sharePercent).toBe(64.29);
|
||||
expect(rows[0].estimatedBookedAllocation).toBe(192.86);
|
||||
expect(rows[0].sharePercent).toBe(69.23);
|
||||
expect(rows[0].estimatedBookedAllocation).toBe(207.69);
|
||||
expect(rows[1].departmentName).toBe('Beta');
|
||||
expect(rows[1].customerPriceAmount).toBe(10);
|
||||
expect(rows[1].totalAmount).toBe(40);
|
||||
});
|
||||
|
||||
it('builds department footer totals without adding customer prices to the total column', () => {
|
||||
const totals = buildDepartmentAllocationFooterTotals([
|
||||
{
|
||||
fixedAmount: 120000,
|
||||
subscriptionAmount: 80000,
|
||||
customerPriceAmount: 15000,
|
||||
totalAmount: 215000,
|
||||
},
|
||||
{
|
||||
fixedAmount: 40000,
|
||||
subscriptionAmount: 57000,
|
||||
customerPriceAmount: 12000,
|
||||
totalAmount: 109000,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(totals.fixedAmount).toBe(160000);
|
||||
expect(totals.subscriptionAmount).toBe(137000);
|
||||
expect(totals.customerPriceAmount).toBe(27000);
|
||||
expect(totals.totalAmount).toBe(297000);
|
||||
});
|
||||
|
||||
it('builds customer rows and respects creation date filter', () => {
|
||||
@@ -196,6 +269,7 @@ describe('invoice distribution calculations', () => {
|
||||
expect(normalized).toEqual({
|
||||
compareMonth: '2026-02',
|
||||
compareMode: 'line_by_line',
|
||||
compareVisibility: 'all',
|
||||
customerSearch: 'acme',
|
||||
customerSource: 'customer_prices',
|
||||
customerDepartment: 'Ops',
|
||||
@@ -241,7 +315,7 @@ describe('invoice distribution calculations', () => {
|
||||
fixedPricingAmount: 60,
|
||||
subscriptionAmount: 40,
|
||||
customerPriceAmount: 20,
|
||||
distributionAmount: 120,
|
||||
distributionAmount: 100,
|
||||
});
|
||||
expect(composition.fixedPercent).toBe(50);
|
||||
expect(composition.subscriptionPercent).toBe(33.33);
|
||||
|
||||
@@ -112,6 +112,13 @@ describe('Distribution monthly view contract', () => {
|
||||
expect(distributionMonthSource).toContain('compareFallback.usedSingleCompareFallback');
|
||||
});
|
||||
|
||||
it('keeps department footer totals excluding customer prices from the total column', () => {
|
||||
expect(distributionMonthSource).toContain('buildDepartmentAllocationFooterTotals');
|
||||
expect(distributionMonthSource).toContain('class="department-table__footer"');
|
||||
expect(distributionMonthSource).toContain('departmentFooterTotals.totalAmount');
|
||||
expect(distributionMonthSource).toContain('has-text-grey">-</td>');
|
||||
});
|
||||
|
||||
it('keeps compare mode values and progress visibility', () => {
|
||||
expect(distributionMonthSource).toContain("const compareMode = ref('invoice_total')");
|
||||
expect(distributionMonthSource).toContain("DISTRIBUTION_COMPARE_MODES");
|
||||
|
||||
Reference in New Issue
Block a user