Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2955b974d2 | ||
|
|
5579cf6566 |
+30
@@ -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).
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# TrackGo Report Engine
|
||||
|
||||
Config-driven reporting for `trackgo-be` (`src/modules/reports`) and `trackgo-fe` (`apps/web/src/core/report`).
|
||||
|
||||
## Architecture
|
||||
|
||||
1. **Report config** — TypeScript object per report (`shared/configs/`). Defines SQL `tableSchema`, columns, filters, and `privilegeKey`.
|
||||
2. **Query builder** — `ReportQueryBuilder` compiles AG Grid `queryModel` + config into parameterized Drizzle SQL (`db.execute`).
|
||||
3. **Generic UI** — `ReportProvider` loads configs for a `groupName` and renders one tab per report via `ReportTable` (AG Grid Server-Side Row Model).
|
||||
|
||||
Persisted engine data:
|
||||
|
||||
- `report_bookmarks` — saved filters (`FILTER_TABLE`) and table layouts (`TABLE_CONFIG`)
|
||||
|
||||
Report rows are **never** stored; they are queried live from business tables.
|
||||
|
||||
## Groups and privilege keys
|
||||
|
||||
| Group | `groupName` | Privilege key | Menu path |
|
||||
| --- | --- | --- | --- |
|
||||
| Sales reports | `sales_report` | `SALES.REPORT` | `/app/sales/reports/index` |
|
||||
| Logistics reports | `logistics_report` | `LOGISTICS.REPORT` | `/app/logistics/reports/index` |
|
||||
|
||||
## HTTP APIs
|
||||
|
||||
| Method | Path | Body / query |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/reports/config` | `groupNames` (array or repeated) |
|
||||
| `POST` | `/reports/data` | `{ groupName, uniqueName, queryModel }` |
|
||||
| `POST` | `/reports/meta` | same as data → `{ totalRow, limit, offset }` |
|
||||
| `GET` | `/report-bookmarks` | list filters (`groupName`, `uniqueName`, `type`, pagination) |
|
||||
| `POST` | `/report-bookmarks` | create bookmark |
|
||||
| `PUT` | `/report-bookmarks/applied/:id` | apply |
|
||||
| `PUT` | `/report-bookmarks/unapplied/:id` | unapply |
|
||||
| `DELETE` | `/report-bookmarks/:id` | delete |
|
||||
|
||||
All endpoints require JWT. Report data/config endpoints use `ReportPrivilegeGuard` (config `privilegeKey` + `view`). Bookmarks are scoped to `createdBy` (current user).
|
||||
|
||||
## Adding a report
|
||||
|
||||
1. Add a `ReportConfigEntity` file under `shared/configs/`.
|
||||
2. Register it in `shared/configs/index.ts`.
|
||||
3. No new controller or React page — the generic UI picks it up when `groupName` matches.
|
||||
|
||||
## TrackGo-specific notes
|
||||
|
||||
- JSON uses **camelCase** (`groupName`, `queryModel`, `columnConfigs`).
|
||||
- SQL values are **bound parameters**; only config-authored fragments use `sql.raw()`.
|
||||
- Cell formatting uses `DateTime`, `Status`, and `Decimal` value objects.
|
||||
- Excel export is **not** implemented in this phase.
|
||||
|
||||
See [report-list.md](./report-list.md) for the seven shipped reports.
|
||||
@@ -0,0 +1,104 @@
|
||||
# TrackGo Reports
|
||||
|
||||
Reports implemented in the report engine. Columns reflect **available data** only — fields from the legacy PMPS UI without backing tables are omitted.
|
||||
|
||||
## Sales reports (`sales_report`)
|
||||
|
||||
### Report Sales Order
|
||||
|
||||
| Column | Source |
|
||||
| --- | --- |
|
||||
| Date | `sales_orders.date` |
|
||||
| Branch | `branches.name` |
|
||||
| Division | `divisions.name` |
|
||||
| No. Sales Order | `sales_orders.code` |
|
||||
| Customer | `customers.name` |
|
||||
| Invoice Amount | `SUM(sales_order_products.quantity * price)` |
|
||||
| Sales Rep. | `employees.name` |
|
||||
| Last Status Order | `sales_orders.status` |
|
||||
|
||||
### Report Request Order
|
||||
|
||||
| Column | Source |
|
||||
| --- | --- |
|
||||
| Date | `sales_requests.date` |
|
||||
| Branch | `branches.name` |
|
||||
| Division | `divisions.name` |
|
||||
| No. Request Order | `sales_requests.code` |
|
||||
| Customer | `customers.name` |
|
||||
| Sales Rep. | `employees.name` |
|
||||
| Status | `sales_requests.status` |
|
||||
|
||||
### Report Invoice
|
||||
|
||||
| Column | Source |
|
||||
| --- | --- |
|
||||
| Date | `sales_invoices.date` |
|
||||
| Branch | `branches.name` |
|
||||
| Division | `divisions.name` |
|
||||
| Customer | `customers.name` |
|
||||
| Customer code | `customers.code` |
|
||||
| Sales Order No. | `sales_invoices.sales_order_code` |
|
||||
| Invoice No. | `sales_invoices.code` |
|
||||
| Status | `sales_invoices.status` |
|
||||
| Sales Rep. | `employees.name` |
|
||||
| Balance | `sales_invoices.balance` |
|
||||
|
||||
### Report Payment
|
||||
|
||||
| Column | Source |
|
||||
| --- | --- |
|
||||
| Date | `sales_payments.date` |
|
||||
| Payment No. | `sales_payments.code` |
|
||||
| Customer code | via `sales_invoices` → `customers.code` |
|
||||
| Branch | via invoice → `branches.name` |
|
||||
| Division | via invoice → `divisions.name` |
|
||||
| Sales Rep | via invoice → `employees.name` |
|
||||
| Invoice ID | `sales_invoices.code` |
|
||||
| Invoice Amount | `sales_invoices.balance` |
|
||||
| Payment Amount | `sales_payment_invoices.amount` |
|
||||
| Status | `sales_payments.status` |
|
||||
|
||||
### Report Visit Plan
|
||||
|
||||
| Column | Source |
|
||||
| --- | --- |
|
||||
| Date | `plans.date` (`purpose = sales`) |
|
||||
| Sales Rep | `employees.name` |
|
||||
| Branch | start branch name |
|
||||
| Plan | count of `plan_destinations` |
|
||||
| Invoice | count of `plan_invoices` |
|
||||
| Status | `plans.status` |
|
||||
|
||||
## Logistics reports (`logistics_report`)
|
||||
|
||||
### Report Packing Slip
|
||||
|
||||
| Column | Source |
|
||||
| --- | --- |
|
||||
| Date | `packing_slips.date` |
|
||||
| Sales Order No. | `packing_slips.sales_order_number` |
|
||||
| Packing Slip No. | `packing_slips.code` |
|
||||
| Customer | `customers.name` |
|
||||
| Status | `packing_slips.status` |
|
||||
|
||||
### Report Delivery Plan
|
||||
|
||||
| Column | Source |
|
||||
| --- | --- |
|
||||
| Date | `plans.date` (`purpose = logistics`) |
|
||||
| Sales Rep | `employees.name` (driver) |
|
||||
| Branch | start branch name |
|
||||
| Plan | count of `plan_destinations` |
|
||||
| Packing Slip | count of `plan_packing_slips` |
|
||||
| Status | `plans.status` |
|
||||
|
||||
## Not built (no backing data)
|
||||
|
||||
These reports from the legacy PMPS list require visit tracking, permissions, or alerts tables that do not exist in TrackGo:
|
||||
|
||||
- Report Performance (sales and logistic)
|
||||
- Report Sales Permission / Report Logistic Permission
|
||||
- Report Alert (sales and logistic)
|
||||
|
||||
Also omitted as columns everywhere: Visited, Break Time, Driving, Stop Time, Cancel, Alert counts, and live visit actuals.
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE "report_bookmarks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"group_name" text NOT NULL,
|
||||
"unique_name" text NOT NULL,
|
||||
"label" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"applied" boolean DEFAULT false NOT NULL,
|
||||
"configuration" jsonb NOT NULL,
|
||||
"status" text DEFAULT 'draft' NOT NULL,
|
||||
"created_at" bigint NOT NULL,
|
||||
"updated_at" bigint NOT NULL,
|
||||
"created_by" uuid NOT NULL,
|
||||
"updated_by" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "report_bookmarks" ADD CONSTRAINT "report_bookmarks_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "report_bookmarks" ADD CONSTRAINT "report_bookmarks_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "report_bookmarks_owner_report_type_applied_unique" ON "report_bookmarks" USING btree ("created_by","group_name","unique_name","type") WHERE "applied" = true;--> statement-breakpoint
|
||||
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
|
||||
('SALES.REPORT', 'Sales reports', 18),
|
||||
('LOGISTICS.REPORT', 'Logistics reports', 19);
|
||||
@@ -92,6 +92,13 @@
|
||||
"when": 1787560000000,
|
||||
"tag": "0012_users_primary",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1787561000000,
|
||||
"tag": "0013_reports",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { DatabaseModule } from './database/database.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { ConfigurationModule } from './modules/configuration/configuration.module';
|
||||
import { FieldModule } from './modules/field/field.module';
|
||||
import { ReportsModule } from './modules/reports/reports.module';
|
||||
import { SalesModule } from './modules/sales/sales.module';
|
||||
import { PrivilegesModule } from './modules/privileges/privileges.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
@@ -24,6 +25,7 @@ import { UsersModule } from './modules/users/users.module';
|
||||
ConfigurationModule,
|
||||
SalesModule,
|
||||
FieldModule,
|
||||
ReportsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
uuid,
|
||||
boolean,
|
||||
jsonb,
|
||||
uniqueIndex,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { users } from './schema';
|
||||
|
||||
export const reportBookmarks = pgTable(
|
||||
'report_bookmarks',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
groupName: text('group_name').notNull(),
|
||||
uniqueName: text('unique_name').notNull(),
|
||||
label: text('label').notNull(),
|
||||
type: text('type').notNull(),
|
||||
applied: boolean('applied').notNull().default(false),
|
||||
configuration: jsonb('configuration').notNull(),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('report_bookmarks_owner_report_type_applied_unique')
|
||||
.on(t.createdBy, t.groupName, t.uniqueName, t.type)
|
||||
.where(sql`${t.applied} = true`),
|
||||
],
|
||||
);
|
||||
|
||||
export type ReportBookmarkRow = typeof reportBookmarks.$inferSelect;
|
||||
export type NewReportBookmarkRow = typeof reportBookmarks.$inferInsert;
|
||||
@@ -247,3 +247,8 @@ export {
|
||||
type PlanPackingSlipRow,
|
||||
type PlanRow,
|
||||
} from './plans-table';
|
||||
export {
|
||||
reportBookmarks,
|
||||
type NewReportBookmarkRow,
|
||||
type ReportBookmarkRow,
|
||||
} from './report-bookmarks-table';
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import {
|
||||
REPORT_BOOKMARK_TYPE,
|
||||
type ReportBookmarkType,
|
||||
} from '../../shared/constants/bookmark-type';
|
||||
|
||||
export class ReportBookmarkDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
uniqueName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
label!: string;
|
||||
|
||||
@ApiProperty({ enum: Object.values(REPORT_BOOKMARK_TYPE) })
|
||||
type!: ReportBookmarkType;
|
||||
|
||||
@ApiProperty()
|
||||
applied!: boolean;
|
||||
|
||||
@ApiProperty({ type: 'object', additionalProperties: true })
|
||||
configuration!: unknown;
|
||||
|
||||
@ApiProperty()
|
||||
status!: string;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: number;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
}
|
||||
|
||||
export class ListReportBookmarksQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
uniqueName?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: Object.values(REPORT_BOOKMARK_TYPE) })
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(REPORT_BOOKMARK_TYPE))
|
||||
type?: ReportBookmarkType;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export class CreateReportBookmarkDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
uniqueName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
label!: string;
|
||||
|
||||
@ApiProperty({ enum: Object.values(REPORT_BOOKMARK_TYPE) })
|
||||
@IsIn(Object.values(REPORT_BOOKMARK_TYPE))
|
||||
type!: ReportBookmarkType;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
applied?: boolean;
|
||||
|
||||
@ApiProperty({ type: 'object', additionalProperties: true })
|
||||
@IsObject()
|
||||
configuration!: unknown;
|
||||
}
|
||||
|
||||
export class AppliedBookmarkQueryDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
uniqueName!: string;
|
||||
|
||||
@ApiProperty({ enum: Object.values(REPORT_BOOKMARK_TYPE) })
|
||||
@IsIn(Object.values(REPORT_BOOKMARK_TYPE))
|
||||
type!: ReportBookmarkType;
|
||||
}
|
||||
|
||||
export class LabelHistoryQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
label?: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReportBookmarksReadController } from './report-bookmarks-read.controller';
|
||||
import { ReportBookmarksWriteController } from './report-bookmarks-write.controller';
|
||||
import { ReportBookmarksRepository } from './report-bookmarks.repository';
|
||||
import { ReportBookmarksService } from './report-bookmarks.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ReportBookmarksReadController, ReportBookmarksWriteController],
|
||||
providers: [ReportBookmarksRepository, ReportBookmarksService],
|
||||
exports: [ReportBookmarksRepository, ReportBookmarksService],
|
||||
})
|
||||
export class ReportBookmarkModule {}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../../common/auth/auth-user';
|
||||
import {
|
||||
Pagination,
|
||||
type PaginationResponse,
|
||||
PaginationMetaDto,
|
||||
} from '../../../common/http/response';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import {
|
||||
AppliedBookmarkQueryDto,
|
||||
LabelHistoryQueryDto,
|
||||
ListReportBookmarksQueryDto,
|
||||
ReportBookmarkDto,
|
||||
} from './dto/report-bookmark.dto';
|
||||
import { ReportBookmarksService } from './report-bookmarks.service';
|
||||
|
||||
@ApiTags('report-bookmarks')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('report-bookmarks')
|
||||
export class ReportBookmarksReadController {
|
||||
constructor(private readonly bookmarksService: ReportBookmarksService) {}
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@ApiOperation({ summary: 'List report bookmarks for current user' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/ReportBookmarkDto' },
|
||||
},
|
||||
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
list(
|
||||
@Query() query: ListReportBookmarksQueryDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<PaginationResponse<ReportBookmarkDto>> {
|
||||
return this.bookmarksService.list(user.id, query);
|
||||
}
|
||||
|
||||
@Get('label-history')
|
||||
@ApiOperation({ summary: 'Distinct bookmark labels' })
|
||||
@ApiOkResponse({ type: [String] })
|
||||
labelHistory(
|
||||
@Query() query: LabelHistoryQueryDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.bookmarksService.labelHistory(user.id, query.label);
|
||||
}
|
||||
|
||||
@Get('applied')
|
||||
@ApiOperation({ summary: 'Get applied bookmark for report and type' })
|
||||
@ApiOkResponse({ type: ReportBookmarkDto })
|
||||
findApplied(
|
||||
@Query() query: AppliedBookmarkQueryDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.bookmarksService.findApplied(
|
||||
user.id,
|
||||
query.groupName,
|
||||
query.uniqueName,
|
||||
query.type,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Put,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../../common/auth/auth-user';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import {
|
||||
CreateReportBookmarkDto,
|
||||
ReportBookmarkDto,
|
||||
} from './dto/report-bookmark.dto';
|
||||
import { ReportBookmarksService } from './report-bookmarks.service';
|
||||
|
||||
@ApiTags('report-bookmarks')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('report-bookmarks')
|
||||
export class ReportBookmarksWriteController {
|
||||
constructor(private readonly bookmarksService: ReportBookmarksService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create report bookmark' })
|
||||
@ApiCreatedResponse({ type: ReportBookmarkDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
create(@Body() dto: CreateReportBookmarkDto, @CurrentUser() user: AuthUser) {
|
||||
return this.bookmarksService.create(user.id, dto);
|
||||
}
|
||||
|
||||
@Put('applied/:id')
|
||||
@ApiOperation({ summary: 'Apply report bookmark' })
|
||||
@ApiOkResponse({ type: ReportBookmarkDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
apply(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) {
|
||||
return this.bookmarksService.apply(user.id, id);
|
||||
}
|
||||
|
||||
@Put('unapplied/:id')
|
||||
@ApiOperation({ summary: 'Unapply report bookmark' })
|
||||
@ApiOkResponse({ type: ReportBookmarkDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
unapply(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.bookmarksService.unapply(user.id, id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete report bookmark' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
async delete(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<void> {
|
||||
await this.bookmarksService.delete(user.id, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { and, desc, eq, ilike } from 'drizzle-orm';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import { reportBookmarks } from '../../../database/report-bookmarks-table';
|
||||
import type { ReportBookmarkType } from '../shared/constants/bookmark-type';
|
||||
|
||||
export type ReportBookmark = {
|
||||
id: string;
|
||||
groupName: string;
|
||||
uniqueName: string;
|
||||
label: string;
|
||||
type: ReportBookmarkType;
|
||||
applied: boolean;
|
||||
configuration: unknown;
|
||||
status: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
updatedBy: string;
|
||||
};
|
||||
|
||||
export type ListReportBookmarksFilters = {
|
||||
groupName?: string;
|
||||
uniqueName?: string;
|
||||
type?: ReportBookmarkType;
|
||||
label?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ReportBookmarksRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async list(
|
||||
ownerId: string,
|
||||
filters: ListReportBookmarksFilters,
|
||||
): Promise<{ data: ReportBookmark[]; total: number }> {
|
||||
const conditions = [eq(reportBookmarks.createdBy, ownerId)];
|
||||
if (filters.groupName) {
|
||||
conditions.push(eq(reportBookmarks.groupName, filters.groupName));
|
||||
}
|
||||
if (filters.uniqueName) {
|
||||
conditions.push(eq(reportBookmarks.uniqueName, filters.uniqueName));
|
||||
}
|
||||
if (filters.type) {
|
||||
conditions.push(eq(reportBookmarks.type, filters.type));
|
||||
}
|
||||
if (filters.label) {
|
||||
conditions.push(ilike(reportBookmarks.label, `%${filters.label}%`));
|
||||
}
|
||||
const where = and(...conditions);
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(reportBookmarks)
|
||||
.where(where)
|
||||
.orderBy(desc(reportBookmarks.updatedAt))
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
const totalRows = await this.db
|
||||
.select({ id: reportBookmarks.id })
|
||||
.from(reportBookmarks)
|
||||
.where(where);
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row)),
|
||||
total: totalRows.length,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(ownerId: string, id: string): Promise<ReportBookmark | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(reportBookmarks)
|
||||
.where(
|
||||
and(eq(reportBookmarks.id, id), eq(reportBookmarks.createdBy, ownerId)),
|
||||
);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async findApplied(
|
||||
ownerId: string,
|
||||
groupName: string,
|
||||
uniqueName: string,
|
||||
type: ReportBookmarkType,
|
||||
): Promise<ReportBookmark | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(reportBookmarks)
|
||||
.where(
|
||||
and(
|
||||
eq(reportBookmarks.createdBy, ownerId),
|
||||
eq(reportBookmarks.groupName, groupName),
|
||||
eq(reportBookmarks.uniqueName, uniqueName),
|
||||
eq(reportBookmarks.type, type),
|
||||
eq(reportBookmarks.applied, true),
|
||||
),
|
||||
);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async labelHistory(ownerId: string, prefix?: string): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.select({ label: reportBookmarks.label })
|
||||
.from(reportBookmarks)
|
||||
.where(eq(reportBookmarks.createdBy, ownerId))
|
||||
.orderBy(desc(reportBookmarks.updatedAt));
|
||||
const labels = rows.map((r) => r.label);
|
||||
if (prefix) {
|
||||
return [...new Set(labels.filter((l) => l.includes(prefix)))];
|
||||
}
|
||||
return [...new Set(labels)];
|
||||
}
|
||||
|
||||
async create(
|
||||
ownerId: string,
|
||||
input: {
|
||||
groupName: string;
|
||||
uniqueName: string;
|
||||
label: string;
|
||||
type: ReportBookmarkType;
|
||||
applied: boolean;
|
||||
configuration: unknown;
|
||||
},
|
||||
): Promise<ReportBookmark> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
if (input.applied) {
|
||||
await this.unapplySiblings(
|
||||
ownerId,
|
||||
input.groupName,
|
||||
input.uniqueName,
|
||||
input.type,
|
||||
now.value,
|
||||
ownerId,
|
||||
);
|
||||
}
|
||||
const rows = await this.db
|
||||
.insert(reportBookmarks)
|
||||
.values({
|
||||
groupName: input.groupName,
|
||||
uniqueName: input.uniqueName,
|
||||
label: input.label,
|
||||
type: input.type,
|
||||
applied: input.applied,
|
||||
configuration: input.configuration,
|
||||
status: Status.DEFAULT,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: ownerId,
|
||||
updatedBy: ownerId,
|
||||
})
|
||||
.returning();
|
||||
return this.toDomain(rows[0]!);
|
||||
}
|
||||
|
||||
async apply(ownerId: string, id: string): Promise<ReportBookmark> {
|
||||
const bookmark = await this.findById(ownerId, id);
|
||||
if (!bookmark) {
|
||||
throw new NotFoundException('Bookmark not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
await this.unapplySiblings(
|
||||
ownerId,
|
||||
bookmark.groupName,
|
||||
bookmark.uniqueName,
|
||||
bookmark.type,
|
||||
now.value,
|
||||
ownerId,
|
||||
);
|
||||
const rows = await this.db
|
||||
.update(reportBookmarks)
|
||||
.set({ applied: true, updatedAt: now.value, updatedBy: ownerId })
|
||||
.where(
|
||||
and(eq(reportBookmarks.id, id), eq(reportBookmarks.createdBy, ownerId)),
|
||||
)
|
||||
.returning();
|
||||
return this.toDomain(rows[0]!);
|
||||
}
|
||||
|
||||
async unapply(ownerId: string, id: string): Promise<ReportBookmark> {
|
||||
const bookmark = await this.findById(ownerId, id);
|
||||
if (!bookmark) {
|
||||
throw new NotFoundException('Bookmark not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(reportBookmarks)
|
||||
.set({ applied: false, updatedAt: now.value, updatedBy: ownerId })
|
||||
.where(
|
||||
and(eq(reportBookmarks.id, id), eq(reportBookmarks.createdBy, ownerId)),
|
||||
)
|
||||
.returning();
|
||||
return this.toDomain(rows[0]!);
|
||||
}
|
||||
|
||||
async delete(ownerId: string, id: string): Promise<void> {
|
||||
const bookmark = await this.findById(ownerId, id);
|
||||
if (!bookmark) {
|
||||
throw new NotFoundException('Bookmark not found');
|
||||
}
|
||||
await this.db
|
||||
.delete(reportBookmarks)
|
||||
.where(
|
||||
and(eq(reportBookmarks.id, id), eq(reportBookmarks.createdBy, ownerId)),
|
||||
);
|
||||
}
|
||||
|
||||
private async unapplySiblings(
|
||||
ownerId: string,
|
||||
groupName: string,
|
||||
uniqueName: string,
|
||||
type: ReportBookmarkType,
|
||||
updatedAt: number,
|
||||
updatedBy: string,
|
||||
) {
|
||||
await this.db
|
||||
.update(reportBookmarks)
|
||||
.set({ applied: false, updatedAt, updatedBy })
|
||||
.where(
|
||||
and(
|
||||
eq(reportBookmarks.createdBy, ownerId),
|
||||
eq(reportBookmarks.groupName, groupName),
|
||||
eq(reportBookmarks.uniqueName, uniqueName),
|
||||
eq(reportBookmarks.type, type),
|
||||
eq(reportBookmarks.applied, true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private toDomain(row: typeof reportBookmarks.$inferSelect): ReportBookmark {
|
||||
return {
|
||||
id: row.id,
|
||||
groupName: row.groupName,
|
||||
uniqueName: row.uniqueName,
|
||||
label: row.label,
|
||||
type: row.type as ReportBookmarkType,
|
||||
applied: row.applied,
|
||||
configuration: row.configuration,
|
||||
status: row.status,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { toListPage } from '../../../common/http/response';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import {
|
||||
REPORT_BOOKMARK_TYPE,
|
||||
type ReportBookmarkType,
|
||||
} from '../shared/constants/bookmark-type';
|
||||
import { findReportConfig } from '../shared/configs';
|
||||
import {
|
||||
ReportBookmarksRepository,
|
||||
type ReportBookmark,
|
||||
} from './report-bookmarks.repository';
|
||||
|
||||
export type ListReportBookmarksQuery = {
|
||||
groupName?: string;
|
||||
uniqueName?: string;
|
||||
type?: ReportBookmarkType;
|
||||
label?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type CreateReportBookmarkInput = {
|
||||
groupName: string;
|
||||
uniqueName: string;
|
||||
label: string;
|
||||
type: ReportBookmarkType;
|
||||
applied?: boolean;
|
||||
configuration: unknown;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ReportBookmarksService {
|
||||
constructor(private readonly repository: ReportBookmarksRepository) {}
|
||||
|
||||
async list(
|
||||
ownerId: string,
|
||||
query: ListReportBookmarksQuery,
|
||||
): Promise<PaginationResponse<ReturnType<ReportBookmarksService['toDto']>>> {
|
||||
const page = toListPage(query);
|
||||
const { data, total } = await this.repository.list(ownerId, {
|
||||
groupName: query.groupName,
|
||||
uniqueName: query.uniqueName,
|
||||
type: query.type,
|
||||
label: query.label,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return { data: data.map((item) => this.toDto(item)), total };
|
||||
}
|
||||
|
||||
async labelHistory(ownerId: string, label?: string): Promise<string[]> {
|
||||
return this.repository.labelHistory(ownerId, label);
|
||||
}
|
||||
|
||||
async findApplied(
|
||||
ownerId: string,
|
||||
groupName?: string,
|
||||
uniqueName?: string,
|
||||
type?: ReportBookmarkType,
|
||||
) {
|
||||
if (!groupName || !uniqueName || !type) {
|
||||
throw new BadRequestException(
|
||||
'groupName, uniqueName, and type are required',
|
||||
);
|
||||
}
|
||||
const bookmark = await this.repository.findApplied(
|
||||
ownerId,
|
||||
groupName,
|
||||
uniqueName,
|
||||
type,
|
||||
);
|
||||
return bookmark ? this.toDto(bookmark) : null;
|
||||
}
|
||||
|
||||
async create(ownerId: string, input: CreateReportBookmarkInput) {
|
||||
this.assertValidBookmarkInput(input);
|
||||
const bookmark = await this.repository.create(ownerId, {
|
||||
groupName: input.groupName,
|
||||
uniqueName: input.uniqueName,
|
||||
label: input.label,
|
||||
type: input.type,
|
||||
applied: input.applied ?? false,
|
||||
configuration: input.configuration,
|
||||
});
|
||||
return this.toDto(bookmark);
|
||||
}
|
||||
|
||||
async apply(ownerId: string, id: string) {
|
||||
const bookmark = await this.repository.apply(ownerId, id);
|
||||
return this.toDto(bookmark);
|
||||
}
|
||||
|
||||
async unapply(ownerId: string, id: string) {
|
||||
const bookmark = await this.repository.unapply(ownerId, id);
|
||||
return this.toDto(bookmark);
|
||||
}
|
||||
|
||||
async delete(ownerId: string, id: string): Promise<void> {
|
||||
await this.repository.delete(ownerId, id);
|
||||
}
|
||||
|
||||
private assertValidBookmarkInput(input: CreateReportBookmarkInput) {
|
||||
if (!findReportConfig(input.groupName, input.uniqueName)) {
|
||||
throw new NotFoundException('Report not found');
|
||||
}
|
||||
if (
|
||||
input.type !== REPORT_BOOKMARK_TYPE.FILTER_TABLE &&
|
||||
input.type !== REPORT_BOOKMARK_TYPE.TABLE_CONFIG
|
||||
) {
|
||||
throw new BadRequestException('Invalid bookmark type');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(bookmark: ReportBookmark) {
|
||||
return {
|
||||
id: bookmark.id,
|
||||
groupName: bookmark.groupName,
|
||||
uniqueName: bookmark.uniqueName,
|
||||
label: bookmark.label,
|
||||
type: bookmark.type,
|
||||
applied: bookmark.applied,
|
||||
configuration: bookmark.configuration,
|
||||
status: bookmark.status,
|
||||
createdAt: bookmark.createdAt,
|
||||
updatedAt: bookmark.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { FILTER_TYPE } from '../../shared/constants/filter-type';
|
||||
import type { FilterType } from '../../shared/constants/filter-type';
|
||||
import type { QueryModelEntity } from '../../shared/entities/report-config.entity';
|
||||
|
||||
export class FilterModelEntryDto {
|
||||
@ApiProperty({ enum: Object.values(FILTER_TYPE) })
|
||||
@IsString()
|
||||
@IsIn(Object.values(FILTER_TYPE))
|
||||
type!: FilterType;
|
||||
|
||||
@ApiProperty()
|
||||
filter!: unknown;
|
||||
}
|
||||
|
||||
export class RowGroupColDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
displayName!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
field?: string;
|
||||
}
|
||||
|
||||
export class ValueColDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
field?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
aggFunc?: string;
|
||||
}
|
||||
|
||||
export class SortModelEntryDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
colId!: string;
|
||||
|
||||
@ApiProperty({ enum: ['asc', 'desc'] })
|
||||
@IsIn(['asc', 'desc'])
|
||||
sort!: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export class QueryModelDto {
|
||||
@ApiProperty()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
startRow!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
endRow!: number;
|
||||
|
||||
@ApiProperty({ type: [RowGroupColDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RowGroupColDto)
|
||||
rowGroupCols!: RowGroupColDto[];
|
||||
|
||||
@ApiProperty({ type: [ValueColDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ValueColDto)
|
||||
valueCols!: ValueColDto[];
|
||||
|
||||
@ApiProperty({ type: 'array', items: { type: 'object' } })
|
||||
@IsArray()
|
||||
pivotCols!: unknown[];
|
||||
|
||||
@ApiProperty()
|
||||
@IsBoolean()
|
||||
pivotMode!: boolean;
|
||||
|
||||
@ApiProperty({ type: 'array' })
|
||||
@IsArray()
|
||||
groupKeys!: unknown[];
|
||||
|
||||
@ApiProperty({ type: 'object', additionalProperties: true })
|
||||
@IsObject()
|
||||
filterModel!: Record<string, FilterModelEntryDto>;
|
||||
|
||||
@ApiProperty({ type: [SortModelEntryDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SortModelEntryDto)
|
||||
sortModel!: SortModelEntryDto[];
|
||||
}
|
||||
|
||||
export class ReportQueryDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
uniqueName!: string;
|
||||
|
||||
@ApiProperty({ type: QueryModelDto })
|
||||
@IsObject()
|
||||
queryModel!: QueryModelEntity;
|
||||
}
|
||||
|
||||
export class ReportMetaDto {
|
||||
@ApiProperty()
|
||||
totalRow!: number;
|
||||
|
||||
@ApiProperty()
|
||||
limit!: number;
|
||||
|
||||
@ApiProperty()
|
||||
offset!: number;
|
||||
}
|
||||
|
||||
export class ListReportConfigQueryDto {
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
groupNames?: string | string[];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { ReportPrivilegeGuard } from '../shared/guards/report-privilege.guard';
|
||||
import { ReportMetaDto, ReportQueryDto } from './dto/report.dto';
|
||||
import { ReportService } from './report.service';
|
||||
|
||||
@ApiTags('reports')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('reports')
|
||||
@UseGuards(ReportPrivilegeGuard)
|
||||
export class ReportQueryController {
|
||||
constructor(private readonly reportService: ReportService) {}
|
||||
|
||||
@Post('data')
|
||||
@ApiOperation({ summary: 'Run report data query' })
|
||||
@ApiOkResponse({ description: 'Report rows' })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
getData(@Body() dto: ReportQueryDto) {
|
||||
return this.reportService.getReportData(dto);
|
||||
}
|
||||
|
||||
@Post('meta')
|
||||
@ApiOperation({ summary: 'Run report count query' })
|
||||
@ApiOkResponse({ type: ReportMetaDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
getMeta(@Body() dto: ReportQueryDto) {
|
||||
return this.reportService.getReportMeta(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../../common/auth/auth-user';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { ReportPrivilegeGuard } from '../shared/guards/report-privilege.guard';
|
||||
import { parseGroupNamesQuery } from '../shared/helpers/report-query-params';
|
||||
import { ReportService } from './report.service';
|
||||
|
||||
@ApiTags('reports')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('reports')
|
||||
export class ReportReadController {
|
||||
constructor(private readonly reportService: ReportService) {}
|
||||
|
||||
@Get('config')
|
||||
@UseGuards(ReportPrivilegeGuard)
|
||||
@ApiOperation({ summary: 'List report configs for group names' })
|
||||
@ApiOkResponse({ description: 'Report config list' })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
listConfigs(
|
||||
@Req() req: { query: Record<string, unknown> },
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
const groupNames = parseGroupNamesQuery(req.query);
|
||||
return this.reportService.listConfigs(
|
||||
groupNames,
|
||||
user.id,
|
||||
user.isSuperadmin,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrivilegesModule } from '../../privileges/privileges.module';
|
||||
import { ReportBookmarkModule } from '../report-bookmark/report-bookmark.module';
|
||||
import { ReportPrivilegeGuard } from '../shared/guards/report-privilege.guard';
|
||||
import { ReportReadController } from './report-read.controller';
|
||||
import { ReportQueryController } from './report-query.controller';
|
||||
import { ReportService } from './report.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrivilegesModule, ReportBookmarkModule],
|
||||
controllers: [ReportReadController, ReportQueryController],
|
||||
providers: [ReportService, ReportPrivilegeGuard],
|
||||
exports: [ReportService],
|
||||
})
|
||||
export class ReportModule {}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import { PrivilegesService } from '../../privileges/privileges.service';
|
||||
import {
|
||||
findReportConfig,
|
||||
findReportConfigsByGroup,
|
||||
toPublicConfig,
|
||||
} from '../shared/configs';
|
||||
import { REPORT_BOOKMARK_TYPE } from '../shared/constants/bookmark-type';
|
||||
import { formatReportRow } from '../shared/helpers/report-cell.formatter';
|
||||
import { ReportQueryBuilder } from '../shared/helpers/report-query.builder';
|
||||
import type { ReportQueryDto } from './dto/report.dto';
|
||||
import { ReportBookmarksRepository } from '../report-bookmark/report-bookmarks.repository';
|
||||
|
||||
@Injectable()
|
||||
export class ReportService {
|
||||
constructor(
|
||||
@Inject(DRIZZLE) private readonly db: DrizzleDB,
|
||||
private readonly privilegesService: PrivilegesService,
|
||||
private readonly bookmarksRepository: ReportBookmarksRepository,
|
||||
) {}
|
||||
|
||||
async listConfigs(
|
||||
groupNames: string[],
|
||||
userId: string,
|
||||
isSuperadmin: boolean,
|
||||
) {
|
||||
const configs = groupNames.flatMap((g) => findReportConfigsByGroup(g));
|
||||
const visible = isSuperadmin
|
||||
? configs
|
||||
: await this.filterVisibleConfigs(configs, userId);
|
||||
|
||||
const result = await Promise.all(
|
||||
visible.map(async (config) => {
|
||||
const activeFilter = await this.bookmarksRepository.findApplied(
|
||||
userId,
|
||||
config.groupName,
|
||||
config.uniqueName,
|
||||
REPORT_BOOKMARK_TYPE.FILTER_TABLE,
|
||||
);
|
||||
const activeTableConfig = await this.bookmarksRepository.findApplied(
|
||||
userId,
|
||||
config.groupName,
|
||||
config.uniqueName,
|
||||
REPORT_BOOKMARK_TYPE.TABLE_CONFIG,
|
||||
);
|
||||
return {
|
||||
...toPublicConfig(config),
|
||||
activeFilter: activeFilter ? this.toBookmarkDto(activeFilter) : null,
|
||||
activeTableConfig: activeTableConfig
|
||||
? this.toBookmarkDto(activeTableConfig)
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async getReportData(dto: ReportQueryDto) {
|
||||
const config = this.requireConfig(dto.groupName, dto.uniqueName);
|
||||
const builder = new ReportQueryBuilder(config, dto.queryModel);
|
||||
const sqlQuery = builder.getSqlData();
|
||||
const rows = (await this.db.execute(sqlQuery)) as Record<string, unknown>[];
|
||||
return rows.map((row) => formatReportRow(row, config.columnConfigs));
|
||||
}
|
||||
|
||||
async getReportMeta(dto: ReportQueryDto) {
|
||||
const config = this.requireConfig(dto.groupName, dto.uniqueName);
|
||||
const builder = new ReportQueryBuilder(config, dto.queryModel);
|
||||
const sqlQuery = builder.getSqlCount();
|
||||
const result = (await this.db.execute(sqlQuery)) as {
|
||||
count: string | number;
|
||||
}[];
|
||||
const totalRow = Number(result[0]?.count ?? 0);
|
||||
const limit = dto.queryModel.endRow - dto.queryModel.startRow;
|
||||
return {
|
||||
totalRow,
|
||||
limit,
|
||||
offset: dto.queryModel.startRow,
|
||||
};
|
||||
}
|
||||
|
||||
private requireConfig(groupName: string, uniqueName: string) {
|
||||
const config = findReportConfig(groupName, uniqueName);
|
||||
if (!config) {
|
||||
throw new NotFoundException('Report not found');
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
private async filterVisibleConfigs(
|
||||
configs: ReturnType<typeof findReportConfigsByGroup>,
|
||||
userId: string,
|
||||
) {
|
||||
const visible: typeof configs = [];
|
||||
for (const config of configs) {
|
||||
const ok = await this.privilegesService.checkPermission(
|
||||
userId,
|
||||
config.privilegeKey,
|
||||
'view',
|
||||
);
|
||||
if (ok) {
|
||||
visible.push(config);
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
private toBookmarkDto(row: {
|
||||
id: string;
|
||||
groupName: string;
|
||||
uniqueName: string;
|
||||
label: string;
|
||||
type: string;
|
||||
applied: boolean;
|
||||
configuration: unknown;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
groupName: row.groupName,
|
||||
uniqueName: row.uniqueName,
|
||||
label: row.label,
|
||||
type: row.type,
|
||||
applied: row.applied,
|
||||
configuration: row.configuration,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrivilegesModule } from '../privileges/privileges.module';
|
||||
import { ReportBookmarkModule } from './report-bookmark/report-bookmark.module';
|
||||
import { ReportModule } from './report/report.module';
|
||||
import { ReportPrivilegeGuard } from './shared/guards/report-privilege.guard';
|
||||
|
||||
@Module({
|
||||
imports: [PrivilegesModule, ReportModule, ReportBookmarkModule],
|
||||
providers: [ReportPrivilegeGuard],
|
||||
exports: [ReportModule, ReportBookmarkModule],
|
||||
})
|
||||
export class ReportsModule {}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ReportConfigEntity } from '../entities';
|
||||
import { deliveryPlanReport, packingSlipReport } from './logistics-reports';
|
||||
import {
|
||||
salesInvoiceReport,
|
||||
salesOrderReport,
|
||||
salesPaymentReport,
|
||||
salesRequestReport,
|
||||
visitPlanReport,
|
||||
} from './sales-reports';
|
||||
|
||||
export const reportConfigs: ReportConfigEntity[] = [
|
||||
salesOrderReport,
|
||||
salesRequestReport,
|
||||
salesInvoiceReport,
|
||||
salesPaymentReport,
|
||||
visitPlanReport,
|
||||
packingSlipReport,
|
||||
deliveryPlanReport,
|
||||
];
|
||||
|
||||
export function findReportConfig(
|
||||
groupName: string,
|
||||
uniqueName: string,
|
||||
): ReportConfigEntity | undefined {
|
||||
return reportConfigs.find(
|
||||
(c) => c.groupName === groupName && c.uniqueName === uniqueName,
|
||||
);
|
||||
}
|
||||
|
||||
export function findReportConfigsByGroup(
|
||||
groupName: string,
|
||||
): ReportConfigEntity[] {
|
||||
return reportConfigs.filter((c) => c.groupName === groupName);
|
||||
}
|
||||
|
||||
export function toPublicConfig(config: ReportConfigEntity) {
|
||||
const {
|
||||
whereCondition: _whereCondition,
|
||||
customQueryColumn: _customQueryColumn,
|
||||
...publicConfig
|
||||
} = config;
|
||||
return publicConfig;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
DATA_FORMAT,
|
||||
DATA_TYPE,
|
||||
FILTER_FIELD_TYPE,
|
||||
FILTER_TYPE,
|
||||
REPORT_GROUP,
|
||||
} from '../constants';
|
||||
import type { ReportConfigEntity } from '../entities';
|
||||
|
||||
const LOGISTICS_PREFIX = `${REPORT_GROUP.LOGISTICS_REPORT}__`;
|
||||
|
||||
export const packingSlipReport: ReportConfigEntity = {
|
||||
groupName: REPORT_GROUP.LOGISTICS_REPORT,
|
||||
uniqueName: `${LOGISTICS_PREFIX}packing_slip`,
|
||||
privilegeKey: 'LOGISTICS.REPORT',
|
||||
label: 'Report Packing Slip',
|
||||
tableSchema: `packing_slips main
|
||||
JOIN customers cust ON cust.id = main.customer_id`,
|
||||
mainTableAlias: 'main',
|
||||
defaultOrderBy: ['main.created_at DESC'],
|
||||
lowLevelOrderBy: ['main.id DESC'],
|
||||
filterPeriodConfig: { hidden: true },
|
||||
columnConfigs: [
|
||||
{
|
||||
column: 'main__date',
|
||||
query: 'main.date',
|
||||
label: 'Date',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.DATE_EPOCH,
|
||||
},
|
||||
{
|
||||
column: 'main__sales_order_number',
|
||||
query: 'main.sales_order_number',
|
||||
label: 'Sales Order No.',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__code',
|
||||
query: 'main.code',
|
||||
label: 'Packing Slip No.',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'cust__name',
|
||||
query: 'cust.name',
|
||||
label: 'Customer',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__status',
|
||||
query: 'main.status',
|
||||
label: 'Status',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.STATUS,
|
||||
},
|
||||
],
|
||||
filterConfigs: [
|
||||
{
|
||||
fieldLabel: 'Date',
|
||||
filterColumn: 'main__date',
|
||||
fieldType: FILTER_FIELD_TYPE.DATE_RANGE_PICKER,
|
||||
filterType: FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Customer',
|
||||
filterColumn: 'cust__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Status',
|
||||
filterColumn: 'main__status',
|
||||
fieldType: FILTER_FIELD_TYPE.SELECT,
|
||||
filterType: FILTER_TYPE.TEXT_IN_MEMBER_TEXT,
|
||||
selectCustomOptions: ['draft', 'active', 'archived'],
|
||||
},
|
||||
],
|
||||
customQueryColumn: (column) => {
|
||||
if (column === 'main__date') {
|
||||
return 'main.date';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
export const deliveryPlanReport: ReportConfigEntity = {
|
||||
groupName: REPORT_GROUP.LOGISTICS_REPORT,
|
||||
uniqueName: `${LOGISTICS_PREFIX}delivery_plan`,
|
||||
privilegeKey: 'LOGISTICS.REPORT',
|
||||
label: 'Report Delivery Plan',
|
||||
tableSchema: `plans main
|
||||
JOIN employees emp ON emp.id = main.employee_id
|
||||
JOIN branches start_br ON start_br.id = main.start_branch_id
|
||||
LEFT JOIN (
|
||||
SELECT plan_id, COUNT(*) AS destination_count
|
||||
FROM plan_destinations
|
||||
GROUP BY plan_id
|
||||
) pd ON pd.plan_id = main.id
|
||||
LEFT JOIN (
|
||||
SELECT plan_id, COUNT(*) AS packing_slip_count
|
||||
FROM plan_packing_slips
|
||||
GROUP BY plan_id
|
||||
) pps ON pps.plan_id = main.id`,
|
||||
mainTableAlias: 'main',
|
||||
whereDefaultConditions: ["main.purpose = 'logistics'"],
|
||||
defaultOrderBy: ['main.date DESC'],
|
||||
lowLevelOrderBy: ['main.id DESC'],
|
||||
filterPeriodConfig: { hidden: true },
|
||||
columnConfigs: [
|
||||
{
|
||||
column: 'main__date',
|
||||
query: 'main.date',
|
||||
label: 'Date',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.DATE_EPOCH,
|
||||
},
|
||||
{
|
||||
column: 'emp__name',
|
||||
query: 'emp.name',
|
||||
label: 'Sales Rep',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'start_br__name',
|
||||
query: 'start_br.name',
|
||||
label: 'Branch',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'pd__destination_count',
|
||||
query: 'COALESCE(pd.destination_count, 0)',
|
||||
label: 'Plan',
|
||||
type: DATA_TYPE.MEASURE,
|
||||
format: DATA_FORMAT.NUMBER,
|
||||
},
|
||||
{
|
||||
column: 'pps__packing_slip_count',
|
||||
query: 'COALESCE(pps.packing_slip_count, 0)',
|
||||
label: 'Packing Slip',
|
||||
type: DATA_TYPE.MEASURE,
|
||||
format: DATA_FORMAT.NUMBER,
|
||||
},
|
||||
{
|
||||
column: 'main__status',
|
||||
query: 'main.status',
|
||||
label: 'Status',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.STATUS,
|
||||
},
|
||||
],
|
||||
filterConfigs: [
|
||||
{
|
||||
fieldLabel: 'Date',
|
||||
filterColumn: 'main__date',
|
||||
fieldType: FILTER_FIELD_TYPE.DATE_RANGE_PICKER,
|
||||
filterType: FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Driver',
|
||||
filterColumn: 'emp__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Status',
|
||||
filterColumn: 'main__status',
|
||||
fieldType: FILTER_FIELD_TYPE.SELECT,
|
||||
filterType: FILTER_TYPE.TEXT_IN_MEMBER_TEXT,
|
||||
selectCustomOptions: ['draft', 'active', 'archived'],
|
||||
},
|
||||
],
|
||||
customQueryColumn: (column) => {
|
||||
if (column === 'main__date') {
|
||||
return 'main.date';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,570 @@
|
||||
import {
|
||||
DATA_FORMAT,
|
||||
DATA_TYPE,
|
||||
FILTER_FIELD_TYPE,
|
||||
FILTER_TYPE,
|
||||
REPORT_GROUP,
|
||||
} from '../constants';
|
||||
import type { ReportConfigEntity } from '../entities';
|
||||
|
||||
const SALES_PREFIX = `${REPORT_GROUP.SALES_REPORT}__`;
|
||||
const LOGISTICS_PREFIX = `${REPORT_GROUP.LOGISTICS_REPORT}__`;
|
||||
|
||||
export const salesOrderReport: ReportConfigEntity = {
|
||||
groupName: REPORT_GROUP.SALES_REPORT,
|
||||
uniqueName: `${SALES_PREFIX}sales_order`,
|
||||
privilegeKey: 'SALES.REPORT',
|
||||
label: 'Report Sales Order',
|
||||
tableSchema: `sales_orders main
|
||||
JOIN customers cust ON cust.id = main.customer_id
|
||||
JOIN branches br ON br.id = main.branch_id
|
||||
JOIN divisions dv ON dv.id = main.division_id
|
||||
JOIN employees emp ON emp.id = main.sales_person_id
|
||||
LEFT JOIN (
|
||||
SELECT sales_order_id, SUM(quantity * price) AS amount
|
||||
FROM sales_order_products
|
||||
GROUP BY sales_order_id
|
||||
) sop ON sop.sales_order_id = main.id`,
|
||||
mainTableAlias: 'main',
|
||||
whereDefaultConditions: [],
|
||||
defaultOrderBy: ['main.created_at DESC'],
|
||||
lowLevelOrderBy: ['main.id DESC'],
|
||||
filterPeriodConfig: { hidden: true },
|
||||
columnConfigs: [
|
||||
{
|
||||
column: 'main__date',
|
||||
query: 'main.date',
|
||||
label: 'Date',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.DATE_EPOCH,
|
||||
},
|
||||
{
|
||||
column: 'br__name',
|
||||
query: 'br.name',
|
||||
label: 'Branch',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'dv__name',
|
||||
query: 'dv.name',
|
||||
label: 'Division',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__code',
|
||||
query: 'main.code',
|
||||
label: 'No. Sales Order',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'cust__name',
|
||||
query: 'cust.name',
|
||||
label: 'Customer',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__amount',
|
||||
query: 'COALESCE(sop.amount, 0)',
|
||||
label: 'Invoice Amount',
|
||||
type: DATA_TYPE.MEASURE,
|
||||
format: DATA_FORMAT.CURRENCY,
|
||||
},
|
||||
{
|
||||
column: 'emp__name',
|
||||
query: 'emp.name',
|
||||
label: 'Sales Rep.',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__status',
|
||||
query: 'main.status',
|
||||
label: 'Last Status Order',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.STATUS,
|
||||
},
|
||||
],
|
||||
filterConfigs: [
|
||||
{
|
||||
fieldLabel: 'Date',
|
||||
filterColumn: 'main__date',
|
||||
fieldType: FILTER_FIELD_TYPE.DATE_RANGE_PICKER,
|
||||
filterType: FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Branch',
|
||||
filterColumn: 'br__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Division',
|
||||
filterColumn: 'dv__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Customer',
|
||||
filterColumn: 'cust__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Sales Rep.',
|
||||
filterColumn: 'emp__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Status',
|
||||
filterColumn: 'main__status',
|
||||
fieldType: FILTER_FIELD_TYPE.SELECT,
|
||||
filterType: FILTER_TYPE.TEXT_IN_MEMBER_TEXT,
|
||||
selectCustomOptions: ['draft', 'active', 'archived'],
|
||||
},
|
||||
],
|
||||
customQueryColumn: (column) => {
|
||||
if (column === 'main__date') {
|
||||
return 'main.date';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
export const salesRequestReport: ReportConfigEntity = {
|
||||
groupName: REPORT_GROUP.SALES_REPORT,
|
||||
uniqueName: `${SALES_PREFIX}sales_request`,
|
||||
privilegeKey: 'SALES.REPORT',
|
||||
label: 'Report Request Order',
|
||||
tableSchema: `sales_requests main
|
||||
JOIN customers cust ON cust.id = main.customer_id
|
||||
JOIN branches br ON br.id = main.branch_id
|
||||
JOIN divisions dv ON dv.id = main.division_id
|
||||
JOIN employees emp ON emp.id = main.sales_person_id`,
|
||||
mainTableAlias: 'main',
|
||||
defaultOrderBy: ['main.created_at DESC'],
|
||||
lowLevelOrderBy: ['main.id DESC'],
|
||||
filterPeriodConfig: { hidden: true },
|
||||
columnConfigs: [
|
||||
{
|
||||
column: 'main__date',
|
||||
query: 'main.date',
|
||||
label: 'Date',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.DATE_EPOCH,
|
||||
},
|
||||
{
|
||||
column: 'br__name',
|
||||
query: 'br.name',
|
||||
label: 'Branch',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'dv__name',
|
||||
query: 'dv.name',
|
||||
label: 'Division',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__code',
|
||||
query: 'main.code',
|
||||
label: 'No. Request Order',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'cust__name',
|
||||
query: 'cust.name',
|
||||
label: 'Customer',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'emp__name',
|
||||
query: 'emp.name',
|
||||
label: 'Sales Rep.',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__status',
|
||||
query: 'main.status',
|
||||
label: 'Status',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.STATUS,
|
||||
},
|
||||
],
|
||||
filterConfigs: [
|
||||
{
|
||||
fieldLabel: 'Date',
|
||||
filterColumn: 'main__date',
|
||||
fieldType: FILTER_FIELD_TYPE.DATE_RANGE_PICKER,
|
||||
filterType: FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Branch',
|
||||
filterColumn: 'br__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Division',
|
||||
filterColumn: 'dv__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Customer',
|
||||
filterColumn: 'cust__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Sales Rep.',
|
||||
filterColumn: 'emp__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Status',
|
||||
filterColumn: 'main__status',
|
||||
fieldType: FILTER_FIELD_TYPE.SELECT,
|
||||
filterType: FILTER_TYPE.TEXT_IN_MEMBER_TEXT,
|
||||
selectCustomOptions: ['draft', 'active', 'archived'],
|
||||
},
|
||||
],
|
||||
customQueryColumn: (column) => {
|
||||
if (column === 'main__date') {
|
||||
return 'main.date';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
export const salesInvoiceReport: ReportConfigEntity = {
|
||||
groupName: REPORT_GROUP.SALES_REPORT,
|
||||
uniqueName: `${SALES_PREFIX}sales_invoice`,
|
||||
privilegeKey: 'SALES.REPORT',
|
||||
label: 'Report Invoice',
|
||||
tableSchema: `sales_invoices main
|
||||
JOIN customers cust ON cust.id = main.customer_id
|
||||
JOIN branches br ON br.id = main.branch_id
|
||||
JOIN divisions dv ON dv.id = main.division_id
|
||||
JOIN employees emp ON emp.id = main.sales_person_id`,
|
||||
mainTableAlias: 'main',
|
||||
defaultOrderBy: ['main.created_at DESC'],
|
||||
lowLevelOrderBy: ['main.id DESC'],
|
||||
filterPeriodConfig: { hidden: true },
|
||||
columnConfigs: [
|
||||
{
|
||||
column: 'main__date',
|
||||
query: 'main.date',
|
||||
label: 'Date',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.DATE_EPOCH,
|
||||
},
|
||||
{
|
||||
column: 'br__name',
|
||||
query: 'br.name',
|
||||
label: 'Branch',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'dv__name',
|
||||
query: 'dv.name',
|
||||
label: 'Division',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'cust__name',
|
||||
query: 'cust.name',
|
||||
label: 'Customer',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'cust__code',
|
||||
query: 'cust.code',
|
||||
label: 'Customer code',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__sales_order_code',
|
||||
query: 'main.sales_order_code',
|
||||
label: 'Sales Order No.',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__code',
|
||||
query: 'main.code',
|
||||
label: 'Invoice No.',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__status',
|
||||
query: 'main.status',
|
||||
label: 'Status',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.STATUS,
|
||||
},
|
||||
{
|
||||
column: 'emp__name',
|
||||
query: 'emp.name',
|
||||
label: 'Sales Rep.',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'main__balance',
|
||||
query: 'main.balance',
|
||||
label: 'Balance',
|
||||
type: DATA_TYPE.MEASURE,
|
||||
format: DATA_FORMAT.CURRENCY,
|
||||
},
|
||||
],
|
||||
filterConfigs: [
|
||||
{
|
||||
fieldLabel: 'Date',
|
||||
filterColumn: 'main__date',
|
||||
fieldType: FILTER_FIELD_TYPE.DATE_RANGE_PICKER,
|
||||
filterType: FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Branch',
|
||||
filterColumn: 'br__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Status',
|
||||
filterColumn: 'main__status',
|
||||
fieldType: FILTER_FIELD_TYPE.SELECT,
|
||||
filterType: FILTER_TYPE.TEXT_IN_MEMBER_TEXT,
|
||||
selectCustomOptions: ['draft', 'active', 'archived'],
|
||||
},
|
||||
],
|
||||
customQueryColumn: (column) => {
|
||||
if (column === 'main__date') {
|
||||
return 'main.date';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
export const salesPaymentReport: ReportConfigEntity = {
|
||||
groupName: REPORT_GROUP.SALES_REPORT,
|
||||
uniqueName: `${SALES_PREFIX}sales_payment`,
|
||||
privilegeKey: 'SALES.REPORT',
|
||||
label: 'Report Payment',
|
||||
tableSchema: `sales_payments main
|
||||
JOIN sales_payment_invoices spi ON spi.sales_payment_id = main.id
|
||||
JOIN sales_invoices inv ON inv.id = spi.sales_invoice_id
|
||||
JOIN customers cust ON cust.id = inv.customer_id
|
||||
JOIN branches br ON br.id = inv.branch_id
|
||||
JOIN divisions dv ON dv.id = inv.division_id
|
||||
JOIN employees emp ON emp.id = inv.sales_person_id`,
|
||||
mainTableAlias: 'main',
|
||||
defaultOrderBy: ['main.created_at DESC'],
|
||||
lowLevelOrderBy: ['main.id DESC'],
|
||||
filterPeriodConfig: { hidden: true },
|
||||
columnConfigs: [
|
||||
{
|
||||
column: 'main__date',
|
||||
query: 'main.date',
|
||||
label: 'Date',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.DATE_EPOCH,
|
||||
},
|
||||
{
|
||||
column: 'main__code',
|
||||
query: 'main.code',
|
||||
label: 'Payment No.',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'cust__code',
|
||||
query: 'cust.code',
|
||||
label: 'Customer code',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'br__name',
|
||||
query: 'br.name',
|
||||
label: 'Branch',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'dv__name',
|
||||
query: 'dv.name',
|
||||
label: 'Division',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'emp__name',
|
||||
query: 'emp.name',
|
||||
label: 'Sales Rep',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'inv__code',
|
||||
query: 'inv.code',
|
||||
label: 'Invoice ID',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'inv__balance',
|
||||
query: 'inv.balance',
|
||||
label: 'Invoice Amount',
|
||||
type: DATA_TYPE.MEASURE,
|
||||
format: DATA_FORMAT.CURRENCY,
|
||||
},
|
||||
{
|
||||
column: 'spi__amount',
|
||||
query: 'spi.amount',
|
||||
label: 'Payment Amount',
|
||||
type: DATA_TYPE.MEASURE,
|
||||
format: DATA_FORMAT.CURRENCY,
|
||||
},
|
||||
{
|
||||
column: 'main__status',
|
||||
query: 'main.status',
|
||||
label: 'Status',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.STATUS,
|
||||
},
|
||||
],
|
||||
filterConfigs: [
|
||||
{
|
||||
fieldLabel: 'Date',
|
||||
filterColumn: 'main__date',
|
||||
fieldType: FILTER_FIELD_TYPE.DATE_RANGE_PICKER,
|
||||
filterType: FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Status',
|
||||
filterColumn: 'main__status',
|
||||
fieldType: FILTER_FIELD_TYPE.SELECT,
|
||||
filterType: FILTER_TYPE.TEXT_IN_MEMBER_TEXT,
|
||||
selectCustomOptions: ['draft', 'active', 'archived'],
|
||||
},
|
||||
],
|
||||
customQueryColumn: (column) => {
|
||||
if (column === 'main__date') {
|
||||
return 'main.date';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
export const visitPlanReport: ReportConfigEntity = {
|
||||
groupName: REPORT_GROUP.SALES_REPORT,
|
||||
uniqueName: `${SALES_PREFIX}visit_plan`,
|
||||
privilegeKey: 'SALES.REPORT',
|
||||
label: 'Report Visit Plan',
|
||||
tableSchema: `plans main
|
||||
JOIN employees emp ON emp.id = main.employee_id
|
||||
JOIN branches start_br ON start_br.id = main.start_branch_id
|
||||
LEFT JOIN (
|
||||
SELECT plan_id, COUNT(*) AS destination_count
|
||||
FROM plan_destinations
|
||||
GROUP BY plan_id
|
||||
) pd ON pd.plan_id = main.id
|
||||
LEFT JOIN (
|
||||
SELECT plan_id, COUNT(*) AS invoice_count
|
||||
FROM plan_invoices
|
||||
GROUP BY plan_id
|
||||
) pi ON pi.plan_id = main.id`,
|
||||
mainTableAlias: 'main',
|
||||
whereDefaultConditions: ["main.purpose = 'sales'"],
|
||||
defaultOrderBy: ['main.date DESC'],
|
||||
lowLevelOrderBy: ['main.id DESC'],
|
||||
filterPeriodConfig: { hidden: true },
|
||||
columnConfigs: [
|
||||
{
|
||||
column: 'main__date',
|
||||
query: 'main.date',
|
||||
label: 'Date',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.DATE_EPOCH,
|
||||
},
|
||||
{
|
||||
column: 'emp__name',
|
||||
query: 'emp.name',
|
||||
label: 'Sales Rep',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'start_br__name',
|
||||
query: 'start_br.name',
|
||||
label: 'Branch',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.TEXT,
|
||||
},
|
||||
{
|
||||
column: 'pd__destination_count',
|
||||
query: 'COALESCE(pd.destination_count, 0)',
|
||||
label: 'Plan',
|
||||
type: DATA_TYPE.MEASURE,
|
||||
format: DATA_FORMAT.NUMBER,
|
||||
},
|
||||
{
|
||||
column: 'pi__invoice_count',
|
||||
query: 'COALESCE(pi.invoice_count, 0)',
|
||||
label: 'Invoice',
|
||||
type: DATA_TYPE.MEASURE,
|
||||
format: DATA_FORMAT.NUMBER,
|
||||
},
|
||||
{
|
||||
column: 'main__status',
|
||||
query: 'main.status',
|
||||
label: 'Status',
|
||||
type: DATA_TYPE.DIMENSION,
|
||||
format: DATA_FORMAT.STATUS,
|
||||
},
|
||||
],
|
||||
filterConfigs: [
|
||||
{
|
||||
fieldLabel: 'Date',
|
||||
filterColumn: 'main__date',
|
||||
fieldType: FILTER_FIELD_TYPE.DATE_RANGE_PICKER,
|
||||
filterType: FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Sales Rep',
|
||||
filterColumn: 'emp__name',
|
||||
fieldType: FILTER_FIELD_TYPE.INPUT_TAG,
|
||||
filterType: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS,
|
||||
},
|
||||
{
|
||||
fieldLabel: 'Status',
|
||||
filterColumn: 'main__status',
|
||||
fieldType: FILTER_FIELD_TYPE.SELECT,
|
||||
filterType: FILTER_TYPE.TEXT_IN_MEMBER_TEXT,
|
||||
selectCustomOptions: ['draft', 'active', 'archived'],
|
||||
},
|
||||
],
|
||||
customQueryColumn: (column) => {
|
||||
if (column === 'main__date') {
|
||||
return 'main.date';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export const REPORT_BOOKMARK_TYPE = {
|
||||
TABLE_CONFIG: 'TABLE_CONFIG',
|
||||
FILTER_TABLE: 'FILTER_TABLE',
|
||||
} as const;
|
||||
|
||||
export type ReportBookmarkType =
|
||||
(typeof REPORT_BOOKMARK_TYPE)[keyof typeof REPORT_BOOKMARK_TYPE];
|
||||
@@ -0,0 +1,15 @@
|
||||
export const DATA_FORMAT = {
|
||||
TEXT: 'text',
|
||||
TEXT_UPPERCASE: 'text_uppercase',
|
||||
TEXT_LOWERCASE: 'text_lowercase',
|
||||
NUMBER: 'number',
|
||||
CURRENCY: 'currency',
|
||||
MINUS_CURRENCY: 'minus_currency',
|
||||
PERCENTAGE: 'percentage',
|
||||
BOOLEAN: 'boolean',
|
||||
STATUS: 'status',
|
||||
DATE_EPOCH: 'date_epoch',
|
||||
DATE_TIMESTAMP: 'date_timestamp',
|
||||
} as const;
|
||||
|
||||
export type DataFormat = (typeof DATA_FORMAT)[keyof typeof DATA_FORMAT];
|
||||
@@ -0,0 +1,6 @@
|
||||
export const DATA_TYPE = {
|
||||
DIMENSION: 'dimension',
|
||||
MEASURE: 'measure',
|
||||
} as const;
|
||||
|
||||
export type DataType = (typeof DATA_TYPE)[keyof typeof DATA_TYPE];
|
||||
@@ -0,0 +1,12 @@
|
||||
export const FILTER_FIELD_TYPE = {
|
||||
SELECT: 'select',
|
||||
INPUT_TEXT: 'input_text',
|
||||
INPUT_NUMBER: 'input_number',
|
||||
INPUT_TAG: 'input_tag',
|
||||
DATE_PICKER: 'date_picker',
|
||||
DATE_RANGE_PICKER: 'date_range_picker',
|
||||
MONTH_RANGE_PICKER: 'month_range_picker',
|
||||
} as const;
|
||||
|
||||
export type FilterFieldType =
|
||||
(typeof FILTER_FIELD_TYPE)[keyof typeof FILTER_FIELD_TYPE];
|
||||
@@ -0,0 +1,17 @@
|
||||
export const FILTER_TYPE = {
|
||||
TEXT_EQUALS: 'text_equals',
|
||||
TEXT_NOT_EQUAL: 'text_not_equal',
|
||||
TEXT_CONTAINS: 'text_contains',
|
||||
TEXT_NOT_CONTAINS: 'text_not_contains',
|
||||
TEXT_MULTIPLE_CONTAINS: 'text_multiple_contains',
|
||||
TEXT_IN_MEMBER_TEXT: 'text_inMemberText',
|
||||
NUMBER_EQUALS: 'number_equals',
|
||||
NUMBER_NOT_EQUAL: 'number_not_equal',
|
||||
NUMBER_GREATER_THAN: 'number_greater_than',
|
||||
NUMBER_LESS_THAN: 'number_less_than',
|
||||
NUMBER_IN_RANGE: 'number_in_range',
|
||||
TEXT_IN_DATE_RANGE_EPOCH: 'text_inDateRange_epoch',
|
||||
TEXT_IN_DATE_RANGE_TIMESTAMP: 'text_inDateRange_timestamp',
|
||||
} as const;
|
||||
|
||||
export type FilterType = (typeof FILTER_TYPE)[keyof typeof FILTER_TYPE];
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './bookmark-type';
|
||||
export * from './data-format';
|
||||
export * from './data-type';
|
||||
export * from './filter-field-type';
|
||||
export * from './filter-type';
|
||||
export * from './report-group';
|
||||
@@ -0,0 +1,6 @@
|
||||
export const REPORT_GROUP = {
|
||||
SALES_REPORT: 'sales_report',
|
||||
LOGISTICS_REPORT: 'logistics_report',
|
||||
} as const;
|
||||
|
||||
export type ReportGroupName = (typeof REPORT_GROUP)[keyof typeof REPORT_GROUP];
|
||||
@@ -0,0 +1 @@
|
||||
export * from './report-config.entity';
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { DataFormat } from '../constants/data-format';
|
||||
import type { DataType } from '../constants/data-type';
|
||||
import type { FilterFieldType } from '../constants/filter-field-type';
|
||||
import type { FilterType } from '../constants/filter-type';
|
||||
import type { ReportGroupName } from '../constants/report-group';
|
||||
|
||||
export interface ReportColumnConfigEntity {
|
||||
column: string;
|
||||
query: string;
|
||||
label: string;
|
||||
type: DataType;
|
||||
format: DataFormat;
|
||||
dateFormat?: string;
|
||||
}
|
||||
|
||||
export interface FilterConfigEntity {
|
||||
filterColumn: string;
|
||||
filterType: FilterType;
|
||||
fieldType: FilterFieldType;
|
||||
fieldLabel: string;
|
||||
hideField?: boolean;
|
||||
selectDataSourceUrl?: string;
|
||||
selectCustomOptions?: string[];
|
||||
selectValueKey?: string;
|
||||
selectLabelKey?: string;
|
||||
dateFormat?: string;
|
||||
}
|
||||
|
||||
export interface FilterPeriodConfig {
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export interface FilterModelEntry {
|
||||
type: FilterType;
|
||||
filter: unknown;
|
||||
}
|
||||
|
||||
export interface ReportConfigEntity {
|
||||
groupName: ReportGroupName;
|
||||
uniqueName: string;
|
||||
privilegeKey: string;
|
||||
label: string;
|
||||
tableSchema: string;
|
||||
mainTableAlias?: string;
|
||||
columnConfigs: ReportColumnConfigEntity[];
|
||||
filterConfigs?: FilterConfigEntity[];
|
||||
filterPeriodConfig?: FilterPeriodConfig;
|
||||
whereDefaultConditions?: string[];
|
||||
whereCondition?: (filterModel: Record<string, FilterModelEntry>) => string[];
|
||||
ignoreFilterKeys?: string[];
|
||||
customQueryColumn?: (column: string) => string | undefined;
|
||||
defaultOrderBy?: string[];
|
||||
lowLevelOrderBy?: string[];
|
||||
}
|
||||
|
||||
export interface ReportConfigPublicEntity extends Omit<
|
||||
ReportConfigEntity,
|
||||
'whereCondition' | 'customQueryColumn'
|
||||
> {}
|
||||
|
||||
export interface RowGroupColEntity {
|
||||
id: string;
|
||||
displayName: string;
|
||||
field: string;
|
||||
}
|
||||
|
||||
export interface ValueColEntity {
|
||||
id: string;
|
||||
field: string;
|
||||
aggFunc: string;
|
||||
}
|
||||
|
||||
export interface SortModelEntry {
|
||||
colId: string;
|
||||
sort: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface QueryModelEntity {
|
||||
startRow: number;
|
||||
endRow: number;
|
||||
rowGroupCols: RowGroupColEntity[];
|
||||
valueCols: ValueColEntity[];
|
||||
pivotCols: unknown[];
|
||||
pivotMode: boolean;
|
||||
groupKeys: unknown[];
|
||||
filterModel: Record<string, FilterModelEntry>;
|
||||
sortModel: SortModelEntry[];
|
||||
}
|
||||
|
||||
export const COUNT_CHILD_GROUP_COLUMN = 'countChildGroup';
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import type { AuthUser } from '../../../../common/auth/auth-user';
|
||||
import { PrivilegesService } from '../../../privileges/privileges.service';
|
||||
import { findReportConfig, findReportConfigsByGroup } from '../configs';
|
||||
import { parseGroupNamesQuery } from '../helpers/report-query-params';
|
||||
|
||||
@Injectable()
|
||||
export class ReportPrivilegeGuard implements CanActivate {
|
||||
constructor(private readonly privilegesService: PrivilegesService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
user?: AuthUser;
|
||||
body?: { groupName?: string; uniqueName?: string };
|
||||
query?: Record<string, unknown>;
|
||||
}>();
|
||||
const user = request.user;
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
if (user.isSuperadmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const groupName = request.body?.groupName;
|
||||
const uniqueName = request.body?.uniqueName;
|
||||
if (groupName && uniqueName) {
|
||||
const config = findReportConfig(groupName, uniqueName);
|
||||
if (!config) {
|
||||
throw new ForbiddenException('Insufficient privilege');
|
||||
}
|
||||
const ok = await this.privilegesService.checkPermission(
|
||||
user.id,
|
||||
config.privilegeKey,
|
||||
'view',
|
||||
);
|
||||
if (!ok) {
|
||||
throw new ForbiddenException('Insufficient privilege');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const groupNames = parseGroupNamesQuery(request.query ?? {});
|
||||
if (groupNames.length === 0) {
|
||||
throw new ForbiddenException('Insufficient privilege');
|
||||
}
|
||||
|
||||
for (const group of groupNames) {
|
||||
const configs = findReportConfigsByGroup(group);
|
||||
for (const config of configs) {
|
||||
const ok = await this.privilegesService.checkPermission(
|
||||
user.id,
|
||||
config.privilegeKey,
|
||||
'view',
|
||||
);
|
||||
if (ok) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new ForbiddenException('Insufficient privilege');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { DATA_FORMAT } from '../constants/data-format';
|
||||
import { formatReportCell, formatReportRow } from './report-cell.formatter';
|
||||
|
||||
describe('report cell formatter', () => {
|
||||
it('formats currency null as zero', () => {
|
||||
expect(formatReportCell(null, DATA_FORMAT.CURRENCY)).toBe('0.0000');
|
||||
});
|
||||
|
||||
it('formats boolean yes/no', () => {
|
||||
expect(formatReportCell(true, DATA_FORMAT.BOOLEAN)).toBe('Yes');
|
||||
expect(formatReportCell(0, DATA_FORMAT.BOOLEAN)).toBe('No');
|
||||
});
|
||||
|
||||
it('formats uppercase text', () => {
|
||||
expect(formatReportCell('draft', DATA_FORMAT.TEXT_UPPERCASE)).toBe('DRAFT');
|
||||
});
|
||||
|
||||
it('formats row by column config', () => {
|
||||
const row = formatReportRow(
|
||||
{ main__status: 'active', main__amount: '10.5' },
|
||||
[
|
||||
{
|
||||
column: 'main__status',
|
||||
query: 'main.status',
|
||||
label: 'Status',
|
||||
type: 'dimension',
|
||||
format: DATA_FORMAT.TEXT_UPPERCASE,
|
||||
},
|
||||
{
|
||||
column: 'main__amount',
|
||||
query: 'main.amount',
|
||||
label: 'Amount',
|
||||
type: 'measure',
|
||||
format: DATA_FORMAT.CURRENCY,
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(row.main__status).toBe('ACTIVE');
|
||||
expect(row.main__amount).toBe('10.5000');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { DateTime } from '../../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../../common/value-objects/decimal/decimal';
|
||||
import { DATA_FORMAT } from '../constants/data-format';
|
||||
import type { DataFormat } from '../constants/data-format';
|
||||
import type { ReportColumnConfigEntity } from '../entities/report-config.entity';
|
||||
|
||||
export function formatReportCell(raw: unknown, format: DataFormat): unknown {
|
||||
if (raw === null || raw === undefined) {
|
||||
switch (format) {
|
||||
case DATA_FORMAT.NUMBER:
|
||||
case DATA_FORMAT.CURRENCY:
|
||||
case DATA_FORMAT.MINUS_CURRENCY:
|
||||
return Decimal.zero().toString();
|
||||
default:
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
switch (format) {
|
||||
case DATA_FORMAT.TEXT:
|
||||
return String(raw);
|
||||
case DATA_FORMAT.TEXT_UPPERCASE:
|
||||
return String(raw).toUpperCase();
|
||||
case DATA_FORMAT.TEXT_LOWERCASE:
|
||||
return String(raw).toLowerCase();
|
||||
case DATA_FORMAT.NUMBER:
|
||||
return formatDecimalOrZero(raw);
|
||||
case DATA_FORMAT.CURRENCY:
|
||||
return formatDecimalOrZero(raw);
|
||||
case DATA_FORMAT.MINUS_CURRENCY:
|
||||
return negateDecimalString(formatDecimalOrZero(raw));
|
||||
case DATA_FORMAT.PERCENTAGE:
|
||||
return `${raw}%`;
|
||||
case DATA_FORMAT.BOOLEAN:
|
||||
return raw === true || raw === 1 || raw === '1' ? 'Yes' : 'No';
|
||||
case DATA_FORMAT.STATUS:
|
||||
return String(raw);
|
||||
case DATA_FORMAT.DATE_EPOCH:
|
||||
return formatEpoch(raw);
|
||||
case DATA_FORMAT.DATE_TIMESTAMP:
|
||||
return formatTimestamp(raw);
|
||||
default:
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatReportRow(
|
||||
row: Record<string, unknown>,
|
||||
columnConfigs: ReportColumnConfigEntity[],
|
||||
): Record<string, unknown> {
|
||||
const formatted: Record<string, unknown> = { ...row };
|
||||
for (const col of columnConfigs) {
|
||||
if (col.column in formatted) {
|
||||
formatted[col.column] = formatReportCell(
|
||||
formatted[col.column],
|
||||
col.format,
|
||||
);
|
||||
}
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
|
||||
function formatDecimalOrZero(raw: unknown): string {
|
||||
try {
|
||||
if (raw === null || raw === undefined || raw === '') {
|
||||
return Decimal.zero().toString();
|
||||
}
|
||||
return Decimal.create(String(raw)).toString();
|
||||
} catch {
|
||||
return Decimal.zero().toString();
|
||||
}
|
||||
}
|
||||
|
||||
function negateDecimalString(value: string): string {
|
||||
try {
|
||||
const decimal = Decimal.create(value);
|
||||
if (decimal.isZero()) {
|
||||
return decimal.toString();
|
||||
}
|
||||
return Decimal.create(`-${value.replace(/^-/, '')}`).toString();
|
||||
} catch {
|
||||
return Decimal.zero().toString();
|
||||
}
|
||||
}
|
||||
|
||||
function formatEpoch(raw: unknown): string {
|
||||
const ms = Number(raw);
|
||||
if (!Number.isFinite(ms)) {
|
||||
return String(raw);
|
||||
}
|
||||
return DateTime.fromUnixMs(ms).format();
|
||||
}
|
||||
|
||||
function formatTimestamp(raw: unknown): string {
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
return DateTime.create(raw).format();
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
const ms = Number(raw);
|
||||
if (Number.isFinite(ms)) {
|
||||
return DateTime.fromUnixMs(ms).format();
|
||||
}
|
||||
return String(raw);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { parseGroupNamesQuery } from './report-query-params';
|
||||
|
||||
describe('parseGroupNamesQuery', () => {
|
||||
it('reads groupNames', () => {
|
||||
expect(parseGroupNamesQuery({ groupNames: 'sales_report' })).toEqual([
|
||||
'sales_report',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads axios bracket notation groupNames[]', () => {
|
||||
expect(parseGroupNamesQuery({ 'groupNames[]': 'sales_report' })).toEqual([
|
||||
'sales_report',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads array values', () => {
|
||||
expect(
|
||||
parseGroupNamesQuery({ groupNames: ['sales_report', 'logistics_report'] }),
|
||||
).toEqual(['sales_report', 'logistics_report']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
export function parseGroupNamesQuery(
|
||||
query: Record<string, unknown>,
|
||||
): string[] {
|
||||
const raw =
|
||||
query.groupNames ??
|
||||
query['groupNames[]'] ??
|
||||
query['groupNames[0]'];
|
||||
|
||||
if (raw === undefined || raw === null || raw === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((item) => String(item)).filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
return [String(raw)];
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { salesOrderReport } from '../configs/sales-reports';
|
||||
import { FILTER_TYPE } from '../constants/filter-type';
|
||||
import { ReportQueryBuilder } from './report-query.builder';
|
||||
import { flattenSql } from './sql-test.helper';
|
||||
|
||||
describe('ReportQueryBuilder', () => {
|
||||
const baseQueryModel = {
|
||||
startRow: 0,
|
||||
endRow: 100,
|
||||
rowGroupCols: [],
|
||||
valueCols: [],
|
||||
pivotCols: [],
|
||||
pivotMode: false,
|
||||
groupKeys: [],
|
||||
filterModel: {},
|
||||
sortModel: [],
|
||||
};
|
||||
|
||||
it('builds ungrouped select with limit offset', () => {
|
||||
const builder = new ReportQueryBuilder(salesOrderReport, baseQueryModel);
|
||||
const text = flattenSql(builder.getSqlData());
|
||||
expect(text).toContain('SELECT');
|
||||
expect(text).toContain('sales_orders main');
|
||||
expect(text).toContain('LIMIT');
|
||||
expect(text).toContain('OFFSET');
|
||||
});
|
||||
|
||||
it('builds count query without grouping', () => {
|
||||
const builder = new ReportQueryBuilder(salesOrderReport, baseQueryModel);
|
||||
const text = flattenSql(builder.getSqlCount());
|
||||
expect(text).toContain('COUNT(main.id)');
|
||||
});
|
||||
|
||||
it('applies text equals filter with bound parameter', () => {
|
||||
const builder = new ReportQueryBuilder(salesOrderReport, {
|
||||
...baseQueryModel,
|
||||
filterModel: {
|
||||
main__status: {
|
||||
type: FILTER_TYPE.TEXT_EQUALS,
|
||||
filter: 'active',
|
||||
},
|
||||
},
|
||||
});
|
||||
const text = flattenSql(builder.getSqlData());
|
||||
expect(text).toContain('main.status');
|
||||
expect(text).toContain('=');
|
||||
});
|
||||
|
||||
it('rejects unknown filter columns', () => {
|
||||
const builder = new ReportQueryBuilder(salesOrderReport, {
|
||||
...baseQueryModel,
|
||||
filterModel: {
|
||||
evil__column: {
|
||||
type: FILTER_TYPE.TEXT_EQUALS,
|
||||
filter: 'x',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(() => builder.getSqlData()).toThrow('Invalid filter column');
|
||||
});
|
||||
|
||||
it('builds grouped select with countChildGroup', () => {
|
||||
const builder = new ReportQueryBuilder(salesOrderReport, {
|
||||
...baseQueryModel,
|
||||
rowGroupCols: [
|
||||
{ id: 'br__name', displayName: 'Branch', field: 'br__name' },
|
||||
],
|
||||
groupKeys: [],
|
||||
});
|
||||
const text = flattenSql(builder.getSqlData());
|
||||
expect(text).toContain('GROUP BY');
|
||||
expect(text).toContain('countChildGroup');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { SQL, sql } from 'drizzle-orm';
|
||||
import { FILTER_TYPE } from '../constants/filter-type';
|
||||
import {
|
||||
COUNT_CHILD_GROUP_COLUMN,
|
||||
type FilterModelEntry,
|
||||
type QueryModelEntity,
|
||||
type ReportConfigEntity,
|
||||
} from '../entities/report-config.entity';
|
||||
|
||||
export class ReportQueryBuilder {
|
||||
constructor(
|
||||
private readonly config: ReportConfigEntity,
|
||||
private readonly queryModel: QueryModelEntity,
|
||||
) {}
|
||||
|
||||
getSqlData(): SQL {
|
||||
const pageSize = this.queryModel.endRow - this.queryModel.startRow;
|
||||
const selectSql = this.buildSelectSql();
|
||||
const fromSql = sql.raw(this.config.tableSchema);
|
||||
const whereSql = this.buildWhereSql();
|
||||
const groupBySql = this.buildGroupBySql();
|
||||
const orderBySql = this.buildOrderBySql();
|
||||
|
||||
const parts: SQL[] = [sql`SELECT ${selectSql} FROM ${fromSql}`];
|
||||
if (whereSql) {
|
||||
parts.push(sql` WHERE ${whereSql}`);
|
||||
}
|
||||
if (groupBySql) {
|
||||
parts.push(sql` ${groupBySql}`);
|
||||
}
|
||||
if (orderBySql) {
|
||||
parts.push(sql` ${orderBySql}`);
|
||||
}
|
||||
parts.push(sql` LIMIT ${pageSize + 1} OFFSET ${this.queryModel.startRow}`);
|
||||
return sql.join(parts, sql.raw(''));
|
||||
}
|
||||
|
||||
getSqlCount(): SQL {
|
||||
const fromSql = sql.raw(this.config.tableSchema);
|
||||
const whereSql = this.buildWhereSql();
|
||||
const alias = this.mainAlias();
|
||||
const isGrouping = this.isDoingGrouping();
|
||||
|
||||
if (!isGrouping && this.queryModel.rowGroupCols.length === 0) {
|
||||
const parts: SQL[] = [
|
||||
sql`SELECT COUNT(${sql.raw(`${alias}.id`)}) AS count FROM ${fromSql}`,
|
||||
];
|
||||
if (whereSql) {
|
||||
parts.push(sql` WHERE ${whereSql}`);
|
||||
}
|
||||
return sql.join(parts, sql.raw(''));
|
||||
}
|
||||
|
||||
const groupExpr = this.currentGroupExpression();
|
||||
const parts: SQL[] = [
|
||||
sql`SELECT COUNT(DISTINCT ${sql.raw(groupExpr)}) + COUNT(DISTINCT CASE WHEN ${sql.raw(groupExpr)} IS NULL THEN 1 END) AS count FROM ${fromSql}`,
|
||||
];
|
||||
if (whereSql) {
|
||||
parts.push(sql` WHERE ${whereSql}`);
|
||||
}
|
||||
return sql.join(parts, sql.raw(''));
|
||||
}
|
||||
|
||||
private mainAlias(): string {
|
||||
return this.config.mainTableAlias ?? 'main';
|
||||
}
|
||||
|
||||
private isDoingGrouping(): boolean {
|
||||
return (
|
||||
this.queryModel.rowGroupCols.length > this.queryModel.groupKeys.length
|
||||
);
|
||||
}
|
||||
|
||||
private currentGroupColumn(): string | undefined {
|
||||
const index = this.queryModel.groupKeys.length;
|
||||
const col = this.queryModel.rowGroupCols[index];
|
||||
return col?.field ?? col?.id;
|
||||
}
|
||||
|
||||
private currentGroupExpression(): string {
|
||||
const field = this.currentGroupColumn();
|
||||
if (!field) {
|
||||
return `${this.mainAlias()}.id`;
|
||||
}
|
||||
return this.resolveColumnExpression(field);
|
||||
}
|
||||
|
||||
private resolveColumnExpression(column: string): string {
|
||||
if (this.config.customQueryColumn) {
|
||||
const custom = this.config.customQueryColumn(column);
|
||||
if (custom) {
|
||||
return custom;
|
||||
}
|
||||
}
|
||||
const colConfig = this.config.columnConfigs.find(
|
||||
(c) => c.column === column,
|
||||
);
|
||||
if (colConfig) {
|
||||
return colConfig.query;
|
||||
}
|
||||
return column.replace(/__/g, '.');
|
||||
}
|
||||
|
||||
private buildSelectSql(): SQL {
|
||||
if (this.isDoingGrouping()) {
|
||||
return this.buildGroupedSelectSql();
|
||||
}
|
||||
const columns = this.config.columnConfigs.map(
|
||||
(col) => sql`${sql.raw(col.query)} AS ${sql.raw(col.column)}`,
|
||||
);
|
||||
return sql.join(columns, sql.raw(', '));
|
||||
}
|
||||
|
||||
private buildGroupedSelectSql(): SQL {
|
||||
const groupField = this.currentGroupColumn();
|
||||
if (!groupField) {
|
||||
throw new BadRequestException('Invalid group configuration');
|
||||
}
|
||||
const groupExpr = this.resolveColumnExpression(groupField);
|
||||
const parts: SQL[] = [sql`${sql.raw(groupExpr)} AS ${sql.raw(groupField)}`];
|
||||
|
||||
const nextGroupIndex = this.queryModel.groupKeys.length + 1;
|
||||
const nextGroup = this.queryModel.rowGroupCols[nextGroupIndex];
|
||||
const alias = this.mainAlias();
|
||||
if (nextGroup) {
|
||||
const nextExpr = this.resolveColumnExpression(
|
||||
nextGroup.field ?? nextGroup.id,
|
||||
);
|
||||
parts.push(
|
||||
sql`COUNT(DISTINCT ${sql.raw(nextExpr)}) AS ${sql.raw(COUNT_CHILD_GROUP_COLUMN)}`,
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
sql`COUNT(${sql.raw(`${alias}.id`)}) AS ${sql.raw(COUNT_CHILD_GROUP_COLUMN)}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const valueCol of this.queryModel.valueCols) {
|
||||
const expr = this.resolveColumnExpression(valueCol.field);
|
||||
const agg = valueCol.aggFunc.toUpperCase();
|
||||
parts.push(
|
||||
sql`${sql.raw(agg)}(${sql.raw(expr)}) AS ${sql.raw(valueCol.field)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return sql.join(parts, sql.raw(', '));
|
||||
}
|
||||
|
||||
private buildWhereSql(): SQL | undefined {
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
for (const cond of this.config.whereDefaultConditions ?? []) {
|
||||
conditions.push(sql.raw(cond));
|
||||
}
|
||||
|
||||
if (this.config.whereCondition) {
|
||||
for (const cond of this.config.whereCondition(
|
||||
this.queryModel.filterModel,
|
||||
)) {
|
||||
conditions.push(sql.raw(cond));
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.queryModel.groupKeys.length; i++) {
|
||||
const groupCol = this.queryModel.rowGroupCols[i];
|
||||
if (!groupCol) {
|
||||
continue;
|
||||
}
|
||||
const field = groupCol.field ?? groupCol.id;
|
||||
const expr = this.resolveColumnExpression(field);
|
||||
const key = this.queryModel.groupKeys[i];
|
||||
if (key === null || key === undefined || key === '') {
|
||||
conditions.push(sql`${sql.raw(expr)} IS NULL`);
|
||||
} else {
|
||||
conditions.push(sql`${sql.raw(expr)} = ${String(key)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const ignoreKeys = new Set(this.config.ignoreFilterKeys ?? []);
|
||||
for (const [key, entry] of Object.entries(this.queryModel.filterModel)) {
|
||||
if (ignoreKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const filterConfig = this.config.filterConfigs?.find(
|
||||
(f) => f.filterColumn === key,
|
||||
);
|
||||
if (filterConfig?.hideField) {
|
||||
continue;
|
||||
}
|
||||
if (!this.isAllowedFilterColumn(key)) {
|
||||
throw new BadRequestException('Invalid filter column');
|
||||
}
|
||||
const filterSql = this.createFilterSql(key, entry);
|
||||
if (filterSql) {
|
||||
conditions.push(filterSql);
|
||||
}
|
||||
}
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return sql.join(conditions, sql` AND `);
|
||||
}
|
||||
|
||||
private isAllowedFilterColumn(column: string): boolean {
|
||||
if (this.config.columnConfigs.some((c) => c.column === column)) {
|
||||
return true;
|
||||
}
|
||||
if (this.config.customQueryColumn?.(column)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private createFilterSql(
|
||||
column: string,
|
||||
entry: FilterModelEntry,
|
||||
): SQL | undefined {
|
||||
const expr = this.resolveColumnExpression(column);
|
||||
const rawExpr = sql.raw(expr);
|
||||
const filter = entry.filter;
|
||||
|
||||
switch (entry.type) {
|
||||
case FILTER_TYPE.TEXT_EQUALS:
|
||||
return sql`${rawExpr} = ${String(filter)}`;
|
||||
case FILTER_TYPE.TEXT_NOT_EQUAL:
|
||||
return sql`${rawExpr} <> ${String(filter)}`;
|
||||
case FILTER_TYPE.TEXT_CONTAINS:
|
||||
return sql`${rawExpr} ILIKE ${`%${String(filter)}%`}`;
|
||||
case FILTER_TYPE.TEXT_NOT_CONTAINS:
|
||||
return sql`${rawExpr} NOT ILIKE ${`%${String(filter)}%`}`;
|
||||
case FILTER_TYPE.TEXT_MULTIPLE_CONTAINS:
|
||||
case FILTER_TYPE.TEXT_IN_MEMBER_TEXT: {
|
||||
const values = Array.isArray(filter) ? filter : [filter];
|
||||
const strings = values.map((v) => String(v));
|
||||
if (strings.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const patterns = strings.map((s) => sql`${`%${s}%`}`);
|
||||
return sql`${rawExpr} ILIKE ANY(ARRAY[${sql.join(patterns, sql`, `)}])`;
|
||||
}
|
||||
case FILTER_TYPE.NUMBER_EQUALS:
|
||||
return sql`${rawExpr} = ${Number(filter)}`;
|
||||
case FILTER_TYPE.NUMBER_NOT_EQUAL:
|
||||
return sql`${rawExpr} <> ${Number(filter)}`;
|
||||
case FILTER_TYPE.NUMBER_GREATER_THAN:
|
||||
return sql`${rawExpr} > ${Number(filter)}`;
|
||||
case FILTER_TYPE.NUMBER_LESS_THAN:
|
||||
return sql`${rawExpr} < ${Number(filter)}`;
|
||||
case FILTER_TYPE.NUMBER_IN_RANGE: {
|
||||
const range = filter as { from?: number; to?: number };
|
||||
if (range.from !== undefined && range.to !== undefined) {
|
||||
return sql`${rawExpr} BETWEEN ${range.from} AND ${range.to}`;
|
||||
}
|
||||
if (range.from !== undefined) {
|
||||
return sql`${rawExpr} >= ${range.from}`;
|
||||
}
|
||||
if (range.to !== undefined) {
|
||||
return sql`${rawExpr} <= ${range.to}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
case FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH: {
|
||||
const range = filter as { from?: number; to?: number };
|
||||
if (range.from !== undefined && range.to !== undefined) {
|
||||
return sql`${rawExpr} BETWEEN ${range.from} AND ${range.to}`;
|
||||
}
|
||||
if (range.from !== undefined) {
|
||||
return sql`${rawExpr} >= ${range.from}`;
|
||||
}
|
||||
if (range.to !== undefined) {
|
||||
return sql`${rawExpr} <= ${range.to}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
case FILTER_TYPE.TEXT_IN_DATE_RANGE_TIMESTAMP: {
|
||||
const range = filter as { from?: string; to?: string };
|
||||
if (range.from !== undefined && range.to !== undefined) {
|
||||
return sql`${rawExpr} BETWEEN ${range.from} AND ${range.to}`;
|
||||
}
|
||||
if (range.from !== undefined) {
|
||||
return sql`${rawExpr} >= ${range.from}`;
|
||||
}
|
||||
if (range.to !== undefined) {
|
||||
return sql`${rawExpr} <= ${range.to}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private buildGroupBySql(): SQL | undefined {
|
||||
if (!this.isDoingGrouping()) {
|
||||
return undefined;
|
||||
}
|
||||
const groupField = this.currentGroupColumn();
|
||||
if (!groupField) {
|
||||
return undefined;
|
||||
}
|
||||
const expr = this.resolveColumnExpression(groupField);
|
||||
return sql`GROUP BY ${sql.raw(expr)}`;
|
||||
}
|
||||
|
||||
private buildOrderBySql(): SQL | undefined {
|
||||
const alias = this.mainAlias();
|
||||
const orders: SQL[] = [];
|
||||
|
||||
if (this.isDoingGrouping()) {
|
||||
const groupField = this.currentGroupColumn();
|
||||
if (groupField) {
|
||||
const expr = this.resolveColumnExpression(groupField);
|
||||
orders.push(sql`${sql.raw(expr)} ASC`);
|
||||
}
|
||||
} else {
|
||||
const sortModel = this.queryModel.sortModel;
|
||||
if (sortModel.length > 0) {
|
||||
for (const sort of sortModel) {
|
||||
const expr = this.resolveColumnExpression(sort.colId);
|
||||
const direction = sort.sort.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
orders.push(sql`${sql.raw(expr)} ${sql.raw(direction)}`);
|
||||
}
|
||||
} else {
|
||||
const defaultOrder = this.config.defaultOrderBy ?? [
|
||||
`${alias}.created_at DESC`,
|
||||
];
|
||||
for (const clause of defaultOrder) {
|
||||
orders.push(sql.raw(clause));
|
||||
}
|
||||
}
|
||||
const lowLevel = this.config.lowLevelOrderBy ?? [`${alias}.id DESC`];
|
||||
for (const clause of lowLevel) {
|
||||
orders.push(sql.raw(clause));
|
||||
}
|
||||
}
|
||||
|
||||
if (orders.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return sql`ORDER BY ${sql.join(orders, sql.raw(', '))}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { SQL } from 'drizzle-orm';
|
||||
|
||||
export function flattenSql(sqlQuery: SQL): string {
|
||||
const parts: string[] = [];
|
||||
for (const chunk of sqlQuery.queryChunks) {
|
||||
if (typeof chunk === 'string') {
|
||||
parts.push(chunk);
|
||||
continue;
|
||||
}
|
||||
if (chunk && typeof chunk === 'object' && 'queryChunks' in chunk) {
|
||||
parts.push(flattenSql(chunk as SQL));
|
||||
continue;
|
||||
}
|
||||
if (chunk && typeof chunk === 'object' && 'value' in chunk) {
|
||||
parts.push(String((chunk as { value: unknown }).value));
|
||||
}
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
@@ -230,6 +231,12 @@ export class ListSalesInvoicesQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === true || value === 'true')
|
||||
@IsBoolean()
|
||||
payable?: boolean;
|
||||
}
|
||||
|
||||
export class SalesInvoiceDto {
|
||||
|
||||
@@ -94,6 +94,7 @@ export type ListSalesInvoicesFilters = {
|
||||
readonly salesOrderId?: string;
|
||||
readonly packingSlipId?: string;
|
||||
readonly search?: string;
|
||||
readonly payable?: boolean;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly limit: number;
|
||||
|
||||
@@ -4,7 +4,18 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, asc, count, eq, 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,
|
||||
@@ -31,7 +42,10 @@ import {
|
||||
} from '../../../database/sales-payments-table';
|
||||
import { DocumentCodeService } from '../shared/document-code.service';
|
||||
import { DOCUMENT_PREFIXES } from '../shared/document-prefixes';
|
||||
import { SALES_INVOICE_STATUSES } from '../shared/sales-fields';
|
||||
import {
|
||||
PAYABLE_INVOICE_STATUSES,
|
||||
SALES_INVOICE_STATUSES,
|
||||
} from '../shared/sales-fields';
|
||||
import type {
|
||||
CreateSalesInvoiceInput,
|
||||
ListSalesInvoicesFilters,
|
||||
@@ -393,6 +407,10 @@ export class SalesInvoicesRepository {
|
||||
parts.push(search);
|
||||
}
|
||||
}
|
||||
if (filters.payable) {
|
||||
parts.push(inArray(salesInvoices.status, [...PAYABLE_INVOICE_STATUSES]));
|
||||
parts.push(gt(salesInvoices.balance, '0'));
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -191,6 +191,36 @@ describe('SalesInvoicesService', () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('updateStatus allows draft to processed even when sourced from a sales order', async () => {
|
||||
repository.findById.mockResolvedValue({
|
||||
...sample,
|
||||
salesOrderId: 'so-1',
|
||||
});
|
||||
repository.updateStatus.mockResolvedValue({
|
||||
...sample,
|
||||
salesOrderId: 'so-1',
|
||||
status: Status.create('processed', SALES_INVOICE_STATUSES),
|
||||
});
|
||||
const result = await service.updateStatus('si-1', 'processed', 'user-1');
|
||||
expect(repository.updateStatus).toHaveBeenCalledWith(
|
||||
'si-1',
|
||||
expect.objectContaining({ value: 'processed' }),
|
||||
'user-1',
|
||||
);
|
||||
expect(result.status).toBe('processed');
|
||||
});
|
||||
|
||||
it('updateStatus rejects processed to completed as a user transition', async () => {
|
||||
repository.findById.mockResolvedValue({
|
||||
...sample,
|
||||
status: Status.create('processed', SALES_INVOICE_STATUSES),
|
||||
});
|
||||
await expect(
|
||||
service.updateStatus('si-1', 'completed', 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repository.updateStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applyPaymentEffects sets partial when underpaid', async () => {
|
||||
repository.findById.mockResolvedValue(sample);
|
||||
repository.computeTotals.mockResolvedValue({
|
||||
|
||||
@@ -57,6 +57,7 @@ export type ListSalesInvoicesQuery = {
|
||||
readonly salesOrderId?: string;
|
||||
readonly packingSlipId?: string;
|
||||
readonly search?: string;
|
||||
readonly payable?: boolean;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly page?: number;
|
||||
@@ -103,6 +104,7 @@ export class SalesInvoicesService {
|
||||
salesOrderId: query.salesOrderId,
|
||||
packingSlipId: query.packingSlipId,
|
||||
search: query.search,
|
||||
payable: query.payable,
|
||||
orderBy: query.orderBy,
|
||||
orderType: query.orderType,
|
||||
limit: page.limit,
|
||||
@@ -215,7 +217,6 @@ export class SalesInvoicesService {
|
||||
}
|
||||
const status = this.assertStatus(statusRaw);
|
||||
if (
|
||||
current.salesOrderId ||
|
||||
!isAllowedStatusTransition(
|
||||
current.status.value,
|
||||
status.value,
|
||||
|
||||
@@ -67,7 +67,11 @@ describe('SalesPaymentsService', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
salesInvoicesService.findById.mockResolvedValue({ id: 'si-1' });
|
||||
salesInvoicesService.findById.mockResolvedValue({
|
||||
id: 'si-1',
|
||||
status: 'processed',
|
||||
balance: '25000.0000',
|
||||
});
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SalesPaymentsService,
|
||||
@@ -92,6 +96,38 @@ describe('SalesPaymentsService', () => {
|
||||
expect(arg.invoices[0]?.amount.value).toBe('10000.0000');
|
||||
});
|
||||
|
||||
it('create rejects a draft invoice allocation', async () => {
|
||||
salesInvoicesService.findById.mockResolvedValue({
|
||||
id: 'si-1',
|
||||
status: 'draft',
|
||||
balance: '25000.0000',
|
||||
});
|
||||
await expect(
|
||||
service.create({
|
||||
date: '2026-08-24T10:00:00+07:00',
|
||||
invoices: [{ invoiceId: 'si-1', amount: '10000' }],
|
||||
userId: 'user-1',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('create rejects an invoice with no remaining balance', async () => {
|
||||
salesInvoicesService.findById.mockResolvedValue({
|
||||
id: 'si-1',
|
||||
status: 'processed',
|
||||
balance: '0.0000',
|
||||
});
|
||||
await expect(
|
||||
service.create({
|
||||
date: '2026-08-24T10:00:00+07:00',
|
||||
invoices: [{ invoiceId: 'si-1', amount: '10000' }],
|
||||
userId: 'user-1',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update rejects status on PATCH', async () => {
|
||||
await expect(
|
||||
service.update('sp-1', { status: 'approved', userId: 'user-1' }),
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
isValidImageUrl,
|
||||
isAllowedStatusTransition,
|
||||
parseCsvRecord,
|
||||
PAYABLE_INVOICE_STATUSES,
|
||||
SALES_PAYMENT_STATUSES,
|
||||
SALES_PAYMENT_USER_TRANSITIONS,
|
||||
} from '../shared/sales-fields';
|
||||
@@ -341,7 +342,8 @@ export class SalesPaymentsService {
|
||||
}
|
||||
const result: SalesPaymentAllocationInput[] = [];
|
||||
for (const line of lines) {
|
||||
await this.salesInvoicesService.findById(line.invoiceId);
|
||||
const invoice = await this.salesInvoicesService.findById(line.invoiceId);
|
||||
this.assertInvoicePayable(invoice);
|
||||
const amount = this.parseDecimal(line.amount);
|
||||
if (!amount.isPositive()) {
|
||||
throw new BadRequestException('Invalid amount');
|
||||
@@ -351,6 +353,28 @@ export class SalesPaymentsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private assertInvoicePayable(invoice: {
|
||||
status?: string;
|
||||
balance?: string;
|
||||
}): void {
|
||||
if (
|
||||
!invoice.status ||
|
||||
!(PAYABLE_INVOICE_STATUSES as readonly string[]).includes(invoice.status)
|
||||
) {
|
||||
throw new BadRequestException('Invoice is not payable');
|
||||
}
|
||||
try {
|
||||
if (!Decimal.create(invoice.balance ?? '0').isPositive()) {
|
||||
throw new BadRequestException('Invoice is not payable');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidDecimalError) {
|
||||
throw new BadRequestException('Invoice is not payable');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private assertImage(image: SalesImageBody): SalesPaymentImageInput {
|
||||
if (!isValidImageUrl(image.url)) {
|
||||
throw new BadRequestException('Invalid image URL');
|
||||
|
||||
@@ -77,14 +77,14 @@ describe('sales fields', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('limits invoice user transitions to cancelled', () => {
|
||||
it('allows invoice user transitions from draft to processed or cancelled', () => {
|
||||
expect(
|
||||
isAllowedStatusTransition(
|
||||
'draft',
|
||||
'processed',
|
||||
SALES_INVOICE_USER_TRANSITIONS,
|
||||
),
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAllowedStatusTransition(
|
||||
'draft',
|
||||
@@ -92,6 +92,13 @@ describe('sales fields', () => {
|
||||
SALES_INVOICE_USER_TRANSITIONS,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAllowedStatusTransition(
|
||||
'processed',
|
||||
'completed',
|
||||
SALES_INVOICE_USER_TRANSITIONS,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('allows payment pending to approved, rejected, or draft', () => {
|
||||
|
||||
@@ -69,13 +69,15 @@ export const PACKING_SLIP_USER_TRANSITIONS: Record<string, readonly string[]> =
|
||||
|
||||
export const SALES_INVOICE_USER_TRANSITIONS: Record<string, readonly string[]> =
|
||||
{
|
||||
draft: ['cancelled'],
|
||||
draft: ['processed', 'cancelled'],
|
||||
processed: ['cancelled'],
|
||||
partial: ['cancelled'],
|
||||
completed: ['cancelled'],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
export const PAYABLE_INVOICE_STATUSES = ['processed', 'partial'] as const;
|
||||
|
||||
export const SALES_PAYMENT_USER_TRANSITIONS: Record<string, readonly string[]> =
|
||||
{
|
||||
draft: ['pending'],
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { configureApp } from '../src/common/configure-app';
|
||||
import { DRIZZLE, type DrizzleDB } from '../src/database/database.module';
|
||||
import {
|
||||
privilegeDetails,
|
||||
privilegeKeys,
|
||||
privileges,
|
||||
users,
|
||||
} from '../src/database/schema';
|
||||
import { REPORT_BOOKMARK_TYPE } from '../src/modules/reports/shared/constants/bookmark-type';
|
||||
import { REPORT_GROUP } from '../src/modules/reports/shared/constants/report-group';
|
||||
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
|
||||
import { registerAndActivate } from './helpers/activate-user';
|
||||
|
||||
describe('Report bookmarks (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let db: DrizzleDB;
|
||||
|
||||
const password = 'password123';
|
||||
const adminUsername = `bookmark_admin_${Date.now()}`;
|
||||
|
||||
let adminAccessToken: string;
|
||||
let adminUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
configureApp(app, {
|
||||
NODE_ENV: 'test',
|
||||
SWAGGER_ENABLED: 'false',
|
||||
});
|
||||
await app.init();
|
||||
db = app.get(DRIZZLE);
|
||||
|
||||
const admin = await registerAndActivate(app, db, adminUsername, password);
|
||||
adminAccessToken = admin.accessToken;
|
||||
adminUserId = admin.userId;
|
||||
|
||||
const now = Date.now();
|
||||
const [priv] = await db
|
||||
.insert(privileges)
|
||||
.values({
|
||||
name: 'Bookmark Admin',
|
||||
code: `BOOKMARK_ADMIN_${now}`,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: adminUserId,
|
||||
updatedBy: adminUserId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const keys = await db.select().from(privilegeKeys);
|
||||
const detailRows = keys.flatMap((key) =>
|
||||
PRIVILEGE_ACTIONS.map((action) => ({
|
||||
privilegeId: priv.id,
|
||||
privilegeKeyId: key.id,
|
||||
action,
|
||||
value: true,
|
||||
})),
|
||||
);
|
||||
await db.insert(privilegeDetails).values(detailRows);
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ privilegeId: priv.id, updatedAt: Date.now() })
|
||||
.where(eq(users.id, adminUserId));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('creates, applies, and unapplies a filter bookmark', async () => {
|
||||
const uniqueName = 'sales_report__sales_order';
|
||||
const create = await request(app.getHttpServer())
|
||||
.post('/report-bookmarks')
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.send({
|
||||
groupName: REPORT_GROUP.SALES_REPORT,
|
||||
uniqueName,
|
||||
label: 'My filter',
|
||||
type: REPORT_BOOKMARK_TYPE.FILTER_TABLE,
|
||||
applied: true,
|
||||
configuration: { main__status: 'active' },
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
const bookmarkId = create.body.id as string;
|
||||
|
||||
const second = await request(app.getHttpServer())
|
||||
.post('/report-bookmarks')
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.send({
|
||||
groupName: REPORT_GROUP.SALES_REPORT,
|
||||
uniqueName,
|
||||
label: 'Second filter',
|
||||
type: REPORT_BOOKMARK_TYPE.FILTER_TABLE,
|
||||
applied: true,
|
||||
configuration: { main__status: 'draft' },
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.put(`/report-bookmarks/applied/${second.body.id}`)
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const applied = await request(app.getHttpServer())
|
||||
.get('/report-bookmarks/applied')
|
||||
.query({
|
||||
groupName: REPORT_GROUP.SALES_REPORT,
|
||||
uniqueName,
|
||||
type: REPORT_BOOKMARK_TYPE.FILTER_TABLE,
|
||||
})
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(applied.body.id).toBe(second.body.id);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.put(`/report-bookmarks/unapplied/${bookmarkId}`)
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.expect(200);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/report-bookmarks/${bookmarkId}`)
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.expect(204);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { configureApp } from '../src/common/configure-app';
|
||||
import { DRIZZLE, type DrizzleDB } from '../src/database/database.module';
|
||||
import {
|
||||
privilegeDetails,
|
||||
privilegeKeys,
|
||||
privileges,
|
||||
users,
|
||||
} from '../src/database/schema';
|
||||
import { REPORT_GROUP } from '../src/modules/reports/shared/constants/report-group';
|
||||
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
|
||||
import { registerAndActivate } from './helpers/activate-user';
|
||||
|
||||
describe('Reports (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let db: DrizzleDB;
|
||||
|
||||
const password = 'password123';
|
||||
const adminUsername = `report_admin_${Date.now()}`;
|
||||
const otherUsername = `report_other_${Date.now()}`;
|
||||
|
||||
let adminAccessToken: string;
|
||||
let adminUserId: string;
|
||||
let otherAccessToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
configureApp(app, {
|
||||
NODE_ENV: 'test',
|
||||
SWAGGER_ENABLED: 'false',
|
||||
});
|
||||
await app.init();
|
||||
db = app.get(DRIZZLE);
|
||||
|
||||
const admin = await registerAndActivate(app, db, adminUsername, password);
|
||||
adminAccessToken = admin.accessToken;
|
||||
adminUserId = admin.userId;
|
||||
|
||||
const other = await registerAndActivate(app, db, otherUsername, password);
|
||||
otherAccessToken = other.accessToken;
|
||||
|
||||
const now = Date.now();
|
||||
const [priv] = await db
|
||||
.insert(privileges)
|
||||
.values({
|
||||
name: 'Report Admin',
|
||||
code: `REPORT_ADMIN_${now}`,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: adminUserId,
|
||||
updatedBy: adminUserId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const keys = await db.select().from(privilegeKeys);
|
||||
const detailRows = keys.flatMap((key) =>
|
||||
PRIVILEGE_ACTIONS.map((action) => ({
|
||||
privilegeId: priv.id,
|
||||
privilegeKeyId: key.id,
|
||||
action,
|
||||
value: true,
|
||||
})),
|
||||
);
|
||||
await db.insert(privilegeDetails).values(detailRows);
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ privilegeId: priv.id, updatedAt: Date.now() })
|
||||
.where(eq(users.id, adminUserId));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const baseQueryModel = {
|
||||
startRow: 0,
|
||||
endRow: 100,
|
||||
rowGroupCols: [],
|
||||
valueCols: [],
|
||||
pivotCols: [],
|
||||
pivotMode: false,
|
||||
groupKeys: [],
|
||||
filterModel: {},
|
||||
sortModel: [],
|
||||
};
|
||||
|
||||
it('forbids report config without permission', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/reports/config')
|
||||
.query({ groupNames: REPORT_GROUP.SALES_REPORT })
|
||||
.set('Authorization', `Bearer ${otherAccessToken}`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('lists sales report configs', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/reports/config')
|
||||
.query({ groupNames: REPORT_GROUP.SALES_REPORT })
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
expect(res.body.length).toBeGreaterThan(0);
|
||||
expect(res.body[0].groupName).toBe(REPORT_GROUP.SALES_REPORT);
|
||||
});
|
||||
|
||||
it('runs sales order report meta and data', async () => {
|
||||
const uniqueName = 'sales_report__sales_order';
|
||||
const body = {
|
||||
groupName: REPORT_GROUP.SALES_REPORT,
|
||||
uniqueName,
|
||||
queryModel: baseQueryModel,
|
||||
};
|
||||
|
||||
const meta = await request(app.getHttpServer())
|
||||
.post('/reports/meta')
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.send(body)
|
||||
.expect(200);
|
||||
|
||||
expect(meta.body.totalRow).toBeDefined();
|
||||
|
||||
const data = await request(app.getHttpServer())
|
||||
.post('/reports/data')
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.send(body)
|
||||
.expect(200);
|
||||
|
||||
expect(Array.isArray(data.body)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -181,6 +181,12 @@ describe('Sales invoices (e2e)', () => {
|
||||
.send({ status: 'processed' })
|
||||
.expect(400);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/sales-invoices/${id}/status`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ status: 'processed' })
|
||||
.expect(200);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/sales-invoices/${id}`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
|
||||
@@ -147,6 +147,11 @@ describe('Sales payments (e2e)', () => {
|
||||
})
|
||||
.expect(201);
|
||||
invoiceId = (invoice.body as { id: string }).id;
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/sales-invoices/${invoiceId}/status`)
|
||||
.set(auth)
|
||||
.send({ status: 'processed' })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -182,6 +187,12 @@ describe('Sales payments (e2e)', () => {
|
||||
.send({ status: 'approved' })
|
||||
.expect(400);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/sales-payments/${id}/status`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ status: 'pending' })
|
||||
.expect(200);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/sales-payments/${id}/status`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
|
||||
Reference in New Issue
Block a user