mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
booking operations and trains scheduling also allocations
This commit is contained in:
@@ -10,7 +10,8 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit",
|
||||||
|
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edr/api-common": "workspace:*",
|
"@edr/api-common": "workspace:*",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
|
|||||||
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
||||||
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
||||||
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
|
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
|
||||||
|
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
|
||||||
import { CustomersModule } from "./modules/customers/customers.module";
|
import { CustomersModule } from "./modules/customers/customers.module";
|
||||||
import { CompaniesModule } from "./modules/companies/companies.module";
|
import { CompaniesModule } from "./modules/companies/companies.module";
|
||||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||||
@@ -82,6 +83,7 @@ import { OverviewModule } from './modules/overview/overview.module';
|
|||||||
TrainSetsModule,
|
TrainSetsModule,
|
||||||
TrainSchedulesModule,
|
TrainSchedulesModule,
|
||||||
TrainSchedulingModule,
|
TrainSchedulingModule,
|
||||||
|
SchedulingRescheduleModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
TrackingModule,
|
TrackingModule,
|
||||||
|
|||||||
@@ -15,3 +15,9 @@ export const BookingStaff = (permission: string | string[]) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
||||||
|
|
||||||
|
export const TrainSchedulingView = () =>
|
||||||
|
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
|
||||||
|
|
||||||
|
export const TrainSchedulingManage = () =>
|
||||||
|
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import { registerAs } from "@nestjs/config";
|
import { registerAs } from "@nestjs/config";
|
||||||
|
|
||||||
|
const numberFromEnv = (key: string, fallback: number): number => {
|
||||||
|
const value = Number(process.env[key]);
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
export default registerAs("app", () => ({
|
export default registerAs("app", () => ({
|
||||||
env: process.env.NODE_ENV ?? "development",
|
env: process.env.NODE_ENV ?? "development",
|
||||||
port: parseInt(process.env.PORT ?? "3001", 10),
|
port: parseInt(process.env.PORT ?? "3001", 10),
|
||||||
apiPrefix: "api",
|
apiPrefix: "api",
|
||||||
|
trainScheduling: {
|
||||||
|
maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500),
|
||||||
|
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
|
||||||
|
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ export class ContractPricingScheduleBuilder {
|
|||||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—',
|
booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—',
|
||||||
containerLines: (booking.bookingContainers ?? []).map((c) => ({
|
containerLines: (booking.bookingContainers ?? []).map((c) => ({
|
||||||
label:
|
label:
|
||||||
c.containerType?.label ?? c.containerType?.code ?? c.containerTypeId,
|
c.containerType?.label ??
|
||||||
|
c.containerType?.code ??
|
||||||
|
c.containerTypeId ??
|
||||||
|
'—',
|
||||||
quantity: c.quantity,
|
quantity: c.quantity,
|
||||||
vgmPerUnitTons: Number(c.vgmPerUnitTons),
|
vgmPerUnitTons: Number(c.vgmPerUnitTons),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddSchedulingAllocationEnhancements1750400000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddSchedulingAllocationEnhancements1750400000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS wagons_required NUMERIC(6,2) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS scheduling_status VARCHAR(30) NOT NULL DEFAULT 'NOT_SCHEDULED',
|
||||||
|
ADD COLUMN IF NOT EXISTS hold_started_at TIMESTAMPTZ NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS hold_expires_at TIMESTAMPTZ NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_schedules
|
||||||
|
ADD COLUMN IF NOT EXISTS train_number VARCHAR(20) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS direction VARCHAR(10) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS actual_departure_at TIMESTAMPTZ NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS actual_arrival_at TIMESTAMPTZ NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS prepared_by_user_id UUID NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS checked_by_user_id UUID NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS max_wagons INT NOT NULL DEFAULT 53;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_set_wagons
|
||||||
|
ADD COLUMN IF NOT EXISTS physical_wagon_id UUID NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PLANNED';
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.wagon_booking_allocations
|
||||||
|
ADD COLUMN IF NOT EXISTS load_type VARCHAR(20) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PLANNED',
|
||||||
|
ADD COLUMN IF NOT EXISTS confirmed_at TIMESTAMPTZ NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS confirmed_by_user_id UUID NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.wagon_types
|
||||||
|
ADD COLUMN IF NOT EXISTS equated_length_m NUMERIC(10,3) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS tare_weight_tons NUMERIC(10,3) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS supports_container BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS max_container_gross_t NUMERIC(10,3) NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.wagons
|
||||||
|
ADD COLUMN IF NOT EXISTS train_set_wagon_id UUID NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS current_train_schedule_id UUID NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.containers
|
||||||
|
ADD COLUMN IF NOT EXISTS booking_id UUID NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS wagon_booking_allocation_id UUID NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS booking_container_id UUID NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.cargoes
|
||||||
|
ADD COLUMN IF NOT EXISTS wagon_booking_allocation_id UUID NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS booking_id UUID NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS load_type VARCHAR(20) NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.cargoes
|
||||||
|
ALTER COLUMN container_id DROP NOT NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.wagon_allocation_container_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
wagon_booking_allocation_id UUID NOT NULL,
|
||||||
|
booking_container_id UUID NULL,
|
||||||
|
container_id UUID NULL,
|
||||||
|
container_number VARCHAR(64) NULL,
|
||||||
|
container_type_id UUID NOT NULL,
|
||||||
|
position_on_wagon SMALLINT NULL,
|
||||||
|
seal_number VARCHAR(64) NULL,
|
||||||
|
chassis_number VARCHAR(64) NULL,
|
||||||
|
gross_weight_tons NUMERIC(10,3) NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ NULL,
|
||||||
|
CONSTRAINT fk_waci_allocation FOREIGN KEY (wagon_booking_allocation_id)
|
||||||
|
REFERENCES freight.wagon_booking_allocations(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_waci_booking_container FOREIGN KEY (booking_container_id)
|
||||||
|
REFERENCES freight.booking_container(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_waci_container FOREIGN KEY (container_id)
|
||||||
|
REFERENCES freight.containers(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_waci_container_type FOREIGN KEY (container_type_id)
|
||||||
|
REFERENCES freight.container_types(id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.wagon_allocation_bulk_loads (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
wagon_booking_allocation_id UUID NOT NULL UNIQUE,
|
||||||
|
booking_id UUID NOT NULL,
|
||||||
|
cargo_type_id UUID NULL,
|
||||||
|
cargo_description TEXT NULL,
|
||||||
|
pricing_unit VARCHAR(20) NOT NULL DEFAULT 'PER_TON',
|
||||||
|
quantity NUMERIC(12,3) NOT NULL DEFAULT 0,
|
||||||
|
weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0,
|
||||||
|
truck_plate_number VARCHAR(32) NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ NULL,
|
||||||
|
CONSTRAINT fk_wabl_allocation FOREIGN KEY (wagon_booking_allocation_id)
|
||||||
|
REFERENCES freight.wagon_booking_allocations(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_wabl_booking FOREIGN KEY (booking_id)
|
||||||
|
REFERENCES freight.bookings(id),
|
||||||
|
CONSTRAINT fk_wabl_cargo_type FOREIGN KEY (cargo_type_id)
|
||||||
|
REFERENCES freight.cargo_types(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bookings_scheduling_status
|
||||||
|
ON freight.bookings(scheduling_status);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_train_schedules_train_number
|
||||||
|
ON freight.train_schedules(train_number);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_train_set_wagons_physical_wagon
|
||||||
|
ON freight.train_set_wagons(physical_wagon_id);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wagons_train_set_wagon_id
|
||||||
|
ON freight.wagons(train_set_wagon_id);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wagons_current_train_schedule_id
|
||||||
|
ON freight.wagons(current_train_schedule_id);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_waci_allocation
|
||||||
|
ON freight.wagon_allocation_container_items(wagon_booking_allocation_id);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wabl_booking
|
||||||
|
ON freight.wagon_allocation_bulk_loads(booking_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE freight.train_set_wagons
|
||||||
|
ADD CONSTRAINT fk_train_set_wagons_physical_wagon
|
||||||
|
FOREIGN KEY (physical_wagon_id) REFERENCES freight.wagons(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE freight.wagons
|
||||||
|
ADD CONSTRAINT fk_wagons_train_set_wagon
|
||||||
|
FOREIGN KEY (train_set_wagon_id) REFERENCES freight.train_set_wagons(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE freight.wagons
|
||||||
|
ADD CONSTRAINT fk_wagons_current_train_schedule
|
||||||
|
FOREIGN KEY (current_train_schedule_id) REFERENCES freight.train_schedules(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE freight.containers
|
||||||
|
ADD CONSTRAINT fk_containers_booking
|
||||||
|
FOREIGN KEY (booking_id) REFERENCES freight.bookings(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE freight.containers
|
||||||
|
ADD CONSTRAINT fk_containers_wagon_allocation
|
||||||
|
FOREIGN KEY (wagon_booking_allocation_id) REFERENCES freight.wagon_booking_allocations(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE freight.containers
|
||||||
|
ADD CONSTRAINT fk_containers_booking_container
|
||||||
|
FOREIGN KEY (booking_container_id) REFERENCES freight.booking_container(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE freight.cargoes
|
||||||
|
ADD CONSTRAINT fk_cargoes_wagon_allocation
|
||||||
|
FOREIGN KEY (wagon_booking_allocation_id) REFERENCES freight.wagon_booking_allocations(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE freight.cargoes
|
||||||
|
ADD CONSTRAINT fk_cargoes_booking
|
||||||
|
FOREIGN KEY (booking_id) REFERENCES freight.bookings(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.wagon_types SET
|
||||||
|
equated_length_m = 1.3,
|
||||||
|
tare_weight_tons = 22.4,
|
||||||
|
supports_container = true,
|
||||||
|
max_container_gross_t = 30.48
|
||||||
|
WHERE code = 'NW5';
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.wagon_types SET
|
||||||
|
equated_length_m = 1.6,
|
||||||
|
tare_weight_tons = 25.2,
|
||||||
|
supports_container = false
|
||||||
|
WHERE code = 'PW2';
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.wagon_types SET
|
||||||
|
equated_length_m = 1.5,
|
||||||
|
tare_weight_tons = 25.2,
|
||||||
|
supports_container = false
|
||||||
|
WHERE code = 'KW2';
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.wagon_types SET
|
||||||
|
equated_length_m = 1.3,
|
||||||
|
tare_weight_tons = 23.4,
|
||||||
|
supports_container = false
|
||||||
|
WHERE code = 'CW3';
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.wagon_types SET
|
||||||
|
equated_length_m = 1.3,
|
||||||
|
tare_weight_tons = 24.8,
|
||||||
|
supports_container = false
|
||||||
|
WHERE code = 'CW4';
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_allocation_bulk_loads;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_allocation_container_items;`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.cargoes
|
||||||
|
ALTER COLUMN container_id SET NOT NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
DROP COLUMN IF EXISTS wagons_required,
|
||||||
|
DROP COLUMN IF EXISTS scheduling_status,
|
||||||
|
DROP COLUMN IF EXISTS hold_started_at,
|
||||||
|
DROP COLUMN IF EXISTS hold_expires_at,
|
||||||
|
DROP COLUMN IF EXISTS scheduled_at;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_schedules
|
||||||
|
DROP COLUMN IF EXISTS train_number,
|
||||||
|
DROP COLUMN IF EXISTS direction,
|
||||||
|
DROP COLUMN IF EXISTS actual_departure_at,
|
||||||
|
DROP COLUMN IF EXISTS actual_arrival_at,
|
||||||
|
DROP COLUMN IF EXISTS prepared_by_user_id,
|
||||||
|
DROP COLUMN IF EXISTS checked_by_user_id,
|
||||||
|
DROP COLUMN IF EXISTS max_wagons;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_set_wagons
|
||||||
|
DROP COLUMN IF EXISTS physical_wagon_id,
|
||||||
|
DROP COLUMN IF EXISTS status;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.wagon_booking_allocations
|
||||||
|
DROP COLUMN IF EXISTS load_type,
|
||||||
|
DROP COLUMN IF EXISTS status,
|
||||||
|
DROP COLUMN IF EXISTS confirmed_at,
|
||||||
|
DROP COLUMN IF EXISTS confirmed_by_user_id;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.wagon_types
|
||||||
|
DROP COLUMN IF EXISTS equated_length_m,
|
||||||
|
DROP COLUMN IF EXISTS tare_weight_tons,
|
||||||
|
DROP COLUMN IF EXISTS supports_container,
|
||||||
|
DROP COLUMN IF EXISTS max_container_gross_t;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.wagons
|
||||||
|
DROP COLUMN IF EXISTS train_set_wagon_id,
|
||||||
|
DROP COLUMN IF EXISTS current_train_schedule_id;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.containers
|
||||||
|
DROP COLUMN IF EXISTS booking_id,
|
||||||
|
DROP COLUMN IF EXISTS wagon_booking_allocation_id,
|
||||||
|
DROP COLUMN IF EXISTS booking_container_id;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.cargoes
|
||||||
|
DROP COLUMN IF EXISTS wagon_booking_allocation_id,
|
||||||
|
DROP COLUMN IF EXISTS booking_id,
|
||||||
|
DROP COLUMN IF EXISTS load_type;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddWagonReadiness1750500000000 implements MigrationInterface {
|
||||||
|
name = 'AddWagonReadiness1750500000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.wagons
|
||||||
|
ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wagons_readiness
|
||||||
|
ON freight.wagons (readiness)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_readiness`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.wagons
|
||||||
|
DROP COLUMN IF EXISTS readiness
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddGovernmentBookingFields1750600000000 implements MigrationInterface {
|
||||||
|
name = 'AddGovernmentBookingFields1750600000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS is_government BOOLEAN NOT NULL DEFAULT false
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS government_institution VARCHAR(255) NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ALTER COLUMN company_id DROP NOT NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bookings_is_government
|
||||||
|
ON freight.bookings (is_government)
|
||||||
|
WHERE is_government = true AND deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_is_government`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.bookings
|
||||||
|
SET company_id = '00000000-0000-0000-0000-000000000000'
|
||||||
|
WHERE company_id IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ALTER COLUMN company_id SET NOT NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
DROP COLUMN IF EXISTS government_institution
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
DROP COLUMN IF EXISTS is_government
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateSchedulingEvents1750700000000 implements MigrationInterface {
|
||||||
|
name = 'CreateSchedulingEvents1750700000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.scheduling_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
train_schedule_id UUID NOT NULL,
|
||||||
|
trigger VARCHAR(40) NOT NULL,
|
||||||
|
actor_user_id UUID NULL,
|
||||||
|
reason TEXT NULL,
|
||||||
|
plan_snapshot JSONB NOT NULL DEFAULT '{}',
|
||||||
|
displaced_booking_ids JSONB NOT NULL DEFAULT '[]',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduling_events_train_schedule_id
|
||||||
|
ON freight.scheduling_events (train_schedule_id)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_scheduling_events_train_schedule_id`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.scheduling_events`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/** 20ft = 0.5 wagon slots (2 per wagon); 40ft = 1.0 wagon slot (1 per wagon). */
|
||||||
|
export class FixContainerWagonsPerUnit1750800000000 implements MigrationInterface {
|
||||||
|
name = 'FixContainerWagonsPerUnit1750800000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const hasContainerTypes = await queryRunner.hasTable('freight.container_types');
|
||||||
|
if (!hasContainerTypes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.container_types
|
||||||
|
SET wagons_per_unit = 0.50
|
||||||
|
WHERE size_ft = 20 OR code LIKE '20%';
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.container_types
|
||||||
|
SET wagons_per_unit = 1.00
|
||||||
|
WHERE size_ft = 40 OR code LIKE '40%';
|
||||||
|
`);
|
||||||
|
|
||||||
|
const hasBookingContainer = await queryRunner.hasTable('freight.booking_container');
|
||||||
|
if (!hasBookingContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.booking_container bc
|
||||||
|
SET wagons_required = CEILING(bc.quantity * ct.wagons_per_unit)
|
||||||
|
FROM freight.container_types ct
|
||||||
|
WHERE ct.id = bc.container_type_id;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const hasContainerTypes = await queryRunner.hasTable('freight.container_types');
|
||||||
|
if (!hasContainerTypes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.container_types SET wagons_per_unit = 1.00;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddContainerNumberToBookingContainer1750900000000 implements MigrationInterface {
|
||||||
|
name = "AddContainerNumberToBookingContainer1750900000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.booking_container
|
||||||
|
ALTER COLUMN container_type_id DROP NOT NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.booking_container
|
||||||
|
ADD COLUMN container_number varchar(64);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.wagon_allocation_container_items
|
||||||
|
ALTER COLUMN container_type_id DROP NOT NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.wagon_allocation_container_items
|
||||||
|
ALTER COLUMN container_type_id SET NOT NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.booking_container
|
||||||
|
DROP COLUMN container_number;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.booking_container
|
||||||
|
ALTER COLUMN container_type_id SET NOT NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class CreateTrainSchedulingGlobalRules1751000000000 implements MigrationInterface {
|
||||||
|
name = "CreateTrainSchedulingGlobalRules1751000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE freight.train_scheduling_global_rules (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
max_train_length_meters numeric(10, 2) NOT NULL DEFAULT 760,
|
||||||
|
max_train_weight_tons numeric(10, 3) NOT NULL DEFAULT 3500,
|
||||||
|
max_wagons_per_train integer NOT NULL DEFAULT 53,
|
||||||
|
max_20ft_container_weight_tons numeric(8, 3) NOT NULL DEFAULT 30,
|
||||||
|
max_20ft_pair_weight_diff_tons numeric(8, 3) NOT NULL DEFAULT 10,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.train_scheduling_global_rules (
|
||||||
|
max_train_length_meters,
|
||||||
|
max_train_weight_tons,
|
||||||
|
max_wagons_per_train,
|
||||||
|
max_20ft_container_weight_tons,
|
||||||
|
max_20ft_pair_weight_diff_tons
|
||||||
|
) VALUES (760, 3500, 53, 30, 10);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_scheduling_global_rules;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddDeletedAtToTrainSchedulingGlobalRules1751000000001
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddDeletedAtToTrainSchedulingGlobalRules1751000000001";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_scheduling_global_rules
|
||||||
|
ADD COLUMN IF NOT EXISTS deleted_at timestamptz NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_scheduling_global_rules
|
||||||
|
DROP COLUMN IF EXISTS deleted_at;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,18 +159,20 @@ export class BookingPricingService {
|
|||||||
|
|
||||||
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
|
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
|
||||||
const containers = await Promise.all(
|
const containers = await Promise.all(
|
||||||
(booking.bookingContainers ?? []).map(async (bc) => {
|
(booking.bookingContainers ?? [])
|
||||||
const ct = await this.containerTypesService.findById(bc.containerTypeId);
|
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||||||
const vgm = Number(bc.vgmPerUnitTons);
|
.map(async (bc) => {
|
||||||
const qty = bc.quantity;
|
const ct = await this.containerTypesService.findById(bc.containerTypeId);
|
||||||
return {
|
const vgm = Number(bc.vgmPerUnitTons);
|
||||||
containerTypeId: bc.containerTypeId,
|
const qty = bc.quantity;
|
||||||
quantity: qty,
|
return {
|
||||||
vgmPerUnitTons: vgm,
|
containerTypeId: bc.containerTypeId,
|
||||||
totalVgmTons: qty * vgm,
|
quantity: qty,
|
||||||
isReefer: ct.isReefer,
|
vgmPerUnitTons: vgm,
|
||||||
};
|
totalVgmTons: qty * vgm,
|
||||||
}),
|
isReefer: ct.isReefer,
|
||||||
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||||
@@ -179,6 +181,7 @@ export class BookingPricingService {
|
|||||||
paymentCurrency: booking.paymentCurrency,
|
paymentCurrency: booking.paymentCurrency,
|
||||||
tradeDirection: booking.tradeDirection,
|
tradeDirection: booking.tradeDirection,
|
||||||
isHazardous: booking.isHazardous,
|
isHazardous: booking.isHazardous,
|
||||||
|
isGovernment: booking.isGovernment,
|
||||||
allowConsolidation: booking.allowConsolidation,
|
allowConsolidation: booking.allowConsolidation,
|
||||||
shippingLineId: booking.shippingLineId,
|
shippingLineId: booking.shippingLineId,
|
||||||
containers,
|
containers,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
type AuthUserPayload,
|
type AuthUserPayload,
|
||||||
resolveAuthUserId,
|
resolveAuthUserId,
|
||||||
} from '../../common/resolve-auth-user-id';
|
} from '../../common/resolve-auth-user-id';
|
||||||
|
import { assertFreightPermission } from '../../common/freight-permission.util';
|
||||||
|
|
||||||
@ApiTags('bookings')
|
@ApiTags('bookings')
|
||||||
@Controller('bookings')
|
@Controller('bookings')
|
||||||
@@ -75,10 +76,12 @@ export class BookingsController {
|
|||||||
create(
|
create(
|
||||||
@Body() dto: CreateBookingDto,
|
@Body() dto: CreateBookingDto,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
@Request() req: { user?: { id?: string; sub?: string } },
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
const userId = req.user?.id ?? req.user?.sub;
|
if (dto.isGovernment) {
|
||||||
return this.bookingsService.create(dto, files ?? [], userId);
|
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
||||||
|
}
|
||||||
|
return this.bookingsService.create(dto, files ?? [], user?.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@@ -244,6 +247,20 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/government-expedite')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||||
|
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
|
||||||
|
async governmentExpedite(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingsService.governmentExpedite(
|
||||||
|
id,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/approval-steps/:stepId/approve')
|
@Post(':id/approval-steps/:stepId/approve')
|
||||||
@BookingStaff([
|
@BookingStaff([
|
||||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import { BookingsRepository } from './bookings.repository';
|
||||||
|
|
||||||
|
function mockQueryBuilder() {
|
||||||
|
const qb = {
|
||||||
|
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||||
|
leftJoin: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
addOrderBy: jest.fn().mockReturnThis(),
|
||||||
|
skip: jest.fn().mockReturnThis(),
|
||||||
|
take: jest.fn().mockReturnThis(),
|
||||||
|
getMany: jest.fn(),
|
||||||
|
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
|
||||||
|
};
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BookingsRepository', () => {
|
||||||
|
let repository: jest.Mocked<Repository<Booking>>;
|
||||||
|
let dataSource: { getRepository: jest.Mock };
|
||||||
|
let bookingsRepository: BookingsRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repository = {
|
||||||
|
createQueryBuilder: jest.fn(),
|
||||||
|
} as unknown as jest.Mocked<Repository<Booking>>;
|
||||||
|
dataSource = { getRepository: jest.fn() };
|
||||||
|
bookingsRepository = new BookingsRepository(repository, dataSource as unknown as DataSource);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findEligibleForScheduling does not filter by schedule date', async () => {
|
||||||
|
const qb = mockQueryBuilder();
|
||||||
|
const bookings = [
|
||||||
|
{ id: 'b1', scheduledDate: new Date('2026-06-20T08:00:00.000Z') },
|
||||||
|
{ id: 'b2', scheduledDate: new Date('2026-06-21T14:00:00.000Z') },
|
||||||
|
];
|
||||||
|
qb.getMany.mockResolvedValue(bookings);
|
||||||
|
repository.createQueryBuilder.mockReturnValue(qb as never);
|
||||||
|
|
||||||
|
const result = await bookingsRepository.findEligibleForScheduling({
|
||||||
|
originStationId: 'yard-origin',
|
||||||
|
destinationStationId: 'yard-destination',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
const dateFilters = qb.andWhere.mock.calls.filter(([clause]) =>
|
||||||
|
String(clause).includes('scheduled_date'),
|
||||||
|
);
|
||||||
|
expect(dateFilters).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyListFilters excludes assigned bookings when assignedToSchedule is false', async () => {
|
||||||
|
const qb = mockQueryBuilder();
|
||||||
|
repository.createQueryBuilder.mockReturnValue(qb as never);
|
||||||
|
dataSource.getRepository.mockReturnValue({ find: jest.fn().mockResolvedValue([]) });
|
||||||
|
|
||||||
|
await bookingsRepository.findAllPaginated({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
assignedToSchedule: 'false',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(qb.andWhere).toHaveBeenCalledWith(expect.stringContaining('NOT EXISTS'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { BaseRepository } from '@edr/api-common';
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { SchedulingStatus } from '@edr/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, FindOptionsWhere, Repository, SelectQueryBuilder } from 'typeorm';
|
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||||
@@ -9,6 +10,7 @@ import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
|||||||
import { BookingContainer } from './entities/booking-container.entity';
|
import { BookingContainer } from './entities/booking-container.entity';
|
||||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||||
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
||||||
|
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import {
|
import {
|
||||||
BookingContractSignature,
|
BookingContractSignature,
|
||||||
@@ -20,6 +22,8 @@ import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
|
|||||||
export interface BookingListFilterOptions {
|
export interface BookingListFilterOptions {
|
||||||
statuses?: string[];
|
statuses?: string[];
|
||||||
status?: string;
|
status?: string;
|
||||||
|
schedulingStatuses?: string[];
|
||||||
|
assignedToSchedule?: 'true' | 'false';
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
contractType?: string;
|
contractType?: string;
|
||||||
serviceTypeId?: string;
|
serviceTypeId?: string;
|
||||||
@@ -427,17 +431,37 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
|
|
||||||
this.applyListFilters(qb, options);
|
this.applyListFilters(qb, options);
|
||||||
|
|
||||||
const sortField =
|
if (options.sortBy === 'isGovernment') {
|
||||||
options.sortBy === 'priorityScore'
|
qb.orderBy('booking.isGovernment', 'DESC')
|
||||||
? 'booking.priorityScore'
|
.addOrderBy('booking.priorityScore', 'DESC')
|
||||||
: 'booking.createdAt';
|
.addOrderBy('booking.scheduledDate', 'ASC');
|
||||||
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
} else {
|
||||||
|
const sortField =
|
||||||
|
options.sortBy === 'priorityScore'
|
||||||
|
? 'booking.priorityScore'
|
||||||
|
: options.sortBy === 'scheduledDate'
|
||||||
|
? 'booking.scheduledDate'
|
||||||
|
: 'booking.createdAt';
|
||||||
|
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
||||||
|
}
|
||||||
|
|
||||||
const [items, total] = await qb
|
const [items, total] = await qb
|
||||||
.skip((page - 1) * pageSize)
|
.skip((page - 1) * pageSize)
|
||||||
.take(pageSize)
|
.take(pageSize)
|
||||||
.getManyAndCount();
|
.getManyAndCount();
|
||||||
|
|
||||||
|
if (items.length) {
|
||||||
|
const links = await this.dataSource.getRepository(TrainScheduleBooking).find({
|
||||||
|
where: { bookingId: In(items.map((item) => item.id)) },
|
||||||
|
select: { bookingId: true, trainScheduleId: true },
|
||||||
|
});
|
||||||
|
const scheduleByBooking = new Map(links.map((link) => [link.bookingId, link.trainScheduleId]));
|
||||||
|
for (const item of items) {
|
||||||
|
(item as Booking & { trainScheduleId?: string | null }).trainScheduleId =
|
||||||
|
scheduleByBooking.get(item.id) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { items, total };
|
return { items, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,6 +580,26 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
} else if (options.consolidationPaired === 'false') {
|
} else if (options.consolidationPaired === 'false') {
|
||||||
qb.andWhere('booking.consolidation_partner_id IS NULL');
|
qb.andWhere('booking.consolidation_partner_id IS NULL');
|
||||||
}
|
}
|
||||||
|
if (options.schedulingStatuses?.length) {
|
||||||
|
qb.andWhere('booking.scheduling_status IN (:...schedulingStatuses)', {
|
||||||
|
schedulingStatuses: options.schedulingStatuses,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.assignedToSchedule === 'true') {
|
||||||
|
qb.andWhere(
|
||||||
|
`EXISTS (
|
||||||
|
SELECT 1 FROM freight.train_schedule_bookings tsb
|
||||||
|
WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL
|
||||||
|
)`,
|
||||||
|
);
|
||||||
|
} else if (options.assignedToSchedule === 'false') {
|
||||||
|
qb.andWhere(
|
||||||
|
`NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.train_schedule_bookings tsb
|
||||||
|
WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL
|
||||||
|
)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAndCountFiltered(where: FindOptionsWhere<Booking>, options: {
|
async findAndCountFiltered(where: FindOptionsWhere<Booking>, options: {
|
||||||
@@ -605,4 +649,99 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
}
|
}
|
||||||
return repo.save(repo.create(data));
|
return repo.save(repo.create(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bookingRepo(manager?: EntityManager) {
|
||||||
|
return manager ? manager.getRepository(Booking) : this.repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
findEligibleForScheduling(options: {
|
||||||
|
freightType?: string;
|
||||||
|
originStationId?: string;
|
||||||
|
destinationStationId?: string;
|
||||||
|
schedulingStatus?: string;
|
||||||
|
}): Promise<Booking[]> {
|
||||||
|
const qb = this.repository
|
||||||
|
.createQueryBuilder('booking')
|
||||||
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
|
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||||
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||||
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
|
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||||
|
.leftJoin(
|
||||||
|
TrainScheduleBooking,
|
||||||
|
'scheduleBooking',
|
||||||
|
'scheduleBooking.booking_id = booking.id',
|
||||||
|
)
|
||||||
|
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
|
||||||
|
.andWhere('scheduleBooking.id IS NULL');
|
||||||
|
|
||||||
|
if (options.freightType) {
|
||||||
|
qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.originStationId) {
|
||||||
|
qb.andWhere('booking.originYardId = :originStationId', {
|
||||||
|
originStationId: options.originStationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.destinationStationId) {
|
||||||
|
qb.andWhere('booking.destinationYardId = :destinationStationId', {
|
||||||
|
destinationStationId: options.destinationStationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.schedulingStatus) {
|
||||||
|
qb.andWhere('booking.scheduling_status = :schedulingStatus', {
|
||||||
|
schedulingStatus: options.schedulingStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return qb
|
||||||
|
.orderBy('booking.priority_score', 'DESC')
|
||||||
|
.addOrderBy('booking.scheduled_date', 'ASC')
|
||||||
|
.addOrderBy('booking.created_at', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
findByIdsForScheduling(bookingIds: string[], manager?: EntityManager): Promise<Booking[]> {
|
||||||
|
if (!bookingIds.length) return Promise.resolve([]);
|
||||||
|
return this.bookingRepo(manager).find({
|
||||||
|
where: { id: In(bookingIds) },
|
||||||
|
relations: {
|
||||||
|
company: true,
|
||||||
|
originYard: true,
|
||||||
|
destinationYard: true,
|
||||||
|
bookingContainers: { containerType: true },
|
||||||
|
cargoType: true,
|
||||||
|
},
|
||||||
|
order: { priorityScore: 'DESC', createdAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSchedulingFields(
|
||||||
|
bookingId: string,
|
||||||
|
fields: Partial<
|
||||||
|
Pick<
|
||||||
|
Booking,
|
||||||
|
'schedulingStatus' | 'wagonsRequired' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt'
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.bookingRepo(manager).update(bookingId, fields as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setHoldWindowOnPaid(bookingId: string, manager?: EntityManager): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
const expires = new Date(now.getTime() + 3 * 60 * 60 * 1000);
|
||||||
|
await this.updateSchedulingFields(
|
||||||
|
bookingId,
|
||||||
|
{
|
||||||
|
schedulingStatus: SchedulingStatus.Holding,
|
||||||
|
holdStartedAt: now,
|
||||||
|
holdExpiresAt: expires,
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { SchedulingStatus } from '@edr/types';
|
||||||
// import { CustomersService } from '../customers/customers.service';
|
// import { CustomersService } from '../customers/customers.service';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
@@ -64,6 +65,7 @@ export class BookingsService {
|
|||||||
paymentCurrency: string;
|
paymentCurrency: string;
|
||||||
tradeDirection: string;
|
tradeDirection: string;
|
||||||
isHazardous?: boolean;
|
isHazardous?: boolean;
|
||||||
|
isGovernment?: boolean;
|
||||||
allowConsolidation?: boolean;
|
allowConsolidation?: boolean;
|
||||||
shippingLineId?: string | null;
|
shippingLineId?: string | null;
|
||||||
containers: CreateBookingContainerDto[];
|
containers: CreateBookingContainerDto[];
|
||||||
@@ -92,6 +94,7 @@ export class BookingsService {
|
|||||||
paymentCurrency: dto.paymentCurrency,
|
paymentCurrency: dto.paymentCurrency,
|
||||||
tradeDirection: dto.tradeDirection,
|
tradeDirection: dto.tradeDirection,
|
||||||
isHazardous: dto.isHazardous ?? false,
|
isHazardous: dto.isHazardous ?? false,
|
||||||
|
isGovernment: dto.isGovernment ?? false,
|
||||||
allowConsolidation:
|
allowConsolidation:
|
||||||
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
|
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
|
||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
@@ -178,8 +181,15 @@ export class BookingsService {
|
|||||||
// customerId = customer.id;
|
// customerId = customer.id;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
let companyId = dto.companyId;
|
const isGovernment = dto.isGovernment === true;
|
||||||
if (!companyId) {
|
|
||||||
|
let companyId: string | null | undefined = dto.companyId;
|
||||||
|
if (isGovernment) {
|
||||||
|
if (!dto.governmentInstitution?.trim()) {
|
||||||
|
throw new BadRequestException('governmentInstitution is required for government bookings');
|
||||||
|
}
|
||||||
|
companyId = dto.companyId ?? null;
|
||||||
|
} else if (!companyId) {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'companyId is required or must be resolvable from auth token',
|
'companyId is required or must be resolvable from auth token',
|
||||||
@@ -209,6 +219,7 @@ export class BookingsService {
|
|||||||
paymentCurrency: dto.paymentCurrency,
|
paymentCurrency: dto.paymentCurrency,
|
||||||
tradeDirection: dto.tradeDirection,
|
tradeDirection: dto.tradeDirection,
|
||||||
isHazardous: dto.isHazardous,
|
isHazardous: dto.isHazardous,
|
||||||
|
isGovernment,
|
||||||
allowConsolidation,
|
allowConsolidation,
|
||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
containers,
|
containers,
|
||||||
@@ -220,7 +231,9 @@ export class BookingsService {
|
|||||||
|
|
||||||
const booking = await this.bookingsRepository.create({
|
const booking = await this.bookingsRepository.create({
|
||||||
reference,
|
reference,
|
||||||
companyId,
|
companyId: companyId ?? null,
|
||||||
|
isGovernment,
|
||||||
|
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
||||||
trainId: dto.trainId,
|
trainId: dto.trainId,
|
||||||
contractType: dto.contractType,
|
contractType: dto.contractType,
|
||||||
previousContractId: dto.previousContractId,
|
previousContractId: dto.previousContractId,
|
||||||
@@ -300,12 +313,13 @@ export class BookingsService {
|
|||||||
const freightType = (dto.freightType ?? existing.freightType) as FreightType;
|
const freightType = (dto.freightType ?? existing.freightType) as FreightType;
|
||||||
let containers =
|
let containers =
|
||||||
dto.containers ??
|
dto.containers ??
|
||||||
existing.bookingContainers?.map((bc) => ({
|
(existing.bookingContainers ?? [])
|
||||||
containerTypeId: bc.containerTypeId,
|
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||||||
quantity: bc.quantity,
|
.map((bc) => ({
|
||||||
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
|
containerTypeId: bc.containerTypeId,
|
||||||
})) ??
|
quantity: bc.quantity,
|
||||||
[];
|
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
|
||||||
|
}));
|
||||||
|
|
||||||
let cargoTypeId =
|
let cargoTypeId =
|
||||||
dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId;
|
dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId;
|
||||||
@@ -403,6 +417,19 @@ export class BookingsService {
|
|||||||
return { booking, warnings };
|
return { booking, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse comma-separated scheduling status query values. */
|
||||||
|
private parseSchedulingStatusFilter(filter: FilterBookingDto): {
|
||||||
|
schedulingStatuses?: string[];
|
||||||
|
} {
|
||||||
|
const raw = filter.schedulingStatuses;
|
||||||
|
if (!raw) return {};
|
||||||
|
const schedulingStatuses = raw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return schedulingStatuses.length ? { schedulingStatuses } : {};
|
||||||
|
}
|
||||||
|
|
||||||
/** Parse comma-separated or repeated status query values. */
|
/** Parse comma-separated or repeated status query values. */
|
||||||
private parseStatusFilter(filter: FilterBookingDto): {
|
private parseStatusFilter(filter: FilterBookingDto): {
|
||||||
statuses?: string[];
|
statuses?: string[];
|
||||||
@@ -433,11 +460,14 @@ export class BookingsService {
|
|||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 20;
|
const pageSize = filter.pageSize ?? 20;
|
||||||
const statusFilter = this.parseStatusFilter(filter);
|
const statusFilter = this.parseStatusFilter(filter);
|
||||||
|
const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);
|
||||||
|
|
||||||
return this.bookingsRepository.findAllPaginated({
|
return this.bookingsRepository.findAllPaginated({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
...statusFilter,
|
...statusFilter,
|
||||||
|
...schedulingStatusFilter,
|
||||||
|
assignedToSchedule: filter.assignedToSchedule,
|
||||||
companyId: filter.companyId,
|
companyId: filter.companyId,
|
||||||
contractType: filter.contractType,
|
contractType: filter.contractType,
|
||||||
serviceTypeId: filter.serviceTypeId,
|
serviceTypeId: filter.serviceTypeId,
|
||||||
@@ -695,12 +725,13 @@ export class BookingsService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (dto.containers !== undefined) {
|
if (dto.containers !== undefined) {
|
||||||
const existingContainers =
|
const existingContainers = (existing.bookingContainers ?? [])
|
||||||
existing.bookingContainers?.map((bc) => ({
|
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||||||
|
.map((bc) => ({
|
||||||
containerTypeId: bc.containerTypeId,
|
containerTypeId: bc.containerTypeId,
|
||||||
quantity: bc.quantity,
|
quantity: bc.quantity,
|
||||||
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
|
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
|
||||||
})) ?? [];
|
}));
|
||||||
if (JSON.stringify(existingContainers) !== JSON.stringify(containers)) {
|
if (JSON.stringify(existingContainers) !== JSON.stringify(containers)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -714,4 +745,32 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */
|
||||||
|
async governmentExpedite(id: string, staffUserId: string): Promise<Booking> {
|
||||||
|
const booking = await this.findById(id);
|
||||||
|
if (!booking.isGovernment) {
|
||||||
|
throw new BadRequestException('Only government bookings can be expedited');
|
||||||
|
}
|
||||||
|
const blocked = ['PAID', 'IN_TRANSIT', 'COMPLETED', 'CANCELLED', 'REJECTED'];
|
||||||
|
if (blocked.includes(booking.status)) {
|
||||||
|
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bookingsRepository.update(id, {
|
||||||
|
status: 'PAID',
|
||||||
|
paymentStatus: 'PAID',
|
||||||
|
schedulingStatus: SchedulingStatus.Eligible,
|
||||||
|
holdStartedAt: null,
|
||||||
|
holdExpiresAt: null,
|
||||||
|
});
|
||||||
|
await this.bookingsRepository.createReviewNote(
|
||||||
|
id,
|
||||||
|
`Government booking expedited to PAID by staff (${staffUserId})`,
|
||||||
|
'STAFF_NOTE',
|
||||||
|
staffUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,11 +76,12 @@ export class ConsolidationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async slotsFromBooking(booking: Booking): Promise<ConsolidationSlot[]> {
|
async slotsFromBooking(booking: Booking): Promise<ConsolidationSlot[]> {
|
||||||
const lines =
|
const lines = (booking.bookingContainers ?? [])
|
||||||
booking.bookingContainers?.map((bc) => ({
|
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||||||
|
.map((bc) => ({
|
||||||
containerTypeId: bc.containerTypeId,
|
containerTypeId: bc.containerTypeId,
|
||||||
quantity: bc.quantity,
|
quantity: bc.quantity,
|
||||||
})) ?? [];
|
}));
|
||||||
return this.slotsFromContainerLines(lines);
|
return this.slotsFromContainerLines(lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
Min,
|
Min,
|
||||||
|
MinLength,
|
||||||
Validate,
|
Validate,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
@@ -66,7 +67,21 @@ export class CreateBookingDto {
|
|||||||
// @IsUUID()
|
// @IsUUID()
|
||||||
// customerId?: string;
|
// customerId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Staff only: government booking flag' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
|
isGovernment?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Required when isGovernment is true' })
|
||||||
|
@ValidateIf((o) => o.isGovernment === true)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
governmentInstitution?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
|
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
|
||||||
|
@ValidateIf((o) => o.isGovernment !== true)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
|
|||||||
@@ -84,8 +84,25 @@ export class FilterBookingDto {
|
|||||||
@Transform(({ value }) => (value ? parseInt(value, 10) : 20))
|
@Transform(({ value }) => (value ? parseInt(value, 10) : 20))
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Comma-separated scheduling statuses (NOT_SCHEDULED,HOLDING,ELIGIBLE,SCHEDULED)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
if (value === undefined || value === null || value === '') return undefined;
|
||||||
|
if (Array.isArray(value)) return value.map(String).join(',');
|
||||||
|
return String(value);
|
||||||
|
})
|
||||||
|
schedulingStatuses?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter by train schedule assignment' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['true', 'false'])
|
||||||
|
assignedToSchedule?: 'true' | 'false';
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: 'createdAt' })
|
@ApiPropertyOptional({ default: 'createdAt' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@IsIn(['createdAt', 'priorityScore', 'scheduledDate', 'isGovernment'])
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
|
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ export class BookingContainer extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'booking_id' })
|
@JoinColumn({ name: 'booking_id' })
|
||||||
booking?: Booking;
|
booking?: Booking;
|
||||||
|
|
||||||
@Column({ name: 'container_type_id', type: 'uuid' })
|
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
|
||||||
containerTypeId!: string;
|
containerTypeId?: string | null;
|
||||||
|
|
||||||
@ManyToOne(() => ContainerType)
|
@ManyToOne(() => ContainerType, { nullable: true })
|
||||||
@JoinColumn({ name: 'container_type_id' })
|
@JoinColumn({ name: 'container_type_id' })
|
||||||
containerType?: ContainerType;
|
containerType?: ContainerType | null;
|
||||||
|
|
||||||
|
@Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true })
|
||||||
|
containerNumber?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'quantity', type: 'smallint' })
|
@Column({ name: 'quantity', type: 'smallint' })
|
||||||
quantity!: number;
|
quantity!: number;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { BaseEntity } from '@edr/api-common';
|
|||||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
import { Booking } from './booking.entity';
|
import { Booking } from './booking.entity';
|
||||||
|
|
||||||
export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION'] as const;
|
export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION', 'STAFF_NOTE'] as const;
|
||||||
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
|
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'booking_review_note' })
|
@Entity({ schema: 'freight', name: 'booking_review_note' })
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { SchedulingStatus } from '@edr/types';
|
||||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||||
// import { Customer } from '../../customers/entities/customer.entity';
|
// import { Customer } from '../../customers/entities/customer.entity';
|
||||||
import { Company } from '../../companies/entities/company.entity';
|
import { Company } from '../../companies/entities/company.entity';
|
||||||
@@ -51,6 +52,16 @@ export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
|
|||||||
export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||||
export type FreightType = (typeof FREIGHT_TYPES)[number];
|
export type FreightType = (typeof FREIGHT_TYPES)[number];
|
||||||
|
|
||||||
|
export const SCHEDULING_STATUSES = [
|
||||||
|
SchedulingStatus.NotScheduled,
|
||||||
|
SchedulingStatus.Holding,
|
||||||
|
SchedulingStatus.Eligible,
|
||||||
|
SchedulingStatus.Scheduled,
|
||||||
|
SchedulingStatus.Dispatched,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type BookingSchedulingStatus = (typeof SCHEDULING_STATUSES)[number];
|
||||||
|
|
||||||
/** Statuses where the customer may edit booking fields. */
|
/** Statuses where the customer may edit booking fields. */
|
||||||
export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [
|
export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [
|
||||||
'DRAFT',
|
'DRAFT',
|
||||||
@@ -69,16 +80,24 @@ export class Booking extends BaseEntity {
|
|||||||
// @JoinColumn({ name: 'customer_id' })
|
// @JoinColumn({ name: 'customer_id' })
|
||||||
// customer?: Customer;
|
// customer?: Customer;
|
||||||
|
|
||||||
@Column({ name: 'company_id', type: 'uuid' })
|
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||||
companyId!: string;
|
companyId?: string | null;
|
||||||
|
|
||||||
@ManyToOne(() => Company)
|
@ManyToOne(() => Company, { nullable: true })
|
||||||
@JoinColumn({ name: 'company_id' })
|
@JoinColumn({ name: 'company_id' })
|
||||||
company?: Company;
|
company?: Company | null;
|
||||||
|
|
||||||
|
@Column({ name: 'is_government', type: 'boolean', default: false })
|
||||||
|
isGovernment!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'government_institution', type: 'varchar', length: 255, nullable: true })
|
||||||
|
governmentInstitution?: string | null;
|
||||||
|
|
||||||
|
/** @deprecated Fleet master data link — scheduling uses train_schedule_bookings instead. */
|
||||||
@Column({ name: 'train_id', type: 'uuid', nullable: true })
|
@Column({ name: 'train_id', type: 'uuid', nullable: true })
|
||||||
trainId?: string | null;
|
trainId?: string | null;
|
||||||
|
|
||||||
|
/** @deprecated Use train_schedule_bookings for operational scheduling. */
|
||||||
@ManyToOne(() => Train, { nullable: true })
|
@ManyToOne(() => Train, { nullable: true })
|
||||||
@JoinColumn({ name: 'train_id' })
|
@JoinColumn({ name: 'train_id' })
|
||||||
train?: Train | null;
|
train?: Train | null;
|
||||||
@@ -240,6 +259,21 @@ export class Booking extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'consolidation_partner_id' })
|
@JoinColumn({ name: 'consolidation_partner_id' })
|
||||||
consolidationPartner?: Booking | null;
|
consolidationPartner?: Booking | null;
|
||||||
|
|
||||||
|
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true })
|
||||||
|
wagonsRequired?: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'scheduling_status', type: 'varchar', length: 30, default: 'NOT_SCHEDULED' })
|
||||||
|
schedulingStatus!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'hold_started_at', type: 'timestamptz', nullable: true })
|
||||||
|
holdStartedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'hold_expires_at', type: 'timestamptz', nullable: true })
|
||||||
|
holdExpiresAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
|
||||||
|
scheduledAt?: Date | null;
|
||||||
|
|
||||||
@OneToMany(() => BookingContainer, (bc) => bc.booking)
|
@OneToMany(() => BookingContainer, (bc) => bc.booking)
|
||||||
bookingContainers?: BookingContainer[];
|
bookingContainers?: BookingContainer[];
|
||||||
|
|
||||||
|
|||||||
@@ -159,9 +159,12 @@ export class CargoesService {
|
|||||||
cargo.status = 'DELIVERED';
|
cargo.status = 'DELIVERED';
|
||||||
if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks;
|
if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks;
|
||||||
|
|
||||||
const remaining = await this.cargoRepo.count({
|
const remaining =
|
||||||
where: { containerId: cargo.containerId, status: 'LOADED' },
|
cargo.containerId != null
|
||||||
});
|
? await this.cargoRepo.count({
|
||||||
|
where: { containerId: cargo.containerId, status: 'LOADED' },
|
||||||
|
})
|
||||||
|
: 0;
|
||||||
if (remaining === 0 && cargo.container) {
|
if (remaining === 0 && cargo.container) {
|
||||||
cargo.container.status = 'AVAILABLE';
|
cargo.container.status = 'AVAILABLE';
|
||||||
await this.containerRepo.save(cargo.container);
|
await this.containerRepo.save(cargo.container);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// apps/edr-freight-api/src/modules/cargoes/entities/cargo.entity.ts
|
// apps/edr-freight-api/src/modules/cargoes/entities/cargo.entity.ts
|
||||||
import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
|
import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { Container } from '../../container-management/entities/container.entity';
|
import { Container } from '../../container-management/entities/container.entity';
|
||||||
|
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
|
|
||||||
@Entity({ name: 'cargoes', schema: 'freight' })
|
@Entity({ name: 'cargoes', schema: 'freight' })
|
||||||
export class Cargo extends BaseEntity {
|
export class Cargo extends BaseEntity {
|
||||||
@@ -11,8 +13,8 @@ export class Cargo extends BaseEntity {
|
|||||||
@Column({ name: 'shipment_id', type: 'uuid' })
|
@Column({ name: 'shipment_id', type: 'uuid' })
|
||||||
shipmentId!: string;
|
shipmentId!: string;
|
||||||
|
|
||||||
@Column({ name: 'container_id', type: 'uuid' })
|
@Column({ name: 'container_id', type: 'uuid', nullable: true })
|
||||||
containerId!: string;
|
containerId!: string | null;
|
||||||
|
|
||||||
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
||||||
cargoTypeId!: string | null; // optional link to cargo_types table
|
cargoTypeId!: string | null; // optional link to cargo_types table
|
||||||
@@ -38,8 +40,24 @@ export class Cargo extends BaseEntity {
|
|||||||
@Column({ name: 'unloaded_at', type: 'timestamp', nullable: true })
|
@Column({ name: 'unloaded_at', type: 'timestamp', nullable: true })
|
||||||
unloadedAt!: Date | null;
|
unloadedAt!: Date | null;
|
||||||
|
|
||||||
// Relationship to Container
|
@Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true })
|
||||||
@ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT' })
|
wagonBookingAllocationId!: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => WagonBookingAllocation, { nullable: true, onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'wagon_booking_allocation_id' })
|
||||||
|
wagonBookingAllocation?: WagonBookingAllocation | null;
|
||||||
|
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||||
|
bookingId!: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => Booking, { nullable: true, onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'booking_id' })
|
||||||
|
booking?: Booking | null;
|
||||||
|
|
||||||
|
@Column({ name: 'load_type', type: 'varchar', length: 20, nullable: true })
|
||||||
|
loadType!: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT', nullable: true })
|
||||||
@JoinColumn({ name: 'container_id' })
|
@JoinColumn({ name: 'container_id' })
|
||||||
container!: Container;
|
container!: Container | null;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
// apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts
|
// apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts
|
||||||
import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
|
import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
|
||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
|
import { BookingContainer } from '../../bookings/entities/booking-container.entity';
|
||||||
|
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||||
import { Cargo } from '../../cargoes/entities/cargoes.entity';
|
import { Cargo } from '../../cargoes/entities/cargoes.entity';
|
||||||
|
|
||||||
@@ -34,7 +37,27 @@ sealNumber!: string | null;
|
|||||||
@Column({ type: 'varchar', default: 'AVAILABLE' })
|
@Column({ type: 'varchar', default: 'AVAILABLE' })
|
||||||
status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED
|
status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED
|
||||||
|
|
||||||
// Relationship to Wagon
|
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||||
|
bookingId!: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => Booking, { nullable: true, onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'booking_id' })
|
||||||
|
booking?: Booking | null;
|
||||||
|
|
||||||
|
@Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true })
|
||||||
|
wagonBookingAllocationId!: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => WagonBookingAllocation, { nullable: true, onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'wagon_booking_allocation_id' })
|
||||||
|
wagonBookingAllocation?: WagonBookingAllocation | null;
|
||||||
|
|
||||||
|
@Column({ name: 'booking_container_id', type: 'uuid', nullable: true })
|
||||||
|
bookingContainerId!: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => BookingContainer, { nullable: true, onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'booking_container_id' })
|
||||||
|
bookingContainer?: BookingContainer | null;
|
||||||
|
|
||||||
@ManyToOne(() => Wagon, (wagon) => wagon.containers, { onDelete: 'SET NULL' })
|
@ManyToOne(() => Wagon, (wagon) => wagon.containers, { onDelete: 'SET NULL' })
|
||||||
@JoinColumn({ name: 'wagon_id' })
|
@JoinColumn({ name: 'wagon_id' })
|
||||||
wagon!: Wagon | null;
|
wagon!: Wagon | null;
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export class OverviewRepository {
|
|||||||
this.wagonRepository
|
this.wagonRepository
|
||||||
.createQueryBuilder('wagon')
|
.createQueryBuilder('wagon')
|
||||||
.where('wagon.deleted_at IS NULL')
|
.where('wagon.deleted_at IS NULL')
|
||||||
.andWhere('wagon.status = :status', { status: 'AVAILABLE' })
|
.andWhere('wagon.status = :status', { status: Freight.WagonStatus.Available })
|
||||||
.getCount(),
|
.getCount(),
|
||||||
this.containerRepository
|
this.containerRepository
|
||||||
.createQueryBuilder('container')
|
.createQueryBuilder('container')
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import * as fs from "fs";
|
|||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import * as Handlebars from "handlebars";
|
import * as Handlebars from "handlebars";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { SchedulingStatus } from "@edr/types";
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
|
|
||||||
type PaymentMethod = PaymentEntity["method"];
|
type PaymentMethod = PaymentEntity["method"];
|
||||||
@@ -168,7 +169,14 @@ export class PaymentService {
|
|||||||
const ordersStatus = bizContent.order_status;
|
const ordersStatus = bizContent.order_status;
|
||||||
if (ordersStatus == "PAY_SUCCESS") {
|
if (ordersStatus == "PAY_SUCCESS") {
|
||||||
await this.datasource.transaction(async (mg) => {
|
await this.datasource.transaction(async (mg) => {
|
||||||
await mg.update(Booking, { id: resp.refId }, { status: "PAID" });
|
const now = new Date();
|
||||||
|
const holdExpires = new Date(now.getTime() + 3 * 60 * 60 * 1000);
|
||||||
|
await mg.update(Booking, { id: resp.refId }, {
|
||||||
|
status: "PAID",
|
||||||
|
schedulingStatus: SchedulingStatus.Holding,
|
||||||
|
holdStartedAt: now,
|
||||||
|
holdExpiresAt: holdExpires,
|
||||||
|
});
|
||||||
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" });
|
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** Ensures government bookings outrank commercial priority (max ~1,500 today). */
|
||||||
|
export const GOVERNMENT_PRIORITY_BONUS = 50_000;
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
SHIPPING_LINES_REPOSITORY,
|
SHIPPING_LINES_REPOSITORY,
|
||||||
} from './interfaces/shipping-lines.repository.interface';
|
} from './interfaces/shipping-lines.repository.interface';
|
||||||
import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults';
|
import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults';
|
||||||
|
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
|
||||||
|
|
||||||
export interface BookingContainerEvalInput {
|
export interface BookingContainerEvalInput {
|
||||||
containerTypeId: string;
|
containerTypeId: string;
|
||||||
@@ -54,6 +55,7 @@ export interface BookingEvaluationInput {
|
|||||||
paymentCurrency: string;
|
paymentCurrency: string;
|
||||||
tradeDirection: string;
|
tradeDirection: string;
|
||||||
isHazardous: boolean;
|
isHazardous: boolean;
|
||||||
|
isGovernment?: boolean;
|
||||||
allowConsolidation?: boolean;
|
allowConsolidation?: boolean;
|
||||||
shippingLineId?: string | null;
|
shippingLineId?: string | null;
|
||||||
containers: BookingContainerEvalInput[];
|
containers: BookingContainerEvalInput[];
|
||||||
@@ -180,6 +182,10 @@ export class RuleEngineService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.isGovernment) {
|
||||||
|
priorityScore += GOVERNMENT_PRIORITY_BONUS;
|
||||||
|
}
|
||||||
|
|
||||||
let shippingLineMapped = false;
|
let shippingLineMapped = false;
|
||||||
if (input.shippingLineId) {
|
if (input.shippingLineId) {
|
||||||
const line = await this.shippingLinesRepo.findById(input.shippingLineId);
|
const line = await this.shippingLinesRepo.findById(input.shippingLineId);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsIn,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
import { RESCHEDULE_TRIGGERS } from '../entities/scheduling-event.entity';
|
||||||
|
|
||||||
|
export class PreviewRescheduleDto {
|
||||||
|
@ApiProperty({ type: [String] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
incomingBookingIds!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ enum: RESCHEDULE_TRIGGERS })
|
||||||
|
@IsIn([...RESCHEDULE_TRIGGERS])
|
||||||
|
trigger!: (typeof RESCHEDULE_TRIGGERS)[number];
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '2026-06-22T08:00:00.000Z' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
newDepartureDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExecuteRescheduleDto extends PreviewRescheduleDto {
|
||||||
|
@ApiProperty({ type: [String], description: 'Booking IDs to assign after reschedule' })
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
finalBookingIds!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [String], description: 'Booking IDs removed from the schedule' })
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
displacedBookingIds!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index } from 'typeorm';
|
||||||
|
|
||||||
|
export const RESCHEDULE_TRIGGERS = [
|
||||||
|
'GOVERNMENT_PREEMPT',
|
||||||
|
'TRAIN_MAINTENANCE',
|
||||||
|
'MANUAL',
|
||||||
|
'CAPACITY_REBALANCE',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type RescheduleTrigger = (typeof RESCHEDULE_TRIGGERS)[number];
|
||||||
|
|
||||||
|
@Entity({ schema: 'freight', name: 'scheduling_events' })
|
||||||
|
@Index(['trainScheduleId'])
|
||||||
|
export class SchedulingEvent extends BaseEntity {
|
||||||
|
@Column({ name: 'train_schedule_id', type: 'uuid' })
|
||||||
|
trainScheduleId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'trigger', type: 'varchar', length: 40 })
|
||||||
|
trigger!: RescheduleTrigger;
|
||||||
|
|
||||||
|
@Column({ name: 'actor_user_id', type: 'uuid', nullable: true })
|
||||||
|
actorUserId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'reason', type: 'text', nullable: true })
|
||||||
|
reason?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'plan_snapshot', type: 'jsonb' })
|
||||||
|
planSnapshot!: Record<string, unknown>;
|
||||||
|
|
||||||
|
@Column({ name: 'displaced_booking_ids', type: 'jsonb', default: '[]' })
|
||||||
|
displacedBookingIds!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
|
||||||
|
import { TrainSchedulingManage } from '../../common/booking-guards';
|
||||||
|
import {
|
||||||
|
type AuthUserPayload,
|
||||||
|
resolveAuthUserId,
|
||||||
|
} from '../../common/resolve-auth-user-id';
|
||||||
|
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
||||||
|
import { SchedulingRescheduleService } from './scheduling-reschedule.service';
|
||||||
|
|
||||||
|
@ApiTags('train-scheduling')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('train-scheduling/schedules/:id/reschedule')
|
||||||
|
export class SchedulingRescheduleController {
|
||||||
|
constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {}
|
||||||
|
|
||||||
|
@Post('preview')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Preview reschedule / government preempt plan' })
|
||||||
|
preview(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: PreviewRescheduleDto,
|
||||||
|
) {
|
||||||
|
return this.schedulingRescheduleService.previewReschedule(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('execute')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Execute a confirmed reschedule plan' })
|
||||||
|
execute(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: ExecuteRescheduleDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.schedulingRescheduleService.executeReschedule(
|
||||||
|
id,
|
||||||
|
dto,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('train-scheduling')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('train-scheduling/schedules/:id')
|
||||||
|
export class SchedulingMaintenanceController {
|
||||||
|
constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {}
|
||||||
|
|
||||||
|
@Post('maintenance')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Reschedule train for maintenance (new departure + rebalance)' })
|
||||||
|
maintenance(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: PreviewRescheduleDto & { newDepartureDate: string },
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.schedulingRescheduleService.maintenanceReschedule(
|
||||||
|
id,
|
||||||
|
dto,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
|
import { TrainSchedulesModule } from '../train-schedules/train-schedules.module';
|
||||||
|
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||||
|
import { SchedulingEvent } from './entities/scheduling-event.entity';
|
||||||
|
import {
|
||||||
|
SchedulingMaintenanceController,
|
||||||
|
SchedulingRescheduleController,
|
||||||
|
} from './scheduling-reschedule.controller';
|
||||||
|
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
|
||||||
|
import { SchedulingRescheduleService } from './scheduling-reschedule.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([SchedulingEvent]),
|
||||||
|
BookingsModule,
|
||||||
|
TrainSchedulesModule,
|
||||||
|
TrainSchedulingModule,
|
||||||
|
],
|
||||||
|
controllers: [SchedulingRescheduleController, SchedulingMaintenanceController],
|
||||||
|
providers: [SchedulingRescheduleRepository, SchedulingRescheduleService],
|
||||||
|
exports: [SchedulingRescheduleService],
|
||||||
|
})
|
||||||
|
export class SchedulingRescheduleModule {}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { SchedulingEvent, type RescheduleTrigger } from './entities/scheduling-event.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SchedulingRescheduleRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(SchedulingEvent)
|
||||||
|
private readonly repository: Repository<SchedulingEvent>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Persist an audit record for a completed reschedule. */
|
||||||
|
async createEvent(data: {
|
||||||
|
trainScheduleId: string;
|
||||||
|
trigger: RescheduleTrigger;
|
||||||
|
actorUserId?: string;
|
||||||
|
reason?: string;
|
||||||
|
planSnapshot: Record<string, unknown>;
|
||||||
|
displacedBookingIds: string[];
|
||||||
|
}): Promise<SchedulingEvent> {
|
||||||
|
return this.repository.save(this.repository.create(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
|
||||||
|
import { SchedulingRescheduleService } from './scheduling-reschedule.service';
|
||||||
|
|
||||||
|
const makeBooking = (
|
||||||
|
id: string,
|
||||||
|
reference: string,
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
|
) => ({
|
||||||
|
id,
|
||||||
|
reference,
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
cargoTotalWeightVgm: 100,
|
||||||
|
scheduledDate: new Date('2026-06-20T08:00:00.000Z'),
|
||||||
|
originYardId: 'yard-origin',
|
||||||
|
destinationYardId: 'yard-destination',
|
||||||
|
status: 'PAID',
|
||||||
|
isGovernment: false,
|
||||||
|
priorityScore: 50,
|
||||||
|
bookingContainers: [
|
||||||
|
{
|
||||||
|
id: `${id}-line`,
|
||||||
|
wagonsRequired: 5,
|
||||||
|
quantity: 1,
|
||||||
|
vgmPerUnitTons: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compareSchedulingPriority', () => {
|
||||||
|
it('orders government before commercial', () => {
|
||||||
|
const sorted = [
|
||||||
|
{
|
||||||
|
isGovernment: false,
|
||||||
|
priorityScore: 50000,
|
||||||
|
scheduledDate: new Date('2026-06-20'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isGovernment: true,
|
||||||
|
priorityScore: 100,
|
||||||
|
scheduledDate: new Date('2026-06-25'),
|
||||||
|
},
|
||||||
|
].sort(compareSchedulingPriority);
|
||||||
|
|
||||||
|
expect(sorted[0]?.isGovernment).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SchedulingRescheduleService', () => {
|
||||||
|
let service: SchedulingRescheduleService;
|
||||||
|
let trainSchedulesRepository: Record<string, jest.Mock>;
|
||||||
|
let bookingsRepository: Record<string, jest.Mock>;
|
||||||
|
let trainSchedulingService: Record<string, jest.Mock>;
|
||||||
|
let schedulingRescheduleRepository: Record<string, jest.Mock>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
trainSchedulesRepository = {
|
||||||
|
findByIdWithFullGraph: jest.fn(),
|
||||||
|
updateStatus: jest.fn(),
|
||||||
|
};
|
||||||
|
bookingsRepository = {
|
||||||
|
findByIdsForScheduling: jest.fn(),
|
||||||
|
updateSchedulingFields: jest.fn(),
|
||||||
|
};
|
||||||
|
trainSchedulingService = {
|
||||||
|
previewTrainSchedule: jest.fn(),
|
||||||
|
unassignBooking: jest.fn(),
|
||||||
|
assignBookingsToSchedule: jest.fn(),
|
||||||
|
};
|
||||||
|
schedulingRescheduleRepository = {
|
||||||
|
createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
service = new SchedulingRescheduleService(
|
||||||
|
trainSchedulesRepository as never,
|
||||||
|
bookingsRepository as never,
|
||||||
|
trainSchedulingService as never,
|
||||||
|
schedulingRescheduleRepository as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects reschedule on dispatched trains', async () => {
|
||||||
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
||||||
|
id: 'sched-1',
|
||||||
|
status: 'DISPATCHED',
|
||||||
|
scheduleBookings: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.previewReschedule('sched-1', {
|
||||||
|
incomingBookingIds: ['gov-1'],
|
||||||
|
trigger: 'GOVERNMENT_PREEMPT',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displaces lower-priority commercial when government incoming exceeds capacity', async () => {
|
||||||
|
const commercial = makeBooking('c1', 'BKG-COMM', { priorityScore: 10, isGovernment: false });
|
||||||
|
const government = makeBooking('g1', 'BKG-GOV', {
|
||||||
|
isGovernment: true,
|
||||||
|
priorityScore: 60000,
|
||||||
|
governmentInstitution: 'Ministry',
|
||||||
|
});
|
||||||
|
|
||||||
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
||||||
|
id: 'sched-1',
|
||||||
|
status: 'DRAFT',
|
||||||
|
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
|
||||||
|
originStationId: 'yard-origin',
|
||||||
|
destinationStationId: 'yard-destination',
|
||||||
|
scheduleBookings: [{ bookingId: 'c1', booking: commercial }],
|
||||||
|
});
|
||||||
|
bookingsRepository.findByIdsForScheduling.mockResolvedValue([government]);
|
||||||
|
|
||||||
|
trainSchedulingService.previewTrainSchedule.mockImplementation(
|
||||||
|
async ({ bookingIds }: { bookingIds: string[] }) => ({
|
||||||
|
valid: bookingIds.length <= 1,
|
||||||
|
violations: bookingIds.length > 1 ? ['Train capacity exceeded'] : [],
|
||||||
|
warnings: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const plan = await service.previewReschedule('sched-1', {
|
||||||
|
incomingBookingIds: ['g1'],
|
||||||
|
trigger: 'GOVERNMENT_PREEMPT',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(plan.retained.map((b) => b.id)).toEqual(['g1']);
|
||||||
|
expect(plan.displaced.map((b) => b.id)).toEqual(['c1']);
|
||||||
|
expect(plan.finalBookingIds).toEqual(['g1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('readmits high-priority commercial when spare capacity remains', async () => {
|
||||||
|
const low = makeBooking('c-low', 'BKG-LOW', { priorityScore: 5 });
|
||||||
|
const high = makeBooking('c-high', 'BKG-HIGH', { priorityScore: 500 });
|
||||||
|
const government = makeBooking('g1', 'BKG-GOV', { isGovernment: true, priorityScore: 60000 });
|
||||||
|
|
||||||
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
||||||
|
id: 'sched-1',
|
||||||
|
status: 'DRAFT',
|
||||||
|
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
|
||||||
|
originStationId: 'yard-origin',
|
||||||
|
destinationStationId: 'yard-destination',
|
||||||
|
scheduleBookings: [
|
||||||
|
{ bookingId: 'c-low', booking: low },
|
||||||
|
{ bookingId: 'c-high', booking: high },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
bookingsRepository.findByIdsForScheduling.mockResolvedValue([government]);
|
||||||
|
|
||||||
|
const fitAttempts = new Map<string, number>();
|
||||||
|
trainSchedulingService.previewTrainSchedule.mockImplementation(
|
||||||
|
async ({ bookingIds }: { bookingIds: string[] }) => {
|
||||||
|
const key = [...bookingIds].sort().join(',');
|
||||||
|
const attempt = (fitAttempts.get(key) ?? 0) + 1;
|
||||||
|
fitAttempts.set(key, attempt);
|
||||||
|
|
||||||
|
const fits =
|
||||||
|
bookingIds.length === 1 ||
|
||||||
|
(key === 'c-high,g1' && attempt > 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: fits,
|
||||||
|
violations: fits ? [] : ['Train capacity exceeded'],
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const plan = await service.previewReschedule('sched-1', {
|
||||||
|
incomingBookingIds: ['g1'],
|
||||||
|
trigger: 'GOVERNMENT_PREEMPT',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(plan.retained.map((b) => b.id)).toEqual(['g1']);
|
||||||
|
expect(plan.readmitted.map((b) => b.id)).toEqual(['c-high']);
|
||||||
|
expect(plan.displaced.map((b) => b.id)).toEqual(['c-low']);
|
||||||
|
expect(plan.finalBookingIds).toEqual(['g1', 'c-high']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maintenance reschedule updates departure and rebalances bookings', async () => {
|
||||||
|
const commercial = makeBooking('c1', 'BKG-COMM', { priorityScore: 10 });
|
||||||
|
const schedule = {
|
||||||
|
id: 'sched-1',
|
||||||
|
status: 'DRAFT',
|
||||||
|
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
|
||||||
|
originStationId: 'yard-origin',
|
||||||
|
destinationStationId: 'yard-destination',
|
||||||
|
scheduleBookings: [{ bookingId: 'c1', booking: commercial }],
|
||||||
|
};
|
||||||
|
|
||||||
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule);
|
||||||
|
bookingsRepository.findByIdsForScheduling.mockResolvedValue([commercial]);
|
||||||
|
trainSchedulingService.previewTrainSchedule.mockResolvedValue({
|
||||||
|
valid: true,
|
||||||
|
violations: [],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
trainSchedulesRepository.updateStatus.mockResolvedValue(undefined);
|
||||||
|
trainSchedulingService.assignBookingsToSchedule.mockResolvedValue({ id: 'sched-1' });
|
||||||
|
|
||||||
|
const result = await service.maintenanceReschedule(
|
||||||
|
'sched-1',
|
||||||
|
{
|
||||||
|
incomingBookingIds: ['c1'],
|
||||||
|
trigger: 'TRAIN_MAINTENANCE',
|
||||||
|
reason: 'Locomotive service',
|
||||||
|
newDepartureDate: '2026-06-22T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
'staff-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith(
|
||||||
|
'sched-1',
|
||||||
|
'DRAFT',
|
||||||
|
{ scheduledDepartureDate: new Date('2026-06-22T10:00:00.000Z') },
|
||||||
|
);
|
||||||
|
expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
trigger: 'TRAIN_MAINTENANCE',
|
||||||
|
actorUserId: 'staff-1',
|
||||||
|
reason: 'Locomotive service',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.plan.trigger).toBe('TRAIN_MAINTENANCE');
|
||||||
|
expect(result.plan.finalBookingIds).toEqual(['c1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { SchedulingStatus, TrainScheduleStatus } from '@edr/types';
|
||||||
|
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
|
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
|
||||||
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
|
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||||
|
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
||||||
|
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
|
||||||
|
|
||||||
|
export interface RescheduleBookingSummary {
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
isGovernment: boolean;
|
||||||
|
priorityScore: number;
|
||||||
|
governmentInstitution?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReschedulePlan {
|
||||||
|
scheduleId: string;
|
||||||
|
trigger: PreviewRescheduleDto['trigger'];
|
||||||
|
retained: RescheduleBookingSummary[];
|
||||||
|
displaced: RescheduleBookingSummary[];
|
||||||
|
readmitted: RescheduleBookingSummary[];
|
||||||
|
finalBookingIds: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SchedulingRescheduleService {
|
||||||
|
constructor(
|
||||||
|
private readonly trainSchedulesRepository: TrainSchedulesRepository,
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
|
private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Preview who is retained, displaced, and readmitted on a schedule. */
|
||||||
|
async previewReschedule(
|
||||||
|
scheduleId: string,
|
||||||
|
dto: PreviewRescheduleDto,
|
||||||
|
): Promise<ReschedulePlan> {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
|
}
|
||||||
|
if (schedule.status === TrainScheduleStatus.Dispatched) {
|
||||||
|
throw new BadRequestException('Cannot reschedule a dispatched train');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentOnSchedule = (schedule.scheduleBookings ?? [])
|
||||||
|
.map((link) => link.booking)
|
||||||
|
.filter((b): b is Booking => Boolean(b));
|
||||||
|
|
||||||
|
const incoming = await this.bookingsRepository.findByIdsForScheduling(dto.incomingBookingIds);
|
||||||
|
if (incoming.length !== dto.incomingBookingIds.length) {
|
||||||
|
throw new BadRequestException('One or more incoming bookings were not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedMap = new Map<string, Booking>();
|
||||||
|
for (const booking of [...currentOnSchedule, ...incoming]) {
|
||||||
|
mergedMap.set(booking.id, booking);
|
||||||
|
}
|
||||||
|
const sorted = [...mergedMap.values()].sort(compareSchedulingPriority);
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const retained: Booking[] = [];
|
||||||
|
|
||||||
|
for (const booking of sorted) {
|
||||||
|
const candidate = [...retained, booking];
|
||||||
|
const fits = await this.bookingsFitOnSchedule(candidate, schedule, scheduleId);
|
||||||
|
if (fits) {
|
||||||
|
retained.push(booking);
|
||||||
|
} else if (currentOnSchedule.some((b) => b.id === booking.id)) {
|
||||||
|
warnings.push(`Booking ${booking.reference} will be displaced from the train`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const retainedIds = new Set(retained.map((b) => b.id));
|
||||||
|
const displacedFromCurrent = currentOnSchedule.filter((b) => !retainedIds.has(b.id));
|
||||||
|
const readmitted: Booking[] = [];
|
||||||
|
|
||||||
|
const displacedCommercial = displacedFromCurrent
|
||||||
|
.filter((b) => !b.isGovernment)
|
||||||
|
.sort(compareSchedulingPriority);
|
||||||
|
|
||||||
|
for (const booking of displacedCommercial) {
|
||||||
|
const candidate = [...retained, ...readmitted, booking];
|
||||||
|
const fits = await this.bookingsFitOnSchedule(candidate, schedule, scheduleId);
|
||||||
|
if (fits) {
|
||||||
|
readmitted.push(booking);
|
||||||
|
warnings.push(`Booking ${booking.reference} readmitted after government placement`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalIds = [...retained, ...readmitted].map((b) => b.id);
|
||||||
|
const displacedIds = new Set(displacedFromCurrent.map((b) => b.id));
|
||||||
|
for (const id of readmitted.map((b) => b.id)) {
|
||||||
|
displacedIds.delete(id);
|
||||||
|
}
|
||||||
|
const displaced = displacedFromCurrent.filter((b) => displacedIds.has(b.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
scheduleId,
|
||||||
|
trigger: dto.trigger,
|
||||||
|
retained: retained.map((b) => this.toSummary(b)),
|
||||||
|
displaced: displaced.map((b) => this.toSummary(b)),
|
||||||
|
readmitted: readmitted.map((b) => this.toSummary(b)),
|
||||||
|
finalBookingIds: finalIds,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Execute a confirmed reschedule plan. */
|
||||||
|
async executeReschedule(
|
||||||
|
scheduleId: string,
|
||||||
|
dto: ExecuteRescheduleDto,
|
||||||
|
actorUserId?: string,
|
||||||
|
) {
|
||||||
|
const plan = await this.previewReschedule(scheduleId, dto);
|
||||||
|
const expectedDisplaced = new Set(plan.displaced.map((b) => b.id));
|
||||||
|
const providedDisplaced = new Set(dto.displacedBookingIds);
|
||||||
|
if (
|
||||||
|
expectedDisplaced.size !== providedDisplaced.size ||
|
||||||
|
[...expectedDisplaced].some((id) => !providedDisplaced.has(id))
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Displaced booking list does not match current preview');
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.newDepartureDate && schedule) {
|
||||||
|
await this.trainSchedulesRepository.updateStatus(
|
||||||
|
scheduleId,
|
||||||
|
schedule.status as TrainScheduleStatus,
|
||||||
|
{ scheduledDepartureDate: new Date(dto.newDepartureDate) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bookingId of dto.displacedBookingIds) {
|
||||||
|
try {
|
||||||
|
await this.trainSchedulingService.unassignBooking(scheduleId, bookingId);
|
||||||
|
} catch {
|
||||||
|
await this.bookingsRepository.updateSchedulingFields(bookingId, {
|
||||||
|
schedulingStatus: SchedulingStatus.Eligible,
|
||||||
|
wagonsRequired: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignResult = await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
|
||||||
|
bookingIds: dto.finalBookingIds,
|
||||||
|
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.schedulingRescheduleRepository.createEvent({
|
||||||
|
trainScheduleId: scheduleId,
|
||||||
|
trigger: dto.trigger,
|
||||||
|
actorUserId,
|
||||||
|
reason: dto.reason,
|
||||||
|
planSnapshot: plan as unknown as Record<string, unknown>,
|
||||||
|
displacedBookingIds: dto.displacedBookingIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { plan, schedule: assignResult };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maintenance shortcut: new departure + rebalance. */
|
||||||
|
async maintenanceReschedule(
|
||||||
|
scheduleId: string,
|
||||||
|
dto: PreviewRescheduleDto & { newDepartureDate: string },
|
||||||
|
actorUserId?: string,
|
||||||
|
) {
|
||||||
|
const currentIds = (
|
||||||
|
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId)
|
||||||
|
)?.scheduleBookings?.map((l) => l.bookingId) ?? [];
|
||||||
|
|
||||||
|
const preview = await this.previewReschedule(scheduleId, {
|
||||||
|
...dto,
|
||||||
|
trigger: 'TRAIN_MAINTENANCE',
|
||||||
|
incomingBookingIds: currentIds.length ? currentIds : dto.incomingBookingIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.executeReschedule(
|
||||||
|
scheduleId,
|
||||||
|
{
|
||||||
|
...dto,
|
||||||
|
trigger: 'TRAIN_MAINTENANCE',
|
||||||
|
incomingBookingIds: dto.incomingBookingIds,
|
||||||
|
finalBookingIds: preview.finalBookingIds,
|
||||||
|
displacedBookingIds: preview.displaced.map((b) => b.id),
|
||||||
|
},
|
||||||
|
actorUserId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async bookingsFitOnSchedule(
|
||||||
|
bookings: Booking[],
|
||||||
|
schedule: { scheduledDepartureDate: Date; originStationId: string; destinationStationId: string },
|
||||||
|
scheduleId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!bookings.length) return true;
|
||||||
|
const preview = await this.trainSchedulingService.previewTrainSchedule({
|
||||||
|
bookingIds: bookings.map((b) => b.id),
|
||||||
|
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||||||
|
originStationId: schedule.originStationId,
|
||||||
|
destinationStationId: schedule.destinationStationId,
|
||||||
|
targetScheduleId: scheduleId,
|
||||||
|
});
|
||||||
|
return preview.valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSummary(booking: Booking): RescheduleBookingSummary {
|
||||||
|
return {
|
||||||
|
id: booking.id,
|
||||||
|
reference: booking.reference,
|
||||||
|
isGovernment: booking.isGovernment,
|
||||||
|
priorityScore: booking.priorityScore,
|
||||||
|
governmentInstitution: booking.governmentInstitution,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export interface SchedulingPriorityBooking {
|
||||||
|
isGovernment?: boolean;
|
||||||
|
priorityScore?: number | null;
|
||||||
|
scheduledDate: Date | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Government first, then priority score, then earliest scheduled date. */
|
||||||
|
export function compareSchedulingPriority(
|
||||||
|
a: SchedulingPriorityBooking,
|
||||||
|
b: SchedulingPriorityBooking,
|
||||||
|
): number {
|
||||||
|
const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment));
|
||||||
|
if (govDiff !== 0) return govDiff;
|
||||||
|
|
||||||
|
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
|
||||||
|
if (priorityDiff !== 0) return priorityDiff;
|
||||||
|
|
||||||
|
return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime();
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
|
||||||
|
|
||||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
@@ -7,11 +8,11 @@ import { TrainSet } from '../../train-sets/entities/train-set.entity';
|
|||||||
import { TrainScheduleBooking } from './train-schedule-booking.entity';
|
import { TrainScheduleBooking } from './train-schedule-booking.entity';
|
||||||
|
|
||||||
export const TRAIN_SCHEDULE_STATUSES = [
|
export const TRAIN_SCHEDULE_STATUSES = [
|
||||||
'DRAFT',
|
TrainScheduleStatusEnum.Draft,
|
||||||
'SCHEDULED',
|
TrainScheduleStatusEnum.Scheduled,
|
||||||
'DISPATCHED',
|
TrainScheduleStatusEnum.Dispatched,
|
||||||
'ARRIVED',
|
TrainScheduleStatusEnum.Arrived,
|
||||||
'CANCELLED',
|
TrainScheduleStatusEnum.Cancelled,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number];
|
export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number];
|
||||||
@@ -57,6 +58,27 @@ export class TrainSchedule extends BaseEntity {
|
|||||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
|
||||||
status!: TrainScheduleStatus;
|
status!: TrainScheduleStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true })
|
||||||
|
trainNumber?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'direction', type: 'varchar', length: 10, nullable: true })
|
||||||
|
direction?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'actual_departure_at', type: 'timestamptz', nullable: true })
|
||||||
|
actualDepartureAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'actual_arrival_at', type: 'timestamptz', nullable: true })
|
||||||
|
actualArrivalAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'prepared_by_user_id', type: 'uuid', nullable: true })
|
||||||
|
preparedByUserId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'checked_by_user_id', type: 'uuid', nullable: true })
|
||||||
|
checkedByUserId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'max_wagons', type: 'int', default: 53 })
|
||||||
|
maxWagons!: number;
|
||||||
|
|
||||||
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
|
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
|
||||||
scheduleBookings?: TrainScheduleBooking[];
|
scheduleBookings?: TrainScheduleBooking[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { BulkPricingUnit } from '@edr/types';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
|
||||||
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
|
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
||||||
|
import { WagonBookingAllocation } from './wagon-booking-allocation.entity';
|
||||||
|
|
||||||
|
export const BULK_PRICING_UNITS = [
|
||||||
|
BulkPricingUnit.PerWagon,
|
||||||
|
BulkPricingUnit.PerTon,
|
||||||
|
BulkPricingUnit.PerItem,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
@Entity({ schema: 'freight', name: 'wagon_allocation_bulk_loads' })
|
||||||
|
@Index(['bookingId'])
|
||||||
|
export class WagonAllocationBulkLoad extends BaseEntity {
|
||||||
|
@Column({ name: 'wagon_booking_allocation_id', type: 'uuid', unique: true })
|
||||||
|
wagonBookingAllocationId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => WagonBookingAllocation, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'wagon_booking_allocation_id' })
|
||||||
|
allocation?: WagonBookingAllocation;
|
||||||
|
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Booking)
|
||||||
|
@JoinColumn({ name: 'booking_id' })
|
||||||
|
booking?: Booking;
|
||||||
|
|
||||||
|
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
||||||
|
cargoTypeId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => CargoType, { nullable: true, onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'cargo_type_id' })
|
||||||
|
cargoType?: CargoType | null;
|
||||||
|
|
||||||
|
@Column({ name: 'cargo_description', type: 'text', nullable: true })
|
||||||
|
cargoDescription?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'pricing_unit', type: 'varchar', length: 20, default: BulkPricingUnit.PerTon })
|
||||||
|
pricingUnit!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||||
|
quantity!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 })
|
||||||
|
weightTons!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'truck_plate_number', type: 'varchar', length: 32, nullable: true })
|
||||||
|
truckPlateNumber?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
|
||||||
|
import { BookingContainer } from '../../bookings/entities/booking-container.entity';
|
||||||
|
import { Container } from '../../container-management/entities/container.entity';
|
||||||
|
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
||||||
|
import { WagonBookingAllocation } from './wagon-booking-allocation.entity';
|
||||||
|
|
||||||
|
@Entity({ schema: 'freight', name: 'wagon_allocation_container_items' })
|
||||||
|
@Index(['wagonBookingAllocationId'])
|
||||||
|
export class WagonAllocationContainerItem extends BaseEntity {
|
||||||
|
@Column({ name: 'wagon_booking_allocation_id', type: 'uuid' })
|
||||||
|
wagonBookingAllocationId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => WagonBookingAllocation, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'wagon_booking_allocation_id' })
|
||||||
|
allocation?: WagonBookingAllocation;
|
||||||
|
|
||||||
|
@Column({ name: 'booking_container_id', type: 'uuid', nullable: true })
|
||||||
|
bookingContainerId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => BookingContainer, { nullable: true, onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'booking_container_id' })
|
||||||
|
bookingContainer?: BookingContainer | null;
|
||||||
|
|
||||||
|
@Column({ name: 'container_id', type: 'uuid', nullable: true })
|
||||||
|
containerId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => Container, { nullable: true, onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'container_id' })
|
||||||
|
container?: Container | null;
|
||||||
|
|
||||||
|
@Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true })
|
||||||
|
containerNumber?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
|
||||||
|
containerTypeId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => ContainerType, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'container_type_id' })
|
||||||
|
containerType?: ContainerType | null;
|
||||||
|
|
||||||
|
@Column({ name: 'position_on_wagon', type: 'smallint', nullable: true })
|
||||||
|
positionOnWagon?: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'seal_number', type: 'varchar', length: 64, nullable: true })
|
||||||
|
sealNumber?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'chassis_number', type: 'varchar', length: 64, nullable: true })
|
||||||
|
chassisNumber?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||||
|
grossWeightTons?: number | null;
|
||||||
|
}
|
||||||
@@ -1,8 +1,22 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
import { AllocationLoadType, AllocationStatus } from '@edr/types';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||||
|
|
||||||
import { Booking } from '../../bookings/entities/booking.entity';
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
||||||
|
import { WagonAllocationContainerItem } from './wagon-allocation-container-item.entity';
|
||||||
|
|
||||||
|
export const ALLOCATION_LOAD_TYPES = [
|
||||||
|
AllocationLoadType.Container,
|
||||||
|
AllocationLoadType.Bulk,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ALLOCATION_STATUSES = [
|
||||||
|
AllocationStatus.Planned,
|
||||||
|
AllocationStatus.Reserved,
|
||||||
|
AllocationStatus.Loaded,
|
||||||
|
AllocationStatus.Departed,
|
||||||
|
] as const;
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'wagon_booking_allocations' })
|
@Entity({ schema: 'freight', name: 'wagon_booking_allocations' })
|
||||||
@Index(['trainSetWagonId', 'bookingId'])
|
@Index(['trainSetWagonId', 'bookingId'])
|
||||||
@@ -23,4 +37,19 @@ export class WagonBookingAllocation extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'allocated_weight_tons', type: 'numeric', precision: 10, scale: 3 })
|
@Column({ name: 'allocated_weight_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||||
allocatedWeightTons!: number;
|
allocatedWeightTons!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'load_type', type: 'varchar', length: 20, nullable: true })
|
||||||
|
loadType?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PLANNED' })
|
||||||
|
status!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'confirmed_at', type: 'timestamptz', nullable: true })
|
||||||
|
confirmedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'confirmed_by_user_id', type: 'uuid', nullable: true })
|
||||||
|
confirmedByUserId?: string | null;
|
||||||
|
|
||||||
|
@OneToMany(() => WagonAllocationContainerItem, (item) => item.allocation)
|
||||||
|
containerItems?: WagonAllocationContainerItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BaseRepository } from '@edr/api-common';
|
import { BaseRepository } from '@edr/api-common';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { DeepPartial, EntityManager, In, Repository } from 'typeorm';
|
||||||
|
|
||||||
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
|
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
|
||||||
|
|
||||||
@@ -13,4 +13,38 @@ export class TrainScheduleBookingsRepository extends BaseRepository<TrainSchedul
|
|||||||
) {
|
) {
|
||||||
super(repository);
|
super(repository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private repo(manager?: EntityManager) {
|
||||||
|
return manager ? manager.getRepository(TrainScheduleBooking) : this.repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMany(
|
||||||
|
records: DeepPartial<TrainScheduleBooking>[],
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<TrainScheduleBooking[]> {
|
||||||
|
if (!records.length) return [];
|
||||||
|
const repo = this.repo(manager);
|
||||||
|
return repo.save(repo.create(records));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteByScheduleAndBooking(
|
||||||
|
trainScheduleId: string,
|
||||||
|
bookingId: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.repo(manager).delete({ trainScheduleId, bookingId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async existsForBooking(bookingId: string, manager?: EntityManager): Promise<boolean> {
|
||||||
|
const count = await this.repo(manager).count({ where: { bookingId } });
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
findByBookingIds(bookingIds: string[], manager?: EntityManager): Promise<TrainScheduleBooking[]> {
|
||||||
|
if (!bookingIds.length) return Promise.resolve([]);
|
||||||
|
return this.repo(manager).find({
|
||||||
|
where: { bookingId: In(bookingIds) },
|
||||||
|
select: { id: true, bookingId: true, trainScheduleId: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,22 +3,38 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
|
|
||||||
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
|
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
|
||||||
import { TrainSchedule } from './entities/train-schedule.entity';
|
import { TrainSchedule } from './entities/train-schedule.entity';
|
||||||
|
import { WagonAllocationBulkLoad } from './entities/wagon-allocation-bulk-load.entity';
|
||||||
|
import { WagonAllocationContainerItem } from './entities/wagon-allocation-container-item.entity';
|
||||||
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
|
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
|
||||||
import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository';
|
import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository';
|
||||||
import { TrainSchedulesRepository } from './train-schedules.repository';
|
import { TrainSchedulesRepository } from './train-schedules.repository';
|
||||||
|
import { WagonAllocationBulkLoadsRepository } from './wagon-allocation-bulk-loads.repository';
|
||||||
|
import { WagonAllocationContainerItemsRepository } from './wagon-allocation-container-items.repository';
|
||||||
import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository';
|
import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([TrainSchedule, TrainScheduleBooking, WagonBookingAllocation])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
TrainSchedule,
|
||||||
|
TrainScheduleBooking,
|
||||||
|
WagonBookingAllocation,
|
||||||
|
WagonAllocationContainerItem,
|
||||||
|
WagonAllocationBulkLoad,
|
||||||
|
]),
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
TrainSchedulesRepository,
|
TrainSchedulesRepository,
|
||||||
TrainScheduleBookingsRepository,
|
TrainScheduleBookingsRepository,
|
||||||
WagonBookingAllocationsRepository,
|
WagonBookingAllocationsRepository,
|
||||||
|
WagonAllocationContainerItemsRepository,
|
||||||
|
WagonAllocationBulkLoadsRepository,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TrainSchedulesRepository,
|
TrainSchedulesRepository,
|
||||||
TrainScheduleBookingsRepository,
|
TrainScheduleBookingsRepository,
|
||||||
WagonBookingAllocationsRepository,
|
WagonBookingAllocationsRepository,
|
||||||
|
WagonAllocationContainerItemsRepository,
|
||||||
|
WagonAllocationBulkLoadsRepository,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TrainSchedulesModule {}
|
export class TrainSchedulesModule {}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { BaseRepository } from '@edr/api-common';
|
import { BaseRepository } from '@edr/api-common';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { EntityManager, Repository } from 'typeorm';
|
||||||
|
|
||||||
import { TrainSchedule } from './entities/train-schedule.entity';
|
import { TrainSchedule, TrainScheduleStatus } from './entities/train-schedule.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||||
@@ -13,4 +13,48 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
|||||||
) {
|
) {
|
||||||
super(repository);
|
super(repository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private repo(manager?: EntityManager) {
|
||||||
|
return manager ? manager.getRepository(TrainSchedule) : this.repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
findByIdWithFullGraph(id: string, manager?: EntityManager): Promise<TrainSchedule | null> {
|
||||||
|
return this.repo(manager).findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: {
|
||||||
|
route: true,
|
||||||
|
trainSet: {
|
||||||
|
locomotive: true,
|
||||||
|
wagons: {
|
||||||
|
wagonType: true,
|
||||||
|
physicalWagon: true,
|
||||||
|
allocations: {
|
||||||
|
booking: { company: true, bookingContainers: { containerType: true } },
|
||||||
|
containerItems: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
originStation: true,
|
||||||
|
destinationStation: true,
|
||||||
|
scheduleBookings: {
|
||||||
|
booking: {
|
||||||
|
company: true,
|
||||||
|
originYard: true,
|
||||||
|
destinationYard: true,
|
||||||
|
bookingContainers: { containerType: true },
|
||||||
|
cargoType: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
id: string,
|
||||||
|
status: TrainScheduleStatus,
|
||||||
|
extra?: Partial<TrainSchedule>,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.repo(manager).update(id, { status, ...extra } as never);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { DeepPartial, EntityManager, In, Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { WagonAllocationBulkLoad } from './entities/wagon-allocation-bulk-load.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WagonAllocationBulkLoadsRepository extends BaseRepository<WagonAllocationBulkLoad> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(WagonAllocationBulkLoad)
|
||||||
|
repository: Repository<WagonAllocationBulkLoad>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
private repo(manager?: EntityManager) {
|
||||||
|
return manager
|
||||||
|
? manager.getRepository(WagonAllocationBulkLoad)
|
||||||
|
: this.repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMany(
|
||||||
|
items: DeepPartial<WagonAllocationBulkLoad>[],
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<WagonAllocationBulkLoad[]> {
|
||||||
|
if (!items.length) return [];
|
||||||
|
const repo = this.repo(manager);
|
||||||
|
return repo.save(repo.create(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteByAllocationIds(allocationIds: string[], manager?: EntityManager): Promise<void> {
|
||||||
|
if (!allocationIds.length) return;
|
||||||
|
await this.repo(manager).delete({ wagonBookingAllocationId: In(allocationIds) });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { DeepPartial, EntityManager, In, Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { WagonAllocationContainerItem } from './entities/wagon-allocation-container-item.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WagonAllocationContainerItemsRepository extends BaseRepository<WagonAllocationContainerItem> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(WagonAllocationContainerItem)
|
||||||
|
repository: Repository<WagonAllocationContainerItem>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
private repo(manager?: EntityManager) {
|
||||||
|
return manager
|
||||||
|
? manager.getRepository(WagonAllocationContainerItem)
|
||||||
|
: this.repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMany(
|
||||||
|
items: DeepPartial<WagonAllocationContainerItem>[],
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<WagonAllocationContainerItem[]> {
|
||||||
|
if (!items.length) return [];
|
||||||
|
const repo = this.repo(manager);
|
||||||
|
return repo.save(repo.create(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteByAllocationIds(allocationIds: string[], manager?: EntityManager): Promise<void> {
|
||||||
|
if (!allocationIds.length) return;
|
||||||
|
await this.repo(manager).delete({ wagonBookingAllocationId: In(allocationIds) });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BaseRepository } from '@edr/api-common';
|
import { BaseRepository } from '@edr/api-common';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { DeepPartial, EntityManager, Repository } from 'typeorm';
|
||||||
|
|
||||||
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
|
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
|
||||||
|
|
||||||
@@ -13,4 +13,43 @@ export class WagonBookingAllocationsRepository extends BaseRepository<WagonBooki
|
|||||||
) {
|
) {
|
||||||
super(repository);
|
super(repository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private repo(manager?: EntityManager) {
|
||||||
|
return manager ? manager.getRepository(WagonBookingAllocation) : this.repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMany(
|
||||||
|
records: DeepPartial<WagonBookingAllocation>[],
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<WagonBookingAllocation[]> {
|
||||||
|
if (!records.length) return [];
|
||||||
|
const repo = this.repo(manager);
|
||||||
|
return repo.save(repo.create(records));
|
||||||
|
}
|
||||||
|
|
||||||
|
findByScheduleId(trainScheduleId: string, manager?: EntityManager): Promise<WagonBookingAllocation[]> {
|
||||||
|
return this.repo(manager)
|
||||||
|
.createQueryBuilder('allocation')
|
||||||
|
.innerJoin('allocation.trainSetWagon', 'wagon')
|
||||||
|
.innerJoin('wagon.trainSet', 'trainSet')
|
||||||
|
.innerJoin('trainSet.trainSchedule', 'schedule')
|
||||||
|
.where('schedule.id = :trainScheduleId', { trainScheduleId })
|
||||||
|
.leftJoinAndSelect('allocation.booking', 'booking')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteByTrainSetId(trainSetId: string, manager?: EntityManager): Promise<string[]> {
|
||||||
|
const allocations = await this.repo(manager)
|
||||||
|
.createQueryBuilder('allocation')
|
||||||
|
.innerJoin('allocation.trainSetWagon', 'wagon')
|
||||||
|
.where('wagon.train_set_id = :trainSetId', { trainSetId })
|
||||||
|
.select(['allocation.id'])
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
const ids = allocations.map((a) => a.id);
|
||||||
|
if (ids.length) {
|
||||||
|
await this.repo(manager).delete(ids);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||||
|
|
||||||
|
describe('deriveScheduleDirection', () => {
|
||||||
|
it('returns IMPORT when origin is Djibouti', () => {
|
||||||
|
expect(
|
||||||
|
deriveScheduleDirection({ country: 'Djibouti' }, { country: 'Ethiopia' }),
|
||||||
|
).toBe('IMPORT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns EXPORT when destination is Djibouti and origin is not', () => {
|
||||||
|
expect(
|
||||||
|
deriveScheduleDirection({ country: 'Ethiopia' }, { country: 'Djibouti' }),
|
||||||
|
).toBe('EXPORT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns DOMESTIC for intra-Ethiopia routes', () => {
|
||||||
|
expect(
|
||||||
|
deriveScheduleDirection({ country: 'Ethiopia' }, { country: 'Ethiopia' }),
|
||||||
|
).toBe('DOMESTIC');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ScheduleTradeDirection } from '@edr/types';
|
||||||
|
|
||||||
|
type YardLike = { country?: string | null };
|
||||||
|
|
||||||
|
export function deriveScheduleDirection(
|
||||||
|
originYard: YardLike,
|
||||||
|
destinationYard: YardLike,
|
||||||
|
): ScheduleTradeDirection {
|
||||||
|
const originCountry = originYard.country?.trim();
|
||||||
|
const destinationCountry = destinationYard.country?.trim();
|
||||||
|
|
||||||
|
if (originCountry === 'Djibouti') {
|
||||||
|
return 'IMPORT';
|
||||||
|
}
|
||||||
|
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
|
||||||
|
return 'EXPORT';
|
||||||
|
}
|
||||||
|
return 'DOMESTIC';
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class ContainerPlacementDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
@IsUUID()
|
||||||
|
bookingContainerId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ minimum: 0 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
unitIndex!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ minimum: 1 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
sequenceNo!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
containerId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
containerNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sealNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssignBookingsDto {
|
||||||
|
@ApiProperty({ type: [String] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
bookingIds!: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Bypass soft hold and overweight warnings' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
forceAssign?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [ContainerPlacementDto] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ContainerPlacementDto)
|
||||||
|
containerPlacements?: ContainerPlacementDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
maxTrainWeightTons?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Maximum total wagon length allowed on this train' })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
maxTrainLengthMeters?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Maximum wagons allowed on this train' })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
maxWagonsPerTrain?: number;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsDateString, IsUUID } from 'class-validator';
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsDateString, IsInt, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
|
||||||
|
|
||||||
export class CreateContainerTrainScheduleDto {
|
export class CreateContainerTrainScheduleDto {
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ format: 'uuid' })
|
||||||
@@ -13,4 +14,25 @@ export class CreateContainerTrainScheduleDto {
|
|||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ format: 'uuid' })
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
locomotiveId!: string;
|
locomotiveId!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
maxTrainWeightTons?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Maximum total wagon length allowed on this train' })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
maxTrainLengthMeters?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Maximum wagons allowed on this train' })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
maxWagonsPerTrain?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class GetEligibleBookingsDto {
|
||||||
|
@ApiPropertyOptional({ enum: ['CONTAINER', 'BULK'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['CONTAINER', 'BULK'])
|
||||||
|
freightType?: 'CONTAINER' | 'BULK';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
originStationId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
destinationStationId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
schedulingStatus?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class GetEligibleBulkBookingsDto {
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
originStationId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
destinationStationId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'HOLDING' })
|
||||||
|
@IsOptional()
|
||||||
|
schedulingStatus?: string;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsDateString, IsOptional, IsUUID } from 'class-validator';
|
import { IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
export class GetEligibleContainerBookingsDto {
|
export class GetEligibleContainerBookingsDto {
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@@ -12,8 +12,7 @@ export class GetEligibleContainerBookingsDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
destinationStationId?: string;
|
destinationStationId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '2026-06-20T08:00:00.000Z' })
|
@ApiPropertyOptional({ example: 'HOLDING' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
schedulingStatus?: string;
|
||||||
scheduleDate?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ArrayMinSize, IsArray, IsUUID, ValidateNested } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class PinWagonAssignmentDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
@IsUUID()
|
||||||
|
trainSetWagonId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
@IsUUID()
|
||||||
|
physicalWagonId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PinWagonsDto {
|
||||||
|
@ApiProperty({ type: [PinWagonAssignmentDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => PinWagonAssignmentDto)
|
||||||
|
assignments!: PinWagonAssignmentDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { PreviewTrainScheduleDto } from './preview-train-schedule.dto';
|
||||||
|
|
||||||
|
export class PreviewBulkTrainScheduleDto extends PreviewTrainScheduleDto {}
|
||||||
@@ -1,22 +1,3 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { PreviewTrainScheduleDto } from './preview-train-schedule.dto';
|
||||||
import { ArrayMinSize, IsArray, IsDateString, IsUUID } from 'class-validator';
|
|
||||||
|
|
||||||
export class PreviewContainerTrainScheduleDto {
|
export class PreviewContainerTrainScheduleDto extends PreviewTrainScheduleDto {}
|
||||||
@ApiProperty({ type: [String] })
|
|
||||||
@IsArray()
|
|
||||||
@ArrayMinSize(1)
|
|
||||||
@IsUUID('4', { each: true })
|
|
||||||
bookingIds!: string[];
|
|
||||||
|
|
||||||
@ApiProperty({ example: '2026-06-20T08:00:00.000Z' })
|
|
||||||
@IsDateString()
|
|
||||||
scheduleDate!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
|
||||||
@IsUUID()
|
|
||||||
originStationId!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
|
||||||
@IsUUID()
|
|
||||||
destinationStationId!: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsUUID,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class PreviewTrainScheduleDto {
|
||||||
|
@ApiProperty({ type: [String] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
bookingIds!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-06-20T08:00:00.000Z' })
|
||||||
|
@IsDateString()
|
||||||
|
scheduleDate!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
@IsUUID()
|
||||||
|
originStationId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
@IsUUID()
|
||||||
|
destinationStationId!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'Allow bookings already assigned to this schedule (re-assign / reschedule)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
targetScheduleId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
maxTrainWeightTons?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Maximum total wagon length allowed on this train' })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
maxTrainLengthMeters?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Maximum wagons allowed on this train' })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
maxWagonsPerTrain?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, IsNumber, IsOptional, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateTrainSchedulingGlobalRulesDto {
|
||||||
|
@ApiPropertyOptional({ example: 760 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
maxTrainLengthMeters?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 3500 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
maxTrainWeightTons?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 53 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
maxWagonsPerTrain?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 30 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.001)
|
||||||
|
max20ftContainerWeightTons?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 10 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
max20ftPairWeightDiffTons?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ schema: 'freight', name: 'train_scheduling_global_rules' })
|
||||||
|
export class TrainSchedulingGlobalRules extends BaseEntity {
|
||||||
|
@Column({
|
||||||
|
name: 'max_train_length_meters',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 10,
|
||||||
|
scale: 2,
|
||||||
|
default: 760,
|
||||||
|
})
|
||||||
|
maxTrainLengthMeters!: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'max_train_weight_tons',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 10,
|
||||||
|
scale: 3,
|
||||||
|
default: 3500,
|
||||||
|
})
|
||||||
|
maxTrainWeightTons!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'max_wagons_per_train', type: 'int', default: 53 })
|
||||||
|
maxWagonsPerTrain!: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'max_20ft_container_weight_tons',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 8,
|
||||||
|
scale: 3,
|
||||||
|
default: 30,
|
||||||
|
})
|
||||||
|
max20ftContainerWeightTons!: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'max_20ft_pair_weight_diff_tons',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 8,
|
||||||
|
scale: 3,
|
||||||
|
default: 10,
|
||||||
|
})
|
||||||
|
max20ftPairWeightDiffTons!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
|
import {
|
||||||
|
computeFleetAvailability,
|
||||||
|
selectBookingsWithinFleetCap,
|
||||||
|
sortBookingsForScheduling,
|
||||||
|
summarizeFleetWarnings,
|
||||||
|
wagonsRequiredForBooking,
|
||||||
|
} from './fleet-plan.util';
|
||||||
|
import { buildContainerWagonPlan, type WagonPlanSlot } from './wagon-plan.util';
|
||||||
|
|
||||||
|
const nw5: WagonType = {
|
||||||
|
id: 'wt-nw5',
|
||||||
|
code: 'NW5',
|
||||||
|
name: 'Flat Wagon',
|
||||||
|
capacityTons: 70,
|
||||||
|
lengthMeters: 14,
|
||||||
|
maxWagonsPerTrain: 53,
|
||||||
|
supportedLoadTypes: ['CONTAINER'],
|
||||||
|
isActive: true,
|
||||||
|
supportsContainer: true,
|
||||||
|
} as WagonType;
|
||||||
|
|
||||||
|
const makeBooking = (
|
||||||
|
id: string,
|
||||||
|
extra: Partial<Booking> = {},
|
||||||
|
): Booking =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
reference: id,
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
isGovernment: false,
|
||||||
|
priorityScore: 0,
|
||||||
|
scheduledDate: new Date('2026-06-20T08:00:00.000Z'),
|
||||||
|
cargoTotalWeightVgm: 50,
|
||||||
|
bookingContainers: [{ id: `${id}-line`, quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 }],
|
||||||
|
...extra,
|
||||||
|
}) as Booking;
|
||||||
|
|
||||||
|
describe('fleet-plan.util', () => {
|
||||||
|
it('sorts bookings government first, then priority, then date', () => {
|
||||||
|
const bookings = [
|
||||||
|
makeBooking('late', { scheduledDate: new Date('2026-06-22T08:00:00.000Z') }),
|
||||||
|
makeBooking('gov', { isGovernment: true, priorityScore: 0 }),
|
||||||
|
makeBooking('prio', { priorityScore: 10 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const sorted = sortBookingsForScheduling(bookings);
|
||||||
|
expect(sorted.map((b) => b.id)).toEqual(['gov', 'prio', 'late']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes fleet availability with shortfall', () => {
|
||||||
|
const plan: WagonPlanSlot[] = buildContainerWagonPlan(
|
||||||
|
[
|
||||||
|
makeBooking('b1', {
|
||||||
|
bookingContainers: [
|
||||||
|
{ id: 'b1-line', quantity: 4, wagonsRequired: 2, vgmPerUnitTons: 25 } as never,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
nw5,
|
||||||
|
);
|
||||||
|
const fleetByTypeId = new Map([[nw5.id, 1]]);
|
||||||
|
|
||||||
|
const rows = computeFleetAvailability(plan, fleetByTypeId, new Map([[nw5.id, 'NW5']]));
|
||||||
|
const nw5Row = rows.find((r) => r.wagonTypeCode === 'NW5');
|
||||||
|
|
||||||
|
expect(nw5Row?.needed).toBe(2);
|
||||||
|
expect(nw5Row?.available).toBe(1);
|
||||||
|
expect(nw5Row?.shortfall).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defers lower-priority bookings when fleet is insufficient', () => {
|
||||||
|
const high = makeBooking('high', {
|
||||||
|
priorityScore: 100,
|
||||||
|
bookingContainers: [
|
||||||
|
{ id: 'high-line', quantity: 2, wagonsRequired: 2, vgmPerUnitTons: 25 } as never,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const low = makeBooking('low', {
|
||||||
|
priorityScore: 1,
|
||||||
|
bookingContainers: [
|
||||||
|
{ id: 'low-line', quantity: 2, wagonsRequired: 2, vgmPerUnitTons: 25 } as never,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const fleet = new Map([[nw5.id, 2]]);
|
||||||
|
|
||||||
|
const { fitting, deferred } = selectBookingsWithinFleetCap(
|
||||||
|
[low, high],
|
||||||
|
fleet,
|
||||||
|
() => nw5.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fitting.map((b) => b.id)).toEqual(['high']);
|
||||||
|
expect(deferred).toHaveLength(1);
|
||||||
|
expect(deferred[0]?.id).toBe('low');
|
||||||
|
expect(deferred[0]?.reason).toContain('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summarizes fleet shortage warnings', () => {
|
||||||
|
const warnings = summarizeFleetWarnings(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
wagonTypeId: nw5.id,
|
||||||
|
wagonTypeCode: 'NW5',
|
||||||
|
needed: 5,
|
||||||
|
available: 2,
|
||||||
|
shortfall: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[{ id: 'b1', reference: 'BKG-1', reason: 'No wagons' }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(warnings.some((w) => w.includes('Fleet shortage'))).toBe(true);
|
||||||
|
expect(warnings.some((w) => w.includes('deferred'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts wagons required per booking from container lines', () => {
|
||||||
|
const booking = makeBooking('b1', {
|
||||||
|
bookingContainers: [
|
||||||
|
{ id: 'b1-line-0', quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 } as never,
|
||||||
|
{ id: 'b1-line-1', quantity: 1, wagonsRequired: 1, vgmPerUnitTons: 25 } as never,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(wagonsRequiredForBooking(booking)).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import type { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
|
import {
|
||||||
|
buildBulkWagonPlan,
|
||||||
|
buildContainerWagonPlan,
|
||||||
|
buildMixedWagonPlan,
|
||||||
|
roundTons,
|
||||||
|
type WagonPlanSlot,
|
||||||
|
} from './wagon-plan.util';
|
||||||
|
|
||||||
|
export type FleetAvailabilityRow = {
|
||||||
|
wagonTypeId: string;
|
||||||
|
wagonTypeCode: string;
|
||||||
|
needed: number;
|
||||||
|
available: number;
|
||||||
|
shortfall: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeferredBookingRow = {
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
|
||||||
|
return [...bookings].sort((a, b) => {
|
||||||
|
const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment));
|
||||||
|
if (govDiff !== 0) return govDiff;
|
||||||
|
|
||||||
|
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
|
||||||
|
if (priorityDiff !== 0) return priorityDiff;
|
||||||
|
|
||||||
|
return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: number): number {
|
||||||
|
if (booking.freightType === 'BULK') {
|
||||||
|
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||||
|
const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1;
|
||||||
|
return Math.max(1, Math.ceil(weight / capacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineSlots = (booking.bookingContainers ?? []).reduce(
|
||||||
|
(sum, line) => sum + Number(line.wagonsRequired ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return Math.max(1, lineSlots);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countSlotsByType(wagonPlan: WagonPlanSlot[]): Map<string, { code: string; count: number }> {
|
||||||
|
const map = new Map<string, { code: string; count: number }>();
|
||||||
|
for (const slot of wagonPlan) {
|
||||||
|
const existing = map.get(slot.wagonTypeId) ?? { code: slot.wagonTypeCode, count: 0 };
|
||||||
|
existing.count += 1;
|
||||||
|
map.set(slot.wagonTypeId, existing);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeFleetAvailability(
|
||||||
|
demandPlan: WagonPlanSlot[],
|
||||||
|
fleetByTypeId: Map<string, number>,
|
||||||
|
fleetTypeCodes: Map<string, string>,
|
||||||
|
): FleetAvailabilityRow[] {
|
||||||
|
const neededByType = countSlotsByType(demandPlan);
|
||||||
|
const typeIds = new Set([...neededByType.keys(), ...fleetByTypeId.keys()]);
|
||||||
|
|
||||||
|
return [...typeIds].map((wagonTypeId) => {
|
||||||
|
const needed = neededByType.get(wagonTypeId)?.count ?? 0;
|
||||||
|
const available = fleetByTypeId.get(wagonTypeId) ?? 0;
|
||||||
|
return {
|
||||||
|
wagonTypeId,
|
||||||
|
wagonTypeCode:
|
||||||
|
neededByType.get(wagonTypeId)?.code ??
|
||||||
|
fleetTypeCodes.get(wagonTypeId) ??
|
||||||
|
wagonTypeId,
|
||||||
|
needed,
|
||||||
|
available,
|
||||||
|
shortfall: Math.max(0, needed - available),
|
||||||
|
};
|
||||||
|
}).filter((row) => row.needed > 0 || row.available > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectBookingsWithinFleetCap(
|
||||||
|
bookings: Booking[],
|
||||||
|
fleetByTypeId: Map<string, number>,
|
||||||
|
resolveWagonTypeId: (booking: Booking) => string,
|
||||||
|
bulkWagonCapacity?: number,
|
||||||
|
): { fitting: Booking[]; deferred: DeferredBookingRow[] } {
|
||||||
|
const remaining = new Map(fleetByTypeId);
|
||||||
|
const fitting: Booking[] = [];
|
||||||
|
const deferred: DeferredBookingRow[] = [];
|
||||||
|
|
||||||
|
for (const booking of sortBookingsForScheduling(bookings)) {
|
||||||
|
const typeId = resolveWagonTypeId(booking);
|
||||||
|
const needed = wagonsRequiredForBooking(booking, bulkWagonCapacity);
|
||||||
|
const available = remaining.get(typeId) ?? 0;
|
||||||
|
|
||||||
|
if (available >= needed) {
|
||||||
|
remaining.set(typeId, available - needed);
|
||||||
|
fitting.push(booking);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
deferred.push({
|
||||||
|
id: booking.id,
|
||||||
|
reference: booking.reference,
|
||||||
|
reason:
|
||||||
|
available > 0
|
||||||
|
? `Needs ${needed} wagons but only ${available} available for this type`
|
||||||
|
: `No available wagons for required type (${needed} needed)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fitting, deferred };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCappedWagonPlan(params: {
|
||||||
|
bookings: Booking[];
|
||||||
|
resolvedMode: 'CONTAINER' | 'BULK' | 'MIXED';
|
||||||
|
containerWagonType: WagonType;
|
||||||
|
bulkWagonType: WagonType;
|
||||||
|
}): WagonPlanSlot[] {
|
||||||
|
const { bookings, resolvedMode, containerWagonType, bulkWagonType } = params;
|
||||||
|
|
||||||
|
if (resolvedMode === 'MIXED') {
|
||||||
|
const containerBookings = bookings.filter((b) => b.freightType === 'CONTAINER');
|
||||||
|
const bulkBookings = bookings.filter((b) => b.freightType === 'BULK');
|
||||||
|
return buildMixedWagonPlan(
|
||||||
|
containerBookings,
|
||||||
|
bulkBookings,
|
||||||
|
containerWagonType,
|
||||||
|
bulkWagonType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedMode === 'BULK') {
|
||||||
|
return buildBulkWagonPlan(bookings, bulkWagonType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildContainerWagonPlan(bookings, containerWagonType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeFleetWarnings(
|
||||||
|
fleetAvailability: FleetAvailabilityRow[],
|
||||||
|
deferred: DeferredBookingRow[],
|
||||||
|
): string[] {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
for (const row of fleetAvailability.filter((r) => r.shortfall > 0)) {
|
||||||
|
warnings.push(
|
||||||
|
`Fleet shortage: need ${row.needed} ${row.wagonTypeCode}, only ${row.available} available (short ${row.shortfall})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deferred.length) {
|
||||||
|
warnings.push(
|
||||||
|
`${deferred.length} booking(s) deferred to next train due to insufficient fleet wagons`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totalAssignedWeight(bookings: Booking[]): number {
|
||||||
|
return roundTons(bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0));
|
||||||
|
}
|
||||||
@@ -1,17 +1,27 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
|
||||||
|
import { AssignBookingsDto } from './dto/assign-bookings.dto';
|
||||||
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
|
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
|
||||||
|
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
|
||||||
|
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
|
||||||
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
||||||
|
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
||||||
|
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
||||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||||
|
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
||||||
|
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
|
|
||||||
@ApiTags('train-scheduling')
|
@ApiTags('train-scheduling')
|
||||||
@@ -20,39 +30,176 @@ import { TrainSchedulingService } from './train-scheduling.service';
|
|||||||
export class TrainSchedulingController {
|
export class TrainSchedulingController {
|
||||||
constructor(private readonly trainSchedulingService: TrainSchedulingService) {}
|
constructor(private readonly trainSchedulingService: TrainSchedulingService) {}
|
||||||
|
|
||||||
|
@Get('global-rules')
|
||||||
|
@TrainSchedulingView()
|
||||||
|
@ApiOperation({ summary: 'Get global train scheduling rules (singleton)' })
|
||||||
|
getGlobalRules() {
|
||||||
|
return this.trainSchedulingService.getTrainSchedulingGlobalRules();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('global-rules')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Update global train scheduling rules (singleton)' })
|
||||||
|
updateGlobalRules(@Body() dto: UpdateTrainSchedulingGlobalRulesDto) {
|
||||||
|
return this.trainSchedulingService.updateTrainSchedulingGlobalRules(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('eligible-bookings')
|
||||||
|
@TrainSchedulingView()
|
||||||
|
@ApiOperation({ summary: 'List eligible bookings (container and/or bulk)' })
|
||||||
|
getEligibleBookings(@Query() query: GetEligibleBookingsDto) {
|
||||||
|
return this.trainSchedulingService.getEligibleBookings(query);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('container/eligible-bookings')
|
@Get('container/eligible-bookings')
|
||||||
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'List eligible container bookings' })
|
@ApiOperation({ summary: 'List eligible container bookings' })
|
||||||
getEligibleContainerBookings(@Query() query: GetEligibleContainerBookingsDto) {
|
getEligibleContainerBookings(@Query() query: GetEligibleContainerBookingsDto) {
|
||||||
return this.trainSchedulingService.getEligibleContainerBookings(query);
|
return this.trainSchedulingService.getEligibleContainerBookings(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('bulk/eligible-bookings')
|
||||||
|
@TrainSchedulingView()
|
||||||
|
@ApiOperation({ summary: 'List eligible bulk bookings' })
|
||||||
|
getEligibleBulkBookings(@Query() query: GetEligibleBulkBookingsDto) {
|
||||||
|
return this.trainSchedulingService.getEligibleBulkBookings(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('preview')
|
||||||
|
@TrainSchedulingView()
|
||||||
|
@ApiOperation({ summary: 'Preview a mixed-capable train schedule' })
|
||||||
|
previewTrainSchedule(@Body() dto: PreviewTrainScheduleDto) {
|
||||||
|
return this.trainSchedulingService.previewTrainSchedule(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('container/preview')
|
@Post('container/preview')
|
||||||
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'Preview a container train schedule' })
|
@ApiOperation({ summary: 'Preview a container train schedule' })
|
||||||
previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) {
|
previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) {
|
||||||
return this.trainSchedulingService.previewContainerTrainSchedule(dto);
|
return this.trainSchedulingService.previewContainerTrainSchedule(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('bulk/preview')
|
||||||
|
@TrainSchedulingView()
|
||||||
|
@ApiOperation({ summary: 'Preview a bulk train schedule' })
|
||||||
|
previewBulkTrainSchedule(@Body() dto: PreviewBulkTrainScheduleDto) {
|
||||||
|
return this.trainSchedulingService.previewBulkTrainSchedule(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('container/schedules')
|
@Post('container/schedules')
|
||||||
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Create a container train schedule' })
|
@ApiOperation({ summary: 'Create a container train schedule' })
|
||||||
createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
|
createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
|
||||||
return this.trainSchedulingService.createContainerTrainSchedule(dto);
|
return this.trainSchedulingService.createContainerTrainSchedule(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('bulk/schedules')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Create a bulk train schedule' })
|
||||||
|
createBulkTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
|
||||||
|
return this.trainSchedulingService.createContainerTrainSchedule(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('schedules/:id/assign-bookings')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Assign bookings to a train schedule (mixed-capable)' })
|
||||||
|
assignBookings(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: AssignBookingsDto,
|
||||||
|
) {
|
||||||
|
return this.trainSchedulingService.assignBookingsToSchedule(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('container/schedules/:id/assign-bookings')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Assign container bookings to a train schedule' })
|
||||||
|
assignContainerBookings(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: AssignBookingsDto,
|
||||||
|
) {
|
||||||
|
return this.trainSchedulingService.assignBookingsToSchedule(id, dto, 'CONTAINER');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bulk/schedules/:id/assign-bookings')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Assign bulk bookings to a train schedule' })
|
||||||
|
assignBulkBookings(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: AssignBookingsDto,
|
||||||
|
) {
|
||||||
|
return this.trainSchedulingService.assignBookingsToSchedule(id, dto, 'BULK');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('schedules/:id/bookings/:bookingId')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Unassign a booking from a train schedule' })
|
||||||
|
unassignBooking(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
) {
|
||||||
|
return this.trainSchedulingService.unassignBooking(id, bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('schedules/:id/pin-wagons')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Pin physical wagons to train set slots' })
|
||||||
|
pinWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PinWagonsDto) {
|
||||||
|
return this.trainSchedulingService.pinWagons(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('schedules/:id/finalize')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Finalize a draft train schedule' })
|
||||||
|
finalizeSchedule(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.trainSchedulingService.finalizeSchedule(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('schedules/:id/dispatch')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Dispatch a scheduled train' })
|
||||||
|
dispatchSchedule(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.trainSchedulingService.dispatchSchedule(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('container/schedules')
|
@Get('container/schedules')
|
||||||
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'List container train schedules' })
|
@ApiOperation({ summary: 'List container train schedules' })
|
||||||
getContainerTrainSchedules() {
|
getContainerTrainSchedules() {
|
||||||
return this.trainSchedulingService.getContainerTrainSchedules();
|
return this.trainSchedulingService.getContainerTrainSchedules();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('bulk/schedules')
|
||||||
|
@TrainSchedulingView()
|
||||||
|
@ApiOperation({ summary: 'List bulk train schedules' })
|
||||||
|
getBulkTrainSchedules() {
|
||||||
|
return this.trainSchedulingService.getContainerTrainSchedules();
|
||||||
|
}
|
||||||
|
|
||||||
@Get('container/schedules/:id')
|
@Get('container/schedules/:id')
|
||||||
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'Get container train schedule detail' })
|
@ApiOperation({ summary: 'Get container train schedule detail' })
|
||||||
getContainerTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
|
getContainerTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('bulk/schedules/:id')
|
||||||
|
@TrainSchedulingView()
|
||||||
|
@ApiOperation({ summary: 'Get bulk train schedule detail' })
|
||||||
|
getBulkTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('container/schedules/:id/cancel')
|
@Post('container/schedules/:id/cancel')
|
||||||
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Cancel container train schedule' })
|
@ApiOperation({ summary: 'Cancel container train schedule' })
|
||||||
cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
|
cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.trainSchedulingService.cancelTrainSchedule(id);
|
return this.trainSchedulingService.cancelTrainSchedule(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('bulk/schedules/:id/cancel')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Cancel bulk train schedule' })
|
||||||
|
cancelBulkTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.trainSchedulingService.cancelTrainSchedule(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,42 +2,40 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Container } from '../container-management/entities/container.entity';
|
||||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
|
||||||
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
||||||
|
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
|
import { Route } from '../routes/entities/route.entity';
|
||||||
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
|
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||||
|
import { TrainSetsModule } from '../train-sets/train-sets.module';
|
||||||
|
import { TrainSchedulesModule } from '../train-schedules/train-schedules.module';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
|
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
|
||||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||||
import { TrainSetsModule } from '../train-sets/train-sets.module';
|
|
||||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
|
||||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
|
||||||
import { TrainSchedulesModule } from '../train-schedules/train-schedules.module';
|
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
|
||||||
import { TrainSchedulingController } from './train-scheduling.controller';
|
import { TrainSchedulingController } from './train-scheduling.controller';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
Booking,
|
|
||||||
BookingContainer,
|
|
||||||
Locomotive,
|
Locomotive,
|
||||||
WagonType,
|
WagonType,
|
||||||
TrainSet,
|
TrainSet,
|
||||||
TrainSetWagon,
|
TrainSetWagon,
|
||||||
TrainSchedule,
|
Route,
|
||||||
TrainScheduleBooking,
|
Wagon,
|
||||||
WagonBookingAllocation,
|
Container,
|
||||||
Yard,
|
TrainSchedulingGlobalRules,
|
||||||
]),
|
]),
|
||||||
BookingsModule,
|
BookingsModule,
|
||||||
LocomotivesModule,
|
LocomotivesModule,
|
||||||
WagonTypesModule,
|
WagonTypesModule,
|
||||||
TrainSetsModule,
|
TrainSetsModule,
|
||||||
TrainSchedulesModule,
|
TrainSchedulesModule,
|
||||||
|
RuleEngineModule,
|
||||||
],
|
],
|
||||||
controllers: [TrainSchedulingController],
|
controllers: [TrainSchedulingController],
|
||||||
providers: [TrainSchedulingService],
|
providers: [TrainSchedulingService],
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { ConflictException } from '@nestjs/common';
|
import { ConflictException } from '@nestjs/common';
|
||||||
|
import { WagonReadiness, WagonStatus } from '@edr/types';
|
||||||
|
|
||||||
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||||
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
|
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
|
|
||||||
const nw5 = {
|
const nw5 = {
|
||||||
@@ -11,6 +16,7 @@ const nw5 = {
|
|||||||
maxWagonsPerTrain: 53,
|
maxWagonsPerTrain: 53,
|
||||||
supportedLoadTypes: ['CONTAINER'],
|
supportedLoadTypes: ['CONTAINER'],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
supportsContainer: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const locomotive = {
|
const locomotive = {
|
||||||
@@ -21,15 +27,29 @@ const locomotive = {
|
|||||||
status: 'AVAILABLE',
|
status: 'AVAILABLE',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cw3 = {
|
||||||
|
id: 'wagon-type-bulk',
|
||||||
|
code: 'CW3',
|
||||||
|
name: 'Covered Wagon',
|
||||||
|
capacityTons: 60,
|
||||||
|
lengthMeters: 14,
|
||||||
|
maxWagonsPerTrain: 53,
|
||||||
|
supportedLoadTypes: ['BULK'],
|
||||||
|
isActive: true,
|
||||||
|
supportsContainer: false,
|
||||||
|
};
|
||||||
|
|
||||||
const makeBooking = (
|
const makeBooking = (
|
||||||
id: string,
|
id: string,
|
||||||
reference: string,
|
reference: string,
|
||||||
weight: number,
|
weight: number,
|
||||||
quantity: number,
|
quantity: number,
|
||||||
containerCode: string,
|
containerCode: string,
|
||||||
|
wagonsRequired: number,
|
||||||
scheduledDate = '2026-06-20T08:00:00.000Z',
|
scheduledDate = '2026-06-20T08:00:00.000Z',
|
||||||
originYardId = 'yard-origin',
|
originYardId = 'yard-origin',
|
||||||
destinationYardId = 'yard-destination',
|
destinationYardId = 'yard-destination',
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
) => ({
|
) => ({
|
||||||
id,
|
id,
|
||||||
reference,
|
reference,
|
||||||
@@ -39,75 +59,177 @@ const makeBooking = (
|
|||||||
originYardId,
|
originYardId,
|
||||||
destinationYardId,
|
destinationYardId,
|
||||||
status: 'PAID',
|
status: 'PAID',
|
||||||
customer: { companyName: 'Demo Customer' },
|
schedulingStatus: 'HOLDING',
|
||||||
|
holdExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||||
|
company: { companyName: 'Demo Customer' },
|
||||||
originYard: { label: 'Djibouti', code: 'DJIBOUTI' },
|
originYard: { label: 'Djibouti', code: 'DJIBOUTI' },
|
||||||
destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' },
|
destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' },
|
||||||
bookingContainers: [
|
bookingContainers: [
|
||||||
{
|
{
|
||||||
|
id: `${id}-line`,
|
||||||
|
containerTypeId: 'ct-1',
|
||||||
quantity,
|
quantity,
|
||||||
|
wagonsRequired,
|
||||||
|
vgmPerUnitTons: weight / quantity,
|
||||||
|
isOverweight: false,
|
||||||
containerType: { code: containerCode, label: containerCode },
|
containerType: { code: containerCode, label: containerCode },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
...extra,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('TrainSchedulingService', () => {
|
describe('TrainSchedulingService', () => {
|
||||||
let service: TrainSchedulingService;
|
let service: TrainSchedulingService;
|
||||||
let dataSource: {
|
let dataSource: { getRepository: jest.Mock; transaction: jest.Mock };
|
||||||
getRepository: jest.Mock;
|
let bookingsRepository: Record<string, jest.Mock>;
|
||||||
transaction: jest.Mock;
|
let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||||
};
|
let wagonTypesRepository: { findAll: jest.Mock };
|
||||||
let locomotivesRepository: {
|
let trainSchedulesRepository: Record<string, jest.Mock>;
|
||||||
findById: jest.Mock;
|
let trainScheduleBookingsRepository: Record<string, jest.Mock>;
|
||||||
};
|
let wagonBookingAllocationsRepository: Record<string, jest.Mock>;
|
||||||
let wagonTypesRepository: {
|
let wagonAllocationContainerItemsRepository: Record<string, jest.Mock>;
|
||||||
findAll: jest.Mock;
|
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
dataSource = {
|
dataSource = { getRepository: jest.fn(), transaction: jest.fn() };
|
||||||
getRepository: jest.fn(),
|
bookingsRepository = {
|
||||||
transaction: jest.fn(),
|
findEligibleForScheduling: jest.fn(),
|
||||||
|
findByIdsForScheduling: jest.fn(),
|
||||||
|
updateSchedulingFields: jest.fn(),
|
||||||
};
|
};
|
||||||
locomotivesRepository = {
|
locomotivesRepository = { findById: jest.fn(), findAll: jest.fn() };
|
||||||
|
wagonTypesRepository = { findAll: jest.fn() };
|
||||||
|
trainSchedulesRepository = {
|
||||||
findById: jest.fn(),
|
findById: jest.fn(),
|
||||||
};
|
findByIdWithFullGraph: jest.fn(),
|
||||||
wagonTypesRepository = {
|
|
||||||
findAll: jest.fn(),
|
findAll: jest.fn(),
|
||||||
|
updateStatus: jest.fn(),
|
||||||
|
};
|
||||||
|
trainScheduleBookingsRepository = {
|
||||||
|
findByBookingIds: jest.fn(),
|
||||||
|
createMany: jest.fn(),
|
||||||
|
deleteByScheduleAndBooking: jest.fn(),
|
||||||
|
};
|
||||||
|
wagonBookingAllocationsRepository = {
|
||||||
|
deleteByTrainSetId: jest.fn().mockResolvedValue([]),
|
||||||
|
createMany: jest.fn(),
|
||||||
|
};
|
||||||
|
wagonAllocationContainerItemsRepository = {
|
||||||
|
createMany: jest.fn(),
|
||||||
|
deleteByAllocationIds: jest.fn(),
|
||||||
|
findAll: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
wagonAllocationBulkLoadsRepository = {
|
||||||
|
createMany: jest.fn(),
|
||||||
|
deleteByAllocationIds: jest.fn(),
|
||||||
|
findAll: jest.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
|
|
||||||
service = new TrainSchedulingService(
|
service = new TrainSchedulingService(
|
||||||
dataSource as never,
|
dataSource as never,
|
||||||
|
bookingsRepository as never,
|
||||||
locomotivesRepository as never,
|
locomotivesRepository as never,
|
||||||
wagonTypesRepository as never,
|
wagonTypesRepository as never,
|
||||||
|
trainSchedulesRepository as never,
|
||||||
|
trainScheduleBookingsRepository as never,
|
||||||
|
wagonBookingAllocationsRepository as never,
|
||||||
|
wagonAllocationContainerItemsRepository as never,
|
||||||
|
wagonAllocationBulkLoadsRepository as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const defaultFleetWagons = [
|
||||||
|
...Array.from({ length: 100 }, (_, index) => ({
|
||||||
|
id: `wagon-nw5-${index}`,
|
||||||
|
wagonTypeId: nw5.id,
|
||||||
|
status: WagonStatus.Available,
|
||||||
|
readiness: WagonReadiness.ImportReady,
|
||||||
|
currentTrainScheduleId: null,
|
||||||
|
})),
|
||||||
|
...Array.from({ length: 50 }, (_, index) => ({
|
||||||
|
id: `wagon-cw3-${index}`,
|
||||||
|
wagonTypeId: cw3.id,
|
||||||
|
status: WagonStatus.Available,
|
||||||
|
readiness: WagonReadiness.ImportReady,
|
||||||
|
currentTrainScheduleId: null,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||||
|
if (entity === TrainSchedulingGlobalRules) {
|
||||||
|
return { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
}
|
||||||
|
if (entity === Wagon) {
|
||||||
|
return { find: jest.fn().mockResolvedValue(defaultFleetWagons) };
|
||||||
|
}
|
||||||
|
if (entity === WagonType) {
|
||||||
|
return { find: jest.fn().mockResolvedValue([nw5, cw3]) };
|
||||||
|
}
|
||||||
|
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('computes the expected valid preview for Group A', async () => {
|
it('returns fleet availability and defers bookings when fleet is insufficient', async () => {
|
||||||
const bookings = [
|
const bookings = [
|
||||||
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT'),
|
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20),
|
||||||
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT'),
|
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10),
|
||||||
makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT'),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||||
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
|
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||||||
if (entity?.name === 'Booking') {
|
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||||||
return { find: jest.fn().mockResolvedValue(bookings) };
|
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||||||
}
|
|
||||||
if (entity?.name === 'TrainScheduleBooking') {
|
const availableWagons = Array.from({ length: 15 }, (_, index) => ({
|
||||||
|
id: `wagon-${index}`,
|
||||||
|
wagonTypeId: nw5.id,
|
||||||
|
status: WagonStatus.Available,
|
||||||
|
readiness: WagonReadiness.ImportReady,
|
||||||
|
currentTrainScheduleId: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||||
|
if (entity === TrainSchedulingGlobalRules) {
|
||||||
return { find: jest.fn().mockResolvedValue([]) };
|
return { find: jest.fn().mockResolvedValue([]) };
|
||||||
}
|
}
|
||||||
if (entity?.name === 'Locomotive') {
|
if (entity === Wagon) {
|
||||||
return {
|
return { find: jest.fn().mockResolvedValue(availableWagons) };
|
||||||
count: jest.fn().mockResolvedValue(2),
|
|
||||||
find: jest.fn().mockResolvedValue([locomotive]),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
throw new Error(`Unexpected repository ${entity?.name}`);
|
if (entity === WagonType) {
|
||||||
|
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||||||
|
}
|
||||||
|
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.previewContainerTrainSchedule({
|
const result = await service.previewContainerTrainSchedule({
|
||||||
bookingIds: bookings.map((booking) => booking.id),
|
bookingIds: bookings.map((b) => b.id),
|
||||||
|
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||||
|
originStationId: 'yard-origin',
|
||||||
|
destinationStationId: 'yard-destination',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.fleetAvailability?.length).toBeGreaterThan(0);
|
||||||
|
expect(result.fleetAvailability?.[0]?.shortfall).toBeGreaterThan(0);
|
||||||
|
expect(result.deferredBookings?.length).toBeGreaterThan(0);
|
||||||
|
expect(result.summary.wagonsNeeded).toBeLessThan(30);
|
||||||
|
expect(result.warnings.some((w) => w.includes('Fleet shortage') || w.includes('deferred'))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes slot-based preview for Group A', async () => {
|
||||||
|
const bookings = [
|
||||||
|
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20),
|
||||||
|
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10),
|
||||||
|
makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT', 15),
|
||||||
|
];
|
||||||
|
|
||||||
|
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||||
|
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||||||
|
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||||||
|
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||||||
|
|
||||||
|
const result = await service.previewContainerTrainSchedule({
|
||||||
|
bookingIds: bookings.map((b) => b.id),
|
||||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||||
originStationId: 'yard-origin',
|
originStationId: 'yard-origin',
|
||||||
destinationStationId: 'yard-destination',
|
destinationStationId: 'yard-destination',
|
||||||
@@ -115,40 +237,50 @@ describe('TrainSchedulingService', () => {
|
|||||||
|
|
||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
expect(result.violations).toEqual([]);
|
expect(result.violations).toEqual([]);
|
||||||
expect(result.summary).toEqual({
|
expect(result.summary.wagonsNeeded).toBe(45);
|
||||||
totalBookings: 3,
|
expect(result.wagonPlan).toHaveLength(45);
|
||||||
totalWeightTons: 1250,
|
});
|
||||||
wagonType: 'NW5',
|
|
||||||
wagonsNeeded: 18,
|
it('returns soft hold warnings without forceAssign', async () => {
|
||||||
totalLengthMeters: 252,
|
const bookings = [makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2)];
|
||||||
});
|
|
||||||
expect(result.wagonPlan).toHaveLength(18);
|
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||||
expect(result.wagonPlan[0]?.allocations[0]).toEqual({
|
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||||||
bookingId: 'b1',
|
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||||||
bookingReference: 'BKG-CONT-001',
|
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||||||
allocatedWeightTons: 70,
|
|
||||||
|
const result = await service.previewContainerTrainSchedule({
|
||||||
|
bookingIds: ['b7'],
|
||||||
|
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||||
|
originStationId: 'yard-origin',
|
||||||
|
destinationStationId: 'yard-destination',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(result.warnings.length).toBeGreaterThan(0);
|
||||||
|
expect(result.warnings[0]).toContain('soft hold window');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('flags the overweight booking as invalid', async () => {
|
it('flags the overweight booking as invalid', async () => {
|
||||||
const bookings = [makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT')];
|
const bookings = [
|
||||||
|
makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT', 80, undefined, undefined, undefined, {
|
||||||
|
bookingContainers: [
|
||||||
|
{
|
||||||
|
id: 'b6-line',
|
||||||
|
containerTypeId: 'ct-1',
|
||||||
|
quantity: 80,
|
||||||
|
wagonsRequired: 80,
|
||||||
|
vgmPerUnitTons: 45,
|
||||||
|
isOverweight: true,
|
||||||
|
containerType: { code: '40FT', label: '40FT' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||||
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
|
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||||||
if (entity?.name === 'Booking') {
|
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||||||
return { find: jest.fn().mockResolvedValue(bookings) };
|
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||||||
}
|
|
||||||
if (entity?.name === 'TrainScheduleBooking') {
|
|
||||||
return { find: jest.fn().mockResolvedValue([]) };
|
|
||||||
}
|
|
||||||
if (entity?.name === 'Locomotive') {
|
|
||||||
return {
|
|
||||||
count: jest.fn().mockResolvedValue(1),
|
|
||||||
find: jest.fn().mockResolvedValue([locomotive]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
throw new Error(`Unexpected repository ${entity?.name}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.previewContainerTrainSchedule({
|
const result = await service.previewContainerTrainSchedule({
|
||||||
bookingIds: ['b6'],
|
bookingIds: ['b6'],
|
||||||
@@ -158,36 +290,70 @@ describe('TrainSchedulingService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.summary.totalWeightTons).toBe(3600);
|
expect(result.violations.some((v) => v.includes('overweight'))).toBe(true);
|
||||||
expect(result.violations).toContain(
|
});
|
||||||
'Total booking weight 3600T exceeds max train weight 3500T',
|
|
||||||
|
it('allows preview when bookings are already on the target schedule', async () => {
|
||||||
|
const bookings = [makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20)];
|
||||||
|
|
||||||
|
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||||
|
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||||||
|
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([
|
||||||
|
{ bookingId: 'b1', trainScheduleId: 'sched-target' },
|
||||||
|
]);
|
||||||
|
trainSchedulesRepository.findById.mockResolvedValue({
|
||||||
|
id: 'sched-target',
|
||||||
|
direction: 'IMPORT',
|
||||||
|
});
|
||||||
|
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||||||
|
|
||||||
|
const result = await service.previewContainerTrainSchedule({
|
||||||
|
bookingIds: ['b1'],
|
||||||
|
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||||
|
originStationId: 'yard-origin',
|
||||||
|
destinationStationId: 'yard-destination',
|
||||||
|
targetScheduleId: 'sched-target',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.violations).not.toContain(
|
||||||
|
'One or more selected bookings are already assigned to a train schedule',
|
||||||
);
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows preview when selected bookings are on different schedule dates', async () => {
|
||||||
|
const bookings = [
|
||||||
|
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'),
|
||||||
|
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'),
|
||||||
|
];
|
||||||
|
|
||||||
|
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||||
|
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||||||
|
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||||||
|
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||||||
|
|
||||||
|
const result = await service.previewContainerTrainSchedule({
|
||||||
|
bookingIds: bookings.map((b) => b.id),
|
||||||
|
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||||
|
originStationId: 'yard-origin',
|
||||||
|
destinationStationId: 'yard-destination',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.violations).not.toContain(
|
||||||
|
'Selected bookings must share the same schedule date',
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects bookings that are not in schedulable status', async () => {
|
it('rejects bookings that are not in schedulable status', async () => {
|
||||||
const bookings = [
|
const bookings = [
|
||||||
{
|
{ ...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2), status: 'APPROVED' },
|
||||||
...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT'),
|
|
||||||
status: 'APPROVED',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||||
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
|
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||||||
if (entity?.name === 'Booking') {
|
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||||||
return { find: jest.fn().mockResolvedValue(bookings) };
|
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||||||
}
|
|
||||||
if (entity?.name === 'TrainScheduleBooking') {
|
|
||||||
return { find: jest.fn().mockResolvedValue([]) };
|
|
||||||
}
|
|
||||||
if (entity?.name === 'Locomotive') {
|
|
||||||
return {
|
|
||||||
count: jest.fn().mockResolvedValue(1),
|
|
||||||
find: jest.fn().mockResolvedValue([locomotive]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
throw new Error(`Unexpected repository ${entity?.name}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.previewContainerTrainSchedule({
|
const result = await service.previewContainerTrainSchedule({
|
||||||
bookingIds: ['b7'],
|
bookingIds: ['b7'],
|
||||||
@@ -239,13 +405,16 @@ describe('TrainSchedulingService', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
|
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
|
||||||
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
|
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||||
if (entity?.name === 'Route') {
|
if ((entity as { name?: string })?.name === 'Route') {
|
||||||
return { findOne: jest.fn().mockResolvedValue(route) };
|
return { findOne: jest.fn().mockResolvedValue(route) };
|
||||||
}
|
}
|
||||||
throw new Error(`Unexpected repository ${entity?.name}`);
|
if (entity === TrainSchedulingGlobalRules) {
|
||||||
|
return { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected repository ${(entity as { name?: string })?.name}`);
|
||||||
});
|
});
|
||||||
jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never);
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ id: 'schedule-1' });
|
||||||
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
|
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
|
||||||
callback(manager),
|
callback(manager),
|
||||||
);
|
);
|
||||||
@@ -259,7 +428,62 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(trainSetRepo.save).toHaveBeenCalled();
|
expect(trainSetRepo.save).toHaveBeenCalled();
|
||||||
expect(trainScheduleRepo.save).toHaveBeenCalled();
|
expect(trainScheduleRepo.save).toHaveBeenCalled();
|
||||||
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
|
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
|
||||||
expect(result).toEqual({ id: 'schedule-1' });
|
expect(result.id).toBe('schedule-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('previews mixed container and bulk bookings', async () => {
|
||||||
|
const containerBooking = makeBooking('c1', 'BKG-CONT', 100, 2, '40FT', 2);
|
||||||
|
const bulkBooking = {
|
||||||
|
id: 'b1',
|
||||||
|
reference: 'BKG-BULK',
|
||||||
|
freightType: 'BULK',
|
||||||
|
cargoTotalWeightVgm: 120,
|
||||||
|
scheduledDate: new Date('2026-06-20T08:00:00.000Z'),
|
||||||
|
originYardId: 'yard-origin',
|
||||||
|
destinationYardId: 'yard-destination',
|
||||||
|
status: 'PAID',
|
||||||
|
bookingContainers: [],
|
||||||
|
cargoType: { code: 'COFFEE' },
|
||||||
|
};
|
||||||
|
|
||||||
|
wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => {
|
||||||
|
if (where?.code === 'NW5') return [nw5];
|
||||||
|
return [nw5, cw3];
|
||||||
|
});
|
||||||
|
bookingsRepository.findByIdsForScheduling.mockResolvedValue([containerBooking, bulkBooking]);
|
||||||
|
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||||||
|
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||||||
|
|
||||||
|
const result = await service.previewTrainSchedule({
|
||||||
|
bookingIds: ['c1', 'b1'],
|
||||||
|
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||||
|
originStationId: 'yard-origin',
|
||||||
|
destinationStationId: 'yard-destination',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.summary.wagonType).toBe('MIXED');
|
||||||
|
expect(result.wagonPlan.length).toBeGreaterThan(2);
|
||||||
|
expect(result.containerUnits).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('previews container bookings without requiring placements', async () => {
|
||||||
|
const bookings = [makeBooking('c2', 'BKG-CONT-2', 50, 1, '40FT', 1)];
|
||||||
|
|
||||||
|
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||||
|
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||||||
|
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||||||
|
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||||||
|
|
||||||
|
const result = await service.previewTrainSchedule({
|
||||||
|
bookingIds: ['c2'],
|
||||||
|
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||||
|
originStationId: 'yard-origin',
|
||||||
|
destinationStationId: 'yard-destination',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.containerUnits).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects create when the locked locomotive is no longer available', async () => {
|
it('rejects create when the locked locomotive is no longer available', async () => {
|
||||||
@@ -296,4 +520,48 @@ describe('TrainSchedulingService', () => {
|
|||||||
}),
|
}),
|
||||||
).rejects.toBeInstanceOf(ConflictException);
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects pin when wagon readiness does not match schedule direction', async () => {
|
||||||
|
const scheduleId = 'sched-1';
|
||||||
|
const slotId = 'slot-1';
|
||||||
|
|
||||||
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
||||||
|
id: scheduleId,
|
||||||
|
status: 'DRAFT',
|
||||||
|
direction: 'IMPORT',
|
||||||
|
trainSet: {
|
||||||
|
wagons: [{ id: slotId, physicalWagonId: null }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = {
|
||||||
|
getRepository: jest.fn((entity: { name?: string }) => {
|
||||||
|
if (entity === Wagon) {
|
||||||
|
return {
|
||||||
|
findOne: jest.fn().mockResolvedValue({
|
||||||
|
id: 'wagon-1',
|
||||||
|
wagonNumber: 'WGN-001',
|
||||||
|
status: WagonStatus.Available,
|
||||||
|
readiness: WagonReadiness.ExportReady,
|
||||||
|
currentTrainScheduleId: null,
|
||||||
|
}),
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (entity === TrainSetWagon) {
|
||||||
|
return { update: jest.fn() };
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected repository ${entity?.name}`);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<void>) =>
|
||||||
|
callback(manager),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.pinWagons(scheduleId, {
|
||||||
|
assignments: [{ trainSetWagonId: slotId, physicalWagonId: 'wagon-1' }],
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,202 @@
|
|||||||
|
import { AllocationLoadType } from '@edr/types';
|
||||||
|
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
|
import {
|
||||||
|
buildBulkWagonPlan,
|
||||||
|
buildContainerWagonPlan,
|
||||||
|
buildMixedWagonPlan,
|
||||||
|
expandBookingContainerUnits,
|
||||||
|
expandContainerItems,
|
||||||
|
roundTons,
|
||||||
|
sumWagonsRequired,
|
||||||
|
validate20ftContainerRules,
|
||||||
|
validateContainerPlacements,
|
||||||
|
} from './wagon-plan.util';
|
||||||
|
|
||||||
|
const nw5: WagonType = {
|
||||||
|
id: 'wt-nw5',
|
||||||
|
code: 'NW5',
|
||||||
|
name: 'Flat Wagon',
|
||||||
|
capacityTons: 70,
|
||||||
|
lengthMeters: 14,
|
||||||
|
maxWagonsPerTrain: 53,
|
||||||
|
supportedLoadTypes: ['CONTAINER'],
|
||||||
|
isActive: true,
|
||||||
|
supportsContainer: true,
|
||||||
|
} as WagonType;
|
||||||
|
|
||||||
|
const cw3: WagonType = {
|
||||||
|
id: 'wt-cw3',
|
||||||
|
code: 'CW3',
|
||||||
|
name: 'Covered Wagon',
|
||||||
|
capacityTons: 60,
|
||||||
|
lengthMeters: 14,
|
||||||
|
maxWagonsPerTrain: 53,
|
||||||
|
supportedLoadTypes: ['BULK'],
|
||||||
|
isActive: true,
|
||||||
|
supportsContainer: false,
|
||||||
|
} as WagonType;
|
||||||
|
|
||||||
|
const makeContainerBooking = (
|
||||||
|
id: string,
|
||||||
|
lines: Array<{ quantity: number; wagonsRequired: number; vgmPerUnitTons?: number }>,
|
||||||
|
): Booking =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
reference: id,
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
cargoTotalWeightVgm: lines.reduce(
|
||||||
|
(sum, line) => sum + line.quantity * (line.vgmPerUnitTons ?? 25),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
bookingContainers: lines.map((line, index) => ({
|
||||||
|
id: `${id}-line-${index}`,
|
||||||
|
containerTypeId: `ct-${index}`,
|
||||||
|
quantity: line.quantity,
|
||||||
|
wagonsRequired: line.wagonsRequired,
|
||||||
|
vgmPerUnitTons: line.vgmPerUnitTons ?? 25,
|
||||||
|
})),
|
||||||
|
}) as Booking;
|
||||||
|
|
||||||
|
describe('wagon-plan.util', () => {
|
||||||
|
it('uses slot-based planning: 2×20ft = 1 wagon slot', () => {
|
||||||
|
const booking = makeContainerBooking('b1', [{ quantity: 2, wagonsRequired: 1 }]);
|
||||||
|
const plan = buildContainerWagonPlan([booking], nw5);
|
||||||
|
expect(plan).toHaveLength(1);
|
||||||
|
expect(plan[0]?.allocations[0]?.loadType).toBe(AllocationLoadType.Container);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses slot-based planning: 1×40ft = 1 wagon slot', () => {
|
||||||
|
const booking = makeContainerBooking('b2', [{ quantity: 1, wagonsRequired: 1 }]);
|
||||||
|
const plan = buildContainerWagonPlan([booking], nw5);
|
||||||
|
expect(plan).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sums wagons across multiple container lines', () => {
|
||||||
|
const booking = makeContainerBooking('b3', [
|
||||||
|
{ quantity: 2, wagonsRequired: 1 },
|
||||||
|
{ quantity: 1, wagonsRequired: 1 },
|
||||||
|
]);
|
||||||
|
expect(sumWagonsRequired(booking)).toBe(2);
|
||||||
|
const plan = buildContainerWagonPlan([booking], nw5);
|
||||||
|
expect(plan).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('6×20ft containers = 3 wagon slots (2 per wagon)', () => {
|
||||||
|
// 20ft containers have wagonsPerUnit = 0.5, so 6 * 0.5 = 3 wagons
|
||||||
|
const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]);
|
||||||
|
expect(sumWagonsRequired(booking)).toBe(3);
|
||||||
|
const plan = buildContainerWagonPlan([booking], nw5);
|
||||||
|
expect(plan).toHaveLength(3);
|
||||||
|
// Verify sequence numbers are 1, 2, 3
|
||||||
|
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands container items per quantity', () => {
|
||||||
|
const booking = makeContainerBooking('b4', [{ quantity: 3, wagonsRequired: 3 }]);
|
||||||
|
const items = expandContainerItems(booking, 'alloc-1');
|
||||||
|
expect(items).toHaveLength(3);
|
||||||
|
expect(items[0]?.wagonBookingAllocationId).toBe('alloc-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rounds tons to three decimal places', () => {
|
||||||
|
expect(roundTons(1.23456)).toBe(1.235);
|
||||||
|
expect(roundTons('bad')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds mixed plan with container block before bulk', () => {
|
||||||
|
const containerBooking = makeContainerBooking('c1', [{ quantity: 2, wagonsRequired: 2 }]);
|
||||||
|
const bulkBooking = {
|
||||||
|
id: 'b1',
|
||||||
|
reference: 'BKG-BULK',
|
||||||
|
freightType: 'BULK',
|
||||||
|
cargoTotalWeightVgm: 120,
|
||||||
|
bookingContainers: [],
|
||||||
|
} as unknown as Booking;
|
||||||
|
|
||||||
|
const plan = buildMixedWagonPlan([containerBooking], [bulkBooking], nw5, cw3);
|
||||||
|
expect(plan).toHaveLength(4);
|
||||||
|
expect(plan[0]?.slotLoadType).toBe('CONTAINER');
|
||||||
|
expect(plan[2]?.slotLoadType).toBe('BULK');
|
||||||
|
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands booking container units for UI rows', () => {
|
||||||
|
const booking = makeContainerBooking('c2', [{ quantity: 3, wagonsRequired: 3 }]);
|
||||||
|
const units = expandBookingContainerUnits([booking]);
|
||||||
|
expect(units).toHaveLength(3);
|
||||||
|
expect(units[1]?.unitIndex).toBe(1);
|
||||||
|
expect(units[1]?.bookingContainerId).toBe('c2-line-0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates required placements per container unit', () => {
|
||||||
|
const booking = makeContainerBooking('c3', [{ quantity: 2, wagonsRequired: 2 }]);
|
||||||
|
const plan = buildContainerWagonPlan([booking], nw5);
|
||||||
|
const violations = validateContainerPlacements([booking], plan, []);
|
||||||
|
expect(violations.some((v) => v.includes('required'))).toBe(true);
|
||||||
|
|
||||||
|
const units = expandBookingContainerUnits([booking]);
|
||||||
|
const placements = units.map((unit, index) => ({
|
||||||
|
bookingContainerId: unit.bookingContainerId,
|
||||||
|
unitIndex: unit.unitIndex,
|
||||||
|
sequenceNo: plan[index]?.sequenceNo ?? 1,
|
||||||
|
containerNumber: `CNTR-${index + 1}`,
|
||||||
|
}));
|
||||||
|
expect(validateContainerPlacements([booking], plan, placements)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects 20ft container over max individual weight', () => {
|
||||||
|
const booking = makeContainerBooking('c20', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]);
|
||||||
|
const units = expandBookingContainerUnits([booking]);
|
||||||
|
const placements = units.map((unit, index) => ({
|
||||||
|
bookingContainerId: unit.bookingContainerId,
|
||||||
|
unitIndex: unit.unitIndex,
|
||||||
|
sequenceNo: 1,
|
||||||
|
containerNumber: `CNTR-${index + 1}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const violations = validate20ftContainerRules(units, placements, {
|
||||||
|
max20ftContainerWeightTons: 30,
|
||||||
|
max20ftPairWeightDiffTons: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(violations.some((v) => v.includes('exceeds max 30T'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects 20ft pair when weight difference exceeds limit', () => {
|
||||||
|
const booking = makeContainerBooking('c21', [
|
||||||
|
{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 },
|
||||||
|
]);
|
||||||
|
booking.bookingContainers![0]!.vgmPerUnitTons = 25;
|
||||||
|
const units = expandBookingContainerUnits([booking]);
|
||||||
|
units[1]!.grossWeightTons = 10;
|
||||||
|
const placements = units.map((unit) => ({
|
||||||
|
bookingContainerId: unit.bookingContainerId,
|
||||||
|
unitIndex: unit.unitIndex,
|
||||||
|
sequenceNo: 1,
|
||||||
|
containerNumber: `CNTR-${unit.unitIndex}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const violations = validate20ftContainerRules(units, placements, {
|
||||||
|
max20ftContainerWeightTons: 30,
|
||||||
|
max20ftPairWeightDiffTons: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(violations.some((v) => v.includes('weight difference'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds bulk-only plan as degenerate mixed case', () => {
|
||||||
|
const bulkBooking = {
|
||||||
|
id: 'b2',
|
||||||
|
reference: 'BKG-BULK-2',
|
||||||
|
freightType: 'BULK',
|
||||||
|
cargoTotalWeightVgm: 60,
|
||||||
|
bookingContainers: [],
|
||||||
|
} as unknown as Booking;
|
||||||
|
const plan = buildMixedWagonPlan([], [bulkBooking], nw5, cw3);
|
||||||
|
expect(plan).toHaveLength(1);
|
||||||
|
expect(plan[0]?.slotLoadType).toBe('BULK');
|
||||||
|
expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
import { AllocationLoadType } from '@edr/types';
|
||||||
|
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
|
|
||||||
|
export const MAX_TRAIN_WEIGHT_TONS = 3500;
|
||||||
|
export const MAX_TRAIN_LENGTH_METERS = 760;
|
||||||
|
export const MAX_TEU_SLOTS_PER_WAGON = 2;
|
||||||
|
|
||||||
|
export type TrainLimitConfig = {
|
||||||
|
maxWeightTons?: number;
|
||||||
|
maxLengthMeters?: number;
|
||||||
|
maxWagonsPerTrain?: number;
|
||||||
|
max20ftContainerWeightTons?: number;
|
||||||
|
max20ftPairWeightDiffTons?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContainerPlacementRules = {
|
||||||
|
max20ftContainerWeightTons?: number;
|
||||||
|
max20ftPairWeightDiffTons?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WagonAllocationRecord = {
|
||||||
|
bookingId: string;
|
||||||
|
bookingReference: string;
|
||||||
|
allocatedWeightTons: number;
|
||||||
|
loadType: AllocationLoadType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SlotLoadType = 'CONTAINER' | 'BULK';
|
||||||
|
|
||||||
|
export type WagonPlanSlot = {
|
||||||
|
sequenceNo: number;
|
||||||
|
wagonTypeId: string;
|
||||||
|
wagonTypeCode: string;
|
||||||
|
capacityTons: number;
|
||||||
|
lengthMeters: number;
|
||||||
|
assignedWeightTons: number;
|
||||||
|
allocations: WagonAllocationRecord[];
|
||||||
|
slotLoadType?: SlotLoadType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContainerUnitRow = {
|
||||||
|
bookingId: string;
|
||||||
|
bookingReference: string;
|
||||||
|
bookingContainerId: string;
|
||||||
|
unitIndex: number;
|
||||||
|
containerTypeId: string;
|
||||||
|
containerTypeCode: string;
|
||||||
|
label: string;
|
||||||
|
grossWeightTons: number;
|
||||||
|
sizeFt?: number;
|
||||||
|
wagonsPerUnit?: number;
|
||||||
|
containersPerWagon?: number;
|
||||||
|
teuSlots?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContainerPlacementInput = {
|
||||||
|
bookingContainerId: string;
|
||||||
|
unitIndex: number;
|
||||||
|
sequenceNo: number;
|
||||||
|
containerId?: string;
|
||||||
|
containerNumber?: string;
|
||||||
|
sealNumber?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function roundTons(value: number | string | null | undefined): number {
|
||||||
|
const numericValue = typeof value === 'number' ? value : Number(value ?? 0);
|
||||||
|
if (!Number.isFinite(numericValue)) return 0;
|
||||||
|
return Number(numericValue.toFixed(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TEU slots on a wagon: 40ft = 2, 20ft = 1 (max 2 TEU / wagon). */
|
||||||
|
export function teuSlotsForSizeFt(sizeFt: number): number {
|
||||||
|
return sizeFt >= 40 ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function containersPerWagonFromType(wagonsPerUnit: number): number {
|
||||||
|
const wpu = Number(wagonsPerUnit);
|
||||||
|
if (!wpu || wpu <= 0) return 1;
|
||||||
|
return Math.max(1, Math.round(1 / wpu));
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineWagonsRequired(line: {
|
||||||
|
quantity?: number | null;
|
||||||
|
wagonsRequired?: number | null;
|
||||||
|
containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null;
|
||||||
|
}): number {
|
||||||
|
const qty = Number(line.quantity ?? 0);
|
||||||
|
if (qty <= 0) return 0;
|
||||||
|
const wpu = Number(line.containerType?.wagonsPerUnit);
|
||||||
|
if (Number.isFinite(wpu) && wpu > 0) {
|
||||||
|
return Math.ceil(qty * wpu);
|
||||||
|
}
|
||||||
|
return Math.max(1, Math.ceil(Number(line.wagonsRequired ?? 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build slot-based wagon plan for CONTAINER bookings using booking_container.wagons_required.
|
||||||
|
*/
|
||||||
|
export function buildContainerWagonPlan(
|
||||||
|
bookings: Booking[],
|
||||||
|
wagonType: WagonType,
|
||||||
|
): WagonPlanSlot[] {
|
||||||
|
const totalSlots = bookings.reduce((sum, booking) => {
|
||||||
|
const lineSlots = (booking.bookingContainers ?? []).reduce(
|
||||||
|
(lineSum, line) => lineSum + lineWagonsRequired(line),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return sum + Math.max(lineSlots, 1);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const slots = Math.max(1, Math.ceil(totalSlots));
|
||||||
|
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
|
||||||
|
sequenceNo: index + 1,
|
||||||
|
wagonTypeId: wagonType.id,
|
||||||
|
wagonTypeCode: wagonType.code,
|
||||||
|
capacityTons: Number(wagonType.capacityTons),
|
||||||
|
lengthMeters: Number(wagonType.lengthMeters),
|
||||||
|
assignedWeightTons: 0,
|
||||||
|
allocations: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return allocateBookingsToSlots(bookings, basePlan, AllocationLoadType.Container).map((slot) => ({
|
||||||
|
...slot,
|
||||||
|
slotLoadType: 'CONTAINER' as SlotLoadType,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build weight-based wagon plan for BULK bookings.
|
||||||
|
*/
|
||||||
|
export function buildBulkWagonPlan(
|
||||||
|
bookings: Booking[],
|
||||||
|
wagonType: WagonType,
|
||||||
|
): WagonPlanSlot[] {
|
||||||
|
const totalWeight = roundTons(
|
||||||
|
bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0),
|
||||||
|
);
|
||||||
|
const capacity = Number(wagonType.capacityTons);
|
||||||
|
const slots = Math.max(1, Math.ceil(totalWeight / capacity));
|
||||||
|
|
||||||
|
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
|
||||||
|
sequenceNo: index + 1,
|
||||||
|
wagonTypeId: wagonType.id,
|
||||||
|
wagonTypeCode: wagonType.code,
|
||||||
|
capacityTons: capacity,
|
||||||
|
lengthMeters: Number(wagonType.lengthMeters),
|
||||||
|
assignedWeightTons: 0,
|
||||||
|
allocations: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return allocateBookingsToSlots(bookings, basePlan, AllocationLoadType.Bulk).map((slot) => ({
|
||||||
|
...slot,
|
||||||
|
slotLoadType: 'BULK' as SlotLoadType,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a mixed consist: container slots first, then bulk slots, with unified sequence numbers.
|
||||||
|
*/
|
||||||
|
export function buildMixedWagonPlan(
|
||||||
|
containerBookings: Booking[],
|
||||||
|
bulkBookings: Booking[],
|
||||||
|
containerWagonType: WagonType,
|
||||||
|
bulkWagonType: WagonType,
|
||||||
|
): WagonPlanSlot[] {
|
||||||
|
const containerPlan = containerBookings.length
|
||||||
|
? buildContainerWagonPlan(containerBookings, containerWagonType)
|
||||||
|
: [];
|
||||||
|
const bulkPlan = bulkBookings.length
|
||||||
|
? buildBulkWagonPlan(bulkBookings, bulkWagonType)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const tagged: WagonPlanSlot[] = [
|
||||||
|
...containerPlan.map((slot) => ({ ...slot, slotLoadType: 'CONTAINER' as SlotLoadType })),
|
||||||
|
...bulkPlan.map((slot) => ({ ...slot, slotLoadType: 'BULK' as SlotLoadType })),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!tagged.length) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
sequenceNo: 1,
|
||||||
|
wagonTypeId: containerWagonType.id,
|
||||||
|
wagonTypeCode: containerWagonType.code,
|
||||||
|
capacityTons: Number(containerWagonType.capacityTons),
|
||||||
|
lengthMeters: Number(containerWagonType.lengthMeters),
|
||||||
|
assignedWeightTons: 0,
|
||||||
|
allocations: [],
|
||||||
|
slotLoadType: 'CONTAINER',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return tagged.map((slot, index) => ({
|
||||||
|
...slot,
|
||||||
|
sequenceNo: index + 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitRow[] {
|
||||||
|
const rows: ContainerUnitRow[] = [];
|
||||||
|
|
||||||
|
for (const booking of bookings.filter((b) => b.freightType === 'CONTAINER')) {
|
||||||
|
for (const line of booking.bookingContainers ?? []) {
|
||||||
|
const qty = Number(line.quantity ?? 0);
|
||||||
|
const code = line.containerType?.code ?? line.containerType?.label ?? 'Container';
|
||||||
|
const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20));
|
||||||
|
const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5));
|
||||||
|
const perWagon = containersPerWagonFromType(wagonsPerUnit);
|
||||||
|
const teuSlots = teuSlotsForSizeFt(sizeFt);
|
||||||
|
for (let i = 0; i < qty; i += 1) {
|
||||||
|
rows.push({
|
||||||
|
bookingId: booking.id,
|
||||||
|
bookingReference: booking.reference,
|
||||||
|
bookingContainerId: line.id,
|
||||||
|
unitIndex: i,
|
||||||
|
containerTypeId: line.containerTypeId ?? '',
|
||||||
|
containerTypeCode: code,
|
||||||
|
label: `${booking.reference} · ${i + 1}/${qty} · ${code}`,
|
||||||
|
grossWeightTons: Number(line.vgmPerUnitTons),
|
||||||
|
sizeFt,
|
||||||
|
wagonsPerUnit,
|
||||||
|
containersPerWagon: perWagon,
|
||||||
|
teuSlots,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getContainerSlotSequenceNos(wagonPlan: WagonPlanSlot[]): number[] {
|
||||||
|
return wagonPlan
|
||||||
|
.filter((slot) => slot.slotLoadType === 'CONTAINER' || slot.allocations.some(
|
||||||
|
(a) => a.loadType === AllocationLoadType.Container,
|
||||||
|
))
|
||||||
|
.map((slot) => slot.sequenceNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
function allocateBookingsToSlots(
|
||||||
|
bookings: Booking[],
|
||||||
|
basePlan: WagonPlanSlot[],
|
||||||
|
loadType: AllocationLoadType,
|
||||||
|
): WagonPlanSlot[] {
|
||||||
|
const remaining = bookings.map((booking) => ({
|
||||||
|
bookingId: booking.id,
|
||||||
|
bookingReference: booking.reference,
|
||||||
|
remainingWeightTons: roundTons(Number(booking.cargoTotalWeightVgm ?? 0)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let bookingIndex = 0;
|
||||||
|
|
||||||
|
return basePlan.map((slot) => {
|
||||||
|
let wagonRemaining = roundTons(slot.capacityTons);
|
||||||
|
const allocations: WagonAllocationRecord[] = [];
|
||||||
|
let assignedWeightTons = 0;
|
||||||
|
|
||||||
|
while (wagonRemaining > 0 && bookingIndex < remaining.length) {
|
||||||
|
const booking = remaining[bookingIndex];
|
||||||
|
const allocatedWeightTons = roundTons(
|
||||||
|
Math.min(wagonRemaining, booking.remainingWeightTons),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allocatedWeightTons <= 0) {
|
||||||
|
bookingIndex += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
allocations.push({
|
||||||
|
bookingId: booking.bookingId,
|
||||||
|
bookingReference: booking.bookingReference,
|
||||||
|
allocatedWeightTons,
|
||||||
|
loadType,
|
||||||
|
});
|
||||||
|
|
||||||
|
booking.remainingWeightTons = roundTons(
|
||||||
|
booking.remainingWeightTons - allocatedWeightTons,
|
||||||
|
);
|
||||||
|
wagonRemaining = roundTons(wagonRemaining - allocatedWeightTons);
|
||||||
|
assignedWeightTons = roundTons(assignedWeightTons + allocatedWeightTons);
|
||||||
|
|
||||||
|
if (booking.remainingWeightTons <= 0) {
|
||||||
|
bookingIndex += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...slot, assignedWeightTons, allocations };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandContainerItems(
|
||||||
|
booking: Booking,
|
||||||
|
allocationId: string,
|
||||||
|
): Array<{
|
||||||
|
wagonBookingAllocationId: string;
|
||||||
|
bookingContainerId: string;
|
||||||
|
containerTypeId: string;
|
||||||
|
grossWeightTons: number;
|
||||||
|
positionOnWagon: number | null;
|
||||||
|
}> {
|
||||||
|
const items: Array<{
|
||||||
|
wagonBookingAllocationId: string;
|
||||||
|
bookingContainerId: string;
|
||||||
|
containerTypeId: string;
|
||||||
|
grossWeightTons: number;
|
||||||
|
positionOnWagon: number | null;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const line of booking.bookingContainers ?? []) {
|
||||||
|
const qty = Number(line.quantity ?? 0);
|
||||||
|
for (let i = 0; i < qty; i += 1) {
|
||||||
|
items.push({
|
||||||
|
wagonBookingAllocationId: allocationId,
|
||||||
|
bookingContainerId: line.id,
|
||||||
|
containerTypeId: line.containerTypeId ?? '',
|
||||||
|
grossWeightTons: Number(line.vgmPerUnitTons),
|
||||||
|
positionOnWagon: qty > 1 ? i + 1 : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sumWagonsRequired(booking: Booking): number {
|
||||||
|
if (booking.freightType === 'BULK') {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return (booking.bookingContainers ?? []).reduce(
|
||||||
|
(sum, line) => sum + Number(line.wagonsRequired ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string[] {
|
||||||
|
const violations: string[] = [];
|
||||||
|
for (const slot of wagonPlan.filter((s) => s.slotLoadType === 'BULK')) {
|
||||||
|
if (slot.assignedWeightTons > slot.capacityTons) {
|
||||||
|
violations.push(
|
||||||
|
`Bulk wagon #${slot.sequenceNo} load ${slot.assignedWeightTons}T exceeds capacity ${slot.capacityTons}T`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTrainLimits(
|
||||||
|
wagonPlan: WagonPlanSlot[],
|
||||||
|
wagonType: WagonType,
|
||||||
|
limits?: TrainLimitConfig,
|
||||||
|
): string[] {
|
||||||
|
const violations: string[] = [];
|
||||||
|
const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS;
|
||||||
|
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
|
||||||
|
const maxWagonsPerTrain =
|
||||||
|
limits?.maxWagonsPerTrain ?? Number(wagonType.maxWagonsPerTrain ?? 53);
|
||||||
|
|
||||||
|
const totalWeightTons = roundTons(
|
||||||
|
wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0),
|
||||||
|
);
|
||||||
|
const totalLengthMeters = roundTons(
|
||||||
|
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (totalWeightTons > maxWeightTons) {
|
||||||
|
violations.push(
|
||||||
|
`Total booking weight ${totalWeightTons}T exceeds max train weight ${maxWeightTons}T`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (totalLengthMeters > maxLengthMeters) {
|
||||||
|
violations.push(
|
||||||
|
`Total wagon length ${totalLengthMeters}m exceeds max train length ${maxLengthMeters}m`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (wagonPlan.length > maxWagonsPerTrain) {
|
||||||
|
violations.push(
|
||||||
|
`Wagon count ${wagonPlan.length} exceeds max wagons per train (${maxWagonsPerTrain})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
violations.push(...validateBulkWagonSlotWeights(wagonPlan));
|
||||||
|
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateMixedTrainLimits(
|
||||||
|
wagonPlan: WagonPlanSlot[],
|
||||||
|
wagonTypes: WagonType[],
|
||||||
|
limits?: TrainLimitConfig,
|
||||||
|
): string[] {
|
||||||
|
const maxWagonsPerTrain =
|
||||||
|
limits?.maxWagonsPerTrain ??
|
||||||
|
Math.max(...wagonTypes.map((wt) => Number(wt.maxWagonsPerTrain ?? 53)), 53);
|
||||||
|
|
||||||
|
return validateTrainLimits(
|
||||||
|
wagonPlan,
|
||||||
|
{ maxWagonsPerTrain } as WagonType,
|
||||||
|
{ ...limits, maxWagonsPerTrain },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validate20ftContainerRules(
|
||||||
|
units: ContainerUnitRow[],
|
||||||
|
placements: ContainerPlacementInput[],
|
||||||
|
rules?: ContainerPlacementRules,
|
||||||
|
): string[] {
|
||||||
|
const violations: string[] = [];
|
||||||
|
const maxEach = rules?.max20ftContainerWeightTons;
|
||||||
|
const maxDiff = rules?.max20ftPairWeightDiffTons;
|
||||||
|
if (maxEach == null && maxDiff == null) return violations;
|
||||||
|
|
||||||
|
const placementByUnit = new Map(
|
||||||
|
placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const weightsBySlot = new Map<number, number[]>();
|
||||||
|
|
||||||
|
for (const unit of units) {
|
||||||
|
const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20);
|
||||||
|
if (sizeFt >= 40) continue;
|
||||||
|
|
||||||
|
if (maxEach != null && unit.grossWeightTons > maxEach) {
|
||||||
|
violations.push(
|
||||||
|
`${unit.label} weight ${unit.grossWeightTons}T exceeds max ${maxEach}T for 20ft containers`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const placement = placementByUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`);
|
||||||
|
if (!placement?.sequenceNo) continue;
|
||||||
|
|
||||||
|
const list = weightsBySlot.get(placement.sequenceNo) ?? [];
|
||||||
|
list.push(unit.grossWeightTons);
|
||||||
|
weightsBySlot.set(placement.sequenceNo, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxDiff != null) {
|
||||||
|
for (const [sequenceNo, weights] of weightsBySlot.entries()) {
|
||||||
|
if (weights.length < 2) continue;
|
||||||
|
const diff = Math.abs(weights[0]! - weights[1]!);
|
||||||
|
if (diff > maxDiff) {
|
||||||
|
violations.push(
|
||||||
|
`Wagon #${sequenceNo} 20ft pair weight difference ${roundTons(diff)}T exceeds max ${maxDiff}T`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateContainerPlacements(
|
||||||
|
containerBookings: Booking[],
|
||||||
|
wagonPlan: WagonPlanSlot[],
|
||||||
|
placements: ContainerPlacementInput[],
|
||||||
|
rules?: ContainerPlacementRules,
|
||||||
|
): string[] {
|
||||||
|
const violations: string[] = [];
|
||||||
|
const units = expandBookingContainerUnits(containerBookings);
|
||||||
|
if (!units.length) return violations;
|
||||||
|
|
||||||
|
const containerSlots = new Set(getContainerSlotSequenceNos(wagonPlan));
|
||||||
|
const unitKeys = new Set(units.map((u) => `${u.bookingContainerId}:${u.unitIndex}`));
|
||||||
|
const placementKeys = new Set<string>();
|
||||||
|
const containerNumbers = new Set<string>();
|
||||||
|
|
||||||
|
if (!placements.length) {
|
||||||
|
violations.push('Container placements are required for container bookings');
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const placement of placements) {
|
||||||
|
const unitKey = `${placement.bookingContainerId}:${placement.unitIndex}`;
|
||||||
|
if (!unitKeys.has(unitKey)) {
|
||||||
|
violations.push(
|
||||||
|
`Unknown container unit ${placement.bookingContainerId}#${placement.unitIndex}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (placementKeys.has(unitKey)) {
|
||||||
|
violations.push(`Duplicate placement for container unit ${unitKey}`);
|
||||||
|
}
|
||||||
|
placementKeys.add(unitKey);
|
||||||
|
|
||||||
|
if (!containerSlots.has(placement.sequenceNo)) {
|
||||||
|
violations.push(`Slot #${placement.sequenceNo} is not a container wagon slot`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasInventory = Boolean(placement.containerId);
|
||||||
|
const hasManual = Boolean(placement.containerNumber?.trim());
|
||||||
|
if (!hasInventory && !hasManual) {
|
||||||
|
violations.push(
|
||||||
|
`Container unit ${unitKey} requires an existing container or a new container number`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasManual) {
|
||||||
|
const normalized = placement.containerNumber!.trim().toUpperCase();
|
||||||
|
if (containerNumbers.has(normalized)) {
|
||||||
|
violations.push(`Duplicate container number ${normalized}`);
|
||||||
|
}
|
||||||
|
containerNumbers.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const unit of units) {
|
||||||
|
const unitKey = `${unit.bookingContainerId}:${unit.unitIndex}`;
|
||||||
|
if (!placementKeys.has(unitKey)) {
|
||||||
|
violations.push(`Missing placement for ${unit.label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slotTeuUsed = new Map<number, number>();
|
||||||
|
const slotWeightUsed = new Map<number, number>();
|
||||||
|
const slotBySeq = new Map(wagonPlan.map((s) => [s.sequenceNo, s]));
|
||||||
|
|
||||||
|
for (const placement of placements) {
|
||||||
|
const unit = units.find(
|
||||||
|
(u) =>
|
||||||
|
u.bookingContainerId === placement.bookingContainerId &&
|
||||||
|
u.unitIndex === placement.unitIndex,
|
||||||
|
);
|
||||||
|
if (!unit) continue;
|
||||||
|
|
||||||
|
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
|
||||||
|
const usedTeu = slotTeuUsed.get(placement.sequenceNo) ?? 0;
|
||||||
|
if (usedTeu + teu > MAX_TEU_SLOTS_PER_WAGON) {
|
||||||
|
violations.push(
|
||||||
|
`Wagon #${placement.sequenceNo} cannot fit another ${unit.containerTypeCode} (max 1×40ft or 2×20ft per wagon)`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
slotTeuUsed.set(placement.sequenceNo, usedTeu + teu);
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = slotBySeq.get(placement.sequenceNo);
|
||||||
|
if (slot) {
|
||||||
|
const weight = roundTons(slotWeightUsed.get(placement.sequenceNo) ?? 0) + unit.grossWeightTons;
|
||||||
|
slotWeightUsed.set(placement.sequenceNo, weight);
|
||||||
|
if (weight > slot.capacityTons) {
|
||||||
|
violations.push(
|
||||||
|
`Wagon #${placement.sequenceNo} total container weight ${weight}T exceeds capacity ${slot.capacityTons}T`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
violations.push(...validate20ftContainerRules(units, placements, rules));
|
||||||
|
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { WagonReadiness } from '@edr/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
requiredWagonReadiness,
|
||||||
|
wagonReadinessMatchesSchedule,
|
||||||
|
} from './wagon-readiness.util';
|
||||||
|
|
||||||
|
describe('wagonReadinessMatchesSchedule', () => {
|
||||||
|
it('requires IMPORT_READY for IMPORT schedules', () => {
|
||||||
|
expect(requiredWagonReadiness('IMPORT')).toBe(WagonReadiness.ImportReady);
|
||||||
|
expect(
|
||||||
|
wagonReadinessMatchesSchedule(WagonReadiness.ImportReady, 'IMPORT'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
wagonReadinessMatchesSchedule(WagonReadiness.ExportReady, 'IMPORT'),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires EXPORT_READY for EXPORT schedules', () => {
|
||||||
|
expect(requiredWagonReadiness('EXPORT')).toBe(WagonReadiness.ExportReady);
|
||||||
|
expect(
|
||||||
|
wagonReadinessMatchesSchedule(WagonReadiness.ExportReady, 'EXPORT'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
wagonReadinessMatchesSchedule(WagonReadiness.ImportReady, 'EXPORT'),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows any readiness for DOMESTIC schedules', () => {
|
||||||
|
expect(requiredWagonReadiness('DOMESTIC')).toBeNull();
|
||||||
|
expect(
|
||||||
|
wagonReadinessMatchesSchedule(WagonReadiness.ExportReady, 'DOMESTIC'),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { WagonReadiness, type ScheduleTradeDirection } from '@edr/types';
|
||||||
|
|
||||||
|
export function requiredWagonReadiness(
|
||||||
|
direction: ScheduleTradeDirection | string | null | undefined,
|
||||||
|
): WagonReadiness | null {
|
||||||
|
if (direction === 'IMPORT') return WagonReadiness.ImportReady;
|
||||||
|
if (direction === 'EXPORT') return WagonReadiness.ExportReady;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wagonReadinessMatchesSchedule(
|
||||||
|
wagonReadiness: WagonReadiness | string,
|
||||||
|
direction: ScheduleTradeDirection | string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
const required = requiredWagonReadiness(direction);
|
||||||
|
if (!required) return true;
|
||||||
|
return wagonReadiness === required;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
|
|
||||||
|
const CARGO_CODE_TO_WAGON_TYPE: Record<string, string> = {
|
||||||
|
COFFEE: 'KW2',
|
||||||
|
GRAIN: 'KW2',
|
||||||
|
WHEAT: 'KW2',
|
||||||
|
SORGHUM: 'KW2',
|
||||||
|
CORN: 'KW2',
|
||||||
|
FERTILIZER: 'PW2',
|
||||||
|
SUGAR: 'PW2',
|
||||||
|
COAL: 'KW3',
|
||||||
|
STEEL: 'CW3',
|
||||||
|
ORE: 'CW3',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_BULK_WAGON_TYPE = 'CW3';
|
||||||
|
const DEFAULT_CONTAINER_WAGON_TYPE = 'NW5';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve wagon type code from cargo type code for bulk freight.
|
||||||
|
*/
|
||||||
|
export function resolveBulkWagonTypeCode(cargoTypeCode?: string | null): string {
|
||||||
|
if (!cargoTypeCode) return DEFAULT_BULK_WAGON_TYPE;
|
||||||
|
const normalized = cargoTypeCode.trim().toUpperCase();
|
||||||
|
return CARGO_CODE_TO_WAGON_TYPE[normalized] ?? DEFAULT_BULK_WAGON_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the best matching wagon type entity for bulk cargo.
|
||||||
|
*/
|
||||||
|
export function pickBulkWagonType(
|
||||||
|
wagonTypes: WagonType[],
|
||||||
|
cargoTypeCode?: string | null,
|
||||||
|
): WagonType | undefined {
|
||||||
|
const preferredCode = resolveBulkWagonTypeCode(cargoTypeCode);
|
||||||
|
const direct = wagonTypes.find((wt) => wt.code === preferredCode && wt.isActive);
|
||||||
|
if (direct) return direct;
|
||||||
|
|
||||||
|
return wagonTypes.find(
|
||||||
|
(wt) =>
|
||||||
|
wt.isActive &&
|
||||||
|
!wt.supportsContainer &&
|
||||||
|
wt.code !== DEFAULT_CONTAINER_WAGON_TYPE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultContainerWagonTypeCode(): string {
|
||||||
|
return DEFAULT_CONTAINER_WAGON_TYPE;
|
||||||
|
}
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { TrainSetWagonStatus } from '@edr/types';
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||||
|
|
||||||
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
|
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
|
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
import { TrainSet } from './train-set.entity';
|
import { TrainSet } from './train-set.entity';
|
||||||
|
|
||||||
|
export const TRAIN_SET_WAGON_STATUSES = [
|
||||||
|
TrainSetWagonStatus.Planned,
|
||||||
|
TrainSetWagonStatus.Reserved,
|
||||||
|
TrainSetWagonStatus.Loaded,
|
||||||
|
TrainSetWagonStatus.Departed,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type TrainSetWagonStatusType = (typeof TRAIN_SET_WAGON_STATUSES)[number];
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'train_set_wagons' })
|
@Entity({ schema: 'freight', name: 'train_set_wagons' })
|
||||||
@Index(['trainSetId', 'sequenceNo'], { unique: true })
|
@Index(['trainSetId', 'sequenceNo'], { unique: true })
|
||||||
export class TrainSetWagon extends BaseEntity {
|
export class TrainSetWagon extends BaseEntity {
|
||||||
@@ -34,6 +45,16 @@ export class TrainSetWagon extends BaseEntity {
|
|||||||
@Column({ name: 'assigned_weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 })
|
@Column({ name: 'assigned_weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 })
|
||||||
assignedWeightTons!: number;
|
assignedWeightTons!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'physical_wagon_id', type: 'uuid', nullable: true })
|
||||||
|
physicalWagonId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => Wagon, { nullable: true, onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'physical_wagon_id' })
|
||||||
|
physicalWagon?: Wagon | null;
|
||||||
|
|
||||||
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PLANNED' })
|
||||||
|
status!: string;
|
||||||
|
|
||||||
@OneToMany(() => WagonBookingAllocation, (allocation) => allocation.trainSetWagon)
|
@OneToMany(() => WagonBookingAllocation, (allocation) => allocation.trainSetWagon)
|
||||||
allocations?: WagonBookingAllocation[];
|
allocations?: WagonBookingAllocation[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { Freight } from '@edr/types';
|
|||||||
import { Column, Entity, OneToMany } from 'typeorm';
|
import { Column, Entity, OneToMany } from 'typeorm';
|
||||||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fleet master data — named wagon consist in inventory (POST /trains).
|
||||||
|
* Operational departures use train_schedules + locomotives; scheduling never creates trains rows.
|
||||||
|
*/
|
||||||
@Entity({ schema: 'freight', name: 'trains' })
|
@Entity({ schema: 'freight', name: 'trains' })
|
||||||
export class Train extends BaseEntity {
|
export class Train extends BaseEntity {
|
||||||
// --- existing fields (keep for backward compatibility) ---
|
// --- existing fields (keep for backward compatibility) ---
|
||||||
|
|||||||
@@ -28,6 +28,18 @@ export class WagonType extends BaseEntity {
|
|||||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
isActive!: boolean;
|
isActive!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'equated_length_m', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||||
|
equatedLengthM?: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||||
|
tareWeightTons?: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'supports_container', type: 'boolean', default: false })
|
||||||
|
supportsContainer!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'max_container_gross_t', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||||
|
maxContainerGrossT?: number | null;
|
||||||
|
|
||||||
@OneToMany(() => TrainSetWagon, (wagon) => wagon.wagonType)
|
@OneToMany(() => TrainSetWagon, (wagon) => wagon.wagonType)
|
||||||
trainSetWagons?: TrainSetWagon[];
|
trainSetWagons?: TrainSetWagon[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsIn } from 'class-validator';
|
import { WagonReadiness, WagonStatus } from '@edr/types';
|
||||||
|
import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsEnum } from 'class-validator';
|
||||||
|
|
||||||
export class CreateWagonDto {
|
export class CreateWagonDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -25,10 +26,14 @@ export class CreateWagonDto {
|
|||||||
maxPayloadWeight!: number;
|
maxPayloadWeight!: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['AVAILABLE', 'ASSIGNED', 'MAINTENANCE', 'RETIRED'])
|
@IsEnum(WagonStatus)
|
||||||
status?: string;
|
status?: WagonStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(WagonReadiness)
|
||||||
|
readiness?: WagonReadiness;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
// apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts
|
// apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts
|
||||||
import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
|
import { WagonReadiness, WagonStatus } from '@edr/types';
|
||||||
|
import { Entity, Column, ManyToOne, OneToMany, JoinColumn, Index } from 'typeorm';
|
||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Train } from '../../trains/entities/train.entity';
|
import { Train } from '../../trains/entities/train.entity';
|
||||||
|
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||||
|
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
||||||
import { Container } from '../../container-management/entities/container.entity';
|
import { Container } from '../../container-management/entities/container.entity';
|
||||||
|
|
||||||
|
export const WAGON_STATUSES = [
|
||||||
|
WagonStatus.Available,
|
||||||
|
WagonStatus.Assigned,
|
||||||
|
WagonStatus.Maintenance,
|
||||||
|
WagonStatus.Retired,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const WAGON_READINESS_VALUES = [
|
||||||
|
WagonReadiness.ImportReady,
|
||||||
|
WagonReadiness.ExportReady,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type WagonStatusType = (typeof WAGON_STATUSES)[number];
|
||||||
|
export type WagonReadinessType = (typeof WAGON_READINESS_VALUES)[number];
|
||||||
|
|
||||||
@Entity({ name: 'wagons', schema: 'freight' })
|
@Entity({ name: 'wagons', schema: 'freight' })
|
||||||
|
@Index(['readiness'])
|
||||||
export class Wagon extends BaseEntity {
|
export class Wagon extends BaseEntity {
|
||||||
@Column({ unique: true, name: 'wagon_number' })
|
@Column({ unique: true, name: 'wagon_number' })
|
||||||
wagonNumber!: string;
|
wagonNumber!: string;
|
||||||
@@ -24,13 +43,30 @@ export class Wagon extends BaseEntity {
|
|||||||
@Column({ name: 'max_payload_weight', type: 'decimal', precision: 10, scale: 2 })
|
@Column({ name: 'max_payload_weight', type: 'decimal', precision: 10, scale: 2 })
|
||||||
maxPayloadWeight!: number;
|
maxPayloadWeight!: number;
|
||||||
|
|
||||||
@Column({ type: 'varchar', default: 'AVAILABLE' })
|
@Column({ type: 'varchar', length: 20, default: WagonStatus.Available })
|
||||||
status!: string; // AVAILABLE, ASSIGNED, MAINTENANCE, RETIRED
|
status!: WagonStatusType;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: WagonReadiness.ImportReady })
|
||||||
|
readiness!: WagonReadinessType;
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
notes!: string | null;
|
notes!: string | null;
|
||||||
|
|
||||||
// Relationship to Train
|
@Column({ name: 'train_set_wagon_id', type: 'uuid', nullable: true })
|
||||||
|
trainSetWagonId!: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => TrainSetWagon, { nullable: true, onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'train_set_wagon_id' })
|
||||||
|
trainSetWagon?: TrainSetWagon | null;
|
||||||
|
|
||||||
|
@Column({ name: 'current_train_schedule_id', type: 'uuid', nullable: true })
|
||||||
|
currentTrainScheduleId!: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => TrainSchedule, { nullable: true, onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'current_train_schedule_id' })
|
||||||
|
currentTrainSchedule?: TrainSchedule | null;
|
||||||
|
|
||||||
|
/** Fleet master consist grouping — separate from operational train_schedules. */
|
||||||
@ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' })
|
@ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' })
|
||||||
@JoinColumn({ name: 'train_id' })
|
@JoinColumn({ name: 'train_id' })
|
||||||
train!: Train | null;
|
train!: Train | null;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { WagonReadiness, WagonStatus } from '@edr/types';
|
||||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm';
|
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm';
|
||||||
@@ -19,7 +20,11 @@ export class WagonsService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(dto: CreateWagonDto): Promise<Wagon> {
|
async create(dto: CreateWagonDto): Promise<Wagon> {
|
||||||
const wagon = this.wagonRepo.create(dto);
|
const wagon = this.wagonRepo.create({
|
||||||
|
...dto,
|
||||||
|
status: dto.status ?? WagonStatus.Available,
|
||||||
|
readiness: dto.readiness ?? WagonReadiness.ImportReady,
|
||||||
|
});
|
||||||
// Convert undefined to null for nullable fields
|
// Convert undefined to null for nullable fields
|
||||||
if (dto.trainId === undefined) wagon.trainId = null;
|
if (dto.trainId === undefined) wagon.trainId = null;
|
||||||
if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null;
|
if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null;
|
||||||
@@ -30,23 +35,28 @@ export class WagonsService {
|
|||||||
const where: FindOptionsWhere<Wagon>[] | FindOptionsWhere<Wagon> = [];
|
const where: FindOptionsWhere<Wagon>[] | FindOptionsWhere<Wagon> = [];
|
||||||
const search = query.search?.trim();
|
const search = query.search?.trim();
|
||||||
const status = query.status?.trim();
|
const status = query.status?.trim();
|
||||||
|
const readiness = query.readiness?.trim();
|
||||||
const trainId = query.trainId?.trim();
|
const trainId = query.trainId?.trim();
|
||||||
|
const filters = {
|
||||||
|
...(status ? { status: status as Wagon['status'] } : {}),
|
||||||
|
...(readiness ? { readiness: readiness as Wagon['readiness'] } : {}),
|
||||||
|
...(trainId ? { trainId } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where.push({
|
where.push({
|
||||||
wagonNumber: ILike(`%${search}%`),
|
wagonNumber: ILike(`%${search}%`),
|
||||||
...(status ? { status } : {}),
|
...filters,
|
||||||
...(trainId ? { trainId } : {}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'sequenceNumber'].includes(query.sortBy ?? '')
|
const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'readiness', 'sequenceNumber'].includes(query.sortBy ?? '')
|
||||||
? (query.sortBy as keyof Wagon)
|
? (query.sortBy as keyof Wagon)
|
||||||
: 'wagonNumber';
|
: 'wagonNumber';
|
||||||
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||||
|
|
||||||
return this.wagonRepo.find({
|
return this.wagonRepo.find({
|
||||||
where: search ? where : { ...(status ? { status } : {}), ...(trainId ? { trainId } : {}) },
|
where: search ? where : filters,
|
||||||
order: { [sortBy]: sortOrder } as FindOptionsOrder<Wagon>,
|
order: { [sortBy]: sortOrder } as FindOptionsOrder<Wagon>,
|
||||||
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
|
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
|
||||||
take: query.limit ? Number(query.limit) : undefined,
|
take: query.limit ? Number(query.limit) : undefined,
|
||||||
@@ -72,7 +82,7 @@ export class WagonsService {
|
|||||||
|
|
||||||
async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise<Wagon> {
|
async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise<Wagon> {
|
||||||
const wagon = await this.findById(wagonId);
|
const wagon = await this.findById(wagonId);
|
||||||
if (wagon.status === 'ASSIGNED') {
|
if (wagon.status === WagonStatus.Assigned) {
|
||||||
throw new ConflictException('Wagon already assigned to a train');
|
throw new ConflictException('Wagon already assigned to a train');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +101,7 @@ export class WagonsService {
|
|||||||
|
|
||||||
wagon.trainId = train.id;
|
wagon.trainId = train.id;
|
||||||
wagon.sequenceNumber = sequence;
|
wagon.sequenceNumber = sequence;
|
||||||
wagon.status = 'ASSIGNED';
|
wagon.status = WagonStatus.Assigned;
|
||||||
return this.wagonRepo.save(wagon);
|
return this.wagonRepo.save(wagon);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +109,7 @@ export class WagonsService {
|
|||||||
const wagon = await this.findById(wagonId);
|
const wagon = await this.findById(wagonId);
|
||||||
wagon.trainId = null;
|
wagon.trainId = null;
|
||||||
wagon.sequenceNumber = null;
|
wagon.sequenceNumber = null;
|
||||||
wagon.status = 'AVAILABLE';
|
wagon.status = WagonStatus.Available;
|
||||||
return this.wagonRepo.save(wagon);
|
return this.wagonRepo.save(wagon);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
apps/edr-freight-api/src/scripts/seed-demo-scheduling.ts
Normal file
29
apps/edr-freight-api/src/scripts/seed-demo-scheduling.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
config({ path: resolve(__dirname, '../../.env') });
|
||||||
|
process.env.SEED_DEMO_BOOKINGS = 'true';
|
||||||
|
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from '../app.module';
|
||||||
|
import { DemoBookingsSeeder } from '../seed/demo-bookings.seeder';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||||
|
logger: ['error', 'warn', 'log'],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seeder = app.get(DemoBookingsSeeder);
|
||||||
|
await seeder.run();
|
||||||
|
console.log('Demo train scheduling data seeded successfully.');
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Demo scheduling seed failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -13,7 +13,13 @@ import { Locomotive } from "../modules/locomotives/entities/locomotive.entity";
|
|||||||
import { ServiceType } from "../modules/rule-engine/entities/service-type.entity";
|
import { ServiceType } from "../modules/rule-engine/entities/service-type.entity";
|
||||||
import { Yard } from "../modules/rule-engine/entities/yard.entity";
|
import { Yard } from "../modules/rule-engine/entities/yard.entity";
|
||||||
import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity";
|
import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity";
|
||||||
|
import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity";
|
||||||
import { ContainerType } from "../modules/rule-engine/entities/container-type.entity";
|
import { ContainerType } from "../modules/rule-engine/entities/container-type.entity";
|
||||||
|
import { Container } from "../modules/container-management/entities/container.entity";
|
||||||
|
import { Route } from "../modules/routes/entities/route.entity";
|
||||||
|
import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity";
|
||||||
|
import { Wagon } from "../modules/wagons/entities/wagon.entity";
|
||||||
|
import { WagonReadiness, WagonStatus } from "@edr/types";
|
||||||
|
|
||||||
const SEED_FLAG = "SEED_DEMO_BOOKINGS";
|
const SEED_FLAG = "SEED_DEMO_BOOKINGS";
|
||||||
|
|
||||||
@@ -144,6 +150,39 @@ const DEMO_BOOKINGS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const DEMO_BULK_BOOKINGS = [
|
||||||
|
{
|
||||||
|
reference: "BKG-BULK-001",
|
||||||
|
cargoCode: "COFFEE",
|
||||||
|
totalWeightTons: 1200,
|
||||||
|
originCode: "DJIBOUTI",
|
||||||
|
destinationCode: "ADDIS_ABABA",
|
||||||
|
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||||
|
status: "PAID",
|
||||||
|
paymentStatus: "PAID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: "BKG-BULK-002",
|
||||||
|
cargoCode: "FERTILIZER",
|
||||||
|
totalWeightTons: 800,
|
||||||
|
originCode: "DJIBOUTI",
|
||||||
|
destinationCode: "ADDIS_ABABA",
|
||||||
|
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||||
|
status: "PAID",
|
||||||
|
paymentStatus: "PAID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: "BKG-BULK-003",
|
||||||
|
cargoCode: "STEEL",
|
||||||
|
totalWeightTons: 450,
|
||||||
|
originCode: "ADDIS_ABABA",
|
||||||
|
destinationCode: "DIRE_DAWA",
|
||||||
|
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||||
|
status: "PAID",
|
||||||
|
paymentStatus: "PAID",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DemoBookingsSeeder {
|
export class DemoBookingsSeeder {
|
||||||
private readonly logger = new Logger(DemoBookingsSeeder.name);
|
private readonly logger = new Logger(DemoBookingsSeeder.name);
|
||||||
@@ -161,15 +200,57 @@ export class DemoBookingsSeeder {
|
|||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await manager.getRepository(WagonType).upsert(
|
await manager.getRepository(WagonType).upsert(
|
||||||
{
|
[
|
||||||
code: "NW5",
|
{
|
||||||
name: "Flat Wagon",
|
code: "NW5",
|
||||||
capacityTons: 70,
|
name: "Flat Wagon",
|
||||||
lengthMeters: 14,
|
capacityTons: 70,
|
||||||
maxWagonsPerTrain: 53,
|
lengthMeters: 14,
|
||||||
supportedLoadTypes: ["CONTAINER"],
|
maxWagonsPerTrain: 53,
|
||||||
isActive: true,
|
supportedLoadTypes: ["CONTAINER"],
|
||||||
},
|
isActive: true,
|
||||||
|
equatedLengthM: 14,
|
||||||
|
tareWeightTons: 20,
|
||||||
|
supportsContainer: true,
|
||||||
|
maxContainerGrossT: 70,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "KW2",
|
||||||
|
name: "Covered Hopper",
|
||||||
|
capacityTons: 60,
|
||||||
|
lengthMeters: 12,
|
||||||
|
maxWagonsPerTrain: 55,
|
||||||
|
supportedLoadTypes: ["BULK"],
|
||||||
|
isActive: true,
|
||||||
|
equatedLengthM: 12,
|
||||||
|
tareWeightTons: 18,
|
||||||
|
supportsContainer: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "PW2",
|
||||||
|
name: "Powder Wagon",
|
||||||
|
capacityTons: 55,
|
||||||
|
lengthMeters: 12,
|
||||||
|
maxWagonsPerTrain: 55,
|
||||||
|
supportedLoadTypes: ["BULK"],
|
||||||
|
isActive: true,
|
||||||
|
equatedLengthM: 12,
|
||||||
|
tareWeightTons: 17,
|
||||||
|
supportsContainer: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "CW3",
|
||||||
|
name: "Open Wagon",
|
||||||
|
capacityTons: 65,
|
||||||
|
lengthMeters: 13,
|
||||||
|
maxWagonsPerTrain: 53,
|
||||||
|
supportedLoadTypes: ["BULK"],
|
||||||
|
isActive: true,
|
||||||
|
equatedLengthM: 13,
|
||||||
|
tareWeightTons: 19,
|
||||||
|
supportsContainer: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
{ conflictPaths: { code: true } },
|
{ conflictPaths: { code: true } },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -320,6 +401,9 @@ export class DemoBookingsSeeder {
|
|||||||
await manager
|
await manager
|
||||||
.getRepository(BookingContainer)
|
.getRepository(BookingContainer)
|
||||||
.delete({ bookingId: booking.id });
|
.delete({ bookingId: booking.id });
|
||||||
|
const wagonsRequired =
|
||||||
|
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1);
|
||||||
|
|
||||||
await manager.getRepository(BookingContainer).insert({
|
await manager.getRepository(BookingContainer).insert({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
@@ -327,15 +411,139 @@ export class DemoBookingsSeeder {
|
|||||||
quantity: demoBooking.quantity,
|
quantity: demoBooking.quantity,
|
||||||
vgmPerUnitTons,
|
vgmPerUnitTons,
|
||||||
totalVgmTons: demoBooking.totalWeightTons,
|
totalVgmTons: demoBooking.totalWeightTons,
|
||||||
wagonsRequired: Math.ceil(demoBooking.totalWeightTons / 70),
|
wagonsRequired,
|
||||||
weightLimitRuleId: null,
|
weightLimitRuleId: null,
|
||||||
isOverweight: demoBooking.totalWeightTons > 70,
|
isOverweight: vgmPerUnitTons > 35,
|
||||||
overweightExcessTons:
|
overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null,
|
||||||
demoBooking.totalWeightTons > 70
|
|
||||||
? demoBooking.totalWeightTons - 70
|
|
||||||
: null,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await manager.getRepository(CargoType).upsert(
|
||||||
|
[
|
||||||
|
{ code: "COFFEE", cargoTypeName: "Coffee", isActive: true, displayOrder: 1 },
|
||||||
|
{ code: "FERTILIZER", cargoTypeName: "Fertilizer", isActive: true, displayOrder: 2 },
|
||||||
|
{ code: "STEEL", cargoTypeName: "Steel", isActive: true, displayOrder: 3 },
|
||||||
|
],
|
||||||
|
{ conflictPaths: { code: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const cargoTypes = await manager.getRepository(CargoType).find();
|
||||||
|
const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c]));
|
||||||
|
|
||||||
|
for (const demoBulk of DEMO_BULK_BOOKINGS) {
|
||||||
|
const origin = yardByCode.get(demoBulk.originCode);
|
||||||
|
const destination = yardByCode.get(demoBulk.destinationCode);
|
||||||
|
const cargoType = cargoByCode.get(demoBulk.cargoCode);
|
||||||
|
|
||||||
|
if (!origin || !destination || !cargoType) {
|
||||||
|
throw new Error(`demo_bulk_seed_dependency_missing:${demoBulk.reference}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.getRepository(Booking).upsert(
|
||||||
|
{
|
||||||
|
reference: demoBulk.reference,
|
||||||
|
companyId: company.id,
|
||||||
|
status: demoBulk.status,
|
||||||
|
scheduledDate: new Date(demoBulk.scheduledDate),
|
||||||
|
totalAmount: 0,
|
||||||
|
paymentStatus: demoBulk.paymentStatus,
|
||||||
|
contractType: "NEW",
|
||||||
|
serviceTypeId: serviceType.id,
|
||||||
|
equipmentReturn: "WITHOUT_RETURN",
|
||||||
|
originYardId: origin.id,
|
||||||
|
destinationYardId: destination.id,
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
freightType: "BULK",
|
||||||
|
cargoTypeId: cargoType.id,
|
||||||
|
cargoFreeText: demoBulk.cargoCode,
|
||||||
|
shippingLineId: null,
|
||||||
|
cargoTotalWeightVgm: demoBulk.totalWeightTons,
|
||||||
|
isHazardous: false,
|
||||||
|
paymentCurrency: "USD",
|
||||||
|
allowConsolidation: false,
|
||||||
|
priorityScore: 10,
|
||||||
|
schedulingStatus: "HOLDING",
|
||||||
|
versionNumber: 1,
|
||||||
|
},
|
||||||
|
{ conflictPaths: { reference: true } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const djibouti = yardByCode.get("DJIBOUTI");
|
||||||
|
const addis = yardByCode.get("ADDIS_ABABA");
|
||||||
|
if (djibouti && addis) {
|
||||||
|
const routeName = "Djibouti → Addis Ababa";
|
||||||
|
let route = await manager.getRepository(Route).findOneBy({ name: routeName });
|
||||||
|
if (!route) {
|
||||||
|
route = await manager.getRepository(Route).save(
|
||||||
|
manager.getRepository(Route).create({
|
||||||
|
name: routeName,
|
||||||
|
originYardId: djibouti.id,
|
||||||
|
destinationYardId: addis.id,
|
||||||
|
isActive: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await manager.getRepository(RouteMilestone).save([
|
||||||
|
manager.getRepository(RouteMilestone).create({
|
||||||
|
routeId: route.id,
|
||||||
|
yardId: djibouti.id,
|
||||||
|
sequenceNo: 1,
|
||||||
|
}),
|
||||||
|
manager.getRepository(RouteMilestone).create({
|
||||||
|
routeId: route.id,
|
||||||
|
yardId: addis.id,
|
||||||
|
sequenceNo: 2,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nw5 = await manager.getRepository(WagonType).findOneBy({ code: "NW5" });
|
||||||
|
if (nw5) {
|
||||||
|
await manager.getRepository(Wagon).upsert(
|
||||||
|
Array.from({ length: 20 }, (_, index) => ({
|
||||||
|
wagonNumber: `WGN-DEMO-${String(index + 1).padStart(3, "0")}`,
|
||||||
|
wagonTypeId: nw5.id,
|
||||||
|
trainId: null,
|
||||||
|
sequenceNumber: null,
|
||||||
|
tareWeight: 20,
|
||||||
|
maxPayloadWeight: 70,
|
||||||
|
status: WagonStatus.Available,
|
||||||
|
readiness:
|
||||||
|
index % 2 === 0
|
||||||
|
? WagonReadiness.ImportReady
|
||||||
|
: WagonReadiness.ExportReady,
|
||||||
|
notes: "Demo wagon for train scheduling",
|
||||||
|
trainSetWagonId: null,
|
||||||
|
currentTrainScheduleId: null,
|
||||||
|
})),
|
||||||
|
{ conflictPaths: { wagonNumber: true } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ft20 = containerTypeByCode.get("20FT");
|
||||||
|
const ft40 = containerTypeByCode.get("40FT");
|
||||||
|
if (ft20 && ft40) {
|
||||||
|
await manager.getRepository(Container).upsert(
|
||||||
|
Array.from({ length: 30 }, (_, index) => {
|
||||||
|
const is40Ft = index % 2 === 0;
|
||||||
|
return {
|
||||||
|
containerNumber: `CONT-DEMO-${String(index + 1).padStart(3, "0")}`,
|
||||||
|
containerTypeId: is40Ft ? ft40.id : ft20.id,
|
||||||
|
wagonId: null,
|
||||||
|
position: null,
|
||||||
|
tareWeight: is40Ft ? 4.0 : 2.5,
|
||||||
|
maxGrossWeight: is40Ft ? 32.5 : 24.5,
|
||||||
|
sealNumber: null,
|
||||||
|
status: "AVAILABLE",
|
||||||
|
bookingId: null,
|
||||||
|
wagonBookingAllocationId: null,
|
||||||
|
bookingContainerId: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
{ conflictPaths: { containerNumber: true } },
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log("Seeded demo train scheduling data");
|
this.logger.log("Seeded demo train scheduling data");
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'),
|
perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'),
|
||||||
perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'),
|
perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'),
|
||||||
perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'),
|
perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'),
|
||||||
|
perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'),
|
||||||
|
perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'),
|
||||||
];
|
];
|
||||||
|
|
||||||
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
|
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
|
||||||
@@ -103,6 +105,10 @@ export const FREIGHT_PERMS = {
|
|||||||
operations: 'edr_freight_app:bookings:operations',
|
operations: 'edr_freight_app:bookings:operations',
|
||||||
cancel: 'edr_freight_app:bookings:cancel',
|
cancel: 'edr_freight_app:bookings:cancel',
|
||||||
},
|
},
|
||||||
|
trainScheduling: {
|
||||||
|
view: 'edr_freight_app:train_scheduling:view',
|
||||||
|
manage: 'edr_freight_app:train_scheduling:manage',
|
||||||
|
},
|
||||||
ruleEngine: {
|
ruleEngine: {
|
||||||
view: (slug: RuleEngineResourceSlug) =>
|
view: (slug: RuleEngineResourceSlug) =>
|
||||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`,
|
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`,
|
||||||
@@ -123,6 +129,8 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||||
FREIGHT_PERMS.bookings.rejectApproval,
|
FREIGHT_PERMS.bookings.rejectApproval,
|
||||||
FREIGHT_PERMS.bookings.cancel,
|
FREIGHT_PERMS.bookings.cancel,
|
||||||
|
FREIGHT_PERMS.trainScheduling.view,
|
||||||
|
FREIGHT_PERMS.trainScheduling.manage,
|
||||||
...allRuleEngineViewKeys(),
|
...allRuleEngineViewKeys(),
|
||||||
],
|
],
|
||||||
director: [
|
director: [
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export class PricingDataSeeder {
|
|||||||
code: "20FT",
|
code: "20FT",
|
||||||
label: "20FT Standard",
|
label: "20FT Standard",
|
||||||
sizeFt: 20,
|
sizeFt: 20,
|
||||||
wagonsPerUnit: 1,
|
wagonsPerUnit: 0.5,
|
||||||
isReefer: false,
|
isReefer: false,
|
||||||
isOpenTop: false,
|
isOpenTop: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -135,7 +135,7 @@ export class PricingDataSeeder {
|
|||||||
code: "20FT_REEFER",
|
code: "20FT_REEFER",
|
||||||
label: "20FT Reefer",
|
label: "20FT Reefer",
|
||||||
sizeFt: 20,
|
sizeFt: 20,
|
||||||
wagonsPerUnit: 1,
|
wagonsPerUnit: 0.5,
|
||||||
isReefer: true,
|
isReefer: true,
|
||||||
isOpenTop: false,
|
isOpenTop: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -323,7 +323,11 @@ export class PricingDataSeeder {
|
|||||||
|
|
||||||
private async seedPriorityRules(prRepo: any): Promise<void> {
|
private async seedPriorityRules(prRepo: any): Promise<void> {
|
||||||
const existing = await prRepo.find({
|
const existing = await prRepo.find({
|
||||||
where: [{ code: "USD_PRIORITY" }, { code: "STANDARD_PRIORITY" }],
|
where: [
|
||||||
|
{ code: "USD_PRIORITY" },
|
||||||
|
{ code: "STANDARD_PRIORITY" },
|
||||||
|
{ code: "GOVERNMENT_ACCOUNT" },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
for (const r of existing) {
|
for (const r of existing) {
|
||||||
await prRepo.remove(r);
|
await prRepo.remove(r);
|
||||||
@@ -343,6 +347,13 @@ export class PricingDataSeeder {
|
|||||||
conditionCurrency: null,
|
conditionCurrency: null,
|
||||||
isActive: true,
|
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 rules");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import LoginPage from "./pages/auth/LoginPage";
|
|||||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||||
|
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||||
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
||||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||||
@@ -35,13 +36,10 @@ import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
|||||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||||
import TrainsPage from "./pages/trains/TrainsPage";
|
import TrainsPage from "./pages/trains/TrainsPage";
|
||||||
import {
|
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
||||||
CargoesCrudPage,
|
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
||||||
ContainersCrudPage,
|
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||||
LocomotivesCrudPage,
|
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||||
TrainMasterDataPage,
|
|
||||||
WagonsCrudPage,
|
|
||||||
} from "./pages/fleet/FleetCrudPages";
|
|
||||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||||
@@ -72,6 +70,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
href: "/dashboard/operations/train-scheduling",
|
href: "/dashboard/operations/train-scheduling",
|
||||||
icon: <Train />,
|
icon: <Train />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Train Schedules v2",
|
||||||
|
href: "/dashboard/operations/train-scheduling-v2",
|
||||||
|
icon: <Train />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -159,7 +162,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Configuration",
|
label: "Configuration",
|
||||||
href: "/dashboard/configuration",
|
href: "/dashboard/configuration",
|
||||||
icon: <Boxes />,
|
icon: <Boxes />,
|
||||||
children: getCategorySidebarChildren("configuration"),
|
children: [
|
||||||
|
...getCategorySidebarChildren("configuration"),
|
||||||
|
{
|
||||||
|
label: "Train scheduling rules",
|
||||||
|
href: "/dashboard/configuration/train-scheduling-rules",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Rules",
|
label: "Rules",
|
||||||
@@ -235,21 +244,28 @@ const App = () => {
|
|||||||
<Route path="overview" element={<OverviewPage />} />
|
<Route path="overview" element={<OverviewPage />} />
|
||||||
|
|
||||||
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
||||||
|
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
||||||
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="booking-requests/:id/contract"
|
path="booking-requests/:id/contract"
|
||||||
element={<BookingContractPage />}
|
element={<BookingContractPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||||
<Route path="trains" element={<TrainMasterDataPage />} />
|
<Route
|
||||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
path="operations/train-scheduling-v2"
|
||||||
|
element={<TrainScheduleV2ListPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="operations/train-scheduling-v2/:scheduleId"
|
||||||
|
element={<TrainScheduleV2DetailPage />}
|
||||||
|
/>
|
||||||
<Route path="routes" element={<RoutesPage />} />
|
<Route path="routes" element={<RoutesPage />} />
|
||||||
<Route path="locomotives" element={<LocomotivesCrudPage />} />
|
<Route path="locomotives" element={<FleetResourcePage />} />
|
||||||
<Route path="trains" element={<TrainMasterDataPage />} />
|
<Route path="trains" element={<FleetResourcePage />} />
|
||||||
<Route path="trains/:id" element={<TrainDetailPage />} />
|
<Route path="trains/:id" element={<TrainDetailPage />} />
|
||||||
<Route path="wagons" element={<WagonsCrudPage />} />
|
<Route path="wagons" element={<FleetResourcePage />} />
|
||||||
<Route path="containers" element={<ContainersCrudPage />} />
|
<Route path="containers" element={<FleetResourcePage />} />
|
||||||
<Route path="cargoes" element={<CargoesCrudPage />} />
|
<Route path="cargoes" element={<FleetResourcePage />} />
|
||||||
|
|
||||||
<Route path="user-management" element={<UserManagementPage />} />
|
<Route path="user-management" element={<UserManagementPage />} />
|
||||||
<Route path="user-management/users" element={<UsersPage />} />
|
<Route path="user-management/users" element={<UsersPage />} />
|
||||||
@@ -265,6 +281,10 @@ const App = () => {
|
|||||||
path="configuration"
|
path="configuration"
|
||||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="configuration/train-scheduling-rules"
|
||||||
|
element={<TrainSchedulingGlobalRulesPage />}
|
||||||
|
/>
|
||||||
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useBookingActionDialog } from "./useBookingActionDialog";
|
|||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import {
|
import {
|
||||||
getNextPendingApprovalStep,
|
getNextPendingApprovalStep,
|
||||||
|
isAllocateAction,
|
||||||
isContractNavAction,
|
isContractNavAction,
|
||||||
listRowHasActions,
|
listRowHasActions,
|
||||||
type BookingActionContext,
|
type BookingActionContext,
|
||||||
@@ -20,12 +21,14 @@ interface BookingActionsMenuProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/** Suppresses table row navigation after menu/dialog close (click-through). */
|
/** Suppresses table row navigation after menu/dialog close (click-through). */
|
||||||
onSuppressRowClick?: () => void;
|
onSuppressRowClick?: () => void;
|
||||||
|
onAllocateBooking?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BookingActionsMenu({
|
export function BookingActionsMenu({
|
||||||
row,
|
row,
|
||||||
variant = "table",
|
variant = "table",
|
||||||
onSuppressRowClick,
|
onSuppressRowClick,
|
||||||
|
onAllocateBooking,
|
||||||
}: BookingActionsMenuProps) {
|
}: BookingActionsMenuProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -34,6 +37,7 @@ export function BookingActionsMenu({
|
|||||||
paymentCurrency: row.paymentCurrency,
|
paymentCurrency: row.paymentCurrency,
|
||||||
reference: row.reference,
|
reference: row.reference,
|
||||||
approvalSteps: row.approvalSteps,
|
approvalSteps: row.approvalSteps,
|
||||||
|
schedulingStatus: row.schedulingStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
const flow = useBookingActionDialog(row.id, context);
|
const flow = useBookingActionDialog(row.id, context);
|
||||||
@@ -46,6 +50,8 @@ export function BookingActionsMenu({
|
|||||||
onSuppressRowClick?.();
|
onSuppressRowClick?.();
|
||||||
if (isContractNavAction(action.id)) {
|
if (isContractNavAction(action.id)) {
|
||||||
goToContract();
|
goToContract();
|
||||||
|
} else if (isAllocateAction(action.id)) {
|
||||||
|
onAllocateBooking?.();
|
||||||
} else {
|
} else {
|
||||||
flow.openAction(action);
|
flow.openAction(action);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Download, Zap, FileText, Clock } from "lucide-react";
|
import { Download, Zap, FileText, Clock } from "lucide-react";
|
||||||
import { Stack, Text, Button } from "@mantine/core";
|
import { Stack, Text, Button } from "@mantine/core";
|
||||||
|
|
||||||
|
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import { BookingActionsMenu } from "./BookingActionsMenu";
|
import { BookingActionsMenu } from "./BookingActionsMenu";
|
||||||
import { SectionCard } from "./detail/SectionCard";
|
import { SectionCard } from "./detail/SectionCard";
|
||||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||||
|
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
|
||||||
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||||
|
|
||||||
type Mutations = ReturnType<typeof useBookingMutations>;
|
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||||
@@ -18,6 +21,7 @@ interface BookingActionsToolbarProps {
|
|||||||
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
|
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
|
||||||
const row = toBookingListRow(booking);
|
const row = toBookingListRow(booking);
|
||||||
const { status } = booking;
|
const { status } = booking;
|
||||||
|
const [allocateOpen, setAllocateOpen] = useState(false);
|
||||||
|
|
||||||
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
|
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
|
||||||
const blob = await fn();
|
const blob = await fn();
|
||||||
@@ -98,7 +102,11 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
|
|||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Confirm each step before it is applied.
|
Confirm each step before it is applied.
|
||||||
</Text>
|
</Text>
|
||||||
<BookingActionsMenu row={row} variant="toolbar" />
|
<BookingActionsMenu
|
||||||
|
row={row}
|
||||||
|
variant="toolbar"
|
||||||
|
onAllocateBooking={() => setAllocateOpen(true)}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
@@ -118,6 +126,14 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
|
|||||||
</Button>
|
</Button>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canAllocateBooking(booking) ? (
|
||||||
|
<AllocateBookingWizard
|
||||||
|
booking={booking}
|
||||||
|
opened={allocateOpen}
|
||||||
|
onClose={() => setAllocateOpen(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { ArrowRight, Building2, Package } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
|
||||||
|
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||||
|
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
|
||||||
|
import type { BookingListRow } from "@/types/booking";
|
||||||
|
import { groupBookingsForOperationsQueue } from "@/utils/groupBookingsForOperationsQueue";
|
||||||
|
|
||||||
|
function BookingQueueRow({
|
||||||
|
booking,
|
||||||
|
selected,
|
||||||
|
disabled,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
booking: BookingListRow;
|
||||||
|
selected: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
align="flex-start"
|
||||||
|
wrap="nowrap"
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--mantine-color-gray-3)",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox checked={selected} disabled={disabled} onChange={onToggle} mt={4} />
|
||||||
|
<Stack gap={4} style={{ flex: 1 }}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Package size={14} />
|
||||||
|
<Text fw={600} size="sm">{booking.reference}</Text>
|
||||||
|
{booking.isGovernment ? (
|
||||||
|
<Badge color="violet" size="xs" leftSection={<Building2 size={10} />}>
|
||||||
|
Government
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
<Badge variant="outline" size="xs">{booking.freightType}</Badge>
|
||||||
|
{booking.schedulingStatus ? (
|
||||||
|
<Badge variant="light" size="xs">{booking.schedulingStatus}</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">{booking.customerLabel}</Text>
|
||||||
|
<Group gap={6}>
|
||||||
|
<Text size="xs">{booking.originLabel}</Text>
|
||||||
|
<ArrowRight size={12} />
|
||||||
|
<Text size="xs">{booking.destinationLabel}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap="sm">
|
||||||
|
<BookingPriorityBadge score={booking.priorityScore} />
|
||||||
|
{booking.serviceTypeLabel ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{booking.serviceTypeLabel}
|
||||||
|
{booking.serviceTypeBonus ? ` (+${booking.serviceTypeBonus} bonus)` : ""}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OperationsBookingQueue({
|
||||||
|
bookings,
|
||||||
|
isLoading,
|
||||||
|
onAllocate,
|
||||||
|
}: {
|
||||||
|
bookings: BookingListRow[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
onAllocate: (bookingIds: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const { government, commercial } = useMemo(
|
||||||
|
() => groupBookingsForOperationsQueue(bookings),
|
||||||
|
[bookings],
|
||||||
|
);
|
||||||
|
const [govSelected, setGovSelected] = useState<string[]>([]);
|
||||||
|
const [selectedByBucket, setSelectedByBucket] = useState<Record<string, string[]>>({});
|
||||||
|
|
||||||
|
const allocatable = (row: BookingListRow) =>
|
||||||
|
row.status === "PAID" &&
|
||||||
|
canAllocateBooking({ status: row.status, schedulingStatus: row.schedulingStatus });
|
||||||
|
|
||||||
|
const govSelection = govSelected.length
|
||||||
|
? govSelected
|
||||||
|
: government.filter(allocatable).map((b) => b.id);
|
||||||
|
|
||||||
|
const bucketSelection = (bucketKey: string, bucketBookings: BookingListRow[]) => {
|
||||||
|
const existing = selectedByBucket[bucketKey];
|
||||||
|
if (existing) return existing;
|
||||||
|
return bucketBookings.filter(allocatable).map((b) => b.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleGov = (bookingId: string) => {
|
||||||
|
setGovSelected((prev) => {
|
||||||
|
const base = prev.length ? prev : government.filter(allocatable).map((b) => b.id);
|
||||||
|
return base.includes(bookingId)
|
||||||
|
? base.filter((id) => id !== bookingId)
|
||||||
|
: [...base, bookingId];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleBucket = (bucketKey: string, bookingId: string) => {
|
||||||
|
setSelectedByBucket((prev) => {
|
||||||
|
const current = prev[bucketKey] ?? [];
|
||||||
|
const next = current.includes(bookingId)
|
||||||
|
? current.filter((id) => id !== bookingId)
|
||||||
|
: [...current, bookingId];
|
||||||
|
return { ...prev, [bucketKey]: next };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Text size="sm" c="dimmed">Loading operations queue…</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!government.length && !commercial.length) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No PAID bookings ready to allocate.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
{government.length > 0 ? (
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Title order={5}>Government priority</Title>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Served first — not grouped by 3-hour window
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge variant="light">{govSelection.length} selected</Badge>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="violet"
|
||||||
|
disabled={!govSelection.length}
|
||||||
|
onClick={() => onAllocate(govSelection)}
|
||||||
|
>
|
||||||
|
Allocate
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{government.map((booking) => (
|
||||||
|
<BookingQueueRow
|
||||||
|
key={booking.id}
|
||||||
|
booking={booking}
|
||||||
|
selected={govSelection.includes(booking.id)}
|
||||||
|
disabled={!allocatable(booking)}
|
||||||
|
onToggle={() => toggleGov(booking.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{commercial.length > 0 ? (
|
||||||
|
<Accordion defaultValue={commercial[0]?.key} variant="separated" radius="md">
|
||||||
|
{commercial.map((bucket) => {
|
||||||
|
const selected = bucketSelection(bucket.key, bucket.bookings);
|
||||||
|
return (
|
||||||
|
<Accordion.Item key={bucket.key} value={bucket.key}>
|
||||||
|
<Accordion.Control>
|
||||||
|
<Group justify="space-between" wrap="nowrap" pr="md">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text fw={600} size="sm">{bucket.label}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{bucket.bookings.length} commercial booking
|
||||||
|
{bucket.bookings.length === 1 ? "" : "s"}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge variant="light">{selected.length} selected</Badge>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="green"
|
||||||
|
disabled={!selected.length}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAllocate(selected);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Allocate
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{bucket.bookings.map((booking) => (
|
||||||
|
<BookingQueueRow
|
||||||
|
key={booking.id}
|
||||||
|
booking={booking}
|
||||||
|
selected={selected.includes(booking.id)}
|
||||||
|
disabled={!allocatable(booking)}
|
||||||
|
onToggle={() => toggleBucket(bucket.key, booking.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Accordion>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { ArrowRight, ExternalLink } from "lucide-react";
|
||||||
|
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||||
|
import type { BookingListRow } from "@/types/booking";
|
||||||
|
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||||
|
|
||||||
|
export function OperationsScheduledBookings({
|
||||||
|
bookings,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
bookings: BookingListRow[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
}) {
|
||||||
|
const columns: ColumnDef<BookingListRow>[] = [
|
||||||
|
{
|
||||||
|
id: "reference",
|
||||||
|
header: "Booking",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Group gap={6}>
|
||||||
|
<Text fw={600} size="sm">{row.original.reference}</Text>
|
||||||
|
{row.original.isGovernment ? (
|
||||||
|
<Badge color="violet" size="xs">Government</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">{row.original.customerLabel}</Text>
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "route",
|
||||||
|
header: "Route",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Group gap={6}>
|
||||||
|
<Text size="sm">{row.original.originLabel}</Text>
|
||||||
|
<ArrowRight size={12} />
|
||||||
|
<Text size="sm">{row.original.destinationLabel}</Text>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scheduled",
|
||||||
|
header: "Scheduled",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm">{String(row.original.scheduledDate).slice(0, 16)}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Scheduling",
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.schedulingStatus ? (
|
||||||
|
<SchedulingStatusBadge status={row.original.schedulingStatus} />
|
||||||
|
) : (
|
||||||
|
<Badge variant="light">—</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to={`/dashboard/booking-requests/${row.original.id}`}
|
||||||
|
variant="light"
|
||||||
|
size="compact-sm"
|
||||||
|
>
|
||||||
|
View booking
|
||||||
|
</Button>
|
||||||
|
{row.original.trainScheduleId ? (
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to={`/dashboard/operations/train-scheduling-v2/${row.original.trainScheduleId}`}
|
||||||
|
variant="subtle"
|
||||||
|
size="compact-sm"
|
||||||
|
leftSection={<ExternalLink size={14} />}
|
||||||
|
>
|
||||||
|
Train schedule
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={bookings}
|
||||||
|
status={isLoading ? "loading" : "success"}
|
||||||
|
emptyMessage="No bookings currently assigned to a train schedule"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Paper, Group, Stack, Title, Text, Button, Box } from "@mantine/core";
|
|||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||||
|
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||||
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
||||||
|
|
||||||
import { detailStyles, formatDate } from "./booking-detail.styles";
|
import { detailStyles, formatDate } from "./booking-detail.styles";
|
||||||
@@ -52,7 +53,15 @@ export function BookingRequestHero({
|
|||||||
</Title>
|
</Title>
|
||||||
<BookingStatusBadge status={booking.status} />
|
<BookingStatusBadge status={booking.status} />
|
||||||
<BookingPriorityBadge score={booking.priorityScore} />
|
<BookingPriorityBadge score={booking.priorityScore} />
|
||||||
|
{booking.schedulingStatus ? (
|
||||||
|
<SchedulingStatusBadge status={booking.schedulingStatus} />
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
|
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
|
||||||
|
<Text size="xs" c="yellow.8">
|
||||||
|
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{booking.nextStep && (
|
{booking.nextStep && (
|
||||||
<Box maw={520}>
|
<Box maw={520}>
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from '@edr/ui-common';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { useContainers, useAssignContainerToWagon } from './use-containers';
|
|
||||||
import { useToast } from '@/hooks/use-toast';
|
|
||||||
import { Plus } from 'lucide-react';
|
|
||||||
|
|
||||||
export function AssignContainerDialog({ wagonId }: { wagonId: string }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [containerId, setContainerId] = useState('');
|
|
||||||
const [position, setPosition] = useState<number>();
|
|
||||||
const { data: containers } = useContainers();
|
|
||||||
const assign = useAssignContainerToWagon();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const available = containers?.filter(c => c.status === 'AVAILABLE' && !c.wagonId);
|
|
||||||
|
|
||||||
const handleAssign = async () => {
|
|
||||||
if (!containerId) return;
|
|
||||||
await assign.mutateAsync({ containerId, wagonId, position });
|
|
||||||
toast({ title: 'Assigned', description: 'Container placed on wagon.' });
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild><Button size="sm"><Plus className="mr-2 h-4 w-4" />Assign Container</Button></DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader><DialogTitle>Assign Container to Wagon</DialogTitle></DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div><Label>Container</Label><Select value={containerId} onValueChange={setContainerId}><SelectTrigger><SelectValue placeholder="Select container" /></SelectTrigger><SelectContent>{available?.map(c => <SelectItem key={c.id} value={c.id}>{c.containerNumber}</SelectItem>)}</SelectContent></Select></div>
|
|
||||||
<div><Label>Position (optional)</Label><Input type="number" value={position ?? ''} onChange={e => setPosition(parseInt(e.target.value) || undefined)} /></div>
|
|
||||||
<Button onClick={handleAssign} disabled={assign.isPending}>Assign</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from '@edr/ui-common';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { useCargoTypes } from './use-cargo-types';
|
|
||||||
import { useCargoMutations } from './use-cargoes';
|
|
||||||
import { Loader2 } from 'lucide-react';
|
|
||||||
|
|
||||||
interface Cargo {
|
|
||||||
id: string;
|
|
||||||
cargoNumber: string;
|
|
||||||
cargoTypeId: string;
|
|
||||||
weight: number;
|
|
||||||
remarks?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CargoFormDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
cargo?: Cargo | null;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CargoFormDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
cargo,
|
|
||||||
onSuccess,
|
|
||||||
}: CargoFormDialogProps) {
|
|
||||||
const { data: cargoTypes } = useCargoTypes();
|
|
||||||
const { createCargo, updateCargo } = useCargoMutations();
|
|
||||||
const [formData, setFormData] = useState<Partial<Cargo>>({
|
|
||||||
cargoNumber: '',
|
|
||||||
cargoTypeId: '',
|
|
||||||
weight: 0,
|
|
||||||
remarks: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (cargo) {
|
|
||||||
setFormData(cargo);
|
|
||||||
} else {
|
|
||||||
setFormData({
|
|
||||||
cargoNumber: '',
|
|
||||||
cargoTypeId: '',
|
|
||||||
weight: 0,
|
|
||||||
remarks: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [cargo, open]);
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (cargo?.id) {
|
|
||||||
updateCargo.mutate(
|
|
||||||
{ id: cargo.id, data: formData },
|
|
||||||
{ onSuccess: () => { onOpenChange(false); onSuccess?.(); } }
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
createCargo.mutate(formData, {
|
|
||||||
onSuccess: () => { onOpenChange(false); onSuccess?.(); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isLoading = createCargo.isPending || updateCargo.isPending;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
|
||||||
<DialogHeader><DialogTitle>{cargo ? 'Edit Cargo' : 'Create New Cargo'}</DialogTitle></DialogHeader>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label>Cargo Number *</Label>
|
|
||||||
<Input value={formData.cargoNumber} onChange={e => setFormData({...formData, cargoNumber: e.target.value})} required />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>Cargo Type *</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.cargoTypeId || ''}
|
|
||||||
onValueChange={(val) => setFormData({ ...formData, cargoTypeId: val })}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select cargo type..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{cargoTypes?.map((type: any) => (
|
|
||||||
<SelectItem key={type.id} value={type.id}>{type.cargo_type_name || type.name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>Weight (kg) *</Label>
|
|
||||||
<Input type="number" value={formData.weight} onChange={e => setFormData({...formData, weight: parseFloat(e.target.value)})} required />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>Remarks</Label>
|
|
||||||
<Textarea value={formData.remarks} onChange={e => setFormData({...formData, remarks: e.target.value})} rows={3} />
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="submit" disabled={isLoading}>{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}{cargo ? 'Update' : 'Create'}</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from '@edr/ui-common';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { useContainerTypes } from './use-container-types';
|
|
||||||
import { useContainerMutations } from './use-containers';
|
|
||||||
import { Loader2 } from 'lucide-react';
|
|
||||||
|
|
||||||
interface Container {
|
|
||||||
id: string;
|
|
||||||
containerNumber: string;
|
|
||||||
containerTypeId: string;
|
|
||||||
wagonId?: string;
|
|
||||||
status: 'AVAILABLE' | 'IN_USE' | 'MAINTENANCE' | 'RETIRED';
|
|
||||||
capacity: number;
|
|
||||||
weight: number;
|
|
||||||
remarks?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Wagon {
|
|
||||||
id: string;
|
|
||||||
wagonNumber: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContainerFormDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
container?: Container | null;
|
|
||||||
wagons: Wagon[];
|
|
||||||
onSuccess?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContainerFormDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
container,
|
|
||||||
wagons = [],
|
|
||||||
onSuccess,
|
|
||||||
}: ContainerFormDialogProps) {
|
|
||||||
const { data: containerTypes } = useContainerTypes();
|
|
||||||
const { createContainer, updateContainer } = useContainerMutations();
|
|
||||||
const [formData, setFormData] = useState<Partial<Container>>({
|
|
||||||
containerNumber: '',
|
|
||||||
containerTypeId: '',
|
|
||||||
status: 'AVAILABLE',
|
|
||||||
capacity: 0,
|
|
||||||
weight: 0,
|
|
||||||
remarks: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (container) {
|
|
||||||
setFormData(container);
|
|
||||||
} else {
|
|
||||||
setFormData({
|
|
||||||
containerNumber: '',
|
|
||||||
containerTypeId: '',
|
|
||||||
status: 'AVAILABLE',
|
|
||||||
capacity: 0,
|
|
||||||
weight: 0,
|
|
||||||
remarks: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [container, open]);
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!formData.containerNumber || !formData.containerTypeId) {
|
|
||||||
toast.error('Please fill in all required fields');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (container?.id) {
|
|
||||||
updateContainer.mutate(
|
|
||||||
{ id: container.id, data: formData },
|
|
||||||
{ onSuccess: () => { onOpenChange(false); onSuccess?.(); } }
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
createContainer.mutate(formData, {
|
|
||||||
onSuccess: () => { onOpenChange(false); onSuccess?.(); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isLoading = createContainer.isPending || updateContainer.isPending;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{container ? 'Edit Container' : 'Create New Container'}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="containerNumber">Container Number *</Label>
|
|
||||||
<Input
|
|
||||||
id="containerNumber"
|
|
||||||
value={formData.containerNumber || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, containerNumber: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="e.g., CNT001"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="containerTypeId">Container Type *</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.containerTypeId || ''}
|
|
||||||
onValueChange={(val:any) => setFormData({ ...formData, containerTypeId: val })}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="containerTypeId">
|
|
||||||
<SelectValue placeholder="Select container type..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{containerTypes?.map((type: any) => (
|
|
||||||
<SelectItem key={type.id} value={type.id}>{type.name || type.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="wagonId">Wagon (Optional)</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.wagonId || 'none'}
|
|
||||||
onValueChange={(val:any) => setFormData({ ...formData, wagonId: val === 'none' ? undefined : val })}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="wagonId">
|
|
||||||
<SelectValue placeholder="Select a wagon..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">None</SelectItem>
|
|
||||||
{wagons.map((wagon) => (
|
|
||||||
<SelectItem key={wagon.id} value={wagon.id}>
|
|
||||||
{wagon.wagonNumber}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="status">Status</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.status || 'AVAILABLE'}
|
|
||||||
onValueChange={(val:any) => setFormData({ ...formData, status: val })}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="status">
|
|
||||||
<SelectValue placeholder="Select status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="AVAILABLE">Available</SelectItem>
|
|
||||||
<SelectItem value="IN_USE">In Use</SelectItem>
|
|
||||||
<SelectItem value="MAINTENANCE">Maintenance</SelectItem>
|
|
||||||
<SelectItem value="RETIRED">Retired</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="capacity">Capacity *</Label>
|
|
||||||
<Input
|
|
||||||
id="capacity"
|
|
||||||
type="number"
|
|
||||||
value={formData.capacity || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
capacity: parseFloat(e.target.value) || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="0"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="weight">Weight (kg)</Label>
|
|
||||||
<Input
|
|
||||||
id="weight"
|
|
||||||
type="number"
|
|
||||||
value={formData.weight || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
weight: parseFloat(e.target.value) || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="remarks">Remarks</Label>
|
|
||||||
<Textarea
|
|
||||||
id="remarks"
|
|
||||||
value={formData.remarks || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, remarks: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="Add any additional notes..."
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isLoading}>
|
|
||||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
{container ? 'Update' : 'Create'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { useContainersByWagon, useUnassignContainer } from './use-containers';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Trash2 } from 'lucide-react';
|
|
||||||
import type { Container } from './container.service';
|
|
||||||
|
|
||||||
export function ContainersTable({ wagonId }: { wagonId: string }) {
|
|
||||||
const { data: containers, refetch } = useContainersByWagon(wagonId);
|
|
||||||
const unassign = useUnassignContainer();
|
|
||||||
|
|
||||||
if (!containers?.length) return <div className="text-muted-foreground">No containers assigned.</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<table className="w-full table-fixed">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="text-left">Number</th>
|
|
||||||
<th className="text-left">Type</th>
|
|
||||||
<th className="text-left">Position</th>
|
|
||||||
<th className="text-left">Status</th>
|
|
||||||
<th className="text-left">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{containers.map((container: Container) => (
|
|
||||||
<tr key={container.id}>
|
|
||||||
<td className="py-2">{container.containerNumber}</td>
|
|
||||||
<td className="py-2">{container.containerTypeId}</td>
|
|
||||||
<td className="py-2">{container.position}</td>
|
|
||||||
<td className="py-2">{container.status}</td>
|
|
||||||
<td className="py-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => unassign.mutateAsync(container.id).then(() => refetch())}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { api } from '../../auth/http';
|
|
||||||
|
|
||||||
type ListResponse<T> = T[] | { data: T[] };
|
|
||||||
|
|
||||||
const asList = <T>(payload: ListResponse<T>): T[] =>
|
|
||||||
Array.isArray(payload) ? payload : payload.data;
|
|
||||||
|
|
||||||
export const cargoTypesService = {
|
|
||||||
async getCargoTypes() {
|
|
||||||
const response = await api.get<ListResponse<unknown>>('/cargo-types', {
|
|
||||||
params: { isActive: true, pageSize: 500 },
|
|
||||||
});
|
|
||||||
return asList(response.data);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { api } from "../../auth/http";
|
|
||||||
|
|
||||||
export const cargoService = {
|
|
||||||
async getCargoes() {
|
|
||||||
const response = await api.get('/cargoes');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
async createCargo(data: any) {
|
|
||||||
const response = await api.post('/cargoes', data);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
async updateCargo(id: string, data: any) {
|
|
||||||
const response = await api.patch(`/cargoes/${id}`, data);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
async deleteCargo(id: string) {
|
|
||||||
await api.delete(`/cargoes/${id}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { api } from '../../auth/http';
|
|
||||||
|
|
||||||
type ListResponse<T> = T[] | { data: T[] };
|
|
||||||
|
|
||||||
const asList = <T>(payload: ListResponse<T>): T[] =>
|
|
||||||
Array.isArray(payload) ? payload : payload.data;
|
|
||||||
|
|
||||||
export const containerTypesService = {
|
|
||||||
async getContainerTypes() {
|
|
||||||
const response = await api.get<ListResponse<unknown>>('/container-types', {
|
|
||||||
params: { isActive: true, pageSize: 500 },
|
|
||||||
});
|
|
||||||
return asList(response.data);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { api } from "../../auth/http";
|
|
||||||
|
|
||||||
export const containerService = {
|
|
||||||
async getContainers() {
|
|
||||||
const response = await api.get('/containers');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
async getContainersByWagon(wagonId: string) {
|
|
||||||
const response = await api.get('/containers', { params: { wagonId } });
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
async createContainer(data: any) {
|
|
||||||
const response = await api.post('/containers', data);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
async updateContainer(id: string, data: any) {
|
|
||||||
const response = await api.patch(`/containers/${id}`, data);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
async deleteContainer(id: string) {
|
|
||||||
await api.delete(`/containers/${id}`);
|
|
||||||
},
|
|
||||||
async assignToWagon(containerId: string, wagonId: string, position?: number) {
|
|
||||||
const response = await api.post(`/containers/${containerId}/assign-wagon`, { wagonId, position });
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
async unassignFromWagon(containerId: string) {
|
|
||||||
const response = await api.post(`/containers/${containerId}/unassign-wagon`);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { cargoTypesService } from './cargo-types.service';
|
|
||||||
|
|
||||||
export const CARGO_TYPES_QUERY_KEY = ['cargo-types'];
|
|
||||||
|
|
||||||
export function useCargoTypes() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: CARGO_TYPES_QUERY_KEY,
|
|
||||||
queryFn: () => cargoTypesService.getCargoTypes(),
|
|
||||||
staleTime: Infinity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { cargoService } from './cargo.service';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
export const CARGOES_QUERY_KEY = ['cargoes'];
|
|
||||||
|
|
||||||
export function useCargoes() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: CARGOES_QUERY_KEY,
|
|
||||||
queryFn: () => cargoService.getCargoes(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCargoMutations() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const createCargo = useMutation({
|
|
||||||
mutationFn: (data: any) => cargoService.createCargo(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
|
|
||||||
toast.success('Cargo created successfully');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateCargo = useMutation({
|
|
||||||
mutationFn: ({ id, data }: { id: string; data: any }) => cargoService.updateCargo(id, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
|
|
||||||
toast.success('Cargo updated successfully');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteCargo = useMutation({
|
|
||||||
mutationFn: (id: string) => cargoService.deleteCargo(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
|
|
||||||
toast.success('Cargo deleted successfully');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { createCargo, updateCargo, deleteCargo };
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user