This commit is contained in:
Marshal
2026-06-16 08:56:52 +00:00
parent a0dbb4ca6f
commit 7151bce288
21 changed files with 429 additions and 304 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(