mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 20:38:17 +00:00
feat(freight): GL rebook of cancelled wagons, seal+train required on completion, train/voyage in SMS
This commit is contained in:
@@ -53,6 +53,8 @@ import {
|
||||
CancelledUnitSnapshot,
|
||||
WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
} from './entities/booking-wagon-cancellation.entity';
|
||||
import { WagonEventType } from '@edr/types';
|
||||
import { WagonHistoryService } from '../wagon-history/wagon-history.service';
|
||||
|
||||
export { WAGON_CANCEL_FEE_INVOICE_TYPE };
|
||||
|
||||
@@ -134,6 +136,7 @@ export class BookingWagonCancellationService {
|
||||
private readonly firstMile: FirstMileService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly events: EventEmitter2,
|
||||
private readonly wagonHistory: WagonHistoryService,
|
||||
) {}
|
||||
|
||||
// ── T1: request ────────────────────────────────────────────────────────────
|
||||
@@ -1847,6 +1850,7 @@ export class BookingWagonCancellationService {
|
||||
.getRepository(WagonAllocationContainerItem)
|
||||
.delete(cut.map((i) => i.id));
|
||||
if (cut.length === items.length) {
|
||||
await this.recordAllocationRelease(manager, [alloc.id], bookingId, 'Containers cancelled from booking');
|
||||
await manager.getRepository(WagonBookingAllocation).delete(alloc.id);
|
||||
} else {
|
||||
const cutWeight = cut.reduce((s, i) => s + Number(i.grossWeightTons ?? 0), 0);
|
||||
@@ -1893,9 +1897,66 @@ export class BookingWagonCancellationService {
|
||||
await manager
|
||||
.getRepository(WagonAllocationBulkLoad)
|
||||
.delete({ wagonBookingAllocationId: In(ids) });
|
||||
await this.recordAllocationRelease(manager, ids, bookingId, 'Wagons cancelled from booking');
|
||||
await manager.getRepository(WagonBookingAllocation).delete(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* BOOKING_CANCELLED history row for every physical wagon behind the released
|
||||
* allocations — resolved through the slot BEFORE the allocation rows go, one
|
||||
* query for the whole batch. Slots with no wagon pinned yet leave no row.
|
||||
*/
|
||||
private async recordAllocationRelease(
|
||||
manager: EntityManager,
|
||||
allocationIds: string[],
|
||||
bookingId: string,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
if (!allocationIds.length) return;
|
||||
const rows: Array<{
|
||||
allocationId: string;
|
||||
wagonId: string;
|
||||
wagonNumber: string;
|
||||
yardId: string | null;
|
||||
trainId: string | null;
|
||||
scheduleId: string | null;
|
||||
weightTons: string | null;
|
||||
loadType: string | null;
|
||||
}> = await 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",
|
||||
w.current_train_schedule_id AS "scheduleId",
|
||||
a.allocated_weight_tons AS "weightTons",
|
||||
a.load_type AS "loadType"
|
||||
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],
|
||||
);
|
||||
await this.wagonHistory.record(
|
||||
manager,
|
||||
rows.map((r) => ({
|
||||
wagonId: r.wagonId,
|
||||
wagonNumber: r.wagonNumber,
|
||||
type: WagonEventType.BookingCancelled,
|
||||
fromYardId: r.yardId,
|
||||
trainId: r.trainId,
|
||||
trainScheduleId: r.scheduleId,
|
||||
bookingId,
|
||||
reason,
|
||||
metadata: {
|
||||
allocationId: r.allocationId,
|
||||
loadType: r.loadType,
|
||||
weightTons: r.weightTons == null ? null : Number(r.weightTons),
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Pre-reduction quantities snapshot (only when the booking was never split before). */
|
||||
private async currentQuantities(
|
||||
manager: EntityManager,
|
||||
|
||||
@@ -8,6 +8,8 @@ import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
||||
import { Container } from './entities/container.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { WagonEventType } from '@edr/types';
|
||||
import { WagonHistoryService } from '../wagon-history/wagon-history.service';
|
||||
|
||||
@Injectable()
|
||||
export class ContainersService {
|
||||
@@ -19,6 +21,7 @@ export class ContainersService {
|
||||
@InjectRepository(ContainerType)
|
||||
private readonly containerTypeRepo: Repository<ContainerType>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly wagonHistory: WagonHistoryService,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateContainerDto): Promise<Container> {
|
||||
@@ -150,7 +153,16 @@ export class ContainersService {
|
||||
// Placing a container on a wagon does not make it AVAILABLE. The status enum
|
||||
// (AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED) has no ASSIGNED/ON_WAGON
|
||||
// state, so leave the existing status unchanged rather than forcing AVAILABLE.
|
||||
return containerRepo.save(container);
|
||||
const saved = await containerRepo.save(container);
|
||||
await this.wagonHistory.record(manager, {
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.ContainerPlaced,
|
||||
toYardId: wagon.currentYardId ?? null,
|
||||
toValue: container.containerNumber,
|
||||
metadata: { containerId: container.id, position },
|
||||
});
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,9 +171,23 @@ export class ContainersService {
|
||||
if (container.status === 'LOADED') {
|
||||
throw new ConflictException('Cannot unassign a loaded container');
|
||||
}
|
||||
const previousWagonId = container.wagonId;
|
||||
const previousPosition = container.position ?? null;
|
||||
container.wagonId = null;
|
||||
container.position = null;
|
||||
container.status = 'AVAILABLE';
|
||||
return this.containerRepo.save(container);
|
||||
const saved = await this.containerRepo.save(container);
|
||||
if (previousWagonId) {
|
||||
const wagon = await this.wagonRepo.findOne({ where: { id: previousWagonId } });
|
||||
await this.wagonHistory.record(null, {
|
||||
wagonId: previousWagonId,
|
||||
wagonNumber: wagon?.wagonNumber ?? null,
|
||||
type: WagonEventType.ContainerRemoved,
|
||||
fromYardId: wagon?.currentYardId ?? null,
|
||||
fromValue: container.containerNumber,
|
||||
metadata: { containerId: container.id, position: previousPosition },
|
||||
});
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
|
||||
import { Freight, WagonEventType, WagonMovementKind, WagonStatus } from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
@@ -25,6 +25,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { WagonStatusLog } from '../wagons/entities/wagon-status-log.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { WagonEventInput, WagonHistoryService } from '../wagon-history/wagon-history.service';
|
||||
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||
import { BuildTrainDto } from './dto/build-train.dto';
|
||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
@@ -76,6 +77,7 @@ export class TrainBuilderService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly wagonHistory: WagonHistoryService,
|
||||
) {}
|
||||
|
||||
async buildTrain(dto: BuildTrainDto) {
|
||||
@@ -134,7 +136,7 @@ export class TrainBuilderService {
|
||||
await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds);
|
||||
|
||||
if (dto.wagonIds?.length) {
|
||||
await this.attachWagons(manager, train, dto.wagonIds, 0);
|
||||
await this.attachWagons(manager, train, dto.wagonIds, 0, null);
|
||||
}
|
||||
return train.id;
|
||||
});
|
||||
@@ -684,8 +686,19 @@ export class TrainBuilderService {
|
||||
wagon.currentYardId === previousYardId,
|
||||
);
|
||||
const now = new Date();
|
||||
const events: WagonEventInput[] = [];
|
||||
for (const wagon of wagons) {
|
||||
if (wagon.currentYardId === yard.id) continue;
|
||||
events.push({
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.MovedWithTrain,
|
||||
occurredAt: now,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
toYardId: yard.id,
|
||||
trainId: train.id,
|
||||
reason: `Train ${train.code} relocated`,
|
||||
});
|
||||
await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id });
|
||||
// Ledger row keeps the wagon's yard history auditable (mirrors the
|
||||
// manual-relocation path in the wagons service).
|
||||
@@ -699,6 +712,7 @@ export class TrainBuilderService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
await this.wagonHistory.record(manager, events);
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
@@ -735,6 +749,16 @@ export class TrainBuilderService {
|
||||
occurredAt: new Date(),
|
||||
}),
|
||||
);
|
||||
await this.wagonHistory.record(manager, {
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.MovedManually,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
toYardId: yard.id,
|
||||
trainId: train.id,
|
||||
reason: 'Coupled wagon moved from the train builder',
|
||||
});
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
@@ -787,6 +811,19 @@ export class TrainBuilderService {
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.update(moving.map((w) => w.id), { currentYardId: yard.id });
|
||||
await this.wagonHistory.record(
|
||||
manager,
|
||||
moving.map((w) => ({
|
||||
wagonId: w.id,
|
||||
wagonNumber: w.wagonNumber,
|
||||
type: WagonEventType.MovedManually,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: w.currentYardId ?? null,
|
||||
toYardId: yard.id,
|
||||
trainId: train.id,
|
||||
reason: 'Coupled wagons moved from the train builder',
|
||||
})),
|
||||
);
|
||||
await manager.getRepository(WagonMovement).save(
|
||||
moving.map((w) =>
|
||||
manager.getRepository(WagonMovement).create({
|
||||
@@ -810,7 +847,7 @@ export class TrainBuilderService {
|
||||
const currentCount = await manager
|
||||
.getRepository(Wagon)
|
||||
.count({ where: { trainId: train.id } });
|
||||
const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount);
|
||||
const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount, userId ?? null);
|
||||
return this.syncLiveScheduleAfterConsistChange(
|
||||
manager,
|
||||
train.id,
|
||||
@@ -949,6 +986,18 @@ export class TrainBuilderService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
await this.wagonHistory.record(manager, {
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.StatusChanged,
|
||||
actorUserId: userId ?? null,
|
||||
trainId: train.id,
|
||||
fromYardId: wagon.currentYardId ?? train.currentYardId ?? null,
|
||||
fromValue: previousStatus,
|
||||
toValue: WagonStatus.Maintenance,
|
||||
reason: note?.trim() || null,
|
||||
metadata: { trainCode: train.code },
|
||||
});
|
||||
// Audit row: which train it came off and when. The wagon does not change
|
||||
// yard here, so from/to are the same — the ledger is the wagon's history
|
||||
// surface, and a maintenance detach has to be in it.
|
||||
@@ -1166,6 +1215,22 @@ export class TrainBuilderService {
|
||||
for (let i = 0; i < dto.wagonIds.length; i++) {
|
||||
await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 });
|
||||
}
|
||||
const previousSeq = new Map(wagons.map((w) => [w.id, w]));
|
||||
await this.wagonHistory.record(
|
||||
manager,
|
||||
dto.wagonIds
|
||||
.map((wid, i) => ({ wagon: previousSeq.get(wid), to: i + 1 }))
|
||||
.filter((x) => x.wagon && x.wagon.sequenceNumber !== x.to)
|
||||
.map(({ wagon, to }) => ({
|
||||
wagonId: wagon!.id,
|
||||
wagonNumber: wagon!.wagonNumber,
|
||||
type: WagonEventType.SequenceChanged,
|
||||
trainId: train.id,
|
||||
fromValue: wagon!.sequenceNumber,
|
||||
toValue: to,
|
||||
reason: 'Consist reordered',
|
||||
})),
|
||||
);
|
||||
|
||||
// Propagate the new order to every live (DRAFT/SCHEDULED) schedule of
|
||||
// this train: slots pinned to a reordered wagon adopt the wagon's new
|
||||
@@ -1287,6 +1352,10 @@ export class TrainBuilderService {
|
||||
'Train has active schedules; cancel them before disbanding the train',
|
||||
);
|
||||
}
|
||||
const consist = await manager.getRepository(Wagon).find({
|
||||
where: { trainId: train.id },
|
||||
select: { id: true, wagonNumber: true, currentYardId: true, sequenceNumber: true, status: true },
|
||||
});
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.update(
|
||||
@@ -1299,6 +1368,19 @@ export class TrainBuilderService {
|
||||
exportTrainNumber: null,
|
||||
},
|
||||
);
|
||||
await this.wagonHistory.record(
|
||||
manager,
|
||||
consist.map((w) => ({
|
||||
wagonId: w.id,
|
||||
wagonNumber: w.wagonNumber,
|
||||
type: WagonEventType.TrainDisbanded,
|
||||
trainId: train.id,
|
||||
fromYardId: w.currentYardId ?? null,
|
||||
fromValue: w.sequenceNumber,
|
||||
reason: `Train ${train.code} disbanded`,
|
||||
metadata: { status: { from: w.status, to: WagonStatus.Available } },
|
||||
})),
|
||||
);
|
||||
await manager.getRepository(TrainLocomotive).delete({ trainId: train.id });
|
||||
await manager.getRepository(Train).remove(train);
|
||||
});
|
||||
@@ -1432,6 +1514,25 @@ export class TrainBuilderService {
|
||||
),
|
||||
);
|
||||
|
||||
// COUPLED rows are written by attachWagons (build + assign); the detach
|
||||
// side is logged here, where the reason and the live schedule are known.
|
||||
await this.wagonHistory.record(
|
||||
manager,
|
||||
changes
|
||||
.filter((c) => c.action === 'REMOVE')
|
||||
.map((c) => ({
|
||||
wagonId: c.wagonId,
|
||||
wagonNumber: c.wagonNumber,
|
||||
type: WagonEventType.UncoupledFromTrain,
|
||||
occurredAt: now,
|
||||
actorUserId: userId,
|
||||
trainId,
|
||||
trainScheduleId: schedule?.id ?? null,
|
||||
fromYardId: yardId,
|
||||
reason: reason?.trim() || null,
|
||||
})),
|
||||
);
|
||||
|
||||
if (!schedule) return null;
|
||||
|
||||
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
|
||||
@@ -1543,6 +1644,7 @@ export class TrainBuilderService {
|
||||
train: Train,
|
||||
wagonIds: string[],
|
||||
startCount: number,
|
||||
userId: string | null = null,
|
||||
): Promise<Wagon[]> {
|
||||
const uniqueIds = [...new Set(wagonIds)];
|
||||
const wagonRepo = manager.getRepository(Wagon);
|
||||
@@ -1572,6 +1674,7 @@ export class TrainBuilderService {
|
||||
await this.assertConsistLengthWithinLimit(manager, train, toAttach);
|
||||
|
||||
let sequence = startCount;
|
||||
const events: WagonEventInput[] = [];
|
||||
for (const wagon of toAttach) {
|
||||
sequence += 1;
|
||||
await wagonRepo.update(wagon.id, {
|
||||
@@ -1583,7 +1686,23 @@ export class TrainBuilderService {
|
||||
importTrainNumber: train.importTrainNumber,
|
||||
exportTrainNumber: train.exportTrainNumber,
|
||||
});
|
||||
events.push({
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.CoupledToTrain,
|
||||
actorUserId: userId,
|
||||
trainId: train.id,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
toValue: sequence,
|
||||
metadata: {
|
||||
trainCode: train.code,
|
||||
status: { from: wagon.status, to: WagonStatus.Assigned },
|
||||
importTrainNumber: train.importTrainNumber ?? null,
|
||||
exportTrainNumber: train.exportTrainNumber ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.wagonHistory.record(manager, events);
|
||||
return toAttach;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { WagonEventCategory, WagonEventType } from '@edr/types';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsArray, IsDateString, IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class WagonHistoryQueryDto {
|
||||
@ApiPropertyOptional({ enum: WagonEventCategory, description: 'Only events of this category' })
|
||||
@IsOptional()
|
||||
@IsEnum(WagonEventCategory)
|
||||
category?: WagonEventCategory;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: WagonEventType,
|
||||
isArray: true,
|
||||
description: 'Only these event types (repeat the param or comma-separate)',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
Array.isArray(value) ? value : String(value).split(',').map((v) => v.trim()).filter(Boolean),
|
||||
)
|
||||
@IsArray()
|
||||
@IsEnum(WagonEventType, { each: true })
|
||||
types?: WagonEventType[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'ISO timestamp — events at or after this moment' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'ISO timestamp — events at or before this moment' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
to?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Opaque `nextCursor` from the previous page' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cursor?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { WagonEventCategory, WagonEventType } from '@edr/types';
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Append-only history of everything that happens to a wagon — one row per
|
||||
* wagon per transition, written inside the same transaction as the change.
|
||||
* Plain id columns, no foreign keys and no soft delete on purpose: the history
|
||||
* must outlive the wagon, train, schedule or booking it refers to, exactly like
|
||||
* `audit_logs` and `schedule_wagon_adjustment_logs`. Rows are never updated.
|
||||
*
|
||||
* Read path: `(wagon_id, occurred_at DESC, id DESC)` keyset pagination — one
|
||||
* index range scan per page regardless of how long the wagon has been in
|
||||
* service. Labels (yard, train, schedule, booking, actor) are joined at read
|
||||
* time on primary keys, so the write path stays a single INSERT.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'wagon_events' })
|
||||
@Index('idx_wagon_events_wagon_time', ['wagonId', 'occurredAt', 'id'])
|
||||
@Index('idx_wagon_events_wagon_cat_time', ['wagonId', 'category', 'occurredAt', 'id'])
|
||||
export class WagonEvent {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'wagon_id', type: 'uuid' })
|
||||
wagonId!: string;
|
||||
|
||||
/** Snapshot so the row still reads after the wagon is purged or renumbered. */
|
||||
@Column({ name: 'wagon_number', type: 'varchar', nullable: true })
|
||||
wagonNumber?: string | null;
|
||||
|
||||
@Column({ name: 'event_type', type: 'varchar', length: 40 })
|
||||
type!: WagonEventType;
|
||||
|
||||
/** Derived from `type` at write time; stored so the category filter hits the index. */
|
||||
@Column({ name: 'category', type: 'varchar', length: 20 })
|
||||
category!: WagonEventCategory;
|
||||
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
||||
occurredAt!: Date;
|
||||
|
||||
@Column({ name: 'actor_user_id', type: 'uuid', nullable: true })
|
||||
actorUserId?: string | null;
|
||||
|
||||
@Column({ name: 'from_yard_id', type: 'uuid', nullable: true })
|
||||
fromYardId?: string | null;
|
||||
|
||||
@Column({ name: 'to_yard_id', type: 'uuid', nullable: true })
|
||||
toYardId?: string | null;
|
||||
|
||||
@Column({ name: 'train_id', type: 'uuid', nullable: true })
|
||||
trainId?: string | null;
|
||||
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||
trainScheduleId?: string | null;
|
||||
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
/** Previous value of whatever the event changed (status, sequence, train code…). */
|
||||
@Column({ name: 'from_value', type: 'varchar', length: 120, nullable: true })
|
||||
fromValue?: string | null;
|
||||
|
||||
@Column({ name: 'to_value', type: 'varchar', length: 120, nullable: true })
|
||||
toValue?: string | null;
|
||||
|
||||
/** Staff-entered reason / note, when the action carried one. */
|
||||
@Column({ name: 'reason', type: 'text', nullable: true })
|
||||
reason?: string | null;
|
||||
|
||||
@Column({ name: 'metadata', type: 'jsonb', nullable: true })
|
||||
metadata?: Record<string, unknown> | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { WagonHistoryService } from './wagon-history.service';
|
||||
|
||||
/**
|
||||
* Global, dependency-free (only the DataSource): every service that writes a
|
||||
* wagon row — wagons desk, train builder, scheduling, booking journey,
|
||||
* containers, cancellations — records history through WagonHistoryService
|
||||
* without adding a module edge, the same pattern as FleetHistoryModule.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [WagonHistoryService],
|
||||
exports: [WagonHistoryService],
|
||||
})
|
||||
export class WagonHistoryModule {}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { WagonEventCategory, WagonEventType } from '@edr/types';
|
||||
|
||||
import { WagonHistoryService } from './wagon-history.service';
|
||||
|
||||
/** Captures the INSERT query-builder chain and the raw list query. */
|
||||
function makeDataSource() {
|
||||
const execute = jest.fn().mockResolvedValue(undefined);
|
||||
const values = jest.fn();
|
||||
const chain = { insert: jest.fn(), into: jest.fn(), values, updateEntity: jest.fn(), execute };
|
||||
chain.insert.mockReturnValue(chain);
|
||||
chain.into.mockReturnValue(chain);
|
||||
values.mockReturnValue(chain);
|
||||
chain.updateEntity.mockReturnValue(chain);
|
||||
const manager = { createQueryBuilder: jest.fn(() => chain) };
|
||||
const query = jest.fn().mockResolvedValue([]);
|
||||
return { dataSource: { manager, query }, manager, values, execute, query };
|
||||
}
|
||||
|
||||
describe('WagonHistoryService.record', () => {
|
||||
it('writes a batch as one INSERT, deriving the category from the type', async () => {
|
||||
const { dataSource, values, execute } = makeDataSource();
|
||||
const service = new WagonHistoryService(dataSource as never);
|
||||
const at = new Date('2026-09-01T10:00:00Z');
|
||||
|
||||
await service.record(dataSource.manager as never, [
|
||||
{ wagonId: 'w1', wagonNumber: 'W-1', type: WagonEventType.MovedManually, toYardId: 'y2', occurredAt: at },
|
||||
{ wagonId: 'w2', type: WagonEventType.CargoLoaded, bookingId: 'b1', toValue: 12.5 },
|
||||
null,
|
||||
]);
|
||||
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
const rows = values.mock.calls[0][0];
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({
|
||||
wagonId: 'w1',
|
||||
wagonNumber: 'W-1',
|
||||
type: WagonEventType.MovedManually,
|
||||
category: WagonEventCategory.Yard,
|
||||
toYardId: 'y2',
|
||||
occurredAt: at,
|
||||
actorUserId: null,
|
||||
});
|
||||
expect(rows[1]).toMatchObject({
|
||||
wagonId: 'w2',
|
||||
category: WagonEventCategory.Cargo,
|
||||
bookingId: 'b1',
|
||||
toValue: '12.5',
|
||||
});
|
||||
expect(rows[1].occurredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('skips empty input without touching the database', async () => {
|
||||
const { dataSource, execute } = makeDataSource();
|
||||
const service = new WagonHistoryService(dataSource as never);
|
||||
await service.record(dataSource.manager as never, []);
|
||||
await service.record(null, null);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates a failure inside a caller transaction but swallows it outside one', async () => {
|
||||
const { dataSource, execute } = makeDataSource();
|
||||
execute.mockRejectedValue(new Error('db down'));
|
||||
const service = new WagonHistoryService(dataSource as never);
|
||||
const input = { wagonId: 'w1', type: WagonEventType.Registered };
|
||||
|
||||
await expect(service.record(dataSource.manager as never, input)).rejects.toThrow('db down');
|
||||
await expect(service.record(null, input)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WagonHistoryService.list', () => {
|
||||
const A = '11111111-1111-4111-8111-111111111111';
|
||||
const B = '22222222-2222-4222-8222-222222222222';
|
||||
const C = '33333333-3333-4333-8333-333333333333';
|
||||
const row = (id: string, at: string) => ({
|
||||
id,
|
||||
wagonId: 'w1',
|
||||
wagonNumber: 'W-1',
|
||||
type: WagonEventType.PassedCheckpoint,
|
||||
category: WagonEventCategory.Yard,
|
||||
occurredAt: new Date(at),
|
||||
actorUserId: null,
|
||||
actorName: null,
|
||||
fromYardId: 'y1',
|
||||
fromYardLabel: 'Origin',
|
||||
toYardId: 'y2',
|
||||
toYardLabel: 'Stop',
|
||||
trainId: null,
|
||||
trainCode: null,
|
||||
trainScheduleId: 's1',
|
||||
scheduleLabel: 'V-100',
|
||||
bookingId: null,
|
||||
bookingReference: null,
|
||||
fromValue: null,
|
||||
toValue: null,
|
||||
reason: null,
|
||||
metadata: null,
|
||||
});
|
||||
|
||||
it('returns a page with a cursor when more rows exist, and decodes that cursor on the next call', async () => {
|
||||
const { dataSource, query } = makeDataSource();
|
||||
const service = new WagonHistoryService(dataSource as never);
|
||||
query.mockResolvedValueOnce([
|
||||
row(A, '2026-09-01T10:00:00Z'),
|
||||
row(B, '2026-09-01T09:00:00Z'),
|
||||
row(C, '2026-09-01T08:00:00Z'), // the +1 probe row
|
||||
]);
|
||||
|
||||
const first = await service.list('w1', { limit: 2, category: WagonEventCategory.Yard });
|
||||
expect(first.items.map((i) => i.id)).toEqual([A, B]);
|
||||
expect(first.items[0].occurredAt).toBe('2026-09-01T10:00:00.000Z');
|
||||
expect(first.nextCursor).toEqual(expect.any(String));
|
||||
const [sql, params] = query.mock.calls[0];
|
||||
expect(sql).toContain('e.wagon_id = $1');
|
||||
expect(sql).toContain('e.category = $2');
|
||||
expect(sql).toContain('LIMIT 3');
|
||||
expect(params).toEqual(['w1', WagonEventCategory.Yard]);
|
||||
|
||||
query.mockResolvedValueOnce([row(C, '2026-09-01T08:00:00Z')]);
|
||||
const second = await service.list('w1', { limit: 2, cursor: first.nextCursor! });
|
||||
expect(second.items.map((i) => i.id)).toEqual([C]);
|
||||
expect(second.nextCursor).toBeNull();
|
||||
const [sql2, params2] = query.mock.calls[1];
|
||||
expect(sql2).toContain('(e.occurred_at, e.id) < ($2, $3::uuid)');
|
||||
expect(params2[1]).toEqual(new Date('2026-09-01T09:00:00Z'));
|
||||
expect(params2[2]).toBe(B);
|
||||
});
|
||||
|
||||
it('rejects a malformed cursor', async () => {
|
||||
const { dataSource } = makeDataSource();
|
||||
const service = new WagonHistoryService(dataSource as never);
|
||||
await expect(service.list('w1', { cursor: 'not-a-cursor' })).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
WAGON_EVENT_CATEGORY,
|
||||
WagonEventCategory,
|
||||
WagonEventType,
|
||||
WagonHistoryEvent,
|
||||
WagonHistoryPage,
|
||||
} from '@edr/types';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
import { WagonHistoryQueryDto } from './dto/wagon-history-query.dto';
|
||||
import { WagonEvent } from './wagon-event.entity';
|
||||
|
||||
/** One transition to append. Everything but the wagon and the type is optional context. */
|
||||
export interface WagonEventInput {
|
||||
wagonId: string;
|
||||
/** Snapshot for the row; pass it when the caller already holds the wagon (no lookup is made). */
|
||||
wagonNumber?: string | null;
|
||||
type: WagonEventType;
|
||||
/** Defaults to now. Pass the business timestamp when the caller has one. */
|
||||
occurredAt?: Date | null;
|
||||
actorUserId?: string | null;
|
||||
fromYardId?: string | null;
|
||||
toYardId?: string | null;
|
||||
trainId?: string | null;
|
||||
trainScheduleId?: string | null;
|
||||
bookingId?: string | null;
|
||||
fromValue?: string | number | null;
|
||||
toValue?: string | number | null;
|
||||
reason?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
/**
|
||||
* The single write and read path for `freight.wagon_events`.
|
||||
*
|
||||
* Writes: {@link record} takes the caller's EntityManager so the history row
|
||||
* commits (or rolls back) with the business change — a wagon can never end up
|
||||
* moved without its history row or vice versa. A batch is one INSERT.
|
||||
*
|
||||
* Reads: {@link list} is keyset-paginated on `(occurred_at, id)` under the
|
||||
* per-wagon index, so page N costs the same as page 1; labels come from
|
||||
* primary-key LEFT JOINs on the page only.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WagonHistoryService {
|
||||
private readonly logger = new Logger(WagonHistoryService.name);
|
||||
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
/**
|
||||
* Append one or more events. Inside a transaction (manager given) a failure
|
||||
* propagates — Postgres has already aborted the transaction at that point,
|
||||
* so swallowing it would only hide the rollback. Outside a transaction the
|
||||
* write is best-effort: logged, never thrown, so history can't break the
|
||||
* operation that produced it.
|
||||
*/
|
||||
async record(
|
||||
manager: EntityManager | null | undefined,
|
||||
input: WagonEventInput | null | Array<WagonEventInput | null>,
|
||||
): Promise<void> {
|
||||
const inputs = (Array.isArray(input) ? input : [input]).filter(
|
||||
(i): i is WagonEventInput => Boolean(i?.wagonId),
|
||||
);
|
||||
if (!inputs.length) return;
|
||||
const now = new Date();
|
||||
const rows = inputs.map((i) => ({
|
||||
wagonId: i.wagonId,
|
||||
wagonNumber: i.wagonNumber ?? null,
|
||||
type: i.type,
|
||||
category: WAGON_EVENT_CATEGORY[i.type] ?? WagonEventCategory.Lifecycle,
|
||||
occurredAt: i.occurredAt ?? now,
|
||||
actorUserId: i.actorUserId ?? null,
|
||||
fromYardId: i.fromYardId ?? null,
|
||||
toYardId: i.toYardId ?? null,
|
||||
trainId: i.trainId ?? null,
|
||||
trainScheduleId: i.trainScheduleId ?? null,
|
||||
bookingId: i.bookingId ?? null,
|
||||
fromValue: i.fromValue == null ? null : String(i.fromValue).slice(0, 120),
|
||||
toValue: i.toValue == null ? null : String(i.toValue).slice(0, 120),
|
||||
reason: i.reason?.trim() ? i.reason.trim() : null,
|
||||
metadata: i.metadata ?? null,
|
||||
}));
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const write = () =>
|
||||
mg
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(WagonEvent)
|
||||
.values(rows as unknown as QueryDeepPartialEntity<WagonEvent>[])
|
||||
.updateEntity(false)
|
||||
.execute();
|
||||
if (manager) {
|
||||
await write();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await write();
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to record ${rows.length} wagon event(s) (${rows[0].type}): ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** One wagon's timeline, newest first, with labels resolved. */
|
||||
async list(wagonId: string, query: WagonHistoryQueryDto = {}): Promise<WagonHistoryPage> {
|
||||
const limit = Math.min(Math.max(query.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT);
|
||||
const params: unknown[] = [wagonId];
|
||||
const where: string[] = ['e.wagon_id = $1'];
|
||||
const push = (value: unknown) => {
|
||||
params.push(value);
|
||||
return `$${params.length}`;
|
||||
};
|
||||
if (query.category) where.push(`e.category = ${push(query.category)}`);
|
||||
if (query.types?.length) where.push(`e.event_type = ANY(${push(query.types)}::text[])`);
|
||||
if (query.from) where.push(`e.occurred_at >= ${push(new Date(query.from))}`);
|
||||
if (query.to) where.push(`e.occurred_at <= ${push(new Date(query.to))}`);
|
||||
const cursor = decodeCursor(query.cursor);
|
||||
if (cursor) {
|
||||
// Row-value comparison walks the (wagon_id, occurred_at DESC, id DESC) index directly.
|
||||
where.push(`(e.occurred_at, e.id) < (${push(cursor.occurredAt)}, ${push(cursor.id)}::uuid)`);
|
||||
}
|
||||
|
||||
const rows: Array<WagonHistoryEvent & { occurredAt: Date }> = await this.dataSource.query(
|
||||
`SELECT e.id,
|
||||
e.wagon_id AS "wagonId",
|
||||
e.wagon_number AS "wagonNumber",
|
||||
e.event_type AS "type",
|
||||
e.category,
|
||||
e.occurred_at AS "occurredAt",
|
||||
e.actor_user_id AS "actorUserId",
|
||||
COALESCE(u.username, u.email) AS "actorName",
|
||||
e.from_yard_id AS "fromYardId",
|
||||
fy.label AS "fromYardLabel",
|
||||
e.to_yard_id AS "toYardId",
|
||||
ty.label AS "toYardLabel",
|
||||
e.train_id AS "trainId",
|
||||
t.code AS "trainCode",
|
||||
e.train_schedule_id AS "trainScheduleId",
|
||||
COALESCE(s.voyage_number, s.train_number) AS "scheduleLabel",
|
||||
e.booking_id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
e.from_value AS "fromValue",
|
||||
e.to_value AS "toValue",
|
||||
e.reason,
|
||||
e.metadata
|
||||
FROM freight.wagon_events e
|
||||
LEFT JOIN iam.users u ON u.id = e.actor_user_id
|
||||
LEFT JOIN freight.yards fy ON fy.id = e.from_yard_id
|
||||
LEFT JOIN freight.yards ty ON ty.id = e.to_yard_id
|
||||
LEFT JOIN freight.trains t ON t.id = e.train_id
|
||||
LEFT JOIN freight.train_schedules s ON s.id = e.train_schedule_id
|
||||
LEFT JOIN freight.bookings b ON b.id = e.booking_id
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY e.occurred_at DESC, e.id DESC
|
||||
LIMIT ${limit + 1}`,
|
||||
params,
|
||||
);
|
||||
|
||||
const hasMore = rows.length > limit;
|
||||
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||
const last = page[page.length - 1];
|
||||
return {
|
||||
items: page.map((r) => ({
|
||||
...r,
|
||||
occurredAt: new Date(r.occurredAt).toISOString(),
|
||||
})),
|
||||
nextCursor: hasMore && last ? encodeCursor(new Date(last.occurredAt), last.id) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCursor(occurredAt: Date, id: string): string {
|
||||
return Buffer.from(`${occurredAt.toISOString()}|${id}`, 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
function decodeCursor(cursor?: string): { occurredAt: Date; id: string } | null {
|
||||
if (!cursor) return null;
|
||||
const raw = Buffer.from(cursor, 'base64url').toString('utf8');
|
||||
const sep = raw.indexOf('|');
|
||||
const occurredAt = sep > 0 ? new Date(raw.slice(0, sep)) : new Date(NaN);
|
||||
const id = sep > 0 ? raw.slice(sep + 1) : '';
|
||||
if (Number.isNaN(occurredAt.getTime()) || !/^[0-9a-f-]{36}$/i.test(id)) {
|
||||
throw new BadRequestException('Invalid history cursor');
|
||||
}
|
||||
return { occurredAt, id };
|
||||
}
|
||||
@@ -14,7 +14,12 @@ const makeService = (wagon: any, counts: [number, number, number], pinned = fals
|
||||
return [];
|
||||
}),
|
||||
};
|
||||
const svc = new WagonsService(wagonRepo as any, {} as any, dataSource as any);
|
||||
const svc = new WagonsService(
|
||||
wagonRepo as any,
|
||||
{} as any,
|
||||
dataSource as any,
|
||||
{ record: jest.fn() } as any,
|
||||
);
|
||||
return { svc, wagonRepo };
|
||||
};
|
||||
|
||||
@@ -54,7 +59,12 @@ describe('WagonsService.purge', () => {
|
||||
|
||||
it('404s an unknown wagon', async () => {
|
||||
const wagonRepo = { findOne: jest.fn().mockResolvedValue(null), remove: jest.fn() };
|
||||
const svc = new WagonsService(wagonRepo as any, {} as any, { query: jest.fn() } as any);
|
||||
const svc = new WagonsService(
|
||||
wagonRepo as any,
|
||||
{} as any,
|
||||
{ query: jest.fn() } as any,
|
||||
{ record: jest.fn() } as any,
|
||||
);
|
||||
await expect(svc.purge('nope')).rejects.toThrow(NotFoundException);
|
||||
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -27,6 +27,8 @@ import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
||||
import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto';
|
||||
import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
|
||||
import { WagonsService } from './wagons.service';
|
||||
import { WagonHistoryQueryDto } from '../wagon-history/dto/wagon-history-query.dto';
|
||||
import { WagonHistoryService } from '../wagon-history/wagon-history.service';
|
||||
|
||||
@ApiTags('wagons')
|
||||
// No class-level guard: reads (list, by-id, movements) are login-only reference
|
||||
@@ -34,13 +36,16 @@ import { WagonsService } from './wagons.service';
|
||||
// fleet:view that drives the Fleet sidebar. Every mutation has its @FleetManage().
|
||||
@Controller('wagons')
|
||||
export class WagonsController {
|
||||
constructor(private readonly wagonsService: WagonsService) {}
|
||||
constructor(
|
||||
private readonly wagonsService: WagonsService,
|
||||
private readonly wagonHistory: WagonHistoryService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage(FREIGHT_PERMS.wagons.create)
|
||||
@ApiOperation({ summary: 'Create a new wagon' })
|
||||
create(@Body() dto: CreateWagonDto) {
|
||||
return this.wagonsService.create(dto);
|
||||
create(@Body() dto: CreateWagonDto, @CurrentUser() user: TCurrentUser) {
|
||||
return this.wagonsService.create(dto, user?.id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@@ -68,11 +73,26 @@ export class WagonsController {
|
||||
return this.wagonsService.listMovements(id);
|
||||
}
|
||||
|
||||
@Get(':id/history')
|
||||
@FleetView(FREIGHT_PERMS.wagons.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Unified wagon history — yard moves, coupling, schedule pins/dispatch, status flips, cargo, lifecycle — newest first, keyset-paginated (`cursor`)',
|
||||
})
|
||||
history(@Param('id', ParseUUIDPipe) id: string, @Query() query: WagonHistoryQueryDto) {
|
||||
// No existence check on purpose: a deleted or purged wagon keeps its history.
|
||||
return this.wagonHistory.list(id, query);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage(FREIGHT_PERMS.wagons.update)
|
||||
@ApiOperation({ summary: 'Update a wagon' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) {
|
||||
return this.wagonsService.update(id, dto);
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateWagonDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.wagonsService.update(id, dto, user?.id);
|
||||
}
|
||||
|
||||
// Declared before @Delete(':id') so "permanent" is never captured as an id.
|
||||
@@ -86,29 +106,33 @@ export class WagonsController {
|
||||
summary:
|
||||
'Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)',
|
||||
})
|
||||
purge(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonsService.purge(id);
|
||||
purge(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) {
|
||||
return this.wagonsService.purge(id, user?.id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage(FREIGHT_PERMS.wagons.delete)
|
||||
@ApiOperation({ summary: 'Delete a wagon' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonsService.remove(id);
|
||||
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) {
|
||||
return this.wagonsService.remove(id, user?.id);
|
||||
}
|
||||
|
||||
@Post(':id/assign-train')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Assign wagon to a train' })
|
||||
assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) {
|
||||
return this.wagonsService.assignToTrain(id, dto);
|
||||
assignToTrain(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AssignWagonToTrainDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.wagonsService.assignToTrain(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Post(':id/unassign-train')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Unassign wagon from train' })
|
||||
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonsService.unassignFromTrain(id);
|
||||
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) {
|
||||
return this.wagonsService.unassignFromTrain(id, user?.id);
|
||||
}
|
||||
|
||||
@Post('bulk-transfer')
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Freight, PaginatedResponse, WagonMovementKind, WagonStatus } from '@edr/types';
|
||||
import {
|
||||
Freight,
|
||||
PaginatedResponse,
|
||||
WagonEventType,
|
||||
WagonMovementKind,
|
||||
WagonStatus,
|
||||
} from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
@@ -19,6 +25,16 @@ import { WagonStatusLog } from './entities/wagon-status-log.entity';
|
||||
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { WagonEventInput, WagonHistoryService } from '../wagon-history/wagon-history.service';
|
||||
|
||||
/** Wagon columns whose manual edits are diffed into a DETAILS_UPDATED history row. */
|
||||
const TRACKED_DETAIL_FIELDS = [
|
||||
'wagonNumber',
|
||||
'wagonTypeId',
|
||||
'exportTrainNumber',
|
||||
'importTrainNumber',
|
||||
'notes',
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class WagonsService {
|
||||
@@ -28,9 +44,10 @@ export class WagonsService {
|
||||
@InjectRepository(Train)
|
||||
private readonly trainRepo: Repository<Train>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly wagonHistory: WagonHistoryService,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateWagonDto): Promise<Wagon> {
|
||||
async create(dto: CreateWagonDto, userId?: string | null): Promise<Wagon> {
|
||||
const wagon = this.wagonRepo.create({
|
||||
...dto,
|
||||
status: dto.status ?? WagonStatus.Available,
|
||||
@@ -41,7 +58,22 @@ export class WagonsService {
|
||||
if (dto.currentYardId === undefined) wagon.currentYardId = null;
|
||||
if (dto.exportTrainNumber === undefined) wagon.exportTrainNumber = null;
|
||||
if (dto.importTrainNumber === undefined) wagon.importTrainNumber = null;
|
||||
return this.wagonRepo.save(wagon);
|
||||
const saved = await this.wagonRepo.save(wagon);
|
||||
await this.wagonHistory.record(null, {
|
||||
wagonId: saved.id,
|
||||
wagonNumber: saved.wagonNumber,
|
||||
type: WagonEventType.Registered,
|
||||
actorUserId: userId ?? null,
|
||||
toYardId: saved.currentYardId ?? null,
|
||||
trainId: saved.trainId ?? null,
|
||||
toValue: saved.status,
|
||||
metadata: {
|
||||
wagonTypeId: saved.wagonTypeId,
|
||||
exportTrainNumber: saved.exportTrainNumber ?? null,
|
||||
importTrainNumber: saved.importTrainNumber ?? null,
|
||||
},
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** Shared filter/sort builder behind `findAll` (array) and `findAllPaged` (envelope). */
|
||||
@@ -210,6 +242,10 @@ export class WagonsService {
|
||||
}
|
||||
}
|
||||
const previousYardId = wagon.currentYardId ?? null;
|
||||
const previousStatus = wagon.status;
|
||||
const before = Object.fromEntries(
|
||||
TRACKED_DETAIL_FIELDS.map((f) => [f, (wagon as unknown as Record<string, unknown>)[f] ?? null]),
|
||||
);
|
||||
Object.assign(wagon, dto);
|
||||
// `findById` eager-loads `currentYard`; when the DTO changes the scalar FK
|
||||
// TypeORM otherwise re-derives `current_yard_id` from the STALE relation
|
||||
@@ -243,6 +279,47 @@ export class WagonsService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
// History: one row per kind of change — a yard move, a status flip, and
|
||||
// the remaining field edits as a single diff.
|
||||
const events: WagonEventInput[] = [];
|
||||
const changes: Record<string, { from: unknown; to: unknown }> = {};
|
||||
for (const f of TRACKED_DETAIL_FIELDS) {
|
||||
if (dto[f] === undefined) continue;
|
||||
const to = (wagon as unknown as Record<string, unknown>)[f] ?? null;
|
||||
if (before[f] !== to) changes[f] = { from: before[f], to };
|
||||
}
|
||||
if (Object.keys(changes).length) {
|
||||
events.push({
|
||||
wagonId: id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.DetailsUpdated,
|
||||
actorUserId: userId ?? null,
|
||||
metadata: { changes },
|
||||
});
|
||||
}
|
||||
if (dto.currentYardId !== undefined && dto.currentYardId !== previousYardId) {
|
||||
events.push({
|
||||
wagonId: id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.MovedManually,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: previousYardId,
|
||||
toYardId: dto.currentYardId ?? null,
|
||||
reason: 'Wagon record edited',
|
||||
});
|
||||
}
|
||||
if (dto.status !== undefined && dto.status !== previousStatus) {
|
||||
events.push({
|
||||
wagonId: id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.StatusChanged,
|
||||
actorUserId: userId ?? null,
|
||||
fromValue: previousStatus,
|
||||
toValue: dto.status,
|
||||
reason: 'Wagon record edited',
|
||||
});
|
||||
}
|
||||
await this.wagonHistory.record(null, events);
|
||||
// Re-read with the relation so the response reflects the new yard label
|
||||
// instead of the stale relation object loaded before the assign.
|
||||
return this.findById(id);
|
||||
@@ -258,7 +335,7 @@ export class WagonsService {
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
async remove(id: string, userId?: string | null): Promise<void> {
|
||||
const wagon = await this.findById(id);
|
||||
// A coupled wagon must be detached via train-builder before it can be
|
||||
// removed, so a built train never silently loses a wagon.
|
||||
@@ -275,6 +352,14 @@ export class WagonsService {
|
||||
// Soft delete (deleted_at) — hard-deleting would strand ledger/schedule
|
||||
// history that references this wagon.
|
||||
await this.wagonRepo.softRemove(wagon);
|
||||
await this.wagonHistory.record(null, {
|
||||
wagonId: id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.Deleted,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
fromValue: wagon.status,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,7 +373,7 @@ export class WagonsService {
|
||||
*
|
||||
* Soft-deleted wagons are purgeable, so `withDeleted` is used to find them.
|
||||
*/
|
||||
async purge(id: string): Promise<void> {
|
||||
async purge(id: string, userId?: string | null): Promise<void> {
|
||||
const wagon = await this.wagonRepo.findOne({
|
||||
where: { id },
|
||||
withDeleted: true,
|
||||
@@ -343,6 +428,16 @@ export class WagonsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Recorded BEFORE the row goes: wagon_events has no FK, so the history of
|
||||
// a purged wagon survives under its id and number snapshot.
|
||||
await this.wagonHistory.record(null, {
|
||||
wagonId: id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.Purged,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
fromValue: wagon.status,
|
||||
});
|
||||
await this.wagonRepo.remove(wagon);
|
||||
}
|
||||
|
||||
@@ -366,7 +461,11 @@ export class WagonsService {
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise<Wagon> {
|
||||
async assignToTrain(
|
||||
wagonId: string,
|
||||
dto: AssignWagonToTrainDto,
|
||||
userId?: string | null,
|
||||
): Promise<Wagon> {
|
||||
const wagon = await this.findById(wagonId);
|
||||
// Mirror train-builder attachWagons: only a truly free, available wagon
|
||||
// (any yard) can be coupled, and never onto a dispatched train.
|
||||
@@ -399,13 +498,25 @@ export class WagonsService {
|
||||
);
|
||||
}
|
||||
|
||||
const previousStatus = wagon.status;
|
||||
wagon.trainId = train.id;
|
||||
wagon.sequenceNumber = nextSequence;
|
||||
wagon.status = WagonStatus.Assigned;
|
||||
return this.wagonRepo.save(wagon);
|
||||
const saved = await this.wagonRepo.save(wagon);
|
||||
await this.wagonHistory.record(null, {
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.CoupledToTrain,
|
||||
actorUserId: userId ?? null,
|
||||
trainId: train.id,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
toValue: nextSequence,
|
||||
metadata: { status: { from: previousStatus, to: WagonStatus.Assigned }, trainCode: train.code },
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
async unassignFromTrain(wagonId: string): Promise<Wagon> {
|
||||
async unassignFromTrain(wagonId: string, userId?: string | null): Promise<Wagon> {
|
||||
const wagon = await this.findById(wagonId);
|
||||
// A wagon pinned to a live schedule is still operationally committed even
|
||||
// if the fleet train is being edited — don't free it out from under it.
|
||||
@@ -414,10 +525,24 @@ export class WagonsService {
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be detached`,
|
||||
);
|
||||
}
|
||||
const previousTrainId = wagon.trainId;
|
||||
const previousSequence = wagon.sequenceNumber;
|
||||
const previousStatus = wagon.status;
|
||||
wagon.trainId = null;
|
||||
wagon.sequenceNumber = null;
|
||||
wagon.status = WagonStatus.Available;
|
||||
return this.wagonRepo.save(wagon);
|
||||
const saved = await this.wagonRepo.save(wagon);
|
||||
await this.wagonHistory.record(null, {
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.UncoupledFromTrain,
|
||||
actorUserId: userId ?? null,
|
||||
trainId: previousTrainId,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
fromValue: previousSequence,
|
||||
metadata: { status: { from: previousStatus, to: WagonStatus.Available } },
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -464,9 +589,20 @@ export class WagonsService {
|
||||
}
|
||||
|
||||
let moved = 0;
|
||||
const events: WagonEventInput[] = [];
|
||||
for (const wagon of wagons) {
|
||||
const previousYardId = wagon.currentYardId ?? null;
|
||||
if (previousYardId === toYardId) continue;
|
||||
events.push({
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.MovedManually,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: previousYardId,
|
||||
toYardId,
|
||||
reason: opts?.transferRequestId ? 'Transfer request fulfilled' : 'Bulk transfer',
|
||||
metadata: opts?.transferRequestId ? { transferRequestId: opts.transferRequestId } : null,
|
||||
});
|
||||
wagon.currentYardId = toYardId;
|
||||
// Drop the eager relation so the scalar FK wins on save (see `update`).
|
||||
wagon.currentYard = null;
|
||||
@@ -484,6 +620,7 @@ export class WagonsService {
|
||||
);
|
||||
moved++;
|
||||
}
|
||||
await this.wagonHistory.record(queryRunner.manager, events);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return { moved };
|
||||
@@ -547,6 +684,18 @@ export class WagonsService {
|
||||
}
|
||||
await queryRunner.manager.save(Wagon, wagons);
|
||||
if (logs.length) await queryRunner.manager.save(WagonStatusLog, logs);
|
||||
await this.wagonHistory.record(
|
||||
queryRunner.manager,
|
||||
logs.map((l) => ({
|
||||
wagonId: l.wagonId,
|
||||
wagonNumber: wagons.find((w) => w.id === l.wagonId)?.wagonNumber ?? null,
|
||||
type: WagonEventType.StatusChanged,
|
||||
actorUserId: changedByUserId ?? null,
|
||||
fromValue: l.fromStatus,
|
||||
toValue: l.toStatus,
|
||||
reason: dto.note ?? null,
|
||||
})),
|
||||
);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return { updated: wagons.length };
|
||||
|
||||
Reference in New Issue
Block a user