mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
Merge pull request #1021 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two active "Sebeta" yards existed (code LEGACY_DEST label "Sebeta", and code
|
||||||
|
* SEBETA label "sebeta") — rates and routes pointed at one or the other, so a
|
||||||
|
* rate configured against one never matched a contract routed via the other.
|
||||||
|
* Merge them: keep the row all rates/distances/facilities reference
|
||||||
|
* (LEGACY_DEST), repoint every yard reference from the duplicate to it, retire
|
||||||
|
* the duplicate, and give the survivor the clean SEBETA code. Then make
|
||||||
|
* duplicate active yard labels/codes impossible at the DB level.
|
||||||
|
*/
|
||||||
|
export class MergeDuplicateSebetaYards3050000000000 implements MigrationInterface {
|
||||||
|
name = "MergeDuplicateSebetaYards3050000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
survivor uuid;
|
||||||
|
dupe uuid;
|
||||||
|
col record;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO survivor FROM freight.yards
|
||||||
|
WHERE code = 'LEGACY_DEST' AND lower(trim(label)) = 'sebeta' AND deleted_at IS NULL;
|
||||||
|
SELECT id INTO dupe FROM freight.yards
|
||||||
|
WHERE code = 'SEBETA' AND deleted_at IS NULL;
|
||||||
|
IF survivor IS NULL OR dupe IS NULL OR survivor = dupe THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Every yard-referencing column in the schema, so rows created between
|
||||||
|
-- authoring and running this migration are repointed too.
|
||||||
|
FOR col IN
|
||||||
|
SELECT table_name, column_name FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'freight'
|
||||||
|
AND table_name <> 'yards'
|
||||||
|
AND (column_name LIKE '%yard_id%' OR column_name LIKE '%station_id%')
|
||||||
|
LOOP
|
||||||
|
EXECUTE format(
|
||||||
|
'UPDATE freight.%I SET %I = $1 WHERE %I = $2',
|
||||||
|
col.table_name, col.column_name, col.column_name
|
||||||
|
) USING survivor, dupe;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
UPDATE freight.yards
|
||||||
|
SET code = 'SEBETA@merged', label = 'sebeta@merged', deleted_at = now()
|
||||||
|
WHERE id = dupe;
|
||||||
|
UPDATE freight.yards SET code = 'SEBETA', label = 'Sebeta' WHERE id = survivor;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yards_label_active"
|
||||||
|
ON freight.yards (lower(trim(label))) WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yards_code_active"
|
||||||
|
ON freight.yards (lower(trim(code))) WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Data repair — not reversible. The uniqueness indexes are the new invariant.
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_yards_label_active"`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_yards_code_active"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -983,6 +983,17 @@ export class BillingService {
|
|||||||
* never fire before the link exists. Throws when the invoice is not found or
|
* never fire before the link exists. Throws when the invoice is not found or
|
||||||
* not in an open/payable status.
|
* not in an open/payable status.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Settlement check before expiring a payable order (reconcile-before-expire):
|
||||||
|
* live-queries the gateway for any settled intent on the source order. Kept
|
||||||
|
* on billing so the domain never talks to the payment service directly.
|
||||||
|
*/
|
||||||
|
reconcilePayable(
|
||||||
|
sourceId: string,
|
||||||
|
): Promise<{ paid: boolean; unverifiable: boolean }> {
|
||||||
|
return this.payment.reconcileShipment(sourceId);
|
||||||
|
}
|
||||||
|
|
||||||
async payInvoice(
|
async payInvoice(
|
||||||
invoiceId: string,
|
invoiceId: string,
|
||||||
opts: {
|
opts: {
|
||||||
@@ -1002,6 +1013,23 @@ export class BillingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A booking's PREPAID invoice is only payable inside its pay window —
|
||||||
|
// `dueAt` mirrors booking.paymentDeadline (issuePayable at reserve time).
|
||||||
|
// Blocking INITIATION here is what makes the deadline real: a payment
|
||||||
|
// STARTED before this gate but settling late is still honored by the
|
||||||
|
// expire-time gateway reconcile. Other invoice types keep dueAt display-only.
|
||||||
|
if (
|
||||||
|
invoice.source === Freight.InvoiceSource.Booking &&
|
||||||
|
invoice.type === "PREPAID" &&
|
||||||
|
invoice.dueAt &&
|
||||||
|
invoice.dueAt.getTime() <= Date.now()
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"The payment window for this booking has closed — the reserved wagons " +
|
||||||
|
"were released. Please book again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
|
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
|
||||||
if (!(amountDue > 0)) {
|
if (!(amountDue > 0)) {
|
||||||
throw new BadRequestException("Invoice has no outstanding balance.");
|
throw new BadRequestException("Invoice has no outstanding balance.");
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
containersPerWagonForSize,
|
containersPerWagonForSize,
|
||||||
wagonsPerUnitForSize,
|
wagonsPerUnitForSize,
|
||||||
} from '../rule-engine/container-type.util';
|
} from '../rule-engine/container-type.util';
|
||||||
|
import { bulkItemWagonsRequired } from '../train-scheduling/train-capacity.util';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { wagonRemainder } from './consolidation.service';
|
import { wagonRemainder } from './consolidation.service';
|
||||||
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||||
@@ -1186,6 +1187,10 @@ export class BookingPricingService {
|
|||||||
...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0),
|
...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0),
|
||||||
);
|
);
|
||||||
if (!(capacity > 0)) return null;
|
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));
|
return Math.max(1, Math.ceil(tons / capacity));
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ describe('BookingTransitionService — operation review', () => {
|
|||||||
};
|
};
|
||||||
const bookingsService = {
|
const bookingsService = {
|
||||||
findById: jest.fn().mockResolvedValue(booking),
|
findById: jest.fn().mockResolvedValue(booking),
|
||||||
|
assertNoUnpaidHold: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const bookingBatchService = {
|
const bookingBatchService = {
|
||||||
enqueueRouteDayProcessing: jest.fn(),
|
enqueueRouteDayProcessing: jest.fn(),
|
||||||
@@ -144,6 +145,7 @@ describe('BookingTransitionService — requestOperation export space gate', () =
|
|||||||
};
|
};
|
||||||
const bookingsService = {
|
const bookingsService = {
|
||||||
findById: jest.fn().mockResolvedValue(booking),
|
findById: jest.fn().mockResolvedValue(booking),
|
||||||
|
assertNoUnpaidHold: jest.fn().mockResolvedValue(undefined),
|
||||||
checkDayCompatibilityForBooking: jest
|
checkDayCompatibilityForBooking: jest
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ hasDeparture: true, hasCompatible: true }),
|
.mockResolvedValue({ hasDeparture: true, hasCompatible: true }),
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { EventEmitter2, OnEvent } from "@nestjs/event-emitter";
|
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 { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
import { isRoadService } from './road.util';
|
import { isRoadService } from './road.util';
|
||||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||||
@@ -401,6 +404,31 @@ export class BookingTransitionService {
|
|||||||
return fresh;
|
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> {
|
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, [
|
assertBookingStatus(booking, [
|
||||||
@@ -891,6 +919,7 @@ export class BookingTransitionService {
|
|||||||
async requestOperation(
|
async requestOperation(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
scheduledDate: string,
|
scheduledDate: string,
|
||||||
|
requestedTrainScheduleId?: string | null,
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, [
|
assertBookingStatus(booking, [
|
||||||
@@ -898,6 +927,13 @@ export class BookingTransitionService {
|
|||||||
"OPERATION_CHANGES_REQUESTED",
|
"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
|
// A bare initiated instance (clearance-first flow) carries no cargo or
|
||||||
// price — it must go through the contract completion endpoint, which
|
// price — it must go through the contract completion endpoint, which
|
||||||
// persists cargo, prices, invoices and only then lands here itself.
|
// 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").
|
// largest bookable leftover ("reduce to N wagons or pick another day").
|
||||||
// Import/domestic bookings are batched + splittable, so they are NOT gated
|
// Import/domestic bookings are batched + splittable, so they are NOT gated
|
||||||
// here — they get an advisory count below and the batch engine sizes them.
|
// here — they get an advisory count below and the batch engine sizes them.
|
||||||
const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
|
|
||||||
const isExportTrain =
|
const isExportTrain =
|
||||||
booking.tradeDirection === "EXPORT" &&
|
booking.tradeDirection === "EXPORT" &&
|
||||||
!isRoadService(booking.serviceType);
|
!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) {
|
if (isExportTrain) {
|
||||||
// With export split ON the booking no longer has to ride ONE train whole:
|
// 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
|
// the largest fitting part is offered and the leftover rebooks on the next
|
||||||
@@ -958,9 +1002,14 @@ export class BookingTransitionService {
|
|||||||
eatDay(date),
|
eatDay(date),
|
||||||
"EXPORT",
|
"EXPORT",
|
||||||
);
|
);
|
||||||
if (!fitting.length) {
|
const fitsRequest = requestedId
|
||||||
|
? fitting.some((f) => f.scheduleId === requestedId)
|
||||||
|
: fitting.length > 0;
|
||||||
|
if (!fitsRequest) {
|
||||||
throw new ConflictException(
|
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 {
|
} else {
|
||||||
@@ -971,6 +1020,7 @@ export class BookingTransitionService {
|
|||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: "OPERATION_REQUEST_PENDING",
|
status: "OPERATION_REQUEST_PENDING",
|
||||||
scheduledDate: date,
|
scheduledDate: date,
|
||||||
|
requestedTrainScheduleId: requestedId,
|
||||||
} as never);
|
} as never);
|
||||||
const fresh = await this.bookingsService.findById(bookingId);
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
this.notifier.operationRequestedToStaff(fresh);
|
this.notifier.operationRequestedToStaff(fresh);
|
||||||
@@ -988,6 +1038,35 @@ export class BookingTransitionService {
|
|||||||
* total covers the booking. `trainsForDay` is false when no departure carries
|
* total covers the booking. `trainsForDay` is false when no departure carries
|
||||||
* the leg — the day is unbookable regardless of space.
|
* 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(
|
async dayAvailabilityForBooking(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
scheduledDate: string,
|
scheduledDate: string,
|
||||||
|
|||||||
@@ -751,10 +751,24 @@ export class BookingsController {
|
|||||||
const booking = await this.transitionService.requestOperation(
|
const booking = await this.transitionService.requestOperation(
|
||||||
id,
|
id,
|
||||||
dto.scheduledDate,
|
dto.scheduledDate,
|
||||||
|
dto.trainScheduleId ?? null,
|
||||||
);
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
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")
|
@Post(":id/operation/review")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@@ -1271,6 +1285,20 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
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")
|
@Post(":id/consolidation")
|
||||||
@ApiOperation({ summary: "Request freight consolidation" })
|
@ApiOperation({ summary: "Request freight consolidation" })
|
||||||
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -1316,6 +1316,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.getMany();
|
.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. */
|
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
|
||||||
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
|
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||||
return this.repository
|
return this.repository
|
||||||
|
|||||||
@@ -602,6 +602,24 @@ export class BookingsService {
|
|||||||
return result.booking;
|
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. */
|
/** Create a new freight booking. */
|
||||||
async create(
|
async create(
|
||||||
dto: CreateBookingDto,
|
dto: CreateBookingDto,
|
||||||
@@ -664,6 +682,10 @@ export class BookingsService {
|
|||||||
companyId = company.id;
|
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) {
|
if (dto.trainScheduleId) {
|
||||||
// Staff manual pin: the schedule must be OPEN and on the same route.
|
// Staff manual pin: the schedule must be OPEN and on the same route.
|
||||||
const schedule = await this.dataSource
|
const schedule = await this.dataSource
|
||||||
@@ -857,6 +879,9 @@ export class BookingsService {
|
|||||||
cargoFreeText: dto.cargoFreeText,
|
cargoFreeText: dto.cargoFreeText,
|
||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
|
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,
|
isHazardous: dto.isHazardous ?? false,
|
||||||
// Bulk reefer is the customer's toggle; container reefer is derived from
|
// 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
|
// the container type at pricing time, so the booking-level flag stays off
|
||||||
@@ -1048,6 +1073,11 @@ export class BookingsService {
|
|||||||
...dto,
|
...dto,
|
||||||
freightType,
|
freightType,
|
||||||
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
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
|
// Booking-level reefer is only meaningful for bulk; container reefer is
|
||||||
// derived from the container type at pricing time.
|
// derived from the container type at pricing time.
|
||||||
isReefer:
|
isReefer:
|
||||||
|
|||||||
@@ -325,6 +325,21 @@ export class CreateBookingDto {
|
|||||||
@Transform(({ value }) => Number(value))
|
@Transform(({ value }) => Number(value))
|
||||||
cargoTotalWeightVgm!: number;
|
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 })
|
@ApiPropertyOptional({ default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
IsUUID,
|
||||||
Max,
|
Max,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
@@ -93,6 +94,17 @@ export class RequestOperationDto {
|
|||||||
})
|
})
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
scheduledDate!: string;
|
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 {
|
export class OperationReviewDto {
|
||||||
|
|||||||
@@ -365,6 +365,15 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'cargo_total_weight_vgm', type: 'numeric', precision: 12, scale: 3 })
|
@Column({ name: 'cargo_total_weight_vgm', type: 'numeric', precision: 12, scale: 3 })
|
||||||
cargoTotalWeightVgm!: number;
|
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 })
|
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||||
isHazardous!: boolean;
|
isHazardous!: boolean;
|
||||||
|
|
||||||
@@ -499,6 +508,18 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||||
trainScheduleId?: string | null;
|
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) ────────────────────────
|
// ── Per-booking journey (segment corridor bookings) ────────────────────────
|
||||||
// A booking rides only its own origin→destination leg of the train's route,
|
// 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
|
// so dispatch/arrival are per-booking facts, not train facts. Clearance gates
|
||||||
|
|||||||
@@ -109,6 +109,23 @@ export class ContractBookingService {
|
|||||||
private readonly bookingTransitionService: BookingTransitionService,
|
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(
|
async createUnderContract(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
dto: CreateBookingUnderContractDto,
|
dto: CreateBookingUnderContractDto,
|
||||||
@@ -169,6 +186,8 @@ export class ContractBookingService {
|
|||||||
// remainder; the customer cannot start any other booking on the contract.
|
// remainder; the customer cannot start any other booking on the contract.
|
||||||
// If the remainder splits again the same rule repeats until the cap is
|
// If the remainder splits again the same rule repeats until the cap is
|
||||||
// exhausted and the contract completes.
|
// exhausted and the contract completes.
|
||||||
|
await this.assertNoUnpaidHold(contract.companyId);
|
||||||
|
|
||||||
if (contract.contractKind === 'ONE_TIME') {
|
if (contract.contractKind === 'ONE_TIME') {
|
||||||
if (await this.hasSplitBooking(contractId)) {
|
if (await this.hasSplitBooking(contractId)) {
|
||||||
await this.assertExactRemainder(contract, dto);
|
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);
|
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { UnprocessableEntityException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
|
import type { Contract } from './entities/contract.entity';
|
||||||
|
import type { Rate } from '../rule-engine/entities/rate.entity';
|
||||||
|
|
||||||
|
const CT20 = 'ct-20';
|
||||||
|
const CT40 = 'ct-40';
|
||||||
|
const DCT = 'yard-dct';
|
||||||
|
const SEBETA = 'yard-sebeta';
|
||||||
|
const GMP = 'yard-gmp';
|
||||||
|
|
||||||
|
const rate = (over: Partial<Rate>): Rate =>
|
||||||
|
({
|
||||||
|
rateType: 'CONTAINER_IMPORT',
|
||||||
|
currency: 'USD',
|
||||||
|
rateValue: 1000,
|
||||||
|
rateUnit: 'PER_CONTAINER',
|
||||||
|
containerTypeId: null,
|
||||||
|
cargoTypeId: null,
|
||||||
|
originYardId: DCT,
|
||||||
|
destinationYardId: SEBETA,
|
||||||
|
...over,
|
||||||
|
}) as Rate;
|
||||||
|
|
||||||
|
const contract = (over: Partial<Contract>): Contract =>
|
||||||
|
({
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
customsClearingEnabled: false,
|
||||||
|
isHazardous: false,
|
||||||
|
isReefer: false,
|
||||||
|
routes: [{ originYardId: DCT, destinationYardId: SEBETA, sortOrder: 0 }],
|
||||||
|
cargoScope: [{ containerSize: '20ft' }],
|
||||||
|
...over,
|
||||||
|
}) as Contract;
|
||||||
|
|
||||||
|
const service = (liveRates: Rate[]): ContractPricingService =>
|
||||||
|
new ContractPricingService(
|
||||||
|
{} as never,
|
||||||
|
{ findLiveRates: async () => liveRates } as never,
|
||||||
|
{
|
||||||
|
findAll: async () => ({
|
||||||
|
items: [
|
||||||
|
{ id: CT20, sizeFt: 20 },
|
||||||
|
{ id: CT40, sizeFt: 40 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
|
{ getRate: async () => 1 } as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('contract base freight is priced on the contract lane only', () => {
|
||||||
|
it('prices from the contract route, never another lane (CTR-2026-00065)', async () => {
|
||||||
|
const breakdown = await service([
|
||||||
|
// Same size, other lane — the leak that priced DCT → Sebeta at GMP rates.
|
||||||
|
rate({ containerTypeId: CT20, destinationYardId: GMP, rateValue: 1690 }),
|
||||||
|
rate({ containerTypeId: CT20, rateValue: 750 }),
|
||||||
|
]).buildBreakdown(contract({}));
|
||||||
|
expect(breakdown.lineItems).toEqual([
|
||||||
|
expect.objectContaining({ code: 'CONTAINER_20FT', unitPrice: 750 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks the contract when its lane has no container rate', async () => {
|
||||||
|
await expect(
|
||||||
|
service([
|
||||||
|
rate({ containerTypeId: CT20, destinationYardId: GMP, rateValue: 1690 }),
|
||||||
|
]).buildBreakdown(contract({})),
|
||||||
|
).rejects.toThrow(UnprocessableEntityException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks bulk contracts too instead of borrowing an arbitrary rate', async () => {
|
||||||
|
const bulk = contract({ freightType: 'BULK', cargoScope: [] });
|
||||||
|
await expect(
|
||||||
|
service([
|
||||||
|
rate({
|
||||||
|
rateType: 'BULK_IMPORT',
|
||||||
|
rateUnit: 'PER_TON',
|
||||||
|
destinationYardId: GMP,
|
||||||
|
}),
|
||||||
|
]).buildBreakdown(bulk),
|
||||||
|
).rejects.toThrow(UnprocessableEntityException);
|
||||||
|
const priced = await service([
|
||||||
|
rate({ rateType: 'BULK_IMPORT', rateUnit: 'PER_TON', rateValue: 32 }),
|
||||||
|
]).buildBreakdown(bulk);
|
||||||
|
expect(priced.lineItems).toEqual([
|
||||||
|
expect.objectContaining({ code: 'BULK_FREIGHT', unitPrice: 32 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -84,6 +84,26 @@ export class ContractPricingService {
|
|||||||
const lineItems: ContractUnitRateLineItem[] = [];
|
const lineItems: ContractUnitRateLineItem[] = [];
|
||||||
const baseType = this.baseRateType(contract);
|
const baseType = this.baseRateType(contract);
|
||||||
|
|
||||||
|
// Base rail freight is quoted per route (CK_rates_yard_scope) — only rates
|
||||||
|
// on the contract's own lane may price it. Matching without the yard filter
|
||||||
|
// is how a DCT → Sebeta contract froze DCT → GMP (Indode) prices, and the
|
||||||
|
// frozen snapshot then bills bookings that the route-scoped booking lookup
|
||||||
|
// would have hard-blocked (CTR-2026-00065).
|
||||||
|
// ponytail: multi-route contracts price the first lane (same as customs
|
||||||
|
// clearance below); per-lane pricing needs per-route breakdowns.
|
||||||
|
const route = [...(contract.routes ?? [])].sort(
|
||||||
|
(a, b) => a.sortOrder - b.sortOrder,
|
||||||
|
)[0];
|
||||||
|
const onLane = route
|
||||||
|
? liveRates.filter(
|
||||||
|
(r) =>
|
||||||
|
r.rateType === baseType &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
r.originYardId === route.originYardId &&
|
||||||
|
r.destinationYardId === route.destinationYardId,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
if (contract.freightType === 'CONTAINER') {
|
if (contract.freightType === 'CONTAINER') {
|
||||||
const sizes = (contract.cargoScope ?? [])
|
const sizes = (contract.cargoScope ?? [])
|
||||||
.map((c) => c.containerSize)
|
.map((c) => c.containerSize)
|
||||||
@@ -97,17 +117,14 @@ export class ContractPricingService {
|
|||||||
const matchedTypes = containerTypes.filter((ct) => ct.sizeFt === sizeFt);
|
const matchedTypes = containerTypes.filter((ct) => ct.sizeFt === sizeFt);
|
||||||
const matchedIds = new Set(matchedTypes.map((ct) => ct.id));
|
const matchedIds = new Set(matchedTypes.map((ct) => ct.id));
|
||||||
const rate =
|
const rate =
|
||||||
liveRates.find(
|
onLane.find(
|
||||||
(r) =>
|
(r) => r.containerTypeId && matchedIds.has(r.containerTypeId),
|
||||||
r.rateType === baseType &&
|
) ?? onLane.find((r) => !r.containerTypeId);
|
||||||
r.currency === 'USD' &&
|
if (!rate || Number(rate.rateValue) <= 0) {
|
||||||
r.containerTypeId &&
|
throw new UnprocessableEntityException(
|
||||||
matchedIds.has(r.containerTypeId),
|
`No rail freight rate is configured for ${size} containers on this direction and route — the contract cannot be priced. Ask the rates team to set a live ${baseType} rate for this container type and origin → destination.`,
|
||||||
) ??
|
|
||||||
liveRates.find(
|
|
||||||
(r) => r.rateType === baseType && r.currency === 'USD' && !r.containerTypeId,
|
|
||||||
);
|
);
|
||||||
if (!rate) continue;
|
}
|
||||||
lineItems.push({
|
lineItems.push({
|
||||||
code: `CONTAINER_${size.toUpperCase()}`,
|
code: `CONTAINER_${size.toUpperCase()}`,
|
||||||
label: `${size} container`,
|
label: `${size} container`,
|
||||||
@@ -120,25 +137,26 @@ export class ContractPricingService {
|
|||||||
const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||||
// Freeze the rate for the contract's own commodity when one is configured
|
// Freeze the rate for the contract's own commodity when one is configured
|
||||||
// — a per-item machinery rate and a per-ton wheat rate live side by side.
|
// — a per-item machinery rate and a per-ton wheat rate live side by side.
|
||||||
const bulkRates = liveRates.filter(
|
// No arbitrary-rate fallback: another commodity's rate must never price
|
||||||
(r) => r.rateType === baseType && r.currency === 'USD',
|
// this contract.
|
||||||
);
|
|
||||||
const bulkRate =
|
const bulkRate =
|
||||||
(cargoScope?.cargoTypeId
|
(cargoScope?.cargoTypeId
|
||||||
? bulkRates.find((r) => r.cargoTypeId === cargoScope.cargoTypeId)
|
? onLane.find((r) => r.cargoTypeId === cargoScope.cargoTypeId)
|
||||||
: undefined) ??
|
: undefined) ??
|
||||||
bulkRates.find((r) => !r.cargoTypeId) ??
|
onLane.find((r) => !r.cargoTypeId) ??
|
||||||
bulkRates[0] ??
|
|
||||||
null;
|
null;
|
||||||
if (bulkRate) {
|
if (!bulkRate || Number(bulkRate.rateValue) <= 0) {
|
||||||
lineItems.push({
|
throw new UnprocessableEntityException(
|
||||||
code: 'BULK_FREIGHT',
|
'No bulk rail freight rate is configured for this cargo type on this direction and route — the contract cannot be priced. Ask the rates team to set a live rate for this commodity and origin → destination.',
|
||||||
label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo',
|
);
|
||||||
unit: toContractUnit(bulkRate.rateUnit),
|
|
||||||
unitPrice: convert(Number(bulkRate.rateValue)),
|
|
||||||
cargoTypeCode: cargoScope?.cargoType?.code ?? null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
lineItems.push({
|
||||||
|
code: 'BULK_FREIGHT',
|
||||||
|
label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo',
|
||||||
|
unit: toContractUnit(bulkRate.rateUnit),
|
||||||
|
unitPrice: convert(Number(bulkRate.rateValue)),
|
||||||
|
cargoTypeCode: cargoScope?.cargoType?.code ?? null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// First / last mile trucking unit rates — shown when the contract carries
|
// First / last mile trucking unit rates — shown when the contract carries
|
||||||
@@ -241,9 +259,6 @@ export class ContractPricingService {
|
|||||||
// one display line per contract size that has a configured rate. A size
|
// one display line per contract size that has a configured rate. A size
|
||||||
// with no rate shows nothing here and hard-blocks at booking time.
|
// with no rate shows nothing here and hard-blocks at booking time.
|
||||||
// ponytail: bookings bill the live route rate, not a frozen snapshot.
|
// ponytail: bookings bill the live route rate, not a frozen snapshot.
|
||||||
const route = [...(contract.routes ?? [])].sort(
|
|
||||||
(a, b) => a.sortOrder - b.sortOrder,
|
|
||||||
)[0];
|
|
||||||
const onLeg = route
|
const onLeg = route
|
||||||
? liveRates.filter(
|
? liveRates.filter(
|
||||||
(r) =>
|
(r) =>
|
||||||
@@ -291,9 +306,6 @@ export class ContractPricingService {
|
|||||||
if (contract.customsClearingEnabled) {
|
if (contract.customsClearingEnabled) {
|
||||||
// Strict, no route-less fallback.
|
// Strict, no route-less fallback.
|
||||||
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
|
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
|
||||||
const route = [...(contract.routes ?? [])].sort(
|
|
||||||
(a, b) => a.sortOrder - b.sortOrder,
|
|
||||||
)[0];
|
|
||||||
const onLeg = route
|
const onLeg = route
|
||||||
? liveRates.filter(
|
? liveRates.filter(
|
||||||
(r) =>
|
(r) =>
|
||||||
|
|||||||
@@ -31,6 +31,24 @@ export class PaymentClientService {
|
|||||||
return this.call("POST", "/payments/initiate", request);
|
return this.call("POST", "/payments/initiate", request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /payments/reconcile — settlement check for a domain order
|
||||||
|
* (reconcile-before-cancel). Live-queries every non-failed intent at the
|
||||||
|
* provider and registers any late capture found (flips it to SUCCEEDED and
|
||||||
|
* emits payment.succeeded). `unverifiable: true` = could not confirm
|
||||||
|
* "not paid" — the caller must NOT cancel/expire the order.
|
||||||
|
*/
|
||||||
|
async reconcileReference(
|
||||||
|
referenceType: PaymentReferenceType,
|
||||||
|
referenceId: string,
|
||||||
|
): Promise<{ paid: boolean; unverifiable: boolean }> {
|
||||||
|
return this.call("POST", "/payments/reconcile", {
|
||||||
|
service: PaymentService.FREIGHT,
|
||||||
|
referenceType,
|
||||||
|
referenceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** GET /payments/intents?… — active intent by domain reference; null when none exists. */
|
/** GET /payments/intents?… — active intent by domain reference; null when none exists. */
|
||||||
async getIntentByReference(
|
async getIntentByReference(
|
||||||
referenceType: PaymentReferenceType,
|
referenceType: PaymentReferenceType,
|
||||||
|
|||||||
@@ -188,6 +188,30 @@ export class PaymentService {
|
|||||||
* marked paid WITHOUT emitting — the caller (billing) settles inline after it
|
* marked paid WITHOUT emitting — the caller (billing) settles inline after it
|
||||||
* has stored the intent id, avoiding a settle-before-correlation race.
|
* has stored the intent id, avoiding a settle-before-correlation race.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Reconcile-before-cancel: ask the payment service whether ANY intent for
|
||||||
|
* this shipment actually settled at the provider (bank/gateway). A late
|
||||||
|
* capture found there is registered as SUCCEEDED and emits payment.succeeded,
|
||||||
|
* which drives the normal paid flow. A network/provider error reports
|
||||||
|
* `unverifiable` — the caller must not expire the order on unknown.
|
||||||
|
*/
|
||||||
|
async reconcileShipment(
|
||||||
|
referenceId: string,
|
||||||
|
): Promise<{ paid: boolean; unverifiable: boolean }> {
|
||||||
|
try {
|
||||||
|
const result = await this.paymentClient.reconcileReference(
|
||||||
|
PaymentReferenceType.SHIPMENT,
|
||||||
|
referenceId,
|
||||||
|
);
|
||||||
|
return { paid: result.paid, unverifiable: result.unverifiable };
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
return { paid: false, unverifiable: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Yard } from '../entities/yard.entity';
|
|||||||
export interface IYardsRepository {
|
export interface IYardsRepository {
|
||||||
findById(id: string): Promise<Yard | null>;
|
findById(id: string): Promise<Yard | null>;
|
||||||
findByCode(code: string): Promise<Yard | null>;
|
findByCode(code: string): Promise<Yard | null>;
|
||||||
|
findByLabelInsensitive(label: string): Promise<Yard | null>;
|
||||||
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]>;
|
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]>;
|
||||||
findAndCount(options?: FindManyOptions<Yard>): Promise<[Yard[], number]>;
|
findAndCount(options?: FindManyOptions<Yard>): Promise<[Yard[], number]>;
|
||||||
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>>;
|
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>>;
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ export class YardsRepository implements IYardsRepository {
|
|||||||
return this.repo.findOne({ where: { code } });
|
return this.repo.findOne({ where: { code } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Case/whitespace-insensitive label lookup — backs the duplicate-yard guard. */
|
||||||
|
findByLabelInsensitive(label: string): Promise<Yard | null> {
|
||||||
|
return this.repo
|
||||||
|
.createQueryBuilder('yard')
|
||||||
|
.where('LOWER(TRIM(yard.label)) = LOWER(TRIM(:label))', { label })
|
||||||
|
.andWhere('yard.deleted_at IS NULL')
|
||||||
|
.getOne();
|
||||||
|
}
|
||||||
|
|
||||||
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]> {
|
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]> {
|
||||||
return this.repo.find(options);
|
return this.repo.find(options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ConflictException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { YardsService } from './yards.service';
|
||||||
|
import type { Yard } from '../entities/yard.entity';
|
||||||
|
|
||||||
|
const sebeta = { id: 'yard-1', code: 'LEGACY_DEST', label: 'Sebeta' } as Yard;
|
||||||
|
|
||||||
|
const service = (): YardsService =>
|
||||||
|
new YardsService(
|
||||||
|
{
|
||||||
|
findById: async (id: string) => ({ ...sebeta, id }),
|
||||||
|
findByCode: async () => null,
|
||||||
|
findByLabelInsensitive: async (label: string) =>
|
||||||
|
label.trim().toLowerCase() === 'sebeta' ? sebeta : null,
|
||||||
|
create: async (d: Partial<Yard>) => d as Yard,
|
||||||
|
update: async (_id: string, d: Partial<Yard>) => d as Yard,
|
||||||
|
} as never,
|
||||||
|
{ resolveCreateOrder: async () => 1 } as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('duplicate yard labels are rejected', () => {
|
||||||
|
it('blocks create even when the generated code differs (Sebeta vs LEGACY_DEST)', async () => {
|
||||||
|
await expect(
|
||||||
|
service().create({ label: ' sebeta ', country: 'ET' } as never),
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks renaming a yard onto another yard label, allows renaming itself', async () => {
|
||||||
|
await expect(
|
||||||
|
service().update('yard-2', { label: 'SEBETA' } as never),
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
await expect(
|
||||||
|
service().update('yard-1', { label: 'Sebeta' } as never),
|
||||||
|
).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,6 +31,9 @@ export class YardsService {
|
|||||||
|
|
||||||
/** Create a yard. */
|
/** Create a yard. */
|
||||||
async create(dto: CreateYardDto): Promise<Yard> {
|
async create(dto: CreateYardDto): Promise<Yard> {
|
||||||
|
// Label check first: the code check alone let "sebeta" in next to "Sebeta"
|
||||||
|
// when the existing yard's code didn't match its label (LEGACY_DEST).
|
||||||
|
await this.assertLabelAvailable(dto.label);
|
||||||
const code = generateCode(dto.label).slice(0, 40);
|
const code = generateCode(dto.label).slice(0, 40);
|
||||||
const existing = await this.repository.findByCode(code);
|
const existing = await this.repository.findByCode(code);
|
||||||
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`);
|
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`);
|
||||||
@@ -53,11 +56,20 @@ export class YardsService {
|
|||||||
/** Update a yard. */
|
/** Update a yard. */
|
||||||
async update(id: string, dto: UpdateYardDto): Promise<Yard> {
|
async update(id: string, dto: UpdateYardDto): Promise<Yard> {
|
||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
|
if (dto.label !== undefined) await this.assertLabelAvailable(dto.label, id);
|
||||||
const updated = await this.repository.update(id, dto);
|
const updated = await this.repository.update(id, dto);
|
||||||
if (!updated) throw new NotFoundException(`Yard ${id} not found`);
|
if (!updated) throw new NotFoundException(`Yard ${id} not found`);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** No two active yards may share a label (case/whitespace-insensitive). */
|
||||||
|
private async assertLabelAvailable(label: string, exceptId?: string): Promise<void> {
|
||||||
|
const dupe = await this.repository.findByLabelInsensitive(label);
|
||||||
|
if (dupe && dupe.id !== exceptId) {
|
||||||
|
throw new ConflictException(`A yard named "${dupe.label}" already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Soft-delete a yard. The unique `code` (and the label) get a `@<epoch-ms>`
|
* Soft-delete a yard. The unique `code` (and the label) get a `@<epoch-ms>`
|
||||||
* suffix first — e.g. SEBETA → SEBETA@1755612345678 — so a new yard with the
|
* suffix first — e.g. SEBETA → SEBETA@1755612345678 — so a new yard with the
|
||||||
|
|||||||
@@ -153,6 +153,14 @@ export class TrainSchedule extends BaseEntity {
|
|||||||
@Column({ name: 'rule_reopen_delay_minutes', type: 'int', nullable: true })
|
@Column({ name: 'rule_reopen_delay_minutes', type: 'int', nullable: true })
|
||||||
ruleReopenDelayMinutes?: number | null;
|
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 })
|
@Column({ name: 'rule_import_window_lead_days', type: 'int', nullable: true })
|
||||||
ruleImportWindowLeadDays?: number | null;
|
ruleImportWindowLeadDays?: number | null;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
|
|
||||||
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
|
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
|
||||||
|
|
||||||
|
/** How long before the pay deadline the one reminder notification goes out. */
|
||||||
|
export const PAYMENT_REMINDER_LEAD_MS = 10 * 60_000;
|
||||||
|
|
||||||
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
|
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
|
||||||
export const DEFAULT_WAGONS_PER_BOOKING = 1;
|
export const DEFAULT_WAGONS_PER_BOOKING = 1;
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
windowDurationHours: 3,
|
windowDurationHours: 3,
|
||||||
docReviewMinutes: 30,
|
docReviewMinutes: 30,
|
||||||
paymentWindowMinutes: 60,
|
paymentWindowMinutes: 60,
|
||||||
|
exportPaymentWindowMinutes: 60,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,6 +151,8 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
{
|
{
|
||||||
issuePayable: jest.fn().mockResolvedValue(null),
|
issuePayable: jest.fn().mockResolvedValue(null),
|
||||||
expirePayable: jest.fn().mockResolvedValue(undefined),
|
expirePayable: jest.fn().mockResolvedValue(undefined),
|
||||||
|
// Gateway reconcile-before-expire: default = verifiably unpaid.
|
||||||
|
reconcilePayable: jest.fn().mockResolvedValue({ paid: false, unverifiable: false }),
|
||||||
} as never,
|
} as never,
|
||||||
{ emitPhase: jest.fn() } as never,
|
{ emitPhase: jest.fn() } as never,
|
||||||
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
||||||
@@ -708,7 +711,13 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
notifier as never,
|
notifier as never,
|
||||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||||
trainSchedulingService as never,
|
trainSchedulingService as never,
|
||||||
{ issuePayable: jest.fn(), expirePayable: jest.fn() } as never,
|
{
|
||||||
|
issuePayable: jest.fn(),
|
||||||
|
expirePayable: jest.fn(),
|
||||||
|
reconcilePayable: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ paid: false, unverifiable: false }),
|
||||||
|
} as never,
|
||||||
{ emitPhase: jest.fn() } as never,
|
{ emitPhase: jest.fn() } as never,
|
||||||
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -731,7 +740,13 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
notifier as never,
|
notifier as never,
|
||||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||||
trainSchedulingService as never,
|
trainSchedulingService as never,
|
||||||
{ issuePayable: jest.fn(), expirePayable: jest.fn() } as never,
|
{
|
||||||
|
issuePayable: jest.fn(),
|
||||||
|
expirePayable: jest.fn(),
|
||||||
|
reconcilePayable: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ paid: false, unverifiable: false }),
|
||||||
|
} as never,
|
||||||
{ emitPhase: jest.fn() } as never,
|
{ emitPhase: jest.fn() } as never,
|
||||||
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -762,7 +777,13 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
notifier as never,
|
notifier as never,
|
||||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||||
trainSchedulingService as never,
|
trainSchedulingService as never,
|
||||||
{ issuePayable: jest.fn(), expirePayable: jest.fn() } as never,
|
{
|
||||||
|
issuePayable: jest.fn(),
|
||||||
|
expirePayable: jest.fn(),
|
||||||
|
reconcilePayable: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ paid: false, unverifiable: false }),
|
||||||
|
} as never,
|
||||||
{ emitPhase: jest.fn() } as never,
|
{ emitPhase: jest.fn() } as never,
|
||||||
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -1015,7 +1036,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
...(waiting as unknown as Record<string, unknown>),
|
...(waiting as unknown as Record<string, unknown>),
|
||||||
status: 'SELECTED_FOR_BATCH',
|
status: 'SELECTED_FOR_BATCH',
|
||||||
trainScheduleId: exportScheduleId,
|
trainScheduleId: exportScheduleId,
|
||||||
paymentDeadline: new Date(Date.now() - 1_000),
|
paymentDeadline: new Date(Date.now() - 60_000),
|
||||||
originYardId: 'yard-a',
|
originYardId: 'yard-a',
|
||||||
destinationYardId: 'yard-b',
|
destinationYardId: 'yard-b',
|
||||||
priorityScore: 0,
|
priorityScore: 0,
|
||||||
|
|||||||
@@ -61,11 +61,13 @@ import {
|
|||||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||||
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||||
DEFAULT_WAGONS_PER_BOOKING,
|
DEFAULT_WAGONS_PER_BOOKING,
|
||||||
|
PAYMENT_REMINDER_LEAD_MS,
|
||||||
} from "./booking-batch.constants";
|
} from "./booking-batch.constants";
|
||||||
import {
|
import {
|
||||||
LocomotiveLimits,
|
LocomotiveLimits,
|
||||||
WagonTypeDimensions,
|
WagonTypeDimensions,
|
||||||
bookingCargoTons,
|
bookingCargoTons,
|
||||||
|
bulkItemWagonsRequired,
|
||||||
bookingGrossWeightTons,
|
bookingGrossWeightTons,
|
||||||
deriveTrainCapacityFromLocomotive,
|
deriveTrainCapacityFromLocomotive,
|
||||||
sizePartialOfferWagons,
|
sizePartialOfferWagons,
|
||||||
@@ -115,6 +117,32 @@ export interface ExportSpaceReport {
|
|||||||
fullMessage: string | null;
|
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. */
|
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
||||||
interface RouteDayGroup {
|
interface RouteDayGroup {
|
||||||
originYardId: string;
|
originYardId: string;
|
||||||
@@ -663,12 +691,16 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
{ 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
|
const candidates = corridor
|
||||||
.filter(
|
.filter(
|
||||||
(s) =>
|
(s) =>
|
||||||
s.scheduledDepartureDate != null &&
|
s.scheduledDepartureDate != null &&
|
||||||
eatDay(s.scheduledDepartureDate) === day &&
|
eatDay(s.scheduledDepartureDate) === day &&
|
||||||
this.isFillable(s),
|
this.isFillable(s) &&
|
||||||
|
(!requestedId || s.id === requestedId),
|
||||||
)
|
)
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
@@ -755,13 +787,18 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
/** Customer-facing "train is full" copy carrying the bookable leftover. */
|
/** Customer-facing "train is full" copy carrying the bookable leftover. */
|
||||||
private exportFullMessage(booking: Booking, report: ExportSpaceReport): string {
|
private exportFullMessage(booking: Booking, report: ExportSpaceReport): string {
|
||||||
|
const picked = Boolean(booking.requestedTrainScheduleId);
|
||||||
if (!report.trainsForDay || !report.corridorMatched) {
|
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 best = report.bestAvailable;
|
||||||
const base =
|
const base = picked
|
||||||
'Not enough train space — an export booking must ride a single train whole, ' +
|
? 'Not enough space left on the selected train — an export booking must ' +
|
||||||
'and no open train on this day can carry it. ';
|
'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) {
|
if (!best || best.wagons <= 0) {
|
||||||
return base + 'No capacity is left on this day — pick another shipment day.';
|
return base + 'No capacity is left on this day — pick another shipment day.';
|
||||||
}
|
}
|
||||||
@@ -864,6 +901,88 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return out;
|
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,
|
* 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
|
* summed across every train on the booking's corridor that day. Unlike the
|
||||||
@@ -1286,6 +1405,31 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
(s.scheduleBookings ?? []).map((l) => l.bookingId),
|
(s.scheduleBookings ?? []).map((l) => l.bookingId),
|
||||||
);
|
);
|
||||||
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
|
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
|
||||||
|
// Under day-level pooling a booking is only pinned to a schedule by
|
||||||
|
// reserve() — until then its train_schedule_id is NULL and the query above
|
||||||
|
// misses it. Merge in the corridor-day candidates so staff see the whole
|
||||||
|
// waiting pool (the 7 that lost the batch), not just the winners. These are
|
||||||
|
// display-only candidates: they are excluded from the capacity meters below.
|
||||||
|
const pinnedIds = new Set(bookings.map((b) => b.id));
|
||||||
|
if (s.scheduledDepartureDate) {
|
||||||
|
try {
|
||||||
|
const stops = await this.stopsForSchedule(s);
|
||||||
|
const candidates =
|
||||||
|
await this.bookingsRepository.findBatchPoolByCorridorDay(
|
||||||
|
stops,
|
||||||
|
eatDay(s.scheduledDepartureDate),
|
||||||
|
);
|
||||||
|
for (const b of candidates) {
|
||||||
|
if (!pinnedIds.has(b.id)) bookings.push(b);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// The board must still render the pinned bookings.
|
||||||
|
this.logger.warn(
|
||||||
|
`Corridor-day candidate merge failed for schedule ${s.id}: ` +
|
||||||
|
`${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let allocationPreview: Awaited<
|
let allocationPreview: Awaited<
|
||||||
ReturnType<TrainSchedulingService["previewAllocationForSchedule"]>
|
ReturnType<TrainSchedulingService["previewAllocationForSchedule"]>
|
||||||
@@ -1437,7 +1581,13 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
|
// Capacity holds come from bookings actually pinned to this train —
|
||||||
|
// unpinned day-pool candidates are shown in the lists but hold nothing.
|
||||||
|
capacity: this.computeBoardCapacity(
|
||||||
|
items.filter((i) => pinnedIds.has(i.id)),
|
||||||
|
loco,
|
||||||
|
s.maxWagons ?? null,
|
||||||
|
),
|
||||||
counts: {
|
counts: {
|
||||||
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
||||||
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
||||||
@@ -2176,7 +2326,10 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
if (!this.fits(offeredNeed, budget)) return null;
|
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);
|
await this.splitService.createOffer(booking, scheduleId, sized, deadline);
|
||||||
// Reserve like a normal batch selection, but the partial invoice + partial
|
// Reserve like a normal batch selection, but the partial invoice + partial
|
||||||
// pay-now notification were already produced by createOffer.
|
// pay-now notification were already produced by createOffer.
|
||||||
@@ -2185,6 +2338,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
status: "SELECTED_FOR_BATCH",
|
status: "SELECTED_FOR_BATCH",
|
||||||
selectedForBatchAt: new Date(),
|
selectedForBatchAt: new Date(),
|
||||||
paymentDeadline: deadline,
|
paymentDeadline: deadline,
|
||||||
|
paymentReminderSentAt: null,
|
||||||
} as never);
|
} as never);
|
||||||
booking.trainScheduleId = scheduleId;
|
booking.trainScheduleId = scheduleId;
|
||||||
return offeredNeed;
|
return offeredNeed;
|
||||||
@@ -2213,6 +2367,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
const isPaid = (b: Booking) =>
|
const isPaid = (b: Booking) =>
|
||||||
b.paymentStatus === "PAID" || b.status === "PAID";
|
b.paymentStatus === "PAID" || b.status === "PAID";
|
||||||
|
// Deadline is the line — no fixed slack. A payment that beat the deadline
|
||||||
|
// but whose webhook is late is caught by expire()'s gateway reconcile.
|
||||||
const isExpired = (b: Booking) =>
|
const isExpired = (b: Booking) =>
|
||||||
b.paymentDeadline
|
b.paymentDeadline
|
||||||
? b.paymentDeadline.getTime() <= now
|
? b.paymentDeadline.getTime() <= now
|
||||||
@@ -2513,6 +2669,39 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
this.notifyBoardChanged(newScheduleId, "booking_moved");
|
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). */
|
/** Staff "expire" override → free a reservation now (booking becomes EXPIRED). */
|
||||||
async expireReservation(bookingId: string): Promise<void> {
|
async expireReservation(bookingId: string): Promise<void> {
|
||||||
const booking = await this.dataSource
|
const booking = await this.dataSource
|
||||||
@@ -2533,6 +2722,52 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer cancel of an unpaid hold: the same immediate release as
|
||||||
|
* expireReservation, but the booking ends CANCELLED (the customer chose to
|
||||||
|
* walk away — "payment window missed" copy would be wrong). Consolidated
|
||||||
|
* pairs are rejected by the caller: the shared wagon is both-or-neither.
|
||||||
|
*/
|
||||||
|
async cancelReservation(bookingId: string): Promise<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 ---------------------------------------------
|
// ---- intercity ride-along API ---------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2617,14 +2852,14 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const now = new Date();
|
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
|
// EXPORT parity: pay windows on an export train never outlive its booking
|
||||||
// window — export bookings expire at close, so anything reserved onto the
|
// window — export bookings expire at close, so anything reserved onto the
|
||||||
// same train (FCFS export or an intercity ride-along) must too. Import
|
// same train (FCFS export or an intercity ride-along) must too. Import
|
||||||
// keeps the plain payment window; its cycles re-fill after settle.
|
// 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") {
|
if (targetSchedule?.direction === "EXPORT") {
|
||||||
const cutoff =
|
const cutoff =
|
||||||
targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate;
|
targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate;
|
||||||
@@ -2642,6 +2877,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
status: "SELECTED_FOR_BATCH",
|
status: "SELECTED_FOR_BATCH",
|
||||||
selectedForBatchAt: now,
|
selectedForBatchAt: now,
|
||||||
paymentDeadline: deadline,
|
paymentDeadline: deadline,
|
||||||
|
paymentReminderSentAt: null,
|
||||||
} as never);
|
} as never);
|
||||||
booking.trainScheduleId = scheduleId;
|
booking.trainScheduleId = scheduleId;
|
||||||
// The invoice was generated DRAFT at booking creation / operation-accept,
|
// The invoice was generated DRAFT at booking creation / operation-accept,
|
||||||
@@ -2825,14 +3061,42 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Reconcile-before-expire (only when a pay window was actually open):
|
||||||
|
// no webhook arrived, so ask the gateway DIRECTLY whether the money
|
||||||
|
// landed. A late capture found there is registered as SUCCEEDED and
|
||||||
|
// emits payment.succeeded — that event marks the booking PAID and
|
||||||
|
// allocates it, so we just leave the hold alone here. `unverifiable`
|
||||||
|
// (provider query errored / payment still in flight) means we could not
|
||||||
|
// confirm "not paid" — never expire on unknown; the next settle tick
|
||||||
|
// asks again.
|
||||||
|
if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) {
|
||||||
|
const reconcile = await this.billing.reconcilePayable(booking.id);
|
||||||
|
if (reconcile.paid) {
|
||||||
|
this.logger.log(
|
||||||
|
`[BATCH] expire skipped for ${booking.reference} — gateway ` +
|
||||||
|
`reconcile found a settled payment; payment.succeeded will allocate it`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reconcile.unverifiable) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[BATCH] expire deferred for ${booking.reference} — settlement ` +
|
||||||
|
`unverifiable at the gateway; retrying next settle tick`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const freedScheduleId = booking.trainScheduleId;
|
const freedScheduleId = booking.trainScheduleId;
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
trainScheduleId: null,
|
trainScheduleId: null,
|
||||||
|
// The customer's train pick died with the hold — a rebook re-picks.
|
||||||
|
requestedTrainScheduleId: null,
|
||||||
status: "EXPIRED",
|
status: "EXPIRED",
|
||||||
schedulingStatus: "ELIGIBLE",
|
schedulingStatus: "ELIGIBLE",
|
||||||
paymentDeadline: null,
|
paymentDeadline: null,
|
||||||
selectedForBatchAt: null,
|
selectedForBatchAt: null,
|
||||||
|
paymentReminderSentAt: null,
|
||||||
} as never);
|
} as never);
|
||||||
booking.trainScheduleId = null;
|
booking.trainScheduleId = null;
|
||||||
// The wagons this reservation held are back — a schedule parked at FULL
|
// The wagons this reservation held are back — a schedule parked at FULL
|
||||||
@@ -3375,7 +3639,11 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const byWeight =
|
const byWeight =
|
||||||
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
|
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
|
||||||
|
|
||||||
return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight);
|
// Break-bulk (PER_ITEM): indivisible items can need more wagons than raw
|
||||||
|
// tonnage suggests (floor items-per-wagon loses the fractional capacity).
|
||||||
|
const byItems = bulkItemWagonsRequired(booking, capacityTons);
|
||||||
|
|
||||||
|
return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight, byItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3571,6 +3839,18 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
* representative dims when no allowed type is configured.
|
* representative dims when no allowed type is configured.
|
||||||
*/
|
*/
|
||||||
private dimsForAllowed(booking: Booking, wagonDims: WagonDims): PerWagonDims[] {
|
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 =
|
const fallback =
|
||||||
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
|
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
|
||||||
const ids =
|
const ids =
|
||||||
@@ -3580,19 +3860,23 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
.flatMap((line) => line.containerType?.wagonTypes ?? [])
|
.flatMap((line) => line.containerType?.wagonTypes ?? [])
|
||||||
.map((wt) => wt.id);
|
.map((wt) => wt.id);
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const dims: PerWagonDims[] = [];
|
const out: Array<{ wagonTypeId: string | null; dims: PerWagonDims }> = [];
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
if (!id || seen.has(id)) continue;
|
if (!id || seen.has(id)) continue;
|
||||||
seen.add(id);
|
seen.add(id);
|
||||||
const d = wagonDims.byWagonTypeId.get(id);
|
const d = wagonDims.byWagonTypeId.get(id);
|
||||||
if (d) {
|
if (d) {
|
||||||
dims.push({
|
out.push({
|
||||||
...d,
|
wagonTypeId: id,
|
||||||
capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons,
|
dims: {
|
||||||
|
...d,
|
||||||
|
capacityTons:
|
||||||
|
d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return dims.length ? dims : [fallback];
|
return out.length ? out : [{ wagonTypeId: null, dims: fallback }];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3777,8 +4061,20 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const allocated = (schedule.scheduleBookings ?? [])
|
const allocated = (schedule.scheduleBookings ?? [])
|
||||||
.map((sb) => sb.booking)
|
.map((sb) => sb.booking)
|
||||||
.filter((b): b is Booking => Boolean(b));
|
.filter((b): b is Booking => Boolean(b));
|
||||||
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
// Lazy-expiry guard: a hold whose deadline lapsed no longer blocks
|
||||||
schedule.id,
|
// capacity, even before the 10s sweep flips it to EXPIRED — availability
|
||||||
|
// shown to the next customer is honest between ticks. A late capture the
|
||||||
|
// gateway reconcile later confirms lands as PAID and, if the wagons went
|
||||||
|
// meanwhile, degrades to WAITING_FOR_WAGON for manual placement.
|
||||||
|
const deadlineCutoff = Date.now();
|
||||||
|
const reserved = (
|
||||||
|
await this.bookingsRepository.findReservedForSchedule(schedule.id)
|
||||||
|
).filter(
|
||||||
|
(b) =>
|
||||||
|
b.paymentStatus === "PAID" ||
|
||||||
|
b.status === "PAID" ||
|
||||||
|
b.paymentDeadline == null ||
|
||||||
|
b.paymentDeadline.getTime() > deadlineCutoff,
|
||||||
);
|
);
|
||||||
for (const b of [...allocated, ...reserved]) {
|
for (const b of [...allocated, ...reserved]) {
|
||||||
budget.subtract(
|
budget.subtract(
|
||||||
@@ -4058,10 +4354,33 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
// ---- timer plumbing -------------------------------------------------------
|
// ---- 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();
|
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 {
|
private timeoutName(scheduleId: string): string {
|
||||||
@@ -4073,8 +4392,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
* engine's minute tick calling settleDueReservations off `paymentDeadline`.
|
* engine's minute tick calling settleDueReservations off `paymentDeadline`.
|
||||||
*/
|
*/
|
||||||
private armSettle(scheduleId: string): void {
|
private armSettle(scheduleId: string): void {
|
||||||
void this.paymentWindowMs()
|
void this.scheduleById(scheduleId)
|
||||||
.then((delayMs) => {
|
.then((schedule) => this.paymentWindowMsFor(schedule))
|
||||||
|
.then((delayMs: number) => {
|
||||||
this.removeTimeout(scheduleId);
|
this.removeTimeout(scheduleId);
|
||||||
const handle = setTimeout(() => {
|
const handle = setTimeout(() => {
|
||||||
void this.settleBatch(scheduleId).catch((err) =>
|
void this.settleBatch(scheduleId).catch((err) =>
|
||||||
@@ -4107,7 +4427,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
.getRepository(TrainSchedule)
|
.getRepository(TrainSchedule)
|
||||||
.findOne({ where: { id: scheduleId } });
|
.findOne({ where: { id: scheduleId } });
|
||||||
if (!schedule || schedule.windowPhase !== "PAYMENT") return;
|
if (!schedule || schedule.windowPhase !== "PAYMENT") return;
|
||||||
const windowMs = await this.paymentWindowMs();
|
const windowMs = await this.paymentWindowMsFor(schedule);
|
||||||
let target = new Date(Date.now() + windowMs);
|
let target = new Date(Date.now() + windowMs);
|
||||||
if (
|
if (
|
||||||
schedule.scheduledDepartureDate &&
|
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
|
* Partial-capacity offer: only `offeredWagons` of the booking's `totalWagons` fit
|
||||||
* this train. Paying accepts the split; letting the deadline pass keeps the
|
* this train. Paying accepts the split; letting the deadline pass keeps the
|
||||||
|
|||||||
@@ -72,7 +72,12 @@ describe('BookingSplitService — applySplit split marking', () => {
|
|||||||
dataSource as never,
|
dataSource as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{ expirePayable: jest.fn() } as never,
|
{
|
||||||
|
expirePayable: jest.fn(),
|
||||||
|
reconcilePayable: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ paid: false, unverifiable: false }),
|
||||||
|
} as never,
|
||||||
{ payNowPartial: jest.fn() } as never,
|
{ payNowPartial: jest.fn() } as never,
|
||||||
);
|
);
|
||||||
return { service, bookingRepo, contractRepo };
|
return { service, bookingRepo, contractRepo };
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ export interface BookingWindowConfig {
|
|||||||
windowDurationHours: number;
|
windowDurationHours: number;
|
||||||
/** Max staff document-review time after the window closes. */
|
/** Max staff document-review time after the window closes. */
|
||||||
docReviewMinutes: number;
|
docReviewMinutes: number;
|
||||||
|
/** Pay window for IMPORT/DOMESTIC bookings (also part of the reopen gap). */
|
||||||
paymentWindowMinutes: number;
|
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
|
* Minutes before departure the IMPORT/DOMESTIC booking window shuts. When set
|
||||||
* (> 0), the effective booking cutoff is `departure − this`, capping the first
|
* (> 0), the effective booking cutoff is `departure − this`, capping the first
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ describe('BookingWindowService — window state machine', () => {
|
|||||||
windowDurationHours: 1,
|
windowDurationHours: 1,
|
||||||
docReviewMinutes: 30,
|
docReviewMinutes: 30,
|
||||||
paymentWindowMinutes: 60,
|
paymentWindowMinutes: 60,
|
||||||
|
exportPaymentWindowMinutes: 60,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseSchedule = (over: Partial<TrainSchedule>): TrainSchedule =>
|
const baseSchedule = (over: Partial<TrainSchedule>): TrainSchedule =>
|
||||||
|
|||||||
@@ -141,6 +141,13 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
|
|
||||||
await this.settleOverdueReservations();
|
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
|
// Legacy fill (DOMESTIC / pre-migration schedules) every 5 minutes
|
||||||
// (30 ticks at the 10-second cadence).
|
// (30 ticks at the 10-second cadence).
|
||||||
this.tickCount += 1;
|
this.tickCount += 1;
|
||||||
@@ -595,6 +602,8 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
.createQueryBuilder('b')
|
.createQueryBuilder('b')
|
||||||
.select('DISTINCT b.train_schedule_id', 'scheduleId')
|
.select('DISTINCT b.train_schedule_id', 'scheduleId')
|
||||||
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
||||||
|
// Deadline is the line — expire() itself reconciles against the gateway
|
||||||
|
// before actually expiring, so a late in-window payment is still caught.
|
||||||
.andWhere('b.payment_deadline <= now()')
|
.andWhere('b.payment_deadline <= now()')
|
||||||
.andWhere('b.train_schedule_id IS NOT NULL')
|
.andWhere('b.train_schedule_id IS NOT NULL')
|
||||||
.getRawMany<{ scheduleId: string }>();
|
.getRawMany<{ scheduleId: string }>();
|
||||||
|
|||||||
@@ -61,13 +61,20 @@ export class UpdateTrainSchedulingGlobalRulesDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
docReviewMinutes?: number;
|
docReviewMinutes?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 60 })
|
@ApiPropertyOptional({ example: 60, description: 'IMPORT/DOMESTIC customer pay window, minutes' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
paymentWindowMinutes?: number;
|
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
|
// Booking-close offsets: minutes before departure the window shuts. The UI
|
||||||
// enters days/hours/minutes and converts to minutes. 0 or null clears the
|
// enters days/hours/minutes and converts to minutes. 0 or null clears the
|
||||||
// offset (close at departure). Nullable so it can be explicitly cleared.
|
// 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 })
|
@Column({ name: 'doc_review_minutes', type: 'int', default: 30 })
|
||||||
docReviewMinutes!: number;
|
docReviewMinutes!: number;
|
||||||
|
|
||||||
|
/** Pay window for IMPORT/DOMESTIC bookings (also feeds the window reopen delay). */
|
||||||
@Column({ name: 'payment_window_minutes', type: 'int', default: 60 })
|
@Column({ name: 'payment_window_minutes', type: 'int', default: 60 })
|
||||||
paymentWindowMinutes!: number;
|
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,
|
* Minutes before departure the IMPORT/DOMESTIC booking window shuts. When set,
|
||||||
* the window's close (first cycle and every reopen) is capped at
|
* 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 { Booking } from '../bookings/entities/booking.entity';
|
||||||
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import {
|
import {
|
||||||
@@ -51,8 +51,12 @@ export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
|
|||||||
|
|
||||||
export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: number): number {
|
export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: number): number {
|
||||||
if (booking.freightType === 'BULK') {
|
if (booking.freightType === 'BULK') {
|
||||||
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
|
||||||
const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1;
|
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));
|
return Math.max(1, Math.ceil(weight / capacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
bookingCargoTons,
|
||||||
bookingGrossWeightTons,
|
bookingGrossWeightTons,
|
||||||
bookingTrainLengthMeters,
|
bookingTrainLengthMeters,
|
||||||
|
bulkItemWagonsRequired,
|
||||||
consistUsage,
|
consistUsage,
|
||||||
consistViolations,
|
consistViolations,
|
||||||
deriveTrainCapacityFromLocomotive,
|
deriveTrainCapacityFromLocomotive,
|
||||||
@@ -30,6 +32,66 @@ describe('train-capacity.util', () => {
|
|||||||
cargoTons,
|
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', () => {
|
describe('deriveTrainCapacityFromLocomotive', () => {
|
||||||
it('derives wagon slots from train length, not a fixed 53', () => {
|
it('derives wagon slots from train length, not a fixed 53', () => {
|
||||||
const shortLoco = deriveTrainCapacityFromLocomotive(
|
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
|
* its container lines (quantity × VGM per unit). The portal's container flow
|
||||||
* stores per-line VGM and leaves `cargoTotalWeightVgm` at 0 — reading the
|
* stores per-line VGM and leaves `cargoTotalWeightVgm` at 0 — reading the
|
||||||
* total alone made every such booking weigh only its tare.
|
* 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: {
|
export function bookingCargoTons(booking: {
|
||||||
cargoTotalWeightVgm?: number | string | null;
|
cargoTotalWeightVgm?: number | string | null;
|
||||||
|
bulkTotalWeightTons?: number | string | null;
|
||||||
bookingContainers?: Array<{
|
bookingContainers?: Array<{
|
||||||
quantity?: number | null;
|
quantity?: number | null;
|
||||||
vgmPerUnitTons?: number | string | null;
|
vgmPerUnitTons?: number | string | null;
|
||||||
}> | null;
|
}> | null;
|
||||||
}): number {
|
}): number {
|
||||||
|
const itemTons = num(booking.bulkTotalWeightTons);
|
||||||
|
if (itemTons > 0) return itemTons;
|
||||||
const total = num(booking.cargoTotalWeightVgm);
|
const total = num(booking.cargoTotalWeightVgm);
|
||||||
if (total > 0) return total;
|
if (total > 0) return total;
|
||||||
return (booking.bookingContainers ?? []).reduce(
|
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. */
|
/** Gross weight of one loaded wagon: it hauls itself plus its cargo. */
|
||||||
export function grossWagonWeightTons(slot: Pick<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number {
|
export function grossWagonWeightTons(slot: Pick<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number {
|
||||||
return num(slot.tareWeightTons) + num(slot.cargoTons);
|
return num(slot.tareWeightTons) + num(slot.cargoTons);
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ export function effectiveWindowConfig(
|
|||||||
ruleWindowCloseHour?: number | null;
|
ruleWindowCloseHour?: number | null;
|
||||||
ruleWindowDurationHours?: number | null;
|
ruleWindowDurationHours?: number | null;
|
||||||
ruleReopenDelayMinutes?: number | null;
|
ruleReopenDelayMinutes?: number | null;
|
||||||
|
rulePaymentWindowMinutes?: number | null;
|
||||||
ruleImportWindowLeadDays?: number | null;
|
ruleImportWindowLeadDays?: number | null;
|
||||||
ruleExportBookingLeadHours?: number | null;
|
ruleExportBookingLeadHours?: number | null;
|
||||||
ruleImportCloseOffsetMinutes?: number | null;
|
ruleImportCloseOffsetMinutes?: number | null;
|
||||||
@@ -232,7 +233,14 @@ export function effectiveWindowConfig(
|
|||||||
? Number(schedule.ruleWindowDurationHours)
|
? Number(schedule.ruleWindowDurationHours)
|
||||||
: liveCfg.windowDurationHours,
|
: liveCfg.windowDurationHours,
|
||||||
docReviewMinutes: liveCfg.docReviewMinutes,
|
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
|
// 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
|
// "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
|
// 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.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
|
||||||
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
|
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
|
||||||
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
|
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.
|
// Store 0 as null so "no offset" is a single canonical value.
|
||||||
if (dto.importCloseOffsetMinutes !== undefined)
|
if (dto.importCloseOffsetMinutes !== undefined)
|
||||||
row.importCloseOffsetMinutes = dto.importCloseOffsetMinutes || null;
|
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
|
// The reopen gap is doc review + payment; keep the config values unless the
|
||||||
// override changes them, so the derived snapshot delay stays consistent.
|
// override changes them, so the derived snapshot delay stays consistent.
|
||||||
docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes,
|
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
|
// 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
|
// offset already frozen on the schedule (null = none), or the live one for
|
||||||
// legacy rows — the override must not silently drop the global offset.
|
// 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) {
|
for (const t of targets) {
|
||||||
await repo.update(t.id, {
|
await repo.update(t.id, {
|
||||||
windowOpensAt: cap(times.windowOpensAt, t.departure),
|
windowOpensAt: cap(times.windowOpensAt, t.departure),
|
||||||
windowClosesAt: cap(times.windowClosesAt, t.departure),
|
windowClosesAt: cap(times.windowClosesAt, t.departure),
|
||||||
...ruleFields,
|
...ruleFields,
|
||||||
|
rulePaymentWindowMinutes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -1199,6 +1222,7 @@ export class TrainSchedulingService {
|
|||||||
windowDurationHours: num(row?.windowDurationHours, 3),
|
windowDurationHours: num(row?.windowDurationHours, 3),
|
||||||
docReviewMinutes: num(row?.docReviewMinutes, 30),
|
docReviewMinutes: num(row?.docReviewMinutes, 30),
|
||||||
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
|
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
|
||||||
|
exportPaymentWindowMinutes: num(row?.exportPaymentWindowMinutes, 60),
|
||||||
importCloseOffsetMinutes: offset(row?.importCloseOffsetMinutes),
|
importCloseOffsetMinutes: offset(row?.importCloseOffsetMinutes),
|
||||||
exportCloseOffsetMinutes: offset(row?.exportCloseOffsetMinutes),
|
exportCloseOffsetMinutes: offset(row?.exportCloseOffsetMinutes),
|
||||||
};
|
};
|
||||||
@@ -1931,8 +1955,14 @@ export class TrainSchedulingService {
|
|||||||
removedAt: new Date(),
|
removedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
// Ops decision, so the customer hears about it: SMS/email + inbox telling
|
||||||
`[NOTIFY] Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer should be notified to reschedule or cancel.`,
|
// 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);
|
return this.getTrainScheduleById(scheduleId);
|
||||||
@@ -6845,7 +6875,14 @@ export class TrainSchedulingService {
|
|||||||
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
|
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
|
||||||
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
|
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
|
||||||
docReviewMinutes: windowCfg.docReviewMinutes,
|
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
|
route: schedule.route
|
||||||
? { id: schedule.route.id, name: formatRouteLabel(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 { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
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_WEIGHT_TONS = 3500;
|
||||||
export const MAX_TRAIN_LENGTH_METERS = 760;
|
export const MAX_TRAIN_LENGTH_METERS = 760;
|
||||||
@@ -171,11 +171,21 @@ export function buildBulkWagonPlan(
|
|||||||
bookings: Booking[],
|
bookings: Booking[],
|
||||||
wagonType: WagonType,
|
wagonType: WagonType,
|
||||||
): WagonPlanSlot[] {
|
): WagonPlanSlot[] {
|
||||||
const totalWeight = roundTons(
|
|
||||||
bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0),
|
|
||||||
);
|
|
||||||
const capacity = Number(wagonType.capacityTons);
|
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) => ({
|
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
|
||||||
sequenceNo: index + 1,
|
sequenceNo: index + 1,
|
||||||
@@ -293,7 +303,9 @@ function allocateBookingsToSlots(
|
|||||||
const remaining = bookings.map((booking) => ({
|
const remaining = bookings.map((booking) => ({
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
bookingReference: booking.reference,
|
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;
|
let bookingIndex = 0;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
import {
|
import {
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
@@ -50,11 +51,11 @@ export class BuildTrainDto {
|
|||||||
@IsUUID('all', { each: true })
|
@IsUUID('all', { each: true })
|
||||||
wagonIds?: string[];
|
wagonIds?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: 100 })
|
@ApiProperty({ maxLength: 100, description: 'Vogue number' })
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'Vogue number is required' })
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
trainName?: string;
|
trainName!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const WAGON_STATUSES = [
|
|||||||
WagonStatus.ExportReady,
|
WagonStatus.ExportReady,
|
||||||
WagonStatus.Maintenance,
|
WagonStatus.Maintenance,
|
||||||
WagonStatus.Detained,
|
WagonStatus.Detained,
|
||||||
|
WagonStatus.OutOfService,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WagonStatusType = (typeof WAGON_STATUSES)[number];
|
export type WagonStatusType = (typeof WAGON_STATUSES)[number];
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
|||||||
}, [opened]);
|
}, [opened]);
|
||||||
|
|
||||||
const handleBuild = async () => {
|
const handleBuild = async () => {
|
||||||
|
if (!trainName.trim()) {
|
||||||
|
toast({
|
||||||
|
title: "Enter the vogue number",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!yardId || locomotiveIds.length < 1) {
|
if (!yardId || locomotiveIds.length < 1) {
|
||||||
toast({
|
toast({
|
||||||
title: "Pick a yard and couple at least one locomotive",
|
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(),
|
importTrainNumber: importTrainNumber.trim(),
|
||||||
currentYardId: yardId,
|
currentYardId: yardId,
|
||||||
locomotiveIds,
|
locomotiveIds,
|
||||||
...(trainName.trim() ? { trainName: trainName.trim() } : {}),
|
trainName: trainName.trim(),
|
||||||
...(notes.trim() ? { notes: notes.trim() } : {}),
|
...(notes.trim() ? { notes: notes.trim() } : {}),
|
||||||
});
|
});
|
||||||
toast({ title: `Train ${composition.code} built` });
|
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.
|
wagons are attached on the next screen.
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Name (optional)"
|
label="Vogue number"
|
||||||
placeholder="e.g. Fertilizer block"
|
placeholder="Enter vogue number"
|
||||||
value={trainName}
|
value={trainName}
|
||||||
onChange={(e) => setTrainName(e.currentTarget.value)}
|
onChange={(e) => setTrainName(e.currentTarget.value)}
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
{/* Fixed by the import run — derived, never typed. */}
|
{/* Fixed by the import run — derived, never typed. */}
|
||||||
|
|||||||
@@ -363,14 +363,17 @@ export default function BookingWindowSettingsModal({
|
|||||||
/>
|
/>
|
||||||
<DurationField
|
<DurationField
|
||||||
label="Payment window"
|
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}
|
value={form.paymentWindowMinutes}
|
||||||
nativeUnit="minutes"
|
nativeUnit="minutes"
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
setForm((f) => f && { ...f, paymentWindowMinutes: v })
|
setForm((f) => f && { ...f, paymentWindowMinutes: v })
|
||||||
}
|
}
|
||||||
min={1}
|
min={1}
|
||||||
disabled={isExport}
|
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
{!isExport ? (
|
{!isExport ? (
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ const WAGON_STATUS_OPTIONS = [
|
|||||||
{ label: "Assigned", value: Freight.WagonStatus.Assigned },
|
{ label: "Assigned", value: Freight.WagonStatus.Assigned },
|
||||||
{ label: "Maintenance", value: Freight.WagonStatus.Maintenance },
|
{ label: "Maintenance", value: Freight.WagonStatus.Maintenance },
|
||||||
{ label: "Detained", value: Freight.WagonStatus.Detained },
|
{ 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
|
// Statuses staff may set BY HAND on the create/edit form. ASSIGNED is omitted
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
"windowDurationHours",
|
"windowDurationHours",
|
||||||
"docReviewMinutes",
|
"docReviewMinutes",
|
||||||
"paymentWindowMinutes",
|
"paymentWindowMinutes",
|
||||||
|
"exportPaymentWindowMinutes",
|
||||||
];
|
];
|
||||||
const payload: Partial<Record<keyof TrainSchedulingGlobalRules, number>> = {};
|
const payload: Partial<Record<keyof TrainSchedulingGlobalRules, number>> = {};
|
||||||
for (const key of fields) {
|
for (const key of fields) {
|
||||||
@@ -209,8 +210,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<DurationField
|
<DurationField
|
||||||
label="Payment window"
|
label="Import payment window"
|
||||||
description="Time a selected customer has to pay before the slot expires"
|
description="Time an import/domestic customer has to pay before the reserved slot expires"
|
||||||
value={form.paymentWindowMinutes ?? ""}
|
value={form.paymentWindowMinutes ?? ""}
|
||||||
nativeUnit="minutes"
|
nativeUnit="minutes"
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
@@ -219,6 +220,17 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
min={1}
|
min={1}
|
||||||
disabled={loading}
|
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>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,10 @@ export interface TrainSchedulingGlobalRules {
|
|||||||
windowCloseHour: number;
|
windowCloseHour: number;
|
||||||
windowDurationHours: number;
|
windowDurationHours: number;
|
||||||
docReviewMinutes: number;
|
docReviewMinutes: number;
|
||||||
|
/** Import/domestic customer pay window, minutes. */
|
||||||
paymentWindowMinutes: number;
|
paymentWindowMinutes: number;
|
||||||
|
/** Export customer pay window, minutes — tuned separately from import. */
|
||||||
|
exportPaymentWindowMinutes: number;
|
||||||
/** Minutes before departure the import window closes; null = close at departure. */
|
/** Minutes before departure the import window closes; null = close at departure. */
|
||||||
importCloseOffsetMinutes: number | null;
|
importCloseOffsetMinutes: number | null;
|
||||||
/** Minutes before departure the export window closes; null = close at departure. */
|
/** Minutes before departure the export window closes; null = close at departure. */
|
||||||
|
|||||||
@@ -149,6 +149,10 @@ function mapBookingToFormValues(
|
|||||||
destinationYard: yardIdFromBooking(booking.destinationYard, referenceData),
|
destinationYard: yardIdFromBooking(booking.destinationYard, referenceData),
|
||||||
cargoType: booking.freightType === "BULK" ? "bulk" : "container",
|
cargoType: booking.freightType === "BULK" ? "bulk" : "container",
|
||||||
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
|
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
|
||||||
|
bulkTotalWeightTons:
|
||||||
|
booking.bulkTotalWeightTons != null
|
||||||
|
? String(Number(booking.bulkTotalWeightTons))
|
||||||
|
: "",
|
||||||
isHazardous: booking.isHazardous ?? false,
|
isHazardous: booking.isHazardous ?? false,
|
||||||
isRefrigerated: booking.isRefrigerated ?? false,
|
isRefrigerated: booking.isRefrigerated ?? false,
|
||||||
bulkHazardousQty: String(Number(booking.bulkHazardousQuantity ?? 0)),
|
bulkHazardousQty: String(Number(booking.bulkHazardousQuantity ?? 0)),
|
||||||
@@ -455,6 +459,15 @@ export default function EditBookingPage() {
|
|||||||
: "IMPORT",
|
: "IMPORT",
|
||||||
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
|
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
|
||||||
cargoTotalWeightVgm: totalWeight,
|
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;
|
// Containers: booking-level flags are the OR of the per-container switches;
|
||||||
// bulk uses the route-step toggles.
|
// bulk uses the route-step toggles.
|
||||||
isHazardous:
|
isHazardous:
|
||||||
|
|||||||
@@ -519,6 +519,11 @@ export default function NewBookingPage() {
|
|||||||
// Day-level pool: the customer picks only a day (scheduledDate); the batch
|
// Day-level pool: the customer picks only a day (scheduledDate); the batch
|
||||||
// engine assigns the train, so no trainScheduleId is sent.
|
// engine assigns the train, so no trainScheduleId is sent.
|
||||||
cargoTotalWeightVgm: totalWeight,
|
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
|
// Booking-level flags drive the HAZARD / REEFER surcharge triggers. For
|
||||||
// containers they're the OR of the per-container switches; for bulk they
|
// containers they're the OR of the per-container switches; for bulk they
|
||||||
// come from the cargo-step toggles.
|
// come from the cargo-step toggles.
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ function BookingActionModalBody({
|
|||||||
leftSection={<CheckCircle2 size={16} />}
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
onClick={handleProceed}
|
onClick={handleProceed}
|
||||||
loading={flow.proceedMutation.isPending}
|
loading={flow.proceedMutation.isPending}
|
||||||
disabled={!flow.scheduledDate}
|
disabled={!flow.scheduledDate || flow.requiresTrainSelection}
|
||||||
>
|
>
|
||||||
Proceed to operation
|
Proceed to operation
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useFileViewer } from "@/hooks/useFileViewer";
|
|||||||
import { bookingDocNoun } from "./bookingNextAction";
|
import { bookingDocNoun } from "./bookingNextAction";
|
||||||
import { OperationDatePicker } from "./OperationDatePicker";
|
import { OperationDatePicker } from "./OperationDatePicker";
|
||||||
import { DayAvailabilityHint } from "./DayAvailabilityHint";
|
import { DayAvailabilityHint } from "./DayAvailabilityHint";
|
||||||
|
import { ExportTrainPicker } from "./ExportTrainPicker";
|
||||||
import type { ClearanceFlowController } from "./useClearanceFlow";
|
import type { ClearanceFlowController } from "./useClearanceFlow";
|
||||||
|
|
||||||
const BORDER = "#E6ECF2";
|
const BORDER = "#E6ECF2";
|
||||||
@@ -71,6 +72,11 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
|||||||
setAdHocFile,
|
setAdHocFile,
|
||||||
scheduledDate,
|
scheduledDate,
|
||||||
setScheduledDate,
|
setScheduledDate,
|
||||||
|
isExportRail,
|
||||||
|
exportTrains,
|
||||||
|
exportTrainsLoading,
|
||||||
|
selectedTrainId,
|
||||||
|
setSelectedTrainId,
|
||||||
uploadMutation,
|
uploadMutation,
|
||||||
proceedMutation,
|
proceedMutation,
|
||||||
} = flow;
|
} = flow;
|
||||||
@@ -224,9 +230,9 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
|||||||
Choose your shipment day
|
Choose your shipment day
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz="12px" c="dimmed" mb="sm">
|
<Text fz="12px" c="dimmed" mb="sm">
|
||||||
Only days with a scheduled departure that can carry your cargo type
|
{isExportRail
|
||||||
can be selected. The operations team assigns the specific train for
|
? "Only days with a scheduled departure that can carry your cargo type can be selected. Pick the train you want for that day below."
|
||||||
that day.
|
: "Only days with a scheduled departure that can carry your cargo type can be selected. The operations team assigns the specific train for that day."}
|
||||||
</Text>
|
</Text>
|
||||||
<OperationDatePicker
|
<OperationDatePicker
|
||||||
originYardId={booking.originYard?.id}
|
originYardId={booking.originYard?.id}
|
||||||
@@ -235,13 +241,21 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
|||||||
value={scheduledDate}
|
value={scheduledDate}
|
||||||
onChange={setScheduledDate}
|
onChange={setScheduledDate}
|
||||||
/>
|
/>
|
||||||
{scheduledDate && (
|
{scheduledDate && !isExportRail && (
|
||||||
<DayAvailabilityHint
|
<DayAvailabilityHint
|
||||||
bookingId={booking.id}
|
bookingId={booking.id}
|
||||||
date={scheduledDate}
|
date={scheduledDate}
|
||||||
tradeDirection={booking.tradeDirection}
|
tradeDirection={booking.tradeDirection}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{scheduledDate && isExportRail && (
|
||||||
|
<ExportTrainPicker
|
||||||
|
options={exportTrains}
|
||||||
|
loading={exportTrainsLoading}
|
||||||
|
value={selectedTrainId}
|
||||||
|
onChange={setSelectedTrainId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</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 [pending, setPending] = useState<Record<string, File>>({});
|
||||||
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
|
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
|
||||||
// Binding shipment day chosen for the operation request (yyyy-MM-dd).
|
// 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 = () => {
|
const refresh = () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -146,9 +178,14 @@ export function useClearanceFlow(booking: Freight.IBooking) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const proceedToOperation = (opts?: { onSuccess?: () => void }) => {
|
const proceedToOperation = (opts?: { onSuccess?: () => void }) => {
|
||||||
if (!scheduledDate) return;
|
if (!scheduledDate || requiresTrainSelection) return;
|
||||||
proceedMutation.mutate(
|
proceedMutation.mutate(
|
||||||
{ id: booking.id, scheduledDate },
|
{
|
||||||
|
id: booking.id,
|
||||||
|
scheduledDate,
|
||||||
|
trainScheduleId:
|
||||||
|
isExportRail && selectedTrainId ? selectedTrainId : undefined,
|
||||||
|
},
|
||||||
{ onSuccess: opts?.onSuccess },
|
{ onSuccess: opts?.onSuccess },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -179,6 +216,13 @@ export function useClearanceFlow(booking: Freight.IBooking) {
|
|||||||
// schedule
|
// schedule
|
||||||
scheduledDate,
|
scheduledDate,
|
||||||
setScheduledDate,
|
setScheduledDate,
|
||||||
|
// export train pick
|
||||||
|
isExportRail,
|
||||||
|
exportTrains,
|
||||||
|
exportTrainsLoading: exportTrainsQuery.isLoading,
|
||||||
|
selectedTrainId,
|
||||||
|
setSelectedTrainId,
|
||||||
|
requiresTrainSelection,
|
||||||
// mutations
|
// mutations
|
||||||
uploadMutation,
|
uploadMutation,
|
||||||
proceedMutation,
|
proceedMutation,
|
||||||
|
|||||||
@@ -164,6 +164,10 @@ export const bookingFormSchema = z
|
|||||||
scheduledDate: z.string().default(""),
|
scheduledDate: z.string().default(""),
|
||||||
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
||||||
cargoWeight: z.string(),
|
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([]),
|
cargoTypePath: z.array(z.string()).default([]),
|
||||||
cargoFreeText: z.string(),
|
cargoFreeText: z.string(),
|
||||||
isHazardous: z.boolean(),
|
isHazardous: z.boolean(),
|
||||||
@@ -239,6 +243,16 @@ export const bookingFormSchema = z
|
|||||||
},
|
},
|
||||||
{ message: "Enter a quantity greater than 0.", path: ["cargoWeight"] },
|
{ 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(
|
.refine(
|
||||||
(data) => !(data.cargoType === "container" && data.containers.length === 0),
|
(data) => !(data.cargoType === "container" && data.containers.length === 0),
|
||||||
{ message: "Add at least one container.", path: ["containers"] },
|
{ message: "Add at least one container.", path: ["containers"] },
|
||||||
@@ -381,6 +395,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
|||||||
extraRoutes: [],
|
extraRoutes: [],
|
||||||
scheduledDate: "",
|
scheduledDate: "",
|
||||||
cargoWeight: "",
|
cargoWeight: "",
|
||||||
|
bulkTotalWeightTons: "",
|
||||||
cargoTypePath: [],
|
cargoTypePath: [],
|
||||||
cargoFreeText: "",
|
cargoFreeText: "",
|
||||||
isHazardous: false,
|
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,
|
{/* Cargo handling — how much of the cargo is hazardous / refrigerated,
|
||||||
in the SAME unit as the quantity above (tons or items). Shown once a
|
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
|
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.
|
// slot, so the action is "none" and no booking button is shown.
|
||||||
const canBookShipment =
|
const canBookShipment =
|
||||||
bookingAction.kind === "book" || bookingAction.kind === "rebook";
|
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
|
// Self-clearance import/export (ONE_TIME or GENERAL): one-click bare booking
|
||||||
// instance — the per-booking clearance runs first, so no window gate here.
|
// instance — the per-booking clearance runs first, so no window gate here.
|
||||||
const canInitiateBooking = bookingAction.kind === "initiate";
|
const canInitiateBooking = bookingAction.kind === "initiate" && isContainer;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box style={{ padding: "28px 32px 40px" }}>
|
<Box style={{ padding: "28px 32px 40px" }}>
|
||||||
|
|||||||
@@ -407,10 +407,10 @@ export const api = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
proceedToOperation: endpoint<
|
proceedToOperation: endpoint<
|
||||||
{ id: string; scheduledDate: string },
|
{ id: string; scheduledDate: string; trainScheduleId?: string },
|
||||||
Freight.IBooking
|
Freight.IBooking
|
||||||
>("bookings", "proceedToOperation", ({ id, scheduledDate }) =>
|
>("bookings", "proceedToOperation", ({ id, scheduledDate, trainScheduleId }) =>
|
||||||
bookingsService.proceedToOperation(id, scheduledDate),
|
bookingsService.proceedToOperation(id, scheduledDate, trainScheduleId),
|
||||||
),
|
),
|
||||||
|
|
||||||
checkPayment: endpoint<{ orderId: string }, { status: string }>(
|
checkPayment: endpoint<{ orderId: string }, { status: string }>(
|
||||||
@@ -468,6 +468,13 @@ export const api = {
|
|||||||
bookingsService.getDayAvailability(bookingId, date),
|
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[]>(
|
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
|
||||||
"train-scheduling",
|
"train-scheduling",
|
||||||
"myBookingWindows",
|
"myBookingWindows",
|
||||||
|
|||||||
@@ -378,10 +378,11 @@ export const bookingsService = {
|
|||||||
proceedToOperation: async (
|
proceedToOperation: async (
|
||||||
id: string,
|
id: string,
|
||||||
scheduledDate: string,
|
scheduledDate: string,
|
||||||
|
trainScheduleId?: string,
|
||||||
): Promise<Freight.IBooking> => {
|
): Promise<Freight.IBooking> => {
|
||||||
const { data } = await client.post(
|
const { data } = await client.post(
|
||||||
`/api/bookings/${id}/clearance/proceed`,
|
`/api/bookings/${id}/clearance/proceed`,
|
||||||
{ scheduledDate },
|
{ scheduledDate, ...(trainScheduleId ? { trainScheduleId } : {}) },
|
||||||
);
|
);
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
@@ -508,6 +509,18 @@ export const bookingsService = {
|
|||||||
return (data.data as Freight.AvailableDaysResponse).days;
|
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).
|
// Advisory free-wagon count for a shipment day (planning hint, not enforced).
|
||||||
getDayAvailability: async (
|
getDayAvailability: async (
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ export enum WagonStatus {
|
|||||||
Maintenance = "MAINTENANCE",
|
Maintenance = "MAINTENANCE",
|
||||||
/** Formerly RETIRED — wagons pulled from circulation. */
|
/** Formerly RETIRED — wagons pulled from circulation. */
|
||||||
Detained = "DETAINED",
|
Detained = "DETAINED",
|
||||||
|
OutOfService = "OUT_OF_SERVICE",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum WagonReadiness {
|
export enum WagonReadiness {
|
||||||
@@ -654,6 +655,8 @@ export interface IBooking extends BaseEntity {
|
|||||||
originYard?: IYard | null;
|
originYard?: IYard | null;
|
||||||
destinationYard?: IYard | null;
|
destinationYard?: IYard | null;
|
||||||
cargoTotalWeightVgm: number;
|
cargoTotalWeightVgm: number;
|
||||||
|
/** Break-bulk (PER_ITEM) only: actual total weight in tons — cargoTotalWeightVgm then holds the item count. */
|
||||||
|
bulkTotalWeightTons?: number | null;
|
||||||
|
|
||||||
freightType: FreightType;
|
freightType: FreightType;
|
||||||
freightSubtype?: string | null;
|
freightSubtype?: string | null;
|
||||||
@@ -1090,6 +1093,30 @@ export interface BookableScheduleItem {
|
|||||||
remainingWagons: number;
|
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 ───────────────────────────────────────────────────────────────────────
|
// ── DTOs ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface CreateBookingContainerDto {
|
export interface CreateBookingContainerDto {
|
||||||
@@ -1148,6 +1175,8 @@ export interface CreateBookingDto {
|
|||||||
cargoFreeText?: string | undefined;
|
cargoFreeText?: string | undefined;
|
||||||
shippingLineId?: string | undefined;
|
shippingLineId?: string | undefined;
|
||||||
cargoTotalWeightVgm: number;
|
cargoTotalWeightVgm: number;
|
||||||
|
/** Break-bulk (PER_ITEM) only: actual total weight in tons — cargoTotalWeightVgm then holds the item count. */
|
||||||
|
bulkTotalWeightTons?: number | undefined;
|
||||||
isHazardous?: boolean | undefined;
|
isHazardous?: boolean | undefined;
|
||||||
/** Booking-level refrigerated flag (bulk freight only; containers derive reefer from the container type). */
|
/** Booking-level refrigerated flag (bulk freight only; containers derive reefer from the container type). */
|
||||||
isReefer?: boolean | undefined;
|
isReefer?: boolean | undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user