Merge pull request #1412 from Tria-plc/freight_feature/usermanagement

feat: enhance booking and audit log functionalities
This commit is contained in:
marshal
2026-08-25 02:50:34 +03:00
committed by GitHub
28 changed files with 1356 additions and 86 deletions

View File

@@ -2370,16 +2370,22 @@ export class TrainSchedulingService {
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule');
}
const link = schedule.scheduleBookings?.find((sb) => sb.bookingId === bookingId);
if (!link) {
throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`);
}
const booking = await this.bookingsRepository.findById(bookingId);
// A dispatched train may still shed a booking staff left behind at its
// boarding yard (dispatch dialog / log-pass "leave") — but never one whose
// cargo is actually on the train.
const leftBehindWhileDispatched =
schedule.status === 'DISPATCHED' &&
!booking?.loadedAt &&
booking?.status !== 'IN_TRANSIT';
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status) && !leftBehindWhileDispatched) {
throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule');
}
if (booking?.isGovernment) {
throw new BadRequestException(
'Government bookings cannot be removed from a train. They can only be switched onto another allocation.',
@@ -2447,6 +2453,21 @@ export class TrainSchedulingService {
for (const slot of survivingSlots) {
const slotAllocations = slot.allocations ?? [];
if (slotAllocations.length === 0) {
// A dispatched train pinned its wagons (ASSIGNED + schedule id) at
// departure — freeing the slot must also free the physical wagon, or
// the checkpoint position-fix keeps dragging it along the corridor.
if (schedule.status === 'DISPATCHED' && slot.physicalWagonId) {
const wagon = await manager
.getRepository(Wagon)
.findOne({ where: { id: slot.physicalWagonId } });
if (wagon && wagon.currentTrainScheduleId === scheduleId) {
await manager.getRepository(Wagon).update(wagon.id, {
currentTrainScheduleId: null,
trainSetWagonId: null,
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
});
}
}
await manager.getRepository(TrainSetWagon).delete(slot.id);
continue;
}
@@ -2476,8 +2497,10 @@ export class TrainSchedulingService {
// Freed wagons may un-full the train — re-derive the window status (this
// also revives a DONE window pre-departure so the freed space is bookable
// again for import/export).
await this.bookingBatchService?.refreshWindowStatus(scheduleId);
// again for import/export). A dispatched train's window stays CLOSED.
if (schedule.status !== 'DISPATCHED') {
await this.bookingBatchService?.refreshWindowStatus(scheduleId);
}
await this.trainCompositionRemovalLogRepository.create({
scheduleId,
@@ -2842,14 +2865,35 @@ export class TrainSchedulingService {
return this.getTrainScheduleById(scheduleId);
}
async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}, userId?: string) {
let schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
}
// Loading is a manual staff decision: when the dispatch dialog sends the
// checked list, every other unloaded origin boarder is left behind —
// deallocated from its wagon and returned to the booking pool — so the
// origin auto-load below only ever touches confirmed cargo. Government
// bookings cannot be unassigned and keep the historic auto-load.
if (dto.loadedBookingIds) {
const keep = new Set(dto.loadedBookingIds);
const candidates = await this.unloadedOriginBoarderIds(scheduleId, schedule.originStationId);
const leftBehind = candidates.filter((id) => !keep.has(id));
for (const bookingId of leftBehind) {
await this.unassignBooking(scheduleId, bookingId, userId);
}
if (leftBehind.length) {
// Unassign deleted allocations and slots — reload the graph dispatch works on.
const reloaded = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!reloaded) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
schedule = reloaded;
}
}
// Staff may record the departure after the fact — past is fine, future is not.
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
this.assertNotFuture(now, 'Departure time');
@@ -3071,6 +3115,34 @@ export class TrainSchedulingService {
});
}
/**
* Origin boarders the dispatch dialog decides over: unloaded (no journey
* load, no workspace LOADED flag), boardable, non-government. Boardable is
* PAID — or FULLY_EXECUTED for shipping-line bookings, which never prepay
* (their charge sits on the credit ledger) yet ride from accept.
*/
private async unloadedOriginBoarderIds(
scheduleId: string,
originYardId: string,
): Promise<string[]> {
const rows: Array<{ id: string }> = await this.dataSource.query(
`SELECT b.id
FROM freight.bookings b
JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id
WHERE tsb.train_schedule_id = $1
AND tsb.deleted_at IS NULL
AND b.deleted_at IS NULL
AND b.origin_yard_id = $2
AND b.loaded_at IS NULL
AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED'
AND b.is_government = false
AND (b.status = 'PAID'
OR (b.shipping_line_company_id IS NOT NULL AND b.status = 'FULLY_EXECUTED'))`,
[scheduleId, originYardId],
);
return rows.map((r) => r.id);
}
async getImportDjiboutiOperation(scheduleId: string) {
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
@@ -9866,6 +9938,9 @@ export class TrainSchedulingService {
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
isGovernment: Boolean(sb.booking?.isGovernment),
// Shipping-line bookings never prepay (credit ledger) — the dispatch
// dialog needs this to know FULLY_EXECUTED means boardable for them.
shippingLineCompanyId: sb.booking?.shippingLineCompanyId ?? null,
})) ?? [],
// Ordered corridor stops (route milestones; falls back to the two
// endpoints) — lets the UI draw per-segment occupancy and label legs.