mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 17:08:18 +00:00
feat(freight): GL rebook of cancelled wagons, seal+train required on completion, train/voyage in SMS
This commit is contained in:
@@ -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' };
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user