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
40 changes: 39 additions & 1 deletion SALES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
105 changes: 105 additions & 0 deletions src/reports/sales/sales-reports.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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).",
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Loading
Loading