mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -0,0 +1,145 @@
|
||||
import { BookingJourneyService } from './booking-journey.service';
|
||||
|
||||
/**
|
||||
* autoPlaceOnFreedWagons: intercity cargo boards the wagons freed by earlier
|
||||
* unloads. Exercised directly with a stubbed EntityManager — the surrounding
|
||||
* loadBooking flow is integration-tested through the running app.
|
||||
*/
|
||||
describe('BookingJourneyService.autoPlaceOnFreedWagons', () => {
|
||||
const service = new BookingJourneyService(
|
||||
{} as never, // dataSource
|
||||
{} as never, // yardFacilities
|
||||
{} as never, // facilityHandling
|
||||
{ emit: jest.fn() } as never, // events
|
||||
);
|
||||
|
||||
const schedule = { id: 'sched-1', trainSetId: 'ts-1' };
|
||||
const booking = {
|
||||
id: 'booking-1',
|
||||
reference: 'BK-1',
|
||||
cargoTotalWeightVgm: 50,
|
||||
freightType: 'CONTAINER',
|
||||
};
|
||||
|
||||
const makeManager = (slots: unknown[], existingAllocs: unknown[] = []) => {
|
||||
const savedAllocs: Array<Record<string, unknown>> = [];
|
||||
const savedItems: Array<Record<string, unknown>> = [];
|
||||
const allocQb = {
|
||||
innerJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue(existingAllocs),
|
||||
};
|
||||
const slotQb = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue(slots),
|
||||
};
|
||||
let allocId = 0;
|
||||
const manager = {
|
||||
getRepository: jest.fn((entity: { name?: string }) => {
|
||||
const name = entity?.name;
|
||||
if (name === 'WagonBookingAllocation') {
|
||||
return {
|
||||
createQueryBuilder: jest.fn(() => allocQb),
|
||||
create: jest.fn((v: Record<string, unknown>) => v),
|
||||
save: jest.fn(async (v: Record<string, unknown>) => {
|
||||
const row = { ...v, id: `alloc-${++allocId}` };
|
||||
savedAllocs.push(row);
|
||||
return row;
|
||||
}),
|
||||
update: jest.fn(),
|
||||
};
|
||||
}
|
||||
if (name === 'TrainSetWagon') {
|
||||
return { createQueryBuilder: jest.fn(() => slotQb) };
|
||||
}
|
||||
if (name === 'BookingContainer') {
|
||||
return {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'line-1',
|
||||
containerNumber: 'LINE-001',
|
||||
containerTypeId: 'ct-20',
|
||||
units: [{ containerNumber: 'UNIT-001' }, { containerNumber: 'UNIT-002' }],
|
||||
},
|
||||
]),
|
||||
};
|
||||
}
|
||||
if (name === 'WagonAllocationContainerItem') {
|
||||
return {
|
||||
create: jest.fn((v: Record<string, unknown>) => v),
|
||||
save: jest.fn(async (v: Record<string, unknown>) => {
|
||||
savedItems.push(v);
|
||||
return v;
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected repository ${name}`);
|
||||
}),
|
||||
};
|
||||
return { manager, savedAllocs, savedItems };
|
||||
};
|
||||
|
||||
const call = (manager: unknown) =>
|
||||
(service as never as {
|
||||
autoPlaceOnFreedWagons: (m: unknown, s: unknown, b: unknown) => Promise<void>;
|
||||
}).autoPlaceOnFreedWagons(manager, schedule, booking);
|
||||
|
||||
it('places the booking on freed slots in consist order, with container items', async () => {
|
||||
const slots = [
|
||||
// Active cargo still riding — NOT freed.
|
||||
{ id: 'slot-1', sequenceNo: 1, capacityTons: 60, allocations: [{ status: 'LOADED' }] },
|
||||
// Freed by an earlier unload.
|
||||
{ id: 'slot-2', sequenceNo: 2, capacityTons: 60, allocations: [{ status: 'DEPARTED' }] },
|
||||
{ id: 'slot-3', sequenceNo: 3, capacityTons: 60, allocations: [] },
|
||||
];
|
||||
const { manager, savedAllocs, savedItems } = makeManager(slots);
|
||||
|
||||
await call(manager);
|
||||
|
||||
// 50 t fits on the first freed slot alone.
|
||||
expect(savedAllocs).toHaveLength(1);
|
||||
expect(savedAllocs[0]).toMatchObject({
|
||||
trainSetWagonId: 'slot-2',
|
||||
bookingId: 'booking-1',
|
||||
allocatedWeightTons: 50,
|
||||
status: 'LOADED',
|
||||
});
|
||||
// One item per physical unit, on the first allocation.
|
||||
expect(savedItems.map((i) => i.containerNumber)).toEqual(['UNIT-001', 'UNIT-002']);
|
||||
expect(savedItems.every((i) => i.wagonBookingAllocationId === 'alloc-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('spills over onto the next freed slot when one is not enough', async () => {
|
||||
const slots = [
|
||||
{ id: 'slot-2', sequenceNo: 2, capacityTons: 30, allocations: [{ status: 'DEPARTED' }] },
|
||||
{ id: 'slot-3', sequenceNo: 3, capacityTons: 30, allocations: [] },
|
||||
];
|
||||
const { manager, savedAllocs } = makeManager(slots);
|
||||
|
||||
await call(manager);
|
||||
|
||||
expect(savedAllocs.map((a) => [a.trainSetWagonId, a.allocatedWeightTons])).toEqual([
|
||||
['slot-2', 30],
|
||||
['slot-3', 20],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does nothing when the booking already has allocations', async () => {
|
||||
const { manager, savedAllocs } = makeManager([], [{ id: 'existing' }]);
|
||||
await call(manager);
|
||||
expect(savedAllocs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('loads without allocation when no wagon is free', async () => {
|
||||
const slots = [
|
||||
{ id: 'slot-1', sequenceNo: 1, capacityTons: 60, allocations: [{ status: 'LOADED' }] },
|
||||
];
|
||||
const { manager, savedAllocs } = makeManager(slots);
|
||||
await expect(call(manager)).resolves.toBeUndefined();
|
||||
expect(savedAllocs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,8 @@ import { Freight } from '@edr/types';
|
||||
import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service';
|
||||
import { FacilityHandlingService } from './facility-handling.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
@@ -82,6 +84,11 @@ export class BookingJourneyService {
|
||||
loadedAt: now,
|
||||
loadedByUserId: userId ?? null,
|
||||
} as never);
|
||||
// Intercity cargo rides the wagons freed by earlier unloads along the
|
||||
// corridor — place it before the status flip so it boards with a wagon.
|
||||
if (booking.tradeDirection === 'DOMESTIC') {
|
||||
await this.autoPlaceOnFreedWagons(manager, schedule, booking);
|
||||
}
|
||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED');
|
||||
// Keep the schedule↔booking link's tracking flag in sync — the dispatch
|
||||
// readiness warnings and workspace badges read loading_status, not loadedAt.
|
||||
@@ -327,19 +334,48 @@ export class BookingJourneyService {
|
||||
RETURNING b.id, b.trade_direction`,
|
||||
[schedule.id, schedule.destinationStationId, now],
|
||||
);
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
// The facility took the cargo off the train at the final yard — raise its
|
||||
// GRN, same as the per-booking unloadBooking() path does. Only when that
|
||||
// yard also has a warehouse (or has no facility at all, e.g. Kality) does
|
||||
// WarehouseInventoryService additionally get to allocate a warehouse/yard/
|
||||
// zone row: a pure facility yard (Dire Dawa, Modjo, Sebeta, Adama) is
|
||||
// fully represented by the facility event alone — there is nothing there
|
||||
// for warehouse_inventory's NOT NULL warehouse/yard/zone to point at.
|
||||
const facility = await this.yardFacilities.facilityForYard(schedule.destinationStationId);
|
||||
const bookings = await manager
|
||||
.getRepository(Booking)
|
||||
.find({ where: { id: In(rows.map((r) => r.id)) }, relations: ['company'] });
|
||||
const bookingById = new Map(bookings.map((b) => [b.id, b]));
|
||||
|
||||
for (const row of rows) {
|
||||
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
|
||||
if (row.trade_direction === 'DOMESTIC') {
|
||||
this.events.emit('booking.completed', { bookingId: row.id });
|
||||
}
|
||||
|
||||
const booking = bookingById.get(row.id);
|
||||
if (booking) {
|
||||
await this.facilityHandling.recordHandling(manager, {
|
||||
booking,
|
||||
yardId: schedule.destinationStationId,
|
||||
trainScheduleId: schedule.id,
|
||||
eventType: 'UNLOAD',
|
||||
occurredAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
// Same event the per-booking unloadBooking() path emits — WarehouseInventoryService
|
||||
// listens for this to auto-create the warehouse_inventory row (import/intercity only,
|
||||
// it filters EXPORT itself). The bulk SQL update above skipped this entirely, so
|
||||
// bookings caught by this fallback never left "awaiting unload".
|
||||
this.events.emit('booking.unloadedAtYard', {
|
||||
bookingId: row.id,
|
||||
tradeDirection: row.trade_direction,
|
||||
});
|
||||
if (row.trade_direction !== 'EXPORT' && (!facility?.hasFacility || facility.hasWarehouse)) {
|
||||
this.events.emit('booking.unloadedAtYard', {
|
||||
bookingId: row.id,
|
||||
tradeDirection: row.trade_direction,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
@@ -435,6 +471,100 @@ export class BookingJourneyService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* INTERCITY ONLY. Intercity cargo does not get its own wagons — it rides the
|
||||
* slots freed by cargo already unloaded along the corridor (e.g. import
|
||||
* containers uncoupled at Dire Dawa). Staff pinning is a pre-dispatch tool,
|
||||
* so a DOMESTIC booking loaded mid-corridor is auto-placed here: greedy over
|
||||
* on-train slots (not DEPARTED) with no active cargo (every allocation
|
||||
* DEPARTED, or none), in consist order, by capacity. Container numbers are
|
||||
* copied onto the first allocation so the marshalling document and its
|
||||
* 40ft/20ft tally stay truthful. When nothing is free the load proceeds
|
||||
* unallocated — the marshalling document then lists the booking as on board
|
||||
* without a recorded wagon.
|
||||
* ponytail: remainder over free capacity is dumped on the last used slot
|
||||
* (paper overload beats missing cargo); upgrade path is a capacity guard in
|
||||
* the intercity accept step.
|
||||
*/
|
||||
private async autoPlaceOnFreedWagons(
|
||||
manager: EntityManager,
|
||||
schedule: TrainSchedule,
|
||||
booking: Booking,
|
||||
): Promise<void> {
|
||||
const existing = await this.allocationsForBooking(manager, schedule.id, booking.id);
|
||||
if (existing.length) return;
|
||||
|
||||
const slots = await manager
|
||||
.getRepository(TrainSetWagon)
|
||||
.createQueryBuilder('slot')
|
||||
.leftJoinAndSelect('slot.allocations', 'alloc')
|
||||
.innerJoin(
|
||||
TrainSchedule,
|
||||
'schedule',
|
||||
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
|
||||
{ scheduleId: schedule.id },
|
||||
)
|
||||
.where(`slot.status != 'DEPARTED'`)
|
||||
.orderBy('slot.sequence_no', 'ASC')
|
||||
.getMany();
|
||||
const freed = slots.filter((slot) =>
|
||||
(slot.allocations ?? []).every((a) => a.status === 'DEPARTED'),
|
||||
);
|
||||
if (!freed.length) {
|
||||
this.logger.warn(
|
||||
`No freed wagon for intercity booking ${booking.reference} on schedule ${schedule.id} — loading without wagon allocation`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let remaining = Number(booking.cargoTotalWeightVgm) || 0;
|
||||
const allocRepo = manager.getRepository(WagonBookingAllocation);
|
||||
const created: WagonBookingAllocation[] = [];
|
||||
for (const slot of freed) {
|
||||
const capacity = Number(slot.capacityTons) || remaining || 1;
|
||||
const take = Math.min(remaining || capacity, capacity);
|
||||
created.push(
|
||||
await allocRepo.save(
|
||||
allocRepo.create({
|
||||
trainSetWagonId: slot.id,
|
||||
bookingId: booking.id,
|
||||
allocatedWeightTons: take,
|
||||
loadType: booking.freightType ?? null,
|
||||
status: 'LOADED',
|
||||
}),
|
||||
),
|
||||
);
|
||||
remaining = Math.max(0, remaining - take);
|
||||
if (remaining <= 0) break;
|
||||
}
|
||||
if (remaining > 0 && created.length) {
|
||||
await allocRepo.update(created[created.length - 1].id, {
|
||||
allocatedWeightTons: () => `allocated_weight_tons + ${remaining}`,
|
||||
} as never);
|
||||
}
|
||||
|
||||
// Container numbers onto the first allocation, from the booking's container
|
||||
// lines (per physical unit when recorded, else per line).
|
||||
const lines = await manager
|
||||
.getRepository(BookingContainer)
|
||||
.find({ where: { bookingId: booking.id }, relations: { units: true } });
|
||||
const itemRepo = manager.getRepository(WagonAllocationContainerItem);
|
||||
const first = created[0];
|
||||
for (const line of lines) {
|
||||
const units = line.units?.length ? line.units : [null];
|
||||
for (const unit of units) {
|
||||
await itemRepo.save(
|
||||
itemRepo.create({
|
||||
wagonBookingAllocationId: first.id,
|
||||
bookingContainerId: line.id,
|
||||
containerNumber: unit?.containerNumber ?? line.containerNumber ?? null,
|
||||
containerTypeId: line.containerTypeId ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async setAllocationStatuses(
|
||||
manager: EntityManager,
|
||||
scheduleId: string,
|
||||
|
||||
@@ -711,6 +711,20 @@ export class TrainSchedulingController {
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/intercity/marshalling/document")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "Download current on-board intercity marshalling (Marshalling 2) PDF" })
|
||||
async intercityMarshallingDocument(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { filename, buffer } = await this.trainSchedulingService.intercityMarshallingDocument(id);
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.setHeader("Content-Length", buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
// ---- batch / booking-window staff actions ----
|
||||
|
||||
@Post("schedules/:id/run-batch")
|
||||
|
||||
@@ -1121,6 +1121,132 @@ describe('TrainSchedulingService', () => {
|
||||
expect(html).not.toContain('empty)');
|
||||
expect(html).not.toContain('EMPTY');
|
||||
});
|
||||
|
||||
// ---- intercity marshalling (Marshalling 2): the current on-board view ----
|
||||
|
||||
const onBoardView = (schedule: unknown) =>
|
||||
(service as never as {
|
||||
intercityOnBoardView: (s: unknown) => { wagons: unknown[]; unassignedBookings: unknown[] };
|
||||
}).intercityOnBoardView(schedule);
|
||||
|
||||
const buildWithOpts = (schedule: unknown, opts: unknown) =>
|
||||
(service as never as {
|
||||
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
|
||||
}).buildExportLoadListHtml(schedule, opts);
|
||||
|
||||
const allocWith = (over: Record<string, unknown>) => ({ ...loadedAllocation, ...over });
|
||||
|
||||
it('drops DEPARTED wagon slots and DEPARTED allocations from the on-board view', () => {
|
||||
const schedule = {
|
||||
trainSet: {
|
||||
wagons: [
|
||||
{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' },
|
||||
{ ...makeWagon(2, 'W-002', [allocWith({ status: 'LOADED' })]), status: 'DEPARTED' },
|
||||
{
|
||||
...makeWagon(3, 'W-003', [
|
||||
allocWith({ status: 'LOADED', bookingId: 'booking-3' }),
|
||||
allocWith({ status: 'DEPARTED', bookingId: 'booking-4' }),
|
||||
]),
|
||||
status: 'RESERVED',
|
||||
},
|
||||
],
|
||||
},
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const { wagons } = onBoardView(schedule);
|
||||
const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map(
|
||||
(w) => w.physicalWagon.wagonNumber,
|
||||
);
|
||||
expect(numbers).toEqual(['W-001', 'W-003']);
|
||||
const w3 = (wagons as Array<{ physicalWagon: { wagonNumber: string }; allocations: Array<{ bookingId: string }> }>).find(
|
||||
(w) => w.physicalWagon.wagonNumber === 'W-003',
|
||||
);
|
||||
expect(w3?.allocations.map((a) => a.bookingId)).toEqual(['booking-3']);
|
||||
});
|
||||
|
||||
it('keeps an attached wagon whose cargo all departed, as an EMPTY row', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
direction: 'EXPORT',
|
||||
trainSet: {
|
||||
wagons: [
|
||||
{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' },
|
||||
{ ...makeWagon(2, 'W-002', [allocWith({ status: 'DEPARTED' })]), status: 'RESERVED' },
|
||||
],
|
||||
},
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const { wagons, unassignedBookings } = onBoardView(schedule);
|
||||
const html = buildWithOpts(schedule, { wagons, unassignedBookings });
|
||||
expect(html).toContain('W-002');
|
||||
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1);
|
||||
expect(html).toContain('2 (1 empty)');
|
||||
});
|
||||
|
||||
it('hides a leg slot (boardYardId set) until it has confirmed LOADED cargo', () => {
|
||||
const legWagonEmpty = { ...makeWagon(2, 'W-LEG', [allocWith({ status: 'RESERVED' })]), status: 'RESERVED', boardYardId: 'yard-mid' };
|
||||
const legWagonLoaded = { ...makeWagon(3, 'W-LEG2', [allocWith({ status: 'LOADED' })]), status: 'RESERVED', boardYardId: 'yard-mid' };
|
||||
const schedule = {
|
||||
trainSet: { wagons: [legWagonEmpty, legWagonLoaded] },
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const { wagons } = onBoardView(schedule);
|
||||
const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map(
|
||||
(w) => w.physicalWagon.wagonNumber,
|
||||
);
|
||||
expect(numbers).toEqual(['W-LEG2']);
|
||||
});
|
||||
|
||||
it('lists an IN_TRANSIT booking with no wagon allocation in the unassigned section', () => {
|
||||
const rider = {
|
||||
id: 'booking-9',
|
||||
reference: 'BK-2026-000009',
|
||||
status: 'IN_TRANSIT',
|
||||
company: { name: 'Rider Co' },
|
||||
cargoType: { cargoTypeName: 'Cement', code: 'CEM' },
|
||||
originYard: { label: 'Adama' },
|
||||
destinationYard: { label: 'Dire Dawa' },
|
||||
bookingContainers: [{ containerNumber: 'RIDE-001' }],
|
||||
};
|
||||
const done = { id: 'booking-8', reference: 'BK-2026-000008', status: 'COMPLETED' };
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
direction: 'EXPORT',
|
||||
trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' }] },
|
||||
scheduleBookings: [{ bookingId: rider.id, booking: rider }, { bookingId: done.id, booking: done }],
|
||||
};
|
||||
|
||||
const { wagons, unassignedBookings } = onBoardView(schedule);
|
||||
expect((unassignedBookings as Array<{ id: string }>).map((b) => b.id)).toEqual(['booking-9']);
|
||||
|
||||
const html = buildWithOpts(schedule, {
|
||||
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
|
||||
positionLabel: 'After Dire Dawa',
|
||||
wagons,
|
||||
unassignedBookings,
|
||||
});
|
||||
expect(html).toContain('ON BOARD — WAGON NOT RECORDED');
|
||||
expect(html).toContain('BK-2026-000009');
|
||||
expect(html).toContain('RIDE-001');
|
||||
expect(html).not.toContain('BK-2026-000008');
|
||||
expect(html).toContain('Intercity Marshalling Document / Load List (Marshalling 2)');
|
||||
expect(html).toContain('After Dire Dawa');
|
||||
});
|
||||
|
||||
it('rejects the intercity marshalling document for a train that has not been dispatched', async () => {
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
||||
id: 'schedule-1',
|
||||
status: 'SCHEDULED',
|
||||
});
|
||||
await expect(
|
||||
service.intercityMarshallingDocument('schedule-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveWagonLoad — staff rearrange', () => {
|
||||
|
||||
@@ -2928,6 +2928,78 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The train's composition as it stands right now — the source for the
|
||||
* intercity marshalling ("Marshalling 2") document printed after mid-corridor
|
||||
* station work. A wagon slot is on the train iff it has not DEPARTED and
|
||||
* either rides the whole corridor (no boardYardId) or has confirmed LOADED
|
||||
* cargo. Kept wagons carry only their LOADED allocations (DEPARTED =
|
||||
* unloaded, PLANNED/RESERVED = not on board yet).
|
||||
* ponytail: boardYardId presence is the "boarded yet?" heuristic; upgrade
|
||||
* path is comparing the board yard against the latest checkpoint sequence.
|
||||
*/
|
||||
private intercityOnBoardView(schedule: TrainSchedule): {
|
||||
wagons: TrainSetWagon[];
|
||||
unassignedBookings: Booking[];
|
||||
} {
|
||||
const wagons = (schedule.trainSet?.wagons ?? [])
|
||||
.filter((wagon) => {
|
||||
if (wagon.status === 'DEPARTED') return false;
|
||||
const hasLoaded = (wagon.allocations ?? []).some((a) => a.status === 'LOADED');
|
||||
return wagon.boardYardId == null || hasLoaded;
|
||||
})
|
||||
.map((wagon) => ({
|
||||
...wagon,
|
||||
allocations: (wagon.allocations ?? []).filter((a) => a.status === 'LOADED'),
|
||||
})) as TrainSetWagon[];
|
||||
|
||||
const onBoardBookingIds = new Set(
|
||||
wagons.flatMap((wagon) => (wagon.allocations ?? []).map((a) => a.bookingId)),
|
||||
);
|
||||
// IN_TRANSIT bookings with no kept allocation: intercity riders accepted
|
||||
// after dispatch (never wagon-pinned) and loads whose allocation was never
|
||||
// confirmed LOADED. They are physically on the train, so they get a row.
|
||||
const unassignedBookings = (schedule.scheduleBookings ?? [])
|
||||
.map((link) => link.booking)
|
||||
.filter((booking): booking is Booking => Boolean(booking))
|
||||
.filter((booking) => booking.status === 'IN_TRANSIT' && !onBoardBookingIds.has(booking.id));
|
||||
|
||||
return { wagons, unassignedBookings };
|
||||
}
|
||||
|
||||
async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (schedule.status !== 'DISPATCHED' && schedule.status !== 'ARRIVED') {
|
||||
throw new BadRequestException(
|
||||
'Intercity marshalling document applies only to dispatched or arrived trains',
|
||||
);
|
||||
}
|
||||
|
||||
const checkpoints = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
|
||||
const last = checkpoints[checkpoints.length - 1];
|
||||
const positionLabel = last
|
||||
? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}`
|
||||
: `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`;
|
||||
|
||||
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
|
||||
const html = this.buildExportLoadListHtml(schedule, {
|
||||
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
|
||||
positionLabel,
|
||||
wagons,
|
||||
unassignedBookings,
|
||||
});
|
||||
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
||||
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
|
||||
const reference = schedule.trainNumber ?? schedule.id;
|
||||
return {
|
||||
filename: `intercity-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A container item's size in feet, for the marshalling document's 40ft/20ft
|
||||
* tally. Two independent sources, since only one is populated depending on
|
||||
@@ -2954,7 +3026,15 @@ export class TrainSchedulingService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildExportLoadListHtml(schedule: TrainSchedule): string {
|
||||
private buildExportLoadListHtml(
|
||||
schedule: TrainSchedule,
|
||||
opts?: {
|
||||
title?: string;
|
||||
positionLabel?: string;
|
||||
wagons?: TrainSetWagon[];
|
||||
unassignedBookings?: Booking[];
|
||||
},
|
||||
): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
.replace(/&/g, '&')
|
||||
@@ -2967,7 +3047,7 @@ export class TrainSchedulingService {
|
||||
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
|
||||
// The document is checked against the physical train, so it has to run in
|
||||
// consist order — the relation comes back unordered.
|
||||
const wagons = [...(schedule.trainSet?.wagons ?? [])].sort(
|
||||
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort(
|
||||
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
|
||||
);
|
||||
const rows = wagons
|
||||
@@ -3011,6 +3091,29 @@ export class TrainSchedulingService {
|
||||
});
|
||||
})
|
||||
.join('');
|
||||
// Intercity riders accepted after dispatch have no wagon slot recorded —
|
||||
// they are still physically on the train, so they get rows of their own.
|
||||
const unassigned = opts?.unassignedBookings ?? [];
|
||||
const unassignedRows = unassigned.length
|
||||
? `<tr class="empty"><td colspan="11">ON BOARD — WAGON NOT RECORDED</td></tr>` +
|
||||
unassigned
|
||||
.map((booking) => {
|
||||
const containerNumbers = (booking.bookingContainers ?? [])
|
||||
.map((container) => container.containerNumber)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'} → ${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`;
|
||||
return `<tr>
|
||||
<td colspan="6">${esc(booking.reference)} — ${esc(leg)}</td>
|
||||
<td>${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)}</td>
|
||||
<td>${esc(booking.company?.name)}</td>
|
||||
<td>${esc(containerNumbers)}</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join('')
|
||||
: '';
|
||||
const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length;
|
||||
const totalWeight = wagons.reduce(
|
||||
(sum, wagon) =>
|
||||
@@ -3034,7 +3137,7 @@ export class TrainSchedulingService {
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Export Marshalling Document</title>
|
||||
<title>${esc(opts?.title ?? 'Export Marshalling Document')}</title>
|
||||
<style>
|
||||
@page { size: A4 landscape; margin: 10mm; }
|
||||
* { box-sizing: border-box; }
|
||||
@@ -3063,7 +3166,7 @@ export class TrainSchedulingService {
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Export Marshalling Document / Load List</h1>
|
||||
<h1>${esc(opts?.title ?? 'Export Marshalling Document / Load List')}</h1>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Train / Schedule
|
||||
@@ -3088,6 +3191,7 @@ export class TrainSchedulingService {
|
||||
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
|
||||
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
|
||||
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
|
||||
${opts?.positionLabel ? `<div class="tile"><span>Current position</span><strong>${esc(opts.positionLabel)}</strong></div>` : ''}
|
||||
</div>
|
||||
|
||||
<table>
|
||||
@@ -3108,6 +3212,7 @@ export class TrainSchedulingService {
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'}
|
||||
${unassignedRows}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -27,12 +27,20 @@ async function main() {
|
||||
const dataSource = app.get(DataSource);
|
||||
const inventory = app.get(WarehouseInventoryService);
|
||||
|
||||
// Skip bookings destined for a pure facility yard (has a facility but no
|
||||
// warehouse, e.g. Dire Dawa) — those are fully represented by their
|
||||
// facility_handling_events UNLOAD record, not a warehouse_inventory row.
|
||||
// Same gate as BookingJourneyService.autoArriveAtFinalYard.
|
||||
const bookings: { id: string; tradeDirection: string }[] = await dataSource.query(
|
||||
`SELECT b.id, b.trade_direction AS "tradeDirection"
|
||||
FROM freight.bookings b
|
||||
JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.yard_facilities yf
|
||||
ON yf.yard_id = dy.id AND yf.deleted_at IS NULL AND yf.is_active = true
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND b.trade_direction IN ('IMPORT', 'DOMESTIC')
|
||||
AND b.status IN ('ARRIVED', 'COMPLETED')
|
||||
AND NOT (dy.has_facility = true AND COALESCE(yf.has_warehouse, false) = false)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.warehouse_inventory wi
|
||||
WHERE wi.booking_id = b.id AND wi.deleted_at IS NULL
|
||||
|
||||
@@ -416,6 +416,8 @@ export const URL_CONSTANTS = {
|
||||
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
|
||||
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/export/load-list/document`,
|
||||
INTERCITY_MARSHALLING_DOCUMENT: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
|
||||
CHECKPOINTS: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/checkpoints`,
|
||||
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Flag,
|
||||
MapPin,
|
||||
Navigation,
|
||||
@@ -40,6 +41,8 @@ import { freightBrand } from "@/theme/freight-brand";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
@@ -160,6 +163,31 @@ export default function TrainScheduleTrackPage() {
|
||||
enabled: Boolean(scheduleId) && trackQuery.data?.status === "DISPATCHED",
|
||||
}),
|
||||
);
|
||||
// Marshalling 2: the current on-board list, reprinted after station work.
|
||||
const intercityMarshalling = useMutation({
|
||||
mutationFn: () =>
|
||||
trainSchedulingService.downloadIntercityMarshallingDocument(scheduleId ?? ""),
|
||||
});
|
||||
const openIntercityMarshalling = async () => {
|
||||
const pdfWindow = window.open("", "_blank");
|
||||
try {
|
||||
const blob = await intercityMarshalling.mutateAsync();
|
||||
const opened = openPdfBlob(blob, `intercity-marshalling-${scheduleId}.pdf`, pdfWindow);
|
||||
toast({
|
||||
title: "Intercity marshalling ready",
|
||||
description: opened
|
||||
? "The PDF opened in a browser tab for printing or saving."
|
||||
: "The browser blocked the preview tab, so the PDF was downloaded.",
|
||||
});
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
title: "Could not open intercity marshalling document",
|
||||
description: parseError(error, "Please try again"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
const [yardModal, setYardModal] = useState<{
|
||||
station: TrackStation;
|
||||
isFinal: boolean;
|
||||
@@ -249,17 +277,32 @@ export default function TrainScheduleTrackPage() {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
w="fit-content"
|
||||
>
|
||||
Back to schedule
|
||||
</Button>
|
||||
<Group justify="space-between" w="100%">
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
w="fit-content"
|
||||
>
|
||||
Back to schedule
|
||||
</Button>
|
||||
{inTransit || arrived ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
size="compact-sm"
|
||||
leftSection={<FileText size={16} />}
|
||||
loading={intercityMarshalling.isPending}
|
||||
onClick={() => void openIntercityMarshalling()}
|
||||
>
|
||||
Intercity Marshalling
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{/* ── Hero: gradient wash, route + a bold progress ring woven together ── */}
|
||||
<Paper
|
||||
|
||||
@@ -208,10 +208,12 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions());
|
||||
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
||||
const downloadMarshalling = useMutation({
|
||||
mutationFn: ({ id, direction }: { id: string; direction?: string | null }) =>
|
||||
direction === "EXPORT"
|
||||
? trainSchedulingService.downloadExportLoadListDocument(id)
|
||||
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
|
||||
mutationFn: ({ id, direction, variant }: { id: string; direction?: string | null; variant?: "INTERCITY" }) =>
|
||||
variant === "INTERCITY"
|
||||
? trainSchedulingService.downloadIntercityMarshallingDocument(id)
|
||||
: direction === "EXPORT"
|
||||
? trainSchedulingService.downloadExportLoadListDocument(id)
|
||||
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -426,14 +428,21 @@ export default function TrainScheduleV2DetailPage() {
|
||||
title?: string;
|
||||
successDescription?: string;
|
||||
errorTitle?: string;
|
||||
variant?: "INTERCITY";
|
||||
}) => {
|
||||
const pdfWindow = window.open("", "_blank");
|
||||
try {
|
||||
const blob = await downloadMarshalling.mutateAsync({
|
||||
id: scheduleId,
|
||||
direction: schedule.direction,
|
||||
variant: options?.variant,
|
||||
});
|
||||
const prefix = schedule.direction === "EXPORT" ? "export-marshalling" : "import-marshalling";
|
||||
const prefix =
|
||||
options?.variant === "INTERCITY"
|
||||
? "intercity-marshalling"
|
||||
: schedule.direction === "EXPORT"
|
||||
? "export-marshalling"
|
||||
: "import-marshalling";
|
||||
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
|
||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||
toast({
|
||||
@@ -973,6 +982,24 @@ export default function TrainScheduleV2DetailPage() {
|
||||
Marshalling PDF
|
||||
</Button>
|
||||
) : null}
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
leftSection={<FileText size={16} />}
|
||||
loading={downloadMarshalling.isPending}
|
||||
onClick={() =>
|
||||
void openMarshallingDocument({
|
||||
title: "Intercity marshalling ready",
|
||||
variant: "INTERCITY",
|
||||
})
|
||||
}
|
||||
>
|
||||
Intercity Marshalling
|
||||
</Button>
|
||||
) : null}
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||
<Button
|
||||
component={Link}
|
||||
|
||||
@@ -624,6 +624,16 @@ export const trainSchedulingService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
downloadIntercityMarshallingDocument: async (
|
||||
scheduleId: string,
|
||||
): Promise<Blob> => {
|
||||
const response = await client.get(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_MARSHALLING_DOCUMENT(scheduleId),
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTrack: async (scheduleId: string): Promise<TrainTrackResponse> => {
|
||||
const response = await client.get<TrainTrackResponse>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),
|
||||
|
||||
Reference in New Issue
Block a user