add detail batch and allocation monitoring page

This commit is contained in:
Marshal
2026-06-13 18:28:39 +00:00
parent e3cd369b01
commit b73bf2154e
30 changed files with 3336 additions and 72 deletions

View File

@@ -0,0 +1,22 @@
import { Column, Entity, Index } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
@Entity({ schema: 'freight', name: 'train_composition_removal_logs' })
@Index(['scheduleId'])
export class TrainCompositionRemovalLog extends BaseEntity {
@Column({ name: 'schedule_id', type: 'uuid' }) scheduleId!: string;
@Column({ name: 'booking_id', type: 'uuid' }) bookingId!: string;
@Column({ name: 'booking_reference', type: 'varchar', length: 64, nullable: true })
bookingReference?: string | null;
@Column({ name: 'removed_by_user_id', type: 'uuid', nullable: true })
removedByUserId?: string | null;
@Column({ name: 'removed_at', type: 'timestamptz', default: () => 'NOW()' })
removedAt!: Date;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
}

View File

@@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { TrainCompositionRemovalLog } from './entities/train-composition-removal-log.entity';
@Injectable()
export class TrainCompositionRemovalLogRepository extends BaseRepository<TrainCompositionRemovalLog> {
constructor(dataSource: DataSource) {
super(dataSource.getRepository(TrainCompositionRemovalLog));
}
async findByScheduleId(scheduleId: string): Promise<TrainCompositionRemovalLog[]> {
return this.findAll({
where: { scheduleId },
order: { removedAt: 'DESC' },
});
}
}

View File

