complete rule engine and booking flow

This commit is contained in:
marshal
2026-05-30 10:27:59 +03:00
parent 800f036005
commit 430dc44937
74 changed files with 4304 additions and 1248 deletions

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,51 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { 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);
}
}

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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()

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateShippingLineDto } from './create-shipping-line.dto';
export class UpdateShippingLineDto extends PartialType(CreateShippingLineDto) {}

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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[];
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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');

View File

@@ -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>;

View File

@@ -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');

View File

@@ -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');

View File

@@ -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>;

View File

@@ -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');

View File

@@ -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[]>;

View File

@@ -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');

View File

@@ -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);
}
}

View File

@@ -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[]> {

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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,
});
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}