diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index a81a539ba..f40e4f40f 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -59,7 +59,6 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; -import { PaidIndodeDemoBookingsSeeder } from "./seed/paid-indode-demo-bookings.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; import { WagonsModule } from './modules/wagons/wagons.module'; @@ -161,7 +160,6 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera ExportDjiboutiInterchangeDemoSeeder, MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, - PaidIndodeDemoBookingsSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -180,7 +178,6 @@ export class AppModule implements OnApplicationBootstrap { private readonly warehouseDemoSeeder: WarehouseDemoSeeder, private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, - private readonly paidIndodeDemoBookingsSeeder: PaidIndodeDemoBookingsSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, private readonly govCompaniesSeeder: GovCompaniesSeeder, @@ -202,7 +199,6 @@ export class AppModule implements OnApplicationBootstrap { await this.warehouseDemoSeeder.run(); await this.exportDjiboutiInterchangeDemoSeeder.run(); await this.marshallingDemoTrainsSeeder.run(); - await this.paidIndodeDemoBookingsSeeder.run(); // Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users. // Each block self-guards on an empty-table check, so this is safe every boot. // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, diff --git a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts index 65052bad4..08ce634eb 100644 --- a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts +++ b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts @@ -93,20 +93,25 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter ); // Create indexes for service_types - await queryRunner.createIndex( - "freight.service_types", - new TableIndex({ - name: "IDX_SERVICE_TYPES_IS_ACTIVE", - columnNames: ["is_active"], - }), - ); - await queryRunner.createIndex( - "freight.service_types", - new TableIndex({ - name: "IDX_SERVICE_TYPES_DISPLAY_ORDER", - columnNames: ["display_order"], - }), - ); + const table = await queryRunner.getTable("freight.service_types"); + if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_IS_ACTIVE")) { + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_IS_ACTIVE", + columnNames: ["is_active"], + }), + ); + } + if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_DISPLAY_ORDER")) { + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_DISPLAY_ORDER", + columnNames: ["display_order"], + }), + ); + } // Create cargo_types table if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable( diff --git a/apps/edr-freight-api/src/migrations/1861000000000-AddBookingWindowGlobalRules.ts b/apps/edr-freight-api/src/migrations/1861000000000-AddBookingWindowGlobalRules.ts new file mode 100644 index 000000000..a98fcd80d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1861000000000-AddBookingWindowGlobalRules.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddBookingWindowGlobalRules1861000000000 implements MigrationInterface { + name = "AddBookingWindowGlobalRules1861000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN import_window_lead_days integer NOT NULL DEFAULT 3, + ADD COLUMN export_booking_lead_hours integer NOT NULL DEFAULT 24, + ADD COLUMN window_open_hour integer NOT NULL DEFAULT 8, + ADD COLUMN window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3, + ADD COLUMN doc_review_minutes integer NOT NULL DEFAULT 30, + ADD COLUMN payment_window_minutes integer NOT NULL DEFAULT 60, + ADD COLUMN reopen_delay_minutes integer NOT NULL DEFAULT 90; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + DROP COLUMN IF EXISTS import_window_lead_days, + DROP COLUMN IF EXISTS export_booking_lead_hours, + DROP COLUMN IF EXISTS window_open_hour, + DROP COLUMN IF EXISTS window_duration_hours, + DROP COLUMN IF EXISTS doc_review_minutes, + DROP COLUMN IF EXISTS payment_window_minutes, + DROP COLUMN IF EXISTS reopen_delay_minutes; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1861000000001-ReleaseStuckAssignedLocomotives.ts b/apps/edr-freight-api/src/migrations/1861000000001-ReleaseStuckAssignedLocomotives.ts new file mode 100644 index 000000000..0d5155391 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1861000000001-ReleaseStuckAssignedLocomotives.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * arriveSchedule used to release only the primary locomotive of a train set, leaving + * secondary locomotives ASSIGNED forever. Locomotives are now only ASSIGNED while out + * on a dispatched train — release every ASSIGNED locomotive that is not attached to a + * currently-DISPATCHED schedule. + */ +export class ReleaseStuckAssignedLocomotives1861000000001 implements MigrationInterface { + name = "ReleaseStuckAssignedLocomotives1861000000001"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.locomotives l + SET status = 'AVAILABLE' + WHERE l.status = 'ASSIGNED' + AND NOT EXISTS ( + SELECT 1 + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + JOIN ( + SELECT tsl.train_set_id, tsl.locomotive_id + FROM freight.train_set_locomotives tsl + WHERE tsl.deleted_at IS NULL + UNION + SELECT t.id AS train_set_id, t.locomotive_id + FROM freight.train_sets t + WHERE t.locomotive_id IS NOT NULL + ) loco ON loco.train_set_id = tset.id + WHERE ts.status = 'DISPATCHED' + AND ts.deleted_at IS NULL + AND loco.locomotive_id = l.id + ); + `); + } + + public async down(): Promise { + // Data fix — not reversible. + } +} diff --git a/apps/edr-freight-api/src/migrations/1862000000000-AddScheduleWindowPhases.ts b/apps/edr-freight-api/src/migrations/1862000000000-AddScheduleWindowPhases.ts new file mode 100644 index 000000000..cae33a534 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1862000000000-AddScheduleWindowPhases.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddScheduleWindowPhases1862000000000 implements MigrationInterface { + name = "AddScheduleWindowPhases1862000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN window_phase varchar(20) NULL, + ADD COLUMN window_opens_at timestamptz NULL, + ADD COLUMN window_closes_at timestamptz NULL, + ADD COLUMN doc_review_ends_at timestamptz NULL, + ADD COLUMN doc_review_completed_at timestamptz NULL, + ADD COLUMN payment_phase_ends_at timestamptz NULL, + ADD COLUMN booking_cycle_no integer NOT NULL DEFAULT 0; + `); + await queryRunner.query(` + CREATE INDEX idx_train_schedules_window_phase + ON freight.train_schedules (window_phase) + WHERE window_phase IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_window_phase;`); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS window_phase, + DROP COLUMN IF EXISTS window_opens_at, + DROP COLUMN IF EXISTS window_closes_at, + DROP COLUMN IF EXISTS doc_review_ends_at, + DROP COLUMN IF EXISTS doc_review_completed_at, + DROP COLUMN IF EXISTS payment_phase_ends_at, + DROP COLUMN IF EXISTS booking_cycle_no; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1863000000000-CreateBookingBatchOffers.ts b/apps/edr-freight-api/src/migrations/1863000000000-CreateBookingBatchOffers.ts new file mode 100644 index 000000000..779807a57 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1863000000000-CreateBookingBatchOffers.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateBookingBatchOffers1863000000000 implements MigrationInterface { + name = "CreateBookingBatchOffers1863000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE freight.booking_batch_offers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + train_schedule_id uuid NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE, + offered_wagons integer NOT NULL, + total_wagons integer NOT NULL, + offered_lines jsonb NULL, + offered_weight_tons numeric(12, 3) NOT NULL, + offered_amount numeric(14, 2) NOT NULL, + offered_pricing_breakdown jsonb NULL, + invoice_id uuid NULL, + payment_deadline timestamptz NOT NULL, + status varchar(10) NOT NULL DEFAULT 'OFFERED', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ); + `); + await queryRunner.query( + `CREATE INDEX idx_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_batch_offers;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts b/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts new file mode 100644 index 000000000..3434631a2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add location_id column to vehicles table to track vehicle base location. + */ +export class AddLocationToVehicles1870000000000 implements MigrationInterface { + name = "AddLocationToVehicles1870000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS location_id uuid; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS location_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts new file mode 100644 index 000000000..484a2686b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add FREE and BUSY statuses to vehicle status enum. + */ +export class AddVehicleStatuses1880000000000 implements MigrationInterface { + name = "AddVehicleStatuses1880000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // Create enum type if it doesn't exist + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'vehicles_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight')) THEN + CREATE TYPE freight.vehicles_status_enum AS ENUM ('ACTIVE', 'FREE', 'BUSY', 'MAINTENANCE', 'RETIRED', 'OUT_OF_SERVICE'); + ELSE + -- Add values if enum already exists but doesn't have them + ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'FREE' BEFORE 'MAINTENANCE'; + ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'BUSY' AFTER 'FREE'; + END IF; + END $$; + `); + } + + public async down(_queryRunner: QueryRunner): Promise { + // Note: Postgres cannot drop individual enum values, so the down migration is a no-op + // The enum values FREE and BUSY will remain but will be unused after downgrade + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000000-SeparateVehicleAvailability.ts b/apps/edr-freight-api/src/migrations/1890000000000-SeparateVehicleAvailability.ts new file mode 100644 index 000000000..c0015c3c6 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000000-SeparateVehicleAvailability.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Split the mixed vehicle status into two fields: + * - status: operational state (ACTIVE, MAINTENANCE, RETIRED, OUT_OF_SERVICE) + * - availability: assignment state (FREE, BUSY) + * + * Existing FREE/BUSY statuses are moved to availability and the status is + * normalized back to ACTIVE. + */ +export class SeparateVehicleAvailability1890000000000 implements MigrationInterface { + name = "SeparateVehicleAvailability1890000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS availability varchar DEFAULT 'FREE' + `); + await queryRunner.query(` + UPDATE freight.vehicles SET availability = 'BUSY' WHERE status = 'BUSY' + `); + await queryRunner.query(` + UPDATE freight.vehicles SET availability = 'FREE' WHERE availability IS NULL + `); + await queryRunner.query(` + UPDATE freight.vehicles SET status = 'ACTIVE' WHERE status IN ('FREE', 'BUSY') + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Fold availability back into status before dropping the column + await queryRunner.query(` + UPDATE freight.vehicles SET status = availability + WHERE status = 'ACTIVE' AND availability IN ('FREE', 'BUSY') + `); + await queryRunner.query(` + ALTER TABLE freight.vehicles DROP COLUMN IF EXISTS availability + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000001-AddVehicleCodeAndPlates.ts b/apps/edr-freight-api/src/migrations/1890000000001-AddVehicleCodeAndPlates.ts new file mode 100644 index 000000000..6f9faa1f8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000001-AddVehicleCodeAndPlates.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add code, power_plate_no and trailer_plate_no columns to vehicles. + * These fields existed in the DTO and UI form but had no entity columns, + * so submitted values were silently dropped. + */ +export class AddVehicleCodeAndPlates1890000000001 implements MigrationInterface { + name = "AddVehicleCodeAndPlates1890000000001"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS code varchar, + ADD COLUMN IF NOT EXISTS power_plate_no varchar, + ADD COLUMN IF NOT EXISTS trailer_plate_no varchar + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS code, + DROP COLUMN IF EXISTS power_plate_no, + DROP COLUMN IF EXISTS trailer_plate_no + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index 21fef08ea..ddf09dea6 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -138,7 +138,7 @@ export class BookingInvoiceService { ); return; } - if (booking.paymentStatus === "PAID") return; + // if (booking.paymentStatus === "PAID") return; await this.dataSource.transaction(async (mg) => { await mg.update( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 898f78cd5..031e521a9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -991,6 +991,15 @@ export class BookingTransitionService { private async acceptOperationRequest(booking: Booking): Promise { const now = new Date(); + // Export is FCFS: fail the accept up-front (409) when no export train on the + // booking's day still has capacity — nothing below runs and the request stays + // pending for staff to move/decline. + const isExportTrain = + booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType); + const exportScheduleId = isExportTrain + ? await this.bookingBatchService.pickExportSchedule(booking) + : null; + const invoice = await this.invoiceService.ensureInvoiceForBooking(booking); this.logger.log( `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`, @@ -1014,7 +1023,15 @@ export class BookingTransitionService { lockedAt: booking.lockedAt ?? now, } as never); - if (booking.scheduledDate) { + if (exportScheduleId) { + // FCFS: reserve the slot and send the payment notification immediately; + // paid → auto-allocated by the settle/paid pipeline. + const fresh = await this.bookingsService.findById(booking.id); + await this.bookingBatchService.reserveExportBooking(fresh, exportScheduleId); + } else if (booking.tradeDirection === "IMPORT") { + // Import bookings wait for their booking-day window cycle — the batch runs + // after staff document review, never at accept time. + } else if (booking.scheduledDate) { this.bookingBatchService.enqueueRouteDayProcessing( booking.originYardId, booking.destinationYardId, @@ -1029,6 +1046,12 @@ export class BookingTransitionService { latestChangeRequestNote?: string | null; contractSummary?: string | null; nextStep: BookingNextStep | null; + activeBatchOffer?: { + offeredWagons: number; + totalWagons: number; + offeredAmount: number; + paymentDeadline: Date; + } | null; } > { const note = await this.bookingsRepository.findLatestReviewNote( @@ -1044,11 +1067,16 @@ export class BookingTransitionService { ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) : null; const nextStep = computeNextStep(booking, nextPending); + const activeBatchOffer = + booking.status === "SELECTED_FOR_BATCH" + ? await this.bookingBatchService.getOpenOfferSummary(booking.id) + : null; return { ...booking, latestChangeRequestNote: note?.note ?? null, contractSummary: summary, nextStep, + activeBatchOffer, }; } } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 688c6ee18..c5574377a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -617,12 +617,14 @@ export class BookingsController { async uploadBookingDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.uploadDeliveryOrder( id, file, resolveAuthUserId(user), + vesselDepartureDate, ); return this.transitionService.enrichBookingResponse(booking); } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 2cc23b3fa..b54b6b49e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -33,14 +33,14 @@ import { BookingContractSignature } from './entities/booking-contract-signature. import { BookingReviewNote } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; -import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder'; -import { ContractRendererService } from '../../contracts/contract-renderer.service'; -import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; -import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; -import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { ContractsModule } from '../contracts/contracts.module'; import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; - +import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder"; +import { ContractRendererService } from "../../contracts/contract-renderer.service"; +import { ContractTemplateResolver } from "../../contracts/contract-template.resolver"; +import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder"; +import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; +import { VehiclesModule } from "../vehicles/vehicles.module"; @Module({ imports: [ @@ -62,6 +62,7 @@ import { BookingContainerAllocation } from "./entities/booking-container-allocat forwardRef(() => ContractsModule), FilesModule, MinioModule, + VehiclesModule, CompaniesModule, // CustomersModule, RuleEngineModule, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 80165cb7e..ac639a4bd 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -31,6 +31,8 @@ import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; +import { VehiclesService } from '../vehicles/vehicles.service'; +import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { assertFreightShape } from './booking-freight.util'; import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto'; import { mapStatusCountsToTabs } from './booking-list-tabs.config'; @@ -94,6 +96,7 @@ export class BookingsService { private readonly ruleEngineService: RuleEngineService, private readonly containerTypesService: ContainerTypesService, private readonly consolidationService: ConsolidationService, + private readonly vehiclesService: VehiclesService, private readonly contractPdfService: ContractPdfService, ) {} @@ -1520,6 +1523,16 @@ export class BookingsService { throw new NotFoundException(`Booking ${bookingId} not found`); } + const previousAllocations = await this.dataSource.manager.find(BookingContainerAllocation, { + where: { + bookingId, + containerId: In(allocations.map((a) => a.containerId)), + }, + }); + const previousVehicleIds = previousAllocations + .map((a) => a.vehicleId) + .filter((id): id is string => Boolean(id)); + await this.dataSource.transaction(async (manager) => { for (const allocation of allocations) { await manager.delete(BookingContainerAllocation, { @@ -1536,6 +1549,16 @@ export class BookingsService { } }); + const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); + await Promise.all( + [...vehicleIds].map((vehicleId) => + this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY), + ), + ); + await this.vehiclesService.releaseIfUnused( + previousVehicleIds.filter((id) => !vehicleIds.has(id)), + ); + return { success: true, allocated: allocations.length, diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 910d3f103..4b033bab5 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -291,7 +291,7 @@ export class Booking extends BaseEntity { @Column({ name: 'origin_yard_id', type: 'uuid' }) originYardId!: string; - @ManyToOne(() => Yard) + @ManyToOne(() => Yard) @JoinColumn({ name: 'origin_yard_id' }) originYard?: Yard; diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index 7ea818e34..2b0126003 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -65,6 +65,16 @@ function makeService(overrides?: { children: [{ value: '2' }], }), }; + const glOperationsService = { + t1State: jest.fn().mockResolvedValue({ + bookingId: 'b-general', + wagonAllocated: false, + trainDepartedAt: null, + trainArrivedAt: null, + closed: false, + closedAt: null, + }), + }; const service = new BookingClearanceService( bookingsRepository as never, @@ -74,6 +84,7 @@ function makeService(overrides?: { workflowService as never, milestoneService as never, dropdownSettingsService as never, + glOperationsService as never, ); return { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 7bb835df2..3f169c49c 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -1,5 +1,11 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { ContractDocPhase } from '@edr/types'; +import { + ContractDocPhase, + type ClearanceFinalInvoiceSummary, + type ClearanceSecondDuty, + type ClearanceT1State, + type ClearanceTrainState, +} from '@edr/types'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; @@ -11,6 +17,7 @@ import { Booking } from '../bookings/entities/booking.entity'; import { clearanceCodesForBooking } from '../bookings/clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { GlOperationsService } from './gl-operations.service'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; @@ -63,6 +70,23 @@ export interface BookingClearanceView { noticeFile?: { id: string; name: string; url: string } | null; } | null; workflowFiles?: ReturnType; + /** Import post-allocation T1 transit document state (null until wagon allocation). */ + t1?: ClearanceT1State | null; + /** Train link state for the booking (both directions). */ + train?: ClearanceTrainState | null; + gatepassGranted?: boolean; + gatepassAt?: string | null; + t1Closed?: boolean; + t1ClosedAt?: string | null; + offloaded?: boolean; + /** GL Djibouti post-offload final invoice (export). */ + finalInvoice?: ClearanceFinalInvoiceSummary | null; + /** Customs risk level assigned by GL ET (import; visible to the customer). */ + riskLevel?: string | null; + riskAssignedAt?: string | null; + /** Post-arrival additional duty/tax round (import). */ + secondDuty?: ClearanceSecondDuty | null; + importReleaseGranted?: boolean; } @Injectable() @@ -75,6 +99,7 @@ export class BookingClearanceService { private readonly workflowService: ClearanceWorkflowService, private readonly milestoneService: ClearanceMilestoneService, private readonly dropdownSettingsService: DropdownSettingsService, + private readonly glOperationsService: GlOperationsService, ) {} private async assertPhasedGeneralCustoms(booking: Booking): Promise { @@ -162,6 +187,29 @@ export class BookingClearanceService { booking.tradeDirection ?? 'IMPORT', ); + let t1: ClearanceT1State | null = null; + if ((booking.tradeDirection ?? 'IMPORT') === 'IMPORT') { + try { + t1 = await this.glOperationsService.t1State(bookingId); + } catch { + t1 = null; + } + } + + let train: ClearanceTrainState | null = null; + try { + train = await this.glOperationsService.trainState(bookingId); + } catch { + train = null; + } + const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId); + const bookingMilestone = (code: string) => + milestones.find((m) => m.milestoneCode === code); + const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); + const riskMilestone = bookingMilestone('RISK_ASSIGNED'); + const secondDuty = this.glOperationsService.secondDutyState(milestones, files); + return { bookingId, status: booking.status, @@ -192,6 +240,34 @@ export class BookingClearanceService { preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt), dutyAdvice, workflowFiles, + t1, + train, + gatepassGranted: gatepassMilestone?.status === 'COMPLETED', + gatepassAt: + gatepassMilestone?.status === 'COMPLETED' + ? (gatepassMilestone.metadata?.gatepassAt ?? + (gatepassMilestone.triggeredAt + ? gatepassMilestone.triggeredAt.toISOString() + : null)) + : null, + t1Closed: t1ClosedMilestone?.status === 'COMPLETED', + t1ClosedAt: + t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt + ? t1ClosedMilestone.triggeredAt.toISOString() + : null, + offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', + finalInvoice, + riskLevel: + riskMilestone?.status === 'COMPLETED' + ? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null) + : null, + riskAssignedAt: + riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt + ? riskMilestone.triggeredAt.toISOString() + : null, + secondDuty, + importReleaseGranted: + bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', }; } @@ -288,6 +364,12 @@ export class BookingClearanceService { : ContractDocPhase.CustomerDuty, } as never); + // Export: the declaration is the last GL ET pre-operation action — release + // immediately so the customer can proceed without a separate confirm click. + if (tradeDirection === 'EXPORT') { + await this.workflowService.onExportReleasedForBooking(bookingId, userId); + } + return this.bookingsService.findById(bookingId); } @@ -421,6 +503,13 @@ export class BookingClearanceService { clearanceCurrentPhase: ContractDocPhase.GlDjCollection, } as never); + // GL Djibouti may have uploaded the DO early (un-gated) — count it now. + const files = await this.filesService.findByResource(bookingId, 'bookings'); + if (files.some((f) => f.code === 'delivery_order')) { + await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED'); + await this.workflowService.markReadyForOperation(bookingId); + } + return this.bookingsService.findById(bookingId); } @@ -428,21 +517,18 @@ export class BookingClearanceService { bookingId: string, file: Express.Multer.File, userId?: string, + vesselDepartureDate?: string, ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('Delivery Order applies only to import bookings.'); } - if (!booking.preClearanceFinalizedAt) { - throw new BadRequestException( - 'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.', - ); - } - - await this.workflowService.assertPriorCompleteForBooking(bookingId, 'IMPORT', 'DO_COLLECTED'); if (!file) throw new BadRequestException('No Delivery Order uploaded'); + // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, + // any file type. The DO_COLLECTED milestone (and operation readiness) still + // waits for GL Ethiopia to finalize pre-clearance so the workflow order holds. await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', @@ -450,8 +536,16 @@ export class BookingClearanceService { file, }); - await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); - await this.workflowService.markReadyForOperation(bookingId); + if (vesselDepartureDate?.trim()) { + await this.bookingsRepository.update(bookingId, { + vesselDepartureDate: vesselDepartureDate.trim(), + } as never); + } + + if (booking.preClearanceFinalizedAt) { + await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); + await this.workflowService.markReadyForOperation(bookingId); + } return this.bookingsService.findById(bookingId); } diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts index ce6f7bea4..648d9666f 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts @@ -39,6 +39,16 @@ const IMPORT_DEFS: Record> = { OFFLOADED: { label: 'Offloaded', ownerRegion: 'OPS', triggeredByDoc: false }, T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'ET', triggeredByDoc: false }, RISK_ASSIGNED: { label: 'Risk Assigned', ownerRegion: 'ET', triggeredByDoc: false }, + SECOND_DUTY_ADVISED: { + label: 'Additional Duty and Taxes Advised', + ownerRegion: 'ET', + triggeredByDoc: false, + }, + SECOND_DUTY_PAID: { + label: 'Additional Duty and Tax Paid', + ownerRegion: 'CUST', + triggeredByDoc: true, + }, IMPORT_RELEASE_GRANTED: { label: 'Import Release Granted', ownerRegion: 'ET', triggeredByDoc: true }, IMPORT_PROCESS_COMPLETED: { label: 'Import Process Completed', ownerRegion: 'ET', triggeredByDoc: true }, STORAGE_INVOICE_RAISED: { label: 'Storage Invoice Raised', ownerRegion: 'OPS', triggeredByDoc: false }, @@ -68,6 +78,7 @@ const EXPORT_DEFS: Record> = { DEPARTED_TO_DJIBOUTI: { label: 'Departed to Djibouti', ownerRegion: 'OPS', triggeredByDoc: false }, ARRIVED_AT_DJIBOUTI: { label: 'Arrived at Djibouti', ownerRegion: 'DJ', triggeredByDoc: false }, GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false }, + T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'DJ', triggeredByDoc: false }, OFFLOADED: { label: 'Offloaded', ownerRegion: 'DJ', triggeredByDoc: true }, }; diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index 7d30b1c5b..e033f8e9b 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -91,6 +91,39 @@ export class ClearanceMilestoneService { }); } + /** + * Find-or-create a post-booking milestone row from the catalog. Needed for codes + * added to the catalog after a booking's rows were seeded (e.g. export T1_CLOSED). + */ + async ensureForBooking( + bookingId: string, + code: string, + tradeDirection: string, + ): Promise { + const existing = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); + if (existing) return existing; + + const { postBooking } = splitMilestones(tradeDirection); + const idx = postBooking.findIndex((d) => d.code === code); + if (idx < 0) { + throw new NotFoundException( + `Milestone ${code} is not a ${tradeDirection} post-booking milestone`, + ); + } + const def = postBooking[idx]!; + return this.repo.save( + this.repo.create({ + bookingId, + milestoneCode: def.code, + milestoneLabel: def.label, + ownerRegion: def.ownerRegion, + triggeredByDoc: def.triggeredByDoc, + status: 'PENDING', + sortOrder: idx, + }), + ); + } + /** Mark a milestone complete (by code) on a booking. */ async completeForBooking( bookingId: string, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index a09de407c..73a4fe117 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -1,5 +1,11 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; -import { ContractDocPhase } from '@edr/types'; +import { + ContractDocPhase, + type ClearanceFinalInvoiceSummary, + type ClearanceSecondDuty, + type ClearanceT1State, + type ClearanceTrainState, +} from '@edr/types'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; @@ -10,6 +16,7 @@ import { BookingsService } from '../bookings/bookings.service'; import { contractClearanceCodes } from './contract-clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { GlOperationsService } from './gl-operations.service'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { Contract } from './entities/contract.entity'; import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; @@ -77,6 +84,23 @@ export interface ContractClearanceView { noticeFile?: { id: string; name: string; url: string } | null; } | null; workflowFiles?: ReturnType; + /** Import post-allocation T1 transit document state (null until a booking is linked). */ + t1?: ClearanceT1State | null; + /** Train link state for the booking (both directions; null until a booking is linked). */ + train?: ClearanceTrainState | null; + gatepassGranted?: boolean; + gatepassAt?: string | null; + t1Closed?: boolean; + t1ClosedAt?: string | null; + offloaded?: boolean; + /** GL Djibouti post-offload final invoice (export). */ + finalInvoice?: ClearanceFinalInvoiceSummary | null; + /** Customs risk level assigned by GL ET (import; visible to the customer). */ + riskLevel?: string | null; + riskAssignedAt?: string | null; + /** Post-arrival additional duty/tax round (import). */ + secondDuty?: ClearanceSecondDuty | null; + importReleaseGranted?: boolean; } @Injectable() @@ -90,6 +114,7 @@ export class ContractClearanceService { private readonly workflowService: ClearanceWorkflowService, private readonly milestoneService: ClearanceMilestoneService, private readonly dropdownSettingsService: DropdownSettingsService, + private readonly glOperationsService: GlOperationsService, ) {} private isPhasedCustoms(contract: Contract): boolean { @@ -208,8 +233,9 @@ export class ContractClearanceService { files, contract.tradeDirection ?? 'IMPORT', ); + let bookingFiles: Awaited> = []; if (cycle?.bookingId) { - const bookingFiles = await this.filesService.findByResource( + bookingFiles = await this.filesService.findByResource( cycle.bookingId, 'bookings', ); @@ -224,11 +250,41 @@ export class ContractClearanceService { workflowFiles = [...byCode.values()]; } - let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); - if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') { - const bookingMilestones = await this.workflowService.listMilestonesForBooking( + let t1: ClearanceT1State | null = null; + if (cycle?.bookingId && contract.tradeDirection === 'IMPORT') { + try { + t1 = await this.glOperationsService.t1State(cycle.bookingId); + } catch { + t1 = null; // linked booking missing — view stays usable + } + } + + let train: ClearanceTrainState | null = null; + let bookingMilestones: ClearanceMilestone[] = []; + let finalInvoice: ClearanceFinalInvoiceSummary | null = null; + if (cycle?.bookingId) { + try { + train = await this.glOperationsService.trainState(cycle.bookingId); + } catch { + train = null; + } + bookingMilestones = await this.workflowService.listMilestonesForBooking( cycle.bookingId, ); + finalInvoice = await this.glOperationsService.finalInvoiceSummary(cycle.bookingId); + } + const bookingMilestone = (code: string) => + bookingMilestones.find((m) => m.milestoneCode === code); + const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); + const riskMilestone = bookingMilestone('RISK_ASSIGNED'); + const secondDuty = this.glOperationsService.secondDutyState( + bookingMilestones, + bookingFiles, + ); + + let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); + if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') { const booking = await this.bookingsService.findById(cycle.bookingId); if (booking) { nextAction = this.workflowService.computeNextActionForBooking( @@ -272,6 +328,34 @@ export class ContractClearanceService { linkedBookingId: cycle?.bookingId ?? null, dutyAdvice, workflowFiles, + t1, + train, + gatepassGranted: gatepassMilestone?.status === 'COMPLETED', + gatepassAt: + gatepassMilestone?.status === 'COMPLETED' + ? (gatepassMilestone.metadata?.gatepassAt ?? + (gatepassMilestone.triggeredAt + ? gatepassMilestone.triggeredAt.toISOString() + : null)) + : null, + t1Closed: t1ClosedMilestone?.status === 'COMPLETED', + t1ClosedAt: + t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt + ? t1ClosedMilestone.triggeredAt.toISOString() + : null, + offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', + finalInvoice, + riskLevel: + riskMilestone?.status === 'COMPLETED' + ? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null) + : null, + riskAssignedAt: + riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt + ? riskMilestone.triggeredAt.toISOString() + : null, + secondDuty, + importReleaseGranted: + bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', }; } @@ -859,6 +943,12 @@ export class ContractClearanceService { }); } + // Export: the declaration is the last GL ET pre-booking action — release + // immediately so booking creation unlocks without a separate confirm click. + if (contract.tradeDirection === 'EXPORT') { + await this.workflowService.onExportReleased(contractId, userId); + } + return this.contractsService.findById(contractId); } @@ -1011,6 +1101,13 @@ export class ContractClearanceService { currentPhase: ContractDocPhase.GlDjCollection, }); + // GL Djibouti may have uploaded the DO early (un-gated) — count it now. + const files = await this.filesService.findByResource(contractId, 'contracts'); + if (files.some((f) => f.code === 'delivery_order')) { + await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED'); + await this.workflowService.markReadyForBooking(contractId); + } + return this.contractsService.findById(contractId); } @@ -1018,6 +1115,7 @@ export class ContractClearanceService { contractId: string, file: Express.Multer.File, userId?: string, + vesselDepartureDate?: string, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); @@ -1025,17 +1123,11 @@ export class ContractClearanceService { throw new BadRequestException('Delivery Order applies only to import contracts.'); } - const cycle = await this.contractsRepository.currentCycle(contractId); - if (!cycle?.preClearanceFinalizedAt) { - throw new BadRequestException( - 'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.', - ); - } - - await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DO_COLLECTED'); - if (!file) throw new BadRequestException('No Delivery Order uploaded'); + // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, + // any file type. The DO_COLLECTED milestone (and booking readiness) still waits + // for GL Ethiopia to finalize pre-clearance so the workflow order holds. await this.filesService.upsertByCode({ resourceId: contractId, resource: 'contracts', @@ -1043,8 +1135,16 @@ export class ContractClearanceService { file, }); - await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId); - await this.workflowService.markReadyForBooking(contractId); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle && vesselDepartureDate?.trim()) { + await this.contractsRepository.updateCycle(cycle.id, { + vesselDepartureDate: vesselDepartureDate.trim(), + }); + } + if (cycle?.preClearanceFinalizedAt) { + await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId); + await this.workflowService.markReadyForBooking(contractId); + } return this.contractsService.findById(contractId); } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 5e712a177..6ded65ae9 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -77,6 +77,7 @@ import { } from './dto/gl-operations.dto'; import { AdviseContractDutyDto, + GatepassDto, RoAmendmentDto, } from './dto/phased-clearance.dto'; @@ -610,9 +611,15 @@ export class ContractsController { uploadDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, @CurrentUser() user: AuthUserPayload, ) { - return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user)); + return this.clearanceService.uploadDeliveryOrder( + id, + file, + resolveAuthUserId(user), + vesselDepartureDate, + ); } @Post(':id/clearance/release-order') @@ -681,6 +688,30 @@ export class ContractsController { return this.clearanceService.djQueue(filter); } + @Get('clearance/dj-schedules') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'Train schedules carrying customs bookings — GL DJ gate-pass table' }) + djClearanceSchedules() { + return this.glOperationsService.djSchedules(); + } + + @Post('clearance/schedules/:scheduleId/gatepass') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ + summary: 'GL DJ grants the gate pass for every customs booking on a train schedule', + }) + grantScheduleGatepass( + @Param('scheduleId', ParseUUIDPipe) scheduleId: string, + @Body() dto: GatepassDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.grantScheduleGatepass( + scheduleId, + dto?.gatepassAt, + resolveAuthUserId(user), + ); + } + // ── Path A self-clearance — Operations reviews the customer's own docs ─────── @Get('clearance/ops-queue') @@ -873,6 +904,147 @@ export class ContractsController { return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []); } + @Post('bookings/:bookingId/t1-documents') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: + 'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs', + }) + uploadT1Documents( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.glOperationsService.uploadT1Documents(bookingId, files ?? []); + } + + @Post('bookings/:bookingId/t1-close') + @BookingStaff([ + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.clearanceDjActions, + ]) + @ApiOperation({ + summary: + 'Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)', + }) + closeT1( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user)); + } + + @Post('bookings/:bookingId/gatepass') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'GL DJ grants the gate pass for a customs booking (captures time)' }) + grantGatepass( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: GatepassDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.grantGatepass( + bookingId, + dto?.gatepassAt, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/final-invoice') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'GL DJ raises the post-offload final invoice (amount + invoice document)', + }) + createFinalInvoice( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body('amount') amountRaw: string, + @Body('currency') currency: string | undefined, + @Body('description') description: string | undefined, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.createFinalInvoice( + bookingId, + { + amount: Number(amountRaw), + currency: currency?.trim() || 'ETB', + description, + }, + file, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/final-invoice-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer attaches the payment slip for the final invoice' }) + uploadFinalInvoiceSlip( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.glOperationsService.uploadFinalInvoiceSlip(bookingId, file); + } + + @Post('bookings/:bookingId/final-invoice/confirm') + @BookingStaff([ + FREIGHT_PERMS.contracts.clearanceDjActions, + FREIGHT_PERMS.contracts.clearanceEtActions, + ]) + @ApiOperation({ summary: 'GL (ET or DJ) confirms the payment slip — settles the final invoice' }) + confirmFinalInvoicePaid( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.confirmFinalInvoicePaid( + bookingId, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/second-duty') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(FileInterceptor('attachment')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'GL ET advises (or skips) the post-arrival additional duty/tax round (import)', + }) + adviseSecondDuty( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body('dutyRequired') dutyRequiredRaw: string, + @Body('amount') amountRaw: string | undefined, + @Body('currency') currency: string | undefined, + @Body('declarationSerial') declarationSerial: string | undefined, + @UploadedFile() attachment: Express.Multer.File | undefined, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.adviseSecondDuty( + bookingId, + { + dutyRequired: dutyRequiredRaw === 'true' || dutyRequiredRaw === '1', + amount: + amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, + currency: currency ?? 'ETB', + declarationSerial, + }, + attachment, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/second-duty-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer attaches the additional duty/tax payment slip' }) + uploadSecondDutySlip( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.glOperationsService.uploadSecondDutySlip(bookingId, file); + } + @Post('bookings/:bookingId/documents') @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 94f469112..e0eece986 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; +import { BillingModule } from '../billing/billing.module'; import { CompaniesModule } from '../companies/companies.module'; import { FilesModule } from '../files/files.module'; import { MinioModule } from '../minio/minio.module'; @@ -64,6 +65,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum Booking, BookingContainerUnit, ]), + BillingModule, RuleEngineModule, FileUploadSettingsModule, DropdownSettingsModule, diff --git a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts index f48653822..6b784073b 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts @@ -35,3 +35,12 @@ export class RoAmendmentDto { @IsString() note?: string; } + +export class GatepassDto { + @ApiPropertyOptional({ + description: 'When the gate pass was granted (ISO datetime; defaults to now)', + }) + @IsOptional() + @IsString() + gatepassAt?: string; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts index 502afaf6a..d4676b8cd 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts @@ -23,6 +23,8 @@ export interface MilestoneMetadata { dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string; + /** When the gate pass was physically granted (GL DJ captures the time). */ + gatepassAt?: string; } /** diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index 73ca3a65d..8fed3a8ff 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -1,14 +1,29 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, In, IsNull } from 'typeorm'; +import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types'; +import { BillingService } from '../billing/billing.service'; +import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { FilesService } from '../files/files.service'; import { Booking } from '../bookings/entities/booking.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity'; import { ClearanceIncident, IncidentType, } from './entities/clearance-incident.entity'; +import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ClearanceMilestoneService } from './clearance-milestone.service'; -import { persistExportTransportUploads } from './phased-clearance.util'; +import { + persistExportTransportUploads, + persistT1TransportUploads, +} from './phased-clearance.util'; /** * Maps a GL post-booking document `code` to the milestone it auto-completes when @@ -18,7 +33,8 @@ import { persistExportTransportUploads } from './phased-clearance.util'; const DOC_CODE_TO_MILESTONE: Record = { release_order: 'RELEASE_ORDER_SECURED', // export — GL DJ delivery_order: 'DO_COLLECTED', // import — GL DJ - t1_transport_document: 'T1_CLOSED', // import — GL ET + // t1_transport_document intentionally NOT doc-triggered: T1_CLOSED completes only + // when GL Ethiopia accepts the T1 set after the train arrives (closeT1). import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET full_in_interchange: 'OFFLOADED', // export — GL DJ final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET @@ -37,6 +53,7 @@ export class GlOperationsService { private readonly dataSource: DataSource, private readonly filesService: FilesService, private readonly milestoneService: ClearanceMilestoneService, + private readonly billingService: BillingService, ) {} private get bookings() { @@ -161,6 +178,629 @@ export class GlOperationsService { return { uploaded: files.length, completedMilestones }; } + /** Wagon-allocation + train-schedule actuals for a booking (both directions). */ + async trainState(bookingId: string): Promise { + const booking = await this.getBooking(bookingId); + const milestones = await this.milestoneService.listForBooking(bookingId); + + const wagonMilestone = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED'); + const wagonAllocated = + wagonMilestone?.status === 'COMPLETED' || + booking.schedulingStatus === 'SCHEDULED' || + booking.schedulingStatus === 'DISPATCHED' || + Boolean(booking.trainScheduleId); + + let schedule: TrainSchedule | null = null; + if (booking.trainScheduleId) { + schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: booking.trainScheduleId } }); + } + + return { + wagonAllocated, + departedAt: schedule?.actualDepartureAt + ? new Date(schedule.actualDepartureAt).toISOString() + : null, + arrivedAt: schedule?.actualArrivalAt + ? new Date(schedule.actualArrivalAt).toISOString() + : null, + }; + } + + /** + * T1 transit-document lifecycle state for an import shipment booking. Wagon + * allocation opens the upload window; train departure locks it; train arrival + * lets GL Ethiopia close (accept) the T1 set. + */ + async t1State(bookingId: string): Promise { + const train = await this.trainState(bookingId); + const milestones = await this.milestoneService.listForBooking(bookingId); + + const closedMilestone = milestones.find( + (m) => m.milestoneCode === 'T1_CLOSED' && m.status === 'COMPLETED', + ); + + return { + bookingId, + wagonAllocated: train.wagonAllocated, + trainDepartedAt: train.departedAt, + trainArrivedAt: train.arrivedAt, + closed: Boolean(closedMilestone), + closedAt: closedMilestone?.triggeredAt + ? new Date(closedMilestone.triggeredAt).toISOString() + : null, + }; + } + + /** + * GL Djibouti uploads T1 transport documents (multi-file) after wagon allocation. + * Replaces the previous batch; locked once the train departs or T1 is closed. + */ + async uploadT1Documents( + bookingId: string, + files: Express.Multer.File[], + ): Promise<{ uploaded: number }> { + const booking = await this.getBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('T1 transport documents apply to import shipments only.'); + } + + const state = await this.t1State(bookingId); + if (!state.wagonAllocated) { + throw new BadRequestException( + 'Wagons must be allocated before T1 transport documents can be uploaded.', + ); + } + if (state.closed) { + throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.'); + } + if (state.trainDepartedAt) { + throw new BadRequestException( + 'The train has departed — T1 transport documents can no longer be changed.', + ); + } + + await persistT1TransportUploads(this.filesService, bookingId, files); + return { uploaded: files.length }; + } + + /** + * Close (accept) the T1/transport document set. + * Import: GL Ethiopia closes once the train has arrived (T1 files required). + * Export: GL Djibouti closes after the gate pass (transport document required). + */ + async closeT1( + bookingId: string, + userId?: string, + ): Promise { + const booking = await this.getBooking(bookingId); + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; + + const state = await this.t1State(bookingId); + if (state.closed) return state; + + if (tradeDirection === 'IMPORT') { + if (!state.trainArrivedAt) { + throw new BadRequestException( + 'The train has not arrived yet — T1 can be closed only after arrival.', + ); + } + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const hasT1 = files.some((f) => isT1TransportFileCode(f.code)); + if (!hasT1) { + throw new BadRequestException( + 'No T1 transport documents on file — GL Djibouti must upload them first.', + ); + } + } else { + const milestones = await this.milestoneService.listForBooking(bookingId); + const done = (code: string) => + milestones.find((m) => m.milestoneCode === code)?.status === 'COMPLETED'; + if (!done('EXPORT_TRANSPORT_ISSUED')) { + throw new BadRequestException( + 'The transport document must be uploaded before T1 can be closed.', + ); + } + if (!done('GATEPASS_GRANTED')) { + throw new BadRequestException('Grant the gate pass before closing T1.'); + } + // Export bookings seeded before T1_CLOSED joined the catalog lack the row. + await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection); + } + + await this.milestoneService.completeForBooking(bookingId, 'T1_CLOSED', userId); + return this.t1State(bookingId); + } + + /** Milestones GL DJ implicitly confirms when granting an export gate pass. */ + private static readonly EXPORT_ARRIVAL_CHAIN = [ + 'CARGO_ARRIVED', + 'READY_FOR_LOADING', + 'LOADED', + 'DEPARTED_TO_DJIBOUTI', + 'ARRIVED_AT_DJIBOUTI', + ]; + + /** + * GL Djibouti grants the gate pass for a customs booking, capturing the time. + * Export: requires the train to have arrived at Djibouti; back-fills the + * arrival-chain milestones. Import: requires wagon allocation (pre-loading). + */ + async grantGatepass( + bookingId: string, + gatepassAt?: string, + userId?: string, + ): Promise<{ bookingId: string; gatepassAt: string }> { + const booking = await this.getBooking(bookingId); + if (!booking.customsClearingEnabled) { + throw new BadRequestException('Gate pass applies to customs bookings only.'); + } + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; + const milestones = await this.milestoneService.listForBooking(bookingId); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + + const existing = byCode.get('GATEPASS_GRANTED'); + if (existing?.status === 'COMPLETED') { + return { + bookingId, + gatepassAt: + existing.metadata?.gatepassAt ?? + (existing.triggeredAt ? new Date(existing.triggeredAt).toISOString() : ''), + }; + } + + const train = await this.trainState(bookingId); + if (tradeDirection === 'EXPORT') { + if (!train.arrivedAt) { + throw new BadRequestException( + 'The train has not arrived at Djibouti yet — gate pass can be granted after arrival.', + ); + } + for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) { + if (byCode.get(code)?.status === 'PENDING') { + await this.milestoneService.completeForBooking(bookingId, code, userId); + } + } + } else if (!train.wagonAllocated) { + throw new BadRequestException( + 'Wagons must be allocated before the gate pass can be granted.', + ); + } + + const at = gatepassAt?.trim() || new Date().toISOString(); + await this.milestoneService.completeWithMetadataForBooking( + bookingId, + 'GATEPASS_GRANTED', + { gatepassAt: at }, + userId, + ); + return { bookingId, gatepassAt: at }; + } + + /** Train schedules carrying ≥1 customs booking — the GL Djibouti gate-pass table. */ + async djSchedules(): Promise { + const schedules = await this.dataSource.getRepository(TrainSchedule).find({ + relations: { + scheduleBookings: { booking: true }, + originStation: true, + destinationStation: true, + }, + order: { scheduledDepartureDate: 'DESC' }, + }); + + const withCustoms = schedules + .filter((s) => s.status !== 'CANCELLED') + .map((s) => ({ + schedule: s, + customs: (s.scheduleBookings ?? []) + .map((sb) => sb.booking) + .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)), + })) + .filter((s) => s.customs.length > 0); + + const bookingIds = withCustoms.flatMap((s) => s.customs.map((b) => b.id)); + const gatepassRows = bookingIds.length + ? await this.dataSource.getRepository(ClearanceMilestone).find({ + where: { bookingId: In(bookingIds), milestoneCode: 'GATEPASS_GRANTED' }, + }) + : []; + const gatepassByBooking = new Map(gatepassRows.map((m) => [m.bookingId, m])); + + return withCustoms.map(({ schedule, customs }) => { + const freightTypes = [...new Set(customs.map((b) => b.freightType).filter(Boolean))]; + return { + id: schedule.id, + trainNumber: schedule.trainNumber ?? null, + routeName: null, + origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, + destination: + schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, + status: schedule.status, + scheduledDepartureDate: schedule.scheduledDepartureDate + ? new Date(schedule.scheduledDepartureDate).toISOString() + : null, + actualDepartureAt: schedule.actualDepartureAt + ? new Date(schedule.actualDepartureAt).toISOString() + : null, + actualArrivalAt: schedule.actualArrivalAt + ? new Date(schedule.actualArrivalAt).toISOString() + : null, + freightType: + freightTypes.length === 1 ? (freightTypes[0] as string) : freightTypes.length ? 'MIXED' : null, + customsBookings: customs.map((b) => { + const m = gatepassByBooking.get(b.id); + const granted = m?.status === 'COMPLETED'; + return { + bookingId: b.id, + reference: b.reference ?? b.id, + tradeDirection: b.tradeDirection ?? 'IMPORT', + contractId: b.contractId ?? null, + gatepassGranted: granted, + gatepassAt: granted + ? (m?.metadata?.gatepassAt ?? + (m?.triggeredAt ? new Date(m.triggeredAt).toISOString() : null)) + : null, + }; + }), + }; + }); + } + + /** + * One-click gate pass for every customs booking on a train schedule. Per-booking + * guard failures are collected, not fatal. Import schedules also get the + * schedule-level ImportDjiboutiOperation gate pass so loading unblocks. + */ + async grantScheduleGatepass( + scheduleId: string, + gatepassAt?: string, + userId?: string, + ): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> { + const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ + where: { id: scheduleId }, + relations: { scheduleBookings: { booking: true } }, + }); + if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); + + const customs = (schedule.scheduleBookings ?? []) + .map((sb) => sb.booking) + .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)); + if (customs.length === 0) { + throw new BadRequestException('No customs bookings ride this schedule.'); + } + + let granted = 0; + const skipped: Array<{ bookingId: string; error: string }> = []; + for (const booking of customs) { + try { + await this.grantGatepass(booking.id, gatepassAt, userId); + granted += 1; + } catch (e) { + skipped.push({ + bookingId: booking.id, + error: e instanceof Error ? e.message : 'Failed', + }); + } + } + + if (granted > 0 && customs.some((b) => (b.tradeDirection ?? 'IMPORT') === 'IMPORT')) { + const opRepo = this.dataSource.getRepository(ImportDjiboutiOperation); + let operation = await opRepo.findOne({ where: { trainScheduleId: scheduleId } }); + if (!operation) { + operation = opRepo.create({ trainScheduleId: scheduleId }); + } + if (!operation.gatepassGrantedAt) { + operation.gatepassGrantedAt = gatepassAt ? new Date(gatepassAt) : new Date(); + await opRepo.save(operation); + } + } + + return { granted, skipped }; + } + + /** + * GL Djibouti raises the post-offload final invoice (export): manual amount + + * attached invoice document. The customer pays offline and attaches a slip; + * GL (ET or DJ) then confirms to settle it. + */ + async createFinalInvoice( + bookingId: string, + input: { amount: number; currency: string; description?: string }, + file: Express.Multer.File, + userId?: string, + ): Promise { + const booking = await this.getBooking(bookingId); + if (!booking.customsClearingEnabled) { + throw new BadRequestException('Final invoice applies to customs bookings only.'); + } + if (!(input.amount > 0)) { + throw new BadRequestException('Invoice amount must be greater than zero.'); + } + if (!file) throw new BadRequestException('Attach the invoice document.'); + + const milestones = await this.milestoneService.listForBooking(bookingId); + const offloaded = milestones.find( + (m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED', + ); + if (!offloaded) { + throw new BadRequestException( + 'Cargo must be offloaded before the final invoice can be raised.', + ); + } + + const existing = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if ( + existing && + existing.status !== Freight.InvoiceStatus.Cancelled && + existing.status !== Freight.InvoiceStatus.Expired + ) { + throw new ConflictException('A final invoice already exists for this shipment.'); + } + + const description = input.description?.trim() || 'Post-offload charges (Djibouti)'; + await this.billingService.generateInvoice({ + source: Freight.InvoiceSource.Booking, + sourceId: bookingId, + type: GL_FINAL_INVOICE_TYPE, + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: input.currency, + lines: [ + { + chargeType: GL_FINAL_INVOICE_TYPE, + description, + quantity: 1, + unitRate: input.amount, + amount: input.amount, + }, + ], + status: Freight.InvoiceStatus.Issued, + }); + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'final_invoice', + file, + }); + + // Export clearance is administratively done once the final invoice goes out. + await this.dataSource + .getRepository(ContractClearanceCycle) + .update({ bookingId, completedAt: IsNull() }, { completedAt: new Date() }); + + void userId; + const summary = await this.finalInvoiceSummary(bookingId); + if (!summary) throw new NotFoundException('Final invoice could not be created.'); + return summary; + } + + /** Customer attaches the payment slip for the final invoice. */ + async uploadFinalInvoiceSlip( + bookingId: string, + file: Express.Multer.File, + ): Promise<{ uploaded: boolean }> { + await this.getBooking(bookingId); + if (!file) throw new BadRequestException('No payment slip uploaded'); + + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) { + throw new BadRequestException('No final invoice has been issued for this shipment.'); + } + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException('The final invoice is already paid.'); + } + if ( + invoice.status === Freight.InvoiceStatus.Cancelled || + invoice.status === Freight.InvoiceStatus.Expired + ) { + throw new BadRequestException('The final invoice is no longer payable.'); + } + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'final_invoice_slip', + file, + }); + return { uploaded: true }; + } + + /** GL (ET or DJ) confirms the customer's slip — settles the final invoice. */ + async confirmFinalInvoicePaid( + bookingId: string, + userId?: string, + ): Promise { + await this.getBooking(bookingId); + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) { + throw new BadRequestException('No final invoice has been issued for this shipment.'); + } + if (invoice.status !== Freight.InvoiceStatus.Paid) { + const files = await this.filesService.findByResource(bookingId, 'bookings'); + if (!files.some((f) => f.code === 'final_invoice_slip')) { + throw new BadRequestException( + 'The customer has not attached a payment slip yet.', + ); + } + await this.billingService.markInvoiceAsPaid(invoice.id); + } + + void userId; + const summary = await this.finalInvoiceSummary(bookingId); + if (!summary) throw new NotFoundException('Final invoice not found.'); + return summary; + } + + /** + * GL ET advises (or skips) the post-arrival additional duty/tax round (import). + * Customer then attaches a slip; SECOND_DUTY_PAID completes on that upload. + */ + async adviseSecondDuty( + bookingId: string, + input: { + dutyRequired: boolean; + amount?: number; + currency?: string; + declarationSerial?: string; + }, + attachment?: Express.Multer.File, + userId?: string, + ): Promise<{ advised: boolean; skipped: boolean }> { + const booking = await this.getBooking(bookingId); + if (!booking.customsClearingEnabled) { + throw new BadRequestException('Additional duty applies to customs bookings only.'); + } + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; + if (tradeDirection !== 'IMPORT') { + throw new BadRequestException('Additional duty applies to import shipments only.'); + } + + await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_ADVISED', tradeDirection); + await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_PAID', tradeDirection); + + if (!input.dutyRequired) { + await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_ADVISED'); + await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_PAID'); + return { advised: false, skipped: true }; + } + + if (!input.amount || input.amount <= 0) { + throw new BadRequestException('Duty amount must be greater than zero.'); + } + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const hasNotice = files.some((f) => f.code === 'duty_tax_notice_2'); + if (!attachment && !hasNotice) { + throw new BadRequestException('Attach the additional duty/tax notice.'); + } + if (attachment) { + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_notice_2', + file: attachment, + }); + } + + await this.milestoneService.completeWithMetadataForBooking( + bookingId, + 'SECOND_DUTY_ADVISED', + { + dutyAmount: input.amount, + dutyCurrency: input.currency ?? 'ETB', + declarationSerial: input.declarationSerial, + }, + userId, + ); + return { advised: true, skipped: false }; + } + + /** Customer attaches the payment slip for the additional duty round. */ + async uploadSecondDutySlip( + bookingId: string, + file: Express.Multer.File, + ): Promise<{ milestoneCompleted: boolean }> { + const booking = await this.getBooking(bookingId); + if (!file) throw new BadRequestException('No payment slip uploaded'); + + const milestones = await this.milestoneService.listForBooking(bookingId); + const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED'); + if (advised?.status !== 'COMPLETED') { + throw new BadRequestException('No additional duty has been advised for this shipment.'); + } + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_receipt_2', + file, + }); + + await this.milestoneService.ensureForBooking( + bookingId, + 'SECOND_DUTY_PAID', + booking.tradeDirection ?? 'IMPORT', + ); + await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID'); + return { milestoneCompleted: true }; + } + + /** Second duty round state for clearance views. */ + secondDutyState( + milestones: Array<{ + milestoneCode: string; + status: string; + metadata?: { dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string } | null; + }>, + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + ): Freight.ClearanceSecondDuty | null { + const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED'); + const paid = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_PAID'); + if (!advised && !paid) return null; + + const toRef = (code: string) => { + const f = files.find((x) => x.code === code); + return f ? { id: f.id, name: f.name, url: f.url } : null; + }; + + return { + advised: advised?.status === 'COMPLETED', + skipped: advised?.status === 'SKIPPED', + amount: advised?.metadata?.dutyAmount ?? null, + currency: advised?.metadata?.dutyCurrency ?? null, + declarationSerial: advised?.metadata?.declarationSerial ?? null, + noticeFile: toRef('duty_tax_notice_2'), + slipFile: toRef('duty_tax_receipt_2'), + paid: paid?.status === 'COMPLETED', + }; + } + + /** Final-invoice state joined with its document + slip files, for clearance views. */ + async finalInvoiceSummary( + bookingId: string, + ): Promise { + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) return null; + + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const toRef = (code: string) => { + const f = files.find((x) => x.code === code); + return f ? { id: f.id, name: f.name, url: f.url } : null; + }; + const line = await this.dataSource + .getRepository(InvoiceLine) + .findOne({ where: { invoiceId: invoice.id } }); + + return { + id: invoice.id, + invoiceNumber: invoice.invoiceNumber, + status: invoice.status, + totalAmount: Number(invoice.totalAmount), + currency: invoice.currency, + description: line?.description ?? null, + invoiceFile: toRef('final_invoice'), + slipFile: toRef('final_invoice_slip'), + confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null, + }; + } + /** * GL ET uploads export transport document after wagon allocation (export ONE_TIME). */ diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts index cf044d6a9..40646ac47 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts @@ -107,8 +107,12 @@ describe('belongsOnDjClearanceQueue', () => { ).toBe(true); }); - it('excludes import contracts still on Ethiopia-side clearance only', () => { - expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(false); + it('keeps import contracts from the start — DO upload is un-gated', () => { + expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(true); + }); + + it('excludes export contracts with no DJ activity or RO hold', () => { + expect(belongsOnDjClearanceQueue('EXPORT', null, [])).toBe(false); }); }); diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts index 89fbf797e..2e8682951 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -5,7 +5,9 @@ import { isDeclarationFileCode, isImportTransitPermitFileCode, isExportTransportFileCode, + isT1TransportFileCode, exportTransportFileLabel, + t1TransportFileLabel, transitPermitFileLabel, type ClearanceWorkflowFile, } from '@edr/types'; @@ -160,6 +162,50 @@ export async function persistExportTransportUploads( ); } +/** Require at least one T1 transport document in the upload batch. */ +export function assertT1TransportFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No T1 transport documents uploaded'); + } +} + +export function normalizeT1TransportFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `t1_transport_document_${index}`, + })); +} + +/** Replace all T1 transport documents on a booking with a new multi-file batch. */ +export async function persistT1TransportUploads( + store: DeclarationFileStore, + bookingId: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeT1TransportFieldNames(files); + assertT1TransportFiles(normalized); + + const existing = await store.findByResource(bookingId, 'bookings'); + await Promise.all( + existing + .filter((f) => f.code && isT1TransportFileCode(f.code)) + .map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId: bookingId, + resource: 'bookings', + code: `t1_transport_document_${index}`, + file, + }), + ), + ); +} + export function parseDutyRequiredForm(value: string | boolean | undefined): boolean { if (typeof value === 'boolean') return value; if (value === undefined || value === '') return false; @@ -194,9 +240,9 @@ export function belongsOnDjClearanceQueue( ); if (hasDjActivity) return true; - const preFinalized = - cycle?.preClearanceFinalizedAt ?? extras?.preClearanceFinalizedAt ?? null; - if (tradeDirection === 'IMPORT' && preFinalized) return true; + // Import DO upload is un-gated — Djibouti GL must see import customs items from + // the start, not only after Ethiopia finalizes pre-clearance. + if (tradeDirection === 'IMPORT') return true; return false; } @@ -295,6 +341,22 @@ export function buildWorkflowFiles( file: { id: file.id, name: file.name, url: file.url }, }); }); + + const extraT1 = files + .filter((f) => f.code && isT1TransportFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraT1.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: t1TransportFileLabel(file.code, index), + uploadedBy: 'gl_dj', + category: 'djibouti', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); } if (tradeDirection === 'EXPORT') { diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts index d5176d14b..2464a26ac 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts @@ -48,12 +48,10 @@ export class DriversService { const qb = this.driverRepo.createQueryBuilder('d'); if (query.search) { - const searchTerm = `%${query.search}%`; - qb.where('d.firstName ILIKE :search', { search: searchTerm }) - .orWhere('d.lastName ILIKE :search', { search: searchTerm }) - .orWhere('d.email ILIKE :search', { search: searchTerm }) - .orWhere('d.licenseNumber ILIKE :search', { search: searchTerm }) - .orWhere('d.phoneNumber ILIKE :search', { search: searchTerm }); + qb.where( + '(d.firstName ILIKE :search OR d.lastName ILIKE :search OR d.email ILIKE :search OR d.licenseNumber ILIKE :search OR d.phoneNumber ILIKE :search)', + { search: `%${query.search}%` }, + ); } if (query.status) { diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 680bb5d09..d5f53ae0c 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -65,6 +65,12 @@ export class FirstMileController { return this.firstMileService.findById(id); } + @Get('acceptitem/:id') + @ApiOperation({ summary: 'Get a first-mile accep by ID' }) + acceptItem(@Param('id', ParseUUIDPipe) id: string) { + return this.firstMileService.acceptBooking(id); + } + @Post('accept/:reference') @TrainSchedulingManage() @ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' }) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index d05964878..40d163220 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,8 +1,8 @@ -import { Injectable, Logger, NotFoundException } from "@nestjs/common"; -import { FindOptionsWhere } from "typeorm"; -import { InjectDataSource } from "@nestjs/typeorm"; -import { DataSource } from "typeorm"; - +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { FindOptionsWhere, In } from 'typeorm'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { BookingsRepository } from "../bookings/bookings.repository"; import { DriversService } from "../drivers/drivers.service"; import { SmsClientService } from "../notifications/sms-client.service"; @@ -95,7 +95,6 @@ export class FirstMileService { if (booking.paymentStatus !== "PAID") { return null; } - if (!this.bookingRequestsFirstMile(booking)) { return null; } @@ -198,7 +197,7 @@ export class FirstMileService { return existing; } - return this.firstMileRepository.create({ + const record = await this.firstMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? "READY_TO_TRANSIT", advancedPayment: dto.advancedPayment ?? 0, @@ -208,6 +207,12 @@ export class FirstMileService { vehicleId: dto.vehicleId ?? null, paid: (dto as any).paid ?? false, }); + + if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + } + + return record; } private async findByBookingId(bookingId: string): Promise { @@ -233,12 +238,10 @@ export class FirstMileService { firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; }): boolean { - // Export bookings always need a first mile (pickup → origin yard); the - // pickup address is captured at assignment time, not required upfront. return Boolean( - booking.tradeDirection === "EXPORT" || - booking.firstMilePickupAddress?.trim() || - booking.serviceType?.includesFirstMile, + booking.tradeDirection === 'EXPORT' && + (booking.firstMilePickupAddress?.trim() || + booking.serviceType?.includesFirstMile), ); } @@ -267,28 +270,62 @@ export class FirstMileService { throw new NotFoundException(`First-mile record ${id} not found`); } + // Keep vehicle statuses in sync: new vehicle goes BUSY, replaced one goes back to FREE + if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { + if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + } + if (existing.vehicleId) { + await this.vehiclesService.releaseIfUnused([existing.vehicleId]); + } + } + // Notify assigned driver on every explicit vehicle assignment or reassignment if (dto.vehicleId) { void this.notifyDriverAssignment(dto.vehicleId, existing); } + // Trip finished — release the vehicles it was holding + if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { + await this.releaseVehicles(updated); + } + return updated; } async updateStatus(id: string, status: FirstMileStatus): Promise { + const existing = await this.findById(id); const updated = await this.firstMileRepository.update(id, { status }); if (!updated) { throw new NotFoundException(`First-mile record ${id} not found`); } + if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { + await this.releaseVehicles(updated); + } + return updated; } - private async notifyDriverAssignment( - vehicleId: string, - record: FirstMile, - ): Promise { + /** + * Free every vehicle held by this record (direct assignment + container + * allocations), unless still in use by another active trip. + */ + private async releaseVehicles(record: FirstMile): Promise { + const recordAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, { + where: { firstMileId: record.id }, + }); + const vehicleIds = recordAllocations + .map((a) => a.vehicleId) + .filter((id): id is string => Boolean(id)); + if (record.vehicleId) { + vehicleIds.push(record.vehicleId); + } + await this.vehiclesService.releaseIfUnused(vehicleIds); + } + + private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); if (!vehicle.assignedDriverId) { @@ -359,6 +396,16 @@ export class FirstMileService { throw new NotFoundException(`First-mile record ${firstMileId} not found`); } + const previousAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, { + where: { + firstMileId, + containerId: In(allocations.map((a) => a.containerId)), + }, + }); + const previousVehicleIds = previousAllocations + .map((a) => a.vehicleId) + .filter((id): id is string => Boolean(id)); + await this.dataSource.transaction(async (manager) => { for (const allocation of allocations) { await manager.delete(FirstMileContainerAllocation, { @@ -375,6 +422,14 @@ export class FirstMileService { } }); + const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); + await Promise.all( + [...vehicleIds].map((vehicleId) => this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY)), + ); + await this.vehiclesService.releaseIfUnused( + previousVehicleIds.filter((id) => !vehicleIds.has(id)), + ); + return { success: true, allocated: allocations.length, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index df439f28f..69eec29ae 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -71,6 +71,7 @@ export class LastMileService { return null; } + return this.create({ bookingId: booking.id, advancedPayment: 0, @@ -131,12 +132,6 @@ export class LastMileService { } async create(dto: CreateLastMileDto): Promise { - const [existing] = await this.lastMileRepository.findAll({ - where: { bookingId: dto.bookingId }, - take: 1, - }); - if (existing) return existing; - return this.lastMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? 'READY_TO_TRANSIT', diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index d1ed23ef7..d67d58811 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -83,6 +83,34 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' }) bookingWindowStatus!: string; + /** + * Booking-window lifecycle for the one-booking-day cycle + * (PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → reopen | CLOSED_FOR_DAY | DONE). + * NULL on legacy and DOMESTIC schedules — the window engine ignores those. + */ + @Column({ name: 'window_phase', type: 'varchar', length: 20, nullable: true }) + windowPhase?: string | null; + + @Column({ name: 'window_opens_at', type: 'timestamptz', nullable: true }) + windowOpensAt?: Date | null; + + @Column({ name: 'window_closes_at', type: 'timestamptz', nullable: true }) + windowClosesAt?: Date | null; + + @Column({ name: 'doc_review_ends_at', type: 'timestamptz', nullable: true }) + docReviewEndsAt?: Date | null; + + /** Staff finished document review early — starts the batch/payment phase immediately. */ + @Column({ name: 'doc_review_completed_at', type: 'timestamptz', nullable: true }) + docReviewCompletedAt?: Date | null; + + @Column({ name: 'payment_phase_ends_at', type: 'timestamptz', nullable: true }) + paymentPhaseEndsAt?: Date | null; + + /** 1-based count of open→settle cycles run on the booking day. */ + @Column({ name: 'booking_cycle_no', type: 'int', default: 0 }) + bookingCycleNo!: number; + @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule) scheduleBookings?: TrainScheduleBooking[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index 7316e1610..650da3adc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -121,6 +121,66 @@ function windowFromEatStart( }; } +/** Build a UTC Date for an EAT wall-clock time on a `yyyy-MM-dd` EAT calendar day. */ +export function eatDayToUtc(day: string, hour: number, minute = 0): Date { + const [year, month, dayOfMonth] = day.split('-').map(Number); + return eatToUtc(year, month, dayOfMonth, hour, minute); +} + +/** Shift a `yyyy-MM-dd` EAT day key by whole days. */ +export function shiftEatDay(day: string, deltaDays: number): string { + // Noon UTC keeps the +3h EAT offset from crossing a day boundary. + const [year, month, dayOfMonth] = day.split('-').map(Number); + const shifted = new Date(Date.UTC(year, month - 1, dayOfMonth + deltaDays, 12)); + return `${shifted.getUTCFullYear()}-${String(shifted.getUTCMonth() + 1).padStart(2, '0')}-${String( + shifted.getUTCDate(), + ).padStart(2, '0')}`; +} + +export interface InitialWindowTimes { + windowOpensAt: Date; + windowClosesAt: Date; +} + +/** + * Import booking-day window: opens at `windowOpenHour` EAT on departure-day minus + * `importWindowLeadDays`, for `windowDurationHours`. A schedule created after its + * computed window has fully passed gets a same-day window starting now instead, + * capped at departure. + */ +export function computeImportWindowTimes( + departure: Date, + cfg: { + importWindowLeadDays: number; + windowOpenHour: number; + windowDurationHours: number; + }, + now: Date, +): InitialWindowTimes { + const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays); + let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour); + let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000); + if (closesAt.getTime() <= now.getTime()) { + opensAt = now; + closesAt = new Date(now.getTime() + cfg.windowDurationHours * 3_600_000); + } + if (closesAt.getTime() > departure.getTime()) { + closesAt = departure; + } + return { windowOpensAt: opensAt, windowClosesAt: closesAt }; +} + +/** Export booking window: FCFS from `exportBookingLeadHours` before departure until departure. */ +export function computeExportWindowTimes( + departure: Date, + cfg: { exportBookingLeadHours: number }, +): InitialWindowTimes { + return { + windowOpensAt: new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000), + windowClosesAt: departure, + }; +} + /** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */ export function getBatchWindowForTimestamp(date: Date): BatchWindow { const { year, month, day, hour } = eatParts(date); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index 5c9cb6119..d8dc6b116 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -1,20 +1,14 @@ /** * Tunables for the demand-batching booking → allocation flow. - * Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock. + * Times run in EAT so window boundaries match the local operating clock. + * + * Cadence and pay-window durations moved to the train_scheduling_global_rules + * table (TrainSchedulingService.getWindowConfig) — the window engine + * (BookingWindowService) drives all timing off that config. */ -/** Batch boundaries — every 3h from 00:00 (00–03, 03–06, … 21–24), matching the board windows. */ -// export const BATCH_CRON = '0 7,10,13,16,19,22 * * *'; -// export const BATCH_CRON = '*/3 * * * *'; -export const BATCH_CRON = '*/5 * * * *'; -// export const BATCH_CRON = '0 */3 * * *';// - export const BATCH_TIMEZONE = 'Africa/Addis_Ababa'; -/** How long a selected commercial customer has to pay before their slot expires. */ -// export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour -export const PAYMENT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes (test mode) - /** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */ export const DEFAULT_WAGONS_PER_BOOKING = 1; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 9e974b31e..2712c6b48 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -35,6 +35,7 @@ describe('BookingBatchService — PAID reconcile', () => { let trainSchedulingService: { tryAutoWagonAllocation: jest.Mock; getBookableSchedules: jest.Mock; + getWindowConfig: jest.Mock; }; let dataSource: { getRepository: jest.Mock; @@ -77,6 +78,15 @@ describe('BookingBatchService — PAID reconcile', () => { violations: [], }), getBookableSchedules: jest.fn().mockResolvedValue([]), + getWindowConfig: jest.fn().mockResolvedValue({ + importWindowLeadDays: 3, + exportBookingLeadHours: 24, + windowOpenHour: 8, + windowDurationHours: 3, + docReviewMinutes: 30, + paymentWindowMinutes: 60, + reopenDelayMinutes: 90, + }), }; const bookingRepo = { @@ -187,17 +197,24 @@ describe('BookingBatchService — PAID reconcile', () => { }) as unknown as Booking; beforeEach(() => { - // Two OPEN trains on the same route + day, train A earlier than train B. - trainSchedulingService.getBookableSchedules.mockResolvedValue([ + // Two OPEN legacy trains on the same route + day, train A earlier than train B. + // fillRouteDay now selects fillable schedules straight from the repository. + trainSchedulesRepository.findAll.mockResolvedValue([ { id: trainA, - scheduleDate: '2026-06-20T06:00:00.000Z', + originStationId: originYardId, + destinationStationId: destinationYardId, + scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), bookingWindowStatus: 'OPEN', + windowPhase: null, }, { id: trainB, - scheduleDate: '2026-06-20T09:00:00.000Z', + originStationId: originYardId, + destinationStationId: destinationYardId, + scheduledDepartureDate: new Date('2026-06-20T09:00:00.000Z'), bookingWindowStatus: 'OPEN', + windowPhase: null, }, ]); trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) => diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 218a20436..722744c2a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, Injectable, Logger, NotFoundException, @@ -7,7 +8,7 @@ import { Optional, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; -import { Cron, SchedulerRegistry } from '@nestjs/schedule'; +import { SchedulerRegistry } from '@nestjs/schedule'; import { DataSource } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; @@ -22,17 +23,14 @@ import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-r import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService } from './train-scheduling.service'; import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; -import { Freight } from "@edr/types"; +import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types"; import { BillingService } from "../billing/billing.service"; import { - BATCH_CRON, - BATCH_TIMEZONE, DEFAULT_BULK_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_WAGONS_PER_BOOKING, - PAYMENT_WINDOW_MS, } from "./booking-batch.constants"; import { bookingTrainLengthMeters, @@ -41,6 +39,7 @@ import { } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; +import { BookingSplitService } from './booking-split.service'; /** A train's remaining capacity along the three physical limits the batch enforces. */ interface Capacity { @@ -121,6 +120,13 @@ export interface BatchBoardScheduleDetail { scheduleDate: string | null; status: string; bookingWindowStatus: string; + direction: string | null; + windowPhase: string | null; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + bookingCycleNo: number; locomotive: BatchBoardSchedule["locomotive"]; capacity: BatchBoardSchedule["capacity"]; counts: BatchBoardSchedule["counts"]; @@ -138,6 +144,13 @@ export interface BatchBoardSchedule { scheduleDate: string | null; status: string; bookingWindowStatus: string; + direction: string | null; + windowPhase: string | null; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + bookingCycleNo: number; locomotive: { code: string; name: string | null; @@ -189,6 +202,7 @@ export class BookingBatchService implements OnModuleInit { private readonly billing: BillingService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, + @Optional() private readonly splitService?: BookingSplitService, ) {} @@ -284,11 +298,17 @@ export class BookingBatchService implements OnModuleInit { await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); } - /** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */ + /** + * Distinct (origin, destination, EAT day) groups across LEGACY OPEN schedules — + * schedules with a `windowPhase` are driven exclusively by the window engine + * (BookingWindowService), never by the periodic legacy fill. + */ private async openRouteDayGroups(): Promise { - const open = await this.trainSchedulesRepository.findAll({ - where: { bookingWindowStatus: "OPEN" }, - }); + const open = ( + await this.trainSchedulesRepository.findAll({ + where: { bookingWindowStatus: "OPEN" }, + }) + ).filter((s) => s.windowPhase == null); const groups = new Map(); for (const s of open) { if (!s.scheduledDepartureDate) continue; @@ -340,6 +360,12 @@ export class BookingBatchService implements OnModuleInit { .update(bookingId, { paymentStatus: "PAID" }); } + // Paying inside the window accepts an open partial offer — reduce the booking + // to the offered part before it boards (remainder returns to the contract cap). + if (this.splitService) { + await this.splitService.applySplit(bookingId); + } + const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); if (!linked) { @@ -381,6 +407,99 @@ export class BookingBatchService implements OnModuleInit { await this.ensurePaidBookingAllocated(bookingId); } + /** Open partial-capacity offer summary for booking detail payloads (null when none). */ + async getOpenOfferSummary(bookingId: string): Promise<{ + offeredWagons: number; + totalWagons: number; + offeredAmount: number; + paymentDeadline: Date; + } | null> { + if (!this.splitService) return null; + const offer = await this.splitService.findOpenOffer(bookingId); + if (!offer) return null; + return { + offeredWagons: offer.offeredWagons, + totalWagons: offer.totalWagons, + offeredAmount: Number(offer.offeredAmount), + paymentDeadline: offer.paymentDeadline, + }; + } + + // ---- export FCFS ----------------------------------------------------------- + + /** + * Export is first-come-first-serve: no window cycle, no priority, no batch. + * Pick the earliest open export train on the booking's corridor/day that still + * fits the booking. Throws ConflictException when every train is full — the + * staff accept fails and no more export bookings are taken. + */ + async pickExportSchedule(booking: Booking): Promise { + if (!booking.scheduledDate) { + throw new BadRequestException('Booking has no scheduled date'); + } + const day = eatDay(new Date(booking.scheduledDate)); + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { + originStationId: booking.originYardId, + destinationStationId: booking.destinationYardId, + status: TrainScheduleStatusEnum.Draft, + }, + { + originStationId: booking.originYardId, + destinationStationId: booking.destinationYardId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }); + const candidates = corridor + .filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + this.isFillable(s), + ) + .sort( + (a, b) => + a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(), + ); + if (!candidates.length) { + throw new ConflictException( + 'No export train is accepting bookings for this day', + ); + } + + const rules = await this.loadGlobalRules(); + const wagonLengths = await this.loadWagonLengths(); + const need = this.needFor(booking, wagonLengths); + for (const candidate of candidates) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + candidate.id, + ); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !locomotive) continue; + const limits = await this.capacityLimits(locomotive, rules); + const budget = await this.remainingCapacity(schedule, limits, wagonLengths); + if (this.fits(need, budget)) return schedule.id; + } + throw new ConflictException('Train is full — no export capacity left for this day'); + } + + /** + * Reserve an accepted export booking on its picked train and open the pay + * window immediately (payment notification goes out on reserve). Marks the + * train FULL when this reservation exhausts the wagon budget. + */ + async reserveExportBooking(booking: Booking, scheduleId: string): Promise { + await this.reserve(booking, scheduleId); + this.armSettle(scheduleId); + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (schedule && (await this.remainingWagons(schedule)) <= 0) { + await this.setWindow(scheduleId, 'FULL'); + } + } + /** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */ async reconcilePaidUnlinked(scheduleId: string): Promise { const unlinked = @@ -393,9 +512,13 @@ export class BookingBatchService implements OnModuleInit { } } - // ---- cron entry point ----------------------------------------------------- + // ---- legacy fill entry point ---------------------------------------------- - @Cron(BATCH_CRON, { name: "booking-batch-fill", timeZone: BATCH_TIMEZONE }) + /** + * Legacy periodic fill for schedules without a window phase (DOMESTIC and + * pre-migration trains). Invoked by BookingWindowService's tick — the old + * standalone cron was replaced by the window engine. + */ async runBatchFill(): Promise { const groups = await this.openRouteDayGroups(); this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`); @@ -595,6 +718,15 @@ export class BookingBatchService implements OnModuleInit { : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, + direction: s.direction ?? null, + windowPhase: s.windowPhase ?? null, + windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null, + windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null, + docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null, + paymentPhaseEndsAt: s.paymentPhaseEndsAt + ? s.paymentPhaseEndsAt.toISOString() + : null, + bookingCycleNo: s.bookingCycleNo ?? 0, locomotive: loco ? { code: loco.code, @@ -679,6 +811,15 @@ export class BookingBatchService implements OnModuleInit { : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, + direction: s.direction ?? null, + windowPhase: s.windowPhase ?? null, + windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null, + windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null, + docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null, + paymentPhaseEndsAt: s.paymentPhaseEndsAt + ? s.paymentPhaseEndsAt.toISOString() + : null, + bookingCycleNo: s.bookingCycleNo ?? 0, locomotive: loco ? { code: loco.code, @@ -722,11 +863,27 @@ export class BookingBatchService implements OnModuleInit { // ---- core fill ------------------------------------------------------------ + /** + * Whether the batch engine may reserve/allocate onto this schedule right now. + * Legacy (no window phase): the customer-facing OPEN gate doubles as the fill gate. + * Import window cycle: the engine fills while the customer window is CLOSED — + * during DOC_REVIEW (early staff trigger) and PAYMENT (batch run + top-ups). + * Export: FCFS while the booking window is open. + */ + isFillable(schedule: TrainSchedule): boolean { + if (schedule.bookingWindowStatus === "FULL") return false; + if (!schedule.windowPhase) return schedule.bookingWindowStatus === "OPEN"; + if (schedule.direction === "EXPORT") { + return schedule.windowPhase === "OPEN" && schedule.bookingWindowStatus === "OPEN"; + } + return schedule.windowPhase === "DOC_REVIEW" || schedule.windowPhase === "PAYMENT"; + } + /** Fill one schedule from its priority-ordered pool until full. */ async fillSchedule(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (!schedule || schedule.bookingWindowStatus !== "OPEN") return; + if (!schedule || !this.isFillable(schedule)) return; const locomotive = schedule.trainSet?.locomotive; if (!schedule.trainSetId || !locomotive) { this.logger.warn( @@ -793,22 +950,33 @@ export class BookingBatchService implements OnModuleInit { destinationYardId: string, day: string, ): Promise { - // The day's OPEN bookable schedules on this exact corridor, earliest first. - const bookable = await this.trainSchedulingService.getBookableSchedules( - originYardId, - destinationYardId, - ); - const scheduleIds = bookable + // The day's fillable schedules on this exact corridor, earliest first. Fillable + // covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT — + // the batch must run while the customer window is closed. + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { + originStationId: originYardId, + destinationStationId: destinationYardId, + status: TrainScheduleStatusEnum.Draft, + }, + { + originStationId: originYardId, + destinationStationId: destinationYardId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }); + const scheduleIds = corridor .filter( (s) => - s.bookingWindowStatus === "OPEN" && - s.scheduleDate != null && - eatDay(new Date(s.scheduleDate)) === day, + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + this.isFillable(s), ) .sort( (a, b) => - new Date(a.scheduleDate).getTime() - - new Date(b.scheduleDate).getTime(), + a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(), ) .map((s) => s.id); @@ -870,7 +1038,33 @@ export class BookingBatchService implements OnModuleInit { } if (!target) { - // Fits no train this day — stays in the pool, retried next batch. + // Fits no train whole. Import GENERAL-contract commercial bookings get a + // partial-capacity offer on the train with the most free wagons: pay = + // accept the split (remainder returns to the contract cap), no pay = + // booking stays whole and expires for this train. + const partialTarget = [...trains] + .filter((t) => t.budget.wagons >= 1) + .sort((a, b) => b.budget.wagons - a.budget.wagons)[0]; + if ( + partialTarget && + !booking.isGovernment && + booking.tradeDirection === "IMPORT" && + booking.contractKind === "GENERAL" && + this.splitService + ) { + const offered = await this.tryPartialOffer( + booking, + partialTarget.id, + partialTarget.budget, + need, + ); + if (offered) { + partialTarget.budget = this.subtract(partialTarget.budget, offered); + partialTarget.armed = true; + continue; + } + } + // Stays in the pool, retried next batch/window cycle. this.notifier.unplaced(booking, day); continue; } @@ -893,6 +1087,62 @@ export class BookingBatchService implements OnModuleInit { return trains.map((t) => t.id); } + /** + * Offer the largest fitting part of an over-capacity booking as a partial + * (split-on-payment). Returns the capacity the offer consumes, or null when no + * meaningful partial fits / an offer is already open. + */ + private async tryPartialOffer( + booking: Booking, + scheduleId: string, + budget: Capacity, + need: Capacity, + ): Promise { + if (!this.splitService) return null; + if (await this.splitService.findOpenOffer(booking.id)) return null; + + const wagonLengths = await this.loadWagonLengths(); + const bulkCapacityTons = await this.loadBulkWagonCapacityTons(); + const sized = await this.splitService.sizeOffer( + booking, + budget.wagons, + need.wagons, + bulkCapacityTons, + ); + if (!sized) return null; + + const offeredNeed: Capacity = { + wagons: sized.offeredWagons, + weightTons: sized.offeredWeightTons, + lengthMeters: bookingTrainLengthMeters(booking.freightType, sized.offeredWagons, { + container: wagonLengths.container, + bulk: wagonLengths.bulk, + }), + }; + if (!this.fits(offeredNeed, budget)) return null; + + const deadline = new Date(Date.now() + (await this.paymentWindowMs())); + await this.splitService.createOffer(booking, scheduleId, sized, deadline); + // Reserve like a normal batch selection, but the partial invoice + partial + // pay-now notification were already produced by createOffer. + await this.bookingsRepository.update(booking.id, { + trainScheduleId: scheduleId, + status: "SELECTED_FOR_BATCH", + selectedForBatchAt: new Date(), + paymentDeadline: deadline, + } as never); + booking.trainScheduleId = scheduleId; + return offeredNeed; + } + + private async loadBulkWagonCapacityTons(): Promise { + const cw3 = await this.dataSource + .getRepository(WagonType) + .findOne({ where: { code: "CW3" } }); + const capacity = cw3 ? wagonTypeDimensionsFromEntity(cw3).capacityTons : 60; + return capacity > 0 ? capacity : 60; + } + /** Durable settle: allocate paid / expire overdue reservations, then top up. */ async settleDueReservations(scheduleId: string): Promise { const reserved = @@ -1063,7 +1313,7 @@ export class BookingBatchService implements OnModuleInit { */ private async reserve(booking: Booking, scheduleId: string): Promise { const now = new Date(); - const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS); + const deadline = new Date(now.getTime() + (await this.paymentWindowMs())); await this.bookingsRepository.update(booking.id, { trainScheduleId: scheduleId, status: "SELECTED_FOR_BATCH", @@ -1136,6 +1386,10 @@ export class BookingBatchService implements OnModuleInit { selectedForBatchAt: null, } as never); booking.trainScheduleId = null; + // An unpaid partial offer dies with the reservation — the booking stays whole. + if (this.splitService) { + await this.splitService.expireOpenOffer(booking.id); + } // Pay window closed before settlement → expire the booking's open invoice too // (emits `booking.invoice.expired`). Domain owns the reaction; billing stays // source-agnostic. @@ -1359,7 +1613,7 @@ export class BookingBatchService implements OnModuleInit { return (schedule.maxWagons ?? 0) - used; } - private async setWindow( + async setWindow( scheduleId: string, status: "OPEN" | "FULL" | "CLOSED", ): Promise { @@ -1368,22 +1622,48 @@ export class BookingBatchService implements OnModuleInit { .update(scheduleId, { bookingWindowStatus: status }); } + /** No wagon slots left for allocated + reserved bookings. */ + async isScheduleFull(scheduleId: string): Promise { + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) return false; + return (await this.remainingWagons(schedule)) <= 0; + } + // ---- timer plumbing ------------------------------------------------------- + /** Configured customer pay window in ms (global rules, with defaults). */ + private async paymentWindowMs(): Promise { + const cfg = await this.trainSchedulingService.getWindowConfig(); + return cfg.paymentWindowMinutes * 60_000; + } + private timeoutName(scheduleId: string): string { return `settle:${scheduleId}`; } + /** + * In-process accelerator only — the durable settle enforcement is the window + * engine's minute tick calling settleDueReservations off `paymentDeadline`. + */ private armSettle(scheduleId: string): void { - this.removeTimeout(scheduleId); - const handle = setTimeout(() => { - void this.settleBatch(scheduleId).catch((err) => - this.logger.error( - `settleBatch ${scheduleId} failed: ${(err as Error).message}`, + void this.paymentWindowMs() + .then((delayMs) => { + this.removeTimeout(scheduleId); + const handle = setTimeout(() => { + void this.settleBatch(scheduleId).catch((err) => + this.logger.error( + `settleBatch ${scheduleId} failed: ${(err as Error).message}`, + ), + ); + }, delayMs); + this.scheduler.addTimeout(this.timeoutName(scheduleId), handle); + }) + .catch((err) => + this.logger.warn( + `armSettle ${scheduleId} skipped: ${(err as Error).message}`, ), ); - }, PAYMENT_WINDOW_MS); - this.scheduler.addTimeout(this.timeoutName(scheduleId), handle); } private removeTimeout(scheduleId: string): void { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index e8f272123..49df19758 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { Booking } from '../bookings/entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; -import { PAYMENT_WINDOW_MS } from './booking-batch.constants'; @Injectable() export class BookingNotifierService { @@ -43,12 +42,32 @@ export class BookingNotifierService { } async payNow(b: Booking, deadline: Date): Promise { - const payMinutes = Math.round(PAYMENT_WINDOW_MS / 60_000); + const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`; await this.notifyContact(b, msg, 'PAY NOW'); } + /** + * Partial-capacity offer: only `offeredWagons` of the booking's `totalWagons` fit + * this train. Paying accepts the split; letting the deadline pass keeps the + * booking whole and expires it for this train. + */ + async payNowPartial( + b: Booking, + deadline: Date, + offeredWagons: number, + totalWagons: number, + ): Promise { + const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); + const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); + const msg = + `Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` + + `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` + + `(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`; + await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)'); + } + secured(b: Booking, reason: 'paid' | 'gov'): void { const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${ reason === 'gov' ? ' (government)' : '' diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts new file mode 100644 index 000000000..2cd2df6d9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts @@ -0,0 +1,270 @@ +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { Freight } from '@edr/types'; + +import { BookingPricingService } from '../bookings/booking-pricing.service'; +import { BookingInvoiceService } from '../bookings/booking-invoice.service'; +import { BillingService } from '../billing/billing.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; +import { + BookingBatchOffer, + OfferedLine, +} from './entities/booking-batch-offer.entity'; +import { BookingNotifierService } from './booking-notifier.service'; + +export interface SizedOffer { + offeredWagons: number; + totalWagons: number; + offeredLines: OfferedLine[] | null; + offeredWeightTons: number; + offeredAmount: number; + offeredPricingBreakdown: Record; +} + +/** + * Partial-capacity booking splits (import batch). The offer is sized and priced + * against an in-memory clone — the booking row is untouched until the customer + * pays, which is the act of accepting the split (applySplit). No payment → + * offer expires and the booking stays whole. + * + * Only GENERAL-contract commercial bookings are offered partials: the remainder + * returns to the contract's quantity cap (derived live from booking_container + * rows, so reducing the lines releases it automatically) and can be rebooked in + * any later window within contract validity. + */ +@Injectable() +export class BookingSplitService { + private readonly logger = new Logger(BookingSplitService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + @Inject(forwardRef(() => BookingPricingService)) + private readonly pricing: BookingPricingService, + @Inject(forwardRef(() => BookingInvoiceService)) + private readonly invoiceService: BookingInvoiceService, + private readonly billing: BillingService, + private readonly notifier: BookingNotifierService, + ) {} + + /** + * Size the largest part of the booking that fits `freeWagons`, priced via an + * in-memory clone. Returns null when nothing meaningful fits (no whole + * container unit / no bulk tonnage, or pricing failed). + */ + async sizeOffer( + booking: Booking, + freeWagons: number, + totalWagons: number, + bulkWagonCapacityTons: number, + ): Promise { + if (freeWagons < 1 || freeWagons >= totalWagons) return null; + + const containers = booking.bookingContainers ?? []; + let offeredLines: OfferedLine[] | null = null; + let offeredWeightTons = 0; + let offeredWagons = 0; + const clone: Booking = Object.assign(Object.create(Object.getPrototypeOf(booking)), booking); + clone.adjustedTotalAmount = null; + + if (containers.length) { + offeredLines = []; + let remaining = freeWagons; + const clonedContainers: BookingContainer[] = []; + for (const line of containers) { + const quantity = Number(line.quantity ?? 0); + const lineWagons = Number(line.wagonsRequired ?? 0); + if (quantity <= 0 || lineWagons <= 0 || remaining <= 0) continue; + const perUnit = lineWagons / quantity; + // Largest unit count whose wagon need still fits the remaining budget. + let take = Math.min(quantity, Math.floor(remaining / perUnit)); + while (take > 0 && Math.ceil(take * perUnit) > remaining) take -= 1; + if (take <= 0) continue; + const takeWagons = Math.ceil(take * perUnit); + const vgmPerUnit = Number(line.vgmPerUnitTons ?? 0); + offeredLines.push({ + bookingContainerId: line.id, + quantity: take, + wagonsRequired: takeWagons, + totalVgmTons: Math.round(take * vgmPerUnit * 1000) / 1000, + }); + offeredWeightTons += take * vgmPerUnit; + offeredWagons += takeWagons; + remaining -= takeWagons; + + const clonedLine: BookingContainer = Object.assign( + Object.create(Object.getPrototypeOf(line)), + line, + { + quantity: take, + wagonsRequired: takeWagons, + totalVgmTons: take * vgmPerUnit, + }, + ); + clonedContainers.push(clonedLine); + } + if (!offeredLines.length || offeredWagons <= 0) return null; + clone.bookingContainers = clonedContainers; + } else { + // Bulk: split by weight — the offered part is what freeWagons can carry. + const totalWeight = Number(booking.cargoTotalWeightVgm ?? 0); + if (totalWeight <= 0 || bulkWagonCapacityTons <= 0) return null; + offeredWeightTons = Math.min(totalWeight, freeWagons * bulkWagonCapacityTons); + if (offeredWeightTons <= 0) return null; + offeredWagons = Math.min( + freeWagons, + Math.max(1, Math.ceil(offeredWeightTons / bulkWagonCapacityTons)), + ); + } + + offeredWeightTons = Math.round(offeredWeightTons * 1000) / 1000; + clone.cargoTotalWeightVgm = offeredWeightTons; + clone.wagonsRequired = offeredWagons; + + try { + const priced = await this.pricing.computePriceForBooking(clone); + return { + offeredWagons, + totalWagons, + offeredLines, + offeredWeightTons, + offeredAmount: priced.totalAmount, + offeredPricingBreakdown: { + lineItems: priced.lineItems, + totalAmount: priced.totalAmount, + currency: priced.currency, + generatedAt: new Date().toISOString(), + partialOfWagons: totalWagons, + }, + }; + } catch (err) { + this.logger.warn( + `Partial pricing failed for ${booking.reference ?? booking.id}: ${(err as Error).message}`, + ); + return null; + } + } + + /** + * Persist the offer and swap the booking's payable to a partial invoice for the + * offered amount. Any previous open offer for the booking is superseded. + */ + async createOffer( + booking: Booking, + scheduleId: string, + sized: SizedOffer, + deadline: Date, + ): Promise { + const repo = this.dataSource.getRepository(BookingBatchOffer); + await repo.update({ bookingId: booking.id, status: 'OFFERED' }, { status: 'EXPIRED' }); + + // The full-amount invoice must not stay payable next to the partial one. + await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, 'PREPAID'); + const invoice = await this.invoiceService.ensureInvoiceForBooking( + { ...booking, pricingBreakdown: sized.offeredPricingBreakdown, adjustedTotalAmount: null } as Booking, + { dueDate: deadline, invoiceStatus: Freight.InvoiceStatus.Pending }, + ); + + const offer = await repo.save( + repo.create({ + bookingId: booking.id, + trainScheduleId: scheduleId, + offeredWagons: sized.offeredWagons, + totalWagons: sized.totalWagons, + offeredLines: sized.offeredLines, + offeredWeightTons: sized.offeredWeightTons, + offeredAmount: sized.offeredAmount, + offeredPricingBreakdown: sized.offeredPricingBreakdown, + invoiceId: invoice.id, + paymentDeadline: deadline, + status: 'OFFERED', + }), + ); + await this.notifier.payNowPartial(booking, deadline, sized.offeredWagons, sized.totalWagons); + return offer; + } + + /** + * Payment received inside the window — the customer accepted the split. + * Reduce the booking to the offered lines/weight; the remainder returns to the + * contract cap automatically (bookedQuantities derives from live lines). + * Idempotent: no OFFERED offer → no-op. + */ + async applySplit(bookingId: string): Promise { + const offer = await this.dataSource.getRepository(BookingBatchOffer).findOne({ + where: { bookingId, status: 'OFFERED' }, + order: { createdAt: 'DESC' }, + }); + if (!offer) return; + + await this.dataSource.transaction(async (manager) => { + if (offer.offeredLines?.length) { + const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l])); + const lines = await manager.getRepository(BookingContainer).find({ + where: { bookingId }, + }); + for (const line of lines) { + const kept = keptByLine.get(line.id); + if (!kept) { + await manager.getRepository(BookingContainer).softDelete(line.id); + await manager + .getRepository(BookingContainerUnit) + .softDelete({ bookingContainerId: line.id }); + continue; + } + const dropCount = Number(line.quantity) - kept.quantity; + await manager.getRepository(BookingContainer).update(line.id, { + quantity: kept.quantity, + wagonsRequired: kept.wagonsRequired, + totalVgmTons: kept.totalVgmTons, + hazardousQuantity: Math.min(Number(line.hazardousQuantity ?? 0), kept.quantity), + reeferQuantity: Math.min(Number(line.reeferQuantity ?? 0), kept.quantity), + }); + if (dropCount > 0) { + // Trim surplus physical units, last-entered first. + const units = await manager.getRepository(BookingContainerUnit).find({ + where: { bookingContainerId: line.id }, + order: { sortOrder: 'DESC', createdAt: 'DESC' }, + take: dropCount, + }); + if (units.length) { + await manager + .getRepository(BookingContainerUnit) + .softDelete(units.map((u) => u.id)); + } + } + } + } + + await manager.getRepository(Booking).update(bookingId, { + wagonsRequired: offer.offeredWagons, + cargoTotalWeightVgm: offer.offeredWeightTons, + totalAmount: offer.offeredAmount, + pricingBreakdown: offer.offeredPricingBreakdown, + } as never); + + await manager + .getRepository(BookingBatchOffer) + .update(offer.id, { status: 'APPLIED' }); + }); + this.logger.log( + `Split applied for booking ${bookingId}: ${offer.offeredWagons}/${offer.totalWagons} wagons ride schedule ${offer.trainScheduleId}`, + ); + } + + /** Pay window closed without payment — offer dies, booking stays whole. */ + async expireOpenOffer(bookingId: string): Promise { + await this.dataSource + .getRepository(BookingBatchOffer) + .update({ bookingId, status: 'OFFERED' }, { status: 'EXPIRED' }); + } + + async findOpenOffer(bookingId: string): Promise { + return this.dataSource.getRepository(BookingBatchOffer).findOne({ + where: { bookingId, status: 'OFFERED' }, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts new file mode 100644 index 000000000..c694eed11 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts @@ -0,0 +1,30 @@ +/** + * Booking-window timings sourced from the train_scheduling_global_rules singleton, + * with hardcoded fallbacks when the row is missing (see TrainSchedulingService.getWindowConfig). + */ +export interface BookingWindowConfig { + /** Days before departure the single import booking-window day falls on. */ + importWindowLeadDays: number; + /** Hours before departure an export booking becomes acceptable (FCFS). */ + exportBookingLeadHours: number; + /** Local (Africa/Addis_Ababa) hour at which the import window opens. */ + windowOpenHour: number; + windowDurationHours: number; + /** Max staff document-review time after the window closes. */ + docReviewMinutes: number; + paymentWindowMinutes: number; + /** Delay after window close before reopening when the train is not full. */ + reopenDelayMinutes: number; +} + +/** Window phase lifecycle for the one-booking-day import cycle. NULL on legacy/DOMESTIC schedules. */ +export const WINDOW_PHASES = [ + 'PRE_WINDOW', + 'OPEN', + 'DOC_REVIEW', + 'PAYMENT', + 'CLOSED_FOR_DAY', + 'DONE', +] as const; + +export type WindowPhase = (typeof WINDOW_PHASES)[number]; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts new file mode 100644 index 000000000..f2fe5db07 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -0,0 +1,346 @@ +import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { BookingBatchService } from './booking-batch.service'; +import { TrainSchedulingService } from './train-scheduling.service'; +import { BATCH_TIMEZONE } from './booking-batch.constants'; +import { eatDay } from './batch-window.util'; +import { type BookingWindowConfig } from './booking-window.config'; + +/** + * Drives the one-booking-day window cycle for IMPORT schedules and the FCFS + * booking window for EXPORT schedules. All state lives in DB timestamps on the + * schedule row, so every transition is derived purely from the clock — a restart + * resumes mid-phase with no loss (onModuleInit runs one tick immediately). + * + * Import phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW (staff accept + * documents) → PAYMENT (batch reserves in priority order, customers pay) → + * reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized). + * Export phases: PRE_WINDOW → OPEN → DONE (no batch, no priority). + * Legacy/DOMESTIC schedules have windowPhase NULL and are served by the legacy + * fill (runBatchFill), which this tick invokes every 5th minute. + */ +@Injectable() +export class BookingWindowService implements OnModuleInit { + private readonly logger = new Logger(BookingWindowService.name); + private ticking = false; + private tickCount = 0; + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly trainSchedulesRepository: TrainSchedulesRepository, + private readonly bookingBatchService: BookingBatchService, + private readonly trainSchedulingService: TrainSchedulingService, + ) {} + + async onModuleInit(): Promise { + await this.tick().catch((err) => + this.logger.warn(`Boot window tick failed: ${(err as Error).message}`), + ); + } + + @Cron('* * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE }) + async tick(): Promise { + if (this.ticking) return; + this.ticking = true; + try { + const now = new Date(); + const cfg = await this.trainSchedulingService.getWindowConfig(); + + const active = ( + await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, + ], + }) + ).filter( + (s) => s.windowPhase != null && s.windowPhase !== 'DONE' && s.windowPhase !== 'CLOSED_FOR_DAY', + ); + + for (const schedule of active) { + try { + await this.advanceSchedule(schedule, cfg, now); + } catch (err) { + this.logger.error( + `Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`, + ); + } + } + + await this.settleOverdueReservations(); + + // Legacy fill (DOMESTIC / pre-migration schedules) every 5th tick. + this.tickCount += 1; + if (this.tickCount % 5 === 0) { + await this.bookingBatchService.runBatchFill(); + } + } finally { + this.ticking = false; + } + } + + /** Staff finished document review early — start the batch/payment phase now. */ + async completeDocReview(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.windowPhase !== 'DOC_REVIEW') { + // Idempotent for the whole route-day group: only DOC_REVIEW schedules move. + return schedule; + } + const now = new Date(); + const cfg = await this.trainSchedulingService.getWindowConfig(); + // Stamp the whole route-day group so one staff action releases every train + // sharing this booking day's pool. + const group = ( + await this.trainSchedulesRepository.findAll({ + where: { + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + }, + }) + ).filter( + (s) => + s.windowPhase === 'DOC_REVIEW' && + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === eatDay(schedule.scheduledDepartureDate), + ); + for (const s of group) { + await this.dataSource + .getRepository(TrainSchedule) + .update(s.id, { docReviewCompletedAt: now }); + s.docReviewCompletedAt = now; + await this.advanceSchedule(s, cfg, now); + } + const fresh = await this.trainSchedulesRepository.findById(scheduleId); + return fresh ?? schedule; + } + + // ---- transitions ------------------------------------------------------------ + + private async advanceSchedule( + schedule: TrainSchedule, + cfg: BookingWindowConfig, + now: Date, + ): Promise { + // Apply every transition that is due, in order (fast-forwards after downtime). + for (let guard = 0; guard < 6; guard += 1) { + const advanced = + schedule.direction === 'EXPORT' + ? await this.advanceExport(schedule, now) + : await this.advanceImport(schedule, cfg, now); + if (!advanced) return; + } + } + + /** Export: PRE_WINDOW → OPEN at opensAt, OPEN → DONE at closesAt (= departure). */ + private async advanceExport(schedule: TrainSchedule, now: Date): Promise { + if ( + schedule.windowPhase === 'PRE_WINDOW' && + schedule.windowOpensAt && + now >= schedule.windowOpensAt + ) { + await this.setPhase(schedule, { + windowPhase: 'OPEN', + bookingCycleNo: schedule.bookingCycleNo + 1, + }); + if (schedule.bookingWindowStatus !== 'FULL') { + await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); + schedule.bookingWindowStatus = 'OPEN'; + } + this.logger.log(`Export booking window opened for schedule ${schedule.id}`); + return true; + } + if ( + schedule.windowPhase === 'OPEN' && + schedule.windowClosesAt && + now >= schedule.windowClosesAt + ) { + await this.setPhase(schedule, { windowPhase: 'DONE' }); + if (schedule.bookingWindowStatus === 'OPEN') { + await this.bookingBatchService.setWindow(schedule.id, 'CLOSED'); + schedule.bookingWindowStatus = 'CLOSED'; + } + return true; + } + return false; + } + + private async advanceImport( + schedule: TrainSchedule, + cfg: BookingWindowConfig, + now: Date, + ): Promise { + const { windowPhase, windowOpensAt, windowClosesAt } = schedule; + + if (windowPhase === 'PRE_WINDOW' && windowOpensAt && now >= windowOpensAt) { + await this.setPhase(schedule, { + windowPhase: 'OPEN', + bookingCycleNo: schedule.bookingCycleNo + 1, + docReviewCompletedAt: null, + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + }); + if (schedule.bookingWindowStatus !== 'FULL') { + await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); + schedule.bookingWindowStatus = 'OPEN'; + } + this.logger.log( + `Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`, + ); + return true; + } + + if (windowPhase === 'OPEN' && windowClosesAt && now >= windowClosesAt) { + const docReviewEndsAt = new Date( + windowClosesAt.getTime() + cfg.docReviewMinutes * 60_000, + ); + await this.setPhase(schedule, { windowPhase: 'DOC_REVIEW', docReviewEndsAt }); + if (schedule.bookingWindowStatus === 'OPEN') { + await this.bookingBatchService.setWindow(schedule.id, 'CLOSED'); + schedule.bookingWindowStatus = 'CLOSED'; + } + this.logger.log( + `Booking stopped for schedule ${schedule.id}; staff document review until ${docReviewEndsAt.toISOString()}`, + ); + return true; + } + + if ( + windowPhase === 'DOC_REVIEW' && + (schedule.docReviewCompletedAt != null || + (schedule.docReviewEndsAt != null && now >= schedule.docReviewEndsAt)) + ) { + const paymentPhaseEndsAt = new Date(now.getTime() + cfg.paymentWindowMinutes * 60_000); + await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt }); + // Run the batch: priority fill over the route-day pool, reserving pay windows + // (or allocating government) — skipped automatically for everyone who fits + // is handled inside the fill (all fit → all reserved → all notified). + await this.bookingBatchService.processRouteDay({ + originYardId: schedule.originStationId, + destinationYardId: schedule.destinationStationId, + day: eatDay(schedule.scheduledDepartureDate), + }); + this.logger.log( + `Batch ran for schedule ${schedule.id}; payment phase until ${paymentPhaseEndsAt.toISOString()}`, + ); + return true; + } + + if ( + windowPhase === 'PAYMENT' && + schedule.paymentPhaseEndsAt != null && + now >= schedule.paymentPhaseEndsAt + ) { + await this.bookingBatchService.settleDueReservations(schedule.id); + await this.concludeCycle(schedule, cfg, now); + return true; + } + + return false; + } + + /** After settle: full → finalize + DONE; space left → reopen same day or close for the day. */ + private async concludeCycle( + schedule: TrainSchedule, + cfg: BookingWindowConfig, + now: Date, + ): Promise { + const full = await this.bookingBatchService.isScheduleFull(schedule.id); + if (full) { + await this.bookingBatchService.setWindow(schedule.id, 'FULL'); + await this.setPhase(schedule, { windowPhase: 'DONE' }); + await this.tryAutoFinalize(schedule.id); + return; + } + + const closesAt = schedule.windowClosesAt ?? now; + const reopenAt = new Date(closesAt.getTime() + cfg.reopenDelayMinutes * 60_000); + const nextOpensAt = reopenAt > now ? reopenAt : now; + let nextClosesAt = new Date(nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000); + if (nextClosesAt > schedule.scheduledDepartureDate) { + nextClosesAt = schedule.scheduledDepartureDate; + } + + const sameBookingDay = eatDay(nextOpensAt) === eatDay(closesAt); + const beforeDeparture = nextOpensAt < schedule.scheduledDepartureDate; + if (sameBookingDay && beforeDeparture) { + await this.setPhase(schedule, { + windowPhase: 'PRE_WINDOW', + windowOpensAt: nextOpensAt, + windowClosesAt: nextClosesAt, + docReviewCompletedAt: null, + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + }); + this.logger.log( + `Schedule ${schedule.id} not full — window reopens at ${nextOpensAt.toISOString()}`, + ); + } else { + await this.setPhase(schedule, { windowPhase: 'CLOSED_FOR_DAY' }); + this.logger.log( + `Booking day over for schedule ${schedule.id} — remaining capacity is staff-managed`, + ); + } + } + + private async tryAutoFinalize(scheduleId: string): Promise { + try { + await this.trainSchedulingService.finalizeSchedule(scheduleId); + this.logger.log(`Schedule ${scheduleId} is full — auto-finalized`); + } catch (err) { + // Not DRAFT / no linked bookings yet — staff finalize manually. + this.logger.warn( + `Auto-finalize skipped for ${scheduleId}: ${(err as Error).message}`, + ); + } + } + + /** Durable settle backstop: expire/allocate reservations whose deadline passed. */ + private async settleOverdueReservations(): Promise { + const overdue = await this.dataSource + .getRepository(Booking) + .createQueryBuilder('b') + .select('DISTINCT b.train_schedule_id', 'scheduleId') + .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .andWhere('b.payment_deadline <= now()') + .andWhere('b.train_schedule_id IS NOT NULL') + .getRawMany<{ scheduleId: string }>(); + for (const { scheduleId } of overdue) { + try { + await this.bookingBatchService.settleDueReservations(scheduleId); + } catch (err) { + this.logger.warn( + `Overdue settle failed for ${scheduleId}: ${(err as Error).message}`, + ); + } + } + } + + private async setPhase( + schedule: TrainSchedule, + patch: Partial< + Pick< + TrainSchedule, + | 'windowPhase' + | 'windowOpensAt' + | 'windowClosesAt' + | 'docReviewEndsAt' + | 'docReviewCompletedAt' + | 'paymentPhaseEndsAt' + | 'bookingCycleNo' + > + >, + ): Promise { + await this.dataSource.getRepository(TrainSchedule).update(schedule.id, patch); + Object.assign(schedule, patch); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index d47195976..1171b0c90 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -1,6 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsInt, IsNumber, IsOptional, Min } from 'class-validator'; +import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator'; export class UpdateTrainSchedulingGlobalRulesDto { @ApiPropertyOptional({ example: 760 }) @@ -37,4 +37,55 @@ export class UpdateTrainSchedulingGlobalRulesDto { @IsNumber() @Min(0) max20ftPairWeightDiffTons?: number; + + @ApiPropertyOptional({ example: 3, description: 'Days before departure the import booking-window day falls on' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + importWindowLeadDays?: number; + + @ApiPropertyOptional({ example: 24, description: 'Hours before departure an export booking becomes acceptable' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + exportBookingLeadHours?: number; + + @ApiPropertyOptional({ example: 8, description: 'Local EAT hour the import window opens' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(23) + windowOpenHour?: number; + + @ApiPropertyOptional({ example: 3 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0.25) + @Max(12) + windowDurationHours?: number; + + @ApiPropertyOptional({ example: 30 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + docReviewMinutes?: number; + + @ApiPropertyOptional({ example: 60 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + paymentWindowMinutes?: number; + + @ApiPropertyOptional({ example: 90 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + reopenDelayMinutes?: number; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/booking-batch-offer.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/booking-batch-offer.entity.ts new file mode 100644 index 000000000..4b6f7c685 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/booking-batch-offer.entity.ts @@ -0,0 +1,75 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; + +export const BOOKING_BATCH_OFFER_STATUSES = ['OFFERED', 'APPLIED', 'EXPIRED'] as const; +export type BookingBatchOfferStatus = (typeof BOOKING_BATCH_OFFER_STATUSES)[number]; + +/** One reduced container line of a partial offer (per original booking_container row). */ +export interface OfferedLine { + bookingContainerId: string; + /** Units of this line that ride the offered train (≤ original quantity). */ + quantity: number; + wagonsRequired: number; + totalVgmTons: number; +} + +/** + * A partial-capacity payment offer made by the batch when a booking needs more + * wagons than the train has left (e.g. needs 20, 3 free). The booking itself is + * NOT mutated at offer time — paying inside the window accepts the split + * (BookingSplitService.applySplit reduces the booking to the offered lines and + * the remainder returns to the contract's quantity cap); letting the deadline + * pass expires the offer and the booking stays whole. + */ +@Entity({ schema: 'freight', name: 'booking_batch_offers' }) +@Index(['bookingId']) +@Index(['trainScheduleId']) +@Index(['status']) +export class BookingBatchOffer extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @ManyToOne(() => TrainSchedule, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'train_schedule_id' }) + trainSchedule?: TrainSchedule; + + @Column({ name: 'offered_wagons', type: 'int' }) + offeredWagons!: number; + + /** Booking's full wagon need at offer time (for messaging / audit). */ + @Column({ name: 'total_wagons', type: 'int' }) + totalWagons!: number; + + /** Reduced container lines (null for bulk offers — bulk splits by weight). */ + @Column({ name: 'offered_lines', type: 'jsonb', nullable: true }) + offeredLines?: OfferedLine[] | null; + + @Column({ name: 'offered_weight_tons', type: 'numeric', precision: 12, scale: 3 }) + offeredWeightTons!: number; + + @Column({ name: 'offered_amount', type: 'numeric', precision: 14, scale: 2 }) + offeredAmount!: number; + + @Column({ name: 'offered_pricing_breakdown', type: 'jsonb', nullable: true }) + offeredPricingBreakdown?: Record | null; + + /** The partial PREPAID invoice generated for the offered part. */ + @Column({ name: 'invoice_id', type: 'uuid', nullable: true }) + invoiceId?: string | null; + + @Column({ name: 'payment_deadline', type: 'timestamptz' }) + paymentDeadline!: Date; + + @Column({ name: 'status', type: 'varchar', length: 10, default: 'OFFERED' }) + status!: BookingBatchOfferStatus; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index 326915933..7b8d9b26a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -41,4 +41,36 @@ export class TrainSchedulingGlobalRules extends BaseEntity { default: 10, }) max20ftPairWeightDiffTons!: number; + + /** Days before departure the single import booking-window day falls on. */ + @Column({ name: 'import_window_lead_days', type: 'int', default: 3 }) + importWindowLeadDays!: number; + + /** Hours before departure an export booking becomes acceptable (FCFS, no window cycle). */ + @Column({ name: 'export_booking_lead_hours', type: 'int', default: 24 }) + exportBookingLeadHours!: number; + + /** Local (Africa/Addis_Ababa) hour at which the import window opens on its window day. */ + @Column({ name: 'window_open_hour', type: 'int', default: 8 }) + windowOpenHour!: number; + + @Column({ + name: 'window_duration_hours', + type: 'numeric', + precision: 4, + scale: 2, + default: 3, + }) + windowDurationHours!: number; + + /** Max time staff have to accept booking documents after the window closes. */ + @Column({ name: 'doc_review_minutes', type: 'int', default: 30 }) + docReviewMinutes!: number; + + @Column({ name: 'payment_window_minutes', type: 'int', default: 60 }) + paymentWindowMinutes!: number; + + /** Delay after window close before the window reopens when the train is not yet full. */ + @Column({ name: 'reopen_delay_minutes', type: 'int', default: 90 }) + reopenDelayMinutes!: number; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 1c15ab801..8887fb4c6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -43,6 +43,8 @@ import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-qu import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto"; import { TrainSchedulingService } from "./train-scheduling.service"; import { BookingBatchService } from "./booking-batch.service"; +import { BookingWindowService } from "./booking-window.service"; +import { BillingService } from "../billing/billing.service"; @ApiTags("train-scheduling") @ApiBearerAuth() @@ -51,8 +53,23 @@ export class TrainSchedulingController { constructor( private readonly trainSchedulingService: TrainSchedulingService, private readonly bookingBatchService: BookingBatchService, + private readonly bookingWindowService: BookingWindowService, + private readonly billingService: BillingService, ) { } + @Get("my-booking-windows") + @ApiOperation({ + summary: + "Upcoming/open booking windows on the signed-in customer's active contract lanes", + }) + async getMyBookingWindows(@CurrentUser() user: AuthUserPayload) { + const companyId = await this.billingService.resolveCompanyId( + resolveAuthUserId(user), + ); + if (!companyId) return []; + return this.trainSchedulingService.getBookingWindowsForCompany(companyId); + } + @Get("global-rules") @TrainSchedulingView() @ApiOperation({ summary: "Get global train scheduling rules (singleton)" }) @@ -457,6 +474,17 @@ export class TrainSchedulingController { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Post("schedules/:id/doc-review-complete") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)", + }) + async completeDocReview(@Param("id", ParseUUIDPipe) id: string) { + await this.bookingWindowService.completeDocReview(id); + return this.bookingBatchService.getBatchBoardDetail(id); + } + @Post("bookings/:bookingId/mark-paid") @TrainSchedulingManage() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 0f2e22076..7e281a72f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -25,6 +25,9 @@ import { TrainSchedulingController } from './train-scheduling.controller'; import { TrainSchedulingService } from './train-scheduling.service'; import { BookingBatchService } from './booking-batch.service'; import { BookingNotifierService } from './booking-notifier.service'; +import { BookingWindowService } from './booking-window.service'; +import { BookingSplitService } from './booking-split.service'; +import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; import { NotificationsModule } from '../notifications/notifications.module'; import { ContractsModule } from '../contracts/contracts.module'; @@ -42,6 +45,7 @@ import { ContractsModule } from '../contracts/contracts.module'; TrainSchedulingGlobalRules, TrainCheckpointEvent, ImportDjiboutiOperation, + BookingBatchOffer, ]), forwardRef(() => BookingsModule), BillingModule, @@ -60,7 +64,9 @@ import { ContractsModule } from '../contracts/contracts.module'; TrainCheckpointEventsRepository, BookingBatchService, BookingNotifierService, + BookingWindowService, + BookingSplitService, ], - exports: [TrainSchedulingService, BookingBatchService], + exports: [TrainSchedulingService, BookingBatchService, BookingWindowService], }) export class TrainSchedulingModule {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index e87dbdd88..ce835e1e4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -13,7 +13,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, EntityManager, In } from 'typeorm'; +import { DataSource, EntityManager, In, Not } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; @@ -58,6 +58,7 @@ import { UploadImportDjiboutiDocumentDto, } from './dto/import-djibouti-operation.dto'; import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; +import { type BookingWindowConfig } from './booking-window.config'; import { buildCappedWagonPlan, computeFleetAvailability, @@ -98,7 +99,11 @@ import { DEFAULT_BULK_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, } from './booking-batch.constants'; -import { eatDay } from './batch-window.util'; +import { + computeExportWindowTimes, + computeImportWindowTimes, + eatDay, +} from './batch-window.util'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; import { RecordCheckpointDto } from './dto/record-checkpoint.dto'; @@ -236,9 +241,37 @@ export class TrainSchedulingService { if (dto.max20ftPairWeightDiffTons != null) { row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons; } + if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays; + if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours; + if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour; + if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours; + if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes; + if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes; + if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes; return this.dataSource.getRepository(TrainSchedulingGlobalRules).save(row); } + /** + * Booking-window timings with hardcoded fallbacks for a missing/legacy config row. + * Numeric columns come back from pg as strings — normalize every field. + */ + async getWindowConfig(): Promise { + const row = await this.loadGlobalRulesRow(); + const num = (v: unknown, fallback: number) => { + const n = v == null ? NaN : Number(v); + return Number.isFinite(n) ? n : fallback; + }; + return { + importWindowLeadDays: num(row?.importWindowLeadDays, 3), + exportBookingLeadHours: num(row?.exportBookingLeadHours, 24), + windowOpenHour: num(row?.windowOpenHour, 8), + windowDurationHours: num(row?.windowDurationHours, 3), + docReviewMinutes: num(row?.docReviewMinutes, 30), + paymentWindowMinutes: num(row?.paymentWindowMinutes, 60), + reopenDelayMinutes: num(row?.reopenDelayMinutes, 90), + }; + } + async previewTrainSchedule(dto: PreviewTrainScheduleDto) { const limits = await this.resolveTrainLimitConfig(dto); return this.buildPreviewResponse( @@ -310,8 +343,12 @@ export class TrainSchedulingService { throw new BadRequestException('A train must be pulled by at least two locomotives'); } + const scheduleWarnings: string[] = []; const createdScheduleId = await this.dataSource.transaction(async (manager) => { - // Lock and validate every locomotive: all must be AVAILABLE and at the origin yard. + // Lock every locomotive. Advance scheduling is allowed: a locomotive may sit on + // multiple future schedules and does not need to be at the origin yard yet — staff + // plan around its arrival. Only decommissioned locomotives are hard-blocked; + // everything else surfaces as a warning. const lockedLocomotives: Locomotive[] = []; for (const locomotiveId of locomotiveIds) { const locked = await manager.getRepository(Locomotive).findOne({ @@ -321,12 +358,17 @@ export class TrainSchedulingService { if (!locked) { throw new NotFoundException(`Locomotive ${locomotiveId} not found`); } + if (locked.status === 'OUT_OF_SERVICE') { + throw new ConflictException(`Locomotive ${locked.code} is out of service`); + } if (locked.status !== 'AVAILABLE') { - throw new ConflictException(`Locomotive ${locked.code} is not available`); + scheduleWarnings.push( + `Locomotive ${locked.code} is currently ${locked.status}; it must be released before this train dispatches`, + ); } if (locked.currentYardId !== route.originYardId) { - throw new ConflictException( - `Locomotive ${locked.code} is at yard ${locked.currentYardId} but schedule originates from ${route.originYardId}`, + scheduleWarnings.push( + `Locomotive ${locked.code} is not at the origin yard yet; it must arrive before this train dispatches`, ); } lockedLocomotives.push(locked); @@ -340,27 +382,46 @@ export class TrainSchedulingService { const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives); // Effective capacity is capped by the weakest locomotive in the set. const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined; + const departure = new Date(dto.scheduleDate); + // IMPORT/EXPORT trains start with a CLOSED customer window; the window engine + // opens it on schedule (import: booking day at 08:00 EAT; export: 24h lead). + // DOMESTIC keeps the legacy always-OPEN behavior (windowPhase stays NULL). + const windowCfg = await this.getWindowConfig(); + const windowFields = + direction === 'IMPORT' + ? { + bookingWindowStatus: 'CLOSED', + windowPhase: 'PRE_WINDOW', + ...computeImportWindowTimes(departure, windowCfg, new Date()), + } + : direction === 'EXPORT' + ? { + bookingWindowStatus: 'CLOSED', + windowPhase: 'PRE_WINDOW', + ...computeExportWindowTimes(departure, windowCfg), + } + : {}; const schedule = manager.getRepository(TrainSchedule).create({ trainSetId: trainSet.id, routeId: route.id, originStationId: route.originYardId, destinationStationId: route.destinationYardId, - scheduledDepartureDate: new Date(dto.scheduleDate), + scheduledDepartureDate: departure, status: TrainScheduleStatusEnum.Draft, direction, maxWagons: ( await this.resolveTrainLimitConfig(dto, limitLoco) ).maxWagonsPerTrain, + ...windowFields, }); const saved = await manager.getRepository(TrainSchedule).save(schedule); - await manager.getRepository(Locomotive).update( - { id: In(lockedLocomotives.map((l) => l.id)) }, - { status: 'ASSIGNED' }, - ); + // Locomotives stay in their current status until dispatch — advance scheduling + // must not block the locomotive from serving earlier trains. return saved.id; }); - return this.getTrainScheduleById(createdScheduleId); + const created = await this.getTrainScheduleById(createdScheduleId); + return { ...created, warnings: scheduleWarnings }; } async assignBookingsToSchedule( @@ -778,10 +839,19 @@ export class TrainSchedulingService { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } await this.assertImportDjiboutiMayDepart(schedule); + // A locomotive may sit on many future schedules, but it can only pull one train + // at a time — block dispatch while any set locomotive is out on a dispatched train. + const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); + await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId); const now = new Date(); await this.dataSource.transaction(async (manager) => { const trainNumber = await this.assignTrainNumber(manager, schedule); + if (setLocomotiveIds.length) { + await manager + .getRepository(Locomotive) + .update({ id: In(setLocomotiveIds) }, { status: 'ASSIGNED' }); + } await this.trainSchedulesRepository.updateStatus( scheduleId, @@ -1461,6 +1531,12 @@ export class TrainSchedulingService { /** Open or close a schedule's booking window (staff override). */ async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise { + if (status === 'OPEN') { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (schedule?.bookingWindowStatus === 'FULL') { + throw new ConflictException('Train is full — the booking window cannot be reopened'); + } + } await this.dataSource .getRepository(TrainSchedule) .update(scheduleId, { bookingWindowStatus: status }); @@ -1685,16 +1761,14 @@ export class TrainSchedulingService { [scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched], ); - if (schedule.trainSet?.locomotiveId) { - const loco = await manager - .getRepository(Locomotive) - .findOne({ where: { id: schedule.trainSet.locomotiveId } }); - if (loco) { - await manager.getRepository(Locomotive).update(loco.id, { - status: 'AVAILABLE', - currentYardId: schedule.destinationStationId, - }); - } + // Release every locomotive of the set (not just the legacy primary) and move it + // to the destination yard where it physically arrived. + const arrivedLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); + if (arrivedLocoIds.length) { + await manager.getRepository(Locomotive).update( + { id: In(arrivedLocoIds) }, + { status: 'AVAILABLE', currentYardId: schedule.destinationStationId }, + ); } for (const slot of schedule.trainSet?.wagons ?? []) { @@ -1769,11 +1843,21 @@ export class TrainSchedulingService { if (schedule.trainSetId) { await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' }); } + // Locomotives are only ASSIGNED while out on a dispatched train. Release ours, + // but never stomp a locomotive that is currently pulling another dispatched train. const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); if (cancelledLocoIds.length) { - await manager - .getRepository(Locomotive) - .update({ id: In(cancelledLocoIds) }, { status: 'AVAILABLE' }); + const busyElsewhere = await this.findLocomotiveIdsDispatchedElsewhere( + cancelledLocoIds, + id, + manager, + ); + const releasable = cancelledLocoIds.filter((locoId) => !busyElsewhere.has(locoId)); + if (releasable.length) { + await manager + .getRepository(Locomotive) + .update({ id: In(releasable), status: 'ASSIGNED' }, { status: 'AVAILABLE' }); + } } for (const wagon of schedule.trainSet?.wagons ?? []) { if (wagon.physicalWagonId) { @@ -2016,15 +2100,17 @@ export class TrainSchedulingService { } if (assignedLocomotives.length) { - // Every locomotive of the set must sit at the origin yard, and the weakest - // one must still be able to pull the train (min limits across the set). + // Advance scheduling: a locomotive that hasn't reached the origin yard yet is a + // warning (it must arrive before dispatch), but a set too weak to pull the train + // is a hard violation. const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId); const setLimits = minLocomotiveLimits(assignedLocomotives); if (offYard) { - violations.push( - `Locomotive ${offYard.code} is not at the schedule origin yard`, + warnings.push( + `Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`, ); - } else if ( + } + if ( setLimits && (setLimits.maxPullWeightTons < totalWeightTons || setLimits.maxTrainLengthMeters < totalLengthMeters) @@ -2034,21 +2120,22 @@ export class TrainSchedulingService { ); } } else { - const availableLocomotives = ( - await this.locomotivesRepository.findAll({ - where: { status: 'AVAILABLE' }, - }) - ).filter((l) => l.currentYardId === originYardId); - if (!availableLocomotives.length) { - violations.push('No available locomotive at the schedule origin yard'); - } else if ( - !availableLocomotives.some( + const inServiceLocomotives = await this.locomotivesRepository.findAll({ + where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) }, + }); + if (!inServiceLocomotives.some((l) => l.currentYardId === originYardId)) { + warnings.push( + 'No locomotive is at the schedule origin yard yet; one must arrive before dispatch', + ); + } + if ( + !inServiceLocomotives.some( (l) => Number(l.maxPullWeightTons) >= totalWeightTons && Number(l.maxTrainLengthMeters) >= totalLengthMeters, ) ) { - violations.push('No available locomotive can support the total train weight and length'); + violations.push('No locomotive can support the total train weight and length'); } } @@ -2596,6 +2683,58 @@ export class TrainSchedulingService { return trainSet.locomotive ? [trainSet.locomotive] : []; } + /** + * Locomotive ids (among the given ones) that are attached to a DISPATCHED train + * other than `excludeScheduleId`. Covers both the multi-loco link rows and the + * legacy single-locomotive column on the train set. + */ + private async findLocomotiveIdsDispatchedElsewhere( + locomotiveIds: string[], + excludeScheduleId: string, + manager?: EntityManager, + ): Promise> { + if (!locomotiveIds.length) return new Set(); + const runner = manager ?? this.dataSource; + const rows: { locomotive_id: string }[] = await runner.query( + `SELECT DISTINCT loco.locomotive_id + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + JOIN ( + SELECT tsl.train_set_id, tsl.locomotive_id + FROM freight.train_set_locomotives tsl + WHERE tsl.deleted_at IS NULL + UNION + SELECT t.id AS train_set_id, t.locomotive_id + FROM freight.train_sets t + WHERE t.locomotive_id IS NOT NULL + ) loco ON loco.train_set_id = tset.id + WHERE ts.status = 'DISPATCHED' + AND ts.deleted_at IS NULL + AND ts.id <> $1 + AND loco.locomotive_id = ANY($2)`, + [excludeScheduleId, locomotiveIds], + ); + return new Set(rows.map((r) => r.locomotive_id)); + } + + private async assertLocomotivesNotDispatchedElsewhere( + locomotiveIds: string[], + excludeScheduleId: string, + ): Promise { + const busy = await this.findLocomotiveIdsDispatchedElsewhere( + locomotiveIds, + excludeScheduleId, + ); + if (!busy.size) return; + const locos = await this.dataSource + .getRepository(Locomotive) + .find({ where: { id: In([...busy]) } }); + const codes = locos.map((l) => l.code).join(', '); + throw new ConflictException( + `Locomotive(s) ${codes} are currently out on another dispatched train`, + ); + } + async selectOrValidateLocomotive( locomotiveId: string, totalWeightTons: number, @@ -2732,15 +2871,113 @@ export class TrainSchedulingService { } /** AVAILABLE locomotives at the route's origin yard. */ - async getAvailableLocomotivesForRoute(routeId: string): Promise { + /** + * All in-service locomotives, annotated for the schedule-creation picker. + * Advance scheduling means nothing is filtered out — staff see status, whether the + * locomotive is at the origin yard yet, and how many future schedules it already has. + */ + async getAvailableLocomotivesForRoute(routeId: string) { const route = await this.getSchedulableRoute(routeId); const locomotives = await this.locomotivesRepository.findAll({ - where: { status: 'AVAILABLE', currentYardId: route.originYardId }, + where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) }, order: { code: 'ASC' }, }); - return locomotives; + const counts: { locomotive_id: string; future_count: string }[] = locomotives.length + ? await this.dataSource.query( + `SELECT loco.locomotive_id, COUNT(DISTINCT ts.id) AS future_count + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + JOIN ( + SELECT tsl.train_set_id, tsl.locomotive_id + FROM freight.train_set_locomotives tsl + WHERE tsl.deleted_at IS NULL + UNION + SELECT t.id AS train_set_id, t.locomotive_id + FROM freight.train_sets t + WHERE t.locomotive_id IS NOT NULL + ) loco ON loco.train_set_id = tset.id + WHERE ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.deleted_at IS NULL + AND loco.locomotive_id = ANY($1) + GROUP BY loco.locomotive_id`, + [locomotives.map((l) => l.id)], + ) + : []; + const futureCounts = new Map(counts.map((c) => [c.locomotive_id, Number(c.future_count)])); + + return locomotives.map((loco) => ({ + ...loco, + atOriginYard: loco.currentYardId === route.originYardId, + futureScheduleCount: futureCounts.get(loco.id) ?? 0, + })); + } + + /** + * Upcoming/open booking windows for a customer's active-contract lanes — + * powers the portal home "booking windows" section. Only window-engine + * schedules (IMPORT cycle / EXPORT lead) are listed; DOMESTIC trains are + * always open and need no announcement. + */ + async getBookingWindowsForCompany(companyId: string) { + const rows: Array<{ + schedule_id: string; + direction: string | null; + window_phase: string | null; + window_opens_at: Date | null; + window_closes_at: Date | null; + booking_window_status: string; + booking_cycle_no: number; + scheduled_departure_date: Date; + origin_label: string | null; + origin_code: string | null; + destination_label: string | null; + destination_code: string | null; + }> = await this.dataSource.query( + `SELECT DISTINCT ts.id AS schedule_id, + ts.direction, + ts.window_phase, + ts.window_opens_at, + ts.window_closes_at, + ts.booking_window_status, + ts.booking_cycle_no, + ts.scheduled_departure_date, + oy.label AS origin_label, oy.code AS origin_code, + dy.label AS destination_label, dy.code AS destination_code + FROM freight.train_schedules ts + JOIN freight.contract_routes cr + ON cr.origin_yard_id = ts.origin_station_id + AND cr.destination_yard_id = ts.destination_station_id + AND cr.deleted_at IS NULL + JOIN freight.contracts c + ON c.id = cr.contract_id + AND c.company_id = $1 + AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') + AND c.deleted_at IS NULL + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.window_phase IS NOT NULL + AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') + AND ts.scheduled_departure_date >= now() + ORDER BY ts.window_opens_at ASC NULLS LAST`, + [companyId], + ); + return rows.map((r) => ({ + scheduleId: r.schedule_id, + direction: r.direction, + windowPhase: r.window_phase, + isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN', + windowOpensAt: r.window_opens_at, + windowClosesAt: r.window_closes_at, + bookingWindowStatus: r.booking_window_status, + bookingCycleNo: r.booking_cycle_no, + departureDate: r.scheduled_departure_date, + origin: r.origin_label ?? r.origin_code ?? null, + destination: r.destination_label ?? r.destination_code ?? null, + })); } /** OPEN schedules a new booking may target (with rough remaining capacity). diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index b5f9abcb8..9158ecca0 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -1,5 +1,5 @@ import { IsString, IsEnum, IsNumber, IsOptional, IsUUID } from 'class-validator'; -import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity'; +import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity'; export class CreateVehicleDto { @IsString() @@ -26,6 +26,10 @@ export class CreateVehicleDto { @IsEnum(VehicleStatus) status!: VehicleStatus; + @IsOptional() + @IsEnum(VehicleAvailability) + availability?: VehicleAvailability; + @IsOptional() @IsString() description?: string; @@ -57,4 +61,8 @@ export class CreateVehicleDto { @IsOptional() @IsNumber() actualDistanceKm?: number; + + @IsOptional() + @IsUUID() + locationId?: string; } diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index fef078ef8..416cddee6 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -25,11 +25,25 @@ export enum VehicleStatus { OUT_OF_SERVICE = 'OUT_OF_SERVICE', } +export enum VehicleAvailability { + FREE = 'FREE', + BUSY = 'BUSY', +} + @Entity({ name: 'vehicles', schema: 'freight' }) export class Vehicle extends BaseEntity { + @Column({ nullable: true }) + code?: string; + @Column({ name: 'plate_number', unique: true, nullable: true }) plateNumber?: string; + @Column({ name: 'power_plate_no', nullable: true }) + powerPlateNo?: string; + + @Column({ name: 'trailer_plate_no', nullable: true }) + trailerPlateNo?: string; + @Column({ name: 'registration_number', unique: true, nullable: true }) registrationNumber?: string; @@ -54,6 +68,9 @@ export class Vehicle extends BaseEntity { @Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE, nullable: true }) status?: VehicleStatus; + @Column({ name: 'availability', type: 'varchar', default: VehicleAvailability.FREE, nullable: true }) + availability?: VehicleAvailability; + @Column({ type: 'text', nullable: true }) description?: string | null; @@ -68,4 +85,7 @@ export class Vehicle extends BaseEntity { @Column({ name: 'actual_distance_km', type: 'numeric', nullable: true }) actualDistanceKm?: number; + + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId?: string; } diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts index 24ff2d022..8e6d8a0a8 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts @@ -34,6 +34,7 @@ export class VehiclesController { findAll( @Query('search') search?: string, @Query('status') status?: string, + @Query('availability') availability?: string, @Query('page') page?: string, @Query('limit') limit?: string, @Query('sortBy') sortBy?: string, @@ -42,6 +43,7 @@ export class VehiclesController { return this.vehiclesService.findAll({ search, status: status as any, + availability: availability as any, page: page ? parseInt(page) : undefined, limit: limit ? parseInt(limit) : undefined, sortBy, diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 2bc882591..69568e483 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -1,9 +1,14 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Not, Repository } from 'typeorm'; import { CreateVehicleDto } from './dto/create-vehicle.dto'; import { UpdateVehicleDto } from './dto/update-vehicle.dto'; -import { Vehicle, VehicleStatus } from './entities/vehicle.entity'; +import { Vehicle, VehicleAvailability, VehicleStatus } from './entities/vehicle.entity'; +import { FirstMile, FirstMileStatus } from '../first-mile/entities/first-mile.entity'; +import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity'; +import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity'; +import { LastMileContainerAllocation } from '../last-mile/entities/last-mile-container-allocation.entity'; +import { BookingContainerAllocation } from '../bookings/entities/booking-container-allocation.entity'; @Injectable() export class VehiclesService { @@ -35,6 +40,7 @@ export class VehiclesService { async findAll(query: { search?: string; status?: VehicleStatus | string; + availability?: VehicleAvailability | string; page?: number; limit?: number; sortBy?: string; @@ -44,7 +50,7 @@ export class VehiclesService { if (query.search) { qb = qb.where( - 'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search', + '(v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search OR v.model ILIKE :search OR v.code ILIKE :search OR v.trailerPlateNo ILIKE :search)', { search: `%${query.search}%` }, ); } @@ -53,6 +59,10 @@ export class VehiclesService { qb = qb.andWhere('v.status = :status', { status: query.status }); } + if (query.availability) { + qb = qb.andWhere('v.availability = :availability', { availability: query.availability }); + } + const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes( query.sortBy ?? '', ) @@ -91,6 +101,48 @@ export class VehiclesService { return this.vehicleRepo.save(vehicle); } + async setAvailability(id: string, availability: VehicleAvailability): Promise { + await this.vehicleRepo.update(id, { availability }); + } + + /** + * Set vehicles back to FREE, but only when no active (non-completed) + * first/last-mile record or container allocation still references them. + * First-mile trips ending in RECEIVED_TO_PORT and last-mile trips ending + * in DELIVERED no longer hold the vehicle. + */ + async releaseIfUnused(vehicleIds: string[]): Promise { + const manager = this.vehicleRepo.manager; + for (const vehicleId of [...new Set(vehicleIds)]) { + const [fmRecords, lmRecords, fmAllocations, lmAllocations, bookingAllocations] = await Promise.all([ + manager.count(FirstMile, { + where: { vehicleId, status: Not('RECEIVED_TO_PORT') }, + }), + manager.count(LastMile, { + where: { vehicleId, status: Not('DELIVERED') }, + }), + manager + .createQueryBuilder(FirstMileContainerAllocation, 'alloc') + .innerJoin(FirstMile, 'fm', 'fm.id = alloc.firstMileId') + .where('alloc.vehicleId = :vehicleId', { vehicleId }) + .andWhere('fm.status != :done', { done: 'RECEIVED_TO_PORT' }) + .andWhere('fm.deletedAt IS NULL') + .getCount(), + manager + .createQueryBuilder(LastMileContainerAllocation, 'alloc') + .innerJoin(LastMile, 'lm', 'lm.id = alloc.lastMileId') + .where('alloc.vehicleId = :vehicleId', { vehicleId }) + .andWhere('lm.status != :done', { done: 'DELIVERED' }) + .andWhere('lm.deletedAt IS NULL') + .getCount(), + manager.count(BookingContainerAllocation, { where: { vehicleId } }), + ]); + if (fmRecords + lmRecords + fmAllocations + lmAllocations + bookingAllocations === 0) { + await this.setAvailability(vehicleId, VehicleAvailability.FREE); + } + } + } + async remove(id: string): Promise { await this.findById(id); await this.vehicleRepo.softDelete(id); diff --git a/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts deleted file mode 100644 index e33baa7fd..000000000 --- a/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts +++ /dev/null @@ -1,1131 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { CargoUnitOfMeasure, TrainScheduleStatus, WagonStatus } from '@edr/types'; -import { randomUUID } from 'crypto'; -import { DataSource, EntityManager, In } from 'typeorm'; - -import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; -import { Booking } from '../modules/bookings/entities/booking.entity'; -import { - Company, - CompanyKind, - CompanyNationality, - CompanyStatus, - CompanyType, -} from '../modules/companies/entities/company.entity'; -import { - CompanyProfile, - ProfileStatus, - ProfileType, -} from '../modules/companies/entities/company-profile.entity'; -import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; -import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; -import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; -import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; -import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; -import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; -import { Yard } from '../modules/rule-engine/entities/yard.entity'; -import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity'; -import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity'; -import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; -import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; -import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity'; -import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; -import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity'; -import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; -import { Wagon } from '../modules/wagons/entities/wagon.entity'; -import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; -import { WarehouseActivityLog } from '../modules/warehouses/entities/warehouse-activity-log.entity'; -import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; -import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity'; -import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; -import { Driver, DriverStatus } from '../modules/drivers/entities/driver.entity'; -import { FuelType, Vehicle, VehicleStatus, VehicleType } from '../modules/vehicles/entities/vehicle.entity'; - -const CUSTOMER_TIN = 'US12DEMO01'; - -const DEMO_TRAINS = [ - { - trainNumber: 'US12-DJI-IND-01', - direction: 'IMPORT', - originCode: 'NAGAD', - destinationCode: 'INDODE', - departureHoursAgo: 30, - arrivalHoursAgo: 14, - }, - { - trainNumber: 'US12-IND-DJI-01', - direction: 'EXPORT', - originCode: 'INDODE', - destinationCode: 'NAGAD', - departureHoursAgo: 28, - arrivalHoursAgo: 12, - }, - { - trainNumber: 'US12-DJI-IND-LM-02', - direction: 'IMPORT', - originCode: 'NAGAD', - destinationCode: 'INDODE', - departureHoursAgo: 24, - arrivalHoursAgo: 8, - }, - { - trainNumber: 'US12-IND-DJI-LM-02', - direction: 'EXPORT', - originCode: 'INDODE', - destinationCode: 'NAGAD', - departureHoursAgo: 22, - arrivalHoursAgo: 6, - }, -] as const; - -const TRAIN_DEMO_BOOKINGS = [ - { - reference: 'US12-IMP-FM-001', - trainNumber: 'US12-DJI-IND-01', - tradeDirection: 'IMPORT', - freightType: 'CONTAINER', - withFirstMile: true, - withLastMile: true, - containerCode: '40FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 27, - totalAmount: 18450, - pickupAddress: 'Doraleh Container Terminal, Djibouti', - pickupLat: 11.5881, - pickupLng: 43.1372, - deliveryAddress: 'Indode bonded warehouse gate, Ethiopia', - deliveryLat: 8.7566, - deliveryLng: 38.9846, - }, - { - reference: 'US12-IMP-NOFM-001', - trainNumber: 'US12-DJI-IND-01', - tradeDirection: 'IMPORT', - freightType: 'BULK', - withFirstMile: false, - withLastMile: false, - containerCode: null, - cargoCode: 'BULK', - weightTons: 42, - totalAmount: 22100, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: null, - deliveryLat: null, - deliveryLng: null, - }, - { - reference: 'US12-EXP-FM-001', - trainNumber: 'US12-IND-DJI-01', - tradeDirection: 'EXPORT', - freightType: 'CONTAINER', - withFirstMile: true, - withLastMile: true, - containerCode: '20FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 19, - totalAmount: 15680, - pickupAddress: 'Indode export truck gate, Ethiopia', - pickupLat: 8.7566, - pickupLng: 38.9846, - deliveryAddress: 'Nagad Terminal customer handover yard, Djibouti', - deliveryLat: 11.5536, - deliveryLng: 43.1103, - }, - { - reference: 'US12-EXP-NOFM-001', - trainNumber: 'US12-IND-DJI-01', - tradeDirection: 'EXPORT', - freightType: 'BULK', - withFirstMile: false, - withLastMile: false, - containerCode: null, - cargoCode: 'BULK', - weightTons: 55, - totalAmount: 29800, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: null, - deliveryLat: null, - deliveryLng: null, - }, - { - reference: 'US12-IMP-LM-TRAIN-001', - trainNumber: 'US12-DJI-IND-LM-02', - tradeDirection: 'IMPORT', - freightType: 'CONTAINER', - withFirstMile: false, - withLastMile: true, - containerCode: '40FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 31, - totalAmount: 20300, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: 'Indode last-mile customer delivery bay, Ethiopia', - deliveryLat: 8.7581, - deliveryLng: 38.9834, - }, - { - reference: 'US12-EXP-LM-TRAIN-001', - trainNumber: 'US12-IND-DJI-LM-02', - tradeDirection: 'EXPORT', - freightType: 'CONTAINER', - withFirstMile: false, - withLastMile: true, - containerCode: '20FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 21, - totalAmount: 17600, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: 'Nagad last-mile consignee handover yard, Djibouti', - deliveryLat: 11.5549, - deliveryLng: 43.1121, - }, -] as const; - -const CUSTOMER_TRUCK_DEMO_BOOKINGS = [ - { - reference: 'US12-EXP-FM-TRUCK-001', - trainNumber: null, - originCode: 'INDODE', - destinationCode: 'NAGAD', - tradeDirection: 'EXPORT', - freightType: 'CONTAINER', - withFirstMile: true, - withLastMile: false, - containerCode: '40FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 24, - totalAmount: 14800, - pickupAddress: 'Customer factory gate, Addis Ababa', - pickupLat: 8.9806, - pickupLng: 38.8736, - deliveryAddress: null, - deliveryLat: null, - deliveryLng: null, - }, - { - reference: 'US12-EXP-NOFM-TRUCK-001', - trainNumber: null, - originCode: 'INDODE', - destinationCode: 'NAGAD', - tradeDirection: 'EXPORT', - freightType: 'CONTAINER', - withFirstMile: false, - withLastMile: false, - containerCode: '20FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 18, - totalAmount: 11200, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: null, - deliveryLat: null, - deliveryLng: null, - customerTruckPlateNumber: 'ET-CUS-2046', - customerTruckDriverName: 'Dawit Customer Carrier', - customerTruckType: 'Container Chassis', - customerTruckContainerNumber: 'USDU1234567', - }, -] as const; - -const DEMO_BOOKINGS = [...TRAIN_DEMO_BOOKINGS, ...CUSTOMER_TRUCK_DEMO_BOOKINGS] as const; - -@Injectable() -export class PaidIndodeDemoBookingsSeeder { - private readonly logger = new Logger(PaidIndodeDemoBookingsSeeder.name); - - constructor(private readonly dataSource: DataSource) {} - - async run(): Promise { - try { - await this.dataSource.transaction(async (manager) => { - const refs = await this.ensureReferenceData(manager); - const schedules = await this.ensureArrivedTrains(manager, refs); - const bookings = await this.ensureBookings(manager, refs, schedules); - await this.ensureTrainLinks(manager, refs, schedules, bookings); - await this.ensureGatepasses(manager, schedules); - await this.ensureImportWarehouseInventory(manager, bookings); - }); - - this.logger.log( - `US12 paid Indode demo bookings ready: ${DEMO_BOOKINGS.length} booking(s), ${DEMO_TRAINS.length} arrived train(s)`, - ); - } catch (error) { - this.logger.error( - `PaidIndodeDemoBookingsSeeder failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - private async ensureReferenceData(manager: EntityManager) { - await manager.getRepository(Yard).upsert( - [ - { - code: 'INDODE', - label: 'Indode Terminal', - country: 'Ethiopia', - isActive: true, - displayOrder: 1, - }, - { - code: 'NAGAD', - label: 'Nagad Terminal, Djibouti', - country: 'Djibouti', - isActive: true, - displayOrder: 2, - }, - ], - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(ServiceType).upsert( - [ - { - code: 'RAIL_CONTAINER_FIRST_LAST', - serviceName: 'Rail Freight with First and Last Mile', - description: 'Rail movement with first-mile pickup and last-mile delivery', - canBeBookedAlone: true, - includesFirstMile: true, - includesLastMile: true, - includesCustoms: false, - priorityBonusPoints: 15, - isActive: true, - displayOrder: 3, - }, - { - code: 'RAIL_CONTAINER_LAST_MILE', - serviceName: 'Rail Freight with Last Mile', - description: 'Rail movement with last-mile delivery from terminal', - canBeBookedAlone: true, - includesFirstMile: false, - includesLastMile: true, - includesCustoms: false, - priorityBonusPoints: 8, - isActive: true, - displayOrder: 4, - }, - { - code: 'RAIL_CONTAINER', - serviceName: 'Rail Freight', - description: 'Rail movement without first-mile pickup', - canBeBookedAlone: true, - includesFirstMile: false, - includesLastMile: false, - includesCustoms: false, - priorityBonusPoints: 0, - isActive: true, - displayOrder: 1, - }, - { - code: 'RAIL_CONTAINER_FIRST_MILE', - serviceName: 'Rail Freight with First Mile', - description: 'Rail movement with first-mile pickup to terminal', - canBeBookedAlone: true, - includesFirstMile: true, - includesLastMile: false, - includesCustoms: false, - priorityBonusPoints: 10, - isActive: true, - displayOrder: 2, - }, - ], - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(ContainerType).upsert( - [ - { - code: '20FT', - label: '20FT Standard', - sizeFt: 20, - wagonsPerUnit: 1, - isReefer: false, - isOpenTop: false, - isActive: true, - displayOrder: 1, - }, - { - code: '40FT', - label: '40FT Standard', - sizeFt: 40, - wagonsPerUnit: 1, - isReefer: false, - isOpenTop: false, - isActive: true, - displayOrder: 2, - }, - ], - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(CargoType).upsert( - [ - { - code: 'GENERAL_CARGO', - cargoTypeName: 'General Cargo', - showFreeTextBox: true, - unitOfMeasure: null, - requiresDirectorApproval: false, - isActive: true, - displayOrder: 1, - }, - { - code: 'BULK', - cargoTypeName: 'Bulk Cargo', - showFreeTextBox: true, - unitOfMeasure: CargoUnitOfMeasure.PerTon, - requiresDirectorApproval: false, - isActive: true, - displayOrder: 2, - }, - ], - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(WagonType).upsert( - { - code: 'US12-DEMO', - name: 'US12 Demo Flat/Bulk Wagon', - capacityTons: 70, - lengthMeters: 14, - maxWagonsPerTrain: 53, - supportedLoadTypes: ['CONTAINER', 'BULK'], - isActive: true, - equatedLengthM: 14, - tareWeightTons: 14, - supportsContainer: true, - maxContainerGrossT: 40, - }, - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(Company).upsert( - { - name: 'US12 Indode Demo Customer PLC', - type: CompanyType.Customer, - kind: CompanyKind.Commercial, - status: CompanyStatus.Active, - tin: CUSTOMER_TIN, - vatNumber: 'VAT-US12-001', - fanNumber: 'US12000000000001', - country: 'Ethiopia', - nationality: CompanyNationality.Ethiopian, - address: 'Bole Road, Addis Ababa, Ethiopia', - phone: '251911120012', - email: 'us12.indode.demo@edr.local', - website: 'https://edr.local/us12-demo', - contactPersonName: 'Aster Bekele', - contactPersonPhone: '251911120013', - generalManagerName: 'Mekonnen Desta', - generalManagerEmail: 'manager.us12.demo@edr.local', - generalManagerPhone: '251911120014', - licenceNumber: 'LIC-US12-2026', - region: 'Addis Ababa', - zone: 'Bole', - woreda: '03', - kebele: '12', - houseNo: 'US12-01', - attributes: { - seededBy: 'PaidIndodeDemoBookingsSeeder', - note: 'Paid customer with import/export demo bookings for US12.', - } as any, - }, - { conflictPaths: { tin: true } }, - ); - - const company = await manager.getRepository(Company).findOneByOrFail({ tin: CUSTOMER_TIN }); - await manager.getRepository(CompanyProfile).upsert( - [ - { - companyId: company.id, - type: ProfileType.importer, - reference: 'US12-IMP', - status: ProfileStatus.Active, - businessLicense: 'BL-US12-IMP-2026', - attributes: { seededBy: 'PaidIndodeDemoBookingsSeeder' } as any, - }, - { - companyId: company.id, - type: ProfileType.exporter, - reference: 'US12-EXP', - status: ProfileStatus.Active, - businessLicense: 'BL-US12-EXP-2026', - attributes: { seededBy: 'PaidIndodeDemoBookingsSeeder' } as any, - }, - ], - { conflictPaths: { reference: true } }, - ); - - const [yards, serviceTypes, containerTypes, cargoTypes, wagonType, importerProfile, exporterProfile] = - await Promise.all([ - manager.getRepository(Yard).find({ where: { code: In(['INDODE', 'NAGAD']) } }), - manager - .getRepository(ServiceType) - .find({ - where: { - code: In([ - 'RAIL_CONTAINER', - 'RAIL_CONTAINER_FIRST_MILE', - 'RAIL_CONTAINER_LAST_MILE', - 'RAIL_CONTAINER_FIRST_LAST', - ]), - }, - }), - manager.getRepository(ContainerType).find({ where: { code: In(['20FT', '40FT']) } }), - manager.getRepository(CargoType).find({ where: { code: In(['GENERAL_CARGO', 'BULK']) } }), - manager.getRepository(WagonType).findOneByOrFail({ code: 'US12-DEMO' }), - manager.getRepository(CompanyProfile).findOneByOrFail({ reference: 'US12-IMP' }), - manager.getRepository(CompanyProfile).findOneByOrFail({ reference: 'US12-EXP' }), - ]); - - return { - company, - importerProfile, - exporterProfile, - yards: new Map(yards.map((yard) => [yard.code, yard])), - serviceTypes: new Map(serviceTypes.map((serviceType) => [serviceType.code, serviceType])), - containerTypes: new Map(containerTypes.map((containerType) => [containerType.code, containerType])), - cargoTypes: new Map(cargoTypes.map((cargoType) => [cargoType.code, cargoType])), - wagonType, - }; - } - - private async ensureArrivedTrains( - manager: EntityManager, - refs: Awaited>, - ): Promise> { - const schedules = new Map(); - const now = new Date(); - - for (const demo of DEMO_TRAINS) { - const origin = refs.yards.get(demo.originCode); - const destination = refs.yards.get(demo.destinationCode); - if (!origin || !destination) { - throw new Error(`US12 demo train missing yard: ${demo.trainNumber}`); - } - - const departure = this.addHours(now, -demo.departureHoursAgo); - const arrival = this.addHours(now, -demo.arrivalHoursAgo); - const locomotive = await this.ensureLocomotive(manager, origin.id); - const trainSet = await this.ensureTrainSet(manager, demo.trainNumber, locomotive.id); - const schedule = await this.ensureTrainSchedule(manager, { - trainNumber: demo.trainNumber, - trainSetId: trainSet.id, - originStationId: origin.id, - destinationStationId: destination.id, - scheduledDepartureDate: departure, - scheduledArrivalDate: arrival, - actualDepartureAt: departure, - actualArrivalAt: arrival, - direction: demo.direction, - }); - - await manager.getRepository(TrainSet).update(trainSet.id, { - totalWeightTons: TRAIN_DEMO_BOOKINGS.filter((booking) => booking.trainNumber === demo.trainNumber) - .reduce((sum, booking) => sum + booking.weightTons, 0), - totalLengthMeters: 28, - wagonCount: 2, - status: 'COMPLETED', - }); - schedules.set(demo.trainNumber, schedule); - } - - return schedules; - } - - private async ensureBookings( - manager: EntityManager, - refs: Awaited>, - schedules: Map, - ): Promise> { - const bookingRepo = manager.getRepository(Booking); - const bookingContainerRepo = manager.getRepository(BookingContainer); - const firstMileRepo = manager.getRepository(FirstMile); - const lastMileRepo = manager.getRepository(LastMile); - const now = new Date(); - const references = DEMO_BOOKINGS.map((booking) => booking.reference); - const existingBookings = await bookingRepo.find({ where: { reference: In(references) } }); - const existingBookingIds = existingBookings.map((booking) => booking.id); - const firstMileVehicle = await this.ensureFirstMileVehicle(manager); - - if (existingBookingIds.length) { - const existingInventory = await manager.getRepository(WarehouseInventory).find({ - where: { bookingId: In(existingBookingIds) }, - select: { id: true }, - }); - const existingInventoryIds = existingInventory.map((item) => item.id); - if (existingInventoryIds.length) { - await manager.getRepository(WarehouseActivityLog).delete({ - inventoryId: In(existingInventoryIds), - }); - await manager.getRepository(WarehouseInventory).delete({ - id: In(existingInventoryIds), - }); - } - await this.deleteBookingTrainChildren(manager, existingBookingIds); - await bookingContainerRepo.delete({ bookingId: In(existingBookingIds) }); - await firstMileRepo.delete({ bookingId: In(existingBookingIds) }); - await lastMileRepo.delete({ bookingId: In(existingBookingIds) }); - } - - for (const demo of DEMO_BOOKINGS) { - const schedule = demo.trainNumber ? schedules.get(demo.trainNumber) : null; - if (demo.trainNumber && !schedule) { - throw new Error(`US12 demo booking missing train: ${demo.reference}`); - } - - const train = demo.trainNumber ? DEMO_TRAINS.find((item) => item.trainNumber === demo.trainNumber) : null; - const originCode = train?.originCode ?? ('originCode' in demo ? demo.originCode : undefined); - const destinationCode = train?.destinationCode ?? ('destinationCode' in demo ? demo.destinationCode : undefined); - const origin = originCode ? refs.yards.get(originCode) : null; - const destination = destinationCode ? refs.yards.get(destinationCode) : null; - const serviceType = refs.serviceTypes.get( - demo.withFirstMile && demo.withLastMile - ? 'RAIL_CONTAINER_FIRST_LAST' - : demo.withFirstMile - ? 'RAIL_CONTAINER_FIRST_MILE' - : demo.withLastMile - ? 'RAIL_CONTAINER_LAST_MILE' - : 'RAIL_CONTAINER', - ); - const cargoType = refs.cargoTypes.get(demo.cargoCode); - const profile = demo.tradeDirection === 'IMPORT' ? refs.importerProfile : refs.exporterProfile; - - if (!origin || !destination || !serviceType || !cargoType) { - throw new Error(`US12 demo booking missing reference data: ${demo.reference}`); - } - - await bookingRepo.upsert( - { - reference: demo.reference, - companyId: refs.company.id, - companyProfileId: profile.id, - isGovernment: false, - status: - 'customerTruckPlateNumber' in demo && demo.customerTruckPlateNumber - ? 'TRUCK_ASSIGNED' - : demo.trainNumber - ? 'IN_TRANSIT' - : 'PAID', - scheduledDate: schedule?.scheduledDepartureDate ?? now, - estimatedShipmentDate: schedule?.scheduledDepartureDate ?? now, - totalAmount: demo.totalAmount, - paymentStatus: 'PAID', - contractType: 'NEW', - serviceTypeId: serviceType.id, - firstMilePickupAddress: demo.pickupAddress, - firstMilePickupLat: demo.pickupLat, - firstMilePickupLng: demo.pickupLng, - lastMileDeliveryAddress: demo.deliveryAddress, - lastMileDeliveryLat: demo.deliveryLat, - lastMileDeliveryLng: demo.deliveryLng, - customerTruckPlateNumber: - 'customerTruckPlateNumber' in demo ? demo.customerTruckPlateNumber : null, - customerTruckDriverName: - 'customerTruckDriverName' in demo ? demo.customerTruckDriverName : null, - customerTruckType: - 'customerTruckType' in demo ? demo.customerTruckType : null, - customerTruckContainerNumber: - 'customerTruckContainerNumber' in demo ? demo.customerTruckContainerNumber : null, - customerTruckAssignedAt: - 'customerTruckPlateNumber' in demo && demo.customerTruckPlateNumber - ? this.addHours(now, -2) - : null, - customerTruckArrivedAt: null, - customsClearingEnabled: false, - equipmentReturn: 'WITHOUT_RETURN', - originYardId: origin.id, - destinationYardId: destination.id, - tradeDirection: demo.tradeDirection, - freightType: demo.freightType, - cargoTypeId: cargoType.id, - cargoFreeText: demo.freightType === 'BULK' ? 'Seeded paid bulk cargo' : 'Seeded paid container cargo', - shippingLineId: null, - cargoTotalWeightVgm: demo.weightTons, - isHazardous: false, - isReefer: false, - paymentCurrency: 'ETB', - pnrCode: `PNR-${demo.reference}`, - versionNumber: 1, - approvedByStaffAt: now, - customerSignedAt: now, - fullyExecutedAt: now, - pricingBreakdown: { - paid: true, - source: 'PaidIndodeDemoBookingsSeeder', - firstMileIncluded: demo.withFirstMile, - lastMileIncluded: demo.withLastMile, - }, - priorityScore: demo.withFirstMile ? 30 : demo.withLastMile ? 25 : 20, - wagonsRequired: 1, - schedulingStatus: demo.trainNumber ? 'DISPATCHED' : 'NOT_SCHEDULED', - scheduledAt: demo.trainNumber ? now : null, - trainScheduleId: schedule?.id ?? null, - paymentDeadline: null, - selectedForBatchAt: demo.trainNumber ? now : null, - }, - { conflictPaths: { reference: true } }, - ); - - const booking = await bookingRepo.findOneByOrFail({ reference: demo.reference }); - - if (demo.freightType === 'CONTAINER' && demo.containerCode) { - const containerType = refs.containerTypes.get(demo.containerCode); - if (!containerType) { - throw new Error(`US12 demo booking missing container type: ${demo.reference}`); - } - await bookingContainerRepo.insert({ - id: randomUUID(), - bookingId: booking.id, - containerTypeId: containerType.id, - containerNumber: this.containerNumber(demo.reference), - containerSize: demo.containerCode.startsWith('40') ? '40ft' : '20ft', - quantity: 1, - hazardousQuantity: 0, - reeferQuantity: 0, - vgmPerUnitTons: demo.weightTons, - totalVgmTons: demo.weightTons, - wagonsRequired: 1, - weightLimitRuleId: null, - isOverweight: false, - overweightExcessTons: null, - }); - } - - if (demo.withFirstMile) { - await firstMileRepo.insert({ - id: randomUUID(), - bookingId: booking.id, - status: 'RECEIVED_TO_PORT', - advancedPayment: demo.totalAmount, - remainingPayment: 0, - estimatedKm: demo.tradeDirection === 'IMPORT' ? 12 : 35, - exactKm: demo.tradeDirection === 'IMPORT' ? 11.8 : 34.6, - vehicleId: firstMileVehicle.id, - }); - } - - if (demo.withLastMile) { - await lastMileRepo.insert({ - id: randomUUID(), - bookingId: booking.id, - status: 'DELIVERED', - advancedPayment: demo.totalAmount, - remainingPayment: 0, - estimatedKm: demo.tradeDirection === 'IMPORT' ? 18 : 14, - exactKm: demo.tradeDirection === 'IMPORT' ? 17.5 : 13.8, - vehicleId: null, - }); - } - } - - const savedBookings = await bookingRepo.find({ where: { reference: In(references) } }); - return new Map(savedBookings.map((booking) => [booking.reference, booking])); - } - - private async ensureTrainLinks( - manager: EntityManager, - refs: Awaited>, - schedules: Map, - bookings: Map, - ): Promise { - const scheduleBookingRepo = manager.getRepository(TrainScheduleBooking); - const trainSetWagonRepo = manager.getRepository(TrainSetWagon); - const allocationRepo = manager.getRepository(WagonBookingAllocation); - const containerItemRepo = manager.getRepository(WagonAllocationContainerItem); - const wagonCapacity = Number(refs.wagonType.capacityTons) || 70; - const wagonLength = Number(refs.wagonType.lengthMeters) || 14; - const tareWeight = Number(refs.wagonType.tareWeightTons) || 14; - - for (const demo of TRAIN_DEMO_BOOKINGS) { - const schedule = schedules.get(demo.trainNumber); - const booking = bookings.get(demo.reference); - if (!schedule || !booking) continue; - - const trainBookings = TRAIN_DEMO_BOOKINGS.filter((item) => item.trainNumber === demo.trainNumber); - const sequence = trainBookings.findIndex((item) => item.reference === demo.reference) + 1; - const wagon = await this.ensureWagon(manager, { - wagonNumber: `${demo.trainNumber}-W${String(sequence).padStart(2, '0')}`, - wagonTypeId: refs.wagonType.id, - yardId: schedule.destinationStationId, - trainScheduleId: schedule.id, - trainSetWagonId: null, - tareWeight, - capacityTons: wagonCapacity, - }); - - let trainSetWagon = await trainSetWagonRepo.findOne({ - where: { trainSetId: schedule.trainSetId, sequenceNo: sequence }, - }); - trainSetWagon = await trainSetWagonRepo.save( - trainSetWagonRepo.create({ - ...(trainSetWagon ? { id: trainSetWagon.id } : {}), - trainSetId: schedule.trainSetId, - wagonTypeId: refs.wagonType.id, - physicalWagonId: wagon.id, - sequenceNo: sequence, - capacityTons: wagonCapacity, - lengthMeters: wagonLength, - assignedWeightTons: demo.weightTons, - status: 'DEPARTED', - }), - ); - - await manager.getRepository(Wagon).update(wagon.id, { - trainSetWagonId: trainSetWagon.id, - currentTrainScheduleId: schedule.id, - currentYardId: schedule.destinationStationId, - status: WagonStatus.Assigned, - }); - - const allocation = await allocationRepo.save( - allocationRepo.create({ - trainSetWagonId: trainSetWagon.id, - bookingId: booking.id, - allocatedWeightTons: demo.weightTons, - loadType: demo.freightType, - status: 'DEPARTED', - confirmedAt: schedule.actualDepartureAt ?? new Date(), - }), - ); - - if (demo.freightType === 'CONTAINER') { - const bookingContainer = await manager.getRepository(BookingContainer).findOne({ - where: { bookingId: booking.id }, - }); - const containerType = demo.containerCode ? refs.containerTypes.get(demo.containerCode) : null; - await containerItemRepo.insert({ - id: randomUUID(), - wagonBookingAllocationId: allocation.id, - bookingContainerId: bookingContainer?.id ?? null, - containerNumber: this.containerNumber(demo.reference), - containerTypeId: containerType?.id ?? null, - positionOnWagon: 1, - sealNumber: `SEAL-${demo.reference}`, - chassisNumber: `CHS-${demo.reference}`, - grossWeightTons: demo.weightTons, - }); - } - - await scheduleBookingRepo.insert({ - id: randomUUID(), - trainScheduleId: schedule.id, - bookingId: booking.id, - }); - } - } - - private async ensureGatepasses( - manager: EntityManager, - schedules: Map, - ): Promise { - const repo = manager.getRepository(ImportDjiboutiOperation); - const securedAt = this.addHours(new Date(), -20); - - for (const schedule of schedules.values()) { - const existing = await repo.findOne({ where: { trainScheduleId: schedule.id } }); - await repo.save( - repo.create({ - ...(existing ? { id: existing.id } : {}), - trainScheduleId: schedule.id, - documents: { - ...(existing?.documents ?? {}), - GATE_PASS: { - reference: `GP-${schedule.trainNumber}`, - uploadedAt: securedAt.toISOString(), - uploadedBy: 'PaidIndodeDemoBookingsSeeder', - notes: 'Seeded secured gate pass for import/export Djibouti port entry testing.', - }, - }, - gatepassGrantedAt: securedAt, - performedBy: 'PaidIndodeDemoBookingsSeeder', - notes: 'Seeded SECURED gate pass for US12 warehouse workflow testing.', - }), - ); - } - } - - private async ensureImportWarehouseInventory( - manager: EntityManager, - bookings: Map, - ): Promise { - const warehouse = await manager.getRepository(Warehouse).findOne({ where: { code: 'INDODE_OPEN' } }); - if (!warehouse) { - this.logger.warn('INDODE_OPEN warehouse missing; skipping US12 import warehouse inventory seed'); - return; - } - - for (const demo of TRAIN_DEMO_BOOKINGS.filter((booking) => booking.tradeDirection === 'IMPORT')) { - const booking = bookings.get(demo.reference); - if (!booking) continue; - - const yard = await this.findWarehouseYard(manager, warehouse.id, demo.freightType); - if (!yard) { - this.logger.warn(`No warehouse yard found for ${warehouse.code}; skipping ${demo.reference}`); - continue; - } - const zone = await manager.getRepository(WarehouseZone).findOne({ where: { yardId: yard.id } }); - if (!zone) { - this.logger.warn(`No warehouse zone found for ${yard.code}; skipping ${demo.reference}`); - continue; - } - - const arrivedAt = this.addHours(new Date(), -Number(demo.trainNumber.includes('LM') ? 7 : 13)); - const grnNumber = `GRN-IMP-${demo.reference.replace(/[^A-Z0-9]/g, '')}`; - const saved = await manager.getRepository(WarehouseInventory).save( - manager.getRepository(WarehouseInventory).create({ - warehouseId: warehouse.id, - yardId: yard.id, - zoneId: zone.id, - bookingId: booking.id, - quantity: demo.freightType === 'CONTAINER' ? 1 : 1, - weight: demo.weightTons, - volume: null, - grnNumber, - status: 'UNLOADED', - inspectionStatus: null, - arrivedAt, - unloadedAt: arrivedAt, - notes: [ - `GRN Number: ${grnNumber}`, - 'Direction: IMPORT', - `Train: ${demo.trainNumber}`, - `Seeded For: ${demo.withLastMile ? 'Import with last mile' : 'Import terminal pickup / no last mile'}`, - 'Seeded by PaidIndodeDemoBookingsSeeder for Receive at Warehouse testing.', - ].join('\n'), - }), - ); - - await manager.getRepository(WarehouseActivityLog).save( - manager.getRepository(WarehouseActivityLog).create({ - inventoryId: saved.id, - warehouseId: warehouse.id, - activityType: 'INVENTORY_UNLOADED', - description: `Seeded import train arrival ${demo.trainNumber} into warehouse queue`, - performedBy: 'PaidIndodeDemoBookingsSeeder', - }), - ); - } - } - - private async findWarehouseYard( - manager: EntityManager, - warehouseId: string, - freightType: string, - ): Promise { - const preferredType = freightType === 'CONTAINER' ? 'CONTAINER_YARD' : 'BULK_YARD'; - return ( - (await manager.getRepository(WarehouseYard).findOne({ - where: { warehouseId, type: preferredType as any }, - })) ?? - (await manager.getRepository(WarehouseYard).findOne({ - where: { warehouseId }, - })) - ); - } - - private async deleteBookingTrainChildren(manager: EntityManager, bookingIds: string[]): Promise { - const allocationRepo = manager.getRepository(WagonBookingAllocation); - const allocations = await allocationRepo.find({ - where: { bookingId: In(bookingIds) }, - select: { id: true }, - }); - const allocationIds = allocations.map((allocation) => allocation.id); - if (allocationIds.length) { - await manager.getRepository(WagonAllocationContainerItem).delete({ - wagonBookingAllocationId: In(allocationIds), - }); - } - await allocationRepo.delete({ bookingId: In(bookingIds) }); - await manager.getRepository(TrainScheduleBooking).delete({ bookingId: In(bookingIds) }); - } - - private async ensureLocomotive( - manager: EntityManager, - currentYardId: string, - ): Promise { - const repo = manager.getRepository(Locomotive); - const existing = await repo.findOne({ where: { code: 'US12-DEMO-LOCO' } }); - if (existing) { - await repo.update(existing.id, { currentYardId, status: 'AVAILABLE' }); - return { ...existing, currentYardId, status: 'AVAILABLE' }; - } - - return repo.save( - repo.create({ - code: 'US12-DEMO-LOCO', - name: 'US12 Demo Locomotive', - locomotiveType: 'DIESEL', - maxPullWeightTons: 4200, - maxTrainLengthMeters: 760, - status: 'AVAILABLE', - currentYardId, - }), - ); - } - - private async ensureTrainSet( - manager: EntityManager, - trainNumber: string, - locomotiveId: string, - ): Promise { - const schedule = await manager.getRepository(TrainSchedule).findOne({ - where: { trainNumber }, - }); - if (schedule) { - const existing = await manager.getRepository(TrainSet).findOneByOrFail({ - id: schedule.trainSetId, - }); - await manager.getRepository(TrainSet).update(existing.id, { - locomotiveId, - status: 'COMPLETED', - }); - return { ...existing, locomotiveId, status: 'COMPLETED' }; - } - - return manager.getRepository(TrainSet).save( - manager.getRepository(TrainSet).create({ - locomotiveId, - totalWeightTons: 0, - totalLengthMeters: 0, - wagonCount: 0, - status: 'COMPLETED', - }), - ); - } - - private async ensureTrainSchedule( - manager: EntityManager, - input: { - trainNumber: string; - trainSetId: string; - originStationId: string; - destinationStationId: string; - scheduledDepartureDate: Date; - scheduledArrivalDate: Date; - actualDepartureAt: Date; - actualArrivalAt: Date; - direction: 'IMPORT' | 'EXPORT'; - }, - ): Promise { - const repo = manager.getRepository(TrainSchedule); - const existing = await repo.findOne({ where: { trainNumber: input.trainNumber } }); - const nextSchedule = repo.create({ - ...(existing ? { id: existing.id } : {}), - trainSetId: input.trainSetId, - originStationId: input.originStationId, - destinationStationId: input.destinationStationId, - scheduledDepartureDate: input.scheduledDepartureDate, - scheduledArrivalDate: input.scheduledArrivalDate, - actualDepartureAt: input.actualDepartureAt, - actualArrivalAt: input.actualArrivalAt, - status: TrainScheduleStatus.Arrived, - trainNumber: input.trainNumber, - direction: input.direction, - maxWagons: 53, - bookingWindowStatus: 'CLOSED', - }); - return repo.save(nextSchedule); - } - - private async ensureWagon( - manager: EntityManager, - input: { - wagonNumber: string; - wagonTypeId: string; - yardId: string; - trainScheduleId: string; - trainSetWagonId: string | null; - tareWeight: number; - capacityTons: number; - }, - ): Promise { - const repo = manager.getRepository(Wagon); - const existing = await repo.findOne({ where: { wagonNumber: input.wagonNumber } }); - return repo.save( - repo.create({ - ...(existing ? { id: existing.id } : {}), - wagonNumber: input.wagonNumber, - wagonTypeId: input.wagonTypeId, - currentYardId: input.yardId, - currentTrainScheduleId: input.trainScheduleId, - trainSetWagonId: input.trainSetWagonId, - tareWeight: input.tareWeight, - maxPayloadWeight: input.capacityTons, - status: WagonStatus.Assigned, - notes: 'US12 paid Indode demo seed wagon', - }), - ); - } - - private async ensureFirstMileVehicle(manager: EntityManager): Promise { - const driverRepo = manager.getRepository(Driver); - const vehicleRepo = manager.getRepository(Vehicle); - const licenseNumber = 'US12-FM-LIC-001'; - const plateNumber = 'ET-FM-1201'; - - await driverRepo.upsert( - { - licenseNumber, - firstName: 'Tesfaye', - lastName: 'Firstmile', - email: 'tesfaye.firstmile@edr.local', - phoneNumber: '251911120120', - licenseExpiryDate: this.addHours(new Date(), 24 * 365), - status: DriverStatus.ACTIVE, - vehicleTypesAuthorized: [VehicleType.TRUCK, VehicleType.FLATBED], - notes: 'Seeded first-mile driver for US12 receive-to-warehouse testing', - }, - { conflictPaths: { licenseNumber: true } }, - ); - const driver = await driverRepo.findOneByOrFail({ licenseNumber }); - - await vehicleRepo.upsert( - { - plateNumber, - registrationNumber: 'US12-FM-REG-001', - vehicleType: VehicleType.TRUCK, - manufacturer: 'Sinotruk', - model: 'HOWO Container Carrier', - year: 2024, - fuelType: FuelType.DIESEL, - capacity: 40, - status: VehicleStatus.ACTIVE, - assignedDriverId: driver.id, - assignedDriverName: `${driver.firstName} ${driver.lastName}`, - description: 'Seeded first-mile truck for US12 receive-to-warehouse testing', - estimatedDistanceKm: 35, - actualDistanceKm: 34.6, - }, - { conflictPaths: { plateNumber: true } }, - ); - const vehicle = await vehicleRepo.findOneByOrFail({ plateNumber }); - await manager.query( - `UPDATE freight.vehicles - SET trailer_plate_no = $2, - assigned_driver_id = $3, - assigned_driver_name = $4, - updated_at = NOW() - WHERE id = $1`, - [vehicle.id, 'ET-TRL-1201', driver.id, `${driver.firstName} ${driver.lastName}`], - ); - return vehicleRepo.findOneByOrFail({ plateNumber }); - } - - private containerNumber(reference: string): string { - const suffix = reference.replace(/[^A-Z0-9]/g, '').slice(-7); - return `US12${suffix}`; - } - - private addHours(date: Date, hours: number): Date { - return new Date(date.getTime() + hours * 60 * 60 * 1000); - } -} diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 48b9bc0a9..72c782e11 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -17,6 +17,7 @@ "@edr/ui-common": "workspace:*", "@hello-pangea/dnd": "^18.0.1", "@mantine/core": "^9.3.0", + "@mantine/dates": "^9.3.0", "@mantine/hooks": "^9.3.0", "@tabler/icons-react": "^3.44.0", "@tanstack/react-query": "^5.100.11", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index e0c47414d..b90dd95f5 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -435,10 +435,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Contract validity", href: "/dashboard/configuration/contract-validity-periods", }, - // { - // label: "Train scheduling rules", - // href: "/dashboard/configuration/train-scheduling-rules", - // }, + { + label: "Train scheduling rules", + href: "/dashboard/configuration/train-scheduling-rules", + }, ], }, { diff --git a/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx index 950cfd476..8971d4ccb 100644 --- a/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx @@ -42,8 +42,8 @@ export function ContainerAllocationTable({ ); const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ - queryKey: ["vehicles", "active"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + queryKey: ["vehicles", "free"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), }); const vehicleOptions = useMemo( @@ -99,7 +99,7 @@ export function ContainerAllocationTable({ {vehicles.length === 0 && ( } color="yellow"> - No active vehicles available. Add vehicles before allocating containers. + No free vehicles available. Free up or add vehicles before allocating containers. )} diff --git a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx index 85bba1dc4..78c5160ca 100644 --- a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx @@ -42,8 +42,8 @@ export function FirstMileContainerAllocationTable({ ); const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ - queryKey: ["vehicles", "active"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + queryKey: ["vehicles", "free"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), }); const vehicleOptions = useMemo( @@ -99,7 +99,7 @@ export function FirstMileContainerAllocationTable({ {vehicles.length === 0 && ( } color="yellow"> - No active vehicles available. Add vehicles before allocating containers. + No free vehicles available. Free up or add vehicles before allocating containers. )} diff --git a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx index d11d99a4a..cc619cb9a 100644 --- a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx @@ -42,8 +42,8 @@ export function LastMileContainerAllocationTable({ ); const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ - queryKey: ["vehicles", "active"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + queryKey: ["vehicles", "free"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), }); const vehicleOptions = useMemo( @@ -99,7 +99,7 @@ export function LastMileContainerAllocationTable({ {vehicles.length === 0 && ( } color="yellow"> - No active vehicles available. Add vehicles before allocating containers. + No free vehicles available. Free up or add vehicles before allocating containers. )} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx new file mode 100644 index 000000000..d7cd9207b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -0,0 +1,1130 @@ +import { useMemo, useState } from "react"; +import { + Alert, + Badge, + Button, + FileInput, + Group, + Modal, + NumberInput, + Paper, + Select, + Stack, + Stepper, + Text, + Textarea, +} from "@mantine/core"; +import { DateInput, DateTimePicker } from "@mantine/dates"; +import { + AlertTriangle, + CheckCircle2, + FileText, + PackageCheck, + Receipt, + Ship, + Train, + Truck, + Upload, +} from "lucide-react"; +import type { Freight } from "@edr/types"; +import toast from "react-hot-toast"; + +import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone"; +import { + TransitPermitMultiUpload, + type TransitPermitUploadedRow, +} from "@/components/contracts/TransitPermitMultiUpload"; +import { + findWorkflowFile, + PhasedUploadedFileRow, +} from "@/components/contracts/PhasedUploadedFileRow"; +import { + DeclarationStep, + StepStatus, + isBookingMilestoneDone, + isMilestoneDone, + type ClearanceViewLike, + type MilestoneRow, +} from "@/components/contracts/PhasedClearanceActionPanel"; +import { contractsService } from "@/services/contracts.service"; +import { bookingsService } from "@/services/bookings.service"; + +/** + * Export customs flow, ordered per the stakeholder process: + * customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET) + * → payment + wagons → transport document (ET) → train to Djibouti → gate pass (DJ) + * → accept T1 (DJ) → final invoice (DJ) + customer slip + GL confirm. + */ +export function computeExportActiveStep( + clearance: ClearanceViewLike, + bookingMilestones: MilestoneRow[], + bookingCreated: boolean, +): number { + const released = Boolean(clearance.bookingReady || clearance.operationReady); + if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0; + if (!isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED")) return 1; + if (!isMilestoneDone(clearance.milestones, "DECLARED") || !released) return 2; + if (!bookingCreated) return 3; + if ( + !isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") || + !( + isBookingMilestoneDone(bookingMilestones, "WAGON_ALLOCATED") || + clearance.train?.wagonAllocated + ) + ) { + return 4; + } + if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 5; + if (!clearance.train?.arrivedAt) return 6; + if (!clearance.gatepassGranted) return 7; + if (!clearance.t1Closed) return 8; + if (clearance.finalInvoice?.status !== "PAID") return 9; + return 10; +} + +export function exportTransitFilesFromWorkflow( + workflowFiles: Freight.ClearanceWorkflowFile[], +): TransitPermitUploadedRow[] { + return workflowFiles + .filter( + (f) => + f.category === "transit" && + f.code.toLowerCase().startsWith("export_transport_document") && + f.file, + ) + .map((f) => ({ + code: f.code, + label: f.label, + file: f.file!, + })); +} + +export function ExportClearanceStepper({ + contractId, + bookingId, + clearance, + workflowFiles = [], + showEt, + canEt, + showDj, + canDj, + onChanged, + bookingCreateHref, + onViewFile, + onDownloadFile, + useUploadModals = false, + onUploadRoRequest, + bookingCreated = false, + bookingMilestones = [], +}: { + contractId?: string; + bookingId?: string; + clearance: ClearanceViewLike; + workflowFiles?: Freight.ClearanceWorkflowFile[]; + showEt: boolean; + canEt: boolean; + showDj: boolean; + canDj: boolean; + onChanged?: () => void; + bookingCreateHref?: string; + onViewFile?: (file: { name: string; url: string }) => void; + onDownloadFile?: (file: { id: string; name: string }) => void; + useUploadModals?: boolean; + onUploadRoRequest?: () => void; + bookingCreated?: boolean; + bookingMilestones?: MilestoneRow[]; +}) { + // Pre-booking actions (RO, declaration, release) target the contract when one + // is present; GENERAL customs bookings run the same flow keyed on the booking. + const isBooking = Boolean(bookingId) && !contractId; + const entityId = contractId ?? bookingId ?? ""; + // The booking that carries the post-booking steps (gate pass, T1, invoice). + const actionBookingId = clearance.linkedBookingId ?? bookingId ?? null; + const effectiveBookingCreated = + bookingCreated || Boolean(clearance.linkedBookingId) || isBooking; + + const activeStep = useMemo( + () => computeExportActiveStep(clearance, bookingMilestones, effectiveBookingCreated), + [clearance, bookingMilestones, effectiveBookingCreated], + ); + + const released = Boolean(clearance.bookingReady || clearance.operationReady); + const declared = isMilestoneDone(clearance.milestones, "DECLARED"); + const paymentSettled = isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED"); + const wagonAllocated = + isBookingMilestoneDone(bookingMilestones, "WAGON_ALLOCATED") || + Boolean(clearance.train?.wagonAllocated); + const transportIssued = isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED"); + + return ( + + {clearance.roHold && clearance.roHoldReason ? ( + } title="Release Order on hold"> + {clearance.roHoldReason} + + ) : null} + + {clearance.nextAction ? ( + + + {clearance.nextAction.actor.replace("_", " ")} —{" "} + {clearance.nextAction.action} + + + ) : null} + + + + Export customs clearance + + + + ) : undefined + } + > + + + + } + > + {showDj && canDj ? ( + useUploadModals ? ( + + ) : ( + + ) + ) : ( + + {findWorkflowFile(workflowFiles, "release_order") ? ( + + ) : null} + {clearance.vesselDepartureDate ? ( + + Vessel departure:{" "} + {new Date(clearance.vesselDepartureDate).toLocaleDateString()} + + ) : null} + + + )} + + + : } + > + {showEt && canEt && !effectiveBookingCreated && (activeStep >= 2 || declared) ? ( + + + {declared && !released ? ( + + ) : null} + + ) : ( + + )} + + + } + > + {released && bookingCreateHref && !effectiveBookingCreated && showEt && canEt ? ( + + + Export is released. Create the shipment booking for the customer. + + + + ) : ( + + )} + + + } + > + + + + + + + : } + > + {showEt && canEt && actionBookingId && wagonAllocated ? ( + + ) : ( + + {exportTransitFilesFromWorkflow(workflowFiles).map((row) => ( + + ))} + + + )} + + + } + > + + + + + + + : } + > + + + + : } + > + + + + + ) : ( + + ) + } + > + + + + + + ); +} + +/** Legacy in-flight contracts: declaration done before auto-release existed. */ +function ConfirmExportReleaseFallback({ + entityId, + isBooking, + onChanged, +}: { + entityId: string; + isBooking: boolean; + onChanged?: () => void; +}) { + const [loading, setLoading] = useState(false); + return ( + + ); +} + +function GatepassStep({ + bookingId, + clearance, + canAct, + onChanged, +}: { + bookingId: string | null; + clearance: ClearanceViewLike; + canAct: boolean; + onChanged?: () => void; +}) { + const [opened, setOpened] = useState(false); + const [at, setAt] = useState(new Date()); + const [loading, setLoading] = useState(false); + + if (clearance.gatepassGranted) { + return ( + + ); + } + + const arrived = Boolean(clearance.train?.arrivedAt); + + return ( + + + {canAct && bookingId ? ( + <> + + setOpened(false)} + title={Grant gate pass} + radius="md" + size="sm" + > + + setAt(v ? new Date(v) : null)} + required + /> + + + + + + + + ) : null} + + ); +} + +function AcceptT1Step({ + bookingId, + clearance, + transportIssued, + workflowFiles = [], + canAct, + onChanged, + onViewFile, + onDownloadFile, +}: { + bookingId: string | null; + clearance: ClearanceViewLike; + transportIssued: boolean; + workflowFiles?: Freight.ClearanceWorkflowFile[]; + canAct: boolean; + onChanged?: () => void; + onViewFile?: (file: { name: string; url: string }) => void; + onDownloadFile?: (file: { id: string; name: string }) => void; +}) { + const [loading, setLoading] = useState(false); + const files = exportTransitFilesFromWorkflow(workflowFiles); + + return ( + + {files.map((row) => ( + + ))} + {clearance.t1Closed ? ( + + ) : ( + <> + + {canAct && bookingId ? ( + + ) : null} + + )} + + ); +} + +function FinalInvoiceStep({ + bookingId, + clearance, + canDjAct, + canConfirm, + onChanged, + onViewFile, + onDownloadFile, +}: { + bookingId: string | null; + clearance: ClearanceViewLike; + canDjAct: boolean; + canConfirm: boolean; + onChanged?: () => void; + onViewFile?: (file: { name: string; url: string }) => void; + onDownloadFile?: (file: { id: string; name: string }) => void; +}) { + const [opened, setOpened] = useState(false); + const [amount, setAmount] = useState(""); + const [currency, setCurrency] = useState("ETB"); + const [description, setDescription] = useState(""); + const [file, setFile] = useState(null); + const [sending, setSending] = useState(false); + const [confirming, setConfirming] = useState(false); + + const invoice = clearance.finalInvoice ?? null; + const paid = invoice?.status === "PAID"; + + if (!clearance.offloaded && !invoice) { + return ( + + ); + } + + return ( + + {invoice ? ( + + +
+ + {invoice.invoiceNumber} + + + {invoice.totalAmount.toLocaleString()} {invoice.currency} + {invoice.description ? ` — ${invoice.description}` : ""} + +
+ + {invoice.status} + +
+
+ ) : null} + + {invoice?.invoiceFile ? ( + + ) : null} + {invoice?.slipFile ? ( + + ) : null} + + {paid ? ( + + ) : invoice ? ( + <> + + {canConfirm && bookingId && invoice.slipFile ? ( + + ) : null} + + ) : canDjAct && bookingId ? ( + <> + + Cargo offloaded — send the final invoice to the customer. + + + setOpened(false)} + title={Send final invoice} + radius="md" + size="md" + > + + + +