complete rule engine and booking flow

This commit is contained in:
marshal
2026-05-30 10:27:59 +03:00
parent 800f036005
commit 430dc44937
74 changed files with 4304 additions and 1248 deletions

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
@@ -12,117 +12,81 @@ import {
IsUUID,
Min,
ValidateNested,
} from "class-validator";
} from 'class-validator';
import { BOOKING_STATUSES } from '../entities/booking.entity';
const BOOKING_STATUSES = [
"DRAFT",
"PENDING_LINE_STAFF",
"PENDING_DIRECTOR",
"PENDING_CEO",
"APPROVED_PENDING_SIGNATURE",
"SIGNED",
"ACTIVE",
"EXPIRED",
"CANCELLED",
"PENDING_CONSOLIDATION",
"CONSOLIDATED",
] as const;
const CONTRACT_TYPES = ["NEW", "RENEWAL"] as const;
const SERVICE_TYPES = ["RAIL_ONLY", "RAIL_AND_FORWARDING"] as const;
const EQUIPMENT_RETURNS = ["WITH_RETURN", "WITHOUT_RETURN"] as const;
const FREIGHT_TYPES = ["BULK", "BREAK_BULK"] as const;
const TRADE_DIRECTIONS = ["IMPORT", "EXPORT"] as const;
const PAYMENT_CURRENCIES = ["ETB", "USD"] as const;
const PAYMENT_STATUSES = ["PENDING", "PAID", "OVERDUE", "CANCELLED", "REFUNDED"] as const;
const CONTAINER_TYPES = ["20FT", "40FT"] as const;
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN', 'NA'] as const;
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
export {
BOOKING_STATUSES,
CONTRACT_TYPES,
SERVICE_TYPES,
EQUIPMENT_RETURNS,
FREIGHT_TYPES,
TRADE_DIRECTIONS,
PAYMENT_CURRENCIES,
PAYMENT_STATUSES,
CONTAINER_TYPES,
};
export class ContainerItem {
@ApiProperty({ enum: CONTAINER_TYPES, description: "Container type (20FT or 40FT)" })
@IsIn([...CONTAINER_TYPES])
type!: string;
export class CreateBookingContainerDto {
@ApiProperty({ format: 'uuid', description: 'FK to container_types.id' })
@IsUUID()
containerTypeId!: string;
@ApiProperty({ description: "Quantity of containers", minimum: 1 })
@ApiProperty({ description: 'Quantity of containers', minimum: 1 })
@IsInt()
@Min(1)
@Transform(({ value }) => Number(value))
qty!: number;
quantity!: number;
@ApiProperty({ description: "VGM per container in tons", minimum: 0 })
@ApiProperty({ description: 'VGM per container in tons', minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
vgm!: number;
vgmPerUnitTons!: number;
}
export class CreateBookingDto {
// ── core ─────────────────────────────────────────────────────────────
@ApiPropertyOptional({ description: "Unique booking reference (auto-generated if not provided)" })
@ApiPropertyOptional({ description: 'Unique booking reference (auto-generated if omitted)' })
@IsOptional()
@IsString()
@Transform(({ value }) => (typeof value === "string" ? value.trim() : value))
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
reference?: string;
@ApiPropertyOptional({ format: "uuid", description: "Admin only: target customer. Omit to resolve from auth token." })
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target customer' })
@IsOptional()
@IsUUID()
customerId?: string;
@ApiPropertyOptional({ format: "uuid" })
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
trainId?: string;
@ApiProperty({ example: "2026-06-15T00:00:00.000Z" })
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
@IsDateString()
scheduledDate!: string;
// ── contract ─────────────────────────────────────────────────────────
@ApiProperty({ enum: CONTRACT_TYPES })
@IsIn([...CONTRACT_TYPES])
contractType!: string;
@ApiPropertyOptional({ format: "uuid", description: "For RENEWAL — previous contract/booking ID" })
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
@Transform(({ value }) => (value === "" || value == null ? undefined : value))
@Transform(({ value }) => (value === '' || value == null ? undefined : value))
previousContractId?: string;
@ApiProperty({ enum: SERVICE_TYPES })
@IsIn([...SERVICE_TYPES])
serviceType!: string;
@ApiProperty({ format: 'uuid', description: 'FK to service_types.id' })
@IsUUID()
serviceTypeId!: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
firstMileEnabled?: boolean;
@ApiPropertyOptional({ description: "Required when firstMileEnabled is true" })
@ApiPropertyOptional()
@IsOptional()
@IsString()
firstMilePickupAddress?: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
lastMileEnabled?: boolean;
@ApiPropertyOptional({ description: "Required when lastMileEnabled is true" })
@ApiPropertyOptional()
@IsOptional()
@IsString()
lastMileDeliveryAddress?: string;
@@ -131,55 +95,59 @@ export class CreateBookingDto {
@IsIn([...EQUIPMENT_RETURNS])
equipmentReturn!: string;
@ApiProperty({ description: "Origin station name or code" })
@IsString()
originStation!: string;
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
@IsUUID()
originYardId!: string;
@ApiProperty({ description: "Destination station name or code" })
@IsString()
destinationStation!: string;
@ApiProperty({ description: "Total cargo weight in VGM tons", minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
cargoTotalWeightVgm!: number;
@ApiProperty({ enum: FREIGHT_TYPES })
@IsIn([...FREIGHT_TYPES])
freightType!: string;
@ApiPropertyOptional({ description: "Coffee, Beans, Machinery, Ro-Ro, etc." })
@IsOptional()
@IsString()
freightSubtype?: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
isHazardous?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
isRefrigerated?: boolean;
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' })
@IsUUID()
destinationYardId!: string;
@ApiProperty({ enum: TRADE_DIRECTIONS })
@IsIn([...TRADE_DIRECTIONS])
tradeDirection!: string;
@ApiProperty({ format: 'uuid', description: 'FK to cargo_types.id' })
@IsUUID()
cargoTypeId!: string;
@ApiPropertyOptional({ maxLength: 200 })
@IsOptional()
@IsString()
cargoFreeText?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'FK to shipping_lines.id' })
@IsOptional()
@IsUUID()
shippingLineId?: string;
@ApiProperty({ description: 'Total cargo weight VGM in tons', minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
cargoTotalWeightVgm!: number;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isHazardous?: boolean;
@ApiProperty({ enum: PAYMENT_CURRENCIES })
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency!: string;
@ApiPropertyOptional({ example: "2026-06-15" })
@ApiPropertyOptional()
@IsOptional()
@IsString()
pnrCode?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({ example: "2027-06-15" })
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
endDate?: string;
@@ -189,19 +157,15 @@ export class CreateBookingDto {
@IsString()
financialTerms?: string;
// ── containers ────────────────────────────────────────────────────────
@ApiProperty({ type: [ContainerItem], description: "Array of container specifications" })
@ApiProperty({ type: [CreateBookingContainerDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => ContainerItem)
containers!: ContainerItem[];
@Type(() => CreateBookingContainerDto)
containers!: CreateBookingContainerDto[];
@ApiPropertyOptional({
default: false,
description: "Auto-set to true when any 20FT container has odd quantity. User may override.",
})
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
@Transform(({ value }) => value === 'true' || value === true)
allowConsolidation?: boolean;
}

View File

@@ -1,15 +1,7 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Min } from "class-validator";
import {
BOOKING_STATUSES,
CONTRACT_TYPES,
FREIGHT_TYPES,
PAYMENT_CURRENCIES,
SERVICE_TYPES,
TRADE_DIRECTIONS,
} from "./create-booking.dto";
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
import { BOOKING_STATUSES, PAYMENT_CURRENCIES, TRADE_DIRECTIONS } from './create-booking.dto';
export class FilterBookingDto {
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
@@ -17,20 +9,24 @@ export class FilterBookingDto {
@IsIn([...BOOKING_STATUSES])
status?: string;
@ApiPropertyOptional({ format: "uuid" })
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
customerId?: string;
@ApiPropertyOptional({ enum: CONTRACT_TYPES })
@ApiPropertyOptional()
@IsOptional()
@IsIn([...CONTRACT_TYPES])
contractType?: string;
@ApiPropertyOptional({ enum: SERVICE_TYPES })
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsIn([...SERVICE_TYPES])
serviceType?: string;
@IsUUID()
serviceTypeId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
cargoTypeId?: string;
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
@IsOptional()
@@ -42,43 +38,31 @@ export class FilterBookingDto {
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency?: string;
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
@ApiPropertyOptional()
@IsOptional()
@IsIn([...FREIGHT_TYPES])
freightType?: string;
@ApiPropertyOptional({ description: "Filter consolidation-eligible bookings" })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
@Transform(({ value }) => value === 'true' || value === true)
allowConsolidation?: boolean;
@ApiPropertyOptional({ description: "Filter by consolidation partner presence (true = paired, false = unpaired)" })
@ApiPropertyOptional({ description: 'true | false — filter paired consolidation' })
@IsOptional()
@IsString()
consolidationPaired?: string;
@ApiPropertyOptional({ enum: ["createdAt", "priorityScore"], default: "createdAt" })
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))
page?: number;
@ApiPropertyOptional({ default: 20 })
@IsOptional()
@Transform(({ value }) => (value ? parseInt(value, 10) : 20))
pageSize?: number;
@ApiPropertyOptional({ default: 'createdAt' })
@IsOptional()
@IsIn(["createdAt", "priorityScore"])
sortBy?: string;
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" })
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
@IsOptional()
@IsIn(["ASC", "DESC"])
sortOrder?: "ASC" | "DESC";
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ default: 20, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number = 20;
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC';
}

View File

@@ -1,41 +1,40 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString, IsUUID } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
const STATUS_ACTIONS = [
"SUBMIT",
"APPROVE_STAFF",
"APPROVE_DIRECTOR",
"APPROVE_CEO",
"REJECT",
"CANCEL",
"ACTIVATE",
"EXPIRE",
'SUBMIT',
'SEND_QUOTATION',
'APPROVE_QUOTATION',
'REJECT_QUOTATION',
'APPROVE_STEP',
'APPROVE',
'CUSTOMER_SIGN',
'MARK_FULLY_EXECUTED',
'MARK_PAID',
'START_TRANSIT',
'COMPLETE',
'REJECT',
'CANCEL',
] as const;
export { STATUS_ACTIONS };
export class UpdateStatusDto {
@ApiProperty({
enum: STATUS_ACTIONS,
description:
"SUBMIT — send to approval queue | " +
"APPROVE_STAFF — line-staff approval | " +
"APPROVE_DIRECTOR — director approval / signature | " +
"APPROVE_CEO — CEO final signature | " +
"REJECT — reject at any pending stage | " +
"CANCEL — customer / admin cancellation | " +
"ACTIVATE — activate a signed booking | " +
"EXPIRE — mark an active booking as expired",
})
@ApiProperty({ enum: STATUS_ACTIONS })
@IsIn([...STATUS_ACTIONS])
action!: string;
@ApiPropertyOptional({ format: "uuid", description: "Actor performing the action (staff/director/CEO)" })
@ApiPropertyOptional({ format: 'uuid', description: 'Staff/director/CEO actor' })
@IsOptional()
@IsUUID()
actorId?: string;
@ApiPropertyOptional({ description: "Required for REJECT and CANCEL actions" })
@ApiPropertyOptional({ description: 'Required role for APPROVE_STEP (LINE_STAFF, DIRECTOR, CEO)' })
@IsOptional()
@IsString()
requiredRole?: string;
@ApiPropertyOptional({ description: 'Required for REJECT, REJECT_QUOTATION, CANCEL' })
@IsOptional()
@IsString()
reason?: string;