diff --git a/apps/edr-freight-api/src/migrations/2120000000000-AddScheduleWagonAllocationSnapshot.ts b/apps/edr-freight-api/src/migrations/2120000000000-AddScheduleWagonAllocationSnapshot.ts new file mode 100644 index 000000000..651c4ba13 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2120000000000-AddScheduleWagonAllocationSnapshot.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add a frozen wagon-allocation snapshot to each train schedule. + * + * Once a schedule leaves the editable DRAFT/SCHEDULED phase (dispatch / arrive / + * cancel), the same physical wagons get released and re-pinned onto later trains. + * The live wagon↔slot joins then no longer describe THIS train's plan, so an + * admin viewing a past schedule saw a mangled or "unavailable" allocation. + * + * This jsonb column stores a one-shot frozen copy of the wagon plan (per-slot + * physical wagon + booking allocations) captured at the transition. Non-editable + * schedules render from the snapshot; DRAFT/SCHEDULED still read live. NULL on + * legacy rows and while editable — the read path falls back to the live joins. + */ +export class AddScheduleWagonAllocationSnapshot2120000000000 + implements MigrationInterface +{ + name = "AddScheduleWagonAllocationSnapshot2120000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS wagon_allocation_snapshot jsonb; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS wagon_allocation_snapshot; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 2d4124cef..dd5011df3 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -31,6 +31,7 @@ import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { Contract } from '../contracts/entities/contract.entity'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; import { VehiclesService } from '../vehicles/vehicles.service'; @@ -1482,6 +1483,18 @@ export class BookingsService { ); } + // Surface the parent contract's reference for drawdown bookings — the + // portal detail header shows it (the entity has no contract relation, so + // the list attaches it via a raw join and the detail attaches it here). + if (booking.contractId) { + const contract = await this.dataSource.getRepository(Contract).findOne({ + where: { id: booking.contractId }, + select: { reference: true }, + }); + (booking as Booking & { contractReference?: string | null }).contractReference = + contract?.reference ?? null; + } + // Surface the assigned train's operational status so the portal stepper // can show the Arrival stage: the booking status stays IN_TRANSIT from // dispatch until delivery, so arrival is only knowable from the schedule. diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index 899b302fc..cf87622ab 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -1,5 +1,8 @@ import { BaseEntity } from '@edr/api-common'; -import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types'; +import { + TrainScheduleStatus as TrainScheduleStatusEnum, + WagonAllocationSnapshot, +} from '@edr/types'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; import { Yard } from '../../rule-engine/entities/yard.entity'; @@ -146,6 +149,14 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'rule_export_booking_lead_hours', type: 'int', nullable: true }) ruleExportBookingLeadHours?: number | null; + // Frozen wagon plan captured once when the schedule leaves the editable + // DRAFT/SCHEDULED phase (dispatch / arrive / cancel). Admin views of a + // non-editable schedule read THIS instead of the live wagon↔slot joins, so the + // historical allocation survives the same physical wagons being re-pinned onto + // later trains. NULL while DRAFT/SCHEDULED (read live) and on legacy rows. + @Column({ name: 'wagon_allocation_snapshot', type: 'jsonb', nullable: true }) + wagonAllocationSnapshot?: WagonAllocationSnapshot | null; + @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule) scheduleBookings?: TrainScheduleBooking[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 0d5acf772..1c829b4ce 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -1191,9 +1191,11 @@ export class BookingBatchService implements OnModuleInit { maxWagons: number | null, ): BatchBoardSchedule["capacity"] { const allocated = items.filter((i) => i.state === "ALLOCATED"); - const committed = items.filter( - (i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH", - ); + // Every booking still targeting this train holds gross weight — including + // PAID ones waiting for wagon allocation (WAITING) and post-dispatch + // catch-all states. Counting only ALLOCATED + SELECTED_FOR_BATCH zeroed the + // board's weight the moment customers paid. Only EXPIRED released its hold. + const committed = items.filter((i) => i.state !== "EXPIRED"); const caps = loco ? trainHardCaps({ maxPullWeightTons: Number(loco.maxPullWeightTons), 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 848ce15f8..e7727a467 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 @@ -4,6 +4,7 @@ SchedulingStatus, TrainCheckpointKind, TrainScheduleStatus as TrainScheduleStatusEnum, + WagonAllocationSnapshot, WagonMovementKind, WagonStatus, } from '@edr/types'; @@ -132,6 +133,8 @@ import { computeImportWindowTimes, earliestSchedulableDeparture, eatDay, + eatDayToUtc, + shiftEatDay, } from './batch-window.util'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { BookingJourneyService } from './booking-journey.service'; @@ -395,6 +398,108 @@ export class TrainSchedulingService { } } + /** + * A schedule GROUP is every schedule sharing an origin, destination, and EAT + * departure day — regardless of intermediate stops (ADD→DJ and ADD→DIRE→DJ + * group together, since the route entity keys only origin + destination). All + * schedules in a group must run ONE shared booking-window timeline so a + * customer booking on a later-created train is never expired by a sibling + * train's payment window closing on a different clock. + * + * Grouping keys off the columns the schedule already carries — no new schema. + * Callers pass a live `manager` so both the create (inside its transaction) and + * the update paths see uncommitted siblings. + */ + private async findGroupSiblings( + manager: EntityManager, + originStationId: string, + destinationStationId: string, + departure: Date, + excludeScheduleId?: string, + ): Promise { + const day = eatDay(departure); + const dayStart = eatDayToUtc(day, 0); + const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0); + const qb = manager + .getRepository(TrainSchedule) + .createQueryBuilder('s') + .where('s.originStationId = :originStationId', { originStationId }) + .andWhere('s.destinationStationId = :destinationStationId', { destinationStationId }) + .andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart }) + .andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart }); + if (excludeScheduleId) { + qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId }); + } + return qb.getMany(); + } + + /** + * The window timeline a brand-new schedule must adopt to join its route+day + * group. Returns the canonical open/close times + rule snapshot copied from an + * existing sibling, or null when this is the first schedule in the group (the + * caller then computes its own times as before — nothing changes for the + * single-schedule case). + * + * The anchor is preferably a still-PRE_WINDOW sibling (its times are the live + * group clock). If every sibling has already opened, we still copy the earliest + * sibling's frozen times so the new train lines up with the group the customer + * already sees rather than drifting onto its own `now`-based clock. + */ + private async findGroupWindowAnchor( + manager: EntityManager, + originStationId: string, + destinationStationId: string, + departure: Date, + ): Promise { + const siblings = await this.findGroupSiblings( + manager, + originStationId, + destinationStationId, + departure, + ); + if (siblings.length === 0) return null; + const withWindow = siblings.filter((s) => s.windowOpensAt != null); + if (withWindow.length === 0) return null; + const pending = withWindow.filter((s) => s.windowPhase === 'PRE_WINDOW'); + const pool = pending.length > 0 ? pending : withWindow; + // Earliest-opening sibling defines the group clock — deterministic and the + // one a customer would have seen first. + return pool.reduce((earliest, s) => + s.windowOpensAt!.getTime() < earliest.windowOpensAt!.getTime() ? s : earliest, + ); + } + + /** + * The window fields (open/close times + frozen rule snapshot) an anchor sibling + * hands down to the rest of its group. The shared open/close instants make every + * schedule in the group advance through the SAME open, doc-review, payment, and + * close instants on the shared 10s tick — doc-review/payment ends are derived + * live from these shared times during phase advance, so they fall in sync. + * + * `targetDeparture` is the JOINING schedule's own departure: the shared times + * are clamped to it so a group whose trains depart at different times of the + * same day never hands an earlier-departing train a window that outlives its + * departure (computeImport/ExportWindowTimes clamp to departure at source; this + * preserves that invariant when the anchor departed later). A window that would + * be entirely after this train's departure collapses to a zero-length window at + * departure — truthful, not a window that never closes. + */ + private groupWindowFieldsFrom(anchor: TrainSchedule, targetDeparture: Date) { + const cap = targetDeparture.getTime(); + const clamp = (d: Date | null | undefined): Date | null => + d == null ? null : d.getTime() > cap ? targetDeparture : d; + return { + windowOpensAt: clamp(anchor.windowOpensAt), + windowClosesAt: clamp(anchor.windowClosesAt), + ruleWindowOpenHour: anchor.ruleWindowOpenHour, + ruleWindowCloseHour: anchor.ruleWindowCloseHour, + ruleWindowDurationHours: anchor.ruleWindowDurationHours, + ruleReopenDelayMinutes: anchor.ruleReopenDelayMinutes, + ruleImportWindowLeadDays: anchor.ruleImportWindowLeadDays, + ruleExportBookingLeadHours: anchor.ruleExportBookingLeadHours, + }; + } + async getEligibleBookings(query: GetEligibleBookingsDto) { // Day-level pooling: when the wizard targets a schedule, surface the whole // (route, EAT day) pool — not just bookings pre-pinned to that train — by @@ -561,15 +666,53 @@ export class TrainSchedulingService { ); } - await this.dataSource.getRepository(TrainSchedule).update(id, { - windowOpensAt: times.windowOpensAt, - windowClosesAt: times.windowClosesAt, - ...windowRuleSnapshot(merged), - }); + // Route+day grouping (IMPORT/DOMESTIC only): the override applies to the + // WHOLE group — every schedule sharing this origin + destination + EAT + // departure day. They all adopt the SAME rule snapshot and share the SAME + // window open/close timeline (the whole point of grouping). The shared times + // are clamped to each train's OWN departure so a group whose trains depart at + // different times of the same day never hands an earlier-departing sibling a + // window that outlives its departure. Only still-PRE_WINDOW siblings are + // touched — a sibling that has already opened, finalized, or dispatched stays + // frozen on the times its customers were shown and simply drops out of the + // group; the remaining pending trains stay in sync. EXPORT is excluded + // (departure-anchored FCFS window, no cross-expiry), so an export override + // only touches its own schedule. + const ruleFields = windowRuleSnapshot(merged); + const repo = this.dataSource.getRepository(TrainSchedule); + const cap = (d: Date, departure: Date): Date => + d.getTime() > departure.getTime() ? departure : d; + + const targets: Array<{ id: string; departure: Date }> = [ + { id, departure: schedule.scheduledDepartureDate }, + ]; + if (schedule.direction !== 'EXPORT') { + const siblings = await this.findGroupSiblings( + this.dataSource.manager, + schedule.originStationId, + schedule.destinationStationId, + schedule.scheduledDepartureDate, + id, + ); + for (const sib of siblings) { + if (sib.windowPhase === 'PRE_WINDOW' && sib.scheduledDepartureDate) { + targets.push({ id: sib.id, departure: sib.scheduledDepartureDate }); + } + } + } + + for (const t of targets) { + await repo.update(t.id, { + windowOpensAt: cap(times.windowOpensAt, t.departure), + windowClosesAt: cap(times.windowClosesAt, t.departure), + ...ruleFields, + }); + } this.logger.log( - `Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`, + `Booking-window rule overridden for schedule ${id} and ${targets.length - 1} ` + + `route+day sibling(s) — reopens ${times.windowOpensAt.toISOString()}`, ); - void this.emitWindowState(id); + for (const t of targets) void this.emitWindowState(t.id); const fresh = await this.trainSchedulesRepository.findById(id); return fresh ?? schedule; @@ -634,14 +777,34 @@ export class TrainSchedulingService { ? computeExportWindowTimes(departure, merged) : computeImportWindowTimes(departure, merged, now); + // Moving the departure moves this train between route+day GROUPS. If the + // destination day already has a group (a sibling on the same origin + + // destination + new EAT day), adopt that group's shared timeline instead of + // the times just derived, so the rescheduled train lines up with the group + // it lands in rather than drifting onto its own clock. Otherwise it keeps its + // own re-derived times and becomes the anchor for that day. EXPORT is + // excluded — its window is anchored to its own departure, not shared. + const anchor = + schedule.direction === 'EXPORT' + ? null + : await this.findGroupWindowAnchor( + this.dataSource.manager, + schedule.originStationId, + schedule.destinationStationId, + departure, + ); + const windowFields = anchor + ? this.groupWindowFieldsFrom(anchor, departure) + : { windowOpensAt: times.windowOpensAt, windowClosesAt: times.windowClosesAt }; + await this.dataSource.getRepository(TrainSchedule).update(id, { scheduledDepartureDate: departure, - windowOpensAt: times.windowOpensAt, - windowClosesAt: times.windowClosesAt, + ...windowFields, }); this.logger.log( `Departure date changed for schedule ${id} → ${departure.toISOString()} ` + - `(window reopens ${times.windowOpensAt.toISOString()})`, + `(window reopens ${windowFields.windowOpensAt?.toISOString() ?? 'n/a'}` + + `${anchor ? `, joined route+day group anchor ${anchor.id}` : ''})`, ); void this.emitWindowState(id); @@ -854,21 +1017,44 @@ export class TrainSchedulingService { // already-open schedule keeps this snapshot, and the batch board draws its // windows from it rather than the live config. const ruleSnapshot = windowRuleSnapshot(windowCfg); - const windowFields = + // Route+day grouping (IMPORT/DOMESTIC only): if a schedule already exists + // on this origin + destination + EAT departure day, this new train JOINS + // its group and adopts the group's shared window timeline (open/close + + // frozen rule) verbatim — it does NOT compute its own `now`-based times. + // That keeps every train on the day advancing through the same open/ + // doc-review/payment/close instants, so a booking on one train is never + // expired by a sibling train's payment window closing on a different clock. + // First train in the group falls through to the normal computation. + // + // EXPORT is excluded: an export window is a single FCFS window anchored to + // each train's OWN departure (windowClosesAt = departure) with no + // doc-review/payment phase — so there is no cross-expiry to fix, and two + // export trains departing the same day at different times must keep their + // own departure-anchored windows. + const groupAnchor = direction === 'EXPORT' - ? { - bookingWindowStatus: 'CLOSED', - windowPhase: 'PRE_WINDOW', - ...ruleSnapshot, - ...computeExportWindowTimes(departure, windowCfg), - } + ? null + : await this.findGroupWindowAnchor( + manager, + route.originYardId, + route.destinationYardId, + departure, + ); + const computedTimes = + direction === 'EXPORT' + ? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) } : { // IMPORT and DOMESTIC share the import booking-day window cycle. - bookingWindowStatus: 'CLOSED', - windowPhase: 'PRE_WINDOW', ...ruleSnapshot, ...computeImportWindowTimes(departure, windowCfg, new Date()), }; + const windowFields = { + bookingWindowStatus: 'CLOSED', + windowPhase: 'PRE_WINDOW', + ...(groupAnchor + ? this.groupWindowFieldsFrom(groupAnchor, departure) + : computedTimes), + }; const maxWagons = (await this.resolveTrainLimitConfig(dto, limitLoco)) .maxWagonsPerTrain; // Retry past a concurrent insert that grabbed the same S- sequence @@ -1522,12 +1708,34 @@ export class TrainSchedulingService { await this.trainSchedulesRepository.updateStatus( scheduleId, TrainScheduleStatusEnum.Dispatched, - { actualDepartureAt: now, trainNumber }, + { + actualDepartureAt: now, + trainNumber, + // Freeze the wagon plan the moment the train leaves the editable phase. + wagonAllocationSnapshot: this.buildWagonAllocationSnapshot( + schedule, + TrainScheduleStatusEnum.Dispatched, + now, + ), + }, manager, ); if (schedule.trainSetId) { await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' }); } + // The train is out — every pinned wagon is ASSIGNED to this schedule and + // stays pinned so no other schedule can pick it while it's rolling. + const dispatchedPhysicalIds = (schedule.trainSet?.wagons ?? []) + .map((slot) => slot.physicalWagonId) + .filter((id): id is string => Boolean(id)); + if (dispatchedPhysicalIds.length) { + await manager + .getRepository(Wagon) + .update( + { id: In(dispatchedPhysicalIds) }, + { status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId }, + ); + } for (const sb of schedule.scheduleBookings ?? []) { await this.bookingsRepository.updateSchedulingFields( sb.bookingId, @@ -1604,7 +1812,24 @@ export class TrainSchedulingService { ); } void this.notifyScheduleBookings(schedule, 'dispatched'); - return this.getTrainScheduleById(scheduleId); + + const detail = await this.getTrainScheduleById(scheduleId); + // Surface a compact dispatch confirmation so the caller can toast the "train + // is out" info (train number, departure, wagons committed) without re-deriving it. + const dispatchedWagonCount = (schedule.trainSet?.wagons ?? []).filter( + (slot) => slot.physicalWagonId, + ).length; + return Object.assign(detail, { + dispatchInfo: { + // The real train number was assigned inside the txn — read it back off + // the persisted detail (schedule.trainNumber is the pre-dispatch value). + trainNumber: detail.trainNumber ?? schedule.trainNumber ?? null, + departedAt: now.toISOString(), + wagonsDispatched: dispatchedWagonCount, + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + }, + }); } async getImportDjiboutiOperation(scheduleId: string) { @@ -2561,7 +2786,15 @@ export class TrainSchedulingService { await this.trainSchedulesRepository.updateStatus( scheduleId, TrainScheduleStatusEnum.Arrived, - { actualArrivalAt: now }, + { + actualArrivalAt: now, + // Freeze the plan before the wagons below are released to their yards. + wagonAllocationSnapshot: this.buildWagonAllocationSnapshot( + schedule, + TrainScheduleStatusEnum.Arrived, + now, + ), + }, manager, ); @@ -2723,13 +2956,24 @@ export class TrainSchedulingService { throw new NotFoundException(`Train schedule ${id} not found`); } + const now = new Date(); + await this.dataSource.transaction(async (manager) => { await this.trainSchedulesRepository.updateStatus( id, TrainScheduleStatusEnum.Cancelled, - // Retire the booking window so a canceled schedule never lingers as an - // "open window" in booking-window lists or the legacy batch fill. - { bookingWindowStatus: 'CLOSED', windowPhase: 'DONE' }, + { + // Retire the booking window so a canceled schedule never lingers as an + // "open window" in booking-window lists or the legacy batch fill. + bookingWindowStatus: 'CLOSED', + windowPhase: 'DONE', + // Freeze the plan before the wagons below are released back to the yard. + wagonAllocationSnapshot: this.buildWagonAllocationSnapshot( + schedule, + TrainScheduleStatusEnum.Cancelled, + now, + ), + }, manager, ); if (schedule.trainSetId) { @@ -2757,6 +3001,9 @@ export class TrainSchedulingService { currentTrainScheduleId: null, trainSetWagonId: null, status: WagonStatus.Available, + // A cancelled train never left — its wagons stay/return at the origin + // yard, free to be re-pinned onto another schedule from there. + currentYardId: schedule.originStationId, }); } } @@ -3296,6 +3543,47 @@ export class TrainSchedulingService { })); } + /** + * Freeze the schedule's live wagon plan into a snapshot. Built from the fully + * hydrated graph (findByIdWithFullGraph) BEFORE the transition releases the + * physical wagons, so the historical allocation survives those wagons being + * re-pinned onto later trains. `capturedStatus` is the status being applied. + */ + private buildWagonAllocationSnapshot( + schedule: TrainSchedule, + capturedStatus: TrainScheduleStatusEnum, + capturedAt: Date, + ): WagonAllocationSnapshot { + const slots = [...(schedule.trainSet?.wagons ?? [])] + .sort((a, b) => a.sequenceNo - b.sequenceNo) + .map((wagon) => ({ + sequenceNo: wagon.sequenceNo, + trainSetWagonId: wagon.id, + physicalWagonId: wagon.physicalWagonId ?? null, + physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null, + wagonTypeId: wagon.wagonTypeId ?? null, + wagonTypeCode: wagon.wagonType?.code ?? null, + slotStatus: wagon.status ?? null, + boardYardId: wagon.boardYardId ?? null, + alightYardId: wagon.alightYardId ?? null, + allocations: (wagon.allocations ?? []).map((allocation) => ({ + bookingId: allocation.bookingId, + bookingReference: allocation.booking?.reference ?? null, + allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0, + loadType: allocation.loadType ?? null, + containerNumbers: (allocation.containerItems ?? []) + .map((item) => item.containerNumber) + .filter((n): n is string => Boolean(n)), + })), + })); + + return { + capturedStatus, + capturedAt: capturedAt.toISOString(), + slots, + }; + } + private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) { const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } }); for (const slot of slots) { @@ -4523,6 +4811,20 @@ export class TrainSchedulingService { bulkLoads.map((load) => [load.wagonBookingAllocationId, load]), ); + // Once a schedule leaves DRAFT/SCHEDULED, its physical wagons are released + // and re-pinned onto later trains — the live wagon↔slot joins no longer + // describe THIS train. If a frozen snapshot was captured at the transition, + // the per-slot wagon number + booking allocations are read from it instead. + const snapshot = schedule.wagonAllocationSnapshot ?? null; + const isWagonAllocationFrozen = Boolean( + snapshot && + schedule.status !== TrainScheduleStatusEnum.Draft && + schedule.status !== TrainScheduleStatusEnum.Scheduled, + ); + const snapshotSlotByTrainSetWagonId = new Map( + (snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]), + ); + return { id: schedule.id, reference: schedule.reference ?? null, @@ -4605,52 +4907,84 @@ export class TrainSchedulingService { })), wagons: [...(schedule.trainSet.wagons ?? [])] .sort((a, b) => a.sequenceNo - b.sequenceNo) - .map((wagon) => ({ - id: wagon.id, - sequenceNo: wagon.sequenceNo, - capacityTons: roundTons(Number(wagon.capacityTons)), - lengthMeters: roundTons(Number(wagon.lengthMeters)), - assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)), - // Empty-wagon weight — the pull limit hauls tare + cargo, so the - // frontend needs it to show the gross train weight. - tareWeightTons: wagon.wagonType - ? roundTons(Number(wagon.wagonType.tareWeightTons)) - : null, - status: wagon.status, - physicalWagonId: wagon.physicalWagonId ?? null, - physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null, - wagonType: wagon.wagonType - ? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name } - : null, - allocations: - wagon.allocations?.map((allocation) => ({ - id: allocation.id, - bookingId: allocation.bookingId, - bookingReference: allocation.booking?.reference ?? null, - allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)), - loadType: allocation.loadType ?? null, - status: allocation.status, - containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map( - (item) => ({ - id: item.id, - containerNumber: item.containerNumber ?? null, - containerTypeId: item.containerTypeId, - grossWeightTons: item.grossWeightTons ?? null, - containerId: item.containerId ?? null, - positionOnWagon: item.positionOnWagon ?? null, - bookingContainerId: item.bookingContainerId ?? null, - }), - ), - bulkLoad: bulkLoadsByAllocation.get(allocation.id) - ? { - id: bulkLoadsByAllocation.get(allocation.id)!.id, - weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons, - cargoDescription: - bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null, - } - : null, - })) ?? [], - })), + .map((wagon) => { + // Frozen schedules read the wagon number + allocations from the + // snapshot slot; the immutable slot geometry (capacity/type) still + // comes live. Falls back to live if a slot is missing from the snap. + const frozenSlot = isWagonAllocationFrozen + ? snapshotSlotByTrainSetWagonId.get(wagon.id) + : undefined; + return { + id: wagon.id, + sequenceNo: wagon.sequenceNo, + capacityTons: roundTons(Number(wagon.capacityTons)), + lengthMeters: roundTons(Number(wagon.lengthMeters)), + assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)), + // Empty-wagon weight — the pull limit hauls tare + cargo, so the + // frontend needs it to show the gross train weight. + tareWeightTons: wagon.wagonType + ? roundTons(Number(wagon.wagonType.tareWeightTons)) + : null, + status: wagon.status, + physicalWagonId: frozenSlot + ? frozenSlot.physicalWagonId + : wagon.physicalWagonId ?? null, + physicalWagonNumber: frozenSlot + ? frozenSlot.physicalWagonNumber + : wagon.physicalWagon?.wagonNumber ?? null, + wagonType: wagon.wagonType + ? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name } + : null, + allocations: frozenSlot + ? frozenSlot.allocations.map((allocation) => ({ + id: null, + bookingId: allocation.bookingId, + bookingReference: allocation.bookingReference, + allocatedWeightTons: roundTons(allocation.allocatedWeightTons), + loadType: allocation.loadType, + status: null, + // Frozen: container detail collapses to the captured numbers; + // per-container geometry isn't re-derivable post-release. + containerItems: allocation.containerNumbers.map((containerNumber) => ({ + id: null, + containerNumber, + containerTypeId: null, + grossWeightTons: null, + containerId: null, + positionOnWagon: null, + bookingContainerId: null, + })), + bulkLoad: null, + })) + : wagon.allocations?.map((allocation) => ({ + id: allocation.id, + bookingId: allocation.bookingId, + bookingReference: allocation.booking?.reference ?? null, + allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)), + loadType: allocation.loadType ?? null, + status: allocation.status, + containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map( + (item) => ({ + id: item.id, + containerNumber: item.containerNumber ?? null, + containerTypeId: item.containerTypeId, + grossWeightTons: item.grossWeightTons ?? null, + containerId: item.containerId ?? null, + positionOnWagon: item.positionOnWagon ?? null, + bookingContainerId: item.bookingContainerId ?? null, + }), + ), + bulkLoad: bulkLoadsByAllocation.get(allocation.id) + ? { + id: bulkLoadsByAllocation.get(allocation.id)!.id, + weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons, + cargoDescription: + bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null, + } + : null, + })) ?? [], + }; + }), } : null, bookings: @@ -4668,6 +5002,11 @@ export class TrainSchedulingService { loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded, wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId), })) ?? [], + // True when the wagon plan above is served from the frozen snapshot (schedule + // is dispatched/arrived/cancelled) rather than the live joins — the UI can badge + // it "historical" and skip re-pin affordances. + isWagonAllocationFrozen, + wagonAllocationSnapshot: snapshot, }; } diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx index 1bfa392bc..c1232fbba 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx @@ -232,6 +232,15 @@ export default function BookingWindowSettingsModal({ ) : ( + {!isExport ? ( + }> + These settings apply to every train on this route (same origin and + destination) departing the same day — they all share one booking + window, so it opens, moves to document review, opens for payment, + and closes at the same time for all of them. + + ) : null} + {isExport ? ( }> Export schedules use a single first-come-first-served window: it diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 964288c87..49b5c9aa7 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -196,11 +196,11 @@ const sidebarItems: SidebarItem[] = [ href: "/bookings", icon: , }, - { - label: "Tracking", - href: "/tracking", - icon: , - }, + // { + // label: "Tracking", + // href: "/tracking", + // icon: , + // }, { label: "Invoices", href: "/billing", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/KeyFactsStrip.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/KeyFactsStrip.tsx index 4d90a3256..27f76ae2d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/KeyFactsStrip.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/KeyFactsStrip.tsx @@ -1,5 +1,6 @@ -import { Box, SimpleGrid, Text } from "@mantine/core"; +import { Anchor, Box, SimpleGrid, Text } from "@mantine/core"; import type { ReactNode } from "react"; +import { Link } from "react-router-dom"; import type { Freight } from "@edr/types"; @@ -59,6 +60,22 @@ export function KeyFactsStrip({ booking }: { booking: BookingLike }) { } /> + {booking.contractId ? ( + + {booking.contractReference ?? "View contract"} + + } + /> + ) : null} - - - {label} - - - ); -} - // ── Context-sensitive action button ─────────────────────────────────────────── function PrimaryAction({ diff --git a/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx b/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx index c96bfdac4..780099d1f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx @@ -1,4 +1,4 @@ -import { Badge, Group, Text } from "@mantine/core"; +import { Badge, Box, Group, Text } from "@mantine/core"; import type { Freight } from "@edr/types"; import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants"; @@ -124,6 +124,45 @@ export function PaymentBadge({ status }: { status?: string | null }) { ); } +/** Booking status pill (dot + label) driven by the shared STATUS_CONFIG. */ +export function BookingStatusBadge({ status }: { status: string }) { + const cfg = STATUS_CONFIG[status]; + const label = cfg?.badgeLabel ?? titleCaseStatus(status); + const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7"; + const text = cfg + ? `var(--mantine-color-${cfg.badgeText}-7, #475569)` + : "#475569"; + const dot = cfg + ? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)` + : "#94A3B8"; + return ( + + + + {label} + + + ); +} + /** Whether a booking is assigned to a train yet (scheduling progress). */ export function SchedulingCell({ booking }: { booking: BookingLike & { trainScheduleId?: string | null; schedulingStatus?: string } }) { const assigned = !!booking.trainScheduleId; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 9de5613b8..64c64ce26 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -21,6 +21,7 @@ import { RingProgress, SimpleGrid, Stack, + Table, Tabs, Text, Title, @@ -60,6 +61,13 @@ import { fileViewUrl } from "@/constants/apiConfig"; import { useFileViewer } from "@/hooks/useFileViewer"; import toast from "react-hot-toast"; import { labelForDocCode } from "@/pages/bookings/resubmit"; +import { + BookingStatusBadge, + BookingTypeBadge, + CargoModeCell, + PaymentBadge, + SchedulingCell, +} from "@/pages/bookings/booking-display"; import { ContractClearancePanel } from "./ContractClearancePanel"; import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; @@ -148,8 +156,15 @@ interface DocGroup { * contract PDF, company profile / onboarding documents, and clearance documents * (everything else — the clearance set uses dynamic per-contract codes). Empty * groups are dropped so the tab only renders sections that have files. + * + * GENERAL contracts clear per booking, so their contract-level "clearance" + * leftovers are not shown — pass includeClearance: false to keep only the + * profile / business-licence sections. */ -function groupContractDocuments(files: ContractFile[]): DocGroup[] { +function groupContractDocuments( + files: ContractFile[], + { includeClearance = true }: { includeClearance?: boolean } = {}, +): DocGroup[] { const businessLicense: ContractFile[] = []; const profile: ContractFile[] = []; const clearance: ContractFile[] = []; @@ -159,7 +174,7 @@ function groupContractDocuments(files: ContractFile[]): DocGroup[] { if (f.code === "contract" || f.code.startsWith("signature_")) continue; else if (BUSINESS_LICENSE_DOC_CODES.has(f.code)) businessLicense.push(f); else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f); - else clearance.push(f); + else if (includeClearance) clearance.push(f); } return [ { key: "clearance", title: "Clearance documents", files: clearance }, @@ -328,7 +343,12 @@ export default function ContractDetailPage() { const routes = contract.routes ?? []; const pricing = contract.pricingBreakdown; const files = contract.files ?? []; - const docGroups = groupContractDocuments(files); + // GENERAL contracts clear per booking — clearance documents live on each + // booking's detail page, so this tab keeps only profile/licence documents. + const docGroups = groupContractDocuments(files, { + includeClearance: contract.contractKind !== "GENERAL", + }); + const docCount = docGroups.reduce((sum, g) => sum + g.files.length, 0); // The generated contract PDF — surfaced via a dedicated "View contract" button // in the header (it's excluded from the Documents tab groups). const contractPdf = files.find((f) => f.code === "contract"); @@ -621,7 +641,7 @@ export default function ContractDetailPage() { active={tab === "documents"} icon={} label="Documents" - count={files.length + (isPhasedCustomsClearance ? workflowFileCount : 0)} + count={docCount + (isPhasedCustomsClearance ? workflowFileCount : 0)} /> - The signed contract and any uploaded clearance documents will - appear here. + {isGeneral + ? "Your company profile documents (TIN certificate, business licence, ID) appear here. Clearance documents are managed on each booking." + : "The signed contract and any uploaded clearance documents will appear here."} ) : ( @@ -1399,49 +1420,113 @@ export default function ContractDetailPage() { ) : ( - - {contractBookings.map((booking) => ( - navigate(`/bookings/${booking.id}`)} - > - - - - + + + + + + + + + + + + + + + {contractBookings.map((booking) => { + const origin = + booking.originYard?.label ?? + booking.originYard?.code ?? + "—"; + const dest = + booking.destinationYard?.label ?? + booking.destinationYard?.code ?? + "—"; + const amount = Number(booking.totalAmount ?? 0); + return ( + navigate(`/bookings/${booking.id}`)} > - {booking.reference} - - {booking.scheduledDate && ( - - Ship{" "} - {new Date( - booking.scheduledDate, - ).toLocaleDateString()} - - )} - - - - - ))} - + + + + + + {booking.reference} + + + {booking.freightType === "BULK" + ? "Bulk cargo" + : "Container"} + + + + + + + + + + + + + {origin} → {dest} + + {booking.scheduledDate && ( + + {new Date( + booking.scheduledDate, + ).toLocaleDateString()} + + )} + + + + + + + + + + + + 0 ? INK : "#94A3B8", + whiteSpace: "nowrap", + }} + > + {amount > 0 + ? `ETB ${amount.toLocaleString()}` + : "—"} + + + + ); + })} + +
+ )} @@ -1572,6 +1657,23 @@ function SectionLabel({ ); } +/** Column header for the bookings table — mirrors the /bookings list styling. */ +function BookingsTh({ label, right }: { label: string; right?: boolean }) { + return ( + + + {label} + + + ); +} + /** * Unit noun for a capacity line: "containers" for CONTAINER freight, else the * bulk cargo's unit of measure ("tons" for PER_TON, "items" for PER_ITEM). diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 282c1113c..7e874ee0f 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -237,6 +237,42 @@ export enum WagonReadiness { ExportReady = "EXPORT_READY", } +/** + * Frozen record of one wagon slot's allocation at the moment a schedule leaves + * DRAFT/SCHEDULED (dispatch / arrive / cancel). Captured once into + * `train_schedules.wagon_allocation_snapshot` so the historical wagon plan + * survives later re-pinning of the same physical wagons onto other trains. + */ +export interface WagonAllocationSnapshotSlot { + sequenceNo: number; + trainSetWagonId: string; + physicalWagonId: string | null; + physicalWagonNumber: string | null; + wagonTypeId: string | null; + wagonTypeCode: string | null; + /** Wagon slot status (RESERVED/LOADED/…) at capture time. */ + slotStatus: string | null; + boardYardId: string | null; + alightYardId: string | null; + allocations: WagonAllocationSnapshotAllocation[]; +} + +export interface WagonAllocationSnapshotAllocation { + bookingId: string; + bookingReference: string | null; + allocatedWeightTons: number; + loadType: string | null; + containerNumbers: string[]; +} + +/** Whole-schedule frozen wagon plan written at a terminal/transit transition. */ +export interface WagonAllocationSnapshot { + /** Schedule status the snapshot was captured at (DISPATCHED/ARRIVED/CANCELLED). */ + capturedStatus: string; + capturedAt: string; + slots: WagonAllocationSnapshotSlot[]; +} + export type ScheduleTradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC"; /**