feat(freight): GL rebook of cancelled wagons, seal+train required on completion, train/voyage in SMS

This commit is contained in:
marshal
2026-09-02 22:28:11 +00:00
parent 6efa9dab93
commit 1d3361d442
21 changed files with 1872 additions and 122 deletions

View File

@@ -13,6 +13,7 @@ describe('BookingJourneyService.autoPlaceOnFreedWagons', () => {
{ emit: jest.fn() } as never, // events
{} as never, // notifications
{} as never, // inbox
{ record: jest.fn() } as never, // wagonHistory
);
const schedule = { id: 'sched-1', trainSetId: 'ts-1' };

View File

@@ -32,6 +32,7 @@ import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/expor
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { notifyCarriageAcceptanceReady } from '../notifications/notify-company.util';
import { WagonEventInput, WagonHistoryService } from '../wagon-history/wagon-history.service';
/**
* Per-booking journey along a train's corridor — for EVERY trade direction.
@@ -61,6 +62,7 @@ export class BookingJourneyService {
private readonly events: EventEmitter2,
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
private readonly wagonHistory: WagonHistoryService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {}
@@ -122,6 +124,7 @@ export class BookingJourneyService {
loadedAt: now,
loadedByUserId: userId ?? null,
});
await this.wagonHistory.record(manager, this.cargoEvent(target, schedule, booking, 'LOADED', now, userId ?? null));
if (!booking.loadingStartedAt) {
await manager
.getRepository(Booking)
@@ -241,7 +244,12 @@ export class BookingJourneyService {
if (booking.tradeDirection === 'DOMESTIC') {
await this.autoPlaceOnFreedWagons(manager, schedule, booking);
}
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED');
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED', {
userId: userId ?? null,
at: now,
schedule,
booking,
});
// Keep the schedule↔booking link's tracking flag in sync — the dispatch
// readiness warnings and workspace badges read loading_status, not loadedAt.
await manager
@@ -334,6 +342,7 @@ export class BookingJourneyService {
unloadedAt: now,
unloadedByUserId: userId ?? null,
});
await this.wagonHistory.record(null, this.cargoEvent(target, schedule, booking, 'DEPARTED', now, userId ?? null));
const remaining = allocations.filter(
(a) => a.id !== target.id && a.status !== 'DEPARTED',
@@ -391,7 +400,12 @@ export class BookingJourneyService {
arrivedAt: now,
arrivedByUserId: userId ?? null,
} as never);
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED');
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED', {
userId: userId ?? null,
at: now,
schedule,
booking,
});
await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null);
// The facility took the cargo off the train — raise its GRN. Where the
// facility also stores cargo (Indode), the event links the storage record
@@ -908,12 +922,61 @@ export class BookingJourneyService {
scheduleId: string,
bookingId: string,
status: 'LOADED' | 'DEPARTED',
ctx?: { userId: string | null; at: Date; schedule: TrainSchedule; booking: Booking },
): Promise<void> {
const allocations = await this.allocationsForBooking(manager, scheduleId, bookingId);
if (!allocations.length) return;
await manager
.getRepository(WagonBookingAllocation)
.update({ id: In(allocations.map((a) => a.id)) }, { status });
if (!ctx) return;
// Per-wagon cargo history. Allocations already at (or past) the target
// status were logged by the per-wagon load/unload endpoint — skip them so
// the whole-booking completion never double-writes a wagon's row.
const pending = allocations.filter((a) =>
status === 'LOADED'
? a.status !== 'LOADED' && a.status !== 'DEPARTED'
: a.status !== 'DEPARTED',
);
await this.wagonHistory.record(
manager,
pending
.map((a) => this.cargoEvent(a, ctx.schedule, ctx.booking, status, ctx.at, ctx.userId))
.filter((e): e is WagonEventInput => e !== null),
);
}
/** CARGO_LOADED / CARGO_UNLOADED row for one allocation's physical wagon; null when the slot has no wagon pinned. */
private cargoEvent(
alloc: WagonBookingAllocation & { trainSetWagon?: TrainSetWagon },
schedule: TrainSchedule,
booking: Booking,
status: 'LOADED' | 'DEPARTED',
at: Date,
userId: string | null,
): WagonEventInput | null {
const slot = alloc.trainSetWagon;
if (!slot?.physicalWagonId) return null;
const loaded = status === 'LOADED';
return {
wagonId: slot.physicalWagonId,
wagonNumber: slot.physicalWagon?.wagonNumber ?? null,
type: loaded ? Freight.WagonEventType.CargoLoaded : Freight.WagonEventType.CargoUnloaded,
occurredAt: at,
actorUserId: userId,
toYardId: loaded
? (slot.boardYardId ?? schedule.originStationId ?? null)
: (booking.destinationYardId ?? slot.alightYardId ?? schedule.destinationStationId ?? null),
trainScheduleId: schedule.id,
trainId: schedule.trainSet?.trainId ?? null,
bookingId: booking.id,
toValue: booking.reference ?? null,
metadata: {
allocationId: alloc.id,
loadType: alloc.loadType ?? null,
weightTons: Number(alloc.allocatedWeightTons ?? 0),
},
};
}
private async allocationsForBooking(
@@ -1001,6 +1064,20 @@ export class BookingJourneyService {
? Freight.WagonStatus.Assigned
: Freight.WagonStatus.Available,
});
await this.wagonHistory.record(manager, {
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
type: Freight.WagonEventType.ReleasedAtUnload,
occurredAt: now,
actorUserId: userId,
fromYardId: boardYardId ?? null,
toYardId: booking.destinationYardId ?? null,
trainScheduleId: schedule.id,
trainId: wagon.trainId ?? null,
bookingId: booking.id,
toValue: wagon.trainId ? Freight.WagonStatus.Assigned : Freight.WagonStatus.Available,
metadata: { slotId: slot.id },
});
}
}
}

