From cfe6f3ed61613068cc266ce0f193a6ef6df45b44 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 6 Nov 2026 13:33:10 +1100 Subject: [PATCH 1/2] Use deployment scripts v2.0.10 with per-image scan budgets --- .circleci/config.yml | 6 +++--- deploy/MIGRATION.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index ec294b7..8bfefde 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -21,14 +21,14 @@ commands: checkout-suite: steps: - run: - name: Verify deployment suite v2.0.6 + name: Verify deployment suite v2.0.10 command: | set -euo pipefail git init --quiet /tmp/tc-deploy-scripts git -C /tmp/tc-deploy-scripts remote add origin https://github.com/topcoder-platform/tc-deploy-scripts.git - git -C /tmp/tc-deploy-scripts fetch --quiet --depth 1 origin refs/tags/v2.0.6 + git -C /tmp/tc-deploy-scripts fetch --quiet --depth 1 origin refs/tags/v2.0.10 git -C /tmp/tc-deploy-scripts checkout --quiet --detach FETCH_HEAD - test "$(git -C /tmp/tc-deploy-scripts rev-parse HEAD)" = 26aaf607463d4a271c0099854c1731214215a9c5 + test "$(git -C /tmp/tc-deploy-scripts rev-parse HEAD)" = e81a6772a33819c1b57c88a95d04494e59c559b8 jobs: build: executor: reviewed diff --git a/deploy/MIGRATION.md b/deploy/MIGRATION.md index db8d633..95cd09f 100644 --- a/deploy/MIGRATION.md +++ b/deploy/MIGRATION.md @@ -1,6 +1,6 @@ # OIDC deployment migration -This change replaces the Auth0 credential broker and SSM-to-shell deployment path with [tc-deploy-scripts v2.0.6](https://github.com/topcoder-platform/tc-deploy-scripts/releases/tag/v2.0.6), fetched by tag and verified against commit `26aaf607463d4a271c0099854c1731214215a9c5`. Build and deployment run in separate jobs. The deployer cannot read Parameter Store, Secrets Manager or decrypt KMS data, including through another assumed role. ECS continues resolving the existing runtime secret references using the execution role. +This change replaces the Auth0 credential broker and SSM-to-shell deployment path with [tc-deploy-scripts v2.0.10](https://github.com/topcoder-platform/tc-deploy-scripts/releases/tag/v2.0.10), fetched by tag and verified against commit `e81a6772a33819c1b57c88a95d04494e59c559b8`. Build and deployment run in separate jobs. The deployer cannot read Parameter Store, Secrets Manager or decrypt KMS data, including through another assumed role. ECS continues resolving the existing runtime secret references using the execution role. ## Production target @@ -28,10 +28,10 @@ IAM templates and detailed AWS baselines are maintained in the private [v6-servi ## Dev branch refresh -This PR targets `develop` and preserves its current application/dependency fixes. The shared suite is v2.0.6 (92 offline tests); service-specific adapters and their tests remain part of review. Use the current readiness record above for the remaining merge/deployment gates. Updating these files does not provision AWS or CircleCI resources. +This PR targets `develop` and preserves its current application/dependency fixes. The shared suite is v2.0.10 (92 offline tests); service-specific adapters and their tests remain part of review. Use the current readiness record above for the remaining merge/deployment gates. Updating these files does not provision AWS or CircleCI resources. Source checkout uses the public HTTPS repository and verifies the exact CircleCI commit, avoiding dependency on failing SSH checkout keys. No GitHub token is added to build or deployment contexts. ## Reviewed health-check bootstrap -The release wrapper passes the environment-specific health-check JSON to v2.0.6. A missing check is added with the new scanned image; the old image is not restarted with a new probe. An identical existing check is preserved, and a different existing check stops the release. Role, runtime-secret reference, scan, concurrency and healthy-digest checks remain enforced. +The release wrapper passes the environment-specific health-check JSON to v2.0.10. A missing check is added with the new scanned image; the old image is not restarted with a new probe. An identical existing check is preserved, and a different existing check stops the release. Role, runtime-secret reference, scan, concurrency and healthy-digest checks remain enforced. From 4e0dc1b385bcc507a852c1a038f2695f830ec89b Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 11 Nov 2026 08:40:37 +1100 Subject: [PATCH 2/2] 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" },