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
+30
View File
@@ -83,3 +83,33 @@ Bootstrap: first user is draft until `UPDATE users SET status = 'active'`.
## Employees ## Employees
Create/update optional `userId` (assign an existing login user) or nested `user` (`id?`, `username?`, `password?`). Nested `user` without `id` creates a login user (`username` + `password` required) or updates the currently linked username. `user.id` / `userId` links an existing user; `username` may be updated, but `password` is rejected (use `PATCH /users/:id`). Nested `user` cannot set `privilegeId`. `user: null` or `userId: null` unlinks. Users link the other way with `employeeId`. DTO nests `user: { id, username } | null`. List filter `userId`. List filter `position` as one or more of `sales` | `driver` | `crew` (`?position=sales&position=driver`). CSV optional `userId`. Unique assigned user → `409`. Create/update optional `userId` (assign an existing login user) or nested `user` (`id?`, `username?`, `password?`). Nested `user` without `id` creates a login user (`username` + `password` required) or updates the currently linked username. `user.id` / `userId` links an existing user; `username` may be updated, but `password` is rejected (use `PATCH /users/:id`). Nested `user` cannot set `privilegeId`. `user: null` or `userId: null` unlinks. Users link the other way with `employeeId`. DTO nests `user: { id, username } | null`. List filter `userId`. List filter `position` as one or more of `sales` | `driver` | `crew` (`?position=sales&position=driver`). CSV optional `userId`. Unique assigned user → `409`.
## Reports
Privilege keys: `SALES.REPORT`, `LOGISTICS.REPORT` (seeded in migration `0013_reports`).
### `GET /reports/config` — bearer — `200`
Query: `groupNames` (e.g. `sales_report`). Returns report configs visible to the caller, each with optional `activeFilter` and `activeTableConfig` bookmarks.
### `POST /reports/data` — bearer — `200`
Body: `{ groupName, uniqueName, queryModel }`. Returns row array keyed by column id.
### `POST /reports/meta` — bearer — `200`
Same body as data. Returns `{ totalRow, limit, offset }`.
### Report bookmarks
| Method | Path | Notes |
| --- | --- | --- |
| `GET` | `/report-bookmarks` | List for current user (`@Pagination()`) |
| `GET` | `/report-bookmarks/label-history` | Distinct labels |
| `GET` | `/report-bookmarks/applied` | Query: `groupName`, `uniqueName`, `type` |
| `POST` | `/report-bookmarks` | Create (`201`) |
| `PUT` | `/report-bookmarks/applied/:id` | Apply (unapplies siblings) |
| `PUT` | `/report-bookmarks/unapplied/:id` | Clear applied |
| `DELETE` | `/report-bookmarks/:id` | `204` |
Bookmark `type`: `FILTER_TABLE` | `TABLE_CONFIG`. `configuration` is opaque JSON (filter form values or AG Grid column state).
+52
View File
@@ -0,0 +1,52 @@
# TrackGo Report Engine
Config-driven reporting for `trackgo-be` (`src/modules/reports`) and `trackgo-fe` (`apps/web/src/core/report`).
## Architecture
1. **Report config** — TypeScript object per report (`shared/configs/`). Defines SQL `tableSchema`, columns, filters, and `privilegeKey`.
2. **Query builder**`ReportQueryBuilder` compiles AG Grid `queryModel` + config into parameterized Drizzle SQL (`db.execute`).
3. **Generic UI**`ReportProvider` loads configs for a `groupName` and renders one tab per report via `ReportTable` (AG Grid Server-Side Row Model).
Persisted engine data:
- `report_bookmarks` — saved filters (`FILTER_TABLE`) and table layouts (`TABLE_CONFIG`)
Report rows are **never** stored; they are queried live from business tables.
## Groups and privilege keys
| Group | `groupName` | Privilege key | Menu path |
| --- | --- | --- | --- |
| Sales reports | `sales_report` | `SALES.REPORT` | `/app/sales/reports/index` |
| Logistics reports | `logistics_report` | `LOGISTICS.REPORT` | `/app/logistics/reports/index` |
## HTTP APIs
| Method | Path | Body / query |
| --- | --- | --- |
| `GET` | `/reports/config` | `groupNames` (array or repeated) |
| `POST` | `/reports/data` | `{ groupName, uniqueName, queryModel }` |
| `POST` | `/reports/meta` | same as data → `{ totalRow, limit, offset }` |
| `GET` | `/report-bookmarks` | list filters (`groupName`, `uniqueName`, `type`, pagination) |
| `POST` | `/report-bookmarks` | create bookmark |
| `PUT` | `/report-bookmarks/applied/:id` | apply |
| `PUT` | `/report-bookmarks/unapplied/:id` | unapply |
| `DELETE` | `/report-bookmarks/:id` | delete |
All endpoints require JWT. Report data/config endpoints use `ReportPrivilegeGuard` (config `privilegeKey` + `view`). Bookmarks are scoped to `createdBy` (current user).
## Adding a report
1. Add a `ReportConfigEntity` file under `shared/configs/`.
2. Register it in `shared/configs/index.ts`.
3. No new controller or React page — the generic UI picks it up when `groupName` matches.
## TrackGo-specific notes
- JSON uses **camelCase** (`groupName`, `queryModel`, `columnConfigs`).
- SQL values are **bound parameters**; only config-authored fragments use `sql.raw()`.
- Cell formatting uses `DateTime`, `Status`, and `Decimal` value objects.
- Excel export is **not** implemented in this phase.
See [report-list.md](./report-list.md) for the seven shipped reports.
+104
View File
@@ -0,0 +1,104 @@
# TrackGo Reports
Reports implemented in the report engine. Columns reflect **available data** only — fields from the legacy PMPS UI without backing tables are omitted.
## Sales reports (`sales_report`)
### Report Sales Order
| Column | Source |
| --- | --- |
| Date | `sales_orders.date` |
| Branch | `branches.name` |
| Division | `divisions.name` |
| No. Sales Order | `sales_orders.code` |
| Customer | `customers.name` |
| Invoice Amount | `SUM(sales_order_products.quantity * price)` |
| Sales Rep. | `employees.name` |
| Last Status Order | `sales_orders.status` |
### Report Request Order
| Column | Source |
| --- | --- |
| Date | `sales_requests.date` |
| Branch | `branches.name` |
| Division | `divisions.name` |
| No. Request Order | `sales_requests.code` |
| Customer | `customers.name` |
| Sales Rep. | `employees.name` |
| Status | `sales_requests.status` |
### Report Invoice
| Column | Source |
| --- | --- |
| Date | `sales_invoices.date` |
| Branch | `branches.name` |
| Division | `divisions.name` |
| Customer | `customers.name` |
| Customer code | `customers.code` |
| Sales Order No. | `sales_invoices.sales_order_code` |
| Invoice No. | `sales_invoices.code` |
| Status | `sales_invoices.status` |
| Sales Rep. | `employees.name` |
| Balance | `sales_invoices.balance` |
### Report Payment
| Column | Source |
| --- | --- |
| Date | `sales_payments.date` |
| Payment No. | `sales_payments.code` |
| Customer code | via `sales_invoices``customers.code` |
| Branch | via invoice → `branches.name` |
| Division | via invoice → `divisions.name` |
| Sales Rep | via invoice → `employees.name` |
| Invoice ID | `sales_invoices.code` |
| Invoice Amount | `sales_invoices.balance` |
| Payment Amount | `sales_payment_invoices.amount` |
| Status | `sales_payments.status` |
### Report Visit Plan
| Column | Source |
| --- | --- |
| Date | `plans.date` (`purpose = sales`) |
| Sales Rep | `employees.name` |
| Branch | start branch name |
| Plan | count of `plan_destinations` |
| Invoice | count of `plan_invoices` |
| Status | `plans.status` |
## Logistics reports (`logistics_report`)
### Report Packing Slip
| Column | Source |
| --- | --- |
| Date | `packing_slips.date` |
| Sales Order No. | `packing_slips.sales_order_number` |
| Packing Slip No. | `packing_slips.code` |
| Customer | `customers.name` |
| Status | `packing_slips.status` |
### Report Delivery Plan
| Column | Source |
| --- | --- |
| Date | `plans.date` (`purpose = logistics`) |
| Sales Rep | `employees.name` (driver) |
| Branch | start branch name |
| Plan | count of `plan_destinations` |
| Packing Slip | count of `plan_packing_slips` |
| Status | `plans.status` |
## Not built (no backing data)
These reports from the legacy PMPS list require visit tracking, permissions, or alerts tables that do not exist in TrackGo:
- Report Performance (sales and logistic)
- Report Sales Permission / Report Logistic Permission
- Report Alert (sales and logistic)
Also omitted as columns everywhere: Visited, Break Time, Driving, Stop Time, Cancel, Alert counts, and live visit actuals.
+21
View File
@@ -0,0 +1,21 @@
CREATE TABLE "report_bookmarks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"group_name" text NOT NULL,
"unique_name" text NOT NULL,
"label" text NOT NULL,
"type" text NOT NULL,
"applied" boolean DEFAULT false NOT NULL,
"configuration" jsonb NOT NULL,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
ALTER TABLE "report_bookmarks" ADD CONSTRAINT "report_bookmarks_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "report_bookmarks" ADD CONSTRAINT "report_bookmarks_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "report_bookmarks_owner_report_type_applied_unique" ON "report_bookmarks" USING btree ("created_by","group_name","unique_name","type") WHERE "applied" = true;--> statement-breakpoint
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
('SALES.REPORT', 'Sales reports', 18),
('LOGISTICS.REPORT', 'Logistics reports', 19);
+7
View File
@@ -92,6 +92,13 @@
"when": 1787560000000, "when": 1787560000000,
"tag": "0012_users_primary", "tag": "0012_users_primary",
"breakpoints": true "breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1787561000000,
"tag": "0013_reports",
"breakpoints": true
} }
] ]
} }
+2
View File
@@ -7,6 +7,7 @@ import { DatabaseModule } from './database/database.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { ConfigurationModule } from './modules/configuration/configuration.module'; import { ConfigurationModule } from './modules/configuration/configuration.module';
import { FieldModule } from './modules/field/field.module'; import { FieldModule } from './modules/field/field.module';
import { ReportsModule } from './modules/reports/reports.module';
import { SalesModule } from './modules/sales/sales.module'; import { SalesModule } from './modules/sales/sales.module';
import { PrivilegesModule } from './modules/privileges/privileges.module'; import { PrivilegesModule } from './modules/privileges/privileges.module';
import { UsersModule } from './modules/users/users.module'; import { UsersModule } from './modules/users/users.module';
@@ -24,6 +25,7 @@ import { UsersModule } from './modules/users/users.module';
ConfigurationModule, ConfigurationModule,
SalesModule, SalesModule,
FieldModule, FieldModule,
ReportsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
+33
View File
@@ -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;
+5
View File
@@ -247,3 +247,8 @@ export {
type PlanPackingSlipRow, type PlanPackingSlipRow,
type PlanRow, type PlanRow,
} from './plans-table'; } 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,
};
}
}
+12
View File
@@ -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, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } 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 { toOrderClauses } from '../../../common/http/response';
import { import {
catalogRelationFromMap, catalogRelationFromMap,
+139
View File
@@ -0,0 +1,139 @@
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { eq } from 'drizzle-orm';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/common/configure-app';
import { DRIZZLE, type DrizzleDB } from '../src/database/database.module';
import {
privilegeDetails,
privilegeKeys,
privileges,
users,
} from '../src/database/schema';
import { REPORT_BOOKMARK_TYPE } from '../src/modules/reports/shared/constants/bookmark-type';
import { REPORT_GROUP } from '../src/modules/reports/shared/constants/report-group';
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
import { registerAndActivate } from './helpers/activate-user';
describe('Report bookmarks (e2e)', () => {
let app: INestApplication<App>;
let db: DrizzleDB;
const password = 'password123';
const adminUsername = `bookmark_admin_${Date.now()}`;
let adminAccessToken: string;
let adminUserId: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
configureApp(app, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'false',
});
await app.init();
db = app.get(DRIZZLE);
const admin = await registerAndActivate(app, db, adminUsername, password);
adminAccessToken = admin.accessToken;
adminUserId = admin.userId;
const now = Date.now();
const [priv] = await db
.insert(privileges)
.values({
name: 'Bookmark Admin',
code: `BOOKMARK_ADMIN_${now}`,
status: 'active',
createdAt: now,
updatedAt: now,
createdBy: adminUserId,
updatedBy: adminUserId,
})
.returning();
const keys = await db.select().from(privilegeKeys);
const detailRows = keys.flatMap((key) =>
PRIVILEGE_ACTIONS.map((action) => ({
privilegeId: priv.id,
privilegeKeyId: key.id,
action,
value: true,
})),
);
await db.insert(privilegeDetails).values(detailRows);
await db
.update(users)
.set({ privilegeId: priv.id, updatedAt: Date.now() })
.where(eq(users.id, adminUserId));
});
afterAll(async () => {
await app.close();
});
it('creates, applies, and unapplies a filter bookmark', async () => {
const uniqueName = 'sales_report__sales_order';
const create = await request(app.getHttpServer())
.post('/report-bookmarks')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
groupName: REPORT_GROUP.SALES_REPORT,
uniqueName,
label: 'My filter',
type: REPORT_BOOKMARK_TYPE.FILTER_TABLE,
applied: true,
configuration: { main__status: 'active' },
})
.expect(201);
const bookmarkId = create.body.id as string;
const second = await request(app.getHttpServer())
.post('/report-bookmarks')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
groupName: REPORT_GROUP.SALES_REPORT,
uniqueName,
label: 'Second filter',
type: REPORT_BOOKMARK_TYPE.FILTER_TABLE,
applied: true,
configuration: { main__status: 'draft' },
})
.expect(201);
await request(app.getHttpServer())
.put(`/report-bookmarks/applied/${second.body.id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
const applied = await request(app.getHttpServer())
.get('/report-bookmarks/applied')
.query({
groupName: REPORT_GROUP.SALES_REPORT,
uniqueName,
type: REPORT_BOOKMARK_TYPE.FILTER_TABLE,
})
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
expect(applied.body.id).toBe(second.body.id);
await request(app.getHttpServer())
.put(`/report-bookmarks/unapplied/${bookmarkId}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
await request(app.getHttpServer())
.delete(`/report-bookmarks/${bookmarkId}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(204);
});
});
+142
View File
@@ -0,0 +1,142 @@
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { eq } from 'drizzle-orm';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/common/configure-app';
import { DRIZZLE, type DrizzleDB } from '../src/database/database.module';
import {
privilegeDetails,
privilegeKeys,
privileges,
users,
} from '../src/database/schema';
import { REPORT_GROUP } from '../src/modules/reports/shared/constants/report-group';
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
import { registerAndActivate } from './helpers/activate-user';
describe('Reports (e2e)', () => {
let app: INestApplication<App>;
let db: DrizzleDB;
const password = 'password123';
const adminUsername = `report_admin_${Date.now()}`;
const otherUsername = `report_other_${Date.now()}`;
let adminAccessToken: string;
let adminUserId: string;
let otherAccessToken: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
configureApp(app, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'false',
});
await app.init();
db = app.get(DRIZZLE);
const admin = await registerAndActivate(app, db, adminUsername, password);
adminAccessToken = admin.accessToken;
adminUserId = admin.userId;
const other = await registerAndActivate(app, db, otherUsername, password);
otherAccessToken = other.accessToken;
const now = Date.now();
const [priv] = await db
.insert(privileges)
.values({
name: 'Report Admin',
code: `REPORT_ADMIN_${now}`,
status: 'active',
createdAt: now,
updatedAt: now,
createdBy: adminUserId,
updatedBy: adminUserId,
})
.returning();
const keys = await db.select().from(privilegeKeys);
const detailRows = keys.flatMap((key) =>
PRIVILEGE_ACTIONS.map((action) => ({
privilegeId: priv.id,
privilegeKeyId: key.id,
action,
value: true,
})),
);
await db.insert(privilegeDetails).values(detailRows);
await db
.update(users)
.set({ privilegeId: priv.id, updatedAt: Date.now() })
.where(eq(users.id, adminUserId));
});
afterAll(async () => {
await app.close();
});
const baseQueryModel = {
startRow: 0,
endRow: 100,
rowGroupCols: [],
valueCols: [],
pivotCols: [],
pivotMode: false,
groupKeys: [],
filterModel: {},
sortModel: [],
};
it('forbids report config without permission', async () => {
await request(app.getHttpServer())
.get('/reports/config')
.query({ groupNames: REPORT_GROUP.SALES_REPORT })
.set('Authorization', `Bearer ${otherAccessToken}`)
.expect(403);
});
it('lists sales report configs', async () => {
const res = await request(app.getHttpServer())
.get('/reports/config')
.query({ groupNames: REPORT_GROUP.SALES_REPORT })
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body.length).toBeGreaterThan(0);
expect(res.body[0].groupName).toBe(REPORT_GROUP.SALES_REPORT);
});
it('runs sales order report meta and data', async () => {
const uniqueName = 'sales_report__sales_order';
const body = {
groupName: REPORT_GROUP.SALES_REPORT,
uniqueName,
queryModel: baseQueryModel,
};
const meta = await request(app.getHttpServer())
.post('/reports/meta')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send(body)
.expect(200);
expect(meta.body.totalRow).toBeDefined();
const data = await request(app.getHttpServer())
.post('/reports/data')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send(body)
.expect(200);
expect(Array.isArray(data.body)).toBe(true);
});
});