mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 20:05:41 +00:00
Merge pull request #1472 from Tria-plc/freight_feature/usermanagement
feat: Implement handling for partially loaded bookings in train sched…
This commit is contained in:
@@ -2992,9 +2992,27 @@ export class TrainSchedulingService {
|
||||
}
|
||||
// The train is out — every pinned wagon is ASSIGNED to this schedule and
|
||||
// stays pinned so no other schedule can pick it while it's rolling.
|
||||
const dispatchedPhysicalIds = (schedule.trainSet?.wagons ?? [])
|
||||
const pinnedDispatchIds = (schedule.trainSet?.wagons ?? [])
|
||||
.map((slot) => slot.physicalWagonId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
// A built train rolls with its WHOLE consist, not just the slots that
|
||||
// carry cargo: an empty wagon coupled to the train is physically leaving
|
||||
// the yard too. Binding only the pinned slots left those empties behind
|
||||
// on `current_train_schedule_id`, so the checkpoint position fix (which
|
||||
// filters on exactly that column) never moved them and they stayed
|
||||
// recorded at the origin yard while the train they are hooked to
|
||||
// travelled the corridor.
|
||||
const consistPhysicalIds = schedule.trainSet?.trainId
|
||||
? (
|
||||
await manager.getRepository(Wagon).find({
|
||||
where: { trainId: schedule.trainSet.trainId },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((w) => w.id)
|
||||
: [];
|
||||
const dispatchedPhysicalIds = [
|
||||
...new Set([...pinnedDispatchIds, ...consistPhysicalIds]),
|
||||
];
|
||||
if (dispatchedPhysicalIds.length) {
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
@@ -3064,6 +3082,15 @@ export class TrainSchedulingService {
|
||||
// that the operator didn't load individually are auto-loaded now — the
|
||||
// train is leaving with them. Mid-corridor boarders stay PAID until the
|
||||
// operator loads them at their own yard.
|
||||
//
|
||||
// When the client sends the confirmed list, loading is a MANUAL decision:
|
||||
// only the ticked bookings are stamped loaded. Anything unticked was
|
||||
// already unassigned above, but a booking can also sit here unticked and
|
||||
// still attached (government, or one this predicate cannot shed) — those
|
||||
// must not be auto-loaded, or an empty wagon rides as if it carried cargo.
|
||||
// Absent (older clients) = auto-load every origin boarder, the historic
|
||||
// behavior.
|
||||
const confirmedLoadedIds = dto.loadedBookingIds;
|
||||
await manager.query(
|
||||
`UPDATE freight.bookings b
|
||||
SET status = 'IN_TRANSIT',
|
||||
@@ -3075,8 +3102,14 @@ export class TrainSchedulingService {
|
||||
AND b.deleted_at IS NULL
|
||||
AND b.origin_yard_id = $2
|
||||
AND b.loaded_at IS NULL
|
||||
AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))`,
|
||||
[scheduleId, schedule.originStationId, now],
|
||||
AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))
|
||||
AND ($4::uuid[] IS NULL OR b.id = ANY($4::uuid[]))`,
|
||||
[
|
||||
scheduleId,
|
||||
schedule.originStationId,
|
||||
now,
|
||||
confirmedLoadedIds ? confirmedLoadedIds : null,
|
||||
],
|
||||
);
|
||||
// Close the booking window; any still-pending (unallocated) reservations don't ride this train.
|
||||
await manager
|
||||
@@ -3174,11 +3207,19 @@ export class TrainSchedulingService {
|
||||
context: { action: string; yardLabel?: string },
|
||||
): Promise<void> {
|
||||
if (!schedule.trainSetId) return;
|
||||
const rows: Array<{ reference: string; loaded: string; total: string }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT b.reference,
|
||||
const rows: Array<{
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
loaded: string;
|
||||
total: string;
|
||||
unloadedAllocationIds: string[];
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT b.id AS "bookingId",
|
||||
b.reference,
|
||||
COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) AS loaded,
|
||||
COUNT(*) AS total
|
||||
COUNT(*) AS total,
|
||||
ARRAY_AGG(a.id) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED'))
|
||||
AS "unloadedAllocationIds"
|
||||
FROM freight.wagon_booking_allocations a
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
|
||||
JOIN freight.bookings b ON b.id = a.booking_id
|
||||
@@ -3190,18 +3231,38 @@ export class TrainSchedulingService {
|
||||
GROUP BY b.id, b.reference
|
||||
HAVING COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) > 0
|
||||
AND COUNT(*) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) > 0`,
|
||||
[schedule.trainSetId, boardingYardId],
|
||||
);
|
||||
[schedule.trainSetId, boardingYardId],
|
||||
);
|
||||
if (rows.length) {
|
||||
const detail = rows
|
||||
.map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`)
|
||||
.join(', ');
|
||||
const where = context.yardLabel ? ` at ${context.yardLabel}` : '';
|
||||
throw new BadRequestException(
|
||||
`Cannot ${context.action}: booking(s) partially loaded${where} — load every wagon ` +
|
||||
// The message stays human-readable for logs and older clients, but the
|
||||
// payload carries the machine-readable cut so the UI can offer the
|
||||
// EDR-fault / customer-fault decision instead of parsing prose.
|
||||
throw new BadRequestException({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
code: 'PARTIALLY_LOADED_BOOKINGS',
|
||||
message:
|
||||
`Cannot ${context.action}: booking(s) partially loaded${where} — load every wagon ` +
|
||||
`or cancel the remainder (customer fault: cancellation fee; EDR fault: no fee, ` +
|
||||
`rebookable) first: ${detail}`,
|
||||
);
|
||||
partiallyLoaded: {
|
||||
scheduleId: schedule.id,
|
||||
boardingYardId,
|
||||
yardLabel: context.yardLabel ?? null,
|
||||
action: context.action,
|
||||
bookings: rows.map((r) => ({
|
||||
bookingId: r.bookingId,
|
||||
reference: r.reference,
|
||||
loadedWagons: Number(r.loaded),
|
||||
totalWagons: Number(r.total),
|
||||
unloadedAllocationIds: r.unloadedAllocationIds ?? [],
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3228,6 +3289,20 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The log-pass twin of {@link unloadedOriginBoarderIds}: bookings that boarded
|
||||
* at `yardId` and are still unloaded once the train has left it. Same
|
||||
* predicate — partially-loaded bookings (loading_started_at set) are excluded
|
||||
* because assertNoPartiallyLoadedBookings resolves those first, and government
|
||||
* bookings can never be shed.
|
||||
*/
|
||||
private async unloadedBoarderIdsAtYard(
|
||||
scheduleId: string,
|
||||
yardId: string,
|
||||
): Promise<string[]> {
|
||||
return this.unloadedOriginBoarderIds(scheduleId, yardId);
|
||||
}
|
||||
|
||||
private async unloadedOriginBoarderIds(
|
||||
scheduleId: string,
|
||||
originYardId: string,
|
||||
@@ -4998,6 +5073,24 @@ export class TrainSchedulingService {
|
||||
// skipped checkpoint log cannot smuggle an unresolved yard past the gate.
|
||||
await this.assertPassedYardsFullyLoaded(schedule, stations, dto.sequenceNo);
|
||||
|
||||
// Mid-corridor leave-behind. Recording THIS station means the train has
|
||||
// left the previous one, so cargo that boarded back there has had its last
|
||||
// chance to load: anything the operator did not tick is deallocated and
|
||||
// returned to the pool, exactly as dispatch does for the origin yard.
|
||||
// Origin (seq 0) is dispatch's job, so only seq >= 1 has a departed yard.
|
||||
if (dto.loadedBookingIds && dto.sequenceNo > 0) {
|
||||
const departedYardId = stations.find(
|
||||
(s) => s.sequenceNo === dto.sequenceNo - 1,
|
||||
)?.yardId;
|
||||
if (departedYardId) {
|
||||
const keep = new Set(dto.loadedBookingIds);
|
||||
const candidates = await this.unloadedBoarderIdsAtYard(scheduleId, departedYardId);
|
||||
for (const bookingId of candidates.filter((id) => !keep.has(id))) {
|
||||
await this.unassignBooking(scheduleId, bookingId, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates.
|
||||
const [existing] = await this.trainCheckpointEventsRepository.findAll({
|
||||
where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo },
|
||||
@@ -5689,6 +5782,22 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Consist-only empties: coupled to the built train and bound at dispatch
|
||||
// so the checkpoint position fix moves them, but they own no slot, so the
|
||||
// per-slot settle above never sees them. Release them here or they stay
|
||||
// locked to a finished schedule and no later train can pick them up.
|
||||
// They carry no cargo, so they simply settle where the train ended up.
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.createQueryBuilder()
|
||||
.update(Wagon)
|
||||
.set({
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
currentYardId: schedule.destinationStationId,
|
||||
})
|
||||
.where('current_train_schedule_id = :scheduleId', { scheduleId })
|
||||
.execute();
|
||||
if (arrivalLogRows.length) {
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(arrivalLogRows);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user