diff --git a/apps/edr-freight-api/src/migrations/3060000000000-AddExportPaymentWindow.ts b/apps/edr-freight-api/src/migrations/3060000000000-AddExportPaymentWindow.ts new file mode 100644 index 000000000..c55745126 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3060000000000-AddExportPaymentWindow.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Export bookings get their own pay window, separately tunable from import: + * - global_rules.export_payment_window_minutes — global default for EXPORT + * (payment_window_minutes keeps governing IMPORT/DOMESTIC). + * - train_schedules.rule_payment_window_minutes — per-schedule override; until + * now the DTO accepted paymentWindowMinutes but only folded it into the + * reopen-delay sum, so the override never reached the actual pay window. + * - bookings.requested_train_schedule_id — the export train the customer picked + * at day-commit; pickExportSchedule honors it instead of earliest-first. + * - bookings.payment_reminder_sent_at — marks the one pre-deadline pay + * reminder so the 10s window tick doesn't re-send it. + */ +export class AddExportPaymentWindow3060000000000 implements MigrationInterface { + name = 'AddExportPaymentWindow3060000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.train_scheduling_global_rules ADD COLUMN IF NOT EXISTS export_payment_window_minutes int NOT NULL DEFAULT 60;`, + ); + await queryRunner.query( + `ALTER TABLE freight.train_schedules ADD COLUMN IF NOT EXISTS rule_payment_window_minutes int;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS requested_train_schedule_id uuid;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS payment_reminder_sent_at timestamptz;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS payment_reminder_sent_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS requested_train_schedule_id;`, + ); + await queryRunner.query( + `ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS rule_payment_window_minutes;`, + ); + await queryRunner.query( + `ALTER TABLE freight.train_scheduling_global_rules DROP COLUMN IF EXISTS export_payment_window_minutes;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3070000000000-AddBulkTotalWeightTons.ts b/apps/edr-freight-api/src/migrations/3070000000000-AddBulkTotalWeightTons.ts new file mode 100644 index 000000000..f2c50ec8d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3070000000000-AddBulkTotalWeightTons.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Break-bulk (PER_ITEM) bookings store their item count in + * cargo_total_weight_vgm, so the actual tonnage was never captured — wagon + * allocation divided an item COUNT by a tons capacity and under-allocated + * (400 machines ÷ 69T wagon read as 6 wagons instead of 12). New column holds + * the real total weight in tons for PER_ITEM cargo; null for PER_TON bulk and + * container bookings. + */ +export class AddBulkTotalWeightTons3070000000000 implements MigrationInterface { + name = 'AddBulkTotalWeightTons3070000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_total_weight_tons numeric(12,3);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_total_weight_tons;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 2bdc6ee16..da5886b81 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1002,6 +1002,23 @@ export class BillingService { ); } + // A booking's PREPAID invoice is only payable inside its pay window — + // `dueAt` mirrors booking.paymentDeadline (issuePayable at reserve time). + // Blocking INITIATION here is what makes the deadline real: the settle + // sweep's grace only shelters payments that started before this gate. + // Other invoice types keep dueAt as display-only. + if ( + invoice.source === Freight.InvoiceSource.Booking && + invoice.type === "PREPAID" && + invoice.dueAt && + invoice.dueAt.getTime() <= Date.now() + ) { + throw new BadRequestException( + "The payment window for this booking has closed — the reserved wagons " + + "were released. Please book again.", + ); + } + const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount); if (!(amountDue > 0)) { throw new BadRequestException("Invoice has no outstanding balance."); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index ec2fc5f2e..0eb65bf5d 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -16,6 +16,7 @@ import { containersPerWagonForSize, wagonsPerUnitForSize, } from '../rule-engine/container-type.util'; +import { bulkItemWagonsRequired } from '../train-scheduling/train-capacity.util'; import { BookingsRepository } from './bookings.repository'; import { wagonRemainder } from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; @@ -1186,6 +1187,10 @@ export class BookingPricingService { ...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0), ); if (!(capacity > 0)) return null; + // Break-bulk (PER_ITEM): `tons` above is the item count; size by + // indivisible items instead of pretending the count is tonnage. + const byItems = bulkItemWagonsRequired(booking, capacity); + if (byItems > 0) return byItems; return Math.max(1, Math.ceil(tons / capacity)); } catch { return null; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index 5f82a8e3b..0726445d5 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -26,6 +26,7 @@ describe('BookingTransitionService — operation review', () => { }; const bookingsService = { findById: jest.fn().mockResolvedValue(booking), + assertNoUnpaidHold: jest.fn().mockResolvedValue(undefined), }; const bookingBatchService = { enqueueRouteDayProcessing: jest.fn(), @@ -144,6 +145,7 @@ describe('BookingTransitionService — requestOperation export space gate', () = }; const bookingsService = { findById: jest.fn().mockResolvedValue(booking), + assertNoUnpaidHold: jest.fn().mockResolvedValue(undefined), checkDayCompatibilityForBooking: jest .fn() .mockResolvedValue({ hasDeparture: true, hasCompatible: true }), 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 d7b24d514..9dea12990 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 @@ -9,7 +9,10 @@ import { } from "@nestjs/common"; import { EventEmitter2, OnEvent } from "@nestjs/event-emitter"; -import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { + BookingBatchService, + type ExportTrainOption, +} from '../train-scheduling/booking-batch.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { isRoadService } from './road.util'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; @@ -401,6 +404,31 @@ export class BookingTransitionService { return fresh; } + /** + * Customer cancels their own unpaid hold (SELECTED_FOR_BATCH): the wagons + * release immediately instead of tying up the train until the pay window + * lapses. Ends CANCELLED; the freed capacity tops up from the waiting list. + */ + async cancelHold(bookingId: string, reason?: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]); + if (booking.consolidationPartnerId) { + throw new BadRequestException( + "This booking shares a consolidated wagon with another booking — " + + "contact support to cancel it.", + ); + } + await this.bookingsRepository.createReviewNote( + bookingId, + reason ?? "Customer cancelled before payment", + "REJECTION", + ); + await this.bookingBatchService.cancelReservation(bookingId); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.cancelled(fresh, reason ?? "Cancelled before payment"); + return fresh; + } + async cancel(bookingId: string, reason: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ @@ -891,6 +919,7 @@ export class BookingTransitionService { async requestOperation( bookingId: string, scheduledDate: string, + requestedTrainScheduleId?: string | null, ): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ @@ -898,6 +927,13 @@ export class BookingTransitionService { "OPERATION_CHANGES_REQUESTED", ]); + // A company sitting on another unpaid hold commits nothing new — this is + // the moment export capacity locks, so the lock applies here too. + // Government bookings allocate without paying and are exempt. + if (!booking.isGovernment) { + await this.bookingsService.assertNoUnpaidHold(booking.companyId); + } + // A bare initiated instance (clearance-first flow) carries no cargo or // price — it must go through the contract completion endpoint, which // persists cargo, prices, invoices and only then lands here itself. @@ -942,10 +978,18 @@ export class BookingTransitionService { // largest bookable leftover ("reduce to N wagons or pick another day"). // Import/domestic bookings are batched + splittable, so they are NOT gated // here — they get an advisory count below and the batch engine sizes them. - const scheduledBooking = { ...booking, scheduledDate: date } as Booking; const isExportTrain = booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType); + // The customer's train pick only exists for export rail; it rides the + // booking through the space checks below AND is persisted so the accept / + // reserve path locks onto that train (pickExportSchedule honors it). + const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null; + const scheduledBooking = { + ...booking, + scheduledDate: date, + requestedTrainScheduleId: requestedId, + } as Booking; if (isExportTrain) { // With export split ON the booking no longer has to ride ONE train whole: // the largest fitting part is offered and the leftover rebooks on the next @@ -958,9 +1002,14 @@ export class BookingTransitionService { eatDay(date), "EXPORT", ); - if (!fitting.length) { + const fitsRequest = requestedId + ? fitting.some((f) => f.scheduleId === requestedId) + : fitting.length > 0; + if (!fitsRequest) { throw new ConflictException( - "No export train on this day has space left — pick another shipment day.", + requestedId + ? "The selected train has no space left — pick another train or day." + : "No export train on this day has space left — pick another shipment day.", ); } } else { @@ -971,6 +1020,7 @@ export class BookingTransitionService { await this.bookingsRepository.update(bookingId, { status: "OPERATION_REQUEST_PENDING", scheduledDate: date, + requestedTrainScheduleId: requestedId, } as never); const fresh = await this.bookingsService.findById(bookingId); this.notifier.operationRequestedToStaff(fresh); @@ -988,6 +1038,35 @@ export class BookingTransitionService { * total covers the booking. `trainsForDay` is false when no departure carries * the leg — the day is unbookable regardless of space. */ + /** + * Export train picker data for a shipment day the customer is choosing: + * each export train on the booking's corridor with per-wagon-type free + * space. Export rail bookings only — nothing else picks a train. + */ + async exportTrainsForBooking( + bookingId: string, + scheduledDate: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + const date = new Date(scheduledDate); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException("A valid schedule date is required"); + } + if ( + booking.tradeDirection !== "EXPORT" || + isRoadService(booking.serviceType) + ) { + throw new BadRequestException( + "Train selection is only available for export rail bookings", + ); + } + const scheduledBooking = { ...booking, scheduledDate: date } as Booking; + return this.bookingBatchService.exportTrainOptionsForDay( + scheduledBooking, + eatDay(date), + ); + } + async dayAvailabilityForBooking( bookingId: string, scheduledDate: string, 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 54ce1660e..875e3b08d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -751,10 +751,24 @@ export class BookingsController { const booking = await this.transitionService.requestOperation( id, dto.scheduledDate, + dto.trainScheduleId ?? null, ); return this.transitionService.enrichBookingResponse(booking); } + @Get(":id/export-trains") + @ApiOperation({ + summary: + "Export train picker: the day's export trains on the booking's corridor " + + "with per-wagon-type free space (export rail bookings only)", + }) + async exportTrainsForBooking( + @Param("id", ParseUUIDPipe) id: string, + @Query("date") date: string, + ) { + return this.transitionService.exportTrainsForBooking(id, date); + } + @Post(":id/operation/review") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ @@ -1271,6 +1285,20 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(":id/cancel-hold") + @ApiOperation({ + summary: + "Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); " + + "reserved wagons release immediately", + }) + async cancelHold( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CancelBookingDto, + ) { + const booking = await this.transitionService.cancelHold(id, dto.reason); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(":id/consolidation") @ApiOperation({ summary: "Request freight consolidation" }) requestConsolidation(@Param("id", ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index b9ced4f96..fb685a37c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1316,6 +1316,16 @@ export class BookingsRepository extends BaseRepository { .getMany(); } + /** Open unpaid holds (wagons reserved, pay window running) for a company. */ + countUnpaidHoldsForCompany(companyId: string): Promise { + return this.repository.count({ + where: { + companyId, + status: In(['SELECTED_FOR_BATCH', 'AWAITING_PAYMENT']), + }, + }); + } + /** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */ findReservedForSchedule(scheduleId: string): Promise { return this.repository diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index d40433f4e..2c5e6ed88 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -602,6 +602,24 @@ export class BookingsService { return result.booking; } + /** + * A company with an open unpaid hold (SELECTED_FOR_BATCH — wagons reserved, + * pay window running) may not take more capacity until it pays or the hold + * dies: otherwise one customer can lock a train's wagons over and over + * without ever paying. EXPIRED / CANCELLED holds free the lock. + */ + async assertNoUnpaidHold(companyId?: string | null): Promise { + if (!companyId) return; + const holds = + await this.bookingsRepository.countUnpaidHoldsForCompany(companyId); + if (holds > 0) { + throw new ConflictException( + 'You already have a booking waiting for payment. Pay it or cancel it ' + + 'before making a new booking.', + ); + } + } + /** Create a new freight booking. */ async create( dto: CreateBookingDto, @@ -664,6 +682,10 @@ export class BookingsService { companyId = company.id; } + // Government bookings allocate without paying, so the unpaid-hold lock + // only applies to commercial companies. + if (!isGovernment) await this.assertNoUnpaidHold(companyId); + if (dto.trainScheduleId) { // Staff manual pin: the schedule must be OPEN and on the same route. const schedule = await this.dataSource @@ -857,6 +879,9 @@ export class BookingsService { cargoFreeText: dto.cargoFreeText, shippingLineId: dto.shippingLineId, cargoTotalWeightVgm: dto.cargoTotalWeightVgm, + // Break-bulk actual tonnage (PER_ITEM cargo); meaningless outside BULK. + bulkTotalWeightTons: + dto.freightType === 'BULK' ? (dto.bulkTotalWeightTons ?? null) : null, isHazardous: dto.isHazardous ?? false, // Bulk reefer is the customer's toggle; container reefer is derived from // the container type at pricing time, so the booking-level flag stays off @@ -1048,6 +1073,11 @@ export class BookingsService { ...dto, freightType, cargoTypeId: freightType === 'BULK' ? cargoTypeId : null, + // Break-bulk actual tonnage; cleared when the booking leaves BULK. + bulkTotalWeightTons: + freightType === 'BULK' + ? (dto.bulkTotalWeightTons ?? existing.bulkTotalWeightTons ?? null) + : null, // Booking-level reefer is only meaningful for bulk; container reefer is // derived from the container type at pricing time. isReefer: diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index c4d971f51..074a45323 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -325,6 +325,21 @@ export class CreateBookingDto { @Transform(({ value }) => Number(value)) cargoTotalWeightVgm!: number; + /** + * Break-bulk only: actual total cargo weight in tons when the bulk cargo + * type is PER_ITEM — `cargoTotalWeightVgm` then carries the item count. + * Omit for PER_TON bulk and container freight. + */ + @ApiPropertyOptional({ + minimum: 0, + description: 'Break-bulk (PER_ITEM) total weight in tons; cargoTotalWeightVgm holds the item count', + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => (value == null ? undefined : Number(value))) + bulkTotalWeightTons?: number; + @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index f27b375bb..63ad5b9f4 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -5,6 +5,7 @@ import { IsInt, IsOptional, IsString, + IsUUID, Max, Min, MinLength, @@ -93,6 +94,17 @@ export class RequestOperationDto { }) @IsDateString() scheduledDate!: string; + + @ApiPropertyOptional({ + description: + 'EXPORT rail only: the specific train (schedule id) the customer picked ' + + 'from GET /bookings/:id/export-trains. The reserve path locks onto this ' + + 'train instead of earliest-first; 409 if it no longer fits. Ignored for ' + + 'import/domestic/road bookings.', + }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; } export class OperationReviewDto { diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index dbe837516..892c82a99 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -351,6 +351,15 @@ export class Booking extends BaseEntity { @Column({ name: 'cargo_total_weight_vgm', type: 'numeric', precision: 12, scale: 3 }) cargoTotalWeightVgm!: number; + /** + * Break-bulk only: actual total cargo weight in tons when the bulk cargo + * type is PER_ITEM (`cargoTotalWeightVgm` then carries the item COUNT). + * Null for PER_TON bulk and all CONTAINER bookings. Wagon allocation uses + * weight ÷ count to size indivisible items per wagon. + */ + @Column({ name: 'bulk_total_weight_tons', type: 'numeric', precision: 12, scale: 3, nullable: true }) + bulkTotalWeightTons?: number | null; + @Column({ name: 'is_hazardous', type: 'boolean', default: false }) isHazardous!: boolean; @@ -485,6 +494,18 @@ export class Booking extends BaseEntity { @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) trainScheduleId?: string | null; + /** + * EXPORT only: the specific train the customer picked at day-commit. + * pickExportSchedule reserves on this train (409 if it no longer fits) + * instead of falling back to earliest-departure-first. NULL = no preference. + */ + @Column({ name: 'requested_train_schedule_id', type: 'uuid', nullable: true }) + requestedTrainScheduleId?: string | null; + + /** Stamped when the one pre-deadline pay reminder went out (tick dedup). */ + @Column({ name: 'payment_reminder_sent_at', type: 'timestamptz', nullable: true }) + paymentReminderSentAt?: Date | null; + // ── Per-booking journey (segment corridor bookings) ──────────────────────── // A booking rides only its own origin→destination leg of the train's route, // so dispatch/arrival are per-booking facts, not train facts. Clearance gates diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index c567f71ce..d8c26290c 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -109,6 +109,23 @@ export class ContractBookingService { private readonly bookingTransitionService: BookingTransitionService, ) {} + /** + * Mirrors BookingsService.assertNoUnpaidHold for the contract booking paths: + * a company sitting on an unpaid hold (SELECTED_FOR_BATCH) books nothing new + * until it pays or the hold dies. + */ + private async assertNoUnpaidHold(companyId?: string | null): Promise { + if (!companyId) return; + const holds = + await this.bookingsRepository.countUnpaidHoldsForCompany(companyId); + if (holds > 0) { + throw new ConflictException( + 'You already have a booking waiting for payment. Pay it or cancel it ' + + 'before making a new booking.', + ); + } + } + async createUnderContract( contractId: string, dto: CreateBookingUnderContractDto, @@ -169,6 +186,8 @@ export class ContractBookingService { // remainder; the customer cannot start any other booking on the contract. // If the remainder splits again the same rule repeats until the cap is // exhausted and the contract completes. + await this.assertNoUnpaidHold(contract.companyId); + if (contract.contractKind === 'ONE_TIME') { if (await this.hasSplitBooking(contractId)) { await this.assertExactRemainder(contract, dto); @@ -455,6 +474,7 @@ export class ContractBookingService { ); } } + await this.assertNoUnpaidHold(contract.companyId); const route = await this.resolveRoute(contract, dto.contractRouteId); 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 2b9968f04..0581f6000 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 @@ -153,6 +153,14 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'rule_reopen_delay_minutes', type: 'int', nullable: true }) ruleReopenDelayMinutes?: number | null; + /** + * Per-schedule pay-window override (minutes). NULL = use the live global + * value for the schedule's direction. Unlike the other rule_* snapshots this + * is only written by an explicit staff override, never stamped at creation. + */ + @Column({ name: 'rule_payment_window_minutes', type: 'int', nullable: true }) + rulePaymentWindowMinutes?: number | null; + @Column({ name: 'rule_import_window_lead_days', type: 'int', nullable: true }) ruleImportWindowLeadDays?: number | null; 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 62ed50c0a..6414ba768 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 @@ -9,6 +9,17 @@ export const BATCH_TIMEZONE = 'Africa/Addis_Ababa'; +/** + * Slack after a booking's paymentDeadline before the settle sweep expires it. + * Covers gateway/webhook lag for a payment STARTED inside the window — + * initiation itself is hard-blocked at the deadline (BillingService.payInvoice), + * so this never extends the time a customer has to begin paying. + */ +export const PAYMENT_GRACE_MS = 5 * 60_000; + +/** How long before the pay deadline the one reminder notification goes out. */ +export const PAYMENT_REMINDER_LEAD_MS = 10 * 60_000; + /** 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 a402e6237..cedcfcb19 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 @@ -109,6 +109,7 @@ describe('BookingBatchService — PAID reconcile', () => { windowDurationHours: 3, docReviewMinutes: 30, paymentWindowMinutes: 60, + exportPaymentWindowMinutes: 60, }), }; @@ -835,7 +836,8 @@ describe('BookingBatchService — PAID reconcile', () => { // One reservation whose pay window lapsed, and one booking on the waiting list. const lapsed = booking('lapsed', 50, { status: 'SELECTED_FOR_BATCH', - paymentDeadline: new Date(Date.now() - 60_000), + // Past deadline + the 5-minute webhook grace, so the settle expires it. + paymentDeadline: new Date(Date.now() - 6 * 60_000), }); const waiting = booking('waiting', 10, { trainScheduleId: null }); @@ -870,7 +872,8 @@ describe('BookingBatchService — PAID reconcile', () => { it('serialises concurrent settles so the same reservation is not settled twice', async () => { const lapsed = booking('lapsed', 50, { status: 'SELECTED_FOR_BATCH', - paymentDeadline: new Date(Date.now() - 60_000), + // Past deadline + the 5-minute webhook grace, so the settle expires it. + paymentDeadline: new Date(Date.now() - 6 * 60_000), }); // Both callers read the reservation; the lock must stop the second from // acting on rows the first already expired. (The PAYMENT transition and the @@ -901,7 +904,8 @@ describe('BookingBatchService — PAID reconcile', () => { it('never expires a reservation whose payment landed — allocates it instead', async () => { const latePaid = booking('late-paid', 50, { status: 'SELECTED_FOR_BATCH', - paymentDeadline: new Date(Date.now() - 60_000), + // Past deadline + the 5-minute webhook grace, so the settle expires it. + paymentDeadline: new Date(Date.now() - 6 * 60_000), }); bookingsRepository.findReservedForSchedule .mockResolvedValueOnce([latePaid]) @@ -1015,7 +1019,8 @@ describe('BookingBatchService — PAID reconcile', () => { ...(waiting as unknown as Record), status: 'SELECTED_FOR_BATCH', trainScheduleId: exportScheduleId, - paymentDeadline: new Date(Date.now() - 1_000), + // Past deadline + the 5-minute webhook grace, so the settle expires it. + paymentDeadline: new Date(Date.now() - 6 * 60_000), originYardId: 'yard-a', destinationYardId: 'yard-b', priorityScore: 0, 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 a5957a285..88cd51b62 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 @@ -61,11 +61,14 @@ import { DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_TARE_TONS, DEFAULT_WAGONS_PER_BOOKING, + PAYMENT_GRACE_MS, + PAYMENT_REMINDER_LEAD_MS, } from "./booking-batch.constants"; import { LocomotiveLimits, WagonTypeDimensions, bookingCargoTons, + bulkItemWagonsRequired, bookingGrossWeightTons, deriveTrainCapacityFromLocomotive, sizePartialOfferWagons, @@ -115,6 +118,32 @@ export interface ExportSpaceReport { fullMessage: string | null; } +/** + * One export train the customer can pick for a shipment day: live free-wagon + * space measured against THE BOOKING'S allowed wagon types (so the per-type + * list doubles as "what cargo this train can take for you"). Unpaid holds + * count as taken; lapsed holds free up via the lazy-expiry capacity filter. + */ +export interface ExportTrainOption { + scheduleId: string; + departure: Date; + /** Booking cutoff for this train (windowClosesAt), null on legacy rows. */ + bookingClosesAt: Date | null; + /** Whether the export FCFS window is open for booking right now. */ + isOpen: boolean; + /** Best bookable wagons across the booking's allowed types. */ + freeWagons: number; + /** Wagons this booking needs — `fits` = freeWagons >= neededWagons. */ + neededWagons: number; + fits: boolean; + byWagonType: Array<{ + wagonTypeId: string | null; + code: string | null; + name: string | null; + freeWagons: number; + }>; +} + /** A day-level pool key: all trains on this route departing on this EAT day. */ interface RouteDayGroup { originYardId: string; @@ -663,12 +692,16 @@ export class BookingBatchService implements OnModuleInit { { status: TrainScheduleStatusEnum.Scheduled }, ], }); + // A customer-picked train narrows the scan to that ONE schedule: export + // FCFS honors the pick or fails loudly (exportFullMessage names it). + const requestedId = booking.requestedTrainScheduleId ?? null; const candidates = corridor .filter( (s) => s.scheduledDepartureDate != null && eatDay(s.scheduledDepartureDate) === day && - this.isFillable(s), + this.isFillable(s) && + (!requestedId || s.id === requestedId), ) .sort( (a, b) => @@ -755,13 +788,18 @@ export class BookingBatchService implements OnModuleInit { /** Customer-facing "train is full" copy carrying the bookable leftover. */ private exportFullMessage(booking: Booking, report: ExportSpaceReport): string { + const picked = Boolean(booking.requestedTrainScheduleId); if (!report.trainsForDay || !report.corridorMatched) { - return 'No export train is accepting bookings for this day'; + return picked + ? 'The selected train is no longer accepting bookings — pick another train or day.' + : 'No export train is accepting bookings for this day'; } const best = report.bestAvailable; - const base = - 'Not enough train space — an export booking must ride a single train whole, ' + - 'and no open train on this day can carry it. '; + const base = picked + ? 'Not enough space left on the selected train — an export booking must ' + + 'ride one train whole. ' + : 'Not enough train space — an export booking must ride a single train whole, ' + + 'and no open train on this day can carry it. '; if (!best || best.wagons <= 0) { return base + 'No capacity is left on this day — pick another shipment day.'; } @@ -864,6 +902,88 @@ export class BookingBatchService implements OnModuleInit { return out; } + /** + * The export train picker: every export train on the booking's corridor/day + * with its live space, measured per allowed wagon type so the customer sees + * what each train can still take for THEIR cargo. Includes full/not-yet-open + * trains (freeWagons 0 / isOpen false) so the UI can show them disabled — + * the request-time gate (exportSpaceReport) stays the enforcement point. + */ + async exportTrainOptionsForDay( + booking: Booking, + day: string, + ): Promise { + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, + ], + }); + const candidates = corridor + .filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + s.direction === 'EXPORT', + ) + .sort( + (a, b) => + a.scheduledDepartureDate!.getTime() - + b.scheduledDepartureDate!.getTime(), + ); + + const wagonDims = await this.loadWagonDims(); + const allowed = this.allowedDimsWithTypes(booking, wagonDims); + const neededWagons = this.wagonsFor(booking, wagonDims); + const typeIds = allowed + .map((a) => a.wagonTypeId) + .filter((id): id is string => Boolean(id)); + const types = typeIds.length + ? await this.dataSource + .getRepository(WagonType) + .find({ where: { id: In(typeIds) } }) + : []; + const typeById = new Map(types.map((t) => [t.id, t])); + + const out: ExportTrainOption[] = []; + for (const candidate of candidates) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + candidate.id, + ); + const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); + if (!schedule || !locomotive) continue; + const limits = await this.capacityLimits(locomotive); + const budget = await this.remainingBudget(schedule, limits, wagonDims); + const leg = budget.legOf(booking.originYardId, booking.destinationYardId); + if (!leg) continue; // this train's route doesn't carry the booking's leg + const room = budget.remainingFor(leg); + const byWagonType = allowed.map(({ wagonTypeId, dims }) => { + const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined; + return { + wagonTypeId, + code: type?.code ?? null, + name: type?.name ?? null, + freeWagons: this.bookableWithin(room, dims).wagons, + }; + }); + const freeWagons = byWagonType.reduce( + (best, t) => Math.max(best, t.freeWagons), + 0, + ); + out.push({ + scheduleId: schedule.id, + departure: schedule.scheduledDepartureDate!, + bookingClosesAt: schedule.windowClosesAt ?? null, + isOpen: this.isFillable(schedule), + freeWagons, + neededWagons, + fits: freeWagons >= neededWagons, + byWagonType, + }); + } + return out; + } + /** * Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day, * summed across every train on the booking's corridor that day. Unlike the @@ -2207,7 +2327,10 @@ export class BookingBatchService implements OnModuleInit { }; if (!this.fits(offeredNeed, budget)) return null; - const deadline = new Date(Date.now() + (await this.paymentWindowMs())); + const deadline = new Date( + Date.now() + + (await this.paymentWindowMsFor(await this.scheduleById(scheduleId))), + ); 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. @@ -2216,6 +2339,7 @@ export class BookingBatchService implements OnModuleInit { status: "SELECTED_FOR_BATCH", selectedForBatchAt: new Date(), paymentDeadline: deadline, + paymentReminderSentAt: null, } as never); booking.trainScheduleId = scheduleId; return offeredNeed; @@ -2244,9 +2368,11 @@ export class BookingBatchService implements OnModuleInit { const isPaid = (b: Booking) => b.paymentStatus === "PAID" || b.status === "PAID"; + // Grace: a payment started inside the window may land minutes late via the + // gateway webhook — don't expire until the slack has passed too. const isExpired = (b: Booking) => b.paymentDeadline - ? b.paymentDeadline.getTime() <= now + ? b.paymentDeadline.getTime() + PAYMENT_GRACE_MS <= now : expireUnpaidUnknownDeadline; for (const booking of reserved) { @@ -2544,6 +2670,39 @@ export class BookingBatchService implements OnModuleInit { this.notifyBoardChanged(newScheduleId, "booking_moved"); } + /** + * One reminder per hold, shortly before its pay deadline (the window tick + * calls this every pass; `payment_reminder_sent_at` dedups). Skips paid + * bookings — a landed payment the settle hasn't processed yet needs no nag. + */ + async sendPaymentReminders(): Promise { + const now = new Date(); + const due = await this.dataSource + .getRepository(Booking) + .createQueryBuilder("b") + .leftJoinAndSelect("b.company", "company") + .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .andWhere(`b.payment_status != 'PAID'`) + .andWhere("b.payment_reminder_sent_at IS NULL") + .andWhere("b.payment_deadline > :now", { now }) + .andWhere("b.payment_deadline <= :soon", { + soon: new Date(now.getTime() + PAYMENT_REMINDER_LEAD_MS), + }) + .getMany(); + for (const booking of due) { + // Stamp BEFORE sending so a slow notifier can't double-send next tick. + await this.bookingsRepository.update(booking.id, { + paymentReminderSentAt: new Date(), + } as never); + if (booking.paymentDeadline) { + await this.notifier.payDeadlineApproaching( + booking, + booking.paymentDeadline, + ); + } + } + } + /** Staff "expire" override → free a reservation now (booking becomes EXPIRED). */ async expireReservation(bookingId: string): Promise { const booking = await this.dataSource @@ -2564,6 +2723,52 @@ export class BookingBatchService implements OnModuleInit { } } + /** + * Customer cancel of an unpaid hold: the same immediate release as + * expireReservation, but the booking ends CANCELLED (the customer chose to + * walk away — "payment window missed" copy would be wrong). Consolidated + * pairs are rejected by the caller: the shared wagon is both-or-neither. + */ + async cancelReservation(bookingId: string): Promise { + const booking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + const freedScheduleId = booking.trainScheduleId; + await this.bookingsRepository.update(booking.id, { + trainScheduleId: null, + requestedTrainScheduleId: null, + status: "CANCELLED", + schedulingStatus: "ELIGIBLE", + paymentDeadline: null, + selectedForBatchAt: null, + paymentReminderSentAt: null, + } as never); + // An unpaid partial offer dies with the hold — same as expire(). + if (this.splitService) { + await this.splitService.expireOpenOffer(booking.id); + } + await this.billing.expirePayable( + Freight.InvoiceSource.Booking, + booking.id, + "PREPAID", + ); + if (freedScheduleId) { + // Same release choreography as expireReservation: reopen a FULL window, + // top up from the waiting list, push one board update with final state. + await this.refreshWindowStatus(freedScheduleId); + const topUpReserved = await this.topUpFill(freedScheduleId); + if (topUpReserved > 0) { + await this.extendPaymentPhaseForTopUp(freedScheduleId); + } + this.notifyBoardChanged(freedScheduleId, "reservation_expired"); + } + this.logger.log( + `[BATCH] CANCELLED hold ${booking.reference} — customer released the ` + + `reservation before paying; wagons freed`, + ); + } + // ---- intercity ride-along API --------------------------------------------- /** @@ -2648,14 +2853,14 @@ export class BookingBatchService implements OnModuleInit { return; } const now = new Date(); - let deadline = new Date(now.getTime() + (await this.paymentWindowMs())); + const targetSchedule = await this.scheduleById(scheduleId); + let deadline = new Date( + now.getTime() + (await this.paymentWindowMsFor(targetSchedule)), + ); // EXPORT parity: pay windows on an export train never outlive its booking // window — export bookings expire at close, so anything reserved onto the // same train (FCFS export or an intercity ride-along) must too. Import // keeps the plain payment window; its cycles re-fill after settle. - const targetSchedule = await this.dataSource - .getRepository(TrainSchedule) - .findOne({ where: { id: scheduleId } }); if (targetSchedule?.direction === "EXPORT") { const cutoff = targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate; @@ -2673,6 +2878,7 @@ export class BookingBatchService implements OnModuleInit { status: "SELECTED_FOR_BATCH", selectedForBatchAt: now, paymentDeadline: deadline, + paymentReminderSentAt: null, } as never); booking.trainScheduleId = scheduleId; // The invoice was generated DRAFT at booking creation / operation-accept, @@ -2860,10 +3066,13 @@ export class BookingBatchService implements OnModuleInit { const freedScheduleId = booking.trainScheduleId; await this.bookingsRepository.update(booking.id, { trainScheduleId: null, + // The customer's train pick died with the hold — a rebook re-picks. + requestedTrainScheduleId: null, status: "EXPIRED", schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, + paymentReminderSentAt: null, } as never); booking.trainScheduleId = null; // The wagons this reservation held are back — a schedule parked at FULL @@ -3406,7 +3615,11 @@ export class BookingBatchService implements OnModuleInit { const byWeight = cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; - return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight); + // Break-bulk (PER_ITEM): indivisible items can need more wagons than raw + // tonnage suggests (floor items-per-wagon loses the fractional capacity). + const byItems = bulkItemWagonsRequired(booking, capacityTons); + + return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight, byItems); } /** @@ -3602,6 +3815,18 @@ export class BookingBatchService implements OnModuleInit { * representative dims when no allowed type is configured. */ private dimsForAllowed(booking: Booking, wagonDims: WagonDims): PerWagonDims[] { + return this.allowedDimsWithTypes(booking, wagonDims).map((p) => p.dims); + } + + /** + * Same allowed set as {@link dimsForAllowed} but keeping each wagon-type id, + * so callers (the export train picker) can label per-type availability. + * `wagonTypeId` is null only on the unconfigured fallback entry. + */ + private allowedDimsWithTypes( + booking: Booking, + wagonDims: WagonDims, + ): Array<{ wagonTypeId: string | null; dims: PerWagonDims }> { const fallback = booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container; const ids = @@ -3611,19 +3836,23 @@ export class BookingBatchService implements OnModuleInit { .flatMap((line) => line.containerType?.wagonTypes ?? []) .map((wt) => wt.id); const seen = new Set(); - const dims: PerWagonDims[] = []; + const out: Array<{ wagonTypeId: string | null; dims: PerWagonDims }> = []; for (const id of ids) { if (!id || seen.has(id)) continue; seen.add(id); const d = wagonDims.byWagonTypeId.get(id); if (d) { - dims.push({ - ...d, - capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons, + out.push({ + wagonTypeId: id, + dims: { + ...d, + capacityTons: + d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons, + }, }); } } - return dims.length ? dims : [fallback]; + return out.length ? out : [{ wagonTypeId: null, dims: fallback }]; } /** @@ -3808,8 +4037,18 @@ export class BookingBatchService implements OnModuleInit { const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); - const reserved = await this.bookingsRepository.findReservedForSchedule( - schedule.id, + // Lazy-expiry guard: a hold whose deadline + grace has lapsed no longer + // blocks capacity, even before the 10s sweep flips it to EXPIRED — so + // availability shown to the next customer is honest between ticks. + const graceCutoff = Date.now() - PAYMENT_GRACE_MS; + const reserved = ( + await this.bookingsRepository.findReservedForSchedule(schedule.id) + ).filter( + (b) => + b.paymentStatus === "PAID" || + b.status === "PAID" || + b.paymentDeadline == null || + b.paymentDeadline.getTime() > graceCutoff, ); for (const b of [...allocated, ...reserved]) { budget.subtract( @@ -3905,7 +4144,7 @@ export class BookingBatchService implements OnModuleInit { b.paymentStatus !== "PAID" && b.status !== "PAID" && b.paymentDeadline != null && - b.paymentDeadline.getTime() > now, + b.paymentDeadline.getTime() + PAYMENT_GRACE_MS > now, ); } @@ -4089,10 +4328,33 @@ export class BookingBatchService implements OnModuleInit { // ---- timer plumbing ------------------------------------------------------- - /** Configured customer pay window in ms (global rules, with defaults). */ - private async paymentWindowMs(): Promise { + /** + * Effective customer pay window in ms for a target schedule: the staff + * per-schedule override wins, else the global value for the schedule's + * direction (export and import pay windows are tuned independently). + * No schedule (unknown target) falls back to the import global. + */ + private async paymentWindowMsFor( + schedule?: Pick< + TrainSchedule, + "direction" | "rulePaymentWindowMinutes" + > | null, + ): Promise { + if (schedule?.rulePaymentWindowMinutes != null) { + return schedule.rulePaymentWindowMinutes * 60_000; + } const cfg = await this.trainSchedulingService.getWindowConfig(); - return cfg.paymentWindowMinutes * 60_000; + const minutes = + schedule?.direction === "EXPORT" + ? cfg.exportPaymentWindowMinutes + : cfg.paymentWindowMinutes; + return minutes * 60_000; + } + + private scheduleById(id: string): Promise { + return this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id } }); } private timeoutName(scheduleId: string): string { @@ -4104,8 +4366,12 @@ export class BookingBatchService implements OnModuleInit { * engine's minute tick calling settleDueReservations off `paymentDeadline`. */ private armSettle(scheduleId: string): void { - void this.paymentWindowMs() - .then((delayMs) => { + void this.scheduleById(scheduleId) + .then((schedule) => this.paymentWindowMsFor(schedule)) + // The timer covers the grace too — firing at the bare deadline would + // settle before the sweep's grace cutoff and find nothing to expire. + .then((windowMs: number) => { + const delayMs = windowMs + PAYMENT_GRACE_MS; this.removeTimeout(scheduleId); const handle = setTimeout(() => { void this.settleBatch(scheduleId).catch((err) => @@ -4138,7 +4404,7 @@ export class BookingBatchService implements OnModuleInit { .getRepository(TrainSchedule) .findOne({ where: { id: scheduleId } }); if (!schedule || schedule.windowPhase !== "PAYMENT") return; - const windowMs = await this.paymentWindowMs(); + const windowMs = await this.paymentWindowMsFor(schedule); let target = new Date(Date.now() + windowMs); if ( schedule.scheduledDepartureDate && 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 e17445b4d..b806de5ca 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 @@ -137,6 +137,25 @@ export class BookingNotifierService { }); } + /** One warning shortly before the pay window closes (sent once per hold). */ + async payDeadlineApproaching(b: Booking, deadline: Date): Promise { + const minutesLeft = Math.max( + 1, + Math.round((deadline.getTime() - Date.now()) / 60_000), + ); + const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); + const msg = + `Payment reminder: about ${minutesLeft} minute${minutesLeft === 1 ? '' : 's'} left ` + + `to pay for booking ${b.reference ?? b.id}. Deadline: ${eat} EAT — ` + + `unpaid reservations are released and the wagons go back on sale.`; + await this.notifyContact(b, msg, 'PAY REMINDER'); + // HIGH: minutes from losing the reserved wagons — must reach SMS/email. + this.inApp(b, 'Payment deadline approaching', msg, { + type: NotificationType.INVOICE_ISSUED, + priority: NotificationPriority.HIGH, + }); + } + /** * Partial-capacity offer: only `offeredWagons` of the booking's `totalWagons` fit * this train. Paying accepts the split; letting the deadline pass keeps the 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 index 617d83561..74e9a0bff 100644 --- 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 @@ -18,7 +18,10 @@ export interface BookingWindowConfig { windowDurationHours: number; /** Max staff document-review time after the window closes. */ docReviewMinutes: number; + /** Pay window for IMPORT/DOMESTIC bookings (also part of the reopen gap). */ paymentWindowMinutes: number; + /** Pay window for EXPORT bookings — independent of the import value. */ + exportPaymentWindowMinutes: number; /** * Minutes before departure the IMPORT/DOMESTIC booking window shuts. When set * (> 0), the effective booking cutoff is `departure − this`, capping the first diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts index 79b279393..9dff62261 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts @@ -36,6 +36,7 @@ describe('BookingWindowService — window state machine', () => { windowDurationHours: 1, docReviewMinutes: 30, paymentWindowMinutes: 60, + exportPaymentWindowMinutes: 60, }; const baseSchedule = (over: Partial): TrainSchedule => 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 index 5ae751164..77c4cfd0d 100644 --- 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 @@ -20,7 +20,7 @@ import { import { BookingBatchService } from './booking-batch.service'; import { BookingWindowGateway } from './booking-window.gateway'; import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; -import { BATCH_TIMEZONE } from './booking-batch.constants'; +import { BATCH_TIMEZONE, PAYMENT_GRACE_MS } from './booking-batch.constants'; import { bookingCloseCutoff, clampCloseToOfficeHours, @@ -141,6 +141,13 @@ export class BookingWindowService implements OnModuleInit { await this.settleOverdueReservations(); + // One pre-deadline pay reminder per hold (deduped via reminder stamp). + await this.bookingBatchService.sendPaymentReminders().catch((err) => + this.logger.warn( + `Payment reminder sweep failed: ${(err as Error).message}`, + ), + ); + // Legacy fill (DOMESTIC / pre-migration schedules) every 5 minutes // (30 ticks at the 10-second cadence). this.tickCount += 1; @@ -595,7 +602,10 @@ export class BookingWindowService implements OnModuleInit { .createQueryBuilder('b') .select('DISTINCT b.train_schedule_id', 'scheduleId') .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) - .andWhere('b.payment_deadline <= now()') + // Deadline + grace: a payment started in-window may webhook in late. + .andWhere('b.payment_deadline <= :graceCutoff', { + graceCutoff: new Date(Date.now() - PAYMENT_GRACE_MS), + }) .andWhere('b.train_schedule_id IS NOT NULL') .getRawMany<{ scheduleId: string }>(); for (const { scheduleId } of overdue) { 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 b10736f0f..74aba1383 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 @@ -61,13 +61,20 @@ export class UpdateTrainSchedulingGlobalRulesDto { @Min(0) docReviewMinutes?: number; - @ApiPropertyOptional({ example: 60 }) + @ApiPropertyOptional({ example: 60, description: 'IMPORT/DOMESTIC customer pay window, minutes' }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) paymentWindowMinutes?: number; + @ApiPropertyOptional({ example: 60, description: 'EXPORT customer pay window, minutes' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + exportPaymentWindowMinutes?: number; + // Booking-close offsets: minutes before departure the window shuts. The UI // enters days/hours/minutes and converts to minutes. 0 or null clears the // offset (close at departure). Nullable so it can be explicitly cleared. 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 caa3ce24f..94882833b 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 @@ -77,9 +77,14 @@ export class TrainSchedulingGlobalRules extends BaseEntity { @Column({ name: 'doc_review_minutes', type: 'int', default: 30 }) docReviewMinutes!: number; + /** Pay window for IMPORT/DOMESTIC bookings (also feeds the window reopen delay). */ @Column({ name: 'payment_window_minutes', type: 'int', default: 60 }) paymentWindowMinutes!: number; + /** Pay window for EXPORT bookings — tunable independently of import. */ + @Column({ name: 'export_payment_window_minutes', type: 'int', default: 60 }) + exportPaymentWindowMinutes!: number; + /** * Minutes before departure the IMPORT/DOMESTIC booking window shuts. When set, * the window's close (first cycle and every reopen) is capped at diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index cacffaba4..631078ef6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -1,4 +1,4 @@ -import { bookingCargoTons } from './train-capacity.util'; +import { bookingCargoTons, bulkItemWagonsRequired } from './train-capacity.util'; import type { Booking } from '../bookings/entities/booking.entity'; import type { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { @@ -51,8 +51,12 @@ export function sortBookingsForScheduling(bookings: Booking[]): Booking[] { export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: number): number { if (booking.freightType === 'BULK') { - const weight = Number(booking.cargoTotalWeightVgm ?? 0); const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1; + // Break-bulk (PER_ITEM) sizes by indivisible items; `cargoTotalWeightVgm` + // holds the item count there, not tons. + const byItems = bulkItemWagonsRequired(booking, capacity); + if (byItems > 0) return byItems; + const weight = Number(booking.cargoTotalWeightVgm ?? 0); return Math.max(1, Math.ceil(weight / capacity)); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index 0b8d4ad05..fd5fe05ed 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -1,6 +1,8 @@ import { + bookingCargoTons, bookingGrossWeightTons, bookingTrainLengthMeters, + bulkItemWagonsRequired, consistUsage, consistViolations, deriveTrainCapacityFromLocomotive, @@ -30,6 +32,66 @@ describe('train-capacity.util', () => { cargoTons, })); + describe('bulkItemWagonsRequired (break-bulk PER_ITEM)', () => { + // cargoTotalWeightVgm carries the ITEM COUNT for PER_ITEM cargo; the real + // tonnage rides in bulkTotalWeightTons. + const breakBulk = (quantity: number, weightTons: number) => ({ + freightType: 'BULK', + cargoTotalWeightVgm: quantity, + bulkTotalWeightTons: weightTons, + }); + + it('floors items per wagon, then ceils wagons: 400 items / 800T on 69T wagons → 12', () => { + // 800/400 = 2T per item; floor(69/2) = 34 per wagon; ceil(400/34) = 12. + expect(bulkItemWagonsRequired(breakBulk(400, 800), 69)).toBe(12); + }); + + it('needs more wagons than raw tonnage suggests when the floor loses capacity', () => { + // 3 items × 40T on 69T wagons: by weight ceil(120/69) = 2, but only ONE + // whole 40T item fits a wagon → 3 wagons. + expect(bulkItemWagonsRequired(breakBulk(3, 120), 69)).toBe(3); + }); + + it('charges one wagon per item when a single item outweighs a wagon', () => { + expect(bulkItemWagonsRequired(breakBulk(2, 200), 69)).toBe(2); + }); + + it('returns 0 for PER_TON bulk (no stored weight) and container bookings', () => { + expect( + bulkItemWagonsRequired( + { freightType: 'BULK', cargoTotalWeightVgm: 500, bulkTotalWeightTons: null }, + 69, + ), + ).toBe(0); + expect( + bulkItemWagonsRequired( + { freightType: 'CONTAINER', cargoTotalWeightVgm: 100, bulkTotalWeightTons: 100 }, + 69, + ), + ).toBe(0); + }); + + it('returns 0 on zero/invalid capacity or amounts', () => { + expect(bulkItemWagonsRequired(breakBulk(400, 800), 0)).toBe(0); + expect(bulkItemWagonsRequired(breakBulk(0, 800), 69)).toBe(0); + expect(bulkItemWagonsRequired(breakBulk(400, 0), 69)).toBe(0); + }); + }); + + describe('bookingCargoTons (break-bulk weight preference)', () => { + it('prefers bulkTotalWeightTons over the item-count VGM column', () => { + expect( + bookingCargoTons({ cargoTotalWeightVgm: 400, bulkTotalWeightTons: 800 }), + ).toBe(800); + }); + + it('falls back to cargoTotalWeightVgm when no break-bulk weight is stored', () => { + expect( + bookingCargoTons({ cargoTotalWeightVgm: 500, bulkTotalWeightTons: null }), + ).toBe(500); + }); + }); + describe('deriveTrainCapacityFromLocomotive', () => { it('derives wagon slots from train length, not a fixed 53', () => { const shortLoco = deriveTrainCapacityFromLocomotive( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 7f9586c3c..061dc0dbd 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -91,14 +91,21 @@ function num(value: unknown, fallback = 0): number { * its container lines (quantity × VGM per unit). The portal's container flow * stores per-line VGM and leaves `cargoTotalWeightVgm` at 0 — reading the * total alone made every such booking weigh only its tare. + * + * Break-bulk (PER_ITEM) bookings overload `cargoTotalWeightVgm` with the ITEM + * COUNT, so their real tonnage lives in `bulkTotalWeightTons` — prefer it, or + * a 400-item / 800T booking would "weigh" 400T against the pull limit. */ export function bookingCargoTons(booking: { cargoTotalWeightVgm?: number | string | null; + bulkTotalWeightTons?: number | string | null; bookingContainers?: Array<{ quantity?: number | null; vgmPerUnitTons?: number | string | null; }> | null; }): number { + const itemTons = num(booking.bulkTotalWeightTons); + if (itemTons > 0) return itemTons; const total = num(booking.cargoTotalWeightVgm); if (total > 0) return total; return (booking.bookingContainers ?? []).reduce( @@ -107,6 +114,32 @@ export function bookingCargoTons(booking: { ); } +/** + * Wagons a break-bulk (PER_ITEM) bulk booking needs. Items are indivisible, so + * floor how many whole items fit one wagon, then ceil the wagon count: + * 400 items / 800T on 69T wagons → 2T per item → 34 items per wagon → 12 wagons. + * Returns 0 when the booking is not item-counted (PER_TON bulk, containers) — + * callers then fall back to the pooled-tonnage math. + */ +export function bulkItemWagonsRequired( + booking: { + freightType?: string | null; + cargoTotalWeightVgm?: number | string | null; + bulkTotalWeightTons?: number | string | null; + }, + capacityTons: number, +): number { + if (booking.freightType !== 'BULK' || !(capacityTons > 0)) return 0; + const quantity = num(booking.cargoTotalWeightVgm); + const totalWeightTons = num(booking.bulkTotalWeightTons); + if (!(quantity > 0) || !(totalWeightTons > 0)) return 0; + const perItemTons = totalWeightTons / quantity; + // ponytail: an item heavier than a whole wagon still charges 1 wagon per + // item; reject such bookings at creation time if the case turns real. + const itemsPerWagon = Math.max(1, Math.floor(capacityTons / perItemTons)); + return Math.max(1, Math.ceil(quantity / itemsPerWagon)); +} + /** Gross weight of one loaded wagon: it hauls itself plus its cargo. */ export function grossWagonWeightTons(slot: Pick): number { return num(slot.tareWeightTons) + num(slot.cargoTons); 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 418338257..3779876fa 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 @@ -213,6 +213,7 @@ export function effectiveWindowConfig( ruleWindowCloseHour?: number | null; ruleWindowDurationHours?: number | null; ruleReopenDelayMinutes?: number | null; + rulePaymentWindowMinutes?: number | null; ruleImportWindowLeadDays?: number | null; ruleExportBookingLeadHours?: number | null; ruleImportCloseOffsetMinutes?: number | null; @@ -232,7 +233,14 @@ export function effectiveWindowConfig( ? Number(schedule.ruleWindowDurationHours) : liveCfg.windowDurationHours, docReviewMinutes: liveCfg.docReviewMinutes, - paymentWindowMinutes: liveCfg.paymentWindowMinutes, + // Pay windows read live values unless staff explicitly overrode this ONE + // schedule (rule_payment_window_minutes is only ever written by that + // override, never stamped at creation). The override wins for whichever + // direction the schedule runs. + paymentWindowMinutes: + schedule.rulePaymentWindowMinutes ?? liveCfg.paymentWindowMinutes, + exportPaymentWindowMinutes: + schedule.rulePaymentWindowMinutes ?? liveCfg.exportPaymentWindowMinutes, // The close offset is frozen per-schedule: a snapshot value of null means // "created with no offset" and must NOT inherit a later live offset (that // would retro-shrink an open train's window). Only a truly legacy row that @@ -696,6 +704,8 @@ export class TrainSchedulingService { 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.exportPaymentWindowMinutes != null) + row.exportPaymentWindowMinutes = dto.exportPaymentWindowMinutes; // Store 0 as null so "no offset" is a single canonical value. if (dto.importCloseOffsetMinutes !== undefined) row.importCloseOffsetMinutes = dto.importCloseOffsetMinutes || null; @@ -790,7 +800,14 @@ export class TrainSchedulingService { // The reopen gap is doc review + payment; keep the config values unless the // override changes them, so the derived snapshot delay stays consistent. docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes, - paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes, + paymentWindowMinutes: + dto.paymentWindowMinutes ?? + schedule.rulePaymentWindowMinutes ?? + liveCfg.paymentWindowMinutes, + exportPaymentWindowMinutes: + dto.paymentWindowMinutes ?? + schedule.rulePaymentWindowMinutes ?? + liveCfg.exportPaymentWindowMinutes, // A per-schedule override isn't a close-offset control, so inherit the // offset already frozen on the schedule (null = none), or the live one for // legacy rows — the override must not silently drop the global offset. @@ -853,11 +870,17 @@ export class TrainSchedulingService { } } + // The pay-window override persists only when staff actually sent it (or the + // schedule already had one) — windowRuleSnapshot never stamps it, so NULL + // keeps meaning "follow the live global value for my direction". + const rulePaymentWindowMinutes = + dto.paymentWindowMinutes ?? schedule.rulePaymentWindowMinutes ?? null; for (const t of targets) { await repo.update(t.id, { windowOpensAt: cap(times.windowOpensAt, t.departure), windowClosesAt: cap(times.windowClosesAt, t.departure), ...ruleFields, + rulePaymentWindowMinutes, }); } this.logger.log( @@ -1199,6 +1222,7 @@ export class TrainSchedulingService { windowDurationHours: num(row?.windowDurationHours, 3), docReviewMinutes: num(row?.docReviewMinutes, 30), paymentWindowMinutes: num(row?.paymentWindowMinutes, 60), + exportPaymentWindowMinutes: num(row?.exportPaymentWindowMinutes, 60), importCloseOffsetMinutes: offset(row?.importCloseOffsetMinutes), exportCloseOffsetMinutes: offset(row?.exportCloseOffsetMinutes), }; @@ -1931,8 +1955,14 @@ export class TrainSchedulingService { removedAt: new Date(), }); - console.log( - `[NOTIFY] Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer should be notified to reschedule or cancel.`, + // Ops decision, so the customer hears about it: SMS/email + inbox telling + // them to rebook or pick a new schedule (the removal log above is the record). + const removedBooking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId }, relations: { company: true } }); + if (removedBooking) this.bookingNotifier.removedFromTrain(removedBooking); + this.logger.log( + `Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`, ); return this.getTrainScheduleById(scheduleId); @@ -6778,7 +6808,14 @@ export class TrainSchedulingService { importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null, exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null, docReviewMinutes: windowCfg.docReviewMinutes, - paymentWindowMinutes: windowCfg.paymentWindowMinutes, + // Editor prefill: this schedule's own override when staff set one, + // else the live global for the schedule's direction (import/export + // pay windows are tuned separately). + paymentWindowMinutes: + schedule.rulePaymentWindowMinutes ?? + (schedule.direction === 'EXPORT' + ? windowCfg.exportPaymentWindowMinutes + : windowCfg.paymentWindowMinutes), }, route: schedule.route ? { id: schedule.route.id, name: formatRouteLabel(schedule.route) } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 84a2dc1f5..eadd0b471 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -3,7 +3,7 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -import { consistViolations } from './train-capacity.util'; +import { bookingCargoTons, bulkItemWagonsRequired, consistViolations } from './train-capacity.util'; export const MAX_TRAIN_WEIGHT_TONS = 3500; export const MAX_TRAIN_LENGTH_METERS = 760; @@ -171,11 +171,21 @@ export function buildBulkWagonPlan( bookings: Booking[], wagonType: WagonType, ): WagonPlanSlot[] { - const totalWeight = roundTons( - bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0), - ); const capacity = Number(wagonType.capacityTons); - const slots = Math.max(1, Math.ceil(totalWeight / capacity)); + // Break-bulk (PER_ITEM) bookings size by indivisible items per booking — + // their tonnage must NOT pool with PER_TON cargo (an item can't split + // across wagons the way loose tonnage can). + const itemSlotsByBooking = bookings.map((b) => bulkItemWagonsRequired(b, capacity)); + const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0); + const totalWeight = roundTons( + bookings.reduce( + (sum, b, i) => + itemSlotsByBooking[i] > 0 ? sum : sum + Number(b.cargoTotalWeightVgm ?? 0), + 0, + ), + ); + const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0; + const slots = Math.max(1, tonSlots + itemSlots); const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({ sequenceNo: index + 1, @@ -293,7 +303,9 @@ function allocateBookingsToSlots( const remaining = bookings.map((booking) => ({ bookingId: booking.id, bookingReference: booking.reference, - remainingWeightTons: roundTons(Number(booking.cargoTotalWeightVgm ?? 0)), + // bookingCargoTons, not the raw VGM column: for break-bulk (PER_ITEM) + // bookings that column is an item COUNT, not tons. + remainingWeightTons: roundTons(bookingCargoTons(booking)), })); let bookingIndex = 0; diff --git a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts index 54322c789..0c9415e42 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts @@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ArrayMinSize, IsArray, + IsNotEmpty, IsOptional, IsString, IsUUID, @@ -50,11 +51,11 @@ export class BuildTrainDto { @IsUUID('all', { each: true }) wagonIds?: string[]; - @ApiPropertyOptional({ maxLength: 100 }) - @IsOptional() + @ApiProperty({ maxLength: 100, description: 'Vogue number' }) @IsString() + @IsNotEmpty({ message: 'Vogue number is required' }) @MaxLength(100) - trainName?: string; + trainName!: string; @ApiPropertyOptional() @IsOptional() diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index 7bfb59e1d..28789b8e5 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -16,6 +16,7 @@ export const WAGON_STATUSES = [ WagonStatus.ExportReady, WagonStatus.Maintenance, WagonStatus.Detained, + WagonStatus.OutOfService, ] as const; export type WagonStatusType = (typeof WAGON_STATUSES)[number]; diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx index 99c7e6ef0..21033e7fe 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx @@ -86,6 +86,13 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain }, [opened]); const handleBuild = async () => { + if (!trainName.trim()) { + toast({ + title: "Enter the vogue number", + variant: "destructive", + }); + return; + } if (!yardId || locomotiveIds.length < 1) { toast({ title: "Pick a yard and couple at least one locomotive", @@ -108,7 +115,7 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain importTrainNumber: importTrainNumber.trim(), currentYardId: yardId, locomotiveIds, - ...(trainName.trim() ? { trainName: trainName.trim() } : {}), + trainName: trainName.trim(), ...(notes.trim() ? { notes: notes.trim() } : {}), }); toast({ title: `Train ${composition.code} built` }); @@ -143,11 +150,12 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain wagons are attached on the next screen. setTrainName(e.currentTarget.value)} maxLength={100} + required /> {/* Fixed by the import run — derived, never typed. */} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx index c1232fbba..d09bce567 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx @@ -363,14 +363,17 @@ export default function BookingWindowSettingsModal({ /> setForm((f) => f && { ...f, paymentWindowMinutes: v }) } min={1} - disabled={isExport} /> {!isExport ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 7f2f89c1e..9ba826e6f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -163,6 +163,7 @@ const WAGON_STATUS_OPTIONS = [ { label: "Assigned", value: Freight.WagonStatus.Assigned }, { label: "Maintenance", value: Freight.WagonStatus.Maintenance }, { label: "Detained", value: Freight.WagonStatus.Detained }, + { label: "Out of service", value: Freight.WagonStatus.OutOfService }, ]; // Statuses staff may set BY HAND on the create/edit form. ASSIGNED is omitted diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx index 843031a69..03f7bca8b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx @@ -66,6 +66,7 @@ export default function TrainSchedulingGlobalRulesPage() { "windowDurationHours", "docReviewMinutes", "paymentWindowMinutes", + "exportPaymentWindowMinutes", ]; const payload: Partial> = {}; for (const key of fields) { @@ -209,8 +210,8 @@ export default function TrainSchedulingGlobalRulesPage() { disabled={loading} /> @@ -219,6 +220,17 @@ export default function TrainSchedulingGlobalRulesPage() { min={1} disabled={loading} /> + + setForm((current) => ({ ...current, exportPaymentWindowMinutes: value })) + } + min={1} + disabled={loading} + /> diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 46e4b6c15..bd518ea76 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -119,7 +119,10 @@ export interface TrainSchedulingGlobalRules { windowCloseHour: number; windowDurationHours: number; docReviewMinutes: number; + /** Import/domestic customer pay window, minutes. */ paymentWindowMinutes: number; + /** Export customer pay window, minutes — tuned separately from import. */ + exportPaymentWindowMinutes: number; /** Minutes before departure the import window closes; null = close at departure. */ importCloseOffsetMinutes: number | null; /** Minutes before departure the export window closes; null = close at departure. */ diff --git a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx index c101d0661..269fa2160 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx @@ -149,6 +149,10 @@ function mapBookingToFormValues( destinationYard: yardIdFromBooking(booking.destinationYard, referenceData), cargoType: booking.freightType === "BULK" ? "bulk" : "container", cargoWeight: String(booking.cargoTotalWeightVgm ?? ""), + bulkTotalWeightTons: + booking.bulkTotalWeightTons != null + ? String(Number(booking.bulkTotalWeightTons)) + : "", isHazardous: booking.isHazardous ?? false, isRefrigerated: booking.isRefrigerated ?? false, bulkHazardousQty: String(Number(booking.bulkHazardousQuantity ?? 0)), @@ -455,6 +459,15 @@ export default function EditBookingPage() { : "IMPORT", cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId, cargoTotalWeightVgm: totalWeight, + // Break-bulk (PER_ITEM commodity): actual tonnage alongside the item + // count, so wagon allocation can size indivisible items per wagon. + ...(data.cargoType === "bulk" && + referenceData?.cargo_type + ?.flatMap((g) => g.children ?? []) + .find((c) => c.id === cargoTypeId)?.unit_of_measure === "PER_ITEM" && + Number(data.bulkTotalWeightTons) > 0 + ? { bulkTotalWeightTons: Number(data.bulkTotalWeightTons) } + : {}), // Containers: booking-level flags are the OR of the per-container switches; // bulk uses the route-step toggles. isHazardous: diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 700967b68..8cf4deefa 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -519,6 +519,11 @@ export default function NewBookingPage() { // Day-level pool: the customer picks only a day (scheduledDate); the batch // engine assigns the train, so no trainScheduleId is sent. cargoTotalWeightVgm: totalWeight, + // Break-bulk: the actual tonnage travels alongside the item count so + // wagon allocation can size indivisible items per wagon. + ...(data.cargoType === "bulk" && isPerItem && Number(data.bulkTotalWeightTons) > 0 + ? { bulkTotalWeightTons: Number(data.bulkTotalWeightTons) } + : {}), // Booking-level flags drive the HAZARD / REEFER surcharge triggers. For // containers they're the OR of the per-container switches; for bulk they // come from the cargo-step toggles. diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx index c8b324d47..17dba738b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx @@ -120,7 +120,7 @@ function BookingActionModalBody({ leftSection={} onClick={handleProceed} loading={flow.proceedMutation.isPending} - disabled={!flow.scheduledDate} + disabled={!flow.scheduledDate || flow.requiresTrainSelection} > Proceed to operation diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx index 196646185..681cbd7b2 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx @@ -27,6 +27,7 @@ import { useFileViewer } from "@/hooks/useFileViewer"; import { bookingDocNoun } from "./bookingNextAction"; import { OperationDatePicker } from "./OperationDatePicker"; import { DayAvailabilityHint } from "./DayAvailabilityHint"; +import { ExportTrainPicker } from "./ExportTrainPicker"; import type { ClearanceFlowController } from "./useClearanceFlow"; const BORDER = "#E6ECF2"; @@ -71,6 +72,11 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) { setAdHocFile, scheduledDate, setScheduledDate, + isExportRail, + exportTrains, + exportTrainsLoading, + selectedTrainId, + setSelectedTrainId, uploadMutation, proceedMutation, } = flow; @@ -224,9 +230,9 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) { Choose your shipment day - Only days with a scheduled departure that can carry your cargo type - can be selected. The operations team assigns the specific train for - that day. + {isExportRail + ? "Only days with a scheduled departure that can carry your cargo type can be selected. Pick the train you want for that day below." + : "Only days with a scheduled departure that can carry your cargo type can be selected. The operations team assigns the specific train for that day."} - {scheduledDate && ( + {scheduledDate && !isExportRail && ( )} + {scheduledDate && isExportRail && ( + + )} )} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ExportTrainPicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ExportTrainPicker.tsx new file mode 100644 index 000000000..5a167cdd0 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ExportTrainPicker.tsx @@ -0,0 +1,131 @@ +import { Badge, Box, Group, Loader, Stack, Text, UnstyledButton } from "@mantine/core"; +import { CheckCircle2, TrainFront } from "lucide-react"; + +import type { Freight } from "@edr/types"; + +const BORDER = "#E6ECF2"; +const SELECTED = "#0E7A5F"; + +function departureLabel(iso: string): string { + const d = new Date(iso); + return d.toLocaleString("en-GB", { + weekday: "short", + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + timeZone: "Africa/Addis_Ababa", + }); +} + +function closesLabel(iso: string | null): string | null { + if (!iso) return null; + return new Date(iso).toLocaleString("en-GB", { + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + timeZone: "Africa/Addis_Ababa", + }); +} + +export interface ExportTrainPickerProps { + options: Freight.ExportTrainOption[]; + loading: boolean; + value: string; + onChange: (scheduleId: string) => void; +} + +/** + * Export shipment-day train picker: one card per export train that day, with + * live free-wagon space per wagon type for THIS booking's cargo. Full or + * not-yet-open trains render disabled — the pick locks the booking onto that + * train when the operation request is submitted. + */ +export function ExportTrainPicker({ + options, + loading, + value, + onChange, +}: ExportTrainPickerProps) { + if (loading) { + return ( + + + + Checking trains for this day… + + + ); + } + if (!options.length) return null; + + return ( + + + Choose your train + + + {options.map((option) => { + const bookable = option.isOpen && option.fits; + const selected = value === option.scheduleId; + const closes = closesLabel(option.bookingClosesAt); + return ( + bookable && onChange(option.scheduleId)} + disabled={!bookable} + style={{ + border: `1.5px solid ${selected ? SELECTED : BORDER}`, + borderRadius: 10, + padding: "10px 12px", + opacity: bookable ? 1 : 0.55, + cursor: bookable ? "pointer" : "not-allowed", + background: selected ? "#F2FAF7" : "#FFFFFF", + }} + > + + + + + + Departs {departureLabel(option.departure)} EAT + + + {option.freeWagons} wagon{option.freeWagons === 1 ? "" : "s"} free + for your cargo · you need {option.neededWagons} + {closes ? ` · booking closes ${closes} EAT` : ""} + + + {option.byWagonType.map((t) => ( + 0 ? "teal" : "gray"} + > + {t.code ?? t.name ?? "Wagon"}: {t.freeWagons} free + + ))} + + + + {selected ? ( + + ) : !option.isOpen ? ( + + Not open + + ) : !option.fits ? ( + + Too little space + + ) : null} + + + ); + })} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts index 63b3d03da..5d8143f79 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts @@ -27,7 +27,39 @@ export function useClearanceFlow(booking: Freight.IBooking) { const [pending, setPending] = useState>({}); const [adHoc, setAdHoc] = useState([]); // Binding shipment day chosen for the operation request (yyyy-MM-dd). - const [scheduledDate, setScheduledDate] = useState(""); + const [scheduledDate, setScheduledDateState] = useState(""); + // Export rail only: the specific train picked for that day. + const [selectedTrainId, setSelectedTrainId] = useState(""); + + // Mirrors the API's isRoadService: road/truck services dispatch a truck and + // never pick a train. IBooking.serviceType is a string code. + const serviceCode = String(booking.serviceType ?? "").toUpperCase(); + const isRoad = serviceCode.startsWith("ROAD") || serviceCode.startsWith("TRUCK"); + const isExportRail = booking.tradeDirection === "EXPORT" && !isRoad; + + // A new day invalidates the old train pick. + const setScheduledDate = (date: string) => { + setScheduledDateState(date); + setSelectedTrainId(""); + }; + + const exportTrainsQuery = useQuery( + api.bookings.getExportTrains.queryOptions({ + input: { bookingId: booking.id, date: scheduledDate }, + enabled: isExportRail && Boolean(scheduledDate), + }), + ); + const exportTrains = useMemo( + () => (isExportRail ? (exportTrainsQuery.data ?? []) : []), + [isExportRail, exportTrainsQuery.data], + ); + // Export must ride the train the customer picked — block proceed until a + // bookable train is chosen (when none is bookable, proceed stays allowed so + // the API can answer with the real capacity error). + const requiresTrainSelection = + isExportRail && + exportTrains.some((t) => t.isOpen && t.fits) && + !selectedTrainId; const refresh = () => { queryClient.invalidateQueries({ @@ -146,9 +178,14 @@ export function useClearanceFlow(booking: Freight.IBooking) { }; const proceedToOperation = (opts?: { onSuccess?: () => void }) => { - if (!scheduledDate) return; + if (!scheduledDate || requiresTrainSelection) return; proceedMutation.mutate( - { id: booking.id, scheduledDate }, + { + id: booking.id, + scheduledDate, + trainScheduleId: + isExportRail && selectedTrainId ? selectedTrainId : undefined, + }, { onSuccess: opts?.onSuccess }, ); }; @@ -179,6 +216,13 @@ export function useClearanceFlow(booking: Freight.IBooking) { // schedule scheduledDate, setScheduledDate, + // export train pick + isExportRail, + exportTrains, + exportTrainsLoading: exportTrainsQuery.isLoading, + selectedTrainId, + setSelectedTrainId, + requiresTrainSelection, // mutations uploadMutation, proceedMutation, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index b137635ae..c81e5571b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -164,6 +164,10 @@ export const bookingFormSchema = z scheduledDate: z.string().default(""), cargoType: z.enum(["container", "bulk"], "Select a cargo type."), cargoWeight: z.string(), + // Break-bulk (PER_ITEM commodities) only: actual total weight in tons — + // cargoWeight then carries the item count. Empty for PER_TON bulk and + // container cargo. + bulkTotalWeightTons: z.string().default(""), cargoTypePath: z.array(z.string()).default([]), cargoFreeText: z.string(), isHazardous: z.boolean(), @@ -239,6 +243,16 @@ export const bookingFormSchema = z }, { message: "Enter a quantity greater than 0.", path: ["cargoWeight"] }, ) + .refine( + (data) => { + // Filled only for PER_ITEM commodities (the field is hidden otherwise); + // when present it must be a positive tonnage. + if (data.cargoType !== "bulk" || !data.bulkTotalWeightTons) return true; + const tons = Number(data.bulkTotalWeightTons); + return !Number.isNaN(tons) && tons > 0; + }, + { message: "Enter a total weight greater than 0.", path: ["bulkTotalWeightTons"] }, + ) .refine( (data) => !(data.cargoType === "container" && data.containers.length === 0), { message: "Add at least one container.", path: ["containers"] }, @@ -381,6 +395,7 @@ export const initialBookingFormValues: DeepPartial = { extraRoutes: [], scheduledDate: "", cargoWeight: "", + bulkTotalWeightTons: "", cargoTypePath: [], cargoFreeText: "", isHazardous: false, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index 734b5e997..d8244cd60 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -350,6 +350,32 @@ export function Step5CargoDetails({ /> )} + {/* Break-bulk: item count alone can't size wagons — indivisible items + pack by weight, so the actual total tonnage is captured too. */} + {selectedCommodity && !isGeneralContract && isPerItem && ( + ( + } + error={fieldState.error?.message} + description="Actual total weight of all items — used to work out how many items fit one wagon." + radius={10} + styles={fieldStyles} + min={0} + step={0.01} + /> + )} + /> + )} + {/* Cargo handling — how much of the cargo is hazardous / refrigerated, in the SAME unit as the quantity above (tons or items). Shown once a commodity is chosen so the unit is known; general contracts handle diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 659b5161d..79b904f47 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -385,10 +385,11 @@ export default function ContractDetailPage() { // slot, so the action is "none" and no booking button is shown. const canBookShipment = bookingAction.kind === "book" || bookingAction.kind === "rebook"; - const canRequestShipment = bookingAction.kind === "request"; + // TODO: bulk contracts pause here for now — remove isContainer gate once bulk flow resumes. + const canRequestShipment = bookingAction.kind === "request" && isContainer; // Self-clearance import/export (ONE_TIME or GENERAL): one-click bare booking // instance — the per-booking clearance runs first, so no window gate here. - const canInitiateBooking = bookingAction.kind === "initiate"; + const canInitiateBooking = bookingAction.kind === "initiate" && isContainer; return ( diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 6fd1bd479..a675198c5 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -407,10 +407,10 @@ export const api = { ), proceedToOperation: endpoint< - { id: string; scheduledDate: string }, + { id: string; scheduledDate: string; trainScheduleId?: string }, Freight.IBooking - >("bookings", "proceedToOperation", ({ id, scheduledDate }) => - bookingsService.proceedToOperation(id, scheduledDate), + >("bookings", "proceedToOperation", ({ id, scheduledDate, trainScheduleId }) => + bookingsService.proceedToOperation(id, scheduledDate, trainScheduleId), ), checkPayment: endpoint<{ orderId: string }, { status: string }>( @@ -468,6 +468,13 @@ export const api = { bookingsService.getDayAvailability(bookingId, date), ), + getExportTrains: endpoint< + { bookingId: string; date: string }, + Freight.ExportTrainOption[] + >("train-scheduling", "exportTrains", ({ bookingId, date }) => + bookingsService.getExportTrains(bookingId, date), + ), + getMyBookingWindows: endpoint( "train-scheduling", "myBookingWindows", diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 68f959b8d..1aa819009 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -378,10 +378,11 @@ export const bookingsService = { proceedToOperation: async ( id: string, scheduledDate: string, + trainScheduleId?: string, ): Promise => { const { data } = await client.post( `/api/bookings/${id}/clearance/proceed`, - { scheduledDate }, + { scheduledDate, ...(trainScheduleId ? { trainScheduleId } : {}) }, ); return data.data; }, @@ -508,6 +509,18 @@ export const bookingsService = { return (data.data as Freight.AvailableDaysResponse).days; }, + // Export train picker: the day's export trains with per-wagon-type free space. + getExportTrains: async ( + bookingId: string, + date: string, + ): Promise => { + const { data } = await client.get( + `/api/bookings/${bookingId}/export-trains`, + { params: { date } }, + ); + return data.data as Freight.ExportTrainOption[]; + }, + // Advisory free-wagon count for a shipment day (planning hint, not enforced). getDayAvailability: async ( bookingId: string, diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 0db592336..c4911c20f 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -239,6 +239,7 @@ export enum WagonStatus { Maintenance = "MAINTENANCE", /** Formerly RETIRED — wagons pulled from circulation. */ Detained = "DETAINED", + OutOfService = "OUT_OF_SERVICE", } export enum WagonReadiness { @@ -654,6 +655,8 @@ export interface IBooking extends BaseEntity { originYard?: IYard | null; destinationYard?: IYard | null; cargoTotalWeightVgm: number; + /** Break-bulk (PER_ITEM) only: actual total weight in tons — cargoTotalWeightVgm then holds the item count. */ + bulkTotalWeightTons?: number | null; freightType: FreightType; freightSubtype?: string | null; @@ -1090,6 +1093,30 @@ export interface BookableScheduleItem { remainingWagons: number; } +/** Per-wagon-type free space on one export train, for the booking's cargo. */ +export interface ExportTrainOptionWagonType { + wagonTypeId: string | null; + code: string | null; + name: string | null; + freeWagons: number; +} + +/** + * One export train the customer can pick for a shipment day + * (GET /bookings/:id/export-trains?date=). Space is measured against the + * booking's own allowed wagon types; unpaid holds count as taken. + */ +export interface ExportTrainOption { + scheduleId: string; + departure: string; + bookingClosesAt: string | null; + isOpen: boolean; + freeWagons: number; + neededWagons: number; + fits: boolean; + byWagonType: ExportTrainOptionWagonType[]; +} + // ── DTOs ─────────────────────────────────────────────────────────────────────── export interface CreateBookingContainerDto { @@ -1148,6 +1175,8 @@ export interface CreateBookingDto { cargoFreeText?: string | undefined; shippingLineId?: string | undefined; cargoTotalWeightVgm: number; + /** Break-bulk (PER_ITEM) only: actual total weight in tons — cargoTotalWeightVgm then holds the item count. */ + bulkTotalWeightTons?: number | undefined; isHazardous?: boolean | undefined; /** Booking-level refrigerated flag (bulk freight only; containers derive reefer from the container type). */ isReefer?: boolean | undefined;