Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions SALES.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,19 @@ and request parameters remain compatible; no database migration or new configura
empty matrices. Missing source columns omit this optional map; the UI reports unavailable data.
`customerRevenue` retains all categories, including unknowns, and revenue charts remain unchanged.

- PM-6740 adds optional `customerRevenueByOfferingType`, keyed by `AI Training`,
`Crowd Powered Outcomes`, `Crowd Powered Innovation`, `Flexible Talent`, `TopGear`
and `TaaS Ops`. Each value contains `customerRevenue` and, when Account Category
is available, `customerRevenueByAccountCategory` with the same structure as the
top-level datasets. Offering matching ignores case and hyphen/space differences;
`TopGear` includes `TopGear Ops`. The existing blank-offering corrections are
applied before filtering: TopGear → TopGear Ops, Multiple App Enhancement(s) 5 →
Flexible Talent. Populated offerings remain authoritative. Rows are filtered
before customer aggregation, retaining credits, all customers and zero-filled
months. Missing offering/customer columns omit the map; valid selections without
rows return empty matrices. ALL uses the unchanged top-level datasets, retaining
unknown/other offerings. Chart breakdown selection does not change these scopes.

Tests: `pnpm test --runInBand sales-dashboard`. To include PostgreSQL aggregation tests, point
`SALES_DASHBOARD_TEST_DB_URL` at a disposable database named `sales_dashboard_test`; the suite
creates and removes its own test schemas. Tests exercise duplicate payments, members recurring
Expand Down
35 changes: 28 additions & 7 deletions src/reports/dashboard/sales-dashboard.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,18 @@ export const SALES_ACCOUNT_CATEGORIES = [
] as const;
export type SalesAccountCategory = (typeof SALES_ACCOUNT_CATEGORIES)[number];

/** Customer revenue filters; TopGear includes Salesforce's TopGear Ops offering. */
export const SALES_REVENUE_OFFERING_TYPES = [
"AI Training",
"Crowd Powered Outcomes",
"Crowd Powered Innovation",
"Flexible Talent",
"TopGear",
"TaaS Ops",
] as const;
export type SalesRevenueOfferingType =
(typeof SALES_REVENUE_OFFERING_TYPES)[number];

/** One zero-filled monthly chart, with nullable values for unknown percentages. */
export class SalesDashboardChartDto {
@ApiProperty() id: string;
Expand All @@ -128,8 +140,20 @@ export class SalesDashboardChartDto {
}>;
}

/** Complete customer revenue matrices for one offering scope (or all offerings). */
export class SalesCustomerRevenueDto {
/** All customer names from the revenue report, without top-category/Other grouping. */
@ApiPropertyOptional({ type: SalesDashboardChartDto })
customerRevenue?: SalesDashboardChartDto;
/** Full customer matrices filtered by Account Category, independent of chart breakdown. */
@ApiPropertyOptional({ type: Object })
customerRevenueByAccountCategory?: Partial<
Record<SalesAccountCategory, SalesDashboardChartDto>
>;
}

