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:
+30
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -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 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user