View File

@@ -1673,6 +1673,8 @@ describe('TrainSchedulingService', () => {
save: jest.fn().mockResolvedValue(undefined),
create: jest.fn((x: unknown) => x),
})),
// Wagon-history lookup of the released allocations' physical wagons.
query: jest.fn().mockResolvedValue([]),
};
beforeEach(() => {

View File

@@ -6,6 +6,7 @@
TrainCheckpointKind,
TrainScheduleStatus as TrainScheduleStatusEnum,
WagonAllocationSnapshot,
WagonEventType,
WagonMovementKind,
WagonStatus,
} from '@edr/types';
@@ -71,6 +72,7 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service';
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
@@ -421,8 +423,28 @@ export class TrainSchedulingService {
@Optional()
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService?: BookingBatchService,
// Per-wagon history ledger (global module). @Optional keeps the positional
// spec constructors working; production always has it.
@Optional() private readonly wagonHistory?: WagonHistoryService,
) {}
/** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */
private async wagonsOfAllocations(
manager: EntityManager,
allocationIds: string[],
): Promise<Array<{ allocationId: string; wagonId: string; wagonNumber: string; yardId: string | null; trainId: string | null }>> {
if (!allocationIds.length) return [];
return manager.query(
`SELECT a.id AS "allocationId", w.id AS "wagonId", w.wagon_number AS "wagonNumber",
w.current_yard_id AS "yardId", w.train_id AS "trainId"
FROM freight.wagon_booking_allocations a
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
WHERE a.id = ANY($1::uuid[])`,
[allocationIds],
);
}
/**
* Notify each booking's customer that their shipment was dispatched / arrived,
* with a deep-link to the booking. Fire-and-forget — never blocks the action.
@@ -2421,6 +2443,21 @@ export class TrainSchedulingService {
manager,
);
await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(allocationIds, manager);
const carried = await this.wagonsOfAllocations(manager, allocationIds);
await this.wagonHistory?.record(
manager,
carried.map((c) => ({
wagonId: c.wagonId,
wagonNumber: c.wagonNumber,
type: WagonEventType.BookingUnassigned,
actorUserId: userId ?? null,
fromYardId: c.yardId,
trainId: c.trainId,
trainScheduleId: scheduleId,
bookingId,
metadata: { allocationId: c.allocationId },
})),
);
await manager.getRepository(WagonBookingAllocation).delete(allocationIds);
}
@@ -2487,6 +2524,18 @@ export class TrainSchedulingService {
trainSetWagonId: null,
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
});
await this.wagonHistory?.record(manager, {
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
type: WagonEventType.ReleasedFromSchedule,
actorUserId: userId ?? null,
fromYardId: wagon.currentYardId ?? null,
trainId: wagon.trainId ?? null,
trainScheduleId: scheduleId,
bookingId,
toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
reason: 'Booking unassigned from the dispatched train',
});
}
}
await manager.getRepository(TrainSetWagon).delete(slot.id);
@@ -2830,10 +2879,34 @@ export class TrainSchedulingService {
// The pin lives ONLY on the schedule's slot — the Wagon entity keeps
// its status untouched so other schedules can still use the wagon.
const previousPinId = slotById.get(assignment.trainSetWagonId)?.physicalWagonId ?? null;
await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, {
physicalWagonId: assignment.physicalWagonId,
status: 'RESERVED',
});
if (previousPinId !== assignment.physicalWagonId) {
const pinEvents: WagonEventInput[] = [
{
wagonId: assignment.physicalWagonId,
type: WagonEventType.PinnedToSchedule,
trainScheduleId: scheduleId,
trainId: builtTrainId ?? null,
fromYardId: schedule.originStationId ?? null,
metadata: { slotId: assignment.trainSetWagonId, auto: false },
},
];
if (previousPinId) {
pinEvents.push({
wagonId: previousPinId,
type: WagonEventType.UnpinnedFromSchedule,
trainScheduleId: scheduleId,
trainId: builtTrainId ?? null,
reason: 'Replaced on the slot',
metadata: { slotId: assignment.trainSetWagonId },
});
}
await this.wagonHistory?.record(manager, pinEvents);
}
for (const [physicalId, slotId] of slotIdByPhysicalId) {
if (slotId === assignment.trainSetWagonId) {
slotIdByPhysicalId.delete(physicalId);
@@ -3027,6 +3100,25 @@ export class TrainSchedulingService {
{ id: In(dispatchedPhysicalIds) },
{ status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId },
);
const dispatchedWagons = await manager.getRepository(Wagon).find({
where: { id: In(dispatchedPhysicalIds) },
select: { id: true, wagonNumber: true, currentYardId: true, trainId: true },
});
await this.wagonHistory?.record(
manager,
dispatchedWagons.map((w) => ({
wagonId: w.id,
wagonNumber: w.wagonNumber,
type: WagonEventType.Dispatched,
occurredAt: now,
actorUserId: userId ?? null,
fromYardId: w.currentYardId ?? null,
trainId: w.trainId ?? schedule.trainSet?.trainId ?? null,
trainScheduleId: scheduleId,
toValue: WagonStatus.Assigned,
metadata: { destinationYardId: schedule.destinationStationId ?? null },
})),
);
}
// Planned couples boarding at the ORIGIN join the built train now — the
// departure is the moment they are physically hooked on. Mid-route
@@ -3064,6 +3156,32 @@ export class TrainSchedulingService {
status: WagonStatus.Assigned,
currentTrainScheduleId: scheduleId,
});
await this.wagonHistory?.record(manager, [
{
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
type: WagonEventType.CoupledToTrain,
occurredAt: now,
actorUserId: userId ?? null,
fromYardId: coupleYardId,
trainId: dispatchTrainId,
trainScheduleId: scheduleId,
toValue: maxSeq,
reason: 'Planned couple at the origin yard',
metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } },
},
{
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
type: WagonEventType.Dispatched,
occurredAt: now,
actorUserId: userId ?? null,
fromYardId: coupleYardId,
trainId: dispatchTrainId,
trainScheduleId: scheduleId,
toValue: WagonStatus.Assigned,
},
]);
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
manager.getRepository(ScheduleWagonAdjustmentLog).create({
trainScheduleId: scheduleId,
@@ -4889,6 +5007,7 @@ export class TrainSchedulingService {
);
const adjustmentRows: ScheduleWagonAdjustmentLog[] = [];
const movementRows: WagonMovement[] = [];
const historyRows: WagonEventInput[] = [];
let realCutHappened = false;
for (const [wagonId, cutYardId] of cutNow) {
const wagon = cutWagonById.get(wagonId);
@@ -4896,6 +5015,31 @@ export class TrainSchedulingService {
if (!wagon || wagon.currentTrainScheduleId !== scheduleId) continue;
if (realCutIds.has(wagonId) && builtTrainId) {
// REAL cut: the built train permanently loses the wagon here.
historyRows.push(
{
wagonId,
wagonNumber: wagon.wagonNumber,
type: WagonEventType.CutAtYard,
occurredAt,
fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId ?? null,
toYardId: cutYardId,
trainId: builtTrainId,
trainScheduleId: scheduleId,
toValue: WagonStatus.Available,
metadata: { permanent: true },
},
{
wagonId,
wagonNumber: wagon.wagonNumber,
type: WagonEventType.UncoupledFromTrain,
occurredAt,
fromYardId: cutYardId,
trainId: builtTrainId,
trainScheduleId: scheduleId,
fromValue: wagon.sequenceNumber,
reason: 'Cut from the train at this yard (permanent)',
},
);
await manager.getRepository(Wagon).update(wagonId, {
currentYardId: cutYardId,
currentTrainScheduleId: null,
@@ -4928,6 +5072,18 @@ export class TrainSchedulingService {
realCutHappened = true;
} else {
// Soft cut: sits out the rest of this trip, stays in the build.
historyRows.push({
wagonId,
wagonNumber: wagon.wagonNumber,
type: WagonEventType.CutAtYard,
occurredAt,
fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId ?? null,
toYardId: cutYardId,
trainId: wagon.trainId ?? null,
trainScheduleId: scheduleId,
toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
metadata: { permanent: false },
});
await manager.getRepository(Wagon).update(wagonId, {
currentYardId: cutYardId,
currentTrainScheduleId: null,
@@ -4952,6 +5108,7 @@ export class TrainSchedulingService {
if (movementRows.length) {
await manager.getRepository(WagonMovement).save(movementRows);
}
await this.wagonHistory?.record(manager, historyRows);
// Keep the coupling order gapless after permanent removals.
if (realCutHappened && builtTrainId) {
const remaining = await manager.getRepository(Wagon).find({
@@ -5005,6 +5162,18 @@ export class TrainSchedulingService {
status: WagonStatus.Assigned,
currentTrainScheduleId: scheduleId,
});
await this.wagonHistory?.record(manager, {
wagonId,
wagonNumber: wagon.wagonNumber,
type: WagonEventType.CoupledToTrain,
occurredAt,
fromYardId: coupleYardId,
trainId: builtTrainId,
trainScheduleId: scheduleId,
toValue: maxSeq,
reason: 'Planned couple at a mid-route stop',
metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } },
});
coupleLogRows.push(
manager.getRepository(ScheduleWagonAdjustmentLog).create({
trainScheduleId: scheduleId,
@@ -5022,6 +5191,20 @@ export class TrainSchedulingService {
await manager.getRepository(ScheduleWagonAdjustmentLog).save(coupleLogRows);
}
}
// Which wagons the position fix below will actually move — read first
// so each gets its own PASSED_CHECKPOINT history row (from → to yard).
const riding = await manager
.getRepository(Wagon)
.createQueryBuilder('w')
.select(['w.id', 'w.wagonNumber', 'w.currentYardId', 'w.trainId'])
.where('w.current_train_schedule_id = :scheduleId', { scheduleId })
.andWhere('(w.current_yard_id IS NULL OR w.current_yard_id IN (:...passedYardIds))', {
passedYardIds,
})
.andWhere('w.current_yard_id IS DISTINCT FROM :stationYardId', {
stationYardId: station.yardId,
})
.getMany();
await manager
.getRepository(Wagon)
.createQueryBuilder()
@@ -5034,6 +5217,20 @@ export class TrainSchedulingService {
passedYardIds,
})
.execute();
await this.wagonHistory?.record(
manager,
riding.map((w) => ({
wagonId: w.id,
wagonNumber: w.wagonNumber,
type: WagonEventType.PassedCheckpoint,
occurredAt,
fromYardId: w.currentYardId ?? null,
toYardId: station.yardId,
trainId: w.trainId ?? schedule.trainSet?.trainId ?? null,
trainScheduleId: scheduleId,
metadata: { sequenceNo: dto.sequenceNo, kind: dto.kind ?? null },
})),
);
if (schedule.trainSet?.trainId) {
await manager
.getRepository(Train)
@@ -5260,6 +5457,7 @@ export class TrainSchedulingService {
);
const arrivalLogRows: ScheduleWagonAdjustmentLog[] = [];
const arrivalMovementRows: WagonMovement[] = [];
const arrivalHistoryRows: WagonEventInput[] = [];
for (const slot of schedule.trainSet?.wagons ?? []) {
if (!slot.physicalWagonId) continue;
const wagon = settleWagonById.get(slot.physicalWagonId);
@@ -5283,6 +5481,32 @@ export class TrainSchedulingService {
// Arrival fallback for a journey logged without mid-route
// checkpoints: the REAL cut still permanently removes the wagon
// from the built train at its cut yard.
arrivalHistoryRows.push(
{
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
type: WagonEventType.CutAtYard,
occurredAt: now,
fromYardId: slot.boardYardId ?? schedule.originStationId ?? null,
toYardId: settleYardId,
trainId: ownerTrainId,
trainScheduleId: scheduleId,
bookingId: (slot.allocations ?? [])[0]?.bookingId ?? null,
toValue: WagonStatus.Available,
metadata: { permanent: true, atArrival: true },
},
{
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
type: WagonEventType.UncoupledFromTrain,
occurredAt: now,
fromYardId: settleYardId,
trainId: ownerTrainId,
trainScheduleId: scheduleId,
fromValue: wagon.sequenceNumber,
reason: 'Cut from the train at its planned yard (permanent)',
},
);
await manager.getRepository(Wagon).update(wagon.id, {
currentTrainScheduleId: null,
trainSetWagonId: null,
@@ -5312,6 +5536,19 @@ export class TrainSchedulingService {
}),
);
} else {
arrivalHistoryRows.push({
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
type: WagonEventType.SettledOnArrival,
occurredAt: now,
fromYardId: slot.boardYardId ?? schedule.originStationId ?? null,
toYardId: settleYardId,
trainId: wagon.trainId ?? null,
trainScheduleId: scheduleId,
bookingId: (slot.allocations ?? [])[0]?.bookingId ?? null,
toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
metadata: { slotId: slot.id, loaded: (slot.allocations ?? []).length > 0 },
});
await manager.getRepository(Wagon).update(wagon.id, {
currentTrainScheduleId: null,
trainSetWagonId: null,
@@ -5363,6 +5600,18 @@ export class TrainSchedulingService {
if (!wagon) continue;
if (wagon.currentTrainScheduleId === scheduleId) {
// Joined during the trip, slot-less: settle at the destination.
arrivalHistoryRows.push({
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
type: WagonEventType.SettledOnArrival,
occurredAt: now,
fromYardId: coupleYardId,
toYardId: schedule.destinationStationId ?? null,
trainId: wagon.trainId ?? null,
trainScheduleId: scheduleId,
toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
metadata: { loaded: false, coupledMidRoute: true },
});
await manager.getRepository(Wagon).update(wagon.id, {
currentTrainScheduleId: null,
trainSetWagonId: null,
@@ -5400,6 +5649,32 @@ export class TrainSchedulingService {
status: WagonStatus.Assigned,
currentYardId: schedule.destinationStationId,
});
arrivalHistoryRows.push(
{
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
type: WagonEventType.CoupledToTrain,
occurredAt: now,
fromYardId: coupleYardId,
trainId: arrivalTrainId,
trainScheduleId: scheduleId,
toValue: arrivalMaxSeq,
reason: 'Planned couple joined on arrival',
metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } },
},
{
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
type: WagonEventType.SettledOnArrival,
occurredAt: now,
fromYardId: coupleYardId,
toYardId: schedule.destinationStationId ?? null,
trainId: arrivalTrainId,
trainScheduleId: scheduleId,
toValue: WagonStatus.Assigned,
metadata: { loaded: false, coupledMidRoute: true },
},
);
arrivalLogRows.push(
manager.getRepository(ScheduleWagonAdjustmentLog).create({
trainScheduleId: scheduleId,
@@ -5429,6 +5704,23 @@ export class TrainSchedulingService {
// per-slot settle above never sees them. Release them here or they stay
// locked to a finished schedule and no later train can pick them up.
// They carry no cargo, so they simply settle where the train ended up.
const looseEmpties = await manager.getRepository(Wagon).find({
where: { currentTrainScheduleId: scheduleId },
select: { id: true, wagonNumber: true, currentYardId: true, trainId: true },
});
arrivalHistoryRows.push(
...looseEmpties.map((w) => ({
wagonId: w.id,
wagonNumber: w.wagonNumber,
type: WagonEventType.SettledOnArrival,
occurredAt: now,
fromYardId: w.currentYardId ?? null,
toYardId: schedule.destinationStationId ?? null,
trainId: w.trainId ?? null,
trainScheduleId: scheduleId,
metadata: { loaded: false, consistOnly: true },
})),
);
await manager
.getRepository(Wagon)
.createQueryBuilder()
@@ -5443,6 +5735,7 @@ export class TrainSchedulingService {
if (arrivalLogRows.length) {
await manager.getRepository(ScheduleWagonAdjustmentLog).save(arrivalLogRows);
}
await this.wagonHistory?.record(manager, arrivalHistoryRows);
if (arrivalMovementRows.length) {
await manager.getRepository(WagonMovement).save(arrivalMovementRows);
}
@@ -5639,6 +5932,19 @@ export class TrainSchedulingService {
}
for (const wagon of schedule.trainSet?.wagons ?? []) {
if (wagon.physicalWagonId) {
await this.wagonHistory?.record(manager, {
wagonId: wagon.physicalWagonId,
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
type: WagonEventType.ReturnedOnCancel,
actorUserId: userId ?? null,
fromYardId: wagon.physicalWagon?.currentYardId ?? null,
toYardId: schedule.originStationId ?? null,
trainId: wagon.physicalWagon?.trainId ?? null,
trainScheduleId: id,
toValue: wagon.physicalWagon?.trainId ? WagonStatus.Assigned : WagonStatus.Available,
reason: dto?.reason?.trim() || 'Schedule cancelled',
metadata: { slotId: wagon.id },
});
await manager.getRepository(Wagon).update(wagon.physicalWagonId, {
currentTrainScheduleId: null,
trainSetWagonId: null,
@@ -6616,6 +6922,15 @@ export class TrainSchedulingService {
physicalWagonId: physical.id,
status: 'RESERVED',
});
await this.wagonHistory?.record(manager, {
wagonId: physical.id,
wagonNumber: physical.wagonNumber,
type: WagonEventType.PinnedToSchedule,
trainScheduleId: scheduleId,
trainId: builtTrainId ?? null,
fromYardId: physical.currentYardId ?? null,
metadata: { slotId: slot.trainSetWagonId, auto: true },
});
const pinnedSpans = occupiedSpans.get(physical.id) ?? [];
pinnedSpans.push(span);
occupiedSpans.set(physical.id, pinnedSpans);
@@ -8692,6 +9007,21 @@ export class TrainSchedulingService {
for (const wagon of removed) {
await manager.getRepository(Wagon).update(wagon.id, detachPatch);
}
const consistReason = (dto as { reason?: string | null }).reason?.trim() || null;
await this.wagonHistory?.record(
manager,
removed.map((wagon) => ({
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
type: WagonEventType.UncoupledFromTrain,
actorUserId: userId ?? null,
trainId: train.id,
trainScheduleId: scheduleId,
fromYardId: currentYardId ?? null,
fromValue: wagon.sequenceNumber,
reason: consistReason ?? 'Trimmed from the consist on the schedule',
})),
);
if (removed.length && ownSetIds.length) {
// This train's own pins (all its runs) on trimmed wagons are stale —
// clear them so the freed wagon isn't still claimed by slots it left.
@@ -8729,6 +9059,32 @@ export class TrainSchedulingService {
// Mirror on the in-memory row — the compaction below sorts by it.
to.sequenceNumber = from.sequenceNumber;
await manager.getRepository(Wagon).update(from.id, detachPatch);
await this.wagonHistory?.record(manager, [
{
wagonId: to.id,
wagonNumber: to.wagonNumber,
type: WagonEventType.CoupledToTrain,
actorUserId: userId ?? null,
trainId: train.id,
trainScheduleId: scheduleId,
fromYardId: to.currentYardId ?? null,
toValue: from.sequenceNumber,
reason: consistReason ?? `Switched in for ${from.wagonNumber}`,
metadata: { replaced: from.wagonNumber, replacedWagonId: from.id },
},
{
wagonId: from.id,
wagonNumber: from.wagonNumber,
type: WagonEventType.UncoupledFromTrain,
actorUserId: userId ?? null,
trainId: train.id,
trainScheduleId: scheduleId,
fromYardId: currentYardId ?? null,
fromValue: from.sequenceNumber,
reason: consistReason ?? `Switched out for ${to.wagonNumber}`,
metadata: { replacedBy: to.wagonNumber, replacedByWagonId: to.id },
},
]);
}
const remaining = consist.filter(
@@ -8744,6 +9100,7 @@ export class TrainSchedulingService {
}
}
let sequence = compacted.length;
const addedEvents: WagonEventInput[] = [];
for (const wagon of added) {
sequence += 1;
await manager.getRepository(Wagon).update(wagon.id, {
@@ -8751,7 +9108,20 @@ export class TrainSchedulingService {
sequenceNumber: sequence,
status: WagonStatus.Assigned,
});
addedEvents.push({
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
type: WagonEventType.CoupledToTrain,
actorUserId: userId ?? null,
trainId: train.id,
trainScheduleId: scheduleId,
fromYardId: wagon.currentYardId ?? null,
toValue: sequence,
reason: consistReason ?? 'Added to the consist on the schedule',
metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } },
});
}
await this.wagonHistory?.record(manager, addedEvents);
// The schedule is full when every consist wagon is allocated.
await manager
@@ -11277,6 +11647,30 @@ export class TrainSchedulingService {
await allocs.update(alloc.id, { trainSetWagonId: created.id });
}
await slotRepo.update(source.id, emptyLoadFields);
await this.wagonHistory?.record(manager, [
...(source.physicalWagonId
? [
{
wagonId: source.physicalWagonId,
wagonNumber: source.physicalWagon?.wagonNumber ?? null,
type: WagonEventType.LoadMovedOut,
trainScheduleId: scheduleId,
bookingId: sourceAllocs[0]?.bookingId ?? null,
toValue: consistWagon.wagonNumber,
metadata: { toWagonId: consistWagon.id, allocations: sourceAllocs.length },
},
]
: []),
{
wagonId: consistWagon.id,
wagonNumber: consistWagon.wagonNumber,
type: WagonEventType.LoadMovedIn,
trainScheduleId: scheduleId,
bookingId: sourceAllocs[0]?.bookingId ?? null,
fromValue: source.physicalWagon?.wagonNumber ?? null,
metadata: { fromWagonId: source.physicalWagonId ?? null, allocations: sourceAllocs.length },
},
]);
return;
}
@@ -11291,6 +11685,54 @@ export class TrainSchedulingService {
}
await slotRepo.update(target.id, sourceLoadFields);
await slotRepo.update(source.id, targetLoadFields);
const moveEvents: WagonEventInput[] = [];
if (source.physicalWagonId) {
moveEvents.push({
wagonId: source.physicalWagonId,
wagonNumber: source.physicalWagon?.wagonNumber ?? null,
type: WagonEventType.LoadMovedOut,
trainScheduleId: scheduleId,
bookingId: sourceAllocs[0]?.bookingId ?? null,
toValue: target.physicalWagon?.wagonNumber ?? null,
metadata: { toWagonId: target.physicalWagonId ?? null, allocations: sourceAllocs.length, swap: targetAllocs.length > 0 },
});
}
if (target.physicalWagonId) {
moveEvents.push({
wagonId: target.physicalWagonId,
wagonNumber: target.physicalWagon?.wagonNumber ?? null,
type: WagonEventType.LoadMovedIn,
trainScheduleId: scheduleId,
bookingId: sourceAllocs[0]?.bookingId ?? null,
fromValue: source.physicalWagon?.wagonNumber ?? null,
metadata: { fromWagonId: source.physicalWagonId ?? null, allocations: sourceAllocs.length, swap: targetAllocs.length > 0 },
});
}
if (targetAllocs.length) {
if (target.physicalWagonId) {
moveEvents.push({
wagonId: target.physicalWagonId,
wagonNumber: target.physicalWagon?.wagonNumber ?? null,
type: WagonEventType.LoadMovedOut,
trainScheduleId: scheduleId,
bookingId: targetAllocs[0]?.bookingId ?? null,
toValue: source.physicalWagon?.wagonNumber ?? null,
metadata: { toWagonId: source.physicalWagonId ?? null, allocations: targetAllocs.length, swap: true },
});
}
if (source.physicalWagonId) {
moveEvents.push({
wagonId: source.physicalWagonId,
wagonNumber: source.physicalWagon?.wagonNumber ?? null,
type: WagonEventType.LoadMovedIn,
trainScheduleId: scheduleId,
bookingId: targetAllocs[0]?.bookingId ?? null,
fromValue: target.physicalWagon?.wagonNumber ?? null,
metadata: { fromWagonId: target.physicalWagonId ?? null, allocations: targetAllocs.length, swap: true },
});
}
}
await this.wagonHistory?.record(manager, moveEvents);
});
return this.getTrainScheduleById(scheduleId);
@@ -11989,6 +12431,12 @@ export class TrainSchedulingService {
// 2. The physical wagons follow the train — the target's stay put, and
// EVERY wagon on the source train (coupled or loose) moves across so
// nothing strands on the deactivated train.
const mergedFromSource = sourceTrainId
? await manager.getRepository(Wagon).find({
where: { trainId: sourceTrainId },
select: { id: true, wagonNumber: true, currentYardId: true },
})
: [];
if (incomingWagons.length) {
await manager.getRepository(Wagon).update(
{ id: In(incomingWagons.map((w) => w.id)) },
@@ -12000,6 +12448,20 @@ export class TrainSchedulingService {
.getRepository(Wagon)
.update({ trainId: sourceTrainId }, { trainId: targetTrain.id });
}
await this.wagonHistory?.record(
manager,
mergedFromSource.map((w) => ({
wagonId: w.id,
wagonNumber: w.wagonNumber,
type: WagonEventType.TrainMerged,
fromYardId: w.currentYardId ?? null,
trainId: targetTrain.id,
trainScheduleId: schedule.id,
fromValue: sourceTrainId,
toValue: targetTrain.code,
reason: `Train merged into ${targetTrain.code}`,
})),
);
// 3. Carry the target's train-set wagon rows into THIS consist, appended
// after the existing wagons. Sequence is provisional — staff reorder