diff --git a/docs/api.md b/docs/api.md index e724f6a..b6a4607 100644 --- a/docs/api.md +++ b/docs/api.md @@ -83,3 +83,33 @@ Bootstrap: first user is draft until `UPDATE users SET status = 'active'`. ## 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`. + +## 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). diff --git a/docs/report-engine.md b/docs/report-engine.md new file mode 100644 index 0000000..9a25bde --- /dev/null +++ b/docs/report-engine.md @@ -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. diff --git a/docs/report-list.md b/docs/report-list.md new file mode 100644 index 0000000..4308e8c --- /dev/null +++ b/docs/report-list.md @@ -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. diff --git a/drizzle/migrations/0013_reports.sql b/drizzle/migrations/0013_reports.sql new file mode 100644 index 0000000..1e8d475 --- /dev/null +++ b/drizzle/migrations/0013_reports.sql @@ -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); diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index bf10e59..8ecf5ab 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1787560000000, "tag": "0012_users_primary", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1787561000000, + "tag": "0013_reports", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 6e386bd..91bebe2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,6 +7,7 @@ import { DatabaseModule } from './database/database.module'; import { AuthModule } from './modules/auth/auth.module'; import { ConfigurationModule } from './modules/configuration/configuration.module'; import { FieldModule } from './modules/field/field.module'; +import { ReportsModule } from './modules/reports/reports.module'; import { SalesModule } from './modules/sales/sales.module'; import { PrivilegesModule } from './modules/privileges/privileges.module'; import { UsersModule } from './modules/users/users.module'; @@ -24,6 +25,7 @@ import { UsersModule } from './modules/users/users.module'; ConfigurationModule, SalesModule, FieldModule, + ReportsModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/database/report-bookmarks-table.ts b/src/database/report-bookmarks-table.ts new file mode 100644 index 0000000..326537e --- /dev/null +++ b/src/database/report-bookmarks-table.ts @@ -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; diff --git a/src/database/schema.ts b/src/database/schema.ts index 15c2a51..33e780e 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -247,3 +247,8 @@ export { type PlanPackingSlipRow, type PlanRow, } from './plans-table'; +export { + reportBookmarks, + type NewReportBookmarkRow, + type ReportBookmarkRow, +} from './report-bookmarks-table'; diff --git a/src/modules/reports/report-bookmark/dto/report-bookmark.dto.ts b/src/modules/reports/report-bookmark/dto/report-bookmark.dto.ts new file mode 100644 index 0000000..a4185c1 --- /dev/null +++ b/src/modules/reports/report-bookmark/dto/report-bookmark.dto.ts @@ -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; +} diff --git a/src/modules/reports/report-bookmark/report-bookmark.module.ts b/src/modules/reports/report-bookmark/report-bookmark.module.ts new file mode 100644 index 0000000..0c18d4b --- /dev/null +++ b/src/modules/reports/report-bookmark/report-bookmark.module.ts @@ -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 {} diff --git a/src/modules/reports/report-bookmark/report-bookmarks-read.controller.ts b/src/modules/reports/report-bookmark/report-bookmarks-read.controller.ts new file mode 100644 index 0000000..7dd2d8b --- /dev/null +++ b/src/modules/reports/report-bookmark/report-bookmarks-read.controller.ts @@ -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> { + 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; diff --git a/src/modules/reports/report-bookmark/report-bookmarks-write.controller.ts b/src/modules/reports/report-bookmark/report-bookmarks-write.controller.ts new file mode 100644 index 0000000..628a0f2 --- /dev/null +++ b/src/modules/reports/report-bookmark/report-bookmarks-write.controller.ts @@ -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 { + await this.bookmarksService.delete(user.id, id); + } +} diff --git a/src/modules/reports/report-bookmark/report-bookmarks.repository.ts b/src/modules/reports/report-bookmark/report-bookmarks.repository.ts new file mode 100644 index 0000000..ba21994 --- /dev/null +++ b/src/modules/reports/report-bookmark/report-bookmarks.repository.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/src/modules/reports/report-bookmark/report-bookmarks.service.ts b/src/modules/reports/report-bookmark/report-bookmarks.service.ts new file mode 100644 index 0000000..9034b60 --- /dev/null +++ b/src/modules/reports/report-bookmark/report-bookmarks.service.ts @@ -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>> { + 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 { + 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 { + 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, + }; + } +} diff --git a/src/modules/reports/report/dto/report.dto.ts b/src/modules/reports/report/dto/report.dto.ts new file mode 100644 index 0000000..b895cbf --- /dev/null +++ b/src/modules/reports/report/dto/report.dto.ts @@ -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; + + @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[]; +} diff --git a/src/modules/reports/report/report-query.controller.ts b/src/modules/reports/report/report-query.controller.ts new file mode 100644 index 0000000..53b5bfb --- /dev/null +++ b/src/modules/reports/report/report-query.controller.ts @@ -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); + } +} diff --git a/src/modules/reports/report/report-read.controller.ts b/src/modules/reports/report/report-read.controller.ts new file mode 100644 index 0000000..4f849dc --- /dev/null +++ b/src/modules/reports/report/report-read.controller.ts @@ -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 }, + @CurrentUser() user: AuthUser, + ) { + const groupNames = parseGroupNamesQuery(req.query); + return this.reportService.listConfigs( + groupNames, + user.id, + user.isSuperadmin, + ); + } +} diff --git a/src/modules/reports/report/report.module.ts b/src/modules/reports/report/report.module.ts new file mode 100644 index 0000000..12a4b25 --- /dev/null +++ b/src/modules/reports/report/report.module.ts @@ -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 {} diff --git a/src/modules/reports/report/report.service.ts b/src/modules/reports/report/report.service.ts new file mode 100644 index 0000000..e708b67 --- /dev/null +++ b/src/modules/reports/report/report.service.ts @@ -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[]; + 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, + 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, + }; + } +} diff --git a/src/modules/reports/reports.module.ts b/src/modules/reports/reports.module.ts new file mode 100644 index 0000000..874fd45 --- /dev/null +++ b/src/modules/reports/reports.module.ts @@ -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 {} diff --git a/src/modules/reports/shared/configs/index.ts b/src/modules/reports/shared/configs/index.ts new file mode 100644 index 0000000..6590682 --- /dev/null +++ b/src/modules/reports/shared/configs/index.ts @@ -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; +} diff --git a/src/modules/reports/shared/configs/logistics-reports.ts b/src/modules/reports/shared/configs/logistics-reports.ts new file mode 100644 index 0000000..103b994 --- /dev/null +++ b/src/modules/reports/shared/configs/logistics-reports.ts @@ -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; + }, +}; diff --git a/src/modules/reports/shared/configs/sales-reports.ts b/src/modules/reports/shared/configs/sales-reports.ts new file mode 100644 index 0000000..09bb76b --- /dev/null +++ b/src/modules/reports/shared/configs/sales-reports.ts @@ -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; + }, +}; diff --git a/src/modules/reports/shared/constants/bookmark-type.ts b/src/modules/reports/shared/constants/bookmark-type.ts new file mode 100644 index 0000000..0dffa1c --- /dev/null +++ b/src/modules/reports/shared/constants/bookmark-type.ts @@ -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]; diff --git a/src/modules/reports/shared/constants/data-format.ts b/src/modules/reports/shared/constants/data-format.ts new file mode 100644 index 0000000..1274c5c --- /dev/null +++ b/src/modules/reports/shared/constants/data-format.ts @@ -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]; diff --git a/src/modules/reports/shared/constants/data-type.ts b/src/modules/reports/shared/constants/data-type.ts new file mode 100644 index 0000000..3135c5d --- /dev/null +++ b/src/modules/reports/shared/constants/data-type.ts @@ -0,0 +1,6 @@ +export const DATA_TYPE = { + DIMENSION: 'dimension', + MEASURE: 'measure', +} as const; + +export type DataType = (typeof DATA_TYPE)[keyof typeof DATA_TYPE]; diff --git a/src/modules/reports/shared/constants/filter-field-type.ts b/src/modules/reports/shared/constants/filter-field-type.ts new file mode 100644 index 0000000..b24d9cc --- /dev/null +++ b/src/modules/reports/shared/constants/filter-field-type.ts @@ -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]; diff --git a/src/modules/reports/shared/constants/filter-type.ts b/src/modules/reports/shared/constants/filter-type.ts new file mode 100644 index 0000000..084be48 --- /dev/null +++ b/src/modules/reports/shared/constants/filter-type.ts @@ -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]; diff --git a/src/modules/reports/shared/constants/index.ts b/src/modules/reports/shared/constants/index.ts new file mode 100644 index 0000000..91c24d3 --- /dev/null +++ b/src/modules/reports/shared/constants/index.ts @@ -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'; diff --git a/src/modules/reports/shared/constants/report-group.ts b/src/modules/reports/shared/constants/report-group.ts new file mode 100644 index 0000000..a3ade8d --- /dev/null +++ b/src/modules/reports/shared/constants/report-group.ts @@ -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]; diff --git a/src/modules/reports/shared/entities/index.ts b/src/modules/reports/shared/entities/index.ts new file mode 100644 index 0000000..152884e --- /dev/null +++ b/src/modules/reports/shared/entities/index.ts @@ -0,0 +1 @@ +export * from './report-config.entity'; diff --git a/src/modules/reports/shared/entities/report-config.entity.ts b/src/modules/reports/shared/entities/report-config.entity.ts new file mode 100644 index 0000000..e12a617 --- /dev/null +++ b/src/modules/reports/shared/entities/report-config.entity.ts @@ -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[]; + 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; + sortModel: SortModelEntry[]; +} + +export const COUNT_CHILD_GROUP_COLUMN = 'countChildGroup'; diff --git a/src/modules/reports/shared/guards/report-privilege.guard.ts b/src/modules/reports/shared/guards/report-privilege.guard.ts new file mode 100644 index 0000000..d3e19e6 --- /dev/null +++ b/src/modules/reports/shared/guards/report-privilege.guard.ts @@ -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 { + const request = context.switchToHttp().getRequest<{ + user?: AuthUser; + body?: { groupName?: string; uniqueName?: string }; + query?: Record; + }>(); + 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'); + } +} diff --git a/src/modules/reports/shared/helpers/report-cell.formatter.spec.ts b/src/modules/reports/shared/helpers/report-cell.formatter.spec.ts new file mode 100644 index 0000000..6515650 --- /dev/null +++ b/src/modules/reports/shared/helpers/report-cell.formatter.spec.ts @@ -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'); + }); +}); diff --git a/src/modules/reports/shared/helpers/report-cell.formatter.ts b/src/modules/reports/shared/helpers/report-cell.formatter.ts new file mode 100644 index 0000000..4a9ca8f --- /dev/null +++ b/src/modules/reports/shared/helpers/report-cell.formatter.ts @@ -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, + columnConfigs: ReportColumnConfigEntity[], +): Record { + const formatted: Record = { ...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); +} diff --git a/src/modules/reports/shared/helpers/report-query-params.spec.ts b/src/modules/reports/shared/helpers/report-query-params.spec.ts new file mode 100644 index 0000000..6a9cd7d --- /dev/null +++ b/src/modules/reports/shared/helpers/report-query-params.spec.ts @@ -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']); + }); +}); diff --git a/src/modules/reports/shared/helpers/report-query-params.ts b/src/modules/reports/shared/helpers/report-query-params.ts new file mode 100644 index 0000000..8eee774 --- /dev/null +++ b/src/modules/reports/shared/helpers/report-query-params.ts @@ -0,0 +1,18 @@ +export function parseGroupNamesQuery( + query: Record, +): 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)]; +} diff --git a/src/modules/reports/shared/helpers/report-query.builder.spec.ts b/src/modules/reports/shared/helpers/report-query.builder.spec.ts new file mode 100644 index 0000000..ece154c --- /dev/null +++ b/src/modules/reports/shared/helpers/report-query.builder.spec.ts @@ -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'); + }); +}); diff --git a/src/modules/reports/shared/helpers/report-query.builder.ts b/src/modules/reports/shared/helpers/report-query.builder.ts new file mode 100644 index 0000000..8b4b593 --- /dev/null +++ b/src/modules/reports/shared/helpers/report-query.builder.ts @@ -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(', '))}`; + } +} diff --git a/src/modules/reports/shared/helpers/sql-test.helper.ts b/src/modules/reports/shared/helpers/sql-test.helper.ts new file mode 100644 index 0000000..4dd3794 --- /dev/null +++ b/src/modules/reports/shared/helpers/sql-test.helper.ts @@ -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(''); +} diff --git a/src/modules/sales/sales-invoices/sales-invoices.repository.ts b/src/modules/sales/sales-invoices/sales-invoices.repository.ts index 2a222f8..7229484 100644 --- a/src/modules/sales/sales-invoices/sales-invoices.repository.ts +++ b/src/modules/sales/sales-invoices/sales-invoices.repository.ts @@ -4,7 +4,18 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { and, asc, count, eq, gt, ilike, inArray, or, SQL, sum } from 'drizzle-orm'; +import { + and, + asc, + count, + eq, + gt, + ilike, + inArray, + or, + SQL, + sum, +} from 'drizzle-orm'; import { toOrderClauses } from '../../../common/http/response'; import { catalogRelationFromMap, diff --git a/test/report-bookmarks.e2e-spec.ts b/test/report-bookmarks.e2e-spec.ts new file mode 100644 index 0000000..1211d70 --- /dev/null +++ b/test/report-bookmarks.e2e-spec.ts @@ -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; + 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); + }); +}); diff --git a/test/reports.e2e-spec.ts b/test/reports.e2e-spec.ts new file mode 100644 index 0000000..834b7b9 --- /dev/null +++ b/test/reports.e2e-spec.ts @@ -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; + 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); + }); +});