mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 14:38:12 +00:00
feat: enhance booking and audit log functionalities
- Implemented read-only locking for customer-requested container sizes and billing currency in the GlCreateBookingForm component. - Added functionality to lock partner quantities based on shipment requests in the ConsolidationPartnerPanel. - Introduced a new Leave action in the LogPassYardWorkModal to unassign bookings from trains. - Enhanced the AuditLogsPage to support filtering by action and added a Go button for direct navigation to entity detail pages. - Updated WagonCancellationsPage to handle odd-20ft credits requiring partner selection during rebooking. - Improved TrainScheduleV2DetailPage to allow manual loading of cargo and display warnings for unassigned bookings. - Added a new reference field to the audit logs for better searchability and tracking of actions. - Created a migration to add the reference column to the audit logs table and established an index for efficient querying. - Defined a registry for audit reference sources to streamline the retrieval of human identifiers for various entities.
This commit is contained in:
@@ -566,8 +566,9 @@ export class TrainSchedulingController {
|
||||
dispatchSchedule(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: DispatchScheduleDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.trainSchedulingService.dispatchSchedule(id, dto);
|
||||
return this.trainSchedulingService.dispatchSchedule(id, dto, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Get("intercity/bookings")
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { TrainCheckpointKind } from '@edr/types';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
@@ -67,4 +69,20 @@ export class DispatchScheduleDto {
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
actualDepartureAt?: string;
|
||||
|
||||
/**
|
||||
* Loading is a manual staff decision. When present, only these bookings are
|
||||
* auto-loaded at the origin; every other unloaded origin boarder is left
|
||||
* behind — deallocated from its wagon and returned to the booking pool.
|
||||
* Absent (older clients) = load every origin boarder, the historic behavior.
|
||||
*/
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description:
|
||||
'Origin-yard bookings confirmed loaded; the rest are unassigned back to the pool. Omit to auto-load all.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
loadedBookingIds?: string[];
|
||||
}
|
||||
|
||||
@@ -2360,16 +2360,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.',
|
||||
@@ -2437,6 +2443,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;
|
||||
}
|
||||
@@ -2466,8 +2487,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,
|
||||
@@ -2832,14 +2855,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');
|
||||
@@ -3061,6 +3105,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);
|
||||
@@ -9798,6 +9870,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.
|
||||
|
||||
Reference in New Issue
Block a user