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:
@@ -7,6 +7,7 @@ import { DatabaseModule } from './database/database.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { ConfigurationModule } from './modules/configuration/configuration.module';
|
||||
import { FieldModule } from './modules/field/field.module';
|
||||
import { ReportsModule } from './modules/reports/reports.module';
|
||||
import { SalesModule } from './modules/sales/sales.module';
|
||||
import { PrivilegesModule } from './modules/privileges/privileges.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
@@ -24,6 +25,7 @@ import { UsersModule } from './modules/users/users.module';
|
||||
ConfigurationModule,
|
||||
SalesModule,
|
||||
FieldModule,
|
||||
ReportsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
uuid,
|
||||
boolean,
|
||||
jsonb,
|
||||
uniqueIndex,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { users } from './schema';
|
||||
|
||||
export const reportBookmarks = pgTable(
|
||||
'report_bookmarks',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
groupName: text('group_name').notNull(),
|
||||
uniqueName: text('unique_name').notNull(),
|
||||
label: text('label').notNull(),
|
||||
type: text('type').notNull(),
|
||||
applied: boolean('applied').notNull().default(false),
|
||||
configuration: jsonb('configuration').notNull(),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('report_bookmarks_owner_report_type_applied_unique')
|
||||
.on(t.createdBy, t.groupName, t.uniqueName, t.type)
|
||||
.where(sql`${t.applied} = true`),
|
||||
],
|
||||
);
|
||||
|
||||
export type ReportBookmarkRow = typeof reportBookmarks.$inferSelect;
|
||||
export type NewReportBookmarkRow = typeof reportBookmarks.$inferInsert;
|
||||
@@ -247,3 +247,8 @@ export {
|
||||
type PlanPackingSlipRow,
|
||||
type PlanRow,
|
||||
} from './plans-table';
|
||||
export {
|
||||
reportBookmarks,
|
||||
type NewReportBookmarkRow,
|
||||
type ReportBookmarkRow,
|
||||
} from './report-bookmarks-table';
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import {
|
||||
REPORT_BOOKMARK_TYPE,
|
||||
type ReportBookmarkType,
|
||||
} from '../../shared/constants/bookmark-type';
|
||||
|
||||
export class ReportBookmarkDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
uniqueName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
label!: string;
|
||||
|
||||
@ApiProperty({ enum: Object.values(REPORT_BOOKMARK_TYPE) })
|
||||
type!: ReportBookmarkType;
|
||||
|
||||
@ApiProperty()
|
||||
applied!: boolean;
|
||||
|
||||
@ApiProperty({ type: 'object', additionalProperties: true })
|
||||
configuration!: unknown;
|
||||
|
||||
@ApiProperty()
|
||||
status!: string;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: number;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
}
|
||||
|
||||
export class ListReportBookmarksQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
uniqueName?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: Object.values(REPORT_BOOKMARK_TYPE) })
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(REPORT_BOOKMARK_TYPE))
|
||||
type?: ReportBookmarkType;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export class CreateReportBookmarkDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
uniqueName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
label!: string;
|
||||
|
||||
@ApiProperty({ enum: Object.values(REPORT_BOOKMARK_TYPE) })
|
||||
@IsIn(Object.values(REPORT_BOOKMARK_TYPE))
|
||||
type!: ReportBookmarkType;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
applied?: boolean;
|
||||
|
||||
@ApiProperty({ type: 'object', additionalProperties: true })
|
||||
@IsObject()
|
||||
configuration!: unknown;
|
||||
}
|
||||
|
||||
export class AppliedBookmarkQueryDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
uniqueName!: string;
|
||||
|
||||
@ApiProperty({ enum: Object.values(REPORT_BOOKMARK_TYPE) })
|
||||
@IsIn(Object.values(REPORT_BOOKMARK_TYPE))
|
||||
type!: ReportBookmarkType;
|
||||
}
|
||||
|
||||
export class LabelHistoryQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
label?: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReportBookmarksReadController } from './report-bookmarks-read.controller';
|
||||
import { ReportBookmarksWriteController } from './report-bookmarks-write.controller';
|
||||
import { ReportBookmarksRepository } from './report-bookmarks.repository';
|
||||
import { ReportBookmarksService } from './report-bookmarks.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ReportBookmarksReadController, ReportBookmarksWriteController],
|
||||
providers: [ReportBookmarksRepository, ReportBookmarksService],
|
||||
exports: [ReportBookmarksRepository, ReportBookmarksService],
|
||||
})
|
||||
export class ReportBookmarkModule {}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../../common/auth/auth-user';
|
||||
import {
|
||||
Pagination,
|
||||
type PaginationResponse,
|
||||
PaginationMetaDto,
|
||||
} from '../../../common/http/response';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import {
|
||||
AppliedBookmarkQueryDto,
|
||||
LabelHistoryQueryDto,
|
||||
ListReportBookmarksQueryDto,
|
||||
ReportBookmarkDto,
|
||||
} from './dto/report-bookmark.dto';
|
||||
import { ReportBookmarksService } from './report-bookmarks.service';
|
||||
|
||||
@ApiTags('report-bookmarks')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('report-bookmarks')
|
||||
export class ReportBookmarksReadController {
|
||||
constructor(private readonly bookmarksService: ReportBookmarksService) {}
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@ApiOperation({ summary: 'List report bookmarks for current user' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/ReportBookmarkDto' },
|
||||
},
|
||||
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
list(
|
||||
@Query() query: ListReportBookmarksQueryDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<PaginationResponse<ReportBookmarkDto>> {
|
||||
return this.bookmarksService.list(user.id, query);
|
||||
}
|
||||
|
||||
@Get('label-history')
|
||||
@ApiOperation({ summary: 'Distinct bookmark labels' })
|
||||
@ApiOkResponse({ type: [String] })
|
||||
labelHistory(
|
||||
@Query() query: LabelHistoryQueryDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.bookmarksService.labelHistory(user.id, query.label);
|
||||
}
|
||||
|
||||
@Get('applied')
|
||||
@ApiOperation({ summary: 'Get applied bookmark for report and type' })
|
||||
@ApiOkResponse({ type: ReportBookmarkDto })
|
||||
findApplied(
|
||||
@Query() query: AppliedBookmarkQueryDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.bookmarksService.findApplied(
|
||||
user.id,
|
||||
query.groupName,
|
||||
query.uniqueName,
|
||||
query.type,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Put,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../../common/auth/auth-user';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import {
|
||||
CreateReportBookmarkDto,
|
||||
ReportBookmarkDto,
|
||||
} from './dto/report-bookmark.dto';
|
||||
import { ReportBookmarksService } from './report-bookmarks.service';
|
||||
|
||||
@ApiTags('report-bookmarks')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('report-bookmarks')
|
||||
export class ReportBookmarksWriteController {
|
||||
constructor(private readonly bookmarksService: ReportBookmarksService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create report bookmark' })
|
||||
@ApiCreatedResponse({ type: ReportBookmarkDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
create(@Body() dto: CreateReportBookmarkDto, @CurrentUser() user: AuthUser) {
|
||||
return this.bookmarksService.create(user.id, dto);
|
||||
}
|
||||
|
||||
@Put('applied/:id')
|
||||
@ApiOperation({ summary: 'Apply report bookmark' })
|
||||
@ApiOkResponse({ type: ReportBookmarkDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
apply(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) {
|
||||
return this.bookmarksService.apply(user.id, id);
|
||||
}
|
||||
|
||||
@Put('unapplied/:id')
|
||||
@ApiOperation({ summary: 'Unapply report bookmark' })
|
||||
@ApiOkResponse({ type: ReportBookmarkDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
unapply(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.bookmarksService.unapply(user.id, id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete report bookmark' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
async delete(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<void> {
|
||||
await this.bookmarksService.delete(user.id, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { and, desc, eq, ilike } from 'drizzle-orm';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import { reportBookmarks } from '../../../database/report-bookmarks-table';
|
||||
import type { ReportBookmarkType } from '../shared/constants/bookmark-type';
|
||||
|
||||
export type ReportBookmark = {
|
||||
id: string;
|
||||
groupName: string;
|
||||
uniqueName: string;
|
||||
label: string;
|
||||
type: ReportBookmarkType;
|
||||
applied: boolean;
|
||||
configuration: unknown;
|
||||
status: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
updatedBy: string;
|
||||
};
|
||||
|
||||
export type ListReportBookmarksFilters = {
|
||||
groupName?: string;
|
||||
uniqueName?: string;
|
||||
type?: ReportBookmarkType;
|
||||
label?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ReportBookmarksRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async list(
|
||||
ownerId: string,
|
||||
filters: ListReportBookmarksFilters,
|
||||
): Promise<{ data: ReportBookmark[]; total: number }> {
|
||||
const conditions = [eq(reportBookmarks.createdBy, ownerId)];
|
||||
if (filters.groupName) {
|
||||
conditions.push(eq(reportBookmarks.groupName, filters.groupName));
|
||||
}
|
||||
if (filters.uniqueName) {
|
||||
conditions.push(eq(reportBookmarks.uniqueName, filters.uniqueName));
|
||||
}
|
||||
if (filters.type) {
|
||||
conditions.push(eq(reportBookmarks.type, filters.type));
|
||||
}
|
||||
if (filters.label) {
|
||||
conditions.push(ilike(reportBookmarks.label, `%${filters.label}%`));
|
||||
}
|
||||
const where = and(...conditions);
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(reportBookmarks)
|
||||
.where(where)
|
||||
.orderBy(desc(reportBookmarks.updatedAt))
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
const totalRows = await this.db
|
||||
.select({ id: reportBookmarks.id })
|
||||
.from(reportBookmarks)
|
||||
.where(where);
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row)),
|
||||
total: totalRows.length,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(ownerId: string, id: string): Promise<ReportBookmark | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(reportBookmarks)
|
||||
.where(
|
||||
and(eq(reportBookmarks.id, id), eq(reportBookmarks.createdBy, ownerId)),
|
||||
);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async findApplied(
|
||||
ownerId: string,
|
||||
groupName: string,
|
||||
uniqueName: string,
|
||||
type: ReportBookmarkType,
|
||||
): Promise<ReportBookmark | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(reportBookmarks)
|
||||
.where(
|
||||
and(
|
||||
eq(reportBookmarks.createdBy, ownerId),
|
||||
eq(reportBookmarks.groupName, groupName),
|
||||
eq(reportBookmarks.uniqueName, uniqueName),
|
||||
eq(reportBookmarks.type, type),
|
||||
eq(reportBookmarks.applied, true),
|
||||
),
|
||||
);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async labelHistory(ownerId: string, prefix?: string): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.select({ label: reportBookmarks.label })
|
||||
.from(reportBookmarks)
|
||||
.where(eq(reportBookmarks.createdBy, ownerId))
|
||||
.orderBy(desc(reportBookmarks.updatedAt));
|
||||
const labels = rows.map((r) => r.label);
|
||||
if (prefix) {
|
||||
return [...new Set(labels.filter((l) => l.includes(prefix)))];
|
||||
}
|
||||
return [...new Set(labels)];
|
||||
}
|
||||
|
||||
async create(
|
||||
ownerId: string,
|
||||
input: {
|
||||
groupName: string;
|
||||
uniqueName: string;
|
||||
label: string;
|
||||
type: ReportBookmarkType;
|
||||
applied: boolean;
|
||||
configuration: unknown;
|
||||
},
|
||||
): Promise<ReportBookmark> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
if (input.applied) {
|
||||
await this.unapplySiblings(
|
||||
ownerId,
|
||||
input.groupName,
|
||||
input.uniqueName,
|
||||
input.type,
|
||||
now.value,
|
||||
ownerId,
|
||||
);
|
||||
}
|
||||
const rows = await this.db
|
||||
.insert(reportBookmarks)
|
||||
.values({
|
||||
groupName: input.groupName,
|
||||
uniqueName: input.uniqueName,
|
||||
label: input.label,
|
||||
type: input.type,
|
||||
applied: input.applied,
|
||||
configuration: input.configuration,
|
||||
status: Status.DEFAULT,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: ownerId,
|
||||
updatedBy: ownerId,
|
||||
})
|
||||
.returning();
|
||||
return this.toDomain(rows[0]!);
|
||||
}
|
||||
|
||||
async apply(ownerId: string, id: string): Promise<ReportBookmark> {
|
||||
const bookmark = await this.findById(ownerId, id);
|
||||
if (!bookmark) {
|
||||
throw new NotFoundException('Bookmark not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
await this.unapplySiblings(
|
||||
ownerId,
|
||||
bookmark.groupName,
|
||||
bookmark.uniqueName,
|
||||
bookmark.type,
|
||||
now.value,
|
||||
ownerId,
|
||||
);
|
||||
const rows = await this.db
|
||||
.update(reportBookmarks)
|
||||
.set({ applied: true, updatedAt: now.value, updatedBy: ownerId })
|
||||
.where(
|
||||
and(eq(reportBookmarks.id, id), eq(reportBookmarks.createdBy, ownerId)),
|
||||
)
|
||||
.returning();
|
||||
return this.toDomain(rows[0]!);
|
||||
}
|
||||
|
||||
async unapply(ownerId: string, id: string): Promise<ReportBookmark> {
|
||||
const bookmark = await this.findById(ownerId, id);
|
||||
if (!bookmark) {
|
||||
throw new NotFoundException('Bookmark not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(reportBookmarks)
|
||||
.set({ applied: false, updatedAt: now.value, updatedBy: ownerId })
|
||||
.where(
|
||||
and(eq(reportBookmarks.id, id), eq(reportBookmarks.createdBy, ownerId)),
|
||||
)
|
||||
.returning();
|
||||
return this.toDomain(rows[0]!);
|
||||
}
|
||||
|
||||
async delete(ownerId: string, id: string): Promise<void> {
|
||||
const bookmark = await this.findById(ownerId, id);
|
||||
if (!bookmark) {
|
||||
throw new NotFoundException('Bookmark not found');
|
||||
}
|
||||
await this.db
|
||||
.delete(reportBookmarks)
|
||||
.where(
|
||||
and(eq(reportBookmarks.id, id), eq(reportBookmarks.createdBy, ownerId)),
|
||||
);
|
||||
}
|
||||
|
||||
private async unapplySiblings(
|
||||
ownerId: string,
|
||||
groupName: string,
|
||||
uniqueName: string,
|
||||
type: ReportBookmarkType,
|
||||
updatedAt: number,
|
||||
updatedBy: string,
|
||||
) {
|
||||
await this.db
|
||||
.update(reportBookmarks)
|
||||
.set({ applied: false, updatedAt, updatedBy })
|
||||
.where(
|
||||
and(
|
||||
eq(reportBookmarks.createdBy, ownerId),
|
||||
eq(reportBookmarks.groupName, groupName),
|
||||
eq(reportBookmarks.uniqueName, uniqueName),
|
||||
eq(reportBookmarks.type, type),
|
||||
eq(reportBookmarks.applied, true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private toDomain(row: typeof reportBookmarks.$inferSelect): ReportBookmark {
|
||||
return {
|
||||
id: row.id,
|
||||
groupName: row.groupName,
|
||||
uniqueName: row.uniqueName,
|
||||
label: row.label,
|
||||
type: row.type as ReportBookmarkType,
|
||||
applied: row.applied,
|
||||
configuration: row.configuration,
|
||||
status: row.status,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { toListPage } from '../../../common/http/response';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import {
|
||||
REPORT_BOOKMARK_TYPE,
|
||||
type ReportBookmarkType,
|
||||
} from '../shared/constants/bookmark-type';
|
||||
import { findReportConfig } from '../shared/configs';
|
||||
import {
|
||||
ReportBookmarksRepository,
|
||||
type ReportBookmark,
|
||||
} from './report-bookmarks.repository';
|
||||
|
||||
export type ListReportBookmarksQuery = {
|
||||
groupName?: string;
|
||||
uniqueName?: string;
|
||||
type?: ReportBookmarkType;
|
||||
label?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type CreateReportBookmarkInput = {
|
||||
groupName: string;
|
||||
uniqueName: string;
|
||||
label: string;
|
||||
type: ReportBookmarkType;
|
||||
applied?: boolean;
|
||||
configuration: unknown;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ReportBookmarksService {
|
||||
constructor(private readonly repository: ReportBookmarksRepository) {}
|
||||
|
||||
async list(
|
||||
ownerId: string,
|
||||
query: ListReportBookmarksQuery,
|
||||
): Promise<PaginationResponse<ReturnType<ReportBookmarksService['toDto']>>> {
|
||||
const page = toListPage(query);
|
||||
const { data, total } = await this.repository.list(ownerId, {
|
||||
groupName: query.groupName,
|
||||
uniqueName: query.uniqueName,
|
||||
type: query.type,
|
||||
label: query.label,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return { data: data.map((item) => this.toDto(item)), total };
|
||||
}
|
||||
|
||||
async labelHistory(ownerId: string, label?: string): Promise<string[]> {
|
||||
return this.repository.labelHistory(ownerId, label);
|
||||
}
|
||||
|
||||
async findApplied(
|
||||
ownerId: string,
|
||||
groupName?: string,
|
||||
uniqueName?: string,
|
||||
type?: ReportBookmarkType,
|
||||
) {
|
||||
if (!groupName || !uniqueName || !type) {
|
||||
throw new BadRequestException(
|
||||
'groupName, uniqueName, and type are required',
|
||||
);
|
||||
}
|
||||
const bookmark = await this.repository.findApplied(
|
||||
ownerId,
|
||||
groupName,
|
||||
uniqueName,
|
||||
type,
|
||||
);
|
||||
return bookmark ? this.toDto(bookmark) : null;
|
||||
}
|
||||
|
||||
async create(ownerId: string, input: CreateReportBookmarkInput) {
|
||||
this.assertValidBookmarkInput(input);
|
||||
const bookmark = await this.repository.create(ownerId, {
|
||||
groupName: input.groupName,
|
||||
uniqueName: input.uniqueName,
|
||||
label: input.label,
|
||||
type: input.type,
|
||||
applied: input.applied ?? false,
|
||||
configuration: input.configuration,
|
||||
});
|
||||
return this.toDto(bookmark);
|
||||
}
|
||||
|
||||
async apply(ownerId: string, id: string) {
|
||||
const bookmark = await this.repository.apply(ownerId, id);
|
||||
return this.toDto(bookmark);
|
||||
}
|
||||
|
||||
async unapply(ownerId: string, id: string) {
|
||||
const bookmark = await this.repository.unapply(ownerId, id);
|
||||
return this.toDto(bookmark);
|
||||
}
|
||||
|
||||
async delete(ownerId: string, id: string): Promise<void> {
|
||||
await this.repository.delete(ownerId, id);
|
||||
}
|
||||
|
||||
private assertValidBookmarkInput(input: CreateReportBookmarkInput) {
|
||||
if (!findReportConfig(input.groupName, input.uniqueName)) {
|
||||
throw new NotFoundException('Report not found');
|
||||
}
|
||||
if (
|
||||
input.type !== REPORT_BOOKMARK_TYPE.FILTER_TABLE &&
|
||||
input.type !== REPORT_BOOKMARK_TYPE.TABLE_CONFIG
|
||||
) {
|
||||
throw new BadRequestException('Invalid bookmark type');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(bookmark: ReportBookmark) {
|
||||
return {
|
||||
id: bookmark.id,
|
||||
groupName: bookmark.groupName,
|
||||
uniqueName: bookmark.uniqueName,
|
||||
label: bookmark.label,
|
||||
type: bookmark.type,
|
||||
applied: bookmark.applied,
|
||||
configuration: bookmark.configuration,
|
||||
status: bookmark.status,
|
||||
createdAt: bookmark.createdAt,
|
||||
updatedAt: bookmark.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { FILTER_TYPE } from '../../shared/constants/filter-type';
|
||||
import type { FilterType } from '../../shared/constants/filter-type';
|
||||
import type { QueryModelEntity } from '../../shared/entities/report-config.entity';
|
||||
|
||||
export class FilterModelEntryDto {
|
||||
@ApiProperty({ enum: Object.values(FILTER_TYPE) })
|
||||
@IsString()
|
||||
@IsIn(Object.values(FILTER_TYPE))
|
||||
type!: FilterType;
|
||||
|
||||
@ApiProperty()
|
||||
filter!: unknown;
|
||||
}
|
||||
|
||||
export class RowGroupColDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
displayName!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
field?: string;
|
||||
}
|
||||
|
||||
export class ValueColDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
field?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
aggFunc?: string;
|
||||
}
|
||||
|
||||
export class SortModelEntryDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
colId!: string;
|
||||
|
||||
@ApiProperty({ enum: ['asc', 'desc'] })
|
||||
@IsIn(['asc', 'desc'])
|
||||
sort!: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export class QueryModelDto {
|
||||
@ApiProperty()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
startRow!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
endRow!: number;
|
||||
|
||||
@ApiProperty({ type: [RowGroupColDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RowGroupColDto)
|
||||
rowGroupCols!: RowGroupColDto[];
|
||||
|
||||
@ApiProperty({ type: [ValueColDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ValueColDto)
|
||||
valueCols!: ValueColDto[];
|
||||
|
||||
@ApiProperty({ type: 'array', items: { type: 'object' } })
|
||||
@IsArray()
|
||||
pivotCols!: unknown[];
|
||||
|
||||
@ApiProperty()
|
||||
@IsBoolean()
|
||||
pivotMode!: boolean;
|
||||
|
||||
@ApiProperty({ type: 'array' })
|
||||
@IsArray()
|
||||
groupKeys!: unknown[];
|
||||
|
||||
@ApiProperty({ type: 'object', additionalProperties: true })
|
||||
@IsObject()
|
||||
filterModel!: Record<string, FilterModelEntryDto>;
|
||||
|
||||
@ApiProperty({ type: [SortModelEntryDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SortModelEntryDto)
|
||||
sortModel!: SortModelEntryDto[];
|
||||
}
|
||||
|
||||
export class ReportQueryDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
uniqueName!: string;
|
||||
|
||||
@ApiProperty({ type: QueryModelDto })
|
||||
@IsObject()
|
||||
queryModel!: QueryModelEntity;
|
||||
}
|
||||
|
||||
export class ReportMetaDto {
|
||||
@ApiProperty()
|
||||
totalRow!: number;
|
||||
|
||||
@ApiProperty()
|
||||
limit!: number;
|
||||
|
||||
@ApiProperty()
|
||||
offset!: number;
|
||||
}
|
||||
|
||||
export class ListReportConfigQueryDto {
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
groupNames?: string | string[];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { ReportPrivilegeGuard } from '../shared/guards/report-privilege.guard';
|
||||
import { ReportMetaDto, ReportQueryDto } from './dto/report.dto';
|
||||
import { ReportService } from './report.service';
|
||||
|
||||
@ApiTags('reports')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('reports')
|
||||
@UseGuards(ReportPrivilegeGuard)
|
||||
export class ReportQueryController {
|
||||
constructor(private readonly reportService: ReportService) {}
|
||||
|
||||
@Post('data')
|
||||
@ApiOperation({ summary: 'Run report data query' })
|
||||
@ApiOkResponse({ description: 'Report rows' })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
getData(@Body() dto: ReportQueryDto) {
|
||||
return this.reportService.getReportData(dto);
|
||||
}
|
||||
|
||||
@Post('meta')
|
||||
@ApiOperation({ summary: 'Run report count query' })
|
||||
@ApiOkResponse({ type: ReportMetaDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
getMeta(@Body() dto: ReportQueryDto) {
|
||||
return this.reportService.getReportMeta(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../../common/auth/auth-user';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { ReportPrivilegeGuard } from '../shared/guards/report-privilege.guard';
|
||||
import { parseGroupNamesQuery } from '../shared/helpers/report-query-params';
|
||||
import { ReportService } from './report.service';
|
||||
|
||||
@ApiTags('reports')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('reports')
|
||||
export class ReportReadController {
|
||||
constructor(private readonly reportService: ReportService) {}
|
||||
|
||||
@Get('config')
|
||||
@UseGuards(ReportPrivilegeGuard)
|
||||
@ApiOperation({ summary: 'List report configs for group names' })
|
||||
@ApiOkResponse({ description: 'Report config list' })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
listConfigs(
|
||||
@Req() req: { query: Record<string, unknown> },
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
const groupNames = parseGroupNamesQuery(req.query);
|
||||
return this.reportService.listConfigs(
|
||||
groupNames,
|
||||
user.id,
|
||||
user.isSuperadmin,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrivilegesModule } from '../../privileges/privileges.module';
|
||||
import { ReportBookmarkModule } from '../report-bookmark/report-bookmark.module';
|
||||
import { ReportPrivilegeGuard } from '../shared/guards/report-privilege.guard';
|
||||
import { ReportReadController } from './report-read.controller';
|
||||
import { ReportQueryController } from './report-query.controller';
|
||||
import { ReportService } from './report.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrivilegesModule, ReportBookmarkModule],
|
||||
controllers: [ReportReadController, ReportQueryController],
|
||||
providers: [ReportService, ReportPrivilegeGuard],
|
||||
exports: [ReportService],
|
||||
})
|
||||
export class ReportModule {}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import { PrivilegesService } from '../../privileges/privileges.service';
|
||||
import {
|
||||
findReportConfig,
|
||||
findReportConfigsByGroup,
|
||||
toPublicConfig,
|
||||
} from '../shared/configs';
|
||||
import { REPORT_BOOKMARK_TYPE } from '../shared/constants/bookmark-type';
|
||||
import { formatReportRow } from '../shared/helpers/report-cell.formatter';
|
||||
import { ReportQueryBuilder } from '../shared/helpers/report-query.builder';
|
||||
import type { ReportQueryDto } from './dto/report.dto';
|
||||
import { ReportBookmarksRepository } from '../report-bookmark/report-bookmarks.repository';
|
||||
|
||||
@Injectable()
|
||||
export class ReportService {
|
||||
constructor(
|
||||
@Inject(DRIZZLE) private readonly db: DrizzleDB,
|
||||
private readonly privilegesService: PrivilegesService,
|
||||
private readonly bookmarksRepository: ReportBookmarksRepository,
|
||||
) {}
|
||||
|
||||
async listConfigs(
|
||||
groupNames: string[],
|
||||
userId: string,
|
||||
isSuperadmin: boolean,
|
||||
) {
|
||||
const configs = groupNames.flatMap((g) => findReportConfigsByGroup(g));
|
||||
const visible = isSuperadmin
|
||||
? configs
|
||||
: await this.filterVisibleConfigs(configs, userId);
|
||||
|
||||
const result = await Promise.all(
|
||||
visible.map(async (config) => {
|
||||
const activeFilter = await this.bookmarksRepository.findApplied(
|
||||
userId,
|
||||
config.groupName,
|
||||
config.uniqueName,
|
||||
REPORT_BOOKMARK_TYPE.FILTER_TABLE,
|
||||
);
|
||||
const activeTableConfig = await this.bookmarksRepository.findApplied(
|
||||
userId,
|
||||
config.groupName,
|
||||
config.uniqueName,
|
||||
REPORT_BOOKMARK_TYPE.TABLE_CONFIG,
|
||||
);
|
||||
return {
|
||||
...toPublicConfig(config),
|
||||
activeFilter: activeFilter ? this.toBookmarkDto(activeFilter) : null,
|
||||
activeTableConfig: activeTableConfig
|
||||
? this.toBookmarkDto(activeTableConfig)
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async getReportData(dto: ReportQueryDto) {
|
||||
const config = this.requireConfig(dto.groupName, dto.uniqueName);
|
||||
const builder = new ReportQueryBuilder(config, dto.queryModel);
|
||||
const sqlQuery = builder.getSqlData();
|
||||
const rows = (await this.db.execute(sqlQuery)) as Record<string, unknown>[];
|
||||
return rows.map((row) => formatReportRow(row, config.columnConfigs));
|
||||
}
|
||||
|
||||
async getReportMeta(dto: ReportQueryDto) {
|
||||
const config = this.requireConfig(dto.groupName, dto.uniqueName);
|
||||
const builder = new ReportQueryBuilder(config, dto.queryModel);
|
||||
const sqlQuery = builder.getSqlCount();
|
||||
const result = (await this.db.execute(sqlQuery)) as {
|
||||
count: string | number;
|
||||
}[];
|
||||
const totalRow = Number(result[0]?.count ?? 0);
|
||||
const limit = dto.queryModel.endRow - dto.queryModel.startRow;
|
||||
return {
|
||||
totalRow,
|
||||
limit,
|
||||
offset: dto.queryModel.startRow,
|
||||
};
|
||||
}
|
||||
|
||||
private requireConfig(groupName: string, uniqueName: string) {
|
||||
const config = findReportConfig(groupName, uniqueName);
|
||||
if (!config) {
|
||||
throw new NotFoundException('Report not found');
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
private async filterVisibleConfigs(
|
||||
configs: ReturnType<typeof findReportConfigsByGroup>,
|
||||
userId: string,
|
||||
) {
|
||||
const visible: typeof configs = [];
|
||||
for (const config of configs) {
|
||||
const ok = await this.privilegesService.checkPermission(
|
||||
userId,
|
||||
config.privilegeKey,
|
||||
'view',
|
||||
);
|
||||
if (ok) {
|
||||
visible.push(config);
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
private toBookmarkDto(row: {
|
||||
id: string;
|
||||
groupName: string;
|
||||
uniqueName: string;
|
||||
label: string;
|
||||
type: string;
|
||||
applied: boolean;
|
||||
configuration: unknown;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
groupName: row.groupName,
|
||||
uniqueName: row.uniqueName,
|
||||
label: row.label,
|
||||
type: row.type,
|
||||
applied: row.applied,
|
||||
configuration: row.configuration,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrivilegesModule } from '../privileges/privileges.module';
|
||||
import { ReportBookmarkModule } from './report-bookmark/report-bookmark.module';
|
||||
import { ReportModule } from './report/report.module';
|
||||
import { ReportPrivilegeGuard } from './shared/guards/report-privilege.guard';
|
||||
|
||||
@Module({
|
||||
imports: [PrivilegesModule, ReportModule, ReportBookmarkModule],
|
||||
providers: [ReportPrivilegeGuard],
|
||||
exports: [ReportModule, ReportBookmarkModule],
|
||||
})
|
||||
export class ReportsModule {}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ReportConfigEntity } from '../entities';
|
||||
import { deliveryPlanReport, packingSlipReport } from './logistics-reports';
|
||||
import {
|
||||
salesInvoiceReport,
|
||||
salesOrderReport,
|
||||
salesPaymentReport,
|
||||
salesRequestReport,
|
||||
visitPlanReport,
|
||||
} from './sales-reports';
|
||||
|
||||
export const reportConfigs: ReportConfigEntity[] = [
|
||||
salesOrderReport,
|
||||
salesRequestReport,
|
||||
salesInvoiceReport,
|
||||
salesPaymentReport,
|
||||
visitPlanReport,
|
||||
packingSlipReport,
|
||||
deliveryPlanReport,
|
||||
];
|
||||
|
||||
export function findReportConfig(
|
||||
groupName: string,
|
||||
uniqueName: string,
|
||||
): ReportConfigEntity | undefined {
|
||||
return reportConfigs.find(
|
||||
(c) => c.groupName === groupName && c.uniqueName === uniqueName,
|
||||
);
|
||||
}
|
||||
|
||||
export function findReportConfigsByGroup(
|
||||
groupName: string,
|
||||
): ReportConfigEntity[] {
|
||||
return reportConfigs.filter((c) => c.groupName === groupName);
|
||||
}
|
||||
|
||||
export function toPublicConfig(config: ReportConfigEntity) {
|
||||
const {
|
||||
whereCondition: _whereCondition,
|
||||
customQueryColumn: _customQueryColumn,
|
||||
...publicConfig
|
||||
} = config;
|
||||
return publicConfig;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
DATA_FORMAT,
|
||||
DATA_TYPE,
|
||||
FILTER_FIELD_TYPE,
|
||||
FILTER_TYPE,
|
||||
REPORT_GROUP,
|
||||
} from '../constants';
|
||||
import type { ReportConfigEntity } from '../entities';
|
||||
|
||||
const LOGISTICS_PREFIX = `${REPORT_GROUP.LOGISTICS_REPORT}__`;
|
||||
|
||||
export const packingSlipReport: ReportConfigEntity = {
|
||||
groupName: REPORT_GROUP.LOGISTICS_REPORT,
|
||||
uniqueName: `${LOGISTICS_PREFIX}packing_slip`,
|
||||
privilegeKey: 'LOGISTICS.REPORT',
|
||||
label: 'Report Packing Slip',
|
||||
tableSchema: `packing_slips main
|
||||
JOIN customers cust ON cust.id = main.customer_id`,
|
||||
mainTableAlias: 'main',
|
||||
defaultOrderBy: ['main.created_at DESC'],
|
||||
lowLevelOrderBy: ['main.id DESC'],
|
||||
filterPeriodConfig: { hidden: true },
|
||||
columnConfigs: [
|
||||
{
|
||||
column: 'main__date',
|
||||
query: 'main.date',
|
||||
label: 'Date',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.DATE_EPOCH,
|
||||
},
|
||||
{
|
||||
column: 'main__sales_order_number',
|
||||
query: 'main.sales_order_number',
|
||||
label: 'Sales Order No.',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__code',
|
||||
query: 'main.code',
|
||||
label: 'Packing Slip No.',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'cust__name',
|
||||
query: 'cust.name',
|
||||
label: 'Customer',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__status',
|
||||
query: 'main.status',
|
||||
label: 'Status',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.STATUS,
|
||||
},
|
||||
],
|
||||
filterConfigs: [
|
||||
{
|
||||
fieldLabel: 'Date',
|
||||
filterColumn: 'main__date',
|
||||
fieldType: FILTER_FIELD_TYPE.DATE_RANGE_PICKER,
|
||||
filterType: FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Customer',
|
||||
filterColumn: 'cust__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Status',
|
||||
filterColumn: 'main__status',
|
||||
fieldType: FILTER_FIELD_TYPE.SELECT,
|
||||
filterType: FILTER_TYPE.TEXT_IN_MEMBER_TEXT,
|
||||
selectCustomOptions: ['draft', 'active', 'archived'],
|
||||
},
|
||||
],
|
||||
customQueryColumn: (column) => {
|
||||
if (column === 'main__date') {
|
||||
return 'main.date';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
export const deliveryPlanReport: ReportConfigEntity = {
|
||||
groupName: REPORT_GROUP.LOGISTICS_REPORT,
|
||||
uniqueName: `${LOGISTICS_PREFIX}delivery_plan`,
|
||||
privilegeKey: 'LOGISTICS.REPORT',
|
||||
label: 'Report Delivery Plan',
|
||||
tableSchema: `plans main
|
||||
JOIN employees emp ON emp.id = main.employee_id
|
||||
JOIN branches start_br ON start_br.id = main.start_branch_id
|
||||
LEFT JOIN (
|
||||
SELECT plan_id, COUNT(*) AS destination_count
|
||||
FROM plan_destinations
|
||||
GROUP BY plan_id
|
||||
) pd ON pd.plan_id = main.id
|
||||
LEFT JOIN (
|
||||
SELECT plan_id, COUNT(*) AS packing_slip_count
|
||||
FROM plan_packing_slips
|
||||
GROUP BY plan_id
|
||||
) pps ON pps.plan_id = main.id`,
|
||||
mainTableAlias: 'main',
|
||||
whereDefaultConditions: ["main.purpose = 'logistics'"],
|
||||
defaultOrderBy: ['main.date DESC'],
|
||||
lowLevelOrderBy: ['main.id DESC'],
|
||||
filterPeriodConfig: { hidden: true },
|
||||
columnConfigs: [
|
||||
{
|
||||
column: 'main__date',
|
||||
query: 'main.date',
|
||||
label: 'Date',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.DATE_EPOCH,
|
||||
},
|
||||
{
|
||||
column: 'emp__name',
|
||||
query: 'emp.name',
|
||||
label: 'Sales Rep',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'start_br__name',
|
||||
query: 'start_br.name',
|
||||
label: 'Branch',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'pd__destination_count',
|
||||
query: 'COALESCE(pd.destination_count, 0)',
|
||||
label: 'Plan',
|
||||
type: DATA_TYPE.MEASURE,
|
||||
format: DATA_FORMAT.NUMBER,
|
||||
},
|
||||
{
|
||||
column: 'pps__packing_slip_count',
|
||||
query: 'COALESCE(pps.packing_slip_count, 0)',
|
||||
label: 'Packing Slip',
|
||||
type: DATA_TYPE.MEASURE,
|
||||
format: DATA_FORMAT.NUMBER,
|
||||
},
|
||||
{
|
||||
column: 'main__status',
|
||||
query: 'main.status',
|
||||
label: 'Status',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.STATUS,
|
||||
},
|
||||
],
|
||||
filterConfigs: [
|
||||
{
|
||||
fieldLabel: 'Date',
|
||||
filterColumn: 'main__date',
|
||||
fieldType: FILTER_FIELD_TYPE.DATE_RANGE_PICKER,
|
||||
filterType: FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Driver',
|
||||
filterColumn: 'emp__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Status',
|
||||
filterColumn: 'main__status',
|
||||
fieldType: FILTER_FIELD_TYPE.SELECT,
|
||||
filterType: FILTER_TYPE.TEXT_IN_MEMBER_TEXT,
|
||||
selectCustomOptions: ['draft', 'active', 'archived'],
|
||||
},
|
||||
],
|
||||
customQueryColumn: (column) => {
|
||||
if (column === 'main__date') {
|
||||
return 'main.date';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,570 @@
|
||||
import {
|
||||
DATA_FORMAT,
|
||||
DATA_TYPE,
|
||||
FILTER_FIELD_TYPE,
|
||||
FILTER_TYPE,
|
||||
REPORT_GROUP,
|
||||
} from '../constants';
|
||||
import type { ReportConfigEntity } from '../entities';
|
||||
|
||||
const SALES_PREFIX = `${REPORT_GROUP.SALES_REPORT}__`;
|
||||
const LOGISTICS_PREFIX = `${REPORT_GROUP.LOGISTICS_REPORT}__`;
|
||||
|
||||
export const salesOrderReport: ReportConfigEntity = {
|
||||
groupName: REPORT_GROUP.SALES_REPORT,
|
||||
uniqueName: `${SALES_PREFIX}sales_order`,
|
||||
privilegeKey: 'SALES.REPORT',
|
||||
label: 'Report Sales Order',
|
||||
tableSchema: `sales_orders main
|
||||
JOIN customers cust ON cust.id = main.customer_id
|
||||
JOIN branches br ON br.id = main.branch_id
|
||||
JOIN divisions dv ON dv.id = main.division_id
|
||||
JOIN employees emp ON emp.id = main.sales_person_id
|
||||
LEFT JOIN (
|
||||
SELECT sales_order_id, SUM(quantity * price) AS amount
|
||||
FROM sales_order_products
|
||||
GROUP BY sales_order_id
|
||||
) sop ON sop.sales_order_id = main.id`,
|
||||
mainTableAlias: 'main',
|
||||
whereDefaultConditions: [],
|
||||
defaultOrderBy: ['main.created_at DESC'],
|
||||
lowLevelOrderBy: ['main.id DESC'],
|
||||
filterPeriodConfig: { hidden: true },
|
||||
columnConfigs: [
|
||||
{
|
||||
column: 'main__date',
|
||||
query: 'main.date',
|
||||
label: 'Date',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.DATE_EPOCH,
|
||||
},
|
||||
{
|
||||
column: 'br__name',
|
||||
query: 'br.name',
|
||||
label: 'Branch',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'dv__name',
|
||||
query: 'dv.name',
|
||||
label: 'Division',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__code',
|
||||
query: 'main.code',
|
||||
label: 'No. Sales Order',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'cust__name',
|
||||
query: 'cust.name',
|
||||
label: 'Customer',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__amount',
|
||||
query: 'COALESCE(sop.amount, 0)',
|
||||
label: 'Invoice Amount',
|
||||
type: DATA_TYPE.MEASURE,
|
||||
format: DATA_FORMAT.CURRENCY,
|
||||
},
|
||||
{
|
||||
column: 'emp__name',
|
||||
query: 'emp.name',
|
||||
label: 'Sales Rep.',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__status',
|
||||
query: 'main.status',
|
||||
label: 'Last Status Order',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.STATUS,
|
||||
},
|
||||
],
|
||||
filterConfigs: [
|
||||
{
|
||||
fieldLabel: 'Date',
|
||||
filterColumn: 'main__date',
|
||||
fieldType: FILTER_FIELD_TYPE.DATE_RANGE_PICKER,
|
||||
filterType: FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Branch',
|
||||
filterColumn: 'br__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Division',
|
||||
filterColumn: 'dv__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Customer',
|
||||
filterColumn: 'cust__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Sales Rep.',
|
||||
filterColumn: 'emp__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Status',
|
||||
filterColumn: 'main__status',
|
||||
fieldType: FILTER_FIELD_TYPE.SELECT,
|
||||
filterType: FILTER_TYPE.TEXT_IN_MEMBER_TEXT,
|
||||
selectCustomOptions: ['draft', 'active', 'archived'],
|
||||
},
|
||||
],
|
||||
customQueryColumn: (column) => {
|
||||
if (column === 'main__date') {
|
||||
return 'main.date';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
export const salesRequestReport: ReportConfigEntity = {
|
||||
groupName: REPORT_GROUP.SALES_REPORT,
|
||||
uniqueName: `${SALES_PREFIX}sales_request`,
|
||||
privilegeKey: 'SALES.REPORT',
|
||||
label: 'Report Request Order',
|
||||
tableSchema: `sales_requests main
|
||||
JOIN customers cust ON cust.id = main.customer_id
|
||||
JOIN branches br ON br.id = main.branch_id
|
||||
JOIN divisions dv ON dv.id = main.division_id
|
||||
JOIN employees emp ON emp.id = main.sales_person_id`,
|
||||
mainTableAlias: 'main',
|
||||
defaultOrderBy: ['main.created_at DESC'],
|
||||
lowLevelOrderBy: ['main.id DESC'],
|
||||
filterPeriodConfig: { hidden: true },
|
||||
columnConfigs: [
|
||||
{
|
||||
column: 'main__date',
|
||||
query: 'main.date',
|
||||
label: 'Date',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.DATE_EPOCH,
|
||||
},
|
||||
{
|
||||
column: 'br__name',
|
||||
query: 'br.name',
|
||||
label: 'Branch',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'dv__name',
|
||||
query: 'dv.name',
|
||||
label: 'Division',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__code',
|
||||
query: 'main.code',
|
||||
label: 'No. Request Order',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'cust__name',
|
||||
query: 'cust.name',
|
||||
label: 'Customer',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'emp__name',
|
||||
query: 'emp.name',
|
||||
label: 'Sales Rep.',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__status',
|
||||
query: 'main.status',
|
||||
label: 'Status',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.STATUS,
|
||||
},
|
||||
],
|
||||
filterConfigs: [
|
||||
{
|
||||
fieldLabel: 'Date',
|
||||
filterColumn: 'main__date',
|
||||
fieldType: FILTER_FIELD_TYPE.DATE_RANGE_PICKER,
|
||||
filterType: FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Branch',
|
||||
filterColumn: 'br__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Division',
|
||||
filterColumn: 'dv__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Customer',
|
||||
filterColumn: 'cust__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Sales Rep.',
|
||||
filterColumn: 'emp__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Status',
|
||||
filterColumn: 'main__status',
|
||||
fieldType: FILTER_FIELD_TYPE.SELECT,
|
||||
filterType: FILTER_TYPE.TEXT_IN_MEMBER_TEXT,
|
||||
selectCustomOptions: ['draft', 'active', 'archived'],
|
||||
},
|
||||
],
|
||||
customQueryColumn: (column) => {
|
||||
if (column === 'main__date') {
|
||||
return 'main.date';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
export const salesInvoiceReport: ReportConfigEntity = {
|
||||
groupName: REPORT_GROUP.SALES_REPORT,
|
||||
uniqueName: `${SALES_PREFIX}sales_invoice`,
|
||||
privilegeKey: 'SALES.REPORT',
|
||||
label: 'Report Invoice',
|
||||
tableSchema: `sales_invoices main
|
||||
JOIN customers cust ON cust.id = main.customer_id
|
||||
JOIN branches br ON br.id = main.branch_id
|
||||
JOIN divisions dv ON dv.id = main.division_id
|
||||
JOIN employees emp ON emp.id = main.sales_person_id`,
|
||||
mainTableAlias: 'main',
|
||||
defaultOrderBy: ['main.created_at DESC'],
|
||||
lowLevelOrderBy: ['main.id DESC'],
|
||||
filterPeriodConfig: { hidden: true },
|
||||
columnConfigs: [
|
||||
{
|
||||
column: 'main__date',
|
||||
query: 'main.date',
|
||||
label: 'Date',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.DATE_EPOCH,
|
||||
},
|
||||
{
|
||||
column: 'br__name',
|
||||
query: 'br.name',
|
||||
label: 'Branch',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'dv__name',
|
||||
query: 'dv.name',
|
||||
label: 'Division',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'cust__name',
|
||||
query: 'cust.name',
|
||||
label: 'Customer',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'cust__code',
|
||||
query: 'cust.code',
|
||||
label: 'Customer code',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__sales_order_code',
|
||||
query: 'main.sales_order_code',
|
||||
label: 'Sales Order No.',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__code',
|
||||
query: 'main.code',
|
||||
label: 'Invoice No.',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__status',
|
||||
query: 'main.status',
|
||||
label: 'Status',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.STATUS,
|
||||
},
|
||||
{
|
||||
column: 'emp__name',
|
||||
query: 'emp.name',
|
||||
label: 'Sales Rep.',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__balance',
|
||||
query: 'main.balance',
|
||||
label: 'Balance',
|
||||
type: DATA_TYPE.MEASURE,
|
||||
format: DATA_FORMAT.CURRENCY,
|
||||
},
|
||||
],
|
||||
filterConfigs: [
|
||||
{
|
||||
fieldLabel: 'Date',
|
||||
filterColumn: 'main__date',
|
||||
fieldType: FILTER_FIELD_TYPE.DATE_RANGE_PICKER,
|
||||
filterType: FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Branch',
|
||||
filterColumn: 'br__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Status',
|
||||
filterColumn: 'main__status',
|
||||
fieldType: FILTER_FIELD_TYPE.SELECT,
|
||||
filterType: FILTER_TYPE.TEXT_IN_MEMBER_TEXT,
|
||||
selectCustomOptions: ['draft', 'active', 'archived'],
|
||||
},
|
||||
],
|
||||
customQueryColumn: (column) => {
|
||||
if (column === 'main__date') {
|
||||
return 'main.date';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
export const salesPaymentReport: ReportConfigEntity = {
|
||||
groupName: REPORT_GROUP.SALES_REPORT,
|
||||
uniqueName: `${SALES_PREFIX}sales_payment`,
|
||||
privilegeKey: 'SALES.REPORT',
|
||||
label: 'Report Payment',
|
||||
tableSchema: `sales_payments main
|
||||
JOIN sales_payment_invoices spi ON spi.sales_payment_id = main.id
|
||||
JOIN sales_invoices inv ON inv.id = spi.sales_invoice_id
|
||||
JOIN customers cust ON cust.id = inv.customer_id
|
||||
JOIN branches br ON br.id = inv.branch_id
|
||||
JOIN divisions dv ON dv.id = inv.division_id
|
||||
JOIN employees emp ON emp.id = inv.sales_person_id`,
|
||||
mainTableAlias: 'main',
|
||||
defaultOrderBy: ['main.created_at DESC'],
|
||||
lowLevelOrderBy: ['main.id DESC'],
|
||||
filterPeriodConfig: { hidden: true },
|
||||
columnConfigs: [
|
||||
{
|
||||
column: 'main__date',
|
||||
query: 'main.date',
|
||||
label: 'Date',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.DATE_EPOCH,
|
||||
},
|
||||
{
|
||||
column: 'main__code',
|
||||
query: 'main.code',
|
||||
label: 'Payment No.',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'cust__code',
|
||||
query: 'cust.code',
|
||||
label: 'Customer code',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'br__name',
|
||||
query: 'br.name',
|
||||
label: 'Branch',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'dv__name',
|
||||
query: 'dv.name',
|
||||
label: 'Division',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'emp__name',
|
||||
query: 'emp.name',
|
||||
label: 'Sales Rep',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'inv__code',
|
||||
query: 'inv.code',
|
||||
label: 'Invoice ID',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'inv__balance',
|
||||
query: 'inv.balance',
|
||||
label: 'Invoice Amount',
|
||||
type: DATA_TYPE.MEASURE,
|
||||
format: DATA_FORMAT.CURRENCY,
|
||||
},
|
||||
{
|
||||
column: 'spi__amount',
|
||||
query: 'spi.amount',
|
||||
label: 'Payment Amount',
|
||||
type: DATA_TYPE.MEASURE,
|
||||
format: DATA_FORMAT.CURRENCY,
|
||||
},
|
||||
{
|
||||
column: 'main__status',
|
||||
query: 'main.status',
|
||||
label: 'Status',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.STATUS,
|
||||
},
|
||||
],
|
||||
filterConfigs: [
|
||||
{
|
||||
fieldLabel: 'Date',
|
||||
filterColumn: 'main__date',
|
||||
fieldType: FILTER_FIELD_TYPE.DATE_RANGE_PICKER,
|
||||
filterType: FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Status',
|
||||
filterColumn: 'main__status',
|
||||
fieldType: FILTER_FIELD_TYPE.SELECT,
|
||||
filterType: FILTER_TYPE.TEXT_IN_MEMBER_TEXT,
|
||||
selectCustomOptions: ['draft', 'active', 'archived'],
|
||||
},
|
||||
],
|
||||
customQueryColumn: (column) => {
|
||||
if (column === 'main__date') {
|
||||
return 'main.date';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
export const visitPlanReport: ReportConfigEntity = {
|
||||
groupName: REPORT_GROUP.SALES_REPORT,
|
||||
uniqueName: `${SALES_PREFIX}visit_plan`,
|
||||
privilegeKey: 'SALES.REPORT',
|
||||
label: 'Report Visit Plan',
|
||||
tableSchema: `plans main
|
||||
JOIN employees emp ON emp.id = main.employee_id
|
||||
JOIN branches start_br ON start_br.id = main.start_branch_id
|
||||
LEFT JOIN (
|
||||
SELECT plan_id, COUNT(*) AS destination_count
|
||||
FROM plan_destinations
|
||||
GROUP BY plan_id
|
||||
) pd ON pd.plan_id = main.id
|
||||
LEFT JOIN (
|
||||
SELECT plan_id, COUNT(*) AS invoice_count
|
||||
FROM plan_invoices
|
||||
GROUP BY plan_id
|
||||
) pi ON pi.plan_id = main.id`,
|
||||
mainTableAlias: 'main',
|
||||
whereDefaultConditions: ["main.purpose = 'sales'"],
|
||||
defaultOrderBy: ['main.date DESC'],
|
||||
lowLevelOrderBy: ['main.id DESC'],
|
||||
filterPeriodConfig: { hidden: true },
|
||||
columnConfigs: [
|
||||
{
|
||||
column: 'main__date',
|
||||
query: 'main.date',
|
||||
label: 'Date',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.DATE_EPOCH,
|
||||
},
|
||||
{
|
||||
column: 'emp__name',
|
||||
query: 'emp.name',
|
||||
label: 'Sales Rep',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'start_br__name',
|
||||
query: 'start_br.name',
|
||||
label: 'Branch',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'pd__destination_count',
|
||||
query: 'COALESCE(pd.destination_count, 0)',
|
||||
label: 'Plan',
|
||||
type: DATA_TYPE.MEASURE,
|
||||
format: DATA_FORMAT.NUMBER,
|
||||
},
|
||||
{
|
||||
column: 'pi__invoice_count',
|
||||
query: 'COALESCE(pi.invoice_count, 0)',
|
||||
label: 'Invoice',
|
||||
type: DATA_TYPE.MEASURE,
|
||||
format: DATA_FORMAT.NUMBER,
|
||||
},
|
||||
{
|
||||
column: 'main__status',
|
||||
query: 'main.status',
|
||||
label: 'Status',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.STATUS,
|
||||
},
|
||||
],
|
||||
filterConfigs: [
|
||||
{
|
||||
fieldLabel: 'Date',
|
||||
filterColumn: 'main__date',
|
||||
fieldType: FILTER_FIELD_TYPE.DATE_RANGE_PICKER,
|
||||
filterType: FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Sales Rep',
|
||||
filterColumn: 'emp__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Status',
|
||||
filterColumn: 'main__status',
|
||||
fieldType: FILTER_FIELD_TYPE.SELECT,
|
||||
filterType: FILTER_TYPE.TEXT_IN_MEMBER_TEXT,
|
||||
selectCustomOptions: ['draft', 'active', 'archived'],
|
||||
},
|
||||
],
|
||||
customQueryColumn: (column) => {
|
||||
if (column === 'main__date') {
|
||||
return 'main.date';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export const REPORT_BOOKMARK_TYPE = {
|
||||
TABLE_CONFIG: 'TABLE_CONFIG',
|
||||
FILTER_TABLE: 'FILTER_TABLE',
|
||||
} as const;
|
||||
|
||||
export type ReportBookmarkType =
|
||||
(typeof REPORT_BOOKMARK_TYPE)[keyof typeof REPORT_BOOKMARK_TYPE];
|
||||
@@ -0,0 +1,15 @@
|
||||
export const DATA_FORMAT = {
|
||||
TEXT: 'text',
|
||||
TEXT_UPPERCASE: 'text_uppercase',
|
||||
TEXT_LOWERCASE: 'text_lowercase',
|
||||
NUMBER: 'number',
|
||||
CURRENCY: 'currency',
|
||||
MINUS_CURRENCY: 'minus_currency',
|
||||
PERCENTAGE: 'percentage',
|
||||
BOOLEAN: 'boolean',
|
||||
STATUS: 'status',
|
||||
DATE_EPOCH: 'date_epoch',
|
||||
DATE_TIMESTAMP: 'date_timestamp',
|
||||
} as const;
|
||||
|
||||
export type DataFormat = (typeof DATA_FORMAT)[keyof typeof DATA_FORMAT];
|
||||
@@ -0,0 +1,6 @@
|
||||
export const DATA_TYPE = {
|
||||
DIMENSION: 'dimension',
|
||||
MEASURE: 'measure',
|
||||
} as const;
|
||||
|
||||
export type DataType = (typeof DATA_TYPE)[keyof typeof DATA_TYPE];
|
||||
@@ -0,0 +1,12 @@
|
||||
export const FILTER_FIELD_TYPE = {
|
||||
SELECT: 'select',
|
||||
INPUT_TEXT: 'input_text',
|
||||
INPUT_NUMBER: 'input_number',
|
||||
INPUT_TAG: 'input_tag',
|
||||
DATE_PICKER: 'date_picker',
|
||||
DATE_RANGE_PICKER: 'date_range_picker',
|
||||
MONTH_RANGE_PICKER: 'month_range_picker',
|
||||
} as const;
|
||||
|
||||
export type FilterFieldType =
|
||||
(typeof FILTER_FIELD_TYPE)[keyof typeof FILTER_FIELD_TYPE];
|
||||
@@ -0,0 +1,17 @@
|
||||
export const FILTER_TYPE = {
|
||||
TEXT_EQUALS: 'text_equals',
|
||||
TEXT_NOT_EQUAL: 'text_not_equal',
|
||||
TEXT_CONTAINS: 'text_contains',
|
||||
TEXT_NOT_CONTAINS: 'text_not_contains',
|
||||
TEXT_MULTIPLE_CONTAINS: 'text_multiple_contains',
|
||||
TEXT_IN_MEMBER_TEXT: 'text_inMemberText',
|
||||
NUMBER_EQUALS: 'number_equals',
|
||||
NUMBER_NOT_EQUAL: 'number_not_equal',
|
||||
NUMBER_GREATER_THAN: 'number_greater_than',
|
||||
NUMBER_LESS_THAN: 'number_less_than',
|
||||
NUMBER_IN_RANGE: 'number_in_range',
|
||||
TEXT_IN_DATE_RANGE_EPOCH: 'text_inDateRange_epoch',
|
||||
TEXT_IN_DATE_RANGE_TIMESTAMP: 'text_inDateRange_timestamp',
|
||||
} as const;
|
||||
|
||||
export type FilterType = (typeof FILTER_TYPE)[keyof typeof FILTER_TYPE];
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './bookmark-type';
|
||||
export * from './data-format';
|
||||
export * from './data-type';
|
||||
export * from './filter-field-type';
|
||||
export * from './filter-type';
|
||||
export * from './report-group';
|
||||
@@ -0,0 +1,6 @@
|
||||
export const REPORT_GROUP = {
|
||||
SALES_REPORT: 'sales_report',
|
||||
LOGISTICS_REPORT: 'logistics_report',
|
||||
} as const;
|
||||
|
||||
export type ReportGroupName = (typeof REPORT_GROUP)[keyof typeof REPORT_GROUP];
|
||||
@@ -0,0 +1 @@
|
||||
export * from './report-config.entity';
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { DataFormat } from '../constants/data-format';
|
||||
import type { DataType } from '../constants/data-type';
|
||||
import type { FilterFieldType } from '../constants/filter-field-type';
|
||||
import type { FilterType } from '../constants/filter-type';
|
||||
import type { ReportGroupName } from '../constants/report-group';
|
||||
|
||||
export interface ReportColumnConfigEntity {
|
||||
column: string;
|
||||
query: string;
|
||||
label: string;
|
||||
type: DataType;
|
||||
format: DataFormat;
|
||||
dateFormat?: string;
|
||||
}
|
||||
|
||||
export interface FilterConfigEntity {
|
||||
filterColumn: string;
|
||||
filterType: FilterType;
|
||||
fieldType: FilterFieldType;
|
||||
fieldLabel: string;
|
||||
hideField?: boolean;
|
||||
selectDataSourceUrl?: string;
|
||||
selectCustomOptions?: string[];
|
||||
selectValueKey?: string;
|
||||
selectLabelKey?: string;
|
||||
dateFormat?: string;
|
||||
}
|
||||
|
||||
export interface FilterPeriodConfig {
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export interface FilterModelEntry {
|
||||
type: FilterType;
|
||||
filter: unknown;
|
||||
}
|
||||
|
||||
export interface ReportConfigEntity {
|
||||
groupName: ReportGroupName;
|
||||
uniqueName: string;
|
||||
privilegeKey: string;
|
||||
label: string;
|
||||
tableSchema: string;
|
||||
mainTableAlias?: string;
|
||||
columnConfigs: ReportColumnConfigEntity[];
|
||||
filterConfigs?: FilterConfigEntity[];
|
||||
filterPeriodConfig?: FilterPeriodConfig;
|
||||
whereDefaultConditions?: string[];
|
||||
whereCondition?: (filterModel: Record<string, FilterModelEntry>) => string[];
|
||||
ignoreFilterKeys?: string[];
|
||||
customQueryColumn?: (column: string) => string | undefined;
|
||||
defaultOrderBy?: string[];
|
||||
lowLevelOrderBy?: string[];
|
||||
}
|
||||
|
||||
export interface ReportConfigPublicEntity extends Omit<
|
||||
ReportConfigEntity,
|
||||
'whereCondition' | 'customQueryColumn'
|
||||
> {}
|
||||
|
||||
export interface RowGroupColEntity {
|
||||
id: string;
|
||||
displayName: string;
|
||||
field: string;
|
||||
}
|
||||
|
||||
export interface ValueColEntity {
|
||||
id: string;
|
||||
field: string;
|
||||
aggFunc: string;
|
||||
}
|
||||
|
||||
export interface SortModelEntry {
|
||||
colId: string;
|
||||
sort: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface QueryModelEntity {
|
||||
startRow: number;
|
||||
endRow: number;
|
||||
rowGroupCols: RowGroupColEntity[];
|
||||
valueCols: ValueColEntity[];
|
||||
pivotCols: unknown[];
|
||||
pivotMode: boolean;
|
||||
groupKeys: unknown[];
|
||||
filterModel: Record<string, FilterModelEntry>;
|
||||
sortModel: SortModelEntry[];
|
||||
}
|
||||
|
||||
export const COUNT_CHILD_GROUP_COLUMN = 'countChildGroup';
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import type { AuthUser } from '../../../../common/auth/auth-user';
|
||||
import { PrivilegesService } from '../../../privileges/privileges.service';
|
||||
import { findReportConfig, findReportConfigsByGroup } from '../configs';
|
||||
import { parseGroupNamesQuery } from '../helpers/report-query-params';
|
||||
|
||||
@Injectable()
|
||||
export class ReportPrivilegeGuard implements CanActivate {
|
||||
constructor(private readonly privilegesService: PrivilegesService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
user?: AuthUser;
|
||||
body?: { groupName?: string; uniqueName?: string };
|
||||
query?: Record<string, unknown>;
|
||||
}>();
|
||||
const user = request.user;
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
if (user.isSuperadmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const groupName = request.body?.groupName;
|
||||
const uniqueName = request.body?.uniqueName;
|
||||
if (groupName && uniqueName) {
|
||||
const config = findReportConfig(groupName, uniqueName);
|
||||
if (!config) {
|
||||
throw new ForbiddenException('Insufficient privilege');
|
||||
}
|
||||
const ok = await this.privilegesService.checkPermission(
|
||||
user.id,
|
||||
config.privilegeKey,
|
||||
'view',
|
||||
);
|
||||
if (!ok) {
|
||||
throw new ForbiddenException('Insufficient privilege');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const groupNames = parseGroupNamesQuery(request.query ?? {});
|
||||
if (groupNames.length === 0) {
|
||||
throw new ForbiddenException('Insufficient privilege');
|
||||
}
|
||||
|
||||
for (const group of groupNames) {
|
||||
const configs = findReportConfigsByGroup(group);
|
||||
for (const config of configs) {
|
||||
const ok = await this.privilegesService.checkPermission(
|
||||
user.id,
|
||||
config.privilegeKey,
|
||||
'view',
|
||||
);
|
||||
if (ok) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new ForbiddenException('Insufficient privilege');
|
||||
}
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -4,7 +4,18 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, asc, count, eq, gt, ilike, inArray, or, SQL, sum } from 'drizzle-orm';
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
eq,
|
||||
gt,
|
||||
ilike,
|
||||
inArray,
|
||||
or,
|
||||
SQL,
|
||||
sum,
|
||||
} from 'drizzle-orm';
|
||||
import { toOrderClauses } from '../../../common/http/response';
|
||||
import {
|
||||
catalogRelationFromMap,
|
||||
|
||||
Reference in New Issue
Block a user