implement rule engine module with dynamic booking evaluation, 7 entities, and Postman endpoints

This commit is contained in:
marshal
2026-05-29 10:00:45 +03:00
parent 3b53042cf8
commit 800f036005
72 changed files with 2544 additions and 838 deletions

View File

@@ -0,0 +1,58 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
import { CargoTypesService } from '../services/cargo-types.service';
@ApiTags('cargo-types')
@Controller('cargo-types')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class CargoTypesController {
constructor(private readonly service: CargoTypesService) {}
@Get()
@ApiOperation({ summary: 'List cargo types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
requiresDirectorApproval: query['requiresDirectorApproval'] !== undefined
? query['requiresDirectorApproval'] === 'true'
: undefined,
parentGroupId: query['parentGroupId'],
search: query['search'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
sortBy: query['sortBy'],
sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC',
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a cargo type by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create a cargo type' })
create(@Body() dto: CreateCargoTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a cargo type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a cargo type' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,51 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
import { ContainerTypesService } from '../services/container-types.service';
@ApiTags('container-types')
@Controller('container-types')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class ContainerTypesController {
constructor(private readonly service: ContainerTypesService) {}
@Get()
@ApiOperation({ summary: 'List container types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a container type by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create a container type' })
create(@Body() dto: CreateContainerTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a container type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a container type' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,51 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto';
import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto';
import { PriorityRulesService } from '../services/priority-rules.service';
@ApiTags('priority-rules')
@Controller('priority-rules')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class PriorityRulesController {
constructor(private readonly service: PriorityRulesService) {}
@Get()
@ApiOperation({ summary: 'List priority rules' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a priority rule by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create a priority rule' })
create(@Body() dto: CreatePriorityRuleDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a priority rule' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityRuleDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a priority rule' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,55 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
import { ServiceTypesService } from '../services/service-types.service';
@ApiTags('service-types')
@Controller('service-types')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class ServiceTypesController {
constructor(private readonly service: ServiceTypesService) {}
@Get()
@ApiOperation({ summary: 'List service types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
canBeBookedAlone: query['canBeBookedAlone'] !== undefined ? query['canBeBookedAlone'] === 'true' : undefined,
search: query['search'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
sortBy: query['sortBy'],
sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC',
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a service type by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create a service type' })
create(@Body() dto: CreateServiceTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a service type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateServiceTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a service type' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,51 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto';
import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto';
import { SurchargeTypesService } from '../services/surcharge-types.service';
@ApiTags('surcharge-types')
@Controller('surcharge-types')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class SurchargeTypesController {
constructor(private readonly service: SurchargeTypesService) {}
@Get()
@ApiOperation({ summary: 'List surcharge types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a surcharge type by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create a surcharge type' })
create(@Body() dto: CreateSurchargeTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a surcharge type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a surcharge type' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,52 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateSurchargeDto } from '../dto/create-surcharge.dto';
import { UpdateSurchargeDto } from '../dto/update-surcharge.dto';
import { SurchargesService } from '../services/surcharges.service';
@ApiTags('surcharges')
@Controller('surcharges')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class SurchargesController {
constructor(private readonly service: SurchargesService) {}
@Get()
@ApiOperation({ summary: 'List surcharges' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
surchargeTypeId: query['surchargeTypeId'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a surcharge by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create a surcharge' })
create(@Body() dto: CreateSurchargeDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a surcharge' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a surcharge' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,52 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
import { WeightLimitRulesService } from '../services/weight-limit-rules.service';
@ApiTags('weight-limit-rules')
@Controller('weight-limit-rules')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class WeightLimitRulesController {
constructor(private readonly service: WeightLimitRulesService) {}
@Get()
@ApiOperation({ summary: 'List weight limit rules' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
containerTypeId: query['containerTypeId'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a weight limit rule by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create a weight limit rule' })
create(@Body() dto: CreateWeightLimitRuleDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a weight limit rule' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWeightLimitRuleDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a weight limit rule' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,40 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateCargoTypeDto {
@ApiProperty({ description: 'Machine-readable code, e.g. BULK, BREAK_BULK', maxLength: 50 })
@IsString()
@MaxLength(50)
code!: string;
@ApiProperty({ description: 'Cargo type display name', maxLength: 255 })
@IsString()
@MaxLength(255)
cargoTypeName!: string;
@ApiPropertyOptional({ description: 'Parent group ID for hierarchical cargo types' })
@IsOptional()
@IsUUID()
parentGroupId?: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
showFreeTextBox?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
requiresDirectorApproval?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@IsInt()
@Min(1)
displayOrder?: number;
}

View File

@@ -0,0 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class CreateContainerTypeDto {
@ApiProperty({ description: 'Size code, e.g. 20FT or 40FT', maxLength: 20 })
@IsString()
@MaxLength(20)
sizeCode!: string;
@ApiPropertyOptional({ description: 'Human-readable description', maxLength: 100 })
@IsOptional()
@IsString()
@MaxLength(100)
description?: string;
@ApiProperty({ description: 'Number of containers that fit per rail wagon (2 for 20FT, 1 for 40FT)' })
@IsInt()
@Min(1)
containersPerWagon!: number;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,34 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
import { Freight } from '@edr/types';
export class CreatePriorityRuleDto {
@ApiProperty({ enum: Freight.PriorityType, description: 'Priority type (unique per rule)' })
@IsEnum(Freight.PriorityType)
priorityType!: Freight.PriorityType;
@ApiProperty({ description: 'Human-readable rule name', maxLength: 255 })
@IsString()
@MaxLength(255)
ruleName!: string;
@ApiPropertyOptional({ description: 'Explanation of when this rule is triggered' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'Technical expression describing the activation condition' })
@IsOptional()
@IsString()
activationCondition?: string;
@ApiProperty({ description: 'Points added to booking.priorityScore when this rule matches', default: 0 })
@IsInt()
@Min(0)
bonusPoints!: number;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,56 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class CreateServiceTypeDto {
@ApiProperty({ description: 'Machine-readable code, e.g. RAIL_ONLY', maxLength: 50 })
@IsString()
@MaxLength(50)
code!: string;
@ApiProperty({ description: 'Service type display name', maxLength: 255 })
@IsString()
@MaxLength(255)
serviceName!: string;
@ApiPropertyOptional({ description: 'Detailed description' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
canBeBookedAlone?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
includesFirstMile?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
includesLastMile?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
includesCustoms?: boolean;
@ApiPropertyOptional({ description: 'Priority bonus points awarded when this service is used', default: 0 })
@IsOptional()
@IsInt()
@Min(0)
priorityBonusPoints?: number;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@IsInt()
@Min(1)
displayOrder?: number;
}

View File

@@ -0,0 +1,24 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
export class CreateSurchargeTypeDto {
@ApiProperty({ description: 'Unique code, e.g. HAZARDOUS, REFRIGERATED', maxLength: 50 })
@IsString()
@MaxLength(50)
code!: string;
@ApiProperty({ description: 'Display name', maxLength: 100 })
@IsString()
@MaxLength(100)
name!: string;
@ApiPropertyOptional({ description: 'Description of when this surcharge type is triggered' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,63 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsEnum,
IsNumber,
IsOptional,
IsString,
IsUUID,
Length,
MaxLength,
Min,
} from 'class-validator';
import { Freight } from '@edr/types';
export class CreateSurchargeDto {
@ApiProperty({ description: 'FK to surcharge_types.id' })
@IsUUID()
surchargeTypeId!: string;
@ApiProperty({ description: 'Display name for this surcharge line item', maxLength: 255 })
@IsString()
@MaxLength(255)
feeName!: string;
@ApiPropertyOptional({ description: 'Human-readable description of when this surcharge is triggered' })
@IsOptional()
@IsString()
triggerDescription?: string;
@ApiProperty({ enum: Freight.CalculationMethod, default: Freight.CalculationMethod.PER_TON })
@IsEnum(Freight.CalculationMethod)
calculationMethod!: Freight.CalculationMethod;
@ApiProperty({ description: 'Rate amount (per ton, flat, or percentage)' })
@IsNumber()
@Min(0)
rate!: number;
@ApiProperty({ description: 'ISO 4217 currency code', default: 'USD' })
@IsString()
@Length(3, 3)
currency!: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
applyToRail?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
applyToFirstMile?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
applyToLastMile?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,38 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
import { Freight } from '@edr/types';
export class CreateWeightLimitRuleDto {
@ApiProperty({ description: 'FK to container_types.id' })
@IsUUID()
containerTypeId!: string;
@ApiProperty({ enum: Freight.TradeDirection, description: 'Trade direction this rule applies to' })
@IsEnum(Freight.TradeDirection)
tradeDirection!: Freight.TradeDirection;
@ApiProperty({ description: 'Maximum allowed weight in tons before surcharge is applied' })
@IsNumber()
@Min(0)
maxWeightTons!: number;
@ApiProperty({ description: 'Weight at which a warning is issued (must be ≤ maxWeightTons)' })
@IsNumber()
@Min(0)
warningThresholdTons!: number;
@ApiPropertyOptional({ enum: Freight.ExceededAction, default: Freight.ExceededAction.WARNING_ONLY })
@IsOptional()
@IsEnum(Freight.ExceededAction)
exceededAction?: Freight.ExceededAction;
@ApiPropertyOptional({ description: 'FK to surcharges.id — surcharge billed when max is exceeded' })
@IsOptional()
@IsUUID()
surchargeId?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateCargoTypeDto } from './create-cargo-type.dto';
export class UpdateCargoTypeDto extends PartialType(CreateCargoTypeDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateContainerTypeDto } from './create-container-type.dto';
export class UpdateContainerTypeDto extends PartialType(CreateContainerTypeDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreatePriorityRuleDto } from './create-priority-rule.dto';
export class UpdatePriorityRuleDto extends PartialType(CreatePriorityRuleDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateServiceTypeDto } from './create-service-type.dto';
export class UpdateServiceTypeDto extends PartialType(CreateServiceTypeDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateSurchargeTypeDto } from './create-surcharge-type.dto';
export class UpdateSurchargeTypeDto extends PartialType(CreateSurchargeTypeDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateSurchargeDto } from './create-surcharge.dto';
export class UpdateSurchargeDto extends PartialType(CreateSurchargeDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateWeightLimitRuleDto } from './create-weight-limit-rule.dto';
export class UpdateWeightLimitRuleDto extends PartialType(CreateWeightLimitRuleDto) {}

View File

@@ -0,0 +1,37 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
@Entity({ schema: 'freight', name: 'cargo_types' })
@Index(['isActive'])
@Index(['displayOrder'])
@Index(['parentGroupId'])
@Index(['code'])
export class CargoType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' })
code!: string;
@Column({ name: 'cargo_type_name', type: 'varchar', length: 255 })
cargoTypeName!: string;
@Column({ name: 'parent_group_id', type: 'uuid', nullable: true })
parentGroupId?: string | null;
@Column({ name: 'show_free_text_box', type: 'boolean', default: false })
showFreeTextBox!: boolean;
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
requiresDirectorApproval!: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@Column({ name: 'display_order', type: 'int', default: 1 })
displayOrder!: number;
@ManyToOne(() => CargoType, (ct) => ct.children, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'parent_group_id' })
parent?: CargoType | null;
@OneToMany(() => CargoType, (ct) => ct.parent)
children?: CargoType[];
}

View File

@@ -0,0 +1,23 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { WeightLimitRule } from './weight-limit-rule.entity';
@Entity({ schema: 'freight', name: 'container_types' })
@Index(['sizeCode'])
@Index(['isActive'])
export class ContainerType extends BaseEntity {
@Column({ name: 'size_code', type: 'varchar', length: 20, unique: true })
sizeCode!: string;
@Column({ name: 'description', type: 'varchar', length: 100, nullable: true })
description?: string | null;
@Column({ name: 'containers_per_wagon', type: 'int' })
containersPerWagon!: number;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@OneToMany(() => WeightLimitRule, (rule) => rule.containerType)
weightLimitRules?: WeightLimitRule[];
}

View File

@@ -0,0 +1,26 @@
import { BaseEntity } from '@edr/api-common';
import { Freight } from '@edr/types';
import { Column, Entity, Index } from 'typeorm';
@Entity({ schema: 'freight', name: 'priority_rules' })
@Index(['priorityType'])
@Index(['isActive'])
export class PriorityRule extends BaseEntity {
@Column({ name: 'priority_type', type: 'enum', enum: Freight.PriorityType, unique: true })
priorityType!: Freight.PriorityType;
@Column({ name: 'rule_name', type: 'varchar', length: 255 })
ruleName!: string;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
@Column({ name: 'activation_condition', type: 'text', nullable: true })
activationCondition?: string | null;
@Column({ name: 'bonus_points', type: 'int', default: 0 })
bonusPoints!: number;
@Column({ name: 'is_active', type: 'boolean', default: false })
isActive!: boolean;
}

View File

@@ -0,0 +1,38 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
@Entity({ schema: 'freight', name: 'service_types' })
@Index(['isActive'])
@Index(['displayOrder'])
@Index(['code'])
export class ServiceType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' })
code!: string;
@Column({ name: 'service_name', type: 'varchar', length: 255 })
serviceName!: string;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
@Column({ name: 'can_be_booked_alone', type: 'boolean', default: true })
canBeBookedAlone!: boolean;
@Column({ name: 'includes_first_mile', type: 'boolean', default: false })
includesFirstMile!: boolean;
@Column({ name: 'includes_last_mile', type: 'boolean', default: false })
includesLastMile!: boolean;
@Column({ name: 'includes_customs', type: 'boolean', default: false })
includesCustoms!: boolean;
@Column({ name: 'priority_bonus_points', type: 'int', default: 0 })
priorityBonusPoints!: number;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@Column({ name: 'display_order', type: 'int', default: 1 })
displayOrder!: number;
}

View File

@@ -0,0 +1,23 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { Surcharge } from './surcharge.entity';
@Entity({ schema: 'freight', name: 'surcharge_types' })
@Index(['code'])
@Index(['isActive'])
export class SurchargeType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 50, unique: true })
code!: string;
@Column({ name: 'name', type: 'varchar', length: 100 })
name!: string;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@OneToMany(() => Surcharge, (s) => s.surchargeType)
surcharges?: Surcharge[];
}

View File

@@ -0,0 +1,52 @@
import { BaseEntity } from '@edr/api-common';
import { Freight } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { SurchargeType } from './surcharge-type.entity';
import { WeightLimitRule } from './weight-limit-rule.entity';
@Entity({ schema: 'freight', name: 'surcharges' })
@Index(['surchargeTypeId'])
@Index(['isActive'])
export class Surcharge extends BaseEntity {
@Column({ name: 'surcharge_type_id', type: 'uuid' })
surchargeTypeId!: string;
@ManyToOne(() => SurchargeType, (st) => st.surcharges)
@JoinColumn({ name: 'surcharge_type_id' })
surchargeType!: SurchargeType;
@Column({ name: 'fee_name', type: 'varchar', length: 255 })
feeName!: string;
@Column({ name: 'trigger_description', type: 'text', nullable: true })
triggerDescription?: string | null;
@Column({
name: 'calculation_method',
type: 'enum',
enum: Freight.CalculationMethod,
default: Freight.CalculationMethod.PER_TON,
})
calculationMethod!: Freight.CalculationMethod;
@Column({ name: 'rate', type: 'numeric', precision: 10, scale: 2 })
rate!: number;
@Column({ name: 'currency', type: 'char', length: 3, default: 'USD' })
currency!: string;
@Column({ name: 'apply_to_rail', type: 'boolean', default: false })
applyToRail!: boolean;
@Column({ name: 'apply_to_first_mile', type: 'boolean', default: false })
applyToFirstMile!: boolean;
@Column({ name: 'apply_to_last_mile', type: 'boolean', default: false })
applyToLastMile!: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@OneToMany(() => WeightLimitRule, (rule) => rule.surcharge)
weightLimitRules?: WeightLimitRule[];
}

View File

@@ -0,0 +1,45 @@
import { BaseEntity } from '@edr/api-common';
import { Freight } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { ContainerType } from './container-type.entity';
import { Surcharge } from './surcharge.entity';
@Entity({ schema: 'freight', name: 'weight_limit_rules' })
@Index(['containerTypeId'])
@Index(['surchargeId'])
@Index(['isActive'])
export class WeightLimitRule extends BaseEntity {
@Column({ name: 'container_type_id', type: 'uuid' })
containerTypeId!: string;
@ManyToOne(() => ContainerType, (ct) => ct.weightLimitRules)
@JoinColumn({ name: 'container_type_id' })
containerType!: ContainerType;
@Column({ name: 'trade_direction', type: 'enum', enum: Freight.TradeDirection })
tradeDirection!: Freight.TradeDirection;
@Column({ name: 'max_weight_tons', type: 'numeric', precision: 10, scale: 2 })
maxWeightTons!: number;
@Column({ name: 'warning_threshold_tons', type: 'numeric', precision: 10, scale: 2 })
warningThresholdTons!: number;
@Column({
name: 'exceeded_action',
type: 'enum',
enum: Freight.ExceededAction,
default: Freight.ExceededAction.WARNING_ONLY,
})
exceededAction!: Freight.ExceededAction;
@Column({ name: 'surcharge_id', type: 'uuid', nullable: true })
surchargeId?: string | null;
@ManyToOne(() => Surcharge, (s) => s.weightLimitRules, { nullable: true })
@JoinColumn({ name: 'surcharge_id' })
surcharge?: Surcharge | null;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -0,0 +1,14 @@
import { FindManyOptions } from 'typeorm';
import { CargoType } from '../entities/cargo-type.entity';
export interface ICargoTypesRepository {
findById(id: string): Promise<CargoType | null>;
findByCode(code: string): Promise<CargoType | null>;
findAll(options?: FindManyOptions<CargoType>): Promise<CargoType[]>;
findAndCount(options?: FindManyOptions<CargoType>): Promise<[CargoType[], number]>;
create(data: Partial<CargoType>): Promise<CargoType>;
update(id: string, data: Partial<CargoType>): Promise<CargoType | null>;
softDelete(id: string): Promise<void>;
}
export const CARGO_TYPES_REPOSITORY = Symbol('CARGO_TYPES_REPOSITORY');

View File

@@ -0,0 +1,14 @@
import { FindManyOptions } from 'typeorm';
import { ContainerType } from '../entities/container-type.entity';
export interface IContainerTypesRepository {
findById(id: string): Promise<ContainerType | null>;
findBySizeCode(sizeCode: string): Promise<ContainerType | null>;
findAll(options?: FindManyOptions<ContainerType>): Promise<ContainerType[]>;
findAndCount(options?: FindManyOptions<ContainerType>): Promise<[ContainerType[], number]>;
create(data: Partial<ContainerType>): Promise<ContainerType>;
update(id: string, data: Partial<ContainerType>): Promise<ContainerType | null>;
softDelete(id: string): Promise<void>;
}
export const CONTAINER_TYPES_REPOSITORY = Symbol('CONTAINER_TYPES_REPOSITORY');

View File

@@ -0,0 +1,14 @@
import { FindManyOptions } from 'typeorm';
import { PriorityRule } from '../entities/priority-rule.entity';
export interface IPriorityRulesRepository {
findById(id: string): Promise<PriorityRule | null>;
findAllActive(): Promise<PriorityRule[]>;
findAll(options?: FindManyOptions<PriorityRule>): Promise<PriorityRule[]>;
findAndCount(options?: FindManyOptions<PriorityRule>): Promise<[PriorityRule[], number]>;
create(data: Partial<PriorityRule>): Promise<PriorityRule>;
update(id: string, data: Partial<PriorityRule>): Promise<PriorityRule | null>;
softDelete(id: string): Promise<void>;
}
export const PRIORITY_RULES_REPOSITORY = Symbol('PRIORITY_RULES_REPOSITORY');

View File

@@ -0,0 +1,14 @@
import { FindManyOptions } from 'typeorm';
import { ServiceType } from '../entities/service-type.entity';
export interface IServiceTypesRepository {
findById(id: string): Promise<ServiceType | null>;
findByCode(code: string): Promise<ServiceType | null>;
findAll(options?: FindManyOptions<ServiceType>): Promise<ServiceType[]>;
findAndCount(options?: FindManyOptions<ServiceType>): Promise<[ServiceType[], number]>;
create(data: Partial<ServiceType>): Promise<ServiceType>;
update(id: string, data: Partial<ServiceType>): Promise<ServiceType | null>;
softDelete(id: string): Promise<void>;
}
export const SERVICE_TYPES_REPOSITORY = Symbol('SERVICE_TYPES_REPOSITORY');

View File

@@ -0,0 +1,14 @@
import { FindManyOptions } from 'typeorm';
import { SurchargeType } from '../entities/surcharge-type.entity';
export interface ISurchargeTypesRepository {
findById(id: string): Promise<SurchargeType | null>;
findByCode(code: string): Promise<SurchargeType | null>;
findAll(options?: FindManyOptions<SurchargeType>): Promise<SurchargeType[]>;
findAndCount(options?: FindManyOptions<SurchargeType>): Promise<[SurchargeType[], number]>;
create(data: Partial<SurchargeType>): Promise<SurchargeType>;
update(id: string, data: Partial<SurchargeType>): Promise<SurchargeType | null>;
softDelete(id: string): Promise<void>;
}
export const SURCHARGE_TYPES_REPOSITORY = Symbol('SURCHARGE_TYPES_REPOSITORY');

View File

@@ -0,0 +1,14 @@
import { FindManyOptions } from 'typeorm';
import { Surcharge } from '../entities/surcharge.entity';
export interface ISurchargesRepository {
findById(id: string): Promise<Surcharge | null>;
findByTypeCode(typeCode: string): Promise<Surcharge | null>;
findAll(options?: FindManyOptions<Surcharge>): Promise<Surcharge[]>;
findAndCount(options?: FindManyOptions<Surcharge>): Promise<[Surcharge[], number]>;
create(data: Partial<Surcharge>): Promise<Surcharge>;
update(id: string, data: Partial<Surcharge>): Promise<Surcharge | null>;
softDelete(id: string): Promise<void>;
}
export const SURCHARGES_REPOSITORY = Symbol('SURCHARGES_REPOSITORY');

View File

@@ -0,0 +1,17 @@
import { FindManyOptions } from 'typeorm';
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
export interface IWeightLimitRulesRepository {
findById(id: string): Promise<WeightLimitRule | null>;
findActiveByContainerTypeAndDirection(
sizeCode: string,
tradeDirection: string,
): Promise<WeightLimitRule[]>;
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]>;
findAndCount(options?: FindManyOptions<WeightLimitRule>): Promise<[WeightLimitRule[], number]>;
create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule>;
update(id: string, data: Partial<WeightLimitRule>): Promise<WeightLimitRule | null>;
softDelete(id: string): Promise<void>;
}
export const WEIGHT_LIMIT_RULES_REPOSITORY = Symbol('WEIGHT_LIMIT_RULES_REPOSITORY');

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { CargoType } from '../entities/cargo-type.entity';
import { ICargoTypesRepository } from '../interfaces/cargo-types.repository.interface';
@Injectable()
export class CargoTypesRepository implements ICargoTypesRepository {
private readonly repo: Repository<CargoType>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(CargoType);
}
findById(id: string): Promise<CargoType | null> {
return this.repo.findOne({ where: { id }, relations: { parent: true } });
}
findByCode(code: string): Promise<CargoType | null> {
return this.repo.findOne({ where: { code } });
}
findAll(options?: FindManyOptions<CargoType>): Promise<CargoType[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<CargoType>): Promise<[CargoType[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<CargoType>): Promise<CargoType> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<CargoType>): Promise<CargoType | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { ContainerType } from '../entities/container-type.entity';
import { IContainerTypesRepository } from '../interfaces/container-types.repository.interface';
@Injectable()
export class ContainerTypesRepository implements IContainerTypesRepository {
private readonly repo: Repository<ContainerType>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(ContainerType);
}
findById(id: string): Promise<ContainerType | null> {
return this.repo.findOne({ where: { id } });
}
findBySizeCode(sizeCode: string): Promise<ContainerType | null> {
return this.repo.findOne({ where: { sizeCode } });
}
findAll(options?: FindManyOptions<ContainerType>): Promise<ContainerType[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<ContainerType>): Promise<[ContainerType[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<ContainerType>): Promise<ContainerType> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<ContainerType>): Promise<ContainerType | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { PriorityRule } from '../entities/priority-rule.entity';
import { IPriorityRulesRepository } from '../interfaces/priority-rules.repository.interface';
@Injectable()
export class PriorityRulesRepository implements IPriorityRulesRepository {
private readonly repo: Repository<PriorityRule>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(PriorityRule);
}
findById(id: string): Promise<PriorityRule | null> {
return this.repo.findOne({ where: { id } });
}
findAllActive(): Promise<PriorityRule[]> {
return this.repo.find({ where: { isActive: true } });
}
findAll(options?: FindManyOptions<PriorityRule>): Promise<PriorityRule[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<PriorityRule>): Promise<[PriorityRule[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<PriorityRule>): Promise<PriorityRule> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<PriorityRule>): Promise<PriorityRule | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { ServiceType } from '../entities/service-type.entity';
import { IServiceTypesRepository } from '../interfaces/service-types.repository.interface';
@Injectable()
export class ServiceTypesRepository implements IServiceTypesRepository {
private readonly repo: Repository<ServiceType>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(ServiceType);
}
findById(id: string): Promise<ServiceType | null> {
return this.repo.findOne({ where: { id } });
}
findByCode(code: string): Promise<ServiceType | null> {
return this.repo.findOne({ where: { code } });
}
findAll(options?: FindManyOptions<ServiceType>): Promise<ServiceType[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<ServiceType>): Promise<[ServiceType[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<ServiceType>): Promise<ServiceType> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<ServiceType>): Promise<ServiceType | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { SurchargeType } from '../entities/surcharge-type.entity';
import { ISurchargeTypesRepository } from '../interfaces/surcharge-types.repository.interface';
@Injectable()
export class SurchargeTypesRepository implements ISurchargeTypesRepository {
private readonly repo: Repository<SurchargeType>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(SurchargeType);
}
findById(id: string): Promise<SurchargeType | null> {
return this.repo.findOne({ where: { id } });
}
findByCode(code: string): Promise<SurchargeType | null> {
return this.repo.findOne({ where: { code } });
}
findAll(options?: FindManyOptions<SurchargeType>): Promise<SurchargeType[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<SurchargeType>): Promise<[SurchargeType[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<SurchargeType>): Promise<SurchargeType> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<SurchargeType>): Promise<SurchargeType | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { Surcharge } from '../entities/surcharge.entity';
import { ISurchargesRepository } from '../interfaces/surcharges.repository.interface';
@Injectable()
export class SurchargesRepository implements ISurchargesRepository {
private readonly repo: Repository<Surcharge>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(Surcharge);
}
findById(id: string): Promise<Surcharge | null> {
return this.repo.findOne({ where: { id }, relations: { surchargeType: true } });
}
findByTypeCode(typeCode: string): Promise<Surcharge | null> {
return this.repo.findOne({
where: { isActive: true, surchargeType: { code: typeCode } },
relations: { surchargeType: true },
});
}
findAll(options?: FindManyOptions<Surcharge>): Promise<Surcharge[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<Surcharge>): Promise<[Surcharge[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<Surcharge>): Promise<Surcharge> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<Surcharge>): Promise<Surcharge | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
import { IWeightLimitRulesRepository } from '../interfaces/weight-limit-rules.repository.interface';
@Injectable()
export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
private readonly repo: Repository<WeightLimitRule>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(WeightLimitRule);
}
findById(id: string): Promise<WeightLimitRule | null> {
return this.repo.findOne({
where: { id },
relations: { containerType: true, surcharge: { surchargeType: true } },
});
}
findActiveByContainerTypeAndDirection(
sizeCode: string,
tradeDirection: string,
): Promise<WeightLimitRule[]> {
return this.repo
.createQueryBuilder('rule')
.innerJoinAndSelect('rule.containerType', 'ct')
.leftJoinAndSelect('rule.surcharge', 'surcharge')
.leftJoinAndSelect('surcharge.surchargeType', 'surchargeType')
.where('ct.size_code = :sizeCode', { sizeCode })
.andWhere('(rule.trade_direction = :dir OR rule.trade_direction = :both)', {
dir: tradeDirection,
both: 'BOTH',
})
.andWhere('rule.is_active = true')
.getMany();
}
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<WeightLimitRule>): Promise<[WeightLimitRule[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<WeightLimitRule>): Promise<WeightLimitRule | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -0,0 +1,106 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CargoType } from './entities/cargo-type.entity';
import { ContainerType } from './entities/container-type.entity';
import { PriorityRule } from './entities/priority-rule.entity';
import { Surcharge } from './entities/surcharge.entity';
import { SurchargeType } from './entities/surcharge-type.entity';
import { ServiceType } from './entities/service-type.entity';
import { WeightLimitRule } from './entities/weight-limit-rule.entity';
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface';
import { CONTAINER_TYPES_REPOSITORY } from './interfaces/container-types.repository.interface';
import { PRIORITY_RULES_REPOSITORY } from './interfaces/priority-rules.repository.interface';
import { SURCHARGES_REPOSITORY } from './interfaces/surcharges.repository.interface';
import { SURCHARGE_TYPES_REPOSITORY } from './interfaces/surcharge-types.repository.interface';
import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface';
import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface';
import { CargoTypesRepository } from './repositories/cargo-types.repository';
import { ContainerTypesRepository } from './repositories/container-types.repository';
import { PriorityRulesRepository } from './repositories/priority-rules.repository';
import { SurchargesRepository } from './repositories/surcharges.repository';
import { SurchargeTypesRepository } from './repositories/surcharge-types.repository';
import { ServiceTypesRepository } from './repositories/service-types.repository';
import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository';
import { CargoTypesService } from './services/cargo-types.service';
import { ContainerTypesService } from './services/container-types.service';
import { PriorityRulesService } from './services/priority-rules.service';
import { SurchargesService } from './services/surcharges.service';
import { SurchargeTypesService } from './services/surcharge-types.service';
import { ServiceTypesService } from './services/service-types.service';
import { WeightLimitRulesService } from './services/weight-limit-rules.service';
import { CargoTypesController } from './controllers/cargo-types.controller';
import { ContainerTypesController } from './controllers/container-types.controller';
import { PriorityRulesController } from './controllers/priority-rules.controller';
import { SurchargesController } from './controllers/surcharges.controller';
import { SurchargeTypesController } from './controllers/surcharge-types.controller';
import { ServiceTypesController } from './controllers/service-types.controller';
import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller';
import { RuleEngineService } from './rule-engine.service';
@Global()
@Module({
imports: [
TypeOrmModule.forFeature([
CargoType,
ContainerType,
PriorityRule,
Surcharge,
SurchargeType,
ServiceType,
WeightLimitRule,
]),
],
controllers: [
CargoTypesController,
ContainerTypesController,
PriorityRulesController,
SurchargesController,
SurchargeTypesController,
ServiceTypesController,
WeightLimitRulesController,
],
providers: [
// Repositories
CargoTypesRepository,
{ provide: CARGO_TYPES_REPOSITORY, useExisting: CargoTypesRepository },
ContainerTypesRepository,
{ provide: CONTAINER_TYPES_REPOSITORY, useExisting: ContainerTypesRepository },
PriorityRulesRepository,
{ provide: PRIORITY_RULES_REPOSITORY, useExisting: PriorityRulesRepository },
SurchargesRepository,
{ provide: SURCHARGES_REPOSITORY, useExisting: SurchargesRepository },
SurchargeTypesRepository,
{ provide: SURCHARGE_TYPES_REPOSITORY, useExisting: SurchargeTypesRepository },
ServiceTypesRepository,
{ provide: SERVICE_TYPES_REPOSITORY, useExisting: ServiceTypesRepository },
WeightLimitRulesRepository,
{ provide: WEIGHT_LIMIT_RULES_REPOSITORY, useExisting: WeightLimitRulesRepository },
// CRUD services
CargoTypesService,
ContainerTypesService,
PriorityRulesService,
SurchargesService,
SurchargeTypesService,
ServiceTypesService,
WeightLimitRulesService,
// Evaluation engine
RuleEngineService,
],
exports: [
RuleEngineService,
CargoTypesService,
ServiceTypesService,
ContainerTypesService,
SurchargeTypesService,
SurchargesService,
WeightLimitRulesService,
PriorityRulesService,
],
})
export class RuleEngineModule {}

View File

@@ -0,0 +1,196 @@
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
import { Freight } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import {
ICargoTypesRepository,
CARGO_TYPES_REPOSITORY,
} from './interfaces/cargo-types.repository.interface';
import {
IServiceTypesRepository,
SERVICE_TYPES_REPOSITORY,
} from './interfaces/service-types.repository.interface';
import {
ISurchargesRepository,
SURCHARGES_REPOSITORY,
} from './interfaces/surcharges.repository.interface';
import {
IWeightLimitRulesRepository,
WEIGHT_LIMIT_RULES_REPOSITORY,
} from './interfaces/weight-limit-rules.repository.interface';
import {
IPriorityRulesRepository,
PRIORITY_RULES_REPOSITORY,
} from './interfaces/priority-rules.repository.interface';
export interface AppliedSurcharge {
feeName: string;
rate: number;
currency: string;
calculationMethod: Freight.CalculationMethod;
applyToRail: boolean;
applyToFirstMile: boolean;
applyToLastMile: boolean;
}
export interface RuleEvaluationResult {
priorityScore: number;
appliedSurcharges: AppliedSurcharge[];
warnings: string[];
hardBlocked: string[];
requiresDirectorApproval: boolean;
}
@Injectable()
export class RuleEngineService {
constructor(
@Inject(CARGO_TYPES_REPOSITORY)
private readonly cargoTypesRepo: ICargoTypesRepository,
@Inject(SERVICE_TYPES_REPOSITORY)
private readonly serviceTypesRepo: IServiceTypesRepository,
@Inject(SURCHARGES_REPOSITORY)
private readonly surchargesRepo: ISurchargesRepository,
@Inject(WEIGHT_LIMIT_RULES_REPOSITORY)
private readonly weightLimitRulesRepo: IWeightLimitRulesRepository,
@Inject(PRIORITY_RULES_REPOSITORY)
private readonly priorityRulesRepo: IPriorityRulesRepository,
) {}
/**
* Evaluate all rule engine rules against a booking snapshot.
* Returns the computed priority score, surcharges to apply, warnings,
* hard-block messages, and whether director approval is required.
* Callers must throw BadRequestException if hardBlocked is non-empty.
*/
async evaluate(
booking: Pick<
Booking,
| 'freightType'
| 'serviceType'
| 'paymentCurrency'
| 'cargoTotalWeightVgm'
| 'tradeDirection'
| 'isHazardous'
| 'isRefrigerated'
| 'containers'
>,
): Promise<RuleEvaluationResult> {
const warnings: string[] = [];
const hardBlocked: string[] = [];
const appliedSurcharges: AppliedSurcharge[] = [];
let priorityScore = 0;
let requiresDirectorApproval = false;
// ── 1. Cargo routing ─────────────────────────────────────────────────
// Look up CargoType by code to determine director-approval routing.
if (booking.freightType) {
const cargoType = await this.cargoTypesRepo.findByCode(booking.freightType);
if (cargoType?.requiresDirectorApproval) {
requiresDirectorApproval = true;
}
}
// ── 2. Weight-limit check ────────────────────────────────────────────
// For each container group in the booking, find matching active rules
// and check whether the per-container VGM exceeds the max weight.
const containers = booking.containers ?? [];
for (const container of containers) {
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeAndDirection(
container.type,
booking.tradeDirection,
);
for (const rule of rules) {
if (container.vgm > rule.maxWeightTons) {
const msg =
`${container.type} container VGM ${container.vgm}t exceeds max ` +
`${rule.maxWeightTons}t (${booking.tradeDirection})`;
if (rule.exceededAction === Freight.ExceededAction.HARD_BLOCK) {
hardBlocked.push(msg);
} else {
warnings.push(msg);
}
if (rule.surcharge) {
appliedSurcharges.push(this.mapSurcharge(rule.surcharge));
}
} else if (container.vgm > rule.warningThresholdTons) {
warnings.push(
`${container.type} container VGM ${container.vgm}t is approaching limit ` +
`of ${rule.maxWeightTons}t (${booking.tradeDirection})`,
);
}
}
}
// ── 3. Surcharge flags ───────────────────────────────────────────────
if (booking.isHazardous) {
const surcharge = await this.surchargesRepo.findByTypeCode('HAZARDOUS');
if (surcharge) appliedSurcharges.push(this.mapSurcharge(surcharge));
}
if (booking.isRefrigerated) {
const surcharge = await this.surchargesRepo.findByTypeCode('REFRIGERATED');
if (surcharge) appliedSurcharges.push(this.mapSurcharge(surcharge));
}
// ── 4. Priority scoring ──────────────────────────────────────────────
const priorityRules = await this.priorityRulesRepo.findAllActive();
for (const rule of priorityRules) {
switch (rule.priorityType) {
case Freight.PriorityType.USD_PAYER:
if (booking.paymentCurrency === 'USD') {
priorityScore += rule.bonusPoints;
}
break;
case Freight.PriorityType.RAIL_AND_FORWARDING: {
// Read bonus points from the matching ServiceType DB row
const serviceType = await this.serviceTypesRepo.findByCode(booking.serviceType);
if (serviceType && serviceType.priorityBonusPoints > 0) {
priorityScore += serviceType.priorityBonusPoints;
} else if (booking.serviceType === 'RAIL_AND_FORWARDING') {
// Fall back to the rule's own bonus_points if no ServiceType found
priorityScore += rule.bonusPoints;
}
break;
}
case Freight.PriorityType.HIGH_VOLUME_SHIPMENT:
if (booking.cargoTotalWeightVgm >= 300) {
priorityScore += rule.bonusPoints;
}
break;
case Freight.PriorityType.GOVERNMENT_ACCOUNT:
// TODO: integrate customer accountTier — evaluate when Customer entity is extended
break;
}
}
return { priorityScore, appliedSurcharges, warnings, hardBlocked, requiresDirectorApproval };
}
/**
* Guard helper — throws BadRequestException if hardBlocked is non-empty.
* Call this immediately after evaluate() in BookingsService.
*/
assertNoHardBlocks(result: RuleEvaluationResult): void {
if (result.hardBlocked.length > 0) {
throw new BadRequestException(result.hardBlocked.join('; '));
}
}
private mapSurcharge(s: { feeName: string; rate: number; currency: string; calculationMethod: Freight.CalculationMethod; applyToRail: boolean; applyToFirstMile: boolean; applyToLastMile: boolean }): AppliedSurcharge {
return {
feeName: s.feeName,
rate: s.rate,
currency: s.currency,
calculationMethod: s.calculationMethod,
applyToRail: s.applyToRail,
applyToFirstMile: s.applyToFirstMile,
applyToLastMile: s.applyToLastMile,
};
}
}

View File

@@ -0,0 +1,102 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { ILike } from 'typeorm';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
import { CargoType } from '../entities/cargo-type.entity';
import {
CARGO_TYPES_REPOSITORY,
ICargoTypesRepository,
} from '../interfaces/cargo-types.repository.interface';
@Injectable()
export class CargoTypesService {
constructor(
@Inject(CARGO_TYPES_REPOSITORY)
private readonly repository: ICargoTypesRepository,
) {}
/** List cargo types with pagination and optional filtering. */
async findAll(filter: {
isActive?: boolean;
requiresDirectorApproval?: boolean;
parentGroupId?: string;
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}): Promise<{ data: CargoType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.requiresDirectorApproval !== undefined) where.requiresDirectorApproval = filter.requiresDirectorApproval;
if (filter.parentGroupId !== undefined) where.parentGroupId = filter.parentGroupId;
if (filter.search) where.cargoTypeName = ILike(`%${filter.search}%`);
const [data, total] = await this.repository.findAndCount({
where,
order: { [filter.sortBy ?? 'displayOrder']: filter.sortOrder ?? 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
relations: { parent: true },
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Get a single cargo type by ID. */
async findById(id: string): Promise<CargoType> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Cargo type ${id} not found`);
return entity;
}
/** Get a cargo type by code. */
async findByCode(code: string): Promise<CargoType | null> {
return this.repository.findByCode(code);
}
/** Create a new cargo type. */
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
const existing = await this.repository.findByCode(dto.code);
if (existing) throw new ConflictException(`Cargo type with code "${dto.code}" already exists`);
if (dto.parentGroupId) {
const parent = await this.repository.findById(dto.parentGroupId);
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
}
return this.repository.create({
code: dto.code,
cargoTypeName: dto.cargoTypeName,
parentGroupId: dto.parentGroupId ?? null,
showFreeTextBox: dto.showFreeTextBox ?? false,
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
isActive: dto.isActive ?? true,
displayOrder: dto.displayOrder ?? 1,
});
}
/** Update an existing cargo type. */
async update(id: string, dto: UpdateCargoTypeDto): Promise<CargoType> {
await this.findById(id);
if (dto.code) {
const conflict = await this.repository.findByCode(dto.code);
if (conflict && conflict.id !== id) {
throw new ConflictException(`Cargo type with code "${dto.code}" already exists`);
}
}
if (dto.parentGroupId) {
if (dto.parentGroupId === id) throw new ConflictException('A cargo type cannot be its own parent');
const parent = await this.repository.findById(dto.parentGroupId);
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
}
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Cargo type ${id} not found`);
return updated;
}
/** Soft-delete a cargo type. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -0,0 +1,75 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
import { ContainerType } from '../entities/container-type.entity';
import {
CONTAINER_TYPES_REPOSITORY,
IContainerTypesRepository,
} from '../interfaces/container-types.repository.interface';
@Injectable()
export class ContainerTypesService {
constructor(
@Inject(CONTAINER_TYPES_REPOSITORY)
private readonly repository: IContainerTypesRepository,
) {}
/** List container types with pagination. */
async findAll(filter: {
isActive?: boolean;
page?: number;
pageSize?: number;
}): Promise<{ data: ContainerType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
const [data, total] = await this.repository.findAndCount({
where,
order: { sizeCode: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Get a single container type by ID. */
async findById(id: string): Promise<ContainerType> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Container type ${id} not found`);
return entity;
}
/** Create a new container type. */
async create(dto: CreateContainerTypeDto): Promise<ContainerType> {
const existing = await this.repository.findBySizeCode(dto.sizeCode);
if (existing) throw new ConflictException(`Container type with sizeCode "${dto.sizeCode}" already exists`);
return this.repository.create({
sizeCode: dto.sizeCode,
description: dto.description ?? null,
containersPerWagon: dto.containersPerWagon,
isActive: dto.isActive ?? true,
});
}
/** Update an existing container type. */
async update(id: string, dto: UpdateContainerTypeDto): Promise<ContainerType> {
await this.findById(id);
if (dto.sizeCode) {
const conflict = await this.repository.findBySizeCode(dto.sizeCode);
if (conflict && conflict.id !== id) {
throw new ConflictException(`Container type with sizeCode "${dto.sizeCode}" already exists`);
}
}
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Container type ${id} not found`);
return updated;
}
/** Soft-delete a container type. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -0,0 +1,73 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto';
import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto';
import { PriorityRule } from '../entities/priority-rule.entity';
import {
IPriorityRulesRepository,
PRIORITY_RULES_REPOSITORY,
} from '../interfaces/priority-rules.repository.interface';
@Injectable()
export class PriorityRulesService {
constructor(
@Inject(PRIORITY_RULES_REPOSITORY)
private readonly repository: IPriorityRulesRepository,
) {}
/** List priority rules with pagination. */
async findAll(filter: {
isActive?: boolean;
page?: number;
pageSize?: number;
}): Promise<{ data: PriorityRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
const [data, total] = await this.repository.findAndCount({
where,
order: { priorityType: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Get a single priority rule by ID. */
async findById(id: string): Promise<PriorityRule> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Priority rule ${id} not found`);
return entity;
}
/** Create a new priority rule. */
async create(dto: CreatePriorityRuleDto): Promise<PriorityRule> {
const existing = await this.repository.findAll({ where: { priorityType: dto.priorityType } });
if (existing.length > 0) {
throw new ConflictException(`Priority rule for type "${dto.priorityType}" already exists`);
}
return this.repository.create({
priorityType: dto.priorityType,
ruleName: dto.ruleName,
description: dto.description ?? null,
activationCondition: dto.activationCondition ?? null,
bonusPoints: dto.bonusPoints,
isActive: dto.isActive ?? false,
});
}
/** Update an existing priority rule. */
async update(id: string, dto: UpdatePriorityRuleDto): Promise<PriorityRule> {
await this.findById(id);
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Priority rule ${id} not found`);
return updated;
}
/** Soft-delete a priority rule. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -0,0 +1,93 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { ILike } from 'typeorm';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
import { ServiceType } from '../entities/service-type.entity';
import {
IServiceTypesRepository,
SERVICE_TYPES_REPOSITORY,
} from '../interfaces/service-types.repository.interface';
@Injectable()
export class ServiceTypesService {
constructor(
@Inject(SERVICE_TYPES_REPOSITORY)
private readonly repository: IServiceTypesRepository,
) {}
/** List service types with pagination and optional filtering. */
async findAll(filter: {
isActive?: boolean;
canBeBookedAlone?: boolean;
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}): Promise<{ data: ServiceType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.canBeBookedAlone !== undefined) where.canBeBookedAlone = filter.canBeBookedAlone;
if (filter.search) where.serviceName = ILike(`%${filter.search}%`);
const [data, total] = await this.repository.findAndCount({
where,
order: { [filter.sortBy ?? 'displayOrder']: filter.sortOrder ?? 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Get a single service type by ID. */
async findById(id: string): Promise<ServiceType> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Service type ${id} not found`);
return entity;
}
/** Get a service type by code. */
async findByCode(code: string): Promise<ServiceType | null> {
return this.repository.findByCode(code);
}
/** Create a new service type. */
async create(dto: CreateServiceTypeDto): Promise<ServiceType> {
const existing = await this.repository.findByCode(dto.code);
if (existing) throw new ConflictException(`Service type with code "${dto.code}" already exists`);
return this.repository.create({
code: dto.code,
serviceName: dto.serviceName,
description: dto.description ?? null,
canBeBookedAlone: dto.canBeBookedAlone ?? true,
includesFirstMile: dto.includesFirstMile ?? false,
includesLastMile: dto.includesLastMile ?? false,
includesCustoms: dto.includesCustoms ?? false,
priorityBonusPoints: dto.priorityBonusPoints ?? 0,
isActive: dto.isActive ?? true,
displayOrder: dto.displayOrder ?? 1,
});
}
/** Update an existing service type. */
async update(id: string, dto: UpdateServiceTypeDto): Promise<ServiceType> {
await this.findById(id);
if (dto.code) {
const conflict = await this.repository.findByCode(dto.code);
if (conflict && conflict.id !== id) {
throw new ConflictException(`Service type with code "${dto.code}" already exists`);
}
}
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Service type ${id} not found`);
return updated;
}
/** Soft-delete a service type. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -0,0 +1,75 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto';
import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto';
import { SurchargeType } from '../entities/surcharge-type.entity';
import {
ISurchargeTypesRepository,
SURCHARGE_TYPES_REPOSITORY,
} from '../interfaces/surcharge-types.repository.interface';
@Injectable()
export class SurchargeTypesService {
constructor(
@Inject(SURCHARGE_TYPES_REPOSITORY)
private readonly repository: ISurchargeTypesRepository,
) {}
/** List surcharge types with pagination. */
async findAll(filter: {
isActive?: boolean;
page?: number;
pageSize?: number;
}): Promise<{ data: SurchargeType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
const [data, total] = await this.repository.findAndCount({
where,
order: { name: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Get a single surcharge type by ID. */
async findById(id: string): Promise<SurchargeType> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Surcharge type ${id} not found`);
return entity;
}
/** Create a new surcharge type. */
async create(dto: CreateSurchargeTypeDto): Promise<SurchargeType> {
const existing = await this.repository.findByCode(dto.code);
if (existing) throw new ConflictException(`Surcharge type with code "${dto.code}" already exists`);
return this.repository.create({
code: dto.code,
name: dto.name,
description: dto.description ?? null,
isActive: dto.isActive ?? true,
});
}
/** Update an existing surcharge type. */
async update(id: string, dto: UpdateSurchargeTypeDto): Promise<SurchargeType> {
await this.findById(id);
if (dto.code) {
const conflict = await this.repository.findByCode(dto.code);
if (conflict && conflict.id !== id) {
throw new ConflictException(`Surcharge type with code "${dto.code}" already exists`);
}
}
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Surcharge type ${id} not found`);
return updated;
}
/** Soft-delete a surcharge type. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -0,0 +1,76 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateSurchargeDto } from '../dto/create-surcharge.dto';
import { UpdateSurchargeDto } from '../dto/update-surcharge.dto';
import { Surcharge } from '../entities/surcharge.entity';
import {
ISurchargesRepository,
SURCHARGES_REPOSITORY,
} from '../interfaces/surcharges.repository.interface';
@Injectable()
export class SurchargesService {
constructor(
@Inject(SURCHARGES_REPOSITORY)
private readonly repository: ISurchargesRepository,
) {}
/** List surcharges with pagination. */
async findAll(filter: {
isActive?: boolean;
surchargeTypeId?: string;
page?: number;
pageSize?: number;
}): Promise<{ data: Surcharge[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.surchargeTypeId) where.surchargeTypeId = filter.surchargeTypeId;
const [data, total] = await this.repository.findAndCount({
where,
relations: { surchargeType: true },
order: { feeName: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Get a single surcharge by ID. */
async findById(id: string): Promise<Surcharge> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Surcharge ${id} not found`);
return entity;
}
/** Create a new surcharge. */
async create(dto: CreateSurchargeDto): Promise<Surcharge> {
return this.repository.create({
surchargeTypeId: dto.surchargeTypeId,
feeName: dto.feeName,
triggerDescription: dto.triggerDescription ?? null,
calculationMethod: dto.calculationMethod,
rate: dto.rate,
currency: dto.currency,
applyToRail: dto.applyToRail ?? false,
applyToFirstMile: dto.applyToFirstMile ?? false,
applyToLastMile: dto.applyToLastMile ?? false,
isActive: dto.isActive ?? true,
});
}
/** Update an existing surcharge. */
async update(id: string, dto: UpdateSurchargeDto): Promise<Surcharge> {
await this.findById(id);
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Surcharge ${id} not found`);
return updated;
}
/** Soft-delete a surcharge. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -0,0 +1,78 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
import {
IWeightLimitRulesRepository,
WEIGHT_LIMIT_RULES_REPOSITORY,
} from '../interfaces/weight-limit-rules.repository.interface';
@Injectable()
export class WeightLimitRulesService {
constructor(
@Inject(WEIGHT_LIMIT_RULES_REPOSITORY)
private readonly repository: IWeightLimitRulesRepository,
) {}
/** List weight limit rules with pagination. */
async findAll(filter: {
isActive?: boolean;
containerTypeId?: string;
page?: number;
pageSize?: number;
}): Promise<{ data: WeightLimitRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.containerTypeId) where.containerTypeId = filter.containerTypeId;
const [data, total] = await this.repository.findAndCount({
where,
relations: { containerType: true, surcharge: { surchargeType: true } },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Get a single weight limit rule by ID. */
async findById(id: string): Promise<WeightLimitRule> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Weight limit rule ${id} not found`);
return entity;
}
/** Create a new weight limit rule. */
async create(dto: CreateWeightLimitRuleDto): Promise<WeightLimitRule> {
if (dto.warningThresholdTons > dto.maxWeightTons) {
throw new BadRequestException('warningThresholdTons must be ≤ maxWeightTons');
}
return this.repository.create({
containerTypeId: dto.containerTypeId,
tradeDirection: dto.tradeDirection,
maxWeightTons: dto.maxWeightTons,
warningThresholdTons: dto.warningThresholdTons,
exceededAction: dto.exceededAction,
surchargeId: dto.surchargeId ?? null,
isActive: dto.isActive ?? true,
});
}
/** Update an existing weight limit rule. */
async update(id: string, dto: UpdateWeightLimitRuleDto): Promise<WeightLimitRule> {
const existing = await this.findById(id);
const warning = dto.warningThresholdTons ?? existing.warningThresholdTons;
const max = dto.maxWeightTons ?? existing.maxWeightTons;
if (warning > max) throw new BadRequestException('warningThresholdTons must be ≤ maxWeightTons');
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Weight limit rule ${id} not found`);
return updated;
}
/** Soft-delete a weight limit rule. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}