From 4e0dc1b385bcc507a852c1a038f2695f830ec89b Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 11 Nov 2026 08:40:37 +1100 Subject: [PATCH] feat: filter customer revenue by offering type (PM-6740) --- SALES.md | 13 + src/reports/dashboard/sales-dashboard.dto.ts | 35 ++- .../dashboard/sales-dashboard.service.ts | 109 +++++--- src/reports/dashboard/sales-dashboard.spec.ts | 237 ++++++++++++++++++ 4 files changed, 351 insertions(+), 43 deletions(-) diff --git a/SALES.md b/SALES.md index 0a092e3..3fe8f4e 100644 --- a/SALES.md +++ b/SALES.md @@ -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 diff --git a/src/reports/dashboard/sales-dashboard.dto.ts b/src/reports/dashboard/sales-dashboard.dto.ts index a612fdd..fb80271 100644 --- a/src/reports/dashboard/sales-dashboard.dto.ts +++ b/src/reports/dashboard/sales-dashboard.dto.ts @@ -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; @@ -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 + >; +} + /** 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. */ @@ -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 + customerRevenueByOfferingType?: Partial< + Record >; } diff --git a/src/reports/dashboard/sales-dashboard.service.ts b/src/reports/dashboard/sales-dashboard.service.ts index c981ec1..9e07031 100644 --- a/src/reports/dashboard/sales-dashboard.service.ts +++ b/src/reports/dashboard/sales-dashboard.service.ts @@ -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, @@ -26,6 +29,8 @@ import { } from "./dashboard-reports.service"; import { SALES_ACCOUNT_CATEGORIES, + SALES_REVENUE_OFFERING_TYPES, + SalesCustomerRevenueDto, SalesDashboardChartDto, SalesDashboardDto, SalesDashboardQueryDto, @@ -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) + ); + }), ), ]), ) @@ -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) => { @@ -609,6 +645,7 @@ export class SalesDashboardService implements OnModuleDestroy { charts, customerRevenue, customerRevenueByAccountCategory, + customerRevenueByOfferingType, notices, filterOptions: filtered.options, }; diff --git a/src/reports/dashboard/sales-dashboard.spec.ts b/src/reports/dashboard/sales-dashboard.spec.ts index c9d1b6d..be26a87 100644 --- a/src/reports/dashboard/sales-dashboard.spec.ts +++ b/src/reports/dashboard/sales-dashboard.spec.ts @@ -9,6 +9,7 @@ import { DbService } from "../../db/db.service"; import { SalesReportDto } from "../sales/sales-reports.dto"; import { SalesReportsService } from "../sales/sales-reports.service"; import { + SalesDashboardGroupBy, SalesDashboardQueryDto, SalesDashboardSection, } from "./sales-dashboard.dto"; @@ -1107,6 +1108,242 @@ describe("Monthly sales dashboards", () => { expect(total(result.charts[0])).toBe(130); }); + it("filters customer revenue by offering and account category before aggregation, including blank-offering corrections", async () => { + const report = { + allData: true, + reportName: "Revenue", + columns: [ + { + id: "PSA_Revenue_Recognition__c.Date__c", + label: "Date", + dataType: "date", + }, + { + id: "PSA_Revenue_Recognition__c.Amount__c", + label: "Gross Amount", + dataType: "currency", + }, + { + id: "offering", + label: "Project Stream: Engagement Offering Type", + dataType: "string", + }, + { + id: "accountCategory", + label: "Account Category", + dataType: "string", + }, + { id: "customer", label: "Reporting Account", dataType: "string" }, + { + id: "project", + label: "Project Stream: Opportunity/Project Name", + dataType: "string", + }, + ], + rows: [ + [ + "2026-09-01", + 100.125, + "AI Training", + "External Revenue", + "Shared", + "Project", + ], + [ + "2026-09-01", + -25, + "AI Training", + "External Revenue", + "Shared", + "Project", + ], + ["2026-10-01", 10, "AI Training", "Through Wipro", "Shared", "Project"], + [ + "2026-09-01", + 20, + " ai training ", + "External Revenue", + "Second", + "Project", + ], + [ + "2026-09-01", + 30, + "AI Training", + "Wipro Internal - Ignore", + "Third", + "TopGear", + ], + [ + "2026-09-01", + 200, + "Flexible Talent", + "External Revenue", + "Shared", + "Project", + ], + [ + "2026-09-01", + 40, + null, + "Wipro Internal - Ignore", + "TopGear customer", + "TopGear", + ], + [ + "2026-09-01", + 5, + "TopGear Ops", + "Wipro Internal", + "TopGear customer", + "Project", + ], + [ + "2026-09-01", + 6, + "TopGear", + "Wipro Internal", + "TopGear customer", + "Project", + ], + [ + "2026-09-01", + 50, + "", + "External Revenue", + "Talent customer", + "Multiple App Enhancement 5", + ], + [ + "2026-09-01", + 60, + "-", + "Through Wipro", + "Talent customer", + "Multiple App Enhancements 5", + ], + [ + "2026-09-01", + 70, + "Crowd-Powered Outcomes", + "External Revenue", + "Outcomes customer", + "Project", + ], + [ + "2026-09-01", + 80, + "Crowd Powered Innovation", + "External Revenue", + "Innovation customer", + "Project", + ], + [ + "2026-09-01", + 90, + "TaaS Ops", + "External Revenue", + "TaaS customer", + "Project", + ], + ["2026-09-01", 1, "", "", "Unknown offering", "Other project"], + [ + "2026-11-01", + 999, + "AI Training", + "External Revenue", + "Out of range", + "Project", + ], + ].map((cells) => ({ + cells: cells.map((value) => ({ + value, + label: value == null ? "" : String(value), + })), + })), + }; + getDashboardSnapshot.mockResolvedValue(report); + const result = await service().load(SalesDashboardSection.Revenue, range); + const scopes = result.customerRevenueByOfferingType!; + const totals = (chart: typeof result.customerRevenue) => + chart!.months.map((month) => + Object.values(month.values).reduce( + (sum, value) => sum + (value ?? 0), + 0, + ), + ); + const ai = scopes["AI Training"]!; + expect( + ai.customerRevenue!.series.map((series) => series.label).sort(), + ).toEqual(["Second", "Shared", "Third"]); + expect(totals(ai.customerRevenue)).toEqual([0, 125.13, 10]); + expect( + totals(ai.customerRevenueByAccountCategory!["External Revenue"]), + ).toEqual([0, 95.13, 0]); + expect( + totals(ai.customerRevenueByAccountCategory!["Through Wipro"]), + ).toEqual([0, 0, 10]); + expect( + totals(ai.customerRevenueByAccountCategory!["Wipro Internal"]), + ).toEqual([0, 30, 0]); + expect(totals(scopes.TopGear!.customerRevenue)).toEqual([0, 51, 0]); + expect(totals(scopes["Flexible Talent"]!.customerRevenue)).toEqual([ + 0, 310, 0, + ]); + expect(totals(scopes["Crowd Powered Outcomes"]!.customerRevenue)).toEqual([ + 0, 70, 0, + ]); + expect(totals(scopes["Crowd Powered Innovation"]!.customerRevenue)).toEqual( + [0, 80, 0], + ); + expect(totals(scopes["TaaS Ops"]!.customerRevenue)).toEqual([0, 90, 0]); + expect( + scopes["TaaS Ops"]!.customerRevenueByAccountCategory!["Through Wipro"]! + .series, + ).toEqual([]); + expect(totals(result.customerRevenue)).toEqual([0, 727.13, 10]); + expect(totals(result.charts[0])).toEqual(totals(result.customerRevenue)); + + // Independent chart breakdowns and configured field IDs keep the same matrices. + const byDeal = await service().load(SalesDashboardSection.Revenue, { + ...range, + groupBy: SalesDashboardGroupBy.DealCategory, + }); + expect(byDeal.customerRevenueByOfferingType).toEqual(scopes); + report.columns[2].label = "Custom offering field"; + const configured = await service({ + SALESFORCE_REVENUE_OFFERING_COLUMN: "offering", + }).load(SalesDashboardSection.Revenue, range); + expect(configured.customerRevenueByOfferingType).toEqual(scopes); + + // A missing column is unavailable, while a supported offering with no rows is empty. + const unavailable = await service().load( + SalesDashboardSection.Revenue, + range, + ); + expect(unavailable.customerRevenueByOfferingType).toBeUndefined(); + report.columns[2].label = "Engagement Offering Type"; + report.rows = []; + const empty = await service().load(SalesDashboardSection.Revenue, range); + expect( + empty.customerRevenueByOfferingType!["AI Training"]!.customerRevenue! + .series, + ).toEqual([]); + report.columns[3].label = "Missing account category"; + const missingAccount = await service().load( + SalesDashboardSection.Revenue, + range, + ); + expect( + missingAccount.customerRevenueByOfferingType!["AI Training"]! + .customerRevenue, + ).toBeDefined(); + expect( + missingAccount.customerRevenueByOfferingType!["AI Training"]! + .customerRevenueByAccountCategory, + ).toBeUndefined(); + }); + it("preserves unknown markup and exposes excluded unknown-payment counts", async () => { query.mockResolvedValue([ { month: "2026-09-01", markup: "25", missing: "2" },