mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 11:21:18 +00:00
complete rule engine and booking flow
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
||||
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
|
||||
import { ApprovalRulesService } from '../services/approval-rules.service';
|
||||
|
||||
@ApiTags('approval-rules')
|
||||
@Controller('approval-rules')
|
||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
||||
@ApiBearerAuth()
|
||||
export class ApprovalRulesController {
|
||||
constructor(private readonly service: ApprovalRulesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List approval rules' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
requiresDirectorApproval:
|
||||
query['requiresDirectorApproval'] !== undefined
|
||||
? query['requiresDirectorApproval'] === 'true'
|
||||
: undefined,
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('chain')
|
||||
@ApiOperation({ summary: 'Get approval chain for cargo routing flag' })
|
||||
findChain(@Query('requiresDirectorApproval') flag: string) {
|
||||
return this.service.findChain(flag === 'true');
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get an approval rule by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create an approval rule step' })
|
||||
create(@Body() dto: CreateApprovalRuleDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update an approval rule' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete an approval rule' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { ApproveRateDto, CreateRateDto } from '../dto/create-rate.dto';
|
||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||
import { RatesService } from '../services/rates.service';
|
||||
|
||||
@ApiTags('rates')
|
||||
@Controller('rates')
|
||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
||||
@ApiBearerAuth()
|
||||
export class RatesController {
|
||||
constructor(private readonly service: RatesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List rates' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
status: query['status'],
|
||||
rateType: query['rateType'],
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('live')
|
||||
@ApiOperation({ summary: 'List all LIVE rates effective now' })
|
||||
findLive() {
|
||||
return this.service.findLiveRates();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a rate by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a rate (DRAFT)' })
|
||||
create(@Body() dto: CreateRateDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a DRAFT rate' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRateDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/submit')
|
||||
@ApiOperation({ summary: 'Submit rate for CEO approval' })
|
||||
submit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.submitForApproval(id);
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@ApiOperation({ summary: 'CEO approves a rate' })
|
||||
approve(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveRateDto) {
|
||||
return this.service.approve(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a rate' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -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 { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
|
||||
import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto';
|
||||
import { ShippingLinesService } from '../services/shipping-lines.service';
|
||||
|
||||
@ApiTags('shipping-lines')
|
||||
@Controller('shipping-lines')
|
||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
||||
@ApiBearerAuth()
|
||||
export class ShippingLinesController {
|
||||
constructor(private readonly service: ShippingLinesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List shipping lines' })
|
||||
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 shipping line by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a shipping line' })
|
||||
create(@Body() dto: CreateShippingLineDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a shipping line' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateShippingLineDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a shipping line' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export class WeightLimitRulesController {
|
||||
@ApiOperation({ summary: 'List weight limit rules' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
||||
tradeDirection: query['tradeDirection'],
|
||||
containerTypeId: query['containerTypeId'],
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
|
||||
@@ -3,49 +3,49 @@ import {
|
||||
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';
|
||||
import { CreateYardDto } from '../dto/create-yard.dto';
|
||||
import { UpdateYardDto } from '../dto/update-yard.dto';
|
||||
import { YardsService } from '../services/yards.service';
|
||||
|
||||
@ApiTags('surcharges')
|
||||
@Controller('surcharges')
|
||||
@ApiTags('yards')
|
||||
@Controller('yards')
|
||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
||||
@ApiBearerAuth()
|
||||
export class SurchargesController {
|
||||
constructor(private readonly service: SurchargesService) {}
|
||||
export class YardsController {
|
||||
constructor(private readonly service: YardsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List surcharges' })
|
||||
@ApiOperation({ summary: 'List yards' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
||||
surchargeTypeId: query['surchargeTypeId'],
|
||||
country: query['country'],
|
||||
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' })
|
||||
@ApiOperation({ summary: 'Get a yard by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a surcharge' })
|
||||
create(@Body() dto: CreateSurchargeDto) {
|
||||
@ApiOperation({ summary: 'Create a yard' })
|
||||
create(@Body() dto: CreateYardDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a surcharge' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeDto) {
|
||||
@ApiOperation({ summary: 'Update a yard' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a surcharge' })
|
||||
@ApiOperation({ summary: 'Soft-delete a yard' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
|
||||
const ROLES = ['LINE_STAFF', 'DIRECTOR', 'CEO'] as const;
|
||||
|
||||
export class CreateApprovalRuleDto {
|
||||
@ApiProperty({ description: 'True = Director+CEO chain; False = LineStaff+Director chain' })
|
||||
@IsBoolean()
|
||||
requiresDirectorApproval!: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Step sequence number (1 = first, 2 = second)', minimum: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
stepOrder!: number;
|
||||
|
||||
@ApiProperty({ enum: ROLES, description: 'Role required to action this step' })
|
||||
@IsString()
|
||||
@MaxLength(30)
|
||||
requiredRole!: string;
|
||||
|
||||
@ApiProperty({ description: 'Label shown in UI, e.g. "Review & Approve"', maxLength: 50 })
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
actionLabel!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ROLES, description: 'Role explicitly blocked from actioning this step' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(30)
|
||||
blocksRole?: string;
|
||||
}
|
||||
@@ -1,25 +1,48 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateContainerTypeDto {
|
||||
@ApiProperty({ description: 'Size code, e.g. 20FT or 40FT', maxLength: 20 })
|
||||
@ApiProperty({ description: 'Unique container code, e.g. 20DV, 40HC', maxLength: 20 })
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
sizeCode!: string;
|
||||
code!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Human-readable description', maxLength: 100 })
|
||||
@IsOptional()
|
||||
@ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
description?: string;
|
||||
label!: string;
|
||||
|
||||
@ApiProperty({ description: 'Number of containers that fit per rail wagon (2 for 20FT, 1 for 40FT)' })
|
||||
@ApiProperty({ description: 'Container size in feet: 20 or 40', enum: [20, 40] })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
containersPerWagon!: number;
|
||||
@Min(20)
|
||||
@Max(40)
|
||||
sizeFt!: number;
|
||||
|
||||
@ApiProperty({ description: 'Wagon fraction per container: 0.50 for 20ft, 1.00 for 40ft' })
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
@Transform(({ value }) => Number(value))
|
||||
wagonsPerUnit!: number;
|
||||
|
||||
@ApiPropertyOptional({ default: false, description: 'True if this is a reefer (refrigerated) container' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isReefer?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: false, description: 'True if this is an open-top container' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isOpenTop?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: 1, description: 'UI display sort order' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
import { Freight } from '@edr/types';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
|
||||
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 })
|
||||
@ApiProperty({ description: 'Unique rule code, e.g. USD_PAYER, GOV_REQUEST', maxLength: 40 })
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
ruleName!: string;
|
||||
@MaxLength(40)
|
||||
code!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Explanation of when this rule is triggered' })
|
||||
@IsOptional()
|
||||
@ApiProperty({ description: 'Human-readable label', maxLength: 100 })
|
||||
@IsString()
|
||||
description?: string;
|
||||
@MaxLength(100)
|
||||
label!: 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 })
|
||||
@ApiProperty({ description: 'Points added to booking.priority_score when condition matches', default: 0 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
bonusPoints!: number;
|
||||
score!: number;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@ApiPropertyOptional({
|
||||
description: 'If set, rule only matches bookings with this payment currency (e.g. USD). Null = matches all.',
|
||||
maxLength: 5,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5)
|
||||
conditionCurrency?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: false, description: 'Feature flag — toggle without code deploy' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
import { RATE_TYPES, RATE_UNITS } from '../entities/rate.entity';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'ANY'] as const;
|
||||
const CURRENCIES = ['ETB', 'USD'] as const;
|
||||
|
||||
export class CreateRateDto {
|
||||
@ApiProperty({ enum: RATE_TYPES, description: 'Rate type identifier' })
|
||||
@IsIn([...RATE_TYPES])
|
||||
rateType!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'FK to container_types.id — null for non-container rates' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
containerTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS, description: 'Trade direction. Null = direction-agnostic' })
|
||||
@IsOptional()
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
tradeDirection?: string;
|
||||
|
||||
@ApiProperty({ enum: CURRENCIES })
|
||||
@IsIn([...CURRENCIES])
|
||||
currency!: string;
|
||||
|
||||
@ApiProperty({ description: 'Numeric rate value', minimum: 0 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
rateValue!: number;
|
||||
|
||||
@ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' })
|
||||
@IsIn([...RATE_UNITS])
|
||||
rateUnit!: string;
|
||||
|
||||
@ApiProperty({ description: 'ID of the staff member (Director) proposing this rate' })
|
||||
@IsUUID()
|
||||
proposedByStaffId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' })
|
||||
@IsDateString()
|
||||
effectiveFrom!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Date when this rate expires. Null = currently active', example: '2025-12-31' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
effectiveTo?: string;
|
||||
}
|
||||
|
||||
export class ApproveRateDto {
|
||||
@ApiProperty({ description: 'ID of the CEO approving this rate' })
|
||||
@IsUUID()
|
||||
approvedByCeoId!: string;
|
||||
}
|
||||
|
||||
export class SubmitRateForApprovalDto {
|
||||
@ApiPropertyOptional({ description: 'Optional note for the approval request', maxLength: 500 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateShippingLineDto {
|
||||
@ApiProperty({ description: 'Unique shipping line code, e.g. MSC, PIL, MAERSK', maxLength: 20 })
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ description: 'Customer-facing label', maxLength: 100 })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
label!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'If set, backend silently uses this code for pricing tier lookups (e.g. PIL → MAERSK)',
|
||||
maxLength: 20,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
mappedToCode?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description: 'If true, quotation renders additional fee notice to customer',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showExtraFeeNotice?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -1,21 +1,32 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { IsBoolean, IsIn, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
|
||||
const TRIGGER_CONDITIONS = [
|
||||
'CARGO_FLAG_HAZARDOUS',
|
||||
'CARGO_FLAG_REEFER',
|
||||
'VGM_EXCEEDS_LIMIT',
|
||||
'SHIPPING_LINE_MAPPED',
|
||||
'CONSOLIDATION_ENABLED',
|
||||
] as const;
|
||||
|
||||
export class CreateSurchargeTypeDto {
|
||||
@ApiProperty({ description: 'Unique code, e.g. HAZARDOUS, REFRIGERATED', maxLength: 50 })
|
||||
@ApiProperty({ description: 'Unique code, e.g. HAZARD, REEFER, OVERWEIGHT', maxLength: 40 })
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
@MaxLength(40)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ description: 'Display name', maxLength: 100 })
|
||||
@ApiProperty({ description: 'Human-readable label', maxLength: 100 })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name!: string;
|
||||
label!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Description of when this surcharge type is triggered' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
@ApiProperty({ enum: TRIGGER_CONDITIONS, description: 'Condition that auto-fires this surcharge' })
|
||||
@IsIn([...TRIGGER_CONDITIONS])
|
||||
triggerCondition!: string;
|
||||
|
||||
@ApiProperty({ description: 'FK to rates.id — the LIVE rate used to price this surcharge' })
|
||||
@IsUUID()
|
||||
rateId!: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,38 +1,30 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
|
||||
import { Freight } from '@edr/types';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'ANY'] as const;
|
||||
|
||||
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({ enum: TRADE_DIRECTIONS, description: 'Trade direction: IMPORT, EXPORT, or ANY' })
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
tradeDirection!: string;
|
||||
|
||||
@ApiProperty({ description: 'Maximum allowed weight in tons before surcharge is applied' })
|
||||
@ApiProperty({ description: 'Maximum allowed VGM in tons', minimum: 0 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxWeightTons!: number;
|
||||
@Transform(({ value }) => Number(value))
|
||||
maxVgmTons!: number;
|
||||
|
||||
@ApiProperty({ description: 'Weight at which a warning is issued (must be ≤ maxWeightTons)' })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
warningThresholdTons!: number;
|
||||
@ApiProperty({ description: 'Date from which this rule is active (ISO date)', example: '2024-01-01' })
|
||||
@IsDateString()
|
||||
effectiveFrom!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: Freight.ExceededAction, default: Freight.ExceededAction.WARNING_ONLY })
|
||||
@ApiPropertyOptional({ description: 'Date when this rule expires (ISO date). Null = currently active', example: '2025-12-31' })
|
||||
@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;
|
||||
@IsDateString()
|
||||
effectiveTo?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateYardDto {
|
||||
@ApiProperty({ description: 'Unique yard code, e.g. KALITY, DJIB_PORT', maxLength: 20 })
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
label!: string;
|
||||
|
||||
@ApiProperty({ description: 'Country where the yard is located, e.g. Ethiopia, Djibouti', maxLength: 50 })
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
country!: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: 1, description: 'UI display sort order' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
displayOrder?: number;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateApprovalRuleDto } from './create-approval-rule.dto';
|
||||
|
||||
export class UpdateApprovalRuleDto extends PartialType(CreateApprovalRuleDto) {}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateRateDto } from './create-rate.dto';
|
||||
|
||||
export class UpdateRateDto extends PartialType(CreateRateDto) {}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateShippingLineDto } from './create-shipping-line.dto';
|
||||
|
||||
export class UpdateShippingLineDto extends PartialType(CreateShippingLineDto) {}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateSurchargeDto } from './create-surcharge.dto';
|
||||
|
||||
export class UpdateSurchargeDto extends PartialType(CreateSurchargeDto) {}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateYardDto } from './create-yard.dto';
|
||||
|
||||
export class UpdateYardDto extends PartialType(CreateYardDto) {}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, Unique } from 'typeorm';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'approval_rules' })
|
||||
@Unique(['requiresDirectorApproval', 'stepOrder'])
|
||||
@Index(['requiresDirectorApproval'])
|
||||
@Index(['stepOrder'])
|
||||
export class ApprovalRule extends BaseEntity {
|
||||
@Column({ name: 'requires_director_approval', type: 'boolean' })
|
||||
requiresDirectorApproval!: boolean;
|
||||
|
||||
@Column({ name: 'step_order', type: 'smallint' })
|
||||
stepOrder!: number;
|
||||
|
||||
@Column({ name: 'required_role', type: 'varchar', length: 30 })
|
||||
requiredRole!: string;
|
||||
|
||||
@Column({ name: 'action_label', type: 'varchar', length: 50 })
|
||||
actionLabel!: string;
|
||||
|
||||
@Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true })
|
||||
blocksRole?: string | null;
|
||||
}
|
||||
@@ -3,21 +3,33 @@ import { Column, Entity, Index, OneToMany } from 'typeorm';
|
||||
import { WeightLimitRule } from './weight-limit-rule.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'container_types' })
|
||||
@Index(['sizeCode'])
|
||||
@Index(['code'])
|
||||
@Index(['isActive'])
|
||||
export class ContainerType extends BaseEntity {
|
||||
@Column({ name: 'size_code', type: 'varchar', length: 20, unique: true })
|
||||
sizeCode!: string;
|
||||
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'description', type: 'varchar', length: 100, nullable: true })
|
||||
description?: string | null;
|
||||
@Column({ name: 'label', type: 'varchar', length: 100, nullable: true })
|
||||
label!: string;
|
||||
|
||||
@Column({ name: 'containers_per_wagon', type: 'int' })
|
||||
containersPerWagon!: number;
|
||||
@Column({ name: 'size_ft', type: 'smallint', nullable: true })
|
||||
sizeFt!: number;
|
||||
|
||||
@Column({ name: 'wagons_per_unit', type: 'numeric', precision: 4, scale: 2, nullable: true })
|
||||
wagonsPerUnit!: number;
|
||||
|
||||
@Column({ name: 'is_reefer', type: 'boolean', default: false, nullable: true })
|
||||
isReefer!: boolean;
|
||||
|
||||
@Column({ name: 'is_open_top', type: 'boolean', default: false, nullable: true })
|
||||
isOpenTop!: boolean;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@Column({ name: 'display_order', type: 'int', default: 1, nullable: true })
|
||||
displayOrder!: number;
|
||||
|
||||
@OneToMany(() => WeightLimitRule, (rule) => rule.containerType)
|
||||
weightLimitRules?: WeightLimitRule[];
|
||||
}
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
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(['code'])
|
||||
@Index(['isActive'])
|
||||
export class PriorityRule extends BaseEntity {
|
||||
@Column({ name: 'priority_type', type: 'enum', enum: Freight.PriorityType, unique: true })
|
||||
priorityType!: Freight.PriorityType;
|
||||
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'rule_name', type: 'varchar', length: 255 })
|
||||
ruleName!: string;
|
||||
@Column({ name: 'label', type: 'varchar', length: 100, nullable: true })
|
||||
label!: string;
|
||||
|
||||
@Column({ name: 'description', type: 'text', nullable: true })
|
||||
description?: string | null;
|
||||
@Column({ name: 'score', type: 'int', default: 0, nullable: true })
|
||||
score!: number;
|
||||
|
||||
@Column({ name: 'activation_condition', type: 'text', nullable: true })
|
||||
activationCondition?: string | null;
|
||||
|
||||
@Column({ name: 'bonus_points', type: 'int', default: 0 })
|
||||
bonusPoints!: number;
|
||||
@Column({ name: 'condition_currency', type: 'varchar', length: 5, nullable: true })
|
||||
conditionCurrency?: string | null;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: false })
|
||||
isActive!: boolean;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { ContainerType } from './container-type.entity';
|
||||
|
||||
export const RATE_TYPES = [
|
||||
'CONTAINER_IMPORT',
|
||||
'CONTAINER_EXPORT',
|
||||
'BULK_IMPORT',
|
||||
'BULK_EXPORT',
|
||||
'INTERCITY_BULK',
|
||||
'INTERCITY_CONTAINER',
|
||||
'FIRST_MILE',
|
||||
'LAST_MILE',
|
||||
'DEMURRAGE',
|
||||
'LASHING',
|
||||
'DOUBLE_HANDLING',
|
||||
'CONTAINER_WITH_RETURN',
|
||||
'CANCELLATION_FEE',
|
||||
'OVERWEIGHT_PER_TON',
|
||||
'HAZARD_SURCHARGE',
|
||||
'REEFER_SURCHARGE',
|
||||
'PIL_EXTRA_FEE',
|
||||
] as const;
|
||||
|
||||
export type RateType = typeof RATE_TYPES[number];
|
||||
|
||||
export const RATE_STATUSES = ['DRAFT', 'PENDING_APPROVAL', 'LIVE', 'SUPERSEDED'] as const;
|
||||
export type RateStatus = typeof RATE_STATUSES[number];
|
||||
|
||||
export const RATE_UNITS = ['PER_WAGON', 'PER_TON', 'PER_CONTAINER', 'PER_KM', 'FLAT'] as const;
|
||||
export type RateUnit = typeof RATE_UNITS[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'rates' })
|
||||
@Index(['rateType'])
|
||||
@Index(['status'])
|
||||
@Index(['effectiveFrom'])
|
||||
@Index(['containerTypeId'])
|
||||
export class Rate extends BaseEntity {
|
||||
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
|
||||
rateType!: RateType;
|
||||
|
||||
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
|
||||
containerTypeId?: string | null;
|
||||
|
||||
@ManyToOne(() => ContainerType, { nullable: true, eager: false })
|
||||
@JoinColumn({ name: 'container_type_id' })
|
||||
containerType?: ContainerType | null;
|
||||
|
||||
@Column({ name: 'trade_direction', type: 'varchar', length: 10, nullable: true })
|
||||
tradeDirection?: string | null;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 5 })
|
||||
currency!: string;
|
||||
|
||||
@Column({ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 })
|
||||
rateValue!: number;
|
||||
|
||||
@Column({ name: 'rate_unit', type: 'varchar', length: 30 })
|
||||
rateUnit!: RateUnit;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
|
||||
status!: RateStatus;
|
||||
|
||||
@Column({ name: 'proposed_by_staff_id', type: 'uuid' })
|
||||
proposedByStaffId!: string;
|
||||
|
||||
@Column({ name: 'approved_by_ceo_id', type: 'uuid', nullable: true })
|
||||
approvedByCeoId?: string | null;
|
||||
|
||||
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
|
||||
approvedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'effective_from', type: 'date' })
|
||||
effectiveFrom!: Date;
|
||||
|
||||
@Column({ name: 'effective_to', type: 'date', nullable: true })
|
||||
effectiveTo?: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'shipping_lines' })
|
||||
@Index(['code'])
|
||||
@Index(['isActive'])
|
||||
export class ShippingLine extends BaseEntity {
|
||||
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'label', type: 'varchar', length: 100 })
|
||||
label!: string;
|
||||
|
||||
@Column({ name: 'mapped_to_code', type: 'varchar', length: 20, nullable: true })
|
||||
mappedToCode?: string | null;
|
||||
|
||||
@Column({ name: 'show_extra_fee_notice', type: 'boolean', default: false })
|
||||
showExtraFeeNotice!: boolean;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -1,23 +1,38 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, OneToMany } from 'typeorm';
|
||||
import { Surcharge } from './surcharge.entity';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Rate } from './rate.entity';
|
||||
|
||||
const TRIGGER_CONDITIONS = [
|
||||
'CARGO_FLAG_HAZARDOUS',
|
||||
'CARGO_FLAG_REEFER',
|
||||
'VGM_EXCEEDS_LIMIT',
|
||||
'SHIPPING_LINE_MAPPED',
|
||||
'CONSOLIDATION_ENABLED',
|
||||
] as const;
|
||||
|
||||
export type TriggerCondition = typeof TRIGGER_CONDITIONS[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'surcharge_types' })
|
||||
@Index(['code'])
|
||||
@Index(['isActive'])
|
||||
@Index(['rateId'])
|
||||
export class SurchargeType extends BaseEntity {
|
||||
@Column({ name: 'code', type: 'varchar', length: 50, unique: true })
|
||||
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 100 })
|
||||
name!: string;
|
||||
@Column({ name: 'label', type: 'varchar', length: 100, nullable: true })
|
||||
label!: string;
|
||||
|
||||
@Column({ name: 'description', type: 'text', nullable: true })
|
||||
description?: string | null;
|
||||
@Column({ name: 'trigger_condition', type: 'varchar', length: 50, nullable: true })
|
||||
triggerCondition!: TriggerCondition;
|
||||
|
||||
@Column({ name: 'rate_id', type: 'uuid', nullable: true })
|
||||
rateId!: string;
|
||||
|
||||
@ManyToOne(() => Rate, { eager: false })
|
||||
@JoinColumn({ name: 'rate_id' })
|
||||
rate?: Rate;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@OneToMany(() => Surcharge, (s) => s.surchargeType)
|
||||
surcharges?: Surcharge[];
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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[];
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
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'])
|
||||
@Index(['tradeDirection'])
|
||||
@Index(['effectiveFrom'])
|
||||
export class WeightLimitRule extends BaseEntity {
|
||||
@Column({ name: 'container_type_id', type: 'uuid' })
|
||||
containerTypeId!: string;
|
||||
@@ -16,30 +14,15 @@ export class WeightLimitRule extends BaseEntity {
|
||||
@JoinColumn({ name: 'container_type_id' })
|
||||
containerType!: ContainerType;
|
||||
|
||||
@Column({ name: 'trade_direction', type: 'enum', enum: Freight.TradeDirection })
|
||||
tradeDirection!: Freight.TradeDirection;
|
||||
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
|
||||
tradeDirection!: string;
|
||||
|
||||
@Column({ name: 'max_weight_tons', type: 'numeric', precision: 10, scale: 2 })
|
||||
maxWeightTons!: number;
|
||||
@Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true })
|
||||
maxVgmTons!: number;
|
||||
|
||||
@Column({ name: 'warning_threshold_tons', type: 'numeric', precision: 10, scale: 2 })
|
||||
warningThresholdTons!: number;
|
||||
@Column({ name: 'effective_from', type: 'date', nullable: true })
|
||||
effectiveFrom!: Date;
|
||||
|
||||
@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;
|
||||
@Column({ name: 'effective_to', type: 'date', nullable: true })
|
||||
effectiveTo?: Date | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'yards' })
|
||||
@Index(['code'])
|
||||
@Index(['country'])
|
||||
@Index(['isActive'])
|
||||
export class Yard extends BaseEntity {
|
||||
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'label', type: 'varchar', length: 100 })
|
||||
label!: string;
|
||||
|
||||
@Column({ name: 'country', type: 'varchar', length: 50 })
|
||||
country!: string;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@Column({ name: 'display_order', type: 'int', default: 1 })
|
||||
displayOrder!: number;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { ApprovalRule } from '../entities/approval-rule.entity';
|
||||
|
||||
export interface IApprovalRulesRepository {
|
||||
findById(id: string): Promise<ApprovalRule | null>;
|
||||
findChainForCargo(requiresDirectorApproval: boolean): Promise<ApprovalRule[]>;
|
||||
findAll(options?: FindManyOptions<ApprovalRule>): Promise<ApprovalRule[]>;
|
||||
findAndCount(options?: FindManyOptions<ApprovalRule>): Promise<[ApprovalRule[], number]>;
|
||||
create(data: Partial<ApprovalRule>): Promise<ApprovalRule>;
|
||||
update(id: string, data: Partial<ApprovalRule>): Promise<ApprovalRule | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const APPROVAL_RULES_REPOSITORY = Symbol('APPROVAL_RULES_REPOSITORY');
|
||||
@@ -3,7 +3,7 @@ import { ContainerType } from '../entities/container-type.entity';
|
||||
|
||||
export interface IContainerTypesRepository {
|
||||
findById(id: string): Promise<ContainerType | null>;
|
||||
findBySizeCode(sizeCode: string): Promise<ContainerType | null>;
|
||||
findByCode(code: string): Promise<ContainerType | null>;
|
||||
findAll(options?: FindManyOptions<ContainerType>): Promise<ContainerType[]>;
|
||||
findAndCount(options?: FindManyOptions<ContainerType>): Promise<[ContainerType[], number]>;
|
||||
create(data: Partial<ContainerType>): Promise<ContainerType>;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { Rate } from '../entities/rate.entity';
|
||||
|
||||
export interface IRatesRepository {
|
||||
findById(id: string): Promise<Rate | null>;
|
||||
findLiveRates(): Promise<Rate[]>;
|
||||
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
|
||||
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
|
||||
create(data: Partial<Rate>): Promise<Rate>;
|
||||
update(id: string, data: Partial<Rate>): Promise<Rate | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const RATES_REPOSITORY = Symbol('RATES_REPOSITORY');
|
||||
@@ -0,0 +1,14 @@
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { ShippingLine } from '../entities/shipping-line.entity';
|
||||
|
||||
export interface IShippingLinesRepository {
|
||||
findById(id: string): Promise<ShippingLine | null>;
|
||||
findByCode(code: string): Promise<ShippingLine | null>;
|
||||
findAll(options?: FindManyOptions<ShippingLine>): Promise<ShippingLine[]>;
|
||||
findAndCount(options?: FindManyOptions<ShippingLine>): Promise<[ShippingLine[], number]>;
|
||||
create(data: Partial<ShippingLine>): Promise<ShippingLine>;
|
||||
update(id: string, data: Partial<ShippingLine>): Promise<ShippingLine | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const SHIPPING_LINES_REPOSITORY = Symbol('SHIPPING_LINES_REPOSITORY');
|
||||
@@ -4,6 +4,7 @@ import { SurchargeType } from '../entities/surcharge-type.entity';
|
||||
export interface ISurchargeTypesRepository {
|
||||
findById(id: string): Promise<SurchargeType | null>;
|
||||
findByCode(code: string): Promise<SurchargeType | null>;
|
||||
findAllActiveWithRate(): Promise<SurchargeType[]>;
|
||||
findAll(options?: FindManyOptions<SurchargeType>): Promise<SurchargeType[]>;
|
||||
findAndCount(options?: FindManyOptions<SurchargeType>): Promise<[SurchargeType[], number]>;
|
||||
create(data: Partial<SurchargeType>): Promise<SurchargeType>;
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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');
|
||||
@@ -3,8 +3,8 @@ import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
|
||||
|
||||
export interface IWeightLimitRulesRepository {
|
||||
findById(id: string): Promise<WeightLimitRule | null>;
|
||||
findActiveByContainerTypeAndDirection(
|
||||
sizeCode: string,
|
||||
findActiveByContainerTypeId(
|
||||
containerTypeId: string,
|
||||
tradeDirection: string,
|
||||
): Promise<WeightLimitRule[]>;
|
||||
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]>;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { Yard } from '../entities/yard.entity';
|
||||
|
||||
export interface IYardsRepository {
|
||||
findById(id: string): Promise<Yard | null>;
|
||||
findByCode(code: string): Promise<Yard | null>;
|
||||
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]>;
|
||||
findAndCount(options?: FindManyOptions<Yard>): Promise<[Yard[], number]>;
|
||||
create(data: Partial<Yard>): Promise<Yard>;
|
||||
update(id: string, data: Partial<Yard>): Promise<Yard | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const YARDS_REPOSITORY = Symbol('YARDS_REPOSITORY');
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||
import { ApprovalRule } from '../entities/approval-rule.entity';
|
||||
import { IApprovalRulesRepository } from '../interfaces/approval-rules.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class ApprovalRulesRepository implements IApprovalRulesRepository {
|
||||
private readonly repo: Repository<ApprovalRule>;
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {
|
||||
this.repo = this.dataSource.getRepository(ApprovalRule);
|
||||
}
|
||||
|
||||
findById(id: string): Promise<ApprovalRule | null> {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
findChainForCargo(requiresDirectorApproval: boolean): Promise<ApprovalRule[]> {
|
||||
return this.repo.find({
|
||||
where: { requiresDirectorApproval },
|
||||
order: { stepOrder: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
findAll(options?: FindManyOptions<ApprovalRule>): Promise<ApprovalRule[]> {
|
||||
return this.repo.find(options);
|
||||
}
|
||||
|
||||
findAndCount(options?: FindManyOptions<ApprovalRule>): Promise<[ApprovalRule[], number]> {
|
||||
return this.repo.findAndCount(options);
|
||||
}
|
||||
|
||||
async create(data: Partial<ApprovalRule>): Promise<ApprovalRule> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<ApprovalRule>): Promise<ApprovalRule | null> {
|
||||
await this.repo.update(id, data);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async softDelete(id: string): Promise<void> {
|
||||
await this.repo.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
findBySizeCode(sizeCode: string): Promise<ContainerType | null> {
|
||||
return this.repo.findOne({ where: { sizeCode } });
|
||||
findByCode(code: string): Promise<ContainerType | null> {
|
||||
return this.repo.findOne({ where: { code } });
|
||||
}
|
||||
|
||||
findAll(options?: FindManyOptions<ContainerType>): Promise<ContainerType[]> {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||
import { Rate } from '../entities/rate.entity';
|
||||
import { IRatesRepository } from '../interfaces/rates.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class RatesRepository implements IRatesRepository {
|
||||
private readonly repo: Repository<Rate>;
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {
|
||||
this.repo = this.dataSource.getRepository(Rate);
|
||||
}
|
||||
|
||||
findById(id: string): Promise<Rate | null> {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
findLiveRates(): Promise<Rate[]> {
|
||||
const now = new Date();
|
||||
return this.repo
|
||||
.createQueryBuilder('rate')
|
||||
.where('rate.status = :status', { status: 'LIVE' })
|
||||
.andWhere('rate.effective_from <= :now', { now })
|
||||
.andWhere('(rate.effective_to IS NULL OR rate.effective_to > :now)', { now })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]> {
|
||||
return this.repo.find(options);
|
||||
}
|
||||
|
||||
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]> {
|
||||
return this.repo.findAndCount(options);
|
||||
}
|
||||
|
||||
async create(data: Partial<Rate>): Promise<Rate> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<Rate>): Promise<Rate | null> {
|
||||
await this.repo.update(id, data);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async softDelete(id: string): Promise<void> {
|
||||
await this.repo.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||
import { ShippingLine } from '../entities/shipping-line.entity';
|
||||
import { IShippingLinesRepository } from '../interfaces/shipping-lines.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class ShippingLinesRepository implements IShippingLinesRepository {
|
||||
private readonly repo: Repository<ShippingLine>;
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {
|
||||
this.repo = this.dataSource.getRepository(ShippingLine);
|
||||
}
|
||||
|
||||
findById(id: string): Promise<ShippingLine | null> {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
findByCode(code: string): Promise<ShippingLine | null> {
|
||||
return this.repo.findOne({ where: { code } });
|
||||
}
|
||||
|
||||
findAll(options?: FindManyOptions<ShippingLine>): Promise<ShippingLine[]> {
|
||||
return this.repo.find(options);
|
||||
}
|
||||
|
||||
findAndCount(options?: FindManyOptions<ShippingLine>): Promise<[ShippingLine[], number]> {
|
||||
return this.repo.findAndCount(options);
|
||||
}
|
||||
|
||||
async create(data: Partial<ShippingLine>): Promise<ShippingLine> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<ShippingLine>): Promise<ShippingLine | null> {
|
||||
await this.repo.update(id, data);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async softDelete(id: string): Promise<void> {
|
||||
await this.repo.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,13 @@ export class SurchargeTypesRepository implements ISurchargeTypesRepository {
|
||||
return this.repo.findOne({ where: { code } });
|
||||
}
|
||||
|
||||
findAllActiveWithRate(): Promise<SurchargeType[]> {
|
||||
return this.repo.find({
|
||||
where: { isActive: true },
|
||||
relations: { rate: true },
|
||||
});
|
||||
}
|
||||
|
||||
findAll(options?: FindManyOptions<SurchargeType>): Promise<SurchargeType[]> {
|
||||
return this.repo.find(options);
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -14,25 +14,25 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
|
||||
findById(id: string): Promise<WeightLimitRule | null> {
|
||||
return this.repo.findOne({
|
||||
where: { id },
|
||||
relations: { containerType: true, surcharge: { surchargeType: true } },
|
||||
relations: { containerType: true },
|
||||
});
|
||||
}
|
||||
|
||||
findActiveByContainerTypeAndDirection(
|
||||
sizeCode: string,
|
||||
findActiveByContainerTypeId(
|
||||
containerTypeId: string,
|
||||
tradeDirection: string,
|
||||
): Promise<WeightLimitRule[]> {
|
||||
const now = new Date();
|
||||
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)', {
|
||||
.where('rule.container_type_id = :containerTypeId', { containerTypeId })
|
||||
.andWhere('(rule.trade_direction = :dir OR rule.trade_direction = :any)', {
|
||||
dir: tradeDirection,
|
||||
both: 'BOTH',
|
||||
any: 'ANY',
|
||||
})
|
||||
.andWhere('rule.is_active = true')
|
||||
.andWhere('rule.effective_from <= :now', { now })
|
||||
.andWhere('(rule.effective_to IS NULL OR rule.effective_to > :now)', { now })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||
import { Yard } from '../entities/yard.entity';
|
||||
import { IYardsRepository } from '../interfaces/yards.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class YardsRepository implements IYardsRepository {
|
||||
private readonly repo: Repository<Yard>;
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {
|
||||
this.repo = this.dataSource.getRepository(Yard);
|
||||
}
|
||||
|
||||
findById(id: string): Promise<Yard | null> {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
findByCode(code: string): Promise<Yard | null> {
|
||||
return this.repo.findOne({ where: { code } });
|
||||
}
|
||||
|
||||
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]> {
|
||||
return this.repo.find(options);
|
||||
}
|
||||
|
||||
findAndCount(options?: FindManyOptions<Yard>): Promise<[Yard[], number]> {
|
||||
return this.repo.findAndCount(options);
|
||||
}
|
||||
|
||||
async create(data: Partial<Yard>): Promise<Yard> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<Yard>): Promise<Yard | null> {
|
||||
await this.repo.update(id, data);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async softDelete(id: string): Promise<void> {
|
||||
await this.repo.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,68 @@
|
||||
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 { ApprovalRulesController } from './controllers/approval-rules.controller';
|
||||
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 { RatesController } from './controllers/rates.controller';
|
||||
import { ServiceTypesController } from './controllers/service-types.controller';
|
||||
import { ShippingLinesController } from './controllers/shipping-lines.controller';
|
||||
import { SurchargeTypesController } from './controllers/surcharge-types.controller';
|
||||
import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller';
|
||||
import { YardsController } from './controllers/yards.controller';
|
||||
|
||||
import { ApprovalRule } from './entities/approval-rule.entity';
|
||||
import { CargoType } from './entities/cargo-type.entity';
|
||||
import { ContainerType } from './entities/container-type.entity';
|
||||
import { PriorityRule } from './entities/priority-rule.entity';
|
||||
import { Rate } from './entities/rate.entity';
|
||||
import { ServiceType } from './entities/service-type.entity';
|
||||
import { ShippingLine } from './entities/shipping-line.entity';
|
||||
import { SurchargeType } from './entities/surcharge-type.entity';
|
||||
import { WeightLimitRule } from './entities/weight-limit-rule.entity';
|
||||
import { Yard } from './entities/yard.entity';
|
||||
|
||||
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
|
||||
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 { RATES_REPOSITORY } from './interfaces/rates.repository.interface';
|
||||
import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface';
|
||||
import { SHIPPING_LINES_REPOSITORY } from './interfaces/shipping-lines.repository.interface';
|
||||
import { SURCHARGE_TYPES_REPOSITORY } from './interfaces/surcharge-types.repository.interface';
|
||||
import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface';
|
||||
import { YARDS_REPOSITORY } from './interfaces/yards.repository.interface';
|
||||
|
||||
import { ApprovalRulesRepository } from './repositories/approval-rules.repository';
|
||||
import { CargoTypesRepository } from './repositories/cargo-types.repository';
|
||||
import { ContainerTypesRepository } from './repositories/container-types.repository';
|
||||
import { PriorityRulesRepository } from './repositories/priority-rules.repository';
|
||||
import { RatesRepository } from './repositories/rates.repository';
|
||||
import { ServiceTypesRepository } from './repositories/service-types.repository';
|
||||
import { ShippingLinesRepository } from './repositories/shipping-lines.repository';
|
||||
import { SurchargeTypesRepository } from './repositories/surcharge-types.repository';
|
||||
import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository';
|
||||
import { YardsRepository } from './repositories/yards.repository';
|
||||
|
||||
import { ApprovalRulesService } from './services/approval-rules.service';
|
||||
import { CargoTypesService } from './services/cargo-types.service';
|
||||
import { ContainerTypesService } from './services/container-types.service';
|
||||
import { PriorityRulesService } from './services/priority-rules.service';
|
||||
import { RatesService } from './services/rates.service';
|
||||
import { ServiceTypesService } from './services/service-types.service';
|
||||
import { ShippingLinesService } from './services/shipping-lines.service';
|
||||
import { SurchargeTypesService } from './services/surcharge-types.service';
|
||||
import { WeightLimitRulesService } from './services/weight-limit-rules.service';
|
||||
import { YardsService } from './services/yards.service';
|
||||
|
||||
import { RuleEngineService } from './rule-engine.service';
|
||||
|
||||
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -50,46 +70,62 @@ import { RuleEngineService } from './rule-engine.service';
|
||||
CargoType,
|
||||
ContainerType,
|
||||
PriorityRule,
|
||||
Surcharge,
|
||||
SurchargeType,
|
||||
ServiceType,
|
||||
WeightLimitRule,
|
||||
Yard,
|
||||
ShippingLine,
|
||||
Rate,
|
||||
ApprovalRule,
|
||||
BookingContainer,
|
||||
BookingCargoModifier,
|
||||
BookingApprovalStep,
|
||||
BookingRateSnapshot,
|
||||
]),
|
||||
],
|
||||
controllers: [
|
||||
CargoTypesController,
|
||||
ContainerTypesController,
|
||||
PriorityRulesController,
|
||||
SurchargesController,
|
||||
SurchargeTypesController,
|
||||
ServiceTypesController,
|
||||
WeightLimitRulesController,
|
||||
YardsController,
|
||||
ShippingLinesController,
|
||||
RatesController,
|
||||
ApprovalRulesController,
|
||||
],
|
||||
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
|
||||
YardsRepository,
|
||||
{ provide: YARDS_REPOSITORY, useExisting: YardsRepository },
|
||||
ShippingLinesRepository,
|
||||
{ provide: SHIPPING_LINES_REPOSITORY, useExisting: ShippingLinesRepository },
|
||||
RatesRepository,
|
||||
{ provide: RATES_REPOSITORY, useExisting: RatesRepository },
|
||||
ApprovalRulesRepository,
|
||||
{ provide: APPROVAL_RULES_REPOSITORY, useExisting: ApprovalRulesRepository },
|
||||
CargoTypesService,
|
||||
ContainerTypesService,
|
||||
PriorityRulesService,
|
||||
SurchargesService,
|
||||
SurchargeTypesService,
|
||||
ServiceTypesService,
|
||||
WeightLimitRulesService,
|
||||
// Evaluation engine
|
||||
YardsService,
|
||||
ShippingLinesService,
|
||||
RatesService,
|
||||
ApprovalRulesService,
|
||||
RuleEngineService,
|
||||
],
|
||||
exports: [
|
||||
@@ -98,9 +134,12 @@ import { RuleEngineService } from './rule-engine.service';
|
||||
ServiceTypesService,
|
||||
ContainerTypesService,
|
||||
SurchargeTypesService,
|
||||
SurchargesService,
|
||||
WeightLimitRulesService,
|
||||
PriorityRulesService,
|
||||
YardsService,
|
||||
ShippingLinesService,
|
||||
RatesService,
|
||||
ApprovalRulesService,
|
||||
],
|
||||
})
|
||||
export class RuleEngineModule {}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { Freight } from '@edr/types';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
|
||||
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
|
||||
import { TriggerCondition } from './entities/surcharge-type.entity';
|
||||
import {
|
||||
ICargoTypesRepository,
|
||||
CARGO_TYPES_REPOSITORY,
|
||||
@@ -9,10 +11,6 @@ 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,
|
||||
@@ -21,20 +19,64 @@ import {
|
||||
IPriorityRulesRepository,
|
||||
PRIORITY_RULES_REPOSITORY,
|
||||
} from './interfaces/priority-rules.repository.interface';
|
||||
import {
|
||||
ISurchargeTypesRepository,
|
||||
SURCHARGE_TYPES_REPOSITORY,
|
||||
} from './interfaces/surcharge-types.repository.interface';
|
||||
import {
|
||||
IRatesRepository,
|
||||
RATES_REPOSITORY,
|
||||
} from './interfaces/rates.repository.interface';
|
||||
import {
|
||||
IApprovalRulesRepository,
|
||||
APPROVAL_RULES_REPOSITORY,
|
||||
} from './interfaces/approval-rules.repository.interface';
|
||||
import {
|
||||
IShippingLinesRepository,
|
||||
SHIPPING_LINES_REPOSITORY,
|
||||
} from './interfaces/shipping-lines.repository.interface';
|
||||
|
||||
export interface AppliedSurcharge {
|
||||
feeName: string;
|
||||
rate: number;
|
||||
export interface BookingContainerEvalInput {
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
vgmPerUnitTons: number;
|
||||
totalVgmTons: number;
|
||||
isReefer?: boolean;
|
||||
isOverweight?: boolean;
|
||||
overweightExcessTons?: number | null;
|
||||
}
|
||||
|
||||
export interface BookingEvaluationInput {
|
||||
cargoTypeId: string;
|
||||
serviceTypeId: string;
|
||||
paymentCurrency: string;
|
||||
tradeDirection: string;
|
||||
isHazardous: boolean;
|
||||
allowConsolidation?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
containers: BookingContainerEvalInput[];
|
||||
}
|
||||
|
||||
export interface AppliedCargoModifier {
|
||||
surchargeTypeId: string;
|
||||
surchargeTypeCode: string;
|
||||
triggerValue: number | null;
|
||||
calculatedAmount: number;
|
||||
rateId: string;
|
||||
currency: string;
|
||||
calculationMethod: Freight.CalculationMethod;
|
||||
applyToRail: boolean;
|
||||
applyToFirstMile: boolean;
|
||||
applyToLastMile: boolean;
|
||||
}
|
||||
|
||||
export interface ContainerWeightResult {
|
||||
containerTypeId: string;
|
||||
weightLimitRuleId: string | null;
|
||||
isOverweight: boolean;
|
||||
overweightExcessTons: number | null;
|
||||
}
|
||||
|
||||
export interface RuleEvaluationResult {
|
||||
priorityScore: number;
|
||||
appliedSurcharges: AppliedSurcharge[];
|
||||
appliedModifiers: AppliedCargoModifier[];
|
||||
containerWeightResults: ContainerWeightResult[];
|
||||
warnings: string[];
|
||||
hardBlocked: string[];
|
||||
requiresDirectorApproval: boolean;
|
||||
@@ -47,150 +89,233 @@ export class RuleEngineService {
|
||||
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,
|
||||
@Inject(SURCHARGE_TYPES_REPOSITORY)
|
||||
private readonly surchargeTypesRepo: ISurchargeTypesRepository,
|
||||
@Inject(RATES_REPOSITORY)
|
||||
private readonly ratesRepo: IRatesRepository,
|
||||
@Inject(APPROVAL_RULES_REPOSITORY)
|
||||
private readonly approvalRulesRepo: IApprovalRulesRepository,
|
||||
@Inject(SHIPPING_LINES_REPOSITORY)
|
||||
private readonly shippingLinesRepo: IShippingLinesRepository,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
async evaluate(input: BookingEvaluationInput): Promise<RuleEvaluationResult> {
|
||||
const warnings: string[] = [];
|
||||
const hardBlocked: string[] = [];
|
||||
const appliedSurcharges: AppliedSurcharge[] = [];
|
||||
const appliedModifiers: AppliedCargoModifier[] = [];
|
||||
const containerWeightResults: ContainerWeightResult[] = [];
|
||||
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;
|
||||
}
|
||||
const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
|
||||
if (!cargoType) {
|
||||
hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`);
|
||||
} else 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 container of input.containers) {
|
||||
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
|
||||
container.containerTypeId,
|
||||
input.tradeDirection,
|
||||
);
|
||||
const rule = rules[0];
|
||||
let isOverweight = container.isOverweight ?? false;
|
||||
let excess = container.overweightExcessTons ?? null;
|
||||
|
||||
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) {
|
||||
if (rule) {
|
||||
const maxTotal = Number(rule.maxVgmTons) * container.quantity;
|
||||
const totalVgm = container.totalVgmTons;
|
||||
if (totalVgm > maxTotal) {
|
||||
isOverweight = true;
|
||||
excess = Math.max(0, totalVgm - maxTotal);
|
||||
warnings.push(
|
||||
`${container.type} container VGM ${container.vgm}t is approaching limit ` +
|
||||
`of ${rule.maxWeightTons}t (${booking.tradeDirection})`,
|
||||
`Container type ${container.containerTypeId} VGM ${totalVgm}t exceeds limit ${maxTotal}t`,
|
||||
);
|
||||
}
|
||||
containerWeightResults.push({
|
||||
containerTypeId: container.containerTypeId,
|
||||
weightLimitRuleId: rule.id,
|
||||
isOverweight,
|
||||
overweightExcessTons: excess,
|
||||
});
|
||||
} else {
|
||||
containerWeightResults.push({
|
||||
containerTypeId: container.containerTypeId,
|
||||
weightLimitRuleId: null,
|
||||
isOverweight,
|
||||
overweightExcessTons: excess,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Surcharge flags ───────────────────────────────────────────────
|
||||
if (booking.isHazardous) {
|
||||
const surcharge = await this.surchargesRepo.findByTypeCode('HAZARDOUS');
|
||||
if (surcharge) appliedSurcharges.push(this.mapSurcharge(surcharge));
|
||||
const serviceType = await this.serviceTypesRepo.findById(input.serviceTypeId);
|
||||
if (serviceType) {
|
||||
priorityScore += serviceType.priorityBonusPoints;
|
||||
}
|
||||
|
||||
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;
|
||||
if (
|
||||
rule.conditionCurrency === null ||
|
||||
rule.conditionCurrency === input.paymentCurrency
|
||||
) {
|
||||
priorityScore += rule.score;
|
||||
}
|
||||
}
|
||||
|
||||
return { priorityScore, appliedSurcharges, warnings, hardBlocked, requiresDirectorApproval };
|
||||
let shippingLineMapped = false;
|
||||
if (input.shippingLineId) {
|
||||
const line = await this.shippingLinesRepo.findById(input.shippingLineId);
|
||||
shippingLineMapped = Boolean(line?.mappedToCode);
|
||||
}
|
||||
|
||||
const hasReefer = input.containers.some((c) => c.isReefer);
|
||||
const hasOverweight = containerWeightResults.some((r) => r.isOverweight);
|
||||
|
||||
const surchargeTypes = await this.surchargeTypesRepo.findAllActiveWithRate();
|
||||
const liveRates = await this.ratesRepo.findLiveRates();
|
||||
const rateById = new Map(liveRates.map((r) => [r.id, r]));
|
||||
|
||||
for (const st of surchargeTypes) {
|
||||
const triggered = this.matchesTrigger(st.triggerCondition, {
|
||||
isHazardous: input.isHazardous,
|
||||
hasReefer,
|
||||
hasOverweight,
|
||||
shippingLineMapped,
|
||||
allowConsolidation: input.allowConsolidation ?? false,
|
||||
});
|
||||
if (!triggered) continue;
|
||||
|
||||
const rate = st.rate ?? rateById.get(st.rateId);
|
||||
if (!rate) continue;
|
||||
|
||||
let triggerValue: number | null = null;
|
||||
let calculatedAmount = Number(rate.rateValue);
|
||||
|
||||
if (st.triggerCondition === 'VGM_EXCEEDS_LIMIT') {
|
||||
triggerValue = containerWeightResults.reduce(
|
||||
(sum, r) => sum + (r.overweightExcessTons ?? 0),
|
||||
0,
|
||||
);
|
||||
if (rate.rateUnit === 'PER_TON') {
|
||||
calculatedAmount = triggerValue * Number(rate.rateValue);
|
||||
}
|
||||
}
|
||||
|
||||
appliedModifiers.push({
|
||||
surchargeTypeId: st.id,
|
||||
surchargeTypeCode: st.code,
|
||||
triggerValue,
|
||||
calculatedAmount,
|
||||
rateId: rate.id,
|
||||
currency: rate.currency,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
priorityScore,
|
||||
appliedModifiers,
|
||||
containerWeightResults,
|
||||
warnings,
|
||||
hardBlocked,
|
||||
requiresDirectorApproval,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard helper — throws BadRequestException if hardBlocked is non-empty.
|
||||
* Call this immediately after evaluate() in BookingsService.
|
||||
* Instantiate booking_approval_step rows from approval_rules for a cargo type.
|
||||
*/
|
||||
async instantiateApprovalSteps(bookingId: string, cargoTypeId: string): Promise<BookingApprovalStep[]> {
|
||||
const cargoType = await this.cargoTypesRepo.findById(cargoTypeId);
|
||||
if (!cargoType) {
|
||||
throw new BadRequestException(`Cargo type ${cargoTypeId} not found`);
|
||||
}
|
||||
|
||||
const chain = await this.approvalRulesRepo.findChainForCargo(
|
||||
cargoType.requiresDirectorApproval,
|
||||
);
|
||||
|
||||
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
|
||||
const steps: BookingApprovalStep[] = [];
|
||||
|
||||
for (const rule of chain) {
|
||||
const step = stepRepo.create({
|
||||
bookingId,
|
||||
approvalRuleId: rule.id,
|
||||
stepOrder: rule.stepOrder,
|
||||
requiredRole: rule.requiredRole,
|
||||
status: 'PENDING',
|
||||
});
|
||||
steps.push(await stepRepo.save(step));
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot all LIVE rates into booking_rate_snapshot for a booking.
|
||||
*/
|
||||
async snapshotLiveRates(bookingId: string): Promise<BookingRateSnapshot[]> {
|
||||
const liveRates = await this.ratesRepo.findLiveRates();
|
||||
const snapshotRepo = this.dataSource.getRepository(BookingRateSnapshot);
|
||||
const now = new Date();
|
||||
const snapshots: BookingRateSnapshot[] = [];
|
||||
|
||||
for (const rate of liveRates) {
|
||||
const snapshot = snapshotRepo.create({
|
||||
bookingId,
|
||||
rateId: rate.id,
|
||||
rateType: rate.rateType,
|
||||
rateValue: rate.rateValue,
|
||||
rateUnit: rate.rateUnit,
|
||||
currency: rate.currency,
|
||||
snapshottedAt: now,
|
||||
});
|
||||
snapshots.push(await snapshotRepo.save(snapshot));
|
||||
}
|
||||
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
/** Guard helper — throws BadRequestException if hardBlocked is non-empty. */
|
||||
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,
|
||||
};
|
||||
private matchesTrigger(
|
||||
condition: TriggerCondition,
|
||||
state: {
|
||||
isHazardous: boolean;
|
||||
hasReefer: boolean;
|
||||
hasOverweight: boolean;
|
||||
shippingLineMapped: boolean;
|
||||
allowConsolidation: boolean;
|
||||
},
|
||||
): boolean {
|
||||
switch (condition) {
|
||||
case 'CARGO_FLAG_HAZARDOUS':
|
||||
return state.isHazardous;
|
||||
case 'CARGO_FLAG_REEFER':
|
||||
return state.hasReefer;
|
||||
case 'VGM_EXCEEDS_LIMIT':
|
||||
return state.hasOverweight;
|
||||
case 'SHIPPING_LINE_MAPPED':
|
||||
return state.shippingLineMapped;
|
||||
case 'CONSOLIDATION_ENABLED':
|
||||
return state.allowConsolidation;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
||||
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
|
||||
import { ApprovalRule } from '../entities/approval-rule.entity';
|
||||
import {
|
||||
APPROVAL_RULES_REPOSITORY,
|
||||
IApprovalRulesRepository,
|
||||
} from '../interfaces/approval-rules.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class ApprovalRulesService {
|
||||
constructor(
|
||||
@Inject(APPROVAL_RULES_REPOSITORY)
|
||||
private readonly repository: IApprovalRulesRepository,
|
||||
) {}
|
||||
|
||||
/** List approval rules. */
|
||||
async findAll(filter: {
|
||||
requiresDirectorApproval?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ data: ApprovalRule[]; 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.requiresDirectorApproval !== undefined) {
|
||||
where.requiresDirectorApproval = filter.requiresDirectorApproval;
|
||||
}
|
||||
|
||||
const [data, total] = await this.repository.findAndCount({
|
||||
where,
|
||||
order: { requiresDirectorApproval: 'ASC', stepOrder: 'ASC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
|
||||
}
|
||||
|
||||
/** Get approval chain for a cargo type flag. */
|
||||
async findChain(requiresDirectorApproval: boolean): Promise<ApprovalRule[]> {
|
||||
return this.repository.findChainForCargo(requiresDirectorApproval);
|
||||
}
|
||||
|
||||
/** Get an approval rule by ID. */
|
||||
async findById(id: string): Promise<ApprovalRule> {
|
||||
const entity = await this.repository.findById(id);
|
||||
if (!entity) throw new NotFoundException(`Approval rule ${id} not found`);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/** Create an approval rule step. */
|
||||
async create(dto: CreateApprovalRuleDto): Promise<ApprovalRule> {
|
||||
return this.repository.create({
|
||||
requiresDirectorApproval: dto.requiresDirectorApproval,
|
||||
stepOrder: dto.stepOrder,
|
||||
requiredRole: dto.requiredRole,
|
||||
actionLabel: dto.actionLabel,
|
||||
blocksRole: dto.blocksRole,
|
||||
});
|
||||
}
|
||||
|
||||
/** Update an approval rule. */
|
||||
async update(id: string, dto: UpdateApprovalRuleDto): Promise<ApprovalRule> {
|
||||
await this.findById(id);
|
||||
const updated = await this.repository.update(id, dto);
|
||||
if (!updated) throw new NotFoundException(`Approval rule ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Soft-delete an approval rule. */
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.repository.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export class ContainerTypesService {
|
||||
|
||||
const [data, total] = await this.repository.findAndCount({
|
||||
where,
|
||||
order: { sizeCode: 'ASC' },
|
||||
order: { code: 'ASC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
@@ -43,23 +43,27 @@ export class ContainerTypesService {
|
||||
|
||||
/** 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`);
|
||||
const existing = await this.repository.findByCode(dto.code);
|
||||
if (existing) throw new ConflictException(`Container type with code "${dto.code}" already exists`);
|
||||
return this.repository.create({
|
||||
sizeCode: dto.sizeCode,
|
||||
description: dto.description ?? null,
|
||||
containersPerWagon: dto.containersPerWagon,
|
||||
code: dto.code,
|
||||
label: dto.label,
|
||||
sizeFt: dto.sizeFt,
|
||||
wagonsPerUnit: dto.wagonsPerUnit,
|
||||
isReefer: dto.isReefer ?? false,
|
||||
isOpenTop: dto.isOpenTop ?? false,
|
||||
isActive: dto.isActive ?? true,
|
||||
displayOrder: dto.displayOrder ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 (dto.code) {
|
||||
const conflict = await this.repository.findByCode(dto.code);
|
||||
if (conflict && conflict.id !== id) {
|
||||
throw new ConflictException(`Container type with sizeCode "${dto.sizeCode}" already exists`);
|
||||
throw new ConflictException(`Container type with code "${dto.code}" already exists`);
|
||||
}
|
||||
}
|
||||
const updated = await this.repository.update(id, dto);
|
||||
|
||||
@@ -27,7 +27,7 @@ export class PriorityRulesService {
|
||||
|
||||
const [data, total] = await this.repository.findAndCount({
|
||||
where,
|
||||
order: { priorityType: 'ASC' },
|
||||
order: { code: 'ASC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
@@ -43,16 +43,15 @@ export class PriorityRulesService {
|
||||
|
||||
/** Create a new priority rule. */
|
||||
async create(dto: CreatePriorityRuleDto): Promise<PriorityRule> {
|
||||
const existing = await this.repository.findAll({ where: { priorityType: dto.priorityType } });
|
||||
const existing = await this.repository.findAll({ where: { code: dto.code } });
|
||||
if (existing.length > 0) {
|
||||
throw new ConflictException(`Priority rule for type "${dto.priorityType}" already exists`);
|
||||
throw new ConflictException(`Priority rule with code "${dto.code}" already exists`);
|
||||
}
|
||||
return this.repository.create({
|
||||
priorityType: dto.priorityType,
|
||||
ruleName: dto.ruleName,
|
||||
description: dto.description ?? null,
|
||||
activationCondition: dto.activationCondition ?? null,
|
||||
bonusPoints: dto.bonusPoints,
|
||||
code: dto.code,
|
||||
label: dto.label,
|
||||
score: dto.score,
|
||||
conditionCurrency: dto.conditionCurrency ?? null,
|
||||
isActive: dto.isActive ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ApproveRateDto, CreateRateDto } from '../dto/create-rate.dto';
|
||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||
import { Rate } from '../entities/rate.entity';
|
||||
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class RatesService {
|
||||
constructor(
|
||||
@Inject(RATES_REPOSITORY)
|
||||
private readonly repository: IRatesRepository,
|
||||
) {}
|
||||
|
||||
/** List rates with pagination. */
|
||||
async findAll(filter: {
|
||||
status?: string;
|
||||
rateType?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ data: Rate[]; 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.status) where.status = filter.status;
|
||||
if (filter.rateType) where.rateType = filter.rateType;
|
||||
|
||||
const [data, total] = await this.repository.findAndCount({
|
||||
where,
|
||||
order: { effectiveFrom: 'DESC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
|
||||
}
|
||||
|
||||
/** Return all currently LIVE rates. */
|
||||
async findLiveRates(): Promise<Rate[]> {
|
||||
return this.repository.findLiveRates();
|
||||
}
|
||||
|
||||
/** Get a rate by ID. */
|
||||
async findById(id: string): Promise<Rate> {
|
||||
const entity = await this.repository.findById(id);
|
||||
if (!entity) throw new NotFoundException(`Rate ${id} not found`);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/** Create a rate in DRAFT status. */
|
||||
async create(dto: CreateRateDto): Promise<Rate> {
|
||||
return this.repository.create({
|
||||
rateType: dto.rateType as Rate['rateType'],
|
||||
containerTypeId: dto.containerTypeId,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
currency: dto.currency,
|
||||
rateValue: dto.rateValue,
|
||||
rateUnit: dto.rateUnit as Rate['rateUnit'],
|
||||
status: 'DRAFT',
|
||||
proposedByStaffId: dto.proposedByStaffId,
|
||||
effectiveFrom: new Date(dto.effectiveFrom),
|
||||
effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a DRAFT rate. */
|
||||
async update(id: string, dto: UpdateRateDto): Promise<Rate> {
|
||||
const existing = await this.findById(id);
|
||||
if (existing.status !== 'DRAFT') {
|
||||
throw new BadRequestException('Only DRAFT rates can be updated');
|
||||
}
|
||||
const updates: Partial<Rate> = {};
|
||||
if (dto.rateType) updates.rateType = dto.rateType as Rate['rateType'];
|
||||
if (dto.containerTypeId !== undefined) updates.containerTypeId = dto.containerTypeId;
|
||||
if (dto.tradeDirection !== undefined) updates.tradeDirection = dto.tradeDirection;
|
||||
if (dto.currency) updates.currency = dto.currency;
|
||||
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
||||
if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit'];
|
||||
if (dto.proposedByStaffId) updates.proposedByStaffId = dto.proposedByStaffId;
|
||||
if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom);
|
||||
if (dto.effectiveTo) updates.effectiveTo = new Date(dto.effectiveTo);
|
||||
const updated = await this.repository.update(id, updates);
|
||||
if (!updated) throw new NotFoundException(`Rate ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Submit a DRAFT rate for CEO approval. */
|
||||
async submitForApproval(id: string): Promise<Rate> {
|
||||
const rate = await this.findById(id);
|
||||
if (rate.status !== 'DRAFT') {
|
||||
throw new BadRequestException('Only DRAFT rates can be submitted for approval');
|
||||
}
|
||||
const updated = await this.repository.update(id, { status: 'PENDING_APPROVAL' });
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/** CEO approves a rate — moves to LIVE. */
|
||||
async approve(id: string, dto: ApproveRateDto): Promise<Rate> {
|
||||
const rate = await this.findById(id);
|
||||
if (rate.status !== 'PENDING_APPROVAL') {
|
||||
throw new BadRequestException('Only PENDING_APPROVAL rates can be approved');
|
||||
}
|
||||
const updated = await this.repository.update(id, {
|
||||
status: 'LIVE',
|
||||
approvedByCeoId: dto.approvedByCeoId,
|
||||
approvedAt: new Date(),
|
||||
});
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/** Soft-delete a rate. */
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.repository.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
|
||||
import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto';
|
||||
import { ShippingLine } from '../entities/shipping-line.entity';
|
||||
import {
|
||||
IShippingLinesRepository,
|
||||
SHIPPING_LINES_REPOSITORY,
|
||||
} from '../interfaces/shipping-lines.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class ShippingLinesService {
|
||||
constructor(
|
||||
@Inject(SHIPPING_LINES_REPOSITORY)
|
||||
private readonly repository: IShippingLinesRepository,
|
||||
) {}
|
||||
|
||||
/** List shipping lines with pagination. */
|
||||
async findAll(filter: {
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ data: ShippingLine[]; 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: { code: 'ASC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
|
||||
}
|
||||
|
||||
/** Get a shipping line by ID. */
|
||||
async findById(id: string): Promise<ShippingLine> {
|
||||
const entity = await this.repository.findById(id);
|
||||
if (!entity) throw new NotFoundException(`Shipping line ${id} not found`);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/** Create a shipping line. */
|
||||
async create(dto: CreateShippingLineDto): Promise<ShippingLine> {
|
||||
const existing = await this.repository.findByCode(dto.code);
|
||||
if (existing) throw new ConflictException(`Shipping line with code "${dto.code}" already exists`);
|
||||
return this.repository.create({
|
||||
code: dto.code,
|
||||
label: dto.label,
|
||||
mappedToCode: dto.mappedToCode,
|
||||
showExtraFeeNotice: dto.showExtraFeeNotice ?? false,
|
||||
isActive: dto.isActive ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a shipping line. */
|
||||
async update(id: string, dto: UpdateShippingLineDto): Promise<ShippingLine> {
|
||||
await this.findById(id);
|
||||
if (dto.code) {
|
||||
const conflict = await this.repository.findByCode(dto.code);
|
||||
if (conflict && conflict.id !== id) {
|
||||
throw new ConflictException(`Shipping line with code "${dto.code}" already exists`);
|
||||
}
|
||||
}
|
||||
const updated = await this.repository.update(id, dto);
|
||||
if (!updated) throw new NotFoundException(`Shipping line ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Soft-delete a shipping line. */
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.repository.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export class SurchargeTypesService {
|
||||
|
||||
const [data, total] = await this.repository.findAndCount({
|
||||
where,
|
||||
order: { name: 'ASC' },
|
||||
order: { label: 'ASC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
@@ -47,8 +47,9 @@ export class SurchargeTypesService {
|
||||
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,
|
||||
label: dto.label,
|
||||
triggerCondition: dto.triggerCondition as SurchargeType['triggerCondition'],
|
||||
rateId: dto.rateId,
|
||||
isActive: dto.isActive ?? true,
|
||||
});
|
||||
}
|
||||
@@ -62,7 +63,13 @@ export class SurchargeTypesService {
|
||||
throw new ConflictException(`Surcharge type with code "${dto.code}" already exists`);
|
||||
}
|
||||
}
|
||||
const updated = await this.repository.update(id, dto);
|
||||
const patch: Partial<SurchargeType> = {};
|
||||
if (dto.code !== undefined) patch.code = dto.code;
|
||||
if (dto.label !== undefined) patch.label = dto.label;
|
||||
if (dto.triggerCondition !== undefined) patch.triggerCondition = dto.triggerCondition as SurchargeType['triggerCondition'];
|
||||
if (dto.rateId !== undefined) patch.rateId = dto.rateId;
|
||||
if (dto.isActive !== undefined) patch.isActive = dto.isActive;
|
||||
const updated = await this.repository.update(id, patch);
|
||||
if (!updated) throw new NotFoundException(`Surcharge type ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { 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';
|
||||
@@ -16,20 +16,21 @@ export class WeightLimitRulesService {
|
||||
|
||||
/** List weight limit rules with pagination. */
|
||||
async findAll(filter: {
|
||||
isActive?: boolean;
|
||||
containerTypeId?: string;
|
||||
tradeDirection?: 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;
|
||||
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
|
||||
|
||||
const [data, total] = await this.repository.findAndCount({
|
||||
where,
|
||||
relations: { containerType: true, surcharge: { surchargeType: true } },
|
||||
relations: { containerType: true },
|
||||
order: { effectiveFrom: 'DESC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
@@ -45,27 +46,25 @@ export class WeightLimitRulesService {
|
||||
|
||||
/** 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,
|
||||
maxVgmTons: dto.maxVgmTons,
|
||||
effectiveFrom: new Date(dto.effectiveFrom),
|
||||
effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : null,
|
||||
});
|
||||
}
|
||||
|
||||
/** 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);
|
||||
await this.findById(id);
|
||||
const patch: Partial<WeightLimitRule> = {};
|
||||
if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId;
|
||||
if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection;
|
||||
if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons;
|
||||
if (dto.effectiveFrom !== undefined) patch.effectiveFrom = new Date(dto.effectiveFrom);
|
||||
if (dto.effectiveTo !== undefined) patch.effectiveTo = new Date(dto.effectiveTo);
|
||||
const updated = await this.repository.update(id, patch);
|
||||
if (!updated) throw new NotFoundException(`Weight limit rule ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CreateYardDto } from '../dto/create-yard.dto';
|
||||
import { UpdateYardDto } from '../dto/update-yard.dto';
|
||||
import { Yard } from '../entities/yard.entity';
|
||||
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class YardsService {
|
||||
constructor(
|
||||
@Inject(YARDS_REPOSITORY)
|
||||
private readonly repository: IYardsRepository,
|
||||
) {}
|
||||
|
||||
/** List yards with pagination. */
|
||||
async findAll(filter: {
|
||||
isActive?: boolean;
|
||||
country?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ data: Yard[]; 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.country) where.country = filter.country;
|
||||
|
||||
const [data, total] = await this.repository.findAndCount({
|
||||
where,
|
||||
order: { displayOrder: 'ASC', code: 'ASC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
|
||||
}
|
||||
|
||||
/** Get a yard by ID. */
|
||||
async findById(id: string): Promise<Yard> {
|
||||
const entity = await this.repository.findById(id);
|
||||
if (!entity) throw new NotFoundException(`Yard ${id} not found`);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/** Create a yard. */
|
||||
async create(dto: CreateYardDto): Promise<Yard> {
|
||||
const existing = await this.repository.findByCode(dto.code);
|
||||
if (existing) throw new ConflictException(`Yard with code "${dto.code}" already exists`);
|
||||
return this.repository.create({
|
||||
code: dto.code,
|
||||
label: dto.label,
|
||||
country: dto.country,
|
||||
isActive: dto.isActive ?? true,
|
||||
displayOrder: dto.displayOrder ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a yard. */
|
||||
async update(id: string, dto: UpdateYardDto): Promise<Yard> {
|
||||
await this.findById(id);
|
||||
if (dto.code) {
|
||||
const conflict = await this.repository.findByCode(dto.code);
|
||||
if (conflict && conflict.id !== id) {
|
||||
throw new ConflictException(`Yard with code "${dto.code}" already exists`);
|
||||
}
|
||||
}
|
||||
const updated = await this.repository.update(id, dto);
|
||||
if (!updated) throw new NotFoundException(`Yard ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Soft-delete a yard. */
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.repository.softDelete(id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user