diff --git a/SALES.md b/SALES.md index c444926..4055cd1 100644 --- a/SALES.md +++ b/SALES.md @@ -44,6 +44,7 @@ Both endpoints accept the same query parameters: | `page`, `perPage` | One-based page (default 1); page size 1–200 (default 25) | | `search` | Case-insensitive substring across all displayed cells, up to 200 characters | | `filterColumn`, `filterValue` | Column ID and case-insensitive displayed-value substring; supply both | +| `drilldownColumn`, `drilldownValue` | Column ID and exact, case-insensitive displayed value; narrows the returned page only, never `summary`; supply both | | `sortBy`, `sortOrder` | Column ID and `asc`/`desc`; numeric and ISO date values sort before pagination | | `dateColumn` | Column ID of a `date`/`datetime` column, such as Created Date or Close Date | | `dateFrom`, `dateTo` | Inclusive `YYYY-MM-DD` bounds; either or both, and both require `dateColumn` | @@ -63,9 +64,20 @@ HTML formulas are projected to text; Forecast Alert uses its image's alt label without fetching a protected Salesforce image. Filtering and sorting operate over the complete **received snapshot**, before -pagination. `total` is the matching received-row count; `sourceRowCount` is its -unfiltered count. Out-of-range pages clamp to the final available page. Empty -reports return zero rows and `totalPages: 0`, `page: 1`. +pagination. `total` is the returned received-row count, after any drilldown; +`sourceRowCount` is its unfiltered count. Out-of-range pages clamp to the final +available page. Empty reports return zero rows and `totalPages: 0`, `page: 1`. + +### Drilldown (PM-6392) + +`drilldownColumn`/`drilldownValue` narrow `rows`, `total` and `totalPages` to the +rows whose displayed value in that column equals `drilldownValue` exactly, ignoring +case and surrounding whitespace. Unlike `filterColumn`/`filterValue` it is applied +**after** aggregation, so `summary` continues to describe the whole filtered set. +That lets a dashboard drill into one `summary.groups[].buckets[]` entry — a pipeline +stage, say — while every bucket stays on screen to be clicked next. A drilldown +therefore makes `summary.recordCount` larger than `total`. Supplying one half of the +pair, or a column ID the report does not define, returns `400`. ### Date range filtering (PM-6364) @@ -91,11 +103,14 @@ page, so counts and totals stay correct under pagination: | Field | Meaning | | --- | --- | -| `recordCount` | Matching rows; always equal to `total` | +| `recordCount` | Matching rows; equal to `total` unless a drilldown narrows the page | | `amounts[]` | One entry per `currency`/`double` column: `columnId`, `label`, `total`, contributing `count`, and `currencyCode` when the contributing rows agree | -| `groups[]` | Up to three `picklist`/`multipicklist`/`combobox`/`boolean` columns broken into `buckets[{label,count,total}]`, ordered by total then count, capped at 25 with the remainder in `otherBuckets` | +| `groups[]` | Up to three `picklist`/`multipicklist`/`combobox`/`boolean` columns broken into `buckets[{label,count,total,amounts[]}]`, ordered by total then count, capped at 25 with the remainder in `otherBuckets` | -Bucket totals use the report's first amount column, named in `amountColumnId`. +Bucket `total` uses the report's first amount column, named in `amountColumnId`, +while `buckets[].amounts[]` repeats every amount column inside the bucket using the +same entry shape and order as `summary.amounts`, so a breakdown can show a stage's +Amount beside its Expected Revenue. Totals round to cents so repeated floating-point addition cannot leak artifacts into displayed currency. A `currencyCode` is omitted when contributing rows declare different currencies; rows that declare none cannot contradict the rest. diff --git a/src/reports/sales/sales-reports.dto.ts b/src/reports/sales/sales-reports.dto.ts index 956679f..556d292 100644 --- a/src/reports/sales/sales-reports.dto.ts +++ b/src/reports/sales/sales-reports.dto.ts @@ -88,6 +88,26 @@ export class SalesReportQueryDto { @MaxLength(200) filterValue?: string; + @ApiPropertyOptional({ + description: + "Column ID the returned page drills into; requires drilldownValue.", + }) + @IsOptional() + @IsString() + @MaxLength(200) + drilldownColumn?: string; + + @ApiPropertyOptional({ + description: + "Exact, case-insensitive displayed value of drilldownColumn. Narrows rows, " + + "total and totalPages only; summary still describes the whole filtered set, " + + "so a dashboard can drill into one bucket while still showing every bucket.", + }) + @IsOptional() + @IsString() + @MaxLength(200) + drilldownValue?: string; + @ApiPropertyOptional({ description: "Date or datetime column ID to range-filter; required with dateFrom/dateTo.", @@ -172,6 +192,13 @@ export class SalesSummaryBucketDto { description: "Sum of the primary amount column within this bucket.", }) total: number; + @ApiProperty({ + description: + "Every amount column totalled within this bucket, in the same order as " + + "summary.amounts, so a breakdown can show more than the primary amount.", + type: [SalesSummaryAmountDto], + }) + amounts: SalesSummaryAmountDto[]; } /** A category column broken down into its distinct values, largest total first. */ @@ -191,7 +218,10 @@ export class SalesSummaryGroupDto { /** Aggregates over every matching row in the snapshot, recomputed for each query. */ export class SalesSummaryDto { - @ApiProperty({ description: "Matching rows; equal to total." }) + @ApiProperty({ + description: + "Matching rows; equal to total unless a drilldown narrows the page.", + }) recordCount: number; @ApiProperty({ type: [SalesSummaryAmountDto] }) amounts: SalesSummaryAmountDto[]; @@ -215,7 +245,10 @@ export class SalesReportDto { "Number of detail rows received from Salesforce before local filtering.", }) sourceRowCount: number; - @ApiProperty({ description: "Number of matching rows in this snapshot." }) + @ApiProperty({ + description: + "Number of returned rows in this snapshot, after any drilldown.", + }) total: number; @ApiProperty() page: number; @ApiProperty() perPage: number; diff --git a/src/reports/sales/sales-reports.service.ts b/src/reports/sales/sales-reports.service.ts index 9711702..e6028ab 100644 --- a/src/reports/sales/sales-reports.service.ts +++ b/src/reports/sales/sales-reports.service.ts @@ -300,53 +300,57 @@ export class SalesReportsService { /** * Breaks a category column into its distinct values with counts and amounts. + * Every amount column is totalled inside each bucket, not only the primary one, + * so a dashboard can show a stage's Amount beside its Expected Revenue. * @param rows Matching rows, before pagination. * @param column The category column being broken down. * @param index The column's position in every row's cells. - * @param amount The primary amount column to total per bucket, when the report has one. - * @returns Buckets ordered by total then count, capped with an explicit remainder. + * @param amounts Every numeric column and its position; the first one is the primary total. + * @returns Buckets ordered by primary total then count, capped with an explicit remainder. * @throws Does not throw for blank category labels, which form their own bucket. */ private group( rows: SalesRowDto[], column: SalesColumnDto, index: number, - amount?: { id: string; index: number }, + amounts: Array<{ column: SalesColumnDto; index: number }>, ): SalesSummaryGroupDto { - const buckets = new Map(); - let currencyCode: string | undefined; - let mixed = false; + const buckets = new Map(); for (const row of rows) { const label = row.cells[index]?.label ?? ""; - const bucket = buckets.get(label) ?? { count: 0, total: 0 }; - bucket.count += 1; - const cell = amount ? row.cells[amount.index] : undefined; - if (typeof cell?.value === "number" && Number.isFinite(cell.value)) { - bucket.total += cell.value; - if (cell.currencyCode !== undefined) { - if (currencyCode === undefined) currencyCode = cell.currencyCode; - else if (currencyCode !== cell.currencyCode) mixed = true; - } - } + const bucket = buckets.get(label) ?? []; + bucket.push(row); buckets.set(label, bucket); } + const primary = amounts[0]; const ordered = [...buckets.entries()] - .map(([label, bucket]) => ({ - label, - count: bucket.count, - total: Math.round(bucket.total * 100) / 100, - })) + .map(([label, bucketRows]) => { + const totals = amounts.map((amount) => + this.amount(bucketRows, amount.column, amount.index), + ); + return { + label, + count: bucketRows.length, + total: totals[0]?.total ?? 0, + amounts: totals, + }; + }) .sort( (left, right) => right.total - left.total || right.count - left.count || left.label.localeCompare(right.label, "en", { sensitivity: "base" }), ); + // The group currency describes the primary total across every bucket, so it + // is omitted as soon as any contributing row declares a different currency. + const currencyCode = primary + ? this.amount(rows, primary.column, primary.index).currencyCode + : undefined; return { columnId: column.id, label: column.label, - ...(amount ? { amountColumnId: amount.id } : {}), - ...(currencyCode !== undefined && !mixed ? { currencyCode } : {}), + ...(primary ? { amountColumnId: primary.column.id } : {}), + ...(currencyCode !== undefined ? { currencyCode } : {}), buckets: ordered.slice(0, MAX_SUMMARY_BUCKETS), otherBuckets: Math.max(0, ordered.length - MAX_SUMMARY_BUCKETS), }; @@ -367,7 +371,6 @@ export class SalesReportsService { const amountIndexes = columns .map((column, index) => ({ column, index })) .filter(({ column }) => AMOUNT_TYPES.includes(column.dataType)); - const primary = amountIndexes[0]; return { recordCount: rows.length, amounts: amountIndexes.map(({ column, index }) => @@ -378,14 +381,7 @@ export class SalesReportsService { .filter(({ column }) => CATEGORY_TYPES.includes(column.dataType)) .slice(0, MAX_SUMMARY_GROUPS) .map(({ column, index }) => - this.group( - rows, - column, - index, - primary - ? { id: primary.column.id, index: primary.index } - : undefined, - ), + this.group(rows, column, index, amountIndexes), ), }; } @@ -433,8 +429,8 @@ export class SalesReportsService { /** * Filters, stably sorts and paginates a live report snapshot for UI or WIN callers. - * @param query Validated page, search, column filter, date range, sorting and refresh options. - * @returns Metadata, snapshot-wide aggregates and one page; total is explicitly the matched received-row count. + * @param query Validated page, search, column filter, drilldown, date range, sorting and refresh options. + * @returns Metadata, aggregates over the filtered set and one page; total is the returned-row count after any drilldown. * @throws BadRequestException for unknown columns, incomplete filters or an inverted date range; upstream exceptions propagate. */ async getReport(query: SalesReportQueryDto): Promise { @@ -443,6 +439,11 @@ export class SalesReportsService { "filterColumn and filterValue must be supplied together.", ); } + if (!!query.drilldownColumn !== !!query.drilldownValue) { + throw new BadRequestException( + "drilldownColumn and drilldownValue must be supplied together.", + ); + } if ((query.dateFrom || query.dateTo) && !query.dateColumn) { throw new BadRequestException( "dateColumn must be supplied with dateFrom or dateTo.", @@ -461,9 +462,13 @@ export class SalesReportsService { const dateIndex = report.columns.findIndex( (column) => column.id === query.dateColumn, ); + const drilldownIndex = report.columns.findIndex( + (column) => column.id === query.drilldownColumn, + ); if ( (query.sortBy && sortIndex < 0) || (query.filterColumn && filterIndex < 0) || + (query.drilldownColumn && drilldownIndex < 0) || (query.dateColumn && dateIndex < 0) ) { throw new BadRequestException( @@ -480,10 +485,11 @@ export class SalesReportsService { } const search = query.search?.trim().toLowerCase(); const filter = query.filterValue?.trim().toLowerCase(); + const drilldown = query.drilldownValue?.trim().toLowerCase(); // A range only applies once a bound is given, so selecting a date field // alone leaves the result set untouched. const ranged = dateIndex >= 0 && !!(query.dateFrom || query.dateTo); - const rows = report.rows.filter((row) => { + const matched = report.rows.filter((row) => { if (ranged) { // Rows without a usable date cannot satisfy a range, so they drop out // rather than silently inflating counts and totals. @@ -504,6 +510,15 @@ export class SalesReportsService { (!filter || row.cells[filterIndex].label.toLowerCase().includes(filter)) ); }); + // The summary covers every matching row, so a drilldown can narrow the page + // to one bucket while the breakdown it was clicked in stays on screen. + const summary = this.summarize(matched, report.columns); + const rows = drilldown + ? matched.filter( + (row) => + row.cells[drilldownIndex].label.trim().toLowerCase() === drilldown, + ) + : matched; if (sortIndex >= 0) { rows.sort((left, right) => { const a = left.cells[sortIndex]; @@ -537,7 +552,7 @@ export class SalesReportsService { totalPages, page, perPage: query.perPage, - summary: this.summarize(rows, report.columns), + summary, }; } } diff --git a/src/reports/sales/sales-reports.spec.ts b/src/reports/sales/sales-reports.spec.ts index 1e3b02c..f0f1298 100644 --- a/src/reports/sales/sales-reports.spec.ts +++ b/src/reports/sales/sales-reports.spec.ts @@ -421,13 +421,133 @@ describe("SalesReportsService", () => { currencyCode: "USD", otherBuckets: 0, buckets: [ - { label: "Proposal", count: 2, total: 1020 }, - { label: "Closed Won", count: 1, total: 0 }, + { + label: "Proposal", + count: 2, + total: 1020, + amounts: [ + { + columnId: "AMOUNT", + label: "Amount", + total: 1020, + count: 2, + currencyCode: "USD", + }, + ], + }, + { + label: "Closed Won", + count: 1, + total: 0, + amounts: [ + { columnId: "AMOUNT", label: "Amount", total: 0, count: 0 }, + ], + }, ], }, ]); }); + it("totals every amount column inside each bucket, not only the primary one", async () => { + const fixture = reportFixture(); + fixture.reportMetadata.groupingsDown = [{ name: "STAGE_NAME" }]; + fixture.reportExtendedMetadata.groupingColumnInfo = { + STAGE_NAME: { label: "Stage", dataType: "picklist" }, + }; + fixture.groupingsDown = { + groupings: [ + { key: "0", label: "Proposal", value: "Proposal", groupings: [] }, + { + key: "1", + label: "Won - SOW Signed", + value: "Won - SOW Signed", + groupings: [], + }, + ], + }; + fixture.reportMetadata.detailColumns.push("EXP_AMOUNT"); + fixture.reportExtendedMetadata.detailColumnInfo.EXP_AMOUNT = { + label: "Expected Revenue", + dataType: "currency", + }; + Object.values(fixture.factMap).forEach((bucket) => + bucket.rows?.forEach((row) => + row.dataCells.push({ + label: "$100", + value: { amount: 100, currencyCode: "USD" }, + }), + ), + ); + runReport.mockResolvedValue(fixture); + const result = await service.getReport(new SalesReportQueryDto()); + const won = result.summary.groups[0].buckets.find( + (bucket) => bucket.label === "Won - SOW Signed", + ); + expect(won).toEqual({ + label: "Won - SOW Signed", + count: 1, + total: 0, + amounts: [ + { columnId: "AMOUNT", label: "Amount", total: 0, count: 0 }, + { + columnId: "EXP_AMOUNT", + label: "Expected Revenue", + total: 100, + count: 1, + currencyCode: "USD", + }, + ], + }); + }); + + it("narrows rows to a drilldown bucket while the summary still covers every bucket", async () => { + const fixture = reportFixture(); + fixture.reportMetadata.groupingsDown = [{ name: "STAGE_NAME" }]; + fixture.reportExtendedMetadata.groupingColumnInfo = { + STAGE_NAME: { label: "Stage", dataType: "picklist" }, + }; + fixture.groupingsDown = { + groupings: [ + { key: "0", label: "Proposal", value: "Proposal", groupings: [] }, + { key: "1", label: "Closing", value: "Closing", groupings: [] }, + ], + }; + runReport.mockResolvedValue(fixture); + const result = await service.getReport( + Object.assign(new SalesReportQueryDto(), { + drilldownColumn: "STAGE_NAME", + // An exact match ignores case and surrounding whitespace. + drilldownValue: " closing ", + }), + ); + expect(result.rows.map((row) => row.cells[1].label)).toEqual(["Gamma"]); + expect(result.total).toBe(1); + expect(result.totalPages).toBe(1); + expect(result.summary.recordCount).toBe(3); + expect(result.summary.groups[0].buckets.map((b) => b.label)).toEqual([ + "Proposal", + "Closing", + ]); + }); + + it("rejects a half-supplied or unknown drilldown", async () => { + await expect( + service.getReport( + Object.assign(new SalesReportQueryDto(), { + drilldownColumn: "NAME", + }), + ), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.getReport( + Object.assign(new SalesReportQueryDto(), { + drilldownColumn: "MISSING", + drilldownValue: "Alpha", + }), + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + it("does not label a total with a currency the matching rows do not share", async () => { const fixture = reportFixture(); fixture.factMap["0!T"].rows![1].dataCells[1] = {