@@ -3,11 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
import { TrainSchedule } from './entities/train-schedule.entity';
import { TrainCompositionRemovalLog } from './entities/train-composition-removal-log.entity';
import { WagonAllocationBulkLoad } from './entities/wagon-allocation-bulk-load.entity';
import { WagonAllocationContainerItem } from './entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository';
import { TrainSchedulesRepository } from './train-schedules.repository';
import { TrainCompositionRemovalLogRepository } from './train-composition-removal-log.repository';
import { WagonAllocationBulkLoadsRepository } from './wagon-allocation-bulk-loads.repository';
import { WagonAllocationContainerItemsRepository } from './wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository';
@@ -17,6 +19,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r
TypeOrmModule.forFeature([
TrainSchedule,
TrainScheduleBooking,
TrainCompositionRemovalLog,
WagonBookingAllocation,
WagonAllocationContainerItem,
WagonAllocationBulkLoad,
@@ -25,6 +28,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r
providers: [
TrainSchedulesRepository,
TrainScheduleBookingsRepository,
TrainCompositionRemovalLogRepository,
WagonBookingAllocationsRepository,
WagonAllocationContainerItemsRepository,
WagonAllocationBulkLoadsRepository,
@@ -32,6 +36,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r
exports: [
TrainSchedulesRepository,
TrainScheduleBookingsRepository,
TrainCompositionRemovalLogRepository,
WagonBookingAllocationsRepository,
WagonAllocationContainerItemsRepository,
WagonAllocationBulkLoadsRepository,

View File

@@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';
export class UpdateContainerItemDto {
@IsString()
@IsOptional()
containerNumber?: string | null;
}

View File

@@ -9,7 +9,10 @@ import {
Post,
Query,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { AuthUserPayload } from '../../common/resolve-auth-user-id';
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
import { AssignBookingsDto } from './dto/assign-bookings.dto';
@@ -18,6 +21,7 @@ import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
import { PinWagonsDto } from './dto/pin-wagons.dto';
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
@@ -176,8 +180,44 @@ export class TrainSchedulingController {
unassignBooking(
@Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainSchedulingService.unassignBooking(id, bookingId);
return this.trainSchedulingService.unassignBooking(id, bookingId, resolveAuthUserId(user));
}
@Delete('schedules/:id/wagons/:trainSetWagonId')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Remove an empty wagon slot from a train' })
removeWagonSlot(
@Param('id', ParseUUIDPipe) id: string,
@Param('trainSetWagonId', ParseUUIDPipe) trainSetWagonId: string,
) {
return this.trainSchedulingService.removeTrainSetWagonSlot(id, trainSetWagonId);
}
@Patch('schedules/:id/container-items/:itemId')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Update a container number on a wagon slot' })
updateContainerItem(
@Param('id', ParseUUIDPipe) id: string,
@Param('itemId', ParseUUIDPipe) itemId: string,
@Body() dto: UpdateContainerItemDto,
) {
return this.trainSchedulingService.updateContainerItem(id, itemId, dto);
}
@Get('schedules/:id/unassigned-bookings')
@TrainSchedulingView()
@ApiOperation({ summary: 'Get unassigned bookings for a schedule' })
getUnassignedBookings(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getUnassignedBookings(id);
}
@Get('schedules/:id/composition-removals')
@TrainSchedulingView()
@ApiOperation({ summary: 'Get removal log for a schedule' })
getCompositionRemovals(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getCompositionRemovals(id);
}
@Post('schedules/:id/pin-wagons')

View File

@@ -144,6 +144,7 @@ describe('TrainSchedulingService', () => {
wagonAllocationContainerItemsRepository as never,
wagonAllocationBulkLoadsRepository as never,
trainCheckpointEventsRepository as never,
{} as never, // trainCompositionRemovalLogRepository
);
const defaultFleetWagons = [

View File

@@ -26,9 +26,11 @@ import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainCompositionRemovalLogRepository } from '../train-schedules/train-composition-removal-log.repository';
import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository';
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
@@ -41,6 +43,7 @@ import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
import { PinWagonsDto } from './dto/pin-wagons.dto';
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
@@ -143,6 +146,7 @@ export class TrainSchedulingService {
private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository,
private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository,
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
private readonly configService?: ConfigService,
) {}
@@ -467,7 +471,7 @@ export class TrainSchedulingService {
return { ...detail, warnings, deferredBookings };
}
async unassignBooking(scheduleId: string, bookingId: string) {
async unassignBooking(scheduleId: string, bookingId: string, userId?: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
@@ -481,6 +485,9 @@ export class TrainSchedulingService {
throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`);
}
const booking = await this.bookingsRepository.findById(bookingId);
const bookingReference = booking?.reference ?? null;
await this.dataSource.transaction(async (manager) => {
const allocationIds = (schedule.trainSet?.wagons ?? [])
.flatMap((w) => w.allocations ?? [])
@@ -529,6 +536,18 @@ export class TrainSchedulingService {
}
});
await this.trainCompositionRemovalLogRepository.create({
scheduleId,
bookingId,
bookingReference,
removedByUserId: userId ?? null,
removedAt: new Date(),
});
console.log(
`[NOTIFY] Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer should be notified to reschedule or cancel.`,
);
return this.getTrainScheduleById(scheduleId);
}
@@ -2236,6 +2255,103 @@ export class TrainSchedulingService {
return result;
}
async removeTrainSetWagonSlot(scheduleId: string, trainSetWagonId: string): Promise<any> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException('Cannot remove wagon slots from a finalized or dispatched schedule');
}
const wagon = (schedule.trainSet?.wagons ?? []).find((w) => w.id === trainSetWagonId);
if (!wagon) {
throw new NotFoundException(`Train set wagon ${trainSetWagonId} not found in this schedule`);
}
if ((wagon.allocations ?? []).length > 0) {
throw new BadRequestException(
'Cannot remove a wagon slot that has active allocations; remove the booking first',
);
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(TrainSetWagon).delete(trainSetWagonId);
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
wagonCount: Math.max(0, (schedule.trainSet?.wagonCount ?? 0) - 1),
totalLengthMeters: Math.max(0, (schedule.trainSet?.totalLengthMeters ?? 0) - (wagon.lengthMeters ?? 0)),
});
});
return this.getTrainScheduleById(scheduleId);
}
async updateContainerItem(
scheduleId: string,
itemId: string,
dto: UpdateContainerItemDto,
): Promise<{ id: string; containerNumber: string | null }> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status === 'DISPATCHED') {
throw new BadRequestException('Cannot edit a dispatched schedule');
}
const item = await this.dataSource.getRepository(WagonAllocationContainerItem).findOne({
where: { id: itemId },
relations: ['wagonBookingAllocation', 'wagonBookingAllocation.trainSetWagon'],
});
if (!item) {
throw new NotFoundException(`Container item ${itemId} not found`);
}
const wagonId = item.wagonBookingAllocationId;
const wagonAllocation = await this.dataSource.getRepository(WagonBookingAllocation).findOne({
where: { id: wagonId },
relations: ['trainSetWagon'],
});
if (!wagonAllocation?.trainSetWagon) {
throw new NotFoundException(`Container item ${itemId} does not belong to this schedule`);
}
const trainSetWagonId = wagonAllocation.trainSetWagon.id;
const wagonIds = (schedule.trainSet?.wagons ?? []).map((w) => w.id);
if (!wagonIds.includes(trainSetWagonId)) {
throw new NotFoundException(`Container item ${itemId} does not belong to this schedule`);
}
await this.dataSource.getRepository(WagonAllocationContainerItem).update(itemId, {
containerNumber: dto.containerNumber ?? null,
});
return { id: itemId, containerNumber: dto.containerNumber ?? null };
}
async getUnassignedBookings(scheduleId: string): Promise<any[]> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
const allBookings = await this.bookingsRepository.findAll({
where: { trainScheduleId: scheduleId },
select: ['id', 'reference', 'freightType', 'priorityScore', 'cargoTotalWeightVgm', 'status', 'schedulingStatus'],
});
const allocatedBookingIds = await this.getWagonAssignedBookingIds(scheduleId);
const unassigned = allBookings.filter((b: any) => !allocatedBookingIds.has(b.id));
return unassigned.sort((a: any, b: any) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0));
}
async getCompositionRemovals(scheduleId: string): Promise<any[]> {
return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId);
}
private async getWagonAssignedBookingIds(scheduleId: string): Promise<Set<string>> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id);