mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 04:45:03 +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:
@@ -232,3 +232,91 @@ describe('BookingWagonCancellationService.buildRebookDto (bulk wagon count)', ()
|
|||||||
expect(dto.requestedWagons).toBeUndefined();
|
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.',
|
'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);
|
const source = await this.bookingsRepository.findById(row.bookingId);
|
||||||
if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`);
|
if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`);
|
||||||
if (!source.contractId) {
|
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 { ApiProperty } from "@nestjs/swagger";
|
||||||
import { TrainCheckpointKind } from '@edr/types';
|
import { TrainCheckpointKind } from "@edr/types";
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
@@ -10,10 +10,12 @@ import {
|
|||||||
IsUUID,
|
IsUUID,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from "class-validator";
|
||||||
|
|
||||||
export class RecordCheckpointDto {
|
export class RecordCheckpointDto {
|
||||||
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
|
@ApiProperty({
|
||||||
|
description: "Station position along the route (0 = origin).",
|
||||||
|
})
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
sequenceNo!: number;
|
sequenceNo!: number;
|
||||||
@@ -31,7 +33,7 @@ export class RecordCheckpointDto {
|
|||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
required: false,
|
required: false,
|
||||||
description:
|
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()
|
@IsOptional()
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
@@ -42,7 +44,10 @@ export class RecordCheckpointDto {
|
|||||||
* loading and unloading time. All optional: a stop logged without them still
|
* loading and unloading time. All optional: a stop logged without them still
|
||||||
* records its staying time.
|
* records its staying time.
|
||||||
*/
|
*/
|
||||||
@ApiProperty({ required: false, description: 'ISO timestamp; unloading start.' })
|
@ApiProperty({
|
||||||
|
required: false,
|
||||||
|
description: "ISO timestamp; unloading start.",
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
unloadingStartedAt?: string;
|
unloadingStartedAt?: string;
|
||||||
@@ -62,6 +67,24 @@ export class RecordCheckpointDto {
|
|||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
loadingCompletedAt?: string;
|
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 })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -73,7 +96,8 @@ export class RecordCheckpointDto {
|
|||||||
export class UpdateCheckpointDto {
|
export class UpdateCheckpointDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
required: false,
|
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()
|
@IsOptional()
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
@@ -110,7 +134,8 @@ export class UpdateCheckpointDto {
|
|||||||
export class DispatchScheduleDto {
|
export class DispatchScheduleDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
required: false,
|
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()
|
@IsOptional()
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
@@ -125,10 +150,10 @@ export class DispatchScheduleDto {
|
|||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
required: false,
|
required: false,
|
||||||
description:
|
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()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsUUID('4', { each: true })
|
@IsUUID("4", { each: true })
|
||||||
loadedBookingIds?: string[];
|
loadedBookingIds?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2992,9 +2992,27 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
// The train is out — every pinned wagon is ASSIGNED to this schedule and
|
// 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.
|
// 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)
|
.map((slot) => slot.physicalWagonId)
|
||||||
.filter((id): id is string => Boolean(id));
|
.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) {
|
if (dispatchedPhysicalIds.length) {
|
||||||
await manager
|
await manager
|
||||||
.getRepository(Wagon)
|
.getRepository(Wagon)
|
||||||
@@ -3064,6 +3082,15 @@ export class TrainSchedulingService {
|
|||||||
// that the operator didn't load individually are auto-loaded now — the
|
// that the operator didn't load individually are auto-loaded now — the
|
||||||
// train is leaving with them. Mid-corridor boarders stay PAID until the
|
// train is leaving with them. Mid-corridor boarders stay PAID until the
|
||||||
// operator loads them at their own yard.
|
// 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(
|
await manager.query(
|
||||||
`UPDATE freight.bookings b
|
`UPDATE freight.bookings b
|
||||||
SET status = 'IN_TRANSIT',
|
SET status = 'IN_TRANSIT',
|
||||||
@@ -3075,8 +3102,14 @@ export class TrainSchedulingService {
|
|||||||
AND b.deleted_at IS NULL
|
AND b.deleted_at IS NULL
|
||||||
AND b.origin_yard_id = $2
|
AND b.origin_yard_id = $2
|
||||||
AND b.loaded_at IS NULL
|
AND b.loaded_at IS NULL
|
||||||
AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))`,
|
AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))
|
||||||
[scheduleId, schedule.originStationId, now],
|
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.
|
// Close the booking window; any still-pending (unallocated) reservations don't ride this train.
|
||||||
await manager
|
await manager
|
||||||
@@ -3174,11 +3207,19 @@ export class TrainSchedulingService {
|
|||||||
context: { action: string; yardLabel?: string },
|
context: { action: string; yardLabel?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!schedule.trainSetId) return;
|
if (!schedule.trainSetId) return;
|
||||||
const rows: Array<{ reference: string; loaded: string; total: string }> =
|
const rows: Array<{
|
||||||
await this.dataSource.query(
|
bookingId: string;
|
||||||
`SELECT b.reference,
|
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(*) 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
|
FROM freight.wagon_booking_allocations a
|
||||||
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
|
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
|
||||||
JOIN freight.bookings b ON b.id = a.booking_id
|
JOIN freight.bookings b ON b.id = a.booking_id
|
||||||
@@ -3190,18 +3231,38 @@ export class TrainSchedulingService {
|
|||||||
GROUP BY b.id, b.reference
|
GROUP BY b.id, b.reference
|
||||||
HAVING COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) > 0
|
HAVING COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) > 0
|
||||||
AND COUNT(*) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) > 0`,
|
AND COUNT(*) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) > 0`,
|
||||||
[schedule.trainSetId, boardingYardId],
|
[schedule.trainSetId, boardingYardId],
|
||||||
);
|
);
|
||||||
if (rows.length) {
|
if (rows.length) {
|
||||||
const detail = rows
|
const detail = rows
|
||||||
.map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`)
|
.map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
const where = context.yardLabel ? ` at ${context.yardLabel}` : '';
|
const where = context.yardLabel ? ` at ${context.yardLabel}` : '';
|
||||||
throw new BadRequestException(
|
// The message stays human-readable for logs and older clients, but the
|
||||||
`Cannot ${context.action}: booking(s) partially loaded${where} — load every wagon ` +
|
// 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, ` +
|
`or cancel the remainder (customer fault: cancellation fee; EDR fault: no fee, ` +
|
||||||
`rebookable) first: ${detail}`,
|
`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(
|
private async unloadedOriginBoarderIds(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
originYardId: string,
|
originYardId: string,
|
||||||
@@ -4998,6 +5073,24 @@ export class TrainSchedulingService {
|
|||||||
// skipped checkpoint log cannot smuggle an unresolved yard past the gate.
|
// skipped checkpoint log cannot smuggle an unresolved yard past the gate.
|
||||||
await this.assertPassedYardsFullyLoaded(schedule, stations, dto.sequenceNo);
|
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.
|
// Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates.
|
||||||
const [existing] = await this.trainCheckpointEventsRepository.findAll({
|
const [existing] = await this.trainCheckpointEventsRepository.findAll({
|
||||||
where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo },
|
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) {
|
if (arrivalLogRows.length) {
|
||||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(arrivalLogRows);
|
await manager.getRepository(ScheduleWagonAdjustmentLog).save(arrivalLogRows);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
@@ -24,6 +25,11 @@ import { useEffect, useState } from "react";
|
|||||||
import { Freight } from "@edr/types";
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
|
import {
|
||||||
|
PartiallyLoadedDecisionModal,
|
||||||
|
parsePartiallyLoaded,
|
||||||
|
type PartiallyLoadedPayload,
|
||||||
|
} from "@/components/trainScheduling/PartiallyLoadedDecisionModal";
|
||||||
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
|
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
|
||||||
import { Chip } from "./trackPrimitives";
|
import { Chip } from "./trackPrimitives";
|
||||||
import { DIRECTION_TONE, track as T } from "./trackTheme";
|
import { DIRECTION_TONE, track as T } from "./trackTheme";
|
||||||
@@ -98,6 +104,7 @@ export function LogPassYardWorkModal({
|
|||||||
onClose,
|
onClose,
|
||||||
scheduleId,
|
scheduleId,
|
||||||
station,
|
station,
|
||||||
|
stations,
|
||||||
isFinal,
|
isFinal,
|
||||||
alreadyLogged,
|
alreadyLogged,
|
||||||
}: {
|
}: {
|
||||||
@@ -105,6 +112,8 @@ export function LogPassYardWorkModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
scheduleId: string;
|
scheduleId: string;
|
||||||
station: TrackStation | null;
|
station: TrackStation | null;
|
||||||
|
/** Whole corridor — used to find the yard the train has just left. */
|
||||||
|
stations: TrackStation[];
|
||||||
isFinal: boolean;
|
isFinal: boolean;
|
||||||
/** True when opened for the current station (pass already logged). */
|
/** True when opened for the current station (pass already logged). */
|
||||||
alreadyLogged: boolean;
|
alreadyLogged: boolean;
|
||||||
@@ -151,6 +160,33 @@ export function LogPassYardWorkModal({
|
|||||||
const loadingStarted = Boolean(workLog?.loading?.startedAt);
|
const loadingStarted = Boolean(workLog?.loading?.startedAt);
|
||||||
const unloadingStarted = Boolean(workLog?.unloading?.startedAt);
|
const unloadingStarted = Boolean(workLog?.unloading?.startedAt);
|
||||||
|
|
||||||
|
// The yard the train is LEAVING by logging this pass. Its boarders have had
|
||||||
|
// their last chance to load, so this is where the leave-behind decision is
|
||||||
|
// made. The origin (seq 0) belongs to dispatch, so there is nothing before it.
|
||||||
|
const departedStation =
|
||||||
|
station && station.sequenceNo > 0
|
||||||
|
? stations.find((s) => s.sequenceNo === station.sequenceNo - 1)
|
||||||
|
: undefined;
|
||||||
|
const departedYard = departedStation
|
||||||
|
? yardWorkQuery.data?.yards.find((y) => y.yardId === departedStation.yardId)
|
||||||
|
: undefined;
|
||||||
|
const departedPendingBoarders: YardWorkBookingRow[] = (departedYard?.toLoad ?? []).filter(
|
||||||
|
(r) => !r.loadedAt,
|
||||||
|
);
|
||||||
|
// Ticked = rides on. Seeded to everyone each time the modal opens on a new
|
||||||
|
// station, so the default is the historic "nobody is left behind".
|
||||||
|
const [departedRidingIds, setDepartedRidingIds] = useState<Set<string>>(new Set());
|
||||||
|
useEffect(() => {
|
||||||
|
setDepartedRidingIds(new Set(departedPendingBoarders.map((r) => r.id)));
|
||||||
|
// Re-seed when the station changes or the rows finish loading.
|
||||||
|
}, [station?.sequenceNo, opened, departedPendingBoarders.length]);
|
||||||
|
const departedLeftBehind = departedPendingBoarders.filter(
|
||||||
|
(r) => !r.isGovernment && !departedRidingIds.has(r.id),
|
||||||
|
);
|
||||||
|
// Set when the pass is rejected because a booking at the departed yard is
|
||||||
|
// part-loaded — drives the EDR-fault / customer-fault decision.
|
||||||
|
const [partialGate, setPartialGate] = useState<PartiallyLoadedPayload | null>(null);
|
||||||
|
|
||||||
const doLogPass = () => {
|
const doLogPass = () => {
|
||||||
if (!station) return;
|
if (!station) return;
|
||||||
recordCheckpoint.mutate(
|
recordCheckpoint.mutate(
|
||||||
@@ -159,11 +195,30 @@ export function LogPassYardWorkModal({
|
|||||||
payload: {
|
payload: {
|
||||||
sequenceNo: station.sequenceNo,
|
sequenceNo: station.sequenceNo,
|
||||||
...(passAt ? { occurredAt: passAt.toISOString() } : {}),
|
...(passAt ? { occurredAt: passAt.toISOString() } : {}),
|
||||||
|
// Logging THIS station means the train left the previous one, so the
|
||||||
|
// cargo that boarded back there has had its last chance to load.
|
||||||
|
// Only the ticked ones ride on; the rest are unassigned and returned
|
||||||
|
// to the pool. Government bookings always ride — the server refuses
|
||||||
|
// to unassign them.
|
||||||
|
...(departedYard
|
||||||
|
? {
|
||||||
|
loadedBookingIds: departedPendingBoarders
|
||||||
|
.filter((r) => r.isGovernment || departedRidingIds.has(r.id))
|
||||||
|
.map((r) => r.id),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setJustLogged(true);
|
setJustLogged(true);
|
||||||
|
if (departedLeftBehind.length) {
|
||||||
|
toast({
|
||||||
|
title: `${departedLeftBehind.length} booking${departedLeftBehind.length === 1 ? "" : "s"} left behind at ${departedYard?.yard ?? "the previous yard"}`,
|
||||||
|
description:
|
||||||
|
"Removed from this train — wagons freed, bookings returned to the pool for a later schedule.",
|
||||||
|
});
|
||||||
|
}
|
||||||
toast({
|
toast({
|
||||||
title: isFinal
|
title: isFinal
|
||||||
? "Train arrived — remaining bookings marked arrived, assets freed"
|
? "Train arrived — remaining bookings marked arrived, assets freed"
|
||||||
@@ -178,12 +233,21 @@ export function LogPassYardWorkModal({
|
|||||||
});
|
});
|
||||||
void yardWorkQuery.refetch();
|
void yardWorkQuery.refetch();
|
||||||
},
|
},
|
||||||
onError: (err) =>
|
onError: (err) => {
|
||||||
|
// A part-loaded booking at the departed yard blocks the pass until
|
||||||
|
// its never-loaded wagons are cut — offer the fault decision instead
|
||||||
|
// of a dead-end error.
|
||||||
|
const gate = parsePartiallyLoaded(err);
|
||||||
|
if (gate) {
|
||||||
|
setPartialGate(gate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast({
|
toast({
|
||||||
title: "Could not log checkpoint",
|
title: "Could not log checkpoint",
|
||||||
description: parseError(err, "Please try again"),
|
description: parseError(err, "Please try again"),
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -558,6 +622,66 @@ export function LogPassYardWorkModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!logged && departedStation && departedPendingBoarders.length > 0 ? (
|
||||||
|
<Stack
|
||||||
|
gap={10}
|
||||||
|
p={14}
|
||||||
|
style={{
|
||||||
|
background: T.amberDim,
|
||||||
|
border: `1px solid ${T.amberBorder}`,
|
||||||
|
borderRadius: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={9} align="center" wrap="nowrap">
|
||||||
|
<PackageCheck size={16} color={T.amber} style={{ flexShrink: 0 }} />
|
||||||
|
<Text size="13px" fw={700} c={T.amber}>
|
||||||
|
{departedPendingBoarders.length} booking
|
||||||
|
{departedPendingBoarders.length === 1 ? "" : "s"} not loaded at{" "}
|
||||||
|
{departedStation.label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="11.5px" c={T.amberText} lh={1.45}>
|
||||||
|
Logging this pass means the train has left {departedStation.label}. Untick
|
||||||
|
anything that never made it onto the train — it is removed and returned to the
|
||||||
|
booking pool.
|
||||||
|
</Text>
|
||||||
|
{departedPendingBoarders.map((r) => (
|
||||||
|
<Group key={r.id} justify="space-between" wrap="nowrap">
|
||||||
|
<Checkbox
|
||||||
|
checked={r.isGovernment || departedRidingIds.has(r.id)}
|
||||||
|
disabled={r.isGovernment || !canLeave}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = new Set(departedRidingIds);
|
||||||
|
if (e.currentTarget.checked) next.add(r.id);
|
||||||
|
else next.delete(r.id);
|
||||||
|
setDepartedRidingIds(next);
|
||||||
|
}}
|
||||||
|
label={
|
||||||
|
<Text size="12.5px">
|
||||||
|
{r.reference ?? r.id}
|
||||||
|
{r.isGovernment ? (
|
||||||
|
<Text span size="11px" c={T.muted}>
|
||||||
|
{" "}
|
||||||
|
· government, cannot be removed
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text size="11px" c={T.muted}>
|
||||||
|
{r.customer}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
{departedLeftBehind.length ? (
|
||||||
|
<Text size="11.5px" fw={700} c={T.amber}>
|
||||||
|
{departedLeftBehind.length} booking
|
||||||
|
{departedLeftBehind.length === 1 ? "" : "s"} will be removed from this train.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{!logged ? (
|
{!logged ? (
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
label={isFinal ? "Arrival time" : "Time at station"}
|
label={isFinal ? "Arrival time" : "Time at station"}
|
||||||
@@ -602,6 +726,13 @@ export function LogPassYardWorkModal({
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{/* Part-loaded gate. Cutting the wagons does NOT log the pass — the
|
||||||
|
operator confirms the pass again once the consist is clean. */}
|
||||||
|
<PartiallyLoadedDecisionModal
|
||||||
|
payload={partialGate}
|
||||||
|
onClose={() => setPartialGate(null)}
|
||||||
|
onResolved={() => void yardWorkQuery.refetch()}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
Paper,
|
||||||
|
Radio,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { AlertTriangle, PackageX } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One partially-loaded booking, as the server reports it in the
|
||||||
|
* PARTIALLY_LOADED_BOOKINGS error payload.
|
||||||
|
*/
|
||||||
|
export interface PartiallyLoadedBooking {
|
||||||
|
bookingId: string;
|
||||||
|
reference: string;
|
||||||
|
loadedWagons: number;
|
||||||
|
totalWagons: number;
|
||||||
|
unloadedAllocationIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartiallyLoadedPayload {
|
||||||
|
scheduleId: string;
|
||||||
|
boardingYardId: string;
|
||||||
|
yardLabel: string | null;
|
||||||
|
action: string;
|
||||||
|
bookings: PartiallyLoadedBooking[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the structured `partiallyLoaded` block off a rejected dispatch or
|
||||||
|
* log-pass. Returns null for every other error so callers can fall through to
|
||||||
|
* their normal toast.
|
||||||
|
*/
|
||||||
|
export function parsePartiallyLoaded(
|
||||||
|
error: unknown,
|
||||||
|
): PartiallyLoadedPayload | null {
|
||||||
|
const data = (
|
||||||
|
error as {
|
||||||
|
response?: {
|
||||||
|
data?: { code?: string; partiallyLoaded?: PartiallyLoadedPayload };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)?.response?.data;
|
||||||
|
if (data?.code !== "PARTIALLY_LOADED_BOOKINGS") return null;
|
||||||
|
return data.partiallyLoaded ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Fault = "EDR" | "CUSTOMER";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gate a partly-loaded booking hits at dispatch or log-pass.
|
||||||
|
*
|
||||||
|
* A booking with some wagons loaded and some never loaded can neither ride
|
||||||
|
* (the empty wagons would leave as ghosts) nor be left behind (unassigning
|
||||||
|
* would strand cargo physically on the train). So the operator decides here:
|
||||||
|
* cut the never-loaded wagons at EDR's fault (no fee, rebookable credit) or
|
||||||
|
* the customer's (cancellation fee invoiced) — or block, and go finish loading.
|
||||||
|
*
|
||||||
|
* Cancelling does NOT then log the pass. The modal closes, the caller refetches,
|
||||||
|
* and the operator clicks their action again with the gate cleared — two
|
||||||
|
* deliberate commits rather than one compound one.
|
||||||
|
*/
|
||||||
|
export function PartiallyLoadedDecisionModal({
|
||||||
|
payload,
|
||||||
|
onClose,
|
||||||
|
onResolved,
|
||||||
|
}: {
|
||||||
|
payload: PartiallyLoadedPayload | null;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Cut succeeded — refetch, so the retry sees the cleared gate. */
|
||||||
|
onResolved: () => void;
|
||||||
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [fault, setFault] = useState<Fault | null>(null);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const cancelRemaining = useMutation(
|
||||||
|
api.trainScheduling.cancelRemainingWagons.mutationOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFault(null);
|
||||||
|
setReason("");
|
||||||
|
}, [payload?.boardingYardId, payload?.bookings.length]);
|
||||||
|
|
||||||
|
if (!payload) return null;
|
||||||
|
|
||||||
|
const { bookings, yardLabel, action } = payload;
|
||||||
|
const totalUnloaded = bookings.reduce(
|
||||||
|
(n, b) => n + b.unloadedAllocationIds.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const where = yardLabel ? ` at ${yardLabel}` : "";
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!fault) return;
|
||||||
|
const edrFault = fault === "EDR";
|
||||||
|
try {
|
||||||
|
// Cut every partly-loaded booking's never-loaded wagons under the one
|
||||||
|
// decision — they are all stuck behind the same gate for the same reason.
|
||||||
|
for (const b of bookings) {
|
||||||
|
await cancelRemaining.mutateAsync({
|
||||||
|
bookingId: b.bookingId,
|
||||||
|
scheduleId: payload.scheduleId,
|
||||||
|
reason: reason.trim(),
|
||||||
|
edrFault,
|
||||||
|
wagonAllocationIds: b.unloadedAllocationIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast({
|
||||||
|
title: `${totalUnloaded} wagon${totalUnloaded === 1 ? "" : "s"} cancelled`,
|
||||||
|
description: edrFault
|
||||||
|
? "EDR's fault — no fee charged; the credit is rebookable."
|
||||||
|
: "Customer's fault — the cancellation fee was invoiced; the credit is rebookable.",
|
||||||
|
});
|
||||||
|
onResolved();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
const message = (
|
||||||
|
err as { response?: { data?: { message?: string | string[] } } }
|
||||||
|
)?.response?.data?.message;
|
||||||
|
toast({
|
||||||
|
title: "Cancellation failed",
|
||||||
|
description: Array.isArray(message)
|
||||||
|
? message.join("; ")
|
||||||
|
: message || (err as Error)?.message || "Please try again",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened
|
||||||
|
onClose={onClose}
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
size="lg"
|
||||||
|
title={
|
||||||
|
<Group gap={8}>
|
||||||
|
<PackageX size={18} />
|
||||||
|
<Text fw={700}>Partly loaded — a decision is needed</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert
|
||||||
|
color="orange"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertTriangle size={18} />}
|
||||||
|
title={`Cannot ${action}${where}`}
|
||||||
|
>
|
||||||
|
<Text size="sm">
|
||||||
|
A booking with some wagons loaded and some never loaded can neither
|
||||||
|
ride nor be removed — the loaded cargo is physically on the train.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Stack gap={8}>
|
||||||
|
{bookings.map((b) => (
|
||||||
|
<Paper key={b.bookingId} withBorder radius="md" p="sm">
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Text fw={700} size="sm">
|
||||||
|
{b.reference}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{b.loadedWagons} of {b.totalWagons} wagons loaded
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{b.unloadedAllocationIds.length} wagon
|
||||||
|
{b.unloadedAllocationIds.length === 1 ? "" : "s"} never loaded —
|
||||||
|
to be cancelled. The {b.loadedWagons} loaded wagon
|
||||||
|
{b.loadedWagons === 1 ? "" : "s"} stay on the train.
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Radio.Group
|
||||||
|
label={`What happens to the ${totalUnloaded} never-loaded wagon${totalUnloaded === 1 ? "" : "s"}?`}
|
||||||
|
value={fault ?? ""}
|
||||||
|
onChange={(v) => setFault(v as Fault)}
|
||||||
|
>
|
||||||
|
<Stack gap={8} mt={8}>
|
||||||
|
<Radio
|
||||||
|
value="EDR"
|
||||||
|
label="Cancel — EDR's fault"
|
||||||
|
description="Wagon shortage, yard problem. No fee charged; the credit is rebookable."
|
||||||
|
/>
|
||||||
|
<Radio
|
||||||
|
value="CUSTOMER"
|
||||||
|
label="Cancel — customer's fault"
|
||||||
|
description="Cargo not ready, no-show. The cancellation fee is invoiced; the credit is rebookable."
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Radio.Group>
|
||||||
|
|
||||||
|
{fault ? (
|
||||||
|
<Textarea
|
||||||
|
label="Reason"
|
||||||
|
placeholder="Why are these wagons not riding?"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.currentTarget.value)}
|
||||||
|
minRows={2}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Cancelling does not {action} — you will confirm that separately once
|
||||||
|
the wagons are cut.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" radius="md" onClick={onClose}>
|
||||||
|
Block — go finish loading
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
disabled={!fault || !reason.trim()}
|
||||||
|
loading={cancelRemaining.isPending}
|
||||||
|
onClick={() => void submit()}
|
||||||
|
>
|
||||||
|
Cancel {totalUnloaded} wagon{totalUnloaded === 1 ? "" : "s"}
|
||||||
|
{fault === "EDR"
|
||||||
|
? " (no fee)"
|
||||||
|
: fault === "CUSTOMER"
|
||||||
|
? " (fee applies)"
|
||||||
|
: ""}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -641,6 +641,7 @@ export default function TrainScheduleTrackPage() {
|
|||||||
onClose={() => setYardModal(null)}
|
onClose={() => setYardModal(null)}
|
||||||
scheduleId={scheduleId}
|
scheduleId={scheduleId}
|
||||||
station={yardModal?.station ?? null}
|
station={yardModal?.station ?? null}
|
||||||
|
stations={track.stations}
|
||||||
isFinal={yardModal?.isFinal ?? false}
|
isFinal={yardModal?.isFinal ?? false}
|
||||||
alreadyLogged={yardModal?.alreadyLogged ?? false}
|
alreadyLogged={yardModal?.alreadyLogged ?? false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Alert,
|
Alert,
|
||||||
|
Anchor,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -64,6 +65,11 @@ import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel"
|
|||||||
import { ScheduleWagonYardPanel } from "@/components/trainScheduling/ScheduleWagonYardPanel";
|
import { ScheduleWagonYardPanel } from "@/components/trainScheduling/ScheduleWagonYardPanel";
|
||||||
import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel";
|
import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel";
|
||||||
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
|
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
|
||||||
|
import {
|
||||||
|
PartiallyLoadedDecisionModal,
|
||||||
|
parsePartiallyLoaded,
|
||||||
|
type PartiallyLoadedPayload,
|
||||||
|
} from "@/components/trainScheduling/PartiallyLoadedDecisionModal";
|
||||||
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
|
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
|
||||||
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
|
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
|
||||||
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
|
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
|
||||||
@@ -135,10 +141,12 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
// Actual departure — staff often dispatch on paper first and record it later,
|
// Actual departure — staff often dispatch on paper first and record it later,
|
||||||
// so the time is picked (defaults to now when the dialog opens).
|
// so the time is picked (defaults to now when the dialog opens).
|
||||||
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
|
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
|
||||||
const openDispatchConfirm = () => {
|
// Set when dispatch is rejected because a booking is part-loaded; drives the
|
||||||
setDispatchAt(new Date());
|
// EDR-fault / customer-fault decision modal.
|
||||||
setDispatchConfirmOpen(true);
|
const [partialGate, setPartialGate] = useState<PartiallyLoadedPayload | null>(null);
|
||||||
};
|
// Log-pass / arrive confirmation for the dispatched leg of the workflow.
|
||||||
|
const [passConfirmOpen, setPassConfirmOpen] = useState(false);
|
||||||
|
const [passAt, setPassAt] = useState<Date | null>(null);
|
||||||
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
|
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
|
||||||
const [visualization3DOpen, setVisualization3DOpen] = useState(false);
|
const [visualization3DOpen, setVisualization3DOpen] = useState(false);
|
||||||
const [loadEmptiesOpen, setLoadEmptiesOpen] = useState(false);
|
const [loadEmptiesOpen, setLoadEmptiesOpen] = useState(false);
|
||||||
@@ -155,6 +163,23 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
refetchInterval: 300_000,
|
refetchInterval: 300_000,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// Journey state for the dispatched leg of the workflow: the corridor stops,
|
||||||
|
// which one the train has reached, and each yard's loading/unloading windows.
|
||||||
|
// Only a rolling train has a journey, so it stays idle until then.
|
||||||
|
const trackQuery = useQuery(
|
||||||
|
api.trainScheduling.trainTrack.queryOptions({
|
||||||
|
input: { id: scheduleId ?? "" },
|
||||||
|
enabled: Boolean(scheduleId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Which bookings board/alight at each yard — drives the loading gate on the
|
||||||
|
// log-pass button (a yard with cargo to load must finish its window first).
|
||||||
|
const yardWorkQuery = useQuery(
|
||||||
|
api.trainScheduling.yardWork.queryOptions({
|
||||||
|
input: { scheduleId: scheduleId ?? "" },
|
||||||
|
enabled: Boolean(scheduleId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
// One-row 60s heartbeat: refetch the (expensive) full detail only when the
|
// One-row 60s heartbeat: refetch the (expensive) full detail only when the
|
||||||
// schedule row actually changed — same freshness as polling the detail
|
// schedule row actually changed — same freshness as polling the detail
|
||||||
// itself, at a fraction of the server cost.
|
// itself, at a fraction of the server cost.
|
||||||
@@ -256,6 +281,9 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
||||||
const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions());
|
const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions());
|
||||||
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
||||||
|
const recordCheckpoint = useMutation(
|
||||||
|
api.trainScheduling.recordCheckpoint.mutationOptions(),
|
||||||
|
);
|
||||||
const downloadMarshalling = useMutation({
|
const downloadMarshalling = useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
id,
|
id,
|
||||||
@@ -503,6 +531,28 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
// Shipping-line bookings ride from accept on the credit ledger.
|
// Shipping-line bookings ride from accept on the credit ledger.
|
||||||
(Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")),
|
(Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")),
|
||||||
);
|
);
|
||||||
|
// A slot can carry several loads of the same booking, so count DISTINCT
|
||||||
|
// slots per booking — the operator is being told how much steel is freed.
|
||||||
|
const slotIdsByBookingId = new Map<string, Set<string>>();
|
||||||
|
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||||||
|
for (const alloc of slot.allocations ?? []) {
|
||||||
|
if (!alloc.bookingId) continue;
|
||||||
|
const slots = slotIdsByBookingId.get(alloc.bookingId) ?? new Set<string>();
|
||||||
|
slots.add(slot.id);
|
||||||
|
slotIdsByBookingId.set(alloc.bookingId, slots);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const wagonsOf = (bookingId: string) => slotIdsByBookingId.get(bookingId)?.size ?? 0;
|
||||||
|
|
||||||
|
const openDispatchConfirm = () => {
|
||||||
|
setDispatchAt(new Date());
|
||||||
|
setDispatchConfirmOpen(true);
|
||||||
|
};
|
||||||
|
// Everything unloaded at the origin comes off the train on dispatch.
|
||||||
|
// Government bookings can never be shed: the server refuses to unassign them.
|
||||||
|
const leftBehind = pendingOriginBoarders.filter((b) => !b.isGovernment);
|
||||||
|
const leftBehindWagons = leftBehind.reduce((n, b) => n + wagonsOf(b.id), 0);
|
||||||
|
|
||||||
// Origin loading time window: dispatch (which marks the boarders loaded)
|
// Origin loading time window: dispatch (which marks the boarders loaded)
|
||||||
// is server-rejected until "Start loading" was clicked for the origin
|
// is server-rejected until "Start loading" was clicked for the origin
|
||||||
// yard, so the button mirrors that gate.
|
// yard, so the button mirrors that gate.
|
||||||
@@ -516,6 +566,89 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
// Same gate the server enforces.
|
// Same gate the server enforces.
|
||||||
const dispatchBlockedByLoading = !originLoadingEnded;
|
const dispatchBlockedByLoading = !originLoadingEnded;
|
||||||
|
|
||||||
|
// ── Journey leg: log pass / mark arrived ────────────────────────────────
|
||||||
|
// Once the train is rolling, the workflow's last step drives the corridor
|
||||||
|
// instead of dispatch. The stop being logged is the one AFTER the train's
|
||||||
|
// current position; the last stop on the route is the arrival.
|
||||||
|
const track = trackQuery.data;
|
||||||
|
const trackStations = track?.stations ?? [];
|
||||||
|
const isRolling = schedule.status === "DISPATCHED";
|
||||||
|
const nextStation = isRolling
|
||||||
|
? trackStations.find((st) => st.sequenceNo === (track?.currentSequenceNo ?? 0) + 1)
|
||||||
|
: undefined;
|
||||||
|
const nextIsFinal =
|
||||||
|
Boolean(nextStation) &&
|
||||||
|
nextStation?.sequenceNo === trackStations[trackStations.length - 1]?.sequenceNo;
|
||||||
|
// Same permission the track page gates its checkpoint actions on.
|
||||||
|
const canLogPass =
|
||||||
|
isRolling && hasPermission(authUser, FREIGHT_PERMS.trainScheduling.update);
|
||||||
|
// Loading gate. Logging a pass means the train LEAVES the yard it is standing
|
||||||
|
// at, so cargo boarding there must have finished loading first — an open (or
|
||||||
|
// never-opened) loading window at a yard with boarders blocks the button.
|
||||||
|
// Unloading never blocks: cargo alighting here can be taken off after the
|
||||||
|
// pass is recorded, and the final arrival is what opens that window at all.
|
||||||
|
const currentStation = isRolling
|
||||||
|
? trackStations.find((st) => st.sequenceNo === (track?.currentSequenceNo ?? 0))
|
||||||
|
: undefined;
|
||||||
|
const currentYardWork = currentStation
|
||||||
|
? yardWorkQuery.data?.yards.find((y) => y.yardId === currentStation.yardId)
|
||||||
|
: undefined;
|
||||||
|
const boardersHere = (currentYardWork?.toLoad ?? []).filter((r) => !r.loadedAt);
|
||||||
|
const currentLoadingLog = currentStation
|
||||||
|
? track?.stationWorkLogs?.[currentStation.yardId]?.loading
|
||||||
|
: undefined;
|
||||||
|
// Only a yard that actually has cargo to load can be blocked by its window.
|
||||||
|
const passBlockedByLoading =
|
||||||
|
boardersHere.length > 0 && !currentLoadingLog?.endedAt;
|
||||||
|
const passBlockReason = !passBlockedByLoading
|
||||||
|
? null
|
||||||
|
: currentLoadingLog?.startedAt
|
||||||
|
? `End the loading window at ${currentStation?.label ?? "this yard"} — the train cannot leave mid-loading.`
|
||||||
|
: `Start and end the loading window at ${currentStation?.label ?? "this yard"} — ${boardersHere.length} booking(s) board here.`;
|
||||||
|
|
||||||
|
const openPassConfirm = () => {
|
||||||
|
setPassAt(new Date());
|
||||||
|
setPassConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runLogPass = async () => {
|
||||||
|
if (!nextStation) return;
|
||||||
|
setPassConfirmOpen(false);
|
||||||
|
try {
|
||||||
|
await recordCheckpoint.mutateAsync({
|
||||||
|
id: scheduleId,
|
||||||
|
payload: {
|
||||||
|
sequenceNo: nextStation.sequenceNo,
|
||||||
|
...(passAt ? { occurredAt: passAt.toISOString() } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: nextIsFinal
|
||||||
|
? `Train arrived at ${nextStation.label}`
|
||||||
|
: `Pass logged at ${nextStation.label}`,
|
||||||
|
description: nextIsFinal
|
||||||
|
? "Remaining bookings are marked arrived and the assets are freed."
|
||||||
|
: "The train's position has moved to this yard.",
|
||||||
|
});
|
||||||
|
void trackQuery.refetch();
|
||||||
|
void yardWorkQuery.refetch();
|
||||||
|
void detailQuery.refetch();
|
||||||
|
} catch (err) {
|
||||||
|
// A part-loaded booking blocks the pass until its never-loaded wagons are
|
||||||
|
// cut — hand over the fault decision rather than a dead-end error.
|
||||||
|
const gate = parsePartiallyLoaded(err);
|
||||||
|
if (gate) {
|
||||||
|
setPartialGate(gate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast({
|
||||||
|
title: nextIsFinal ? "Could not mark arrived" : "Could not log pass",
|
||||||
|
description: parseError(err, "Please try again"),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||||
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||||
const canPrintMarshalling =
|
const canPrintMarshalling =
|
||||||
@@ -572,17 +705,34 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
id: scheduleId,
|
id: scheduleId,
|
||||||
payload: {
|
payload: {
|
||||||
...(dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {}),
|
...(dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {}),
|
||||||
// No per-booking ticking in the dispatch dialog: every pending origin
|
// Dispatch never loads cargo — loading is recorded in the yard, per
|
||||||
// boarder rides — none are left behind at dispatch time.
|
// booking. Anything still unloaded when the train leaves did not make
|
||||||
loadedBookingIds: pendingOriginBoarders.map((b) => b.id),
|
// it aboard: the server unassigns it (wagons freed, booking back in
|
||||||
|
// the pool). Government bookings are exempt and ride regardless.
|
||||||
|
loadedBookingIds: pendingOriginBoarders
|
||||||
|
.filter((b) => b.isGovernment)
|
||||||
|
.map((b) => b.id),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (leftBehind.length) {
|
||||||
|
toast({
|
||||||
|
title: `${leftBehind.length} booking${leftBehind.length === 1 ? "" : "s"} removed from the train`,
|
||||||
|
description: `Never loaded at the origin — ${leftBehindWagons} wagon${leftBehindWagons === 1 ? "" : "s"} freed. The bookings are back in the pool and can be allocated to another train or cancelled.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
await openMarshallingDocument({
|
await openMarshallingDocument({
|
||||||
title: "Train dispatched",
|
title: "Train dispatched",
|
||||||
successDescription: "Marshalling document generated for the dispatched train.",
|
successDescription: "Marshalling document generated for the dispatched train.",
|
||||||
errorTitle: "Train dispatched, but document could not open",
|
errorTitle: "Train dispatched, but document could not open",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// A part-loaded booking blocks dispatch until its never-loaded wagons are
|
||||||
|
// cut — hand the operator the fault decision instead of a dead error.
|
||||||
|
const gate = parsePartiallyLoaded(err);
|
||||||
|
if (gate) {
|
||||||
|
setPartialGate(gate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast({
|
toast({
|
||||||
title: "Dispatch failed",
|
title: "Dispatch failed",
|
||||||
description: parseError(err, "Could not dispatch"),
|
description: parseError(err, "Could not dispatch"),
|
||||||
@@ -705,8 +855,10 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
{
|
{
|
||||||
key: "finalize",
|
key: "finalize",
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
title: "Dispatch",
|
title: isRolling ? "Journey" : "Dispatch",
|
||||||
subtitle: "Review the consist & dispatch",
|
subtitle: isRolling
|
||||||
|
? "Log each pass, then mark arrived"
|
||||||
|
: "Review the consist & dispatch",
|
||||||
complete: finalizeComplete,
|
complete: finalizeComplete,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -958,14 +1110,51 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
<CheckCircle2 size={22} />
|
<CheckCircle2 size={22} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Text fw={600}>Ready to depart</Text>
|
<Text fw={600}>
|
||||||
|
{isRolling
|
||||||
|
? nextIsFinal
|
||||||
|
? "Final leg"
|
||||||
|
: `In transit — at ${currentStation?.label ?? "the corridor"}`
|
||||||
|
: "Ready to depart"}
|
||||||
|
</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Dispatch begins rail movement and notifies the yard.
|
{isRolling
|
||||||
|
? nextIsFinal
|
||||||
|
? "Marking arrived ends the journey and frees the locomotive and wagons."
|
||||||
|
: "Logging the pass moves the train to the next yard and settles its cargo there."
|
||||||
|
: "Dispatch begins rail movement and notifies the yard."}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
{originYardId ? (
|
{/* Mid-route loading/unloading is recorded on the TRACKING page, per
|
||||||
|
yard — only the origin's window lives here (below), because dispatch
|
||||||
|
is the action this page owns. What stays is the read-only reason the
|
||||||
|
pass button is held, so the blocker is explainable without
|
||||||
|
duplicating the controls. */}
|
||||||
|
{isRolling && currentStation && passBlockedByLoading ? (
|
||||||
|
<Alert
|
||||||
|
color="orange"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertTriangle size={18} />}
|
||||||
|
title={`Loading is not finished at ${currentStation.label}`}
|
||||||
|
>
|
||||||
|
<Text size="xs">
|
||||||
|
{boardersHere.length} booking(s) board here, so the train cannot leave until
|
||||||
|
the loading window is closed. Start and end it on the{" "}
|
||||||
|
<Anchor
|
||||||
|
component={Link}
|
||||||
|
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
|
||||||
|
fw={600}
|
||||||
|
>
|
||||||
|
tracking page
|
||||||
|
</Anchor>
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
{!isRolling && originYardId ? (
|
||||||
<Paper p="md" radius="lg" withBorder>
|
<Paper p="md" radius="lg" withBorder>
|
||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
<Text fw={600} size="sm">
|
<Text fw={600} size="sm">
|
||||||
@@ -1000,7 +1189,35 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
Dispatch train
|
Dispatch train
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{!canDispatch ? (
|
{/* The train is rolling: the same slot now drives the corridor. */}
|
||||||
|
{canLogPass && nextStation ? (
|
||||||
|
<Tooltip
|
||||||
|
label={passBlockReason ?? ""}
|
||||||
|
disabled={!passBlockedByLoading}
|
||||||
|
withArrow
|
||||||
|
multiline
|
||||||
|
w={280}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
size="md"
|
||||||
|
radius="md"
|
||||||
|
leftSection={
|
||||||
|
nextIsFinal ? <CheckCircle2 size={18} /> : <Navigation size={18} />
|
||||||
|
}
|
||||||
|
loading={recordCheckpoint.isPending}
|
||||||
|
disabled={passBlockedByLoading}
|
||||||
|
onClick={openPassConfirm}
|
||||||
|
>
|
||||||
|
{nextIsFinal
|
||||||
|
? `Mark arrived at ${nextStation.label}`
|
||||||
|
: `Log pass at ${nextStation.label}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
{!canDispatch && !(canLogPass && nextStation) ? (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
No actions available for this schedule status.
|
No actions available for this schedule status.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1636,6 +1853,25 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
radius="md"
|
radius="md"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{leftBehind.length > 0 ? (
|
||||||
|
<Alert
|
||||||
|
color="orange"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertTriangle size={18} />}
|
||||||
|
title={`${leftBehind.length} booking${leftBehind.length === 1 ? "" : "s"} will be removed from this train`}
|
||||||
|
>
|
||||||
|
<Text size="xs">
|
||||||
|
Never loaded at the origin, so {leftBehind.length === 1 ? "it is" : "they are"}{" "}
|
||||||
|
not aboard. Dispatch frees {leftBehindWagons} wagon
|
||||||
|
{leftBehindWagons === 1 ? "" : "s"} and returns{" "}
|
||||||
|
{leftBehind.length === 1 ? "the booking" : "them"} to the pool, ready to be
|
||||||
|
allocated to another train or cancelled. Load cargo from the yard workspace
|
||||||
|
before dispatching if it should ride.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{hasDispatchWarnings ? (
|
{hasDispatchWarnings ? (
|
||||||
<Alert
|
<Alert
|
||||||
color="orange"
|
color="orange"
|
||||||
@@ -1717,6 +1953,62 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
{/* Log pass / arrival — confirmation only, with the recorded time. */}
|
||||||
|
<Modal
|
||||||
|
opened={passConfirmOpen}
|
||||||
|
onClose={() => setPassConfirmOpen(false)}
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
title={
|
||||||
|
<Group gap={8}>
|
||||||
|
{nextIsFinal ? <CheckCircle2 size={18} /> : <Navigation size={18} />}
|
||||||
|
<Text fw={700}>
|
||||||
|
{nextIsFinal ? "Mark the train arrived?" : "Log the pass?"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{nextIsFinal
|
||||||
|
? `Recording arrival at ${nextStation?.label ?? "the destination"} ends the journey: remaining bookings are marked arrived and the locomotive and wagons are freed.`
|
||||||
|
: `Recording the pass at ${nextStation?.label ?? "the next yard"} moves the train there. Cargo destined for that yard alights, and cargo boarding there becomes loadable.`}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<DateTimePicker
|
||||||
|
label={nextIsFinal ? "Arrival time" : "Time at station"}
|
||||||
|
description="Defaults to now — pick an earlier time if you are recording after the fact."
|
||||||
|
value={passAt}
|
||||||
|
onChange={(v) => setPassAt(v ? new Date(v) : null)}
|
||||||
|
maxDate={new Date()}
|
||||||
|
valueFormat="DD MMM YYYY HH:mm"
|
||||||
|
clearable={false}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" radius="md" onClick={() => setPassConfirmOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
loading={recordCheckpoint.isPending}
|
||||||
|
onClick={() => void runLogPass()}
|
||||||
|
>
|
||||||
|
{nextIsFinal ? "Mark arrived" : "Log pass"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Part-loaded gate. Cutting the wagons does NOT dispatch — the operator
|
||||||
|
confirms dispatch again once the consist is clean. */}
|
||||||
|
<PartiallyLoadedDecisionModal
|
||||||
|
payload={partialGate}
|
||||||
|
onClose={() => setPartialGate(null)}
|
||||||
|
onResolved={() => void detailQuery.refetch()}
|
||||||
|
/>
|
||||||
{visualization3DOpen ? (
|
{visualization3DOpen ? (
|
||||||
<Train3DVisualization schedule={schedule} onClose={() => setVisualization3DOpen(false)} />
|
<Train3DVisualization schedule={schedule} onClose={() => setVisualization3DOpen(false)} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -139,8 +139,17 @@ export function WagonCancellationCard({
|
|||||||
// rows this booking opened itself can be paid/withdrawn/rebooked from here.
|
// rows this booking opened itself can be paid/withdrawn/rebooked from here.
|
||||||
const rows = data?.items ?? [];
|
const rows = data?.items ?? [];
|
||||||
const ownRows = rows.filter((r) => r.bookingId === booking.id);
|
const ownRows = rows.filter((r) => r.bookingId === booking.id);
|
||||||
const openRow = ownRows.find((r) => r.status === "FEE_PENDING");
|
// A cancellation owes its fee whenever a customer-fault fee is still
|
||||||
const creditRow = ownRows.find((r) => r.status === "CREDIT_AVAILABLE");
|
// unsettled. FEE_PENDING is the customer-requested flow (cut applies at
|
||||||
|
// payment); an AT-LOADING cut applies immediately and jumps straight to
|
||||||
|
// CREDIT_AVAILABLE with its invoice left open — so status alone would hide
|
||||||
|
// the fee and offer a "no further payment needed" rebook on money still owed.
|
||||||
|
const owesFee = (r: (typeof ownRows)[number]) =>
|
||||||
|
r.fault === "CUSTOMER" && Number(r.feeAmount ?? 0) > 0 && !r.feePaidAt;
|
||||||
|
const openRow = ownRows.find((r) => r.status === "FEE_PENDING" || owesFee(r));
|
||||||
|
const creditRow = ownRows.find(
|
||||||
|
(r) => r.status === "CREDIT_AVAILABLE" && !owesFee(r),
|
||||||
|
);
|
||||||
|
|
||||||
const feePay = useFeeInvoicePayment(booking.id);
|
const feePay = useFeeInvoicePayment(booking.id);
|
||||||
|
|
||||||
@@ -237,7 +246,14 @@ export function WagonCancellationCard({
|
|||||||
toast.error(apiErrorMessage(e, "Could not rebook the wagons. Please try again.")),
|
toast.error(apiErrorMessage(e, "Could not rebook the wagons. Please try again.")),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!eligible) return null;
|
// Showing the card is NOT the same as allowing a new cut. A partial cancel at
|
||||||
|
// loading leaves the kept wagons to ride, so the booking moves on to
|
||||||
|
// IN_TRANSIT/ARRIVED/COMPLETED while its cancellation still owes a fee and
|
||||||
|
// holds a rebookable credit. Gating on PAID/CANCELLED hid exactly that case —
|
||||||
|
// the customer saw neither the cancelled wagons nor the fee they owe. Any
|
||||||
|
// booking that HAS cancellation rows keeps the card, whatever its status;
|
||||||
|
// `canRequest` still limits NEW cuts to a live PAID booking.
|
||||||
|
if (!eligible && !ownRows.length) return null;
|
||||||
if (!canRequest && !ownRows.length) return null;
|
if (!canRequest && !ownRows.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -265,8 +281,10 @@ export function WagonCancellationCard({
|
|||||||
{fmtMoney(openRow.feeAmount, openRow.feeCurrency)}
|
{fmtMoney(openRow.feeAmount, openRow.feeCurrency)}
|
||||||
</Text>
|
</Text>
|
||||||
. The cancelled wagons have left the train. Pay the fee to unlock
|
. The cancelled wagons have left the train. Pay the fee to unlock
|
||||||
the rebooking credit. The request cannot be withdrawn from here —
|
the rebooking credit — the {Number(openRow.wagonsCancelled)} cancelled
|
||||||
if it was a mistake, contact EDR staff.
|
wagon(s) can then be rebooked on a coming train day. The request
|
||||||
|
cannot be withdrawn from here — if it was a mistake, contact EDR
|
||||||
|
staff.
|
||||||
</Alert>
|
</Alert>
|
||||||
<Group gap={8}>
|
<Group gap={8}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -459,7 +459,15 @@ export default function BookingsListPage() {
|
|||||||
const creditByBooking = useMemo(() => {
|
const creditByBooking = useMemo(() => {
|
||||||
const m = new Map<string, WagonCancellation>();
|
const m = new Map<string, WagonCancellation>();
|
||||||
for (const r of myCancellations?.items ?? []) {
|
for (const r of myCancellations?.items ?? []) {
|
||||||
if (r.status === "CREDIT_AVAILABLE" && !m.has(r.bookingId)) m.set(r.bookingId, r);
|
// A customer-fault cut invoices a fee. An at-loading cut applies at once
|
||||||
|
// and opens the credit with that invoice still OPEN, so CREDIT_AVAILABLE
|
||||||
|
// alone never means the fee was settled — offering "Rebook" here would
|
||||||
|
// let the customer redeem the wagons without ever paying. Those rows fall
|
||||||
|
// through to the row's Pay button instead (the fee is on my-payables).
|
||||||
|
const owesFee =
|
||||||
|
r.fault === "CUSTOMER" && Number(r.feeAmount ?? 0) > 0 && !r.feePaidAt;
|
||||||
|
if (r.status === "CREDIT_AVAILABLE" && !owesFee && !m.has(r.bookingId))
|
||||||
|
m.set(r.bookingId, r);
|
||||||
}
|
}
|
||||||
return m;
|
return m;
|
||||||
}, [myCancellations]);
|
}, [myCancellations]);
|
||||||
|
|||||||
@@ -282,6 +282,8 @@ export interface WagonCancellation {
|
|||||||
feeCurrency: string;
|
feeCurrency: string;
|
||||||
feeInvoiceId?: string | null;
|
feeInvoiceId?: string | null;
|
||||||
feePaidAt?: string | null;
|
feePaidAt?: string | null;
|
||||||
|
/** Who caused the cut: CUSTOMER pays a fee, EDR never does. */
|
||||||
|
fault?: "CUSTOMER" | "EDR" | null;
|
||||||
status: WagonCancellationStatus;
|
status: WagonCancellationStatus;
|
||||||
reason?: string | null;
|
reason?: string | null;
|
||||||
rebookedAt?: string | null;
|
rebookedAt?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user