From 400ae096c7c1d7e07d298b40816556a68ada79a8 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Fri, 18 Sep 2026 05:38:22 +1000 Subject: [PATCH] PM-6364 Add sales date range filters and snapshot-wide summary Filtering the Sales report by Created Date or Close Date has to happen over the whole received snapshot, because the endpoint paginates server-side and a client only ever holds one page. Adds dateColumn plus inclusive dateFrom/dateTo bounds, applied alongside the existing search and column filters. Comparison uses each cell's underlying Salesforce value rather than its localized label, so a datetime resolves to the day the report displays; rows without a usable date are excluded rather than counted. Bounds are validated as real calendar days, so 2026-02-30 and non-leap 2027-02-29 are rejected. Adds a summary block covering every matching row so metrics stay correct under pagination: record count, per-column amount totals with currency agreement, and category breakdowns such as pipeline stage. Totals round to cents to keep floating-point artifacts out of displayed currency. Co-Authored-By: Claude Opus 5 (1M context) --- SALES.md | 40 +++- src/reports/sales/sales-reports.dto.ts | 105 ++++++++++ src/reports/sales/sales-reports.service.ts | 218 ++++++++++++++++++++- src/reports/sales/sales-reports.spec.ts | 196 ++++++++++++++++++ 4 files changed, 549 insertions(+), 10 deletions(-) diff --git a/SALES.md b/SALES.md index c8725ca..c444926 100644 --- a/SALES.md +++ b/SALES.md @@ -45,11 +45,14 @@ Both endpoints accept the same query parameters: | `search` | Case-insensitive substring across all displayed cells, up to 200 characters | | `filterColumn`, `filterValue` | Column ID and case-insensitive displayed-value substring; 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` | | `refresh` | `true` to refresh, subject to the five-second minimum interval; default `false` | Response fields: `reportId`, `reportName`, `columns[{id,label,dataType}]`, `rows[{id,cells:[{label,value,currencyCode?}]}]`, `allData`, `sourceRowCount`, -`total`, `page`, `perPage`, `totalPages`, `refreshedAt`, `refreshAfterSeconds`. +`total`, `page`, `perPage`, `totalPages`, `refreshedAt`, `refreshAfterSeconds`, +`summary`. Cells follow column order. Labels are plain text, never HTML. Currency values retain their amount and currency code. Null values are preserved. Row IDs are snapshot-local fact-map keys, not durable Salesforce record identifiers. @@ -64,6 +67,41 @@ 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`. +### Date range filtering (PM-6364) + +`dateColumn` selects which date the range applies to, so the same report answers +both pipeline generation (Created Date) and revenue realization (Close Date) +questions. Bounds are inclusive and combine with `search` and +`filterColumn`/`filterValue`. Selecting a `dateColumn` with no bound is a no-op, +which lets a client keep the field selected while the range is empty. + +Comparison uses each cell's **underlying** Salesforce value, never its localized +label: date and datetime values arrive as ISO 8601, and a datetime keeps the +report's own offset, so its day matches the day the report displays. A row whose +date cell is null or unparseable cannot satisfy a range and is excluded rather +than counted. `400` responses cover a bound without `dateColumn`, `dateFrom` +after `dateTo`, a `dateColumn` that is not a `date`/`datetime` column, and any +bound that is not a real `YYYY-MM-DD` calendar day (`2026-02-30` and non-leap +`2027-02-29` are rejected; datetimes and offsets are not accepted as bounds). + +### Summary aggregates (PM-6364) + +`summary` describes **every matching row in the snapshot**, not the returned +page, so counts and totals stay correct under pagination: + +| Field | Meaning | +| --- | --- | +| `recordCount` | Matching rows; always equal to `total` | +| `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` | + +Bucket totals use the report's first amount column, named in `amountColumnId`. +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. +Because aggregates cover received rows only, `allData: false` limits them +exactly as it limits `total`. + Salesforce Analytics limits detail responses to 2,000 rows. `allData: false` explicitly flags an incomplete upstream snapshot; the UI warns that search, filtering and counts apply only to returned rows. It must never be treated as a diff --git a/src/reports/sales/sales-reports.dto.ts b/src/reports/sales/sales-reports.dto.ts index d940868..956679f 100644 --- a/src/reports/sales/sales-reports.dto.ts +++ b/src/reports/sales/sales-reports.dto.ts @@ -3,14 +3,38 @@ import { IsBoolean, IsIn, IsInt, + IsISO8601, IsOptional, IsString, + Matches, Max, MaxLength, Min, } from "class-validator"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +/** Rejects datetimes and offsets so a bound is always a plain calendar day. */ +const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Applies both shape and calendar validation to a range bound: the pattern keeps + * the value date-only, and strict ISO 8601 rejects impossible days such as + * 2026-02-30 and non-leap 2027-02-29 before they reach the snapshot comparison. + * @param field Query parameter name used in the validation message. + * @returns The decorators to spread onto the property. + * @throws Does not throw. + */ +function IsCalendarDate(field: string): PropertyDecorator { + const message = `${field} must be a real YYYY-MM-DD calendar date.`; + return function apply(target: object, key: string | symbol): void { + Matches(DATE_ONLY, { message })(target, key); + IsISO8601({ strict: true, strictSeparator: true }, { message })( + target, + key, + ); + }; +} + /** Validated view options shared by the Sales UI and WIN report endpoint. */ export class SalesReportQueryDto { @ApiPropertyOptional({ default: 1, minimum: 1 }) @@ -64,6 +88,31 @@ export class SalesReportQueryDto { @MaxLength(200) filterValue?: string; + @ApiPropertyOptional({ + description: + "Date or datetime column ID to range-filter; required with dateFrom/dateTo.", + }) + @IsOptional() + @IsString() + @MaxLength(200) + dateColumn?: string; + + @ApiPropertyOptional({ + description: "Inclusive lower bound as a YYYY-MM-DD calendar date.", + example: "2026-09-01", + }) + @IsOptional() + @IsCalendarDate("dateFrom") + dateFrom?: string; + + @ApiPropertyOptional({ + description: "Inclusive upper bound as a YYYY-MM-DD calendar date.", + example: "2026-09-30", + }) + @IsOptional() + @IsCalendarDate("dateTo") + dateTo?: string; + @ApiPropertyOptional({ default: false, description: "Refresh Salesforce data (minimum five-second interval).", @@ -100,6 +149,56 @@ export class SalesRowDto { @ApiProperty({ type: [SalesCellDto] }) cells: SalesCellDto[]; } +/** A numeric column totalled across every matching row, not only the current page. */ +export class SalesSummaryAmountDto { + @ApiProperty() columnId: string; + @ApiProperty() label: string; + @ApiProperty({ description: "Sum of the matching rows' underlying values." }) + total: number; + @ApiProperty({ description: "Rows contributing a value to this total." }) + count: number; + @ApiPropertyOptional({ + description: + "Shared currency of every contributing row; omitted when rows mix currencies.", + }) + currencyCode?: string; +} + +/** One distinct value of a category column, such as a pipeline stage. */ +export class SalesSummaryBucketDto { + @ApiProperty() label: string; + @ApiProperty() count: number; + @ApiProperty({ + description: "Sum of the primary amount column within this bucket.", + }) + total: number; +} + +/** A category column broken down into its distinct values, largest total first. */ +export class SalesSummaryGroupDto { + @ApiProperty() columnId: string; + @ApiProperty() label: string; + @ApiPropertyOptional({ description: "Column totalled in each bucket." }) + amountColumnId?: string; + @ApiPropertyOptional() currencyCode?: string; + @ApiProperty({ type: [SalesSummaryBucketDto] }) + buckets: SalesSummaryBucketDto[]; + @ApiProperty({ + description: "Buckets beyond the returned set, omitted from buckets[].", + }) + otherBuckets: number; +} + +/** Aggregates over every matching row in the snapshot, recomputed for each query. */ +export class SalesSummaryDto { + @ApiProperty({ description: "Matching rows; equal to total." }) + recordCount: number; + @ApiProperty({ type: [SalesSummaryAmountDto] }) + amounts: SalesSummaryAmountDto[]; + @ApiProperty({ type: [SalesSummaryGroupDto] }) + groups: SalesSummaryGroupDto[]; +} + /** Read-only report snapshot with explicit completeness and filtered pagination metadata. */ export class SalesReportDto { @ApiProperty() reportId: string; @@ -123,4 +222,10 @@ export class SalesReportDto { @ApiProperty() totalPages: number; @ApiProperty({ format: "date-time" }) refreshedAt: string; @ApiProperty() refreshAfterSeconds: number; + @ApiProperty({ + description: + "Aggregates over all matching rows in the snapshot, not just this page.", + type: SalesSummaryDto, + }) + summary: SalesSummaryDto; } diff --git a/src/reports/sales/sales-reports.service.ts b/src/reports/sales/sales-reports.service.ts index a23e7da..9711702 100644 --- a/src/reports/sales/sales-reports.service.ts +++ b/src/reports/sales/sales-reports.service.ts @@ -7,9 +7,12 @@ import { ConfigService } from "@nestjs/config"; import { compile } from "html-to-text"; import { SalesCellDto, + SalesColumnDto, SalesReportDto, SalesReportQueryDto, SalesRowDto, + SalesSummaryDto, + SalesSummaryGroupDto, } from "./sales-reports.dto"; import { SalesforceGrouping, @@ -19,6 +22,15 @@ import { const CACHE_MS = 60000; const REFRESH_COOLDOWN_MS = 5000; +/** Column types that can carry a pipeline or revenue amount worth totalling. */ +const AMOUNT_TYPES = ["currency", "double"]; +/** Column types that a date range can be applied to. */ +const DATE_TYPES = ["date", "datetime"]; +/** Column types that describe a category, such as a pipeline stage. */ +const CATEGORY_TYPES = ["picklist", "multipicklist", "combobox", "boolean"]; +/** Keeps a breakdown readable and the response bounded for very wide reports. */ +const MAX_SUMMARY_GROUPS = 3; +const MAX_SUMMARY_BUCKETS = 25; const htmlToPlainText = compile({ wordwrap: false, selectors: [ @@ -223,6 +235,158 @@ export class SalesReportsService { totalPages: Math.ceil(rows.length / 25), refreshedAt: new Date().toISOString(), refreshAfterSeconds: CACHE_MS / 1000, + summary: this.summarize(rows, columns), + }; + } + + /** + * Reads the calendar day a date cell falls on, using the underlying Salesforce + * value rather than its locale-formatted label. + * @param cell Cell taken from a column whose dataType is date or datetime. + * @returns The YYYY-MM-DD day, or undefined when the cell holds no usable date. + * @throws Does not throw for null, blank or unparseable values. + */ + private day(cell: SalesCellDto | undefined): string | undefined { + const raw = cell?.value; + if (typeof raw === "number" && Number.isFinite(raw)) { + return new Date(raw).toISOString().slice(0, 10); + } + // Salesforce emits date and datetime values as ISO 8601; a datetime keeps + // the report's own offset, so slicing matches the day the report displays. + return typeof raw === "string" && /^\d{4}-\d{2}-\d{2}/.test(raw) + ? raw.slice(0, 10) + : undefined; + } + + /** + * Totals one numeric column across matching rows, tracking currency agreement. + * @param rows Matching rows, before pagination. + * @param column The numeric column being totalled. + * @param index The column's position in every row's cells. + * @returns The total, the number of contributing rows and a shared currency code when unanimous. + * @throws Does not throw for null or non-numeric cells, which are skipped. + */ + private amount( + rows: SalesRowDto[], + column: SalesColumnDto, + index: number, + ): SalesSummaryDto["amounts"][number] { + let total = 0; + let count = 0; + let currencyCode: string | undefined; + let mixed = false; + for (const row of rows) { + const cell = row.cells[index]; + if (typeof cell?.value !== "number" || !Number.isFinite(cell.value)) + continue; + total += cell.value; + count += 1; + if (cell.currencyCode === undefined) continue; + if (currencyCode === undefined) currencyCode = cell.currencyCode; + else if (currencyCode !== cell.currencyCode) mixed = true; + } + return { + columnId: column.id, + label: column.label, + // Rounded to cents: repeated float addition otherwise leaks artifacts + // such as 0.30000000000000004 into displayed currency totals. + total: Math.round(total * 100) / 100, + count, + // A mixed-currency total is still the report's own sum, but it must not + // be labelled with a currency the amounts do not share. + ...(currencyCode !== undefined && !mixed ? { currencyCode } : {}), + }; + } + + /** + * Breaks a category column into its distinct values with counts and amounts. + * @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. + * @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 }, + ): SalesSummaryGroupDto { + const buckets = new Map(); + let currencyCode: string | undefined; + let mixed = false; + 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; + } + } + buckets.set(label, bucket); + } + const ordered = [...buckets.entries()] + .map(([label, bucket]) => ({ + label, + count: bucket.count, + total: Math.round(bucket.total * 100) / 100, + })) + .sort( + (left, right) => + right.total - left.total || + right.count - left.count || + left.label.localeCompare(right.label, "en", { sensitivity: "base" }), + ); + return { + columnId: column.id, + label: column.label, + ...(amount ? { amountColumnId: amount.id } : {}), + ...(currencyCode !== undefined && !mixed ? { currencyCode } : {}), + buckets: ordered.slice(0, MAX_SUMMARY_BUCKETS), + otherBuckets: Math.max(0, ordered.length - MAX_SUMMARY_BUCKETS), + }; + } + + /** + * Aggregates every matching row so counts and totals describe the filtered + * result rather than the page currently being displayed. + * @param rows Matching rows, before pagination. + * @param columns The snapshot's column schema, in cell order. + * @returns Record count, per-column amount totals and category breakdowns. + * @throws Does not throw for reports without numeric or category columns. + */ + private summarize( + rows: SalesRowDto[], + columns: SalesColumnDto[], + ): SalesSummaryDto { + 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 }) => + this.amount(rows, column, index), + ), + groups: columns + .map((column, index) => ({ column, index })) + .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, + ), + ), }; } @@ -269,9 +433,9 @@ export class SalesReportsService { /** * Filters, stably sorts and paginates a live report snapshot for UI or WIN callers. - * @param query Validated page, search, column filter, sorting and refresh options. - * @returns Metadata and one page; total is explicitly the matched received-row count. - * @throws BadRequestException for unknown columns or incomplete filters; upstream exceptions propagate. + * @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. + * @throws BadRequestException for unknown columns, incomplete filters or an inverted date range; upstream exceptions propagate. */ async getReport(query: SalesReportQueryDto): Promise { if (!!query.filterColumn !== !!query.filterValue) { @@ -279,6 +443,14 @@ export class SalesReportsService { "filterColumn and filterValue must be supplied together.", ); } + if ((query.dateFrom || query.dateTo) && !query.dateColumn) { + throw new BadRequestException( + "dateColumn must be supplied with dateFrom or dateTo.", + ); + } + if (query.dateFrom && query.dateTo && query.dateFrom > query.dateTo) { + throw new BadRequestException("dateFrom must not be after dateTo."); + } const report = await this.getSnapshot(query.refresh); const sortIndex = report.columns.findIndex( (column) => column.id === query.sortBy, @@ -286,25 +458,52 @@ export class SalesReportsService { const filterIndex = report.columns.findIndex( (column) => column.id === query.filterColumn, ); + const dateIndex = report.columns.findIndex( + (column) => column.id === query.dateColumn, + ); if ( (query.sortBy && sortIndex < 0) || - (query.filterColumn && filterIndex < 0) + (query.filterColumn && filterIndex < 0) || + (query.dateColumn && dateIndex < 0) ) { throw new BadRequestException( "Unknown report column. Use a column ID from the report response.", ); } + if ( + dateIndex >= 0 && + !DATE_TYPES.includes(report.columns[dateIndex].dataType) + ) { + throw new BadRequestException( + "dateColumn must reference a date or datetime column.", + ); + } const search = query.search?.trim().toLowerCase(); const filter = query.filterValue?.trim().toLowerCase(); - const rows = report.rows.filter( - (row) => + // 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) => { + if (ranged) { + // Rows without a usable date cannot satisfy a range, so they drop out + // rather than silently inflating counts and totals. + const day = this.day(row.cells[dateIndex]); + if ( + !day || + (query.dateFrom && day < query.dateFrom) || + (query.dateTo && day > query.dateTo) + ) { + return false; + } + } + return ( (!search || row.cells.some((cell) => cell.label.toLowerCase().includes(search), )) && - (!filter || - row.cells[filterIndex].label.toLowerCase().includes(filter)), - ); + (!filter || row.cells[filterIndex].label.toLowerCase().includes(filter)) + ); + }); if (sortIndex >= 0) { rows.sort((left, right) => { const a = left.cells[sortIndex]; @@ -338,6 +537,7 @@ export class SalesReportsService { totalPages, page, perPage: query.perPage, + summary: this.summarize(rows, report.columns), }; } } diff --git a/src/reports/sales/sales-reports.spec.ts b/src/reports/sales/sales-reports.spec.ts index 8150225..1e3b02c 100644 --- a/src/reports/sales/sales-reports.spec.ts +++ b/src/reports/sales/sales-reports.spec.ts @@ -269,6 +269,181 @@ describe("SalesReportsService", () => { expect(runReport).toHaveBeenCalledTimes(2); }); + it("filters an inclusive date range on the selected column before counting", async () => { + const september = await service.getReport( + Object.assign(new SalesReportQueryDto(), { + dateColumn: "CLOSE_DATE", + dateFrom: "2026-09-01", + dateTo: "2026-09-30", + }), + ); + expect(september.total).toBe(1); + expect(september.rows[0].cells[0].label).toBe("Alpha"); + expect(september.sourceRowCount).toBe(3); + const openEnded = await service.getReport( + Object.assign(new SalesReportQueryDto(), { + dateColumn: "CLOSE_DATE", + dateFrom: "2026-09-01", + }), + ); + expect(openEnded.rows.map((row) => row.cells[0].label)).toEqual([ + "Alpha", + "Beta", + ]); + const upToOnly = await service.getReport( + Object.assign(new SalesReportQueryDto(), { + dateColumn: "CLOSE_DATE", + dateTo: "2026-08-31", + }), + ); + expect(upToOnly.rows.map((row) => row.cells[0].label)).toEqual(["Gamma"]); + }); + + it("selecting a date column without a bound leaves the result set untouched", async () => { + const result = await service.getReport( + Object.assign(new SalesReportQueryDto(), { dateColumn: "CLOSE_DATE" }), + ); + expect(result.total).toBe(3); + }); + + it("uses the underlying date value and drops rows the range cannot place", async () => { + const fixture = reportFixture(); + // A localized label with no usable underlying value must not be guessed at. + fixture.factMap["0!T"].rows![1].dataCells[2] = { + label: "10/1/2026", + value: null, + }; + // A datetime keeps the report's own offset; the displayed day is what counts. + fixture.factMap["0!T"].rows![0].dataCells[2] = { + label: "9/30/2026", + value: "2026-09-30T22:00:00-07:00", + }; + runReport.mockResolvedValue(fixture); + const result = await service.getReport( + Object.assign(new SalesReportQueryDto(), { + dateColumn: "CLOSE_DATE", + dateFrom: "2026-09-01", + dateTo: "2026-09-30", + }), + ); + expect(result.rows.map((row) => row.cells[0].label)).toEqual(["Alpha"]); + expect(result.total).toBe(1); + }); + + it("combines the date range with search and column filters", async () => { + const result = await service.getReport( + Object.assign(new SalesReportQueryDto(), { + dateColumn: "CLOSE_DATE", + dateFrom: "2026-08-01", + dateTo: "2026-10-31", + search: "a", + filterColumn: "NAME", + filterValue: "alpha", + }), + ); + expect(result.rows.map((row) => row.cells[0].label)).toEqual(["Alpha"]); + }); + + it("rejects incomplete, inverted and non-date range requests", async () => { + for (const query of [ + { dateFrom: "2026-09-01" }, + { dateTo: "2026-09-30" }, + { + dateColumn: "CLOSE_DATE", + dateFrom: "2026-09-30", + dateTo: "2026-09-01", + }, + { dateColumn: "AMOUNT", dateFrom: "2026-09-01" }, + { dateColumn: "missing", dateFrom: "2026-09-01" }, + ]) { + await expect( + service.getReport(Object.assign(new SalesReportQueryDto(), query)), + ).rejects.toBeInstanceOf(BadRequestException); + } + }); + + it("summarizes every matching row rather than the returned page", async () => { + const unfiltered = await service.getReport( + Object.assign(new SalesReportQueryDto(), { perPage: 1 }), + ); + expect(unfiltered.rows).toHaveLength(1); + expect(unfiltered.summary).toMatchObject({ recordCount: 3 }); + // Beta's plain 20 declares no currency, so it cannot contradict Alpha's USD. + expect(unfiltered.summary.amounts).toEqual([ + { + columnId: "AMOUNT", + label: "Amount", + total: 1020, + count: 2, + currencyCode: "USD", + }, + ]); + const september = await service.getReport( + Object.assign(new SalesReportQueryDto(), { + dateColumn: "CLOSE_DATE", + dateFrom: "2026-09-01", + dateTo: "2026-09-30", + }), + ); + expect(september.summary).toMatchObject({ recordCount: 1 }); + expect(september.summary.amounts[0]).toEqual({ + columnId: "AMOUNT", + label: "Amount", + total: 1000, + count: 1, + currencyCode: "USD", + }); + }); + + it("breaks stage groupings down by count and amount, largest total first", 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: "Closed Won", value: "Closed Won", groupings: [] }, + ], + }; + fixture.factMap["0!T"].rows![1].dataCells[1] = { + label: "$20", + value: { amount: 20, currencyCode: "USD" }, + }; + runReport.mockResolvedValue(fixture); + const result = await service.getReport(new SalesReportQueryDto()); + expect(result.summary.groups).toEqual([ + { + columnId: "STAGE_NAME", + label: "Stage", + amountColumnId: "AMOUNT", + currencyCode: "USD", + otherBuckets: 0, + buckets: [ + { label: "Proposal", count: 2, total: 1020 }, + { label: "Closed Won", count: 1, total: 0 }, + ], + }, + ]); + }); + + 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] = { + label: "\u20ac20", + value: { amount: 20, currencyCode: "EUR" }, + }; + runReport.mockResolvedValue(fixture); + const result = await service.getReport(new SalesReportQueryDto()); + expect(result.summary.amounts[0]).toEqual({ + columnId: "AMOUNT", + label: "Amount", + total: 1020, + count: 2, + }); + }); + it("distinguishes an empty report, truncated data and an invalid detail-disabled report", async () => { const fixture = reportFixture(); fixture.factMap = { "T!T": { rows: [] } }; @@ -352,12 +527,33 @@ describe("Sales query validation", () => { ), ).toMatchObject({ refresh: false, page: 2, perPage: 50 }); }); + it("accepts a well-formed calendar range", async () => { + expect( + await pipe.transform( + { + dateColumn: "CLOSE_DATE", + dateFrom: "2028-02-29", + dateTo: "2026-09-30", + }, + metadata, + ), + ).toMatchObject({ + dateColumn: "CLOSE_DATE", + dateFrom: "2028-02-29", + dateTo: "2026-09-30", + }); + }); it.each([ { page: "0" }, { perPage: "201" }, { refresh: "1" }, { sortOrder: "invalid" }, { search: ["a", "b"] }, + { dateFrom: "09/01/2026" }, + { dateFrom: "2026-09-01T00:00:00Z" }, + { dateFrom: "2026-02-30" }, + { dateTo: "2027-02-29" }, + { dateTo: "2026-13-01" }, ])("rejects invalid input %j", async (query) => { await expect(pipe.transform(query, metadata)).rejects.toBeInstanceOf( BadRequestException,