/** Independently loaded section; notices expose missing source data without fabricating metrics. */
export class SalesDashboardDto {
export class SalesDashboardDto extends SalesCustomerRevenueDto {
@ApiProperty({ type: [SalesDashboardChartDto] })
charts: SalesDashboardChartDto[];
/** Unfiltered source categories so selecting one value does not remove the other choices. */
Expand All @@ -143,12 +167,9 @@ export class SalesDashboardDto {
/** Full customer series for the payments matrix, before top-five/Other grouping. */
@ApiPropertyOptional({ type: SalesDashboardChartDto })
customerPayments?: SalesDashboardChartDto;
/** All customer names from the revenue report, without top-category/Other grouping. */
@ApiPropertyOptional({ type: SalesDashboardChartDto })
customerRevenue?: SalesDashboardChartDto;
/** Full customer matrices filtered by Account Category, independent of chart breakdown. */
/** Offering-scoped customer matrices, including intersections with Account Category. */
@ApiPropertyOptional({ type: Object })
customerRevenueByAccountCategory?: Partial<
Record<SalesAccountCategory, SalesDashboardChartDto>
customerRevenueByOfferingType?: Partial<
Record<SalesRevenueOfferingType, SalesCustomerRevenueDto>
>;
}
109 changes: 73 additions & 36 deletions src/reports/dashboard/sales-dashboard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { ConfigService } from "@nestjs/config";
import { Pool } from "pg";
import { SqlLoaderService } from "../../common/sql-loader.service";
import { DbService } from "../../db/db.service";
import { customerTypeLabel } from "../sales/sales-category.utils";
import {
categoryValue,
customerTypeLabel,
} from "../sales/sales-category.utils";
import { SalesRowDto } from "../sales/sales-reports.dto";
import {
DashboardDateFilter,
Expand All @@ -26,6 +29,8 @@ import {
} from "./dashboard-reports.service";
import {
SALES_ACCOUNT_CATEGORIES,
SALES_REVENUE_OFFERING_TYPES,
SalesCustomerRevenueDto,
SalesDashboardChartDto,
SalesDashboardDto,
SalesDashboardQueryDto,
Expand Down Expand Up @@ -541,43 +546,70 @@ export class SalesDashboardService implements OnModuleDestroy {
]
.map((label) => categoryColumn(report, label))
.find((index) => index >= 0) ?? -1);
const customerRevenue =
customerIndex >= 0
? revenueBreakdown(
range,
entries,
(entry) =>
categoryLabel(entry.row, customerIndex) || "Unknown customer",
{
id: "revenue-customer-all",
title: "Revenue Customer-Wise",
description: `All customers by ${report.columns[customerIndex].label}, with monthly revenue and annual totals.`,
},
Infinity,
)
: undefined;
const accountCategoryIndex = categoryColumn(report, /^account category$/i);
const customerRevenueByAccountCategory =
customerIndex >= 0 && accountCategoryIndex >= 0
// Build each matrix from matching detail rows, so customers with multiple
// offerings contribute only the selected offering's recognized revenue.
const customerMatrices = (
scopedEntries: RevenueEntry[],
): SalesCustomerRevenueDto => {
if (customerIndex < 0) return {};
const matrix = (rows: RevenueEntry[], description: string) =>
revenueBreakdown(
range,
rows,
(entry) =>
categoryLabel(entry.row, customerIndex) || "Unknown customer",
{
id: "revenue-customer-all",
title: "Revenue Customer-Wise",
description,
},
Infinity,
);
return {
customerRevenue: matrix(
scopedEntries,
`All customers by ${report.columns[customerIndex].label}, with monthly revenue and annual totals.`,
),
customerRevenueByAccountCategory:
accountCategoryIndex >= 0
? Object.fromEntries(
SALES_ACCOUNT_CATEGORIES.map((category) => [
category,
matrix(
scopedEntries.filter(
(entry) =>
categoryLabel(entry.row, accountCategoryIndex)
.replace(/\s*-\s*ignore$/i, "")
.toLowerCase() === category.toLowerCase(),
),
`Customer revenue for ${category}.`,
),
]),
)
: undefined,
};
};
const { customerRevenue, customerRevenueByAccountCategory } =
customerMatrices(entries);
const offeringIndex = columns.offering
? report.columns.findIndex((column) => column.id === columns.offering)
: categoryColumn(report, CATEGORY_LABELS.offering);
const customerRevenueByOfferingType =
customerIndex >= 0 && offeringIndex >= 0
? Object.fromEntries(
SALES_ACCOUNT_CATEGORIES.map((category) => [
category,
revenueBreakdown(
range,
entries.filter(
(entry) =>
categoryLabel(entry.row, accountCategoryIndex)
.replace(/\s*-\s*ignore$/i, "")
.toLowerCase() === category.toLowerCase(),
),
(entry) =>
categoryLabel(entry.row, customerIndex) || "Unknown customer",
{
id: "revenue-customer-all",
title: "Revenue Customer-Wise",
description: `Customer revenue for ${category}.`,
},
Infinity,
SALES_REVENUE_OFFERING_TYPES.map((offering) => [
offering,
customerMatrices(
entries.filter((entry) => {
const label = categoryValue(
offeringLabel(entry.row, offeringIndex, projectIndex),
);
return (
(label === "topgear ops" ? "topgear" : label) ===
categoryValue(offering)
);
}),
),
]),
)
Expand All @@ -590,6 +622,10 @@ export class SalesDashboardService implements OnModuleDestroy {
...charts,
...(customerRevenue ? [customerRevenue] : []),
...Object.values(customerRevenueByAccountCategory ?? {}),
...Object.values(customerRevenueByOfferingType ?? {}).flatMap((scope) => [
...(scope.customerRevenue ? [scope.customerRevenue] : []),
...Object.values(scope.customerRevenueByAccountCategory ?? {}),
]),
].forEach((chart) =>
chart.months.forEach((month) =>
Object.keys(month.values).forEach((key) => {
Expand All @@ -609,6 +645,7 @@ export class SalesDashboardService implements OnModuleDestroy {
charts,
customerRevenue,
customerRevenueByAccountCategory,
customerRevenueByOfferingType,
notices,
filterOptions: filtered.options,
};
Expand Down
Loading
Loading