diff --git a/apps/edr-freight-api/src/migrations/3050000000000-MergeDuplicateSebetaYards.ts b/apps/edr-freight-api/src/migrations/3050000000000-MergeDuplicateSebetaYards.ts new file mode 100644 index 000000000..c8a2c5722 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3050000000000-MergeDuplicateSebetaYards.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Two active "Sebeta" yards existed (code LEGACY_DEST label "Sebeta", and code + * SEBETA label "sebeta") — rates and routes pointed at one or the other, so a + * rate configured against one never matched a contract routed via the other. + * Merge them: keep the row all rates/distances/facilities reference + * (LEGACY_DEST), repoint every yard reference from the duplicate to it, retire + * the duplicate, and give the survivor the clean SEBETA code. Then make + * duplicate active yard labels/codes impossible at the DB level. + */ +export class MergeDuplicateSebetaYards3050000000000 implements MigrationInterface { + name = "MergeDuplicateSebetaYards3050000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + DECLARE + survivor uuid; + dupe uuid; + col record; + BEGIN + SELECT id INTO survivor FROM freight.yards + WHERE code = 'LEGACY_DEST' AND lower(trim(label)) = 'sebeta' AND deleted_at IS NULL; + SELECT id INTO dupe FROM freight.yards + WHERE code = 'SEBETA' AND deleted_at IS NULL; + IF survivor IS NULL OR dupe IS NULL OR survivor = dupe THEN + RETURN; + END IF; + + -- Every yard-referencing column in the schema, so rows created between + -- authoring and running this migration are repointed too. + FOR col IN + SELECT table_name, column_name FROM information_schema.columns + WHERE table_schema = 'freight' + AND table_name <> 'yards' + AND (column_name LIKE '%yard_id%' OR column_name LIKE '%station_id%') + LOOP + EXECUTE format( + 'UPDATE freight.%I SET %I = $1 WHERE %I = $2', + col.table_name, col.column_name, col.column_name + ) USING survivor, dupe; + END LOOP; + + UPDATE freight.yards + SET code = 'SEBETA@merged', label = 'sebeta@merged', deleted_at = now() + WHERE id = dupe; + UPDATE freight.yards SET code = 'SEBETA', label = 'Sebeta' WHERE id = survivor; + END $$; + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yards_label_active" + ON freight.yards (lower(trim(label))) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yards_code_active" + ON freight.yards (lower(trim(code))) WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Data repair — not reversible. The uniqueness indexes are the new invariant. + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_yards_label_active"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_yards_code_active"`); + } +} 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..ca43295d1 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -983,6 +983,17 @@ export class BillingService { * never fire before the link exists. Throws when the invoice is not found or * not in an open/payable status. */ + /** + * Settlement check before expiring a payable order (reconcile-before-expire): + * live-queries the gateway for any settled intent on the source order. Kept + * on billing so the domain never talks to the payment service directly. + */ + reconcilePayable( + sourceId: string, + ): Promise<{ paid: boolean; unverifiable: boolean }> { + return this.payment.reconcileShipment(sourceId); + } + async payInvoice( invoiceId: string, opts: { @@ -1002,6 +1013,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: a payment + // STARTED before this gate but settling late is still honored by the + // expire-time gateway reconcile. Other invoice types keep dueAt 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 3d0603f5f..3d339fa19 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 @@ -365,6 +365,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; @@ -499,6 +508,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/contracts/contract-pricing.lane-scope.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts new file mode 100644 index 000000000..ffbef03e3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts @@ -0,0 +1,92 @@ +import { UnprocessableEntityException } from '@nestjs/common'; + +import { ContractPricingService } from './contract-pricing.service'; +import type { Contract } from './entities/contract.entity'; +import type { Rate } from '../rule-engine/entities/rate.entity'; + +const CT20 = 'ct-20'; +const CT40 = 'ct-40'; +const DCT = 'yard-dct'; +const SEBETA = 'yard-sebeta'; +const GMP = 'yard-gmp'; + +const rate = (over: Partial): Rate => + ({ + rateType: 'CONTAINER_IMPORT', + currency: 'USD', + rateValue: 1000, + rateUnit: 'PER_CONTAINER', + containerTypeId: null, + cargoTypeId: null, + originYardId: DCT, + destinationYardId: SEBETA, + ...over, + }) as Rate; + +const contract = (over: Partial): Contract => + ({ + freightType: 'CONTAINER', + tradeDirection: 'IMPORT', + paymentCurrency: 'USD', + customsClearingEnabled: false, + isHazardous: false, + isReefer: false, + routes: [{ originYardId: DCT, destinationYardId: SEBETA, sortOrder: 0 }], + cargoScope: [{ containerSize: '20ft' }], + ...over, + }) as Contract; + +const service = (liveRates: Rate[]): ContractPricingService => + new ContractPricingService( + {} as never, + { findLiveRates: async () => liveRates } as never, + { + findAll: async () => ({ + items: [ + { id: CT20, sizeFt: 20 }, + { id: CT40, sizeFt: 40 }, + ], + }), + } as never, + { getRate: async () => 1 } as never, + ); + +describe('contract base freight is priced on the contract lane only', () => { + it('prices from the contract route, never another lane (CTR-2026-00065)', async () => { + const breakdown = await service([ + // Same size, other lane — the leak that priced DCT → Sebeta at GMP rates. + rate({ containerTypeId: CT20, destinationYardId: GMP, rateValue: 1690 }), + rate({ containerTypeId: CT20, rateValue: 750 }), + ]).buildBreakdown(contract({})); + expect(breakdown.lineItems).toEqual([ + expect.objectContaining({ code: 'CONTAINER_20FT', unitPrice: 750 }), + ]); + }); + + it('blocks the contract when its lane has no container rate', async () => { + await expect( + service([ + rate({ containerTypeId: CT20, destinationYardId: GMP, rateValue: 1690 }), + ]).buildBreakdown(contract({})), + ).rejects.toThrow(UnprocessableEntityException); + }); + + it('blocks bulk contracts too instead of borrowing an arbitrary rate', async () => { + const bulk = contract({ freightType: 'BULK', cargoScope: [] }); + await expect( + service([ + rate({ + rateType: 'BULK_IMPORT', + rateUnit: 'PER_TON', + destinationYardId: GMP, + }), + ]).buildBreakdown(bulk), + ).rejects.toThrow(UnprocessableEntityException); + const priced = await service([ + rate({ rateType: 'BULK_IMPORT', rateUnit: 'PER_TON', rateValue: 32 }), + ]).buildBreakdown(bulk); + expect(priced.lineItems).toEqual([ + expect.objectContaining({ code: 'BULK_FREIGHT', unitPrice: 32 }), + ]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index a4e4b43b5..fdf4fa3d1 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -84,6 +84,26 @@ export class ContractPricingService { const lineItems: ContractUnitRateLineItem[] = []; const baseType = this.baseRateType(contract); + // Base rail freight is quoted per route (CK_rates_yard_scope) — only rates + // on the contract's own lane may price it. Matching without the yard filter + // is how a DCT → Sebeta contract froze DCT → GMP (Indode) prices, and the + // frozen snapshot then bills bookings that the route-scoped booking lookup + // would have hard-blocked (CTR-2026-00065). + // ponytail: multi-route contracts price the first lane (same as customs + // clearance below); per-lane pricing needs per-route breakdowns. + const route = [...(contract.routes ?? [])].sort( + (a, b) => a.sortOrder - b.sortOrder, + )[0]; + const onLane = route + ? liveRates.filter( + (r) => + r.rateType === baseType && + r.currency === 'USD' && + r.originYardId === route.originYardId && + r.destinationYardId === route.destinationYardId, + ) + : []; + if (contract.freightType === 'CONTAINER') { const sizes = (contract.cargoScope ?? []) .map((c) => c.containerSize) @@ -97,17 +117,14 @@ export class ContractPricingService { const matchedTypes = containerTypes.filter((ct) => ct.sizeFt === sizeFt); const matchedIds = new Set(matchedTypes.map((ct) => ct.id)); const rate = - liveRates.find( - (r) => - r.rateType === baseType && - r.currency === 'USD' && - r.containerTypeId && - matchedIds.has(r.containerTypeId), - ) ?? - liveRates.find( - (r) => r.rateType === baseType && r.currency === 'USD' && !r.containerTypeId, + onLane.find( + (r) => r.containerTypeId && matchedIds.has(r.containerTypeId), + ) ?? onLane.find((r) => !r.containerTypeId); + if (!rate || Number(rate.rateValue) <= 0) { + throw new UnprocessableEntityException( + `No rail freight rate is configured for ${size} containers on this direction and route — the contract cannot be priced. Ask the rates team to set a live ${baseType} rate for this container type and origin → destination.`, ); - if (!rate) continue; + } lineItems.push({ code: `CONTAINER_${size.toUpperCase()}`, label: `${size} container`, @@ -120,25 +137,26 @@ export class ContractPricingService { const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId); // Freeze the rate for the contract's own commodity when one is configured // — a per-item machinery rate and a per-ton wheat rate live side by side. - const bulkRates = liveRates.filter( - (r) => r.rateType === baseType && r.currency === 'USD', - ); + // No arbitrary-rate fallback: another commodity's rate must never price + // this contract. const bulkRate = (cargoScope?.cargoTypeId - ? bulkRates.find((r) => r.cargoTypeId === cargoScope.cargoTypeId) + ? onLane.find((r) => r.cargoTypeId === cargoScope.cargoTypeId) : undefined) ?? - bulkRates.find((r) => !r.cargoTypeId) ?? - bulkRates[0] ?? + onLane.find((r) => !r.cargoTypeId) ?? null; - if (bulkRate) { - lineItems.push({ - code: 'BULK_FREIGHT', - label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo', - unit: toContractUnit(bulkRate.rateUnit), - unitPrice: convert(Number(bulkRate.rateValue)), - cargoTypeCode: cargoScope?.cargoType?.code ?? null, - }); + if (!bulkRate || Number(bulkRate.rateValue) <= 0) { + throw new UnprocessableEntityException( + 'No bulk rail freight rate is configured for this cargo type on this direction and route — the contract cannot be priced. Ask the rates team to set a live rate for this commodity and origin → destination.', + ); } + lineItems.push({ + code: 'BULK_FREIGHT', + label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo', + unit: toContractUnit(bulkRate.rateUnit), + unitPrice: convert(Number(bulkRate.rateValue)), + cargoTypeCode: cargoScope?.cargoType?.code ?? null, + }); } // First / last mile trucking unit rates — shown when the contract carries @@ -241,9 +259,6 @@ export class ContractPricingService { // one display line per contract size that has a configured rate. A size // with no rate shows nothing here and hard-blocks at booking time. // ponytail: bookings bill the live route rate, not a frozen snapshot. - const route = [...(contract.routes ?? [])].sort( - (a, b) => a.sortOrder - b.sortOrder, - )[0]; const onLeg = route ? liveRates.filter( (r) => @@ -291,9 +306,6 @@ export class ContractPricingService { if (contract.customsClearingEnabled) { // Strict, no route-less fallback. // ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots. - const route = [...(contract.routes ?? [])].sort( - (a, b) => a.sortOrder - b.sortOrder, - )[0]; const onLeg = route ? liveRates.filter( (r) => diff --git a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts index 4c2ebe971..81d1a2a0d 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts @@ -31,6 +31,24 @@ export class PaymentClientService { return this.call("POST", "/payments/initiate", request); } + /** + * POST /payments/reconcile — settlement check for a domain order + * (reconcile-before-cancel). Live-queries every non-failed intent at the + * provider and registers any late capture found (flips it to SUCCEEDED and + * emits payment.succeeded). `unverifiable: true` = could not confirm + * "not paid" — the caller must NOT cancel/expire the order. + */ + async reconcileReference( + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise<{ paid: boolean; unverifiable: boolean }> { + return this.call("POST", "/payments/reconcile", { + service: PaymentService.FREIGHT, + referenceType, + referenceId, + }); + } + /** GET /payments/intents?… — active intent by domain reference; null when none exists. */ async getIntentByReference( referenceType: PaymentReferenceType, diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index d96bcf492..6cb359dba 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -188,6 +188,30 @@ export class PaymentService { * marked paid WITHOUT emitting — the caller (billing) settles inline after it * has stored the intent id, avoiding a settle-before-correlation race. */ + /** + * Reconcile-before-cancel: ask the payment service whether ANY intent for + * this shipment actually settled at the provider (bank/gateway). A late + * capture found there is registered as SUCCEEDED and emits payment.succeeded, + * which drives the normal paid flow. A network/provider error reports + * `unverifiable` — the caller must not expire the order on unknown. + */ + async reconcileShipment( + referenceId: string, + ): Promise<{ paid: boolean; unverifiable: boolean }> { + try { + const result = await this.paymentClient.reconcileReference( + PaymentReferenceType.SHIPMENT, + referenceId, + ); + return { paid: result.paid, unverifiable: result.unverifiable }; + } catch (err) { + this.logger.warn( + `Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`, + ); + return { paid: false, unverifiable: true }; + } + } + async initiate(input: InitiateIntentInput): Promise { try { diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts index 75baca5de..104603c6d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts @@ -6,6 +6,7 @@ import { Yard } from '../entities/yard.entity'; export interface IYardsRepository { findById(id: string): Promise; findByCode(code: string): Promise; + findByLabelInsensitive(label: string): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[Yard[], number]>; findPaged(query: ListYardsQueryDto): Promise>; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts index 1cb74d9ce..5db5b72ae 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts @@ -22,6 +22,15 @@ export class YardsRepository implements IYardsRepository { return this.repo.findOne({ where: { code } }); } + /** Case/whitespace-insensitive label lookup — backs the duplicate-yard guard. */ + findByLabelInsensitive(label: string): Promise { + return this.repo + .createQueryBuilder('yard') + .where('LOWER(TRIM(yard.label)) = LOWER(TRIM(:label))', { label }) + .andWhere('yard.deleted_at IS NULL') + .getOne(); + } + findAll(options?: FindManyOptions): Promise { return this.repo.find(options); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts new file mode 100644 index 000000000..8affab199 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts @@ -0,0 +1,36 @@ +import { ConflictException } from '@nestjs/common'; + +import { YardsService } from './yards.service'; +import type { Yard } from '../entities/yard.entity'; + +const sebeta = { id: 'yard-1', code: 'LEGACY_DEST', label: 'Sebeta' } as Yard; + +const service = (): YardsService => + new YardsService( + { + findById: async (id: string) => ({ ...sebeta, id }), + findByCode: async () => null, + findByLabelInsensitive: async (label: string) => + label.trim().toLowerCase() === 'sebeta' ? sebeta : null, + create: async (d: Partial) => d as Yard, + update: async (_id: string, d: Partial) => d as Yard, + } as never, + { resolveCreateOrder: async () => 1 } as never, + ); + +describe('duplicate yard labels are rejected', () => { + it('blocks create even when the generated code differs (Sebeta vs LEGACY_DEST)', async () => { + await expect( + service().create({ label: ' sebeta ', country: 'ET' } as never), + ).rejects.toThrow(ConflictException); + }); + + it('blocks renaming a yard onto another yard label, allows renaming itself', async () => { + await expect( + service().update('yard-2', { label: 'SEBETA' } as never), + ).rejects.toThrow(ConflictException); + await expect( + service().update('yard-1', { label: 'Sebeta' } as never), + ).resolves.toBeTruthy(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts index b698b456c..7dd29ded1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts @@ -31,6 +31,9 @@ export class YardsService { /** Create a yard. */ async create(dto: CreateYardDto): Promise { + // Label check first: the code check alone let "sebeta" in next to "Sebeta" + // when the existing yard's code didn't match its label (LEGACY_DEST). + await this.assertLabelAvailable(dto.label); const code = generateCode(dto.label).slice(0, 40); const existing = await this.repository.findByCode(code); if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`); @@ -53,11 +56,20 @@ export class YardsService { /** Update a yard. */ async update(id: string, dto: UpdateYardDto): Promise { await this.findById(id); + if (dto.label !== undefined) await this.assertLabelAvailable(dto.label, id); const updated = await this.repository.update(id, dto); if (!updated) throw new NotFoundException(`Yard ${id} not found`); return updated; } + /** No two active yards may share a label (case/whitespace-insensitive). */ + private async assertLabelAvailable(label: string, exceptId?: string): Promise { + const dupe = await this.repository.findByLabelInsensitive(label); + if (dupe && dupe.id !== exceptId) { + throw new ConflictException(`A yard named "${dupe.label}" already exists`); + } + } + /** * Soft-delete a yard. The unique `code` (and the label) get a `@` * suffix first — e.g. SEBETA → SEBETA@1755612345678 — so a new yard with the 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..d43cff60b 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,9 @@ export const BATCH_TIMEZONE = 'Africa/Addis_Ababa'; +/** 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..004c28e6b 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, }), }; @@ -150,6 +151,8 @@ describe('BookingBatchService — PAID reconcile', () => { { issuePayable: jest.fn().mockResolvedValue(null), expirePayable: jest.fn().mockResolvedValue(undefined), + // Gateway reconcile-before-expire: default = verifiably unpaid. + reconcilePayable: jest.fn().mockResolvedValue({ paid: false, unverifiable: false }), } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, @@ -708,7 +711,13 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, - { issuePayable: jest.fn(), expirePayable: jest.fn() } as never, + { + issuePayable: jest.fn(), + expirePayable: jest.fn(), + reconcilePayable: jest + .fn() + .mockResolvedValue({ paid: false, unverifiable: false }), + } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, @@ -731,7 +740,13 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, - { issuePayable: jest.fn(), expirePayable: jest.fn() } as never, + { + issuePayable: jest.fn(), + expirePayable: jest.fn(), + reconcilePayable: jest + .fn() + .mockResolvedValue({ paid: false, unverifiable: false }), + } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, @@ -762,7 +777,13 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, - { issuePayable: jest.fn(), expirePayable: jest.fn() } as never, + { + issuePayable: jest.fn(), + expirePayable: jest.fn(), + reconcilePayable: jest + .fn() + .mockResolvedValue({ paid: false, unverifiable: false }), + } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, @@ -1015,7 +1036,7 @@ describe('BookingBatchService — PAID reconcile', () => { ...(waiting as unknown as Record), status: 'SELECTED_FOR_BATCH', trainScheduleId: exportScheduleId, - paymentDeadline: new Date(Date.now() - 1_000), + paymentDeadline: new Date(Date.now() - 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 41c8023f0..37e2e99e8 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,13 @@ import { DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_TARE_TONS, DEFAULT_WAGONS_PER_BOOKING, + PAYMENT_REMINDER_LEAD_MS, } from "./booking-batch.constants"; import { LocomotiveLimits, WagonTypeDimensions, bookingCargoTons, + bulkItemWagonsRequired, bookingGrossWeightTons, deriveTrainCapacityFromLocomotive, sizePartialOfferWagons, @@ -115,6 +117,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 +691,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 +787,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 +901,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 @@ -1286,6 +1405,31 @@ export class BookingBatchService implements OnModuleInit { (s.scheduleBookings ?? []).map((l) => l.bookingId), ); const bookings = await this.bookingsRepository.findAllBySchedule(s.id); + // Under day-level pooling a booking is only pinned to a schedule by + // reserve() — until then its train_schedule_id is NULL and the query above + // misses it. Merge in the corridor-day candidates so staff see the whole + // waiting pool (the 7 that lost the batch), not just the winners. These are + // display-only candidates: they are excluded from the capacity meters below. + const pinnedIds = new Set(bookings.map((b) => b.id)); + if (s.scheduledDepartureDate) { + try { + const stops = await this.stopsForSchedule(s); + const candidates = + await this.bookingsRepository.findBatchPoolByCorridorDay( + stops, + eatDay(s.scheduledDepartureDate), + ); + for (const b of candidates) { + if (!pinnedIds.has(b.id)) bookings.push(b); + } + } catch (err) { + // The board must still render the pinned bookings. + this.logger.warn( + `Corridor-day candidate merge failed for schedule ${s.id}: ` + + `${(err as Error).message}`, + ); + } + } let allocationPreview: Awaited< ReturnType @@ -1437,7 +1581,13 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), + // Capacity holds come from bookings actually pinned to this train — + // unpinned day-pool candidates are shown in the lists but hold nothing. + capacity: this.computeBoardCapacity( + items.filter((i) => pinnedIds.has(i.id)), + loco, + s.maxWagons ?? null, + ), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -2176,7 +2326,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. @@ -2185,6 +2338,7 @@ export class BookingBatchService implements OnModuleInit { status: "SELECTED_FOR_BATCH", selectedForBatchAt: new Date(), paymentDeadline: deadline, + paymentReminderSentAt: null, } as never); booking.trainScheduleId = scheduleId; return offeredNeed; @@ -2213,6 +2367,8 @@ export class BookingBatchService implements OnModuleInit { const isPaid = (b: Booking) => b.paymentStatus === "PAID" || b.status === "PAID"; + // Deadline is the line — no fixed slack. A payment that beat the deadline + // but whose webhook is late is caught by expire()'s gateway reconcile. const isExpired = (b: Booking) => b.paymentDeadline ? b.paymentDeadline.getTime() <= now @@ -2513,6 +2669,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 @@ -2533,6 +2722,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 --------------------------------------------- /** @@ -2617,14 +2852,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; @@ -2642,6 +2877,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, @@ -2825,14 +3061,42 @@ export class BookingBatchService implements OnModuleInit { } return; } + // Reconcile-before-expire (only when a pay window was actually open): + // no webhook arrived, so ask the gateway DIRECTLY whether the money + // landed. A late capture found there is registered as SUCCEEDED and + // emits payment.succeeded — that event marks the booking PAID and + // allocates it, so we just leave the hold alone here. `unverifiable` + // (provider query errored / payment still in flight) means we could not + // confirm "not paid" — never expire on unknown; the next settle tick + // asks again. + if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) { + const reconcile = await this.billing.reconcilePayable(booking.id); + if (reconcile.paid) { + this.logger.log( + `[BATCH] expire skipped for ${booking.reference} — gateway ` + + `reconcile found a settled payment; payment.succeeded will allocate it`, + ); + return; + } + if (reconcile.unverifiable) { + this.logger.warn( + `[BATCH] expire deferred for ${booking.reference} — settlement ` + + `unverifiable at the gateway; retrying next settle tick`, + ); + return; + } + } } 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 @@ -3375,7 +3639,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); } /** @@ -3571,6 +3839,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 = @@ -3580,19 +3860,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 }]; } /** @@ -3777,8 +4061,20 @@ 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 lapsed no longer blocks + // capacity, even before the 10s sweep flips it to EXPIRED — availability + // shown to the next customer is honest between ticks. A late capture the + // gateway reconcile later confirms lands as PAID and, if the wagons went + // meanwhile, degrades to WAITING_FOR_WAGON for manual placement. + const deadlineCutoff = Date.now(); + const reserved = ( + await this.bookingsRepository.findReservedForSchedule(schedule.id) + ).filter( + (b) => + b.paymentStatus === "PAID" || + b.status === "PAID" || + b.paymentDeadline == null || + b.paymentDeadline.getTime() > deadlineCutoff, ); for (const b of [...allocated, ...reserved]) { budget.subtract( @@ -4058,10 +4354,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 { @@ -4073,8 +4392,9 @@ 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)) + .then((delayMs: number) => { this.removeTimeout(scheduleId); const handle = setTimeout(() => { void this.settleBatch(scheduleId).catch((err) => @@ -4107,7 +4427,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-split.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts index fc47d8a70..50cba543f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts @@ -72,7 +72,12 @@ describe('BookingSplitService — applySplit split marking', () => { dataSource as never, {} as never, {} as never, - { expirePayable: jest.fn() } as never, + { + expirePayable: jest.fn(), + reconcilePayable: jest + .fn() + .mockResolvedValue({ paid: false, unverifiable: false }), + } as never, { payNowPartial: jest.fn() } as never, ); return { service, bookingRepo, contractRepo }; 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..6cdef8c05 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 @@ -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,6 +602,8 @@ export class BookingWindowService implements OnModuleInit { .createQueryBuilder('b') .select('DISTINCT b.train_schedule_id', 'scheduleId') .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + // Deadline is the line — expire() itself reconciles against the gateway + // before actually expiring, so a late in-window payment is still caught. .andWhere('b.payment_deadline <= now()') .andWhere('b.train_schedule_id IS NOT NULL') .getRawMany<{ scheduleId: string }>(); 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 d75eadc4a..658ea7c78 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); @@ -6845,7 +6875,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;