Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-07 08:09:34 +00:00
79 changed files with 7273 additions and 802 deletions

View File

@@ -31,6 +31,19 @@ export function paymentDrainMs(): number {
);
}
/**
* ISO timestamp of the end of a pay window's drain tail, for client display
* (the "payment processing" countdown). Null in ⇒ null out.
*/
export function paymentDrainEndsAtIso(
deadline: Date | string | null | undefined,
): string | null {
if (deadline == null) return null;
const ms = new Date(deadline).getTime();
if (!Number.isFinite(ms)) return null;
return new Date(ms + paymentDrainMs()).toISOString();
}
/**
* A pay window AND its drain tail have closed.
*

View File

@@ -14,6 +14,7 @@ import { Server, Socket } from 'socket.io';
import { WsAuthService } from '../notification-inbox/ws-auth.service';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { paymentDrainEndsAtIso } from './booking-batch.constants';
/**
* Server → client push for booking-window state changes. Same handshake model
@@ -61,6 +62,7 @@ export class BookingWindowGateway implements OnGatewayConnection {
windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null,
docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null,
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
paymentDrainEndsAt: paymentDrainEndsAtIso(schedule.paymentPhaseEndsAt),
scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null,
};
this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload);

View File

@@ -1,5 +1,6 @@
import {
DEFAULT_PAYMENT_DRAIN_MINUTES,
paymentDrainEndsAtIso,
paymentDrainMs,
payWindowLapsed,
} from "./booking-batch.constants";
@@ -64,4 +65,16 @@ describe("payWindowLapsed — pay-window drain tail", () => {
expect(paymentDrainMs()).toBe(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN);
}
});
it("paymentDrainEndsAtIso reports deadline + drain, null/garbage-safe", () => {
expect(paymentDrainEndsAtIso(deadline)).toBe(
new Date(at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN)).toISOString(),
);
expect(paymentDrainEndsAtIso(deadline.toISOString())).toBe(
new Date(at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN)).toISOString(),
);
expect(paymentDrainEndsAtIso(null)).toBeNull();
expect(paymentDrainEndsAtIso(undefined)).toBeNull();
expect(paymentDrainEndsAtIso("not-a-date")).toBeNull();
});
});

View File

@@ -153,6 +153,7 @@ import {
DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
paymentDrainEndsAtIso,
} from './booking-batch.constants';
import { orderConsistWagons } from './consist-order.util';
import {
@@ -1546,23 +1547,10 @@ export class TrainSchedulingService {
}
: globalCfg;
// Staff cannot schedule inside the lead window — there must be room for a
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT
// days (lead 3, today 11th → first allowed departure is the 14th); EXPORT
// lead is in hours (24h = 1 day ahead). Checked against the schedule's OWN
// lead, so a custom lead is honoured rather than rejected by the global one.
const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date());
if (departure.getTime() < earliest.getTime()) {
const detail =
direction === 'EXPORT'
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
throw new BadRequestException(
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
`${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
`(earliest ${earliest.toISOString()})`,
);
}
// Short-notice trains are allowed: a departure inside the booking lead
// window is NOT rejected — the window just opens immediately (opensAt is
// clamped to `now` below) instead of waiting out a lead that has already
// passed. Only `updateScheduleDate` still enforces the lead floor.
// Freeze the rule this schedule is born with. A later global-rules edit
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
@@ -1577,6 +1565,11 @@ export class TrainSchedulingService {
...ruleSnapshot,
...computeImportWindowTimes(departure, windowCfg, new Date()),
};
// Inside-lead departure (e.g. a huge configured lead): the raw open lands
// in the past — clamp it to `now` so the window tick opens it immediately.
if (computedTimes.windowOpensAt.getTime() < Date.now()) {
computedTimes.windowOpensAt = new Date();
}
if (
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
) {
@@ -6948,6 +6941,7 @@ export class TrainSchedulingService {
windowClosesAt: r.window_closes_at,
docReviewEndsAt: r.doc_review_ends_at,
paymentPhaseEndsAt: r.payment_phase_ends_at,
paymentDrainEndsAt: paymentDrainEndsAtIso(r.payment_phase_ends_at),
bookingWindowStatus: r.booking_window_status,
bookingCycleNo: r.booking_cycle_no,
departureDate: r.scheduled_departure_date,