mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 05:58:18 +00:00
implement rule engine module with dynamic booking evaluation, 7 entities, and Postman endpoints
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user