update booking logic for train schedules and customs contracts

This commit is contained in:
Marshal
2026-07-10 12:13:08 +00:00
parent 10111c9e01
commit a58650bdfb
4 changed files with 39 additions and 40 deletions

View File

@@ -2533,21 +2533,17 @@ export class BookingBatchService implements OnModuleInit {
} }
/** /**
* Keep schedule.max_wagons aligned with the train's real boarding limit: the * Keep schedule.max_wagons aligned with the train's boarding limit: the
* locomotive's length-derived slot count, floored by the physical wagons in * locomotive's length-derived slot count. The physical wagons currently in
* the train set (slots that exist on paper but not in the yard must not be * the train set do NOT cap this — bookings are admitted on length/weight
* sold — see {@link remainingBudget}). * alone and yard staff attach the wagons manually before departure.
*/ */
private async syncScheduleMaxWagons( private async syncScheduleMaxWagons(
schedule: TrainSchedule, schedule: TrainSchedule,
locomotive: Locomotive, locomotive: Locomotive,
): Promise<void> { ): Promise<void> {
const limits = await this.capacityLimits(locomotive); const limits = await this.capacityLimits(locomotive);
const physicalWagons = schedule.trainSet?.wagons?.length ?? 0; const maxWagons = limits.base.wagons;
const maxWagons =
physicalWagons > 0
? Math.min(limits.base.wagons, physicalWagons)
: limits.base.wagons;
if ((schedule.maxWagons ?? 0) !== maxWagons) { if ((schedule.maxWagons ?? 0) !== maxWagons) {
await this.dataSource await this.dataSource
.getRepository(TrainSchedule) .getRepository(TrainSchedule)
@@ -2664,11 +2660,10 @@ export class BookingBatchService implements OnModuleInit {
* reserved bookings already use ON THEIR OWN LEGS. A booking riding only * reserved bookings already use ON THEIR OWN LEGS. A booking riding only
* Dire→Djibouti leaves the Addis→Dire edges untouched. * Dire→Djibouti leaves the Addis→Dire edges untouched.
* *
* The wagon axis is additionally capped by the PHYSICAL wagons marshalled in * The wagon axis is the locomotive's length-derived slot count only — the
* the schedule's train set. The length-derived slot count says how many wagons * physical wagons currently marshalled in the train set do NOT cap it.
* the locomotive could pull, not how many exist: a 760m/54-slot train with a * Bookings are admitted on length/weight capacity and yard staff attach
* 50-wagon set once split-offered 4 wagons that were never buildable — the * the missing wagons manually before wagon assignment.
* customer paid and the wagon planner had nothing to assign.
*/ */
private async remainingBudget( private async remainingBudget(
schedule: TrainSchedule, schedule: TrainSchedule,
@@ -2676,12 +2671,7 @@ export class BookingBatchService implements OnModuleInit {
wagonDims: WagonDims, wagonDims: WagonDims,
): Promise<CorridorBudget> { ): Promise<CorridorBudget> {
const stops = await this.stopsForSchedule(schedule); const stops = await this.stopsForSchedule(schedule);
const physicalWagons = schedule.trainSet?.wagons?.length ?? 0; const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
const base =
physicalWagons > 0
? { ...limits.base, wagons: Math.min(limits.base.wagons, physicalWagons) }
: limits.base;
const budget = new CorridorBudget(stops, base, limits.tolerance);
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));

View File

@@ -241,11 +241,10 @@ export default function ContractDetailPage() {
}); });
// Intercity contracts are never window-gated: the shipment rides a passing // Intercity contracts are never window-gated: the shipment rides a passing
// import/export train that staff assign later, so booking is always open. // import/export train that staff assign later, so booking is always open.
// GENERAL contracts are also not gated at creation — the booking enters the // ONE_TIME and GENERAL contracts are both gated — booking is only possible
// per-booking clearance gate first and picks its shipment day at proceed time. // while a window on the contract's lane is open.
const bookingWindowOpen = const bookingWindowOpen =
contract?.tradeDirection === "DOMESTIC" || contract?.tradeDirection === "DOMESTIC" ||
contract?.contractKind === "GENERAL" ||
hasOpenWindow(bookingWindows); hasOpenWindow(bookingWindows);
// Draw-down capacity per cargo line (GENERAL contracts only). The backend // Draw-down capacity per cargo line (GENERAL contracts only). The backend

View File

@@ -146,14 +146,12 @@ export default function NewShipmentPage() {
// Coarse gate: if the customer deep-links here while no booking window is // Coarse gate: if the customer deep-links here while no booking window is
// open, show the same closed-state notice as the contract page instead of the // open, show the same closed-state notice as the contract page instead of the
// form. Still allowed the moment any window isOpenNow. Intercity contracts // form. Still allowed the moment any window isOpenNow. Applies to ONE_TIME
// are never window-gated — the shipment rides a passing train that staff // and GENERAL alike. Intercity contracts are never window-gated — the
// pick at finalize time, so booking is always open. GENERAL contracts are not // shipment rides a passing train that staff pick at finalize time, so
// gated at creation either: the booking enters per-booking clearance first // booking is always open.
// and picks its shipment day at proceed time.
if ( if (
contract.tradeDirection !== "DOMESTIC" && contract.tradeDirection !== "DOMESTIC" &&
contract.contractKind !== "GENERAL" &&
!hasOpenWindow(bookingWindows) !hasOpenWindow(bookingWindows)
) { ) {
return ( return (

View File

@@ -61,11 +61,16 @@ export default function NewShipmentRequestPage() {
const isContainer = contract.freightType === "CONTAINER"; const isContainer = contract.freightType === "CONTAINER";
const route = contract.routes?.[0]; const route = contract.routes?.[0];
// GENERAL customs contracts: GL schedules the shipment during clearance —
// the customer only states the quantity, never picks a date.
const hasCustoms =
contract.contractKind === "GENERAL" &&
(contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled);
const handleSubmit = () => { const handleSubmit = () => {
const dto: Freight.CreateBookingRequestDto = { const dto: Freight.CreateBookingRequestDto = {
contractRouteId: route?.id, contractRouteId: route?.id,
scheduledDate: scheduledDate || undefined, scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
notes: notes.trim() || undefined, notes: notes.trim() || undefined,
}; };
@@ -104,19 +109,26 @@ export default function NewShipmentRequestPage() {
<Paper withBorder radius="lg" p="xl" style={{ borderColor: BORDER }}> <Paper withBorder radius="lg" p="xl" style={{ borderColor: BORDER }}>
<Stack gap="md"> <Stack gap="md">
<DatePickerInput {!hasCustoms && (
label="Preferred shipment date" <DatePickerInput
placeholder="Pick a date" label="Preferred shipment date"
leftSection={<CalendarDays size={16} />} placeholder="Pick a date"
minDate={new Date().toISOString().slice(0, 10)} leftSection={<CalendarDays size={16} />}
value={scheduledDate || null} minDate={new Date().toISOString().slice(0, 10)}
onChange={(v) => setScheduledDate(v ?? "")} value={scheduledDate || null}
radius="md" onChange={(v) => setScheduledDate(v ?? "")}
popoverProps={{ withinPortal: true }} radius="md"
/> popoverProps={{ withinPortal: true }}
/>
)}
<NumberInput <NumberInput
label={isContainer ? "Number of containers" : "Cargo weight (tons)"} label={isContainer ? "Number of containers" : "Cargo weight (tons)"}
description={
hasCustoms
? "Global Logistics schedules the shipment date during customs clearance — you only state the quantity."
: undefined
}
value={quantity} value={quantity}
onChange={setQuantity} onChange={setQuantity}
min={1} min={1}