Add reporting features with new report engine and bookmark management

- Introduced a comprehensive report engine for generating and managing reports, including sales and logistics reports.
- Added new API endpoints for retrieving report configurations, data, and metadata, ensuring secure access with privilege checks.
- Implemented a report bookmarks system to allow users to save and manage report filters and configurations.
- Created database migrations for the `report_bookmarks` table and updated the schema to support new report functionalities.
- Developed services and controllers for handling report queries and bookmarks, including CRUD operations for bookmarks.
- Enhanced API documentation to reflect the new reporting features and endpoints.
- Added unit and integration tests to validate the new functionalities and ensure data integrity across report operations.
This commit is contained in:
shancheas
2026-09-01 08:45:52 +07:00
parent 5579cf6566
commit 2955b974d2
43 changed files with 3257 additions and 1 deletions
@@ -0,0 +1,41 @@
import { DATA_FORMAT } from '../constants/data-format';
import { formatReportCell, formatReportRow } from './report-cell.formatter';
describe('report cell formatter', () => {
it('formats currency null as zero', () => {
expect(formatReportCell(null, DATA_FORMAT.CURRENCY)).toBe('0.0000');
});
it('formats boolean yes/no', () => {
expect(formatReportCell(true, DATA_FORMAT.BOOLEAN)).toBe('Yes');
expect(formatReportCell(0, DATA_FORMAT.BOOLEAN)).toBe('No');
});
it('formats uppercase text', () => {
expect(formatReportCell('draft', DATA_FORMAT.TEXT_UPPERCASE)).toBe('DRAFT');
});
it('formats row by column config', () => {
const row = formatReportRow(
{ main__status: 'active', main__amount: '10.5' },
[
{
column: 'main__status',
query: 'main.status',
label: 'Status',
type: 'dimension',
format: DATA_FORMAT.TEXT_UPPERCASE,
},
{
column: 'main__amount',
query: 'main.amount',
label: 'Amount',
type: 'measure',
format: DATA_FORMAT.CURRENCY,
},
],
);
expect(row.main__status).toBe('ACTIVE');
expect(row.main__amount).toBe('10.5000');
});
});
@@ -0,0 +1,107 @@
import { DateTime } from '../../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../../common/value-objects/decimal/decimal';
import { DATA_FORMAT } from '../constants/data-format';
import type { DataFormat } from '../constants/data-format';
import type { ReportColumnConfigEntity } from '../entities/report-config.entity';
export function formatReportCell(raw: unknown, format: DataFormat): unknown {
if (raw === null || raw === undefined) {
switch (format) {
case DATA_FORMAT.NUMBER:
case DATA_FORMAT.CURRENCY:
case DATA_FORMAT.MINUS_CURRENCY:
return Decimal.zero().toString();
default:
return raw;
}
}
switch (format) {
case DATA_FORMAT.TEXT:
return String(raw);
case DATA_FORMAT.TEXT_UPPERCASE:
return String(raw).toUpperCase();
case DATA_FORMAT.TEXT_LOWERCASE:
return String(raw).toLowerCase();
case DATA_FORMAT.NUMBER:
return formatDecimalOrZero(raw);
case DATA_FORMAT.CURRENCY:
return formatDecimalOrZero(raw);
case DATA_FORMAT.MINUS_CURRENCY:
return negateDecimalString(formatDecimalOrZero(raw));
case DATA_FORMAT.PERCENTAGE:
return `${raw}%`;
case DATA_FORMAT.BOOLEAN:
return raw === true || raw === 1 || raw === '1' ? 'Yes' : 'No';
case DATA_FORMAT.STATUS:
return String(raw);
case DATA_FORMAT.DATE_EPOCH:
return formatEpoch(raw);
case DATA_FORMAT.DATE_TIMESTAMP:
return formatTimestamp(raw);
default:
return raw;
}
}
export function formatReportRow(
row: Record<string, unknown>,
columnConfigs: ReportColumnConfigEntity[],
): Record<string, unknown> {
const formatted: Record<string, unknown> = { ...row };
for (const col of columnConfigs) {
if (col.column in formatted) {
formatted[col.column] = formatReportCell(
formatted[col.column],
col.format,
);
}
}
return formatted;
}
function formatDecimalOrZero(raw: unknown): string {
try {
if (raw === null || raw === undefined || raw === '') {
return Decimal.zero().toString();
}
return Decimal.create(String(raw)).toString();
} catch {
return Decimal.zero().toString();
}
}
function negateDecimalString(value: string): string {
try {
const decimal = Decimal.create(value);
if (decimal.isZero()) {
return decimal.toString();
}
return Decimal.create(`-${value.replace(/^-/, '')}`).toString();
} catch {
return Decimal.zero().toString();
}
}
function formatEpoch(raw: unknown): string {
const ms = Number(raw);
if (!Number.isFinite(ms)) {
return String(raw);
}
return DateTime.fromUnixMs(ms).format();
}
function formatTimestamp(raw: unknown): string {
if (typeof raw === 'string') {
try {
return DateTime.create(raw).format();
} catch {
return raw;
}
}
const ms = Number(raw);
if (Number.isFinite(ms)) {
return DateTime.fromUnixMs(ms).format();
}
return String(raw);
}
@@ -0,0 +1,21 @@
import { parseGroupNamesQuery } from './report-query-params';
describe('parseGroupNamesQuery', () => {
it('reads groupNames', () => {
expect(parseGroupNamesQuery({ groupNames: 'sales_report' })).toEqual([
'sales_report',
]);
});
it('reads axios bracket notation groupNames[]', () => {
expect(parseGroupNamesQuery({ 'groupNames[]': 'sales_report' })).toEqual([
'sales_report',
]);
});
it('reads array values', () => {
expect(
parseGroupNamesQuery({ groupNames: ['sales_report', 'logistics_report'] }),
).toEqual(['sales_report', 'logistics_report']);
});
});
@@ -0,0 +1,18 @@
export function parseGroupNamesQuery(
query: Record<string, unknown>,
): string[] {
const raw =
query.groupNames ??
query['groupNames[]'] ??
query['groupNames[0]'];
if (raw === undefined || raw === null || raw === '') {
return [];
}
if (Array.isArray(raw)) {
return raw.map((item) => String(item)).filter((item) => item.length > 0);
}
return [String(raw)];
}
@@ -0,0 +1,74 @@
import { salesOrderReport } from '../configs/sales-reports';
import { FILTER_TYPE } from '../constants/filter-type';
import { ReportQueryBuilder } from './report-query.builder';
import { flattenSql } from './sql-test.helper';
describe('ReportQueryBuilder', () => {
const baseQueryModel = {
startRow: 0,
endRow: 100,
rowGroupCols: [],
valueCols: [],
pivotCols: [],
pivotMode: false,
groupKeys: [],
filterModel: {},
sortModel: [],
};
it('builds ungrouped select with limit offset', () => {
const builder = new ReportQueryBuilder(salesOrderReport, baseQueryModel);
const text = flattenSql(builder.getSqlData());
expect(text).toContain('SELECT');
expect(text).toContain('sales_orders main');
expect(text).toContain('LIMIT');
expect(text).toContain('OFFSET');
});
it('builds count query without grouping', () => {
const builder = new ReportQueryBuilder(salesOrderReport, baseQueryModel);
const text = flattenSql(builder.getSqlCount());
expect(text).toContain('COUNT(main.id)');
});
it('applies text equals filter with bound parameter', () => {
const builder = new ReportQueryBuilder(salesOrderReport, {
...baseQueryModel,
filterModel: {
main__status: {
type: FILTER_TYPE.TEXT_EQUALS,
filter: 'active',
},
},
});
const text = flattenSql(builder.getSqlData());
expect(text).toContain('main.status');
expect(text).toContain('=');
});
it('rejects unknown filter columns', () => {
const builder = new ReportQueryBuilder(salesOrderReport, {
...baseQueryModel,
filterModel: {
evil__column: {
type: FILTER_TYPE.TEXT_EQUALS,
filter: 'x',
},
},
});
expect(() => builder.getSqlData()).toThrow('Invalid filter column');
});
it('builds grouped select with countChildGroup', () => {
const builder = new ReportQueryBuilder(salesOrderReport, {
...baseQueryModel,
rowGroupCols: [
{ id: 'br__name', displayName: 'Branch', field: 'br__name' },
],
groupKeys: [],
});
const text = flattenSql(builder.getSqlData());
expect(text).toContain('GROUP BY');
expect(text).toContain('countChildGroup');
});
});
@@ -0,0 +1,344 @@
import { BadRequestException } from '@nestjs/common';
import { SQL, sql } from 'drizzle-orm';
import { FILTER_TYPE } from '../constants/filter-type';
import {
COUNT_CHILD_GROUP_COLUMN,
type FilterModelEntry,
type QueryModelEntity,
type ReportConfigEntity,
} from '../entities/report-config.entity';
export class ReportQueryBuilder {
constructor(
private readonly config: ReportConfigEntity,
private readonly queryModel: QueryModelEntity,
) {}
getSqlData(): SQL {
const pageSize = this.queryModel.endRow - this.queryModel.startRow;
const selectSql = this.buildSelectSql();
const fromSql = sql.raw(this.config.tableSchema);
const whereSql = this.buildWhereSql();
const groupBySql = this.buildGroupBySql();
const orderBySql = this.buildOrderBySql();
const parts: SQL[] = [sql`SELECT ${selectSql} FROM ${fromSql}`];
if (whereSql) {
parts.push(sql` WHERE ${whereSql}`);
}
if (groupBySql) {
parts.push(sql` ${groupBySql}`);
}
if (orderBySql) {
parts.push(sql` ${orderBySql}`);
}
parts.push(sql` LIMIT ${pageSize + 1} OFFSET ${this.queryModel.startRow}`);
return sql.join(parts, sql.raw(''));
}
getSqlCount(): SQL {
const fromSql = sql.raw(this.config.tableSchema);
const whereSql = this.buildWhereSql();
const alias = this.mainAlias();
const isGrouping = this.isDoingGrouping();
if (!isGrouping && this.queryModel.rowGroupCols.length === 0) {
const parts: SQL[] = [
sql`SELECT COUNT(${sql.raw(`${alias}.id`)}) AS count FROM ${fromSql}`,
];
if (whereSql) {
parts.push(sql` WHERE ${whereSql}`);
}
return sql.join(parts, sql.raw(''));
}
const groupExpr = this.currentGroupExpression();
const parts: SQL[] = [
sql`SELECT COUNT(DISTINCT ${sql.raw(groupExpr)}) + COUNT(DISTINCT CASE WHEN ${sql.raw(groupExpr)} IS NULL THEN 1 END) AS count FROM ${fromSql}`,
];
if (whereSql) {
parts.push(sql` WHERE ${whereSql}`);
}
return sql.join(parts, sql.raw(''));
}
private mainAlias(): string {
return this.config.mainTableAlias ?? 'main';
}
private isDoingGrouping(): boolean {
return (
this.queryModel.rowGroupCols.length > this.queryModel.groupKeys.length
);
}
private currentGroupColumn(): string | undefined {
const index = this.queryModel.groupKeys.length;
const col = this.queryModel.rowGroupCols[index];
return col?.field ?? col?.id;
}
private currentGroupExpression(): string {
const field = this.currentGroupColumn();
if (!field) {
return `${this.mainAlias()}.id`;
}
return this.resolveColumnExpression(field);
}
private resolveColumnExpression(column: string): string {
if (this.config.customQueryColumn) {
const custom = this.config.customQueryColumn(column);
if (custom) {
return custom;
}
}
const colConfig = this.config.columnConfigs.find(
(c) => c.column === column,
);
if (colConfig) {
return colConfig.query;
}
return column.replace(/__/g, '.');
}
private buildSelectSql(): SQL {
if (this.isDoingGrouping()) {
return this.buildGroupedSelectSql();
}
const columns = this.config.columnConfigs.map(
(col) => sql`${sql.raw(col.query)} AS ${sql.raw(col.column)}`,
);
return sql.join(columns, sql.raw(', '));
}
private buildGroupedSelectSql(): SQL {
const groupField = this.currentGroupColumn();
if (!groupField) {
throw new BadRequestException('Invalid group configuration');
}
const groupExpr = this.resolveColumnExpression(groupField);
const parts: SQL[] = [sql`${sql.raw(groupExpr)} AS ${sql.raw(groupField)}`];
const nextGroupIndex = this.queryModel.groupKeys.length + 1;
const nextGroup = this.queryModel.rowGroupCols[nextGroupIndex];
const alias = this.mainAlias();
if (nextGroup) {
const nextExpr = this.resolveColumnExpression(
nextGroup.field ?? nextGroup.id,
);
parts.push(
sql`COUNT(DISTINCT ${sql.raw(nextExpr)}) AS ${sql.raw(COUNT_CHILD_GROUP_COLUMN)}`,
);
} else {
parts.push(
sql`COUNT(${sql.raw(`${alias}.id`)}) AS ${sql.raw(COUNT_CHILD_GROUP_COLUMN)}`,
);
}
for (const valueCol of this.queryModel.valueCols) {
const expr = this.resolveColumnExpression(valueCol.field);
const agg = valueCol.aggFunc.toUpperCase();
parts.push(
sql`${sql.raw(agg)}(${sql.raw(expr)}) AS ${sql.raw(valueCol.field)}`,
);
}
return sql.join(parts, sql.raw(', '));
}
private buildWhereSql(): SQL | undefined {
const conditions: SQL[] = [];
for (const cond of this.config.whereDefaultConditions ?? []) {
conditions.push(sql.raw(cond));
}
if (this.config.whereCondition) {
for (const cond of this.config.whereCondition(
this.queryModel.filterModel,
)) {
conditions.push(sql.raw(cond));
}
}
for (let i = 0; i < this.queryModel.groupKeys.length; i++) {
const groupCol = this.queryModel.rowGroupCols[i];
if (!groupCol) {
continue;
}
const field = groupCol.field ?? groupCol.id;
const expr = this.resolveColumnExpression(field);
const key = this.queryModel.groupKeys[i];
if (key === null || key === undefined || key === '') {
conditions.push(sql`${sql.raw(expr)} IS NULL`);
} else {
conditions.push(sql`${sql.raw(expr)} = ${String(key)}`);
}
}
const ignoreKeys = new Set(this.config.ignoreFilterKeys ?? []);
for (const [key, entry] of Object.entries(this.queryModel.filterModel)) {
if (ignoreKeys.has(key)) {
continue;
}
const filterConfig = this.config.filterConfigs?.find(
(f) => f.filterColumn === key,
);
if (filterConfig?.hideField) {
continue;
}
if (!this.isAllowedFilterColumn(key)) {
throw new BadRequestException('Invalid filter column');
}
const filterSql = this.createFilterSql(key, entry);
if (filterSql) {
conditions.push(filterSql);
}
}
if (conditions.length === 0) {
return undefined;
}
return sql.join(conditions, sql` AND `);
}
private isAllowedFilterColumn(column: string): boolean {
if (this.config.columnConfigs.some((c) => c.column === column)) {
return true;
}
if (this.config.customQueryColumn?.(column)) {
return true;
}
return false;
}
private createFilterSql(
column: string,
entry: FilterModelEntry,
): SQL | undefined {
const expr = this.resolveColumnExpression(column);
const rawExpr = sql.raw(expr);
const filter = entry.filter;
switch (entry.type) {
case FILTER_TYPE.TEXT_EQUALS:
return sql`${rawExpr} = ${String(filter)}`;
case FILTER_TYPE.TEXT_NOT_EQUAL:
return sql`${rawExpr} <> ${String(filter)}`;
case FILTER_TYPE.TEXT_CONTAINS:
return sql`${rawExpr} ILIKE ${`%${String(filter)}%`}`;
case FILTER_TYPE.TEXT_NOT_CONTAINS:
return sql`${rawExpr} NOT ILIKE ${`%${String(filter)}%`}`;
case FILTER_TYPE.TEXT_MULTIPLE_CONTAINS:
case FILTER_TYPE.TEXT_IN_MEMBER_TEXT: {
const values = Array.isArray(filter) ? filter : [filter];
const strings = values.map((v) => String(v));
if (strings.length === 0) {
return undefined;
}
const patterns = strings.map((s) => sql`${`%${s}%`}`);
return sql`${rawExpr} ILIKE ANY(ARRAY[${sql.join(patterns, sql`, `)}])`;
}
case FILTER_TYPE.NUMBER_EQUALS:
return sql`${rawExpr} = ${Number(filter)}`;
case FILTER_TYPE.NUMBER_NOT_EQUAL:
return sql`${rawExpr} <> ${Number(filter)}`;
case FILTER_TYPE.NUMBER_GREATER_THAN:
return sql`${rawExpr} > ${Number(filter)}`;
case FILTER_TYPE.NUMBER_LESS_THAN:
return sql`${rawExpr} < ${Number(filter)}`;
case FILTER_TYPE.NUMBER_IN_RANGE: {
const range = filter as { from?: number; to?: number };
if (range.from !== undefined && range.to !== undefined) {
return sql`${rawExpr} BETWEEN ${range.from} AND ${range.to}`;
}
if (range.from !== undefined) {
return sql`${rawExpr} >= ${range.from}`;
}
if (range.to !== undefined) {
return sql`${rawExpr} <= ${range.to}`;
}
return undefined;
}
case FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH: {
const range = filter as { from?: number; to?: number };
if (range.from !== undefined && range.to !== undefined) {
return sql`${rawExpr} BETWEEN ${range.from} AND ${range.to}`;
}
if (range.from !== undefined) {
return sql`${rawExpr} >= ${range.from}`;
}
if (range.to !== undefined) {
return sql`${rawExpr} <= ${range.to}`;
}
return undefined;
}
case FILTER_TYPE.TEXT_IN_DATE_RANGE_TIMESTAMP: {
const range = filter as { from?: string; to?: string };
if (range.from !== undefined && range.to !== undefined) {
return sql`${rawExpr} BETWEEN ${range.from} AND ${range.to}`;
}
if (range.from !== undefined) {
return sql`${rawExpr} >= ${range.from}`;
}
if (range.to !== undefined) {
return sql`${rawExpr} <= ${range.to}`;
}
return undefined;
}
default:
return undefined;
}
}
private buildGroupBySql(): SQL | undefined {
if (!this.isDoingGrouping()) {
return undefined;
}
const groupField = this.currentGroupColumn();
if (!groupField) {
return undefined;
}
const expr = this.resolveColumnExpression(groupField);
return sql`GROUP BY ${sql.raw(expr)}`;
}
private buildOrderBySql(): SQL | undefined {
const alias = this.mainAlias();
const orders: SQL[] = [];
if (this.isDoingGrouping()) {
const groupField = this.currentGroupColumn();
if (groupField) {
const expr = this.resolveColumnExpression(groupField);
orders.push(sql`${sql.raw(expr)} ASC`);
}
} else {
const sortModel = this.queryModel.sortModel;
if (sortModel.length > 0) {
for (const sort of sortModel) {
const expr = this.resolveColumnExpression(sort.colId);
const direction = sort.sort.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
orders.push(sql`${sql.raw(expr)} ${sql.raw(direction)}`);
}
} else {
const defaultOrder = this.config.defaultOrderBy ?? [
`${alias}.created_at DESC`,
];
for (const clause of defaultOrder) {
orders.push(sql.raw(clause));
}
}
const lowLevel = this.config.lowLevelOrderBy ?? [`${alias}.id DESC`];
for (const clause of lowLevel) {
orders.push(sql.raw(clause));
}
}
if (orders.length === 0) {
return undefined;
}
return sql`ORDER BY ${sql.join(orders, sql.raw(', '))}`;
}
}
@@ -0,0 +1,19 @@
import type { SQL } from 'drizzle-orm';
export function flattenSql(sqlQuery: SQL): string {
const parts: string[] = [];
for (const chunk of sqlQuery.queryChunks) {
if (typeof chunk === 'string') {
parts.push(chunk);
continue;
}
if (chunk && typeof chunk === 'object' && 'queryChunks' in chunk) {
parts.push(flattenSql(chunk as SQL));
continue;
}
if (chunk && typeof chunk === 'object' && 'value' in chunk) {
parts.push(String((chunk as { value: unknown }).value));
}
}
return parts.join('');
}