mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
Merge pull request #826 from Tria-plc/freight_feature/usermanagement
changes
This commit is contained in:
@@ -1287,6 +1287,23 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Same as {@link findAllBySchedule} but for a page of schedules at once —
|
||||
* one query instead of one per schedule (batch monitoring board). */
|
||||
findAllBySchedules(scheduleIds: string[]): Promise<Booking[]> {
|
||||
if (!scheduleIds.length) return Promise.resolve([]);
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||
.where('booking.train_schedule_id IN (:...scheduleIds)', { scheduleIds })
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
|
||||
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
@@ -1342,6 +1359,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
if (!bookingIds.length) return Promise.resolve([]);
|
||||
return this.bookingRepo(manager).find({
|
||||
where: { id: In(bookingIds) },
|
||||
// Per-relation SELECTs: the containerType/cargoType→wagonTypes M2M joins
|
||||
// multiply rows badly in a single join (hot path for every allocation
|
||||
// preview / assignment validation).
|
||||
relationLoadStrategy: 'query',
|
||||
relations: {
|
||||
company: true,
|
||||
originYard: true,
|
||||
|
||||
@@ -21,6 +21,10 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||
findByIdWithFullGraph(id: string, manager?: EntityManager): Promise<TrainSchedule | null> {
|
||||
return this.repo(manager).findOne({
|
||||
where: { id },
|
||||
// One SELECT per relation instead of a single monster join — the nested
|
||||
// wagon×allocation×booking×container branches multiply rows catastrophically
|
||||
// when joined (measured ~925ms vs ~84ms on a 21-wagon schedule).
|
||||
relationLoadStrategy: 'query',
|
||||
relations: {
|
||||
// Yards carry the route's display name; without them formatRouteLabel
|
||||
// degrades to the literal "Origin → Destination". Milestones (with
|
||||
|
||||
@@ -33,7 +33,7 @@ import { TrainSchedulesRepository } from '../train-schedules/train-schedules.rep
|
||||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||
import { eatDay } from './batch-window.util';
|
||||
import {
|
||||
BATCH_BOARD_STATUSES,
|
||||
BatchBoardQueryDto,
|
||||
@@ -172,23 +172,18 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||
consolidationPartnerRef: string | null;
|
||||
}
|
||||
|
||||
export interface BatchWindowGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
/** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */
|
||||
date: string;
|
||||
/** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */
|
||||
dateLabel: string;
|
||||
start: string;
|
||||
end: string;
|
||||
counts: {
|
||||
allocated: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
expired: number;
|
||||
pendingContract: number;
|
||||
};
|
||||
export interface BatchBoardCounts {
|
||||
allocated: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
expired: number;
|
||||
pendingContract: number;
|
||||
}
|
||||
|
||||
/** A booking bucket on the detail board (in-window vs pending-contract). */
|
||||
export interface BatchBoardBucket {
|
||||
counts: BatchBoardCounts;
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
}
|
||||
|
||||
@@ -215,8 +210,9 @@ export interface BatchBoardScheduleDetail {
|
||||
locomotive: BatchBoardSchedule["locomotive"];
|
||||
capacity: BatchBoardSchedule["capacity"];
|
||||
counts: BatchBoardSchedule["counts"];
|
||||
windows: BatchWindowGroup[];
|
||||
pendingContract: BatchWindowGroup;
|
||||
/** Bookings inside the schedule's booking window (fully-executed contracts). */
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
pendingContract: BatchBoardBucket;
|
||||
allocationViolations: string[];
|
||||
}
|
||||
|
||||
@@ -1193,11 +1189,32 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
|
||||
// One links query + one bookings query for the whole page (was 2 per card).
|
||||
const scheduleIds = schedules.map((s) => s.id);
|
||||
const [allLinks, allBookings] = await Promise.all([
|
||||
scheduleIds.length
|
||||
? linkRepo.find({ where: { trainScheduleId: In(scheduleIds) } })
|
||||
: Promise.resolve([]),
|
||||
this.bookingsRepository.findAllBySchedules(scheduleIds),
|
||||
]);
|
||||
const linkedIdsBySchedule = new Map<string, Set<string>>();
|
||||
for (const l of allLinks) {
|
||||
let set = linkedIdsBySchedule.get(l.trainScheduleId);
|
||||
if (!set) linkedIdsBySchedule.set(l.trainScheduleId, (set = new Set()));
|
||||
set.add(l.bookingId);
|
||||
}
|
||||
const bookingsBySchedule = new Map<string, Booking[]>();
|
||||
for (const b of allBookings) {
|
||||
if (!b.trainScheduleId) continue;
|
||||
let list = bookingsBySchedule.get(b.trainScheduleId);
|
||||
if (!list) bookingsBySchedule.set(b.trainScheduleId, (list = []));
|
||||
list.push(b);
|
||||
}
|
||||
|
||||
const board: BatchBoardSchedule[] = [];
|
||||
for (const s of schedules) {
|
||||
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
|
||||
const linkedIds = new Set(links.map((l) => l.bookingId));
|
||||
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
|
||||
const linkedIds = linkedIdsBySchedule.get(s.id) ?? new Set<string>();
|
||||
const bookings = bookingsBySchedule.get(s.id) ?? [];
|
||||
|
||||
const items: BatchBoardBooking[] = bookings.map((b) => {
|
||||
const need = this.needFor(b, wagonDims);
|
||||
@@ -1226,7 +1243,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return { items: board, meta: buildPaginationMeta(total, page, pageSize) };
|
||||
}
|
||||
|
||||
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
|
||||
/** Schedule-level batch board: the schedule's own booking window plus its
|
||||
* bookings split into in-window (contract executed) vs pending-contract. */
|
||||
async getBatchBoardDetail(
|
||||
scheduleId: string,
|
||||
): Promise<BatchBoardScheduleDetail> {
|
||||
@@ -1244,17 +1262,24 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
|
||||
const linkedIds = new Set(links.map((l) => l.bookingId));
|
||||
// The full graph already carries the schedule↔booking links — no separate
|
||||
// link query needed.
|
||||
const linkedIds = new Set(
|
||||
(s.scheduleBookings ?? []).map((l) => l.bookingId),
|
||||
);
|
||||
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
|
||||
|
||||
let allocationPreview: Awaited<
|
||||
ReturnType<TrainSchedulingService["previewAllocationForSchedule"]>
|
||||
>;
|
||||
try {
|
||||
// Reuse the graph loaded above — the preview otherwise re-loads the same
|
||||
// heavy schedule graph a second time per request.
|
||||
allocationPreview =
|
||||
await this.trainSchedulingService.previewAllocationForSchedule(s.id);
|
||||
await this.trainSchedulingService.previewAllocationForSchedule(
|
||||
s.id,
|
||||
s,
|
||||
);
|
||||
} catch {
|
||||
allocationPreview = {
|
||||
assignedBookingIds: [],
|
||||
@@ -1324,73 +1349,22 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
|
||||
// Display windows are the REAL booking-window cycles this schedule was FROZEN
|
||||
// with at creation (import: opens at its stored window time, lasts its rule's
|
||||
// duration, reopens per its rule's delay; export: single FCFS lead window) —
|
||||
// NOT the live global config. A later global-rules edit only re-derives
|
||||
// not-yet-open schedules (restampPendingWindows), so an already-open schedule
|
||||
// must keep drawing from its own snapshot, anchored on its stored open time.
|
||||
// Legacy rows with no snapshot fall back to the live config.
|
||||
const liveCfg = await this.trainSchedulingService.getWindowConfig();
|
||||
const num = (v: unknown, fallback: number) => {
|
||||
const n = v == null ? NaN : Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
const windowCfg = {
|
||||
windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour),
|
||||
windowCloseHour: num(s.ruleWindowCloseHour, liveCfg.windowCloseHour),
|
||||
windowDurationHours: num(
|
||||
s.ruleWindowDurationHours,
|
||||
liveCfg.windowDurationHours,
|
||||
),
|
||||
// Frozen doc-review + payment sum; legacy rows fall back to the live sum.
|
||||
reopenGapMinutes: num(
|
||||
s.ruleReopenDelayMinutes,
|
||||
liveCfg.docReviewMinutes + liveCfg.paymentWindowMinutes,
|
||||
),
|
||||
importWindowLeadDays: num(
|
||||
s.ruleImportWindowLeadDays,
|
||||
liveCfg.importWindowLeadDays,
|
||||
),
|
||||
exportBookingLeadHours: num(
|
||||
s.ruleExportBookingLeadHours,
|
||||
liveCfg.exportBookingLeadHours,
|
||||
),
|
||||
// Frozen close offsets: a snapshot null means "no offset for this train"
|
||||
// and stays null (not the live offset); only legacy rows lacking the
|
||||
// column (undefined) fall back to live config.
|
||||
importCloseOffsetMinutes:
|
||||
s.ruleImportCloseOffsetMinutes !== undefined
|
||||
? s.ruleImportCloseOffsetMinutes
|
||||
: liveCfg.importCloseOffsetMinutes,
|
||||
exportCloseOffsetMinutes:
|
||||
s.ruleExportCloseOffsetMinutes !== undefined
|
||||
? s.ruleExportCloseOffsetMinutes
|
||||
: liveCfg.exportCloseOffsetMinutes,
|
||||
};
|
||||
const departureDate = s.scheduledDepartureDate ?? new Date();
|
||||
const windowBuckets = groupBookingsIntoBoardWindows(
|
||||
items,
|
||||
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
|
||||
s.direction ?? null,
|
||||
departureDate,
|
||||
windowCfg,
|
||||
undefined,
|
||||
s.windowOpensAt ?? null,
|
||||
);
|
||||
|
||||
const emptyCounts = () => ({
|
||||
allocated: 0,
|
||||
selectedForBatch: 0,
|
||||
ready: 0,
|
||||
waiting: 0,
|
||||
expired: 0,
|
||||
pendingContract: 0,
|
||||
});
|
||||
|
||||
const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => {
|
||||
const counts = emptyCounts();
|
||||
for (const b of bookingsInWindow) {
|
||||
// The board renders ONE booking window — the schedule's own frozen window
|
||||
// (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings
|
||||
// split into two buckets: contract executed (in the window) vs pending
|
||||
// contract. The old per-cycle window projection was dropped — the UI never
|
||||
// showed it, and reconstructing every cycle cost a config load + grouping
|
||||
// pass per request.
|
||||
const countFor = (bucket: BatchBoardBookingDetail[]): BatchBoardCounts => {
|
||||
const counts: BatchBoardCounts = {
|
||||
allocated: 0,
|
||||
selectedForBatch: 0,
|
||||
ready: 0,
|
||||
waiting: 0,
|
||||
expired: 0,
|
||||
pendingContract: 0,
|
||||
};
|
||||
for (const b of bucket) {
|
||||
if (b.state === "ALLOCATED") counts.allocated += 1;
|
||||
else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1;
|
||||
else if (b.state === "READY") counts.ready += 1;
|
||||
@@ -1401,26 +1375,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return counts;
|
||||
};
|
||||
|
||||
const windows: BatchWindowGroup[] = [];
|
||||
for (const [key, bucket] of windowBuckets) {
|
||||
if (key === "pending-contract" || !bucket.window) continue;
|
||||
const w = bucket.window;
|
||||
windows.push({
|
||||
key: w.key,
|
||||
label: w.label,
|
||||
date: w.date,
|
||||
dateLabel: w.dateLabel,
|
||||
start: w.start.toISOString(),
|
||||
end: w.end.toISOString(),
|
||||
counts: countFor(bucket.items),
|
||||
bookings: bucket.items,
|
||||
});
|
||||
}
|
||||
windows.sort(
|
||||
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(),
|
||||
);
|
||||
|
||||
const pendingBookings = windowBuckets.get("pending-contract")?.items ?? [];
|
||||
const windowBookings = items.filter((i) => i.fullyExecutedAt);
|
||||
const pendingBookings = items.filter((i) => !i.fullyExecutedAt);
|
||||
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
@@ -1470,14 +1426,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.length,
|
||||
expired: items.filter((i) => i.state === "EXPIRED").length,
|
||||
},
|
||||
windows,
|
||||
bookings: windowBookings,
|
||||
pendingContract: {
|
||||
key: "pending-contract",
|
||||
label: "Pending contract",
|
||||
date: "",
|
||||
dateLabel: "",
|
||||
start: "",
|
||||
end: "",
|
||||
counts: countFor(pendingBookings),
|
||||
bookings: pendingBookings,
|
||||
},
|
||||
@@ -2511,7 +2461,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (!schedule || !locomotive) return null;
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
// Built trains: collapse to a single train-wide pool so the freed capacity of
|
||||
// a booking that alights mid-corridor is NOT re-offered on the pass-through
|
||||
// leg (see remainingBudget). Keeps intercity accept consistent with the
|
||||
// train-wide isTrainFull / committedWagons finalize signal.
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims, {
|
||||
collapseForBuiltTrain: true,
|
||||
});
|
||||
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
|
||||
}
|
||||
|
||||
@@ -3305,7 +3261,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* (NW5 flat for containers, CW3 gondola for bulk) for bookings whose type has
|
||||
* no wagon type configured yet.
|
||||
*/
|
||||
/** Wagon types are near-static reference data — a short TTL cache spares one
|
||||
* table scan per board/detail request without letting edits go stale long. */
|
||||
private wagonDimsCache: { value: WagonDims; expiresAt: number } | null = null;
|
||||
|
||||
private async loadWagonDims(): Promise<WagonDims> {
|
||||
if (this.wagonDimsCache && this.wagonDimsCache.expiresAt > Date.now()) {
|
||||
return this.wagonDimsCache.value;
|
||||
}
|
||||
const types = await this.dataSource.getRepository(WagonType).find();
|
||||
const byCode = new Map(
|
||||
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
|
||||
@@ -3319,7 +3282,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// must fall back rather than yield an infinite wagon count.
|
||||
const payload = (value: number | undefined, fallback: number): number =>
|
||||
value && value > 0 ? value : fallback;
|
||||
return {
|
||||
const value: WagonDims = {
|
||||
container: {
|
||||
lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||
@@ -3332,6 +3295,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
},
|
||||
byWagonTypeId,
|
||||
};
|
||||
this.wagonDimsCache = { value, expiresAt: Date.now() + 60_000 };
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3433,6 +3398,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
schedule: TrainSchedule,
|
||||
limits: TrainLimits,
|
||||
wagonDims: WagonDims,
|
||||
opts?: { collapseForBuiltTrain?: boolean },
|
||||
): Promise<CorridorBudget> {
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
if (physicalWagons != null) {
|
||||
@@ -3445,7 +3411,21 @@ export class BookingBatchService implements OnModuleInit {
|
||||
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||||
};
|
||||
}
|
||||
const stops = await this.stopsForSchedule(schedule);
|
||||
// A built train's wagons are coupled for the WHOLE trip, and the allocator
|
||||
// commits each booking to a wagon for the entire route — it never reloads a
|
||||
// wagon at a mid-corridor alight yard. So a built train has no leg concept:
|
||||
// its capacity is one train-wide pool, exactly as isTrainFull /
|
||||
// committedWagons already count it. When a caller opts in, collapse the
|
||||
// corridor to a single whole-route edge so every booking (full-route OR
|
||||
// mid-corridor) draws from that one pool — a train full of import-to-DireDawa
|
||||
// then correctly shows NO room for a DireDawa->Addis intercity booking on the
|
||||
// leg it merely passes through, instead of over-promising the freed slots.
|
||||
// Locomotive-derived schedules keep the leg-aware multi-edge corridor: their
|
||||
// abstract slot/weight/length budget genuinely frees past an alight yard.
|
||||
const stops =
|
||||
physicalWagons != null && opts?.collapseForBuiltTrain
|
||||
? [schedule.originStationId, schedule.destinationStationId]
|
||||
: await this.stopsForSchedule(schedule);
|
||||
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
|
||||
@@ -104,7 +104,8 @@ export class IntercityService {
|
||||
|
||||
async listCandidates(scheduleId: string) {
|
||||
const schedule = await this.getSchedule(scheduleId);
|
||||
const milestoneSeq = await this.routeMilestoneSequence(schedule);
|
||||
const milestones = await this.routeMilestones(schedule);
|
||||
const milestoneSeq = this.milestoneSequenceOf(schedule, milestones);
|
||||
const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
|
||||
|
||||
const waiting = milestoneSeq
|
||||
@@ -112,16 +113,32 @@ export class IntercityService {
|
||||
: [];
|
||||
const accepted = await this.findAcceptedIntercityBookings(scheduleId);
|
||||
|
||||
// Mid-corridor intercity matching needs a real stop list (>= 2 route
|
||||
// milestones). Without one the fallback is a 2-stop origin->destination
|
||||
// pseudo-route that only matches bookings on the train's exact corridor —
|
||||
// surface that so an empty candidate list isn't misread as "nobody waiting".
|
||||
const warning =
|
||||
milestoneSeq == null
|
||||
? 'This schedule has no route or origin/destination set, so no intercity corridors can be served.'
|
||||
: schedule.routeId && milestones.length < 2
|
||||
? "This schedule's route has no stop list (needs at least 2 route milestones), so mid-corridor intercity bookings cannot be matched — only bookings on the train's exact origin→destination will appear."
|
||||
: null;
|
||||
|
||||
return {
|
||||
scheduleId,
|
||||
routeId: schedule.routeId ?? null,
|
||||
warning,
|
||||
// Segment-based: "remaining" is the most-open edge; each candidate's
|
||||
// `fits` is judged against ITS OWN leg, so a booking on a free leg fits
|
||||
// even when the train is full elsewhere.
|
||||
remaining: capacity?.budget.maxRemaining() ?? null,
|
||||
candidates: waiting.map((booking) => {
|
||||
const need = capacity?.needFor(booking) ?? null;
|
||||
const leg = capacity?.budget.legOf(
|
||||
// legForYards, not legOf: on a built train the budget is a single
|
||||
// whole-route edge (see intercityCapacity), so a mid-corridor booking
|
||||
// must draw from that one pool via the whole-route fallback. On a
|
||||
// locomotive-derived schedule it still resolves to the booking's own leg.
|
||||
const leg = capacity?.budget.legForYards(
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
);
|
||||
@@ -186,14 +203,17 @@ export class IntercityService {
|
||||
continue;
|
||||
}
|
||||
const need = capacity.needFor(booking);
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
// Segment-based: only the booking's own leg must have room, so an
|
||||
// intercity booking still boards a train that is full on other legs.
|
||||
if (!leg || !budget.fits(need, leg)) {
|
||||
// legForYards, not legOf: a built train's budget is a single whole-route
|
||||
// pool (mid-corridor wagons are committed for the whole trip and never
|
||||
// reloaded), so the booking draws from that pool via the whole-route
|
||||
// fallback; a locomotive-derived schedule still gets the booking's own
|
||||
// leg, so it can still board a train that is full only on other legs.
|
||||
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||
if (!budget.fits(need, leg)) {
|
||||
rejected.push({
|
||||
bookingId,
|
||||
reason:
|
||||
'Does not fit the remaining wagon/weight/length capacity on its leg',
|
||||
'Does not fit the remaining wagon/weight/length capacity for this train',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -244,16 +264,21 @@ export class IntercityService {
|
||||
* so an intercity booking exactly matching the train's own corridor still
|
||||
* qualifies.
|
||||
*/
|
||||
private async routeMilestoneSequence(
|
||||
private async routeMilestones(
|
||||
schedule: TrainSchedule,
|
||||
): Promise<Map<string, number> | null> {
|
||||
if (schedule.routeId) {
|
||||
const milestones = await this.dataSource
|
||||
.getRepository(RouteMilestone)
|
||||
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
|
||||
if (milestones.length >= 2) {
|
||||
return new Map(milestones.map((m) => [m.yardId, m.sequenceNo]));
|
||||
}
|
||||
): Promise<RouteMilestone[]> {
|
||||
if (!schedule.routeId) return [];
|
||||
return this.dataSource
|
||||
.getRepository(RouteMilestone)
|
||||
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
|
||||
}
|
||||
|
||||
private milestoneSequenceOf(
|
||||
schedule: TrainSchedule,
|
||||
milestones: RouteMilestone[],
|
||||
): Map<string, number> | null {
|
||||
if (milestones.length >= 2) {
|
||||
return new Map(milestones.map((m) => [m.yardId, m.sequenceNo]));
|
||||
}
|
||||
if (schedule.originStationId && schedule.destinationStationId) {
|
||||
return new Map([
|
||||
@@ -264,6 +289,15 @@ export class IntercityService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async routeMilestoneSequence(
|
||||
schedule: TrainSchedule,
|
||||
): Promise<Map<string, number> | null> {
|
||||
return this.milestoneSequenceOf(
|
||||
schedule,
|
||||
await this.routeMilestones(schedule),
|
||||
);
|
||||
}
|
||||
|
||||
/** Waiting = ready intercity bookings not yet on any train, corridor on this route. */
|
||||
private async findWaitingIntercityBookings(
|
||||
milestoneSeq: Map<string, number>,
|
||||
|
||||
@@ -139,7 +139,8 @@ export class TrainSchedulingController {
|
||||
@Get("batch-board/:scheduleId")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary: "Batch board detail for one schedule with EAT 3h windows",
|
||||
summary:
|
||||
"Batch board detail for one schedule: its booking window, in-window bookings and pending-contract bucket",
|
||||
})
|
||||
getBatchBoardDetail(@Param("scheduleId", ParseUUIDPipe) scheduleId: string) {
|
||||
return this.bookingBatchService.getBatchBoardDetail(scheduleId);
|
||||
|
||||
@@ -479,6 +479,38 @@ export class TrainSchedulingService {
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* A built train makes at most ONE departure per route per EAT day. Returns
|
||||
* the non-cancelled schedule already holding this train on this route for
|
||||
* `departure`'s EAT day, or null when the day is free. Route+day GROUPS stay
|
||||
* legal — siblings must be different trains.
|
||||
*/
|
||||
private async findTrainRouteDayConflict(
|
||||
trainId: string,
|
||||
routeId: string,
|
||||
departure: Date,
|
||||
excludeScheduleId?: string,
|
||||
): Promise<TrainSchedule | null> {
|
||||
const day = eatDay(departure);
|
||||
const dayStart = eatDayToUtc(day, 0);
|
||||
const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0);
|
||||
const qb = this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.createQueryBuilder('s')
|
||||
.innerJoin('s.trainSet', 'ts')
|
||||
.where('ts.trainId = :trainId', { trainId })
|
||||
.andWhere('s.routeId = :routeId', { routeId })
|
||||
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
|
||||
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart })
|
||||
.andWhere('s.status != :cancelledStatus', {
|
||||
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
|
||||
});
|
||||
if (excludeScheduleId) {
|
||||
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
|
||||
}
|
||||
return qb.getOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* The window timeline a brand-new schedule must adopt to join its route+day
|
||||
* group. Returns the canonical open/close times + rule snapshot copied from an
|
||||
@@ -882,6 +914,28 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
// Moving onto a day where this same built train already runs this route
|
||||
// would double-book the physical train — blocked for planning moves.
|
||||
if (schedule.trainSetId && schedule.routeId) {
|
||||
const trainSet = await this.dataSource
|
||||
.getRepository(TrainSet)
|
||||
.findOne({ where: { id: schedule.trainSetId } });
|
||||
if (trainSet?.trainId) {
|
||||
const conflict = await this.findTrainRouteDayConflict(
|
||||
trainSet.trainId,
|
||||
schedule.routeId,
|
||||
departure,
|
||||
id,
|
||||
);
|
||||
if (conflict) {
|
||||
throw new ConflictException(
|
||||
`This train is already scheduled on this route for that day ` +
|
||||
`(${conflict.reference ?? conflict.id}) — one departure per route per day`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-derive the window from the schedule's own rule snapshot (falling back to
|
||||
// the live config where a legacy row has no snapshot) against the new date.
|
||||
const merged = effectiveWindowConfig(schedule, windowCfg);
|
||||
@@ -915,10 +969,37 @@ export class TrainSchedulingService {
|
||||
scheduledDepartureDate: departure,
|
||||
...windowFields,
|
||||
});
|
||||
|
||||
// Only customers whose bookings already HOLD wagons on this train are told
|
||||
// about the move (SMS + email + portal inbox). Linked-but-unallocated
|
||||
// bookings are skipped — nothing of theirs is riding this departure yet.
|
||||
let notifiedCount = 0;
|
||||
if (schedule.trainSetId) {
|
||||
const allocations = await this.dataSource
|
||||
.getRepository(WagonBookingAllocation)
|
||||
.createQueryBuilder('a')
|
||||
.innerJoin('a.trainSetWagon', 'slot')
|
||||
.where('slot.trainSetId = :trainSetId', { trainSetId: schedule.trainSetId })
|
||||
.getMany();
|
||||
const allocatedBookingIds = [...new Set(allocations.map((a) => a.bookingId))];
|
||||
if (allocatedBookingIds.length) {
|
||||
const allocatedBookings = await this.dataSource.getRepository(Booking).find({
|
||||
where: { id: In(allocatedBookingIds) },
|
||||
relations: { company: true },
|
||||
});
|
||||
for (const booking of allocatedBookings) {
|
||||
if (['CANCELLED', 'EXPIRED', 'REJECTED'].includes(booking.status)) continue;
|
||||
this.bookingNotifier.rescheduled(booking, departure);
|
||||
notifiedCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Departure date changed for schedule ${id} → ${departure.toISOString()} ` +
|
||||
`(window reopens ${windowFields.windowOpensAt?.toISOString() ?? 'n/a'}` +
|
||||
`${anchor ? `, joined route+day group anchor ${anchor.id}` : ''})`,
|
||||
`${anchor ? `, joined route+day group anchor ${anchor.id}` : ''}); ` +
|
||||
`${notifiedCount} allocated customer booking(s) notified`,
|
||||
);
|
||||
void this.emitWindowState(id);
|
||||
|
||||
@@ -1222,6 +1303,17 @@ export class TrainSchedulingService {
|
||||
`Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
|
||||
);
|
||||
}
|
||||
const conflict = await this.findTrainRouteDayConflict(
|
||||
builtTrain.id,
|
||||
route.id,
|
||||
new Date(dto.scheduleDate),
|
||||
);
|
||||
if (conflict) {
|
||||
throw new ConflictException(
|
||||
`Train ${builtTrain.code} is already scheduled on this route for that day ` +
|
||||
`(${conflict.reference ?? conflict.id}) — one departure per route per day`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
locomotiveIds = [...new Set(dto.locomotiveIds ?? [])];
|
||||
if (locomotiveIds.length < 2) {
|
||||
@@ -1681,6 +1773,7 @@ export class TrainSchedulingService {
|
||||
scheduleId,
|
||||
schedule.originStationId,
|
||||
savedWagons,
|
||||
schedule.reverseWagonOrder ?? false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4283,6 +4376,7 @@ export class TrainSchedulingService {
|
||||
scheduleId: string,
|
||||
originYardId: string,
|
||||
slots: TrainSetWagon[],
|
||||
reverseWagonOrder = false,
|
||||
) {
|
||||
const wagons = await manager.getRepository(Wagon).find();
|
||||
const wagonTypes = await manager.getRepository(WagonType).find();
|
||||
@@ -4328,6 +4422,7 @@ export class TrainSchedulingService {
|
||||
assignedPhysicalIds,
|
||||
builtTrainId,
|
||||
pinnedToScheduleIds,
|
||||
reverseWagonOrder,
|
||||
);
|
||||
if (!physical) continue;
|
||||
|
||||
@@ -4421,6 +4516,7 @@ export class TrainSchedulingService {
|
||||
assignedPhysicalIds: Set<string>,
|
||||
builtTrainId: string | null = null,
|
||||
pinnedToScheduleIds: Set<string> = new Set(),
|
||||
reverseWagonOrder = false,
|
||||
): Wagon | undefined {
|
||||
const usable = (wagon: Wagon): boolean => {
|
||||
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
|
||||
@@ -4441,12 +4537,26 @@ export class TrainSchedulingService {
|
||||
// wherever they currently sit (they travel with the train), never a loose
|
||||
// yard wagon.
|
||||
if (builtTrainId) {
|
||||
return wagons.find(
|
||||
(w) =>
|
||||
w.trainId === builtTrainId &&
|
||||
w.wagonTypeId === slot.wagonTypeId &&
|
||||
!assignedPhysicalIds.has(w.id),
|
||||
);
|
||||
// Pin in the train's as-built coupling order (wagon.sequenceNumber) so the
|
||||
// consist views draw the schedule exactly like the train builder; a schedule
|
||||
// created with reverseWagonOrder pins back-to-front (physically-last wagon
|
||||
// takes slot #1). Unsequenced wagons sort after every sequenced one.
|
||||
const candidates = wagons
|
||||
.filter(
|
||||
(w) =>
|
||||
w.trainId === builtTrainId &&
|
||||
w.wagonTypeId === slot.wagonTypeId &&
|
||||
!assignedPhysicalIds.has(w.id),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.sequenceNumber == null || b.sequenceNumber == null) {
|
||||
return (a.sequenceNumber == null ? 1 : 0) - (b.sequenceNumber == null ? 1 : 0);
|
||||
}
|
||||
return reverseWagonOrder
|
||||
? b.sequenceNumber - a.sequenceNumber
|
||||
: a.sequenceNumber - b.sequenceNumber;
|
||||
});
|
||||
return candidates[0];
|
||||
}
|
||||
// Prefer a wagon already waiting at the slot's board yard (no empty haul);
|
||||
// fall back to one riding from the train's origin.
|
||||
@@ -6220,11 +6330,21 @@ export class TrainSchedulingService {
|
||||
* engine's representative fallbacks for bookings whose cargo/container type
|
||||
* has no wagon type configured. Loaded once per request before mapping.
|
||||
*/
|
||||
/** Wagon types are near-static reference data — a short TTL cache spares one
|
||||
* table scan per detail/board request without letting edits go stale long. */
|
||||
private wagonTareDimsCache: {
|
||||
value: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>;
|
||||
expiresAt: number;
|
||||
} | null = null;
|
||||
|
||||
private async loadWagonTareDims(): Promise<{
|
||||
byWagonTypeId: Map<string, { tareWeightTons: number; capacityTons: number }>;
|
||||
bulk: { tareWeightTons: number; capacityTons: number };
|
||||
container: { tareWeightTons: number; capacityTons: number };
|
||||
}> {
|
||||
if (this.wagonTareDimsCache && this.wagonTareDimsCache.expiresAt > Date.now()) {
|
||||
return this.wagonTareDimsCache.value;
|
||||
}
|
||||
const types = await this.dataSource.getRepository(WagonType).find();
|
||||
const byWagonTypeId = new Map(
|
||||
types.map((t) => [
|
||||
@@ -6235,7 +6355,7 @@ export class TrainSchedulingService {
|
||||
},
|
||||
]),
|
||||
);
|
||||
return {
|
||||
const value = {
|
||||
byWagonTypeId,
|
||||
bulk: {
|
||||
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
|
||||
@@ -6246,6 +6366,8 @@ export class TrainSchedulingService {
|
||||
capacityTons: DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
|
||||
},
|
||||
};
|
||||
this.wagonTareDimsCache = { value, expiresAt: Date.now() + 60_000 };
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6302,49 +6424,14 @@ export class TrainSchedulingService {
|
||||
);
|
||||
const allocationIds = allocations.map((a) => a.id);
|
||||
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
|
||||
// Booking weights are reported GROSS (cargo + wagon tare) — the number the
|
||||
// locomotive actually hauls and the axis its pull limit is compared against.
|
||||
const tareDims = await this.loadWagonTareDims();
|
||||
|
||||
// Import-from-Djibouti trains can only dispatch once loading is confirmed
|
||||
// (loadedOnTrainAt on the operation). Other directions have no departure
|
||||
// loading gate, so the workspace shows the confirm button as already done.
|
||||
const requiresLoadingConfirmation = this.isImportDjiboutiSchedule(schedule);
|
||||
let loadingConfirmed = !requiresLoadingConfirmation;
|
||||
if (requiresLoadingConfirmation) {
|
||||
const op = await this.dataSource
|
||||
.getRepository(ImportDjiboutiOperation)
|
||||
.findOne({ where: { trainScheduleId: schedule.id } });
|
||||
loadingConfirmed = Boolean(op?.loadedOnTrainAt);
|
||||
}
|
||||
|
||||
const windowCfg = await this.getWindowConfig();
|
||||
|
||||
const [containerItems, bulkLoads] = await Promise.all([
|
||||
allocationIds.length
|
||||
? this.wagonAllocationContainerItemsRepository.findAll({
|
||||
where: { wagonBookingAllocationId: In(allocationIds) },
|
||||
relations: { containerType: true, bookingContainer: true },
|
||||
})
|
||||
: [],
|
||||
allocationIds.length
|
||||
? this.wagonAllocationBulkLoadsRepository.findAll({
|
||||
where: { wagonBookingAllocationId: In(allocationIds) },
|
||||
relations: { cargoType: true },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
const containerItemsByAllocation = new Map<string, typeof containerItems>();
|
||||
for (const item of containerItems) {
|
||||
const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? [];
|
||||
list.push(item);
|
||||
containerItemsByAllocation.set(item.wagonBookingAllocationId, list);
|
||||
}
|
||||
const bulkLoadsByAllocation = new Map(
|
||||
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
|
||||
);
|
||||
|
||||
// Snapshot state decides below whether the live consist may be drawn at
|
||||
// all, so it is derived before the consist wagons are fetched.
|
||||
// Once a schedule leaves DRAFT/SCHEDULED, its physical wagons are released
|
||||
// and re-pinned onto later trains — the live wagon↔slot joins no longer
|
||||
// describe THIS train. If a frozen snapshot was captured at the transition,
|
||||
@@ -6359,6 +6446,55 @@ export class TrainSchedulingService {
|
||||
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
|
||||
);
|
||||
|
||||
// All independent lookups fired at once — they used to run one after
|
||||
// another, stacking round-trips onto every detail request.
|
||||
// tareDims: booking weights are reported GROSS (cargo + wagon tare) — the
|
||||
// number the locomotive actually hauls against its pull limit.
|
||||
const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons] =
|
||||
await Promise.all([
|
||||
this.loadWagonTareDims(),
|
||||
requiresLoadingConfirmation
|
||||
? this.dataSource
|
||||
.getRepository(ImportDjiboutiOperation)
|
||||
.findOne({ where: { trainScheduleId: schedule.id } })
|
||||
: null,
|
||||
this.getWindowConfig(),
|
||||
allocationIds.length
|
||||
? this.wagonAllocationContainerItemsRepository.findAll({
|
||||
where: { wagonBookingAllocationId: In(allocationIds) },
|
||||
relations: { containerType: true, bookingContainer: true },
|
||||
})
|
||||
: [],
|
||||
allocationIds.length
|
||||
? this.wagonAllocationBulkLoadsRepository.findAll({
|
||||
where: { wagonBookingAllocationId: In(allocationIds) },
|
||||
relations: { cargoType: true },
|
||||
})
|
||||
: [],
|
||||
schedule.trainSet?.trainId && !isWagonAllocationFrozen
|
||||
? this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: schedule.trainSet.trainId },
|
||||
relations: { wagonType: true },
|
||||
// Mirror the pinning direction: a reverse-order schedule draws the
|
||||
// whole consist back-to-front, empties included.
|
||||
order: { sequenceNumber: schedule.reverseWagonOrder ? 'DESC' : 'ASC' },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
const loadingConfirmed = requiresLoadingConfirmation
|
||||
? Boolean(importOp?.loadedOnTrainAt)
|
||||
: true;
|
||||
|
||||
const containerItemsByAllocation = new Map<string, typeof containerItems>();
|
||||
for (const item of containerItems) {
|
||||
const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? [];
|
||||
list.push(item);
|
||||
containerItemsByAllocation.set(item.wagonBookingAllocationId, list);
|
||||
}
|
||||
const bulkLoadsByAllocation = new Map(
|
||||
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
|
||||
);
|
||||
|
||||
// The trainSet slots below are the PLANNED wagons (one per allocation). A
|
||||
// schedule tied to a built train hauls EVERY coupled wagon — empty ones
|
||||
// included (the pull-limit check already counts their tare) — so append the
|
||||
@@ -6381,41 +6517,32 @@ export class TrainSchedulingService {
|
||||
0,
|
||||
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
|
||||
);
|
||||
const emptyConsistWagons =
|
||||
schedule.trainSet?.trainId && !isWagonAllocationFrozen
|
||||
? (
|
||||
await this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: schedule.trainSet.trainId },
|
||||
relations: { wagonType: true },
|
||||
order: { sequenceNumber: 'ASC' },
|
||||
})
|
||||
)
|
||||
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
|
||||
.map((wagon, index) => ({
|
||||
// Physical wagon id — there is no TrainSetWagon slot behind this
|
||||
// row, so remove/edit affordances must stay disabled (consistOnly).
|
||||
id: wagon.id,
|
||||
sequenceNo: maxSlotSequenceNo + index + 1,
|
||||
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
|
||||
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
|
||||
assignedWeightTons: 0,
|
||||
tareWeightTons: wagon.wagonType
|
||||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||
: null,
|
||||
status: 'EMPTY',
|
||||
physicalWagonId: wagon.id,
|
||||
physicalWagonNumber: wagon.wagonNumber ?? null,
|
||||
wagonType: wagon.wagonType
|
||||
? {
|
||||
id: wagon.wagonType.id,
|
||||
code: wagon.wagonType.code,
|
||||
name: wagon.wagonType.name,
|
||||
}
|
||||
: null,
|
||||
allocations: [],
|
||||
consistOnly: true,
|
||||
}))
|
||||
: [];
|
||||
const emptyConsistWagons = rawConsistWagons
|
||||
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
|
||||
.map((wagon, index) => ({
|
||||
// Physical wagon id — there is no TrainSetWagon slot behind this
|
||||
// row, so remove/edit affordances must stay disabled (consistOnly).
|
||||
id: wagon.id,
|
||||
sequenceNo: maxSlotSequenceNo + index + 1,
|
||||
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
|
||||
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
|
||||
assignedWeightTons: 0,
|
||||
tareWeightTons: wagon.wagonType
|
||||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||
: null,
|
||||
status: 'EMPTY',
|
||||
physicalWagonId: wagon.id,
|
||||
physicalWagonNumber: wagon.wagonNumber ?? null,
|
||||
wagonType: wagon.wagonType
|
||||
? {
|
||||
id: wagon.wagonType.id,
|
||||
code: wagon.wagonType.code,
|
||||
name: wagon.wagonType.name,
|
||||
}
|
||||
: null,
|
||||
allocations: [],
|
||||
consistOnly: true,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
@@ -6425,6 +6552,7 @@ export class TrainSchedulingService {
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
maxWagons: schedule.maxWagons ?? null,
|
||||
direction: schedule.direction ?? null,
|
||||
reverseWagonOrder: schedule.reverseWagonOrder ?? false,
|
||||
requiresLoadingConfirmation,
|
||||
loadingConfirmed,
|
||||
// Booking-window phase + phase deadlines drive the countdown timers in the
|
||||
@@ -6725,8 +6853,13 @@ export class TrainSchedulingService {
|
||||
/** Preview wagon allocation issues per linked booking without mutating the schedule. */
|
||||
async previewAllocationForSchedule(
|
||||
scheduleId: string,
|
||||
// Callers that already hold the full schedule graph (batch board detail)
|
||||
// pass it in so the preview doesn't re-load the same heavy graph.
|
||||
preloadedSchedule?: TrainSchedule,
|
||||
): Promise<WagonAllocationAttemptResult> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
const schedule =
|
||||
preloadedSchedule ??
|
||||
(await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId));
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
@@ -6773,7 +6906,10 @@ export class TrainSchedulingService {
|
||||
);
|
||||
if (!eligible.length) return empty;
|
||||
|
||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(schedule.id);
|
||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(
|
||||
schedule.id,
|
||||
schedule,
|
||||
);
|
||||
const previewDto = {
|
||||
bookingIds: eligible.map((b) => b.id),
|
||||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||||
@@ -7304,8 +7440,15 @@ export class TrainSchedulingService {
|
||||
return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId);
|
||||
}
|
||||
|
||||
private async getWagonAssignedBookingIds(scheduleId: string): Promise<Set<string>> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
private async getWagonAssignedBookingIds(
|
||||
scheduleId: string,
|
||||
// Pass when the caller already holds the schedule with trainSet.wagons —
|
||||
// only wagon ids are read here, the old full-graph reload was pure waste.
|
||||
preloadedSchedule?: TrainSchedule,
|
||||
): Promise<Set<string>> {
|
||||
const schedule =
|
||||
preloadedSchedule ??
|
||||
(await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId));
|
||||
const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id);
|
||||
if (!wagonIds.length) return new Set();
|
||||
|
||||
|
||||
@@ -1926,7 +1926,8 @@ export class WarehouseInventoryService {
|
||||
|
||||
// 1. Schedule must exist, be ARRIVED, and be an IMPORT route (derived from station countries).
|
||||
const [schedule] = await this.dataSource.query(
|
||||
`SELECT ts.id, ts.status, oy.country AS "originCountry", dy.country AS "destinationCountry"
|
||||
`SELECT ts.id, ts.status, ts.destination_station_id AS "destinationStationId",
|
||||
oy.country AS "originCountry", dy.country AS "destinationCountry"
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
@@ -1957,14 +1958,20 @@ export class WarehouseInventoryService {
|
||||
tradeDirection: string | null;
|
||||
cargoTypeCode: string | null;
|
||||
}[] = await this.dataSource.query(
|
||||
// Only bookings whose destination IS this train's final yard unload into
|
||||
// this (final-destination) warehouse. A mid-corridor import that alighted
|
||||
// at an intermediate yard was already unloaded there by the checkpoint
|
||||
// auto-unload; without this filter it would be mis-located into the final
|
||||
// yard's inventory too.
|
||||
`SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight,
|
||||
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
|
||||
cgt.code AS "cargoTypeCode"
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL`,
|
||||
[scheduleId],
|
||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||
AND b.destination_yard_id = $2`,
|
||||
[scheduleId, schedule.destinationStationId],
|
||||
);
|
||||
|
||||
const requestedLocation = warehouseId ? await this.pickDefaultLocation(warehouseId) : null;
|
||||
|
||||
@@ -104,6 +104,11 @@ export function ApiErrorModal() {
|
||||
onClose={close}
|
||||
centered
|
||||
radius="md"
|
||||
// Mounted at the app root, so its portal is FIRST in <body> — at the
|
||||
// default z-index (200) any page modal opened later (create schedule,
|
||||
// allocation wizard, …) paints over it and the error hides underneath.
|
||||
// Hoist above every Mantine overlay and the react-hot-toast layer (9999).
|
||||
zIndex={10000}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<AlertTriangle size={18} color="var(--mantine-color-red-6)" />
|
||||
|
||||
@@ -66,8 +66,8 @@ import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
BatchBoardBookingDetail,
|
||||
BatchBoardBookingState,
|
||||
BatchBoardCounts,
|
||||
BatchBoardScheduleDetail,
|
||||
BatchWindowGroup,
|
||||
BookingAllocationStatus,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
@@ -398,7 +398,7 @@ const BookingTable = memo(function BookingTable({
|
||||
);
|
||||
});
|
||||
|
||||
function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) {
|
||||
function WindowCountChips({ counts }: { counts: BatchBoardCounts }) {
|
||||
const chips: Array<{ value: number; color: string; label: string }> = [
|
||||
{ value: counts.allocated, color: "edr-green", label: "allocated" },
|
||||
{ value: counts.selectedForBatch, color: "orange", label: "selected" },
|
||||
@@ -609,9 +609,7 @@ export default function BatchScheduleDetailPage() {
|
||||
const hasAssignedWagons = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
data?.windows.some((w) =>
|
||||
w.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
|
||||
) ||
|
||||
data?.bookings.some((b) => b.allocationStatus === "ASSIGNED") ||
|
||||
data?.pendingContract.bookings.some(
|
||||
(b) => b.allocationStatus === "ASSIGNED",
|
||||
),
|
||||
@@ -619,36 +617,33 @@ export default function BatchScheduleDetailPage() {
|
||||
[data],
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string | null>("overview");
|
||||
|
||||
const scheduleDetailQuery = useQuery(
|
||||
api.trainScheduling.scheduleDetail.queryOptions({
|
||||
input: { id: scheduleId ?? "", freightType: "CONTAINER" },
|
||||
enabled: Boolean(scheduleId),
|
||||
// The heavy composition graph is only rendered by the composition tab and
|
||||
// the overview diagram (which needs assigned wagons) — don't fetch it
|
||||
// until one of them can actually show something.
|
||||
enabled:
|
||||
Boolean(scheduleId) &&
|
||||
(hasAssignedWagons || activeTab === "composition"),
|
||||
// Composition data only changes through mutations, which invalidate the
|
||||
// whole train-scheduling root — no need to refetch on remounts in between.
|
||||
staleTime: 5 * 60_000,
|
||||
}),
|
||||
);
|
||||
|
||||
// Every booking on this schedule, flattened across windows + pending-contract,
|
||||
// de-duplicated (a booking only appears once). Feeds the management table.
|
||||
// Every booking on this schedule: in-window + pending-contract (the two
|
||||
// buckets are disjoint). Feeds the management table.
|
||||
const allBookings = useMemo(() => {
|
||||
if (!data) return [] as BatchBoardBookingDetail[];
|
||||
const merged = [
|
||||
...data.windows.flatMap((w) => w.bookings),
|
||||
...data.pendingContract.bookings,
|
||||
];
|
||||
const byId = new Map<string, BatchBoardBookingDetail>();
|
||||
for (const b of merged) if (!byId.has(b.id)) byId.set(b.id, b);
|
||||
return [...byId.values()];
|
||||
return [...data.bookings, ...data.pendingContract.bookings];
|
||||
}, [data]);
|
||||
|
||||
// All bookings that fall inside the schedule's booking window (every window
|
||||
// cycle, flattened) — the window is one booking day, so these belong to the
|
||||
// single window panel above.
|
||||
const windowBookings = useMemo(
|
||||
() => (data?.windows ?? []).flatMap((w) => w.bookings),
|
||||
[data?.windows],
|
||||
);
|
||||
// Bookings inside the schedule's booking window — they belong to the single
|
||||
// window panel above.
|
||||
const windowBookings = data?.bookings ?? [];
|
||||
|
||||
const windowCounts = useMemo(() => {
|
||||
const counts = {
|
||||
@@ -684,7 +679,6 @@ export default function BatchScheduleDetailPage() {
|
||||
[data?.status],
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string | null>("overview");
|
||||
const [adjustConsistOpen, setAdjustConsistOpen] = useState(false);
|
||||
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
|
||||
null,
|
||||
|
||||
@@ -400,23 +400,18 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||
consolidationPartnerRef: string | null;
|
||||
}
|
||||
|
||||
export interface BatchWindowGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
/** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */
|
||||
date: string;
|
||||
/** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */
|
||||
dateLabel: string;
|
||||
start: string;
|
||||
end: string;
|
||||
counts: {
|
||||
allocated: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
expired: number;
|
||||
pendingContract: number;
|
||||
};
|
||||
export interface BatchBoardCounts {
|
||||
allocated: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
expired: number;
|
||||
pendingContract: number;
|
||||
}
|
||||
|
||||
/** A booking bucket on the detail board (in-window vs pending-contract). */
|
||||
export interface BatchBoardBucket {
|
||||
counts: BatchBoardCounts;
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
}
|
||||
|
||||
@@ -443,8 +438,9 @@ export interface BatchBoardScheduleDetail {
|
||||
locomotive: BatchBoardSchedule["locomotive"];
|
||||
capacity: BatchBoardSchedule["capacity"];
|
||||
counts: BatchBoardSchedule["counts"];
|
||||
windows: BatchWindowGroup[];
|
||||
pendingContract: BatchWindowGroup;
|
||||
/** Bookings inside the schedule's booking window (fully-executed contracts). */
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
pendingContract: BatchBoardBucket;
|
||||
allocationViolations: string[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user