mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 10:58:14 +00:00
fix issues
This commit is contained in:
@@ -1,11 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
forwardRef,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
import { ExchangeService } from '@edr/api-common';
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingContainer } from '../bookings/entities/booking-container.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 { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||||
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||||
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
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 { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
@@ -62,6 +66,9 @@ export class ContractBookingService {
|
|||||||
private readonly workflowService: ClearanceWorkflowService,
|
private readonly workflowService: ClearanceWorkflowService,
|
||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly exchangeService: ExchangeService,
|
||||||
|
@Inject(forwardRef(() => TrainSchedulingService))
|
||||||
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createUnderContract(
|
async createUnderContract(
|
||||||
@@ -113,6 +120,20 @@ export class ContractBookingService {
|
|||||||
const generalCustoms =
|
const generalCustoms =
|
||||||
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
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.
|
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
||||||
const booking = await this.bookingsRepository.create({
|
const booking = await this.bookingsRepository.create({
|
||||||
reference,
|
reference,
|
||||||
@@ -582,13 +603,22 @@ export class ContractBookingService {
|
|||||||
maxAllowedTons: number;
|
maxAllowedTons: number;
|
||||||
excessTons: number;
|
excessTons: number;
|
||||||
}>;
|
}>;
|
||||||
|
overweightSurchargeAmount: number;
|
||||||
|
currency: string | null;
|
||||||
pairingErrors: string[];
|
pairingErrors: string[];
|
||||||
}> {
|
}> {
|
||||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||||
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||||
|
|
||||||
const lines = dto.containers ?? [];
|
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
|
// 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).
|
// rule engine can flag overweight per line (maxVgmTons × quantity vs total).
|
||||||
@@ -661,7 +691,28 @@ export class ContractBookingService {
|
|||||||
(v) => v.message,
|
(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> {
|
private async max20ftPairDiffTons(): Promise<number> {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se
|
|||||||
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
|
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
|
||||||
import { SignaturesModule } from '../signatures/signatures.module';
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
|
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||||
|
|
||||||
import { ContractsController } from './contracts.controller';
|
import { ContractsController } from './contracts.controller';
|
||||||
import { ContractsService } from './contracts.service';
|
import { ContractsService } from './contracts.service';
|
||||||
@@ -76,6 +77,10 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
||||||
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
||||||
forwardRef(() => BookingsModule),
|
forwardRef(() => BookingsModule),
|
||||||
|
// TrainSchedulingModule provides the config-driven booking-window gate used
|
||||||
|
// by ContractBookingService.createUnderContract. forwardRef because
|
||||||
|
// TrainSchedulingModule already imports ContractsModule.
|
||||||
|
forwardRef(() => TrainSchedulingModule),
|
||||||
ExchangeModule.forRootAsync({
|
ExchangeModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import {
|
|||||||
listBatchWindowsForDate,
|
listBatchWindowsForDate,
|
||||||
listBatchWindowsForBookings,
|
listBatchWindowsForBookings,
|
||||||
BATCH_WINDOW_START_HOURS,
|
BATCH_WINDOW_START_HOURS,
|
||||||
boardWindowForTimestamp,
|
listConfigBookingWindows,
|
||||||
listBoardWindowsForRange,
|
|
||||||
groupBookingsIntoBoardWindows,
|
groupBookingsIntoBoardWindows,
|
||||||
|
type BoardWindowConfig,
|
||||||
} from './batch-window.util';
|
} from './batch-window.util';
|
||||||
|
|
||||||
describe('batch-window.util', () => {
|
describe('batch-window.util', () => {
|
||||||
@@ -54,83 +54,87 @@ describe('batch-window.util', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('batch-window board windows (midnight-based 3h slots)', () => {
|
describe('batch-window board windows (config-driven booking cycles)', () => {
|
||||||
it('maps 04:00 EAT to the 03:00–06:00 slot', () => {
|
// Default rules: open 08:00 EAT, 3 days before departure, 3h long, reopen 90m later.
|
||||||
// 01:00 UTC = 04:00 EAT on 11 Jun
|
const cfg: BoardWindowConfig = {
|
||||||
const w = boardWindowForTimestamp(new Date('2026-06-11T01:00:00.000Z'));
|
importWindowLeadDays: 3,
|
||||||
expect(w.label).toContain('03:00');
|
windowOpenHour: 8,
|
||||||
expect(w.label).toContain('06:00');
|
windowDurationHours: 3,
|
||||||
expect(w.date).toBe('2026-06-11');
|
reopenDelayMinutes: 90,
|
||||||
expect(w.dateLabel).toContain('11 Jun');
|
exportBookingLeadHours: 24,
|
||||||
});
|
};
|
||||||
|
|
||||||
it('maps 00:30 EAT to the 00:00–03:00 slot of that EAT day', () => {
|
it('import: first window opens at windowOpenHour EAT, importWindowLeadDays before departure', () => {
|
||||||
// 21:30 UTC on 10 Jun = 00:30 EAT on 11 Jun
|
// departs 08 Jun 14:00 EAT (11:00 UTC) → window day = 05 Jun, opens 08:00 EAT (05:00 UTC)
|
||||||
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:00–24: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');
|
|
||||||
const departure = new Date('2026-06-08T11:00:00.000Z');
|
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].date).toBe('2026-06-05');
|
||||||
expect(windows[0].label).toContain('06:00');
|
expect(windows[0].label).toContain('08:00');
|
||||||
expect(windows[0].label).toContain('09:00');
|
expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z');
|
||||||
const last = windows[windows.length - 1];
|
// end = open + windowDurationHours (3h) = 08:00 → 11:00 EAT (08:00 UTC)
|
||||||
expect(last.date).toBe('2026-06-08');
|
expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z');
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles a same-day open→departure range', () => {
|
it('import: reopens reopenDelayMinutes after close, same booking day', () => {
|
||||||
const open = new Date('2026-06-05T05:00:00.000Z'); // 08:00 EAT (06–09 slot)
|
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||||
const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (12–15 slot)
|
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
|
||||||
const windows = listBoardWindowsForRange(open, departure);
|
// cycle 1: 08:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT
|
||||||
// 06,09,12 = 3 slots
|
expect(windows.length).toBeGreaterThanOrEqual(2);
|
||||||
expect(windows).toHaveLength(3);
|
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);
|
expect(windows.every((w) => w.date === '2026-06-05')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('buckets bookings by fullyExecutedAt and keeps empty + pending windows', () => {
|
it('export: single FCFS window exportBookingLeadHours before departure', () => {
|
||||||
const open = new Date('2026-06-05T05:00:00.000Z');
|
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||||
const departure = new Date('2026-06-06T11: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 = [
|
const items = [
|
||||||
{ id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → 06–09 on 5th
|
{ id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → inside cycle 1
|
||||||
{ id: 'b', ts: null }, // pending
|
{ id: 'b', ts: null }, // pending
|
||||||
];
|
];
|
||||||
const map = groupBookingsIntoBoardWindows(
|
const map = groupBookingsIntoBoardWindows(
|
||||||
items,
|
items,
|
||||||
(i) => i.ts,
|
(i) => i.ts,
|
||||||
open,
|
'IMPORT',
|
||||||
departure,
|
departure,
|
||||||
|
cfg,
|
||||||
'pending-contract',
|
'pending-contract',
|
||||||
);
|
);
|
||||||
const pending = map.get('pending-contract');
|
const pending = map.get('pending-contract');
|
||||||
expect(pending?.items.map((i) => i.id)).toEqual(['b']);
|
expect(pending?.items.map((i) => i.id)).toEqual(['b']);
|
||||||
const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a'));
|
const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a'));
|
||||||
expect(withA?.window?.date).toBe('2026-06-05');
|
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(
|
const emptyCount = [...map.values()].filter(
|
||||||
(b) => b.window && b.items.length === 0,
|
(b) => b.window && b.items.length === 0,
|
||||||
).length;
|
).length;
|
||||||
expect(emptyCount).toBeGreaterThan(0);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -230,14 +230,13 @@ export function listBatchWindowsForBookings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Board-display windows: full-day, midnight-based 3h slots over a date range.
|
// Board-display windows: the REAL booking-window cycles derived from the
|
||||||
// These are used ONLY for the batch-board UI grouping (not persisted, and
|
// train_scheduling_global_rules config (window open hour, lead days, duration,
|
||||||
// independent of the cron intake hours above).
|
// 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 (00–03, 03–06, … 21–24). */
|
|
||||||
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. */
|
/** A board window carries an EAT calendar date in addition to the slot times. */
|
||||||
export interface BoardWindow extends BatchWindow {
|
export interface BoardWindow extends BatchWindow {
|
||||||
/** EAT calendar day as ISO `YYYY-MM-DD`. */
|
/** EAT calendar day as ISO `YYYY-MM-DD`. */
|
||||||
@@ -246,6 +245,15 @@ export interface BoardWindow extends BatchWindow {
|
|||||||
dateLabel: string;
|
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', {
|
const dayLabelFmt = new Intl.DateTimeFormat('en-GB', {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
@@ -257,119 +265,124 @@ function pad2(n: number): string {
|
|||||||
return String(n).padStart(2, '0');
|
return String(n).padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a midnight-based 3h board window for an EAT calendar day + slot start hour. */
|
/** Wrap a [start, end] interval as a labelled BoardWindow keyed on its EAT day. */
|
||||||
function boardWindowFromEatStart(
|
function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
|
||||||
year: number,
|
const { year, month, day } = eatParts(start);
|
||||||
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`;
|
|
||||||
return {
|
return {
|
||||||
key: start.toISOString(),
|
key: start.toISOString(),
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
label: formatWindowLabel(start, end, endLabel),
|
label: formatWindowLabel(start, end),
|
||||||
date: `${year}-${pad2(month)}-${pad2(day)}`,
|
date: `${year}-${pad2(month)}-${pad2(day)}`,
|
||||||
dateLabel: dayLabelFmt.format(start),
|
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),
|
* The real booking-window cycles for a schedule, straight from config.
|
||||||
* clamped to the slot containing `openDate` on the first day and the slot
|
*
|
||||||
* containing `departureDate` on the last day. Returned in chronological order.
|
* 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(
|
export function listConfigBookingWindows(
|
||||||
openDate: Date,
|
direction: string | null | undefined,
|
||||||
departureDate: Date,
|
departure: Date,
|
||||||
|
cfg: BoardWindowConfig,
|
||||||
): BoardWindow[] {
|
): BoardWindow[] {
|
||||||
const startWin = boardWindowForTimestamp(openDate);
|
if (direction === 'EXPORT') {
|
||||||
const endWin = boardWindowForTimestamp(departureDate);
|
const start = new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
|
||||||
// Guard against an inverted range (departure before open).
|
return [boardWindowFromInterval(start, departure)];
|
||||||
if (endWin.start.getTime() < startWin.start.getTime()) {
|
|
||||||
return [startWin];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const windows: BoardWindow[] = [];
|
const windows: BoardWindow[] = [];
|
||||||
const seen = new Set<string>();
|
const durationMs = cfg.windowDurationHours * 3_600_000;
|
||||||
// Walk day-by-day in EAT, emitting each day's slots, stepping via UTC noon to
|
const reopenMs = cfg.reopenDelayMinutes * 60_000;
|
||||||
// avoid any boundary ambiguity, then filter to [startWin.start, endWin.start].
|
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
|
||||||
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();
|
|
||||||
|
|
||||||
while (cursor.getTime() <= lastDayMs) {
|
let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour);
|
||||||
const { year, month, day } = eatParts(cursor);
|
// Reopen stays on the same EAT booking day and before departure; cap at 12 cycles.
|
||||||
for (const h of BOARD_WINDOW_HOURS) {
|
for (let cycle = 0; cycle < 12; cycle += 1) {
|
||||||
const w = boardWindowFromEatStart(year, month, day, h);
|
if (opensAt.getTime() >= departure.getTime()) break;
|
||||||
if (
|
let closesAt = new Date(opensAt.getTime() + durationMs);
|
||||||
w.start.getTime() >= startWin.start.getTime() &&
|
if (closesAt.getTime() > departure.getTime()) closesAt = departure;
|
||||||
w.start.getTime() <= endWin.start.getTime() &&
|
windows.push(boardWindowFromInterval(opensAt, closesAt));
|
||||||
!seen.has(w.key)
|
|
||||||
) {
|
const nextOpensAt = new Date(closesAt.getTime() + reopenMs);
|
||||||
seen.add(w.key);
|
if (
|
||||||
windows.push(w);
|
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;
|
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
|
* Group items into the real config booking-window cycles for a schedule. Empty
|
||||||
* windows are kept so the UI shows every slot. Items whose timestamp falls
|
* windows are kept so the UI shows every cycle. Items whose timestamp falls
|
||||||
* outside the range still get their own window (nothing hidden). Items without
|
* outside every window (e.g. a booking created before the window opened) are
|
||||||
* a timestamp go to `pendingKey`.
|
* attached to the nearest window by start time so nothing is hidden. Items
|
||||||
|
* without a timestamp go to `pendingKey`.
|
||||||
*/
|
*/
|
||||||
export function groupBookingsIntoBoardWindows<T>(
|
export function groupBookingsIntoBoardWindows<T>(
|
||||||
items: T[],
|
items: T[],
|
||||||
getTimestamp: (item: T) => Date | null | undefined,
|
getTimestamp: (item: T) => Date | null | undefined,
|
||||||
openDate: Date,
|
direction: string | null | undefined,
|
||||||
departureDate: Date,
|
departure: Date,
|
||||||
|
cfg: BoardWindowConfig,
|
||||||
pendingKey = 'pending-contract',
|
pendingKey = 'pending-contract',
|
||||||
): Map<string, { window: BoardWindow | null; items: T[] }> {
|
): Map<string, { window: BoardWindow | null; items: T[] }> {
|
||||||
|
const windows = listConfigBookingWindows(direction, departure, cfg);
|
||||||
const map = new Map<string, { window: BoardWindow | null; items: T[] }>();
|
const map = new Map<string, { window: BoardWindow | null; items: T[] }>();
|
||||||
|
for (const w of windows) {
|
||||||
for (const w of listBoardWindowsForRange(openDate, departureDate)) {
|
|
||||||
map.set(w.key, { window: w, items: [] });
|
map.set(w.key, { window: w, items: [] });
|
||||||
}
|
}
|
||||||
map.set(pendingKey, { window: null, items: [] });
|
map.set(pendingKey, { window: null, items: [] });
|
||||||
|
|
||||||
|
const firstWindow = windows[0] ?? null;
|
||||||
|
const lastWindow = windows[windows.length - 1] ?? null;
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const ts = getTimestamp(item);
|
const ts = getTimestamp(item);
|
||||||
if (!ts) {
|
if (!ts) {
|
||||||
map.get(pendingKey)!.items.push(item);
|
map.get(pendingKey)!.items.push(item);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const w = boardWindowForTimestamp(ts);
|
let w = configWindowForTimestamp(windows, ts);
|
||||||
if (!map.has(w.key)) {
|
if (!w) {
|
||||||
map.set(w.key, { window: w, items: [] });
|
// 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);
|
map.get(w.key)!.items.push(item);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -590,6 +590,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const board: BatchBoardSchedule[] = [];
|
const board: BatchBoardSchedule[] = [];
|
||||||
for (const s of schedules) {
|
for (const s of schedules) {
|
||||||
if (s.status === "ARRIVED" || s.status === "CANCELLED") continue;
|
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 links = await linkRepo.find({ where: { trainScheduleId: s.id } });
|
||||||
const linkedIds = new Set(links.map((l) => l.bookingId));
|
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") {
|
if (s.status === "ARRIVED" || s.status === "CANCELLED") {
|
||||||
throw new BadRequestException("Schedule is no longer active");
|
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 wagonLengths = await this.loadWagonLengths();
|
||||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||||
@@ -710,15 +719,18 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
const loco = s.trainSet?.locomotive ?? null;
|
const loco = s.trainSet?.locomotive ?? null;
|
||||||
|
|
||||||
// Display windows span the whole booking window: from when it opened
|
// Display windows are the REAL booking-window cycles from the global-rules
|
||||||
// (schedule creation) through the scheduled departure, in 3-hour EAT slots.
|
// config (import: opens at windowOpenHour EAT importWindowLeadDays before
|
||||||
const openDate = s.createdAt ?? s.scheduledDepartureDate ?? new Date();
|
// 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 departureDate = s.scheduledDepartureDate ?? new Date();
|
||||||
const windowBuckets = groupBookingsIntoBoardWindows(
|
const windowBuckets = groupBookingsIntoBoardWindows(
|
||||||
items,
|
items,
|
||||||
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
|
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
|
||||||
openDate,
|
s.direction ?? null,
|
||||||
departureDate,
|
departureDate,
|
||||||
|
windowCfg,
|
||||||
);
|
);
|
||||||
|
|
||||||
const emptyCounts = () => ({
|
const emptyCounts = () => ({
|
||||||
|
|||||||
@@ -3174,6 +3174,53 @@ export class TrainSchedulingService {
|
|||||||
return days.includes(day);
|
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(
|
private async mapScheduleDetail(
|
||||||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -178,12 +178,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// {
|
{
|
||||||
// label: "Shipment Requests",
|
label: "Shipment Requests",
|
||||||
// href: "/dashboard/shipment-requests",
|
href: "/dashboard/shipment-requests",
|
||||||
// icon: <Send />,
|
icon: <Send />,
|
||||||
// permission: FREIGHT_PERMS.contracts.createBooking,
|
permission: FREIGHT_PERMS.contracts.createBooking,
|
||||||
// },
|
},
|
||||||
{
|
{
|
||||||
label: "GL Djibouti Clearance",
|
label: "GL Djibouti Clearance",
|
||||||
href: "/dashboard/gl-djibouti/clearance",
|
href: "/dashboard/gl-djibouti/clearance",
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -65,7 +68,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
description="Sum of all wagon lengths must not exceed this"
|
description="Sum of all wagon lengths must not exceed this"
|
||||||
value={form.maxTrainLengthMeters ?? ""}
|
value={form.maxTrainLengthMeters ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, maxTrainLengthMeters: Number(value) }))
|
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
|
||||||
}
|
}
|
||||||
min={1}
|
min={1}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -75,7 +78,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
description="Total container and bulk cargo weight must not exceed this"
|
description="Total container and bulk cargo weight must not exceed this"
|
||||||
value={form.maxTrainWeightTons ?? ""}
|
value={form.maxTrainWeightTons ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, maxTrainWeightTons: Number(value) }))
|
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
|
||||||
}
|
}
|
||||||
min={1}
|
min={1}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -84,7 +87,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
label="Max wagons per train"
|
label="Max wagons per train"
|
||||||
value={form.maxWagonsPerTrain ?? ""}
|
value={form.maxWagonsPerTrain ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, maxWagonsPerTrain: Number(value) }))
|
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
|
||||||
}
|
}
|
||||||
min={1}
|
min={1}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -96,7 +99,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({
|
setForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
max20ftContainerWeightTons: Number(value),
|
max20ftContainerWeightTons: value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
min={0.001}
|
min={0.001}
|
||||||
@@ -109,7 +112,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({
|
setForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
max20ftPairWeightDiffTons: Number(value),
|
max20ftPairWeightDiffTons: value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
min={0}
|
min={0}
|
||||||
@@ -129,7 +132,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
description="The single booking day opens this many days before departure"
|
description="The single booking day opens this many days before departure"
|
||||||
value={form.importWindowLeadDays ?? ""}
|
value={form.importWindowLeadDays ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, importWindowLeadDays: Number(value) }))
|
setForm((current) => ({ ...current, importWindowLeadDays: value }))
|
||||||
}
|
}
|
||||||
min={0}
|
min={0}
|
||||||
disabled={loading}
|
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"
|
description="Export bookings are accepted first-come-first-serve starting this many hours before departure"
|
||||||
value={form.exportBookingLeadHours ?? ""}
|
value={form.exportBookingLeadHours ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, exportBookingLeadHours: Number(value) }))
|
setForm((current) => ({ ...current, exportBookingLeadHours: value }))
|
||||||
}
|
}
|
||||||
min={1}
|
min={1}
|
||||||
disabled={loading}
|
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)"
|
description="Local hour the import window opens on its booking day (e.g. 8 = 08:00)"
|
||||||
value={form.windowOpenHour ?? ""}
|
value={form.windowOpenHour ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, windowOpenHour: Number(value) }))
|
setForm((current) => ({ ...current, windowOpenHour: value }))
|
||||||
}
|
}
|
||||||
min={0}
|
min={0}
|
||||||
max={23}
|
max={23}
|
||||||
@@ -159,7 +162,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
label="Window duration (hours)"
|
label="Window duration (hours)"
|
||||||
value={form.windowDurationHours ?? ""}
|
value={form.windowDurationHours ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, windowDurationHours: Number(value) }))
|
setForm((current) => ({ ...current, windowDurationHours: value }))
|
||||||
}
|
}
|
||||||
min={0.25}
|
min={0.25}
|
||||||
max={12}
|
max={12}
|
||||||
@@ -171,7 +174,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
description="Max staff time to accept booking documents after the window closes"
|
description="Max staff time to accept booking documents after the window closes"
|
||||||
value={form.docReviewMinutes ?? ""}
|
value={form.docReviewMinutes ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, docReviewMinutes: Number(value) }))
|
setForm((current) => ({ ...current, docReviewMinutes: value }))
|
||||||
}
|
}
|
||||||
min={0}
|
min={0}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -181,7 +184,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
description="Time a selected customer has to pay before the slot expires"
|
description="Time a selected customer has to pay before the slot expires"
|
||||||
value={form.paymentWindowMinutes ?? ""}
|
value={form.paymentWindowMinutes ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, paymentWindowMinutes: Number(value) }))
|
setForm((current) => ({ ...current, paymentWindowMinutes: value }))
|
||||||
}
|
}
|
||||||
min={1}
|
min={1}
|
||||||
disabled={loading}
|
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)"
|
description="Delay after window close before reopening when the train is not full (90 = 11:00 close → 12:30 reopen)"
|
||||||
value={form.reopenDelayMinutes ?? ""}
|
value={form.reopenDelayMinutes ?? ""}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, reopenDelayMinutes: Number(value) }))
|
setForm((current) => ({ ...current, reopenDelayMinutes: value }))
|
||||||
}
|
}
|
||||||
min={1}
|
min={1}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|||||||
@@ -147,6 +147,31 @@ async function searchPlaces(
|
|||||||
return found;
|
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
|
* Resolve a picked prediction to its coordinates via Place Details. Runs once
|
||||||
* per selection (closes the Autocomplete session), so billing stays on the
|
* per selection (closes the Autocomplete session), so billing stays on the
|
||||||
@@ -174,10 +199,7 @@ async function resolvePrediction(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolve({
|
resolve({
|
||||||
displayName:
|
displayName: placeDisplayName(place, prediction),
|
||||||
place?.formatted_address ||
|
|
||||||
place?.name ||
|
|
||||||
prediction.displayName,
|
|
||||||
lat: loc.lat(),
|
lat: loc.lat(),
|
||||||
lng: loc.lng(),
|
lng: loc.lng(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -371,16 +371,41 @@ function PriceConfirmModal({
|
|||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onReject: () => void;
|
onReject: () => void;
|
||||||
}) {
|
}) {
|
||||||
const total = useMemo(
|
const baseTotal = useMemo(
|
||||||
() => (values ? computeShipmentTotal(contract, values) : null),
|
() => (values ? computeShipmentTotal(contract, values) : null),
|
||||||
[contract, values],
|
[contract, values],
|
||||||
);
|
);
|
||||||
|
|
||||||
const overweightLines = validation?.overweightLines ?? [];
|
const overweightLines = validation?.overweightLines ?? [];
|
||||||
|
const overweightSurchargeAmount = validation?.overweightSurchargeAmount ?? 0;
|
||||||
const pairingErrors = validation?.pairingErrors ?? [];
|
const pairingErrors = validation?.pairingErrors ?? [];
|
||||||
const hasPairingBlock = pairingErrors.length > 0;
|
const hasPairingBlock = pairingErrors.length > 0;
|
||||||
const confirmDisabled = loading || validationLoading || hasPairingBlock;
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened={Boolean(values)}
|
opened={Boolean(values)}
|
||||||
@@ -456,8 +481,11 @@ function PriceConfirmModal({
|
|||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
<Text fz="xs" c="#9A5B00" mt={2}>
|
<Text fz="xs" c="#9A5B00" mt={2}>
|
||||||
An overweight surcharge applies. You can still submit, or go
|
{overweightSurchargeAmount > 0
|
||||||
back and adjust weights.
|
? `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>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@@ -46,9 +46,13 @@ export interface OverweightLine {
|
|||||||
* `overweightLines` are WARNINGS only (an overweight surcharge applies — the
|
* `overweightLines` are WARNINGS only (an overweight surcharge applies — the
|
||||||
* customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers
|
* customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers
|
||||||
* that cannot be balanced onto wagons) and must prevent booking.
|
* 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 {
|
export interface ShipmentValidation {
|
||||||
overweightLines: OverweightLine[];
|
overweightLines: OverweightLine[];
|
||||||
|
overweightSurchargeAmount: number;
|
||||||
|
currency: string | null;
|
||||||
pairingErrors: string[];
|
pairingErrors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user