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
27 changes: 21 additions & 6 deletions SALES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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)

Expand All @@ -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.
Expand Down
37 changes: 35 additions & 2 deletions src/reports/sales/sales-reports.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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. */
Expand All @@ -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[];
Expand All @@ -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;
Expand Down
87 changes: 51 additions & 36 deletions src/reports/sales/sales-reports.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { count: number; total: number }>();
let currencyCode: string | undefined;
let mixed = false;
const buckets = new Map<string, SalesRowDto[]>();
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),
};
Expand All @@ -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 }) =>
Expand All @@ -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),
),
};
}
Expand Down Expand Up @@ -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<SalesReportDto> {
Expand All @@ -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.",
Expand All @@ -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(
Expand All @@ -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.
Expand All @@ -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];
Expand Down Expand Up @@ -537,7 +552,7 @@ export class SalesReportsService {
totalPages,
page,
perPage: query.perPage,
summary: this.summarize(rows, report.columns),
summary,
};
}
}
Loading
Loading