mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 21:45:03 +00:00
feat: Implement handling for partially loaded bookings in train scheduling
- Added to manage decisions on partially loaded bookings during log-pass and dispatch actions. - Enhanced to track and manage bookings left behind when a train departs a yard. - Updated and to pass necessary station data for handling partially loaded bookings. - Modified and to account for customer-fault fees and ensure proper handling of credits. - Introduced fault tracking in interface to differentiate between customer and EDR faults.
This commit is contained in:
@@ -232,3 +232,91 @@ describe('BookingWagonCancellationService.buildRebookDto (bulk wagon count)', ()
|
||||
expect(dto.requestedWagons).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The cancellation fee is paid BEFORE the credit is redeemed.
|
||||
*
|
||||
* An at-loading cut applies immediately and opens the credit while its fee
|
||||
* invoice stays open, so CREDIT_AVAILABLE on its own never means the fee was
|
||||
* settled. Without the gate the customer rebooks the same wagons and the
|
||||
* cancellation fee is simply never collected. EDR-fault cuts carry no fee and
|
||||
* must stay freely rebookable — partial or whole, container or bulk.
|
||||
*/
|
||||
describe('BookingWagonCancellationService.rebook (cancellation fee gate)', () => {
|
||||
const source = {
|
||||
id: 'b1',
|
||||
contractId: 'c1',
|
||||
paymentCurrency: 'ETB',
|
||||
originYardId: 'y1',
|
||||
destinationYardId: 'y2',
|
||||
tradeDirection: 'IMPORT',
|
||||
};
|
||||
|
||||
const makeSvc = (row: Record<string, unknown>) => {
|
||||
const svc = Object.create(BookingWagonCancellationService.prototype) as Record<
|
||||
string,
|
||||
unknown
|
||||
> & { rebook(id: string, dto: unknown): Promise<unknown> };
|
||||
svc.repo = { findById: async () => row };
|
||||
svc.bookingsRepository = {
|
||||
findById: async () => source,
|
||||
findByIdWithFiles: async () => null,
|
||||
};
|
||||
return svc;
|
||||
};
|
||||
|
||||
/** Bulk credit — no bySize, so nothing depends on container snapshots. */
|
||||
const bulkRow = (over: Record<string, unknown>) => ({
|
||||
id: 'wc1',
|
||||
bookingId: 'b1',
|
||||
status: 'CREDIT_AVAILABLE',
|
||||
creditAmount: 5000,
|
||||
wagonsCancelled: 2,
|
||||
cancelledQuantities: { bulkTons: 100 },
|
||||
feeCurrency: 'ETB',
|
||||
...over,
|
||||
});
|
||||
|
||||
it('blocks a rebook while a customer-fault fee is unpaid', async () => {
|
||||
const svc = makeSvc(
|
||||
bulkRow({ fault: 'CUSTOMER', feeAmount: 1500, feePaidAt: null }),
|
||||
);
|
||||
await expect(svc.rebook('wc1', { scheduledDate: '2026-09-01' })).rejects.toThrow(
|
||||
/pay the ETB 1500\.00 cancellation fee for 2 wagon\(s\)/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks a WHOLE-booking customer-fault cancel just the same', async () => {
|
||||
const svc = makeSvc(
|
||||
bulkRow({ fault: 'CUSTOMER', feeAmount: 4000, feePaidAt: null, wagonsCancelled: 4 }),
|
||||
);
|
||||
await expect(svc.rebook('wc1', { scheduledDate: '2026-09-01' })).rejects.toThrow(
|
||||
/4 wagon\(s\) before rebooking/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the rebook through once the fee is paid', async () => {
|
||||
const svc = makeSvc(
|
||||
bulkRow({ fault: 'CUSTOMER', feeAmount: 1500, feePaidAt: new Date() }),
|
||||
);
|
||||
// Past the gate it fails later (no contract/create wiring in this harness) —
|
||||
// what matters is that it is no longer the fee that stops it.
|
||||
await expect(
|
||||
svc.rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.not.toThrow(/cancellation fee/i);
|
||||
});
|
||||
|
||||
it('never charges an EDR-fault cut', async () => {
|
||||
const svc = makeSvc(bulkRow({ fault: 'EDR', feeAmount: 0, feePaidAt: null }));
|
||||
await expect(
|
||||
svc.rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.not.toThrow(/cancellation fee/i);
|
||||
});
|
||||
|
||||
it('leaves legacy rows without a fee untouched', async () => {
|
||||
const svc = makeSvc(bulkRow({ fault: null, feeAmount: 0, feePaidAt: null }));
|
||||
await expect(
|
||||
svc.rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.not.toThrow(/cancellation fee/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1003,6 +1003,18 @@ export class BookingWagonCancellationService {
|
||||
'This cancellation has no rebooking credit — the booking was never paid. Create a new booking instead.',
|
||||
);
|
||||
}
|
||||
// Customer-fault fee settles BEFORE the credit is redeemed. An at-loading
|
||||
// cut applies immediately and opens the credit while its invoice stays
|
||||
// open, so CREDIT_AVAILABLE alone does not mean the fee was paid — without
|
||||
// this the customer rebooks the wagons and never pays the cancellation
|
||||
// fee the notice already promised. EDR fault carries no fee and is
|
||||
// unaffected; onFeePaid stamps feePaidAt and the gate opens by itself.
|
||||
if (row.fault === 'CUSTOMER' && Number(row.feeAmount) > 0 && !row.feePaidAt) {
|
||||
throw new BadRequestException(
|
||||
`Pay the ${row.feeCurrency} ${Number(row.feeAmount).toFixed(2)} cancellation fee for ` +
|
||||
`${Math.ceil(Number(row.wagonsCancelled))} wagon(s) before rebooking this credit.`,
|
||||
);
|
||||
}
|
||||
const source = await this.bookingsRepository.findById(row.bookingId);
|
||||
if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`);
|
||||
if (!source.contractId) {
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { TrainSchedulingService } from "./services/train-scheduling.service";
|
||||
|
||||
/**
|
||||
* Mid-corridor leave-behind. Logging a pass at station N means the train has
|
||||
* LEFT station N-1, so cargo that boarded back there has had its last chance
|
||||
* to load: anything the operator did not tick rides no further and is
|
||||
* unassigned back to the booking pool.
|
||||
*
|
||||
* Dispatch already does this for the origin yard; these cover the log-pass
|
||||
* twin, plus the structured payload the UI needs to offer the
|
||||
* EDR-fault / customer-fault cut on a part-loaded booking.
|
||||
*/
|
||||
describe("recordCheckpoint — mid-corridor leave-behind", () => {
|
||||
const STATIONS = [
|
||||
{ sequenceNo: 0, yardId: "yard-a", label: "Yard A" },
|
||||
{ sequenceNo: 1, yardId: "yard-b", label: "Yard B" },
|
||||
{ sequenceNo: 2, yardId: "yard-c", label: "Yard C" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Exercises the leave-behind block in isolation — the surrounding
|
||||
* recordCheckpoint does heavy graph/transaction work irrelevant here.
|
||||
*/
|
||||
const runLeaveBehind = async (
|
||||
dto: { sequenceNo: number; loadedBookingIds?: string[] },
|
||||
candidatesByYard: Record<string, string[]>,
|
||||
) => {
|
||||
const unassigned: Array<{ scheduleId: string; bookingId: string }> = [];
|
||||
const svc = Object.create(TrainSchedulingService.prototype) as {
|
||||
unloadedBoarderIdsAtYard(
|
||||
scheduleId: string,
|
||||
yardId: string,
|
||||
): Promise<string[]>;
|
||||
unassignBooking(
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
userId?: string,
|
||||
): Promise<void>;
|
||||
};
|
||||
svc.unloadedBoarderIdsAtYard = async (
|
||||
_scheduleId: string,
|
||||
yardId: string,
|
||||
) => candidatesByYard[yardId] ?? [];
|
||||
svc.unassignBooking = async (scheduleId: string, bookingId: string) => {
|
||||
unassigned.push({ scheduleId, bookingId });
|
||||
};
|
||||
|
||||
// Mirrors the block inside recordCheckpoint.
|
||||
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 svc.unloadedBoarderIdsAtYard(
|
||||
"sched-1",
|
||||
departedYardId,
|
||||
);
|
||||
for (const bookingId of candidates.filter((id) => !keep.has(id))) {
|
||||
await svc.unassignBooking("sched-1", bookingId, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
return unassigned.map((u) => u.bookingId);
|
||||
};
|
||||
|
||||
it("drops the unticked boarders of the yard the train just left", async () => {
|
||||
// b4 and b5 boarded at Yard B; only b5 was loaded. Logging Yard C means
|
||||
// the train has left B, so b4 is stranded and comes off the train.
|
||||
const dropped = await runLeaveBehind(
|
||||
{ sequenceNo: 2, loadedBookingIds: ["b5"] },
|
||||
{ "yard-b": ["b4", "b5"] },
|
||||
);
|
||||
expect(dropped).toEqual(["b4"]);
|
||||
});
|
||||
|
||||
it("scopes the drop to the DEPARTED yard, never the one being logged", async () => {
|
||||
// Cargo boarding at Yard C is not due until the train is there — logging
|
||||
// the pass at C must not shed it.
|
||||
const dropped = await runLeaveBehind(
|
||||
{ sequenceNo: 2, loadedBookingIds: [] },
|
||||
{ "yard-b": [], "yard-c": ["b6", "b7"] },
|
||||
);
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves nobody behind when the client omits the list", async () => {
|
||||
// Older clients send no list — the historic behavior is that everyone rides.
|
||||
const dropped = await runLeaveBehind(
|
||||
{ sequenceNo: 2 },
|
||||
{ "yard-b": ["b4"] },
|
||||
);
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not shed at the origin — that is dispatch's decision", async () => {
|
||||
const dropped = await runLeaveBehind(
|
||||
{ sequenceNo: 0, loadedBookingIds: [] },
|
||||
{ "yard-a": ["b1", "b3"] },
|
||||
);
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps every ticked booking on the train", async () => {
|
||||
const dropped = await runLeaveBehind(
|
||||
{ sequenceNo: 2, loadedBookingIds: ["b4", "b5"] },
|
||||
{ "yard-b": ["b4", "b5"] },
|
||||
);
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertNoPartiallyLoadedBookings — structured payload", () => {
|
||||
const makeService = (
|
||||
rows: Array<{
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
loaded: string;
|
||||
total: string;
|
||||
unloadedAllocationIds: string[];
|
||||
}>,
|
||||
) => {
|
||||
const svc = Object.create(TrainSchedulingService.prototype) as {
|
||||
dataSource: {
|
||||
query: (sql: string, params: unknown[]) => Promise<unknown>;
|
||||
};
|
||||
assertNoPartiallyLoadedBookings(
|
||||
schedule: unknown,
|
||||
boardingYardId: string,
|
||||
context: { action: string; yardLabel?: string },
|
||||
): Promise<void>;
|
||||
};
|
||||
svc.dataSource = { query: async () => rows };
|
||||
return svc;
|
||||
};
|
||||
const schedule = {
|
||||
id: "sched-1",
|
||||
trainSetId: "set-1",
|
||||
originStationId: "yard-a",
|
||||
};
|
||||
|
||||
it("carries the never-loaded allocation ids the fault-cut modal needs", async () => {
|
||||
const svc = makeService([
|
||||
{
|
||||
bookingId: "b4",
|
||||
reference: "BK-2026-000853",
|
||||
loaded: "1",
|
||||
total: "4",
|
||||
unloadedAllocationIds: ["w2", "w3", "w4"],
|
||||
},
|
||||
]);
|
||||
|
||||
const err = await svc
|
||||
.assertNoPartiallyLoadedBookings(schedule, "yard-b", {
|
||||
action: "record this checkpoint",
|
||||
yardLabel: "Yard B",
|
||||
})
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(BadRequestException);
|
||||
const body = (err as BadRequestException).getResponse() as {
|
||||
code: string;
|
||||
message: string;
|
||||
partiallyLoaded: {
|
||||
yardLabel: string | null;
|
||||
bookings: Array<{
|
||||
bookingId: string;
|
||||
loadedWagons: number;
|
||||
totalWagons: number;
|
||||
unloadedAllocationIds: string[];
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
expect(body.code).toBe("PARTIALLY_LOADED_BOOKINGS");
|
||||
expect(body.partiallyLoaded.yardLabel).toBe("Yard B");
|
||||
expect(body.partiallyLoaded.bookings).toEqual([
|
||||
{
|
||||
bookingId: "b4",
|
||||
reference: "BK-2026-000853",
|
||||
loadedWagons: 1,
|
||||
totalWagons: 4,
|
||||
unloadedAllocationIds: ["w2", "w3", "w4"],
|
||||
},
|
||||
]);
|
||||
// The prose message survives for logs and older clients.
|
||||
expect(body.message).toContain("BK-2026-000853 (1/4 wagons loaded)");
|
||||
});
|
||||
|
||||
it("stays silent when nothing at the yard is half-loaded", async () => {
|
||||
const svc = makeService([]);
|
||||
await expect(
|
||||
svc.assertNoPartiallyLoadedBookings(schedule, "yard-b", {
|
||||
action: "dispatch",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Dispatch's origin auto-load. This UPDATE is the reason an unticked booking
|
||||
* could still end up marked loaded: it stamps every PAID origin boarder, so
|
||||
* without the confirmed-list guard a booking left attached (or one the
|
||||
* unassign predicate cannot shed) rides as if its cargo were aboard.
|
||||
*/
|
||||
describe('dispatchSchedule — origin auto-load respects the confirmed list', () => {
|
||||
/** Mirrors the `($4::uuid[] IS NULL OR b.id = ANY($4::uuid[]))` guard. */
|
||||
const wouldAutoLoad = (bookingId: string, confirmed: string[] | undefined) =>
|
||||
confirmed === undefined || confirmed.includes(bookingId);
|
||||
|
||||
it('stamps only the ticked bookings', () => {
|
||||
expect(wouldAutoLoad('b2', ['b2'])).toBe(true);
|
||||
expect(wouldAutoLoad('b1', ['b2'])).toBe(false);
|
||||
});
|
||||
|
||||
it('stamps nobody when the operator unticks everyone', () => {
|
||||
expect(wouldAutoLoad('b1', [])).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the historic auto-load for clients that send no list', () => {
|
||||
expect(wouldAutoLoad('b1', undefined)).toBe(true);
|
||||
expect(wouldAutoLoad('b2', undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Empty wagons must travel with their train.
|
||||
*
|
||||
* The checkpoint position fix moves wagons by `current_train_schedule_id`, but
|
||||
* dispatch used to bind only the PINNED slots (the ones carrying cargo). A
|
||||
* built train rolls with its whole consist, so every empty wagon coupled to it
|
||||
* was left unbound — and stayed recorded at the origin yard while the train it
|
||||
* is hooked to travelled the corridor.
|
||||
*/
|
||||
describe('dispatchSchedule — the whole consist travels, not just loaded slots', () => {
|
||||
/** Mirrors dispatch's binding set: pinned slots ∪ built-train consist. */
|
||||
const boundAtDispatch = (
|
||||
pinnedSlotWagonIds: Array<string | null>,
|
||||
builtTrainWagonIds: string[],
|
||||
) => [
|
||||
...new Set([
|
||||
...pinnedSlotWagonIds.filter((id): id is string => Boolean(id)),
|
||||
...builtTrainWagonIds,
|
||||
]),
|
||||
];
|
||||
|
||||
it('binds the empty wagons coupled to the built train', () => {
|
||||
// The real shape of the reported schedule: 3 slots carry cargo, 45 empties
|
||||
// ride along. All 48 must move when a checkpoint is logged.
|
||||
const pinned = ['w1', 'w2', 'w3'];
|
||||
const consist = ['w1', 'w2', 'w3', 'e1', 'e2', 'e3'];
|
||||
const bound = boundAtDispatch(pinned, consist);
|
||||
expect(bound).toEqual(['w1', 'w2', 'w3', 'e1', 'e2', 'e3']);
|
||||
expect(bound).toContain('e1');
|
||||
});
|
||||
|
||||
it('never double-binds a wagon that is both pinned and on the train', () => {
|
||||
const bound = boundAtDispatch(['w1', 'w1'], ['w1']);
|
||||
expect(bound).toEqual(['w1']);
|
||||
});
|
||||
|
||||
it('still binds pinned slots when there is no built train', () => {
|
||||
// A set-only schedule (no Train row) has no consist to add.
|
||||
expect(boundAtDispatch(['w1', null, 'w2'], [])).toEqual(['w1', 'w2']);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { TrainCheckpointKind } from '@edr/types';
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { TrainCheckpointKind } from "@edr/types";
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
} from "class-validator";
|
||||
|
||||
export class RecordCheckpointDto {
|
||||
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
|
||||
@ApiProperty({
|
||||
description: "Station position along the route (0 = origin).",
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sequenceNo!: number;
|
||||
@@ -31,7 +33,7 @@ export class RecordCheckpointDto {
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description:
|
||||
'ISO timestamp; defaults to now. Past allowed, future rejected, must be in corridor order.',
|
||||
"ISO timestamp; defaults to now. Past allowed, future rejected, must be in corridor order.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@@ -42,7 +44,10 @@ export class RecordCheckpointDto {
|
||||
* loading and unloading time. All optional: a stop logged without them still
|
||||
* records its staying time.
|
||||
*/
|
||||
@ApiProperty({ required: false, description: 'ISO timestamp; unloading start.' })
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: "ISO timestamp; unloading start.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
unloadingStartedAt?: string;
|
||||
@@ -62,6 +67,24 @@ export class RecordCheckpointDto {
|
||||
@IsISO8601()
|
||||
loadingCompletedAt?: string;
|
||||
|
||||
/**
|
||||
* Mid-corridor leave-behind, the log-pass twin of DispatchScheduleDto's field.
|
||||
* Recording THIS station means the train left the previous one, so the
|
||||
* bookings that boarded back there have had their last chance to load. When
|
||||
* present, only these ride on; every other unloaded boarder of the departed
|
||||
* yard is deallocated from its wagon and returned to the booking pool.
|
||||
* Absent (older clients) = nobody is left behind, the historic behavior.
|
||||
*/
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description:
|
||||
"Bookings from the yard just departed confirmed loaded; the rest are unassigned back to the pool. Omit to leave nobody behind.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID("4", { each: true })
|
||||
loadedBookingIds?: string[];
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -73,7 +96,8 @@ export class RecordCheckpointDto {
|
||||
export class UpdateCheckpointDto {
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'ISO timestamp. Past allowed, future rejected, must be in corridor order.',
|
||||
description:
|
||||
"ISO timestamp. Past allowed, future rejected, must be in corridor order.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@@ -110,7 +134,8 @@ export class UpdateCheckpointDto {
|
||||
export class DispatchScheduleDto {
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'Actual departure time; defaults to now. Past allowed, future rejected.',
|
||||
description:
|
||||
"Actual departure time; defaults to now. Past allowed, future rejected.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@@ -125,10 +150,10 @@ export class DispatchScheduleDto {
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description:
|
||||
'Origin-yard bookings confirmed loaded; the rest are unassigned back to the pool. Omit to auto-load all.',
|
||||
"Origin-yard bookings confirmed loaded; the rest are unassigned back to the pool. Omit to auto-load all.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
@IsUUID("4", { each: true })
|
||||
loadedBookingIds?: string[];
|
||||
}
|
||||
|
||||
@@ -2991,9 +2991,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)
|
||||
@@ -3063,6 +3081,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',
|
||||
@@ -3074,8 +3101,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
|
||||
@@ -3173,11 +3206,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
|
||||
@@ -3189,18 +3230,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 ?? [],
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3227,6 +3288,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,
|
||||
@@ -4702,6 +4777,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 },
|
||||
@@ -5323,6 +5416,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