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/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/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 4e9a7b69d..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, type ClearanceT1State } 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'; @@ -66,6 +72,21 @@ export interface BookingClearanceView { 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() @@ -175,6 +196,20 @@ export class BookingClearanceService { } } + 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, @@ -206,6 +241,33 @@ export class BookingClearanceService { 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', }; } @@ -302,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); } @@ -449,6 +517,7 @@ export class BookingClearanceService { bookingId: string, file: Express.Multer.File, userId?: string, + vesselDepartureDate?: string, ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { @@ -467,6 +536,12 @@ export class BookingClearanceService { file, }); + 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); 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 34e3d3d94..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, type ClearanceT1State } 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'; @@ -80,6 +86,21 @@ export interface ContractClearanceView { 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() @@ -212,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', ); @@ -237,11 +259,32 @@ export class ContractClearanceService { } } - let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); - if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') { - const bookingMilestones = await this.workflowService.listMilestonesForBooking( + 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( @@ -286,6 +329,33 @@ export class ContractClearanceService { 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', }; } @@ -873,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); } @@ -1039,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); @@ -1059,6 +1136,11 @@ export class ContractClearanceService { }); 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); 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 872d13a0c..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') @@ -889,9 +920,13 @@ export class ContractsController { } @Post('bookings/:bookingId/t1-close') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @BookingStaff([ + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.clearanceDjActions, + ]) @ApiOperation({ - summary: 'GL Ethiopia closes (accepts) the T1 document set after the train arrives', + summary: + 'Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)', }) closeT1( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -900,6 +935,116 @@ export class ContractsController { 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 f900c564f..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,24 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { isT1TransportFileCode, type Freight } from '@edr/types'; +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, @@ -43,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() { @@ -167,12 +178,8 @@ export class GlOperationsService { return { uploaded: files.length, completedMilestones }; } - /** - * 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 { + /** 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); @@ -190,19 +197,35 @@ export class GlOperationsService { .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, - trainDepartedAt: schedule?.actualDepartureAt - ? new Date(schedule.actualDepartureAt).toISOString() - : null, - trainArrivedAt: schedule?.actualArrivalAt - ? new Date(schedule.actualArrivalAt).toISOString() - : null, + wagonAllocated: train.wagonAllocated, + trainDepartedAt: train.departedAt, + trainArrivedAt: train.arrivedAt, closed: Boolean(closedMilestone), closedAt: closedMilestone?.triggeredAt ? new Date(closedMilestone.triggeredAt).toISOString() @@ -243,38 +266,541 @@ export class GlOperationsService { } /** - * GL Ethiopia closes (accepts) the T1 document set once the train has arrived. - * Completes the T1_CLOSED milestone; the document set becomes final. + * 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); - if (booking.tradeDirection !== 'IMPORT') { - throw new BadRequestException('T1 closure applies to import shipments only.'); - } + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; const state = await this.t1State(bookingId); if (state.closed) return state; - 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.', - ); + 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/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-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 567ecf074..8c2453e4d 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -419,10 +419,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/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" + > + + + +