mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 14:23:40 +00:00
remove reopen delay minutes from global rules and update related types
- Removed the field from and related components. - Updated to reflect the removal of the reopen delay input field. - Modified to include new train number fields: and . - Added interface to manage active schedules with trade direction. - Introduced interface to track wagon shortages in bookings. - Updated logic to ensure consistent UI state representation. - Created migrations to drop the column and add and columns to the table. - Added tests for the new booking window display logic and wagon planning functionality.
This commit is contained in:
@@ -99,6 +99,7 @@ import {
|
||||
summarizeFleetWarnings,
|
||||
totalAssignedWeight,
|
||||
wagonsRequiredForBooking,
|
||||
type BookingWagonShortage,
|
||||
type DeferredBookingRow,
|
||||
type FleetAvailabilityRow,
|
||||
} from './fleet-plan.util';
|
||||
@@ -214,8 +215,6 @@ export function effectiveWindowConfig(
|
||||
: liveCfg.windowDurationHours,
|
||||
docReviewMinutes: liveCfg.docReviewMinutes,
|
||||
paymentWindowMinutes: liveCfg.paymentWindowMinutes,
|
||||
reopenDelayMinutes:
|
||||
schedule.ruleReopenDelayMinutes ?? liveCfg.reopenDelayMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -251,6 +250,8 @@ export interface CompositionUnassignedBookingRow {
|
||||
yardWagonsAvailable: number;
|
||||
canAssign: boolean;
|
||||
blockReason: string | null;
|
||||
/** Structured fleet shortage when the block is missing wagons (null otherwise). */
|
||||
shortage: BookingWagonShortage | null;
|
||||
}
|
||||
|
||||
export interface UnassignedBookingsResponse {
|
||||
@@ -613,7 +614,6 @@ export class TrainSchedulingService {
|
||||
if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
|
||||
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
|
||||
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
|
||||
if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes;
|
||||
|
||||
// The booking desk supports three shapes: a same-day range
|
||||
// (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an
|
||||
@@ -702,7 +702,6 @@ export class TrainSchedulingService {
|
||||
// override changes them, so the derived snapshot delay stays consistent.
|
||||
docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes,
|
||||
paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes,
|
||||
reopenDelayMinutes: liveCfg.reopenDelayMinutes,
|
||||
};
|
||||
|
||||
// Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid
|
||||
@@ -929,7 +928,6 @@ export class TrainSchedulingService {
|
||||
windowDurationHours: num(row?.windowDurationHours, 3),
|
||||
docReviewMinutes: num(row?.docReviewMinutes, 30),
|
||||
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
|
||||
reopenDelayMinutes: num(row?.reopenDelayMinutes, 90),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1079,6 +1077,20 @@ export class TrainSchedulingService {
|
||||
// getSchedulableRoute already rejected DOMESTIC (intercity).
|
||||
const direction = this.resolveRouteDirection(route);
|
||||
|
||||
// Direction-matched fixed number from the built train's typed pair.
|
||||
// Legacy locomotive-picked schedules keep dispatch-time pool assignment
|
||||
// (assignTrainNumber is idempotent, so both paths compose).
|
||||
const pairTrainNumber = builtTrain
|
||||
? (direction === 'IMPORT'
|
||||
? builtTrain.importTrainNumber
|
||||
: builtTrain.exportTrainNumber) ?? null
|
||||
: null;
|
||||
if (builtTrain && !pairTrainNumber) {
|
||||
scheduleWarnings.push(
|
||||
`Train ${builtTrain.code} has no ${direction === 'IMPORT' ? 'import' : 'export'} train number; a pool number will be assigned at dispatch`,
|
||||
);
|
||||
}
|
||||
|
||||
const trainSet = await this.buildEmptyTrainSet(
|
||||
manager,
|
||||
lockedLocomotives,
|
||||
@@ -1174,6 +1186,7 @@ export class TrainSchedulingService {
|
||||
scheduledDepartureDate: departure,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
direction,
|
||||
trainNumber: pairTrainNumber ?? undefined,
|
||||
maxWagons,
|
||||
...windowFields,
|
||||
}),
|
||||
@@ -2684,7 +2697,23 @@ export class TrainSchedulingService {
|
||||
manager: EntityManager,
|
||||
schedule: TrainSchedule,
|
||||
): Promise<string> {
|
||||
if (schedule.trainNumber) return schedule.trainNumber;
|
||||
if (schedule.trainNumber) {
|
||||
// Creation-assigned pair number: two live runs may never share a number,
|
||||
// so block dispatch while another DISPATCHED schedule still carries it.
|
||||
const clash = await manager
|
||||
.getRepository(TrainSchedule)
|
||||
.createQueryBuilder('s')
|
||||
.where('s.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
|
||||
.andWhere('s.train_number = :trainNumber', { trainNumber: schedule.trainNumber })
|
||||
.andWhere('s.id != :id', { id: schedule.id })
|
||||
.getOne();
|
||||
if (clash) {
|
||||
throw new ConflictException(
|
||||
`Train number ${schedule.trainNumber} is already out on ${clash.reference ?? clash.id}; it must arrive before this train dispatches`,
|
||||
);
|
||||
}
|
||||
return schedule.trainNumber;
|
||||
}
|
||||
|
||||
// Count container vs bulk wagons from the planned allocations.
|
||||
let containerWagons = 0;
|
||||
@@ -2705,17 +2734,38 @@ export class TrainSchedulingService {
|
||||
|
||||
// Lock the set of currently-active numbered schedules so two concurrent
|
||||
// dispatches serialize and can't both claim the same lowest-free number.
|
||||
// DRAFT/SCHEDULED are included because pair numbers are now assigned at
|
||||
// creation and must be invisible to pool picks.
|
||||
const activeNumbered = await manager
|
||||
.getRepository(TrainSchedule)
|
||||
.createQueryBuilder('schedule')
|
||||
.setLock('pessimistic_write')
|
||||
.where('schedule.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
|
||||
.where('schedule.status IN (:...statuses)', {
|
||||
statuses: [
|
||||
TrainScheduleStatusEnum.Draft,
|
||||
TrainScheduleStatusEnum.Scheduled,
|
||||
TrainScheduleStatusEnum.Dispatched,
|
||||
],
|
||||
})
|
||||
.andWhere('schedule.train_number IS NOT NULL')
|
||||
.getMany();
|
||||
|
||||
const usedNumbers = activeNumbered
|
||||
.map((s) => s.trainNumber)
|
||||
.filter((n): n is string => Boolean(n));
|
||||
// Every typed train pair is reserved for its train — the pool may never
|
||||
// hand one out, even when that train has no active schedule right now.
|
||||
const pairRows: { n: string }[] = await manager.query(
|
||||
`SELECT import_train_number AS n FROM freight.trains
|
||||
WHERE deleted_at IS NULL AND import_train_number IS NOT NULL
|
||||
UNION
|
||||
SELECT export_train_number FROM freight.trains
|
||||
WHERE deleted_at IS NULL AND export_train_number IS NOT NULL`,
|
||||
);
|
||||
|
||||
const usedNumbers = [
|
||||
...activeNumbered
|
||||
.map((s) => s.trainNumber)
|
||||
.filter((n): n is string => Boolean(n)),
|
||||
...pairRows.map((row) => row.n),
|
||||
];
|
||||
|
||||
const number = pickLowestFreeNumber(pool.numbers, usedNumbers);
|
||||
if (!number) {
|
||||
@@ -4624,6 +4674,8 @@ export class TrainSchedulingService {
|
||||
code: train.code,
|
||||
trainName: train.trainName ?? null,
|
||||
status: train.status,
|
||||
importTrainNumber: train.importTrainNumber ?? null,
|
||||
exportTrainNumber: train.exportTrainNumber ?? null,
|
||||
currentYardId: train.currentYardId ?? null,
|
||||
currentYard: train.currentYard
|
||||
? {
|
||||
@@ -5522,7 +5574,7 @@ export class TrainSchedulingService {
|
||||
// Per-schedule booking-window rule snapshot — powers the "Booking window
|
||||
// settings" editor on the ops board (prefill + save one schedule's
|
||||
// override). docReview/payment are not snapshotted per schedule (only their
|
||||
// sum, as reopenDelayMinutes), so the editor prefills them from live config.
|
||||
// sum, as the frozen reopen gap), so the editor prefills them from live config.
|
||||
windowRule: {
|
||||
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
|
||||
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
|
||||
@@ -5530,7 +5582,6 @@ export class TrainSchedulingService {
|
||||
schedule.ruleWindowDurationHours != null
|
||||
? Number(schedule.ruleWindowDurationHours)
|
||||
: null,
|
||||
reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null,
|
||||
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
|
||||
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
|
||||
docReviewMinutes: windowCfg.docReviewMinutes,
|
||||
@@ -6158,6 +6209,7 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable: number;
|
||||
canAssign: boolean;
|
||||
blockReason: string | null;
|
||||
shortage: BookingWagonShortage | null;
|
||||
}> {
|
||||
if (!schedule.trainSet?.locomotive) {
|
||||
return {
|
||||
@@ -6166,6 +6218,7 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable: 0,
|
||||
canAssign: false,
|
||||
blockReason: 'Schedule has no locomotive',
|
||||
shortage: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6186,6 +6239,7 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable: 0,
|
||||
canAssign: false,
|
||||
blockReason: 'No suitable wagon type found',
|
||||
shortage: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6230,6 +6284,7 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable,
|
||||
canAssign: false,
|
||||
blockReason: err instanceof Error ? err.message : 'Validation failed',
|
||||
shortage: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6240,6 +6295,7 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable,
|
||||
canAssign: false,
|
||||
blockReason: validation.violations[0] ?? 'Booking validation failed',
|
||||
shortage: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6259,6 +6315,16 @@ export class TrainSchedulingService {
|
||||
deferred?.reason ??
|
||||
yardShortfall ??
|
||||
`Need ${wagonsRequired} ${requiredWagonTypeCode} wagon(s) at origin yard`,
|
||||
shortage:
|
||||
deferred?.shortage ??
|
||||
(yardShortfall
|
||||
? {
|
||||
wagonTypeCodes: requiredWagonTypeCode,
|
||||
wagonsNeeded: wagonsRequired,
|
||||
wagonsAvailable: yardWagonsAvailable,
|
||||
wagonsShort: Math.max(1, wagonsRequired - yardWagonsAvailable),
|
||||
}
|
||||
: null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6277,6 +6343,7 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable,
|
||||
canAssign: false,
|
||||
blockReason: missing.issue,
|
||||
shortage: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6287,9 +6354,49 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable,
|
||||
canAssign: true,
|
||||
blockReason: null,
|
||||
shortage: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fleet-shortage preflight for a PAID booking targeting a schedule: the
|
||||
* structured per-type shortage this booking would hit if placed on top of the
|
||||
* schedule's current wagon assignments, or null when it fits (or is blocked
|
||||
* by something other than missing wagons — those keep the legacy link-then-
|
||||
* fix-manually path).
|
||||
*/
|
||||
async previewPaidBookingWagonShortage(
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
): Promise<BookingWagonShortage | null> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule?.trainSet?.locomotive) return null;
|
||||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) return null;
|
||||
|
||||
const [booking] = await this.bookingsRepository.findByIdsForScheduling([bookingId]);
|
||||
if (!booking) return null;
|
||||
|
||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||||
const fleetCounts = await this.countFleetAvailability(
|
||||
schedule.originStationId,
|
||||
scheduleId,
|
||||
);
|
||||
const fleetByTypeId = new Map(
|
||||
fleetCounts.map((row) => [
|
||||
row.wagonTypeId,
|
||||
{ code: row.wagonTypeCode, available: row.available },
|
||||
]),
|
||||
);
|
||||
|
||||
const assignability = await this.previewUnassignedBookingAssignability(
|
||||
schedule,
|
||||
wagonAssignedIds,
|
||||
booking,
|
||||
fleetByTypeId,
|
||||
);
|
||||
return assignability.shortage;
|
||||
}
|
||||
|
||||
/** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */
|
||||
private isReadyToLoadBooking(booking: {
|
||||
status: string;
|
||||
|
||||
Reference in New Issue
Block a user