implement rule engine module with dynamic booking evaluation, 7 entities, and Postman endpoints

This commit is contained in:
marshal
2026-05-29 10:00:45 +03:00
parent 3b53042cf8
commit 800f036005
72 changed files with 2544 additions and 838 deletions

View File

@@ -4,13 +4,14 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { CustomersModule } from "../customers/customers.module";
import { FilesModule } from "../files/files.module";
import { MinioModule } from "../minio/minio.module";
import { RuleEngineModule } from "../rule-engine/rule-engine.module";
import { BookingsController } from "./bookings.controller";
import { BookingsRepository } from "./bookings.repository";
import { BookingsService } from "./bookings.service";
import { Booking } from "./entities/booking.entity";
@Module({
imports: [TypeOrmModule.forFeature([Booking]), FilesModule, MinioModule, CustomersModule],
imports: [TypeOrmModule.forFeature([Booking]), FilesModule, MinioModule, CustomersModule, RuleEngineModule],
controllers: [BookingsController],
providers: [BookingsService, BookingsRepository],
exports: [BookingsService],

View File

@@ -9,6 +9,7 @@ import { IsNull, Not } from "typeorm";
import { CustomersService } from "../customers/customers.service";
import { FilesService } from "../files/files.service";
import { MinioService } from "../minio/minio.service";
import { RuleEngineService } from "../rule-engine/rule-engine.service";
import { BookingsRepository } from "./bookings.repository";
import { CreateBookingDto } from "./dto/create-booking.dto";
import { FilterBookingDto } from "./dto/filter-booking.dto";
@@ -17,16 +18,6 @@ import { UpdateStatusDto } from "./dto/update-status.dto";
import { Booking } from "./entities/booking.entity";
import { FileRecord } from "../files/entities/file.entity";
/** Weight thresholds (tons) that trigger overweight surcharge alerts. */
const WEIGHT_LIMITS = {
IMPORT_20FT: 20,
EXPORT_20FT: 25,
ANY_40FT: 32.5,
} as const;
/** Bookings above this total VGM are considered high-volume. */
const HIGH_VOLUME_THRESHOLD_TONS = 500;
@Injectable()
export class BookingsService {
constructor(
@@ -34,6 +25,7 @@ export class BookingsService {
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly customersService: CustomersService,
private readonly ruleEngineService: RuleEngineService,
) {}
// ── helpers ──────────────────────────────────────────────────────────
@@ -65,15 +57,6 @@ export class BookingsService {
return explicit ?? false;
}
/** Calculate priority score based on currency and service type. */
private calculatePriorityScore(currency: string, serviceType: string): number {
let score = 0;
if (currency === "USD") score += 100;
if (serviceType === "RAIL_AND_FORWARDING") score += 50;
else if (serviceType === "RAIL_ONLY") score += 25;
return score;
}
/** Calculate required wagons: each 40ft = 1 wagon, each pair of 20ft = 1 wagon. */
private calculateWagonCount(
containers: Array<{ type: string; qty: number }>,
@@ -87,33 +70,6 @@ export class BookingsService {
}, 0);
}
/** Check per-container weight limits and return warnings if exceeded. */
private checkOverweight(
containers: Array<{ type: string; vgm: number }>,
tradeDirection: string,
): string[] {
const warnings: string[] = [];
for (const container of containers) {
if (container.type === "40FT" && container.vgm > WEIGHT_LIMITS.ANY_40FT) {
warnings.push(
`40FT container VGM ${container.vgm}t exceeds limit of ${WEIGHT_LIMITS.ANY_40FT}t`
);
}
if (container.type === "20FT") {
const limit =
tradeDirection === "IMPORT"
? WEIGHT_LIMITS.IMPORT_20FT
: WEIGHT_LIMITS.EXPORT_20FT;
if (container.vgm > limit) {
warnings.push(
`20FT ${tradeDirection} container VGM ${container.vgm}t exceeds limit of ${limit}t`
);
}
}
}
return warnings;
}
// ── CRUD ─────────────────────────────────────────────────────────────
@@ -143,16 +99,19 @@ export class BookingsService {
dto.allowConsolidation,
);
const priorityScore = this.calculatePriorityScore(
dto.paymentCurrency,
dto.serviceType,
);
const overweightWarnings = this.checkOverweight(
dto.containers,
dto.tradeDirection,
);
warnings.push(...overweightWarnings);
// ── Rule engine evaluation ──────────────────────────────────────────
const ruleResult = await this.ruleEngineService.evaluate({
freightType: dto.freightType,
serviceType: dto.serviceType,
paymentCurrency: dto.paymentCurrency,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous ?? false,
isRefrigerated: dto.isRefrigerated ?? false,
containers: dto.containers,
});
this.ruleEngineService.assertNoHardBlocks(ruleResult);
warnings.push(...ruleResult.warnings);
const wagonCount = this.calculateWagonCount(dto.containers);
warnings.push(`Estimated wagons required: ${wagonCount}`);
@@ -168,7 +127,7 @@ export class BookingsService {
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
status: "DRAFT",
allowConsolidation,
priorityScore,
priorityScore: ruleResult.priorityScore,
});
if (files.length > 0) {
@@ -208,15 +167,20 @@ export class BookingsService {
dto.allowConsolidation,
);
// Recalculate priority
const currency = dto.paymentCurrency ?? existing.paymentCurrency;
const serviceType = dto.serviceType ?? existing.serviceType;
updates.priorityScore = this.calculatePriorityScore(currency, serviceType);
// Overweight check
const direction = dto.tradeDirection ?? existing.tradeDirection;
const overweightWarnings = this.checkOverweight(containers, direction);
warnings.push(...overweightWarnings);
// ── Rule engine re-evaluation ────────────────────────────────────────
const ruleResult = await this.ruleEngineService.evaluate({
freightType: dto.freightType ?? existing.freightType,
serviceType: dto.serviceType ?? existing.serviceType,
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm ?? existing.cargoTotalWeightVgm,
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
isHazardous: dto.isHazardous ?? existing.isHazardous ?? false,
isRefrigerated: dto.isRefrigerated ?? existing.isRefrigerated ?? false,
containers,
});
this.ruleEngineService.assertNoHardBlocks(ruleResult);
warnings.push(...ruleResult.warnings);
updates.priorityScore = ruleResult.priorityScore;
if (files.length > 0) {
await this.filesService.uploadMany(id, "bookings", files);
@@ -348,13 +312,13 @@ export class BookingsService {
}
}
/** SUBMIT: DRAFT → PENDING_LINE_STAFF or PENDING_DIRECTOR (bulk). */
/** SUBMIT: DRAFT → PENDING_LINE_STAFF or PENDING_DIRECTOR (cargo routing from rule engine). */
private async handleSubmit(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ["DRAFT"]);
const isBulk =
booking.freightType === "BULK" ||
booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS;
const nextStatus = isBulk ? "PENDING_DIRECTOR" : "PENDING_LINE_STAFF";
const ruleResult = await this.ruleEngineService.evaluate(booking);
const nextStatus = ruleResult.requiresDirectorApproval
? "PENDING_DIRECTOR"
: "PENDING_LINE_STAFF";
const updated = await this.bookingsRepository.update(booking.id, {
status: nextStatus,
} as never);
@@ -370,13 +334,11 @@ export class BookingsService {
if (!actorId)
throw new BadRequestException("actorId is required for APPROVE_STAFF");
// Line staff cannot approve bulk
if (
booking.freightType === "BULK" ||
booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS
) {
// Line staff cannot approve bookings that require director approval
const ruleResult = await this.ruleEngineService.evaluate(booking);
if (ruleResult.requiresDirectorApproval) {
throw new BadRequestException(
"Line staff cannot approve bulk or high-volume bookings",
"Line staff cannot approve bookings that require director approval",
);
}
@@ -400,10 +362,8 @@ export class BookingsService {
if (!actorId)
throw new BadRequestException("actorId is required for APPROVE_DIRECTOR");
const isBulk =
booking.freightType === "BULK" ||
booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS;
const nextStatus = isBulk ? "PENDING_CEO" : "SIGNED";
const ruleResult = await this.ruleEngineService.evaluate(booking);
const nextStatus = ruleResult.requiresDirectorApproval ? "PENDING_CEO" : "SIGNED";
const updated = await this.bookingsRepository.update(booking.id, {
status: nextStatus,