mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
changes export flow
This commit is contained in:
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,6 +36,7 @@ describe('BookingWindowService — window state machine', () => {
|
||||
windowDurationHours: 1,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
exportPaymentWindowMinutes: 60,
|
||||
};
|
||||
|
||||
const baseSchedule = (over: Partial<TrainSchedule>): TrainSchedule =>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user