diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 4d647e3ef..0f60876d4 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -913,9 +913,9 @@ export class BillingService { dueAt, ...(issuing ? { - status: Freight.InvoiceStatus.Pending, - issuedAt: invoice.issuedAt ?? new Date(), - } + status: Freight.InvoiceStatus.Pending, + issuedAt: invoice.issuedAt ?? new Date(), + } : {}), }; await mg.update(Invoice, { id: invoice.id }, patch); @@ -1032,20 +1032,20 @@ export class BillingService { .getRepository(Invoice) .update({ id: invoice.id }, { paymentId: result.intentId }); - // // DEMO: manually fire the gateway `payment.succeeded` callback here, without - // // waiting for real gateway settlement. Runs AFTER the paymentId link above so - // // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO: - // // remove — real settlement flips this via the `${source}.invoice.paid` handler. - // if (!result.immediateSuccess) { - // await this.payment.handlePaymentEvent({ - // eventType: "payment.succeeded", - // eventId: `demo-${result.intentId}`, - // referenceId: invoice.sourceId, - // intentId: result.intentId, - // providerTxnId: result.providerTxnId, - // paidAt: (result.paidAt ?? new Date()).toISOString(), - // }); - // } + // DEMO: manually fire the gateway `payment.succeeded` callback here, without + // waiting for real gateway settlement. Runs AFTER the paymentId link above so + // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO: + // remove — real settlement flips this via the `${source}.invoice.paid` handler. + if (!result.immediateSuccess) { + await this.payment.handlePaymentEvent({ + eventType: "payment.succeeded", + eventId: `demo-${result.intentId}`, + referenceId: invoice.sourceId, + intentId: result.intentId, + providerTxnId: result.providerTxnId, + paidAt: (result.paidAt ?? new Date()).toISOString(), + }); + } if (result.immediateSuccess) { await this.settleByPaymentId( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/maintenance-reschedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/maintenance-reschedule.dto.ts new file mode 100644 index 000000000..d6b8ba10e --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/maintenance-reschedule.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsISO8601, IsOptional, IsString, IsUUID } from 'class-validator'; + +/** + * Admin maintenance reschedule: move a train's departure to a new date/time. + * Every allocated booking rides along (links and wagon assignments untouched); + * only the dates move — the schedule's train set, route, and window rule + * snapshot all stay exactly as they were. + */ +export class MaintenanceRescheduleDto { + @ApiProperty({ + example: '2026-07-20T05:00:00.000Z', + description: 'New scheduled departure date/time (ISO 8601)', + }) + @IsISO8601() + newDepartureDate!: string; + + @ApiPropertyOptional({ description: 'Why the train is being moved (logged)' }) + @IsOptional() + @IsString() + reason?: string; + + @ApiPropertyOptional({ + description: 'Client-side trigger tag (e.g. TRAIN_MAINTENANCE) — logged only', + }) + @IsOptional() + @IsString() + trigger?: string; + + @ApiPropertyOptional({ + description: + "The bookings the client believes are aboard — informational; the server moves the schedule's actual bookings", + type: [String], + }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + incomingBookingIds?: string[]; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index e4efaab9a..bbb19dbee 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -49,6 +49,7 @@ import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-qu import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto"; import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto"; import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto"; +import { MaintenanceRescheduleDto } from "./dto/maintenance-reschedule.dto"; import { TrainSchedulingService } from "./train-scheduling.service"; import { BookingBatchService } from "./booking-batch.service"; import { BookingJourneyService } from "./booking-journey.service"; @@ -711,6 +712,20 @@ export class TrainSchedulingController { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Post("schedules/:id/maintenance") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged", + }) + async maintenanceReschedule( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: MaintenanceRescheduleDto, + ) { + await this.trainSchedulingService.maintenanceReschedule(id, dto); + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + @Post("schedules/:id/doc-review-complete") @TrainSchedulingManage() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index f417f3dc2..b92a7ea90 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -91,6 +91,7 @@ import { import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto'; import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto'; +import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto'; import { type BookingWindowConfig } from './booking-window.config'; import { BookingWindowGateway } from './booking-window.gateway'; import { BookingNotifierService } from './booking-notifier.service'; @@ -864,6 +865,121 @@ export class TrainSchedulingService { return fresh ?? schedule; } + /** + * Maintenance reschedule: the admin moves a train (with everything aboard) to + * a new departure. Unlike {@link updateScheduleDate} this runs at ANY window + * phase and inside the booking lead window — a maintenance move is an + * operational fact, not a planning choice. What moves and what stays: + * + * - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every + * aboard/targeted booking's scheduledDate (the day-pool queries key on it, + * so a booking left on the old day would fall out of its own train's pool). + * - STAYS: train set, wagon assignments, schedule↔booking links, route, + * maxWagons, and the window RULE snapshot. Stamped window times are only + * re-derived for PRE_WINDOW schedules (their window hasn't run yet); a + * schedule mid- or post-window keeps its timeline untouched. + * + * Customers of every moved booking are notified (maintenanceMoved). + */ + async maintenanceReschedule( + id: string, + dto: MaintenanceRescheduleDto, + ): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException( + `Cannot reschedule a ${schedule.status.toLowerCase()} train`, + ); + } + + const departure = new Date(dto.newDepartureDate); + if (Number.isNaN(departure.getTime())) { + throw new BadRequestException('Invalid departure date.'); + } + if (departure.getTime() <= Date.now()) { + throw new BadRequestException('New departure must be in the future.'); + } + + const deltaMs = + departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime(); + const scheduledArrivalDate = schedule.scheduledArrivalDate + ? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs) + : undefined; + + // PRE_WINDOW only: the stamped open/close were derived from the old + // departure and the window hasn't opened yet, so re-derive them from the + // schedule's own rule snapshot against the new date (joining the target + // day's route group timeline when one exists, exactly like + // updateScheduleDate). Mid/post-window schedules keep their timeline. + const windowFields = + schedule.windowPhase === 'PRE_WINDOW' + ? await (async () => { + const merged = effectiveWindowConfig( + schedule, + await this.getWindowConfig(), + ); + const times = + schedule.direction === 'EXPORT' + ? computeExportWindowTimes(departure, merged) + : computeImportWindowTimes(departure, merged, new Date()); + const anchor = + schedule.direction === 'EXPORT' + ? null + : await this.findGroupWindowAnchor( + this.dataSource.manager, + schedule.originStationId, + schedule.destinationStationId, + departure, + ); + return anchor + ? this.groupWindowFieldsFrom(anchor, departure) + : { + windowOpensAt: times.windowOpensAt, + windowClosesAt: times.windowClosesAt, + }; + })() + : {}; + + await this.dataSource.getRepository(TrainSchedule).update(id, { + scheduledDepartureDate: departure, + ...(scheduledArrivalDate ? { scheduledArrivalDate } : {}), + ...windowFields, + }); + + // Everything aboard or targeted rides along: bookings linked on the train + // (schedule_bookings) plus reservations still pointing at it via + // train_schedule_id (paid-but-unlinked, awaiting payment, …). + const linkedIds = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId); + const targeted = await this.dataSource.getRepository(Booking).find({ + where: [{ trainScheduleId: id }, ...(linkedIds.length ? [{ id: In(linkedIds) }] : [])], + relations: { company: true }, + }); + const aboard = targeted.filter( + (b) => !['CANCELLED', 'EXPIRED', 'REJECTED'].includes(b.status), + ); + if (aboard.length) { + await this.dataSource + .getRepository(Booking) + .update(aboard.map((b) => b.id), { scheduledDate: departure } as never); + for (const booking of aboard) { + this.bookingNotifier.maintenanceMoved(booking, departure); + } + } + + this.logger.log( + `[MAINTENANCE] Schedule ${schedule.reference ?? id} moved to ${departure.toISOString()} ` + + `(${dto.trigger ?? 'TRAIN_MAINTENANCE'}${dto.reason ? `: ${dto.reason}` : ''}); ` + + `${aboard.length} booking(s) moved with the train.`, + ); + void this.emitWindowState(id); + + const fresh = await this.trainSchedulesRepository.findById(id); + return fresh ?? schedule; + } + /** * Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has * not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure @@ -4717,6 +4833,7 @@ export class TrainSchedulingService { createdAt: schedule.createdAt ?? null, scheduleDate: schedule.scheduledDepartureDate, trainNumber: schedule.trainNumber ?? null, + direction: schedule.direction ?? null, routeName: schedule.route ? formatRouteLabel(schedule.route) : null, origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: @@ -5942,6 +6059,64 @@ export class TrainSchedulingService { (snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]), ); + // The trainSet slots below are the PLANNED wagons (one per allocation). A + // schedule tied to a built train hauls EVERY coupled wagon — empty ones + // included (the pull-limit check already counts their tare) — so append the + // train's remaining wagons as consist-only entries and the composition views + // (scheduling-v2 finalize, batch-board composition tab) draw the train as it + // really is: loaded slots first, then the empty consist. Skipped for frozen + // (dispatched/arrived) schedules: their wagons are released and re-pinned to + // later trains, so the live consist no longer describes THIS departure. + const coveredPhysicalIds = new Set(); + for (const slot of schedule.trainSet?.wagons ?? []) { + const frozenSlot = isWagonAllocationFrozen + ? snapshotSlotByTrainSetWagonId.get(slot.id) + : undefined; + const physicalId = frozenSlot + ? frozenSlot.physicalWagonId + : slot.physicalWagonId ?? null; + if (physicalId) coveredPhysicalIds.add(physicalId); + } + const maxSlotSequenceNo = Math.max( + 0, + ...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo), + ); + const emptyConsistWagons = + schedule.trainSet?.trainId && !isWagonAllocationFrozen + ? ( + await this.dataSource.getRepository(Wagon).find({ + where: { trainId: schedule.trainSet.trainId }, + relations: { wagonType: true }, + order: { sequenceNumber: 'ASC' }, + }) + ) + .filter((wagon) => !coveredPhysicalIds.has(wagon.id)) + .map((wagon, index) => ({ + // Physical wagon id — there is no TrainSetWagon slot behind this + // row, so remove/edit affordances must stay disabled (consistOnly). + id: wagon.id, + sequenceNo: maxSlotSequenceNo + index + 1, + capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)), + lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)), + assignedWeightTons: 0, + tareWeightTons: wagon.wagonType + ? roundTons(Number(wagon.wagonType.tareWeightTons)) + : null, + status: 'EMPTY', + physicalWagonId: wagon.id, + physicalWagonNumber: wagon.wagonNumber ?? null, + wagonType: wagon.wagonType + ? { + id: wagon.wagonType.id, + code: wagon.wagonType.code, + name: wagon.wagonType.name, + } + : null, + allocations: [], + consistOnly: true, + })) + : []; + return { id: schedule.id, reference: schedule.reference ?? null, @@ -6109,7 +6284,8 @@ export class TrainSchedulingService { : null, })) ?? [], }; - }), + }) + .concat(emptyConsistWagons), } : null, bookings: diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 9e7fa15ca..b7807f3cd 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -26,6 +26,7 @@ import { TextInput, ThemeIcon, Title, + Tooltip, } from "@mantine/core"; import { AlertCircle, @@ -642,6 +643,20 @@ export default function GlCreateBookingForm() { }); }, [isContainer, contract, containerLines, contractWithReturn]); + // 20ft containers ride two per wagon, so an odd total leaves one unpaired and + // the booking can never be planned. The server rejects it too (the price + // modal's `pairingErrors`), but that only lands after GL has filled the whole + // form — mirror the customer portal (new-booking-form/schema.ts `calcWagons`) + // and block it inline instead. Size strings arrive as "20ft" from the contract + // scope but as a bare "20" from the rebook seed, so match on the leading digits. + const ft20Total = useMemo(() => { + if (!isContainer) return 0; + return containerLines + .filter((l) => parseInt(l.containerSize, 10) === 20) + .reduce((sum, l) => sum + Number(l.quantity || 0), 0); + }, [isContainer, containerLines]); + const hasOdd20ft = ft20Total % 2 === 1; + const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON"; const bulkErrors = useMemo(() => { @@ -688,7 +703,7 @@ export default function GlCreateBookingForm() { ) : !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer; - const formValid = cargoValid && !dateError && !routeError; + const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError; /** The create-booking DTO from the current form state — shared by the * authoritative price preview and the actual submit so what GL confirms is @@ -1286,6 +1301,21 @@ export default function GlCreateBookingForm() { )) )} + + {hasOdd20ft ? ( + } + title={`Odd number of 20ft containers (${ft20Total})`} + > + 20ft containers travel two per wagon, so they must be booked in + even numbers. Add one more 20ft container or remove one (e.g. + book {ft20Total + 1} or {ft20Total - 1} instead of {ft20Total}) + — the booking cannot be created with an unpaired 20ft container. + + ) : null} ) : ( @@ -1530,14 +1560,27 @@ export default function GlCreateBookingForm() { ) : null} - + {/* Mantine tooltips get no pointer events from a disabled button, + so the wrapper carries the hover target. */} + + + + diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PinWagonsForm.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PinWagonsForm.tsx index 80f72d9d0..ecaa43161 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PinWagonsForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PinWagonsForm.tsx @@ -36,7 +36,9 @@ export function PinWagonsForm({ autoFillOnMount?: boolean; }) { const originYardId = schedule.originStation?.id; - const slots = schedule.trainSet?.wagons ?? []; + // Consist-only rows are the built train's coupled-but-empty wagons — display + // entries with no TrainSetWagon slot behind them, so nothing can be pinned. + const slots = (schedule.trainSet?.wagons ?? []).filter((w) => !w.consistOnly); const [assignments, setAssignments] = useState>({}); const wagonOptionsByType = useMemo(() => { diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx index ee69fe145..8af9a6e34 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx @@ -359,7 +359,9 @@ function WagonCar({ {isEmpty ? ( - Empty slot — available for allocation. + {wagon.consistOnly + ? "Empty wagon — coupled on the train, no load planned." + : "Empty slot — available for allocation."} ) : ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx index 663ab2e6f..f735e6474 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx @@ -167,9 +167,9 @@ export const WagonCard = ({ - Empty slot + {wagon.consistOnly ? "Empty wagon — coupled on the train" : "Empty slot"} - {!isDispatched ? ( + {!isDispatched && !wagon.consistOnly ? ( + {/* Mantine tooltips get no pointer events from a disabled button, + so the wrapper carries the hover target. */} + + + + @@ -1265,6 +1292,28 @@ function CargoStep({ This contract has no container sizes in scope. )} + {(() => { + const ft20 = lines + .filter((l) => l.containerSize === "20ft") + .reduce((sum, l) => sum + Number(l.quantity || 0), 0); + if (ft20 % 2 !== 1) return null; + return ( + } + title={`Odd number of 20ft containers (${ft20})`} + > + + 20ft containers travel two per wagon, so they must be booked in + even numbers. Please add one more 20ft container or remove one + (e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20}) — the + booking cannot be submitted with an unpaired 20ft container. + + + ); + })()} ); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx index d4a562b24..c86791268 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useMutation, useQuery } from "@tanstack/react-query"; import { + Alert, Box, Button, Group, @@ -13,7 +14,7 @@ import { Textarea, Title, } from "@mantine/core"; -import { ArrowLeft, CalendarDays, Send } from "lucide-react"; +import { AlertCircle, ArrowLeft, CalendarDays, Send } from "lucide-react"; import toast from "react-hot-toast"; import type { Freight } from "@edr/types"; import { DatePickerInput } from "@mantine/dates"; @@ -98,7 +99,14 @@ export default function NewShipmentRequestPage() { contract.cargoScope?.[0]; const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM"; + // 20ft containers ride two per wagon, so an odd total can never be planned — + // and GL's create-booking form blocks it too, so an odd request would only + // dead-end there. Same even-number rule the booking forms apply. + const ft20Requested = isContainer ? Number(qtyBySize["20ft"]) || 0 : 0; + const hasOdd20ft = ft20Requested % 2 === 1; + const handleSubmit = () => { + if (hasOdd20ft) return; const dto: Freight.CreateBookingRequestDto = { contractRouteId: route?.id, scheduledDate: hasCustoms ? undefined : scheduledDate || undefined, @@ -202,6 +210,23 @@ export default function NewShipmentRequestPage() { /> )} + {hasOdd20ft ? ( + } + title={`Odd number of 20ft containers (${ft20Requested})`} + > + + 20ft containers travel two per wagon, so they must be requested + in even numbers. Please add one more 20ft container or remove + one (e.g. request {ft20Requested + 1} or {ft20Requested - 1}{" "} + instead of {ft20Requested}). + + + ) : null} + {capacity?.length ? ( Remaining capacity is shown on the contract — GL will validate your request. @@ -220,6 +245,7 @@ export default function NewShipmentRequestPage() { leftSection={} loading={submit.isPending} onClick={handleSubmit} + disabled={hasOdd20ft} > Submit shipment request