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:
Jeppe Bundgaard
2026-03-16 13:04:56 +01:00
parent aba9f63788
commit 412339744f
5 changed files with 218 additions and 45 deletions
@@ -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;
@@ -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);
};
+8 -2
View File
@@ -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");