fix issues

This commit is contained in:
Marshal
2026-07-03 13:26:40 +00:00
parent a4c6848233
commit e14df58a47
11 changed files with 354 additions and 165 deletions

View File

@@ -1,11 +1,14 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
Logger,
NotFoundException,
forwardRef,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { ExchangeService } from '@edr/api-common';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
@@ -15,6 +18,7 @@ import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
@@ -62,6 +66,9 @@ export class ContractBookingService {
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly dataSource: DataSource,
private readonly exchangeService: ExchangeService,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
) {}
async createUnderContract(
@@ -113,6 +120,20 @@ export class ContractBookingService {
const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
// Booking-window gate (config-driven): an operations booking may only be
// created while the route's booking window is open — import: the day's window
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
// export: within exportBookingLeadHours of departure. Customs Path B bookings
// enter clearance first and are scheduled later, so they are not gated here.
if (!generalCustoms) {
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
scheduledDate: dto.scheduledDate ?? null,
direction: contract.tradeDirection ?? null,
});
}
// Denormalize route/direction/freight onto the booking for the scheduling engine.
const booking = await this.bookingsRepository.create({
reference,
@@ -582,13 +603,22 @@ export class ContractBookingService {
maxAllowedTons: number;
excessTons: number;
}>;
overweightSurchargeAmount: number;
currency: string | null;
pairingErrors: string[];
}> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
const lines = dto.containers ?? [];
if (!lines.length) return { overweightLines: [], pairingErrors: [] };
if (!lines.length) {
return {
overweightLines: [],
overweightSurchargeAmount: 0,
currency: null,
pairingErrors: [],
};
}
// Resolve each line's container type + total VGM (sum of unit weights) so the
// rule engine can flag overweight per line (maxVgmTons × quantity vs total).
@@ -661,7 +691,28 @@ export class ContractBookingService {
(v) => v.message,
);
return { overweightLines, pairingErrors };
// Real overweight surcharge (same rate the rule engine bills at booking-create
// time) so the confirm-modal total isn't missing the charge the warning refers to.
// Rates are stored in USD; convert to the contract's payment currency the same
// way BookingPricingService does so this preview matches the eventual booking total.
const overweightModifier = ruleResult.appliedModifiers.find(
(m) => m.surchargeCode === 'OVERWEIGHT_PER_TON',
);
let overweightSurchargeAmount = 0;
if (overweightModifier) {
const isEtb = contract.paymentCurrency === 'ETB';
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
overweightSurchargeAmount = isEtb
? Math.round(overweightModifier.calculatedAmount * usdToEtb)
: overweightModifier.calculatedAmount;
}
return {
overweightLines,
overweightSurchargeAmount,
currency: overweightLines.length ? contract.paymentCurrency : null,
pairingErrors,
};
}
private async max20ftPairDiffTons(): Promise<number> {

View File

@@ -12,6 +12,7 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { BookingsModule } from '../bookings/bookings.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
@@ -76,6 +77,10 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
// BookingsModule provides BookingsRepository/BookingPricingService used by the
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
forwardRef(() => BookingsModule),
// TrainSchedulingModule provides the config-driven booking-window gate used
// by ContractBookingService.createUnderContract. forwardRef because
// TrainSchedulingModule already imports ContractsModule.
forwardRef(() => TrainSchedulingModule),
ExchangeModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions =>

View File

@@ -3,9 +3,9 @@ import {
listBatchWindowsForDate,
listBatchWindowsForBookings,
BATCH_WINDOW_START_HOURS,
boardWindowForTimestamp,
listBoardWindowsForRange,
listConfigBookingWindows,
groupBookingsIntoBoardWindows,
type BoardWindowConfig,
} from './batch-window.util';
describe('batch-window.util', () => {
@@ -54,83 +54,87 @@ describe('batch-window.util', () => {
});
});
describe('batch-window board windows (midnight-based 3h slots)', () => {
it('maps 04:00 EAT to the 03:0006:00 slot', () => {
// 01:00 UTC = 04:00 EAT on 11 Jun
const w = boardWindowForTimestamp(new Date('2026-06-11T01:00:00.000Z'));
expect(w.label).toContain('03:00');
expect(w.label).toContain('06:00');
expect(w.date).toBe('2026-06-11');
expect(w.dateLabel).toContain('11 Jun');
});
describe('batch-window board windows (config-driven booking cycles)', () => {
// Default rules: open 08:00 EAT, 3 days before departure, 3h long, reopen 90m later.
const cfg: BoardWindowConfig = {
importWindowLeadDays: 3,
windowOpenHour: 8,
windowDurationHours: 3,
reopenDelayMinutes: 90,
exportBookingLeadHours: 24,
};
it('maps 00:30 EAT to the 00:0003:00 slot of that EAT day', () => {
// 21:30 UTC on 10 Jun = 00:30 EAT on 11 Jun
const w = boardWindowForTimestamp(new Date('2026-06-10T21:30:00.000Z'));
expect(w.label).toContain('00:00');
expect(w.label).toContain('03:00');
expect(w.date).toBe('2026-06-11');
});
it('maps 23:00 EAT to the final 21:0024:00 slot', () => {
// 20:00 UTC = 23:00 EAT on 11 Jun
const w = boardWindowForTimestamp(new Date('2026-06-11T20:00:00.000Z'));
expect(w.label).toContain('21:00');
expect(w.label).toContain('24:00');
expect(w.date).toBe('2026-06-11');
});
it('lists a continuous range open→departure clamped at both ends', () => {
// open 05 Jun 08:00 EAT (05:00 UTC) → departs 08 Jun 14:00 EAT (11:00 UTC)
const open = new Date('2026-06-05T05:00:00.000Z');
it('import: first window opens at windowOpenHour EAT, importWindowLeadDays before departure', () => {
// departs 08 Jun 14:00 EAT (11:00 UTC) → window day = 05 Jun, opens 08:00 EAT (05:00 UTC)
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listBoardWindowsForRange(open, departure);
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
// Day 5: 06,09,12,15,18,21 = 6 ; Days 6,7: 8 each ; Day 8: 00,03,06,09,12 = 5
expect(windows).toHaveLength(6 + 8 + 8 + 5);
expect(windows[0].date).toBe('2026-06-05');
expect(windows[0].label).toContain('06:00');
expect(windows[0].label).toContain('09:00');
const last = windows[windows.length - 1];
expect(last.date).toBe('2026-06-08');
expect(last.label).toContain('12:00');
expect(last.label).toContain('15:00');
// chronological + unique keys
const keys = windows.map((w) => w.key);
expect(new Set(keys).size).toBe(keys.length);
expect(windows[0].label).toContain('08:00');
expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z');
// end = open + windowDurationHours (3h) = 08:00 → 11:00 EAT (08:00 UTC)
expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z');
});
it('handles a same-day open→departure range', () => {
const open = new Date('2026-06-05T05:00:00.000Z'); // 08:00 EAT (0609 slot)
const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (1215 slot)
const windows = listBoardWindowsForRange(open, departure);
// 06,09,12 = 3 slots
expect(windows).toHaveLength(3);
it('import: reopens reopenDelayMinutes after close, same booking day', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
// cycle 1: 08:0011:00; reopen +90m → cycle 2 opens 12:30 EAT
expect(windows.length).toBeGreaterThanOrEqual(2);
expect(windows[1].start.toISOString()).toBe('2026-06-05T09:30:00.000Z'); // 12:30 EAT
// all cycles stay on the same EAT booking day
expect(windows.every((w) => w.date === '2026-06-05')).toBe(true);
});
it('buckets bookings by fullyExecutedAt and keeps empty + pending windows', () => {
const open = new Date('2026-06-05T05:00:00.000Z');
const departure = new Date('2026-06-06T11:00:00.000Z');
it('export: single FCFS window exportBookingLeadHours before departure', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('EXPORT', departure, cfg);
expect(windows).toHaveLength(1);
// 24h before 11:00 UTC on 08 Jun = 11:00 UTC on 07 Jun
expect(windows[0].start.toISOString()).toBe('2026-06-07T11:00:00.000Z');
expect(windows[0].end.toISOString()).toBe(departure.toISOString());
});
it('buckets bookings into config cycles and keeps empty + pending windows', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const items = [
{ id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → 0609 on 5th
{ id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → inside cycle 1
{ id: 'b', ts: null }, // pending
];
const map = groupBookingsIntoBoardWindows(
items,
(i) => i.ts,
open,
'IMPORT',
departure,
cfg,
'pending-contract',
);
const pending = map.get('pending-contract');
expect(pending?.items.map((i) => i.id)).toEqual(['b']);
const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a'));
expect(withA?.window?.date).toBe('2026-06-05');
// empty slots are retained for the UI
// empty cycles are retained for the UI
const emptyCount = [...map.values()].filter(
(b) => b.window && b.items.length === 0,
).length;
expect(emptyCount).toBeGreaterThan(0);
});
it('attaches a booking made before the window opened to the first cycle', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const items = [{ id: 'early', ts: new Date('2026-06-01T00:00:00.000Z') }];
const map = groupBookingsIntoBoardWindows(
items,
(i) => i.ts,
'IMPORT',
departure,
cfg,
'pending-contract',
);
const withEarly = [...map.values()].find((b) =>
b.items.some((i) => i.id === 'early'),
);
expect(withEarly?.window?.date).toBe('2026-06-05');
expect(withEarly?.window?.label).toContain('08:00');
});
});

View File

@@ -230,14 +230,13 @@ export function listBatchWindowsForBookings(
}
// ---------------------------------------------------------------------------
// Board-display windows: full-day, midnight-based 3h slots over a date range.
// These are used ONLY for the batch-board UI grouping (not persisted, and
// independent of the cron intake hours above).
// Board-display windows: the REAL booking-window cycles derived from the
// train_scheduling_global_rules config (window open hour, lead days, duration,
// reopen delay) — NOT a fixed clock grid. Import shows each booking-window cycle
// (opens at windowOpenHour EAT, lasts windowDurationHours, reopens after
// reopenDelayMinutes until departure). Export shows the single FCFS lead window.
// ---------------------------------------------------------------------------
/** Midnight-based 3-hour slot starts (0003, 0306, … 2124). */
export const BOARD_WINDOW_HOURS = [0, 3, 6, 9, 12, 15, 18, 21] as const;
/** A board window carries an EAT calendar date in addition to the slot times. */
export interface BoardWindow extends BatchWindow {
/** EAT calendar day as ISO `YYYY-MM-DD`. */
@@ -246,6 +245,15 @@ export interface BoardWindow extends BatchWindow {
dateLabel: string;
}
/** Config fields the board needs to reconstruct booking-window cycles. */
export interface BoardWindowConfig {
importWindowLeadDays: number;
windowOpenHour: number;
windowDurationHours: number;
reopenDelayMinutes: number;
exportBookingLeadHours: number;
}
const dayLabelFmt = new Intl.DateTimeFormat('en-GB', {
weekday: 'short',
day: '2-digit',
@@ -257,119 +265,124 @@ function pad2(n: number): string {
return String(n).padStart(2, '0');
}
/** Build a midnight-based 3h board window for an EAT calendar day + slot start hour. */
function boardWindowFromEatStart(
year: number,
month: number,
day: number,
startHour: number,
): BoardWindow {
const start = eatToUtc(year, month, day, startHour);
const endHour = startHour + 3; // 21 -> 24 (handled by Date.UTC roll-over)
const end = eatToUtc(year, month, day, endHour);
const endLabel = endHour >= 24 ? '24:00' : `${pad2(endHour)}:00`;
/** Wrap a [start, end] interval as a labelled BoardWindow keyed on its EAT day. */
function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
const { year, month, day } = eatParts(start);
return {
key: start.toISOString(),
start,
end,
label: formatWindowLabel(start, end, endLabel),
label: formatWindowLabel(start, end),
date: `${year}-${pad2(month)}-${pad2(day)}`,
dateLabel: dayLabelFmt.format(start),
};
}
/** Which midnight-based 3h EAT slot a timestamp falls in. */
export function boardWindowForTimestamp(date: Date): BoardWindow {
const { year, month, day, hour } = eatParts(date);
let startHour: (typeof BOARD_WINDOW_HOURS)[number] = 0;
for (const h of BOARD_WINDOW_HOURS) {
if (hour >= h) startHour = h;
}
return boardWindowFromEatStart(year, month, day, startHour);
}
/**
* Continuous list of board windows from `openDate` to `departureDate` (inclusive),
* clamped to the slot containing `openDate` on the first day and the slot
* containing `departureDate` on the last day. Returned in chronological order.
* The real booking-window cycles for a schedule, straight from config.
*
* IMPORT: first window opens at `windowOpenHour` EAT on `departure importWindowLeadDays`
* for `windowDurationHours`; if the train isn't full it reopens `reopenDelayMinutes`
* after each close, on the same booking day, until departure. This mirrors
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
* exact windows the engine runs.
* EXPORT: a single FCFS window from `departure exportBookingLeadHours` to departure.
*/
export function listBoardWindowsForRange(
openDate: Date,
departureDate: Date,
export function listConfigBookingWindows(
direction: string | null | undefined,
departure: Date,
cfg: BoardWindowConfig,
): BoardWindow[] {
const startWin = boardWindowForTimestamp(openDate);
const endWin = boardWindowForTimestamp(departureDate);
// Guard against an inverted range (departure before open).
if (endWin.start.getTime() < startWin.start.getTime()) {
return [startWin];
if (direction === 'EXPORT') {
const start = new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
return [boardWindowFromInterval(start, departure)];
}
const windows: BoardWindow[] = [];
const seen = new Set<string>();
// Walk day-by-day in EAT, emitting each day's slots, stepping via UTC noon to
// avoid any boundary ambiguity, then filter to [startWin.start, endWin.start].
let cursor = new Date(eatToUtc(
Number(startWin.date.slice(0, 4)),
Number(startWin.date.slice(5, 7)),
Number(startWin.date.slice(8, 10)),
12,
));
const lastDayMs = eatToUtc(
Number(endWin.date.slice(0, 4)),
Number(endWin.date.slice(5, 7)),
Number(endWin.date.slice(8, 10)),
12,
).getTime();
const durationMs = cfg.windowDurationHours * 3_600_000;
const reopenMs = cfg.reopenDelayMinutes * 60_000;
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
while (cursor.getTime() <= lastDayMs) {
const { year, month, day } = eatParts(cursor);
for (const h of BOARD_WINDOW_HOURS) {
const w = boardWindowFromEatStart(year, month, day, h);
if (
w.start.getTime() >= startWin.start.getTime() &&
w.start.getTime() <= endWin.start.getTime() &&
!seen.has(w.key)
) {
seen.add(w.key);
windows.push(w);
}
let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour);
// Reopen stays on the same EAT booking day and before departure; cap at 12 cycles.
for (let cycle = 0; cycle < 12; cycle += 1) {
if (opensAt.getTime() >= departure.getTime()) break;
let closesAt = new Date(opensAt.getTime() + durationMs);
if (closesAt.getTime() > departure.getTime()) closesAt = departure;
windows.push(boardWindowFromInterval(opensAt, closesAt));
const nextOpensAt = new Date(closesAt.getTime() + reopenMs);
if (
nextOpensAt.getTime() >= departure.getTime() ||
eatDay(nextOpensAt) !== eatDay(opensAt)
) {
break;
}
cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000);
opensAt = nextOpensAt;
}
windows.sort(compareBatchWindows);
// Degenerate config (no window before departure) — surface a single window
// clamped to departure so the board still renders something meaningful.
if (windows.length === 0) {
windows.push(boardWindowFromInterval(new Date(departure.getTime() - durationMs), departure));
}
return windows;
}
/** Which config booking-window a timestamp falls in; null if before/after all of them. */
function configWindowForTimestamp(
windows: BoardWindow[],
date: Date,
): BoardWindow | null {
const ms = date.getTime();
for (const w of windows) {
if (ms >= w.start.getTime() && ms < w.end.getTime()) return w;
}
return null;
}
/**
* Group items into board windows spanning [openDate, departureDate]. Empty
* windows are kept so the UI shows every slot. Items whose timestamp falls
* outside the range still get their own window (nothing hidden). Items without
* a timestamp go to `pendingKey`.
* Group items into the real config booking-window cycles for a schedule. Empty
* windows are kept so the UI shows every cycle. Items whose timestamp falls
* outside every window (e.g. a booking created before the window opened) are
* attached to the nearest window by start time so nothing is hidden. Items
* without a timestamp go to `pendingKey`.
*/
export function groupBookingsIntoBoardWindows<T>(
items: T[],
getTimestamp: (item: T) => Date | null | undefined,
openDate: Date,
departureDate: Date,
direction: string | null | undefined,
departure: Date,
cfg: BoardWindowConfig,
pendingKey = 'pending-contract',
): Map<string, { window: BoardWindow | null; items: T[] }> {
const windows = listConfigBookingWindows(direction, departure, cfg);
const map = new Map<string, { window: BoardWindow | null; items: T[] }>();
for (const w of listBoardWindowsForRange(openDate, departureDate)) {
for (const w of windows) {
map.set(w.key, { window: w, items: [] });
}
map.set(pendingKey, { window: null, items: [] });
const firstWindow = windows[0] ?? null;
const lastWindow = windows[windows.length - 1] ?? null;
for (const item of items) {
const ts = getTimestamp(item);
if (!ts) {
map.get(pendingKey)!.items.push(item);
continue;
}
const w = boardWindowForTimestamp(ts);
if (!map.has(w.key)) {
map.set(w.key, { window: w, items: [] });
let w = configWindowForTimestamp(windows, ts);
if (!w) {
// Booked before the window opened → first cycle; after it closed → last cycle.
w =
firstWindow && ts.getTime() < firstWindow.start.getTime()
? firstWindow
: lastWindow;
}
if (!w) {
map.get(pendingKey)!.items.push(item);
continue;
}
map.get(w.key)!.items.push(item);
}

View File

@@ -590,6 +590,9 @@ export class BookingBatchService implements OnModuleInit {
const board: BatchBoardSchedule[] = [];
for (const s of schedules) {
if (s.status === "ARRIVED" || s.status === "CANCELLED") continue;
// Batch board is IMPORT-only: export is FCFS with no batch/priority calc,
// and domestic/legacy schedules run the legacy fill, not the window batch.
if (s.direction !== "IMPORT") continue;
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId));
@@ -630,6 +633,12 @@ export class BookingBatchService implements OnModuleInit {
if (s.status === "ARRIVED" || s.status === "CANCELLED") {
throw new BadRequestException("Schedule is no longer active");
}
// Batch board is IMPORT-only (export is FCFS, no batch/priority calc).
if (s.direction !== "IMPORT") {
throw new BadRequestException(
"The batch board only covers import schedules",
);
}
const wagonLengths = await this.loadWagonLengths();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
@@ -710,15 +719,18 @@ export class BookingBatchService implements OnModuleInit {
const loco = s.trainSet?.locomotive ?? null;
// Display windows span the whole booking window: from when it opened
// (schedule creation) through the scheduled departure, in 3-hour EAT slots.
const openDate = s.createdAt ?? s.scheduledDepartureDate ?? new Date();
// Display windows are the REAL booking-window cycles from the global-rules
// config (import: opens at windowOpenHour EAT importWindowLeadDays before
// departure, lasts windowDurationHours, reopens per reopenDelayMinutes;
// export: single FCFS lead window) — not a fixed clock grid.
const windowCfg = await this.trainSchedulingService.getWindowConfig();
const departureDate = s.scheduledDepartureDate ?? new Date();
const windowBuckets = groupBookingsIntoBoardWindows(
items,
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
openDate,
s.direction ?? null,
departureDate,
windowCfg,
);
const emptyCounts = () => ({

View File

@@ -3174,6 +3174,53 @@ export class TrainSchedulingService {
return days.includes(day);
}
/**
* Enforce the config-driven booking window at booking-create time.
*
* A booking is only allowed when the route has an OPEN departure the customer
* can join for the requested day — which, because the window engine keeps
* `bookingWindowStatus === 'OPEN'` in lockstep with the live window, means:
* - IMPORT: the day's window is currently open (opens at `windowOpenHour` EAT,
* `importWindowLeadDays` before departure, for `windowDurationHours`).
* - EXPORT: now is within `exportBookingLeadHours` before that departure (FCFS).
*
* `getBookableScheduleEntities` filters on `bookingWindowStatus === 'OPEN'`, so
* both gates are satisfied by checking that route for open departures. When a
* specific day is requested, require an open departure on that EAT day; when no
* day is given, require at least one open departure on the route at all.
* Throws `BadRequestException` when the window is closed. No-ops when the route
* yards are unknown (nothing to gate against).
*/
async assertBookingWindowOpen(input: {
originYardId?: string | null;
destinationYardId?: string | null;
scheduledDate?: Date | string | null;
direction?: string | null;
}): Promise<void> {
const { originYardId, destinationYardId } = input;
if (!originYardId || !destinationYardId) return;
const { days } = await this.getAvailableDays(originYardId, destinationYardId);
if (days.length === 0) {
throw new BadRequestException(
input.direction === 'EXPORT'
? 'The export booking window for this route is not open yet'
: 'The import booking window for this route is closed right now',
);
}
if (input.scheduledDate) {
const day = eatDay(new Date(input.scheduledDate));
if (!days.includes(day)) {
throw new BadRequestException(
input.direction === 'EXPORT'
? 'No departure is within the export booking window on the selected day'
: 'The import booking window is not open for the selected day',
);
}
}
}
private async mapScheduleDetail(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
) {

View File

@@ -178,12 +178,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
FREIGHT_PERMS.contracts.clearanceEtActions,
],
},
// {
// label: "Shipment Requests",
// href: "/dashboard/shipment-requests",
// icon: <Send />,
// permission: FREIGHT_PERMS.contracts.createBooking,
// },
{
label: "Shipment Requests",
href: "/dashboard/shipment-requests",
icon: <Send />,
permission: FREIGHT_PERMS.contracts.createBooking,
},
{
label: "GL Djibouti Clearance",
href: "/dashboard/gl-djibouti/clearance",

View File

@@ -10,7 +10,10 @@ export default function TrainSchedulingGlobalRulesPage() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<Partial<TrainSchedulingGlobalRules>>({});
// Fields hold raw NumberInput values (number | string) while editing; coerced to Number on save.
const [form, setForm] = useState<
Partial<Record<keyof TrainSchedulingGlobalRules, number | string>>
>({});
useEffect(() => {
void (async () => {
@@ -65,7 +68,7 @@ export default function TrainSchedulingGlobalRulesPage() {
description="Sum of all wagon lengths must not exceed this"
value={form.maxTrainLengthMeters ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxTrainLengthMeters: Number(value) }))
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
}
min={1}
disabled={loading}
@@ -75,7 +78,7 @@ export default function TrainSchedulingGlobalRulesPage() {
description="Total container and bulk cargo weight must not exceed this"
value={form.maxTrainWeightTons ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxTrainWeightTons: Number(value) }))
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
}
min={1}
disabled={loading}
@@ -84,7 +87,7 @@ export default function TrainSchedulingGlobalRulesPage() {
label="Max wagons per train"
value={form.maxWagonsPerTrain ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxWagonsPerTrain: Number(value) }))
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
}
min={1}
disabled={loading}
@@ -96,7 +99,7 @@ export default function TrainSchedulingGlobalRulesPage() {
onChange={(value) =>
setForm((current) => ({
...current,
max20ftContainerWeightTons: Number(value),
max20ftContainerWeightTons: value,
}))
}
min={0.001}
@@ -109,7 +112,7 @@ export default function TrainSchedulingGlobalRulesPage() {
onChange={(value) =>
setForm((current) => ({
...current,
max20ftPairWeightDiffTons: Number(value),
max20ftPairWeightDiffTons: value,
}))
}
min={0}
@@ -129,7 +132,7 @@ export default function TrainSchedulingGlobalRulesPage() {
description="The single booking day opens this many days before departure"
value={form.importWindowLeadDays ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, importWindowLeadDays: Number(value) }))
setForm((current) => ({ ...current, importWindowLeadDays: value }))
}
min={0}
disabled={loading}
@@ -139,7 +142,7 @@ export default function TrainSchedulingGlobalRulesPage() {
description="Export bookings are accepted first-come-first-serve starting this many hours before departure"
value={form.exportBookingLeadHours ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, exportBookingLeadHours: Number(value) }))
setForm((current) => ({ ...current, exportBookingLeadHours: value }))
}
min={1}
disabled={loading}
@@ -149,7 +152,7 @@ export default function TrainSchedulingGlobalRulesPage() {
description="Local hour the import window opens on its booking day (e.g. 8 = 08:00)"
value={form.windowOpenHour ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, windowOpenHour: Number(value) }))
setForm((current) => ({ ...current, windowOpenHour: value }))
}
min={0}
max={23}
@@ -159,7 +162,7 @@ export default function TrainSchedulingGlobalRulesPage() {
label="Window duration (hours)"
value={form.windowDurationHours ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, windowDurationHours: Number(value) }))
setForm((current) => ({ ...current, windowDurationHours: value }))
}
min={0.25}
max={12}
@@ -171,7 +174,7 @@ export default function TrainSchedulingGlobalRulesPage() {
description="Max staff time to accept booking documents after the window closes"
value={form.docReviewMinutes ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, docReviewMinutes: Number(value) }))
setForm((current) => ({ ...current, docReviewMinutes: value }))
}
min={0}
disabled={loading}
@@ -181,7 +184,7 @@ export default function TrainSchedulingGlobalRulesPage() {
description="Time a selected customer has to pay before the slot expires"
value={form.paymentWindowMinutes ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, paymentWindowMinutes: Number(value) }))
setForm((current) => ({ ...current, paymentWindowMinutes: value }))
}
min={1}
disabled={loading}
@@ -191,7 +194,7 @@ export default function TrainSchedulingGlobalRulesPage() {
description="Delay after window close before reopening when the train is not full (90 = 11:00 close → 12:30 reopen)"
value={form.reopenDelayMinutes ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, reopenDelayMinutes: Number(value) }))
setForm((current) => ({ ...current, reopenDelayMinutes: value }))
}
min={1}
disabled={loading}

View File

@@ -147,6 +147,31 @@ async function searchPlaces(
return found;
}
/**
* Build the address label for a picked place.
*
* For an establishment / POI (e.g. "Bole Medhanialem") Google's
* `formatted_address` is the *postal* address, which for many Ethiopian places
* collapses to just the city ("Addis Ababa, Ethiopia") — so taking it verbatim
* silently replaces the specific place the user picked with a broad city. The
* place `name` carries the specific label, so we lead with it and only append
* the formatted address for context when it doesn't already contain the name.
* Falls back to the prediction's own description (what the user saw and clicked).
*/
function placeDisplayName(
place: google.maps.places.PlaceResult | null,
prediction: PlacePrediction,
): string {
const name = place?.name?.trim();
const formatted = place?.formatted_address?.trim();
if (name && formatted) {
return formatted.toLowerCase().includes(name.toLowerCase())
? formatted
: `${name}, ${formatted}`;
}
return name || formatted || prediction.displayName;
}
/**
* Resolve a picked prediction to its coordinates via Place Details. Runs once
* per selection (closes the Autocomplete session), so billing stays on the
@@ -174,10 +199,7 @@ async function resolvePrediction(
return;
}
resolve({
displayName:
place?.formatted_address ||
place?.name ||
prediction.displayName,
displayName: placeDisplayName(place, prediction),
lat: loc.lat(),
lng: loc.lng(),
});

View File

@@ -371,16 +371,41 @@ function PriceConfirmModal({
onConfirm: () => void;
onReject: () => void;
}) {
const total = useMemo(
const baseTotal = useMemo(
() => (values ? computeShipmentTotal(contract, values) : null),
[contract, values],
);
const overweightLines = validation?.overweightLines ?? [];
const overweightSurchargeAmount = validation?.overweightSurchargeAmount ?? 0;
const pairingErrors = validation?.pairingErrors ?? [];
const hasPairingBlock = pairingErrors.length > 0;
const confirmDisabled = loading || validationLoading || hasPairingBlock;
// The contract's frozen unit rates (computeShipmentTotal) don't carry an
// overweight line — that surcharge only exists in the live rule engine. Fold
// the real amount from validateShipment into the displayed total so the
// customer sees the actual charge the overweight warning refers to, not just
// the warning text.
const total = useMemo(() => {
if (!baseTotal) return null;
if (!(overweightSurchargeAmount > 0)) return baseTotal;
return {
...baseTotal,
lines: [
...baseTotal.lines,
{
label: "Overweight surcharge",
unitPrice: overweightSurchargeAmount,
unit: "flat" as const,
quantity: 1,
amount: overweightSurchargeAmount,
},
],
total: baseTotal.total + overweightSurchargeAmount,
};
}, [baseTotal, overweightSurchargeAmount]);
return (
<Modal
opened={Boolean(values)}
@@ -456,8 +481,11 @@ function PriceConfirmModal({
</Text>
))}
<Text fz="xs" c="#9A5B00" mt={2}>
An overweight surcharge applies. You can still submit, or go
back and adjust weights.
{overweightSurchargeAmount > 0
? `An overweight surcharge of ${overweightSurchargeAmount.toLocaleString()} ${
validation?.currency ?? total?.currency ?? ""
} applies (included in the total below). You can still submit, or go back and adjust weights.`
: "An overweight surcharge applies. You can still submit, or go back and adjust weights."}
</Text>
</Stack>
</Alert>

View File

@@ -46,9 +46,13 @@ export interface OverweightLine {
* `overweightLines` are WARNINGS only (an overweight surcharge applies — the
* customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers
* that cannot be balanced onto wagons) and must prevent booking.
* `overweightSurchargeAmount` is the real overweight charge (same rate the
* booking is billed at on submit) so the confirm-modal total can include it.
*/
export interface ShipmentValidation {
overweightLines: OverweightLine[];
overweightSurchargeAmount: number;
currency: string | null;
pairingErrors: string[];
}