Merge pull request #171 from Tria-plc/freight_feature/priority

Freight feature/priority
This commit is contained in:
marshal
2026-06-16 13:34:18 +03:00
committed by GitHub
28 changed files with 495 additions and 333 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreatePriorityConfigDto } from './create-priority-config.dto';
export class UpdatePriorityConfigDto extends PartialType(CreatePriorityConfigDto) {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 120", currency: null, minWagonCount: 1, maxWagonCount: 20, scorePoints: 0, displayOrder: 1 },
{ type: "WAGON", label: "Wagons 2130", currency: null, minWagonCount: 21, maxWagonCount: 30, scorePoints: 15, displayOrder: 2 },
{ type: "WAGON", label: "Wagons 3140", currency: null, minWagonCount: 31, maxWagonCount: 40, scorePoints: 30, displayOrder: 3 },
{ type: "WAGON", label: "Wagons 4150", currency: null, minWagonCount: 41, maxWagonCount: 50, scorePoints: 50, displayOrder: 4 },
// ── Payment Currency Block ──────────────────────────────────────────
{ type: "CURRENCY", label: "USD · Wagons 125", currency: "USD", minWagonCount: 1, maxWagonCount: 25, scorePoints: 17, displayOrder: 5 },
{ type: "CURRENCY", label: "USD · Wagons 2650", currency: "USD", minWagonCount: 26, maxWagonCount: 50, scorePoints: 35, displayOrder: 6 },
{ type: "CURRENCY", label: "ETB · Wagons 150", 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(

View File

@@ -179,10 +179,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Boxes />,
children: [
...getCategorySidebarChildren("configuration"),
{
label: "Train scheduling rules",
href: "/dashboard/configuration/train-scheduling-rules",
},
// {
// label: "Train scheduling rules",
// href: "/dashboard/configuration/train-scheduling-rules",
// },
],
},
{
@@ -320,7 +320,7 @@ const App = () => {
<Route
path="rules"
element={<Navigate to="/dashboard/rules/priority-rules" replace />}
element={<Navigate to="/dashboard/rules/priority-configs" replace />}
/>
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />

View File

@@ -165,7 +165,17 @@ const RuleEngineFormDialog = ({
}
}, [open, fields, initialRecord]);
const formRows = useMemo(() => buildFormRows(fields), [fields]);
const visibleFields = useMemo(
() =>
fields.filter(
(field) =>
!field.hideWhen ||
!field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? "")),
),
[fields, values],
);
const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]);
const setField = (name: string, value: unknown) => {
setValues((current) => ({ ...current, [name]: value }));
@@ -175,7 +185,7 @@ const RuleEngineFormDialog = ({
event.preventDefault();
const payload: Record<string, unknown> = {};
for (const field of fields) {
for (const field of visibleFields) {
const raw = values[field.name];
if (field.type === "number") {
if (raw === "" || raw === undefined) continue;

View File

@@ -212,8 +212,8 @@ export const URL_CONSTANTS = {
WAGON_TYPES: "/wagon-types",
WAGON_TYPE_BY_ID: (id: string) => `/wagon-types/${id}`,
PRIORITY_RULES: "/priority-rules",
PRIORITY_RULE_BY_ID: (id: string) => `/priority-rules/${id}`,
PRIORITY_CONFIGS: "/priority-configs",
PRIORITY_CONFIG_BY_ID: (id: string) => `/priority-configs/${id}`,
SERVICE_TYPES: "/service-types",
SERVICE_TYPE_BY_ID: (id: string) => `/service-types/${id}`,

View File

@@ -296,8 +296,13 @@ const RuleEngineResourcePage = () => {
};
const handleFormSubmit = (values: Record<string, unknown>) => {
const payload =
config.slug === "rates" ? { ...values, currency: "USD" } : values;
let payload = values;
if (config.slug === "rates") {
payload = { ...values, currency: "USD" };
} else if (config.slug === "priority-configs") {
// Label is required by the backend but hidden in the UI for now.
payload = { ...values, label: String(Date.now()) };
}
if (editing?.id) {
update.mutate(

View File

@@ -34,6 +34,8 @@ export interface FormFieldDef {
optional?: boolean;
options?: { label: string; value: string }[];
placeholder?: string;
/** Hide this field when another field currently equals one of these values. */
hideWhen?: { field: string; equals: string[] };
}
export interface RuleEngineOrderConfig {
@@ -110,7 +112,15 @@ const RATE_UNITS = ["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "FLAT"].m
value: v,
}));
const CURRENCIES = [{ label: "USD", value: "USD" }];
const CURRENCIES = [
{ label: "USD", value: "USD" },
{ label: "ETB", value: "ETB" },
];
const PRIORITY_CONFIG_TYPES = [
{ label: "Wagon count", value: "WAGON" },
{ label: "Payment currency", value: "CURRENCY" },
];
const codeColumn = (key: string, header = "Code"): ResourceColumn => ({
id: key,
@@ -226,29 +236,41 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
],
},
{
slug: "priority-rules",
slug: "priority-configs",
label: "Priority Rules",
category: "rules",
subtitle: "Booking priority scoring rules",
subtitle: "Wagon-count and payment-currency scoring rules",
searchPlaceholder: "Search priority rules...",
orderConfig: { field: "displayOrder", label: "Display order" },
columns: [
codeColumn("code"),
{ id: "label", header: "Label", accessorKey: "label" },
{ id: "score", header: "Score", accessorKey: "score", format: "number" },
{ id: "conditionCurrency", header: "Currency", accessorKey: "conditionCurrency" },
{ id: "type", header: "Type", accessorKey: "type" },
{ id: "currency", header: "Currency", accessorKey: "currency" },
{ id: "minWagonCount", header: "Min wagons", accessorKey: "minWagonCount", format: "number" },
{ id: "maxWagonCount", header: "Max wagons", accessorKey: "maxWagonCount", format: "number" },
{ id: "scorePoints", header: "Points", accessorKey: "scorePoints", format: "number" },
activeColumn,
],
formFields: [
{ name: "label", label: "Label", type: "text", required: true },
{ name: "score", label: "Score", type: "number", required: true },
{
name: "conditionCurrency",
label: "Condition currency",
name: "type",
label: "Type",
type: "select",
required: true,
options: PRIORITY_CONFIG_TYPES,
placeholder: "Wagon count or payment currency",
},
{
name: "currency",
label: "Currency",
type: "select",
optional: true,
options: [{ label: "Any", value: RULE_ENGINE_SELECT_NONE }, ...CURRENCIES],
placeholder: "Any currency (optional)",
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...CURRENCIES],
placeholder: "Select a currency",
hideWhen: { field: "type", equals: ["WAGON"] },
},
{ name: "minWagonCount", label: "Min wagon count", type: "number", required: true },
{ name: "maxWagonCount", label: "Max wagon count", type: "number", required: true },
{ name: "scorePoints", label: "Score points", type: "number", required: true },
{ name: "isActive", label: "Active", type: "boolean" },
],
},
@@ -500,7 +522,7 @@ export const getCategorySidebarChildren = (
}));
export const DEFAULT_CONFIGURATION_SLUG: RuleEngineResourceSlug = "cargo-types";
export const DEFAULT_RULES_SLUG: RuleEngineResourceSlug = "priority-rules";
export const DEFAULT_RULES_SLUG: RuleEngineResourceSlug = "priority-configs";
/** @deprecated Use DEFAULT_CONFIGURATION_SLUG */
export const DEFAULT_RULE_ENGINE_SLUG = DEFAULT_CONFIGURATION_SLUG;

View File

@@ -28,7 +28,7 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
"wagon-types": URL_CONSTANTS.RULE_ENGINE.WAGON_TYPES,
"priority-rules": URL_CONSTANTS.RULE_ENGINE.PRIORITY_RULES,
"priority-configs": URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIGS,
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
"surcharge-types": URL_CONSTANTS.RULE_ENGINE.SURCHARGE_TYPES,
"weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES,
@@ -46,8 +46,8 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
return URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id);
case "wagon-types":
return URL_CONSTANTS.RULE_ENGINE.WAGON_TYPE_BY_ID(id);
case "priority-rules":
return URL_CONSTANTS.RULE_ENGINE.PRIORITY_RULE_BY_ID(id);
case "priority-configs":
return URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIG_BY_ID(id);
case "service-types":
return URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPE_BY_ID(id);
case "surcharge-types":

View File

@@ -2,7 +2,7 @@ export type RuleEngineResourceSlug =
| "cargo-types"
| "container-types"
| "wagon-types"
| "priority-rules"
| "priority-configs"
| "service-types"
| "surcharge-types"
| "weight-limit-rules"