mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
changes
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ReplacePriorityRulesWithPriorityConfigs1783000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.priority_configs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
type VARCHAR(20) NOT NULL CHECK (type IN ('WAGON', 'CURRENCY')),
|
||||
label VARCHAR(100) NOT NULL,
|
||||
currency VARCHAR(5) NULL,
|
||||
min_wagon_count INT NOT NULL,
|
||||
max_wagon_count INT NOT NULL,
|
||||
score_points INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT false,
|
||||
display_order INT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT chk_wagon_range CHECK (min_wagon_count <= max_wagon_count),
|
||||
CONSTRAINT chk_currency_for_type CHECK (
|
||||
(type = 'WAGON' AND currency IS NULL) OR
|
||||
(type = 'CURRENCY' AND currency IS NOT NULL)
|
||||
)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_priority_configs_type_active ON freight.priority_configs (type, is_active);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_priority_configs_currency_type ON freight.priority_configs (currency, type);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.priority_configs;`);
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,17 @@ export class BookingPricingService {
|
||||
};
|
||||
}),
|
||||
);
|
||||
// Wagon count is persisted per container line at booking creation; sum it.
|
||||
const totalWagons =
|
||||
booking.freightType === 'CONTAINER'
|
||||
? Math.ceil(
|
||||
(booking.bookingContainers ?? []).reduce(
|
||||
(sum, bc) => sum + Number(bc.wagonsRequired ?? 0),
|
||||
0,
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: booking.cargoTypeId ?? null,
|
||||
@@ -192,6 +203,7 @@ export class BookingPricingService {
|
||||
isGovernment: booking.isGovernment,
|
||||
allowConsolidation: booking.allowConsolidation,
|
||||
shippingLineId: booking.shippingLineId,
|
||||
totalWagons,
|
||||
containers,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,9 +120,13 @@ export class BookingsService {
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
totalVgmTons,
|
||||
isReefer: ct.isReefer,
|
||||
wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const totalWagons = Math.ceil(
|
||||
containers.reduce((sum, c) => sum + c.wagonsRequired, 0),
|
||||
);
|
||||
|
||||
return {
|
||||
freightType: dto.freightType,
|
||||
@@ -135,6 +139,7 @@ export class BookingsService {
|
||||
allowConsolidation:
|
||||
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
totalWagons,
|
||||
containers,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
|
||||
import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
|
||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
import { PriorityConfigsService } from '../services/priority-configs.service';
|
||||
|
||||
@ApiTags('priority-configs')
|
||||
@Controller('priority-configs')
|
||||
@ApiBearerAuth()
|
||||
export class PriorityConfigsController {
|
||||
constructor(private readonly service: PriorityConfigsService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('priority-configs')
|
||||
@ApiOperation({ summary: 'List priority configs' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
type: (query['type'] as 'WAGON' | 'CURRENCY') || undefined,
|
||||
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')
|
||||
@RuleEngineView('priority-configs')
|
||||
@ApiOperation({ summary: 'Get a priority config by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('priority-configs')
|
||||
@ApiOperation({ summary: 'Create a priority config' })
|
||||
create(@Body() dto: CreatePriorityConfigDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Post('reorder')
|
||||
@RuleEngineManage('priority-configs')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Bulk reorder priority configs by ID list' })
|
||||
reorder(@Body() dto: ReorderItemsDto) {
|
||||
return this.service.reorder(dto.ids);
|
||||
}
|
||||
|
||||
@Post(':id/move-order')
|
||||
@RuleEngineManage('priority-configs')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Move a priority config up or down in display order' })
|
||||
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
||||
return this.service.moveOrder(id, dto.direction);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('priority-configs')
|
||||
@ApiOperation({ summary: 'Update a priority config' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityConfigDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('priority-configs')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a priority config' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto';
|
||||
import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto';
|
||||
import { PriorityRulesService } from '../services/priority-rules.service';
|
||||
|
||||
@ApiTags('priority-rules')
|
||||
@Controller('priority-rules')
|
||||
@ApiBearerAuth()
|
||||
export class PriorityRulesController {
|
||||
constructor(private readonly service: PriorityRulesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('priority-rules')
|
||||
@ApiOperation({ summary: 'List priority rules' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('priority-rules')
|
||||
@ApiOperation({ summary: 'Get a priority rule by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('priority-rules')
|
||||
@ApiOperation({ summary: 'Create a priority rule' })
|
||||
create(@Body() dto: CreatePriorityRuleDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('priority-rules')
|
||||
@ApiOperation({ summary: 'Update a priority rule' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityRuleDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('priority-rules')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a priority rule' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreatePriorityConfigDto {
|
||||
@ApiProperty({ description: 'Config type: WAGON or CURRENCY', enum: ['WAGON', 'CURRENCY'] })
|
||||
@IsIn(['WAGON', 'CURRENCY'])
|
||||
type!: 'WAGON' | 'CURRENCY';
|
||||
|
||||
@ApiProperty({ description: 'Human-readable label', maxLength: 100 })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
label!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Currency code (e.g., USD, ETB). Required for type=CURRENCY, must be null for type=WAGON',
|
||||
maxLength: 5,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5)
|
||||
currency?: string;
|
||||
|
||||
@ApiProperty({ description: 'Minimum wagon count in range (inclusive)' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
minWagonCount!: number;
|
||||
|
||||
@ApiProperty({ description: 'Maximum wagon count in range (inclusive)' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
maxWagonCount!: number;
|
||||
|
||||
@ApiProperty({ description: 'Points awarded when booking matches this rule', default: 0 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
scorePoints!: number;
|
||||
|
||||
@ApiPropertyOptional({ default: false, description: 'Feature flag — toggle without code deploy' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreatePriorityRuleDto {
|
||||
@ApiProperty({ description: 'Human-readable label', maxLength: 100 })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
label!: string;
|
||||
|
||||
@ApiProperty({ description: 'Points added to booking.priority_score when condition matches', default: 0 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
score!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'If set, rule only matches bookings with this payment currency (e.g. USD). Null = matches all.',
|
||||
maxLength: 5,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5)
|
||||
conditionCurrency?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: false, description: 'Feature flag — toggle without code deploy' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreatePriorityConfigDto } from './create-priority-config.dto';
|
||||
|
||||
export class UpdatePriorityConfigDto extends PartialType(CreatePriorityConfigDto) {}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreatePriorityRuleDto } from './create-priority-rule.dto';
|
||||
|
||||
export class UpdatePriorityRuleDto extends PartialType(CreatePriorityRuleDto) {}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'priority_configs' })
|
||||
@Index(['type', 'isActive'])
|
||||
@Index(['currency', 'type'])
|
||||
export class PriorityConfig extends BaseEntity {
|
||||
@Column({ name: 'type', type: 'varchar', length: 20 })
|
||||
type!: 'WAGON' | 'CURRENCY';
|
||||
|
||||
@Column({ name: 'label', type: 'varchar', length: 100 })
|
||||
label!: string;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 5, nullable: true })
|
||||
currency?: string | null;
|
||||
|
||||
@Column({ name: 'min_wagon_count', type: 'int' })
|
||||
minWagonCount!: number;
|
||||
|
||||
@Column({ name: 'max_wagon_count', type: 'int' })
|
||||
maxWagonCount!: number;
|
||||
|
||||
@Column({ name: 'score_points', type: 'int', default: 0 })
|
||||
scorePoints!: number;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: false })
|
||||
isActive!: boolean;
|
||||
|
||||
@Column({ name: 'display_order', type: 'int', default: 1 })
|
||||
displayOrder!: number;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'priority_rules' })
|
||||
@Index(['code'])
|
||||
@Index(['isActive'])
|
||||
export class PriorityRule extends BaseEntity {
|
||||
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'label', type: 'varchar', length: 100, nullable: true })
|
||||
label!: string;
|
||||
|
||||
@Column({ name: 'score', type: 'int', default: 0, nullable: true })
|
||||
score!: number;
|
||||
|
||||
@Column({ name: 'condition_currency', type: 'varchar', length: 5, nullable: true })
|
||||
conditionCurrency?: string | null;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: false })
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { PriorityConfig } from '../entities/priority-config.entity';
|
||||
|
||||
export const PRIORITY_CONFIGS_REPOSITORY = Symbol('PRIORITY_CONFIGS_REPOSITORY');
|
||||
|
||||
export interface IPriorityConfigsRepository {
|
||||
findById(id: string): Promise<PriorityConfig | null>;
|
||||
findAll(options?: FindManyOptions<PriorityConfig>): Promise<PriorityConfig[]>;
|
||||
findAndCount(options?: FindManyOptions<PriorityConfig>): Promise<[PriorityConfig[], number]>;
|
||||
findAllActive(): Promise<PriorityConfig[]>;
|
||||
create(data: Partial<PriorityConfig>): Promise<PriorityConfig>;
|
||||
update(id: string, data: Partial<PriorityConfig>): Promise<PriorityConfig | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { PriorityRule } from '../entities/priority-rule.entity';
|
||||
|
||||
export interface IPriorityRulesRepository {
|
||||
findById(id: string): Promise<PriorityRule | null>;
|
||||
findAllActive(): Promise<PriorityRule[]>;
|
||||
findAll(options?: FindManyOptions<PriorityRule>): Promise<PriorityRule[]>;
|
||||
findAndCount(options?: FindManyOptions<PriorityRule>): Promise<[PriorityRule[], number]>;
|
||||
create(data: Partial<PriorityRule>): Promise<PriorityRule>;
|
||||
update(id: string, data: Partial<PriorityRule>): Promise<PriorityRule | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const PRIORITY_RULES_REPOSITORY = Symbol('PRIORITY_RULES_REPOSITORY');
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||
import { PriorityConfig } from '../entities/priority-config.entity';
|
||||
import { IPriorityConfigsRepository } from '../interfaces/priority-configs.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PriorityConfigsRepository implements IPriorityConfigsRepository {
|
||||
private readonly repo: Repository<PriorityConfig>;
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {
|
||||
this.repo = this.dataSource.getRepository(PriorityConfig);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<PriorityConfig | null> {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findAll(options?: FindManyOptions<PriorityConfig>): Promise<PriorityConfig[]> {
|
||||
return this.repo.find(options);
|
||||
}
|
||||
|
||||
async findAndCount(options?: FindManyOptions<PriorityConfig>): Promise<[PriorityConfig[], number]> {
|
||||
return this.repo.findAndCount(options);
|
||||
}
|
||||
|
||||
async findAllActive(): Promise<PriorityConfig[]> {
|
||||
return this.repo.find({
|
||||
where: { isActive: true },
|
||||
order: { displayOrder: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Partial<PriorityConfig>): Promise<PriorityConfig> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<PriorityConfig>): Promise<PriorityConfig | null> {
|
||||
await this.repo.update(id, data);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async softDelete(id: string): Promise<void> {
|
||||
await this.repo.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||
import { PriorityRule } from '../entities/priority-rule.entity';
|
||||
import { IPriorityRulesRepository } from '../interfaces/priority-rules.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PriorityRulesRepository implements IPriorityRulesRepository {
|
||||
private readonly repo: Repository<PriorityRule>;
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {
|
||||
this.repo = this.dataSource.getRepository(PriorityRule);
|
||||
}
|
||||
|
||||
findById(id: string): Promise<PriorityRule | null> {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
findAllActive(): Promise<PriorityRule[]> {
|
||||
return this.repo.find({ where: { isActive: true } });
|
||||
}
|
||||
|
||||
findAll(options?: FindManyOptions<PriorityRule>): Promise<PriorityRule[]> {
|
||||
return this.repo.find(options);
|
||||
}
|
||||
|
||||
findAndCount(options?: FindManyOptions<PriorityRule>): Promise<[PriorityRule[], number]> {
|
||||
return this.repo.findAndCount(options);
|
||||
}
|
||||
|
||||
async create(data: Partial<PriorityRule>): Promise<PriorityRule> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<PriorityRule>): Promise<PriorityRule | null> {
|
||||
await this.repo.update(id, data);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async softDelete(id: string): Promise<void> {
|
||||
await this.repo.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
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 { PriorityConfigsController } from './controllers/priority-configs.controller';
|
||||
import { RatesController } from './controllers/rates.controller';
|
||||
import { ServiceTypesController } from './controllers/service-types.controller';
|
||||
import { ShippingLinesController } from './controllers/shipping-lines.controller';
|
||||
@@ -15,7 +15,7 @@ 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 { PriorityConfig } from './entities/priority-config.entity';
|
||||
import { Rate } from './entities/rate.entity';
|
||||
import { ServiceType } from './entities/service-type.entity';
|
||||
import { ShippingLine } from './entities/shipping-line.entity';
|
||||
@@ -26,7 +26,7 @@ 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 { PRIORITY_CONFIGS_REPOSITORY } from './interfaces/priority-configs.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';
|
||||
@@ -37,7 +37,7 @@ 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 { PriorityConfigsRepository } from './repositories/priority-configs.repository';
|
||||
import { RatesRepository } from './repositories/rates.repository';
|
||||
import { ServiceTypesRepository } from './repositories/service-types.repository';
|
||||
import { ShippingLinesRepository } from './repositories/shipping-lines.repository';
|
||||
@@ -49,7 +49,7 @@ import { ApprovalRulesService } from './services/approval-rules.service';
|
||||
import { DisplayOrderService } from './services/display-order.service';
|
||||
import { CargoTypesService } from './services/cargo-types.service';
|
||||
import { ContainerTypesService } from './services/container-types.service';
|
||||
import { PriorityRulesService } from './services/priority-rules.service';
|
||||
import { PriorityConfigsService } from './services/priority-configs.service';
|
||||
import { RatesService } from './services/rates.service';
|
||||
import { ServiceTypesService } from './services/service-types.service';
|
||||
import { ShippingLinesService } from './services/shipping-lines.service';
|
||||
@@ -70,7 +70,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
TypeOrmModule.forFeature([
|
||||
CargoType,
|
||||
ContainerType,
|
||||
PriorityRule,
|
||||
PriorityConfig,
|
||||
SurchargeType,
|
||||
ServiceType,
|
||||
WeightLimitRule,
|
||||
@@ -87,7 +87,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
controllers: [
|
||||
CargoTypesController,
|
||||
ContainerTypesController,
|
||||
PriorityRulesController,
|
||||
PriorityConfigsController,
|
||||
SurchargeTypesController,
|
||||
ServiceTypesController,
|
||||
WeightLimitRulesController,
|
||||
@@ -101,8 +101,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
{ provide: CARGO_TYPES_REPOSITORY, useExisting: CargoTypesRepository },
|
||||
ContainerTypesRepository,
|
||||
{ provide: CONTAINER_TYPES_REPOSITORY, useExisting: ContainerTypesRepository },
|
||||
PriorityRulesRepository,
|
||||
{ provide: PRIORITY_RULES_REPOSITORY, useExisting: PriorityRulesRepository },
|
||||
PriorityConfigsRepository,
|
||||
{ provide: PRIORITY_CONFIGS_REPOSITORY, useExisting: PriorityConfigsRepository },
|
||||
SurchargeTypesRepository,
|
||||
{ provide: SURCHARGE_TYPES_REPOSITORY, useExisting: SurchargeTypesRepository },
|
||||
ServiceTypesRepository,
|
||||
@@ -119,7 +119,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
{ provide: APPROVAL_RULES_REPOSITORY, useExisting: ApprovalRulesRepository },
|
||||
CargoTypesService,
|
||||
ContainerTypesService,
|
||||
PriorityRulesService,
|
||||
PriorityConfigsService,
|
||||
SurchargeTypesService,
|
||||
ServiceTypesService,
|
||||
WeightLimitRulesService,
|
||||
@@ -137,7 +137,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
ContainerTypesService,
|
||||
SurchargeTypesService,
|
||||
WeightLimitRulesService,
|
||||
PriorityRulesService,
|
||||
PriorityConfigsService,
|
||||
YardsService,
|
||||
ShippingLinesService,
|
||||
RatesService,
|
||||
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
WEIGHT_LIMIT_RULES_REPOSITORY,
|
||||
} from './interfaces/weight-limit-rules.repository.interface';
|
||||
import {
|
||||
IPriorityRulesRepository,
|
||||
PRIORITY_RULES_REPOSITORY,
|
||||
} from './interfaces/priority-rules.repository.interface';
|
||||
IPriorityConfigsRepository,
|
||||
PRIORITY_CONFIGS_REPOSITORY,
|
||||
} from './interfaces/priority-configs.repository.interface';
|
||||
import {
|
||||
ISurchargeTypesRepository,
|
||||
SURCHARGE_TYPES_REPOSITORY,
|
||||
@@ -58,6 +58,7 @@ export interface BookingEvaluationInput {
|
||||
isGovernment?: boolean;
|
||||
allowConsolidation?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
totalWagons: number;
|
||||
containers: BookingContainerEvalInput[];
|
||||
}
|
||||
|
||||
@@ -95,8 +96,8 @@ export class RuleEngineService {
|
||||
private readonly serviceTypesRepo: IServiceTypesRepository,
|
||||
@Inject(WEIGHT_LIMIT_RULES_REPOSITORY)
|
||||
private readonly weightLimitRulesRepo: IWeightLimitRulesRepository,
|
||||
@Inject(PRIORITY_RULES_REPOSITORY)
|
||||
private readonly priorityRulesRepo: IPriorityRulesRepository,
|
||||
@Inject(PRIORITY_CONFIGS_REPOSITORY)
|
||||
private readonly priorityConfigsRepo: IPriorityConfigsRepository,
|
||||
@Inject(SURCHARGE_TYPES_REPOSITORY)
|
||||
private readonly surchargeTypesRepo: ISurchargeTypesRepository,
|
||||
@Inject(RATES_REPOSITORY)
|
||||
@@ -172,13 +173,20 @@ export class RuleEngineService {
|
||||
priorityScore += serviceType.priorityBonusPoints;
|
||||
}
|
||||
|
||||
const priorityRules = await this.priorityRulesRepo.findAllActive();
|
||||
for (const rule of priorityRules) {
|
||||
if (
|
||||
rule.conditionCurrency === null ||
|
||||
rule.conditionCurrency === input.paymentCurrency
|
||||
) {
|
||||
priorityScore += rule.score;
|
||||
// Additive priority blocks, each keyed on the booking's total wagon count:
|
||||
// - WAGON rules apply regardless of currency.
|
||||
// - CURRENCY rules apply only when the payment currency matches.
|
||||
const priorityConfigs = await this.priorityConfigsRepo.findAllActive();
|
||||
const wagonsInRange = (cfg: { minWagonCount: number; maxWagonCount: number }) =>
|
||||
input.totalWagons >= cfg.minWagonCount &&
|
||||
input.totalWagons <= cfg.maxWagonCount;
|
||||
|
||||
for (const cfg of priorityConfigs) {
|
||||
const applies =
|
||||
cfg.type === 'WAGON' ||
|
||||
(cfg.type === 'CURRENCY' && cfg.currency === input.paymentCurrency);
|
||||
if (applies && wagonsInRange(cfg)) {
|
||||
priorityScore += cfg.scorePoints;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
|
||||
import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
|
||||
import { PriorityConfig } from '../entities/priority-config.entity';
|
||||
import {
|
||||
IPriorityConfigsRepository,
|
||||
PRIORITY_CONFIGS_REPOSITORY,
|
||||
} from '../interfaces/priority-configs.repository.interface';
|
||||
import { DisplayOrderService } from './display-order.service';
|
||||
|
||||
@Injectable()
|
||||
export class PriorityConfigsService {
|
||||
constructor(
|
||||
@Inject(PRIORITY_CONFIGS_REPOSITORY)
|
||||
private readonly repository: IPriorityConfigsRepository,
|
||||
private readonly displayOrder: DisplayOrderService,
|
||||
) {}
|
||||
|
||||
async findAll(filter: {
|
||||
type?: 'WAGON' | 'CURRENCY';
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ data: PriorityConfig[]; 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.type !== undefined) where.type = filter.type;
|
||||
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
||||
|
||||
const [data, total] = await this.repository.findAndCount({
|
||||
where,
|
||||
order: { displayOrder: 'ASC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<PriorityConfig> {
|
||||
const entity = await this.repository.findById(id);
|
||||
if (!entity) throw new NotFoundException(`Priority config ${id} not found`);
|
||||
return entity;
|
||||
}
|
||||
|
||||
async create(dto: CreatePriorityConfigDto): Promise<PriorityConfig> {
|
||||
this.validateCurrencyField(dto.type, dto.currency);
|
||||
|
||||
const displayOrder = await this.displayOrder.resolveCreateOrder(PriorityConfig, 'displayOrder', {});
|
||||
|
||||
return this.repository.create({
|
||||
type: dto.type,
|
||||
label: dto.label,
|
||||
currency: dto.currency ?? null,
|
||||
minWagonCount: dto.minWagonCount,
|
||||
maxWagonCount: dto.maxWagonCount,
|
||||
scorePoints: dto.scorePoints ?? 0,
|
||||
isActive: dto.isActive ?? false,
|
||||
displayOrder,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdatePriorityConfigDto): Promise<PriorityConfig> {
|
||||
const existing = await this.findById(id);
|
||||
|
||||
const type = dto.type ?? existing.type;
|
||||
const currency = dto.currency !== undefined ? dto.currency : existing.currency;
|
||||
this.validateCurrencyField(type, currency);
|
||||
|
||||
const { ...patch } = dto;
|
||||
const updated = await this.repository.update(id, patch);
|
||||
if (!updated) throw new NotFoundException(`Priority config ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.repository.softDelete(id);
|
||||
}
|
||||
|
||||
async reorder(ids: string[]): Promise<void> {
|
||||
await this.displayOrder.reorderByIds(PriorityConfig, 'displayOrder', ids);
|
||||
}
|
||||
|
||||
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.displayOrder.moveOne(PriorityConfig, 'displayOrder', id, direction);
|
||||
}
|
||||
|
||||
private validateCurrencyField(type: 'WAGON' | 'CURRENCY', currency: string | undefined | null): void {
|
||||
if (type === 'CURRENCY' && !currency) {
|
||||
throw new BadRequestException('currency field is required when type is CURRENCY');
|
||||
}
|
||||
if (type === 'WAGON' && currency) {
|
||||
throw new BadRequestException('currency field must be null when type is WAGON');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||
import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto';
|
||||
import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto';
|
||||
import { PriorityRule } from '../entities/priority-rule.entity';
|
||||
import {
|
||||
IPriorityRulesRepository,
|
||||
PRIORITY_RULES_REPOSITORY,
|
||||
} from '../interfaces/priority-rules.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PriorityRulesService {
|
||||
constructor(
|
||||
@Inject(PRIORITY_RULES_REPOSITORY)
|
||||
private readonly repository: IPriorityRulesRepository,
|
||||
) {}
|
||||
|
||||
/** List priority rules with pagination. */
|
||||
async findAll(filter: {
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ data: PriorityRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
const where: Record<string, unknown> = {};
|
||||
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
||||
|
||||
const [data, total] = await this.repository.findAndCount({
|
||||
where,
|
||||
order: { label: 'ASC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
|
||||
}
|
||||
|
||||
/** Get a single priority rule by ID. */
|
||||
async findById(id: string): Promise<PriorityRule> {
|
||||
const entity = await this.repository.findById(id);
|
||||
if (!entity) throw new NotFoundException(`Priority rule ${id} not found`);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/** Create a new priority rule. */
|
||||
async create(dto: CreatePriorityRuleDto): Promise<PriorityRule> {
|
||||
const code = generateCode(dto.label);
|
||||
const existing = await this.repository.findAll({ where: { code } });
|
||||
if (existing.length > 0) {
|
||||
throw new ConflictException(`Priority rule with label "${dto.label}" conflicts with existing code "${code}"`);
|
||||
}
|
||||
return this.repository.create({
|
||||
code,
|
||||
label: dto.label,
|
||||
score: dto.score,
|
||||
conditionCurrency: dto.conditionCurrency ?? null,
|
||||
isActive: dto.isActive ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Update an existing priority rule. */
|
||||
async update(id: string, dto: UpdatePriorityRuleDto): Promise<PriorityRule> {
|
||||
await this.findById(id);
|
||||
const { ...patch } = dto;
|
||||
const updated = await this.repository.update(id, patch);
|
||||
if (!updated) throw new NotFoundException(`Priority rule ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Soft-delete a priority rule. */
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.repository.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
|
||||
'shipping-lines',
|
||||
'weight-limit-rules',
|
||||
'surcharge-types',
|
||||
'priority-rules',
|
||||
'priority-configs',
|
||||
'rates',
|
||||
'approval-rules',
|
||||
] as const;
|
||||
@@ -65,7 +65,7 @@ const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string;
|
||||
'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },
|
||||
'weight-limit-rules': { view: 'b2000001-0001-4000-8000-00000000000b', manage: 'b2000001-0001-4000-8000-00000000000c' },
|
||||
'surcharge-types': { view: 'b2000001-0001-4000-8000-00000000000d', manage: 'b2000001-0001-4000-8000-00000000000e' },
|
||||
'priority-rules': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' },
|
||||
'priority-configs': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' },
|
||||
rates: { view: 'b2000001-0001-4000-8000-000000000011', manage: 'b2000001-0001-4000-8000-000000000012' },
|
||||
'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' },
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DataSource } from "typeorm";
|
||||
import { BookingCargoModifier } from "../modules/bookings/entities/booking-cargo-modifier.entity";
|
||||
import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity";
|
||||
import { ContainerType } from "../modules/rule-engine/entities/container-type.entity";
|
||||
import { PriorityRule } from "../modules/rule-engine/entities/priority-rule.entity";
|
||||
import { PriorityConfig } from "../modules/rule-engine/entities/priority-config.entity";
|
||||
import { Rate } from "../modules/rule-engine/entities/rate.entity";
|
||||
import { ServiceType } from "../modules/rule-engine/entities/service-type.entity";
|
||||
import { ShippingLine } from "../modules/rule-engine/entities/shipping-line.entity";
|
||||
@@ -30,13 +30,13 @@ export class PricingDataSeeder {
|
||||
const yRepo = manager.getRepository(Yard);
|
||||
const slRepo = manager.getRepository(ShippingLine);
|
||||
const wlRepo = manager.getRepository(WeightLimitRule);
|
||||
const prRepo = manager.getRepository(PriorityRule);
|
||||
const prRepo = manager.getRepository(PriorityConfig);
|
||||
const rRepo = manager.getRepository(Rate);
|
||||
|
||||
await this.upsertReferenceData(manager, ctRepo, stRepo, yRepo, slRepo);
|
||||
await this.seedDomesticRoute(manager, yRepo);
|
||||
await this.seedWeightLimits(wlRepo, ctRepo);
|
||||
await this.seedPriorityRules(prRepo);
|
||||
await this.seedPriorityConfigs(prRepo);
|
||||
const containerTypes = await ctRepo.find();
|
||||
const ctByCode = new Map(containerTypes.map((ct) => [ct.code, ct]));
|
||||
|
||||
@@ -381,41 +381,34 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
|
||||
this.logger.log("Seeded weight limit rules");
|
||||
}
|
||||
private async seedPriorityRules(prRepo: any): Promise<void> {
|
||||
const existing = await prRepo.find({
|
||||
where: [
|
||||
{ code: "USD_PRIORITY" },
|
||||
{ code: "STANDARD_PRIORITY" },
|
||||
{ code: "GOVERNMENT_ACCOUNT" },
|
||||
],
|
||||
});
|
||||
for (const r of existing) {
|
||||
await prRepo.remove(r);
|
||||
private async seedPriorityConfigs(prRepo: any): Promise<void> {
|
||||
// Wagon Count Block — independent, applies regardless of currency.
|
||||
// Currency Block — applies only to the matching payment currency, within the wagon range.
|
||||
// Both blocks are additive (see RuleEngineService.evaluate).
|
||||
const rows = [
|
||||
// ── Wagon Count Block ───────────────────────────────────────────────
|
||||
{ type: "WAGON", label: "Wagons 1–20", currency: null, minWagonCount: 1, maxWagonCount: 20, scorePoints: 0, displayOrder: 1 },
|
||||
{ type: "WAGON", label: "Wagons 21–30", currency: null, minWagonCount: 21, maxWagonCount: 30, scorePoints: 15, displayOrder: 2 },
|
||||
{ type: "WAGON", label: "Wagons 31–40", currency: null, minWagonCount: 31, maxWagonCount: 40, scorePoints: 30, displayOrder: 3 },
|
||||
{ type: "WAGON", label: "Wagons 41–50", currency: null, minWagonCount: 41, maxWagonCount: 50, scorePoints: 50, displayOrder: 4 },
|
||||
// ── Payment Currency Block ──────────────────────────────────────────
|
||||
{ type: "CURRENCY", label: "USD · Wagons 1–25", currency: "USD", minWagonCount: 1, maxWagonCount: 25, scorePoints: 17, displayOrder: 5 },
|
||||
{ type: "CURRENCY", label: "USD · Wagons 26–50", currency: "USD", minWagonCount: 26, maxWagonCount: 50, scorePoints: 35, displayOrder: 6 },
|
||||
{ type: "CURRENCY", label: "ETB · Wagons 1–50", currency: "ETB", minWagonCount: 1, maxWagonCount: 50, scorePoints: 0, displayOrder: 7 },
|
||||
];
|
||||
|
||||
for (const row of rows) {
|
||||
const existing = await prRepo.findOne({
|
||||
where: { type: row.type, label: row.label },
|
||||
withDeleted: true,
|
||||
});
|
||||
if (existing) {
|
||||
await prRepo.save({ ...existing, ...row, isActive: true, deletedAt: null });
|
||||
} else {
|
||||
await prRepo.save(prRepo.create({ ...row, isActive: true }));
|
||||
}
|
||||
}
|
||||
await prRepo.save([
|
||||
prRepo.create({
|
||||
code: "USD_PRIORITY",
|
||||
label: "USD Payment Priority",
|
||||
score: 200,
|
||||
conditionCurrency: "USD",
|
||||
isActive: true,
|
||||
}),
|
||||
prRepo.create({
|
||||
code: "STANDARD_PRIORITY",
|
||||
label: "Standard Priority",
|
||||
score: 50,
|
||||
conditionCurrency: null,
|
||||
isActive: true,
|
||||
}),
|
||||
prRepo.create({
|
||||
code: "GOVERNMENT_ACCOUNT",
|
||||
label: "Government Account Priority",
|
||||
score: 50000,
|
||||
conditionCurrency: null,
|
||||
isActive: true,
|
||||
}),
|
||||
]);
|
||||
this.logger.log("Seeded priority rules");
|
||||
this.logger.log("Seeded priority configs");
|
||||
}
|
||||
|
||||
private async seedRates(
|
||||
|
||||
Reference in New Issue
Block a user