changes export flow

This commit is contained in:
Marshal
2026-07-30 11:30:34 +00:00
parent b671f07ce6
commit 2ca04b8f98
47 changed files with 1195 additions and 74 deletions

View File

@@ -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<void> {
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<void> {
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;`,
);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_total_weight_tons;`,
);
}
}

View File

@@ -1002,6 +1002,23 @@ export class BillingService {
);
}
// A booking's PREPAID invoice is only payable inside its pay window —
// `dueAt` mirrors booking.paymentDeadline (issuePayable at reserve time).
// Blocking INITIATION here is what makes the deadline real: the settle
// sweep's grace only shelters payments that started before this gate.
// Other invoice types keep dueAt as display-only.
if (
invoice.source === Freight.InvoiceSource.Booking &&
invoice.type === "PREPAID" &&
invoice.dueAt &&
invoice.dueAt.getTime() <= Date.now()
) {
throw new BadRequestException(
"The payment window for this booking has closed — the reserved wagons " +
"were released. Please book again.",
);
}
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
if (!(amountDue > 0)) {
throw new BadRequestException("Invoice has no outstanding balance.");

View File

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

View File

@@ -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 }),

View File

@@ -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<Booking> {
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<Booking> {
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<Booking> {
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<ExportTrainOption[]> {
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,

View File

@@ -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) {

View File

@@ -1316,6 +1316,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
.getMany();
}
/** Open unpaid holds (wagons reserved, pay window running) for a company. */
countUnpaidHoldsForCompany(companyId: string): Promise<number> {
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<Booking[]> {
return this.repository

View File

@@ -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<void> {
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:

View File

@@ -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()

View File

@@ -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 {

View File

@@ -351,6 +351,15 @@ export class Booking extends BaseEntity {
@Column({ name: 'cargo_total_weight_vgm', type: 'numeric', precision: 12, scale: 3 })
cargoTotalWeightVgm!: number;
/**
* Break-bulk only: actual total cargo weight in tons when the bulk cargo
* type is PER_ITEM (`cargoTotalWeightVgm` then carries the item COUNT).
* Null for PER_TON bulk and all CONTAINER bookings. Wagon allocation uses
* weight ÷ count to size indivisible items per wagon.
*/
@Column({ name: 'bulk_total_weight_tons', type: 'numeric', precision: 12, scale: 3, nullable: true })
bulkTotalWeightTons?: number | null;
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
isHazardous!: boolean;
@@ -485,6 +494,18 @@ export class Booking extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;
/**
* EXPORT only: the specific train the customer picked at day-commit.
* pickExportSchedule reserves on this train (409 if it no longer fits)
* instead of falling back to earliest-departure-first. NULL = no preference.
*/
@Column({ name: 'requested_train_schedule_id', type: 'uuid', nullable: true })
requestedTrainScheduleId?: string | null;
/** Stamped when the one pre-deadline pay reminder went out (tick dedup). */
@Column({ name: 'payment_reminder_sent_at', type: 'timestamptz', nullable: true })
paymentReminderSentAt?: Date | null;
// ── Per-booking journey (segment corridor bookings) ────────────────────────
// A booking rides only its own origin→destination leg of the train's route,
// so dispatch/arrival are per-booking facts, not train facts. Clearance gates

View File

@@ -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<void> {
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);

View File

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

View File

@@ -9,6 +9,17 @@
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
/**
* Slack after a booking's paymentDeadline before the settle sweep expires it.
* Covers gateway/webhook lag for a payment STARTED inside the window —
* initiation itself is hard-blocked at the deadline (BillingService.payInvoice),
* so this never extends the time a customer has to begin paying.
*/
export const PAYMENT_GRACE_MS = 5 * 60_000;
/** How long before the pay deadline the one reminder notification goes out. */
export const PAYMENT_REMINDER_LEAD_MS = 10 * 60_000;
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
export const DEFAULT_WAGONS_PER_BOOKING = 1;

View File

@@ -109,6 +109,7 @@ describe('BookingBatchService — PAID reconcile', () => {
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
exportPaymentWindowMinutes: 60,
}),
};
@@ -835,7 +836,8 @@ describe('BookingBatchService — PAID reconcile', () => {
// One reservation whose pay window lapsed, and one booking on the waiting list.
const lapsed = booking('lapsed', 50, {
status: 'SELECTED_FOR_BATCH',
paymentDeadline: new Date(Date.now() - 60_000),
// Past deadline + the 5-minute webhook grace, so the settle expires it.
paymentDeadline: new Date(Date.now() - 6 * 60_000),
});
const waiting = booking('waiting', 10, { trainScheduleId: null });
@@ -870,7 +872,8 @@ describe('BookingBatchService — PAID reconcile', () => {
it('serialises concurrent settles so the same reservation is not settled twice', async () => {
const lapsed = booking('lapsed', 50, {
status: 'SELECTED_FOR_BATCH',
paymentDeadline: new Date(Date.now() - 60_000),
// Past deadline + the 5-minute webhook grace, so the settle expires it.
paymentDeadline: new Date(Date.now() - 6 * 60_000),
});
// Both callers read the reservation; the lock must stop the second from
// acting on rows the first already expired. (The PAYMENT transition and the
@@ -901,7 +904,8 @@ describe('BookingBatchService — PAID reconcile', () => {
it('never expires a reservation whose payment landed — allocates it instead', async () => {
const latePaid = booking('late-paid', 50, {
status: 'SELECTED_FOR_BATCH',
paymentDeadline: new Date(Date.now() - 60_000),
// Past deadline + the 5-minute webhook grace, so the settle expires it.
paymentDeadline: new Date(Date.now() - 6 * 60_000),
});
bookingsRepository.findReservedForSchedule
.mockResolvedValueOnce([latePaid])
@@ -1015,7 +1019,8 @@ describe('BookingBatchService — PAID reconcile', () => {
...(waiting as unknown as Record<string, unknown>),
status: 'SELECTED_FOR_BATCH',
trainScheduleId: exportScheduleId,
paymentDeadline: new Date(Date.now() - 1_000),
// Past deadline + the 5-minute webhook grace, so the settle expires it.
paymentDeadline: new Date(Date.now() - 6 * 60_000),
originYardId: 'yard-a',
destinationYardId: 'yard-b',
priorityScore: 0,

View File

@@ -61,11 +61,14 @@ import {
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
DEFAULT_WAGONS_PER_BOOKING,
PAYMENT_GRACE_MS,
PAYMENT_REMINDER_LEAD_MS,
} from "./booking-batch.constants";
import {
LocomotiveLimits,
WagonTypeDimensions,
bookingCargoTons,
bulkItemWagonsRequired,
bookingGrossWeightTons,
deriveTrainCapacityFromLocomotive,
sizePartialOfferWagons,
@@ -115,6 +118,32 @@ export interface ExportSpaceReport {
fullMessage: string | null;
}
/**
* One export train the customer can pick for a shipment day: live free-wagon
* space measured against THE BOOKING'S allowed wagon types (so the per-type
* list doubles as "what cargo this train can take for you"). Unpaid holds
* count as taken; lapsed holds free up via the lazy-expiry capacity filter.
*/
export interface ExportTrainOption {
scheduleId: string;
departure: Date;
/** Booking cutoff for this train (windowClosesAt), null on legacy rows. */
bookingClosesAt: Date | null;
/** Whether the export FCFS window is open for booking right now. */
isOpen: boolean;
/** Best bookable wagons across the booking's allowed types. */
freeWagons: number;
/** Wagons this booking needs — `fits` = freeWagons >= neededWagons. */
neededWagons: number;
fits: boolean;
byWagonType: Array<{
wagonTypeId: string | null;
code: string | null;
name: string | null;
freeWagons: number;
}>;
}
/** A day-level pool key: all trains on this route departing on this EAT day. */
interface RouteDayGroup {
originYardId: string;
@@ -663,12 +692,16 @@ export class BookingBatchService implements OnModuleInit {
{ status: TrainScheduleStatusEnum.Scheduled },
],
});
// A customer-picked train narrows the scan to that ONE schedule: export
// FCFS honors the pick or fails loudly (exportFullMessage names it).
const requestedId = booking.requestedTrainScheduleId ?? null;
const candidates = corridor
.filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day &&
this.isFillable(s),
this.isFillable(s) &&
(!requestedId || s.id === requestedId),
)
.sort(
(a, b) =>
@@ -755,13 +788,18 @@ export class BookingBatchService implements OnModuleInit {
/** Customer-facing "train is full" copy carrying the bookable leftover. */
private exportFullMessage(booking: Booking, report: ExportSpaceReport): string {
const picked = Boolean(booking.requestedTrainScheduleId);
if (!report.trainsForDay || !report.corridorMatched) {
return 'No export train is accepting bookings for this day';
return picked
? 'The selected train is no longer accepting bookings — pick another train or day.'
: 'No export train is accepting bookings for this day';
}
const best = report.bestAvailable;
const base =
'Not enough train space — an export booking must ride a single train whole, ' +
'and no open train on this day can carry it. ';
const base = picked
? 'Not enough space left on the selected train — an export booking must ' +
'ride one train whole. '
: 'Not enough train space — an export booking must ride a single train whole, ' +
'and no open train on this day can carry it. ';
if (!best || best.wagons <= 0) {
return base + 'No capacity is left on this day — pick another shipment day.';
}
@@ -864,6 +902,88 @@ export class BookingBatchService implements OnModuleInit {
return out;
}
/**
* The export train picker: every export train on the booking's corridor/day
* with its live space, measured per allowed wagon type so the customer sees
* what each train can still take for THEIR cargo. Includes full/not-yet-open
* trains (freeWagons 0 / isOpen false) so the UI can show them disabled —
* the request-time gate (exportSpaceReport) stays the enforcement point.
*/
async exportTrainOptionsForDay(
booking: Booking,
day: string,
): Promise<ExportTrainOption[]> {
const corridor = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
],
});
const candidates = corridor
.filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day &&
s.direction === 'EXPORT',
)
.sort(
(a, b) =>
a.scheduledDepartureDate!.getTime() -
b.scheduledDepartureDate!.getTime(),
);
const wagonDims = await this.loadWagonDims();
const allowed = this.allowedDimsWithTypes(booking, wagonDims);
const neededWagons = this.wagonsFor(booking, wagonDims);
const typeIds = allowed
.map((a) => a.wagonTypeId)
.filter((id): id is string => Boolean(id));
const types = typeIds.length
? await this.dataSource
.getRepository(WagonType)
.find({ where: { id: In(typeIds) } })
: [];
const typeById = new Map(types.map((t) => [t.id, t]));
const out: ExportTrainOption[] = [];
for (const candidate of candidates) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id,
);
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
if (!leg) continue; // this train's route doesn't carry the booking's leg
const room = budget.remainingFor(leg);
const byWagonType = allowed.map(({ wagonTypeId, dims }) => {
const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined;
return {
wagonTypeId,
code: type?.code ?? null,
name: type?.name ?? null,
freeWagons: this.bookableWithin(room, dims).wagons,
};
});
const freeWagons = byWagonType.reduce(
(best, t) => Math.max(best, t.freeWagons),
0,
);
out.push({
scheduleId: schedule.id,
departure: schedule.scheduledDepartureDate!,
bookingClosesAt: schedule.windowClosesAt ?? null,
isOpen: this.isFillable(schedule),
freeWagons,
neededWagons,
fits: freeWagons >= neededWagons,
byWagonType,
});
}
return out;
}
/**
* Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day,
* summed across every train on the booking's corridor that day. Unlike the
@@ -2207,7 +2327,10 @@ export class BookingBatchService implements OnModuleInit {
};
if (!this.fits(offeredNeed, budget)) return null;
const deadline = new Date(Date.now() + (await this.paymentWindowMs()));
const deadline = new Date(
Date.now() +
(await this.paymentWindowMsFor(await this.scheduleById(scheduleId))),
);
await this.splitService.createOffer(booking, scheduleId, sized, deadline);
// Reserve like a normal batch selection, but the partial invoice + partial
// pay-now notification were already produced by createOffer.
@@ -2216,6 +2339,7 @@ export class BookingBatchService implements OnModuleInit {
status: "SELECTED_FOR_BATCH",
selectedForBatchAt: new Date(),
paymentDeadline: deadline,
paymentReminderSentAt: null,
} as never);
booking.trainScheduleId = scheduleId;
return offeredNeed;
@@ -2244,9 +2368,11 @@ export class BookingBatchService implements OnModuleInit {
const isPaid = (b: Booking) =>
b.paymentStatus === "PAID" || b.status === "PAID";
// Grace: a payment started inside the window may land minutes late via the
// gateway webhook — don't expire until the slack has passed too.
const isExpired = (b: Booking) =>
b.paymentDeadline
? b.paymentDeadline.getTime() <= now
? b.paymentDeadline.getTime() + PAYMENT_GRACE_MS <= now
: expireUnpaidUnknownDeadline;
for (const booking of reserved) {
@@ -2544,6 +2670,39 @@ export class BookingBatchService implements OnModuleInit {
this.notifyBoardChanged(newScheduleId, "booking_moved");
}
/**
* One reminder per hold, shortly before its pay deadline (the window tick
* calls this every pass; `payment_reminder_sent_at` dedups). Skips paid
* bookings — a landed payment the settle hasn't processed yet needs no nag.
*/
async sendPaymentReminders(): Promise<void> {
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<void> {
const booking = await this.dataSource
@@ -2564,6 +2723,52 @@ export class BookingBatchService implements OnModuleInit {
}
}
/**
* Customer cancel of an unpaid hold: the same immediate release as
* expireReservation, but the booking ends CANCELLED (the customer chose to
* walk away — "payment window missed" copy would be wrong). Consolidated
* pairs are rejected by the caller: the shared wagon is both-or-neither.
*/
async cancelReservation(bookingId: string): Promise<void> {
const booking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
const freedScheduleId = booking.trainScheduleId;
await this.bookingsRepository.update(booking.id, {
trainScheduleId: null,
requestedTrainScheduleId: null,
status: "CANCELLED",
schedulingStatus: "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
paymentReminderSentAt: null,
} as never);
// An unpaid partial offer dies with the hold — same as expire().
if (this.splitService) {
await this.splitService.expireOpenOffer(booking.id);
}
await this.billing.expirePayable(
Freight.InvoiceSource.Booking,
booking.id,
"PREPAID",
);
if (freedScheduleId) {
// Same release choreography as expireReservation: reopen a FULL window,
// top up from the waiting list, push one board update with final state.
await this.refreshWindowStatus(freedScheduleId);
const topUpReserved = await this.topUpFill(freedScheduleId);
if (topUpReserved > 0) {
await this.extendPaymentPhaseForTopUp(freedScheduleId);
}
this.notifyBoardChanged(freedScheduleId, "reservation_expired");
}
this.logger.log(
`[BATCH] CANCELLED hold ${booking.reference} — customer released the ` +
`reservation before paying; wagons freed`,
);
}
// ---- intercity ride-along API ---------------------------------------------
/**
@@ -2648,14 +2853,14 @@ export class BookingBatchService implements OnModuleInit {
return;
}
const now = new Date();
let deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
const targetSchedule = await this.scheduleById(scheduleId);
let deadline = new Date(
now.getTime() + (await this.paymentWindowMsFor(targetSchedule)),
);
// EXPORT parity: pay windows on an export train never outlive its booking
// window — export bookings expire at close, so anything reserved onto the
// same train (FCFS export or an intercity ride-along) must too. Import
// keeps the plain payment window; its cycles re-fill after settle.
const targetSchedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: scheduleId } });
if (targetSchedule?.direction === "EXPORT") {
const cutoff =
targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate;
@@ -2673,6 +2878,7 @@ export class BookingBatchService implements OnModuleInit {
status: "SELECTED_FOR_BATCH",
selectedForBatchAt: now,
paymentDeadline: deadline,
paymentReminderSentAt: null,
} as never);
booking.trainScheduleId = scheduleId;
// The invoice was generated DRAFT at booking creation / operation-accept,
@@ -2860,10 +3066,13 @@ export class BookingBatchService implements OnModuleInit {
const freedScheduleId = booking.trainScheduleId;
await this.bookingsRepository.update(booking.id, {
trainScheduleId: null,
// The customer's train pick died with the hold — a rebook re-picks.
requestedTrainScheduleId: null,
status: "EXPIRED",
schedulingStatus: "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
paymentReminderSentAt: null,
} as never);
booking.trainScheduleId = null;
// The wagons this reservation held are back — a schedule parked at FULL
@@ -3406,7 +3615,11 @@ export class BookingBatchService implements OnModuleInit {
const byWeight =
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight);
// Break-bulk (PER_ITEM): indivisible items can need more wagons than raw
// tonnage suggests (floor items-per-wagon loses the fractional capacity).
const byItems = bulkItemWagonsRequired(booking, capacityTons);
return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight, byItems);
}
/**
@@ -3602,6 +3815,18 @@ export class BookingBatchService implements OnModuleInit {
* representative dims when no allowed type is configured.
*/
private dimsForAllowed(booking: Booking, wagonDims: WagonDims): PerWagonDims[] {
return this.allowedDimsWithTypes(booking, wagonDims).map((p) => p.dims);
}
/**
* Same allowed set as {@link dimsForAllowed} but keeping each wagon-type id,
* so callers (the export train picker) can label per-type availability.
* `wagonTypeId` is null only on the unconfigured fallback entry.
*/
private allowedDimsWithTypes(
booking: Booking,
wagonDims: WagonDims,
): Array<{ wagonTypeId: string | null; dims: PerWagonDims }> {
const fallback =
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
const ids =
@@ -3611,19 +3836,23 @@ export class BookingBatchService implements OnModuleInit {
.flatMap((line) => line.containerType?.wagonTypes ?? [])
.map((wt) => wt.id);
const seen = new Set<string>();
const dims: PerWagonDims[] = [];
const out: Array<{ wagonTypeId: string | null; dims: PerWagonDims }> = [];
for (const id of ids) {
if (!id || seen.has(id)) continue;
seen.add(id);
const d = wagonDims.byWagonTypeId.get(id);
if (d) {
dims.push({
...d,
capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons,
out.push({
wagonTypeId: id,
dims: {
...d,
capacityTons:
d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons,
},
});
}
}
return dims.length ? dims : [fallback];
return out.length ? out : [{ wagonTypeId: null, dims: fallback }];
}
/**
@@ -3808,8 +4037,18 @@ export class BookingBatchService implements OnModuleInit {
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(
schedule.id,
// Lazy-expiry guard: a hold whose deadline + grace has lapsed no longer
// blocks capacity, even before the 10s sweep flips it to EXPIRED — so
// availability shown to the next customer is honest between ticks.
const graceCutoff = Date.now() - PAYMENT_GRACE_MS;
const reserved = (
await this.bookingsRepository.findReservedForSchedule(schedule.id)
).filter(
(b) =>
b.paymentStatus === "PAID" ||
b.status === "PAID" ||
b.paymentDeadline == null ||
b.paymentDeadline.getTime() > graceCutoff,
);
for (const b of [...allocated, ...reserved]) {
budget.subtract(
@@ -3905,7 +4144,7 @@ export class BookingBatchService implements OnModuleInit {
b.paymentStatus !== "PAID" &&
b.status !== "PAID" &&
b.paymentDeadline != null &&
b.paymentDeadline.getTime() > now,
b.paymentDeadline.getTime() + PAYMENT_GRACE_MS > now,
);
}
@@ -4089,10 +4328,33 @@ export class BookingBatchService implements OnModuleInit {
// ---- timer plumbing -------------------------------------------------------
/** Configured customer pay window in ms (global rules, with defaults). */
private async paymentWindowMs(): Promise<number> {
/**
* 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<number> {
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<TrainSchedule | null> {
return this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id } });
}
private timeoutName(scheduleId: string): string {
@@ -4104,8 +4366,12 @@ export class BookingBatchService implements OnModuleInit {
* engine's minute tick calling settleDueReservations off `paymentDeadline`.
*/
private armSettle(scheduleId: string): void {
void this.paymentWindowMs()
.then((delayMs) => {
void this.scheduleById(scheduleId)
.then((schedule) => this.paymentWindowMsFor(schedule))
// The timer covers the grace too — firing at the bare deadline would
// settle before the sweep's grace cutoff and find nothing to expire.
.then((windowMs: number) => {
const delayMs = windowMs + PAYMENT_GRACE_MS;
this.removeTimeout(scheduleId);
const handle = setTimeout(() => {
void this.settleBatch(scheduleId).catch((err) =>
@@ -4138,7 +4404,7 @@ export class BookingBatchService implements OnModuleInit {
.getRepository(TrainSchedule)
.findOne({ where: { id: scheduleId } });
if (!schedule || schedule.windowPhase !== "PAYMENT") return;
const windowMs = await this.paymentWindowMs();
const windowMs = await this.paymentWindowMsFor(schedule);
let target = new Date(Date.now() + windowMs);
if (
schedule.scheduledDepartureDate &&

View File

@@ -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<void> {
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

View File

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

View File

@@ -36,6 +36,7 @@ describe('BookingWindowService — window state machine', () => {
windowDurationHours: 1,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
exportPaymentWindowMinutes: 60,
};
const baseSchedule = (over: Partial<TrainSchedule>): TrainSchedule =>

View File

@@ -20,7 +20,7 @@ import {
import { BookingBatchService } from './booking-batch.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
import { BATCH_TIMEZONE, PAYMENT_GRACE_MS } from './booking-batch.constants';
import {
bookingCloseCutoff,
clampCloseToOfficeHours,
@@ -141,6 +141,13 @@ export class BookingWindowService implements OnModuleInit {
await this.settleOverdueReservations();
// One pre-deadline pay reminder per hold (deduped via reminder stamp).
await this.bookingBatchService.sendPaymentReminders().catch((err) =>
this.logger.warn(
`Payment reminder sweep failed: ${(err as Error).message}`,
),
);
// Legacy fill (DOMESTIC / pre-migration schedules) every 5 minutes
// (30 ticks at the 10-second cadence).
this.tickCount += 1;
@@ -595,7 +602,10 @@ export class BookingWindowService implements OnModuleInit {
.createQueryBuilder('b')
.select('DISTINCT b.train_schedule_id', 'scheduleId')
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.andWhere('b.payment_deadline <= now()')
// Deadline + grace: a payment started in-window may webhook in late.
.andWhere('b.payment_deadline <= :graceCutoff', {
graceCutoff: new Date(Date.now() - PAYMENT_GRACE_MS),
})
.andWhere('b.train_schedule_id IS NOT NULL')
.getRawMany<{ scheduleId: string }>();
for (const { scheduleId } of overdue) {

View File

@@ -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.

View File

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

View File

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

View File

@@ -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(

View File

@@ -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<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number {
return num(slot.tareWeightTons) + num(slot.cargoTons);

View File

@@ -213,6 +213,7 @@ export function effectiveWindowConfig(
ruleWindowCloseHour?: number | null;
ruleWindowDurationHours?: number | null;
ruleReopenDelayMinutes?: number | null;
rulePaymentWindowMinutes?: number | null;
ruleImportWindowLeadDays?: number | null;
ruleExportBookingLeadHours?: number | null;
ruleImportCloseOffsetMinutes?: number | null;
@@ -232,7 +233,14 @@ export function effectiveWindowConfig(
? Number(schedule.ruleWindowDurationHours)
: liveCfg.windowDurationHours,
docReviewMinutes: liveCfg.docReviewMinutes,
paymentWindowMinutes: liveCfg.paymentWindowMinutes,
// Pay windows read live values unless staff explicitly overrode this ONE
// schedule (rule_payment_window_minutes is only ever written by that
// override, never stamped at creation). The override wins for whichever
// direction the schedule runs.
paymentWindowMinutes:
schedule.rulePaymentWindowMinutes ?? liveCfg.paymentWindowMinutes,
exportPaymentWindowMinutes:
schedule.rulePaymentWindowMinutes ?? liveCfg.exportPaymentWindowMinutes,
// The close offset is frozen per-schedule: a snapshot value of null means
// "created with no offset" and must NOT inherit a later live offset (that
// would retro-shrink an open train's window). Only a truly legacy row that
@@ -696,6 +704,8 @@ export class TrainSchedulingService {
if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
if (dto.exportPaymentWindowMinutes != null)
row.exportPaymentWindowMinutes = dto.exportPaymentWindowMinutes;
// Store 0 as null so "no offset" is a single canonical value.
if (dto.importCloseOffsetMinutes !== undefined)
row.importCloseOffsetMinutes = dto.importCloseOffsetMinutes || null;
@@ -790,7 +800,14 @@ export class TrainSchedulingService {
// The reopen gap is doc review + payment; keep the config values unless the
// override changes them, so the derived snapshot delay stays consistent.
docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes,
paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes,
paymentWindowMinutes:
dto.paymentWindowMinutes ??
schedule.rulePaymentWindowMinutes ??
liveCfg.paymentWindowMinutes,
exportPaymentWindowMinutes:
dto.paymentWindowMinutes ??
schedule.rulePaymentWindowMinutes ??
liveCfg.exportPaymentWindowMinutes,
// A per-schedule override isn't a close-offset control, so inherit the
// offset already frozen on the schedule (null = none), or the live one for
// legacy rows — the override must not silently drop the global offset.
@@ -853,11 +870,17 @@ export class TrainSchedulingService {
}
}
// The pay-window override persists only when staff actually sent it (or the
// schedule already had one) — windowRuleSnapshot never stamps it, so NULL
// keeps meaning "follow the live global value for my direction".
const rulePaymentWindowMinutes =
dto.paymentWindowMinutes ?? schedule.rulePaymentWindowMinutes ?? null;
for (const t of targets) {
await repo.update(t.id, {
windowOpensAt: cap(times.windowOpensAt, t.departure),
windowClosesAt: cap(times.windowClosesAt, t.departure),
...ruleFields,
rulePaymentWindowMinutes,
});
}
this.logger.log(
@@ -1199,6 +1222,7 @@ export class TrainSchedulingService {
windowDurationHours: num(row?.windowDurationHours, 3),
docReviewMinutes: num(row?.docReviewMinutes, 30),
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
exportPaymentWindowMinutes: num(row?.exportPaymentWindowMinutes, 60),
importCloseOffsetMinutes: offset(row?.importCloseOffsetMinutes),
exportCloseOffsetMinutes: offset(row?.exportCloseOffsetMinutes),
};
@@ -1931,8 +1955,14 @@ export class TrainSchedulingService {
removedAt: new Date(),
});
console.log(
`[NOTIFY] Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer should be notified to reschedule or cancel.`,
// Ops decision, so the customer hears about it: SMS/email + inbox telling
// them to rebook or pick a new schedule (the removal log above is the record).
const removedBooking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId }, relations: { company: true } });
if (removedBooking) this.bookingNotifier.removedFromTrain(removedBooking);
this.logger.log(
`Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`,
);
return this.getTrainScheduleById(scheduleId);
@@ -6778,7 +6808,14 @@ export class TrainSchedulingService {
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
docReviewMinutes: windowCfg.docReviewMinutes,
paymentWindowMinutes: windowCfg.paymentWindowMinutes,
// Editor prefill: this schedule's own override when staff set one,
// else the live global for the schedule's direction (import/export
// pay windows are tuned separately).
paymentWindowMinutes:
schedule.rulePaymentWindowMinutes ??
(schedule.direction === 'EXPORT'
? windowCfg.exportPaymentWindowMinutes
: windowCfg.paymentWindowMinutes),
},
route: schedule.route
? { id: schedule.route.id, name: formatRouteLabel(schedule.route) }

View File

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

View File

@@ -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()

View File

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

View File

@@ -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.
</Text>
<TextInput
label="Name (optional)"
placeholder="e.g. Fertilizer block"
label="Vogue number"
placeholder="Enter vogue number"
value={trainName}
onChange={(e) => setTrainName(e.currentTarget.value)}
maxLength={100}
required
/>
<Group grow>
{/* Fixed by the import run — derived, never typed. */}

View File

@@ -363,14 +363,17 @@ export default function BookingWindowSettingsModal({
/>
<DurationField
label="Payment window"
description="Time a selected customer has to pay"
description={
isExport
? "Time an export customer has to pay before the reserved wagons are released — overrides the global export payment window for THIS train only"
: "Time a selected customer has to pay"
}
value={form.paymentWindowMinutes}
nativeUnit="minutes"
onChange={(v) =>
setForm((f) => f && { ...f, paymentWindowMinutes: v })
}
min={1}
disabled={isExport}
/>
</Group>
{!isExport ? (

View File

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

View File

@@ -66,6 +66,7 @@ export default function TrainSchedulingGlobalRulesPage() {
"windowDurationHours",
"docReviewMinutes",
"paymentWindowMinutes",
"exportPaymentWindowMinutes",
];
const payload: Partial<Record<keyof TrainSchedulingGlobalRules, number>> = {};
for (const key of fields) {
@@ -209,8 +210,8 @@ export default function TrainSchedulingGlobalRulesPage() {
disabled={loading}
/>
<DurationField
label="Payment window"
description="Time a selected customer has to pay before the slot expires"
label="Import payment window"
description="Time an import/domestic customer has to pay before the reserved slot expires"
value={form.paymentWindowMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
@@ -219,6 +220,17 @@ export default function TrainSchedulingGlobalRulesPage() {
min={1}
disabled={loading}
/>
<DurationField
label="Export payment window"
description="Time an export customer has to pay before the reserved wagons are released"
value={form.exportPaymentWindowMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
setForm((current) => ({ ...current, exportPaymentWindowMinutes: value }))
}
min={1}
disabled={loading}
/>
</Stack>
</Card>

View File

@@ -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. */

View File

@@ -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:

View File

@@ -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.

View File

@@ -120,7 +120,7 @@ function BookingActionModalBody({
leftSection={<CheckCircle2 size={16} />}
onClick={handleProceed}
loading={flow.proceedMutation.isPending}
disabled={!flow.scheduledDate}
disabled={!flow.scheduledDate || flow.requiresTrainSelection}
>
Proceed to operation
</Button>

View File

@@ -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
</Text>
<Text fz="12px" c="dimmed" mb="sm">
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."}
</Text>
<OperationDatePicker
originYardId={booking.originYard?.id}
@@ -235,13 +241,21 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
value={scheduledDate}
onChange={setScheduledDate}
/>
{scheduledDate && (
{scheduledDate && !isExportRail && (
<DayAvailabilityHint
bookingId={booking.id}
date={scheduledDate}
tradeDirection={booking.tradeDirection}
/>
)}
{scheduledDate && isExportRail && (
<ExportTrainPicker
options={exportTrains}
loading={exportTrainsLoading}
value={selectedTrainId}
onChange={setSelectedTrainId}
/>
)}
</Box>
)}

View File

@@ -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 (
<Group gap="xs" mt="sm">
<Loader size="xs" />
<Text fz="12px" c="dimmed">
Checking trains for this day
</Text>
</Group>
);
}
if (!options.length) return null;
return (
<Box mt="sm">
<Text fz="13px" fw={700} c="#10202F" mb={6}>
Choose your train
</Text>
<Stack gap="xs">
{options.map((option) => {
const bookable = option.isOpen && option.fits;
const selected = value === option.scheduleId;
const closes = closesLabel(option.bookingClosesAt);
return (
<UnstyledButton
key={option.scheduleId}
onClick={() => 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",
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="xs" align="flex-start" wrap="nowrap">
<TrainFront size={16} color={selected ? SELECTED : "#5B6B7A"} />
<Box>
<Text fz="13px" fw={600} c="#10202F">
Departs {departureLabel(option.departure)} EAT
</Text>
<Text fz="12px" c="dimmed">
{option.freeWagons} wagon{option.freeWagons === 1 ? "" : "s"} free
for your cargo · you need {option.neededWagons}
{closes ? ` · booking closes ${closes} EAT` : ""}
</Text>
<Group gap={6} mt={4}>
{option.byWagonType.map((t) => (
<Badge
key={t.wagonTypeId ?? "default"}
size="sm"
variant="light"
color={t.freeWagons > 0 ? "teal" : "gray"}
>
{t.code ?? t.name ?? "Wagon"}: {t.freeWagons} free
</Badge>
))}
</Group>
</Box>
</Group>
{selected ? (
<CheckCircle2 size={18} color={SELECTED} />
) : !option.isOpen ? (
<Badge size="sm" color="gray" variant="light">
Not open
</Badge>
) : !option.fits ? (
<Badge size="sm" color="red" variant="light">
Too little space
</Badge>
) : null}
</Group>
</UnstyledButton>
);
})}
</Stack>
</Box>
);
}

View File

@@ -27,7 +27,39 @@ export function useClearanceFlow(booking: Freight.IBooking) {
const [pending, setPending] = useState<Record<string, File>>({});
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
// Binding shipment day chosen for the operation request (yyyy-MM-dd).
const [scheduledDate, setScheduledDate] = useState<string>("");
const [scheduledDate, setScheduledDateState] = useState<string>("");
// Export rail only: the specific train picked for that day.
const [selectedTrainId, setSelectedTrainId] = useState<string>("");
// 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,

View File

@@ -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<BookingFormValues> = {
extraRoutes: [],
scheduledDate: "",
cargoWeight: "",
bulkTotalWeightTons: "",
cargoTypePath: [],
cargoFreeText: "",
isHazardous: false,

View File

@@ -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 && (
<Controller
name="bulkTotalWeightTons"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
id="bulkTotalWeightTons"
type="number"
onKeyDown={blockNegative}
label="Total weight (Tons) *"
placeholder="e.g. 800"
leftSection={<Weight className="h-4 w-4" />}
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

View File

@@ -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 (
<Box style={{ padding: "28px 32px 40px" }}>

View File

@@ -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<void, MyBookingWindow[]>(
"train-scheduling",
"myBookingWindows",

View File

@@ -378,10 +378,11 @@ export const bookingsService = {
proceedToOperation: async (
id: string,
scheduledDate: string,
trainScheduleId?: string,
): Promise<Freight.IBooking> => {
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<Freight.ExportTrainOption[]> => {
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,