Add booking window management and locomotive scheduling features

- Implemented migration to release stuck assigned locomotives.
- Added schedule window phases to train schedules.
- Created booking batch offers table for partial capacity bookings.
- Developed BookingSplitService to handle partial booking offers and splits.
- Introduced BookingWindowService to manage booking window lifecycle and transitions.
- Added BookingBatchOffer entity to represent offers made during booking splits.
- Enhanced locomotive options with warnings for scheduling.
- Created UpcomingWindowsSection component to display upcoming booking windows.
This commit is contained in:
Marshal
2026-07-03 03:36:06 +00:00
parent 18c158fb61
commit 56de90892d
41 changed files with 2480 additions and 124 deletions

View File

@@ -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<string, unknown> | 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;
}

View File

@@ -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;
}